From 3612763135df32da94f936cf95430519f5b7cc55 Mon Sep 17 00:00:00 2001 From: Paperclip Date: Mon, 27 Apr 2026 02:02:24 -0500 Subject: [PATCH 1/2] STE-46: Portal budgets + reconciliation screens - Added GET /api/budgets list endpoint - Added GET /api/reconciliations list endpoint - Added GET /api/categories endpoint - Added IReconciliationRepository.ListAsync with implementation - Portal: budgets list page with create modal - Portal: budget detail page with period start, inline allocation editing, period close - Portal: reconciliation list page with start modal - Portal: reconciliation detail page with transaction checkboxes, live diff, complete/abort/force-complete - Portal: updated types.ts and api.ts for budgets/reconciliations - Portal: added Budgets/Reconcile nav links to dashboard - Reused MoneyDisplay component from C2 Co-Authored-By: Paperclip --- portal/src/lib/api.ts | 203 +++++----- portal/src/lib/types.ts | 81 ++++ portal/src/routes/+page.svelte | 6 +- portal/src/routes/budgets/+page.svelte | 250 +++++++++++++ portal/src/routes/budgets/[id]/+page.svelte | 347 ++++++++++++++++++ portal/src/routes/reconciliation/+page.svelte | 222 +++++++++++ .../routes/reconciliation/[id]/+page.svelte | 274 ++++++++++++++ src/BitThicket.Steward.Api/BudgetEndpoints.fs | 25 ++ src/BitThicket.Steward.Api/Program.fs | 12 + .../ReconciliationEndpoints.fs | 9 + .../ReconciliationRepository.fs | 18 + 11 files changed, 1346 insertions(+), 101 deletions(-) create mode 100644 portal/src/routes/budgets/+page.svelte create mode 100644 portal/src/routes/budgets/[id]/+page.svelte create mode 100644 portal/src/routes/reconciliation/+page.svelte create mode 100644 portal/src/routes/reconciliation/[id]/+page.svelte diff --git a/portal/src/lib/api.ts b/portal/src/lib/api.ts index 631a1be..ca17c72 100644 --- a/portal/src/lib/api.ts +++ b/portal/src/lib/api.ts @@ -4,10 +4,13 @@ import type { Account, Balance, Transaction, - TransactionSplit, - Attachment, DataFeedConnection, - Category + Category, + Budget, + BudgetCurrentPeriod, + BudgetReport, + Reconciliation, + ReconciliationWithTransactions } from './types'; const API_BASE = ''; @@ -177,100 +180,14 @@ export async function deleteTransaction(id: string) { return api(`/api/transactions/${id}`, { method: 'DELETE' }); } -// ── Splits ───────────────────────────────────────────────────────────────── - -export async function listSplits(transactionId: string) { - return api<{ splits: TransactionSplit[] }>(`/api/transactions/${transactionId}/splits`); -} - -export async function createSplit( - transactionId: string, - data: { - amountMinor: number; - currency: string; - categoryId?: string; - description?: string; - memo?: string; - sortOrder?: number; - } -) { - return api(`/api/transactions/${transactionId}/splits`, { - method: 'POST', - body: JSON.stringify(data) - }); -} - -export async function updateSplit( - transactionId: string, - splitId: string, - data: { - amountMinor?: number; - currency?: string; - categoryId?: string | null; - description?: string | null; - memo?: string | null; - sortOrder?: number; - } -) { - return api(`/api/transactions/${transactionId}/splits/${splitId}`, { - method: 'PATCH', - body: JSON.stringify(data) - }); -} - -export async function deleteSplit(transactionId: string, splitId: string) { - return api(`/api/transactions/${transactionId}/splits/${splitId}`, { method: 'DELETE' }); -} - -// ── Attachments ──────────────────────────────────────────────────────────── - -export async function listTransactionAttachments(transactionId: string) { - return api<{ attachments: Attachment[] }>(`/api/transactions/${transactionId}/attachments`); -} - -export async function uploadTransactionAttachment( - transactionId: string, - file: File, - kind: string = 'other' -) { - const form = new FormData(); - form.append('file', file); - form.append('kind', kind); - return api(`/api/transactions/${transactionId}/attachments`, { - method: 'POST', - body: form - }); -} - -export async function uploadSplitAttachment( - transactionId: string, - splitId: string, - file: File, - kind: string = 'other' -) { - const form = new FormData(); - form.append('file', file); - form.append('kind', kind); - return api(`/api/transactions/${transactionId}/splits/${splitId}/attachments`, { - method: 'POST', - body: form - }); -} - -export function getAttachmentUrl(attachmentId: string) { - return `/api/attachments/${attachmentId}`; -} - -export async function deleteAttachment(attachmentId: string) { - return api(`/api/attachments/${attachmentId}`, { method: 'DELETE' }); -} - // ── Connections ──────────────────────────────────────────────────────────── export async function listConnections() { return api<{ connections: DataFeedConnection[] }>('/api/connections'); } +// ── Categories ───────────────────────────────────────────────────────────── + export async function listCategories() { return api<{ categories: Category[] }>('/api/categories'); } @@ -303,15 +220,12 @@ export async function patchOnboarding(data: { // ── Budgets ──────────────────────────────────────────────────────────────── -export interface Budget { - id: string; - name: string; - period: string; - currency: string; - style: string; - income: { amount: number; currencyCode: string } | null; - startsOn: string; - isActive: boolean; +export async function listBudgets() { + return api<{ budgets: Budget[] }>('/api/budgets'); +} + +export async function getBudget(id: string) { + return api(`/api/budgets/${id}`); } export async function createBudget(data: { @@ -328,3 +242,92 @@ export async function createBudget(data: { }); } +export async function createPeriod( + budgetId: string, + data: { startDate: string; allocations: { categoryId: string; amountMinor: number }[] } +) { + return api(`/api/budgets/${budgetId}/periods`, { + method: 'POST', + body: JSON.stringify(data) + }); +} + +export async function updateAllocation( + budgetId: string, + periodId: string, + categoryId: string, + data: { amountMinor: number; rolloverEnabled?: boolean } +) { + return api<{ success: boolean }>( + `/api/budgets/${budgetId}/periods/${periodId}/categories/${categoryId}`, + { + method: 'PATCH', + body: JSON.stringify(data) + } + ); +} + +export async function closePeriod(budgetId: string, periodId: string) { + return api<{ + closedPeriodId: string; + nextPeriodId: string; + rolloverBalances: { categoryId: string; rolloverAmountMinor: number; currency: string }[]; + }>(`/api/budgets/${budgetId}/periods/${periodId}/close`, { method: 'POST' }); +} + +export async function getBudgetReport(budgetId: string, periodId: string, displayCurrency?: string) { + const qs = displayCurrency ? `?displayCurrency=${displayCurrency}` : ''; + return api(`/api/budgets/${budgetId}/periods/${periodId}/report${qs}`); +} + +export async function getCurrentBudgetReport(budgetId: string, displayCurrency?: string) { + const qs = displayCurrency ? `?displayCurrency=${displayCurrency}` : ''; + return api(`/api/budgets/${budgetId}/periods/current/report${qs}`); +} + +// ── Reconciliations ──────────────────────────────────────────────────────── + +export async function listReconciliations() { + return api<{ reconciliations: Reconciliation[] }>('/api/reconciliations'); +} + +export async function getReconciliation(id: string) { + return api(`/api/reconciliations/${id}`); +} + +export async function createReconciliation(data: { + accountId: string; + statementDate: string; + statementBalanceMinor: number; + currency: string; +}) { + return api<{ reconciliation: Reconciliation; candidateTransactions: Transaction[] }>( + '/api/reconciliations', + { + method: 'POST', + body: JSON.stringify(data) + } + ); +} + +export async function updateReconciliationTransactions( + id: string, + data: { included: string[]; excluded: string[] } +) { + return api<{ success: boolean }>(`/api/reconciliations/${id}/transactions`, { + method: 'PATCH', + body: JSON.stringify(data) + }); +} + +export async function completeReconciliation(id: string, force = false) { + return api<{ status: string; diffMinor: number }>( + `/api/reconciliations/${id}/complete?force=${force}`, + { method: 'POST' } + ); +} + +export async function abortReconciliation(id: string) { + return api<{ status: string }>(`/api/reconciliations/${id}/abort`, { method: 'POST' }); +} + diff --git a/portal/src/lib/types.ts b/portal/src/lib/types.ts index 156afc7..c0ccb2d 100644 --- a/portal/src/lib/types.ts +++ b/portal/src/lib/types.ts @@ -105,3 +105,84 @@ export interface Category { name: string; color: string | null; } + +// ── Budgets ──────────────────────────────────────────────────────────────── + +export type BudgetPeriod = 'monthly' | 'biweekly' | 'weekly'; +export type BudgetStyle = 'zeroBased' | 'envelope' | 'flexible' | 'traditionalLimits'; + +export interface BudgetPeriodCategory { + categoryId: string; + allocatedMinor: number; + openingBalanceMinor: number; + rolloverBalanceMinor: number; + currency: string; + rolloverEnabled: boolean; +} + +export interface BudgetCurrentPeriod { + id: string; + startDate: string; + endDate: string; + status: 'Open' | 'Closed'; + allocations: BudgetPeriodCategory[]; +} + +export interface Budget { + id: string; + name: string; + period: BudgetPeriod; + currency: string; + style: BudgetStyle; + incomeMinor: number; + isActive: boolean; + startsOn: string; + currentPeriod: BudgetCurrentPeriod | null; +} + +export interface BudgetReportTotals { + allocatedMinor: number; + spentMinor: number; + remainingMinor: number; + currency: string; +} + +export interface BudgetReportCategoryItem { + categoryId: string; + name: string; + allocatedMinor: number; + spentMinor: number; + remainingMinor: number; + rolloverBalanceMinor: number; + percentUsed: number; + currency: string; +} + +export interface BudgetReport { + periodId: string; + totals: BudgetReportTotals; + byCategory: BudgetReportCategoryItem[]; + displayCurrency: string; +} + +// ── Reconciliations ──────────────────────────────────────────────────────── + +export type ReconciliationStatus = 'open' | 'completed' | 'aborted'; + +export interface Reconciliation { + id: string; + accountId: string; + statementDate: string; + statementBalanceMinor: number; + currency: string; + status: ReconciliationStatus; + note: string | null; + createdByUserId: string; + startedAt: string; + completedAt: string | null; +} + +export interface ReconciliationWithTransactions extends Reconciliation { + includedTransactions: Transaction[]; + diffMinor: number; +} diff --git a/portal/src/routes/+page.svelte b/portal/src/routes/+page.svelte index ec22eff..55a980e 100644 --- a/portal/src/routes/+page.svelte +++ b/portal/src/routes/+page.svelte @@ -100,7 +100,11 @@

Steward

Welcome back, {auth.user.displayName}

- Log out + {#if onboarding && onboarding.currentStep < 5} diff --git a/portal/src/routes/budgets/+page.svelte b/portal/src/routes/budgets/+page.svelte new file mode 100644 index 0000000..d7fd343 --- /dev/null +++ b/portal/src/routes/budgets/+page.svelte @@ -0,0 +1,250 @@ + + +{#if auth.user} +
+
+
+

Budgets

+

Track spending against your plans

+
+ +
+ + {#if loading} +
Loading…
+ {:else if error} +
{error}
+ {:else if budgets.length === 0} +
+

No budgets yet. Create your first budget to get started.

+
+ {:else} + + {/if} +
+ + {#if showCreateModal} +
{ + if (e.target === e.currentTarget) showCreateModal = false; + }} + > +
+

Create budget

+ +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+
+ + {#if createError} +

{createError}

+ {/if} + +
+ + +
+
+
+ {/if} +{/if} diff --git a/portal/src/routes/budgets/[id]/+page.svelte b/portal/src/routes/budgets/[id]/+page.svelte new file mode 100644 index 0000000..4e1a1b0 --- /dev/null +++ b/portal/src/routes/budgets/[id]/+page.svelte @@ -0,0 +1,347 @@ + + +{#if auth.user} +
+ {#if loading} +
Loading…
+ {:else if error} +
{error}
+ {:else if budget} +
+
+

{budget.name}

+

+ {budget.period} · {budget.style} · {budget.currency} +

+
+
+ {#if budget.currentPeriod} + + {:else} + + {/if} +
+
+ + {#if report} + +
+
+

Allocated

+

+ +

+
+
+

Spent

+

+ +

+
+
+

Remaining

+

+ +

+
+
+ + +
+

Categories

+
+ {#each report.byCategory as item} +
+
+
+ {item.name} + {#if editingCategory === item.categoryId} +
+ + + + +
+ {:else} + + {/if} +
+
+
+
+
+ + Spent: + + + Rem: + +
+
+
+ {/each} +
+
+ {:else} +
+

No active period. Start a period to track spending.

+
+ {/if} + {/if} +
+ + {#if showPeriodModal} +
{ + if (e.target === e.currentTarget) showPeriodModal = false; + }} + > +
+

Start new period

+ +
+ + +
+ +
+

Allocations

+
+ {#each categories as cat} +
+ {cat.name} + +
+ {/each} +
+
+ + {#if periodError} +

{periodError}

+ {/if} + +
+ + +
+
+
+ {/if} +{/if} diff --git a/portal/src/routes/reconciliation/+page.svelte b/portal/src/routes/reconciliation/+page.svelte new file mode 100644 index 0000000..e308667 --- /dev/null +++ b/portal/src/routes/reconciliation/+page.svelte @@ -0,0 +1,222 @@ + + +{#if auth.user} +
+
+
+

Reconciliation

+

Match transactions to bank statements

+
+ +
+ + {#if loading} +
Loading…
+ {:else if error} +
{error}
+ {:else} + {#if reconciliations.length === 0} +
+

No reconciliations yet. Start one to match your statement.

+
+ {:else} + + {/if} + {/if} +
+ + {#if showStartModal} +
{ + if (e.target === e.currentTarget) showStartModal = false; + }} + > +
+

Start reconciliation

+ +
+
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+
+ + {#if startError} +

{startError}

+ {/if} + +
+ + +
+
+
+ {/if} +{/if} diff --git a/portal/src/routes/reconciliation/[id]/+page.svelte b/portal/src/routes/reconciliation/[id]/+page.svelte new file mode 100644 index 0000000..1b5f018 --- /dev/null +++ b/portal/src/routes/reconciliation/[id]/+page.svelte @@ -0,0 +1,274 @@ + + +{#if auth.user} +
+ {#if loading} +
Loading…
+ {:else if error} +
{error}
+ {:else if recon} +
+
+
+

Reconciliation

+

+ {accountName(recon.accountId)} · Statement {formatDate(recon.statementDate)} +

+
+
+ {#if recon.status === 'open'} + + + {:else} + + {recon.status} + + {/if} +
+
+ + +
+
+
+

+ Statement balance: + +

+

+ Included transactions sum:{' '} + +

+
+
+

+ {#if diffAmount === 0} + Balanced ✓ + {:else} + Diff: + {/if} +

+
+
+
+
+ + {#if recon.status === 'open'} +

+ {saving ? 'Saving…' : 'Check transactions that appear on your statement.'} +

+ {/if} + +
+ {#each recon.includedTransactions as txn} +
+ {#if recon.status === 'open'} + toggleTransaction(txn.id, e.currentTarget.checked)} + class="h-4 w-4 rounded border-gray-300 text-blue-600" + /> + {:else} + ✓ + {/if} +
+

{txn.description}

+

+ {formatDate(txn.occurredAt)} + {#if txn.postedAt} + · Posted {formatDate(txn.postedAt)} + {/if} + {#if txn.merchant} + · {txn.merchant} + {/if} +

+
+ + + +
+ {:else} +
+

No transactions included.

+
+ {/each} +
+ {/if} +
+ + {#if showForceConfirm} +
{ + if (e.target === e.currentTarget) showForceConfirm = false; + }} + > +
+

Balance mismatch

+

+ The included transactions do not match the statement balance. Diff:{" "} + +

+

+ Force-complete will mark this reconciliation as done and note the discrepancy. +

+
+ + +
+
+
+ {/if} +{/if} diff --git a/src/BitThicket.Steward.Api/BudgetEndpoints.fs b/src/BitThicket.Steward.Api/BudgetEndpoints.fs index 98b02bc..e666f9c 100644 --- a/src/BitThicket.Steward.Api/BudgetEndpoints.fs +++ b/src/BitThicket.Steward.Api/BudgetEndpoints.fs @@ -212,6 +212,31 @@ module private BudgetHelpers = module BudgetEndpoints = open BudgetHelpers + // GET /api/budgets + let listBudgetsHandler : HttpHandler = fun ctx -> + task { + let budgetRepo = ctx.RequestServices.GetRequiredService() + let periodRepo = ctx.RequestServices.GetRequiredService() + let! budgets = budgetRepo.ListAsync() + let! responses = + budgets + |> List.map (fun budget -> + task { + let! openPeriodOpt = periodRepo.GetOpenPeriodAsync(budget.Id) + let! currentPeriodResp = + match openPeriodOpt with + | None -> Task.FromResult None + | Some period -> + task { + let! allocs = periodRepo.ListAllocationsByPeriodAsync(period.Id) + return Some (periodToResponse period allocs) + } + return budgetToResponse budget currentPeriodResp + }) + |> Task.WhenAll + do! Response.ofJson {| budgets = responses |> Array.toList |} ctx + } + // POST /api/budgets let createBudgetHandler : HttpHandler = fun ctx -> task { diff --git a/src/BitThicket.Steward.Api/Program.fs b/src/BitThicket.Steward.Api/Program.fs index a9d1e67..f7ba2a9 100644 --- a/src/BitThicket.Steward.Api/Program.fs +++ b/src/BitThicket.Steward.Api/Program.fs @@ -930,6 +930,17 @@ wapp.UseRouting() get "/internal/connections/{connectionId:guid}/credentials" (requireServiceToken serviceToken internalConnectionCredentialsHandler) post "/internal/sync-trigger" syncTriggerHandler post "/webhooks/plaid" plaidWebhookHandler + get "/api/categories" (AuthHelpers.requireAuth (fun ctx -> + task { + let categoryRepo = ctx.RequestServices.GetRequiredService() + let! categories = categoryRepo.ListAsync() + let responses = + categories + |> List.map (fun c -> + {| id = c.Id; name = c.Name; color = (None :> string option) |}) + do! Response.ofJson {| categories = responses |} ctx + })) + get "/api/budgets" (AuthHelpers.requireAuth BudgetEndpoints.listBudgetsHandler) post "/api/budgets" (AuthHelpers.requireAuth BudgetEndpoints.createBudgetHandler) get "/api/budgets/{budgetId:guid}" (AuthHelpers.requireAuth (fun ctx -> let budgetId = ctx.Request.RouteValues.["budgetId"] :?> Guid @@ -970,6 +981,7 @@ wapp.UseRouting() get "/api/onboarding" (AuthHelpers.requireAuth getOnboardingHandler) patch "/api/onboarding" (AuthHelpers.requireAuth patchOnboardingHandler) // Reconciliations + get "/api/reconciliations" (AuthHelpers.requireAuth ReconciliationEndpoints.listReconciliationsHandler) post "/api/reconciliations" (AuthHelpers.requireAuth ReconciliationEndpoints.createReconciliationHandler) get "/api/reconciliations/{reconciliationId:guid}" (AuthHelpers.requireAuth (fun ctx -> let id = ctx.Request.RouteValues.["reconciliationId"] :?> Guid diff --git a/src/BitThicket.Steward.Api/ReconciliationEndpoints.fs b/src/BitThicket.Steward.Api/ReconciliationEndpoints.fs index eb8b6ab..61368af 100644 --- a/src/BitThicket.Steward.Api/ReconciliationEndpoints.fs +++ b/src/BitThicket.Steward.Api/ReconciliationEndpoints.fs @@ -108,6 +108,15 @@ module private ReconciliationHelpers = module ReconciliationEndpoints = open ReconciliationHelpers + // GET /api/reconciliations + let listReconciliationsHandler : HttpHandler = fun ctx -> + task { + let repo = ctx.RequestServices.GetRequiredService() + let! recons = repo.ListAsync() + let responses = recons |> List.map reconToResponse + do! Response.ofJson {| reconciliations = responses |} ctx + } + // POST /api/reconciliations let createReconciliationHandler : HttpHandler = fun ctx -> task { diff --git a/src/BitThicket.Steward.Api/ReconciliationRepository.fs b/src/BitThicket.Steward.Api/ReconciliationRepository.fs index b39a40e..cbd7339 100644 --- a/src/BitThicket.Steward.Api/ReconciliationRepository.fs +++ b/src/BitThicket.Steward.Api/ReconciliationRepository.fs @@ -11,6 +11,7 @@ type IReconciliationRepository = abstract CreateAsync : reconciliation:Reconciliation -> Task abstract GetAsync : id:Guid -> Task abstract GetWithTransactionsAsync : id:Guid -> Task<(Reconciliation * Transaction list) option> + abstract ListAsync : unit -> Task abstract ListCandidateTransactionsAsync : accountId:Guid * statementDate:DateOnly -> Task abstract UpdateIncludedTransactionsAsync : id:Guid * included:Guid list * excluded:Guid list -> Task abstract CompleteAsync : id:Guid * force:bool * note:string option -> Task> @@ -95,6 +96,22 @@ module ReconciliationRepository = return if hasRow then Some(mapReconciliation reader) else None } + let listAsync (factory: IDbConnectionFactory) (tenantContext: TenantContext) = + task { + use! conn = factory.OpenForTenantAsync(tenantContext) + use cmd = conn.CreateCommand() + cmd.CommandText <- + """SELECT id, tenant_id, account_id, statement_date, statement_balance_minor, + currency, status, note, created_by_user_id, started_at, completed_at + FROM reconciliations ORDER BY started_at DESC""" + let! reader = cmd.ExecuteReaderAsync() + use reader = reader + let recons = ResizeArray() + while! reader.ReadAsync() do + recons.Add(mapReconciliation reader) + return recons |> Seq.toList + } + let getWithTransactionsAsync (factory: IDbConnectionFactory) (tenantContext: TenantContext) (id: Guid) = task { use! conn = factory.OpenForTenantAsync(tenantContext) @@ -312,6 +329,7 @@ module ReconciliationRepository = member _.CreateAsync(recon) = createAsync factory recon member _.GetAsync(id) = getAsync factory (requireCtx()) id member _.GetWithTransactionsAsync(id) = getWithTransactionsAsync factory (requireCtx()) id + member _.ListAsync() = listAsync factory (requireCtx()) member _.ListCandidateTransactionsAsync(accountId, statementDate) = listCandidateTransactionsAsync factory (requireCtx()) (accountId, statementDate) member _.UpdateIncludedTransactionsAsync(id, included, excluded) = updateIncludedTransactionsAsync factory (requireCtx()) (id, included, excluded) member _.CompleteAsync(id, force, note) = completeAsync factory (requireCtx()) (id, force, note) From 1b5eec8a7daa3b7def002f729c78dd656f1d855d Mon Sep 17 00:00:00 2001 From: Paperclip Date: Mon, 27 Apr 2026 16:00:56 -0500 Subject: [PATCH 2/2] fix(STE-46): return candidateTransactions in GET /api/reconciliations/{id} - Extend ReconciliationWithTransactionsResponse F# DTO with candidateTransactions - Call ListCandidateTransactionsAsync in getReconciliationHandler - Extend ReconciliationWithTransactions TypeScript type with candidateTransactions - Render merged included + candidate transactions as checkable rows on detail page - Reload full recon after toggle to keep includedTransactions/candidates in sync - Update GET test to verify candidateTransactions is populated Fixes H1 from CTO review: new reconciliations now show candidate transactions so users can add them instead of seeing an empty 'No transactions included' state. Co-Authored-By: Paperclip --- portal/src/lib/types.ts | 1 + .../routes/reconciliation/[id]/+page.svelte | 95 +++++++++++-------- .../ReconciliationEndpoints.fs | 3 + .../ReconciliationEndpointsTests.fs | 8 +- 4 files changed, 64 insertions(+), 43 deletions(-) diff --git a/portal/src/lib/types.ts b/portal/src/lib/types.ts index c0ccb2d..89fc120 100644 --- a/portal/src/lib/types.ts +++ b/portal/src/lib/types.ts @@ -184,5 +184,6 @@ export interface Reconciliation { export interface ReconciliationWithTransactions extends Reconciliation { includedTransactions: Transaction[]; + candidateTransactions: Transaction[]; diffMinor: number; } diff --git a/portal/src/routes/reconciliation/[id]/+page.svelte b/portal/src/routes/reconciliation/[id]/+page.svelte index 1b5f018..ac3053c 100644 --- a/portal/src/routes/reconciliation/[id]/+page.svelte +++ b/portal/src/routes/reconciliation/[id]/+page.svelte @@ -10,7 +10,7 @@ listAccounts } from '$lib/api'; import MoneyDisplay from '$lib/MoneyDisplay.svelte'; - import type { ReconciliationWithTransactions, Account } from '$lib/types'; + import type { ReconciliationWithTransactions, Account, Transaction } from '$lib/types'; $effect(() => { if (!auth.user && !auth.loading) { @@ -35,6 +35,21 @@ } }); + function mergeTransactions(included: Transaction[], candidates: Transaction[]): Transaction[] { + const map = new Map(); + for (const t of included) map.set(t.id, t); + for (const t of candidates) map.set(t.id, t); + return Array.from(map.values()).sort((a, b) => { + const aDate = a.postedAt || a.occurredAt; + const bDate = b.postedAt || b.occurredAt; + return new Date(bDate).getTime() - new Date(aDate).getTime(); + }); + } + + const displayTransactions = $derived( + !recon ? [] : mergeTransactions(recon.includedTransactions, recon.candidateTransactions) + ); + async function loadRecon() { loading = true; error = null; @@ -57,15 +72,9 @@ const included = checked ? [txnId] : []; const excluded = checked ? [] : [txnId]; await updateReconciliationTransactions(reconId, { included, excluded }); - if (checked) { - includedIds.add(txnId); - } else { - includedIds.delete(txnId); - } - includedIds = new Set(includedIds); - // Refresh diff const updated = await getReconciliation(reconId); - recon = { ...recon, diffMinor: updated.diffMinor }; + recon = updated; + includedIds = new Set(updated.includedTransactions.map((t) => t.id)); } catch (e) { alert(e instanceof Error ? e.message : 'Failed to update'); } finally { @@ -196,41 +205,43 @@ {/if}
- {#each recon.includedTransactions as txn} -
- {#if recon.status === 'open'} - toggleTransaction(txn.id, e.currentTarget.checked)} - class="h-4 w-4 rounded border-gray-300 text-blue-600" - /> - {:else} - ✓ - {/if} -
-

{txn.description}

-

- {formatDate(txn.occurredAt)} - {#if txn.postedAt} - · Posted {formatDate(txn.postedAt)} - {/if} - {#if txn.merchant} - · {txn.merchant} - {/if} -

-
- - - -
- {:else} + {#if displayTransactions.length === 0}
-

No transactions included.

+

No candidate transactions.

- {/each} + {:else} + {#each displayTransactions as txn} +
+ {#if recon.status === 'open'} + toggleTransaction(txn.id, e.currentTarget.checked)} + class="h-4 w-4 rounded border-gray-300 text-blue-600" + /> + {:else} + ✓ + {/if} +
+

{txn.description}

+

+ {formatDate(txn.occurredAt)} + {#if txn.postedAt} + · Posted {formatDate(txn.postedAt)} + {/if} + {#if txn.merchant} + · {txn.merchant} + {/if} +

+
+ + + +
+ {/each} + {/if}
{/if} diff --git a/src/BitThicket.Steward.Api/ReconciliationEndpoints.fs b/src/BitThicket.Steward.Api/ReconciliationEndpoints.fs index 61368af..fd6268c 100644 --- a/src/BitThicket.Steward.Api/ReconciliationEndpoints.fs +++ b/src/BitThicket.Steward.Api/ReconciliationEndpoints.fs @@ -48,6 +48,7 @@ type ReconciliationWithTransactionsResponse = { startedAt: DateTimeOffset completedAt: DateTimeOffset option includedTransactions: Transaction list + candidateTransactions: Transaction list diffMinor: int64 } @@ -169,6 +170,7 @@ module ReconciliationEndpoints = ctx.Response.StatusCode <- 404 do! Response.ofJson {| error = "Reconciliation not found" |} ctx | Some (recon, txns) -> + let! candidates = repo.ListCandidateTransactionsAsync(recon.AccountId, recon.StatementDate) let diffMinor = computeDiffMinor recon txns let resp = { id = recon.Id @@ -182,6 +184,7 @@ module ReconciliationEndpoints = startedAt = recon.StartedAt completedAt = recon.CompletedAt includedTransactions = txns + candidateTransactions = candidates diffMinor = diffMinor } do! Response.ofJson resp ctx diff --git a/test/BitThicket.Steward.Api.Test/ReconciliationEndpointsTests.fs b/test/BitThicket.Steward.Api.Test/ReconciliationEndpointsTests.fs index c786f5d..b535fde 100644 --- a/test/BitThicket.Steward.Api.Test/ReconciliationEndpointsTests.fs +++ b/test/BitThicket.Steward.Api.Test/ReconciliationEndpointsTests.fs @@ -186,7 +186,7 @@ type ReconciliationEndpointsTests() = } [] - member _.``GET /api/reconciliations/{id} returns reconciliation with diff``() = + member _.``GET /api/reconciliations/{id} returns reconciliation with diff and candidates``() = task { if not (canConnect ()) then return () else @@ -209,6 +209,9 @@ type ReconciliationEndpointsTests() = use seedConn = dataSource.OpenConnection() seedAccount seedConn tenantId userId accountId + let txn1 = Guid.NewGuid() + seedTransaction seedConn tenantId userId accountId txn1 100L (DateTimeOffset(2026, 4, 10, 0, 0, 0, TimeSpan.Zero)) "cleared" + let createCtx = createHttpContextWithAuth factory token setJsonBody createCtx $"{{\"accountId\":\"{accountId}\",\"statementDate\":\"2026-04-15\",\"statementBalanceMinor\":100,\"currency\":\"USD\"}}" do! ReconciliationEndpoints.createReconciliationHandler createCtx @@ -222,6 +225,9 @@ type ReconciliationEndpointsTests() = let doc = readResponseJson getCtx Assert.Equal(reconId.ToString(), doc.RootElement.GetProperty("id").GetString()) test <@ doc.RootElement.GetProperty("diffMinor").GetInt64() = 0L @> + let candidates = doc.RootElement.GetProperty("candidateTransactions").EnumerateArray() |> Seq.toList + test <@ candidates.Length = 1 @> + Assert.Equal(txn1.ToString(), candidates.[0].GetProperty("id").GetString()) } []