mirror of
https://github.com/luxfi/compliance.git
synced 2026-10-30 16:59:39 +00:00
474 lines
13 KiB
Go
474 lines
13 KiB
Go
package reporting
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// --- SAR Tests ---
|
|
|
|
func TestBuildSAR(t *testing.T) {
|
|
subject := Entity{
|
|
Name: "Jane Smith",
|
|
FirstName: "Jane",
|
|
LastName: "Smith",
|
|
DOB: "1985-03-15",
|
|
SSNLast4: "4567",
|
|
AccountID: "acct-001",
|
|
Address: "123 Main St",
|
|
City: "New York",
|
|
State: "NY",
|
|
ZipCode: "10001",
|
|
Country: "US",
|
|
}
|
|
|
|
alerts := []Alert{
|
|
{
|
|
ID: "alert-1",
|
|
RuleName: "Structuring",
|
|
Severity: "medium",
|
|
Amount: 9500,
|
|
CreatedAt: time.Date(2026, 1, 15, 10, 0, 0, 0, time.UTC),
|
|
},
|
|
{
|
|
ID: "alert-2",
|
|
RuleName: "Structuring",
|
|
Severity: "medium",
|
|
Amount: 9800,
|
|
CreatedAt: time.Date(2026, 1, 20, 14, 0, 0, 0, time.UTC),
|
|
},
|
|
}
|
|
|
|
sar := BuildSAR(subject, alerts, "Subject made multiple transactions just below the $10,000 CTR threshold over a 5-day period, consistent with structuring to avoid reporting.")
|
|
if sar == nil {
|
|
t.Fatal("BuildSAR returned nil")
|
|
}
|
|
if sar.Subject.Name != "Jane Smith" {
|
|
t.Errorf("subject name = %q, want %q", sar.Subject.Name, "Jane Smith")
|
|
}
|
|
if sar.SuspiciousActivity.TotalAmount != 19300 {
|
|
t.Errorf("total amount = %.2f, want %.2f", sar.SuspiciousActivity.TotalAmount, 19300.0)
|
|
}
|
|
if sar.FilingType != "initial" {
|
|
t.Errorf("filing_type = %q, want %q", sar.FilingType, "initial")
|
|
}
|
|
}
|
|
|
|
func TestValidateSAR_Valid(t *testing.T) {
|
|
sar := &SARData{
|
|
FilingType: "initial",
|
|
Subject: SARSubject{Name: "John Doe"},
|
|
SuspiciousActivity: SARActivity{
|
|
DateStart: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
|
|
DateEnd: time.Date(2026, 3, 31, 0, 0, 0, 0, time.UTC),
|
|
TotalAmount: 25000,
|
|
},
|
|
Narrative: "Subject transferred $25,000 across multiple accounts in a pattern consistent with layering, using accounts opened within days of each other.",
|
|
}
|
|
|
|
errs := ValidateSAR(sar)
|
|
if len(errs) != 0 {
|
|
t.Errorf("expected no errors, got %v", errs)
|
|
}
|
|
}
|
|
|
|
func TestValidateSAR_MissingFields(t *testing.T) {
|
|
sar := &SARData{}
|
|
errs := ValidateSAR(sar)
|
|
if len(errs) == 0 {
|
|
t.Error("expected validation errors for empty SAR")
|
|
}
|
|
|
|
// Must flag: filing_type, subject name, dates, amount, narrative.
|
|
want := 6
|
|
if len(errs) < want {
|
|
t.Errorf("got %d errors, want at least %d", len(errs), want)
|
|
}
|
|
}
|
|
|
|
func TestValidateSAR_ShortNarrative(t *testing.T) {
|
|
sar := &SARData{
|
|
FilingType: "initial",
|
|
Subject: SARSubject{Name: "X"},
|
|
SuspiciousActivity: SARActivity{
|
|
DateStart: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
|
|
DateEnd: time.Date(2026, 3, 31, 0, 0, 0, 0, time.UTC),
|
|
TotalAmount: 10000,
|
|
},
|
|
Narrative: "Suspicious.",
|
|
}
|
|
|
|
errs := ValidateSAR(sar)
|
|
found := false
|
|
for _, e := range errs {
|
|
if e == "narrative must be at least 50 characters — explain the suspicious activity" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("expected short narrative error")
|
|
}
|
|
}
|
|
|
|
func TestValidateSAR_BelowThreshold(t *testing.T) {
|
|
sar := &SARData{
|
|
FilingType: "initial",
|
|
Subject: SARSubject{Name: "X"},
|
|
SuspiciousActivity: SARActivity{
|
|
DateStart: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
|
|
DateEnd: time.Date(2026, 3, 31, 0, 0, 0, 0, time.UTC),
|
|
TotalAmount: 3000,
|
|
},
|
|
Narrative: "Subject transferred money around, and it looked kind of suspicious but was below the threshold amount.",
|
|
}
|
|
|
|
errs := ValidateSAR(sar)
|
|
found := false
|
|
for _, e := range errs {
|
|
if e == "total_amount $3000.00 below $5,000 SAR threshold" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("expected below-threshold error")
|
|
}
|
|
}
|
|
|
|
// --- CTR Tests ---
|
|
|
|
func TestBuildCTR(t *testing.T) {
|
|
user := Entity{Name: "Alice Example", SSNLast4: "1234"}
|
|
date := time.Date(2026, 4, 10, 0, 0, 0, 0, time.UTC)
|
|
txs := []Transaction{
|
|
{ID: "tx-1", Type: "deposit", Amount: 7000, Currency: "USD", Date: date},
|
|
{ID: "tx-2", Type: "deposit", Amount: 5000, Currency: "USD", Date: date},
|
|
}
|
|
|
|
ctr := BuildCTR(user, txs, date)
|
|
if ctr.TotalCashIn != 12000 {
|
|
t.Errorf("total_cash_in = %.2f, want %.2f", ctr.TotalCashIn, 12000.0)
|
|
}
|
|
if len(ctr.Transactions) != 2 {
|
|
t.Errorf("got %d transactions, want 2", len(ctr.Transactions))
|
|
}
|
|
}
|
|
|
|
func TestBuildCTR_FiltersOtherDays(t *testing.T) {
|
|
user := Entity{Name: "Bob"}
|
|
date := time.Date(2026, 4, 10, 0, 0, 0, 0, time.UTC)
|
|
txs := []Transaction{
|
|
{ID: "tx-1", Type: "deposit", Amount: 11000, Currency: "USD", Date: date},
|
|
{ID: "tx-2", Type: "deposit", Amount: 5000, Currency: "USD", Date: date.AddDate(0, 0, 1)},
|
|
}
|
|
|
|
ctr := BuildCTR(user, txs, date)
|
|
if len(ctr.Transactions) != 1 {
|
|
t.Errorf("got %d transactions, want 1 (other day filtered)", len(ctr.Transactions))
|
|
}
|
|
}
|
|
|
|
func TestValidateCTR_BelowThreshold(t *testing.T) {
|
|
ctr := &CTRData{
|
|
Person: CTRPerson{Name: "X"},
|
|
Date: time.Now(),
|
|
Transactions: []CTRTxDetail{{Amount: 5000, Direction: "cash_in"}},
|
|
TotalCashIn: 5000,
|
|
}
|
|
errs := ValidateCTR(ctr)
|
|
found := false
|
|
for _, e := range errs {
|
|
if e == "aggregate total below $10,000 CTR threshold" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("expected below-threshold error")
|
|
}
|
|
}
|
|
|
|
func TestRequiresCTR(t *testing.T) {
|
|
if !RequiresCTR(10001) {
|
|
t.Error("10001 should require CTR")
|
|
}
|
|
if RequiresCTR(9999) {
|
|
t.Error("9999 should not require CTR")
|
|
}
|
|
if RequiresCTR(10000) {
|
|
t.Error("exactly 10000 should not require CTR (threshold is >)")
|
|
}
|
|
}
|
|
|
|
// --- Form D Tests ---
|
|
|
|
func TestBuildFormD(t *testing.T) {
|
|
offering := Offering{
|
|
IssuerName: "Acme Fund LP",
|
|
IssuerState: "DE",
|
|
IssuerCountry: "US",
|
|
EntityType: "lp",
|
|
ExemptionType: "reg_d_506b",
|
|
SecurityType: "pooled_interest",
|
|
TotalOfferingSize: 10000000,
|
|
TotalSold: 3000000,
|
|
FirstSaleDate: time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC),
|
|
}
|
|
issuer := Entity{Name: "Acme Fund LP"}
|
|
sales := map[string]float64{"NY": 1000000, "CA": 2000000}
|
|
|
|
fd := BuildFormD(offering, issuer, sales)
|
|
if fd.Issuer.Name != "Acme Fund LP" {
|
|
t.Errorf("issuer name = %q, want %q", fd.Issuer.Name, "Acme Fund LP")
|
|
}
|
|
if fd.Offering.ExemptionClaimed[0] != "rule_506_b" {
|
|
t.Errorf("exemption = %q, want %q", fd.Offering.ExemptionClaimed[0], "rule_506_b")
|
|
}
|
|
}
|
|
|
|
func TestValidateFormD_506cNonAccredited(t *testing.T) {
|
|
fd := &FormDData{
|
|
Issuer: FormDIssuer{
|
|
Name: "X",
|
|
EntityType: "corporation",
|
|
StateOfIncorporation: "DE",
|
|
},
|
|
Offering: FormDOffering{
|
|
SecurityType: "equity",
|
|
ExemptionClaimed: []string{"rule_506_c"},
|
|
TotalOfferingSize: 1000000,
|
|
HasNonAccredited: true,
|
|
FirstSaleDate: time.Now(),
|
|
},
|
|
}
|
|
|
|
errs := ValidateFormD(fd)
|
|
found := false
|
|
for _, e := range errs {
|
|
if e == "Rule 506(c) requires all investors to be accredited" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("expected 506(c) non-accredited error")
|
|
}
|
|
}
|
|
|
|
// --- ATS-N Tests ---
|
|
|
|
func TestBuildATSN(t *testing.T) {
|
|
orders := []Order{
|
|
{ID: "o-1", Side: "buy", Type: "limit", Qty: 100, Price: 50, FilledQty: 100, FilledPrice: 50, Status: "filled", SubscriberID: "sub-1"},
|
|
{ID: "o-2", Side: "sell", Type: "market", Qty: 200, Price: 25, FilledQty: 0, Status: "cancelled", SubscriberID: "sub-2"},
|
|
{ID: "o-3", Side: "buy", Type: "limit", Qty: 50, Price: 100, FilledQty: 50, FilledPrice: 99, Status: "filled", SubscriberID: "sub-1"},
|
|
}
|
|
|
|
atsn := BuildATSN(orders, "2026-Q1")
|
|
if atsn.OrderSummary.TotalOrders != 3 {
|
|
t.Errorf("total_orders = %d, want 3", atsn.OrderSummary.TotalOrders)
|
|
}
|
|
if atsn.OrderSummary.BuyOrders != 2 {
|
|
t.Errorf("buy_orders = %d, want 2", atsn.OrderSummary.BuyOrders)
|
|
}
|
|
if atsn.OrderSummary.CancelledOrders != 1 {
|
|
t.Errorf("cancelled = %d, want 1", atsn.OrderSummary.CancelledOrders)
|
|
}
|
|
if atsn.ExecutionSummary.TotalExecutions != 2 {
|
|
t.Errorf("executions = %d, want 2", atsn.ExecutionSummary.TotalExecutions)
|
|
}
|
|
if atsn.Subscribers.Active != 2 {
|
|
t.Errorf("active_subscribers = %d, want 2", atsn.Subscribers.Active)
|
|
}
|
|
}
|
|
|
|
// --- MiFID Tests ---
|
|
|
|
func TestBuildMiFID(t *testing.T) {
|
|
tx := Transaction{
|
|
ID: "tx-001",
|
|
Amount: 15000,
|
|
Currency: "EUR",
|
|
Side: "buy",
|
|
Asset: "DE000BASF111",
|
|
Date: time.Now(),
|
|
}
|
|
entity := Entity{
|
|
LEI: "5299001Y2H7A4QBQ2N56",
|
|
FirstName: "Hans",
|
|
LastName: "Mueller",
|
|
Country: "DE",
|
|
}
|
|
|
|
m := BuildMiFID(tx, entity)
|
|
if m.Buyer.LEI != entity.LEI {
|
|
t.Errorf("buyer LEI = %q, want %q", m.Buyer.LEI, entity.LEI)
|
|
}
|
|
if m.Execution.Side != "buy" {
|
|
t.Errorf("side = %q, want %q", m.Execution.Side, "buy")
|
|
}
|
|
}
|
|
|
|
func TestBuildMiFID_Sell(t *testing.T) {
|
|
tx := Transaction{ID: "tx-002", Amount: 5000, Currency: "EUR", Side: "sell", Date: time.Now()}
|
|
entity := Entity{LEI: "ABC123", LastName: "Test"}
|
|
|
|
m := BuildMiFID(tx, entity)
|
|
if m.Seller.LEI != "ABC123" {
|
|
t.Error("seller not populated for sell side")
|
|
}
|
|
}
|
|
|
|
func TestValidateMiFID_MissingLEI(t *testing.T) {
|
|
m := &MiFIDData{
|
|
Execution: MiFIDExecution{Price: 100, Side: "buy"},
|
|
Instrument: MiFIDInstrument{Currency: "EUR"},
|
|
TradingDateTime: time.Now(),
|
|
}
|
|
errs := ValidateMiFID(m)
|
|
if len(errs) == 0 {
|
|
t.Error("expected validation errors for missing LEI")
|
|
}
|
|
}
|
|
|
|
// --- Tax 1099 Tests ---
|
|
|
|
func TestBuild1099(t *testing.T) {
|
|
user := Entity{Name: "Tax Payer", SSNLast4: "9999"}
|
|
trades := []Trade{
|
|
{
|
|
Symbol: "AAPL",
|
|
Side: "sell",
|
|
Qty: 10,
|
|
Proceeds: 1500,
|
|
CostBasis: 1000,
|
|
AcquiredDate: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC),
|
|
DisposedDate: time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC),
|
|
HoldingPeriod: "short",
|
|
},
|
|
{
|
|
Symbol: "MSFT",
|
|
Side: "sell",
|
|
Qty: 5,
|
|
Proceeds: 5000,
|
|
CostBasis: 3000,
|
|
AcquiredDate: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
|
|
DisposedDate: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC),
|
|
HoldingPeriod: "long",
|
|
},
|
|
}
|
|
dividends := []Dividend{
|
|
{Symbol: "AAPL", Amount: 50, Qualified: true, Date: time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)},
|
|
}
|
|
|
|
td := Build1099(user, trades, dividends, 2026)
|
|
if td.Year != 2026 {
|
|
t.Errorf("year = %d, want 2026", td.Year)
|
|
}
|
|
if len(td.Proceeds) != 2 {
|
|
t.Errorf("proceeds count = %d, want 2", len(td.Proceeds))
|
|
}
|
|
if td.Summary.ShortTermGainLoss != 500 {
|
|
t.Errorf("short_term = %.2f, want 500", td.Summary.ShortTermGainLoss)
|
|
}
|
|
if td.Summary.LongTermGainLoss != 2000 {
|
|
t.Errorf("long_term = %.2f, want 2000", td.Summary.LongTermGainLoss)
|
|
}
|
|
if td.Summary.TotalQualifiedDividends != 50 {
|
|
t.Errorf("qualified_dividends = %.2f, want 50", td.Summary.TotalQualifiedDividends)
|
|
}
|
|
}
|
|
|
|
func TestWashSaleDetection(t *testing.T) {
|
|
trades := []Trade{
|
|
{
|
|
Symbol: "AAPL",
|
|
Side: "sell",
|
|
Qty: 10,
|
|
Proceeds: 900,
|
|
CostBasis: 1000,
|
|
AcquiredDate: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
|
|
DisposedDate: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC),
|
|
HoldingPeriod: "short",
|
|
},
|
|
{
|
|
Symbol: "AAPL",
|
|
Side: "buy",
|
|
Qty: 10,
|
|
Proceeds: 950,
|
|
CostBasis: 950,
|
|
AcquiredDate: time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC),
|
|
DisposedDate: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC),
|
|
HoldingPeriod: "short",
|
|
},
|
|
}
|
|
|
|
result := detectWashSales(trades)
|
|
if !result[0].WashSale {
|
|
t.Error("first trade should be flagged as wash sale")
|
|
}
|
|
if result[0].WashAmount != 100 {
|
|
t.Errorf("wash amount = %.2f, want 100", result[0].WashAmount)
|
|
}
|
|
}
|
|
|
|
// --- Travel Rule Tests ---
|
|
|
|
func TestRequiresTravelRule_USThreshold(t *testing.T) {
|
|
if !RequiresTravelRule(5000, "USD", "US") {
|
|
t.Error("$5,000 USD in US should require travel rule")
|
|
}
|
|
if RequiresTravelRule(2000, "USD", "US") {
|
|
t.Error("$2,000 USD in US should not require travel rule")
|
|
}
|
|
}
|
|
|
|
func TestRequiresTravelRule_UnknownJurisdiction(t *testing.T) {
|
|
// Unknown jurisdiction uses conservative $1,000 threshold.
|
|
if !RequiresTravelRule(1500, "USD", "XX") {
|
|
t.Error("$1,500 in unknown jurisdiction should require travel rule")
|
|
}
|
|
}
|
|
|
|
func TestBuildTravelRuleMessage(t *testing.T) {
|
|
data := TravelRuleData{
|
|
Originator: TravelRuleParty{
|
|
Name: "Alice Corp",
|
|
AccountID: "alice-001",
|
|
Institution: "VASP Alpha",
|
|
},
|
|
Beneficiary: TravelRuleParty{
|
|
Name: "Bob Ltd",
|
|
AccountID: "bob-002",
|
|
Institution: "VASP Beta",
|
|
},
|
|
Amount: 50000,
|
|
Currency: "USD",
|
|
Timestamp: time.Now(),
|
|
}
|
|
|
|
msg := BuildTravelRuleMessage(data)
|
|
if msg.Version != "IVMS101" {
|
|
t.Errorf("version = %q, want %q", msg.Version, "IVMS101")
|
|
}
|
|
if msg.Originator.Name != "Alice Corp" {
|
|
t.Errorf("originator name = %q, want %q", msg.Originator.Name, "Alice Corp")
|
|
}
|
|
}
|
|
|
|
func TestValidateTravelRule(t *testing.T) {
|
|
// Missing everything.
|
|
errs := ValidateTravelRule(TravelRuleData{})
|
|
if len(errs) < 4 {
|
|
t.Errorf("expected at least 4 errors, got %d", len(errs))
|
|
}
|
|
|
|
// Valid.
|
|
errs = ValidateTravelRule(TravelRuleData{
|
|
Originator: TravelRuleParty{Name: "A", AccountID: "a1"},
|
|
Beneficiary: TravelRuleParty{Name: "B", AccountID: "b1"},
|
|
Amount: 5000,
|
|
Currency: "USD",
|
|
})
|
|
if len(errs) != 0 {
|
|
t.Errorf("expected no errors, got %v", errs)
|
|
}
|
|
}
|