From 287828a850e6486900305a6a50db87a4922af034 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Sat, 5 Sep 2026 11:59:59 +0200 Subject: [PATCH] fix(payments): refuse to book a bank row that unlinked vouchers already explain (#2300) * fix(payments): refuse to book a bank row that unlinked vouchers already explain A bank feed can deliver several affarshandelser as one row (a Bankgirot daily aggregate: two customers' invoices, one "BGGIRERING" row with no payer). When each invoice was already marked paid by hand, nothing on the account equals the row, the 1:1 duplicate check passes, and "Dela betalning" books the money a second time against whatever open invoices the user picks (the next period's identical ones, in the reported case). - lib/reconciliation/covering-set.ts: exact ore subset sum over a capped candidate list, smallest set first, closest in date second. - detectExplainingVoucherSet(+ForTransaction): the vouchers whose bank legs on the row's settlement account, in the row's direction, within 7 days, add up exactly to the row; linked through any of the three anchors drops a voucher, a payment row without a bank transaction keeps it. - POST match-batch refuses with BATCH_TX_POSSIBLE_DUPLICATE and returns the set; force=true must echo expected_journal_entry_ids (same binding as the single door). Fails open on a detection error. - GET duplicate-payment-check returns candidate_set next to candidate. - MatchAllocationDialog: pre-flight panel with the vouchers, one click links the row to them through the existing 1:1 or 1:N bank link (no new voucher), "Bokfor anda" acknowledges the set; confirm is disabled until then. Invoices dated after the bank row get a hint badge. - Mark-paid guard: aggregate sweep (row = this invoice + an exact subset of other open invoices, 7 days, kronor) when the name sweeps found nothing; PaymentBookingDialog shows the covered invoice numbers and points to the split under Transaktioner. Follow-ups: #2293 (1:N proposals in the auto-matcher), #2294 (MCP staging guard), #2299 (supplier-side text guard). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NyjeEi1U8vnuPT4QXgayXu * test(invoices): account for the aggregate sweep in the mark-paid route queue The sweep issues one more transactions query whenever the name probes come back empty, so every queued-mock sequence that reaches it gains a slot. The sweep itself now fails open on odd client shapes (a single object for a list query) and on errors: an advisory guard must never block "Markera som betald". Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NyjeEi1U8vnuPT4QXgayXu * fix(payments): fail open on resolved query errors; aggregate sweep without a payer name Review follow-ups on #2300. A PostgREST failure resolves with { data: null, error } instead of throwing, so the set detector read a failed link lookup as "no links" and a failed cash-account lookup as "scan every 19xx account"; both now return null (the booking RPC keeps the last word). The aggregate sweep never needed a customer name (a Bankgirot row names nobody), so a nameless invoice goes straight to it instead of skipping the guard. The already-booked panel is announced as a live region, and the "also covers" string is plural-aware. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NyjeEi1U8vnuPT4QXgayXu --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- DECISIONS.md | 1 + .../[id]/mark-paid/__tests__/route.test.ts | 9 + .../__tests__/route.test.ts | 116 +++++++ .../[id]/duplicate-payment-check/route.ts | 51 ++- .../[id]/match-batch/__tests__/route.test.ts | 139 ++++++++ .../transactions/[id]/match-batch/route.ts | 45 +++ components/invoices/PaymentBookingDialog.tsx | 23 +- .../transactions/MatchAllocationDialog.tsx | 276 ++++++++++++++- lib/api/schemas.ts | 19 + lib/errors/structured-errors.ts | 7 + .../duplicate-payment-candidates.test.ts | 137 +++++++- .../duplicate-payment-detection.test.ts | 275 ++++++++++++++- lib/invoices/duplicate-payment-candidates.ts | 184 +++++++++- lib/invoices/duplicate-payment-detection.ts | 328 ++++++++++++++++++ .../__tests__/covering-set.test.ts | 61 ++++ lib/reconciliation/covering-set.ts | 107 ++++++ messages/en.json | 17 +- messages/sv.json | 17 +- 18 files changed, 1781 insertions(+), 31 deletions(-) create mode 100644 app/api/transactions/[id]/duplicate-payment-check/__tests__/route.test.ts create mode 100644 lib/reconciliation/__tests__/covering-set.test.ts create mode 100644 lib/reconciliation/covering-set.ts diff --git a/DECISIONS.md b/DECISIONS.md index a7115f42..024d2fb6 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1586,6 +1586,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-09-04] company_settings.pays_salaries (and a never-attested employer_registered) now follow the ledger: trigger salary_runs_booked_marks_employer (20260904191000) sets them when a salary run is booked, with a backfill for the 12 companies already booking payroll with the flag at its default. Support case 2026-09-04: the assistant told a payroll-running aktiebolag in every answer that it "betalar inte löner" because lib/agent/ask/snapshot.ts asserted the column default (NOT NULL DEFAULT false, only written by the Skatt settings form) as a fact. The snapshot and the composer's KÄNDA FAKTA now apply the employee-facts doctrine (positive evidence or an attested negative, never the default) and the snapshot names Inställningar > Skatt / > Bokföring so the model can point at the page. Chose a trigger over calling a helper from the booking code because there are three booking writers (lib/salary/book-run.ts, the v1 route's own copy, seeders) and 20260717151000 already treated in-app payroll as employer evidence once as a one-off backfill; the trigger makes that rule continuous. An explicit employer_registered = false stays the user's answer. [2026-09-04] Supplier credit notes rest at 'credited' from creation (one row builder in lib/supplier-invoices/credit-note.ts for the dashboard route, the MCP executor and the v1 API) and a CHECK (supplier_invoices_credit_note_not_payable, 20260904190000) keeps every writer out of the payable states; the worklist attest count also excludes is_credit_note. Support case 2026-09-04: a credit note was inserted at 'registered' (the attest entry state) while the detail page rightly had no attest button for it, so Att göra showed "1 leverantörsfaktura att attestera" that nobody could clear; 14 such rows on prod plus one MCP-approved credit note, all backfilled by the migration (one immutable reset-source row skipped, hence NOT VALID). Chose the DB CHECK over fixing only the three insert literals because the invariant is "a credit note is never a payable", not "this literal says registered"; chose 'credited' over 'approved'+approved_at (would surface Markera betald and the Att betala tab) and over 'paid' (the customer side forbids paid credit notes by CHECK); the provider importers already used 'credited'. Also: the GET route now hydrates credited_original with a second scoped query because PostgREST cannot pick a direction for a self-referencing embed hint and returned the one-to-many side (an empty array), rendered as "Krediterar: Ankomst #" with no number. [2026-09-04] Underlag attach on a folder-picked Fortnox export (Loftux, 50 of 50 files refused with UNDERLAG_REF_MISMATCH): the multipart filename is reduced to its basename at the route boundary (lib/documents/upload-file-name.ts), rather than teaching the voucher-ref parser to strip directories or adding a client-supplied file_name field. Chrome writes webkitRelativePath as the multipart filename for folder selections, so the attach check saw "2026/06/Leverantörsfakturor/A166_x.pdf" while the preview had resolved File.name "A166_x.pdf"; the two endpoints received the same file under two names and the guard compared them. Stripping inside the parser would turn a typed manual ref "2024/01/31" into voucher 31 (the manual box shares the parser), and a second client-supplied name is no more trustworthy than the first, so the boundary is the only level that fixes the class. +[2026-09-04] Bankgirot aggregate double booking (Discord, gecko): the fix generalises the existing 1:1 "already booked" check to a SET of unlinked vouchers (exact öre sum, same account, same direction, ±7 days) and runs it at the two doors that could book the money a second time, instead of blocking "Markera som betald" for bank-connected companies or teaching the batch RPC about vouchers. Rejected: a bank-feed gate on mark-paid (payments outside the feed are legitimate and Fortnox keeps both doors), a name-tokenised text guard (the rows this exists for, "BGGIRERING 03447786", carry no payer at all), and storno-then-rebook as the remedy (the mark-paid vouchers are correct bookkeeping; the truthful representation is one bank row linked to two vouchers, which linkTransactionToVouchers already supports). match-batch now refuses with BATCH_TX_POSSIBLE_DUPLICATE unless force=true echoes the detected ids (same binding as the single door); the dialog links the row to the vouchers through the existing 1:N link. The invoice-side guard gained an aggregate sweep (row = this invoice + an exact subset of other open invoices, ±7 days, kronor only) that runs only when the name sweeps found nothing, so one warning is enough and no extra queries hit the common path. Not folded in: 1:N proposals in the reconciliation auto-matcher and the MCP batch staging (follow-up issues). [2026-09-04] Sandbox seed recovery (#2292) uses a per-anonymous-user database claim and a completion marker written last. A failed or expired attempt archives its exclusively owned demo company and starts a fresh one, preserving partial posted history for normal sandbox expiry instead of reseeding into it. Legacy demos are adopted only when the final payroll links prove completion. All demo vouchers now use the bookkeeping engine; the posting-integrity guards stay unchanged. [2026-09-04] Migrated sales invoices without rows (Profilio 384/384, Loftux 311/672, Damac 182/542, Clearstoq 1 125/1 125): completed by an hourly re-runnable pass (extensions/general/arcim-migration/lib/complete-invoice-lines.ts, cron /api/extensions/arcim-migration/complete-invoice-lines/cron) that starts from OUR row-less invoices, joins them to the provider register on number + date, hydrates only that subset and writes rows once the detail total matches the stored total to the öre; the header VAT split is rewritten only when the stored one holds no evidence (null rate, or 0 kr VAT beside subtotal = total). Why not a bigger in-run budget: the largest register (1 911 invoices at Fortnox's platform-wide 4 req/s) does not fit one 300 s function whatever the split, and a budget-bounded one-shot pass leaves whatever it misses missing forever, silently (the wizard never showed the hydration report; it does now). Why not re-running fetchSalesInvoicesHydrated: it sorts the whole register open-first every time, so a second run re-spends its budget on the same invoices and never reaches the rest. Why not reset + re-import or an arithmetic backfill: reset deletes rows that payments and vouchers already point at, and total/1,25 asserts a rate the source never stated (DECISIONS 2026-08-22). The pass reuses mapSalesInvoice, so a row it writes is indistinguishable from a fully hydrated import; it never touches totals, status, payments or any journal entry (momsdeklaration and every report read the ledger). [2026-09-04] Connector-hop failures (timeout, error envelope, wire-contract mismatch) are transient in every sync path: the row keeps its status and the user message says no renewal is needed, same as AspspUnavailableError (#2202), and the cron now treats AspspUnavailableError the same way instead of parking it in 'error'. Why: on 2026-09-04 the Connect service answered a shape the client rejects and the cron flipped four canary companies to 'error' with SYNC_FAILED_MESSAGE, so users re-authorized consents that were fine. The Zod issues are logged (field paths) because a bare 'unexpected shape' left the failure undiagnosable. Rejected: a new 'degraded' connection status (one more state every filter and the probe would have to learn; the health probe already catches a dead session on the same run) and removing the canary companies from the env (hides the contract bug instead of exposing its field paths). diff --git a/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts index 62ed19e4..af3bb6e4 100644 --- a/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts +++ b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts @@ -165,6 +165,8 @@ describe('POST /api/invoices/[id]/mark-paid', () => { enqueue({ data: [], error: null }) // Duplicate-payment guard: description ILIKE, no candidates enqueue({ data: [], error: null }) + // Duplicate-payment guard: aggregate sweep (larger unbooked kronor rows), none + enqueue({ data: [], error: null }) // Fetch company settings (now before update due to journal-first ordering) enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) // Update invoice status (CAS guard: returns matched row) @@ -235,6 +237,8 @@ describe('POST /api/invoices/[id]/mark-paid', () => { // Duplicate-payment guard: two ILIKE probes, no candidates enqueue({ data: [], error: null }) enqueue({ data: [], error: null }) + // Duplicate-payment guard: aggregate sweep (larger unbooked kronor rows), none + enqueue({ data: [], error: null }) // Company settings enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) // Deliberately NO status-update enqueued: the route must fail closed BEFORE @@ -266,6 +270,8 @@ describe('POST /api/invoices/[id]/mark-paid', () => { enqueue({ data: [], error: null }) // Duplicate-payment guard: description ILIKE, no candidates enqueue({ data: [], error: null }) + // Duplicate-payment guard: aggregate sweep (larger unbooked kronor rows), none + enqueue({ data: [], error: null }) enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null }) // Update invoice status (CAS guard: returns matched row) enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert @@ -875,6 +881,8 @@ describe('POST /api/invoices/[id]/mark-paid', () => { enqueue({ data: [], error: null }) // Duplicate-payment guard: description ILIKE, no candidates enqueue({ data: [], error: null }) + // Duplicate-payment guard: aggregate sweep (larger unbooked kronor rows), none + enqueue({ data: [], error: null }) enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) // Update invoice status (CAS guard: returns matched row) enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert @@ -1153,6 +1161,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => { enqueue({ data: invoice, error: null }) enqueue({ data: [], error: null }) // duplicate guard: merchant_name enqueue({ data: [], error: null }) // duplicate guard: description + enqueue({ data: [], error: null }) // duplicate guard: aggregate sweep enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null }) enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert enqueue({ data: [{ id: 'inv-1' }], error: null }) // CAS update matched diff --git a/app/api/transactions/[id]/duplicate-payment-check/__tests__/route.test.ts b/app/api/transactions/[id]/duplicate-payment-check/__tests__/route.test.ts new file mode 100644 index 00000000..618ac9fe --- /dev/null +++ b/app/api/transactions/[id]/duplicate-payment-check/__tests__/route.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { + createMockRequest, + createMockRouteParams, + parseJsonResponse, + createQueuedMockSupabase, +} from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const { mockDetectOne, mockDetectSet } = vi.hoisted(() => ({ + mockDetectOne: vi.fn(), + mockDetectSet: vi.fn(), +})) +vi.mock('@/lib/invoices/duplicate-payment-detection', () => ({ + detectDuplicatePaymentVoucher: mockDetectOne, + detectExplainingVoucherSetForTransaction: mockDetectSet, +})) + +import { GET } from '../route' + +const TX_UUID = '11111111-1111-4111-8111-111111111111' + +describe('GET /api/transactions/[id]/duplicate-payment-check', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: { id: 'user-1', email: 't@t.se' } } }) + mockDetectOne.mockResolvedValue(null) + mockDetectSet.mockResolvedValue(null) + }) + + it('returns 401 when unauthenticated', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + const response = await GET( + createMockRequest(`/api/transactions/${TX_UUID}/duplicate-payment-check`), + createMockRouteParams({ id: TX_UUID }), + ) + expect(response.status).toBe(401) + }) + + it('returns 404 when the transaction is not in the company', async () => { + enqueue({ data: null, error: { message: 'not found' } }) + const response = await GET( + createMockRequest(`/api/transactions/${TX_UUID}/duplicate-payment-check`), + createMockRouteParams({ id: TX_UUID }), + ) + expect(response.status).toBe(404) + }) + + it('returns both nulls for a row that is already linked, without detecting', async () => { + enqueue({ data: { id: TX_UUID, date: '2026-07-31', amount: 100, currency: 'SEK', journal_entry_id: 'je-live' }, error: null }) + const response = await GET( + createMockRequest(`/api/transactions/${TX_UUID}/duplicate-payment-check`), + createMockRouteParams({ id: TX_UUID }), + ) + const { status, body } = await parseJsonResponse<{ candidate: unknown; candidate_set: unknown }>(response) + expect(status).toBe(200) + expect(body).toEqual({ candidate: null, candidate_set: null }) + expect(mockDetectOne).not.toHaveBeenCalled() + expect(mockDetectSet).not.toHaveBeenCalled() + }) + + it('returns the 1:1 candidate and the explaining set side by side', async () => { + enqueue({ + data: { id: TX_UUID, date: '2026-07-31', amount: 88250, currency: 'SEK', amount_sek: null, exchange_rate: null, journal_entry_id: null, cash_account_id: 'ca-1' }, + error: null, + }) + const set = { + vouchers: [{ journal_entry_id: 'je-a', voucher_label: 'A57', entry_date: '2026-07-31', description: null, source_type: 'invoice_paid', amount: 62500, bank_account_number: '1930' }], + total: 62500, + bank_account_number: '1930', + same_date: true, + } + mockDetectSet.mockResolvedValue(set) + + const response = await GET( + createMockRequest(`/api/transactions/${TX_UUID}/duplicate-payment-check`), + createMockRouteParams({ id: TX_UUID }), + ) + const { status, body } = await parseJsonResponse<{ candidate: unknown; candidate_set: typeof set }>(response) + expect(status).toBe(200) + expect(body.candidate).toBeNull() + expect(body.candidate_set).toEqual(set) + // The row the route already holds is handed over: no second transactions fetch. + expect(mockDetectSet).toHaveBeenCalledWith( + mockSupabase, + 'company-1', + expect.objectContaining({ id: TX_UUID, amount: 88250, cash_account_id: 'ca-1', journal_entry_id: null }), + ) + }) + + it('fails open per detector: a throwing set detector still returns the 1:1 candidate', async () => { + enqueue({ data: { id: TX_UUID, date: '2026-07-31', amount: 100, currency: 'SEK', journal_entry_id: null }, error: null }) + const candidate = { journal_entry_id: 'je-1', voucher_label: 'A1', entry_date: '2026-07-31', description: null, amount: 100, bank_account_number: '1930', reason: 'exact_amount_same_date', amount_verified: true, unverified_reason: null } + mockDetectOne.mockResolvedValue(candidate) + mockDetectSet.mockRejectedValue(new Error('boom')) + + const response = await GET( + createMockRequest(`/api/transactions/${TX_UUID}/duplicate-payment-check`), + createMockRouteParams({ id: TX_UUID }), + ) + const { status, body } = await parseJsonResponse<{ candidate: unknown; candidate_set: unknown }>(response) + expect(status).toBe(200) + expect(body.candidate).toEqual(candidate) + expect(body.candidate_set).toBeNull() + }) +}) diff --git a/app/api/transactions/[id]/duplicate-payment-check/route.ts b/app/api/transactions/[id]/duplicate-payment-check/route.ts index b91241a5..0426d104 100644 --- a/app/api/transactions/[id]/duplicate-payment-check/route.ts +++ b/app/api/transactions/[id]/duplicate-payment-check/route.ts @@ -1,18 +1,24 @@ /** * GET /api/transactions/[id]/duplicate-payment-check * - * Proactive check used by the InvoiceMatchDialog: returns the candidate - * verifikation that already books this bank transaction, or null if no - * duplicate is detected. Lets the UI display the warning panel without + * Proactive check used by the InvoiceMatchDialog and MatchAllocationDialog: + * returns the candidate verifikation that already books this bank + * transaction (`candidate`, 1:1, or null), and the set of one or more posted + * unlinked vouchers whose bank legs add up exactly to the row + * (`candidate_set`, or null). Lets the UI display the warning panel without * needing to first submit a doomed match. * - * Same detector as the match-invoice route's pre-flight, so what you see - * here matches what the POST would refuse. + * Same detectors as the match-invoice and match-batch pre-flights, so what + * you see here matches what the POSTs would refuse. */ import { NextResponse } from 'next/server' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' -import { detectDuplicatePaymentVoucher } from '@/lib/invoices/duplicate-payment-detection' +import { + detectDuplicatePaymentVoucher, + detectExplainingVoucherSetForTransaction, + type ExplainingVoucherSet, +} from '@/lib/invoices/duplicate-payment-detection' export const GET = withRouteContext( 'transaction.duplicate_payment_check', @@ -35,7 +41,7 @@ export const GET = withRouteContext( // exactly how this guard would go dead on FX rows. const { data: transaction, error } = await supabase .from('transactions') - .select('id, date, amount, currency, amount_sek, exchange_rate, journal_entry_id') + .select('id, date, amount, currency, amount_sek, exchange_rate, journal_entry_id, cash_account_id') .eq('id', transactionId) .eq('company_id', companyId) .single() @@ -46,11 +52,15 @@ export const GET = withRouteContext( // Already linked → no possible duplicate to surface. if (transaction.journal_entry_id) { - return NextResponse.json({ candidate: null }) + return NextResponse.json({ candidate: null, candidate_set: null }) } + // Both detectors fail open: returning null preserves current UX. The + // POSTs still run their own checks, so a missed pre-flight doesn't allow + // a duplicate booking. + let candidate: Awaited> = null try { - const candidate = await detectDuplicatePaymentVoucher(supabase, { + candidate = await detectDuplicatePaymentVoucher(supabase, { companyId: companyId!, transactionId, transactionDate: transaction.date, @@ -59,13 +69,26 @@ export const GET = withRouteContext( transactionAmountSek: transaction.amount_sek ?? null, transactionExchangeRate: transaction.exchange_rate ?? null, }) - return NextResponse.json({ candidate }) } catch (err) { log.warn('duplicate-payment-voucher detection failed', err as Error) - // Fail-open: returning null preserves current UX. The POST still - // runs its own check, so a missed pre-flight doesn't allow a - // duplicate booking. - return NextResponse.json({ candidate: null }) } + + let candidateSet: ExplainingVoucherSet | null = null + try { + candidateSet = await detectExplainingVoucherSetForTransaction(supabase, companyId!, { + id: transaction.id, + date: transaction.date, + amount: transaction.amount, + currency: transaction.currency ?? null, + amount_sek: transaction.amount_sek ?? null, + exchange_rate: transaction.exchange_rate ?? null, + cash_account_id: transaction.cash_account_id ?? null, + journal_entry_id: null, + }) + } catch (err) { + log.warn('explaining-voucher-set detection failed', err as Error) + } + + return NextResponse.json({ candidate, candidate_set: candidateSet }) }, ) diff --git a/app/api/transactions/[id]/match-batch/__tests__/route.test.ts b/app/api/transactions/[id]/match-batch/__tests__/route.test.ts index 7ff81d53..fb6e8335 100644 --- a/app/api/transactions/[id]/match-batch/__tests__/route.test.ts +++ b/app/api/transactions/[id]/match-batch/__tests__/route.test.ts @@ -28,6 +28,15 @@ vi.mock('@/lib/invoices/clear-settled-invoice-suggestions', () => ({ clearSettledInvoiceSuggestions: mockClearSuggestions, })) +// The already-explained guard (BATCH_TX_POSSIBLE_DUPLICATE) runs before the +// RPC. Mocked so it consumes no slot in the queued Supabase mock; the +// detector's own query shape is pinned by +// lib/invoices/__tests__/duplicate-payment-detection.test.ts. +const { mockDetectExplaining } = vi.hoisted(() => ({ mockDetectExplaining: vi.fn() })) +vi.mock('@/lib/invoices/duplicate-payment-detection', () => ({ + detectExplainingVoucherSetForTransaction: mockDetectExplaining, +})) + vi.mock('@/lib/company/context', () => ({ requireCompanyId: vi.fn().mockResolvedValue('company-1'), getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), @@ -51,6 +60,7 @@ describe('POST /api/transactions/[id]/match-batch', () => { vi.clearAllMocks() reset() mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + mockDetectExplaining.mockResolvedValue(null) }) it('returns 400 when allocations is missing', async () => { @@ -257,3 +267,132 @@ describe('POST /api/transactions/[id]/match-batch', () => { expect(mockSupabase.rpc).not.toHaveBeenCalled() }) }) + +describe('POST /api/transactions/[id]/match-batch: already-explained guard', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + const JE_A = '55555555-5555-4555-8555-555555555555' + const JE_B = '66666666-6666-4666-8666-666666666666' + const explainingSet = { + vouchers: [ + { journal_entry_id: JE_A, voucher_label: 'A57', entry_date: '2026-07-31', description: 'Inbetalning kundfaktura 063', source_type: 'invoice_paid', amount: 62500, bank_account_number: '1930' }, + { journal_entry_id: JE_B, voucher_label: 'A58', entry_date: '2026-07-31', description: 'Inbetalning kundfaktura 064', source_type: 'invoice_paid', amount: 25750, bank_account_number: '1930' }, + ], + total: 88250, + bank_account_number: '1930', + same_date: true, + } + + function enqueueHappyRpc() { + enqueue({ data: [{ id: INV_UUID, document_type: 'invoice' }], error: null }) + enqueue({ + data: { + ok: true, + journal_entry_id: 'je-batch-9', + voucher_series: 'A', + voucher_number: 59, + tx_id: TX_UUID, + allocations: [ + { kind: 'customer_invoice', invoice_id: INV_UUID, payment_id: 'ip-9', status: 'paid', paid_amount: 88250, remaining_amount: 0, amount: 88250 }, + ], + total_allocated: 88250, + leftover: 0, + }, + error: null, + }) + enqueue({ data: { id: TX_UUID, amount: 88250, currency: 'SEK' }, error: null }) + enqueue({ data: { id: INV_UUID, currency: 'SEK', status: 'paid' }, error: null }) + } + + beforeEach(() => { + vi.clearAllMocks() + reset() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + mockDetectExplaining.mockResolvedValue(null) + }) + + it('refuses with 409 and the vouchers when unlinked vouchers already sum to the row', async () => { + mockDetectExplaining.mockResolvedValue(explainingSet) + // document_type pre-check runs before the guard. + enqueue({ data: [{ id: INV_UUID, document_type: 'invoice' }], error: null }) + + const request = createMockRequest(`/api/transactions/${TX_UUID}/match-batch`, { + method: 'POST', + body: { allocations: [{ kind: 'customer_invoice', invoice_id: INV_UUID, amount: 88250 }] }, + }) + const response = await POST(request, createMockRouteParams({ id: TX_UUID })) + const { status, body } = await parseJsonResponse<{ + error: { code: string; details: { vouchers: Array<{ voucher_label: string }>; total: number; force_rejected: boolean } } + }>(response) + + expect(status).toBe(409) + expect(body.error.code).toBe('BATCH_TX_POSSIBLE_DUPLICATE') + expect(body.error.details.vouchers.map((v) => v.voucher_label)).toEqual(['A57', 'A58']) + expect(body.error.details.total).toBe(88250) + expect(body.error.details.force_rejected).toBe(false) + expect(mockDetectExplaining).toHaveBeenCalledWith(mockSupabase, 'company-1', TX_UUID) + // The RPC was never reached. + expect(mockSupabase.rpc).not.toHaveBeenCalled() + }) + + it('books anyway when force=true echoes exactly the reviewed voucher ids', async () => { + mockDetectExplaining.mockResolvedValue(explainingSet) + enqueueHappyRpc() + + const request = createMockRequest(`/api/transactions/${TX_UUID}/match-batch`, { + method: 'POST', + body: { + allocations: [{ kind: 'customer_invoice', invoice_id: INV_UUID, amount: 88250 }], + force: true, + // Order must not matter. + expected_journal_entry_ids: [JE_B, JE_A], + }, + }) + const response = await POST(request, createMockRouteParams({ id: TX_UUID })) + expect(response.status).toBe(200) + expect(mockSupabase.rpc).toHaveBeenCalledTimes(1) + }) + + it('refuses force=true whose ids do not match the set it re-detects', async () => { + mockDetectExplaining.mockResolvedValue(explainingSet) + enqueue({ data: [{ id: INV_UUID, document_type: 'invoice' }], error: null }) + + const request = createMockRequest(`/api/transactions/${TX_UUID}/match-batch`, { + method: 'POST', + body: { + allocations: [{ kind: 'customer_invoice', invoice_id: INV_UUID, amount: 88250 }], + force: true, + expected_journal_entry_ids: [JE_A], + }, + }) + const response = await POST(request, createMockRouteParams({ id: TX_UUID })) + const { status, body } = await parseJsonResponse<{ error: { code: string; details: { force_rejected: boolean } } }>(response) + expect(status).toBe(409) + expect(body.error.code).toBe('BATCH_TX_POSSIBLE_DUPLICATE') + expect(body.error.details.force_rejected).toBe(true) + expect(mockSupabase.rpc).not.toHaveBeenCalled() + }) + + it('rejects force=true without expected_journal_entry_ids at the schema (400)', async () => { + const request = createMockRequest(`/api/transactions/${TX_UUID}/match-batch`, { + method: 'POST', + body: { + allocations: [{ kind: 'customer_invoice', invoice_id: INV_UUID, amount: 88250 }], + force: true, + }, + }) + const response = await POST(request, createMockRouteParams({ id: TX_UUID })) + expect(response.status).toBe(400) + }) + + it('fails open when the detector throws: the RPC still decides', async () => { + mockDetectExplaining.mockRejectedValue(new Error('ledger scan timed out')) + enqueueHappyRpc() + + const request = createMockRequest(`/api/transactions/${TX_UUID}/match-batch`, { + method: 'POST', + body: { allocations: [{ kind: 'customer_invoice', invoice_id: INV_UUID, amount: 88250 }] }, + }) + const response = await POST(request, createMockRouteParams({ id: TX_UUID })) + expect(response.status).toBe(200) + }) +}) diff --git a/app/api/transactions/[id]/match-batch/route.ts b/app/api/transactions/[id]/match-batch/route.ts index 3961183a..805c18a8 100644 --- a/app/api/transactions/[id]/match-batch/route.ts +++ b/app/api/transactions/[id]/match-batch/route.ts @@ -5,6 +5,7 @@ import { MatchBatchSchema } from '@/lib/api/schemas' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' import { eventBus } from '@/lib/events/bus' import { clearSettledBatchAllocationSuggestions } from '@/lib/invoices/clear-settled-batch-allocations' +import { detectExplainingVoucherSetForTransaction } from '@/lib/invoices/duplicate-payment-detection' import { ensureInitialized } from '@/lib/init' import type { Invoice, SupplierInvoice, Transaction } from '@/types' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' @@ -102,6 +103,50 @@ export const POST = withRouteContext( } } + // Already-explained guard. A bank feed can deliver several affärshändelser + // as ONE row (a Bankgirot daily aggregate covering two customers' + // invoices), and each may already be booked on its own via "Markera som + // betald". The RPC only knows the invoices in the request: it correctly + // refuses the PAID ones, and then books the money a second time against + // whatever open invoices the user picked (the next period's identical + // ones, in the case that prompted this). The vouchers that explain the + // row are on the ledger, so refuse here and hand them back; the dialog + // links the row to them (1:N, /api/reconciliation/bank/link) instead of + // creating a new voucher. Fail-open on a detection error: the guard is + // advisory, the RPC remains the atomicity boundary. + let explaining: Awaited> = null + try { + explaining = await detectExplainingVoucherSetForTransaction(supabase, companyId!, transactionId) + } catch (err) { + txLog.warn('match-batch: explaining-voucher detection failed', err as Error) + } + if (explaining) { + const detectedIds = explaining.vouchers.map((v) => v.journal_entry_id).sort() + const expectedIds = [...(validation.data.expected_journal_entry_ids ?? [])].sort() + const acknowledged = + validation.data.force === true && + detectedIds.length === expectedIds.length && + detectedIds.every((id, i) => id === expectedIds[i]) + if (!acknowledged) { + return errorResponseFromCode('BATCH_TX_POSSIBLE_DUPLICATE', txLog, { + requestId, + details: { + vouchers: explaining.vouchers, + total: explaining.total, + bank_account_number: explaining.bank_account_number, + same_date: explaining.same_date, + // force=true with a stale or missing set: the caller must re-read. + force_rejected: validation.data.force === true, + }, + }) + } + txLog.warn('match-batch: already-explained guard bypassed', { + reason: 'force=true', + journalEntryIds: detectedIds, + userId: user.id, + }) + } + const { data, error } = await supabase.rpc('match_batch_allocate', { p_tx_id: transactionId, p_allocations: validation.data.allocations, diff --git a/components/invoices/PaymentBookingDialog.tsx b/components/invoices/PaymentBookingDialog.tsx index d54eaaa6..26a194d2 100644 --- a/components/invoices/PaymentBookingDialog.tsx +++ b/components/invoices/PaymentBookingDialog.tsx @@ -30,7 +30,7 @@ import type { EntityType } from '@/types' import type { InvoiceWithRelations } from '@/components/invoices/types' import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client' -type DuplicateMatchReason = 'ocr_exact' | 'name_amount_fuzzy' | 'amount_only' +type DuplicateMatchReason = 'ocr_exact' | 'name_amount_fuzzy' | 'amount_only' | 'aggregate_exact' interface DuplicateCandidate { id: string @@ -41,6 +41,8 @@ interface DuplicateCandidate { reference: string | null match_reason: DuplicateMatchReason match_confidence: number + /** aggregate_exact: the other open invoices the bank row also covers. */ + aggregate_invoice_numbers?: string[] } interface PaymentBookingDialogProps { @@ -67,6 +69,7 @@ export default function PaymentBookingDialog({ ocr_exact: t('match_reason_ocr_exact'), name_amount_fuzzy: t('match_reason_name_amount_fuzzy'), amount_only: t('match_reason_amount_only'), + aggregate_exact: t('match_reason_aggregate_exact'), } // Session-cached reference data (lib/reference-data), seeded by the @@ -363,11 +366,13 @@ export default function PaymentBookingDialog({
    {duplicateCandidates.map((c) => { const reasonVariant: 'success' | 'secondary' | 'outline' = - c.match_reason === 'ocr_exact' + c.match_reason === 'ocr_exact' || c.match_reason === 'aggregate_exact' ? 'success' : c.match_reason === 'name_amount_fuzzy' ? 'secondary' : 'outline' + const isAggregate = + c.match_reason === 'aggregate_exact' && (c.aggregate_invoice_numbers?.length ?? 0) > 0 return (
  • {c.merchant_name || c.description || '-'}

    + {/* A Bankgirot aggregate: the row also settles other + invoices, so the remedy is the split under + Transaktioner (one samlingsverifikation, row linked), + never marking the invoices paid one by one. */} + {isAggregate && ( +

    + {t('aggregate_covers', { + count: c.aggregate_invoice_numbers!.length, + numbers: c.aggregate_invoice_numbers!.join(', '), + })} +

    + )}
  • ) diff --git a/components/transactions/MatchAllocationDialog.tsx b/components/transactions/MatchAllocationDialog.tsx index 0d074bd8..6e1e251d 100644 --- a/components/transactions/MatchAllocationDialog.tsx +++ b/components/transactions/MatchAllocationDialog.tsx @@ -19,7 +19,7 @@ import { Badge } from '@/components/ui/badge' import { useToast } from '@/components/ui/use-toast' import { getErrorMessage } from '@/lib/errors/get-error-message' import { formatCurrency, formatDate, cn, isValidExchangeRate } from '@/lib/utils' -import { Loader2, Search, X, Plus, Check, AlertTriangle } from 'lucide-react' +import { Loader2, Search, X, Plus, Check, AlertTriangle, Link2 } from 'lucide-react' import type { Invoice, Customer, SupplierInvoice, Supplier } from '@/types' import type { TransactionWithInvoice } from './transaction-types' @@ -51,6 +51,43 @@ interface AllocationCandidate { currency: string exchangeRate: number | null dueDate: string + /** Invoice date: an invoice issued AFTER the bank row cannot normally be what it paid. */ + invoiceDate: string | null +} + +/** + * Mirror of ExplainingVoucherSet (lib/invoices/duplicate-payment-detection.ts): + * the posted, unlinked vouchers whose bank legs add up exactly to this row. + * Served by GET /api/transactions/[id]/duplicate-payment-check and by the + * BATCH_TX_POSSIBLE_DUPLICATE refusal of POST match-batch. + */ +interface ExplainingVoucher { + journal_entry_id: string + voucher_label: string + entry_date: string + description: string | null + source_type: string | null + amount: number + bank_account_number: string +} + +interface ExplainingSet { + vouchers: ExplainingVoucher[] + total: number + bank_account_number: string + same_date: boolean +} + +function readExplainingSet(value: unknown): ExplainingSet | null { + if (!value || typeof value !== 'object') return null + const v = value as Partial + if (!Array.isArray(v.vouchers) || v.vouchers.length === 0) return null + return { + vouchers: v.vouchers, + total: Number(v.total ?? 0), + bank_account_number: v.bank_account_number ?? v.vouchers[0].bank_account_number, + same_date: v.same_date === true, + } } type AllocationDraft = { @@ -95,6 +132,13 @@ export default function MatchAllocationDialog({ const [search, setSearch] = useState('') const [drafts, setDrafts] = useState>({}) const [submitting, setSubmitting] = useState(false) + // Already-explained guard: the vouchers that already book this row, if any. + // Set from the pre-flight on open, or from the route's 409 on submit. The + // user either links the row to them (no new voucher) or acknowledges the + // set, which is what lets the confirm through with force=true. + const [explaining, setExplaining] = useState(null) + const [explainingAcknowledged, setExplainingAcknowledged] = useState(false) + const [linking, setLinking] = useState(false) useEffect(() => { if (!open || !transaction || !company) return @@ -129,6 +173,7 @@ export default function MatchAllocationDialog({ currency: r.currency, exchangeRate: r.exchange_rate != null ? Number(r.exchange_rate) : null, dueDate: r.due_date, + invoiceDate: r.invoice_date ?? null, })), ) } else { @@ -152,6 +197,7 @@ export default function MatchAllocationDialog({ currency: r.currency, exchangeRate: r.exchange_rate != null ? Number(r.exchange_rate) : null, dueDate: r.due_date, + invoiceDate: r.invoice_date ?? null, })), ) } @@ -165,16 +211,46 @@ export default function MatchAllocationDialog({ } }, [open, transaction, company, kind, supabase, t]) + // Pre-flight: does the ledger already explain this row? Same detector the + // route refuses with, so the panel shows before a doomed submit. Fail-open: + // a failed pre-flight only means the route's own check does the refusing. + useEffect(() => { + if (!open || !transaction) return + let cancelled = false + async function check() { + try { + const res = await fetch(`/api/transactions/${transaction!.id}/duplicate-payment-check`) + if (!res.ok) return + const json = (await res.json()) as { candidate_set?: unknown } + if (!cancelled) setExplaining(readExplainingSet(json.candidate_set)) + } catch { + // Pre-flight is advisory; the POST guard still runs. + } + } + void check() + return () => { + cancelled = true + } + }, [open, transaction]) + // Reset state every time the dialog re-opens for a new tx. useEffect(() => { if (!open) { setDrafts({}) setSearch('') + setExplaining(null) + setExplainingAcknowledged(false) } }, [open]) const txAmountAbs = transaction ? Math.abs(transaction.amount) : 0 const txCurrency = transaction?.currency ?? 'SEK' + // The explaining set is stated in SEK and the 1:N link slices are stated in + // the row's currency, so the one-click link is only offered for kronor rows; + // a foreign row is pointed to the reconciliation view instead. + const explainingLinkable = !!explaining && txCurrency === 'SEK' + const explainingBlocks = !!explaining && !explainingAcknowledged + const explainingLabels = explaining ? explaining.vouchers.map((v) => v.voucher_label).join(' + ') : '' // Each draft's `amount` is the allocation in TRANSACTION currency (SEK // for a Swedish bank import). For cross-currency invoices the FX @@ -298,11 +374,34 @@ export default function MatchAllocationDialog({ const response = await fetch(`/api/transactions/${transaction.id}/match-batch`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ allocations }), + body: JSON.stringify({ + allocations, + // Acknowledged set: echo its ids so the route can verify that what + // the user overrode is what it still detects. + ...(explaining && explainingAcknowledged + ? { + force: true, + expected_journal_entry_ids: explaining.vouchers.map((v) => v.journal_entry_id), + } + : {}), + }), }) if (!response.ok) { const body = await response.json().catch(() => null) + const code = (body as { error?: { code?: string; details?: unknown } } | null)?.error?.code + if (code === 'BATCH_TX_POSSIBLE_DUPLICATE') { + // The pre-flight missed it (or the set changed since): show the + // vouchers instead of an error toast and let the user decide. + const set = readExplainingSet( + (body as { error?: { details?: unknown } }).error?.details, + ) + if (set) { + setExplaining(set) + setExplainingAcknowledged(false) + return + } + } toast({ title: t('error_submit_title'), description: getErrorMessage(body, { @@ -334,6 +433,65 @@ export default function MatchAllocationDialog({ } } + /** + * Link the row to the vouchers that already book it. One voucher goes + * through the 1:1 link, several through the 1:N split + * (linkTransactionToVouchers): slices carry the row's sign and each + * voucher's SEK bank leg, which the engine checks against the voucher's + * line and against the row total. No new verifikat is created. + */ + async function handleLinkToExplaining() { + if (!transaction || !explaining || !explainingLinkable) return + setLinking(true) + try { + const sign = transaction.amount > 0 ? 1 : -1 + const body = + explaining.vouchers.length === 1 + ? { + transaction_id: transaction.id, + journal_entry_id: explaining.vouchers[0].journal_entry_id, + account_number: explaining.bank_account_number, + } + : { + transaction_id: transaction.id, + account_number: explaining.bank_account_number, + allocations: explaining.vouchers.map((v) => ({ + journal_entry_id: v.journal_entry_id, + amount: round2(sign * v.amount), + })), + } + const res = await fetch('/api/reconciliation/bank/link', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + const json = await res.json().catch(() => null) + if (!res.ok || (json as { error?: unknown } | null)?.error) { + toast({ + title: t('already_booked_link_failed'), + description: getErrorMessage(json, { context: 'transaction', statusCode: res.status }), + variant: 'destructive', + }) + return + } + toast({ + title: t('already_booked_link_success_title'), + description: t('already_booked_link_success_description', { labels: explainingLabels }), + variant: 'success', + }) + onSuccess() + onOpenChange(false) + } catch (err) { + toast({ + title: t('already_booked_link_failed'), + description: getErrorMessage(err, { context: 'transaction' }), + variant: 'destructive', + }) + } finally { + setLinking(false) + } + } + if (!transaction) return null return ( @@ -369,6 +527,108 @@ export default function MatchAllocationDialog({ + {/* Already-explained guard: the ledger already books this row. + Shown first, before any invoice can be picked: the mistake this + prevents is picking the next period's identical invoices for a + row whose payment was already booked by hand. */} + {explaining && ( +
    +
    + +
    +

    + {explainingAcknowledged + ? t('already_booked_acknowledged_title') + : t('already_booked_title')} +

    + {!explainingAcknowledged && ( +

    + {t( + transaction.amount > 0 ? 'already_booked_body_in' : 'already_booked_body_out', + { + amount: formatCurrency(explaining.total, 'SEK'), + count: explaining.vouchers.length, + }, + )} +

    + )} +
    +
    + {!explainingAcknowledged && ( +
      + {explaining.vouchers.map((v) => ( +
    • +
      +
      + {v.voucher_label} + + {formatDate(v.entry_date)} + +
      + {v.description && ( +

      {v.description}

      + )} +
      + + {formatCurrency(v.amount, 'SEK')} + +
    • + ))} +
    + )} + {!explainingAcknowledged && ( +
    + {explainingLinkable ? ( + + ) : ( +

    + {t('already_booked_foreign_hint', { currency: txCurrency })} +

    + )} + +
    + )} + {explainingAcknowledged && ( +

    + {t('already_booked_acknowledged_note', { labels: explainingLabels })} +

    + )} +
    + )} + {/* Search */}
    @@ -424,6 +684,16 @@ export default function MatchAllocationDialog({ amount: formatCurrency(c.remaining, c.currency), })}

    + {/* Money that arrived before the invoice existed rarely + paid it: the next period's identical invoice is the + classic wrong pick when the real one is already + settled. A hint, not a block: prepayments exist. */} + {c.invoiceDate && c.invoiceDate > transaction.date && ( + + + {t('invoiced_after_payment_badge', { date: formatDate(c.invoiceDate) })} + + )}
    {isSelected ? (
    @@ -537,7 +807,7 @@ export default function MatchAllocationDialog({ // Confirm requires sum == tx_abs exactly (within rounding). // Anything else lets the JE diverge from the bank line and // breaks reconciliation. PR #607 round-1 review fix. - disabled={submitting || !balanced || overshoot} + disabled={submitting || linking || !balanced || overshoot || explainingBlocks} > {submitting && } {t('confirm')} diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 9a974287..2478acd3 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -2096,8 +2096,27 @@ export const MatchBatchSchema = z // (PR #603 compliance review, OWASP V4.2). Domain-appropriate ceiling: // a real samlingsverifikat rarely covers more than a few dozen invoices. .max(100, 'At most 100 allocations per batch'), + // Bypass the already-explained guard (BATCH_TX_POSSIBLE_DUPLICATE): the + // bank row is fully covered by one or more posted, unlinked vouchers on + // its settlement account (an invoice marked paid by hand, a salary + // voucher per employee). Set only after the user has seen those vouchers + // and decided the row is a separate event. + force: z.boolean().optional(), + // Required whenever force=true: the journal_entry_ids of the set the + // user reviewed. The route re-detects the set and refuses force unless + // the ids match, so an automation cannot sweep through force=true + // without ever consulting the vouchers (same binding as + // MatchInvoiceSchema.expected_journal_entry_id). + expected_journal_entry_ids: z.array(uuid).max(10).optional(), }) .superRefine((data, ctx) => { + if (data.force && !(data.expected_journal_entry_ids?.length)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['expected_journal_entry_ids'], + message: 'expected_journal_entry_ids is required when force=true', + }) + } // Reject mixed customer + supplier in a single batch: semantically a // single bank transfer settles invoices on one side. The RPC also guards // this with BATCH_MIXED_KINDS_UNSUPPORTED, but rejecting at the schema diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index cd77dac3..e6d6ad20 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -3644,6 +3644,13 @@ const MATCH_BATCH: Record = { message_en: 'Transaction is already booked. Reverse the existing journal entry before re-allocating.', }, + BATCH_TX_POSSIBLE_DUPLICATE: { + httpStatus: 409, + message_sv: + 'Transaktionen ser redan ut att vara bokförd: en eller flera verifikationer utan bankkoppling summerar exakt till beloppet. Koppla transaktionen till dem i stället, eller bokför ändå om de inte hör ihop.', + message_en: + 'The transaction already looks booked: one or more posted vouchers with no bank link add up exactly to its amount. Link the transaction to them instead, or pass force=true with expected_journal_entry_ids to book anyway.', + }, BATCH_TX_ZERO_AMOUNT: { httpStatus: 400, message_sv: 'Transaktioner med beloppet 0 kan inte bokföras.', diff --git a/lib/invoices/__tests__/duplicate-payment-candidates.test.ts b/lib/invoices/__tests__/duplicate-payment-candidates.test.ts index 1b93e6f1..97c56499 100644 --- a/lib/invoices/__tests__/duplicate-payment-candidates.test.ts +++ b/lib/invoices/__tests__/duplicate-payment-candidates.test.ts @@ -234,8 +234,25 @@ describe('findDuplicatePaymentCandidatesForInvoice', () => { expect(warn).not.toHaveBeenCalled() }) - it('returns nothing when the invoice has no customer name', async () => { - const { supabase, queries } = createRecordingSupabase([]) + it('runs the aggregate sweep for a nameless SEK invoice: a Bankgirot row names nobody anyway', async () => { + const { supabase, queries } = createRecordingSupabase([ + [{ id: 'tx-bg', date: '2026-07-31', amount: 88250, description: 'BGGIRERING 03447786', merchant_name: null, reference: null }], + [{ id: 'inv-064', invoice_number: '064', remaining_amount: 25750, total: 25750, due_date: '2026-07-31' }], + ]) + const candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, { + companyId: 'company-1', + invoice: { ...sekInvoice, invoice_number: '063', customer_name: null, total: 62500, total_sek: 62500 }, + paymentAmount: 62500, + paymentDate: '2026-07-31', + }) + // No name sweeps at all: straight to the two aggregate queries. + expect(queries).toHaveLength(2) + expect(queries[0].gt).toContainEqual(['amount', 62500]) + expect(candidates.map((c) => c.match_reason)).toEqual(['aggregate_exact']) + }) + + it('skips the name sweeps when the invoice has no customer name; only the aggregate row sweep runs', async () => { + const { supabase, queries } = createRecordingSupabase([[]]) const candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, { companyId: 'company-1', invoice: { ...sekInvoice, customer_name: null }, @@ -243,6 +260,120 @@ describe('findDuplicatePaymentCandidatesForInvoice', () => { paymentDate: '2026-05-10', }) expect(candidates).toEqual([]) - expect(queries).toHaveLength(0) + // No ILIKE probe without a name; the aggregate row sweep found nothing and stopped. + expect(queries).toHaveLength(1) + expect(queries[0].ilike).toBeUndefined() + }) +}) + +describe('findDuplicatePaymentCandidatesForInvoice: Bankgirot aggregate rows', () => { + const invoice063 = { ...sekInvoice, invoice_number: '063', customer_name: 'Twelve Football AB', total: 62500, total_sek: 62500 } + + function aggregateRow(over: Partial> = {}) { + return { + id: 'tx-bg', + date: '2026-07-31', + amount: 88250, + description: 'BGGIRERING 03447786', + merchant_name: null, + reference: null, + ...over, + } + } + + it('offers the aggregate row whose excess is exactly another open invoice', async () => { + // Name sweeps find nothing ("BGGIRERING" carries no payer), then the + // aggregate sweep: 88 250 - 62 500 = 25 750 = invoice 064's remaining. + const { supabase, queries } = createRecordingSupabase([ + [], + [], + [aggregateRow()], + [ + { id: 'inv-064', invoice_number: '064', remaining_amount: 25750, total: 25750, due_date: '2026-07-31' }, + { id: 'inv-065', invoice_number: '065', remaining_amount: 25750, total: 25750, due_date: '2026-09-30' }, + { id: 'inv-070', invoice_number: '070', remaining_amount: 999, total: 999, due_date: '2026-08-15' }, + ], + ]) + + const candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, { + companyId: 'company-1', + invoice: invoice063, + paymentAmount: 62500, + paymentDate: '2026-07-31', + }) + + expect(queries).toHaveLength(4) + // Rows larger than the payment, unbooked, kronor, on the payment day ± 7. + expect(queries[2].gt).toContainEqual(['amount', 62500]) + expect(queries[2].is).toContainEqual(['journal_entry_id', null]) + expect(queries[2].or).toEqual([['currency.is.null,currency.eq.SEK']]) + expect(queries[2].gte).toContainEqual(['date', '2026-07-24']) + expect(queries[2].lte).toContainEqual(['date', '2026-08-07']) + // Other open invoices only: this one is excluded by number. + expect(queries[3].neq).toContainEqual(['invoice_number', '063']) + expect(queries[3].in).toContainEqual(['status', ['sent', 'overdue', 'partially_paid']]) + + expect(candidates).toHaveLength(1) + expect(candidates[0]).toMatchObject({ + id: 'tx-bg', + amount: 88250, + match_reason: 'aggregate_exact', + match_confidence: 0.9, + }) + // The invoice due on the row's date wins over the identical one due later. + expect(candidates[0].aggregate_invoice_numbers).toEqual(['064']) + }) + + it('does not run the aggregate sweep when a 1:1 candidate already exists', async () => { + const { supabase, queries } = createRecordingSupabase([[bankRow()], []]) + const candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, { + companyId: 'company-1', + invoice: sekInvoice, + paymentAmount: 12500, + paymentDate: '2026-05-10', + }) + expect(queries).toHaveLength(2) + expect(candidates[0].match_reason).not.toBe('aggregate_exact') + }) + + it('stays silent when the excess is not an exact sum of other open invoices', async () => { + const { supabase } = createRecordingSupabase([ + [], + [], + [aggregateRow()], + [{ id: 'inv-x', invoice_number: '099', remaining_amount: 25000, total: 25000, due_date: '2026-07-31' }], + ]) + const candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, { + companyId: 'company-1', + invoice: invoice063, + paymentAmount: 62500, + paymentDate: '2026-07-31', + }) + expect(candidates).toEqual([]) + }) + + it('stops after the row sweep when no larger unbooked row exists', async () => { + const { supabase, queries } = createRecordingSupabase([[], [], []]) + const candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, { + companyId: 'company-1', + invoice: invoice063, + paymentAmount: 62500, + paymentDate: '2026-07-31', + }) + expect(queries).toHaveLength(3) + expect(candidates).toEqual([]) + }) + + it('never runs for a foreign-currency invoice', async () => { + const { supabase, queries } = createRecordingSupabase([[], [], [], []]) + const candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, { + companyId: 'company-1', + invoice: eurInvoiceWithRate, + paymentAmount: 1000, + paymentDate: '2026-05-10', + }) + // The four name sweeps (two currencies x two patterns) and nothing more. + expect(queries).toHaveLength(4) + expect(candidates).toEqual([]) }) }) diff --git a/lib/invoices/__tests__/duplicate-payment-detection.test.ts b/lib/invoices/__tests__/duplicate-payment-detection.test.ts index ddc0eaed..60be7349 100644 --- a/lib/invoices/__tests__/duplicate-payment-detection.test.ts +++ b/lib/invoices/__tests__/duplicate-payment-detection.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect, beforeEach } from 'vitest' -import { detectDuplicatePaymentVoucher } from '../duplicate-payment-detection' +import { + detectDuplicatePaymentVoucher, + detectExplainingVoucherSet, + detectExplainingVoucherSetForTransaction, +} from '../duplicate-payment-detection' import { createQueuedMockSupabase } from '@/tests/helpers' const { supabase, enqueue, reset } = createQueuedMockSupabase() @@ -581,3 +585,272 @@ describe('detectDuplicatePaymentVoucher', () => { expect(result!.amount_verified).toBe(true) }) }) + +describe('detectExplainingVoucherSet', () => { + beforeEach(() => { + reset() + }) + + type SetLine = { + account_number: string + debit_amount: number + credit_amount: number + journal_entry: { + id: string + entry_date: string + description: string | null + voucher_series: string + voucher_number: number + status: string + source_type: string | null + company_id: string + } + } + + function leg(opts: { + je_id: string + date: string + debit?: number + credit?: number + account?: string + label?: string + source_type?: string | null + description?: string + }): SetLine { + const label = opts.label ?? 'A1' + return { + account_number: opts.account ?? '1930', + debit_amount: opts.debit ?? 0, + credit_amount: opts.credit ?? 0, + journal_entry: { + id: opts.je_id, + entry_date: opts.date, + description: opts.description ?? `Voucher ${opts.je_id}`, + voucher_series: label[0], + voucher_number: parseInt(label.slice(1), 10) || 1, + status: 'posted', + source_type: opts.source_type === undefined ? 'invoice_paid' : opts.source_type, + company_id: 'company-1', + }, + } + } + + /** entries page, lines page, then the four link lookups (all empty unless given). */ + function enqueueScan( + rows: SetLine[], + links: { + invoicePayments?: unknown[] + supplierPayments?: unknown[] + transactions?: unknown[] + junction?: unknown[] + } = {}, + ) { + const entries = [...new Map(rows.map((r) => [r.journal_entry.id, r.journal_entry])).values()] + enqueue({ data: entries, error: null }) + if (entries.length === 0) return + enqueue({ + data: rows.map((r, i) => ({ + id: `line-${i}`, + journal_entry_id: r.journal_entry.id, + account_number: r.account_number, + debit_amount: r.debit_amount, + credit_amount: r.credit_amount, + })), + error: null, + }) + enqueue({ data: links.invoicePayments ?? [], error: null }) + enqueue({ data: links.supplierPayments ?? [], error: null }) + enqueue({ data: links.transactions ?? [], error: null }) + enqueue({ data: links.junction ?? [], error: null }) + } + + const baseArgs = { + companyId: 'company-1', + transactionId: 'tx-bg', + transactionDate: '2026-07-31', + transactionCurrency: 'SEK', + } + + it('explains a Bankgirot aggregate with the two mark-paid vouchers that sum to it', async () => { + // The reported case: 063 and 064 marked paid by hand (A57 + A58), then one + // 88 250 "BGGIRERING" row. Their payment rows carry no bank transaction. + enqueueScan( + [ + leg({ je_id: 'A57', date: '2026-07-31', debit: 62500, label: 'A57', description: 'Inbetalning kundfaktura 063' }), + leg({ je_id: 'A58', date: '2026-07-31', debit: 25750, label: 'A58', description: 'Inbetalning kundfaktura 064' }), + leg({ je_id: 'A56', date: '2026-07-20', debit: 150, label: 'A56', source_type: 'bank_transaction' }), + ], + { + invoicePayments: [ + { journal_entry_id: 'A57', transaction_id: null }, + { journal_entry_id: 'A58', transaction_id: null }, + ], + }, + ) + + const set = await detectExplainingVoucherSet(supabase as never, { + ...baseArgs, + transactionAmount: 88250, + bankAccountNumber: '1930', + }) + + expect(set).not.toBeNull() + expect(set!.vouchers.map((v) => v.journal_entry_id).sort()).toEqual(['A57', 'A58']) + expect(set!.total).toBe(88250) + expect(set!.bank_account_number).toBe('1930') + expect(set!.same_date).toBe(true) + expect(set!.vouchers[0].voucher_label).toBe('A57') + }) + + it('scopes the scan to the row settlement account and the row direction', async () => { + const callsBefore = supabase.from.mock.calls.length + enqueueScan([leg({ je_id: 'je-1', date: '2026-07-31', credit: 1185 })]) + + const set = await detectExplainingVoucherSet(supabase as never, { + ...baseArgs, + transactionAmount: -1185, + bankAccountNumber: '1930', + }) + + const tables = supabase.from.mock.calls.slice(callsBefore).map((c) => c[0]) + expect(tables.slice(0, 2)).toEqual(['journal_entries', 'journal_entry_lines']) + // Money out: the credit leg is the voucher's bank line. + expect(set?.vouchers.map((v) => v.amount)).toEqual([1185]) + }) + + it('returns null when no set of at most four vouchers sums exactly to the row', async () => { + enqueueScan([ + leg({ je_id: 'a', date: '2026-07-31', debit: 62500 }), + leg({ je_id: 'b', date: '2026-07-31', debit: 25000 }), + ]) + const set = await detectExplainingVoucherSet(supabase as never, { + ...baseArgs, + transactionAmount: 88250, + }) + expect(set).toBeNull() + }) + + it('drops vouchers a bank transaction already explains through any anchor', async () => { + enqueueScan( + [ + leg({ je_id: 'via-tx', date: '2026-07-31', debit: 100 }), + leg({ je_id: 'via-payment', date: '2026-07-31', debit: 100 }), + leg({ je_id: 'via-supplier-payment', date: '2026-07-31', debit: 100 }), + leg({ je_id: 'via-junction', date: '2026-07-31', debit: 100 }), + leg({ je_id: 'free', date: '2026-07-31', debit: 100 }), + ], + { + invoicePayments: [{ journal_entry_id: 'via-payment', transaction_id: 'tx-other' }], + supplierPayments: [{ journal_entry_id: 'via-supplier-payment', transaction_id: 'tx-other' }], + transactions: [{ id: 'tx-other', journal_entry_id: 'via-tx' }], + junction: [{ journal_entry_id: 'via-junction' }], + }, + ) + const set = await detectExplainingVoucherSet(supabase as never, { + ...baseArgs, + transactionAmount: 100, + }) + expect(set?.vouchers.map((v) => v.journal_entry_id)).toEqual(['free']) + }) + + it('never sums storno, correction or opening-balance entries', async () => { + enqueueScan([ + leg({ je_id: 'storno', date: '2026-07-31', debit: 500, source_type: 'storno' }), + leg({ je_id: 'corr', date: '2026-07-31', debit: 300, source_type: 'correction' }), + leg({ je_id: 'ib', date: '2026-07-31', debit: 200, source_type: 'opening_balance' }), + ]) + const set = await detectExplainingVoucherSet(supabase as never, { + ...baseArgs, + transactionAmount: 1000, + }) + expect(set).toBeNull() + }) + + it('returns null without scanning when the row cannot be stated in SEK', async () => { + const callsBefore = supabase.from.mock.calls.length + const set = await detectExplainingVoucherSet(supabase as never, { + ...baseArgs, + transactionAmount: 1000, + transactionCurrency: 'EUR', + transactionAmountSek: null, + transactionExchangeRate: null, + }) + expect(set).toBeNull() + expect(supabase.from.mock.calls.length).toBe(callsBefore) + }) + + it('states a foreign row in SEK before summing', async () => { + enqueueScan([leg({ je_id: 'je-eur', date: '2026-07-31', debit: 11500 })]) + const set = await detectExplainingVoucherSet(supabase as never, { + ...baseArgs, + transactionAmount: 1000, + transactionCurrency: 'EUR', + transactionAmountSek: 11500, + }) + expect(set?.vouchers.map((v) => v.journal_entry_id)).toEqual(['je-eur']) + expect(set?.total).toBe(11500) + }) + + it('fails open (null) when a link lookup resolves with an error instead of throwing', async () => { + const entries = [leg({ je_id: 'je-1', date: '2026-07-31', debit: 1000 })] + enqueue({ data: entries.map((r) => r.journal_entry), error: null }) + enqueue({ + data: entries.map((r, i) => ({ id: `line-${i}`, journal_entry_id: r.journal_entry.id, account_number: '1930', debit_amount: 1000, credit_amount: 0 })), + error: null, + }) + enqueue({ data: [], error: null }) + enqueue({ data: [], error: null }) + // transactions lookup fails: PostgREST resolves, it does not throw. + enqueue({ data: null, error: { message: 'permission denied' } }) + enqueue({ data: [], error: null }) + const set = await detectExplainingVoucherSet(supabase as never, { + ...baseArgs, + transactionAmount: 1000, + }) + expect(set).toBeNull() + }) + + it('fails open (null) when the ledger scan throws', async () => { + enqueue({ data: null, error: { message: 'boom' } }) + const set = await detectExplainingVoucherSet(supabase as never, { + ...baseArgs, + transactionAmount: 1000, + }) + expect(set).toBeNull() + }) +}) + +describe('detectExplainingVoucherSetForTransaction', () => { + beforeEach(() => { + reset() + }) + + it('returns null for a row that already carries a pointer, without scanning', async () => { + enqueue({ data: { id: 'tx-1', date: '2026-07-31', amount: 100, currency: 'SEK', journal_entry_id: 'je-live' }, error: null }) + const callsBefore = supabase.from.mock.calls.length + const set = await detectExplainingVoucherSetForTransaction(supabase as never, 'company-1', 'tx-1') + expect(set).toBeNull() + expect(supabase.from.mock.calls.length - callsBefore).toBe(1) + }) + + it('fails open when the cash-account lookup errors instead of widening the scan', async () => { + const callsBefore = supabase.from.mock.calls.length + enqueue({ data: { id: 'tx-1', date: '2026-07-31', amount: 100, currency: 'SEK', cash_account_id: 'ca-1', journal_entry_id: null }, error: null }) + enqueue({ data: null, error: { message: 'boom' } }) + const set = await detectExplainingVoucherSetForTransaction(supabase as never, 'company-1', 'tx-1') + expect(set).toBeNull() + expect(supabase.from.mock.calls.length - callsBefore).toBe(2) + }) + + it('resolves the settlement account from the cash account before scanning', async () => { + const callsBefore = supabase.from.mock.calls.length + enqueue({ data: { id: 'tx-1', date: '2026-07-31', amount: 100, currency: 'SEK', cash_account_id: 'ca-1', journal_entry_id: null }, error: null }) + enqueue({ data: { ledger_account: '1940' }, error: null }) + // entries page: nothing on the account, scan ends. + enqueue({ data: [], error: null }) + const set = await detectExplainingVoucherSetForTransaction(supabase as never, 'company-1', 'tx-1') + expect(set).toBeNull() + const tables = supabase.from.mock.calls.slice(callsBefore).map((c) => c[0]) + expect(tables).toEqual(['transactions', 'cash_accounts', 'journal_entries']) + }) +}) diff --git a/lib/invoices/duplicate-payment-candidates.ts b/lib/invoices/duplicate-payment-candidates.ts index b7866983..ae875bb3 100644 --- a/lib/invoices/duplicate-payment-candidates.ts +++ b/lib/invoices/duplicate-payment-candidates.ts @@ -13,6 +13,8 @@ import { type ComparableAmount, } from './duplicate-guard-currency' import { resolveTransactionAmountSek } from '@/lib/transactions/booking-duplicate-detection' +import { findExactCoveringSet } from '@/lib/reconciliation/covering-set' +import { roundOre } from '@/lib/money' import { createLogger } from '@/lib/logger' const log = createLogger('invoices/duplicate-payment-candidates') @@ -21,6 +23,13 @@ export type DuplicatePaymentMatchReason = | 'ocr_exact' | 'name_amount_fuzzy' | 'amount_only' + /** + * The bank row is larger than this payment and the difference is exactly + * (to the öre) the remaining amount of one to three OTHER open invoices: + * a Bankgirot daily aggregate that settled this invoice together with + * them. No counterparty text is consulted; such rows carry none. + */ + | 'aggregate_exact' export interface DuplicatePaymentCandidate { id: string @@ -31,20 +40,33 @@ export interface DuplicatePaymentCandidate { reference: string | null match_reason: DuplicatePaymentMatchReason match_confidence: number + /** For aggregate_exact: the other open invoices the row also covers. */ + aggregate_invoice_numbers?: string[] } const MATCH_REASON_RANK: Record = { ocr_exact: 0, - name_amount_fuzzy: 1, - amount_only: 2, + aggregate_exact: 1, + name_amount_fuzzy: 2, + amount_only: 3, } const MATCH_REASON_CONFIDENCE: Record = { ocr_exact: 0.99, + aggregate_exact: 0.9, name_amount_fuzzy: 0.7, amount_only: 0.5, } +/** ± days around the payment date an aggregate row is looked for: a Bankgirot + * aggregate lands on the payment day, so the wide name-sweep window would only + * add coincidental sums. */ +const AGGREGATE_DATE_WINDOW_DAYS = 7 +/** Other open invoices an aggregate row may cover besides this one. */ +const AGGREGATE_MAX_OTHER_INVOICES = 3 +const AGGREGATE_MAX_ROWS = 40 +const AGGREGATE_MAX_OPEN_INVOICES = 200 + interface CustomerInvoice { invoice_number: string | null customer_name: string | null | undefined @@ -116,9 +138,14 @@ export async function findDuplicatePaymentCandidatesForInvoice( ): Promise { const { companyId, invoice, paymentAmount, paymentDate } = params const customerName = invoice.customer_name - if (!customerName) return [] - const paymentCurrency = normalizeCurrencyCode(invoice.currency) + + // The name sweeps need a payer to look for; the aggregate sweep does not + // (a Bankgirot row names nobody), so a nameless invoice skips straight to it. + if (!customerName) { + if (paymentCurrency !== 'SEK') return [] + return runAggregateSweep(supabase, { companyId, invoice, paymentAmount, paymentDate }) + } const reference: ComparableAmount = { amount: paymentAmount, currency: paymentCurrency, @@ -200,7 +227,14 @@ export async function findDuplicatePaymentCandidatesForInvoice( .sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0)) .slice(0, 5) - if (data.length === 0) return [] + // Nothing of this invoice's own size: look for the row that paid it TOGETHER + // with other invoices. One warning is enough, so the sweep only runs when + // the name sweeps came back empty. Kronor only: the sum is taken over + // remaining amounts stored in invoice currency. + if (data.length === 0) { + if (paymentCurrency !== 'SEK') return [] + return runAggregateSweep(supabase, { companyId, invoice, paymentAmount, paymentDate }) + } const invoiceOcr = normalizeOcrReference(invoice.invoice_number) const searchTerms = customerName @@ -230,6 +264,146 @@ export async function findDuplicatePaymentCandidatesForInvoice( return candidates } +async function runAggregateSweep( + supabase: SupabaseClient, + params: { + companyId: string + invoice: Pick + paymentAmount: number + paymentDate: string + }, +): Promise { + const { companyId, invoice, paymentAmount, paymentDate } = params + try { + return await findAggregateCandidates(supabase, { + companyId, + invoiceNumber: invoice.invoice_number, + paymentAmount, + paymentDate, + }) + } catch (err) { + // Advisory guard: a failed sweep must never block "Markera som betald". + // Logged so the blind spot is visible rather than passing silently. + log.warn('duplicate-payment guard: aggregate sweep failed', { + companyId, + invoiceNumber: invoice.invoice_number, + error: err instanceof Error ? err.message : String(err), + }) + return [] + } +} + +type OpenInvoiceRow = { + id: string + invoice_number: string | null + remaining_amount: number | string | null + total: number | string | null + due_date: string | null +} + +type AggregateRow = Pick + +/** + * Unlinked inbound kronor rows around the payment date that are LARGER than + * the payment, where the excess is exactly the remaining amount of one to + * three other open invoices. That is what a Bankgirot daily aggregate looks + * like from the invoice side: "BGGIRERING", no payer, one sum for two + * customers' invoices. Same exact-sum search the bank-side guard uses + * (lib/reconciliation/covering-set.ts), so the two doors agree on what + * "already paid" means. The remedy is the split under Transaktioner, which + * books ONE samlingsverifikation and links the row; marking the invoices + * paid one by one is what books the money twice. + */ +async function findAggregateCandidates( + supabase: SupabaseClient, + params: { + companyId: string + invoiceNumber: string | null + paymentAmount: number + paymentDate: string + }, +): Promise { + const { companyId, invoiceNumber, paymentAmount, paymentDate } = params + const payment = roundOre(paymentAmount) + if (!(payment > 0)) return [] + const dateMs = new Date(paymentDate).getTime() + if (Number.isNaN(dateMs)) return [] + const dayMs = 24 * 3600 * 1000 + const dateLow = new Date(dateMs - AGGREGATE_DATE_WINDOW_DAYS * dayMs).toISOString().split('T')[0] + const dateHigh = new Date(dateMs + AGGREGATE_DATE_WINDOW_DAYS * dayMs).toISOString().split('T')[0] + + const { data: rowsData } = await supabase + .from('transactions') + .select('id, date, amount, description, merchant_name, reference') + .eq('company_id', companyId) + .eq('is_business', true) + .is('invoice_id', null) + .is('supplier_invoice_id', null) + .is('journal_entry_id', null) + .or('currency.is.null,currency.eq.SEK') + .gt('amount', payment) + .gte('date', dateLow) + .lte('date', dateHigh) + .order('date', { ascending: false }) + .limit(AGGREGATE_MAX_ROWS) + // Defensive shape check: a client that answers a list query with a single + // object (older test doubles do) must read as "no rows", not throw. + const rows = (Array.isArray(rowsData) ? rowsData : []) as AggregateRow[] + if (rows.length === 0) return [] + + let othersQuery = supabase + .from('invoices') + .select('id, invoice_number, remaining_amount, total, due_date') + .eq('company_id', companyId) + .eq('document_type', 'invoice') + .is('credited_invoice_id', null) + .in('status', ['sent', 'overdue', 'partially_paid']) + .gt('remaining_amount', 0) + .or('currency.is.null,currency.eq.SEK') + if (invoiceNumber) othersQuery = othersQuery.neq('invoice_number', invoiceNumber) + const { data: othersData } = await othersQuery + .order('due_date', { ascending: true }) + .limit(AGGREGATE_MAX_OPEN_INVOICES) + const others = ((Array.isArray(othersData) ? othersData : []) as OpenInvoiceRow[]).filter( + (inv) => inv.invoice_number && Number(inv.remaining_amount ?? inv.total ?? 0) > 0, + ) + if (others.length === 0) return [] + + const candidates: DuplicatePaymentCandidate[] = [] + for (const row of rows) { + const residual = roundOre(Number(row.amount) - payment) + if (!(residual > 0)) continue + const rowMs = new Date(row.date).getTime() + const set = findExactCoveringSet( + residual, + others.map((inv) => ({ + id: inv.id, + amount: Number(inv.remaining_amount ?? inv.total ?? 0), + dateDistanceDays: + inv.due_date && !Number.isNaN(rowMs) + ? Math.round(Math.abs(new Date(inv.due_date).getTime() - rowMs) / dayMs) + : AGGREGATE_DATE_WINDOW_DAYS, + invoiceNumber: inv.invoice_number as string, + })), + { maxSize: AGGREGATE_MAX_OTHER_INVOICES }, + ) + if (!set) continue + candidates.push({ + id: row.id, + date: row.date, + amount: Number(row.amount), + description: row.description, + merchant_name: row.merchant_name, + reference: row.reference, + match_reason: 'aggregate_exact', + match_confidence: MATCH_REASON_CONFIDENCE.aggregate_exact, + aggregate_invoice_numbers: set.map((s) => s.invoiceNumber), + }) + if (candidates.length >= 5) break + } + return candidates +} + /** * A bank row as a comparable amount. `resolveTransactionAmountSek` is the one * definition of "this bank line in kronor" (shared with the booking-time diff --git a/lib/invoices/duplicate-payment-detection.ts b/lib/invoices/duplicate-payment-detection.ts index 0be2699f..204ecd8b 100644 --- a/lib/invoices/duplicate-payment-detection.ts +++ b/lib/invoices/duplicate-payment-detection.ts @@ -26,6 +26,8 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines' import { resolveTransactionAmountSek } from '@/lib/transactions/booking-duplicate-detection' +import { findExactCoveringSet } from '@/lib/reconciliation/covering-set' +import { roundOre } from '@/lib/money' /** ± days around the transaction date considered "the same payment". */ const DATE_WINDOW_DAYS = 7 @@ -274,3 +276,329 @@ export async function detectDuplicatePaymentVoucher( unverified_reason: targetSek === null ? 'transaction_missing_sek_value' : null, } } + +// ============================================================ +// Explaining voucher SET: one bank row, one or several vouchers +// ============================================================ + +/** ± days around the bank row considered "the same payment" for a set. */ +const SET_DATE_WINDOW_DAYS = 7 + +/** Largest set of vouchers offered as the explanation of one bank row. */ +export const EXPLAINING_SET_MAX_VOUCHERS = 4 + +export interface ExplainingVoucher { + journal_entry_id: string + voucher_label: string + entry_date: string + description: string | null + source_type: string | null + /** The voucher's bank leg in SEK, positive, in the bank row's direction. */ + amount: number + bank_account_number: string +} + +export interface ExplainingVoucherSet { + /** One to EXPLAINING_SET_MAX_VOUCHERS vouchers, closest in date first. */ + vouchers: ExplainingVoucher[] + /** SEK sum of the legs: equals the bank row stated in SEK, to the öre. */ + total: number + bank_account_number: string + /** True when every voucher is dated on the bank row's date. */ + same_date: boolean +} + +export interface DetectSetArgs extends DetectArgs { + /** + * The settlement account the bank row belongs to (cash_accounts.ledger_account) + * when known. Narrows the scan to that account, so a 1940 leg can never be + * summed into a 1930 row. Null or omitted scans the whole 19xx range, the + * legacy shape for rows with no resolvable cash account. + */ + bankAccountNumber?: string | null +} + +/** + * Find the vouchers that already book this bank row, allowing the row to be + * explained by SEVERAL of them. + * + * The 1:1 detector above answers "is there one voucher of this amount?". A + * bank feed regularly delivers one row for several affärshändelser (a + * Bankgirot daily aggregate: two customers' invoices, one "BGGIRERING" row + * with no payer and no reference), and each of those may already be booked on + * its own: "Markera som betald" per invoice, one salary voucher per employee. + * Nothing on the account then equals the row, the 1:1 check passes, and the + * next door (a batch allocation, a fresh categorisation) books the same + * money a second time. That is exactly the double booking this catches. + * + * Deterministic on purpose: the only signal is an exact öre sum of unlinked + * bank legs in the row's direction, on the row's account, within ±7 days. + * No counterparty text is consulted: the bank rows this exists for carry + * none. A voucher counts as linked (and drops out) when a transaction points + * at it, a payment row with a bank transaction references it, or a + * transaction_voucher_links row anchors it: the same three storage + * locations isTransactionBooked reads, seen from the voucher side. A payment + * row WITHOUT a bank transaction is a manual settlement (#2019) and keeps the + * voucher in play: its bank line is precisely what has not been matched yet. + * + * Returns null when the row cannot be stated in SEK (a foreign row with no + * stored rate): a set cannot be summed in an unknown unit, and the 1:1 + * detector's `amount_verified: false` path already surfaces that case. + */ +export async function detectExplainingVoucherSet( + supabase: SupabaseClient, + args: DetectSetArgs, +): Promise { + const { companyId, transactionId, transactionDate, transactionAmount } = args + if (Math.round(Math.abs(transactionAmount) * 100) === 0) return null + + const signedSek = resolveTransactionAmountSek({ + amount: transactionAmount, + currency: args.transactionCurrency, + amount_sek: args.transactionAmountSek, + exchange_rate: args.transactionExchangeRate, + }) + if (signedSek === null) return null + const targetSek = roundOre(Math.abs(signedSek)) + if (targetSek === 0) return null + + const dateMs = new Date(transactionDate).getTime() + if (Number.isNaN(dateMs)) return null + const lowDate = new Date(dateMs - SET_DATE_WINDOW_DAYS * 24 * 3600 * 1000) + .toISOString() + .split('T')[0] + const highDate = new Date(dateMs + SET_DATE_WINDOW_DAYS * 24 * 3600 * 1000) + .toISOString() + .split('T')[0] + + // Money in: the voucher DEBITS the bank account. Money out: it CREDITS it. + const inbound = transactionAmount > 0 + const account = args.bankAccountNumber?.trim() || null + + type SetLineRow = { + account_number: string + debit_amount: number | string | null + credit_amount: number | string | null + journal_entry: { + id: string + entry_date: string + description: string | null + voucher_series: string | null + voucher_number: number | null + status: string + source_type: string | null + } + } + + let lines: SetLineRow[] + try { + lines = await fetchEntryLines({ + supabase, + entryColumns: + 'id, entry_date, description, voucher_series, voucher_number, status, source_type, company_id', + lineColumns: 'account_number, debit_amount, credit_amount', + filterEntries: (q: EntryLinesQuery) => + q + .eq('company_id', companyId) + .eq('status', 'posted') + .gte('entry_date', lowDate) + .lte('entry_date', highDate), + filterLines: (q: EntryLinesQuery) => { + const scoped = account + ? q.eq('account_number', account) + : q.gte('account_number', String(BANK_ACCOUNT_LOW)).lte('account_number', String(BANK_ACCOUNT_HIGH)) + return inbound ? scoped.gt('debit_amount', 0) : scoped.gt('credit_amount', 0) + }, + attachEntriesAs: 'journal_entry', + }) + } catch { + // Fail-open like the 1:1 detector: a detection failure must not block a + // booking. Callers log the miss. + return null + } + if (lines.length === 0) return null + + // Reversals, corrections and opening balances are bookkeeping scaffolding, + // never the payment itself (the reconciliation RPCs drop the same three). + const legs = lines.filter( + (l) => + l.journal_entry.source_type !== 'storno' && + l.journal_entry.source_type !== 'correction' && + l.journal_entry.source_type !== 'opening_balance', + ) + if (legs.length === 0) return null + + // One candidate per voucher and account: a voucher with two legs on the + // same account (a split payment line) is summed, a voucher touching two + // bank accounts (a transfer) keeps its largest leg so it can appear once. + type Candidate = ExplainingVoucher & { dateDistanceDays: number; id: string } + const byEntry = new Map() + for (const leg of legs) { + const raw = inbound ? leg.debit_amount : leg.credit_amount + const amount = roundOre(Number(raw)) + if (!(amount > 0)) continue + const entry = leg.journal_entry + const existing = byEntry.get(entry.id) + if (existing && existing.bank_account_number === leg.account_number) { + existing.amount = roundOre(existing.amount + amount) + continue + } + if (existing && existing.amount >= amount) continue + const entryMs = new Date(entry.entry_date).getTime() + byEntry.set(entry.id, { + id: entry.id, + journal_entry_id: entry.id, + voucher_label: `${entry.voucher_series ?? 'A'}${entry.voucher_number ?? ''}`, + entry_date: entry.entry_date, + description: entry.description, + source_type: entry.source_type, + amount, + bank_account_number: leg.account_number, + dateDistanceDays: Number.isNaN(entryMs) + ? SET_DATE_WINDOW_DAYS + : Math.round(Math.abs(entryMs - dateMs) / (24 * 3600 * 1000)), + }) + } + if (byEntry.size === 0) return null + + // Drop vouchers a bank transaction already explains, through any of the + // three anchors. All four lookups are company-scoped (defense in depth). + const entryIds = Array.from(byEntry.keys()) + const [paymentLinksRes, supplierPaymentLinksRes, txLinksRes, junctionLinksRes] = + await Promise.all([ + supabase + .from('invoice_payments') + .select('journal_entry_id, transaction_id') + .eq('company_id', companyId) + .in('journal_entry_id', entryIds), + supabase + .from('supplier_invoice_payments') + .select('journal_entry_id, transaction_id') + .eq('company_id', companyId) + .in('journal_entry_id', entryIds), + supabase + .from('transactions') + .select('id, journal_entry_id') + .eq('company_id', companyId) + .in('journal_entry_id', entryIds), + supabase + .from('transaction_voucher_links') + .select('journal_entry_id') + .eq('company_id', companyId) + .in('journal_entry_id', entryIds), + ]) + // A PostgREST failure resolves with { data: null, error } rather than + // throwing. Reading that as "no links" would offer a voucher a bank row + // already settles, so a failed lookup fails open (null) like a thrown one: + // the guard stays advisory and the booking RPC keeps the last word. + if (paymentLinksRes.error || supplierPaymentLinksRes.error || txLinksRes.error || junctionLinksRes.error) { + return null + } + const paymentLinks = paymentLinksRes.data + const supplierPaymentLinks = supplierPaymentLinksRes.data + const txLinks = txLinksRes.data + const junctionLinks = junctionLinksRes.data + + const linkedIds = new Set() + for (const row of [...((paymentLinks ?? []) as PaymentLinkRow[]), ...((supplierPaymentLinks ?? []) as PaymentLinkRow[])]) { + if (row.journal_entry_id && row.transaction_id) linkedIds.add(row.journal_entry_id) + } + for (const row of (txLinks ?? []) as { id: string; journal_entry_id: string | null }[]) { + // The caller's own row is never a link: the guard runs before it is linked. + if (row.journal_entry_id && row.id !== transactionId) linkedIds.add(row.journal_entry_id) + } + for (const row of (junctionLinks ?? []) as { journal_entry_id: string | null }[]) { + if (row.journal_entry_id) linkedIds.add(row.journal_entry_id) + } + + const pool = Array.from(byEntry.values()).filter((c) => !linkedIds.has(c.journal_entry_id)) + if (pool.length === 0) return null + + // Sets never mix accounts: the link that resolves the warning is made on + // one settlement account. Search per account, closest account first. + const accounts = Array.from(new Set(pool.map((c) => c.bank_account_number))).sort() + for (const accountNumber of accounts) { + const set = findExactCoveringSet( + targetSek, + pool.filter((c) => c.bank_account_number === accountNumber), + { maxSize: EXPLAINING_SET_MAX_VOUCHERS }, + ) + if (!set) continue + const vouchers = [...set] + .sort((a, b) => a.dateDistanceDays - b.dateDistanceDays || a.entry_date.localeCompare(b.entry_date)) + .map(({ id: _id, dateDistanceDays: _distance, ...voucher }) => voucher) + return { + vouchers, + total: targetSek, + bank_account_number: accountNumber, + same_date: vouchers.every((v) => v.entry_date === transactionDate), + } + } + return null +} + +type PaymentLinkRow = { journal_entry_id: string | null; transaction_id: string | null } + +/** The transaction columns the set detector needs; a caller that already holds the row passes it. */ +export interface TransactionForExplaining { + id: string + date: string + amount: number + currency: string | null + amount_sek?: number | null + exchange_rate?: number | null + cash_account_id?: string | null + journal_entry_id?: string | null +} + +/** + * Convenience for the routes: resolve the row's settlement account from its + * cash account and run the set detector. Accepts the transaction id (one + * fetch) or a row a caller already holds. A row that already carries a live + * pointer returns null: the booking RPCs refuse it on their own terms. + */ +export async function detectExplainingVoucherSetForTransaction( + supabase: SupabaseClient, + companyId: string, + transaction: string | TransactionForExplaining, +): Promise { + let row: TransactionForExplaining | null + if (typeof transaction === 'string') { + const { data, error } = await supabase + .from('transactions') + .select('id, date, amount, currency, amount_sek, exchange_rate, cash_account_id, journal_entry_id') + .eq('id', transaction) + .eq('company_id', companyId) + .maybeSingle() + if (error) return null + row = (data as TransactionForExplaining | null) ?? null + } else { + row = transaction + } + if (!row || row.journal_entry_id) return null + + let bankAccountNumber: string | null = null + if (row.cash_account_id) { + const { data: cashAccount, error } = await supabase + .from('cash_accounts') + .select('ledger_account') + .eq('id', row.cash_account_id) + .eq('company_id', companyId) + .maybeSingle() + // Without the account the scan would widen to every 19xx account: an + // unverified answer, so a failed lookup is a pass, not a wider guess. + if (error) return null + bankAccountNumber = (cashAccount?.ledger_account as string | null) ?? null + } + + return detectExplainingVoucherSet(supabase, { + companyId, + transactionId: row.id, + transactionDate: row.date, + transactionAmount: Number(row.amount), + transactionCurrency: row.currency ?? null, + transactionAmountSek: row.amount_sek ?? null, + transactionExchangeRate: row.exchange_rate ?? null, + bankAccountNumber, + }) +} diff --git a/lib/reconciliation/__tests__/covering-set.test.ts b/lib/reconciliation/__tests__/covering-set.test.ts new file mode 100644 index 00000000..5a66db2f --- /dev/null +++ b/lib/reconciliation/__tests__/covering-set.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest' +import { findExactCoveringSet } from '../covering-set' + +function c(id: string, amount: number, dateDistanceDays = 0) { + return { id, amount, dateDistanceDays } +} + +describe('findExactCoveringSet', () => { + it('returns null for an empty list or a non-positive target', () => { + expect(findExactCoveringSet(100, [])).toBeNull() + expect(findExactCoveringSet(0, [c('a', 100)])).toBeNull() + expect(findExactCoveringSet(-100, [c('a', 100)])).toBeNull() + }) + + it('finds the single voucher of the same amount', () => { + const set = findExactCoveringSet(1000, [c('a', 999), c('b', 1000), c('c', 1)]) + expect(set?.map((s) => s.id)).toEqual(['b']) + }) + + it('finds the Bankgirot aggregate: two vouchers that sum to the row', () => { + // gecko's case: 62 500 + 25 750 booked by hand, one 88 250 bank row. + const set = findExactCoveringSet(88250, [c('A57', 62500), c('A58', 25750), c('A56', 150)]) + expect(set?.map((s) => s.id).sort()).toEqual(['A57', 'A58']) + }) + + it('prefers the smallest set, then the one closest in date', () => { + const one = findExactCoveringSet(1000, [c('pair-1', 400, 0), c('pair-2', 600, 0), c('single', 1000, 3)]) + expect(one?.map((s) => s.id)).toEqual(['single']) + + const near = findExactCoveringSet(1000, [c('far', 1000, 6), c('near', 1000, 1)]) + expect(near?.map((s) => s.id)).toEqual(['near']) + }) + + it('is exact to the öre and never reads a near miss as a match', () => { + expect(findExactCoveringSet(1000, [c('a', 999.99)])).toBeNull() + expect(findExactCoveringSet(1000.01, [c('a', 600), c('b', 400.01)])?.map((s) => s.id)).toEqual(['a', 'b']) + expect(findExactCoveringSet(1000, [c('a', 600), c('b', 400.01)])).toBeNull() + }) + + it('ignores candidates larger than the target or with no amount', () => { + const set = findExactCoveringSet(500, [c('big', 5000), c('zero', 0), c('neg', -500), c('ok', 500)]) + expect(set?.map((s) => s.id)).toEqual(['ok']) + }) + + it('stops at maxSize and caps the candidate pool', () => { + const parts = [c('a', 100), c('b', 200), c('c', 300), c('d', 400)] + expect(findExactCoveringSet(1000, parts, { maxSize: 3 })).toBeNull() + expect(findExactCoveringSet(1000, parts, { maxSize: 4 })?.length).toBe(4) + // Pool cap keeps only the two closest rows, so the pair cannot be formed. + const far = [c('near-1', 100, 0), c('near-2', 200, 0), c('far-1', 700, 5)] + expect(findExactCoveringSet(1000, far, { maxCandidates: 2 })).toBeNull() + }) + + it('handles a busy account without blowing up', () => { + const many = Array.from({ length: 200 }, (_, i) => c(`v${i}`, 100 + (i % 37) * 13, i % 8)) + const start = Date.now() + const set = findExactCoveringSet(100 + 113 + 126 + 139, many) + expect(Date.now() - start).toBeLessThan(500) + expect(set).not.toBeNull() + }) +}) diff --git a/lib/reconciliation/covering-set.ts b/lib/reconciliation/covering-set.ts new file mode 100644 index 00000000..2cd2961b --- /dev/null +++ b/lib/reconciliation/covering-set.ts @@ -0,0 +1,107 @@ +/** + * Exact subset sum over a short candidate list: which posted bank legs, taken + * together, add up to one bank row to the öre. + * + * Why this exists: a bank feed can deliver several affärshändelser as ONE row + * (a Bankgirot daily aggregate, a lump payout), and each of them may already + * be booked on its own (an invoice marked paid by hand, a salary voucher per + * employee). The 1:1 duplicate check then sees no voucher of the row's amount + * and stays silent, while the row is fully explained by two or three vouchers + * that carry no bank link. The exact sum is a deterministic signal that needs + * no counterparty text, which is what bank rows like "BGGIRERING 03447786" + * never carry. + * + * Pure and client-safe on purpose: the same search can rank a suggestion in + * the reconciliation view or guard a booking route without dragging server + * dependencies into a component. + * + * Search order is smallest set first (one voucher beats two), and within one + * size the set closest in date to the bank row. The candidate list is capped + * before the search so a busy account cannot make the combinatorics + * unbounded: with 40 candidates and sets of at most 4 the worst case is under + * a hundred thousand partial sums, which is well below a millisecond of work. + */ + +export interface CoveringCandidate { + id: string + /** Positive amount in the unit the target is stated in (SEK for bank legs). */ + amount: number + /** |candidate date - bank row date| in whole days. Ranks equal-size sets. */ + dateDistanceDays: number +} + +export interface CoveringSetOptions { + /** Largest set considered. Default 4. */ + maxSize?: number + /** Candidates kept (closest in date first) before the search. Default 40. */ + maxCandidates?: number +} + +const DEFAULT_MAX_SIZE = 4 +const DEFAULT_MAX_CANDIDATES = 40 + +function toOre(amount: number): number { + return Math.round(amount * 100) +} + +/** + * Returns the best set of candidates whose amounts sum exactly to `target` + * (to the öre), or null when no set of at most `maxSize` candidates does. + * Candidates with a non-positive amount never take part; the target must be + * positive (callers pass the absolute value of the bank row). + */ +export function findExactCoveringSet( + target: number, + candidates: T[], + options: CoveringSetOptions = {}, +): T[] | null { + const maxSize = Math.max(1, options.maxSize ?? DEFAULT_MAX_SIZE) + const maxCandidates = Math.max(1, options.maxCandidates ?? DEFAULT_MAX_CANDIDATES) + const targetOre = toOre(target) + if (targetOre <= 0) return null + + const pool = candidates + .map((c) => ({ candidate: c, ore: toOre(c.amount) })) + .filter((c) => c.ore > 0 && c.ore <= targetOre) + .sort((a, b) => { + if (a.candidate.dateDistanceDays !== b.candidate.dateDistanceDays) { + return a.candidate.dateDistanceDays - b.candidate.dateDistanceDays + } + if (a.ore !== b.ore) return b.ore - a.ore + return a.candidate.id < b.candidate.id ? -1 : a.candidate.id > b.candidate.id ? 1 : 0 + }) + .slice(0, maxCandidates) + + for (let size = 1; size <= Math.min(maxSize, pool.length); size++) { + let best: { indices: number[]; distance: number } | null = null + const chosen: number[] = [] + + const walk = (start: number, remaining: number, distance: number) => { + if (chosen.length === size) { + if (remaining === 0 && (best === null || distance < best.distance)) { + best = { indices: [...chosen], distance } + } + return + } + const slotsLeft = size - chosen.length + for (let i = start; i <= pool.length - slotsLeft; i++) { + const entry = pool[i] + if (entry.ore > remaining) continue + // Nothing smaller than what is left can complete the set once the + // last slot is being filled: skip instead of descending. + if (slotsLeft === 1 && entry.ore !== remaining) continue + chosen.push(i) + walk(i + 1, remaining - entry.ore, distance + entry.candidate.dateDistanceDays) + chosen.pop() + } + } + + walk(0, targetOre, 0) + if (best !== null) { + const found = best as { indices: number[]; distance: number } + return found.indices.map((i) => pool[i].candidate) + } + } + + return null +} diff --git a/messages/en.json b/messages/en.json index 15ba341b..03ac6029 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3500,7 +3500,19 @@ "success_title": "Payment allocated", "success_description": "{count, plural, one {The invoice has} other {The invoices have}} been booked against the transaction.", "cancel": "Cancel", - "confirm": "Confirm allocation" + "confirm": "Confirm allocation", + "already_booked_title": "Already looks booked", + "already_booked_body_in": "The incoming payment of {amount} is covered exactly by {count, plural, =1 {one posted voucher} other {# posted vouchers}} with no link to any bank transaction. Most often the invoice was already marked paid in the invoice module. Link the transaction there instead of booking the payment a second time.", + "already_booked_body_out": "The outgoing payment of {amount} is covered exactly by {count, plural, =1 {one posted voucher} other {# posted vouchers}} with no link to any bank transaction. Most often the supplier invoice was already marked paid. Link the transaction there instead of booking the payment a second time.", + "already_booked_link": "Link to {labels}", + "already_booked_book_anyway": "Book anyway", + "already_booked_acknowledged_title": "Booking anyway", + "already_booked_acknowledged_note": "You chose to book the payment despite {labels}. The voucher is created when you confirm the allocation.", + "already_booked_foreign_hint": "The transaction is in {currency}. Link it under Reconciliation, Match manually.", + "already_booked_link_success_title": "Transaction linked", + "already_booked_link_success_description": "Linked to {labels}. No new booking created.", + "already_booked_link_failed": "Could not link", + "invoiced_after_payment_badge": "Invoiced {date}, after the payment" }, "tx_skattekonto_card": { "skv_badge": "Skatteverket", @@ -4469,6 +4481,9 @@ "match_reason_ocr_exact": "Exact OCR match", "match_reason_name_amount_fuzzy": "Likely match", "match_reason_amount_only": "Possible match", + "match_reason_aggregate_exact": "Aggregated payment", + "aggregate_covers": "The amount also covers {count, plural, =1 {invoice {numbers}} other {invoices {numbers}}} exactly. Split the payment under Transactions so it is booked once.", + "allocate_transaction": "Split under Transactions", "duplicate_title": "Possible duplicate payment", "duplicate_one": "An incoming bank transaction looks like it could be this payment. Link it instead of creating a new verifikat, or post anyway if you're sure.", "duplicate_many": "{count} incoming bank transactions look like they could be this payment. Link the right one instead of creating a new verifikat, or post anyway if you're sure.", diff --git a/messages/sv.json b/messages/sv.json index fc06cd42..08a9e080 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -3500,7 +3500,19 @@ "success_title": "Betalning fördelad", "success_description": "{count, plural, one {Fakturan} other {Fakturorna}} har bokförts mot transaktionen.", "cancel": "Avbryt", - "confirm": "Bekräfta fördelning" + "confirm": "Bekräfta fördelning", + "already_booked_title": "Ser redan ut att vara bokförd", + "already_booked_body_in": "Inbetalningen på {amount} täcks exakt av {count, plural, =1 {en bokförd verifikation} other {# bokförda verifikationer}} som saknar koppling till någon banktransaktion. Vanligast är att fakturan redan markerats som betald i fakturamodulen. Koppla transaktionen dit i stället för att bokföra betalningen en gång till.", + "already_booked_body_out": "Utbetalningen på {amount} täcks exakt av {count, plural, =1 {en bokförd verifikation} other {# bokförda verifikationer}} som saknar koppling till någon banktransaktion. Vanligast är att leverantörsfakturan redan markerats som betald. Koppla transaktionen dit i stället för att bokföra betalningen en gång till.", + "already_booked_link": "Koppla till {labels}", + "already_booked_book_anyway": "Bokför ändå", + "already_booked_acknowledged_title": "Bokförs ändå", + "already_booked_acknowledged_note": "Du har valt att bokföra betalningen trots {labels}. Verifikationen skapas när du bekräftar fördelningen.", + "already_booked_foreign_hint": "Transaktionen är i {currency}. Koppla den under Avstämning, Matcha manuellt.", + "already_booked_link_success_title": "Transaktionen kopplad", + "already_booked_link_success_description": "Kopplad till {labels}. Ingen ny bokföring skapad.", + "already_booked_link_failed": "Kunde inte koppla", + "invoiced_after_payment_badge": "Fakturerad {date}, efter betalningen" }, "tx_skattekonto_card": { "skv_badge": "Skatteverket", @@ -4469,6 +4481,9 @@ "match_reason_ocr_exact": "Exakt OCR-träff", "match_reason_name_amount_fuzzy": "Sannolik träff", "match_reason_amount_only": "Möjlig träff", + "match_reason_aggregate_exact": "Samlad inbetalning", + "aggregate_covers": "Beloppet täcker exakt även {count, plural, =1 {faktura {numbers}} other {fakturorna {numbers}}}. Fördela inbetalningen under Transaktioner så bokförs den en gång.", + "allocate_transaction": "Fördela under Transaktioner", "duplicate_title": "Möjlig dubblettbetalning", "duplicate_one": "En inkommande banktransaktion ser ut att vara denna betalning. Länka den istället för att skapa en ny verifikation, eller bokför ändå om du är säker.", "duplicate_many": "{count} inkommande banktransaktioner ser ut att kunna vara denna betalning. Länka rätt transaktion istället för att skapa en ny verifikation, eller bokför ändå om du är säker.",