fix(reconciliation): Bankavstämning phase 0 — correctness + feedback batch (+ nav IA regrouping) (#879)
* feat(nav): interaction-mode sidebar grouping — Arbeta/Analys/Data/Skatt & bokslut Nav IA redesign phase 0 (dev_docs/nav_ia_redesign.md): same routes, regrouped by what the user is doing. CLAUDE.md restructured around Hard Rules (doc references updated); pending-page explainer removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): correctness + feedback batch for Bankavstämning (phase 0) Engine: fetchAllRows pagination on status/run/RPC fetches (silent 1000-row cap corrupted totals), optimistic-lock guards on manualLink + apply, unlink audit rows attributed to the acting user (was: company UUID), selected_matches partial apply intersected with a fresh match run. View: silent in-place refresh instead of a full-page skeleton per action, checkbox-gated apply with confidence badges (fuzzy unticked) in chunks of 500, honest result toasts, dry-run errors surfaced, ranked per-row picker candidates pinned to the applied date window, currency-correct amounts (bank side in account currency, GL side SEK), voucher links, translated source types, colored differens, dirty-date-filter guard. Discovery: year-end preflight 404 href fixed (/reconciliation/bank never existed), ⌘K palette entry, real links from the transactions page. v1: status registry schema now matches the actual ReconciliationStatus payload, errors documented as a count, false ~0.85-threshold pitfall replaced, route test mocks the real shape. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
59ecaee650
commit
ea236cbcdf
@@ -24,7 +24,7 @@ export async function POST(request: Request) {
|
||||
|
||||
const validation = await validateBody(request, RunReconciliationSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const { date_from, date_to, account_number, dry_run } = validation.data
|
||||
const { date_from, date_to, account_number, dry_run, selected_matches } = validation.data
|
||||
|
||||
const accountNumber = account_number ?? '1930'
|
||||
|
||||
@@ -59,6 +59,10 @@ export async function POST(request: Request) {
|
||||
// a secondary same-currency account must scope strictly to its own id.
|
||||
includeUnassigned: Boolean(cashAccount?.is_primary),
|
||||
dryRun: dry_run ?? false,
|
||||
applyOnly: selected_matches?.map((m) => ({
|
||||
transactionId: m.transaction_id,
|
||||
journalEntryId: m.journal_entry_id,
|
||||
})),
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function POST(request: Request) {
|
||||
if (!validation.success) return validation.response
|
||||
const { transaction_id } = validation.data
|
||||
|
||||
const result = await unlinkReconciliation(supabase, companyId, transaction_id)
|
||||
const result = await unlinkReconciliation(supabase, companyId, transaction_id, user.id)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json({ error: result.error }, { status: 400 })
|
||||
|
||||
@@ -41,16 +41,22 @@ const { runRecMock, statusMock } = vi.hoisted(() => ({
|
||||
},
|
||||
],
|
||||
applied: 1,
|
||||
errors: [],
|
||||
errors: 0,
|
||||
}),
|
||||
// The REAL ReconciliationStatus shape from lib/reconciliation. The mock used
|
||||
// to return the registry's invented shape (matched_transactions, bank_balance,
|
||||
// …), which hid that documented and actual payloads had drifted apart.
|
||||
statusMock: vi.fn().mockResolvedValue({
|
||||
matched_transactions: 100,
|
||||
unmatched_transactions: 5,
|
||||
unmatched_gl_lines: 2,
|
||||
total_unmatched_amount: 1500,
|
||||
bank_balance: 50000,
|
||||
gl_balance: 48500,
|
||||
bank_transaction_total: 48500,
|
||||
gl_1930_balance: 98500,
|
||||
gl_1930_period_movement: 47000,
|
||||
gl_1930_opening_balance: 51500,
|
||||
gl_1930_correction_adjustment: 0,
|
||||
difference: 1500,
|
||||
is_reconciled: false,
|
||||
matched_count: 100,
|
||||
unmatched_transaction_count: 5,
|
||||
unmatched_gl_line_count: 2,
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -261,8 +267,12 @@ describe('GET /reconciliation/bank/status', () => {
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.matched_transactions).toBe(100)
|
||||
expect(body.data.unmatched_transactions).toBe(5)
|
||||
// Passthrough of the lib's ReconciliationStatus — assert the real field
|
||||
// names so a registry/actual drift can never hide behind the mock again.
|
||||
expect(body.data.matched_count).toBe(100)
|
||||
expect(body.data.unmatched_transaction_count).toBe(5)
|
||||
expect(body.data.bank_transaction_total).toBe(48500)
|
||||
expect(body.data.is_reconciled).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects invalid date filter', async () => {
|
||||
|
||||
@@ -55,7 +55,10 @@ const MatchOut = z.object({
|
||||
const RunResponse = z.object({
|
||||
matches: z.array(MatchOut),
|
||||
applied: z.number().int(),
|
||||
errors: z.array(z.string()),
|
||||
// Count of matches that failed to apply (DB error or lost an optimistic-lock
|
||||
// race). Documented as z.array(z.string()) until 2026-07 — the lib has always
|
||||
// returned a number.
|
||||
errors: z.number().int(),
|
||||
})
|
||||
|
||||
registerEndpoint({
|
||||
@@ -73,12 +76,13 @@ registerEndpoint({
|
||||
'date_from / date_to default to the company\'s full bank history if omitted. Specify a window for predictable performance.',
|
||||
'account_number defaults to 1930. Multi-account companies must pass the BAS code of the account they are reconciling (e.g. 1932 for a EUR account), or it silently reconciles 1930.',
|
||||
'Idempotency-Key is mandatory.',
|
||||
'matches.confidence is between 0 and 1; the matcher only applies matches above the internal threshold (currently ~0.85).',
|
||||
'A non-dry run applies EVERY match found, including fuzzy ones at confidence 0.75 — there is no internal confidence threshold. Dry-run first and review matches.confidence before applying.',
|
||||
'The 366-day window bound only applies when BOTH date_from and date_to are set; a single-sided or absent window scans full history.',
|
||||
],
|
||||
example: {
|
||||
request: { date_from: '2026-05-01', date_to: '2026-05-31' },
|
||||
response: {
|
||||
data: { matches: [], applied: 0, errors: [] },
|
||||
data: { matches: [], applied: 0, errors: 0 },
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -12,14 +12,27 @@ import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation'
|
||||
|
||||
// Mirrors ReconciliationStatus from lib/reconciliation/bank-reconciliation.ts —
|
||||
// the handler passes that object straight through. This schema previously
|
||||
// documented a different, invented shape (matched_transactions, bank_balance,
|
||||
// total_unmatched_amount, …) that the endpoint never returned; any client coded
|
||||
// against it read undefined for every field except difference.
|
||||
const StatusResponse = z.object({
|
||||
matched_transactions: z.number().int(),
|
||||
unmatched_transactions: z.number().int(),
|
||||
unmatched_gl_lines: z.number().int(),
|
||||
total_unmatched_amount: z.number(),
|
||||
bank_balance: z.number(),
|
||||
gl_balance: z.number(),
|
||||
/** Sum of bank-feed transactions in the window (the bank side). */
|
||||
bank_transaction_total: z.number(),
|
||||
/** Full ledger balance on the account incl. opening balance — matches the balance sheet. */
|
||||
gl_1930_balance: z.number(),
|
||||
/** Ledger movement excluding opening balance — what `difference` compares against. */
|
||||
gl_1930_period_movement: z.number(),
|
||||
gl_1930_opening_balance: z.number(),
|
||||
/** Net storno/correction activity in the window. Informational; included in the movement. */
|
||||
gl_1930_correction_adjustment: z.number(),
|
||||
/** bank_transaction_total − gl_1930_period_movement. */
|
||||
difference: z.number(),
|
||||
is_reconciled: z.boolean(),
|
||||
matched_count: z.number().int(),
|
||||
unmatched_transaction_count: z.number().int(),
|
||||
unmatched_gl_line_count: z.number().int(),
|
||||
})
|
||||
|
||||
registerEndpoint({
|
||||
@@ -35,18 +48,22 @@ registerEndpoint({
|
||||
'Running the matcher — that\'s POST `/reconciliation/bank/run`. Per-transaction detail — use the transaction list with `?status=unbooked`.',
|
||||
pitfalls: [
|
||||
'A non-zero difference is normal between sync runs (uncleared cheques, in-flight transfers). Investigate only if it persists across reconciliations.',
|
||||
'total_unmatched_amount is the absolute sum — positive even when the unmatched rows include both credits and debits.',
|
||||
'difference compares against gl_1930_period_movement (movement excl. opening balance), NOT gl_1930_balance. Do not display gl_1930_balance next to difference.',
|
||||
'is_reconciled means |difference| < 0.01 for the window — an aggregate check, not a per-transaction guarantee.',
|
||||
],
|
||||
example: {
|
||||
response: {
|
||||
data: {
|
||||
matched_transactions: 142,
|
||||
unmatched_transactions: 3,
|
||||
unmatched_gl_lines: 2,
|
||||
total_unmatched_amount: 1850.0,
|
||||
bank_balance: 50000,
|
||||
gl_balance: 48150,
|
||||
difference: 1850,
|
||||
bank_transaction_total: 48150,
|
||||
gl_1930_balance: 98150,
|
||||
gl_1930_period_movement: 48150,
|
||||
gl_1930_opening_balance: 50000,
|
||||
gl_1930_correction_adjustment: 0,
|
||||
difference: 0,
|
||||
is_reconciled: true,
|
||||
matched_count: 142,
|
||||
unmatched_transaction_count: 3,
|
||||
unmatched_gl_line_count: 2,
|
||||
},
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user