fix(invoices): guard cash-method partial payments across every payment path (#1413)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
0449b1d0ea
commit
17d48ebb84
@@ -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({
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user