From 17d48ebb84847cab7562b9f878d41455d2532e91 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Wed, 5 Aug 2026 18:05:11 +0200 Subject: [PATCH] fix(invoices): guard cash-method partial payments across every payment path (#1413) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(invoices): guard cash-method partial payments across every payment path A never-booked kontantmetoden invoice can only be settled by the generated cash entry (createInvoiceCashEntry / createSupplierInvoiceCashEntry), and that entry always books the FULL invoice: it takes no payment amount. Three payment surfaces still let partial payments through to it, corrupting books: - settleInvoicePayment dropped the fully-paid term entirely, so a partial payment (Stripe sync, mark-paid) booked the entire invoice: over-recognized revenue, over-declared output VAT, and a bank debit that did not match the money received. - The dashboard and agent match-transaction paths fell back to an accrual-style clearing entry against an EMPTY 1510: negative receivable, no revenue, no moms (ML 13 kap 8 § puts each installment's moms in its own receipt period). The comment claimed the credit "gets resolved on final payment", but the cash builder never touches 1510 and books the full total, so the final payment double-debited the bank instead. - The supplier routes had no full-settlement term at all, so a partial payment booked the full expense + input VAT. Fix: one shared predicate (cashPartialBlockReason in booking-mode.ts) rejects generated cash entries unless the payment settles the invoice in full from a fully unpaid state, wired into all six POST surfaces, the agent commit paths, and the three preview routes (so dialogs cannot propose a verifikat the POST refuses). New bilingual error codes INVOICE_PAID_CASH_PARTIAL_UNSUPPORTED / SI_CASH_PARTIAL_UNSUPPORTED. Invoices booked at issue are unaffected: their partial payments keep the normal 1510/2440 clearing path. The v1 match-invoice route already had this guard (VALIDATION_ERROR); its behavior is unchanged. Proper per-installment recognition (proportional revenue + moms per receipt) is the follow-up feature that would lift this restriction. Co-Authored-By: Claude Fable 5 * docs(review): drop stale ML 13 kap 8 § cites for kontantmetoden VAT timing Compliance-review finding: the section is the old ML 1994:200 numbering; in ML 2023:200, 13 kap covers input-VAT deduction, not redovisningstidpunkt. The substantive rule (bokslutsmetoden reports moms at payment, per installment, except at year-end) is unchanged and stated without a section cite until the current-law section is verified. Comments and cookbook prose only; no behavior change. Also fixes the two pre-existing occurrences. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + .../[id]/mark-paid/__tests__/route.test.ts | 61 ++++++++++++ .../[id]/mark-paid/preview/route.ts | 19 ++++ .../supplier-invoices/[id]/mark-paid/route.ts | 23 +++++ .../match-invoice/__tests__/route.test.ts | 95 +++++++++++++++++-- .../preview/__tests__/route.test.ts | 27 ++---- .../[id]/match-invoice/preview/route.ts | 20 ++++ .../transactions/[id]/match-invoice/route.ts | 49 +++++++--- .../__tests__/route.test.ts | 20 ++-- .../match-supplier-invoice/preview/route.ts | 17 ++++ .../[id]/match-supplier-invoice/route.ts | 23 +++++ .../invoices/[id]/mark-paid/route.ts | 25 +++++ .../supplier-invoices/[id]/mark-paid/route.ts | 23 +++++ .../transactions/[id]/match-invoice/route.ts | 2 +- .../[id]/match-supplier-invoice/route.ts | 21 ++++ .../__tests__/booking-mode.test.ts | 46 ++++++++- lib/bookkeeping/booking-mode.ts | 33 +++++++ .../cookbook/ingest-bank-transactions.ts | 2 +- lib/errors/structured-errors.ts | 14 +++ .../__tests__/settle-invoice-payment.test.ts | 56 +++++++++++ lib/invoices/settle-invoice-payment.ts | 30 ++++++ lib/pending-operations/commit.ts | 50 +++++++++- 22 files changed, 599 insertions(+), 58 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 8e271bd4..d12fe4da 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -793,3 +793,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-03] whatsapp-inbox M11 ('stopp'): copy changed to say PAUSED rather than disconnected; the keyword only sets muted_at. Muting also stops persisting content (body_text/raw_payload null on muted senders, matching the unknown-sender discipline) so the promise is true in the data too. Actually revoking on 'stopp' was rejected: 'start' must be able to re-open the same binding, and the settings panel already owns real disconnection ("Koppla från"). [2026-08-03] whatsapp-inbox raw_payload: the sender's plaintext E.164 number is stripped before persisting (redactRawPayload) and replies resolve the recipient by decrypting the link's phone_enc. Storing the number verbatim on every message row defeated the point of the AES-256-GCM column and contradicted the RoPA claim that it is never in the clear. Legacy rows still holding `from` keep working via a fallback read. [2026-08-05] Representation clarifying question has NO amount floor (removed the 150 kr gate after the Swedish compliance review on PR #1340): documenting deltagare + syfte is what makes a representation expense deductible at all (BFL 5 kap 6-7 §) and that duty is not conditioned on any sum; the 300 kr/person figure is the VAT-deduction base cap, an unrelated rule. Noise is bounded by the triggers instead (receipt-shaped + restaurant/cafe/hotel merchant, <=1 question per receipt, <=2 per burst, <=6 per sender per day, one "nej" dismisses). +[2026-08-05] Dropped "ML 13 kap 8 §" cites for kontantmetoden VAT timing (comments/docs only): section is the old ML 1994:200 numbering; in ML 2023:200, 13 kap is input-VAT deduction. Rule stated without section cite until the current-law section is verified. diff --git a/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts index c73c1a2e..680bb39e 100644 --- a/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts +++ b/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts @@ -276,6 +276,67 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => { expect(mockCreateSupplierInvoicePaymentEntry).not.toHaveBeenCalled() }) + it('rejects a cash-method partial payment on a never-booked supplier invoice', async () => { + // createSupplierInvoiceCashEntry books the FULL invoice (all items + VAT) + // and takes no payment amount, so a partial would over-book the expense. + const supplier = makeSupplier() + const invoice = makeSupplierInvoice({ + id: 'si-1', + status: 'approved', + total: 10000, + remaining_amount: 10000, + paid_amount: 0, + supplier, + items: [], + }) + + enqueue({ data: invoice, error: null }) + // Duplicate-payment guard is skipped on partials, so the next query is + // the settings fetch. + enqueue({ data: { accounting_method: 'cash' }, error: null }) + + const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', { + method: 'POST', + body: { amount: 4000 }, + }) + const response = await POST(request, createMockRouteParams({ id: 'si-1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(400) + expect(body.error.code).toBe('SI_CASH_PARTIAL_UNSUPPORTED') + expect(mockCreateSupplierInvoiceCashEntry).not.toHaveBeenCalled() + expect(mockCreateSupplierInvoicePaymentEntry).not.toHaveBeenCalled() + }) + + it('rejects completing a previously part-paid never-booked cash supplier invoice', async () => { + const supplier = makeSupplier() + const invoice = makeSupplierInvoice({ + id: 'si-1', + status: 'partially_paid', + total: 10000, + remaining_amount: 6000, + paid_amount: 4000, + supplier, + items: [], + }) + + enqueue({ data: invoice, error: null }) + // Full-remaining payment: duplicate-payment guard runs (no candidates). + enqueue({ data: [], error: null }) + enqueue({ data: { accounting_method: 'cash' }, error: null }) + + const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', { + method: 'POST', + body: {}, + }) + const response = await POST(request, createMockRouteParams({ id: 'si-1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(400) + expect(body.error.code).toBe('SI_CASH_PARTIAL_UNSUPPORTED') + expect(mockCreateSupplierInvoiceCashEntry).not.toHaveBeenCalled() + }) + it('cash method: anchors the invoice document to a posted verifikat (BFL 5 kap 6 §)', async () => { const supplier = makeSupplier() const invoice = makeSupplierInvoice({ diff --git a/app/api/supplier-invoices/[id]/mark-paid/preview/route.ts b/app/api/supplier-invoices/[id]/mark-paid/preview/route.ts index 458e6035..a1233b56 100644 --- a/app/api/supplier-invoices/[id]/mark-paid/preview/route.ts +++ b/app/api/supplier-invoices/[id]/mark-paid/preview/route.ts @@ -10,6 +10,7 @@ import { NextResponse } from 'next/server' import { z } from 'zod' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { cashPartialBlockReason } from '@/lib/bookkeeping/booking-mode' import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils' import type { SupplierInvoice, SupplierInvoiceItem } from '@/types' @@ -66,6 +67,24 @@ export const GET = withRouteContext( const siAlreadyBooked = !!(invoice as { registration_journal_entry_id?: string | null }).registration_journal_entry_id const useCashEntry = !siAlreadyBooked && accountingMethod === 'cash' + // The POST handler rejects cash-method partials and part-paid completions + // for never-booked invoices (the cash builder books the full invoice), so + // refuse to preview lines it will never book. + const remainingForGuard = + (invoice as { remaining_amount?: number | null }).remaining_amount ?? invoice.total + const cashBlock = cashPartialBlockReason({ + invoiceAlreadyBooked: siAlreadyBooked, + accountingMethod, + priorPaidAmount: (invoice as { paid_amount?: number | null }).paid_amount, + paysRemainingInFull: amount >= remainingForGuard - 0.005, + }) + if (cashBlock) { + return errorResponseFromCode('SI_CASH_PARTIAL_UNSUPPORTED', log, { + requestId, + details: { reason: cashBlock }, + }) + } + const lines: PreviewLine[] = [] let entryType: 'clearing' | 'cash' = 'clearing' diff --git a/app/api/supplier-invoices/[id]/mark-paid/route.ts b/app/api/supplier-invoices/[id]/mark-paid/route.ts index 40788a48..e4f09adf 100644 --- a/app/api/supplier-invoices/[id]/mark-paid/route.ts +++ b/app/api/supplier-invoices/[id]/mark-paid/route.ts @@ -6,6 +6,7 @@ import { createSupplierInvoiceCashEntry, } from '@/lib/bookkeeping/supplier-invoice-entries' import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine' +import { cashPartialBlockReason } from '@/lib/bookkeeping/booking-mode' import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry' import { isBookkeepingError } from '@/lib/bookkeeping/errors' import { anchorSupplierInvoiceDocument } from '@/lib/core/documents/supplier-invoice-underlag' @@ -230,6 +231,28 @@ export const POST = withRouteContext( const siAlreadyBooked = !!(invoice as { registration_journal_entry_id?: string | null }).registration_journal_entry_id const useCashEntry = !siAlreadyBooked && accountingMethod === 'cash' + // createSupplierInvoiceCashEntry books the FULL invoice (all items + VAT) + // and takes no payment amount: reject partials and part-paid completions + // for never-booked kontantmetoden invoices instead of over-booking the + // expense against a smaller bank movement. Custom lines are not exempt: + // the dialog pre-fills the same full-invoice shape. + const cashBlock = cashPartialBlockReason({ + invoiceAlreadyBooked: siAlreadyBooked, + accountingMethod, + priorPaidAmount: (invoice as { paid_amount?: number | null }).paid_amount, + paysRemainingInFull: paymentAmount >= invoice.remaining_amount - 0.005, + }) + if (cashBlock) { + return errorResponseFromCode('SI_CASH_PARTIAL_UNSUPPORTED', opLog, { + requestId, + details: { + reason: cashBlock, + payment_amount: paymentAmount, + remaining_amount: invoice.remaining_amount, + }, + }) + } + let journalEntryId: string | null = null try { diff --git a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts index 3fb88769..71def122 100644 --- a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts +++ b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts @@ -702,6 +702,72 @@ describe('POST /api/transactions/[id]/match-invoice', () => { ) }) + it('rejects a cash-method partial match on a never-booked invoice (no negative 1510, no silent moms)', async () => { + // Regression: the old fallback booked an accrual-style clearing entry + // against an EMPTY 1510 (negative receivable, no revenue, no moms), and + // the final payment then booked the FULL total via createInvoiceCashEntry, + // double-debiting the bank account. + const tx = makeTransaction({ id: 'tx-1', amount: 5000, invoice_id: null, date: '2024-06-15' }) + const invoice = { + ...makeInvoice({ + id: VALID_UUID, + status: 'sent', + total: 12500, + remaining_amount: 12500, + paid_amount: 0, + }), + journal_entry_id: null, + } + + enqueue({ data: tx, error: null }) + enqueue({ data: invoice, error: null }) + enqueue({ data: [], error: null }) // hard-duplicate check + enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null }) + + const request = createMockRequest('/api/transactions/tx-1/match-invoice', { + method: 'POST', + body: { invoice_id: VALID_UUID }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(400) + expect(body.error.code).toBe('INVOICE_PAID_CASH_PARTIAL_UNSUPPORTED') + expect(mockCreateJournalEntry).not.toHaveBeenCalled() + expect(mockCreateInvoiceCashEntry).not.toHaveBeenCalled() + }) + + it('rejects completing a previously part-paid never-booked cash invoice (cash entry books the full total)', async () => { + const tx = makeTransaction({ id: 'tx-1', amount: 7500, invoice_id: null, date: '2024-06-15' }) + const invoice = { + ...makeInvoice({ + id: VALID_UUID, + status: 'partially_paid', + total: 12500, + remaining_amount: 7500, + paid_amount: 5000, + }), + journal_entry_id: null, + } + + enqueue({ data: tx, error: null }) + enqueue({ data: invoice, error: null }) + // No hard-duplicate check here: it only runs for 'sent'/'overdue', so the + // next query is the settings fetch. + enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null }) + + const request = createMockRequest('/api/transactions/tx-1/match-invoice', { + method: 'POST', + body: { invoice_id: VALID_UUID }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(400) + expect(body.error.code).toBe('INVOICE_PAID_CASH_PARTIAL_UNSUPPORTED') + expect(mockCreateInvoiceCashEntry).not.toHaveBeenCalled() + }) + it('cash method ignores cash entry when invoice was already booked (accrual→cash migration)', async () => { // Regression: customer sent invoices under accrual (1510 was debited on // send), then switched to kontantmetoden before the bank receipt arrived. @@ -987,15 +1053,22 @@ describe('POST /api/transactions/[id]/match-invoice', () => { expect(details.excess).toBe(7000) }) - it('cash method partial payment uses clearing entry with note', async () => { + it('cash setting still allows a partial on an accrual-booked invoice (clearing entry)', async () => { + // Pure kontantmetoden partials are rejected (see the rejection tests + // above), but an invoice booked at send under accrual keeps its normal + // partial clearing path even after the company switches to cash: 1510 + // has a real balance to clear. const tx = makeTransaction({ id: 'tx-1', amount: 5000, invoice_id: null, date: '2024-06-15' }) - const invoice = makeInvoice({ - id: VALID_UUID, - status: 'sent', - total: 12500, - remaining_amount: 12500, - paid_amount: 0, - }) + const invoice = { + ...makeInvoice({ + id: VALID_UUID, + status: 'sent', + total: 12500, + remaining_amount: 12500, + paid_amount: 0, + }), + journal_entry_id: 'je-send-on-accrual', + } enqueue({ data: tx, error: null }) enqueue({ data: invoice, error: null }) @@ -1004,6 +1077,8 @@ describe('POST /api/transactions/[id]/match-invoice', () => { mockCreateJournalEntry.mockResolvedValue({ id: 'je-clearing' }) + // PDF re-attach lookup (invoice.journal_entry_id set; null result skips) + enqueue({ data: null, error: null }) // Update invoice enqueue({ data: [{ id: VALID_UUID }], error: null }) // Insert invoice_payments @@ -1022,8 +1097,8 @@ describe('POST /api/transactions/[id]/match-invoice', () => { expect(status).toBe(200) expect(body.invoice_status).toBe('partially_paid') - // Cash partial uses accrual-style clearing entry (now via the shared - // helper + createJournalEntry), NOT createInvoiceCashEntry. + // Accrual-booked partial uses the clearing entry (via the shared helper + + // createJournalEntry), NOT createInvoiceCashEntry. expect(mockCreateJournalEntry).toHaveBeenCalled() expect(mockCreateInvoiceCashEntry).not.toHaveBeenCalled() }) diff --git a/app/api/transactions/[id]/match-invoice/preview/__tests__/route.test.ts b/app/api/transactions/[id]/match-invoice/preview/__tests__/route.test.ts index bc035008..1d0acc4e 100644 --- a/app/api/transactions/[id]/match-invoice/preview/__tests__/route.test.ts +++ b/app/api/transactions/[id]/match-invoice/preview/__tests__/route.test.ts @@ -53,7 +53,7 @@ describe('GET /api/transactions/[id]/match-invoice/preview', () => { // invoice previewed a cash entry (Dr 1930 / Cr 30xx) while the POST: which // converts first: commits the clearing entry (Dr 1930 / Cr 1510). The user // approved one verifikat and a different one was booked. - it('cross-currency partial under kontantmetoden previews a clearing entry, not a cash entry', async () => { + it('cross-currency partial under kontantmetoden is rejected like the POST handler', async () => { const tx = makeTransaction({ id: 'tx-1', amount: 1000, @@ -82,24 +82,17 @@ describe('GET /api/transactions/[id]/match-invoice/preview', () => { }) const response = await GET(request, createMockRouteParams({ id: 'tx-1' })) const { status, body } = await parseJsonResponse<{ - entry_type: string - is_fully_paid: boolean - lines: Array<{ account_number: string }> - fx_conversion: { required: boolean; paid_in_invoice_currency?: number } + error: { code: string; details?: { reason?: string } } }>(response) - expect(status).toBe(200) - // The crux of the bug: NOT a cash entry, NOT fully paid. - expect(body.entry_type).toBe('clearing') - expect(body.is_fully_paid).toBe(false) - // Clearing lines clear 1510 and never recognise revenue on a 30xx account. - const accounts = body.lines.map((l) => l.account_number) - expect(accounts).toContain('1510') - expect(accounts).not.toContain('3001') - // FX surfaced with the invoice-currency equivalent (1000 / 10.45 ≈ 95.69), - // matching what the POST handler accumulates. - expect(body.fx_conversion.required).toBe(true) - expect(body.fx_conversion.paid_in_invoice_currency).toBeCloseTo(95.69, 1) + // The preview must never show a verifikat the POST refuses to book: a + // partial payment of a never-booked kontantmetoden invoice is rejected on + // both surfaces (the old clearing fallback credited an empty 1510 with no + // revenue and no moms). The original PR #615 regression (preview showing + // cash while POST books clearing) stays covered: both now agree. + expect(status).toBe(400) + expect(body.error.code).toBe('INVOICE_PAID_CASH_PARTIAL_UNSUPPORTED') + expect(body.error.details?.reason).toBe('partial_payment') }) // Guard the cash path the fix reorders around: a same-currency full payment diff --git a/app/api/transactions/[id]/match-invoice/preview/route.ts b/app/api/transactions/[id]/match-invoice/preview/route.ts index b31fe581..c17ed037 100644 --- a/app/api/transactions/[id]/match-invoice/preview/route.ts +++ b/app/api/transactions/[id]/match-invoice/preview/route.ts @@ -22,6 +22,7 @@ import { NextResponse } from 'next/server' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { cashPartialBlockReason } from '@/lib/bookkeeping/booking-mode' import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils' import { roundOre, ORE_ROUNDING_SETTLEMENT_MAX } from '@/lib/money' import { getRevenueAccount, getOutputVatAccount } from '@/lib/bookkeeping/invoice-entries' @@ -230,6 +231,25 @@ export const GET = withRouteContext( const invoiceAlreadyBooked = !!(invoice as { journal_entry_id?: string | null }).journal_entry_id const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash' && isFullyPaid + // The POST handler rejects cash-method partials and part-paid completions + // for never-booked invoices, so refuse to preview lines it will never + // book. Skipped while the FX rate is unresolved: the dialog must still + // render to collect a manual rate, and the POST recomputes the real check. + if (!fxRateUnavailable) { + const cashBlock = cashPartialBlockReason({ + invoiceAlreadyBooked, + accountingMethod, + priorPaidAmount: (invoice as { paid_amount?: number | null }).paid_amount, + paysRemainingInFull: isFullyPaid, + }) + if (cashBlock) { + return errorResponseFromCode('INVOICE_PAID_CASH_PARTIAL_UNSUPPORTED', log, { + requestId, + details: { reason: cashBlock }, + }) + } + } + const lines: PreviewLine[] = [] let entryType: 'clearing' | 'cash' = 'clearing' diff --git a/app/api/transactions/[id]/match-invoice/route.ts b/app/api/transactions/[id]/match-invoice/route.ts index 1175b512..15cfc3a0 100644 --- a/app/api/transactions/[id]/match-invoice/route.ts +++ b/app/api/transactions/[id]/match-invoice/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server' +import { cashPartialBlockReason } from '@/lib/bookkeeping/booking-mode' import { createInvoiceCashEntry } from '@/lib/bookkeeping/invoice-entries' import { buildInvoicePaymentClearingLines } from '@/lib/bookkeeping/invoice-payment-lines' import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils' @@ -420,6 +421,33 @@ export const POST = withRouteContext( const invoiceAlreadyBooked = !!(invoice as { journal_entry_id?: string | null }).journal_entry_id const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash' && isFullyPaid + // Reject cash-method partial payments and part-paid completions for pure + // kontantmetoden invoices (no prior JE), mirroring the v1 route. The old + // partial fallback booked an accrual-style clearing entry against an + // EMPTY 1510 (negative receivable, no revenue, no moms: bokslutsmetoden + // reports moms at payment, per installment), and the + // "resolved on final payment" theory was wrong: createInvoiceCashEntry + // never touches 1510 and books the FULL total, so the final payment + // double-debited the bank account instead. When the invoice was already + // booked under accrual, the clearing entry IS the correct partial path + // regardless of the company's current setting. + const cashBlock = cashPartialBlockReason({ + invoiceAlreadyBooked, + accountingMethod, + priorPaidAmount: (invoice as { paid_amount?: number | null }).paid_amount, + paysRemainingInFull: isFullyPaid, + }) + if (cashBlock) { + return errorResponseFromCode('INVOICE_PAID_CASH_PARTIAL_UNSUPPORTED', txLog, { + requestId, + details: { + reason: cashBlock, + payment_amount: paidAmountInInvoiceCurrency, + invoice_total: invoice.total, + }, + }) + } + let journalEntryId: string | null = null try { @@ -463,11 +491,10 @@ export const POST = withRouteContext( ) journalEntryId = journalEntry?.id ?? null } else { - // Clearing entry against 1510. Covers accrual, cash-with-prior-JE - // (mid-stream switch), and cash partial. The cash partial path is - // intentional: under kontantmetoden 1510 has no prior balance, so - // partials leave a credit on 1510 that gets resolved on final - // payment when createInvoiceCashEntry would normally run. + // Clearing entry against 1510. Covers accrual and cash-with-prior-JE + // (mid-stream method switch). Pure kontantmetoden partials never + // reach this branch: they are rejected above, because 1510 has no + // prior balance to clear and the cash builder cannot book a partial. // // Builds lines via buildInvoicePaymentClearingLines so the verifikat // is byte-identical to what the preview route showed the user. For @@ -640,13 +667,9 @@ export const POST = withRouteContext( return errorResponseFromCode('MATCH_INVOICE_ALREADY_PAID', txLog, { requestId }) } - // The "intäkt bokförs vid slutbetalning" note only applies to genuine - // kontantmetoden partials: invoices that were never booked. When the - // invoice was booked under accrual, the clearing entry already handles - // the partial cleanly and the note would be misleading. - const cashMethodNote = (!invoiceAlreadyBooked && accountingMethod === 'cash' && !isFullyPaid) - ? 'Kontantmetoden: intäkt bokförs vid slutbetalning' - : null + // No cash-method note anymore: pure kontantmetoden partials are rejected + // above, and for an invoice booked at send the clearing entry handles a + // partial correctly, so the note would be misleading. // Provenance for a manually-supplied FX rate. The Riksbanken spot rate is // self-documenting (rate + rate_date are reproducible), but a rate the @@ -658,7 +681,7 @@ export const POST = withRouteContext( ? `Manuell valutakurs ${fx.rate} ${invoice.currency}/SEK (betalningsdatum ${transaction.date})` : null - const paymentNotes = [cashMethodNote, manualRateNote].filter(Boolean).join(' · ') || null + const paymentNotes = manualRateNote // Payment row stores amount in INVOICE currency (the column unit). For // same-currency that's tx.amount; for cross-currency it's the spot-rate 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 e04aa189..9dcd242b 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 @@ -686,25 +686,23 @@ describe('POST /api/transactions/[id]/match-supplier-invoice: cash method + FX', expect(mockCreateCashEntry).not.toHaveBeenCalled() }) - it('does NOT absorb öre under the cash method: a SEK sub-krona diff stays partial', async () => { + it('does NOT absorb öre under the cash method: a SEK sub-krona diff is rejected as 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. + // öre band is accrual-only. Previously the sub-krona shortfall booked the + // FULL cash entry while leaving the invoice partially_paid (an over-book + // the invoice could never recover from); now it is rejected outright. 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) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res) + expect(status).toBe(400) + expect(body.error.code).toBe('SI_CASH_PARTIAL_UNSUPPORTED') + expect(mockCreateCashEntry).not.toHaveBeenCalled() + expect(mockCreateJournalEntry).not.toHaveBeenCalled() }) }) 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 25e30dbd..82268889 100644 --- a/app/api/transactions/[id]/match-supplier-invoice/preview/route.ts +++ b/app/api/transactions/[id]/match-supplier-invoice/preview/route.ts @@ -11,6 +11,7 @@ import { NextResponse } from 'next/server' import { z } from 'zod' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { cashPartialBlockReason } from '@/lib/bookkeeping/booking-mode' import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils' import { buildSupplierPaymentClearingLines } from '@/lib/bookkeeping/supplier-payment-lines' import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account' @@ -145,6 +146,22 @@ export const GET = withRouteContext( transaction.currency !== si.currency || txAmountAbs >= remainingInvoiceCurrency - 0.005 + // The POST handler rejects cash-method partials and part-paid completions + // for never-booked invoices (the cash builder books the full invoice), so + // refuse to preview lines it will never book. + const cashBlock = cashPartialBlockReason({ + invoiceAlreadyBooked: siAlreadyBooked, + accountingMethod, + priorPaidAmount: (si as { paid_amount?: number | null }).paid_amount, + paysRemainingInFull: fullSettlement, + }) + if (cashBlock) { + return errorResponseFromCode('SI_CASH_PARTIAL_UNSUPPORTED', log, { + requestId, + details: { reason: cashBlock }, + }) + } + const lines: PreviewLine[] = [] let entryType: 'clearing' | 'cash' = 'clearing' // Drives the dialog's "markeras som betald" / öresavrundning copy. Cash diff --git a/app/api/transactions/[id]/match-supplier-invoice/route.ts b/app/api/transactions/[id]/match-supplier-invoice/route.ts index 67d18158..4b2a5bd1 100644 --- a/app/api/transactions/[id]/match-supplier-invoice/route.ts +++ b/app/api/transactions/[id]/match-supplier-invoice/route.ts @@ -4,6 +4,7 @@ import { createSupplierInvoiceCashEntry, } from '@/lib/bookkeeping/supplier-invoice-entries' import { buildSupplierPaymentClearingLines } from '@/lib/bookkeeping/supplier-payment-lines' +import { cashPartialBlockReason } from '@/lib/bookkeeping/booking-mode' import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account' import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry' import { planSupplierPayment } from '@/lib/invoices/apply-supplier-payment' @@ -243,6 +244,28 @@ export const POST = withRouteContext( }) } + // Same-currency partials and part-paid completions are equally unbookable + // under kontantmetoden (createSupplierInvoiceCashEntry books the FULL + // invoice, so a partial bank amount would over-book the expense): reject + // them too, not only the FX case above. Custom lines are not exempt: the + // dialog pre-fills the same full-invoice shape. + const cashBlock = cashPartialBlockReason({ + invoiceAlreadyBooked: siAlreadyBooked, + accountingMethod, + priorPaidAmount: (invoice as { paid_amount?: number | null }).paid_amount, + paysRemainingInFull: fullSettlement, + }) + if (cashBlock) { + return errorResponseFromCode('SI_CASH_PARTIAL_UNSUPPORTED', txLog, { + requestId, + details: { + reason: cashBlock, + payment_amount: txAmountAbs, + remaining_amount: invoice.remaining_amount, + }, + }) + } + // Verifikat header description, shared by every booking branch below. const desc = invoice.supplier?.name ? `Utbetalning leverantörsfaktura ${invoice.supplier_invoice_number}, ${invoice.supplier.name}` diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts index 0994b12d..cfa25e08 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts @@ -38,6 +38,7 @@ import { createInvoicePaymentJournalEntry, } from '@/lib/bookkeeping/invoice-entries' import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine' +import { cashPartialBlockReason } from '@/lib/bookkeeping/booking-mode' import { AccountsNotInChartError } from '@/lib/bookkeeping/errors' import { getErrorMessage } from '@/lib/errors/get-error-message' import { eventBus } from '@/lib/events' @@ -332,6 +333,30 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string const { newPaidAmount, newRemaining, newStatus, isFullyPaid } = payment.plan const isPartial = customLines !== undefined && !isFullyPaid + // The generated cash entry books the FULL invoice and takes no payment + // amount: reject partials and part-paid completions for never-booked + // kontantmetoden invoices (mirrors the v1 match-invoice guard; + // bokslutsmetoden reports moms at payment, so each installment's moms + // belongs to its own receipt period). Custom lines are not exempt: they would book + // the same full-invoice shape under a user-shaped label. + const cashBlock = cashPartialBlockReason({ + invoiceAlreadyBooked, + accountingMethod, + priorPaidAmount: typed.paid_amount, + paysRemainingInFull: isFullyPaid, + }) + if ((!typed.document_type || typed.document_type === 'invoice') && cashBlock) { + return v1ErrorResponseFromCode('INVOICE_PAID_CASH_PARTIAL_UNSUPPORTED', ctx.log, { + requestId: ctx.requestId, + details: { + reason: cashBlock, + payment_amount: paymentAmountInInvoiceCurrency, + paid_amount: typed.paid_amount ?? 0, + invoice_total: typed.total, + }, + }) + } + // Duplicate-payment guard: surface a likely-matching unlinked inbound // bank transaction before booking (or before dry-run preview, so a // successful dry-run can't mask the warning). Skipped on partial diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts index 9af5966e..2cfb8280 100644 --- a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts +++ b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts @@ -27,6 +27,7 @@ import { createSupplierInvoicePaymentEntry, } from '@/lib/bookkeeping/supplier-invoice-entries' import { reverseEntry, createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine' +import { cashPartialBlockReason } from '@/lib/bookkeeping/booking-mode' import { isBookkeepingError } from '@/lib/bookkeeping/errors' import { anchorSupplierInvoiceDocument } from '@/lib/core/documents/supplier-invoice-underlag' import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions' @@ -291,6 +292,28 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string const siAlreadyBooked = !!(typed as { registration_journal_entry_id?: string | null }).registration_journal_entry_id const useCashEntry = !siAlreadyBooked && accountingMethod === 'cash' + // createSupplierInvoiceCashEntry books the FULL invoice and takes no + // payment amount: reject partials and part-paid completions for + // never-booked kontantmetoden invoices instead of over-booking the + // expense. Fires in dry-run too, so a preview cannot mask the rejection. + const cashBlock = cashPartialBlockReason({ + invoiceAlreadyBooked: siAlreadyBooked, + accountingMethod, + priorPaidAmount: typed.paid_amount, + paysRemainingInFull: newStatus === 'paid', + }) + if (cashBlock) { + return v1ErrorResponseFromCode('SI_CASH_PARTIAL_UNSUPPORTED', ctx.log, { + requestId: ctx.requestId, + details: { + reason: cashBlock, + payment_amount: paymentAmount, + paid_amount: typed.paid_amount, + remaining_amount: typed.remaining_amount, + }, + }) + } + // FX-required validation. Whenever the registration JE used the invoice's // exchange rate to compute subtotal_sek (i.e. the SI was booked under // accrual or migrated from accrual), the payment JE has to book any rate diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts index 313ef9e7..e8e61070 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts @@ -465,7 +465,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string // Reject cash-method partial payments ONLY for pure kontantmetoden // invoices (no prior JE). Under kontantmetoden utgående moms must be - // reported in the period of actual receipt (ML 13 kap 8 §); the + // reported in the period of actual receipt (bokslutsmetoden); the // partial-payment branch uses the accrual-style clearing entry which // doesn't model the per-installment moms event. When the invoice was // already booked under accrual, the clearing entry IS the correct diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts index ca11304f..9a336916 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts @@ -17,6 +17,7 @@ import { createSupplierInvoiceCashEntry, } from '@/lib/bookkeeping/supplier-invoice-entries' import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account' +import { cashPartialBlockReason } from '@/lib/bookkeeping/booking-mode' import { reverseEntry, createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine' import { AccountsNotInChartError } from '@/lib/bookkeeping/errors' import { findUnresolvableAccounts } from '@/lib/bookkeeping/account-validation' @@ -304,6 +305,26 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }) } + // Same-currency partials and part-paid completions are equally unbookable + // under kontantmetoden (createSupplierInvoiceCashEntry books the FULL + // invoice, so a partial bank amount would over-book the expense): reject + // them too, not only the FX case above. Mirrors the dashboard route. + const cashBlock = cashPartialBlockReason({ + invoiceAlreadyBooked: siAlreadyBooked, + accountingMethod, + priorPaidAmount: (invoice as { paid_amount?: number | null }).paid_amount, + paysRemainingInFull: fullSettlement, + }) + if (cashBlock) { + return v1ErrorResponseFromCode('SI_CASH_PARTIAL_UNSUPPORTED', txLog, { + requestId: ctx.requestId, + details: { + reason: cashBlock, + remaining_amount: invoice.remaining_amount, + }, + }) + } + // Strict-mode for the public API: abort before mutating state if the // payment JE can't be created. See the parallel comment in match-invoice. let journalEntryId: string | null = null diff --git a/lib/bookkeeping/__tests__/booking-mode.test.ts b/lib/bookkeeping/__tests__/booking-mode.test.ts index 0323d633..3b2225d1 100644 --- a/lib/bookkeeping/__tests__/booking-mode.test.ts +++ b/lib/bookkeeping/__tests__/booking-mode.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { booksInvoicesOnIssue } from '../booking-mode' +import { booksInvoicesOnIssue, cashPartialBlockReason } from '../booking-mode' describe('booksInvoicesOnIssue (#967)', () => { it('books at issue for accrual companies by default', () => { @@ -22,3 +22,47 @@ describe('booksInvoicesOnIssue (#967)', () => { expect(booksInvoicesOnIssue({})).toBe(true) }) }) + +describe('cashPartialBlockReason', () => { + const base = { + invoiceAlreadyBooked: false, + accountingMethod: 'cash', + priorPaidAmount: 0, + paysRemainingInFull: true, + } + + it('allows a full settlement from a fully unpaid state', () => { + expect(cashPartialBlockReason(base)).toBeNull() + }) + + it('blocks a partial payment on a never-booked cash invoice', () => { + expect(cashPartialBlockReason({ ...base, paysRemainingInFull: false })).toBe('partial_payment') + }) + + it('blocks completing a previously part-paid never-booked invoice', () => { + expect(cashPartialBlockReason({ ...base, priorPaidAmount: 500 })).toBe( + 'previously_partially_paid', + ) + }) + + it('never blocks invoices that were booked at issue (clearing entry handles partials)', () => { + expect( + cashPartialBlockReason({ ...base, invoiceAlreadyBooked: true, paysRemainingInFull: false }), + ).toBeNull() + }) + + it('never blocks under the accrual method, including the null-settings fallback', () => { + expect( + cashPartialBlockReason({ ...base, accountingMethod: 'accrual', paysRemainingInFull: false }), + ).toBeNull() + expect( + cashPartialBlockReason({ ...base, accountingMethod: '', paysRemainingInFull: false }), + ).toBeNull() + }) + + it('ignores sub-öre noise in the prior paid amount', () => { + expect(cashPartialBlockReason({ ...base, priorPaidAmount: 0.004 })).toBeNull() + expect(cashPartialBlockReason({ ...base, priorPaidAmount: null })).toBeNull() + expect(cashPartialBlockReason({ ...base, priorPaidAmount: undefined })).toBeNull() + }) +}) diff --git a/lib/bookkeeping/booking-mode.ts b/lib/bookkeeping/booking-mode.ts index b6754ae1..cb29d683 100644 --- a/lib/bookkeeping/booking-mode.ts +++ b/lib/bookkeeping/booking-mode.ts @@ -22,3 +22,36 @@ export function booksInvoicesOnIssue( if (!settings) return true return (settings.accounting_method || 'accrual') === 'accrual' && !settings.defer_invoice_booking } + +/** + * Kontantmetoden guard for the GENERATED payment entries on never-booked + * invoices. createInvoiceCashEntry and createSupplierInvoiceCashEntry always + * book the FULL invoice (revenue or expense + VAT + a full-total settlement + * leg); neither takes a payment amount. A generated cash entry is therefore + * only valid when the payment settles the invoice in full from a fully + * unpaid state: + * + * - a partial payment would book the whole invoice against a smaller bank + * movement and declare the whole VAT at once, but bokslutsmetoden reports + * moms at payment, so each installment's moms belongs to its own receipt + * period; + * - completing a previously part-paid invoice would book the full total a + * second time on the settlement account. + * + * Callers reject with INVOICE_PAID_CASH_PARTIAL_UNSUPPORTED (customer) or + * SI_CASH_PARTIAL_UNSUPPORTED (supplier) until per-installment recognition + * exists. Invoices already booked at issue are never affected: their payment + * is a plain clearing entry against 1510/2440, which handles partials fine. + */ +export function cashPartialBlockReason(opts: { + invoiceAlreadyBooked: boolean + accountingMethod: string + priorPaidAmount: number | null | undefined + paysRemainingInFull: boolean +}): 'partial_payment' | 'previously_partially_paid' | null { + if (opts.invoiceAlreadyBooked) return null + if ((opts.accountingMethod || 'accrual') !== 'cash') return null + if (!opts.paysRemainingInFull) return 'partial_payment' + if (Math.round((opts.priorPaidAmount ?? 0) * 100) !== 0) return 'previously_partially_paid' + return null +} diff --git a/lib/docs/content/cookbook/ingest-bank-transactions.ts b/lib/docs/content/cookbook/ingest-bank-transactions.ts index 6077e662..9b621ef3 100644 --- a/lib/docs/content/cookbook/ingest-bank-transactions.ts +++ b/lib/docs/content/cookbook/ingest-bank-transactions.ts @@ -214,7 +214,7 @@ The ledger is SEK-denominated; foreign amounts are converted to SEK automaticall - **Re-running the same file is safe; date-only overlap is also safe.** Dedup is primarily by a stable \`external_id\` (date + amount + counterparty); a secondary content match on \`(date, amount, description)\` against already-booked rows also skips, so partial overlap of two statements doesn't double-import. - **Settlement account selection matters.** The wrong settlement account silently breaks bank reconciliation later. \`1930\` (företagskonto) is the SEK default; a foreign-currency bank account uses its own asset account (e.g. \`1932\` for USD). \`/imports/bank\` resolves the settlement account automatically — to set it explicitly, ingest via \`POST /transactions/ingest\` with \`settlement_account\`. -- **Cash-method companies and partial payments don't mix.** If \`company_settings.accounting_method = 'cash'\` and you try to match a partial payment, the response is \`VALIDATION_ERROR\` rather than booking accrual entries: cash-method cannot model the per-installment moms event correctly (ML 13 kap 8 §). Either book the partial payment as a separate categorisation or switch to accrual. +- **Cash-method companies and partial payments don't mix.** If \`company_settings.accounting_method = 'cash'\` and you try to match a partial payment, the response is \`VALIDATION_ERROR\` rather than booking accrual entries: cash-method cannot model the per-installment moms event correctly (bokslutsmetoden reports moms at payment, per installment). Either book the partial payment as a separate categorisation or switch to accrual. - **Batch-categorize is partial-success by default.** If one item hits a locked period, the others still commit. The summary block tells you the totals; check per-item \`ok\` flags. ## Next steps diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index e2e38efb..8cc7ab09 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -669,6 +669,20 @@ const MATCH_SI: Record = { message_en: 'The cash method cannot handle a partial foreign-currency payment. Pay the invoice in full, switch to accrual, or book the payment manually.', }, + INVOICE_PAID_CASH_PARTIAL_UNSUPPORTED: { + httpStatus: 400, + message_sv: + 'Kontantmetoden kan inte bokföra delbetalningar av en obokförd faktura automatiskt: hela fakturan bokförs vid betalning. Ta emot hela beloppet i en betalning, byt till faktureringsmetoden eller bokför betalningen manuellt som verifikation.', + message_en: + 'The cash method cannot auto-book partial payments of an unbooked invoice: the generated entry always books the full invoice. Receive the full amount in one payment, switch to the accrual method, or book the payment manually as a journal entry.', + }, + SI_CASH_PARTIAL_UNSUPPORTED: { + httpStatus: 400, + message_sv: + 'Kontantmetoden kan inte bokföra delbetalningar av en obokförd leverantörsfaktura automatiskt: hela fakturan bokförs vid betalning. Betala hela beloppet i en betalning eller bokför betalningen manuellt som verifikation.', + message_en: + 'The cash method cannot auto-book partial payments of an unbooked supplier invoice: the generated entry always books the full invoice. Pay the full amount in one payment or book the payment manually as a journal entry.', + }, MATCH_SI_AMOUNT_EXCEEDS_REMAINING: { httpStatus: 400, message_sv: diff --git a/lib/invoices/__tests__/settle-invoice-payment.test.ts b/lib/invoices/__tests__/settle-invoice-payment.test.ts index 19ccde05..314e82d4 100644 --- a/lib/invoices/__tests__/settle-invoice-payment.test.ts +++ b/lib/invoices/__tests__/settle-invoice-payment.test.ts @@ -103,6 +103,62 @@ describe('settleInvoicePayment', () => { ) }) + it('rejects a cash-method partial payment on a never-booked invoice before booking anything', async () => { + const { supabase } = createQueuedMockSupabase() + const invoice = payableInvoice({ journal_entry_id: null } as Partial) + const result = await settleInvoicePayment( + supabase as unknown as SupabaseClient, + 'company-1', + 'user-1', + { + ...BASE_PARAMS, + invoice, + accountingMethod: 'cash', + paymentAmountInInvoiceCurrency: 500, + }, + ) + + expect(result).toMatchObject({ + ok: false, + code: 'INVOICE_PAID_CASH_PARTIAL_UNSUPPORTED', + details: { reason: 'partial_payment' }, + }) + // The full-invoice cash entry must never book against a partial receipt, + // and no invoice state may change. + expect(vi.mocked(createInvoiceCashEntry)).not.toHaveBeenCalled() + expect(vi.mocked(createInvoicePaymentJournalEntry)).not.toHaveBeenCalled() + expect(vi.mocked(createJournalEntry)).not.toHaveBeenCalled() + }) + + it('rejects completing a previously part-paid never-booked cash invoice (would double-book the total)', async () => { + const { supabase } = createQueuedMockSupabase() + const invoice = payableInvoice({ + status: 'partially_paid', + journal_entry_id: null, + remaining_amount: 750, + paid_amount: 500, + } as Partial) + const result = await settleInvoicePayment( + supabase as unknown as SupabaseClient, + 'company-1', + 'user-1', + { + ...BASE_PARAMS, + invoice, + accountingMethod: 'cash', + paymentAmountInInvoiceCurrency: 750, + }, + ) + + expect(result).toMatchObject({ + ok: false, + code: 'INVOICE_PAID_CASH_PARTIAL_UNSUPPORTED', + details: { reason: 'previously_partially_paid' }, + }) + expect(vi.mocked(createInvoiceCashEntry)).not.toHaveBeenCalled() + expect(vi.mocked(createInvoicePaymentJournalEntry)).not.toHaveBeenCalled() + }) + it('uses the cash entry for unbooked kontantmetoden invoices', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: [{ id: 'inv-1' }] }) diff --git a/lib/invoices/settle-invoice-payment.ts b/lib/invoices/settle-invoice-payment.ts index 2ecd7897..c97e94e5 100644 --- a/lib/invoices/settle-invoice-payment.ts +++ b/lib/invoices/settle-invoice-payment.ts @@ -4,6 +4,7 @@ import { createInvoiceCashEntry, } from '@/lib/bookkeeping/invoice-entries' import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine' +import { cashPartialBlockReason } from '@/lib/bookkeeping/booking-mode' import { resolveInvoicePaymentSourceType } from '@/lib/bookkeeping/propose-payment-lines' import { isBookkeepingError } from '@/lib/bookkeeping/errors' import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry' @@ -80,6 +81,7 @@ export type SettleInvoicePaymentResult = paidAt: string | null } | { ok: false; code: 'MATCH_AMOUNT_EXCEEDS_REMAINING'; details: Record } + | { ok: false; code: 'INVOICE_PAID_CASH_PARTIAL_UNSUPPORTED'; details: Record } | { ok: false; code: 'INVOICE_PAID_LINES_UNBALANCED'; details: Record } | { ok: false; code: 'INVOICE_PAID_NO_FISCAL_PERIOD'; details: Record } | { ok: false; code: 'INVOICE_PAID_BOOK_FAILED'; details: Record } @@ -149,6 +151,34 @@ export async function settleInvoicePayment( const paidAt = newStatus === 'paid' ? paidAtFromDate(paymentDate) : null const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice' + + // The generated cash entry (createInvoiceCashEntry) books the FULL invoice + // and takes no payment amount, so a never-booked kontantmetoden invoice can + // only be settled in full from a fully unpaid state. Partials used to book + // the entire revenue + moms against a smaller bank movement (bokslutsmetoden + // reports moms at payment, per installment), and completing a + // prior partial would book the full total a second time. Custom lines are + // NOT exempt: the dialog pre-fills the same full-invoice shape, so lines + // would book the identical error under a user-shaped label. + const cashBlock = cashPartialBlockReason({ + invoiceAlreadyBooked, + accountingMethod, + priorPaidAmount: invoice.paid_amount, + paysRemainingInFull: newStatus === 'paid', + }) + if (isRealInvoice && cashBlock) { + return { + ok: false, + code: 'INVOICE_PAID_CASH_PARTIAL_UNSUPPORTED', + details: { + reason: cashBlock, + payment_amount: paymentAmountInInvoiceCurrency, + paid_amount: invoice.paid_amount ?? 0, + invoice_total: invoice.total, + }, + } + } + let journalEntryId: string | null = null if (isRealInvoice) { diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index 7e01beac..721b46d5 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -35,6 +35,7 @@ import { createCreditNoteJournalEntry, } from '@/lib/bookkeeping/invoice-entries' import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account' +import { cashPartialBlockReason } from '@/lib/bookkeeping/booking-mode' import { ensureManualCashAccount } from '@/lib/cash-accounts/service' import { createJournalEntry, findFiscalPeriod, getSwedishLocalDate, reverseEntry, validateBalance } from '@/lib/bookkeeping/engine' import { @@ -1989,6 +1990,26 @@ async function commitMarkInvoicePaid( } const { newPaidAmount, newRemaining, newStatus } = payment.plan + // The generated cash entry books the FULL invoice: refuse to complete a + // previously part-paid, never-booked kontantmetoden invoice (it would book + // the full total a second time on the settlement account). A partial cannot + // arise here (this path always settles the full remaining), but the shared + // predicate covers it for safety. + const cashBlock = cashPartialBlockReason({ + invoiceAlreadyBooked, + accountingMethod, + priorPaidAmount: inv.paid_amount, + paysRemainingInFull: newStatus === 'paid', + }) + if (isRealInvoice && cashBlock) { + return { + error: + getErrorEntry('INVOICE_PAID_CASH_PARTIAL_UNSUPPORTED')?.message_sv ?? + 'Kontantmetoden kan inte bokföra delbetalningar av en obokförd faktura automatiskt.', + status: 400, + } + } + if (isRealInvoice) { if (useCashEntry) { const je = await createInvoiceCashEntry( @@ -2520,6 +2541,27 @@ async function commitMatchTransactionInvoice( const invoiceAlreadyBooked = !!(invoice as { journal_entry_id?: string | null }).journal_entry_id const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash' && isFullyPaid + // Reject cash-method partials and part-paid completions on never-booked + // invoices BEFORE the irreversible storno below. The old fallback booked an + // accrual-style clearing entry against an EMPTY 1510 (negative receivable, + // no revenue, no moms: bokslutsmetoden reports moms at payment, per + // installment), and the cash builder books the FULL invoice, so + // neither shape is bookable here. Mirrors the dashboard and v1 match routes. + const cashBlock = cashPartialBlockReason({ + invoiceAlreadyBooked, + accountingMethod, + priorPaidAmount: (invoice as { paid_amount?: number | null }).paid_amount, + paysRemainingInFull: isFullyPaid, + }) + if (cashBlock) { + return { + error: + getErrorEntry('INVOICE_PAID_CASH_PARTIAL_UNSUPPORTED')?.message_sv ?? + 'Kontantmetoden kan inte bokföra delbetalningar av en obokförd faktura automatiskt.', + status: 400, + } + } + // Debit the cash account THIS transaction actually belongs to, never a // hardcoded 1930: cash_account_id -> cash_accounts.ledger_account is the // only source of truth for which bank/cash account a real, matched @@ -2601,9 +2643,9 @@ async function commitMatchTransactionInvoice( } } - const paymentNotes = (accountingMethod === 'cash' && !isFullyPaid) - ? 'Kontantmetoden: intäkt bokförs vid slutbetalning' : null - + // No cash-method note here anymore: pure kontantmetoden partials are now + // rejected above, and for an invoice booked at send the clearing entry + // handles a partial correctly, so the note would be misleading. await supabase.from('invoice_payments').insert({ user_id: userId, company_id: companyId, @@ -2614,7 +2656,7 @@ async function commitMatchTransactionInvoice( exchange_rate: invoice.exchange_rate, journal_entry_id: journalEntryId, transaction_id: transactionId, - notes: paymentNotes, + notes: null, }) // The invoice is now settled, so every OTHER transaction still carrying a