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