summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/FundLab.Api.Tests/OrderTests.fs599
1 files changed, 599 insertions, 0 deletions
diff --git a/tests/FundLab.Api.Tests/OrderTests.fs b/tests/FundLab.Api.Tests/OrderTests.fs
index d1f57df..d975462 100644
--- a/tests/FundLab.Api.Tests/OrderTests.fs
+++ b/tests/FundLab.Api.Tests/OrderTests.fs
@@ -473,3 +473,602 @@ type OrderTests(fixture: PostgresFixture) =
Assert.Equal(0L, negativeCount)
Assert.Equal(1L, zeroCount)
+
+type ConfirmationPolicyTests() =
+ let shanghai (hour: int) (minute: int) (second: int) (day: int) =
+ DateTimeOffset(2026, 9, day, hour, minute, second, TimeSpan.FromHours(8.0))
+
+ let navQuote navDate nav collectedAt publishedAt : ConfirmationPolicy.NavQuote =
+ { NavDate = navDate
+ Nav = nav
+ CollectedAt = collectedAt
+ PublishedAt = publishedAt }
+
+ [<Fact>]
+ member _.``trade date is the shanghai date before the 15:00 cutoff``() =
+ let submitted = shanghai 14 59 59 21
+ Assert.Equal(DateOnly(2026, 9, 21), ConfirmationPolicy.tradeDateFor submitted)
+
+ [<Fact>]
+ member _.``trade date rolls to the next day at the 15:00 cutoff``() =
+ let submitted = shanghai 15 0 0 21
+ Assert.Equal(DateOnly(2026, 9, 22), ConfirmationPolicy.tradeDateFor submitted)
+
+ [<Fact>]
+ member _.``trade date is derived from shanghai wall clock regardless of offset``() =
+ let utcBeforeCutoff = DateTimeOffset(2026, 9, 21, 6, 59, 59, TimeSpan.Zero)
+ let utcAtCutoff = DateTimeOffset(2026, 9, 21, 7, 0, 0, TimeSpan.Zero)
+ Assert.Equal(DateOnly(2026, 9, 21), ConfirmationPolicy.tradeDateFor utcBeforeCutoff)
+ Assert.Equal(DateOnly(2026, 9, 22), ConfirmationPolicy.tradeDateFor utcAtCutoff)
+
+ [<Fact>]
+ member _.``friday cutoff rolls the trade date to monday``() =
+ let submitted = shanghai 15 0 0 25
+ Assert.Equal(DateOnly(2026, 9, 28), ConfirmationPolicy.tradeDateFor submitted)
+
+ [<Fact>]
+ member _.``weekend submissions roll the trade date to monday``() =
+ Assert.Equal(DateOnly(2026, 9, 28), ConfirmationPolicy.tradeDateFor (shanghai 10 0 0 26))
+ Assert.Equal(DateOnly(2026, 9, 28), ConfirmationPolicy.tradeDateFor (shanghai 15 0 0 27))
+
+ [<Fact>]
+ member _.``modeled trading day covers weekends only``() =
+ Assert.False(ConfirmationPolicy.isModeledTradingDay (DateOnly(2026, 9, 26)))
+ Assert.True(ConfirmationPolicy.isModeledTradingDay (DateOnly(2026, 9, 21)))
+ Assert.True(ConfirmationPolicy.isModeledTradingDay (DateOnly(2026, 10, 1)))
+
+ [<Fact>]
+ member _.``units truncate at eight decimals and invested cash at cents``() =
+ match ConfirmationPolicy.compute 1000.00m 2.375m with
+ | Ok confirmed ->
+ Assert.Equal(421.05263157m, confirmed.Units)
+ Assert.Equal(999.99m, confirmed.InvestedCash)
+ Assert.Equal(0.01m, confirmed.ResidualCash)
+ | Error reason -> failwithf "unexpected deferral: %s" reason
+
+ [<Fact>]
+ member _.``exact division keeps the full amount invested``() =
+ match ConfirmationPolicy.compute 100.00m 2.5m with
+ | Ok confirmed ->
+ Assert.Equal(40m, confirmed.Units)
+ Assert.Equal(100.00m, confirmed.InvestedCash)
+ Assert.Equal(0.00m, confirmed.ResidualCash)
+ | Error reason -> failwithf "unexpected deferral: %s" reason
+
+ [<Fact>]
+ member _.``amounts below one unit are deferred``() =
+ Assert.Equal(
+ Error "amount converts to zero units at this unit nav",
+ ConfirmationPolicy.compute 0.00000001m 2.5m
+ )
+
+ [<Fact>]
+ member _.``unit amounts beyond database precision are deferred``() =
+ Assert.Equal(
+ Error "unit amount exceeds database precision",
+ ConfirmationPolicy.compute 999999999999999999.99m 0.00000001m
+ )
+
+ [<Fact>]
+ member _.``non positive unit nav is rejected``() =
+ Assert.Equal(Error "unit nav must be positive", ConfirmationPolicy.compute 100.00m 0m)
+
+ [<Fact>]
+ member _.``usable nav quotes pass the no lookahead checks``() =
+ let quote =
+ navQuote (DateOnly(2026, 9, 21)) 2.5m (shanghai 14 0 0 21) (Some(shanghai 13 0 0 21))
+
+ let now = shanghai 15 0 0 21
+
+ Assert.Equal(
+ None,
+ ConfirmationPolicy.navDeferralReason quote (DateOnly(2026, 9, 21)) (DateOnly(2026, 9, 21)) now
+ )
+
+ [<Fact>]
+ member _.``quotes observed before confirmation without a publish timestamp are usable``() =
+ let quote = navQuote (DateOnly(2026, 9, 21)) 2.5m (shanghai 14 0 0 21) None
+ let now = shanghai 15 0 0 21
+
+ Assert.Equal(
+ None,
+ ConfirmationPolicy.navDeferralReason quote (DateOnly(2026, 9, 21)) (DateOnly(2026, 9, 21)) now
+ )
+
+ [<Fact>]
+ member _.``quotes collected after the confirmation moment defer without backdating``() =
+ let quote = navQuote (DateOnly(2026, 9, 21)) 2.5m (shanghai 16 0 0 21) None
+ let now = shanghai 15 0 0 21
+
+ Assert.Equal(
+ Some "nav for trade date 2026-09-21 is not yet collected",
+ ConfirmationPolicy.navDeferralReason quote (DateOnly(2026, 9, 21)) (DateOnly(2026, 9, 21)) now
+ )
+
+ [<Fact>]
+ member _.``stale nav quotes defer confirmation``() =
+ let quote = navQuote (DateOnly(2026, 9, 20)) 2.5m (shanghai 14 0 0 21) None
+ let now = shanghai 15 0 0 21
+
+ Assert.Equal(
+ Some "nav for trade date 2026-09-21 is not available yet",
+ ConfirmationPolicy.navDeferralReason quote (DateOnly(2026, 9, 21)) (DateOnly(2026, 9, 21)) now
+ )
+
+ [<Fact>]
+ member _.``future dated nav quotes defer confirmation``() =
+ let quote = navQuote (DateOnly(2026, 9, 22)) 2.5m (shanghai 14 0 0 21) None
+ let now = shanghai 15 0 0 21
+
+ Assert.Equal(
+ Some "nav for trade date 2026-09-21 is dated in the future",
+ ConfirmationPolicy.navDeferralReason quote (DateOnly(2026, 9, 21)) (DateOnly(2026, 9, 21)) now
+ )
+
+ [<Fact>]
+ member _.``unpublished nav quotes defer confirmation``() =
+ let quote =
+ navQuote (DateOnly(2026, 9, 21)) 2.5m (shanghai 14 0 0 21) (Some(shanghai 16 0 0 21))
+
+ let now = shanghai 15 0 0 21
+
+ Assert.Equal(
+ Some "nav for trade date 2026-09-21 is not yet published",
+ ConfirmationPolicy.navDeferralReason quote (DateOnly(2026, 9, 21)) (DateOnly(2026, 9, 21)) now
+ )
+
+ [<Fact>]
+ member _.``holiday trade dates without a matching nav stay pending``() =
+ let quote = navQuote (DateOnly(2026, 9, 30)) 2.5m (shanghai 10 0 0 30) None
+ let now = DateTimeOffset(2026, 10, 1, 15, 0, 0, TimeSpan.FromHours(8.0))
+
+ Assert.Equal(
+ Some "nav for trade date 2026-10-01 is not available yet",
+ ConfirmationPolicy.navDeferralReason quote (DateOnly(2026, 10, 1)) (DateOnly(2026, 10, 1)) now
+ )
+
+[<Collection("postgres")>]
+type SubscriptionConfirmationTests(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, "confirm-test-hash")
+ code
+
+ let createFund (initialCash: decimal) =
+ let command =
+ {
+ Name = "确认测试 FOF"
+ InitialCash = initialCash
+ InitialUnitNav = 1.00000000m
+ IsSynthetic = true
+ }
+
+ let key = fixture.Key(sprintf "confirm-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 postOrder fundId idempotencyKey amount fee =
+ let body =
+ sprintf
+ "{\"fundCode\":\"%s\",\"amount\":\"%s\",\"feeAmount\":\"%s\"}"
+ (seedInstrument ())
+ 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
+ PersistenceTestHelpers.responseId response
+
+ 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) (publishedAt: DateTimeOffset option) =
+ let payload: MarketDataNavPayload =
+ {
+ Source = "akshare"
+ SourceRevision = sprintf "akshare-test/%O" (Guid.NewGuid())
+ CollectedAt = collectedAt
+ Code = code
+ Observations =
+ [
+ {
+ NavDate = navDate
+ PublishedAt = publishedAt
+ Nav = nav
+ AccumulatedNav = Some nav
+ DailyReturn = Some 0.0m
+ }
+ ]
+ }
+
+ repository().UpsertNavObservations(payload, "confirm-quote-hash")
+
+ let insertQuote (code: string) (nav: decimal) (collectedAt: DateTimeOffset) (publishedAt: DateTimeOffset option) =
+ insertQuoteOnDate code nav (ConfirmationPolicy.tradeDateFor DateTimeOffset.Now) collectedAt publishedAt
+
+ 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 scalarText (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() :?> string
+
+ let orderFundCode orderId =
+ scalarText "SELECT fund_code FROM subscription_orders WHERE id = @order_id" [ "order_id", box orderId, NpgsqlTypes.NpgsqlDbType.Uuid ]
+
+ 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 orderStatus orderId =
+ scalarDecimal
+ "SELECT count(*)::numeric FROM subscription_orders WHERE id = @order_id AND status = 'confirmed'"
+ [ "order_id", box orderId, NpgsqlTypes.NpgsqlDbType.Uuid ]
+ |> int64
+
+ let pendingOrderReason orderId =
+ use connection = new NpgsqlConnection(fixture.ConnectionString)
+ connection.Open()
+ use command = connection.CreateCommand()
+ command.CommandText <- "SELECT status || '|' || coalesce(pending_reason, '') FROM subscription_orders WHERE id = @order_id"
+ let parameter = command.Parameters.Add("order_id", NpgsqlTypes.NpgsqlDbType.Uuid)
+ parameter.Value <- box orderId
+ command.ExecuteScalar() :?> string
+
+ let confirmKey fundId orderId key = repository().ConfirmSubscriptionOrder(key, fundId, orderId)
+
+ [<Fact>]
+ member _.``confirmation persists quote evidence cash and positions atomically``() =
+ let fundId = createFund 10000.00m
+ let orderId = postOrder fundId "confirm-success-1" "1000.00" "0.00"
+ let code = orderFundCode orderId
+ let collected = truncateMicroseconds (DateTimeOffset.Now.AddSeconds(-10.0))
+ insertQuote code 2.5m collected None
+ let tradeDate = ConfirmationPolicy.tradeDateFor DateTimeOffset.Now
+ let key = fixture.Key("confirm-success-1")
+
+ match confirmKey fundId orderId key with
+ | OrderConfirmed confirmed ->
+ Assert.Equal("confirmed", confirmed.Status)
+ Assert.Equal(Some key, confirmed.ConfirmIdempotencyKey)
+ Assert.Equal(Some 400.00000000m, confirmed.ConfirmedUnits)
+ Assert.Equal(Some 1000.00m, confirmed.ConfirmedInvestedCash)
+ Assert.Equal(Some 0.00m, confirmed.ConfirmedResidualCash)
+ Assert.True(confirmed.ConfirmedAt.IsSome)
+
+ match confirmed.ConfirmedQuote with
+ | Some quote ->
+ Assert.Equal(2.5m, quote.Nav)
+ Assert.Equal(tradeDate, quote.NavDate)
+ Assert.Equal("akshare", quote.Source)
+ Assert.Equal(collected, quote.CollectedAt)
+ Assert.Equal(None, quote.PublishedAt)
+ | None -> failwith "confirmed quote evidence missing"
+
+ Assert.Equal(9000.00m, availableCash fundId)
+ Assert.Equal(0.00m, reservedCash fundId)
+
+ let positions = repository().GetFundPositions(fundId)
+ Assert.Equal(1, positions.Length)
+ let position = positions.[0]
+ Assert.Equal(400.00000000m, position.Units)
+ Assert.Equal(1000.00m, position.CostCash)
+ Assert.Equal(Some 2.5m, position.ValuationNav)
+ Assert.Equal(Some tradeDate, position.ValuationNavDate)
+ | other -> failwithf "unexpected confirm result: %A" other
+
+ [<Fact>]
+ member _.``replaying the same idempotency key never double executes``() =
+ let fundId = createFund 10000.00m
+ let orderId = postOrder fundId "confirm-replay-1" "1000.00" "0.00"
+ let code = orderFundCode orderId
+ let collected = truncateMicroseconds (DateTimeOffset.Now.AddSeconds(-10.0))
+ insertQuote code 2.5m collected None
+ let key = fixture.Key("confirm-replay-key")
+
+ match confirmKey fundId orderId key with
+ | OrderConfirmed _ -> ()
+ | other -> failwithf "unexpected first confirm result: %A" other
+
+ let cashAfterFirst = availableCash fundId
+
+ insertQuote code 9.9m (truncateMicroseconds (DateTimeOffset.Now.AddSeconds(-8.0))) None
+
+ match confirmKey fundId orderId key with
+ | ConfirmReplayed replayed ->
+ Assert.Equal("confirmed", replayed.Status)
+
+ match replayed.ConfirmedQuote with
+ | Some quote ->
+ Assert.Equal(2.5m, quote.Nav)
+ Assert.Equal(collected, quote.CollectedAt)
+ Assert.Equal(None, quote.PublishedAt)
+ | None -> failwith "replayed quote evidence missing"
+
+ Assert.Equal(400.00000000m, replayed.ConfirmedUnits |> Option.get)
+ | other -> failwithf "unexpected replay result: %A" other
+
+ Assert.Equal(cashAfterFirst, availableCash fundId)
+ Assert.Equal(1, (repository().GetFundPositions(fundId)).Length)
+ Assert.Equal(1L, PersistenceTestHelpers.queryCount fixture.ConnectionString "SELECT count(*) FROM subscription_order_events WHERE order_id = @order_id" [ "order_id", box orderId, NpgsqlTypes.NpgsqlDbType.Uuid ])
+
+ [<Fact>]
+ member _.``a confirmed order cannot be confirmed with a different key``() =
+ let fundId = createFund 10000.00m
+ let orderId = postOrder fundId "confirm-other-key-1" "1000.00" "0.00"
+ let code = orderFundCode orderId
+ let collected = truncateMicroseconds (DateTimeOffset.Now.AddSeconds(-10.0))
+ insertQuote code 2.5m collected None
+ let cashAfterFirst =
+ match confirmKey fundId orderId (fixture.Key("confirm-other-key-first")) with
+ | OrderConfirmed _ -> availableCash fundId
+ | other -> failwithf "unexpected first confirm result: %A" other
+
+ match confirmKey fundId orderId (fixture.Key("confirm-other-key-second")) with
+ | ConfirmAlreadyConfirmed -> Assert.Equal(cashAfterFirst, availableCash fundId)
+ | other -> failwithf "unexpected second confirm result: %A" other
+
+ [<Fact>]
+ member _.``a consumed confirm key cannot bind to another order``() =
+ let fundId = createFund 10000.00m
+ let firstOrder = postOrder fundId "confirm-consume-1" "1000.00" "0.00"
+ let code = orderFundCode firstOrder
+ let collected = truncateMicroseconds (DateTimeOffset.Now.AddSeconds(-10.0))
+ insertQuote code 2.5m collected None
+ let key = fixture.Key("confirm-consume-shared-key")
+
+ match confirmKey fundId firstOrder key with
+ | OrderConfirmed _ -> ()
+ | other -> failwithf "unexpected first confirm result: %A" other
+
+ let secondOrder = postOrder fundId "confirm-consume-2" "500.00" "0.00"
+
+ match confirmKey fundId secondOrder key with
+ | ConfirmIdempotencyConflict ->
+ Assert.Equal(0L, orderStatus secondOrder)
+ Assert.Equal(400.00000000m, (repository().GetFundPositions(fundId)).[0].Units)
+ | other -> failwithf "unexpected conflict result: %A" other
+
+ [<Fact>]
+ member _.``mid confirmation failure rolls back every write and the same key can retry``() =
+ let fundId = createFund 10000.00m
+ let orderId = postOrder fundId "confirm-rollback-1" "1000.00" "0.00"
+ let code = orderFundCode orderId
+ let collected = truncateMicroseconds (DateTimeOffset.Now.AddSeconds(-10.0))
+ insertQuote code 2.5m collected None
+ let key = fixture.Key("confirm-rollback-key")
+
+ execute
+ "UPDATE subscription_orders SET reserved_total = reserved_total + 1 WHERE id = @order_id"
+ [ "order_id", box orderId, NpgsqlTypes.NpgsqlDbType.Uuid ]
+
+ Assert.Throws<Exception>(fun () -> confirmKey fundId orderId key |> ignore) |> ignore
+
+ Assert.Equal(0L, orderStatus orderId)
+ Assert.Equal(0L, PersistenceTestHelpers.queryCount fixture.ConnectionString "SELECT count(*) FROM subscription_confirm_idempotencies WHERE order_id = @order_id" [ "order_id", box orderId, NpgsqlTypes.NpgsqlDbType.Uuid ])
+ Assert.Equal(0L, PersistenceTestHelpers.queryCount fixture.ConnectionString "SELECT count(*) FROM subscription_order_events WHERE order_id = @order_id" [ "order_id", box orderId, NpgsqlTypes.NpgsqlDbType.Uuid ])
+ Assert.Equal(0L, PersistenceTestHelpers.queryCount fixture.ConnectionString "SELECT count(*) FROM fund_positions WHERE fund_id = @fund_id" [ "fund_id", box fundId, NpgsqlTypes.NpgsqlDbType.Uuid ])
+ Assert.Equal(9000.00m, availableCash fundId)
+ Assert.Equal(1000.00m, reservedCash fundId)
+
+ execute
+ "UPDATE subscription_orders SET reserved_total = reserved_total - 1 WHERE id = @order_id"
+ [ "order_id", box orderId, NpgsqlTypes.NpgsqlDbType.Uuid ]
+
+ match confirmKey fundId orderId key with
+ | OrderConfirmed confirmed ->
+ Assert.Equal(400.00000000m, confirmed.ConfirmedUnits |> Option.get)
+ Assert.Equal(9000.00m, availableCash fundId)
+ | other -> failwithf "unexpected retry result: %A" other
+
+ [<Fact>]
+ member _.``missing nav stays pending without consuming the key and confirms later``() =
+ let fundId = createFund 10000.00m
+ let orderId = postOrder fundId "confirm-pending-1" "1000.00" "0.00"
+ let key = fixture.Key("confirm-pending-key")
+
+ match confirmKey fundId orderId key with
+ | ConfirmPendingNav pending ->
+ Assert.Equal("pending_nav", pending.Status)
+ Assert.True(pending.PendingReason.IsSome)
+ Assert.True(pending.PendingReason.Value.Contains("not available yet"))
+ Assert.Equal(None, pending.ConfirmIdempotencyKey)
+ Assert.Equal(None, pending.ConfirmedAt)
+ Assert.Equal(None, pending.ConfirmedQuote)
+ | other -> failwithf "unexpected pending result: %A" other
+
+ let code = orderFundCode orderId
+ let collected = truncateMicroseconds (DateTimeOffset.Now.AddSeconds(-10.0))
+ insertQuote code 2.5m collected None
+
+ match confirmKey fundId orderId key with
+ | OrderConfirmed confirmed ->
+ Assert.Equal(Some key, confirmed.ConfirmIdempotencyKey)
+ Assert.Equal(9000.00m, availableCash fundId)
+
+ match confirmed.ConfirmedQuote with
+ | Some quote ->
+ Assert.Equal(ConfirmationPolicy.tradeDateFor DateTimeOffset.Now, quote.NavDate)
+ Assert.Equal(collected, quote.CollectedAt)
+ | None -> failwith "late confirmed quote evidence missing"
+ | other -> failwithf "unexpected late confirm result: %A" other
+
+ [<Fact>]
+ member _.``retrying after the cutoff keeps the original order trade date``() =
+ let fundId = createFund 10000.00m
+ let orderId = postOrder fundId "confirm-overnight-1" "1000.00" "0.00"
+ let code = orderFundCode orderId
+ let originalTradeDate = ConfirmationPolicy.tradeDateFor (DateTimeOffset.Now.AddDays(-1.0))
+ let originalCollected = truncateMicroseconds (DateTimeOffset.Now.AddDays(-1.0).AddHours(-2.0))
+ insertQuoteOnDate code 3.7m originalTradeDate originalCollected None
+
+ execute
+ "UPDATE subscription_orders SET submitted_at = submitted_at - interval '1 day', trade_date = @trade_date WHERE id = @order_id"
+ [ "trade_date", box originalTradeDate, NpgsqlTypes.NpgsqlDbType.Date; "order_id", box orderId, NpgsqlTypes.NpgsqlDbType.Uuid ]
+
+ match confirmKey fundId orderId (fixture.Key("confirm-overnight-key")) with
+ | OrderConfirmed confirmed ->
+ Assert.Equal(originalTradeDate, confirmed.TradeDate)
+
+ match confirmed.ConfirmedQuote with
+ | Some quote ->
+ Assert.Equal(originalTradeDate, quote.NavDate)
+ Assert.Equal(3.7m, quote.Nav)
+ Assert.Equal(originalCollected, quote.CollectedAt)
+ | None -> failwith "overnight quote evidence missing"
+
+ Assert.Equal(Some 270.27027027m, confirmed.ConfirmedUnits)
+ Assert.Equal(Some 999.99m, confirmed.ConfirmedInvestedCash)
+ Assert.Equal(9000.01m, availableCash fundId)
+ | other -> failwithf "unexpected overnight confirm result: %A" other
+
+ [<Fact>]
+ member _.``quotes collected after the confirmation moment stay pending``() =
+ let fundId = createFund 10000.00m
+ let orderId = postOrder fundId "confirm-future-collect-1" "1000.00" "0.00"
+ let code = orderFundCode orderId
+ let futureCollected = truncateMicroseconds (DateTimeOffset.Now.AddHours(1.0))
+ insertQuote code 2.5m futureCollected None
+
+ match confirmKey fundId orderId (fixture.Key("confirm-future-collect-key")) with
+ | ConfirmPendingNav pending ->
+ Assert.Equal("pending_nav", pending.Status)
+ Assert.True(pending.PendingReason.Value.Contains("not yet collected"))
+ Assert.Equal(None, pending.ConfirmIdempotencyKey)
+ | other -> failwithf "unexpected future collected result: %A" other
+
+ [<Fact>]
+ member _.``concurrent confirms with distinct keys execute exactly once``() =
+ let fundId = createFund 10000.00m
+ let orderId = postOrder fundId "confirm-parallel-1" "1000.00" "0.00"
+ let code = orderFundCode orderId
+ let collected = truncateMicroseconds (DateTimeOffset.Now.AddSeconds(-10.0))
+ insertQuote code 2.5m collected None
+
+ let results =
+ [| 1..4 |]
+ |> Array.map (fun index ->
+ Task.Run(fun () ->
+ confirmKey fundId orderId (fixture.Key(sprintf "confirm-parallel-key-%d" index))))
+ |> Task.WhenAll
+ |> fun task -> task.GetAwaiter().GetResult()
+ |> Array.toList
+
+ let confirmedCount =
+ results
+ |> List.filter (function
+ | OrderConfirmed _ -> true
+ | _ -> false)
+ |> List.length
+
+ Assert.Equal(1, confirmedCount)
+
+ for result in results do
+ match result with
+ | OrderConfirmed _ -> ()
+ | ConfirmAlreadyConfirmed
+ | ConfirmIdempotencyConflict -> ()
+ | other -> failwithf "unexpected concurrent result: %A" other
+
+ Assert.Equal(9000.00m, availableCash fundId)
+ Assert.Equal(0.00m, reservedCash fundId)
+ Assert.Equal(1, (repository().GetFundPositions(fundId)).Length)
+ Assert.Equal(400.00000000m, (repository().GetFundPositions(fundId)).[0].Units)
+
+ [<Fact>]
+ member _.``a rebuilt repository re reads the persisted evidence``() =
+ let fundId = createFund 10000.00m
+ let orderId = postOrder fundId "confirm-rebuild-1" "1000.00" "0.00"
+ let code = orderFundCode orderId
+ let collected = truncateMicroseconds (DateTimeOffset.Now.AddSeconds(-10.0))
+ insertQuote code 2.5m collected (Some(truncateMicroseconds (DateTimeOffset.Now.AddSeconds(-5.0))))
+ let key = fixture.Key("confirm-rebuild-key")
+
+ match confirmKey fundId orderId key with
+ | OrderConfirmed _ -> ()
+ | other -> failwithf "unexpected confirm result: %A" other
+
+ let rebuilt = FundRepository(fixture.ConnectionString)
+ rebuilt.EnsureSchema()
+
+ let orders = rebuilt.GetSubscriptionOrders(fundId)
+ Assert.Equal(1, orders.Length)
+ let order = orders.[0]
+
+ match order.ConfirmedQuote with
+ | Some quote ->
+ Assert.Equal(2.5m, quote.Nav)
+ Assert.Equal("akshare", quote.Source)
+ Assert.Equal(collected, quote.CollectedAt)
+ Assert.True(quote.PublishedAt.IsSome)
+ | None -> failwith "rebuilt quote evidence missing"
+
+ Assert.Equal(Some key, order.ConfirmIdempotencyKey)
+ Assert.Equal(Some 400.00000000m, order.ConfirmedUnits)
+ Assert.Equal(Some 1000.00m, order.ConfirmedInvestedCash)
+ Assert.Equal(Some 0.00m, order.ConfirmedResidualCash)
+
+ let positions = rebuilt.GetFundPositions(fundId)
+ Assert.Equal(1, positions.Length)
+ Assert.Equal(400.00000000m, positions.[0].Units)