diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 11:21:51 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 11:21:51 +0800 |
| commit | b8811922f4e167d50efb8817da35a12d8a9d653b (patch) | |
| tree | b85cc56677be5d4d58ad2277f00cbc935933301e /tests | |
| parent | 3d9398234a54774544eb48a1caed6a1b2d5132eb (diff) | |
| download | fund-lab-b8811922f4e167d50efb8817da35a12d8a9d653b.tar.gz | |
Add redemption order minimal closed loop (3d-3)
Redeem confirmed holdings end to end with exact decimal semantics:
submission freezes position units (reserved_units) after an
available-units check without touching cash, confirmation settles at
the trade-date NAV with evidence and deferral rules identical to
subscriptions, credits the fund with proceeds net of the stated fee,
and writes off position cost pro rata (full redemption removes the
position row). Cash conservation holds at every step: frozen shares
and receivable cash never appear as available cash before settlement.
Domain gains RedemptionPolicy (request validation plus proceeds/fee/
cost-release computation) with unit tests. Persistence adds
redemption_orders, idempotency tables, reserved_units on positions
and Create/Confirm repository members; the API exposes
POST/GET /funds/{id}/redemptions and POST .../confirm. The frontend
adds a 06/REDEEM panel (submit, confirm, pending reasons, confirmed
proceeds/cost display), frozen-share display on the holdings panel,
and refresh wiring. Browser QA gains a K-series covering freeze,
settlement, cash accounting, replay and over-redemption rejection.
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/FundLab.Api.Tests/OrderTests.fs | 333 | ||||
| -rw-r--r-- | tests/FundLab.Domain.Tests/DomainTests.fs | 48 | ||||
| -rw-r--r-- | tests/FundLab.Web.Tests/BoundaryTests.fs | 76 |
3 files changed, 457 insertions, 0 deletions
diff --git a/tests/FundLab.Api.Tests/OrderTests.fs b/tests/FundLab.Api.Tests/OrderTests.fs index d9725d5..4fc1da0 100644 --- a/tests/FundLab.Api.Tests/OrderTests.fs +++ b/tests/FundLab.Api.Tests/OrderTests.fs @@ -1547,3 +1547,336 @@ type SubscriptionConfirmationTests(fixture: PostgresFixture) = Assert.Equal(200, cashStatus) Assert.Contains("\"availableCash\":\"9000.00\"", cashBody) + +[<Collection("postgres")>] +type RedemptionTests(fixture: PostgresFixture) = + let sharedRepository = + lazy + let value = FundRepository(fixture.ConnectionString) + value.EnsureSchema() + value + + let repository () = sharedRepository.Value + + let seedInstrument () = + let code = Random.Shared.Next(0, 1000000).ToString("D6") + let payload = + { + Source = "akshare" + SourceRevision = "akshare-test/eastmoney" + CollectedAt = DateTimeOffset(2026, 9, 21, 10, 0, 0, TimeSpan.Zero) + Instruments = [ { Code = code; Name = "赎回测试基金"; FundType = None } ] + } + + repository().UpsertInstruments(payload, "redemption-test-hash") + code + + let createFund (initialCash: decimal) = + let command = + { + Name = "赎回测试 FOF" + InitialCash = initialCash + InitialUnitNav = 1.00000000m + IsSynthetic = true + } + + let key = fixture.Key(sprintf "redemption-fund-%s" (Guid.NewGuid().ToString("N"))) + + match repository().CreateFund(key, command) with + | FundWriteResult.Created fund -> fund.Id + | other -> failwithf "unexpected fund creation result: %A" other + + let app () = App.createApplication (repository ()) + + let truncateMicroseconds (moment: DateTimeOffset) = + let utc = moment.ToUniversalTime() + DateTimeOffset(utc.Ticks - (utc.Ticks % 10L), TimeSpan.Zero) + + let insertQuoteOnDate (code: string) (nav: decimal) (navDate: DateOnly) (collectedAt: DateTimeOffset) = + let revision = sprintf "akshare-test/%O" (Guid.NewGuid()) + let payload: MarketDataNavPayload = + { + Source = "akshare" + SourceRevision = revision + CollectedAt = collectedAt + Code = code + Observations = + [ + { + NavDate = navDate + PublishedAt = None + Nav = nav + AccumulatedNav = Some nav + DailyReturn = Some 0.0m + } + ] + } + + repository().UpsertNavObservations(payload, sprintf "redemption-quote-hash/%s" revision) + + let insertQuote (code: string) (nav: decimal) (collectedAt: DateTimeOffset) = + insertQuoteOnDate code nav (ConfirmationPolicy.tradeDateFor DateTimeOffset.Now) collectedAt + + let execute (sql: string) (parameters: (string * obj * NpgsqlTypes.NpgsqlDbType) list) = + use connection = new NpgsqlConnection(fixture.ConnectionString) + connection.Open() + use command = connection.CreateCommand() + command.CommandText <- sql + + for name, value, dbType in parameters do + let parameter = command.Parameters.Add(name, dbType) + parameter.Value <- value + + command.ExecuteNonQuery() |> ignore + + let scalarDecimal (sql: string) (parameters: (string * obj * NpgsqlTypes.NpgsqlDbType) list) = + use connection = new NpgsqlConnection(fixture.ConnectionString) + connection.Open() + use command = connection.CreateCommand() + command.CommandText <- sql + + for name, value, dbType in parameters do + let parameter = command.Parameters.Add(name, dbType) + parameter.Value <- value + + command.ExecuteScalar() :?> decimal + + let availableCash fundId = + scalarDecimal "SELECT available_cash FROM funds WHERE id = @fund_id" [ "fund_id", box fundId, NpgsqlTypes.NpgsqlDbType.Uuid ] + + let reservedCash fundId = + scalarDecimal "SELECT reserved_cash FROM funds WHERE id = @fund_id" [ "fund_id", box fundId, NpgsqlTypes.NpgsqlDbType.Uuid ] + + let positionValue fundId code column = + scalarDecimal + (sprintf "SELECT %s FROM fund_positions WHERE fund_id = @fund_id AND instrument_code = @code" column) + [ + "fund_id", box fundId, NpgsqlTypes.NpgsqlDbType.Uuid + "code", box code, NpgsqlTypes.NpgsqlDbType.Text + ] + + let postOrder fundId idempotencyKey amount fee = + let code = seedInstrument () + let body = sprintf "{\"fundCode\":\"%s\",\"amount\":\"%s\",\"feeAmount\":\"%s\"}" code amount fee + + PersistenceTestHelpers.invoke + (app ()) + "POST" + (sprintf "/api/funds/%O/orders" fundId) + [ + "Authorization", "Bearer test-token" + "Idempotency-Key", idempotencyKey + ] + body + |> fun (status, response) -> + if status <> 201 then failwithf "unexpected order status %d: %s" status response + code, PersistenceTestHelpers.responseId response + + let confirmOrder (fundId: Guid) (orderId: Guid) (code: string) = + let collected = truncateMicroseconds (DateTimeOffset.Now.AddSeconds(-10.0)) + insertQuote code 2.5m collected + let key = fixture.Key(sprintf "confirm-%s" (Guid.NewGuid().ToString("N"))) + + PersistenceTestHelpers.invoke + (app ()) + "POST" + (sprintf "/api/funds/%O/orders/%O/confirm" fundId orderId) + [ + "Authorization", "Bearer test-token" + "Idempotency-Key", key + ] + "" + |> fun (status, response) -> + if status <> 200 then failwithf "unexpected confirm status %d: %s" status response + + let postRedemption (fundId: Guid) (code: string) (idempotencyKey: string) (units: string) (fee: string) = + let body = + sprintf + "{\"instrumentCode\":\"%s\",\"units\":\"%s\",\"feeAmount\":\"%s\"}" + code + units + fee + + PersistenceTestHelpers.invoke + (app ()) + "POST" + (sprintf "/api/funds/%O/redemptions" fundId) + [ + "Authorization", "Bearer test-token" + "Idempotency-Key", idempotencyKey + ] + body + + let confirmRedemptionOverHttp (fundId: Guid) (orderId: Guid) (idempotencyKey: string) = + PersistenceTestHelpers.invoke + (app ()) + "POST" + (sprintf "/api/funds/%O/redemptions/%O/confirm" fundId orderId) + [ + "Authorization", "Bearer test-token" + "Idempotency-Key", idempotencyKey + ] + "" + + let getRedemptionsOverHttp (fundId: Guid) = + PersistenceTestHelpers.invoke + (app ()) + "GET" + (sprintf "/api/funds/%O/redemptions" fundId) + [ "Authorization", "Bearer test-token" ] + "" + + let confirmedSubscription fundId initialCash amount = + let code, orderId = postOrder fundId (fixture.Key(sprintf "sub-%s" (Guid.NewGuid().ToString("N")))) amount "0.00" + confirmOrder fundId orderId code + code + + [<Fact>] + member _.``redemption submission freezes holding units without touching cash``() = + let fundId = createFund 10000.00m + let code = confirmedSubscription fundId 10000.00m "1000.00" + let key = fixture.Key(sprintf "red-%s" (Guid.NewGuid().ToString("N"))) + + let status, response = postRedemption fundId code key "150.00000000" "1.50" + + Assert.Equal(201, status) + Assert.Contains("\"status\":\"submitted\"", response) + Assert.Contains("\"units\":\"150.00000000\"", response) + Assert.Contains("\"feeAmount\":\"1.50\"", response) + + Assert.Equal(9000.00m, availableCash fundId) + Assert.Equal(0.00m, reservedCash fundId) + Assert.Equal(400.00000000m, positionValue fundId code "units") + Assert.Equal(150.00000000m, positionValue fundId code "reserved_units") + Assert.Equal(1000.00m, positionValue fundId code "cost_cash") + + [<Fact>] + member _.``redemption above available units is rejected without freezing``() = + let fundId = createFund 10000.00m + let code = confirmedSubscription fundId 10000.00m "1000.00" + let key = fixture.Key(sprintf "red-%s" (Guid.NewGuid().ToString("N"))) + + let status, response = postRedemption fundId code key "400.00000001" "0.00" + + Assert.Equal(409, status) + Assert.Contains("INSUFFICIENT_UNITS", response) + Assert.Equal(0.00000000m, positionValue fundId code "reserved_units") + Assert.Equal(400.00000000m, positionValue fundId code "units") + + [<Fact>] + member _.``redemption replay returns the same order without double freezing``() = + let fundId = createFund 10000.00m + let code = confirmedSubscription fundId 10000.00m "1000.00" + let key = fixture.Key(sprintf "red-%s" (Guid.NewGuid().ToString("N"))) + + let firstStatus, firstResponse = postRedemption fundId code key "100.00000000" "0.50" + let secondStatus, secondResponse = postRedemption fundId code key "100.00000000" "0.50" + + Assert.Equal(201, firstStatus) + Assert.Equal(200, secondStatus) + Assert.Equal(firstResponse, secondResponse) + Assert.Equal(100.00000000m, positionValue fundId code "reserved_units") + + let conflictingStatus, conflictingResponse = postRedemption fundId code key "50.00000000" "0.50" + + Assert.Equal(409, conflictingStatus) + Assert.Contains("IDEMPOTENCY_CONFLICT", conflictingResponse) + Assert.Equal(100.00000000m, positionValue fundId code "reserved_units") + + [<Fact>] + member _.``redemption confirmation settles cash and writes off position``() = + let fundId = createFund 10000.00m + let code = confirmedSubscription fundId 10000.00m "1000.00" + let key = fixture.Key(sprintf "red-%s" (Guid.NewGuid().ToString("N"))) + + let _, submitResponse = postRedemption fundId code key "150.00000000" "1.50" + use document = System.Text.Json.JsonDocument.Parse(submitResponse) + let orderId = document.RootElement.GetProperty("id").GetGuid() + + let status, response = confirmRedemptionOverHttp fundId orderId (fixture.Key(sprintf "redconf-%s" (Guid.NewGuid().ToString("N")))) + + Assert.Equal(200, status) + Assert.Contains("\"status\":\"confirmed\"", response) + Assert.Contains("\"confirmedNav\":\"2.50000000\"", response) + Assert.Contains("\"confirmedProceeds\":\"373.50\"", response) + Assert.Contains("\"confirmedCostReleased\":\"375.00\"", response) + + Assert.Equal(9373.50m, availableCash fundId) + Assert.Equal(0.00m, reservedCash fundId) + Assert.Equal(250.00000000m, positionValue fundId code "units") + Assert.Equal(0.00000000m, positionValue fundId code "reserved_units") + Assert.Equal(625.00m, positionValue fundId code "cost_cash") + + [<Fact>] + member _.``full redemption removes the position row while cash conservation holds``() = + let fundId = createFund 10000.00m + let code = confirmedSubscription fundId 10000.00m "1000.00" + let key = fixture.Key(sprintf "red-%s" (Guid.NewGuid().ToString("N"))) + + let _, submitResponse = postRedemption fundId code key "400.00000000" "0.00" + use document = System.Text.Json.JsonDocument.Parse(submitResponse) + let orderId = document.RootElement.GetProperty("id").GetGuid() + + let status, response = confirmRedemptionOverHttp fundId orderId (fixture.Key(sprintf "redconf-%s" (Guid.NewGuid().ToString("N")))) + + Assert.Equal(200, status) + Assert.Contains("\"confirmedProceeds\":\"1000.00\"", response) + Assert.Contains("\"confirmedCostReleased\":\"1000.00\"", response) + + use connection = new NpgsqlConnection(fixture.ConnectionString) + connection.Open() + use command = connection.CreateCommand() + command.CommandText <- "SELECT count(*) FROM fund_positions WHERE fund_id = @fund_id AND instrument_code = @code" + let fundParameter = command.Parameters.Add("fund_id", NpgsqlTypes.NpgsqlDbType.Uuid) + fundParameter.Value <- box fundId + let codeParameter = command.Parameters.Add("code", NpgsqlTypes.NpgsqlDbType.Text) + codeParameter.Value <- box code + let remaining = command.ExecuteScalar() :?> int64 + + Assert.Equal(0L, remaining) + Assert.Equal(10000.00m, availableCash fundId) + Assert.Equal(0.00m, reservedCash fundId) + + [<Fact>] + member _.``redemption confirm replay keeps cash and positions stable``() = + let fundId = createFund 10000.00m + let code = confirmedSubscription fundId 10000.00m "1000.00" + let submitKey = fixture.Key(sprintf "red-%s" (Guid.NewGuid().ToString("N"))) + + let _, submitResponse = postRedemption fundId code submitKey "80.00000000" "0.80" + use document = System.Text.Json.JsonDocument.Parse(submitResponse) + let orderId = document.RootElement.GetProperty("id").GetGuid() + + let confirmKey = fixture.Key(sprintf "redconf-%s" (Guid.NewGuid().ToString("N"))) + let firstStatus, firstResponse = confirmRedemptionOverHttp fundId orderId confirmKey + let secondStatus, secondResponse = confirmRedemptionOverHttp fundId orderId confirmKey + + Assert.Equal(200, firstStatus) + Assert.Equal(200, secondStatus) + Assert.Equal(firstResponse, secondResponse) + Assert.Equal(9000.00m + 199.20m, availableCash fundId) + Assert.Equal(320.00000000m, positionValue fundId code "units") + + let conflictingStatus, conflictingResponse = confirmRedemptionOverHttp fundId orderId (fixture.Key(sprintf "redconf-%s" (Guid.NewGuid().ToString("N")))) + + Assert.Equal(409, conflictingStatus) + Assert.Contains("ORDER_ALREADY_CONFIRMED", conflictingResponse) + Assert.Equal(9000.00m + 199.20m, availableCash fundId) + + [<Fact>] + member _.``redemption list exposes confirmation evidence``() = + let fundId = createFund 10000.00m + let code = confirmedSubscription fundId 10000.00m "1000.00" + let submitKey = fixture.Key(sprintf "red-%s" (Guid.NewGuid().ToString("N"))) + + let _, submitResponse = postRedemption fundId code submitKey "40.00000000" "0.40" + use document = System.Text.Json.JsonDocument.Parse(submitResponse) + let orderId = document.RootElement.GetProperty("id").GetGuid() + + let _, confirmResponse = confirmRedemptionOverHttp fundId orderId (fixture.Key(sprintf "redconf-%s" (Guid.NewGuid().ToString("N")))) + let listStatus, listResponse = getRedemptionsOverHttp fundId + + Assert.Equal(200, listStatus) + Assert.Contains("\"instrumentCode\":\"" + code + "\"", listResponse) + Assert.Contains("\"status\":\"confirmed\"", listResponse) + Assert.Contains("\"confirmedProceeds\":\"99.60\"", listResponse) diff --git a/tests/FundLab.Domain.Tests/DomainTests.fs b/tests/FundLab.Domain.Tests/DomainTests.fs index f2b4138..6a9fc66 100644 --- a/tests/FundLab.Domain.Tests/DomainTests.fs +++ b/tests/FundLab.Domain.Tests/DomainTests.fs @@ -491,3 +491,51 @@ module DomainTests = Assert.Equal(Error(InvalidObservation "net assets cannot be negative"), negative) Assert.Equal(Error(InvalidObservation "first net assets must be positive"), zero) + +module RedemptionPolicyTests = + + open Xunit + open FundLab.Domain + + let private unwrap result = + match result with + | Ok value -> value + | Error error -> failwithf "%A" error + + [<Fact>] + let ``redemption request above available units is rejected`` () = + Assert.Equal(Error "requested units exceed available holdings", RedemptionPolicy.validateRequest 10.00000000m 10.00000001m 0.00m) + Assert.Equal(Error "redemption units must be positive", RedemptionPolicy.validateRequest 10.00000000m 0m 0.00m) + Assert.Equal(Error "redemption fee cannot be negative", RedemptionPolicy.validateRequest 10.00000000m 5.00000000m -0.01m) + Assert.Equal(Ok(), RedemptionPolicy.validateRequest 10.00000000m 10.00000000m 1.50m) + + [<Fact>] + let ``full redemption releases the entire position cost`` () = + let computed = RedemptionPolicy.compute 40.00000000m 2.5m 1.00m 100.00m 40.00000000m |> unwrap + + Assert.Equal(100.00m, computed.GrossCash) + Assert.Equal(1.00m, computed.Fee) + Assert.Equal(99.00m, computed.Proceeds) + Assert.Equal(100.00m, computed.RedeemedCost) + + [<Fact>] + let ``partial redemption writes off cost pro rata rounded down`` () = + let computed = RedemptionPolicy.compute 10.00000000m 2.5m 0.00m 100.00m 40.00000000m |> unwrap + + Assert.Equal(25.00m, computed.GrossCash) + Assert.Equal(0.00m, computed.Fee) + Assert.Equal(25.00m, computed.Proceeds) + Assert.Equal(25.00m, computed.RedeemedCost) + + let uneven = RedemptionPolicy.compute 1.00000000m 3m 0.00m 100.00m 3.00000000m |> unwrap + + Assert.Equal(3.00m, uneven.GrossCash) + Assert.Equal(33.33m, uneven.RedeemedCost) + + [<Fact>] + let ``redemption computation rejects invalid economics`` () = + Assert.Equal(Error "redemption units exceed position units", RedemptionPolicy.compute 41.00000000m 2.5m 0.00m 100.00m 40.00000000m) + Assert.Equal(Error "unit nav must be positive", RedemptionPolicy.compute 1.00000000m 0m 0.00m 100.00m 40.00000000m) + Assert.Equal(Error "redemption fee exceeds redemption value", RedemptionPolicy.compute 1.00000000m 2.5m 3.00m 100.00m 40.00000000m) + Assert.Equal(Error "redemption proceeds rounds to zero", RedemptionPolicy.compute 0.00000001m 1500000m 0.01m 100.00m 40.00000000m) + Assert.Equal(Error "redemption cash rounds to zero", RedemptionPolicy.compute 0.00000001m 2m 0.00m 100.00m 40.00000000m) diff --git a/tests/FundLab.Web.Tests/BoundaryTests.fs b/tests/FundLab.Web.Tests/BoundaryTests.fs index d48d08b..015ed45 100644 --- a/tests/FundLab.Web.Tests/BoundaryTests.fs +++ b/tests/FundLab.Web.Tests/BoundaryTests.fs @@ -379,6 +379,7 @@ module ConfirmationBoundaryTests = [| { instrumentCode = "000001" units = "40.00000000" + reservedUnits = "0.00000000" costCash = "100.00" lastConfirmedAt = "2026-09-21T11:00:00+00:00" valuationNav = someText "2.61000000" @@ -386,6 +387,7 @@ module ConfirmationBoundaryTests = valuationCollectedAt = someText "2026-09-21T09:30:00+00:00" } { instrumentCode = "110011" units = "10.00000000" + reservedUnits = "5.00000000" costCash = "25.00" lastConfirmedAt = "2026-09-21T11:00:00+00:00" valuationNav = null @@ -559,3 +561,77 @@ module ConfirmationBoundaryTests = let stalePositions, _ = update (PositionsReadCompleted(3, samplePositions ())) cleared Assert.Equal(None, stalePositions.positions) + +module RedemptionBoundaryTests = + + let sampleRedemptionRaw (status: string) : RawRedemption = + { + id = "9b2f1c3d-0000-0000-0000-000000000001" + instrumentCode = "000001" + units = "150.00000000" + feeAmount = "1.50" + status = status + submittedAt = "2026-09-21T10:00:00+00:00" + tradeDate = "2026-09-21" + pendingReason = null + confirmedAt = null + confirmedNav = null + confirmedNavDate = null + confirmedProceeds = null + confirmedCostReleased = null + } + + let confirmedRedemptionRaw () : RawRedemption = + let someText (value: string) : obj = box { case = "Some"; fields = [| value |] } + + { sampleRedemptionRaw "confirmed" with + pendingReason = null + confirmedAt = someText "2026-09-21T11:00:00+00:00" + confirmedNav = someText "2.50000000" + confirmedNavDate = someText "2026-09-21" + confirmedProceeds = someText "373.50" + confirmedCostReleased = someText "375.00" } + + [<Fact>] + let ``redemption decode keeps decimal strings and option fields`` () = + let confirmed = Api.decodeRedemption (confirmedRedemptionRaw ()) + + Assert.Equal("confirmed", confirmed.status) + Assert.Equal("150.00000000", confirmed.units) + Assert.Equal("1.50", confirmed.feeAmount) + Assert.Equal("2.50000000", confirmed.confirmedNav.Value) + Assert.Equal("373.50", confirmed.confirmedProceeds.Value) + Assert.Equal("375.00", confirmed.confirmedCostReleased.Value) + Assert.Equal(None, confirmed.pendingReason) + + let sampleRedemptionPositions () : RawPositions = + { + availableCash = "9900.00" + reservedCash = "101.50" + positions = + [| + { instrumentCode = "000001" + units = "40.00000000" + reservedUnits = "0.00000000" + costCash = "100.00" + lastConfirmedAt = "2026-09-21T11:00:00+00:00" + valuationNav = null + valuationNavDate = null + valuationCollectedAt = null } + { instrumentCode = "110011" + units = "10.00000000" + reservedUnits = "5.00000000" + costCash = "25.00" + lastConfirmedAt = "2026-09-21T11:00:00+00:00" + valuationNav = null + valuationNavDate = null + valuationCollectedAt = null } + |] + } + + [<Fact>] + let ``positions decode keeps reserved units for frozen shares`` () = + let decoded = Api.decodePositions (sampleRedemptionPositions ()) + + Assert.Equal("0.00000000", decoded.positions[0].reservedUnits) + Assert.Equal("5.00000000", decoded.positions[1].reservedUnits) |
