diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 12:38:56 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 12:38:56 +0800 |
| commit | d2a547b6a60ea769092894fff1f2b5105954f226 (patch) | |
| tree | 5dfd493d926891509258ef133c0a54b0fac2363f /src/FundLab.Api/App.fs | |
| parent | f4ab0b08d7648914f6fd049fa1887c0422f69318 (diff) | |
| download | fund-lab-d2a547b6a60ea769092894fff1f2b5105954f226.tar.gz | |
Drive SIP plans through the shared order pipeline (3d-6)
Advance executes due periods as real subscription orders: each due
trade date claims a sip_executions row (PRIMARY KEY plan_id+trade_date
makes same-plan same-day runs atomic), places the order through
CreateSubscriptionOrder with a deterministic idempotency key
(sip:{plan}:{date}) and the period's trade date, then confirms through
the shared ConfirmSubscriptionOrder pipeline with key
sip-confirm:{plan}:{date} — identical freeze/fee/units math as manual
orders, nothing bypasses confirmation. Outcomes: succeeded,
pending_nav (cash stays frozen; later drives retry the confirmation
once the NAV lands), insufficient_cash (no order placed, period fails
visibly, reruns keep the marker), failed. Plans roll their
next_trade_date past the processed window and repeats of the same
endDate replay recorded executions instead of re-debiting.
Domain adds SipPolicy.advancePlan (due-date enumeration; weekends-only
trading-calendar approximation, noted in code). POST
/funds/{id}/sip/advance {endDate?, limit?} drives execution on demand
(no daemon). Plan responses/rows now carry last execution status/date;
front-end shows 已执行/待净值/现金不足 per plan. Optional
tradeDateOverride/anchorOverride keep manual flows untouched.
Diffstat (limited to 'src/FundLab.Api/App.fs')
| -rw-r--r-- | src/FundLab.Api/App.fs | 92 |
1 files changed, 92 insertions, 0 deletions
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs index 9f88468..9badcfa 100644 --- a/src/FundLab.Api/App.fs +++ b/src/FundLab.Api/App.fs @@ -142,6 +142,8 @@ type SipPlanResponse = status: string anchorDate: string nextTradeDate: string + lastExecutionStatus: string option + lastExecutionDate: string option isSynthetic: bool createdAt: string } @@ -306,6 +308,8 @@ module App = status = plan.Status anchorDate = dateText plan.AnchorDate nextTradeDate = dateText plan.NextTradeDate + lastExecutionStatus = plan.LastExecutionStatus + lastExecutionDate = plan.LastExecutionDate |> Option.map dateText isSynthetic = plan.IsSynthetic createdAt = timestampText plan.CreatedAt } @@ -810,6 +814,93 @@ module App = json (plans |> List.map sipPlanResponse) next ctx with _ -> errorResponse 500 "PERSISTENCE_ERROR" "sip plan persistence failed" next ctx + + let private parseSipAdvanceCommand (body: string) = + try + if String.IsNullOrWhiteSpace body then + Ok(None, 24) + else + use document = JsonDocument.Parse(body) + let root = document.RootElement + + if root.ValueKind <> JsonValueKind.Object then + Error "request body must be a JSON object" + else + let endDate = + match tryStringProperty root "endDate" with + | None -> None + | Some text -> + match DateOnly.TryParseExact(text, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None) with + | true, date -> Some date + | _ -> failwith "endDate must be yyyy-MM-dd" + + let limit = + match tryStringProperty root "limit" with + | None -> 24 + | Some text -> + match Int32.TryParse(text, CultureInfo.InvariantCulture) with + | true, value when value > 0 -> value + | _ -> failwith "limit must be a positive integer" + + Ok(endDate, limit) + with + | :? JsonException -> Error "request body must be valid JSON" + + let private sipPlanAdvanceResponse (result: SipPlanAdvanceResult) = + {| + planId = result.PlanId + instrumentCode = result.InstrumentCode + amount = cashText result.Amount + frequency = SipPolicy.frequencyText result.Frequency + nextTradeDate = dateText result.NextTradeDate + executions = + result.Executions + |> List.map (fun outcome -> + {| + tradeDate = dateText outcome.TradeDate + status = outcome.Status + orderId = outcome.OrderId |> Option.map (fun id -> id.ToString("D")) + pendingReason = outcome.PendingReason + |}) + |} + + let private advanceSipPlans (repository: FundRepository) (fundIdText: string) : HttpHandler = + fun next ctx -> + task { + match Guid.TryParse fundIdText with + | false, _ -> + return! invokeHandler (errorResponse 400 "INVALID_SIP_REQUEST" "fund id must be a UUID") next ctx + | true, fundId -> + use reader = new StreamReader(ctx.Request.Body) + let! body = reader.ReadToEndAsync() + + match parseSipAdvanceCommand body with + | Error message -> + return! invokeHandler (errorResponse 400 "INVALID_SIP_REQUEST" message) next ctx + | Ok(endDateText, limit) -> + let endDate = + endDateText + |> Option.defaultWith (fun () -> ConfirmationPolicy.tradeDateFor DateTimeOffset.UtcNow) + + try + match repository.GetFund fundId with + | None -> + return! invokeHandler (errorResponse 404 "FUND_NOT_FOUND" "fund was not found") next ctx + | Some _ -> + let advanced = repository.AdvanceSipPlans(fundId, endDate, limit) + + return! + json + {| + fundId = fundId + endDate = dateText endDate + plans = advanced.Plans |> List.map sipPlanAdvanceResponse + |} + next + ctx + with _ -> + return! invokeHandler (errorResponse 500 "PERSISTENCE_ERROR" "sip advance failed") next ctx + } let private marketDataError (failure: MarketDataFailure) : HttpHandler = let status, error, message = match failure with @@ -916,6 +1007,7 @@ module App = GET >=> routef "/funds/%s/capital/deposits" (getCapitalDeposits repository) POST >=> routef "/funds/%s/sip/plans" (createSipPlan repository) GET >=> routef "/funds/%s/sip/plans" (getSipPlans repository) + POST >=> routef "/funds/%s/sip/advance" (advanceSipPlans repository) GET >=> routef "/funds/%s" (getFund repository) ] @ (marketData |> Option.map marketDataRoutes |> Option.defaultValue []) |
