namespace FundLab.Api.Tests open System open System.Text.Json open Xunit open FundLab.Api open FundLab.Domain [] type InvestmentPlanTests(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, "investment-plan-test-hash") code let createFund (initialCash: decimal) = let command = { Name = "定投测试 FOF" InitialCash = initialCash InitialUnitNav = 1.00000000m IsSynthetic = true } let key = fixture.Key(sprintf "investment-plan-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 "investment-plan-hash/%s" revision) let createPlanViaApi fundId (code: string) (amount: string) (frequency: string) = let body = sprintf "{\"instrumentCode\":\"%s\",\"amount\":\"%s\",\"frequency\":\"%s\"}" code amount frequency PersistenceTestHelpers.invoke (app ()) "POST" (sprintf "/api/funds/%O/investment-plans" fundId) [ "Authorization", "Bearer test-token" "Idempotency-Key", fixture.Key(sprintf "investment-plan-%s" (Guid.NewGuid().ToString("N"))) ] body let createPlanWithKey fundId (code: string) (amount: string) (frequency: string) idempotencyKey = let body = sprintf "{\"instrumentCode\":\"%s\",\"amount\":\"%s\",\"frequency\":\"%s\"}" code amount frequency PersistenceTestHelpers.invoke (app ()) "POST" (sprintf "/api/funds/%O/investment-plans" fundId) [ "Authorization", "Bearer test-token"; "Idempotency-Key", idempotencyKey ] body let listPlansViaApi fundId = PersistenceTestHelpers.invoke (app ()) "GET" (sprintf "/api/funds/%O/investment-plans" fundId) [ "Authorization", "Bearer test-token" ] "" let runPlansViaApi fundId = PersistenceTestHelpers.invoke (app ()) "POST" (sprintf "/api/funds/%O/investment-plans/run" fundId) [ "Authorization", "Bearer test-token" ] "" let runPlansOnViaApi fundId (processingDate: DateOnly) = PersistenceTestHelpers.invoke (app ()) "POST" (sprintf "/api/funds/%O/investment-plans/run" fundId) [ "Authorization", "Bearer test-token" ] (sprintf "{\"processingDate\":\"%s\"}" (processingDate.ToString("yyyy-MM-dd"))) let createPlanWithAnchor (fundId: Guid) (code: string) (amount: decimal) (frequency: InvestmentFrequency) (anchor: DateOnly) = let key = fixture.Key(sprintf "investment-plan-anchor-%s" (Guid.NewGuid().ToString("N"))) match repository().CreateInvestmentPlan(key, fundId, { InstrumentCode = code; Amount = amount; Frequency = frequency }, anchor) with | InvestmentPlanWriteResult.InvestmentPlanCreated plan -> plan.Id | other -> failwithf "unexpected investment plan result: %A" other let today = ConfirmationPolicy.tradeDateFor DateTimeOffset.UtcNow let availableCash fundId = match repository().GetFund fundId with | Some fund -> fund.AvailableCash | None -> failwith "fund was not found" let unitsFor fundId (code: string) = repository().GetFundPositions fundId |> List.tryFind (fun position -> position.InstrumentCode = code) |> Option.map (fun position -> position.Units) |> Option.defaultValue 0m let planRuns (body: string) = use document = JsonDocument.Parse(body) document.RootElement.GetProperty("plans").EnumerateArray() |> Seq.collect (fun plan -> plan.GetProperty("runs").EnumerateArray()) |> Seq.map (fun run -> run.Clone()) |> Seq.toList [] member _.``create is idempotent, lists the plan and rejects an unknown instrument``() = let fundId = createFund 10000.00m let code = seedInstrument () let key = fixture.Key(sprintf "investment-plan-%s" (Guid.NewGuid().ToString("N"))) let firstStatus, firstBody = createPlanWithKey fundId code "100.00" "daily" key Assert.Equal(201, firstStatus) use firstDocument = JsonDocument.Parse(firstBody) let planId = firstDocument.RootElement.GetProperty("id").GetString() Assert.Equal(code, firstDocument.RootElement.GetProperty("instrumentCode").GetString()) Assert.Equal("100.00", firstDocument.RootElement.GetProperty("amount").GetString()) Assert.Equal("daily", firstDocument.RootElement.GetProperty("frequency").GetString()) Assert.Equal(today.ToString("yyyy-MM-dd"), firstDocument.RootElement.GetProperty("nextRunDate").GetString()) let replayStatus, replayBody = createPlanWithKey fundId code "100.00" "daily" key Assert.Equal(200, replayStatus) use replayDocument = JsonDocument.Parse(replayBody) Assert.Equal(planId, replayDocument.RootElement.GetProperty("id").GetString()) let listStatus, listBody = listPlansViaApi fundId Assert.Equal(200, listStatus) Assert.Contains(planId, listBody) let missingStatus, missingBody = createPlanViaApi fundId "999999" "100.00" "daily" Assert.Equal(404, missingStatus) Assert.Contains("INSTRUMENT_NOT_FOUND", missingBody) [] member _.``create rejects a bad amount and a bad frequency``() = let fundId = createFund 10000.00m let code = seedInstrument () let badAmountStatus, badAmountBody = createPlanViaApi fundId code "0.00" "daily" Assert.Equal(400, badAmountStatus) Assert.Contains("INVALID_INVESTMENT_PLAN_REQUEST", badAmountBody) let badFrequencyStatus, badFrequencyBody = createPlanViaApi fundId code "100.00" "hourly" Assert.Equal(400, badFrequencyStatus) Assert.Contains("frequency must be one of daily, weekly or monthly", badFrequencyBody) [] member _.``run executes a due plan through the shared order pipeline``() = let fundId = createFund 10000.00m let code = seedInstrument () insertQuoteOnDate code 2.0m today let _ = createPlanViaApi fundId code "100.00" "daily" let status, body = runPlansViaApi fundId Assert.Equal(200, status) let runs = planRuns body Assert.Single(runs) |> ignore Assert.Equal("succeeded", runs.Head.GetProperty("status").GetString()) Assert.Equal(today.ToString("yyyy-MM-dd"), runs.Head.GetProperty("runDate").GetString()) Assert.Equal(50.00000000m, unitsFor fundId code) Assert.Equal(9900.00m, availableCash fundId) let returnsStatus, returnsBody = PersistenceTestHelpers.invoke (app ()) "GET" (sprintf "/api/funds/%O/returns" fundId) [ "Authorization", "Bearer test-token" ] "" Assert.Equal(200, returnsStatus) Assert.Contains("\"cumulativeReturn\":\"0.00\"", returnsBody) [] member _.``run is idempotent across repeated drives``() = let fundId = createFund 10000.00m let code = seedInstrument () insertQuoteOnDate code 2.0m today let _ = createPlanViaApi fundId code "100.00" "daily" let firstStatus, _ = runPlansViaApi fundId Assert.Equal(200, firstStatus) let secondStatus, secondBody = runPlansViaApi fundId Assert.Equal(200, secondStatus) Assert.Single(planRuns secondBody) |> ignore Assert.Equal(50.00000000m, unitsFor fundId code) Assert.Equal(9900.00m, availableCash fundId) [] member _.``run defers a plan when nav is missing and retries once it lands``() = let fundId = createFund 10000.00m let code = seedInstrument () let _ = createPlanViaApi fundId code "100.00" "daily" let pendingStatus, pendingBody = runPlansViaApi fundId Assert.Equal(200, pendingStatus) let pendingRuns = planRuns pendingBody Assert.Single(pendingRuns) |> ignore Assert.Equal("pending_nav", pendingRuns.Head.GetProperty("status").GetString()) // the pending order freezes the scheduled amount without spending it Assert.Equal(9900.00m, availableCash fundId) Assert.Equal(0m, unitsFor fundId code) insertQuoteOnDate code 2.0m today let retryStatus, retryBody = runPlansViaApi fundId Assert.Equal(200, retryStatus) let retryRuns = planRuns retryBody Assert.Single(retryRuns) |> ignore Assert.Equal("succeeded", retryRuns.Head.GetProperty("status").GetString()) Assert.Equal(50.00000000m, unitsFor fundId code) Assert.Equal(9900.00m, availableCash fundId) [] member _.``run records insufficient cash without debiting twice``() = let fundId = createFund 50.00m let code = seedInstrument () insertQuoteOnDate code 2.0m today let _ = createPlanViaApi fundId code "100.00" "daily" let firstStatus, firstBody = runPlansViaApi fundId Assert.Equal(200, firstStatus) let firstRuns = planRuns firstBody Assert.Single(firstRuns) |> ignore Assert.Equal("insufficient_cash", firstRuns.Head.GetProperty("status").GetString()) Assert.Equal(50.00m, availableCash fundId) Assert.Equal(0m, unitsFor fundId code) let secondStatus, secondBody = runPlansViaApi fundId Assert.Equal(200, secondStatus) Assert.Single(planRuns secondBody) |> ignore Assert.Equal(50.00m, availableCash fundId) [] member _.``run rolls the plan pointer across multiple due dates``() = let fundId = createFund 10000.00m let code = seedInstrument () insertQuoteOnDate code 2.0m (DateOnly(2026, 9, 15)) insertQuoteOnDate code 2.0m (DateOnly(2026, 9, 16)) insertQuoteOnDate code 2.0m (DateOnly(2026, 9, 17)) let _ = createPlanWithAnchor fundId code 100.00m InvestmentFrequency.Daily (DateOnly(2026, 9, 15)) let status, body = runPlansOnViaApi fundId (DateOnly(2026, 9, 17)) Assert.Equal(200, status) let runs = planRuns body Assert.Equal(3, runs.Length) Assert.All(runs, fun run -> Assert.Equal("succeeded", run.GetProperty("status").GetString())) Assert.Equal(150.00000000m, unitsFor fundId code) Assert.Equal(9700.00m, availableCash fundId) use document = JsonDocument.Parse(body) let nextRunDate = document.RootElement.GetProperty("plans").EnumerateArray() |> Seq.head |> fun plan -> plan.GetProperty("nextRunDate").GetString() Assert.Equal("2026-09-18", nextRunDate) [] member _.``run for an unknown fund is not found``() = let status, body = runPlansViaApi (Guid.NewGuid()) Assert.Equal(404, status) Assert.Contains("FUND_NOT_FOUND", body)