namespace FundLab.Api.Tests open System open System.Globalization open System.Text.Json open Npgsql open Xunit open FundLab.Api [] type DividendTests(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, 8, 0, 0, TimeSpan.Zero) Instruments = [ { Code = code; Name = "分红测试基金"; FundType = None } ] } repository().UpsertInstruments(payload, "dividend-test-hash") code let createFund (initialCash: decimal) = let command = { Name = "分红测试 FOF" InitialCash = initialCash InitialUnitNav = 1.00000000m IsSynthetic = true } let key = fixture.Key(sprintf "div-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) = let revision = sprintf "akshare-test/%O" (Guid.NewGuid()) let payload: MarketDataNavPayload = { Source = "akshare" SourceRevision = revision CollectedAt = truncateMicroseconds (DateTimeOffset.Now.AddSeconds(-10.0)) Code = code Observations = [ { NavDate = navDate PublishedAt = None Nav = nav AccumulatedNav = Some nav DailyReturn = Some 0.0m } ] } repository().UpsertNavObservations(payload, sprintf "dividend-hash/%s" revision) let anchDate = DateOnly(2026, 9, 21) let confirmedHolding fundId (code: string) (amount: decimal) = insertQuoteOnDate code 2.5m anchDate let orderKey = fixture.Key(sprintf "div-hold-%s" (Guid.NewGuid().ToString("N"))) let order = match repository().CreateSubscriptionOrder( orderKey, fundId, { FundCode = code; Amount = amount; FeeAmount = 0m }, anchDate ) with | SubscriptionOrderWriteResult.OrderCreated order | SubscriptionOrderWriteResult.OrderReplayed order -> order | other -> failwithf "unexpected holding order result: %A" other match repository().ConfirmSubscriptionOrder( fixture.Key(sprintf "div-confirm-%s" (Guid.NewGuid().ToString("N"))), fundId, order.Id ) with | SubscriptionConfirmResult.OrderConfirmed _ -> () | confirmResult -> failwithf "unexpected holding confirm: %A" confirmResult code 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 positionUnits fundId code = scalarDecimal "SELECT units FROM fund_positions WHERE fund_id = @fund_id AND instrument_code = @code" [ "fund_id", box fundId, NpgsqlTypes.NpgsqlDbType.Uuid "code", box code, NpgsqlTypes.NpgsqlDbType.Text ] let otherPositionUnits fundId code = use connection = new NpgsqlConnection(fixture.ConnectionString) connection.Open() use command = connection.CreateCommand() command.CommandText <- "SELECT units 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 use reader = command.ExecuteReader() if reader.Read() then reader.GetDecimal(0) else 0m let postDividend (fundId: Guid) (code: string) (navDate: string) (dps: string) (mode: string) = let body = sprintf "{\"instrumentCode\":\"%s\",\"navDate\":\"%s\",\"dps\":\"%s\",\"mode\":\"%s\"}" code navDate dps mode PersistenceTestHelpers.invoke (app ()) "POST" (sprintf "/api/funds/%O/dividends" fundId) [ "Authorization", "Bearer test-token" "Idempotency-Key", fixture.Key(sprintf "div-key-%s" (Guid.NewGuid().ToString("N"))) ] body let listDividends (fundId: Guid) = PersistenceTestHelpers.invoke (app ()) "GET" (sprintf "/api/funds/%O/dividends" fundId) [ "Authorization", "Bearer test-token" ] "" [] member _.``cash dividend credits available cash exactly once per scheme``() = let fundId = createFund 10000.00m let codeA = confirmedHolding fundId (seedInstrument ()) 250.00m let codeB = confirmedHolding fundId (seedInstrument ()) 250.00m // holdings: A 250 -> 100 units; B 250 -> 100 units let navDate = anchDate Assert.Equal(100.00000000m, positionUnits fundId codeA) let firstStatus, firstResponse = postDividend fundId codeA (navDate.ToString("yyyy-MM-dd")) "0.10000000" "cash" Assert.Equal(200, firstStatus) Assert.Contains("\"status\":\"cash_credited\"", firstResponse) Assert.Contains("\"grossCash\":\"10.00\"", firstResponse) // 100 units * 0.10000000 = 10.00 credited; other holding untouched (cross-position invariant) Assert.Equal(10000.00m - 250.00m - 250.00m + 10.00m, availableCash fundId) Assert.Equal(100.00000000m, positionUnits fundId codeA) Assert.Equal(100.00000000m, positionUnits fundId codeB) let replayStatus, replayResponse = postDividend fundId codeA (navDate.ToString("yyyy-MM-dd")) "0.10000000" "cash" Assert.Equal(200, replayStatus) Assert.Equal(firstResponse, replayResponse) Assert.Equal(10000.00m - 250.00m - 250.00m + 10.00m, availableCash fundId) let conflictingStatus, conflictingResponse = postDividend fundId codeA (navDate.ToString("yyyy-MM-dd")) "0.20000000" "cash" Assert.Equal(409, conflictingStatus) Assert.Contains("IDEMPOTENCY_CONFLICT", conflictingResponse) Assert.Equal(10000.00m - 250.00m - 250.00m + 10.00m, availableCash fundId) [] member _.``reinvest dividend credits new units through the subscription pipeline``() = let fundId = createFund 10000.00m let codeA = confirmedHolding fundId (seedInstrument ()) 250.00m let codeB = confirmedHolding fundId (seedInstrument ()) 100.00m insertQuoteOnDate codeA 2.5m (ConfirmationPolicy.tradeDateFor DateTimeOffset.UtcNow) insertQuoteOnDate codeA 2.5m anchDate let registerStatus, registerResponse = postDividend fundId codeA (anchDate.ToString("yyyy-MM-dd")) "0.10000000" "reinvest" Assert.Contains("reinvest", registerResponse) Assert.Contains("\"grossCash\":\"10.00\"", registerResponse) Assert.Contains("\"creditedUnits\":\"4.00000000\"", registerResponse) Assert.Contains("\"status\":\"succeeded\"", registerResponse) // 100 units * 0.10000000 = 10.00 payout; re-invested at nav 2.5 = 4 new units Assert.Equal(100.00000000m + 4.00000000m, positionUnits fundId codeA) // cross-position invariant: B untouched (100.00 at nav 2.5 = 40 units) Assert.Equal(40.00000000m, positionUnits fundId codeB) let replayStatus, replayResponse = postDividend fundId codeA (anchDate.ToString("yyyy-MM-dd")) "0.10000000" "reinvest" Assert.Equal(200, replayStatus) Assert.Equal(registerResponse, replayResponse) Assert.Equal(100.00000000m + 4.00000000m, positionUnits fundId codeA) [] member _.``future dividend dates are rejected without look-ahead``() = let fundId = createFund 10000.00m let codeA = confirmedHolding fundId (seedInstrument ()) 250.00m let status, response = postDividend fundId codeA "2027-01-01" "0.10000000" "cash" Assert.Equal(400, status) Assert.Contains("INVALID_DIVIDEND_REQUEST", response) [] member _.``dividend history lists registered records``() = let fundId = createFund 10000.00m let codeA = confirmedHolding fundId (seedInstrument ()) 250.00m let _, _ = postDividend fundId codeA (anchDate.ToString("yyyy-MM-dd")) "0.10000000" "cash" let listStatus, listResponse = PersistenceTestHelpers.invoke (app ()) "GET" (sprintf "/api/funds/%O/dividends" fundId) [ "Authorization", "Bearer test-token" ] "" Assert.Equal(200, listStatus) Assert.Contains("\"instrumentCode\":\"" + codeA + "\"", listResponse) Assert.Contains("\"mode\":\"cash\"", listResponse) [] member _.``dividend history exposes booked cash fields and never duplicates on replay``() = let fundId = createFund 10000.00m let codeA = confirmedHolding fundId (seedInstrument ()) 250.00m let navDate = anchDate.ToString("yyyy-MM-dd") let firstStatus, _ = postDividend fundId codeA navDate "0.10000000" "cash" Assert.Equal(200, firstStatus) let listStatus, listResponse = listDividends fundId Assert.Equal(200, listStatus) use document = JsonDocument.Parse(listResponse) let records = document.RootElement Assert.Equal(1, records.GetArrayLength()) let record = records[0] Assert.Equal(codeA, record.GetProperty("instrumentCode").GetString()) Assert.Equal("2026-09-21", record.GetProperty("navDate").GetString()) Assert.Equal("cash", record.GetProperty("mode").GetString()) Assert.Equal("0.10000000", record.GetProperty("dps").GetString()) Assert.Equal("10.00", record.GetProperty("grossCash").GetString()) Assert.Equal("cash_credited", record.GetProperty("status").GetString()) let sourceKey = record.GetProperty("sourceKey").GetString() Assert.Contains("dividend-scheme:", sourceKey) Assert.Contains(codeA, sourceKey) Assert.EndsWith(":cash", sourceKey) let replayStatus, _ = postDividend fundId codeA navDate "0.10000000" "cash" Assert.Equal(200, replayStatus) let _, afterReplay = listDividends fundId use afterDocument = JsonDocument.Parse(afterReplay) Assert.Equal(1, afterDocument.RootElement.GetArrayLength()) [] member _.``dividend history is empty for a fund without dividends``() = let fundId = createFund 10000.00m let status, body = listDividends fundId Assert.Equal(200, status) Assert.Equal("[]", body)