diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 12:03:39 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 12:03:39 +0800 |
| commit | f4ab0b08d7648914f6fd049fa1887c0422f69318 (patch) | |
| tree | c2dc484ed97bd0dc573986318cce921340fbd783 /src/FundLab.Api/App.fs | |
| parent | 205e2a592bf781f08fe23f3577cdfe74ff9a076a (diff) | |
| download | fund-lab-f4ab0b08d7648914f6fd049fa1887c0422f69318.tar.gz | |
Add SIP plan first slice (3d-5)
Introduce scheduled-investment plans as a schedule-only slice with no
cash, position or NAV side effects. Domain gains SipPolicy: amount
validation (positive, two-decimal, overflow-checked), frequency
parsing/round-trip (weekly/biweekly/monthly), nextTradeDate generating
the next due trade date off the plan anchor with month-end clamping
and weekend roll-forward, plus the isDue/executionNetAmount contract
the future executor will implement (execution itself stays
unimplemented and is marked as such; nothing debits cash in this
slice).
Persistence stores plans in sip_plans with anchor/next trade dates and
sip_plan_idempotencies under the shared replay/conflict contract; the
API exposes POST/GET /funds/{id}/sip/plans behind the bearer token.
The frontend adds a 07/SIP panel (code, amount, frequency select,
plan list with next debit date) and Web tests cover decoding,
validation, in-flight guards and stale completions. Choice note:
first debit is scheduled strictly after the anchor date (creating a
plan today never debits today).
Diffstat (limited to 'src/FundLab.Api/App.fs')
| -rw-r--r-- | src/FundLab.Api/App.fs | 104 |
1 files changed, 104 insertions, 0 deletions
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs index 55a29f4..9f88468 100644 --- a/src/FundLab.Api/App.fs +++ b/src/FundLab.Api/App.fs @@ -4,6 +4,7 @@ open System open System.Globalization open System.IO open System.Text.Json +open FundLab.Domain open Giraffe open Microsoft.AspNetCore.Http open Microsoft.Extensions.DependencyInjection @@ -131,6 +132,20 @@ type FundPositionsResponse = positions: FundPositionResponse list } +type SipPlanResponse = + { + id: Guid + fundId: Guid + instrumentCode: string + amount: string + frequency: string + status: string + anchorDate: string + nextTradeDate: string + isSynthetic: bool + createdAt: string + } + type CapitalDepositResponse = { id: Guid @@ -281,6 +296,20 @@ module App = createdAt = timestampText deposit.CreatedAt } + let private sipPlanResponse (plan: SipPlanRecord) : SipPlanResponse = + { + id = plan.Id + fundId = plan.FundId + instrumentCode = plan.InstrumentCode + amount = cashText plan.Amount + frequency = SipPolicy.frequencyText plan.Frequency + status = plan.Status + anchorDate = dateText plan.AnchorDate + nextTradeDate = dateText plan.NextTradeDate + isSynthetic = plan.IsSynthetic + createdAt = timestampText plan.CreatedAt + } + let private errorResponse status error message : HttpHandler = setStatusCode status >=> json ({ @@ -411,8 +440,34 @@ module App = with | :? JsonException -> Error "request body must be valid JSON" + let private parseSipPlanCommand (body: string) = + try + use document = JsonDocument.Parse(body) + let root = document.RootElement + + if root.ValueKind <> JsonValueKind.Object then + Error "request body must be a JSON object" + else + match tryStringProperty root "instrumentCode", tryStringProperty root "amount", tryStringProperty root "frequency" with + | Some code, Some amountText, Some frequencyText -> + match tryDecimal "amount" amountText, SipPolicy.parseFrequency frequencyText with + | Ok amount, Some frequency -> + Ok + { + InstrumentCode = code + Amount = amount + Frequency = frequency + } + | Error message, _ -> Error message + | _, None -> Error "frequency must be one of weekly, biweekly or monthly" + | _ -> + Error "instrumentCode, amount and frequency are required" + with + | :? JsonException -> Error "request body must be valid JSON" + let private invokeHandler handler next ctx = handler next ctx + let private unauthorized : HttpHandler = setStatusCode 401 >=> setHttpHeader "WWW-Authenticate" "Bearer" @@ -708,6 +763,53 @@ module App = json (deposits |> List.map capitalDepositResponse) next ctx with _ -> errorResponse 500 "PERSISTENCE_ERROR" "capital deposit persistence failed" next ctx + + let private createSipPlan (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() + let idempotencyKey = ctx.Request.Headers["Idempotency-Key"].ToString() + + match parseSipPlanCommand body with + | Error message -> + return! invokeHandler (errorResponse 400 "INVALID_SIP_REQUEST" message) next ctx + | Ok command -> + try + match repository.CreateSipPlan(idempotencyKey, fundId, command) with + | SipPlanWriteResult.SipPlanCreated plan -> + return! invokeHandler (setStatusCode 201 >=> json (sipPlanResponse plan)) next ctx + | SipPlanWriteResult.SipPlanReplayed plan -> + return! invokeHandler (json (sipPlanResponse plan)) next ctx + | SipPlanWriteResult.SipPlanIdempotencyConflict -> + return! invokeHandler (errorResponse 409 "IDEMPOTENCY_CONFLICT" "idempotency key was used with a different request") next ctx + | SipPlanWriteResult.SipPlanInvalid message -> + return! invokeHandler (errorResponse 400 "INVALID_SIP_REQUEST" message) next ctx + | SipPlanWriteResult.SipPlanFundNotFound -> + return! invokeHandler (errorResponse 404 "FUND_NOT_FOUND" "fund was not found") next ctx + | SipPlanWriteResult.SipPlanInstrumentNotFound -> + return! invokeHandler (errorResponse 404 "INSTRUMENT_NOT_FOUND" "instrument code was not found in the instrument catalog") next ctx + with _ -> + return! invokeHandler (errorResponse 500 "PERSISTENCE_ERROR" "sip plan persistence failed") next ctx + } + + let private getSipPlans (repository: FundRepository) (fundIdText: string) : HttpHandler = + fun next ctx -> + match Guid.TryParse fundIdText with + | false, _ -> errorResponse 400 "INVALID_FUND_ID" "fund id must be a UUID" next ctx + | true, fundId -> + try + match repository.GetFund fundId with + | None -> errorResponse 404 "FUND_NOT_FOUND" "fund was not found" next ctx + | Some _ -> + let plans = repository.GetSipPlans fundId + json (plans |> List.map sipPlanResponse) next ctx + with _ -> + errorResponse 500 "PERSISTENCE_ERROR" "sip plan persistence failed" next ctx let private marketDataError (failure: MarketDataFailure) : HttpHandler = let status, error, message = match failure with @@ -812,6 +914,8 @@ module App = GET >=> routef "/funds/%s/positions" (getPositions repository) POST >=> routef "/funds/%s/capital/deposit" (createCapitalDeposit repository) GET >=> routef "/funds/%s/capital/deposits" (getCapitalDeposits repository) + POST >=> routef "/funds/%s/sip/plans" (createSipPlan repository) + GET >=> routef "/funds/%s/sip/plans" (getSipPlans repository) GET >=> routef "/funds/%s" (getFund repository) ] @ (marketData |> Option.map marketDataRoutes |> Option.defaultValue []) |
