summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 05:23:52 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 05:23:52 +0800
commit6abd4c206fbfe8369b3fde4c905619d08f13c5d1 (patch)
treef53ce1746591b640f78cd11d63c8411160059d98
parent545f2a507fd0138f7fd50ed7fb2631d0273db625 (diff)
downloadfund-lab-6abd4c206fbfe8369b3fde4c905619d08f13c5d1.tar.gz
Add SIP plan pause/resume and rebalance plan archive lifecycle (3d-24)
-rw-r--r--qa/driver/browser-test.js186
-rw-r--r--src/FundLab.Api/App.fs55
-rw-r--r--src/FundLab.Api/Persistence.fs105
-rw-r--r--src/FundLab.Web/App.fs146
-rw-r--r--src/FundLab.Web/src/api.js20
-rw-r--r--tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj2
-rw-r--r--tests/FundLab.Api.Tests/RebalanceArchiveTests.fs149
-rw-r--r--tests/FundLab.Api.Tests/SipPlanStatusTests.fs177
-rw-r--r--tests/FundLab.Web.Tests/BoundaryTests.fs60
9 files changed, 894 insertions, 6 deletions
diff --git a/qa/driver/browser-test.js b/qa/driver/browser-test.js
index 8ad9ab5..dd49263 100644
--- a/qa/driver/browser-test.js
+++ b/qa/driver/browser-test.js
@@ -42,6 +42,8 @@ let lastStockPositionsResponse = null;
let lastBondTradeResponse = null;
let lastBondPositionsResponse = null;
let lastValuationResponse = null;
+let lastSipStatusResponse = null;
+let lastSipAdvanceResponse = null;
function check(name, ok, detail) {
results.push({ name, ok, detail: detail || "" });
@@ -231,7 +233,17 @@ async function summaryLabelExists(page, label, name) {
lastValuationResponse = await r.json();
} catch {}
}
- if (r.status() >= 400 && !/\/api\/instruments\//.test(r.url()) && !/\/api\/market\//.test(r.url()) && !/\/orders/.test(r.url()) && !/\/redemptions/.test(r.url()) && !/\/capital/.test(r.url()) && !/\/stock-trades/.test(r.url()) && !/\/stock-positions/.test(r.url()) && !/\/bond-trades/.test(r.url()) && !/\/bond-positions/.test(r.url()) && !/\/valuation/.test(r.url())) {
+ if (/\/sip\/plans\/[0-9a-f-]+\/(pause|resume)$/i.test(new URL(r.url()).pathname) && r.request().method() === "POST" && r.status() < 400) {
+ try {
+ lastSipStatusResponse = await r.json();
+ } catch {}
+ }
+ if (/\/sip\/advance$/i.test(new URL(r.url()).pathname) && r.request().method() === "POST" && r.status() < 400) {
+ try {
+ lastSipAdvanceResponse = await r.json();
+ } catch {}
+ }
+ if (r.status() >= 400 && !/\/api\/instruments\//.test(r.url()) && !/\/api\/market\//.test(r.url()) && !/\/orders/.test(r.url()) && !/\/redemptions/.test(r.url()) && !/\/capital/.test(r.url()) && !/\/stock-trades/.test(r.url()) && !/\/stock-positions/.test(r.url()) && !/\/bond-trades/.test(r.url()) && !/\/bond-positions/.test(r.url()) && !/\/valuation/.test(r.url()) && !/\/rebalance\/plans\//.test(r.url())) {
consoleErrors.push("resource " + r.status() + ": " + r.url());
}
});
@@ -288,6 +300,8 @@ async function summaryLabelExists(page, label, name) {
await stockTradeScenario(page);
await bondTradeScenario(page);
await valuationScenario(page);
+ await sipStatusScenario(page);
+ await rebalanceArchiveScenario(page);
} finally {
check("G1 无浏览器控制台/页面错误", consoleErrors.length === 0, consoleErrors.slice(0, 3).join(" | "));
await browser.close();
@@ -1220,6 +1234,111 @@ async function plansScenario(page) {
await page.screenshot({ path: SHOTS + "/17-investment-plans.png" });
}
+async function sipStatusScenario(page) {
+ // P8: 定投计划暂停/恢复最小闭环(暂停时不扣款,恢复后到期继续执行)。
+ await page.waitForSelector(".sip-panel", { timeout: 10000 });
+ const fundId = await currentFundId(page);
+ const token = process.env.QA_TOKEN || "qa-token";
+
+ const planId = await page.evaluate(
+ async ({ fundId, token }) => {
+ const response = await fetch(`/api/funds/${fundId}/sip/plans`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ "Idempotency-Key": "qasippauseresume",
+ },
+ body: JSON.stringify({ instrumentCode: "000001", amount: "10.00", frequency: "weekly" }),
+ });
+ if (response.status !== 201) return "";
+ const body = await response.json();
+ return body.id || "";
+ },
+ { fundId, token }
+ );
+
+ await page.click(".sip-plans-refresh-action");
+ await page.waitForSelector(".sip-panel .sip-plans .order-row", { timeout: 15000 });
+
+ lastSipStatusResponse = null;
+ await page.click(".sip-pause-action");
+
+ for (let i = 0; i < 100 && lastSipStatusResponse === null; i++) {
+ await page.waitForTimeout(100);
+ }
+
+ const pausedText = ((await page.textContent(".sip-plans")) || "").trim();
+
+ const pausedAdvance = await page.evaluate(
+ async ({ fundId, token }) => {
+ const response = await fetch(`/api/funds/${fundId}/sip/advance`, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
+ body: JSON.stringify({ endDate: "2026-10-31" }),
+ });
+ const body = await response.json();
+ return body;
+ },
+ { fundId, token }
+ );
+
+ const pausedPlans = pausedAdvance && Array.isArray(pausedAdvance.plans) ? pausedAdvance.plans : [];
+ const pausedOutcomes = pausedPlans.reduce(
+ (acc, plan) => acc.concat(Array.isArray(plan.executions) ? plan.executions : []),
+ []
+ );
+
+ check(
+ "P8a 暂停定投计划不参与到期扣款",
+ planId !== "" &&
+ lastSipStatusResponse &&
+ lastSipStatusResponse.status === "paused" &&
+ pausedText.includes("已暂停") &&
+ pausedOutcomes.length === 0,
+ `plan=${planId} status=${lastSipStatusResponse?.status} outcomes=${pausedOutcomes.length} text=${pausedText.slice(0, 160)}`
+ );
+
+ lastSipStatusResponse = null;
+ await page.click(".sip-resume-action");
+
+ for (let i = 0; i < 100 && lastSipStatusResponse === null; i++) {
+ await page.waitForTimeout(100);
+ }
+
+ const resumedText = ((await page.textContent(".sip-plans")) || "").trim();
+
+ const resumedAdvance = await page.evaluate(
+ async ({ fundId, token }) => {
+ const response = await fetch(`/api/funds/${fundId}/sip/advance`, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
+ body: JSON.stringify({ endDate: "2026-10-31" }),
+ });
+ const body = await response.json();
+ return body;
+ },
+ { fundId, token }
+ );
+
+ const resumedPlans = resumedAdvance && Array.isArray(resumedAdvance.plans) ? resumedAdvance.plans : [];
+ const resumedOutcomes = resumedPlans.reduce(
+ (acc, plan) => acc.concat(Array.isArray(plan.executions) ? plan.executions : []),
+ []
+ );
+
+ check(
+ "P8b 恢复定投计划后到期继续执行",
+ lastSipStatusResponse &&
+ lastSipStatusResponse.status === "active" &&
+ resumedText.includes("进行中") &&
+ resumedOutcomes.length > 0,
+ `status=${lastSipStatusResponse?.status} outcomes=${resumedOutcomes.length} text=${resumedText.slice(0, 160)}`
+ );
+
+ await page.screenshot({ path: SHOTS + "/27-sip-status.png" });
+}
+
async function rebalanceScenario(page) {
// R 系列: 再平衡最小闭环(接 P 场景:现金 5343.00,持仓 134.01949252 @1.1902)
await page.waitForSelector(".rebalance-panel", { timeout: 10000 });
@@ -1334,6 +1453,71 @@ async function rebalanceScenario(page) {
await page.screenshot({ path: SHOTS + "/18-rebalance.png" });
}
+async function rebalanceArchiveScenario(page) {
+ // P9: 再平衡计划归档后不可预览/执行。
+ await page.waitForSelector(".rebalance-panel", { timeout: 10000 });
+ const fundId = await currentFundId(page);
+ const token = process.env.QA_TOKEN || "qa-token";
+
+ await page.waitForSelector(".rebalance-plans .rebalance-plan-row", { timeout: 15000 });
+
+ const activePreviewCount = await page.locator(".rebalance-preview-action").count();
+ const activeArchiveCount = await page.locator(".rebalance-archive-action").count();
+
+ await page.click(".rebalance-archive-action");
+ await page.waitForFunction(
+ () => {
+ const element = document.querySelector(".rebalance-plans");
+ return element && element.textContent.includes("已归档");
+ },
+ { timeout: 10000 }
+ );
+
+ const archivedText = ((await page.textContent(".rebalance-plans")) || "").trim();
+ const archivedPreviewCount = await page.locator(".rebalance-preview-action").count();
+
+ const planId = await page.evaluate(
+ async ({ fundId, token }) => {
+ const response = await fetch(`/api/funds/${fundId}/rebalance/plans`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ const plans = await response.json();
+ const archived = (Array.isArray(plans) ? plans : []).find((plan) => plan.status === "archived");
+ return archived ? archived.id : "";
+ },
+ { fundId, token }
+ );
+
+ const previewStatus = await page.evaluate(
+ async ({ fundId, planId, token }) => {
+ const response = await fetch(`/api/funds/${fundId}/rebalance/plans/${planId}/preview`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ let body = null;
+ try {
+ body = await response.json();
+ } catch {}
+ return { status: response.status, body };
+ },
+ { fundId, planId, token }
+ );
+
+ check(
+ "P9 归档后再平衡计划不可预览且按钮隐藏",
+ activePreviewCount > 0 &&
+ activeArchiveCount > 0 &&
+ archivedText.includes("已归档") &&
+ archivedPreviewCount === 0 &&
+ planId !== "" &&
+ previewStatus.status === 409 &&
+ previewStatus.body &&
+ previewStatus.body.error === "PLAN_ARCHIVED",
+ `active=${activePreviewCount} archived=${archivedPreviewCount} status=${previewStatus.status} text=${archivedText.slice(0, 160)}`
+ );
+
+ await page.screenshot({ path: SHOTS + "/28-rebalance-archive.png" });
+}
+
const failed = results.filter((r) => !r.ok);
console.log(`\n==== ${results.length - failed.length}/${results.length} passed ====`);
process.exit(failed.length > 0 ? 1 : 0);
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<RebalanceExecutionResult, string> =
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"
diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs
index 62bb20e..7dea8bb 100644
--- a/src/FundLab.Web/App.fs
+++ b/src/FundLab.Web/App.fs
@@ -1005,6 +1005,9 @@ module Api =
[<Import("getRebalancePlans", "./src/api.js")>]
let getRebalancePlans (token: string) (fundId: string) : JS.Promise<RawRebalancePlan array> = jsNative
+ [<Import("archiveRebalancePlan", "./src/api.js")>]
+ let archiveRebalancePlan (token: string) (fundId: string) (planId: string) : JS.Promise<RawRebalancePlan> = jsNative
+
[<Import("executeRebalancePlan", "./src/api.js")>]
let executeRebalancePlan (token: string) (fundId: string) (planId: string) : JS.Promise<RawRebalanceExecution> = jsNative
@@ -1020,6 +1023,12 @@ module Api =
[<Import("getSipPlans", "./src/api.js")>]
let getSipPlans (token: string) (fundId: string) : JS.Promise<RawSipPlan array> = jsNative
+ [<Import("pauseSipPlan", "./src/api.js")>]
+ let pauseSipPlan (token: string) (fundId: string) (planId: string) : JS.Promise<RawSipPlan> = jsNative
+
+ [<Import("resumeSipPlan", "./src/api.js")>]
+ let resumeSipPlan (token: string) (fundId: string) (planId: string) : JS.Promise<RawSipPlan> = jsNative
+
[<Import("getInvestmentPlans", "./src/api.js")>]
let getInvestmentPlans (token: string) (fundId: string) : JS.Promise<RawInvestmentPlan array> = jsNative
@@ -1411,11 +1420,15 @@ type Model =
sipCreateSeq: int
sipReadSeq: int
sipInFlight: bool
+ sipStatusSeq: int
+ sipStatusInFlight: bool
lastSipAttempt: SipAttempt option
sipPlans: SipPlan list
rebalTargets: RebalanceTargetDraft list
rebalCreateSeq: int
rebalReadSeq: int
+ rebalArchiveSeq: int
+ rebalArchiveInFlight: bool
rebalInFlight: bool
rebalExecuteSeq: int
rebalExecuteInFlight: bool
@@ -1548,6 +1561,10 @@ type Msg =
| SipPlansReadRequested
| SipPlansReadCompleted of requestId: int * plans: RawSipPlan array
| SipPlansReadFailed of requestId: int * message: string
+ | SipPlanPauseRequested of planId: string
+ | SipPlanResumeRequested of planId: string
+ | SipPlanStatusChanged of requestId: int * plan: RawSipPlan
+ | SipPlanStatusChangeFailed of requestId: int * message: string
| RebalTargetCodeChanged of index: int * value: string
| RebalTargetPercentChanged of index: int * value: string
| RebalTargetAdded
@@ -1558,6 +1575,9 @@ type Msg =
| RebalancePlansReadRequested
| RebalancePlansReadCompleted of requestId: int * plans: RawRebalancePlan array
| RebalancePlansReadFailed of requestId: int * message: string
+ | RebalancePlanArchiveRequested of planId: string
+ | RebalancePlanArchived of requestId: int * plan: RawRebalancePlan
+ | RebalancePlanArchiveFailed of requestId: int * message: string
| RebalancePreviewRequested of planId: string
| RebalancePreviewCompleted of requestId: int * preview: RawRebalancePreview
| RebalancePreviewFailed of requestId: int * message: string
@@ -1705,11 +1725,15 @@ let init () =
sipCreateSeq = 0
sipReadSeq = 0
sipInFlight = false
+ sipStatusSeq = 0
+ sipStatusInFlight = false
lastSipAttempt = None
sipPlans = []
rebalTargets = [ { code = ""; percent = "" }; { code = ""; percent = "" } ]
rebalCreateSeq = 0
rebalReadSeq = 0
+ rebalArchiveSeq = 0
+ rebalArchiveInFlight = false
rebalInFlight = false
rebalExecuteSeq = 0
rebalExecuteInFlight = false
@@ -1894,6 +1918,20 @@ let private readSipPlansCommand token fundId requestId =
(fun plans -> SipPlansReadCompleted(requestId, plans))
(fun error -> SipPlansReadFailed(requestId, errorText error))
+let private pauseSipPlanCommand token fundId planId requestId =
+ Cmd.OfPromise.either
+ (fun () -> Api.pauseSipPlan token fundId planId)
+ ()
+ (fun plan -> SipPlanStatusChanged(requestId, plan))
+ (fun error -> SipPlanStatusChangeFailed(requestId, errorText error))
+
+let private resumeSipPlanCommand token fundId planId requestId =
+ Cmd.OfPromise.either
+ (fun () -> Api.resumeSipPlan token fundId planId)
+ ()
+ (fun plan -> SipPlanStatusChanged(requestId, plan))
+ (fun error -> SipPlanStatusChangeFailed(requestId, errorText error))
+
let private createRebalancePlanCommand token fundId payload requestId =
Cmd.OfPromise.either
(fun () -> Api.createRebalancePlan token fundId payload)
@@ -1908,6 +1946,13 @@ let private readRebalancePlansCommand token fundId requestId =
(fun plans -> RebalancePlansReadCompleted(requestId, plans))
(fun error -> RebalancePlansReadFailed(requestId, errorText error))
+let private archiveRebalancePlanCommand token fundId planId requestId =
+ Cmd.OfPromise.either
+ (fun () -> Api.archiveRebalancePlan token fundId planId)
+ ()
+ (fun plan -> RebalancePlanArchived(requestId, plan))
+ (fun error -> RebalancePlanArchiveFailed(requestId, errorText error))
+
let private executeRebalanceCommand token fundId planId requestId =
Cmd.OfPromise.either
(fun () -> Api.executeRebalancePlan token fundId planId)
@@ -2027,6 +2072,18 @@ let private runInvestmentPlansCommand token fundId requestId =
(fun run -> InvestmentPlansRunCompleted(requestId, fundId, run))
(fun error -> InvestmentPlansRunFailed(requestId, fundId, errorText error))
+let private dispatchSipStatusChange model planId pause =
+ match model.createdFund with
+ | Some fund when not (String.IsNullOrWhiteSpace model.token) && not model.sipStatusInFlight ->
+ let requestId = model.sipStatusSeq + 1
+
+ { model with sipStatusSeq = requestId; sipStatusInFlight = true; error = None },
+ (if pause then
+ pauseSipPlanCommand model.token fund.id planId requestId
+ else
+ resumeSipPlanCommand model.token fund.id planId requestId)
+ | _ -> model, Cmd.none
+
let update message model =
match message with
| TokenChanged token ->
@@ -2834,6 +2891,28 @@ let update message model =
{ model with error = Some message }, Cmd.none
else
model, Cmd.none
+ | SipPlanPauseRequested planId -> dispatchSipStatusChange model planId true
+ | SipPlanResumeRequested planId -> dispatchSipStatusChange model planId false
+ | SipPlanStatusChanged (requestId, plan) ->
+ if requestId = model.sipStatusSeq then
+ let decoded = Api.decodeSipPlan plan
+
+ {
+ model with
+ sipStatusInFlight = false
+ sipPlans =
+ model.sipPlans
+ |> List.map (fun existing -> if existing.id = decoded.id then decoded else existing)
+ error = None
+ },
+ Cmd.none
+ else
+ model, Cmd.none
+ | SipPlanStatusChangeFailed (requestId, message) ->
+ if requestId = model.sipStatusSeq then
+ { model with sipStatusInFlight = false; error = Some message }, Cmd.none
+ else
+ model, Cmd.none
| RebalTargetCodeChanged (index, value) ->
{
model with
@@ -2958,6 +3037,33 @@ let update message model =
{ model with error = Some message }, Cmd.none
else
model, Cmd.none
+ | RebalancePlanArchiveRequested planId ->
+ match model.createdFund with
+ | Some fund when not (String.IsNullOrWhiteSpace model.token) && not model.rebalArchiveInFlight ->
+ let requestId = model.rebalArchiveSeq + 1
+ { model with rebalArchiveSeq = requestId; rebalArchiveInFlight = true; error = None },
+ archiveRebalancePlanCommand model.token fund.id planId requestId
+ | _ -> model, Cmd.none
+ | RebalancePlanArchived (requestId, plan) ->
+ if requestId = model.rebalArchiveSeq then
+ let decoded = Api.decodeRebalancePlan plan
+
+ {
+ model with
+ rebalArchiveInFlight = false
+ rebalPlans =
+ model.rebalPlans
+ |> List.map (fun existing -> if existing.id = decoded.id then decoded else existing)
+ error = None
+ },
+ Cmd.none
+ else
+ model, Cmd.none
+ | RebalancePlanArchiveFailed (requestId, message) ->
+ if requestId = model.rebalArchiveSeq then
+ { model with rebalArchiveInFlight = false; error = Some message }, Cmd.none
+ else
+ model, Cmd.none
| RebalancePreviewRequested planId ->
match model.createdFund with
| Some fund when not (String.IsNullOrWhiteSpace model.token) && not model.rebalPreviewInFlight ->
@@ -4394,7 +4500,7 @@ let private sipFrequencyText (frequency: string) =
elif frequency = "monthly" then "每月"
else frequency
-let private sipPlanRow (plan: SipPlan) =
+let private sipPlanRow dispatch (plan: SipPlan) =
Html.div [
prop.className "order-row"
prop.children [
@@ -4418,7 +4524,31 @@ let private sipPlanRow (plan: SipPlan) =
| None -> "最近执行 暂无"
Html.span [ prop.className "order-cell"; prop.text lastExecution ]
- Html.span [ prop.className "order-status"; prop.text (if plan.status = "active" then "进行中" else plan.status) ]
+ Html.span [
+ prop.className "order-status"
+ prop.text (
+ if plan.status = "active" then "进行中"
+ elif plan.status = "paused" then "已暂停"
+ else plan.status
+ )
+ ]
+ Html.span [
+ prop.className "order-cell sip-plan-actions"
+ prop.children [
+ if plan.status = "paused" then
+ Html.button [
+ prop.className "secondary-action sip-resume-action"
+ prop.onClick (fun _ -> dispatch (SipPlanResumeRequested plan.id))
+ prop.text "恢复"
+ ]
+ else
+ Html.button [
+ prop.className "secondary-action sip-pause-action"
+ prop.onClick (fun _ -> dispatch (SipPlanPauseRequested plan.id))
+ prop.text "暂停"
+ ]
+ ]
+ ]
]
]
@@ -4497,7 +4627,7 @@ let private sipPanel model dispatch =
if List.isEmpty model.sipPlans then
Html.p [ prop.className "hint"; prop.text "暂无定投计划" ]
else
- yield! (model.sipPlans |> List.map sipPlanRow)
+ yield! (model.sipPlans |> List.map (sipPlanRow dispatch))
]
]
Html.div [
@@ -4691,6 +4821,10 @@ let private rebalancePanel model dispatch =
|> List.map (fun target -> sprintf "%s %s%%" target.instrumentCode target.targetPercent)
|> String.concat " / ")
]
+ Html.span [
+ prop.className "order-status"
+ prop.text (if plan.status = "archived" then "已归档" else "进行中")
+ ]
(if plan.status = "active" then
Html.span [
prop.className "order-actions"
@@ -4707,6 +4841,12 @@ let private rebalancePanel model dispatch =
prop.onClick (fun _ -> dispatch (RebalanceExecuteRequested plan.id))
prop.text ((if model.rebalExecuteInFlight then "执行中..." else "执行再平衡"): string)
]
+ Html.button [
+ prop.className "secondary-action rebalance-archive-action"
+ prop.disabled model.rebalArchiveInFlight
+ prop.onClick (fun _ -> dispatch (RebalancePlanArchiveRequested plan.id))
+ prop.text "归档"
+ ]
]
]
else
diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js
index 031ff74..a03b97f 100644
--- a/src/FundLab.Web/src/api.js
+++ b/src/FundLab.Web/src/api.js
@@ -199,6 +199,18 @@ export function getSipPlans(token, fundId) {
return requestJson(`/api/funds/${encodeURIComponent(fundId)}/sip/plans`, token);
}
+export function pauseSipPlan(token, fundId, planId) {
+ return requestJson(`/api/funds/${encodeURIComponent(fundId)}/sip/plans/${encodeURIComponent(planId)}/pause`, token, {
+ method: "POST"
+ });
+}
+
+export function resumeSipPlan(token, fundId, planId) {
+ return requestJson(`/api/funds/${encodeURIComponent(fundId)}/sip/plans/${encodeURIComponent(planId)}/resume`, token, {
+ method: "POST"
+ });
+}
+
export function getInvestmentPlans(token, fundId) {
return requestJson(`/api/funds/${encodeURIComponent(fundId)}/investment-plans`, token);
}
@@ -227,6 +239,14 @@ export function getRebalancePlans(token, fundId) {
return requestJson(`/api/funds/${encodeURIComponent(fundId)}/rebalance/plans`, token);
}
+export function archiveRebalancePlan(token, fundId, planId) {
+ return requestJson(`/api/funds/${encodeURIComponent(fundId)}/rebalance/plans/${encodeURIComponent(planId)}/archive`, token, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: "{}"
+ });
+}
+
export function executeRebalancePlan(token, fundId, planId) {
return requestJson(
`/api/funds/${encodeURIComponent(fundId)}/rebalance/plans/${encodeURIComponent(planId)}/execute`,
diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
index 38a9e7e..d42612a 100644
--- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
+++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
@@ -37,6 +37,8 @@
<Compile Include="StockTradeTests.fs" />
<Compile Include="BondTradeTests.fs" />
<Compile Include="FundValuationTests.fs" />
+ <Compile Include="SipPlanStatusTests.fs" />
+ <Compile Include="RebalanceArchiveTests.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
</Project>
diff --git a/tests/FundLab.Api.Tests/RebalanceArchiveTests.fs b/tests/FundLab.Api.Tests/RebalanceArchiveTests.fs
new file mode 100644
index 0000000..ebb238b
--- /dev/null
+++ b/tests/FundLab.Api.Tests/RebalanceArchiveTests.fs
@@ -0,0 +1,149 @@
+namespace FundLab.Api.Tests
+
+open System
+open Xunit
+open FundLab.Api
+
+[<Collection("postgres")>]
+type RebalanceArchiveTests(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, "rebalance-archive-test-hash")
+ code
+
+ let createFund (initialCash: decimal) =
+ let command =
+ {
+ Name = "再平衡归档测试 FOF"
+ InitialCash = initialCash
+ InitialUnitNav = 1.00000000m
+ IsSynthetic = true
+ }
+
+ let key = fixture.Key(sprintf "rebalance-archive-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 createPlan (fundId: Guid) (code: string) =
+ let key = fixture.Key(sprintf "rebalance-archive-plan-%s" (Guid.NewGuid().ToString("N")))
+
+ match
+ repository().CreateRebalancePlan(key, fundId, { Targets = [ { InstrumentCode = code; TargetPercent = 100.00m } ] })
+ with
+ | RebalanceWriteResult.RebalancePlanCreated plan -> plan.Id
+ | other -> failwithf "unexpected rebalance plan result: %A" other
+
+ let archiveViaApi fundId planId =
+ PersistenceTestHelpers.invoke
+ (app ())
+ "POST"
+ (sprintf "/api/funds/%O/rebalance/plans/%O/archive" fundId planId)
+ [ "Authorization", "Bearer test-token" ]
+ "{}"
+
+ let previewViaApi fundId planId =
+ PersistenceTestHelpers.invoke
+ (app ())
+ "GET"
+ (sprintf "/api/funds/%O/rebalance/plans/%O/preview" fundId planId)
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ let executeViaApi fundId planId =
+ PersistenceTestHelpers.invoke
+ (app ())
+ "POST"
+ (sprintf "/api/funds/%O/rebalance/plans/%O/execute" fundId planId)
+ [ "Authorization", "Bearer test-token" ]
+ "{}"
+
+ let plansViaApi fundId =
+ PersistenceTestHelpers.invoke
+ (app ())
+ "GET"
+ (sprintf "/api/funds/%O/rebalance/plans" fundId)
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ [<Fact>]
+ member _.``archiving an active plan marks it archived in the plan list``() =
+ let fundId = createFund 1000.00m
+ let code = seedInstrument ()
+ let planId = createPlan fundId code
+
+ let status, body = archiveViaApi fundId planId
+ Assert.Equal(200, status)
+ Assert.Contains("\"status\":\"archived\"", body)
+
+ let _, plansBody = plansViaApi fundId
+ Assert.Contains("\"status\":\"archived\"", plansBody)
+
+ [<Fact>]
+ member _.``archived plan cannot be previewed``() =
+ let fundId = createFund 1000.00m
+ let code = seedInstrument ()
+ let planId = createPlan fundId code
+
+ let _, _ = archiveViaApi fundId planId
+ let status, body = previewViaApi fundId planId
+
+ Assert.Equal(409, status)
+ Assert.Contains("PLAN_ARCHIVED", body)
+
+ [<Fact>]
+ member _.``archived plan cannot be executed``() =
+ let fundId = createFund 1000.00m
+ let code = seedInstrument ()
+ let planId = createPlan fundId code
+
+ let _, _ = archiveViaApi fundId planId
+ let status, body = executeViaApi fundId planId
+
+ Assert.Equal(409, status)
+ Assert.Contains("PLAN_ARCHIVED", body)
+
+ [<Fact>]
+ member _.``archiving an already archived plan is idempotent``() =
+ let fundId = createFund 1000.00m
+ let code = seedInstrument ()
+ let planId = createPlan fundId code
+
+ let firstStatus, firstBody = archiveViaApi fundId planId
+ Assert.Equal(200, firstStatus)
+ Assert.Contains("\"status\":\"archived\"", firstBody)
+
+ let secondStatus, secondBody = archiveViaApi fundId planId
+ Assert.Equal(200, secondStatus)
+ Assert.Contains("\"status\":\"archived\"", secondBody)
+
+ [<Fact>]
+ member _.``archiving a plan owned by another fund answers 404``() =
+ let fundId = createFund 1000.00m
+ let otherFundId = createFund 1000.00m
+ let code = seedInstrument ()
+ let planId = createPlan fundId code
+
+ let status, body = archiveViaApi otherFundId planId
+ Assert.Equal(404, status)
+ Assert.Contains("PLAN_NOT_FOUND", body)
diff --git a/tests/FundLab.Api.Tests/SipPlanStatusTests.fs b/tests/FundLab.Api.Tests/SipPlanStatusTests.fs
new file mode 100644
index 0000000..780d85f
--- /dev/null
+++ b/tests/FundLab.Api.Tests/SipPlanStatusTests.fs
@@ -0,0 +1,177 @@
+namespace FundLab.Api.Tests
+
+open System
+open Xunit
+open FundLab.Api
+open FundLab.Domain
+
+[<Collection("postgres")>]
+type SipPlanStatusTests(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, "sip-status-test-hash")
+ code
+
+ let createFund (initialCash: decimal) =
+ let command =
+ {
+ Name = "定投状态测试 FOF"
+ InitialCash = initialCash
+ InitialUnitNav = 1.00000000m
+ IsSynthetic = true
+ }
+
+ let key = fixture.Key(sprintf "sip-status-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 "sip-status-hash/%s" revision)
+
+ let createPlanWithAnchor (fundId: Guid) (code: string) (amount: decimal) (frequency: SipFrequency) =
+ let key = fixture.Key(sprintf "sip-status-plan-%s" (Guid.NewGuid().ToString("N")))
+
+ match
+ repository().CreateSipPlan(
+ key,
+ fundId,
+ { InstrumentCode = code; Amount = amount; Frequency = frequency },
+ DateOnly(2026, 9, 7)
+ )
+ with
+ | SipPlanWriteResult.SipPlanCreated plan -> plan.Id
+ | other -> failwithf "unexpected sip plan result: %A" other
+
+ let postStatus (fundId: Guid) (planId: Guid) (action: string) =
+ PersistenceTestHelpers.invoke
+ (app ())
+ "POST"
+ (sprintf "/api/funds/%O/sip/plans/%O/%s" fundId planId action)
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ let advance (fundId: Guid) (body: string) =
+ PersistenceTestHelpers.invoke
+ (app ())
+ "POST"
+ (sprintf "/api/funds/%O/sip/advance" fundId)
+ [ "Authorization", "Bearer test-token" ]
+ body
+
+ let listPlans (fundId: Guid) =
+ PersistenceTestHelpers.invoke
+ (app ())
+ "GET"
+ (sprintf "/api/funds/%O/sip/plans" fundId)
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ [<Fact>]
+ member _.``pause skips advance, resume lets the next due period execute again``() =
+ let fundId = createFund 10000.00m
+ let code = seedInstrument ()
+ let planId = createPlanWithAnchor fundId code 100.00m Weekly
+
+ insertQuoteOnDate code 2.5m (DateOnly(2026, 9, 14))
+ insertQuoteOnDate code 2.5m (DateOnly(2026, 9, 21))
+
+ let pauseStatus, pauseBody = postStatus fundId planId "pause"
+ Assert.Equal(200, pauseStatus)
+ Assert.Contains("\"status\":\"paused\"", pauseBody)
+
+ let advanceStatus, advanceBody = advance fundId "{\"endDate\":\"2026-09-21\"}"
+ Assert.Equal(200, advanceStatus)
+ Assert.DoesNotContain("\"status\":\"succeeded\"", advanceBody)
+ Assert.DoesNotContain("\"status\":\"pending_nav\"", advanceBody)
+
+ let resumeStatus, resumeBody = postStatus fundId planId "resume"
+ Assert.Equal(200, resumeStatus)
+ Assert.Contains("\"status\":\"active\"", resumeBody)
+
+ let _, resumedBody = advance fundId "{\"endDate\":\"2026-09-21\"}"
+ Assert.Contains("\"status\":\"succeeded\"", resumedBody)
+ Assert.Contains("\"tradeDate\":\"2026-09-14\"", resumedBody)
+
+ [<Fact>]
+ member _.``pausing an already paused plan is idempotent``() =
+ let fundId = createFund 1000.00m
+ let code = seedInstrument ()
+ let planId = createPlanWithAnchor fundId code 100.00m Weekly
+
+ let firstStatus, firstBody = postStatus fundId planId "pause"
+ Assert.Equal(200, firstStatus)
+ Assert.Contains("\"status\":\"paused\"", firstBody)
+
+ let secondStatus, secondBody = postStatus fundId planId "pause"
+ Assert.Equal(200, secondStatus)
+ Assert.Contains("\"status\":\"paused\"", secondBody)
+
+ [<Fact>]
+ member _.``paused plan status is reflected in the sip plan list``() =
+ let fundId = createFund 1000.00m
+ let code = seedInstrument ()
+ let planId = createPlanWithAnchor fundId code 100.00m Weekly
+
+ let _, _ = postStatus fundId planId "pause"
+ let _, listBody = listPlans fundId
+ Assert.Contains("\"status\":\"paused\"", listBody)
+
+ let _, _ = postStatus fundId planId "resume"
+ let _, resumedListBody = listPlans fundId
+ Assert.Contains("\"status\":\"active\"", resumedListBody)
+
+ [<Fact>]
+ member _.``pausing a plan that belongs to another fund answers 404``() =
+ let fundId = createFund 1000.00m
+ let otherFundId = createFund 1000.00m
+ let code = seedInstrument ()
+ let planId = createPlanWithAnchor fundId code 100.00m Weekly
+
+ let status, body = postStatus otherFundId planId "pause"
+ Assert.Equal(404, status)
+ Assert.Contains("PLAN_NOT_FOUND", body)
diff --git a/tests/FundLab.Web.Tests/BoundaryTests.fs b/tests/FundLab.Web.Tests/BoundaryTests.fs
index d8bb472..4c05f37 100644
--- a/tests/FundLab.Web.Tests/BoundaryTests.fs
+++ b/tests/FundLab.Web.Tests/BoundaryTests.fs
@@ -1164,6 +1164,36 @@ module SipBoundaryTests =
Assert.Equal(1, staleList.sipPlans.Length)
+ [<Fact>]
+ let ``sip pause completion updates the matching plan row status`` () =
+ let model =
+ { init () with
+ token = "qa-token"
+ createdFund = Some(sampleFund ())
+ sipStatusSeq = 1
+ sipStatusInFlight = true
+ sipPlans = [ Api.decodeSipPlan (sampleSipRaw ()) ] }
+
+ let pausedRaw = { sampleSipRaw () with status = "paused" }
+ let updated, _ = update (SipPlanStatusChanged(1, pausedRaw)) model
+
+ Assert.Equal("paused", updated.sipPlans[0].status)
+ Assert.False(updated.sipStatusInFlight)
+
+ [<Fact>]
+ let ``sip pause failure clears in-flight and surfaces a message`` () =
+ let model =
+ { init () with
+ token = "qa-token"
+ createdFund = Some(sampleFund ())
+ sipStatusSeq = 1
+ sipStatusInFlight = true }
+
+ let updated, _ = update (SipPlanStatusChangeFailed(1, "sip plan does not belong to this fund")) model
+
+ Assert.Equal(Some "sip plan does not belong to this fund", updated.error)
+ Assert.False(updated.sipStatusInFlight)
+
module RebalanceBoundaryTests =
open Xunit
@@ -1249,3 +1279,33 @@ module RebalanceBoundaryTests =
Assert.True(stale.rebalExecuteInFlight)
Assert.Equal(None, stale.rebalResult)
+
+ [<Fact>]
+ let ``rebalance archive completion updates the matching plan row status`` () =
+ let model =
+ { init () with
+ token = "qa-token"
+ createdFund = Some(sampleFundInfo ())
+ rebalArchiveSeq = 1
+ rebalArchiveInFlight = true
+ rebalPlans = [ Api.decodeRebalancePlan (sampleRebalanceRaw ()) ] }
+
+ let archivedRaw = { sampleRebalanceRaw () with status = "archived" }
+ let updated, _ = update (RebalancePlanArchived(1, archivedRaw)) model
+
+ Assert.Equal("archived", updated.rebalPlans[0].status)
+ Assert.False(updated.rebalArchiveInFlight)
+
+ [<Fact>]
+ let ``rebalance archive failure clears in-flight and surfaces a message`` () =
+ let model =
+ { init () with
+ token = "qa-token"
+ createdFund = Some(sampleFundInfo ())
+ rebalArchiveSeq = 1
+ rebalArchiveInFlight = true }
+
+ let updated, _ = update (RebalancePlanArchiveFailed(1, "rebalance plan does not belong to this fund")) model
+
+ Assert.Equal(Some "rebalance plan does not belong to this fund", updated.error)
+ Assert.False(updated.rebalArchiveInFlight)