diff --git a/DECISIONS.md b/DECISIONS.md index 91255ba3..4551669e 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1051,3 +1051,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [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] 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/invoices/preview-pdf/__tests__/route.test.ts b/app/api/invoices/preview-pdf/__tests__/route.test.ts index b094c441..909bb612 100644 --- a/app/api/invoices/preview-pdf/__tests__/route.test.ts +++ b/app/api/invoices/preview-pdf/__tests__/route.test.ts @@ -25,8 +25,9 @@ vi.mock('@react-pdf/renderer', () => ({ renderToBuffer: (...args: unknown[]) => renderToBufferMock(...args), })) +const invoicePdfMock = vi.fn().mockReturnValue('mock-pdf-element') vi.mock('@/lib/invoices/pdf-template', () => ({ - InvoicePDF: vi.fn().mockReturnValue('mock-pdf-element'), + InvoicePDF: (...args: unknown[]) => invoicePdfMock(...args), })) vi.mock('@/lib/invoices/pdf-render-helpers', () => ({ @@ -36,6 +37,14 @@ vi.mock('@/lib/invoices/pdf-render-helpers', () => ({ })) import { POST } from '../route' +import type { Invoice, InvoiceItem } from '@/types' + +/** The invoice + items the route handed to the PDF template on the last render. */ +function lastRenderProps(): { invoice: Invoice; items: InvoiceItem[] } { + const call = invoicePdfMock.mock.calls.at(-1) + if (!call) throw new Error('InvoicePDF was not called') + return call[0] as { invoice: Invoice; items: InvoiceItem[] } +} describe('POST /api/invoices/preview-pdf', () => { const user = { id: 'user-1', email: 'owner@example.test' } @@ -139,6 +148,168 @@ describe('POST /api/invoices/preview-pdf', () => { expect(mockSupabase.from).not.toHaveBeenCalledWith('customers') }) + // ROT/RUT (issue #1686): the preview must state the same avdrag row, info + // box and "Att betala" as the invoice the write path creates. The PDF + // template reads invoice.deduction_total / deduction_personnummer_last4 and + // the per-item deduction fields, so those are what the route must carry. + describe('ROT/RUT deduction', () => { + const rutBody = { + ...validBody, + document_type: 'invoice', + deduction_personnummer: '19900101-2385', + deduction_housing_designation: 'Stockholm Kvarteret 1:2', + items: [ + { + description: 'Städning', + quantity: 4, + unit: 'tim', + unit_price: 500, + vat_rate: 25, + deduction_type: 'rut', + labor_hours: 4, + work_type: 'STAD', + }, + { + description: 'Rengöringsmedel', + quantity: 1, + unit: 'st', + unit_price: 200, + vat_rate: 25, + }, + ], + } + + it('computes deduction_total from the posted items and carries the per-item fields', async () => { + enqueue({ data: company, error: null }) + enqueue({ data: customer, error: null }) + + const response = await POST( + createMockRequest('/api/invoices/preview-pdf', { method: 'POST', body: rutBody }), + createMockRouteParams({}), + ) + + expect(response.status).toBe(200) + const { invoice, items } = lastRenderProps() + // 4 x 500 = 2 000 exkl. moms = 2 500 inkl. 25% moms; RUT = 50% = 1 250. + expect(invoice.deduction_total).toBe(1250) + expect(invoice.total).toBe(2750) + expect(invoice.deduction_personnummer_last4).toBe('2385') + expect(items[0]).toMatchObject({ + deduction_type: 'rut', + deduction_amount: 1250, + labor_hours: 4, + work_type: 'STAD', + housing_designation: 'Stockholm Kvarteret 1:2', + }) + expect(items[1]).toMatchObject({ deduction_type: null, deduction_amount: 0, housing_designation: null }) + }) + + it('uses the deduction base inkl. moms at the rate the line is rendered with', async () => { + enqueue({ data: company, error: null }) + enqueue({ data: customer, error: null }) + + // Skatteverket worked example: 18 000 kr arbetskostnad = 22 500 kr inkl. + // moms, ROT 30% = 6 750 kr. + const response = await POST( + createMockRequest('/api/invoices/preview-pdf', { + method: 'POST', + body: { + ...rutBody, + items: [{ + description: 'Målning', + quantity: 1, + unit: 'st', + unit_price: 18000, + vat_rate: 25, + deduction_type: 'rot', + labor_hours: 30, + work_type: 'MALNING', + }], + }, + }), + createMockRouteParams({}), + ) + + expect(response.status).toBe(200) + const { invoice, items } = lastRenderProps() + expect(invoice.deduction_total).toBe(6750) + expect(items[0].deduction_amount).toBe(6750) + }) + + it('falls back to the kundkort personnummer of an individual customer, like the write path', async () => { + enqueue({ data: company, error: null }) + enqueue({ + data: makeCustomer({ id: customer.id, customer_type: 'individual', personal_number: '900101-2385' }), + error: null, + }) + + const response = await POST( + createMockRequest('/api/invoices/preview-pdf', { + method: 'POST', + body: { ...rutBody, deduction_personnummer: '' }, + }), + createMockRouteParams({}), + ) + + expect(response.status).toBe(200) + expect(lastRenderProps().invoice.deduction_personnummer_last4).toBe('2385') + }) + + it('leaves a non-deduction invoice unchanged', async () => { + enqueue({ data: company, error: null }) + enqueue({ data: customer, error: null }) + + const response = await POST( + createMockRequest('/api/invoices/preview-pdf', { method: 'POST', body: validBody }), + createMockRouteParams({}), + ) + + expect(response.status).toBe(200) + const { invoice, items } = lastRenderProps() + expect(invoice.deduction_total).toBe(0) + expect(invoice.deduction_personnummer_last4).toBeNull() + expect(invoice.total).toBe(17500) + expect(items[0]).toMatchObject({ deduction_type: null, deduction_amount: 0 }) + }) + + it('ignores deduction fields on non-invoice document types, like the write path', async () => { + enqueue({ data: company, error: null }) + enqueue({ data: customer, error: null }) + + const response = await POST( + createMockRequest('/api/invoices/preview-pdf', { + method: 'POST', + body: { ...rutBody, document_type: 'proforma' }, + }), + createMockRouteParams({}), + ) + + expect(response.status).toBe(200) + const { invoice, items } = lastRenderProps() + expect(invoice.deduction_total).toBe(0) + expect(invoice.deduction_personnummer_last4).toBeNull() + expect(items[0]).toMatchObject({ deduction_type: null, deduction_amount: 0, work_type: null }) + }) + + it('does not compute a deduction for a seller that is not VAT registered on VAT-free labor', async () => { + enqueue({ data: { ...company, vat_registered: false }, error: null }) + enqueue({ data: customer, error: null }) + + const response = await POST( + createMockRequest('/api/invoices/preview-pdf', { method: 'POST', body: rutBody }), + createMockRouteParams({}), + ) + + expect(response.status).toBe(200) + const { invoice, items } = lastRenderProps() + // Base is the line total inkl. moms; with no output VAT the base is the + // bare 2 000 kr, RUT 50% = 1 000. + expect(items[0].vat_rate).toBe(0) + expect(items[0].deduction_amount).toBe(1000) + expect(invoice.deduction_total).toBe(1000) + }) + }) + it('marks preview generation errors as private and non-cacheable', async () => { enqueue({ data: company, error: null }) enqueue({ data: customer, error: null }) diff --git a/app/api/invoices/preview-pdf/route.ts b/app/api/invoices/preview-pdf/route.ts index 27fe41b2..23db18e6 100644 --- a/app/api/invoices/preview-pdf/route.ts +++ b/app/api/invoices/preview-pdf/route.ts @@ -8,6 +8,9 @@ import { invoicePdfFilename } from '@/lib/invoices/pdf-filename' import { contentDisposition } from '@/lib/api/content-disposition' import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { computeDeduction, computeInvoiceDeductionTotal, type DeductionType } from '@/lib/invoices/rot-rut-rules' +import { expandPersonnummerTo12, extractLast4, validatePersonnummer } from '@/lib/salary/personnummer' +import { revealStoredCustomerPersonalNumber } from '@/lib/customers/protect-personal-number' import { hasRequiredInvoicePaymentAccount, invoiceRequiresPaymentAccount, @@ -20,6 +23,48 @@ function privateNoStore(response: NextResponse): NextResponse { return response } +/** The per-line ROT/RUT fields the editor posts alongside the amounts. */ +interface PreviewItemInput { + description: string + quantity: number + unit: string + unit_price: number + vat_rate?: number + deduction_type?: DeductionType | null + labor_hours?: number | null + work_type?: string | null + housing_designation?: string | null + apartment_number?: string | null + brf_org_number?: string | null +} + +function optionalTrimmed(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null +} + +/** + * The masked personnummer the deduction info box shows, resolved the same way + * the write path does (lib/invoices/build-invoice-write.ts): the value typed + * on the claim card wins; otherwise an individual customer's kundkort + * personnummer, if it expands to a valid 12-digit number. Only the last four + * digits leave this function; the plaintext is never stored or logged. + */ +function resolvePreviewPersonnummerLast4(typed: string | null, customer: Customer): string | null { + if (typed) { + const last4 = extractLast4(typed) + return last4.length === 4 ? last4 : null + } + if (customer.customer_type !== 'individual') return null + try { + const revealed = revealStoredCustomerPersonalNumber(customer.personal_number) + const expanded = revealed ? expandPersonnummerTo12(revealed) : null + if (expanded && validatePersonnummer(expanded).valid) return extractLast4(expanded) + } catch { + // Undecryptable customer value: same as absent. + } + return null +} + /** * POST /api/invoices/preview-pdf * @@ -34,7 +79,11 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, { requestId, }) => { const body = await request.json() - const { customer_id, invoice_date, due_date, delivery_date, currency, items, your_reference, our_reference, notes, document_type, invoice_number, payment_link_url } = body + const { + customer_id, invoice_date, due_date, delivery_date, currency, items, your_reference, our_reference, notes, + document_type, invoice_number, payment_link_url, + deduction_personnummer, deduction_housing_designation, deduction_apartment_number, deduction_brf_org_number, + } = body // Preview-only https gate, mirroring CreateInvoiceSchema: the value is // rendered as a clickable link + QR in the preview PDF. @@ -163,10 +212,31 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, { const notVatRegistered = (company as { vat_registered?: boolean }).vat_registered === false const zeroVat = notVatRegistered && !isDeliveryNote - // Build items with line totals and per-item VAT - const invoiceItems: InvoiceItem[] = items.map((item: { description: string; quantity: number; unit: string; unit_price: number; vat_rate?: number }, index: number) => { + // ROT/RUT (fakturamodellen) only exists on real invoices: the write path + // zeroes deduction fields on proformas, delivery notes and quotes, so the + // preview must too or it would show an avdrag the created document lacks. + const deductionsApply = docType === 'invoice' + const claimHousing = optionalTrimmed(deduction_housing_designation) + const claimApartment = optionalTrimmed(deduction_apartment_number) + const claimBrf = optionalTrimmed(deduction_brf_org_number) + + // Build items with line totals, per-item VAT and the per-line ROT/RUT + // deduction, mirroring build-invoice-write.ts so the preview states the + // same avdrag row, info box and "Att betala" as the invoice it becomes. + const invoiceItems: InvoiceItem[] = items.map((item: PreviewItemInput, index: number) => { const lineTotal = Math.round(item.quantity * item.unit_price * 100) / 100 const rate = zeroVat ? 0 : (item.vat_rate ?? vatRules.rate) + const deductionType = deductionsApply ? (item.deduction_type ?? null) : null + // Same base as the write path: the line total inkl. moms at the rate the + // line is rendered with (HUSFL 6-9 §§). + const deductionAmount = deductionType + ? computeDeduction({ + unit_price: item.unit_price, + quantity: item.quantity, + deduction_type: deductionType, + vat_rate: rate, + }) + : 0 return { id: `preview-${index}`, invoice_id: 'preview', @@ -178,6 +248,15 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, { line_total: lineTotal, vat_rate: rate, vat_amount: isDeliveryNote ? 0 : Math.round(lineTotal * (rate / 100) * 100) / 100, + deduction_type: deductionType, + deduction_amount: deductionAmount, + labor_hours: deductionType ? (item.labor_hours ?? null) : null, + work_type: deductionType ? (item.work_type ?? null) : null, + // Property info: per-line value wins, else the invoice-level claim-card + // value is stamped onto every deduction line (same as the write path). + housing_designation: deductionType ? (optionalTrimmed(item.housing_designation) ?? claimHousing) : null, + apartment_number: deductionType ? (optionalTrimmed(item.apartment_number) ?? claimApartment) : null, + brf_org_number: deductionType ? (optionalTrimmed(item.brf_org_number) ?? claimBrf) : null, created_at: new Date().toISOString(), } }) @@ -186,6 +265,22 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, { const vatAmount = isDeliveryNote ? 0 : invoiceItems.reduce((sum, item) => sum + item.vat_amount, 0) const total = isDeliveryNote ? 0 : subtotal + vatAmount + // Invoice-level deduction: the sum of the per-line amounts, computed with + // the same helper the write path stores on invoices.deduction_total. + const deductionTotal = deductionsApply + ? computeInvoiceDeductionTotal( + invoiceItems.map((item) => ({ + unit_price: item.unit_price, + quantity: item.quantity, + deduction_type: item.deduction_type ?? null, + vat_rate: item.vat_rate, + })), + ) + : 0 + const deductionPersonnummerLast4 = deductionTotal > 0 + ? resolvePreviewPersonnummerLast4(optionalTrimmed(deduction_personnummer), customer) + : null + // Derive vat_rate from items: single rate → that rate, mixed → null const itemRates = new Set(invoiceItems.map((item) => item.vat_rate)) const effectiveVatRate = isDeliveryNote ? 0 : (itemRates.size === 1 ? itemRates.values().next().value! : null) @@ -224,6 +319,8 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, { converted_from_id: null, paid_at: null, paid_amount: null, + deduction_total: deductionTotal, + deduction_personnummer_last4: deductionPersonnummerLast4, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), } as Invoice diff --git a/components/invoices/InvoiceEditor.tsx b/components/invoices/InvoiceEditor.tsx index b5754501..ad4d8e9d 100644 --- a/components/invoices/InvoiceEditor.tsx +++ b/components/invoices/InvoiceEditor.tsx @@ -1722,6 +1722,16 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat notes: data.notes, payment_link_url: data.payment_link_url, invoice_number: numberPreview, + // ROT/RUT claim card: the preview shows the same masked personnummer + // and fastighetsbeteckning in its deduction box as the created + // invoice will. Only sent when a line actually claims a deduction + // (same privacy rule as buildInvoiceWritePayload). + ...(data.items.some((i) => i.deduction_type) + ? { + deduction_personnummer: data.deduction_personnummer, + deduction_housing_designation: data.deduction_housing_designation, + } + : {}), }), })