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>
372 lines
14 KiB
TypeScript
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 }
|
|
)
|
|
}
|
|
})
|