fix(invoices): make the Swish QR encode the amount to pay after ROT/RUT deduction (#1685)
* fix(invoices): make the Swish QR encode the amount to pay after ROT/RUT deduction The Swish payment QR on invoice PDFs encoded the pre-deduction invoice total (getDisplayTotal), while the totals block and the invoice email state "Att betala" as total minus the ROT/RUT deduction (getAmountToPay, fakturamodellen). Since the Swish payload locks the amount (editmask 0), a customer scanning a RUT/ROT invoice was asked to pay the full total with no way to correct it: overpaying by the entire skattereduktion. Swap the QR amount source to getAmountToPay(...).toPay so the QR, the printed "Att betala" and the email always agree. A fully deducted invoice (toPay = 0) now renders no QR via the existing amount > 0 guard. All seven render surfaces (send, preview, pdf, v1 send/pdf, MCP commit, recurring, issue-and-book) go through this one helper. Reported by a user: "QR-koden for swish stammer INTE med beloppet man ska betala. Den tar INTE hansyn till reduktionen." Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): select the amount-to-pay columns on the v1 pdf and send surfaces Skeptic review of the Swish QR fix found it was a silent no-op on the v1 GET pdf route: its column projection predated ROT/RUT and omitted deduction_total (and ore_rounding), so getAmountToPay saw undefined, treated it as "no deduction", and the route kept emitting a locked full-amount QR while the sent email said the deducted "Att betala". INVOICE_FULL_COLUMNS (v1 send renders from it) likewise omitted ore_rounding, ignoring the per-invoice oresavrundning override there. Move INVOICE_PDF_COLUMNS into lib/api/v1/invoice-columns.ts, add deduction_total, deduction_personnummer_last4 and ore_rounding to it, add ore_rounding to INVOICE_FULL_COLUMNS, and pin the amount-path columns of both projections with a test: a projection gap does not error, it renders the wrong money on one surface only, so it must be caught structurally. Also records the defect and remediation in DECISIONS.md per the compliance-swarm change-risk finding (the repo has no risk_register.csv; the decision log is its equivalent). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): gate the Swish QR to payable documents and restore delivery_date on the v1 pdf Swedish accounting review round 2: buildSwishQrDataUrl had no non-payable gate, so a kreditfaktura (a refund document) still produced a locked Swish payment QR at helper level; the template happens to hide the payment box for credit notes, but a payment request against a refund must stay impossible rather than merely unrendered. Apply the same document gate buildPaymentLinkQrDataUrl already has (invoice documents without credited_invoice_id only) and pin it with tests replacing the credit-note parity case. Also add delivery_date to INVOICE_PDF_COLUMNS: ML 17 kap 24 p.7 requires leveransdatum on the invoice when it differs from the invoice date, the template renders exactly that, and the v1 pdf projection silently dropped it. Same projection-starvation class as the previous commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(invoices): name the covered render surfaces and drop the contested lagrum point number CodeRabbit round 3, both documentation-only: the DECISIONS defect record said "all surfaces" while the editor preview is deferred to #1686, so it now lists the covered surfaces explicitly; and the delivery_date comment cited ML 17 kap 24 p.7 where CodeRabbit reads p.8 in SFS 2023:200 while the repo's swedish-invoice-compliance reference table says p.7, so the citation drops the point number and stays at the paragraph, which is correct under either enumeration. No behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
ffa18019f4
commit
619b446c52
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Guards the v1 invoice projections against silently starving the render
|
||||
* path. getAmountToPay treats a missing column as "no deduction" / "default
|
||||
* rounding" (`deduction_total ?? 0`, `ore_rounding ?? company ?? true`), so a
|
||||
* projection that drops one of its inputs does not error: it renders a PDF
|
||||
* and a locked Swish QR (editmask 0) with the WRONG amount on that surface
|
||||
* only, while the dashboard PDF and the sent email for the same invoice show
|
||||
* the deducted "Att betala". That is the exact bug shipped on the v1 pdf
|
||||
* route when INVOICE_PDF_COLUMNS predated ROT/RUT and nothing pinned it.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { INVOICE_FULL_COLUMNS, INVOICE_PDF_COLUMNS } from '@/lib/api/v1/invoice-columns'
|
||||
|
||||
/** Columns getAmountToPay + the Swish QR/totals path read off the invoice. */
|
||||
const AMOUNT_TO_PAY_COLUMNS = ['total', 'currency', 'ore_rounding', 'deduction_total', 'credited_invoice_id']
|
||||
|
||||
const parse = (projection: string): string[] => projection.split(',').map((c) => c.trim())
|
||||
|
||||
describe('v1 invoice projections feed the render amount path', () => {
|
||||
it.each(AMOUNT_TO_PAY_COLUMNS)('INVOICE_PDF_COLUMNS contains %s', (column) => {
|
||||
expect(parse(INVOICE_PDF_COLUMNS)).toContain(column)
|
||||
})
|
||||
|
||||
it.each(AMOUNT_TO_PAY_COLUMNS)('INVOICE_FULL_COLUMNS contains %s (v1 send renders from it)', (column) => {
|
||||
expect(parse(INVOICE_FULL_COLUMNS)).toContain(column)
|
||||
})
|
||||
|
||||
it('INVOICE_PDF_COLUMNS carries the display-safe personnummer for the PDF deduction box', () => {
|
||||
expect(parse(INVOICE_PDF_COLUMNS)).toContain('deduction_personnummer_last4')
|
||||
})
|
||||
|
||||
it('INVOICE_PDF_COLUMNS carries delivery_date (ML 17 kap 24 §: rendered when it differs from invoice_date)', () => {
|
||||
expect(parse(INVOICE_PDF_COLUMNS)).toContain('delivery_date')
|
||||
})
|
||||
|
||||
it('never selects the encrypted personnummer blob', () => {
|
||||
expect(INVOICE_FULL_COLUMNS).not.toContain('deduction_personnummer_encrypted')
|
||||
expect(INVOICE_PDF_COLUMNS).not.toContain('deduction_personnummer_encrypted')
|
||||
})
|
||||
})
|
||||
@@ -12,7 +12,27 @@
|
||||
*/
|
||||
|
||||
export const INVOICE_FULL_COLUMNS =
|
||||
'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, payment_link_url, stripe_payment_link_id, payment_link_auto, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, default_dimensions, deduction_total, deduction_personnummer_last4, created_at, updated_at'
|
||||
'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, ore_rounding, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, payment_link_url, stripe_payment_link_id, payment_link_auto, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, default_dimensions, deduction_total, deduction_personnummer_last4, created_at, updated_at'
|
||||
|
||||
/**
|
||||
* Projection for the v1 PDF download route. Narrower than INVOICE_FULL_COLUMNS
|
||||
* (no payment-link/dimension internals), but it MUST contain every column the
|
||||
* render path reads to compute the customer-facing amount: getAmountToPay
|
||||
* derives "Att betala" from total, ore_rounding, deduction_total and
|
||||
* credited_invoice_id, and the same figure is locked into the Swish QR
|
||||
* (editmask 0). A column missing here silently zeroes that part of the
|
||||
* calculation for this surface only: the v1 PDF then disagrees with the
|
||||
* dashboard PDF and the sent email for the same invoice, which is exactly
|
||||
* the byte-equivalence this endpoint promises. delivery_date is statutory
|
||||
* content on top of that: ML 17 kap 24 § requires leveransdatum on the
|
||||
* invoice when it differs from the invoice date, and the template renders it
|
||||
* exactly then. Pinned by __tests__/invoice-columns.test.ts.
|
||||
*/
|
||||
export const INVOICE_PDF_COLUMNS =
|
||||
'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, document_type, ' +
|
||||
'currency, subtotal, vat_amount, total, ore_rounding, vat_treatment, vat_rate, moms_ruta, ' +
|
||||
'reverse_charge_text, your_reference, our_reference, notes, credited_invoice_id, ' +
|
||||
'paid_amount, remaining_amount, deduction_total, deduction_personnummer_last4'
|
||||
|
||||
export const INVOICE_ITEM_FULL_COLUMNS =
|
||||
'id, sort_order, line_type, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, article_id, revenue_account, deduction_type, deduction_amount, labor_hours, work_type, housing_designation, apartment_number, brf_org_number, dimensions, created_at'
|
||||
|
||||
@@ -11,11 +11,12 @@
|
||||
* mock `fetch` and exercise the real sharp pipeline.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import sharp from 'sharp'
|
||||
import { prepareInvoicePdfRender, buildPaymentLinkQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
|
||||
import QRCode from 'qrcode'
|
||||
import { prepareInvoicePdfRender, buildPaymentLinkQrDataUrl, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
|
||||
import { makeCompanySettings, makeInvoice } from '@/tests/helpers'
|
||||
|
||||
const fontDownloadMock = vi.hoisted(() => vi.fn())
|
||||
@@ -336,3 +337,54 @@ describe('buildPaymentLinkQrDataUrl', () => {
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSwishQrDataUrl', () => {
|
||||
// Spy without replacing the implementation: the payload string is the unit
|
||||
// under test (the PNG pixels are qrcode's concern), and the passthrough
|
||||
// keeps the returned data URL real. The Swish payload locks the amount
|
||||
// (editmask 0), so an amount above "Att betala" makes the customer overpay
|
||||
// with no way to correct it in the app.
|
||||
let toDataURL: MockInstance
|
||||
|
||||
beforeEach(() => {
|
||||
toDataURL = vi.spyOn(QRCode, 'toDataURL')
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const company = () => makeCompanySettings({ swish: '1234567890' })
|
||||
|
||||
it('encodes "Att betala" (total minus ROT/RUT deduction), not the invoice total', async () => {
|
||||
const invoice = makeInvoice({ total: 1250, deduction_total: 625 })
|
||||
const qr = await buildSwishQrDataUrl(company(), invoice)
|
||||
expect(qr).toMatch(new RegExp(`^${PNG_DATA_URL_PREFIX}`))
|
||||
expect(toDataURL).toHaveBeenCalledWith('C1234567890;625.00;F-2024001;0', expect.anything())
|
||||
})
|
||||
|
||||
it('applies öresavrundning before the deduction, same order as the PDF totals block', async () => {
|
||||
const invoice = makeInvoice({ total: 1250.49, ore_rounding: true, deduction_total: 625 })
|
||||
await buildSwishQrDataUrl(company(), invoice)
|
||||
expect(toDataURL).toHaveBeenCalledWith('C1234567890;625.00;F-2024001;0', expect.anything())
|
||||
})
|
||||
|
||||
it('renders no QR when the deduction covers the whole invoice (nothing to pay)', async () => {
|
||||
const invoice = makeInvoice({ total: 1250, deduction_total: 1250 })
|
||||
expect(await buildSwishQrDataUrl(company(), invoice)).toBeNull()
|
||||
expect(toDataURL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders no QR for non-payable documents (credit note, proforma, delivery note)', async () => {
|
||||
const creditNote = makeInvoice({ total: 1250, deduction_total: 625, credited_invoice_id: 'inv-orig' })
|
||||
expect(await buildSwishQrDataUrl(company(), creditNote)).toBeNull()
|
||||
expect(await buildSwishQrDataUrl(company(), makeInvoice({ document_type: 'proforma' }))).toBeNull()
|
||||
expect(await buildSwishQrDataUrl(company(), makeInvoice({ document_type: 'delivery_note' }))).toBeNull()
|
||||
expect(toDataURL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the plain total for invoices without a deduction', async () => {
|
||||
const invoice = makeInvoice()
|
||||
await buildSwishQrDataUrl(company(), invoice)
|
||||
expect(toDataURL).toHaveBeenCalledWith('C1234567890;12500.00;F-2024001;0', expect.anything())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,7 +23,7 @@ import QRCode from 'qrcode'
|
||||
import type { CompanySettings, Currency, Invoice } from '@/types'
|
||||
import { brandingFromCompanySettings, SHOW_SWISH_ON_INVOICE, type InvoiceBranding } from '@/lib/invoices/pdf-template'
|
||||
import { buildSwishQrPayload } from '@/lib/payments/swish'
|
||||
import { getDisplayTotal } from '@/lib/invoices/rounding'
|
||||
import { getAmountToPay } from '@/lib/invoices/rounding'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { LOGO_UPLOAD_MAX_BYTES } from '@/lib/invoices/branding-constants'
|
||||
import { prepareInvoiceFont } from '@/lib/invoices/pdf-fonts'
|
||||
@@ -175,14 +175,6 @@ export async function prepareInvoicePdfRender(
|
||||
return { branding, company: resolved }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Swish payment QR for an invoice as a PNG data URL, or null when:
|
||||
* Swish display is off, there's no/invalid Swish number, the invoice isn't in
|
||||
* SEK (Swish is SEK-only), or the amount is not positive. Generated locally with
|
||||
* the `qrcode` lib: no call to any Swish API. Pass the result to InvoicePDF's
|
||||
* `swishQrDataUrl` prop; the template gates rendering on the same payment box
|
||||
* that already shows the Swish number.
|
||||
*/
|
||||
/**
|
||||
* Build the payment-link QR for an invoice as a PNG data URL, or null when the
|
||||
* invoice carries no payment_link_url or it isn't a payable document (credit
|
||||
@@ -206,6 +198,20 @@ export async function buildPaymentLinkQrDataUrl(invoice: Invoice): Promise<strin
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Swish payment QR for an invoice as a PNG data URL, or null when:
|
||||
* Swish display is off, the document isn't a payable invoice (credit notes,
|
||||
* proformas and delivery notes collect no payment), there's no/invalid Swish
|
||||
* number, the invoice isn't in SEK (Swish is SEK-only), or the amount to pay
|
||||
* is not positive. The encoded amount is the customer-facing "Att betala"
|
||||
* from getAmountToPay: the rounded total minus any ROT/RUT deduction, the
|
||||
* same figure the PDF totals block and the invoice email state. The Swish payload locks the amount (editmask 0),
|
||||
* so encoding anything else makes the customer overpay with no way to correct
|
||||
* it in the app. A fully deducted invoice (toPay = 0) therefore renders no QR.
|
||||
* Generated locally with the `qrcode` lib: no call to any Swish API. Pass the
|
||||
* result to InvoicePDF's `swishQrDataUrl` prop; the template gates rendering
|
||||
* on the same payment box that already shows the Swish number.
|
||||
*/
|
||||
export async function buildSwishQrDataUrl(
|
||||
company: CompanySettings,
|
||||
invoice: Invoice,
|
||||
@@ -216,11 +222,17 @@ export async function buildSwishQrDataUrl(
|
||||
// Swish display off is the normal "no QR" case: stay quiet. Every other
|
||||
// skip is logged so a missing QR is diagnosable instead of silent.
|
||||
if (!(company.invoice_show_swish ?? false)) return null
|
||||
// Non-payable documents: the PDF hides the whole payment box for them, and
|
||||
// a locked payment QR on a kreditfaktura (a refund document, "Er tillgodo")
|
||||
// must stay impossible even if a template regression ever exposed the
|
||||
// corner. Same gate as buildPaymentLinkQrDataUrl; quiet like display-off.
|
||||
const docType = invoice.document_type || 'invoice'
|
||||
if (docType !== 'invoice' || invoice.credited_invoice_id) return null
|
||||
if ((invoice.currency ?? 'SEK') !== 'SEK') {
|
||||
log.info('swish QR skipped: invoice not in SEK', { invoiceId: invoice.id, currency: invoice.currency })
|
||||
return null
|
||||
}
|
||||
const amount = getDisplayTotal(invoice, company).displayed
|
||||
const amount = getAmountToPay(invoice, company).toPay
|
||||
const payload = buildSwishQrPayload(company.swish, amount, invoice.invoice_number ?? '')
|
||||
if (!payload) {
|
||||
log.warn('swish QR skipped: invalid number or non-positive amount', {
|
||||
|
||||
Reference in New Issue
Block a user