mirror of
https://github.com/luxfi/captable.git
synced 2026-10-30 17:01:02 +00:00
165 lines
4.7 KiB
Go
165 lines
4.7 KiB
Go
package dividend
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// HoldingsProvider is the interface for looking up stakeholder positions at a record date.
|
|
// Consumers inject this — it is NOT provided by this package.
|
|
type HoldingsProvider interface {
|
|
// GetHoldingsAtDate returns stakeholder ID -> shares held for a share class at a point in time.
|
|
GetHoldingsAtDate(ctx context.Context, companyID, shareClassID string, date time.Time) (map[string]int64, error)
|
|
}
|
|
|
|
// Service handles dividend declaration, record date processing, and distribution.
|
|
type Service struct {
|
|
repo Repository
|
|
holdings HoldingsProvider
|
|
}
|
|
|
|
// NewService creates a dividend service.
|
|
func NewService(repo Repository, holdings HoldingsProvider) *Service {
|
|
return &Service{repo: repo, holdings: holdings}
|
|
}
|
|
|
|
// Declare creates a new dividend declaration.
|
|
func (s *Service) Declare(ctx context.Context, d *Declaration) error {
|
|
if d.CompanyID == "" {
|
|
return fmt.Errorf("company_id is required")
|
|
}
|
|
if d.ShareClassID == "" {
|
|
return fmt.Errorf("share_class_id is required")
|
|
}
|
|
if d.Type == "" {
|
|
return fmt.Errorf("type is required")
|
|
}
|
|
if d.Type == "cash" && d.AmountPerShare <= 0 {
|
|
return fmt.Errorf("amount_per_share must be positive for cash dividend")
|
|
}
|
|
if d.Type == "stock" && d.StockRatio <= 0 {
|
|
return fmt.Errorf("stock_ratio must be positive for stock dividend")
|
|
}
|
|
if d.RecordDate.IsZero() {
|
|
return fmt.Errorf("record_date is required")
|
|
}
|
|
if d.PayableDate.IsZero() {
|
|
return fmt.Errorf("payable_date is required")
|
|
}
|
|
if d.PayableDate.Before(d.RecordDate) {
|
|
return fmt.Errorf("payable_date must be after record_date")
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
d.CreatedAt = now
|
|
if d.DeclarationDate.IsZero() {
|
|
d.DeclarationDate = now
|
|
}
|
|
if d.ExDividendDate.IsZero() {
|
|
// Ex-dividend is typically 1 business day before record date.
|
|
d.ExDividendDate = d.RecordDate.Add(-24 * time.Hour)
|
|
}
|
|
if d.Status == "" {
|
|
d.Status = "declared"
|
|
}
|
|
|
|
return s.repo.CreateDeclaration(ctx, d)
|
|
}
|
|
|
|
// ProcessRecordDate looks up all holders at the record date and creates distributions.
|
|
func (s *Service) ProcessRecordDate(ctx context.Context, declarationID string) (int, error) {
|
|
decl, err := s.repo.GetDeclaration(ctx, declarationID)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("get declaration: %w", err)
|
|
}
|
|
if decl.Status != "declared" {
|
|
return 0, fmt.Errorf("declaration %s is not in declared state (status=%s)", decl.ID, decl.Status)
|
|
}
|
|
|
|
holdings, err := s.holdings.GetHoldingsAtDate(ctx, decl.CompanyID, decl.ShareClassID, decl.RecordDate)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("get holdings: %w", err)
|
|
}
|
|
|
|
var totalAmount float64
|
|
count := 0
|
|
|
|
for stakeholderID, shares := range holdings {
|
|
if shares <= 0 {
|
|
continue
|
|
}
|
|
|
|
var amount float64
|
|
var stockShares int64
|
|
if decl.Type == "cash" {
|
|
amount = float64(shares) * decl.AmountPerShare
|
|
} else if decl.Type == "stock" {
|
|
stockShares = int64(float64(shares) * decl.StockRatio)
|
|
if stockShares <= 0 {
|
|
continue
|
|
}
|
|
}
|
|
|
|
dist := &Distribution{
|
|
ID: fmt.Sprintf("dist_%s_%s", declarationID, stakeholderID),
|
|
DeclarationID: declarationID,
|
|
StakeholderID: stakeholderID,
|
|
Shares: shares,
|
|
GrossAmount: amount,
|
|
StockShares: stockShares,
|
|
NetAmount: amount, // withholding computed separately
|
|
Status: "pending",
|
|
}
|
|
if err := s.repo.CreateDistribution(ctx, dist); err != nil {
|
|
return count, fmt.Errorf("create distribution for %s: %w", stakeholderID, err)
|
|
}
|
|
totalAmount += amount
|
|
count++
|
|
}
|
|
|
|
// Update declaration.
|
|
decl.TotalAmount = totalAmount
|
|
decl.Status = "record_set"
|
|
if err := s.repo.UpdateDeclaration(ctx, decl); err != nil {
|
|
return count, fmt.Errorf("update declaration: %w", err)
|
|
}
|
|
|
|
return count, nil
|
|
}
|
|
|
|
// GetSummary returns aggregate distribution stats for a declaration.
|
|
func (s *Service) GetSummary(ctx context.Context, declarationID string) (*DistributionSummary, error) {
|
|
dists, err := s.repo.ListDistributions(ctx, declarationID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list distributions: %w", err)
|
|
}
|
|
|
|
summary := &DistributionSummary{DeclarationID: declarationID}
|
|
for _, d := range dists {
|
|
summary.RecipientsCount++
|
|
summary.TotalGross += d.GrossAmount
|
|
summary.TotalWithholding += d.TaxWithholding
|
|
summary.TotalNet += d.NetAmount
|
|
switch d.Status {
|
|
case "paid":
|
|
summary.PaidCount++
|
|
case "pending":
|
|
summary.PendingCount++
|
|
}
|
|
}
|
|
return summary, nil
|
|
}
|
|
|
|
// MarkPaid marks a distribution as paid.
|
|
func (s *Service) MarkPaid(ctx context.Context, distributionID string) error {
|
|
d, err := s.repo.GetDistribution(ctx, distributionID)
|
|
if err != nil {
|
|
return fmt.Errorf("get distribution: %w", err)
|
|
}
|
|
now := time.Now().UTC()
|
|
d.Status = "paid"
|
|
d.PaidAt = &now
|
|
return s.repo.UpdateDistribution(ctx, d)
|
|
}
|