mirror of
https://github.com/luxfi/captable.git
synced 2026-10-30 17:01:02 +00:00
272 lines
6.3 KiB
Go
272 lines
6.3 KiB
Go
package corporate
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type memRepo struct {
|
|
mu sync.RWMutex
|
|
items map[string]*Action
|
|
}
|
|
|
|
func newMemRepo() *memRepo {
|
|
return &memRepo{items: make(map[string]*Action)}
|
|
}
|
|
|
|
func (r *memRepo) CreateAction(_ context.Context, a *Action) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
cp := *a
|
|
r.items[a.ID] = &cp
|
|
return nil
|
|
}
|
|
|
|
func (r *memRepo) GetAction(_ context.Context, id string) (*Action, error) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
a, ok := r.items[id]
|
|
if !ok {
|
|
return nil, fmt.Errorf("action %s not found", id)
|
|
}
|
|
cp := *a
|
|
return &cp, nil
|
|
}
|
|
|
|
func (r *memRepo) UpdateAction(_ context.Context, a *Action) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
cp := *a
|
|
r.items[a.ID] = &cp
|
|
return nil
|
|
}
|
|
|
|
func (r *memRepo) ListActions(_ context.Context, companyID string) ([]*Action, error) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
var out []*Action
|
|
for _, a := range r.items {
|
|
if a.CompanyID == companyID {
|
|
cp := *a
|
|
out = append(out, &cp)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func TestProposeAction(t *testing.T) {
|
|
svc := NewService(newMemRepo())
|
|
ctx := context.Background()
|
|
|
|
a := &Action{
|
|
ID: "ca1",
|
|
CompanyID: "c1",
|
|
Type: "stock_split",
|
|
Description: "2:1 stock split",
|
|
EffectiveDate: time.Now().Add(30 * 24 * time.Hour),
|
|
}
|
|
if err := svc.ProposeAction(ctx, a); err != nil {
|
|
t.Fatalf("ProposeAction: %v", err)
|
|
}
|
|
if a.Status != "proposed" {
|
|
t.Fatalf("status = %q, want proposed", a.Status)
|
|
}
|
|
}
|
|
|
|
func TestProposeActionValidation(t *testing.T) {
|
|
svc := NewService(newMemRepo())
|
|
ctx := context.Background()
|
|
|
|
tests := []struct {
|
|
name string
|
|
a Action
|
|
wantErr string
|
|
}{
|
|
{"missing company", Action{ID: "ca1", Type: "stock_split", EffectiveDate: time.Now()}, "company_id"},
|
|
{"missing type", Action{ID: "ca1", CompanyID: "c1", EffectiveDate: time.Now()}, "type"},
|
|
{"missing date", Action{ID: "ca1", CompanyID: "c1", Type: "stock_split"}, "effective_date"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := svc.ProposeAction(ctx, &tt.a)
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
if !strings.Contains(err.Error(), tt.wantErr) {
|
|
t.Fatalf("error %q does not contain %q", err.Error(), tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestApproveAndExecute(t *testing.T) {
|
|
repo := newMemRepo()
|
|
svc := NewService(repo)
|
|
ctx := context.Background()
|
|
|
|
repo.CreateAction(ctx, &Action{
|
|
ID: "ca1", CompanyID: "c1", Type: "stock_split", Status: "proposed",
|
|
EffectiveDate: time.Now(),
|
|
})
|
|
|
|
// Approve.
|
|
if err := svc.ApproveAction(ctx, "ca1"); err != nil {
|
|
t.Fatalf("ApproveAction: %v", err)
|
|
}
|
|
a, _ := repo.GetAction(ctx, "ca1")
|
|
if a.Status != "approved" {
|
|
t.Fatalf("status = %q, want approved", a.Status)
|
|
}
|
|
if a.ApprovedDate == nil {
|
|
t.Fatal("approved_date should be set")
|
|
}
|
|
|
|
// Execute.
|
|
if err := svc.ExecuteAction(ctx, "ca1"); err != nil {
|
|
t.Fatalf("ExecuteAction: %v", err)
|
|
}
|
|
a, _ = repo.GetAction(ctx, "ca1")
|
|
if a.Status != "executed" {
|
|
t.Fatalf("status = %q, want executed", a.Status)
|
|
}
|
|
}
|
|
|
|
func TestCannotApproveNonProposed(t *testing.T) {
|
|
repo := newMemRepo()
|
|
svc := NewService(repo)
|
|
ctx := context.Background()
|
|
|
|
repo.CreateAction(ctx, &Action{ID: "ca1", Status: "executed"})
|
|
err := svc.ApproveAction(ctx, "ca1")
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
}
|
|
|
|
func TestCannotExecuteNonApproved(t *testing.T) {
|
|
repo := newMemRepo()
|
|
svc := NewService(repo)
|
|
ctx := context.Background()
|
|
|
|
repo.CreateAction(ctx, &Action{ID: "ca1", Status: "proposed"})
|
|
err := svc.ExecuteAction(ctx, "ca1")
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
}
|
|
|
|
func TestCannotCancelExecuted(t *testing.T) {
|
|
repo := newMemRepo()
|
|
svc := NewService(repo)
|
|
ctx := context.Background()
|
|
|
|
repo.CreateAction(ctx, &Action{ID: "ca1", Status: "executed"})
|
|
err := svc.CancelAction(ctx, "ca1")
|
|
if err == nil {
|
|
t.Fatal("expected error for cancelling executed action")
|
|
}
|
|
}
|
|
|
|
func TestCancelProposed(t *testing.T) {
|
|
repo := newMemRepo()
|
|
svc := NewService(repo)
|
|
ctx := context.Background()
|
|
|
|
repo.CreateAction(ctx, &Action{ID: "ca1", CompanyID: "c1", Status: "proposed"})
|
|
if err := svc.CancelAction(ctx, "ca1"); err != nil {
|
|
t.Fatalf("CancelAction: %v", err)
|
|
}
|
|
a, _ := repo.GetAction(ctx, "ca1")
|
|
if a.Status != "cancelled" {
|
|
t.Fatalf("status = %q, want cancelled", a.Status)
|
|
}
|
|
}
|
|
|
|
func TestCalculateSplit2to1(t *testing.T) {
|
|
svc := NewService(newMemRepo())
|
|
|
|
holdings := map[string]int64{
|
|
"sh1": 10_000,
|
|
"sh2": 5_000,
|
|
"sh3": 1,
|
|
}
|
|
|
|
results, err := svc.CalculateSplit(&StockSplit{
|
|
ShareClassID: "sc1",
|
|
Ratio: 2.0,
|
|
}, holdings)
|
|
if err != nil {
|
|
t.Fatalf("CalculateSplit: %v", err)
|
|
}
|
|
if len(results) != 3 {
|
|
t.Fatalf("results len = %d, want 3", len(results))
|
|
}
|
|
|
|
found := make(map[string]SplitResult)
|
|
for _, r := range results {
|
|
found[r.StakeholderID] = r
|
|
}
|
|
|
|
if found["sh1"].SharesAfter != 20_000 {
|
|
t.Fatalf("sh1 after = %d, want 20000", found["sh1"].SharesAfter)
|
|
}
|
|
if found["sh2"].SharesAfter != 10_000 {
|
|
t.Fatalf("sh2 after = %d, want 10000", found["sh2"].SharesAfter)
|
|
}
|
|
if found["sh3"].SharesAfter != 2 {
|
|
t.Fatalf("sh3 after = %d, want 2", found["sh3"].SharesAfter)
|
|
}
|
|
}
|
|
|
|
func TestCalculateSplitFractional(t *testing.T) {
|
|
svc := NewService(newMemRepo())
|
|
|
|
holdings := map[string]int64{
|
|
"sh1": 3, // 3 * 1.5 = 4.5 -> 4 shares + 0.5 fractional
|
|
}
|
|
|
|
results, err := svc.CalculateSplit(&StockSplit{
|
|
ShareClassID: "sc1",
|
|
Ratio: 1.5,
|
|
}, holdings)
|
|
if err != nil {
|
|
t.Fatalf("CalculateSplit: %v", err)
|
|
}
|
|
if results[0].SharesAfter != 4 {
|
|
t.Fatalf("shares_after = %d, want 4", results[0].SharesAfter)
|
|
}
|
|
if results[0].Fractional < 0.49 || results[0].Fractional > 0.51 {
|
|
t.Fatalf("fractional = %f, want ~0.5", results[0].Fractional)
|
|
}
|
|
}
|
|
|
|
func TestCalculateSplitZeroRatio(t *testing.T) {
|
|
svc := NewService(newMemRepo())
|
|
_, err := svc.CalculateSplit(&StockSplit{ShareClassID: "sc1", Ratio: 0}, map[string]int64{"sh1": 100})
|
|
if err == nil {
|
|
t.Fatal("expected error for zero ratio")
|
|
}
|
|
}
|
|
|
|
func TestCalculateReverseSplit(t *testing.T) {
|
|
svc := NewService(newMemRepo())
|
|
|
|
results, err := svc.CalculateSplit(&StockSplit{
|
|
ShareClassID: "sc1",
|
|
Ratio: 0.1, // 1:10 reverse split
|
|
}, map[string]int64{
|
|
"sh1": 100_000,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CalculateSplit: %v", err)
|
|
}
|
|
if results[0].SharesAfter != 10_000 {
|
|
t.Fatalf("shares_after = %d, want 10000", results[0].SharesAfter)
|
|
}
|
|
}
|