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
@@ -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())
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user