Files
accounted/app/api/invoices/preview-pdf/route.ts
T
ffa18019f4 fix(invoices): carry ROT/RUT deduction into the editor PDF preview (#1687)
The preview route built previewInvoice without any deduction fields and
its item mapping dropped deduction_type, so the editor's PDF preview of a
ROT/RUT invoice showed no avdrag row, no deduction info box, and "Att
betala" at the full undeduced total, unlike the invoice that is then
created and sent.

The preview now mirrors build-invoice-write.ts: per-line deduction_amount
via computeDeduction (base inkl. moms at the rendered rate, invoice
document type only), invoice-level deduction_total via
computeInvoiceDeductionTotal, and the per-line work_type / labor_hours /
housing fields the PDF's info box reads. The masked personnummer is
resolved like the write path (typed value, else an individual customer's
kundkort personnummer). The editor posts deduction_personnummer and
deduction_housing_designation to the preview only when a line claims a
deduction.

Closes #1686

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 16:32:32 +02:00

372 lines
14 KiB
TypeScript

import { NextResponse } from 'next/server'
import { renderToBuffer } from '@react-pdf/renderer'
import { withRouteContext } from '@/lib/api/with-route-context'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { prepareInvoicePdfRender, buildSwishQrDataUrl, buildPaymentLinkQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
import { getVatRules } from '@/lib/invoices/vat-rules'
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,
} from '@/lib/invoices/payment-accounts'
const PRIVATE_NO_STORE_HEADERS = { 'Cache-Control': 'private, no-store' }
function privateNoStore(response: NextResponse): NextResponse {
response.headers.set('Cache-Control', 'private, no-store')
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
*
* Generates a preview PDF from form data without creating an invoice.
* Returns the PDF as an inline blob for display in a new browser tab.
*/
export const POST = withRouteContext('invoice.preview_pdf', async (request, {
supabase,
user,
companyId,
log,
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,
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.
const previewPaymentLink = (() => {
if (typeof payment_link_url !== 'string' || !payment_link_url.trim()) return null
try {
return new URL(payment_link_url).protocol === 'https:' ? payment_link_url.trim() : null
} catch {
return null
}
})()
if (!items || items.length === 0) {
return NextResponse.json(
{ error: 'Rader krävs' },
{ status: 400, headers: PRIVATE_NO_STORE_HEADERS },
)
}
const docType: InvoiceDocumentType = document_type || 'invoice'
const requestedCurrency = currency || 'SEK'
// Fetch and validate company payment settings before customer data. The
// preview performs no writes, but a request that cannot be rendered should
// still stop before processing customer details.
const { data: company, error: companyError } = await supabase
.from('company_settings')
.select('*')
.eq('company_id', companyId)
.single()
if (companyError || !company) {
return NextResponse.json(
{ error: 'Företagsinställningar saknas' },
{ status: 404, headers: PRIVATE_NO_STORE_HEADERS },
)
}
if (!hasRequiredInvoicePaymentAccount(company as CompanySettings, {
currency: requestedCurrency,
document_type: docType,
credited_invoice_id: null,
})) {
return privateNoStore(errorResponseFromCode('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING', log, {
requestId,
details: { currency: requestedCurrency },
}))
}
// When customer_id is omitted, only allow the synthetic preview if the
// company has no real customers: this is the settings-preview dead-end
// case. Derived server-side so a client can't bypass the ownership check
// by passing a flag.
const isMockCustomer = !customer_id
let customer: Customer
if (isMockCustomer) {
const { count, error: countError } = await supabase
.from('customers')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
if (countError || (count ?? 0) > 0) {
return NextResponse.json(
{ error: 'Kunduppgifter krävs' },
{ status: 400, headers: PRIVATE_NO_STORE_HEADERS },
)
}
const nowIso = new Date().toISOString()
customer = {
id: 'preview-customer',
user_id: 'preview-user',
company_id: 'preview-company',
name: 'Exempel AB',
customer_type: 'swedish_business',
customer_number: null,
email: 'kund@exempel.se',
phone: null,
address_line1: 'Storgatan 1',
address_line2: null,
postal_code: '111 22',
city: 'Stockholm',
country: 'SE',
org_number: '556677-8899',
vat_number: null,
vat_number_validated: false,
vat_number_validated_at: null,
personal_number: null,
contact_person: null,
invoice_email_cc_addresses: null,
invoice_email_bcc_addresses: null,
language: 'sv',
default_payment_terms: 30,
notes: null,
created_at: nowIso,
updated_at: nowIso,
}
} else {
const { data, error: customerError } = await supabase
.from('customers')
.select('*')
.eq('id', customer_id)
.eq('company_id', companyId)
.single()
if (customerError || !data) {
return NextResponse.json(
{ error: 'Kunden hittades inte' },
{ status: 404, headers: PRIVATE_NO_STORE_HEADERS },
)
}
customer = data as Customer
}
// VAT rules are customer-type-driven and only know the customer side.
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
const isDeliveryNote = docType === 'delivery_note'
// VAT registration gate: mirror the server-side write gate
// (lib/invoices/build-invoice-write.ts) so the preview never shows output VAT
// for a non-momsregistrerad seller. Without this the per-item fallback below
// (`?? vatRules.rate`) would render 25% for a Swedish customer even though the
// created invoice books no VAT, misleading the user at the review step.
const notVatRegistered = (company as { vat_registered?: boolean }).vat_registered === false
const zeroVat = notVatRegistered && !isDeliveryNote
// 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',
sort_order: index,
description: item.description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
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(),
}
})
const subtotal = invoiceItems.reduce((sum, item) => sum + item.line_total, 0)
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)
// Construct a temporary Invoice-like object
const previewInvoice = {
id: 'preview',
user_id: isMockCustomer ? 'preview-user' : user.id,
customer_id: customer.id,
invoice_number: typeof invoice_number === 'string' && invoice_number.trim()
? invoice_number
: isMockCustomer ? '1' : null,
invoice_date: invoice_date || new Date().toISOString().split('T')[0],
due_date: due_date || new Date().toISOString().split('T')[0],
delivery_date: delivery_date || null,
status: 'draft',
currency: requestedCurrency,
exchange_rate: null,
exchange_rate_date: null,
subtotal: isDeliveryNote ? 0 : subtotal,
subtotal_sek: null,
vat_amount: vatAmount,
vat_amount_sek: null,
total,
total_sek: null,
vat_treatment: vatRules.treatment,
vat_rate: effectiveVatRate,
moms_ruta: vatRules.momsRuta,
your_reference: your_reference || null,
our_reference: our_reference || null,
notes: notes || null,
payment_link_url: previewPaymentLink,
reverse_charge_text: vatRules.reverseChargeText || null,
credited_invoice_id: null,
document_type: docType,
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
try {
const { branding, company: renderCompany } = await prepareInvoicePdfRender(
company as CompanySettings,
previewInvoice.currency,
{ paymentAccountRequired: invoiceRequiresPaymentAccount(previewInvoice) },
)
const swishQrDataUrl = await buildSwishQrDataUrl(renderCompany, previewInvoice)
const paymentLinkQrDataUrl = await buildPaymentLinkQrDataUrl(previewInvoice)
const pdfBuffer = await renderToBuffer(
InvoicePDF({
invoice: previewInvoice,
customer,
items: invoiceItems,
company: renderCompany,
isPreview: true,
branding,
swishQrDataUrl,
paymentLinkQrDataUrl,
})
)
const filename = invoicePdfFilename({
companyName: (company as CompanySettings).company_name,
customerName: customer.name,
invoiceNumber: previewInvoice.invoice_number,
invoiceId: previewInvoice.id,
invoiceDate: previewInvoice.invoice_date,
documentType: previewInvoice.document_type,
})
return new Response(new Uint8Array(pdfBuffer), {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': contentDisposition('inline', filename),
'Cache-Control': 'private, no-store',
},
})
} catch (error) {
log.error('invoice preview PDF generation failed', error, { requestId })
return NextResponse.json(
{ error: 'Kunde inte generera PDF-förhandsgranskning' },
{ status: 500, headers: PRIVATE_NO_STORE_HEADERS }
)
}
})