diff --git a/app/api/transactions/[id]/match-invoice/preview/route.ts b/app/api/transactions/[id]/match-invoice/preview/route.ts index 1b3aeeb4..97958d74 100644 --- a/app/api/transactions/[id]/match-invoice/preview/route.ts +++ b/app/api/transactions/[id]/match-invoice/preview/route.ts @@ -18,7 +18,7 @@ import { NextResponse } from 'next/server' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils' -import { roundOre } from '@/lib/money' +import { roundOre, ORE_ROUNDING_SETTLEMENT_MAX } from '@/lib/money' import { getRevenueAccount, getOutputVatAccount } from '@/lib/bookkeeping/invoice-entries' import { buildInvoicePaymentClearingLines } from '@/lib/bookkeeping/invoice-payment-lines' import { fetchExchangeRate } from '@/lib/currency/riksbanken' @@ -170,7 +170,13 @@ export const GET = withRouteContext( // a guess — the dialog blocks confirm until a manual rate is entered and // the POST recomputes the real figure. const fxRateUnavailable = fxConversion.required && 'error' in fxConversion - const isFullyPaid = !fxRateUnavailable && newRemaining <= 0 + // Pure-SEK whole-krona settlements absorb a sub-krona remainder as + // öresavrundning (3740) and settle in full — mirror that here so the + // preview's fully-paid signal matches the committed verifikat. + const pureSek = transaction.currency === 'SEK' && invoice.currency === 'SEK' + const isFullyPaid = + !fxRateUnavailable && + (newRemaining <= 0 || (pureSek && newRemaining < ORE_ROUNDING_SETTLEMENT_MAX)) const invoiceAlreadyBooked = !!(invoice as { journal_entry_id?: string | null }).journal_entry_id const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash' && isFullyPaid diff --git a/app/api/transactions/[id]/match-invoice/route.ts b/app/api/transactions/[id]/match-invoice/route.ts index 9e3c970f..d40c3c3a 100644 --- a/app/api/transactions/[id]/match-invoice/route.ts +++ b/app/api/transactions/[id]/match-invoice/route.ts @@ -300,7 +300,12 @@ export const POST = withRouteContext( // Overshoot guard + paid/remaining math — shared with the v1 and agent // (commit) paths via planInvoicePayment so they cannot drift again. Runs // before any JE is created, so a doomed match never burns a voucher number. - const payment = planInvoicePayment(invoice, paidAmountInInvoiceCurrency) + // Pure-SEK settlements absorb sub-krona öresavrundning (booked to 3740 by + // buildInvoicePaymentClearingLines) so a whole-krona payment settles in full. + const pureSek = transaction.currency === 'SEK' && invoice.currency === 'SEK' + const payment = planInvoicePayment(invoice, paidAmountInInvoiceCurrency, { + absorbOreRounding: pureSek, + }) if (!payment.ok) { return errorResponseFromCode('MATCH_AMOUNT_EXCEEDS_REMAINING', txLog, { requestId, diff --git a/app/api/transactions/[id]/match-supplier-invoice/__tests__/route.test.ts b/app/api/transactions/[id]/match-supplier-invoice/__tests__/route.test.ts index 27608ab3..6a781842 100644 --- a/app/api/transactions/[id]/match-supplier-invoice/__tests__/route.test.ts +++ b/app/api/transactions/[id]/match-supplier-invoice/__tests__/route.test.ts @@ -38,6 +38,16 @@ vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({ createSupplierInvoiceCashEntry: (...args: unknown[]) => mockCreateCashEntry(...args), })) +// Pure-SEK clearing now posts via the shared builder + createJournalEntry +// (not createSupplierInvoicePaymentEntry). Mock the engine so that path doesn't +// hit the queued Supabase mock. +const mockCreateJournalEntry = vi.fn() +const mockFindFiscalPeriod = vi.fn() +vi.mock('@/lib/bookkeeping/engine', () => ({ + createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args), + findFiscalPeriod: (...args: unknown[]) => mockFindFiscalPeriod(...args), +})) + import { POST } from '../route' const mockUser = { id: 'user-1', email: 'test@test.se' } @@ -48,6 +58,8 @@ beforeEach(() => { mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) mockCreatePaymentEntry.mockResolvedValue({ id: 'je-1' }) mockCreateCashEntry.mockResolvedValue({ id: 'je-1' }) + mockCreateJournalEntry.mockResolvedValue({ id: 'je-1' }) + mockFindFiscalPeriod.mockResolvedValue('fp-1') }) const TX_UUID = '11111111-1111-4111-8111-111111111111' @@ -109,17 +121,26 @@ function enqueueHappyPath(opts: { } describe('POST /api/transactions/[id]/match-supplier-invoice — FX residual', () => { - it('passes no exchangeRateDifference for a SEK transaction paying a SEK invoice', async () => { + it('books a clean SEK clearing entry (no FX) for a SEK tx paying a SEK invoice', async () => { + // SEK/SEK now routes through buildSupplierPaymentClearingLines + + // createJournalEntry, not createSupplierInvoicePaymentEntry. An exact + // payment yields just Dr 2440 / Cr 1930 — no 3960/7960 FX line, no 3740. enqueueHappyPath({ transaction: { amount: -2390, currency: 'SEK' }, invoice: { currency: 'SEK', remaining_amount: 2390 }, }) await POST(makeReq(), createMockRouteParams({ id: TX_UUID })) - expect(mockCreatePaymentEntry).toHaveBeenCalledTimes(1) - const args = mockCreatePaymentEntry.mock.calls[0] - // (supabase, companyId, userId, invoice, paymentAmountSek, paymentDate, exchangeRateDifference?) - expect(args[4]).toBe(2390) // paymentAmountSek = actual bank SEK - expect(args[6]).toBeUndefined() // no FX diff + expect(mockCreatePaymentEntry).not.toHaveBeenCalled() + expect(mockCreateJournalEntry).toHaveBeenCalledTimes(1) + const input = mockCreateJournalEntry.mock.calls[0][3] as { + lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }> + } + expect(input.lines).toHaveLength(2) + expect(input.lines.find((l) => l.account_number === '2440')?.debit_amount).toBe(2390) + expect(input.lines.find((l) => l.account_number === '1930')?.credit_amount).toBe(2390) + expect( + input.lines.some((l) => ['3960', '7960', '3740'].includes(l.account_number)), + ).toBe(false) }) it('computes a loss when the SEK paid exceeds the AP booked SEK (EUR invoice)', async () => { @@ -197,6 +218,32 @@ describe('POST /api/transactions/[id]/match-supplier-invoice — non-FX paths', expect(body.remaining_amount).toBe(0) }) + it('öresavrundning: a whole-krona Bankgiro payment settles an öre-bearing invoice in full via 3740', async () => { + // The reported bug: invoice 11 231,25, bank paid 11 231 → previously left + // 0,25 stranded as partially_paid. Now → paid, with 0,25 booked to 3740. + enqueueHappyPath({ + transaction: { amount: -11231, currency: 'SEK' }, + invoice: { currency: 'SEK', remaining_amount: 11231.25 }, + }) + const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID })) + const { status, body } = await parseJsonResponse<{ + invoice_status: string + paid_amount: number + remaining_amount: number + }>(res) + expect(status).toBe(200) + expect(body.invoice_status).toBe('paid') + expect(body.remaining_amount).toBe(0) + expect(body.paid_amount).toBe(11231.25) + expect(mockCreatePaymentEntry).not.toHaveBeenCalled() + const input = mockCreateJournalEntry.mock.calls[0][3] as { + lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }> + } + expect(input.lines.find((l) => l.account_number === '2440')?.debit_amount).toBe(11231.25) + expect(input.lines.find((l) => l.account_number === '1930')?.credit_amount).toBe(11231) + expect(input.lines.find((l) => l.account_number === '3740')?.credit_amount).toBe(0.25) + }) + it('returns 400 MATCH_SI_AMOUNT_EXCEEDS_REMAINING when tx exceeds invoice remaining (same currency)', async () => { // Tx pays out 6 000 SEK, invoice has 5 000 SEK remaining. Legacy code path // would push paid_amount past invoice.total. The new guard rejects so the @@ -314,4 +361,25 @@ describe('POST /api/transactions/[id]/match-supplier-invoice — cash method + F expect(body.error.code).toBe('MATCH_SI_CASH_FX_UNSUPPORTED') expect(mockCreateCashEntry).not.toHaveBeenCalled() }) + + it('does NOT absorb öre under the cash method — a SEK sub-krona diff stays partial', async () => { + // Kontantmetoden books the full invoice via the cash entry (not the bank + // amount), so folding the 0,25 to 3740 would hide a 1930 discrepancy. The + // öre band is accrual-only; here the invoice stays partially_paid. + enqueueHappyPath({ + transaction: { amount: -11231, currency: 'SEK' }, + invoice: { currency: 'SEK', remaining_amount: 11231.25 }, + accountingMethod: 'cash', + }) + const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID })) + const { status, body } = await parseJsonResponse<{ + invoice_status: string + remaining_amount: number + }>(res) + expect(status).toBe(200) + expect(mockCreateCashEntry).toHaveBeenCalledTimes(1) + expect(mockCreateJournalEntry).not.toHaveBeenCalled() // no 3740 clearing entry + expect(body.invoice_status).toBe('partially_paid') + expect(body.remaining_amount).toBe(0.25) + }) }) diff --git a/app/api/transactions/[id]/match-supplier-invoice/preview/route.ts b/app/api/transactions/[id]/match-supplier-invoice/preview/route.ts index 07172ca8..bca46143 100644 --- a/app/api/transactions/[id]/match-supplier-invoice/preview/route.ts +++ b/app/api/transactions/[id]/match-supplier-invoice/preview/route.ts @@ -12,6 +12,8 @@ import { z } from 'zod' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils' +import { buildSupplierPaymentClearingLines } from '@/lib/bookkeeping/supplier-payment-lines' +import { ORE_TOLERANCE } from '@/lib/money' import type { SupplierInvoice, SupplierInvoiceItem } from '@/types' type PreviewLine = { @@ -81,6 +83,10 @@ export const GET = withRouteContext( const lines: PreviewLine[] = [] let entryType: 'clearing' | 'cash' = 'clearing' + // Drives the dialog's "markeras som betald" / öresavrundning copy. Cash + // entries always book the full invoice, so they default to fully paid. + let isFullyPaid = true + let oreRounding = false if (useCashEntry) { entryType = 'cash' @@ -158,26 +164,52 @@ export const GET = withRouteContext( } else { // Clearing: Dr 2440 / Cr 1930 (or chosen payment account). const si = invoice as SupplierInvoice - const amountSek = resolveSekAmount( - Math.abs(transaction.amount), - null, - transaction.currency, - null, - ) - const total = resolveSekAmount(si.total, si.total_sek, si.currency, si.exchange_rate) - const amount = Math.round(Math.min(amountSek, total) * 100) / 100 - lines.push({ - account_number: '2440', - debit_amount: amount, - credit_amount: 0, - description: 'Kvittning leverantörsskuld', - }) - lines.push({ - account_number: paymentAccount, - debit_amount: 0, - credit_amount: amount, - description: 'Utbetalning från bank', - }) + const isPureSek = transaction.currency === 'SEK' && si.currency === 'SEK' + if (isPureSek) { + // Shared builder so the previewed lines — including any 3740 + // öresavrundning row — are byte-identical to what the POST commits. + const remainingSek = si.remaining_amount ?? si.total + const bankSek = Math.abs(transaction.amount) + const { lines: clearingLines, oreDiffSek } = buildSupplierPaymentClearingLines({ + apSek: remainingSek, + bankSek, + paymentAccount, + }) + for (const l of clearingLines) { + lines.push({ + account_number: l.account_number, + debit_amount: l.debit_amount, + credit_amount: l.credit_amount, + description: l.line_description ?? '', + }) + } + oreRounding = oreDiffSek !== 0 + // Full settlement when the öre residual is absorbed or the bank covers + // the whole remaining; a ≥1 kr short payment leaves a partial. + isFullyPaid = oreRounding || bankSek >= remainingSek - ORE_TOLERANCE + } else { + const amountSek = resolveSekAmount( + Math.abs(transaction.amount), + null, + transaction.currency, + null, + ) + const total = resolveSekAmount(si.total, si.total_sek, si.currency, si.exchange_rate) + const amount = Math.round(Math.min(amountSek, total) * 100) / 100 + lines.push({ + account_number: '2440', + debit_amount: amount, + credit_amount: 0, + description: 'Kvittning leverantörsskuld', + }) + lines.push({ + account_number: paymentAccount, + debit_amount: 0, + credit_amount: amount, + description: 'Utbetalning från bank', + }) + isFullyPaid = amount >= total - ORE_TOLERANCE + } } return NextResponse.json({ @@ -185,6 +217,8 @@ export const GET = withRouteContext( lines, invoice_already_booked: siAlreadyBooked, accounting_method: accountingMethod, + is_fully_paid: isFullyPaid, + ore_rounding: oreRounding, }) }, ) diff --git a/app/api/transactions/[id]/match-supplier-invoice/route.ts b/app/api/transactions/[id]/match-supplier-invoice/route.ts index 102b135d..007e4064 100644 --- a/app/api/transactions/[id]/match-supplier-invoice/route.ts +++ b/app/api/transactions/[id]/match-supplier-invoice/route.ts @@ -3,6 +3,8 @@ import { createSupplierInvoicePaymentEntry, createSupplierInvoiceCashEntry, } from '@/lib/bookkeeping/supplier-invoice-entries' +import { buildSupplierPaymentClearingLines } from '@/lib/bookkeeping/supplier-payment-lines' +import { planSupplierPayment } from '@/lib/invoices/apply-supplier-payment' import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine' import { isBookkeepingError } from '@/lib/bookkeeping/errors' import { getErrorMessage } from '@/lib/errors/get-error-message' @@ -82,26 +84,10 @@ export const POST = withRouteContext( const txAmountAbs = Math.abs(transaction.amount) - // Overshoot guard for the same-currency branch. The legacy code path used - // txAmountAbs wholesale and would push supplier_invoices.paid_amount past - // invoice.total whenever the bank transaction was larger than what was - // owed. Reject and direct the user at the split-payment flow which can - // allocate the excess to additional supplier invoices. - // FX branch (currency mismatch) is already clamped below to - // invoice.remaining_amount, so it cannot overshoot. - if ( - transaction.currency === invoice.currency && - txAmountAbs > invoice.remaining_amount + 0.005 - ) { - return errorResponseFromCode('MATCH_SI_AMOUNT_EXCEEDS_REMAINING', txLog, { - requestId, - details: { - transaction_amount: txAmountAbs, - remaining_amount: Math.round(invoice.remaining_amount * 100) / 100, - excess: Math.round((txAmountAbs - invoice.remaining_amount) * 100) / 100, - }, - }) - } + // Pure SEK settlements route through the shared clearing builder so öre + // rounding lands on 3740 and the invoice settles in full; foreign legs keep + // the kursvinst/kursförlust path in createSupplierInvoicePaymentEntry. + const isPureSek = transaction.currency === 'SEK' && invoice.currency === 'SEK' // Amount in the *invoice's* currency — used to update // supplier_invoices.paid_amount/remaining_amount and the @@ -116,6 +102,43 @@ export const POST = withRouteContext( ? txAmountAbs : invoice.remaining_amount + const { data: settings } = await supabase + .from('company_settings') + .select('accounting_method, last_supplier_payment_account') + .eq('company_id', companyId) + .single() + + const accountingMethod = settings?.accounting_method || 'accrual' + // Same default the preview route uses, so the committed verifikat credits the + // same account the user saw previewed (the old path hardcoded 1930 here). + const paymentAccount = + (settings as { last_supplier_payment_account?: string } | null)?.last_supplier_payment_account || '1930' + + // Route on the supplier invoice's actual booking state — if 2440 was posted + // at receipt (accrual), the match clears 2440 regardless of the company's + // current setting. Only true kontantmetoden invoices (no registration JE) + // book expense + input VAT here. + const siAlreadyBooked = !!(invoice as { registration_journal_entry_id?: string | null }).registration_journal_entry_id + const useCashEntry = !siAlreadyBooked && accountingMethod === 'cash' + + // Ledger math + overshoot guard in one place (mirrors planInvoicePayment on + // the customer side). Öre absorption applies ONLY to the accrual SEK clearing + // path: there a whole-krona payment within 1 kr settles the invoice in full + // and the residual is booked to 3740 by the line builder. Cash-method entries + // book the full invoice total (not the bank amount), so absorbing there would + // mark the invoice paid while leaving a hidden 1930 discrepancy — keep strict. + // Rejecting here, BEFORE any JE is created, keeps a doomed overshoot from + // burning a voucher number. + const paymentPlan = planSupplierPayment(invoice, paymentAmountInvoiceCurrency, { + absorbOreRounding: isPureSek && !useCashEntry, + }) + if (!paymentPlan.ok) { + return errorResponseFromCode('MATCH_SI_AMOUNT_EXCEEDS_REMAINING', txLog, { + requestId, + details: paymentPlan.details, + }) + } + // SEK that actually left the bank, when we know it. SEK transaction → the // absolute amount; foreign transaction with a stored amount_sek → that // value; foreign transaction WITHOUT amount_sek → unknown (null). The raw @@ -162,21 +185,6 @@ export const POST = withRouteContext( const now = new Date().toISOString() - const { data: settings } = await supabase - .from('company_settings') - .select('accounting_method') - .eq('company_id', companyId) - .single() - - const accountingMethod = settings?.accounting_method || 'accrual' - - // Route on the supplier invoice's actual booking state — if 2440 was - // posted at receipt (accrual), the match must clear 2440 regardless of - // the company's current setting. Only true kontantmetoden invoices - // (no registration JE) book expense + input VAT here. - const siAlreadyBooked = !!(invoice as { registration_journal_entry_id?: string | null }).registration_journal_entry_id - const useCashEntry = !siAlreadyBooked && accountingMethod === 'cash' - // A full settlement pays off the whole remaining balance. Cross-currency // matches always do (paymentAmountInvoiceCurrency is clamped to // invoice.remaining_amount above); same-currency does when the bank amount @@ -205,6 +213,11 @@ export const POST = withRouteContext( }) } + // Verifikat header description, shared by every booking branch below. + const desc = invoice.supplier?.name + ? `Utbetalning leverantörsfaktura ${invoice.supplier_invoice_number}, ${invoice.supplier.name}` + : `Utbetalning leverantörsfaktura ${invoice.supplier_invoice_number}` + let journalEntryId: string | null = null let journalEntryError: string | null = null @@ -226,9 +239,6 @@ export const POST = withRouteContext( }) } const sourceType = useCashEntry ? 'supplier_invoice_cash_payment' : 'supplier_invoice_paid' - const desc = invoice.supplier?.name - ? `Utbetalning leverantörsfaktura ${invoice.supplier_invoice_number}, ${invoice.supplier.name}` - : `Utbetalning leverantörsfaktura ${invoice.supplier_invoice_number}` const journalEntry = await createJournalEntry(supabase, companyId!, user.id, { fiscal_period_id: fiscalPeriodId, entry_date: transaction.date, @@ -252,6 +262,32 @@ export const POST = withRouteContext( exchangeRateDifference !== 0 && fullSettlement ? actualBankSek : undefined, ) if (journalEntry) journalEntryId = journalEntry.id + } else if (isPureSek) { + // SEK clearing through the shared builder: a sub-krona difference is + // booked to 3740 and 2440 is cleared in full (invoice → paid); an exact + // or ≥1 kr-short payment clears what moved. Byte-identical to the preview + // (same payment account + line descriptions). No FX here by definition. + const fiscalPeriodId = await findFiscalPeriod(supabase, companyId!, transaction.date) + if (!fiscalPeriodId) { + return errorResponseFromCode('INVOICE_PAID_NO_FISCAL_PERIOD', txLog, { + requestId, + details: { paymentDate: transaction.date }, + }) + } + const { lines } = buildSupplierPaymentClearingLines({ + apSek: invoice.remaining_amount, + bankSek: txAmountAbs, + paymentAccount, + }) + const journalEntry = await createJournalEntry(supabase, companyId!, user.id, { + fiscal_period_id: fiscalPeriodId, + entry_date: transaction.date, + description: desc, + source_type: 'supplier_invoice_paid', + source_id: invoice.id, + lines, + }) + if (journalEntry) journalEntryId = journalEntry.id } else { const journalEntry = await createSupplierInvoicePaymentEntry( supabase, companyId, user.id, invoice as SupplierInvoice, @@ -271,10 +307,10 @@ export const POST = withRouteContext( } } - const newRemaining = Math.max(0, Math.round((invoice.remaining_amount - paymentAmountInvoiceCurrency) * 100) / 100) - const newPaidAmount = Math.round((invoice.paid_amount + paymentAmountInvoiceCurrency) * 100) / 100 - const isFullyPaid = newRemaining <= 0 - const newStatus = isFullyPaid ? 'paid' : 'partially_paid' + // Ledger update from the plan computed up front. An öre-absorbed settlement + // reports remaining 0 / status paid even though the bank paid a sub-krona + // less (or more) — the residual lives on 3740, not the supplier ledger. + const { newRemaining, newPaidAmount, isFullyPaid, newStatus } = paymentPlan.plan const { data: updatedRows, error: updateInvError } = await supabase .from('supplier_invoices') diff --git a/components/transactions/InvoiceMatchDialog.tsx b/components/transactions/InvoiceMatchDialog.tsx index 9db1d912..084b9af4 100644 --- a/components/transactions/InvoiceMatchDialog.tsx +++ b/components/transactions/InvoiceMatchDialog.tsx @@ -449,7 +449,14 @@ export default function InvoiceMatchDialog({ // built by buildInvoicePaymentClearingLines, which posts the // FX diff to 3960/7960 so the books balance correctly even // when the on-screen numbers can't be naively compared. - const amountsMatch = sameCurrency && Math.abs(txAbs - invRemaining) < 0.01 + const diff = Math.abs(txAbs - invRemaining) + const amountsMatch = sameCurrency && diff < 0.01 + // A sub-krona SEK difference is öresavrundning: the backend books + // it to 3740 and settles the invoice in full instead of leaving it + // delbetald (see ORE_ROUNDING_SETTLEMENT_MAX). SEK only — keep the + // 1 kr band in sync with the server constant. + const isOreRounding = + sameCurrency && transaction.currency === 'SEK' && diff >= 0.01 && diff < 1.0 if (amountsMatch) { return ( @@ -460,6 +467,19 @@ export default function InvoiceMatchDialog({ ) } + if (isOreRounding) { + return ( +
+ +

+ {t('ore_rounding_note', { + amount: formatCurrency(diff, transaction.currency), + })} +

+
+ ) + } + return (
diff --git a/lib/bookkeeping/__tests__/invoice-payment-lines.test.ts b/lib/bookkeeping/__tests__/invoice-payment-lines.test.ts index 3c51833e..7b27f4da 100644 --- a/lib/bookkeeping/__tests__/invoice-payment-lines.test.ts +++ b/lib/bookkeeping/__tests__/invoice-payment-lines.test.ts @@ -201,6 +201,50 @@ describe('buildInvoicePaymentClearingLines', () => { }) }) + describe('öresavrundning (pure SEK, sub-krona difference → 3740)', () => { + it('customer paid a sub-krona SHORT: clears full 1510, books 3740 debit (förlust)', () => { + const result = buildInvoicePaymentClearingLines( + { amount: 1000, amount_sek: null, currency: 'SEK', exchange_rate: null }, + { currency: 'SEK', exchange_rate: null, remaining_amount: 1000.25, total: 1000.25, paid_amount: 0 }, + 'Inbetalning kundfaktura', + ) + expect(result.arSek).toBe(1000.25) // full remaining cleared → invoice settles + expect(result.oreRoundingSek).toBe(0.25) + expect(result.lines).toHaveLength(3) + expect(result.lines.find((l) => l.account_number === '1930')?.debit_amount).toBe(1000) + expect(result.lines.find((l) => l.account_number === '1510')?.credit_amount).toBe(1000.25) + expect(result.lines.find((l) => l.account_number === '3740')?.debit_amount).toBe(0.25) + const debit = result.lines.reduce((s, l) => s + l.debit_amount, 0) + const credit = result.lines.reduce((s, l) => s + l.credit_amount, 0) + expect(Math.round((debit - credit) * 100)).toBe(0) + }) + + it('customer paid a sub-krona OVER: clears full 1510, books 3740 credit (vinst)', () => { + const result = buildInvoicePaymentClearingLines( + { amount: 1000.25, amount_sek: null, currency: 'SEK', exchange_rate: null }, + { currency: 'SEK', exchange_rate: null, remaining_amount: 1000, total: 1000, paid_amount: 0 }, + 'Inbetalning kundfaktura', + ) + expect(result.arSek).toBe(1000) + expect(result.oreRoundingSek).toBe(-0.25) + expect(result.lines.find((l) => l.account_number === '3740')?.credit_amount).toBe(0.25) + const debit = result.lines.reduce((s, l) => s + l.debit_amount, 0) + const credit = result.lines.reduce((s, l) => s + l.credit_amount, 0) + expect(Math.round((debit - credit) * 100)).toBe(0) + }) + + it('a ≥1 kr shortfall stays a partial (no 3740, AR = bank)', () => { + const result = buildInvoicePaymentClearingLines( + { amount: 600, amount_sek: null, currency: 'SEK', exchange_rate: null }, + { currency: 'SEK', exchange_rate: null, remaining_amount: 1000, total: 1000, paid_amount: 0 }, + 'Delbetalning kundfaktura', + ) + expect(result.arSek).toBe(600) + expect(result.oreRoundingSek).toBe(0) + expect(result.lines).toHaveLength(2) + }) + }) + describe('cross currency (USD invoice + USD tx)', () => { it('uses tx amount_sek for the bank-leg when populated', () => { // USD-denominated bank account paying a USD invoice — ingest converts diff --git a/lib/bookkeeping/__tests__/supplier-payment-lines.test.ts b/lib/bookkeeping/__tests__/supplier-payment-lines.test.ts new file mode 100644 index 00000000..5f684695 --- /dev/null +++ b/lib/bookkeeping/__tests__/supplier-payment-lines.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest' +import { buildSupplierPaymentClearingLines } from '@/lib/bookkeeping/supplier-payment-lines' +import { sumOre } from '@/lib/money' + +function sumDebit(lines: Array<{ debit_amount: number }>): number { + return sumOre(lines.map((l) => l.debit_amount)) +} +function sumCredit(lines: Array<{ credit_amount: number }>): number { + return sumOre(lines.map((l) => l.credit_amount)) +} +function line(lines: Array<{ account_number: string }>, acct: string) { + return lines.find((l) => l.account_number === acct) +} + +describe('buildSupplierPaymentClearingLines', () => { + it('books the öre residual to 3740 (credit) when the bank paid a sub-krona LESS — the reported 11 231,25 / 11 231,00 case', () => { + const { lines, oreDiffSek } = buildSupplierPaymentClearingLines({ + apSek: 11231.25, + bankSek: 11231, + paymentAccount: '1930', + }) + expect(oreDiffSek).toBe(0.25) + // 2440 cleared in FULL so the invoice settles; bank leg = actual SEK paid. + expect(line(lines, '2440')?.debit_amount).toBe(11231.25) + expect(line(lines, '1930')?.credit_amount).toBe(11231) + expect(line(lines, '3740')?.credit_amount).toBe(0.25) + expect(line(lines, '3740')?.debit_amount).toBe(0) + // Balances to the öre. + expect(sumDebit(lines)).toBe(sumCredit(lines)) + }) + + it('books the öre residual to 3740 (debit) when the bank paid a sub-krona MORE', () => { + const { lines, oreDiffSek } = buildSupplierPaymentClearingLines({ + apSek: 11231, + bankSek: 11231.25, + paymentAccount: '1930', + }) + expect(oreDiffSek).toBe(-0.25) + expect(line(lines, '2440')?.debit_amount).toBe(11231) + expect(line(lines, '1930')?.credit_amount).toBe(11231.25) + expect(line(lines, '3740')?.debit_amount).toBe(0.25) + expect(sumDebit(lines)).toBe(sumCredit(lines)) + }) + + it('emits no 3740 line for an exact settlement', () => { + const { lines, oreDiffSek } = buildSupplierPaymentClearingLines({ + apSek: 2390, + bankSek: 2390, + paymentAccount: '1930', + }) + expect(oreDiffSek).toBe(0) + expect(lines).toHaveLength(2) + expect(line(lines, '3740')).toBeUndefined() + expect(line(lines, '2440')?.debit_amount).toBe(2390) + expect(line(lines, '1930')?.credit_amount).toBe(2390) + }) + + it('treats a ≥1 kr shortfall as a genuine partial — clamps to the bank amount, no 3740', () => { + const { lines, oreDiffSek } = buildSupplierPaymentClearingLines({ + apSek: 11231.25, + bankSek: 5000, + paymentAccount: '1930', + }) + expect(oreDiffSek).toBe(0) + expect(lines).toHaveLength(2) + expect(line(lines, '3740')).toBeUndefined() + // Only what actually moved clears 2440 — the remainder stays a partial. + expect(line(lines, '2440')?.debit_amount).toBe(5000) + expect(line(lines, '1930')?.credit_amount).toBe(5000) + }) + + it('honours the 1 kr band boundary: 0,99 absorbs, exactly 1,00 does not', () => { + const absorbed = buildSupplierPaymentClearingLines({ apSek: 1000.99, bankSek: 1000, paymentAccount: '1930' }) + expect(absorbed.oreDiffSek).toBe(0.99) + expect(line(absorbed.lines, '3740')?.credit_amount).toBe(0.99) + + const notAbsorbed = buildSupplierPaymentClearingLines({ apSek: 1001, bankSek: 1000, paymentAccount: '1930' }) + expect(notAbsorbed.oreDiffSek).toBe(0) + expect(line(notAbsorbed.lines, '3740')).toBeUndefined() + expect(line(notAbsorbed.lines, '2440')?.debit_amount).toBe(1000) // clamped + }) + + it('credits the chosen payment account, not a hardcoded 1930', () => { + const { lines } = buildSupplierPaymentClearingLines({ apSek: 500, bankSek: 500, paymentAccount: '1932' }) + expect(line(lines, '1932')?.credit_amount).toBe(500) + expect(line(lines, '1930')).toBeUndefined() + }) +}) diff --git a/lib/bookkeeping/handlers/__tests__/supplier-invoice-handler.test.ts b/lib/bookkeeping/handlers/__tests__/supplier-invoice-handler.test.ts index 9cf01ae1..60679b6d 100644 --- a/lib/bookkeeping/handlers/__tests__/supplier-invoice-handler.test.ts +++ b/lib/bookkeeping/handlers/__tests__/supplier-invoice-handler.test.ts @@ -13,12 +13,72 @@ vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({ createSupplierInvoiceRegistrationEntry: vi.fn(), })) +vi.mock('@/lib/invoices/match-log', () => ({ + logMatchEvent: vi.fn(), +})) + import { createClient } from '@/lib/supabase/server' import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries' +import { logMatchEvent } from '@/lib/invoices/match-log' +import { makeTransaction } from '@/tests/helpers' import { registerSupplierInvoiceHandler } from '../supplier-invoice-handler' const mockCreateClient = vi.mocked(createClient) const mockCreateEntry = vi.mocked(createSupplierInvoiceRegistrationEntry) +const mockLogMatch = vi.mocked(logMatchEvent) + +/** + * Minimal Supabase mock for the retro-match handler that RECORDS the + * transactions `.update()` payloads (the queued proxy mock can't), so we can + * assert it writes a suggestion column rather than an auto-link. + */ +function makeRetroMock(opts: { + invoice: unknown + linkedCount: number + candidates: unknown[] +}) { + const updates: Record[] = [] + const chain = (result: unknown): unknown => + new Proxy( + {}, + { + get(_t, prop) { + if (prop === 'then') return (resolve: (v: unknown) => void) => resolve(result) + return () => chain(result) + }, + }, + ) + const supabase = { + from: vi.fn((table: string) => { + if (table === 'supplier_invoices') return chain({ data: opts.invoice, error: null }) + if (table === 'transactions') { + return { + select: (_cols: string, selOpts?: { count?: string }) => + selOpts?.count + ? chain({ count: opts.linkedCount, data: null, error: null }) + : chain({ data: opts.candidates, error: null }), + update: (payload: Record) => { + updates.push(payload) + return chain({ data: null, error: null }) + }, + } + } + return chain({ data: null, error: null }) + }), + } + return { supabase, updates } +} + +function emitRegistered(invoiceId: string) { + return eventBus.emit({ + type: 'supplier_invoice.registered', + payload: { + supplierInvoice: { id: invoiceId } as never, + userId: 'user-1', + companyId: 'company-1', + }, + }) +} describe('Supplier Invoice Core Handler', () => { let unsubscribe: () => void @@ -189,4 +249,90 @@ describe('Supplier Invoice Core Handler', () => { expect(mockCreateEntry).not.toHaveBeenCalled() }) + + describe('retroactive match on supplier_invoice.registered', () => { + const baseInvoice = () => + makeSupplierInvoice({ + id: 'si-retro', + status: 'registered', + remaining_amount: 29890, + total: 29890, + invoice_date: '2026-06-05', + due_date: '2026-07-05', + transaction_id: null, + payment_reference: null, + }) + + const matchingTx = () => + makeTransaction({ + id: 'tx-retro', + amount: -29890, // exact, in-window → Pass 3 amount_date (0.85) + date: '2026-06-08', + description: 'Bg-bet via internet', + reference: null, + supplier_invoice_id: null, + journal_entry_id: null, + }) + + it('writes a SUGGESTION (never an auto-link) for an exact in-window payment', async () => { + const { supabase, updates } = makeRetroMock({ + invoice: baseInvoice(), + linkedCount: 0, + candidates: [matchingTx()], + }) + mockCreateClient.mockResolvedValue(supabase as never) + + await emitRegistered('si-retro') + + // The whole point of "pre-fill, confirm to book": suggestion column only. + expect(updates).toEqual([{ potential_supplier_invoice_id: 'si-retro' }]) + expect(mockLogMatch).toHaveBeenCalledWith( + expect.anything(), + 'user-1', + 'tx-retro', + 'auto_suggested', + expect.objectContaining({ + supplierInvoiceId: 'si-retro', + matchMethod: 'amount_date', + matchConfidence: 0.85, + }), + ) + }) + + it('no-ops when the invoice is already tied to a transaction', async () => { + const { supabase, updates } = makeRetroMock({ + invoice: { ...baseInvoice(), transaction_id: 'tx-existing' }, + linkedCount: 0, + candidates: [matchingTx()], + }) + mockCreateClient.mockResolvedValue(supabase as never) + await emitRegistered('si-retro') + expect(updates).toHaveLength(0) + expect(mockLogMatch).not.toHaveBeenCalled() + }) + + it('no-ops (idempotent) when a transaction is already linked to the invoice', async () => { + const { supabase, updates } = makeRetroMock({ + invoice: baseInvoice(), + linkedCount: 1, + candidates: [matchingTx()], + }) + mockCreateClient.mockResolvedValue(supabase as never) + await emitRegistered('si-retro') + expect(updates).toHaveLength(0) + expect(mockLogMatch).not.toHaveBeenCalled() + }) + + it('no-ops when no candidate transaction matches', async () => { + const { supabase, updates } = makeRetroMock({ + invoice: baseInvoice(), + linkedCount: 0, + candidates: [], + }) + mockCreateClient.mockResolvedValue(supabase as never) + await emitRegistered('si-retro') + expect(updates).toHaveLength(0) + expect(mockLogMatch).not.toHaveBeenCalled() + }) + }) }) diff --git a/lib/bookkeeping/handlers/supplier-invoice-handler.ts b/lib/bookkeeping/handlers/supplier-invoice-handler.ts index 17ed1596..1babfea5 100644 --- a/lib/bookkeeping/handlers/supplier-invoice-handler.ts +++ b/lib/bookkeeping/handlers/supplier-invoice-handler.ts @@ -2,8 +2,10 @@ import { eventBus } from '@/lib/events/bus' import type { EventPayload } from '@/lib/events/types' import { createClient } from '@/lib/supabase/server' import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries' +import { findSupplierInvoiceMatch } from '@/lib/invoices/supplier-invoice-matching' +import { logMatchEvent } from '@/lib/invoices/match-log' import { createLogger } from '@/lib/logger' -import type { SupplierInvoiceItem } from '@/types' +import type { SupplierInvoice, SupplierInvoiceItem, Transaction } from '@/types' const log = createLogger('supplier-invoice-handler') @@ -88,9 +90,122 @@ async function handleSupplierInvoiceConfirmed( } /** - * Register the core supplier invoice handler on the event bus. - * Returns an unsubscribe function. + * Retroactive match: when a supplier invoice is registered or approved, scan + * recent unmatched expense transactions for the bank payment that settles it. + * + * The forward direction (a freshly imported tx scanning existing invoices) lives + * in lib/transactions/ingest.ts. This is the mirror — needed because a Bankgiro + * payment is often imported BEFORE the invoice is registered, and nothing + * re-matched it afterwards (the reported RosholmDell case). Reuses the same + * `findSupplierInvoiceMatch` scorer (one invoice, many txs) so the two + * directions can never score differently. + * + * Writes a SUGGESTION (potential_supplier_invoice_id), never an auto-link + * (supplier_invoice_id): the match card / confirm dialog only surfaces for the + * suggestion column (transactions page + lib/worklist), and the product choice + * is "pre-fill, confirm to book" — the user reviews and posts the verifikat. + * Setting supplier_invoice_id directly would skip that confirmation and strand + * the payment unbooked. This handler therefore never creates a journal entry. + */ +async function handleSupplierInvoiceRetroMatch( + // .registered and .approved share this payload shape. + payload: EventPayload<'supplier_invoice.registered'> +): Promise { + const { supplierInvoice, userId, companyId } = payload + + try { + const supabase = await createClient() + + // Re-fetch with the supplier relation (the scorer reads bankgiro/plusgiro/ + // name) — the emitted payload can be stale or lack the join. + const { data: invoice } = await supabase + .from('supplier_invoices') + .select('*, supplier:suppliers(*)') + .eq('id', supplierInvoice.id) + .eq('company_id', companyId) + .single() + + if (!invoice) return + if (!['registered', 'approved'].includes(invoice.status)) return + if ((invoice.remaining_amount ?? invoice.total) <= 0) return + if (invoice.transaction_id) return // already settled by a bank tx + + // Idempotency: if a tx already points at this invoice (a prior retro run, or + // ingest's forward match), don't add a competing suggestion. + const { count: linkedCount } = await supabase + .from('transactions') + .select('id', { count: 'exact', head: true }) + .eq('company_id', companyId) + .eq('supplier_invoice_id', invoice.id) + if (linkedCount && linkedCount > 0) return + + // Bound the scan: ~90 days before the invoice/due date covers normal terms + // and the early-payment case, without trawling the whole ledger. + const anchor = invoice.invoice_date || invoice.due_date + if (!anchor) return + const anchorMs = new Date(anchor).getTime() + const lowDate = new Date(anchorMs - 90 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10) + + const { data: candidates } = await supabase + .from('transactions') + .select('*') + .eq('company_id', companyId) + .is('supplier_invoice_id', null) + .is('potential_supplier_invoice_id', null) + .is('journal_entry_id', null) + .lt('amount', 0) + .gte('date', lowDate) + .order('date', { ascending: false }) + .limit(200) + + if (!candidates || candidates.length === 0) return + + // Pick the best candidate: highest confidence, tie-break on the payment + // closest to the invoice date. + let best: { tx: Transaction; confidence: number; matchMethod: string } | null = null + for (const tx of candidates) { + const match = findSupplierInvoiceMatch(tx as Transaction, [invoice as SupplierInvoice]) + if (!match) continue + const closer = + best !== null && + match.confidence === best.confidence && + Math.abs(new Date(tx.date).getTime() - anchorMs) < + Math.abs(new Date(best.tx.date).getTime() - anchorMs) + if (!best || match.confidence > best.confidence || closer) { + best = { tx: tx as Transaction, confidence: match.confidence, matchMethod: match.matchMethod } + } + } + + if (!best) return + + // Suggestion only. The `.is('supplier_invoice_id', null)` guard avoids a + // race where the tx was linked between the scan and this write. + await supabase + .from('transactions') + .update({ potential_supplier_invoice_id: invoice.id }) + .eq('id', best.tx.id) + .is('supplier_invoice_id', null) + + logMatchEvent(supabase, userId, best.tx.id, 'auto_suggested', { + supplierInvoiceId: invoice.id, + matchConfidence: best.confidence, + matchMethod: best.matchMethod, + }) + } catch (err) { + // Never break invoice registration — this is a best-effort convenience. + log.error('Retroactive supplier-invoice match failed:', err) + } +} + +/** + * Register the core supplier invoice handlers on the event bus. + * Returns a combined unsubscribe function. */ export function registerSupplierInvoiceHandler(): () => void { - return eventBus.on('supplier_invoice.confirmed', handleSupplierInvoiceConfirmed) + const unsubscribers = [ + eventBus.on('supplier_invoice.confirmed', handleSupplierInvoiceConfirmed), + eventBus.on('supplier_invoice.registered', handleSupplierInvoiceRetroMatch), + eventBus.on('supplier_invoice.approved', handleSupplierInvoiceRetroMatch), + ] + return () => unsubscribers.forEach((unsub) => unsub()) } diff --git a/lib/bookkeeping/invoice-payment-lines.ts b/lib/bookkeeping/invoice-payment-lines.ts index 7f8a3efb..bde700c2 100644 --- a/lib/bookkeeping/invoice-payment-lines.ts +++ b/lib/bookkeeping/invoice-payment-lines.ts @@ -50,6 +50,7 @@ * partially_paid. */ import type { CreateJournalEntryLineInput } from '@/types' +import { ORE_TOLERANCE, ORE_ROUNDING_SETTLEMENT_MAX } from '@/lib/money' import { resolveSekAmount } from './currency-utils' const TWO_DP = (n: number): number => Math.round(n * 100) / 100 @@ -92,6 +93,14 @@ export interface PaymentClearingLines { * in caller logic without reading this paragraph. */ fxDiffSek: number + /** + * Öresavrundning residual (SEK), pure-SEK same-currency settlements only. + * remainingSek − bankSek: >0 → customer paid a sub-krona short (3740 debit, + * förlust); <0 → paid a sub-krona over (3740 credit, vinst); 0 → no 3740 line. + * When non-zero the AR leg (1510) is credited the FULL remaining so the + * invoice settles, and the residual balances the verifikat via 3740. + */ + oreRoundingSek: number lines: CreateJournalEntryLineInput[] } @@ -141,11 +150,27 @@ export function buildInvoicePaymentClearingLines( const sameCurrency = tx.currency === invoice.currency const invoiceIsForeign = invoice.currency !== 'SEK' + // Pure SEK both sides: the only place whole-krona öresavrundning applies. + const pureSek = sameCurrency && invoice.currency === 'SEK' let arSek: number let fxDiffSek: number + let oreRoundingSek = 0 - if (sameCurrency || !invoiceIsForeign) { + if (pureSek) { + // A whole-krona bank settlement of an öre-bearing SEK invoice leaves a + // sub-krona residual. Clear the FULL remaining off 1510 (invoice → paid) + // and let 3740 absorb the öre; a ≥1 kr short payment stays a real partial. + const remainingSek = TWO_DP(invoice.remaining_amount ?? invoice.total - (invoice.paid_amount ?? 0)) + const oreDiff = TWO_DP(remainingSek - bankSek) + if (oreDiff !== 0 && Math.abs(oreDiff) < ORE_ROUNDING_SETTLEMENT_MAX) { + arSek = remainingSek + oreRoundingSek = oreDiff + } else { + arSek = bankSek + } + fxDiffSek = 0 + } else if (sameCurrency || !invoiceIsForeign) { // Same currency (or SEK invoice paid by SEK tx): the customer-debt // reduction equals what hit the bank. No FX diff possible. arSek = bankSek @@ -212,5 +237,27 @@ export function buildInvoicePaymentClearingLines( } } - return { bankSek, arSek, fxDiffSek, lines } + // Öresavrundning (3740) — pure-SEK only, mutually exclusive with an FX diff. + // The AR leg above is already the full remaining, so 3740 balances the + // verifikat: customer paid a sub-krona short → 3740 debit (förlust); over → + // credit (vinst). Opposite polarity to the supplier side (AP cleared by a Dr). + if (Math.abs(oreRoundingSek) >= ORE_TOLERANCE) { + if (oreRoundingSek > 0) { + lines.push({ + account_number: '3740', + debit_amount: Math.abs(oreRoundingSek), + credit_amount: 0, + line_description: 'Öresavrundning', + }) + } else { + lines.push({ + account_number: '3740', + debit_amount: 0, + credit_amount: Math.abs(oreRoundingSek), + line_description: 'Öresavrundning', + }) + } + } + + return { bankSek, arSek, fxDiffSek, oreRoundingSek, lines } } diff --git a/lib/bookkeeping/supplier-payment-lines.ts b/lib/bookkeeping/supplier-payment-lines.ts new file mode 100644 index 00000000..3cd19cda --- /dev/null +++ b/lib/bookkeeping/supplier-payment-lines.ts @@ -0,0 +1,132 @@ +/** + * Builds the journal-entry lines for the clearing entry that closes (fully or + * partially) a supplier invoice against an actual bank transaction under + * faktureringsmetoden (accrual) — Dr 2440 / Cr . + * + * Shared between: + * - GET /api/transactions/[id]/match-supplier-invoice/preview (read-only, + * drives the dialog the user confirms against) + * - POST /api/transactions/[id]/match-supplier-invoice (the commit path) + * + * Single source of truth so the preview and the committed verifikat are + * byte-identical — including the payment account and the per-line descriptions, + * which previously drifted (the preview used `last_supplier_payment_account` and + * "Kvittning leverantörsskuld" / "Utbetalning från bank", while the commit path + * defaulted to 1930 and "Utbetalning leverantörsfaktura …"). + * + * # Öresavrundning (3740) + * + * A whole-krona Bankgiro/Swish settlement of an öre-bearing invoice total leaves + * a sub-krona residual (e.g. paying 11 231,25 with a rounded 11 231,00). Rather + * than strand that 0,25 kr as a permanent partial, the difference is booked to + * BAS 3740 (Öres- och kronutjämning) and 2440 is cleared in full so the invoice + * reaches `paid`. The residual sign drives the 3740 side: + * + * bank paid LESS than owed (apSek > bankSek) → öresavrundningsvinst → Cr 3740 + * bank paid MORE than owed (apSek < bankSek) → öresavrundningsförlust → Dr 3740 + * + * This polarity is the mirror of the customer side (`buildInvoicePaymentClearingLines`, + * where AR is cleared with a credit and 3740 takes the opposite side). + * + * # SEK only + * + * `apSek`/`bankSek` are home-currency (SEK). Cross-currency settlement carries a + * kursvinst/kursförlust (3960/7960) handled by `createSupplierInvoicePaymentEntry`, + * not here — öresavrundning is the residual AFTER FX and only meaningful in whole + * SEK kronor, so callers route only same-currency SEK payments through this helper. + */ +import type { CreateJournalEntryLineInput } from '@/types' +import { roundOre, ORE_ROUNDING_SETTLEMENT_MAX } from '@/lib/money' + +export interface SupplierClearingArgs { + /** SEK on 2440 to clear for this settlement — the full remaining when an öre + * diff is absorbed, so the invoice reaches `paid`. */ + apSek: number + /** Actual SEK that left the bank — the payment-account credit. */ + bankSek: number + /** Bank/clearing account credited (e.g. 1930). */ + paymentAccount: string +} + +export interface SupplierClearingResult { + apSek: number + bankSek: number + /** roundOre(apSek − bankSek): >0 → 3740 credit (vinst); <0 → 3740 debit + * (förlust); 0 → no 3740 line. Non-zero only within ORE_ROUNDING_SETTLEMENT_MAX. */ + oreDiffSek: number + lines: CreateJournalEntryLineInput[] +} + +/** + * Build the verifikat lines for a supplier-invoice payment matched against a + * SEK bank tx. Pure — no DB calls. Caller decides how to persist. + * + * |apSek − bankSek| < ORE_ROUNDING_SETTLEMENT_MAX (and ≠ 0) + * → clear the full apSek off 2440, credit the actual bankSek, book the + * residual to 3740. Invoice settles fully. + * otherwise (exact, or a genuine ≥ 1 kr partial) + * → clear min(bankSek, apSek), no 3740 line (unchanged legacy behaviour). + */ +export function buildSupplierPaymentClearingLines( + args: SupplierClearingArgs, +): SupplierClearingResult { + const apSek = roundOre(args.apSek) + const bankSek = roundOre(args.bankSek) + const diff = roundOre(apSek - bankSek) + + const isOreRounding = diff !== 0 && Math.abs(diff) < ORE_ROUNDING_SETTLEMENT_MAX + + const lines: CreateJournalEntryLineInput[] = [] + + if (isOreRounding) { + // Clear the FULL debt off 2440 so the invoice → paid; the bank leg is the + // actual SEK paid; 3740 absorbs the öre residual. + lines.push({ + account_number: '2440', + debit_amount: apSek, + credit_amount: 0, + line_description: 'Kvittning leverantörsskuld', + }) + lines.push({ + account_number: args.paymentAccount, + debit_amount: 0, + credit_amount: bankSek, + line_description: 'Utbetalning från bank', + }) + if (diff > 0) { + // Paid fewer kronor than owed → öresavrundningsvinst → 3740 credit. + lines.push({ + account_number: '3740', + debit_amount: 0, + credit_amount: Math.abs(diff), + line_description: 'Öresavrundning', + }) + } else { + // Paid more kronor than owed → öresavrundningsförlust → 3740 debit. + lines.push({ + account_number: '3740', + debit_amount: Math.abs(diff), + credit_amount: 0, + line_description: 'Öresavrundning', + }) + } + return { apSek, bankSek, oreDiffSek: diff, lines } + } + + // Exact settlement, or a genuine partial payment (≥ 1 kr short): clear what + // was actually moved, leave any remainder on the supplier ledger. + const amount = roundOre(Math.min(bankSek, apSek)) + lines.push({ + account_number: '2440', + debit_amount: amount, + credit_amount: 0, + line_description: 'Kvittning leverantörsskuld', + }) + lines.push({ + account_number: args.paymentAccount, + debit_amount: 0, + credit_amount: amount, + line_description: 'Utbetalning från bank', + }) + return { apSek, bankSek, oreDiffSek: 0, lines } +} diff --git a/lib/invoices/__tests__/apply-invoice-payment.test.ts b/lib/invoices/__tests__/apply-invoice-payment.test.ts index e6642778..a21db868 100644 --- a/lib/invoices/__tests__/apply-invoice-payment.test.ts +++ b/lib/invoices/__tests__/apply-invoice-payment.test.ts @@ -14,6 +14,7 @@ describe('planInvoicePayment', () => { newRemaining: 0, isFullyPaid: true, newStatus: 'paid', + oreSettled: false, }) } }) diff --git a/lib/invoices/__tests__/apply-supplier-payment.test.ts b/lib/invoices/__tests__/apply-supplier-payment.test.ts new file mode 100644 index 00000000..e4df5338 --- /dev/null +++ b/lib/invoices/__tests__/apply-supplier-payment.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest' +import { planSupplierPayment } from '@/lib/invoices/apply-supplier-payment' + +describe('planSupplierPayment', () => { + const invoice = { total: 11231.25, paid_amount: 0, remaining_amount: 11231.25 } + + it('settles in full and flags öre when a whole-krona payment is a sub-krona short (absorbOreRounding)', () => { + const r = planSupplierPayment(invoice, 11231, { absorbOreRounding: true }) + expect(r.ok).toBe(true) + if (r.ok) { + expect(r.plan.newStatus).toBe('paid') + expect(r.plan.newRemaining).toBe(0) + expect(r.plan.newPaidAmount).toBe(11231.25) // the AP, not the cash, is fully cleared + expect(r.plan.oreSettled).toBe(true) + } + }) + + it('accepts a sub-krona OVERpayment as öresavrundning instead of rejecting it', () => { + const inv = { total: 11231, paid_amount: 0, remaining_amount: 11231 } + const r = planSupplierPayment(inv, 11231.25, { absorbOreRounding: true }) + expect(r.ok).toBe(true) + if (r.ok) { + expect(r.plan.newStatus).toBe('paid') + expect(r.plan.oreSettled).toBe(true) + } + }) + + it('leaves a ≥1 kr shortfall as a genuine partial', () => { + const r = planSupplierPayment(invoice, 5000, { absorbOreRounding: true }) + expect(r.ok).toBe(true) + if (r.ok) { + expect(r.plan.newStatus).toBe('partially_paid') + expect(r.plan.newRemaining).toBe(6231.25) + expect(r.plan.oreSettled).toBe(false) + } + }) + + it('rejects an overpayment beyond the 1 kr öre band', () => { + const r = planSupplierPayment(invoice, 12000, { absorbOreRounding: true }) + expect(r.ok).toBe(false) + if (!r.ok) { + expect(r.code).toBe('MATCH_SI_AMOUNT_EXCEEDS_REMAINING') + expect(r.details.remaining_amount).toBe(11231.25) + } + }) + + it('exact payment settles fully without flagging öre', () => { + const inv = { total: 1000, paid_amount: 0, remaining_amount: 1000 } + const r = planSupplierPayment(inv, 1000, { absorbOreRounding: true }) + expect(r.ok).toBe(true) + if (r.ok) { + expect(r.plan.newStatus).toBe('paid') + expect(r.plan.oreSettled).toBe(false) + } + }) + + describe('without öre absorption (default — preserves legacy behaviour)', () => { + it('strands the sub-krona remainder as a partial', () => { + const r = planSupplierPayment(invoice, 11231) + expect(r.ok).toBe(true) + if (r.ok) { + expect(r.plan.newStatus).toBe('partially_paid') + expect(r.plan.newRemaining).toBe(0.25) + expect(r.plan.oreSettled).toBe(false) + } + }) + + it('rejects even a sub-krona overpayment (strict half-öre tolerance)', () => { + const inv = { total: 11231, paid_amount: 0, remaining_amount: 11231 } + const r = planSupplierPayment(inv, 11231.25) + expect(r.ok).toBe(false) + }) + }) +}) diff --git a/lib/invoices/__tests__/supplier-invoice-matching.test.ts b/lib/invoices/__tests__/supplier-invoice-matching.test.ts index c07b99ac..de131937 100644 --- a/lib/invoices/__tests__/supplier-invoice-matching.test.ts +++ b/lib/invoices/__tests__/supplier-invoice-matching.test.ts @@ -194,4 +194,64 @@ describe('findSupplierInvoiceMatch', () => { // "AB" is filtered out (length < 3), so no name match expect(result).toBeNull() }) + + // Pass 3, widened window: early payments (the reported RosholmDell case) + it('auto-matches an EARLY exact payment near the invoice date, weeks before due', () => { + // Paid 2026-06-08, invoice issued 2026-06-05, due 2026-07-05 (27 days out). + // The old due-date-only ±5d window missed this; the issue→due window catches it. + const tx = makeTransaction({ amount: -29890, date: '2026-06-08', description: 'RosholmDell Advo BG 0000007746514' }) + const inv = makeSupplierInvoice({ + status: 'registered', + remaining_amount: 29890, + invoice_date: '2026-06-05', + due_date: '2026-07-05', + }) + + const result = findSupplierInvoiceMatch(tx, [inv]) + + expect(result).not.toBeNull() + expect(result!.confidence).toBe(0.85) + expect(result!.matchMethod).toBe('amount_date') + expect(result!.ambiguous).toBeFalsy() + }) + + it('still matches a few days AFTER the due date', () => { + const tx = makeTransaction({ amount: -29890, date: '2026-07-09' }) // due + 4d + const inv = makeSupplierInvoice({ + status: 'registered', + remaining_amount: 29890, + invoice_date: '2026-06-05', + due_date: '2026-07-05', + }) + expect(findSupplierInvoiceMatch(tx, [inv])!.matchMethod).toBe('amount_date') + }) + + it('flags amount_date as AMBIGUOUS when two invoices share the amount in-window', () => { + const tx = makeTransaction({ amount: -29890, date: '2026-06-08', description: 'bankgiro-betalning' }) + const a = makeSupplierInvoice({ + id: 'inv-a', status: 'registered', remaining_amount: 29890, + invoice_date: '2026-06-05', due_date: '2026-07-05', + }) + const b = makeSupplierInvoice({ + id: 'inv-b', status: 'registered', remaining_amount: 29890, + invoice_date: '2026-06-04', due_date: '2026-07-04', + }) + + const result = findSupplierInvoiceMatch(tx, [a, b]) + + expect(result).not.toBeNull() + expect(result!.confidence).toBe(0.85) + expect(result!.ambiguous).toBe(true) // caller must demote to a suggestion + }) + + it('uses an invoice_date-only window when there is no due_date', () => { + const tx = makeTransaction({ amount: -29890, date: '2026-06-20' }) // 15 days after issue + const inv = makeSupplierInvoice({ + status: 'registered', + remaining_amount: 29890, + invoice_date: '2026-06-05', + due_date: null, + }) + expect(findSupplierInvoiceMatch(tx, [inv])!.matchMethod).toBe('amount_date') + }) }) diff --git a/lib/invoices/apply-invoice-payment.ts b/lib/invoices/apply-invoice-payment.ts index 70262d4f..fc472f5a 100644 --- a/lib/invoices/apply-invoice-payment.ts +++ b/lib/invoices/apply-invoice-payment.ts @@ -22,7 +22,7 @@ * guard rail #9 — identical to the route's previous `Math.round(x*100)/100` * except on exact-half-öre amounts, where `roundOre` rounds correctly. */ -import { roundOre, ORE_TOLERANCE } from '@/lib/money' +import { roundOre, ORE_TOLERANCE, ORE_ROUNDING_SETTLEMENT_MAX } from '@/lib/money' /** Half an öre — anything over the remaining by more than this is a real overpayment. */ export const PAYMENT_OVERSHOOT_TOLERANCE = ORE_TOLERANCE @@ -38,6 +38,10 @@ export interface InvoicePaymentPlan { newRemaining: number isFullyPaid: boolean newStatus: 'paid' | 'partially_paid' + /** True when a sub-krona öre residual was absorbed (full settlement of an + * inexact amount) — the 3740 line carries it. Always false unless the caller + * opts in via `absorbOreRounding`. */ + oreSettled: boolean } export type PlanInvoicePaymentResult = @@ -51,11 +55,17 @@ export type PlanInvoicePaymentResult = export function planInvoicePayment( invoice: InvoicePaymentTotals, paymentAmountInInvoiceCurrency: number, + opts?: { absorbOreRounding?: boolean }, ): PlanInvoicePaymentResult { + const absorbOre = opts?.absorbOreRounding === true const currentRemaining = invoice.remaining_amount ?? invoice.total - (invoice.paid_amount || 0) - if (paymentAmountInInvoiceCurrency > currentRemaining + PAYMENT_OVERSHOOT_TOLERANCE) { + // A rounded-up whole-krona payment is not an overpayment — widen the reject + // band to one krona when absorbing öre; otherwise keep the strict half-öre + // float tolerance the three legacy callers rely on. + const overshootTolerance = absorbOre ? ORE_ROUNDING_SETTLEMENT_MAX : PAYMENT_OVERSHOOT_TOLERANCE + if (paymentAmountInInvoiceCurrency > currentRemaining + overshootTolerance) { return { ok: false, code: 'MATCH_AMOUNT_EXCEEDS_REMAINING', @@ -67,6 +77,23 @@ export function planInvoicePayment( } } + const diff = roundOre(currentRemaining - paymentAmountInInvoiceCurrency) + + // Within the öre band (and absorbing) → settle in full; the 3740 line carries + // the residual. Covers both a short whole-krona payment and a rounded-up one. + if (absorbOre && Math.abs(diff) < ORE_ROUNDING_SETTLEMENT_MAX) { + return { + ok: true, + plan: { + newPaidAmount: roundOre((invoice.paid_amount || 0) + currentRemaining), + newRemaining: 0, + isFullyPaid: true, + newStatus: 'paid', + oreSettled: Math.abs(diff) >= ORE_TOLERANCE, + }, + } + } + const newPaidAmount = roundOre((invoice.paid_amount || 0) + paymentAmountInInvoiceCurrency) const newRemaining = Math.max(0, roundOre(currentRemaining - paymentAmountInInvoiceCurrency)) const isFullyPaid = newRemaining <= 0 @@ -78,6 +105,7 @@ export function planInvoicePayment( newRemaining, isFullyPaid, newStatus: isFullyPaid ? 'paid' : 'partially_paid', + oreSettled: false, }, } } diff --git a/lib/invoices/apply-supplier-payment.ts b/lib/invoices/apply-supplier-payment.ts new file mode 100644 index 00000000..f77428b1 --- /dev/null +++ b/lib/invoices/apply-supplier-payment.ts @@ -0,0 +1,108 @@ +/** + * Single source of truth for applying a payment amount to a SUPPLIER invoice — + * the supplier-side mirror of `planInvoicePayment` (@/lib/invoices/apply-invoice-payment). + * + * Computes the new paid/remaining/status and REJECTS overpayment before the + * caller creates any journal entry, so a doomed match never burns a voucher + * number. The supplier match route previously inlined this math (and its + * overshoot guard) directly; centralizing it keeps the two off-by-one tolerances + * (overshoot vs öre absorption) honest and unit-testable without a DB. + * + * # Öresavrundning (opt-in) + * + * When `absorbOreRounding` is set (callers pass it only for same-currency SEK + * settlements), a payment within `ORE_ROUNDING_SETTLEMENT_MAX` of the remaining + * — short OR over — settles the invoice IN FULL; the residual is booked to BAS + * 3740 by the line builder (`buildSupplierPaymentClearingLines`). Without the + * flag the behaviour is the strict legacy one (half-öre overshoot tolerance, + * any real shortfall left as a partial), preserving every other caller. + * + * FX: `paymentAmountInInvoiceCurrency` MUST already be in the invoice's currency. + * The caller owns any conversion, keeping this helper FX-agnostic. + */ +import { roundOre, ORE_TOLERANCE, ORE_ROUNDING_SETTLEMENT_MAX } from '@/lib/money' + +export interface SupplierPaymentTotals { + total: number + paid_amount?: number | null + remaining_amount?: number | null +} + +export interface SupplierPaymentPlan { + newPaidAmount: number + newRemaining: number + isFullyPaid: boolean + newStatus: 'paid' | 'partially_paid' + /** True when an öre residual was absorbed (full settlement of an inexact + * amount). Lets callers/tests assert the 3740 path without re-deriving it. */ + oreSettled: boolean +} + +export type PlanSupplierPaymentResult = + | { ok: true; plan: SupplierPaymentPlan } + | { + ok: false + code: 'MATCH_SI_AMOUNT_EXCEEDS_REMAINING' + details: { transaction_amount: number; remaining_amount: number; excess: number } + } + +export function planSupplierPayment( + invoice: SupplierPaymentTotals, + paymentAmountInInvoiceCurrency: number, + opts?: { absorbOreRounding?: boolean }, +): PlanSupplierPaymentResult { + const absorbOre = opts?.absorbOreRounding === true + const currentRemaining = + invoice.remaining_amount ?? invoice.total - (invoice.paid_amount || 0) + + // Overpayment past the tolerated band is a real overshoot → reject. With öre + // absorption the band is one krona (a rounded-up whole-krona payment is not an + // overpayment); otherwise it's the strict half-öre float tolerance. + const overshootTolerance = absorbOre ? ORE_ROUNDING_SETTLEMENT_MAX : ORE_TOLERANCE + if (paymentAmountInInvoiceCurrency > currentRemaining + overshootTolerance) { + return { + ok: false, + code: 'MATCH_SI_AMOUNT_EXCEEDS_REMAINING', + details: { + transaction_amount: paymentAmountInInvoiceCurrency, + remaining_amount: roundOre(currentRemaining), + excess: roundOre(paymentAmountInInvoiceCurrency - currentRemaining), + }, + } + } + + const diff = roundOre(currentRemaining - paymentAmountInInvoiceCurrency) + + // Within the öre band (and absorbing) → settle in full; the 3740 line carries + // the residual. Covers both a short whole-krona payment and a rounded-up one. + if (absorbOre && Math.abs(diff) < ORE_ROUNDING_SETTLEMENT_MAX) { + const newPaidAmount = roundOre((invoice.paid_amount || 0) + currentRemaining) + return { + ok: true, + plan: { + newPaidAmount, + newRemaining: 0, + isFullyPaid: true, + newStatus: 'paid', + // Only flag öre settlement when there is an actual residual to book — + // an exact payment needs no 3740 line. + oreSettled: Math.abs(diff) >= ORE_TOLERANCE, + }, + } + } + + const newPaidAmount = roundOre((invoice.paid_amount || 0) + paymentAmountInInvoiceCurrency) + const newRemaining = Math.max(0, roundOre(currentRemaining - paymentAmountInInvoiceCurrency)) + const isFullyPaid = newRemaining <= 0 + + return { + ok: true, + plan: { + newPaidAmount, + newRemaining, + isFullyPaid, + newStatus: isFullyPaid ? 'paid' : 'partially_paid', + oreSettled: false, + }, + } +} diff --git a/lib/invoices/supplier-invoice-matching.ts b/lib/invoices/supplier-invoice-matching.ts index 51589d36..88ec283b 100644 --- a/lib/invoices/supplier-invoice-matching.ts +++ b/lib/invoices/supplier-invoice-matching.ts @@ -4,11 +4,18 @@ * 4-pass matching algorithm (ordered by confidence): * 1. Payment reference/OCR exact match → 0.98 * 2. Exact amount + bankgiro/plusgiro match → 0.92 - * 3. Exact amount + date ±5 days → 0.85 + * 3. Exact amount + payment date within [invoice_date − 5, due_date + 5] → 0.85 * 4. Fuzzy amount (±0.01) + supplier name in description → 0.70 * * Auto-match threshold: ≥0.85 → applied automatically * Suggestion threshold: 0.70–0.85 → stored as potential_supplier_invoice_id + * + * The Pass-3 window spans the whole credit period (issue → due, ±5d) so an + * early payment — common when a bank pays a Bankgiro the day the invoice lands, + * weeks before the due date — still auto-matches. To contain the false-positive + * risk of the wider window, a Pass-3 hit where more than one invoice matches the + * same amount in-window is flagged `ambiguous`; callers must downgrade an + * ambiguous auto-match to a mere suggestion. */ import type { Transaction, SupplierInvoice } from '@/types' @@ -17,6 +24,13 @@ export interface SupplierInvoiceMatch { supplierInvoice: SupplierInvoice confidence: number matchMethod: 'payment_reference' | 'amount_bankgiro' | 'amount_date' | 'fuzzy_name' + /** + * True when this is a Pass-3 (amount + date-window) match but more than one + * invoice matched the same amount in-window — the date heuristic alone can't + * disambiguate. Callers must treat an ambiguous 0.85 as a suggestion, never an + * auto-link. Undefined/false for the unique passes (OCR, bankgiro). + */ + ambiguous?: boolean } /** @@ -43,6 +57,10 @@ export function findSupplierInvoiceMatch( if (txAmount === 0) return null let bestMatch: SupplierInvoiceMatch | null = null + // How many invoices matched the exact amount within their date window. >1 + // makes a Pass-3 (amount_date) winner ambiguous — the date can't pick between + // same-amount invoices, so the caller must not auto-link it. + let amountDateMatchCount = 0 for (const invoice of unpaidInvoices) { // Only match against registered/approved invoices with remaining amount @@ -81,13 +99,21 @@ export function findSupplierInvoiceMatch( } } - // Pass 3: Exact amount + date ±5 days → 0.85 - if (amountMatch && invoice.due_date) { - const txDate = new Date(transaction.date) - const dueDate = new Date(invoice.due_date) - const diffDays = Math.abs((txDate.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24)) + // Pass 3: Exact amount + payment date within the credit period → 0.85. + // Window = [invoice_date − 5, due_date + 5]; when only one date is known, + // span ~30 days on the missing side (typical net terms). This catches early + // payments (paid near the invoice date, weeks before due) that a due-date- + // only window missed. + if (amountMatch && (invoice.invoice_date || invoice.due_date)) { + const DAY = 24 * 60 * 60 * 1000 + const txMs = new Date(transaction.date).getTime() + const invoiceMs = invoice.invoice_date ? new Date(invoice.invoice_date).getTime() : null + const dueMs = invoice.due_date ? new Date(invoice.due_date).getTime() : null + const startMs = invoiceMs !== null ? invoiceMs - 5 * DAY : (dueMs as number) - 35 * DAY + const endMs = dueMs !== null ? dueMs + 5 * DAY : (invoiceMs as number) + 35 * DAY - if (diffDays <= 5) { + if (txMs >= startMs && txMs <= endMs) { + amountDateMatchCount++ const confidence = 0.85 if (!bestMatch || confidence > bestMatch.confidence) { bestMatch = { @@ -128,5 +154,12 @@ export function findSupplierInvoiceMatch( } } + // A Pass-3 winner is only trustworthy enough to auto-link when its amount was + // unique in-window. If several invoices shared the amount, the date can't + // disambiguate — flag it so the caller demotes it to a suggestion. + if (bestMatch && bestMatch.matchMethod === 'amount_date' && amountDateMatchCount > 1) { + bestMatch.ambiguous = true + } + return bestMatch } diff --git a/lib/money.ts b/lib/money.ts index 32bc14b5..89a986c3 100644 --- a/lib/money.ts +++ b/lib/money.ts @@ -44,6 +44,21 @@ export function roundOre(n: number): number { */ export const ORE_TOLERANCE = 0.005 +/** + * Maximum |bank payment − invoice remaining| (in SEK) that is treated as + * öresavrundning — booked to BAS 3740 (Öres- och kronutjämning) so the invoice + * settles fully — rather than left as a genuine partial payment. + * + * Swedish whole-krona settlements (Bankgiro, Swish, kort) pay an öre-bearing + * invoice total rounded to the nearest krona, so the residual is always strictly + * under 1 krona. A real shortfall is ≥ 1 krona, so this band can never hide one. + * + * NOTE: deliberately looser than `ORE_TOLERANCE` (0,005). That constant is + * float-equalisation; this is an accounting policy band. Keep them distinct — + * never reuse `ORE_TOLERANCE` for settlement rounding. + */ +export const ORE_ROUNDING_SETTLEMENT_MAX = 1.0 + /** * True when two amounts are equal to the öre (within `ORE_TOLERANCE`). Prefer * this over `a === b` for money — direct equality on floats fails on drift. diff --git a/lib/transactions/ingest.ts b/lib/transactions/ingest.ts index fa49c186..ed0b3869 100644 --- a/lib/transactions/ingest.ts +++ b/lib/transactions/ingest.ts @@ -449,7 +449,10 @@ export async function ingestTransactions( ) if (match && !matchedSupplierInvoiceIds.has(match.supplierInvoice.id)) { - if (match.confidence >= 0.85) { + // Ambiguous amount_date hits (several same-amount invoices in-window) + // are demoted to suggestions — auto-linking the wrong one is worse + // than asking the user to pick. + if (match.confidence >= 0.85 && !match.ambiguous) { // Auto-link at high confidence await supabase .from('transactions') diff --git a/messages/en.json b/messages/en.json index 0ada5724..46c80976 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1826,6 +1826,7 @@ "amount_diff": "Difference: {amount}", "different_currencies": " (different currencies)", "partial_payment_note": " — the invoice will become partially paid.", + "ore_rounding_note": "The {amount} difference is booked as rounding (account 3740). The invoice is marked as paid.", "fx_title": "Currency conversion", "fx_rate_description": "Riksbanken mid-rate {date}: 1 {invoiceCurrency} = {rate} SEK", "fx_paid_in_invoice_currency": "Payment equals: {amount}", diff --git a/messages/sv.json b/messages/sv.json index 78294783..05f71a4d 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -1826,6 +1826,7 @@ "amount_diff": "Differens: {amount}", "different_currencies": " (olika valutor)", "partial_payment_note": " — fakturan blir delbetald.", + "ore_rounding_note": "Differens {amount} bokförs som öresavrundning (konto 3740). Fakturan markeras som betald.", "fx_title": "Valutaomräkning", "fx_rate_description": "Riksbankens mittkurs {date}: 1 {invoiceCurrency} = {rate} SEK", "fx_paid_in_invoice_currency": "Inbetalning motsvarar: {amount}", diff --git a/vitest.config.ts b/vitest.config.ts index 949970ba..b2f51e8f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,7 +10,9 @@ const unitProject = { globals: true, environment: 'node' as const, include: ['**/*.test.ts'], - exclude: ['**/node_modules/**', '**/*.pg.test.ts'], + // `.claude/worktrees/*` are ephemeral agent checkouts whose `@/*` imports + // resolve back to this root — never part of the suite. + exclude: ['**/node_modules/**', '**/*.pg.test.ts', '**/.claude/**'], }, } @@ -21,7 +23,7 @@ const pgRealProject = { globals: true, environment: 'node' as const, include: ['**/*.pg.test.ts'], - exclude: ['**/node_modules/**'], + exclude: ['**/node_modules/**', '**/.claude/**'], setupFiles: ['tests/pg/setup.ts'], // One-connection-at-a-time to avoid cross-file DB contention. fileParallelism: false,