diff --git a/DECISIONS.md b/DECISIONS.md index 4551669e..dc250855 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1047,8 +1047,12 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-17] 77xx nedskrivningar split per official BAS kopplingstabell in BOTH k2-mapper and ink2-engine (fältkod 7515: 7700-7739, 7750-7789, 7800-7899; 7516: 774x, 779x): agent feedback 2026-07-07 reported the K2 side; the INK2R side and the swedish-sru-filing reference table had the same whole-77xx-to-7516 error, verified against bas.se INK2_P1_intervall-240118.pdf before overriding the skill reference. NE-bilaga mappings deliberately untouched (NE has no separate omsättningstillgångar line). [2026-08-17] MCP feedback loop = local /loop-feedback-triage appending dev_docs/mcp_feedback_digest.md + small PRs, NOT a GitHub-issue digest or Resend email: closes the loops.md backlog item blocked since 07-09 on a "channel decision". Issues stay founder-authorised; the digest is the read surface. gnubok_feedback reply copy no longer promises weekly aggregation (it was never true); tool advertised in server instructions + agent briefing (feedback_channel), where it was previously discoverable only by scanning tools/list. [2026-08-17] Non-IBAN foreign payment accounts (USD/GBP): added generic bank_code + foreign_account_number to InvoicePaymentAccount (JSONB, no migration) instead of per-country fields (routing_number, sort_code, bsb); rule = IBAN OR (bank_code + foreign_account_number + BIC), only for NON_IBAN_CURRENCIES, label per currency. Chosen over a field per country: the Currency union only carries USD/GBP among non-IBAN systems, and one generic pair keeps the PDF/settings/schema surface small; extend NON_IBAN_CURRENCIES + bankCodeLabelKey when AUD/CAD land. Agent feedback 2026-08-03. + +[2026-08-18] Swish QR fix (RUT/ROT) scoped to buildSwishQrDataUrl only, preview-pdf left as-is: previewInvoice carries no deduction fields at all, so the editor preview is internally consistent (its QR matches its own totals block) but ignores ROT/RUT entirely; that is a separate display gap to file as its own issue rather than widening this money-math diff. [2026-08-18] Shopify webshop_orders port: vat_breakdown is reconstructed from the ORDER-LEVEL taxLines (net = tax / rate, remainder as a 0%-bucket, refuse on missing rates or overshoot) instead of summing line items like the WooCommerce sync: Shopify's discountedTotalSet excludes cart-level discount allocations and lineItems is a paginated connection, so part-summing can silently produce a wrong per-rate net, while tax-per-rate and the charged total are authoritative order-level facts. Refund VAT is always prorated from the parent's mix (Shopify's Refund object exposes no per-rate tax without paging refundLineItems per refund). [2026-08-18] Shopify order feed keeps its paid-only qualification (PAID/PARTIALLY_REFUNDED/REFUNDED) after the webshop_orders port, unlike WooCommerce which also imports unpaid orders for the invoice flow: widening qualification is a product decision, out of scope for the port; unpaid orders re-surface via updatedAt when payment captures. The line-item snapshot is stored only when the parts reconstruct the charged total to the ore (else [] and the invoice conversion falls back to one aggregate line), and the bookkeeping-lock row filter was dropped: an Orders-page row behind the lock is an overview row, not permanent inbox noise, and booking is still blocked by the lock triggers (parity with WooCommerce). [2026-08-18] Skattekontoutdrag sum mismatch (opening + events != closing) demoted from a hard 400 to a preview confirm gate showing ingående/händelser/utgående/differens, mirroring the orgnr-mismatch gate: Sebastian's real export was refused on it (2026-08-18) with no way forward and no figures to diagnose; nothing is booked at import and dedup makes a later complete re-import safe, so refusing the file only blocked the rows that WERE readable. Parser also takes the earliest opening / latest closing across several marker pairs, reads a marker saldo from a trailing running-saldo column, and accepts U+2212 / plus-sign amounts; the route logs the figures (amounts and counts, never row text) so the next report is diagnosable from Vercel logs. Kept the hard reject only for zero readable rows. + +[2026-08-18] Defect record (compliance-swarm PI1.3, no risk_register.csv exists in repo so this log is the equivalent): Swish QR on invoice PDFs encoded the pre-deduction total on ROT/RUT invoices with the amount locked (editmask 0), risking customer overpayment by the full skattereduktion; impact window = since Swish QR + ROT/RUT coexisted; remediation PR #1685 = QR now encodes getAmountToPay().toPay on every send/download surface (dashboard send/preview-of-send/pdf, v1 send/pdf, MCP pending-operations commit, recurring, issue-and-book; the editor preview is tracked separately in #1686), v1 pdf/send projections gained the amount-path columns (deduction_total, ore_rounding), pinned by lib/api/v1/__tests__/invoice-columns.test.ts. [2026-08-18] Generic CSV mapping (#1671): description guess now excludes clock-time columns (Time/Tid/Klockslag by label, HH:MM by values) and knows Lunar's Title/Titel label; Lunar detect() sniffs comma/semicolon/tab and matches header CELLS exactly (date, title|text, amount, balance) instead of substrings, aligned with what parse() resolves on. NOT changed: the 2026-08-13 generic_csv exemption from the parsed-0-rows auto-detect fallback stays; lifting it would route an explicit "Annan CSV" pick into a dedicated parser and remove the manual escape hatch. Not verified against the customer's actual file (Gmail thread not readable in-session): the semicolon/tab widening is the plausible detection miss, a Swedish-localized Lunar header is not confirmed to exist and was not special-cased in the Lunar detector (the generic path now maps it correctly anyway). [2026-08-18] Editor PDF preview (#1686) recomputes ROT/RUT server-side from the posted lines with the same helpers as build-invoice-write.ts (computeDeduction / computeInvoiceDeductionTotal, base inkl. moms at the rendered rate, invoice-doc only) and resolves the masked personnummer the same way (typed value, else an individual customer's kundkort personnummer): the client is not trusted with the deduction math, and the preview must state the same avdrag row, info box and "Att betala" as the invoice that gets created. The editor now also posts deduction_personnummer / deduction_housing_designation to the preview route, only when a line claims a deduction (same privacy rule as buildInvoiceWritePayload). Swish QR amount in the preview follows buildSwishQrDataUrl, fixed separately in #1685. diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/pdf/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/pdf/route.ts index 52b101cc..a2d0585a 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/pdf/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/pdf/route.ts @@ -30,14 +30,9 @@ import { hasRequiredInvoicePaymentAccount, invoiceRequiresPaymentAccount, } from '@/lib/invoices/payment-accounts' +import { INVOICE_PDF_COLUMNS } from '@/lib/api/v1/invoice-columns' import type { CompanySettings, Customer, Invoice, InvoiceItem } from '@/types' -const INVOICE_PDF_COLUMNS = - 'id, invoice_number, customer_id, invoice_date, due_date, status, document_type, ' + - 'currency, subtotal, vat_amount, total, vat_treatment, vat_rate, moms_ruta, ' + - 'reverse_charge_text, your_reference, our_reference, notes, credited_invoice_id, ' + - 'paid_amount, remaining_amount' - const PDF_FETCH_SELECT = ` ${INVOICE_PDF_COLUMNS}, customer:customers(*), diff --git a/lib/api/v1/__tests__/invoice-columns.test.ts b/lib/api/v1/__tests__/invoice-columns.test.ts new file mode 100644 index 00000000..53468ac3 --- /dev/null +++ b/lib/api/v1/__tests__/invoice-columns.test.ts @@ -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') + }) +}) diff --git a/lib/api/v1/invoice-columns.ts b/lib/api/v1/invoice-columns.ts index 002e0c5b..f884b01a 100644 --- a/lib/api/v1/invoice-columns.ts +++ b/lib/api/v1/invoice-columns.ts @@ -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' diff --git a/lib/invoices/__tests__/pdf-render-helpers.test.ts b/lib/invoices/__tests__/pdf-render-helpers.test.ts index 20e7c608..445c8f2d 100644 --- a/lib/invoices/__tests__/pdf-render-helpers.test.ts +++ b/lib/invoices/__tests__/pdf-render-helpers.test.ts @@ -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()) + }) +}) diff --git a/lib/invoices/pdf-render-helpers.ts b/lib/invoices/pdf-render-helpers.ts index 5e26ca3e..98fc6b2d 100644 --- a/lib/invoices/pdf-render-helpers.ts +++ b/lib/invoices/pdf-render-helpers.ts @@ -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