mirror of
https://github.com/luxfi/captable.git
synced 2026-10-30 17:01:02 +00:00
158 lines
4.5 KiB
Go
158 lines
4.5 KiB
Go
package transfer
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math"
|
|
"time"
|
|
)
|
|
|
|
// Service evaluates transfer restrictions including Rule 144, lockup periods,
|
|
// and custom restrictions. It does NOT execute transfers — that is done by
|
|
// the securities service. This service answers: "is this transfer allowed?"
|
|
type Service struct {
|
|
repo Repository
|
|
}
|
|
|
|
// NewService creates a transfer restriction service.
|
|
func NewService(repo Repository) *Service {
|
|
return &Service{repo: repo}
|
|
}
|
|
|
|
// CreateRestriction registers a new transfer restriction.
|
|
func (s *Service) CreateRestriction(ctx context.Context, r *Restriction) error {
|
|
if r.CompanyID == "" {
|
|
return fmt.Errorf("company_id is required")
|
|
}
|
|
if r.Type == "" {
|
|
return fmt.Errorf("type is required")
|
|
}
|
|
if r.StartDate.IsZero() {
|
|
r.StartDate = time.Now().UTC()
|
|
}
|
|
if r.Status == "" {
|
|
r.Status = "active"
|
|
}
|
|
r.CreatedAt = time.Now().UTC()
|
|
return s.repo.CreateRestriction(ctx, r)
|
|
}
|
|
|
|
// CheckTransfer evaluates all active restrictions for a proposed transfer.
|
|
func (s *Service) CheckTransfer(ctx context.Context, req *CheckRequest) (*CheckResult, error) {
|
|
if req.CompanyID == "" {
|
|
return nil, fmt.Errorf("company_id is required")
|
|
}
|
|
if req.FromStakeholder == "" {
|
|
return nil, fmt.Errorf("from_stakeholder is required")
|
|
}
|
|
if req.Shares <= 0 {
|
|
return nil, fmt.Errorf("shares must be positive")
|
|
}
|
|
|
|
restrictions, err := s.repo.ListRestrictions(ctx, req.CompanyID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list restrictions: %w", err)
|
|
}
|
|
|
|
result := &CheckResult{Allowed: true}
|
|
now := time.Now()
|
|
|
|
for _, r := range restrictions {
|
|
if r.Status != "active" {
|
|
continue
|
|
}
|
|
// Check if expired.
|
|
if r.EndDate != nil && r.EndDate.Before(now) {
|
|
continue
|
|
}
|
|
// Check if restriction applies to this stakeholder.
|
|
if r.StakeholderID != "" && r.StakeholderID != req.FromStakeholder {
|
|
continue
|
|
}
|
|
// Check if restriction applies to this share class.
|
|
if r.ShareClassID != "" && r.ShareClassID != req.ShareClassID {
|
|
continue
|
|
}
|
|
|
|
// This restriction applies — block the transfer.
|
|
result.Allowed = false
|
|
desc := fmt.Sprintf("%s restriction (id=%s)", r.Type, r.ID)
|
|
if r.Conditions != "" {
|
|
desc += ": " + r.Conditions
|
|
}
|
|
if r.EndDate != nil {
|
|
desc += fmt.Sprintf(" [expires %s]", r.EndDate.Format("2006-01-02"))
|
|
}
|
|
result.Restrictions = append(result.Restrictions, desc)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// CheckRule144 evaluates SEC Rule 144 eligibility for restricted securities.
|
|
//
|
|
// Rule 144 allows public resale of restricted/control securities if conditions are met:
|
|
// - Holding period: 6 months (reporting issuer) or 12 months (non-reporting)
|
|
// - Volume limit (affiliates): greater of 1% outstanding or avg weekly trading vol
|
|
// - Current public information must be available
|
|
// - Affiliates must file Form 144 if > 5,000 shares or > $50,000
|
|
func (s *Service) CheckRule144(check *Rule144Check) *Rule144Result {
|
|
now := time.Now()
|
|
monthsHeld := int(now.Sub(check.AcquiredDate).Hours() / (24 * 30))
|
|
|
|
// Determine required holding period.
|
|
requiredMonths := 12
|
|
if check.IsReporting {
|
|
requiredMonths = 6
|
|
}
|
|
|
|
result := &Rule144Result{
|
|
HoldingPeriod: requiredMonths,
|
|
MonthsHeld: monthsHeld,
|
|
}
|
|
|
|
// Check holding period.
|
|
if monthsHeld < requiredMonths {
|
|
result.Eligible = false
|
|
result.Reason = fmt.Sprintf("holding period not met: %d months held, %d required", monthsHeld, requiredMonths)
|
|
return result
|
|
}
|
|
|
|
// Non-affiliates with 12+ months holding: no further restrictions.
|
|
if !check.IsAffiliate && monthsHeld >= 12 {
|
|
result.Eligible = true
|
|
return result
|
|
}
|
|
|
|
// Affiliates: apply volume limitations.
|
|
if check.IsAffiliate {
|
|
// Volume limit: greater of 1% of outstanding shares or average weekly volume.
|
|
onePercent := int64(math.Ceil(float64(check.SharesHeld) * 0.01))
|
|
volumeLimit := onePercent
|
|
if check.AvgWeeklyVol > 0 && check.AvgWeeklyVol > volumeLimit {
|
|
volumeLimit = check.AvgWeeklyVol
|
|
}
|
|
result.VolumeLimit = volumeLimit
|
|
|
|
if check.SharesToSell > volumeLimit {
|
|
result.Eligible = false
|
|
result.Reason = fmt.Sprintf("volume limit exceeded: selling %d, limit %d per 90-day period",
|
|
check.SharesToSell, volumeLimit)
|
|
return result
|
|
}
|
|
}
|
|
|
|
result.Eligible = true
|
|
return result
|
|
}
|
|
|
|
// WaiveRestriction marks a restriction as waived (e.g., board approval granted).
|
|
func (s *Service) WaiveRestriction(ctx context.Context, restrictionID string) error {
|
|
r, err := s.repo.GetRestriction(ctx, restrictionID)
|
|
if err != nil {
|
|
return fmt.Errorf("get restriction: %w", err)
|
|
}
|
|
r.Status = "waived"
|
|
return s.repo.UpdateRestriction(ctx, r)
|
|
}
|