diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-22 13:40:27 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-22 13:40:27 +0800 |
| commit | ecae6f77ade52c6c2f4d008dc3d1ce3e338a1e0e (patch) | |
| tree | 22a31dfeb3a28f88c0e1822fc8f953345ccb3859 | |
| parent | 9bdc062e99b8de10700036a13a6e07a1d45411bd (diff) | |
| download | fund-lab-ecae6f77ade52c6c2f4d008dc3d1ce3e338a1e0e.tar.gz | |
Lock external flows vs internal trade ledger sources against net inflow (3d-36)
| -rw-r--r-- | tests/FundLab.Api.Tests/CashReconciliationTests.fs | 71 |
1 files changed, 71 insertions, 0 deletions
diff --git a/tests/FundLab.Api.Tests/CashReconciliationTests.fs b/tests/FundLab.Api.Tests/CashReconciliationTests.fs index e969472..b9b024a 100644 --- a/tests/FundLab.Api.Tests/CashReconciliationTests.fs +++ b/tests/FundLab.Api.Tests/CashReconciliationTests.fs @@ -210,6 +210,30 @@ type CashReconciliationTests(fixture: PostgresFixture) = | SubscriptionConfirmResult.OrderConfirmed _ -> () | other -> failwithf "unexpected subscription confirm: %A" other + let redeem fundId code units idempotencyKey = + let order = + match + repository().CreateRedemptionOrder( + idempotencyKey, + fundId, + { InstrumentCode = code; Units = units; FeeAmount = 0m } + ) + with + | RedemptionWriteResult.RedemptionCreated order + | RedemptionWriteResult.RedemptionReplayed order -> order + | other -> failwithf "unexpected redemption order result: %A" other + + match + repository().ConfirmRedemptionOrder( + fixture.Key(sprintf "cash-recon-redeem-confirm-%s" (Guid.NewGuid().ToString("N"))), + fundId, + order.Id + ) + with + | RedemptionConfirmResult.RedemptionConfirmed _ + | RedemptionConfirmResult.RedemptionConfirmReplayed _ -> () + | other -> failwithf "unexpected redemption confirm: %A" other + let app () = App.createApplication (repository ()) let reconciliation fundId = @@ -234,6 +258,11 @@ type CashReconciliationTests(fixture: PostgresFixture) = |> Seq.filter (fun entry -> entry.GetProperty("source").GetString() = source) |> Seq.length + let sourceNet (body: string) (source: string) = + match (sourceEntry body source).GetProperty("netAmount").GetString() with + | null -> failwithf "source %s has no netAmount" source + | text -> Decimal.Parse(text, System.Globalization.CultureInfo.InvariantCulture) + let deleteLedgerEvents fundId = use connection = new NpgsqlConnection(fixture.ConnectionString) connection.Open() @@ -471,3 +500,45 @@ type CashReconciliationTests(fixture: PostgresFixture) = Assert.Contains("\"difference\":\"0.00\"", body) Assert.Equal(1, eventCount body "stock_sell") Assert.Equal(1L, tableCount "stock_sells" fundId) + + [<Fact>] + member _.``external flows and internal trades stay separate sources reconciled against net inflow``() = + let fundId = createFund 10000.00m + let code = seedInstrument () + deposit fundId 5000.00m (fixture.Key "cash-flow-split-deposit") + subscribe fundId code 1000.00m (fixture.Key "cash-flow-split-subscribe") + redeem fundId code 400m (fixture.Key "cash-flow-split-redeem") + buyStock fundId "600519" 100m 10.00m (fixture.Key "cash-flow-split-buy") + sellStock fundId "600519" 40m 12.00m (fixture.Key "cash-flow-split-sell") + + let status, body = reconciliation fundId + Assert.Equal(200, status) + Assert.Contains("\"openingCash\":\"10000.00\"", body) + Assert.Contains("\"netInflow\":\"3880.00\"", body) + Assert.Contains("\"closingCash\":\"13880.00\"", body) + Assert.Contains("\"ledgerBalance\":\"13880.00\"", body) + Assert.Contains("\"difference\":\"0.00\"", body) + + // external capital/subscription/redemption are their own ledger sources + Assert.Equal(5000.00m, sourceNet body "capital_deposit") + Assert.Equal(-1000.00m, sourceNet body "subscription") + Assert.Equal(400.00m, sourceNet body "redemption") + Assert.Equal(1, eventCount body "subscription") + Assert.Equal(1, eventCount body "redemption") + + // internal stock trades are their own ledger sources, never external flows + Assert.Equal(-1000.00m, sourceNet body "stock_buy") + Assert.Equal(480.00m, sourceNet body "stock_sell") + Assert.Equal(1, eventCount body "stock_buy") + Assert.Equal(1, eventCount body "stock_sell") + + // the two buckets add up to net inflow: external flows are not smuggled into trade sources + let externalTotal = + sourceNet body "capital_deposit" + sourceNet body "subscription" + sourceNet body "redemption" + + let internalTotal = sourceNet body "stock_buy" + sourceNet body "stock_sell" + + Assert.Equal(4400.00m, externalTotal) + Assert.Equal(-520.00m, internalTotal) + Assert.Equal(3880.00m, externalTotal + internalTotal) + Assert.Equal(0.00m, (repository().GetCashReconciliation fundId |> Result.defaultWith failwith).Difference) |
