From 6abd4c206fbfe8369b3fde4c905619d08f13c5d1 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 05:23:52 +0800 Subject: Add SIP plan pause/resume and rebalance plan archive lifecycle (3d-24) --- src/FundLab.Api/App.fs | 55 ++++++++++++++++++++- src/FundLab.Api/Persistence.fs | 105 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 2 deletions(-) (limited to 'src/FundLab.Api') diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs index 138230f..5ff7fdd 100644 --- a/src/FundLab.Api/App.fs +++ b/src/FundLab.Api/App.fs @@ -1505,6 +1505,29 @@ module App = with _ -> errorResponse 500 "PERSISTENCE_ERROR" "sip plan persistence failed" next ctx + let private setSipPlanStatus + (repository: FundRepository) + (targetStatus: string) + (fundIdText: string) + (planIdText: string) + : HttpHandler = + fun next ctx -> + match Guid.TryParse fundIdText, Guid.TryParse planIdText with + | (false, _), _ + | _, (false, _) -> + errorResponse 400 "INVALID_SIP_REQUEST" "fund id and plan id must be UUIDs" next ctx + | (true, fundId), (true, planId) -> + try + match repository.GetFund fundId with + | None -> errorResponse 404 "FUND_NOT_FOUND" "fund was not found" next ctx + | Some _ -> + match repository.SetSipPlanStatus(fundId, planId, targetStatus) with + | SipPlanStatusResult.SipPlanStatusChanged plan -> json (sipPlanResponse plan) next ctx + | SipPlanStatusResult.SipPlanStatusNotFound -> + errorResponse 404 "PLAN_NOT_FOUND" "sip plan does not belong to this fund" next ctx + with _ -> + errorResponse 500 "PERSISTENCE_ERROR" "sip plan status update failed" next ctx + let private createInvestmentPlan (repository: FundRepository) (fundIdText: string) : HttpHandler = fun next ctx -> task { @@ -1735,6 +1758,24 @@ module App = with _ -> errorResponse 500 "PERSISTENCE_ERROR" "rebalance plan persistence failed" next ctx + let private archiveRebalancePlan (repository: FundRepository) (fundIdText: string) (planIdText: string) : HttpHandler = + fun next ctx -> + match Guid.TryParse fundIdText, Guid.TryParse planIdText with + | (false, _), _ + | _, (false, _) -> + errorResponse 400 "INVALID_REBALANCE_REQUEST" "fund id and plan id must be UUIDs" next ctx + | (true, fundId), (true, planId) -> + try + match repository.GetFund fundId with + | None -> errorResponse 404 "FUND_NOT_FOUND" "fund was not found" next ctx + | Some _ -> + match repository.ArchiveRebalancePlan(fundId, planId) with + | RebalanceArchiveResult.RebalancePlanArchived plan -> json (rebalancePlanResponse plan) next ctx + | RebalanceArchiveResult.RebalanceArchiveNotFound -> + errorResponse 404 "PLAN_NOT_FOUND" "rebalance plan does not belong to this fund" next ctx + with _ -> + errorResponse 500 "PERSISTENCE_ERROR" "rebalance plan archive failed" next ctx + let private executeRebalancePlan (repository: FundRepository) (fundIdText: string) (planIdText: string) : HttpHandler = fun next ctx -> match Guid.TryParse fundIdText, Guid.TryParse planIdText with @@ -1749,7 +1790,10 @@ module App = | Some _ -> match repository.ExecuteRebalancePlan planId with | Error message -> - errorResponse (if message.Contains("was not found") then 404 else 400) "INVALID_REBALANCE_REQUEST" message next ctx + if message.Contains("is archived") then + errorResponse 409 "PLAN_ARCHIVED" "rebalance plan is archived" next ctx + else + errorResponse (if message.Contains("was not found") then 404 else 400) "INVALID_REBALANCE_REQUEST" message next ctx | Ok result -> if result.PlanId <> Guid.Empty && repository.GetRebalancePlans fundId |> List.exists (fun plan -> plan.Id = result.PlanId) |> not then errorResponse 404 "PLAN_NOT_FOUND" "rebalance plan does not belong to this fund" next ctx @@ -1787,7 +1831,11 @@ module App = errorResponse 404 "PLAN_NOT_FOUND" "rebalance plan does not belong to this fund" next ctx else match repository.PreviewRebalancePlan planId with - | Error message -> errorResponse 400 "INVALID_REBALANCE_REQUEST" message next ctx + | Error message -> + if message.Contains("is archived") then + errorResponse 409 "PLAN_ARCHIVED" "rebalance plan is archived" next ctx + else + errorResponse 400 "INVALID_REBALANCE_REQUEST" message next ctx | Ok preview -> json (rebalancePreviewResponse preview) next ctx with _ -> errorResponse 500 "PERSISTENCE_ERROR" "rebalance preview failed" next ctx @@ -2275,8 +2323,11 @@ module App = POST >=> routef "/funds/%s/sip/plans" (createSipPlan repository) GET >=> routef "/funds/%s/sip/plans" (getSipPlans repository) POST >=> routef "/funds/%s/sip/advance" (advanceSipPlans repository) + POST >=> routef "/funds/%s/sip/plans/%s/pause" (fun (fundId, planId) -> setSipPlanStatus repository "paused" fundId planId) + POST >=> routef "/funds/%s/sip/plans/%s/resume" (fun (fundId, planId) -> setSipPlanStatus repository "active" fundId planId) POST >=> routef "/funds/%s/rebalance/plans" (createRebalancePlan repository) GET >=> routef "/funds/%s/rebalance/plans" (getRebalancePlans repository) + POST >=> routef "/funds/%s/rebalance/plans/%s/archive" (fun (fundId, planId) -> archiveRebalancePlan repository fundId planId) POST >=> routef "/funds/%s/rebalance/plans/%s/execute" (fun (fundId, planId) -> executeRebalancePlan repository fundId planId) GET >=> routef "/funds/%s/rebalance/plans/%s/preview" (fun (fundId, planId) -> previewRebalancePlan repository fundId planId) GET >=> routef "/funds/%s/rebalance/executions" (getRebalanceExecutions repository) diff --git a/src/FundLab.Api/Persistence.fs b/src/FundLab.Api/Persistence.fs index 5000161..379c10d 100644 --- a/src/FundLab.Api/Persistence.fs +++ b/src/FundLab.Api/Persistence.fs @@ -389,6 +389,10 @@ type SipPlanWriteResult = | SipPlanFundNotFound | SipPlanInstrumentNotFound +type SipPlanStatusResult = + | SipPlanStatusChanged of SipPlanRecord + | SipPlanStatusNotFound + type SipExecutionOutcome = { TradeDate: DateOnly @@ -494,6 +498,10 @@ type RebalanceWriteResult = | RebalanceFundNotFound | RebalanceInstrumentNotFound +type RebalanceArchiveResult = + | RebalancePlanArchived of RebalancePlanRecord + | RebalanceArchiveNotFound + type RebalanceOrderOutcome = { InstrumentCode: string @@ -3347,6 +3355,54 @@ type FundRepository(connectionString: string) = records |> Seq.toList + member _.SetSipPlanStatus(fundId: Guid, planId: Guid, status: string) : SipPlanStatusResult = + use connection = new NpgsqlConnection(connectionString) + connection.Open() + use transaction = connection.BeginTransaction(IsolationLevel.ReadCommitted) + + try + use lockCommand = + commandWithTransaction + connection + (Some transaction) + "SELECT pg_advisory_xact_lock(hashtext(@lock_key))" + + addParameter lockCommand "lock_key" NpgsqlDbType.Text (box (sprintf "sip-plan-status:%O" planId)) |> ignore + lockCommand.ExecuteNonQuery() |> ignore + + match findSipPlan connection (Some transaction) planId with + | Some plan when plan.FundId = fundId -> + if plan.Status = status then + transaction.Commit() + SipPlanStatusResult.SipPlanStatusChanged plan + else + use updateCommand = + commandWithTransaction + connection + (Some transaction) + "UPDATE sip_plans SET status = @status WHERE id = @plan_id" + + addParameter updateCommand "status" NpgsqlDbType.Text (box status) |> ignore + addParameter updateCommand "plan_id" NpgsqlDbType.Uuid (box planId) |> ignore + updateCommand.ExecuteNonQuery() |> ignore + + let updated = findSipPlan connection (Some transaction) planId + transaction.Commit() + + match updated with + | Some updatedPlan -> SipPlanStatusResult.SipPlanStatusChanged updatedPlan + | None -> SipPlanStatusResult.SipPlanStatusNotFound + | _ -> + transaction.Rollback() + SipPlanStatusResult.SipPlanStatusNotFound + with error -> + try + transaction.Rollback() + with _ -> + () + + raise error + member _.CreateInvestmentPlan(idempotencyKey: string, fundId: Guid, command: InvestmentPlanCommand, ?anchorOverride: DateOnly) : InvestmentPlanWriteResult = if String.IsNullOrWhiteSpace idempotencyKey then InvestmentPlanWriteResult.InvestmentPlanInvalid "idempotency key cannot be empty" @@ -3838,6 +3894,53 @@ type FundRepository(connectionString: string) = reader.Close() plans |> Seq.toList |> List.map (rebalancePlanWithTargets connection None) + member _.ArchiveRebalancePlan(fundId: Guid, planId: Guid) : RebalanceArchiveResult = + use connection = new NpgsqlConnection(connectionString) + connection.Open() + use transaction = connection.BeginTransaction(IsolationLevel.ReadCommitted) + + try + use lockCommand = + commandWithTransaction + connection + (Some transaction) + "SELECT pg_advisory_xact_lock(hashtext(@lock_key))" + + addParameter lockCommand "lock_key" NpgsqlDbType.Text (box (sprintf "rebalance-archive:%O" planId)) |> ignore + lockCommand.ExecuteNonQuery() |> ignore + + match findRebalancePlan connection (Some transaction) planId with + | Some plan when plan.FundId = fundId -> + if plan.Status = "archived" then + transaction.Commit() + RebalanceArchiveResult.RebalancePlanArchived plan + else + use updateCommand = + commandWithTransaction + connection + (Some transaction) + "UPDATE rebalance_plans SET status = 'archived' WHERE id = @plan_id" + + addParameter updateCommand "plan_id" NpgsqlDbType.Uuid (box planId) |> ignore + updateCommand.ExecuteNonQuery() |> ignore + + let updated = findRebalancePlan connection (Some transaction) planId + transaction.Commit() + + match updated with + | Some updatedPlan -> RebalanceArchiveResult.RebalancePlanArchived updatedPlan + | None -> RebalanceArchiveResult.RebalanceArchiveNotFound + | _ -> + transaction.Rollback() + RebalanceArchiveResult.RebalanceArchiveNotFound + with error -> + try + transaction.Rollback() + with _ -> + () + + raise error + member this.ExecuteRebalancePlan(planId: Guid) : Result = use connection = new NpgsqlConnection(connectionString) connection.Open() @@ -3868,6 +3971,7 @@ type FundRepository(connectionString: string) = match plan with | None -> Error "rebalance plan was not found" + | Some plan when plan.Status = "archived" -> Error "rebalance plan is archived" | Some plan -> let fundId = plan.FundId let runDate = ConfirmationPolicy.tradeDateFor DateTimeOffset.UtcNow @@ -4033,6 +4137,7 @@ type FundRepository(connectionString: string) = match findRebalancePlan connection None planId with | None -> Error "rebalance plan was not found" + | Some plan when plan.Status = "archived" -> Error "rebalance plan is archived" | Some plan -> match this.GetFund plan.FundId with | None -> Error "fund was not found" -- cgit v1.2.3