diff --git a/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts index 84b073f2..17dd4b2a 100644 --- a/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts +++ b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts @@ -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({ diff --git a/app/api/invoices/[id]/mark-paid/route.ts b/app/api/invoices/[id]/mark-paid/route.ts index 5d4a9f32..80f12848 100644 --- a/app/api/invoices/[id]/mark-paid/route.ts +++ b/app/api/invoices/[id]/mark-paid/route.ts @@ -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 diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/__tests__/route.test.ts index df8b26af..9215f9f5 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/__tests__/route.test.ts @@ -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 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 cfa25e08..d0a79a2b 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 @@ -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 diff --git a/lib/bookkeeping/__tests__/payment-sync.test.ts b/lib/bookkeeping/__tests__/payment-sync.test.ts index cef97e31..5f6015b7 100644 --- a/lib/bookkeeping/__tests__/payment-sync.test.ts +++ b/lib/bookkeeping/__tests__/payment-sync.test.ts @@ -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 () => { diff --git a/lib/bookkeeping/payment-sync.ts b/lib/bookkeeping/payment-sync.ts index dfd09843..07e3968b 100644 --- a/lib/bookkeeping/payment-sync.ts +++ b/lib/bookkeeping/payment-sync.ts @@ -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() diff --git a/lib/invoices/__tests__/apply-invoice-payment.test.ts b/lib/invoices/__tests__/apply-invoice-payment.test.ts index 2fe2301d..dfb0f0d7 100644 --- a/lib/invoices/__tests__/apply-invoice-payment.test.ts +++ b/lib/invoices/__tests__/apply-invoice-payment.test.ts @@ -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) + }) +}) diff --git a/lib/invoices/apply-invoice-payment.ts b/lib/invoices/apply-invoice-payment.ts index b03941dc..12792aa1 100644 --- a/lib/invoices/apply-invoice-payment.ts +++ b/lib/invoices/apply-invoice-payment.ts @@ -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.