From d4b539a2791a0cd4d098fecfece0039073a9f7e2 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 21 Sep 2026 23:21:54 +0800 Subject: Add scheduled investment plans (3d-10) --- src/FundLab.Api/App.fs | 208 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 207 insertions(+), 1 deletion(-) (limited to 'src/FundLab.Api/App.fs') diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs index fc174e5..b559ac2 100644 --- a/src/FundLab.Api/App.fs +++ b/src/FundLab.Api/App.fs @@ -148,6 +148,47 @@ type SipPlanResponse = createdAt: string } +type InvestmentPlanResponse = + { + id: Guid + fundId: Guid + instrumentCode: string + amount: string + frequency: string + status: string + anchorDate: string + nextRunDate: string + lastRunStatus: string option + lastRunDate: string option + isSynthetic: bool + createdAt: string + } + +type InvestmentPlanRunOutcomeResponse = + { + runDate: string + status: string + orderId: string option + pendingReason: string option + } + +type InvestmentPlanRunPlanResponse = + { + planId: Guid + instrumentCode: string + amount: string + frequency: string + runs: InvestmentPlanRunOutcomeResponse list + nextRunDate: string + } + +type InvestmentPlanRunResponse = + { + fundId: Guid + processingDate: string + plans: InvestmentPlanRunPlanResponse list + } + type DividendResponse = { id: Guid @@ -370,6 +411,47 @@ module App = createdAt = timestampText plan.CreatedAt } + let private investmentPlanResponse (plan: InvestmentPlanRecord) : InvestmentPlanResponse = + { + id = plan.Id + fundId = plan.FundId + instrumentCode = plan.InstrumentCode + amount = cashText plan.Amount + frequency = InvestmentPlanPolicy.frequencyText plan.Frequency + status = plan.Status + anchorDate = dateText plan.AnchorDate + nextRunDate = dateText plan.NextRunDate + lastRunStatus = plan.LastRunStatus + lastRunDate = plan.LastRunDate |> Option.map dateText + isSynthetic = plan.IsSynthetic + createdAt = timestampText plan.CreatedAt + } + + let private investmentPlanRunOutcomeResponse (outcome: InvestmentPlanRunOutcome) : InvestmentPlanRunOutcomeResponse = + { + runDate = dateText outcome.RunDate + status = outcome.Status + orderId = outcome.OrderId |> Option.map (fun id -> id.ToString("D")) + pendingReason = outcome.PendingReason + } + + let private investmentPlanRunPlanResponse (result: InvestmentPlanRunPlanResult) : InvestmentPlanRunPlanResponse = + { + planId = result.PlanId + instrumentCode = result.InstrumentCode + amount = cashText result.Amount + frequency = InvestmentPlanPolicy.frequencyText result.Frequency + runs = result.Runs |> List.map investmentPlanRunOutcomeResponse + nextRunDate = dateText result.NextRunDate + } + + let private investmentPlanRunResponse (result: InvestmentPlanRunResult) : InvestmentPlanRunResponse = + { + fundId = result.FundId + processingDate = dateText result.ProcessingDate + plans = result.Plans |> List.map investmentPlanRunPlanResponse + } + let private rebalancePlanResponse (plan: RebalancePlanRecord) : RebalancePlanResponse = { id = plan.Id @@ -532,7 +614,7 @@ module App = with | :? JsonException -> Error "request body must be valid JSON" - let private parseSipPlanCommand (body: string) = + let private parseSipPlanCommand (body: string) : Result = try use document = JsonDocument.Parse(body) let root = document.RootElement @@ -557,6 +639,31 @@ module App = with | :? JsonException -> Error "request body must be valid JSON" + let private parseInvestmentPlanCommand (body: string) : Result = + 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, InvestmentPlanPolicy.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 daily, weekly or monthly" + | _ -> + Error "instrumentCode, amount and frequency are required" + with + | :? JsonException -> Error "request body must be valid JSON" + let private parseRebalancePlanCommand (body: string) = try use document = JsonDocument.Parse(body) @@ -1078,6 +1185,102 @@ module App = with _ -> errorResponse 500 "PERSISTENCE_ERROR" "sip plan persistence failed" next ctx + let private createInvestmentPlan (repository: FundRepository) (fundIdText: string) : HttpHandler = + fun next ctx -> + task { + match Guid.TryParse fundIdText with + | false, _ -> + return! invokeHandler (errorResponse 400 "INVALID_INVESTMENT_PLAN_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 parseInvestmentPlanCommand body with + | Error message -> + return! invokeHandler (errorResponse 400 "INVALID_INVESTMENT_PLAN_REQUEST" message) next ctx + | Ok command -> + try + match repository.CreateInvestmentPlan(idempotencyKey, fundId, command) with + | InvestmentPlanWriteResult.InvestmentPlanCreated plan -> + return! invokeHandler (setStatusCode 201 >=> json (investmentPlanResponse plan)) next ctx + | InvestmentPlanWriteResult.InvestmentPlanReplayed plan -> + return! invokeHandler (json (investmentPlanResponse plan)) next ctx + | InvestmentPlanWriteResult.InvestmentPlanIdempotencyConflict -> + return! invokeHandler (errorResponse 409 "IDEMPOTENCY_CONFLICT" "idempotency key was used with a different request") next ctx + | InvestmentPlanWriteResult.InvestmentPlanInvalid message -> + return! invokeHandler (errorResponse 400 "INVALID_INVESTMENT_PLAN_REQUEST" message) next ctx + | InvestmentPlanWriteResult.InvestmentPlanFundNotFound -> + return! invokeHandler (errorResponse 404 "FUND_NOT_FOUND" "fund was not found") next ctx + | InvestmentPlanWriteResult.InvestmentPlanInstrumentNotFound -> + 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" "investment plan persistence failed") next ctx + } + + let private getInvestmentPlans (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.GetInvestmentPlans fundId + json (plans |> List.map investmentPlanResponse) next ctx + with _ -> + errorResponse 500 "PERSISTENCE_ERROR" "investment plan persistence failed" next ctx + + let private parseInvestmentPlanRunCommand (body: string) = + try + if String.IsNullOrWhiteSpace body then + Ok None + 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 + match tryStringProperty root "processingDate" with + | None -> Ok None + | Some text -> + match DateOnly.TryParseExact(text, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None) with + | true, date -> Ok(Some date) + | _ -> Error "processingDate must be yyyy-MM-dd" + with + | :? JsonException -> Error "request body must be valid JSON" + + let private runInvestmentPlans (repository: FundRepository) (fundIdText: string) : HttpHandler = + fun next ctx -> + task { + match Guid.TryParse fundIdText with + | false, _ -> + return! invokeHandler (errorResponse 400 "INVALID_INVESTMENT_PLAN_REQUEST" "fund id must be a UUID") next ctx + | true, fundId -> + use reader = new StreamReader(ctx.Request.Body) + let! body = reader.ReadToEndAsync() + + match parseInvestmentPlanRunCommand body with + | Error message -> + return! invokeHandler (errorResponse 400 "INVALID_INVESTMENT_PLAN_REQUEST" message) next ctx + | Ok processingDateText -> + let processingDate = + processingDateText + |> 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 result = repository.RunInvestmentPlans(fundId, processingDate) + return! invokeHandler (json (investmentPlanRunResponse result)) next ctx + with _ -> + return! invokeHandler (errorResponse 500 "PERSISTENCE_ERROR" "investment plan run failed") next ctx + } + let private parseSipAdvanceCommand (body: string) = try if String.IsNullOrWhiteSpace body then @@ -1347,6 +1550,9 @@ module App = POST >=> routef "/funds/%s/dividends" (createDividend repository) GET >=> routef "/funds/%s/dividends" (getDividends repository) GET >=> routef "/funds/%s/returns" (getFundReturns repository) + POST >=> routef "/funds/%s/investment-plans/run" (runInvestmentPlans repository) + POST >=> routef "/funds/%s/investment-plans" (createInvestmentPlan repository) + GET >=> routef "/funds/%s/investment-plans" (getInvestmentPlans repository) GET >=> routef "/funds/%s" (getFund repository) ] @ (marketData |> Option.map marketDataRoutes |> Option.defaultValue []) -- cgit v1.2.3