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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-05 11:59:59 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent 9418de585f
commit 287828a850
18 changed files with 1781 additions and 31 deletions
+1
View File
@@ -1586,6 +1586,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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).
@@ -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
@@ -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()
})
})
@@ -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<ReturnType<typeof detectDuplicatePaymentVoucher>> = 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 })
},
)
@@ -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)
})
})
@@ -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<ReturnType<typeof detectExplainingVoucherSetForTransaction>> = 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,
+20 -3
View File
@@ -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({
<ul className="space-y-2">
{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 (
<li
key={c.id}
@@ -386,6 +391,18 @@ export default function PaymentBookingDialog({
<p className="truncate text-xs text-muted-foreground">
{c.merchant_name || c.description || '-'}
</p>
{/* 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 && (
<p className="text-xs text-muted-foreground">
{t('aggregate_covers', {
count: c.aggregate_invoice_numbers!.length,
numbers: c.aggregate_invoice_numbers!.join(', '),
})}
</p>
)}
</div>
<Button
type="button"
@@ -394,7 +411,7 @@ export default function PaymentBookingDialog({
onClick={() => handleLinkExisting(c.id)}
className="shrink-0"
>
{t('link_transaction')}
{isAggregate ? t('allocate_transaction') : t('link_transaction')}
</Button>
</li>
)
@@ -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<ExplainingSet>
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<Record<string, AllocationDraft>>({})
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<ExplainingSet | null>(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({
</div>
</div>
{/* 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 && (
<div
className={cn(
'rounded-lg border p-4 space-y-3',
explainingAcknowledged ? 'border-border bg-muted/20' : 'border-attn/40 bg-muted/30',
)}
data-testid="already-booked-panel"
role="status"
aria-live="polite"
>
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 flex-shrink-0 mt-0.5 text-attn" />
<div className="min-w-0 flex-1 space-y-1 text-sm">
<p className="font-medium text-attn">
{explainingAcknowledged
? t('already_booked_acknowledged_title')
: t('already_booked_title')}
</p>
{!explainingAcknowledged && (
<p className="text-muted-foreground">
{t(
transaction.amount > 0 ? 'already_booked_body_in' : 'already_booked_body_out',
{
amount: formatCurrency(explaining.total, 'SEK'),
count: explaining.vouchers.length,
},
)}
</p>
)}
</div>
</div>
{!explainingAcknowledged && (
<ul className="space-y-1.5">
{explaining.vouchers.map((v) => (
<li
key={v.journal_entry_id}
className="flex items-center justify-between gap-3 rounded-sm border bg-card px-3 py-2 text-sm"
>
<div className="min-w-0 space-y-0.5">
<div className="flex items-center gap-2">
<span className="font-medium tabular-nums">{v.voucher_label}</span>
<span className="text-xs tabular-nums text-muted-foreground">
{formatDate(v.entry_date)}
</span>
</div>
{v.description && (
<p className="truncate text-xs text-muted-foreground">{v.description}</p>
)}
</div>
<span className="shrink-0 font-medium tabular-nums">
{formatCurrency(v.amount, 'SEK')}
</span>
</li>
))}
</ul>
)}
{!explainingAcknowledged && (
<div className="flex flex-col gap-2 sm:flex-row">
{explainingLinkable ? (
<Button
type="button"
size="sm"
onClick={handleLinkToExplaining}
disabled={linking || submitting}
className="sm:flex-1"
>
{linking ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Link2 className="mr-2 h-4 w-4" />
)}
{t('already_booked_link', { labels: explainingLabels })}
</Button>
) : (
<p className="text-xs text-muted-foreground sm:flex-1">
{t('already_booked_foreign_hint', { currency: txCurrency })}
</p>
)}
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setExplainingAcknowledged(true)}
disabled={linking || submitting}
className="text-muted-foreground"
>
{t('already_booked_book_anyway')}
</Button>
</div>
)}
{explainingAcknowledged && (
<p className="text-xs text-muted-foreground">
{t('already_booked_acknowledged_note', { labels: explainingLabels })}
</p>
)}
</div>
)}
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
@@ -424,6 +684,16 @@ export default function MatchAllocationDialog({
amount: formatCurrency(c.remaining, c.currency),
})}
</p>
{/* 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 && (
<Badge variant="warning" className="gap-1">
<AlertTriangle className="h-3 w-3" />
{t('invoiced_after_payment_badge', { date: formatDate(c.invoiceDate) })}
</Badge>
)}
</div>
{isSelected ? (
<div className="flex flex-col items-end gap-1">
@@ -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 && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('confirm')}
+19
View File
@@ -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
+7
View File
@@ -3644,6 +3644,13 @@ const MATCH_BATCH: Record<string, StructuredErrorEntry> = {
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.',
@@ -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<Record<string, unknown>> = {}) {
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([])
})
})
@@ -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'])
})
})
+179 -5
View File
@@ -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<DuplicatePaymentMatchReason, number> = {
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<DuplicatePaymentMatchReason, number> = {
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<DuplicatePaymentCandidate[]> {
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<CustomerInvoice, 'invoice_number'>
paymentAmount: number
paymentDate: string
},
): Promise<DuplicatePaymentCandidate[]> {
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<Row, 'id' | 'date' | 'amount' | 'description' | 'merchant_name' | 'reference'>
/**
* 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<DuplicatePaymentCandidate[]> {
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
+328
View File
@@ -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<ExplainingVoucherSet | null> {
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<SetLineRow>({
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<string, Candidate>()
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<string>()
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<ExplainingVoucherSet | null> {
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,
})
}
@@ -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()
})
})
+107
View File
@@ -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<T extends CoveringCandidate>(
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
}
+16 -1
View File
@@ -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.",
+16 -1
View File
@@ -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.",