fix(invoices): ROT/RUT kontantmetoden invoices could not be marked paid (#2040)
* fix(invoices): derive mark-paid amount as customer settlement, not gross debit sum remaining_amount on a ROT/RUT invoice is stored net of the deduction (total - deduction_total): the customer owes only their share, and Skatteverket's share sits on 1513 until the payout flow clears it. The kontantmetoden payment entry correctly books two debit legs (bank = customer share, 1513 = deduction), but both mark-paid routes summed ALL debit lines as the payment amount, so the gross total was compared against the net remaining and every ROT/RUT cash invoice was rejected with MATCH_AMOUNT_EXCEEDS_REMAINING by exactly deduction_total, stalling the whole ROT chain (unpaid invoice never becomes a payout candidate). New deriveCustomerSettlementAmount in lib/invoices/apply-invoice-payment excludes the net 1513 debit, capped at the invoice's own deduction (so invoices without a deduction keep byte-identical behavior, including rejecting a hand-added 1513 overshoot), and both the dashboard and v1 mark-paid routes use it. The verifikat still books the full entry including the 1513 leg; only the settlement math changes. Reported by a user unable to mark ROT invoice 1123 as paid. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uy5xt3nCKwL1vhRFPYjAjJ * fix(invoices): harden ROT settlement derivation per skeptic refutations Three fixes from adversarial review of the previous commit: 1. v1 mark-paid never fetched deduction_total (the pre-flight select projects explicit columns), so the exclusion cap was always 0 and the v1 API still failed with MATCH_AMOUNT_EXCEEDS_REMAINING. Fetch it ad hoc next to journal_entry_id (kept out of the response contract) and assert the projection in the route test, since the mock harness ignores select strings. 2. Gate the 1513 exclusion on the invoice NOT being booked yet, in both routes. An invoice booked at send already debited 1513 in its registration entry; ungated, a cash-shaped payment entry on such an invoice would post (orphaned 1510, doubled 1513, double revenue and VAT) where the gross guard used to reject it. 3. payment-sync's reversal recompute now stores remaining_amount net of deduction_total, matching build-invoice-write and the DB guard. Recomputing gross made a storno'd ROT cash invoice permanently un-payable under the net settlement derivation (net payment can never reach a gross remaining; the cash-partial block rejects the rest). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uy5xt3nCKwL1vhRFPYjAjJ --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
355723c566
commit
e313bfa8ec
@@ -638,6 +638,132 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts the kontantmetoden ROT payment entry: the 1513 leg is not customer money', async () => {
|
||||
// proposeCashLines' own prefill on a ROT invoice: total 124 000, 30 %
|
||||
// deduction 37 200, remaining_amount stored net (86 800). Summing all
|
||||
// debits (124 000) used to trip MATCH_AMOUNT_EXCEEDS_REMAINING by exactly
|
||||
// the deduction, making every ROT/RUT cash invoice un-payable.
|
||||
const invoice = makeInvoice({
|
||||
id: 'inv-1',
|
||||
status: 'sent',
|
||||
subtotal: 99200,
|
||||
vat_amount: 24800,
|
||||
total: 124000,
|
||||
deduction_total: 37200,
|
||||
remaining_amount: 86800,
|
||||
})
|
||||
|
||||
// Fetch invoice
|
||||
enqueue({ data: invoice, error: null })
|
||||
// Fetch company settings
|
||||
enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null })
|
||||
// Update invoice status (CAS guard: returns matched row)
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
|
||||
mockFindFiscalPeriod.mockResolvedValue('fp-1')
|
||||
mockCreateJournalEntry.mockResolvedValue({ id: 'je-rot' })
|
||||
|
||||
const rotCashLines = [
|
||||
{ account_number: '1930', debit_amount: 86800, credit_amount: 0 },
|
||||
{ account_number: '1513', debit_amount: 37200, credit_amount: 0 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 99200 },
|
||||
{ account_number: '2611', debit_amount: 0, credit_amount: 24800 },
|
||||
]
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
|
||||
method: 'POST',
|
||||
body: { payment_date: '2026-08-29', lines: rotCashLines },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
status: string
|
||||
paid_amount: number
|
||||
remaining_amount: number
|
||||
journal_entry_id: string | null
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.status).toBe('paid')
|
||||
expect(body.paid_amount).toBe(86800)
|
||||
expect(body.remaining_amount).toBe(0)
|
||||
expect(body.journal_entry_id).toBe('je-rot')
|
||||
// The verifikat still books the FULL entry including the 1513 leg.
|
||||
expect(mockCreateJournalEntry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'user-1',
|
||||
expect.objectContaining({ lines: rotCashLines }),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects the cash-shaped 1513 lines on a ROT invoice already booked at send', async () => {
|
||||
// 1513 was already debited in the registration entry; a 1513 debit in the
|
||||
// payment lines would double it and double-count revenue + VAT. The
|
||||
// exclusion is gated off for booked invoices, so the gross sum (124 000)
|
||||
// still trips the guard against the net remaining (86 800).
|
||||
const invoice = makeInvoice({
|
||||
id: 'inv-1',
|
||||
status: 'sent',
|
||||
total: 124000,
|
||||
deduction_total: 37200,
|
||||
remaining_amount: 86800,
|
||||
journal_entry_id: 'je-registration',
|
||||
})
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
lines: [
|
||||
{ account_number: '1930', debit_amount: 86800, credit_amount: 0 },
|
||||
{ account_number: '1513', debit_amount: 37200, credit_amount: 0 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 99200 },
|
||||
{ account_number: '2611', debit_amount: 0, credit_amount: 24800 },
|
||||
],
|
||||
},
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect((body.error as unknown as { code: string }).code).toBe('MATCH_AMOUNT_EXCEEDS_REMAINING')
|
||||
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still rejects when the bank leg alone overpays a ROT invoice', async () => {
|
||||
const invoice = makeInvoice({
|
||||
id: 'inv-1',
|
||||
status: 'sent',
|
||||
total: 124000,
|
||||
deduction_total: 37200,
|
||||
remaining_amount: 86800,
|
||||
})
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
|
||||
// Bank 90 000 exceeds the 86 800 customer share even after the 1513
|
||||
// exclusion: the overpayment guard must still fire.
|
||||
const overpayLines = [
|
||||
{ account_number: '1930', debit_amount: 90000, credit_amount: 0 },
|
||||
{ account_number: '1513', debit_amount: 37200, credit_amount: 0 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 127200 },
|
||||
]
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
|
||||
method: 'POST',
|
||||
body: { lines: overpayLines },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect((body.error as unknown as { code: string }).code).toBe('MATCH_AMOUNT_EXCEEDS_REMAINING')
|
||||
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces ocr_exact match_reason when tx reference normalizes to invoice_number', async () => {
|
||||
const customer = makeCustomer()
|
||||
const invoice = makeInvoice({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { MarkInvoicePaidSchema } from '@/lib/api/schemas'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { deriveCustomerSettlementAmount } from '@/lib/invoices/apply-invoice-payment'
|
||||
import { findDuplicatePaymentCandidatesForInvoice } from '@/lib/invoices/duplicate-payment-candidates'
|
||||
import { settleInvoicePayment } from '@/lib/invoices/settle-invoice-payment'
|
||||
import { roundOre } from '@/lib/money'
|
||||
@@ -121,9 +122,6 @@ export const POST = withRouteContext(
|
||||
}
|
||||
const remainingAmount =
|
||||
invForRemaining.remaining_amount ?? invoice.total - (invForRemaining.paid_amount ?? 0)
|
||||
const paymentAmount = customLines
|
||||
? customLines.reduce((s, l) => s + l.debit_amount, 0)
|
||||
: remainingAmount
|
||||
|
||||
// Unit contract: total / paid_amount / remaining_amount are stored in the
|
||||
// INVOICE currency (total_sek carries the SEK view of total); custom lines
|
||||
@@ -150,6 +148,33 @@ export const POST = withRouteContext(
|
||||
details: { invoice_id: id, currency: invoice.currency },
|
||||
})
|
||||
}
|
||||
|
||||
// Payment amount = customer settlement, not the gross debit sum: the
|
||||
// kontantmetoden ROT/RUT entry carries a second debit leg on 1513
|
||||
// (Skatteverket's share) while remaining_amount is stored net of the
|
||||
// deduction, so summing all debits would reject every such invoice with
|
||||
// MATCH_AMOUNT_EXCEEDS_REMAINING by exactly deduction_total (see
|
||||
// deriveCustomerSettlementAmount). deduction_total is invoice-currency;
|
||||
// the cap is converted to SEK to match the lines.
|
||||
//
|
||||
// Gated on the invoice NOT being booked yet: an invoice booked at send
|
||||
// already debited 1513 in its registration entry, so a 1513 debit in the
|
||||
// PAYMENT lines is always wrong there (it would double 1513 and, with
|
||||
// revenue credits, double 30xx/26xx). For those, the gross sum stays the
|
||||
// payment amount and the overpayment guard keeps rejecting the
|
||||
// wrong-shaped entry exactly as before.
|
||||
const invoiceAlreadyBooked = !!(invoice as { journal_entry_id?: string | null })
|
||||
.journal_entry_id
|
||||
const deductionTotal = invoiceAlreadyBooked
|
||||
? 0
|
||||
: (invoice as { deduction_total?: number | null }).deduction_total ?? 0
|
||||
const deductionCapSek =
|
||||
deductionTotal > 0 && needsFxConversion
|
||||
? roundOre(deductionTotal * fxRate!)
|
||||
: deductionTotal
|
||||
const paymentAmount = customLines
|
||||
? deriveCustomerSettlementAmount(customLines, deductionCapSek)
|
||||
: remainingAmount
|
||||
const paymentAmountInInvoiceCurrency = needsFxConversion
|
||||
? roundOre(paymentAmount / fxRate!)
|
||||
: paymentAmount
|
||||
|
||||
Reference in New Issue
Block a user