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:
Mattsson
2026-08-30 15:12:37 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 355723c566
commit e313bfa8ec
8 changed files with 497 additions and 23 deletions
@@ -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({
+28 -3
View File
@@ -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
@@ -267,8 +267,13 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
// routing reads it; omitting it silently forces the cash path.
expect(invoiceSelects.length).toBeGreaterThanOrEqual(2)
expect(String(invoiceSelects[0].args[0])).toContain('journal_entry_id')
// ...and deduction_total: the ROT/RUT settlement derivation reads it, and
// the mock harness ignores projections, so without this assertion the
// route can green-test while fetching a row that lacks the column.
expect(String(invoiceSelects[0].args[0])).toContain('deduction_total')
// Response select (the update's .select) keeps the public contract unchanged.
expect(String(invoiceSelects[1].args[0])).not.toContain('journal_entry_id')
expect(String(invoiceSelects[1].args[0])).not.toContain('deduction_total')
})
it('clears AR (payment entry) when a cash-method company pays an invoice booked at send', async () => {
@@ -395,6 +400,139 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
expect(mockPayment).not.toHaveBeenCalled()
})
it('accepts the kontantmetoden ROT payment entry: the 1513 leg is not customer money', async () => {
// remaining_amount is stored net of the ROT/RUT deduction; the cash entry
// still books the gross shape with a 1513 debit for Skatteverket's share.
// Summing all debits (124 000) used to reject every such invoice against
// the 86 800 remaining by exactly deduction_total.
const ROT_INVOICE = {
...SENT_INVOICE,
subtotal: 99200,
vat_amount: 24800,
total: 124000,
deduction_total: 37200,
remaining_amount: 86800,
}
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: [
{ data: ROT_INVOICE, error: null },
{ data: { ...ROT_INVOICE, status: 'paid', remaining_amount: 0, paid_amount: 86800, paid_at: '2026-08-29T12:00:00Z' }, error: null },
],
company_settings: { data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null },
transactions: { data: [], error: null },
}),
)
const res = await markPaid(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-paid`,
{
payment_date: '2026-08-29',
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 },
],
},
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.status).toBe('paid')
expect(body.data.remaining_amount).toBe(0)
expect(body.data.paid_amount).toBe(86800)
})
it('rejects the cash-shaped 1513 lines on a ROT invoice already booked at send', async () => {
// Booked-at-send accrual ROT invoice: 1513 was debited in the
// registration entry, so a 1513 debit in the PAYMENT lines would double
// 1513 and double-count revenue + VAT. The 1513 exclusion must be gated
// off, leaving the gross sum (124 000) to trip the overpayment guard
// against the net remaining (86 800), exactly as before the fix.
const BOOKED_ROT_INVOICE = {
...SENT_INVOICE,
subtotal: 99200,
vat_amount: 24800,
total: 124000,
deduction_total: 37200,
remaining_amount: 86800,
journal_entry_id: 'rrrrrrrr-rrrr-4rrr-8rrr-rrrrrrrrrrrr',
}
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: { data: BOOKED_ROT_INVOICE, error: null },
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
transactions: { data: [], error: null },
}),
)
const res = await markPaid(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-paid`,
{
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 },
],
},
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('MATCH_AMOUNT_EXCEEDS_REMAINING')
expect(mockPayment).not.toHaveBeenCalled()
expect(mockCash).not.toHaveBeenCalled()
})
it('still rejects when the bank leg alone overpays a ROT invoice', async () => {
const ROT_INVOICE = {
...SENT_INVOICE,
total: 124000,
deduction_total: 37200,
remaining_amount: 86800,
}
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: { data: ROT_INVOICE, error: null },
company_settings: { data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null },
transactions: { data: [], error: null },
}),
)
// 90 000 to the bank exceeds the 86 800 customer share even after the
// 1513 exclusion: the overpayment guard must still fire.
const res = await markPaid(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-paid`,
{
lines: [
{ 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 },
],
},
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('MATCH_AMOUNT_EXCEEDS_REMAINING')
expect(mockPayment).not.toHaveBeenCalled()
expect(mockCash).not.toHaveBeenCalled()
})
it('absorbs an öresavrundning overshoot on SEK custom lines (rounded "Att betala")', async () => {
// Invoice stored with öre (1234.75); the PDF shows 1235.00 and the customer
// pays that. The 3740 line carries the residual and the invoice settles in
@@ -43,7 +43,10 @@ import { AccountsNotInChartError } from '@/lib/bookkeeping/errors'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { eventBus } from '@/lib/events'
import { findDuplicatePaymentCandidatesForInvoice } from '@/lib/invoices/duplicate-payment-candidates'
import { planInvoicePaymentForLines } from '@/lib/invoices/apply-invoice-payment'
import {
deriveCustomerSettlementAmount,
planInvoicePaymentForLines,
} from '@/lib/invoices/apply-invoice-payment'
import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions'
import { paidAtFromDate } from '@/lib/invoices/paid-at'
import { roundOre } from '@/lib/money'
@@ -177,13 +180,14 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
}
// Pre-flight: fetch invoice with relations needed for journal entry.
// journal_entry_id is fetched for booking-state routing only (it stays out
// of INVOICE_MARK_PAID_RESPONSE_COLUMNS so the response contract and the
// invoice.paid event payload are unchanged).
// journal_entry_id (booking-state routing) and deduction_total (ROT/RUT
// settlement derivation) are fetched ad hoc: they stay out of
// INVOICE_MARK_PAID_RESPONSE_COLUMNS so the response contract and the
// invoice.paid event payload are unchanged.
const { data: invoice, error: fetchErr } = await ctx.supabase
.from('invoices')
.select(
`${INVOICE_MARK_PAID_RESPONSE_COLUMNS}, journal_entry_id, customer:customers(id, name, customer_type), items:invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, dimensions)`,
`${INVOICE_MARK_PAID_RESPONSE_COLUMNS}, journal_entry_id, deduction_total, customer:customers(id, name, customer_type), items:invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, dimensions)`,
)
.eq('company_id', ctx.companyId!)
.eq('id', invoiceId)
@@ -264,19 +268,6 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
const invoiceAlreadyBooked = !!(typed as { journal_entry_id?: string | null }).journal_entry_id
const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash'
// Compute the would-be payment amount. Default path (no customLines):
// use remaining_amount, not total: protects against over-crediting AR
// when a concurrent partial payment slips through the pre-flight check
// (pre-flight sees status='sent' but the race-guard UPDATE later sees
// status='partially_paid' so a second full-total amount would be booked
// against an already-reduced AR balance). For legacy rows where
// remaining_amount was never written, derive it from total paid_amount
// rather than the full total. This is the booking-currency amount (SEK for
// custom lines) used by the duplicate guard, the JE builder, and the event.
const paymentAmount = customLines
? customLines.reduce((s, l) => s + l.debit_amount, 0)
: (typed.remaining_amount ?? typed.total - (typed.paid_amount ?? 0))
// Unit contract: total / paid_amount / remaining_amount are stored in the
// INVOICE currency (total_sek carries the SEK view of total, and there is
// no remaining_amount_sek twin); custom lines are journal lines, so they
@@ -303,6 +294,40 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
details: { invoice_id: invoiceId, currency: typed.currency },
})
}
// Compute the would-be payment amount. Default path (no customLines):
// use remaining_amount, not total: protects against over-crediting AR
// when a concurrent partial payment slips through the pre-flight check
// (pre-flight sees status='sent' but the race-guard UPDATE later sees
// status='partially_paid' so a second full-total amount would be booked
// against an already-reduced AR balance). For legacy rows where
// remaining_amount was never written, derive it from total - paid_amount
// rather than the full total. This is the booking-currency amount (SEK for
// custom lines) used by the duplicate guard, the JE builder, and the event.
//
// Custom lines yield the 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 (double 1513, 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 deductionTotal = invoiceAlreadyBooked
? 0
: (typed as { deduction_total?: number | null }).deduction_total ?? 0
const deductionCapSek =
deductionTotal > 0 && needsFxConversion
? roundOre(deductionTotal * fxRate!)
: deductionTotal
const paymentAmount = customLines
? deriveCustomerSettlementAmount(customLines, deductionCapSek)
: (typed.remaining_amount ?? typed.total - (typed.paid_amount ?? 0))
const paymentAmountInInvoiceCurrency = needsFxConversion
? roundOre(paymentAmount / fxRate!)
: paymentAmount
@@ -239,6 +239,34 @@ describe('syncInvoiceStatusFromPaymentEntry', () => {
expect(wasDeleted('invoice_payments')).toBe(true)
})
// ROT/RUT: remaining_amount is the CUSTOMER share (total - deduction_total),
// as build-invoice-write stores it. Recomputing it gross on reversal used to
// inflate remaining to total, after which the net customer settlement could
// never reach it again and the invoice was permanently un-payable.
it('customer cash-payment reversal keeps remaining net of the ROT/RUT deduction', async () => {
const { supabase, updatePayload } = createRecordingSupabase([
{ data: null }, // invoice_payments select amount → none (cash entry)
{ data: { paid_amount: 86800, total: 124000, deduction_total: 37200, due_date: '2099-12-31' } }, // invoices select
{ data: null }, // invoices update
{ data: [] }, // invoice_payments select transaction_id
{ data: null }, // invoice_payments delete
{ data: null }, // transactions update
])
await syncInvoiceStatusFromPaymentEntry(
supabase,
'co-1',
entry({ source_type: 'invoice_cash_payment', source_id: 'invoice-1' }),
)
expect(updatePayload('invoices')).toEqual({
status: 'sent',
paid_at: null,
paid_amount: 0,
remaining_amount: 86800,
})
})
// Partial reversal (clearing entry with a payment row): only the reversed
// amount comes off, remaining = total - newPaid, status stays partially_paid.
it('customer partial reversal keeps remaining_amount = total - newPaid', async () => {
+17 -2
View File
@@ -152,7 +152,7 @@ export async function syncInvoiceStatusFromPaymentEntry(
const { data: customerInvoice } = await supabase
.from('invoices')
.select('paid_amount, total, due_date')
.select('paid_amount, total, due_date, deduction_total')
.eq('id', entry.source_id)
.eq('company_id', companyId)
.single()
@@ -172,7 +172,22 @@ export async function syncInvoiceStatusFromPaymentEntry(
// .in('status', …) guard below can leave status/remaining un-updated if
// the invoice isn't paid/partially_paid: only reachable on a non-storno
// path; the payment-row delete + tx release still run, freeing the line.)
const newRemaining = roundOre(customerInvoice.total - safePaidAmount)
//
// remaining_amount is the CUSTOMER share: net of the ROT/RUT deduction,
// exactly as build-invoice-write stores it at creation (total -
// deduction_total) and as the invoices_remaining_amount_guard trigger
// derives it. Recomputing gross here inflated remaining on ROT/RUT
// invoices after a storno, which made them permanently un-settleable
// once mark-paid started comparing the net customer settlement against
// remaining (a net payment can never reach a gross remaining, and the
// cash-partial block rejects the "partial"). Mirrors rot-rut-file's
// derivation, which distrusted this very writer.
const deductionTotal =
(customerInvoice as { deduction_total?: number | null }).deduction_total ?? 0
const newRemaining = Math.max(
0,
roundOre(customerInvoice.total - deductionTotal - safePaidAmount),
)
const revertStatus = newPaidAmount > 0
? 'partially_paid'
: customerInvoice.due_date && new Date(customerInvoice.due_date) < new Date()
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'
import {
deriveCustomerSettlementAmount,
planInvoicePayment,
planInvoicePaymentForLines,
PAYMENT_OVERSHOOT_TOLERANCE,
@@ -247,3 +248,81 @@ describe('planInvoicePaymentForLines', () => {
expect(planInvoicePaymentForLines(INV, 1235, undefined, 'SEK').ok).toBe(false)
})
})
describe('deriveCustomerSettlementAmount', () => {
// The kontantmetoden ROT shape from proposeCashLines: total 124 000,
// deduction 37 200 (30 %), customer share 86 800.
const ROT_CASH_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 },
]
it('excludes the 1513 leg on a ROT invoice (the reported bug)', () => {
expect(deriveCustomerSettlementAmount(ROT_CASH_LINES, 37200)).toBe(86800)
})
it('is the plain debit sum without any 1513 line', () => {
const lines = [
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 1000 },
]
expect(deriveCustomerSettlementAmount(lines, 0)).toBe(1000)
expect(deriveCustomerSettlementAmount(lines, 37200)).toBe(1000)
})
it('with cap 0 a hand-added 1513 debit still counts as payment (non-ROT unchanged)', () => {
const lines = [
{ account_number: '1930', debit_amount: 900, credit_amount: 0 },
{ account_number: '1513', debit_amount: 100, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 1000 },
]
expect(deriveCustomerSettlementAmount(lines, 0)).toBe(1000)
})
it('excludes at most the deduction cap when the 1513 debit overshoots it', () => {
const lines = [
{ account_number: '1930', debit_amount: 86800, credit_amount: 0 },
{ account_number: '1513', debit_amount: 40000, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 102000 },
{ account_number: '2611', debit_amount: 0, credit_amount: 24800 },
]
// Only 37 200 of the 40 000 is deduction; the surplus stays in the amount
// so the overpayment guard still sees it.
expect(deriveCustomerSettlementAmount(lines, 37200)).toBe(89600)
})
it('nets 1513 debits against 1513 credits before excluding', () => {
// A self-canceling 1513 debit/credit pair does not raise the exclusion:
// net 1513 stays 37 200, so the extra 500 debit stays in the settlement
// amount and the overpayment guard sees it (safe direction: rejects
// rather than silently excludes).
const lines = [
...ROT_CASH_LINES,
{ account_number: '1513', debit_amount: 500, credit_amount: 0 },
{ account_number: '1513', debit_amount: 0, credit_amount: 500 },
]
expect(deriveCustomerSettlementAmount(lines, 37200)).toBe(87300)
})
it('a net 1513 credit is not added to the settlement', () => {
const lines = [
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
{ account_number: '1513', debit_amount: 0, credit_amount: 200 },
{ account_number: '1510', debit_amount: 0, credit_amount: 800 },
]
expect(deriveCustomerSettlementAmount(lines, 37200)).toBe(1000)
})
it('leaves 3740 öre legs untouched and rounds float sums', () => {
const lines = [
{ account_number: '1930', debit_amount: 86800.4, credit_amount: 0 },
{ account_number: '1513', debit_amount: 37200, credit_amount: 0 },
{ account_number: '3740', debit_amount: 0.1, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 99200.5 },
{ account_number: '2611', debit_amount: 0, credit_amount: 24800 },
]
expect(deriveCustomerSettlementAmount(lines, 37200)).toBe(86800.5)
})
})
+38
View File
@@ -120,6 +120,44 @@ export function planInvoicePayment(
/** BAS öres- och kronutjämning: the only account that may carry an absorbed residual. */
const ORE_ROUNDING_ACCOUNT = '3740'
/** BAS 1513, Kundfordringar delad faktura: Skatteverket's ROT/RUT share. */
export const ROT_RUT_RECEIVABLE_ACCOUNT = '1513'
/**
* Derives the customer-settlement amount from caller-supplied booking lines.
*
* `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-request flow clears it.
* The kontantmetoden payment entry, however, correctly books TWO debit legs
* (bank = customer share, 1513 = deduction), so a naive sum of all debits
* yields the gross total and every ROT/RUT cash invoice fails the
* overpayment guard by exactly the deduction.
*
* The 1513 exclusion is netted against 1513 credits (a debit/credit
* correction pair in one entry must not shrink the settlement) and capped at
* the invoice's own deduction, so on an invoice without a deduction the
* result is the plain debit sum and behavior is unchanged, including the
* rejection of a hand-added 1513 debit that overshoots the remaining.
*
* `deductionCapSek` must be in SEK (the lines' currency); the caller owns
* converting a foreign-currency invoice's deduction_total.
*/
export function deriveCustomerSettlementAmount(
lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }>,
deductionCapSek: number,
): number {
const totalDebit = lines.reduce((s, l) => s + l.debit_amount, 0)
if (deductionCapSek <= 0) return totalDebit
const net1513 = roundOre(
lines
.filter((l) => l.account_number === ROT_RUT_RECEIVABLE_ACCOUNT)
.reduce((s, l) => s + l.debit_amount - l.credit_amount, 0),
)
const excluded = Math.min(Math.max(net1513, 0), deductionCapSek)
return roundOre(totalDebit - excluded)
}
/**
* `planInvoicePayment` for caller-supplied booking lines (the mark-paid
* dialog and the v1 API), where the server does NOT build the verifikat.