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:
Jakob Wennberg
2026-07-03 11:21:38 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 59ecaee650
commit ea236cbcdf
20 changed files with 1124 additions and 563 deletions
+5 -1
View File
@@ -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({
+1 -1
View File
@@ -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' },
},