feat(invoices): Betald-stämpel i faktura-PDF + betalningsbekräftelse till kund (#1700)

* feat(invoices): Betald-stämpel i faktura-PDF + betalningsbekräftelse till kund

Closes #1693. A paid faktura re-renders with a BETALD banner (paid date
and amount) and "Betalt: X" followed by "Att betala: 0"; partially_paid
gets the Betalt / Att betala (remaining) rows without a banner. Credit
notes and proformas are unchanged. Labels in sv and en.

The paid copy is its own document, a betalningsbekräftelse, never the
archived original: GET /api/invoices/[id]/pdf?variant=paid refuses
anything but status paid (409 INVOICE_PAYMENT_CONFIRMATION_NOT_PAID),
names the file Betalningsbekraftelse-<nr>.pdf and never reads or
replaces the delivery archive. invoice-pdf-source gains the
'payment_confirmation' re-render reason so the UI caveats it like any
re-render. POST /api/invoices/[id]/send-payment-confirmation emails the
paid PDF with a dedicated subject/body through the existing email
service and recipient routing, without touching status, sent_at,
journal entries or invoice_deliveries (no kind column there; logged via
the route logger instead).

Detail page: the two actions sit inside the Betald card (download paid
copy, send confirmation with an up-front confirm dialog), not in the
header row. No migrations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invoices): one-line hint for the betalningsbekräftelse actions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-19 20:17:18 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 926ca75fd3
commit 6b9338f17e
19 changed files with 1485 additions and 20 deletions
@@ -3,6 +3,9 @@ import {
generateInvoiceEmailHtml,
generateInvoiceEmailText,
generateInvoiceEmailSubject,
generatePaymentConfirmationEmailHtml,
generatePaymentConfirmationEmailSubject,
generatePaymentConfirmationEmailText,
} from '../invoice-templates'
import { makeCustomer, makeInvoice, makeCompanySettings } from '@/tests/helpers'
@@ -487,3 +490,88 @@ describe('invoice email templates', () => {
})
})
})
// #1693: the betalningsbekräftelse mail. Customer-language driven like the
// invoice mail, but it never lists payment details (nothing is due) and never
// applies the company's custom invoice texts.
describe('payment confirmation email templates', () => {
const paidInvoice = makeInvoice({
invoice_number: '1042',
invoice_date: '2026-05-22',
currency: 'SEK',
total: 12500,
status: 'paid',
paid_amount: 12500,
remaining_amount: 0,
paid_at: '2026-06-10T12:00:00+00:00',
})
const customText = makeCompanySettings({
company_name: 'Acme AB',
invoice_email_texts: { sv: { subject: 'Egen rubrik {fakturanummer}', body: 'Egen text' } },
})
describe('Swedish customer', () => {
const customer = makeCustomer({ name: 'Erik Andersson', email: 'erik@example.se', language: 'sv' })
const data = { invoice: paidInvoice, customer, company }
it('subject names the invoice and the sender', () => {
expect(generatePaymentConfirmationEmailSubject(data)).toBe(
'Betalningsbekräftelse för faktura 1042 från Acme AB',
)
})
it('html confirms the payment with date and amount, without payment details', () => {
const html = generatePaymentConfirmationEmailHtml(data)
expect(html).toContain('<html lang="sv">')
expect(html).toContain('Betalningsbekräftelse från Acme AB')
expect(html).toContain('Hej Erik,')
expect(html).toContain('faktura 1042 är betald i sin helhet')
expect(html).toContain('2026-06-10')
expect(html).toContain('12\u00a0500,00 SEK')
expect(html).not.toContain('Betalningsinformation')
expect(html).not.toContain('Att betala:')
})
it('text mirrors the html', () => {
const text = generatePaymentConfirmationEmailText(data)
expect(text).toContain('Betalningsbekräftelse från Acme AB')
expect(text).toContain('Betald: 2026-06-10')
expect(text).toContain('Betalt belopp: 12\u00a0500,00 SEK')
expect(text).not.toContain('IBAN')
})
it('ignores the company custom invoice texts', () => {
const subject = generatePaymentConfirmationEmailSubject({ ...data, company: customText })
expect(subject).toBe('Betalningsbekräftelse för faktura 1042 från Acme AB')
expect(generatePaymentConfirmationEmailHtml({ ...data, company: customText })).not.toContain('Egen text')
})
it('omits the paid date when paid_at was never recorded', () => {
const text = generatePaymentConfirmationEmailText({
...data,
invoice: { ...paidInvoice, paid_at: null },
})
expect(text).not.toContain('Betald:')
expect(text).toContain('Betalt belopp:')
})
})
describe('English customer', () => {
const customer = makeCustomer({ name: 'John Smith', email: 'john@example.com', language: 'en' })
const data = { invoice: paidInvoice, customer, company }
it('uses English chrome', () => {
expect(generatePaymentConfirmationEmailSubject(data)).toBe(
'Payment confirmation for invoice 1042 from Acme AB',
)
const html = generatePaymentConfirmationEmailHtml(data)
expect(html).toContain('<html lang="en">')
expect(html).toContain('Hi John,')
expect(html).toContain('invoice 1042 has been paid in full')
expect(html).toContain('12,500.00 SEK')
const text = generatePaymentConfirmationEmailText(data)
expect(text).toContain('Paid on: 2026-06-10')
expect(text).toContain('Amount paid: 12,500.00 SEK')
})
})
})
+153
View File
@@ -39,6 +39,13 @@ const LABELS = {
fSkatt: 'Innehar F-skattsedel',
documentSummary: (doc: string) => `${doc.toLowerCase()}sammanfattning:`,
subjectFrom: (doc: string, num: string, sender: string) => `${doc} ${num} från ${sender}`,
// Payment confirmation (#1693)
confirmationSubject: (num: string, sender: string) => `Betalningsbekräftelse för faktura ${num} från ${sender}`,
confirmationHeading: (sender: string) => `Betalningsbekräftelse från ${sender}`,
confirmationBody: (num: string) => `Tack för din betalning! Vi bekräftar att faktura ${num} är betald i sin helhet. Bifogat hittar du en betalningsbekräftelse.`,
confirmationPaidOn: 'Betald:',
confirmationPaidAmount: 'Betalt belopp:',
confirmationQuestions: 'Har du frågor om betalningen? Svara direkt på detta mejl så hjälper vi dig.',
},
en: {
docInvoice: 'Invoice',
@@ -70,6 +77,12 @@ const LABELS = {
fSkatt: 'Innehar F-skattsedel',
documentSummary: (doc: string) => `${doc} summary:`,
subjectFrom: (doc: string, num: string, sender: string) => `${doc} ${num} from ${sender}`,
confirmationSubject: (num: string, sender: string) => `Payment confirmation for invoice ${num} from ${sender}`,
confirmationHeading: (sender: string) => `Payment confirmation from ${sender}`,
confirmationBody: (num: string) => `Thank you for your payment. We confirm that invoice ${num} has been paid in full. A payment confirmation is attached.`,
confirmationPaidOn: 'Paid on:',
confirmationPaidAmount: 'Amount paid:',
confirmationQuestions: 'Questions about the payment? Reply directly to this email and we will help you.',
},
} as const
@@ -426,3 +439,143 @@ export function generateInvoiceEmailSubject(data: InvoiceEmailData): string {
const documentType = getDocumentLabel(invoice, lang)
return L.subjectFrom(documentType, invoice.invoice_number ?? '', getCompanyPrimaryName(company))
}
// ---------------------------------------------------------------------------
// Payment confirmation (#1693)
//
// Sent on request for a paid faktura, with the BETALD re-render attached. It
// is not an invoice send: no custom invoice texts apply, no payment details
// are listed (nothing is due), and nothing here touches delivery history.
// ---------------------------------------------------------------------------
function paidAmountForCustomer(invoice: Invoice, company: CompanySettings): number {
// What the customer actually paid; the deduction-aware amount to pay is only
// the fallback for legacy rows marked paid before paid_amount was recorded.
return invoice.paid_amount ?? getAmountToPay(invoice, company).toPay
}
function paidDateForCustomer(invoice: Invoice): string | null {
return invoice.paid_at ? formatDate(invoice.paid_at) : null
}
export function generatePaymentConfirmationEmailSubject(data: InvoiceEmailData): string {
const { invoice, customer, company } = data
const L = LABELS[resolveLang(customer)]
return sanitizeSubjectLine(
L.confirmationSubject(invoice.invoice_number ?? '', getCompanyPrimaryName(company)),
)
}
export function generatePaymentConfirmationEmailHtml(data: InvoiceEmailData): string {
const { invoice, customer, company } = data
const lang = resolveLang(customer)
const L = LABELS[lang]
const firstName = customer.name ? customer.name.split(' ')[0] : ''
const primaryColor = safeBrandingColor(company.invoice_primary_color, '#111111')
const paidDate = paidDateForCustomer(invoice)
const paidAmount = formatCurrencyForCustomer(paidAmountForCustomer(invoice, company), invoice.currency, lang)
const invoiceNumber = escapeHtml(invoice.invoice_number ?? '')
return `
<!DOCTYPE html>
<html lang="${L.htmlLang}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(L.confirmationSubject(invoice.invoice_number ?? '', getCompanyPrimaryName(company)))}</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 40px 20px;">
<div style="margin-bottom: 30px; border-bottom: 2px solid ${primaryColor}; padding-bottom: 16px;">
<h1 style="margin: 0 0 10px 0; font-size: 24px; font-weight: 600; color: ${primaryColor};">
${escapeHtml(L.confirmationHeading(getCompanyPrimaryName(company)))}
</h1>
<p style="margin: 0; color: #666; font-size: 14px;">
${L.documentNumber(L.docInvoice)} ${invoiceNumber}
</p>
</div>
<div style="margin-bottom: 30px;">
<p style="margin: 0 0 15px 0;">${escapeHtml(L.greeting(firstName))}</p>
<p style="margin: 0;">${escapeHtml(L.confirmationBody(invoice.invoice_number ?? ''))}</p>
</div>
<div style="background: #f8f9fa; border-radius: 8px; padding: 25px; margin-bottom: 30px;">
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">${L.documentNumber(L.docInvoice)}</td>
<td style="padding: 8px 0; text-align: right; font-weight: 500;">${invoiceNumber}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">${L.documentDate(L.docInvoice)}</td>
<td style="padding: 8px 0; text-align: right;">${formatDate(invoice.invoice_date)}</td>
</tr>
${paidDate ? `
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">${L.confirmationPaidOn}</td>
<td style="padding: 8px 0; text-align: right;">${paidDate}</td>
</tr>
` : ''}
<tr>
<td colspan="2" style="padding: 15px 0 8px 0; border-top: 1px solid #e5e7eb;"></td>
</tr>
<tr>
<td style="padding: 8px 0; font-size: 18px; font-weight: 600;">${L.confirmationPaidAmount}</td>
<td style="padding: 8px 0; text-align: right; font-size: 18px; font-weight: 600; color: #059669;">
${paidAmount}
</td>
</tr>
</table>
</div>
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb;">
<p style="margin: 0 0 10px 0; color: #666; font-size: 14px;">${L.confirmationQuestions}</p>
<p style="margin: 0; color: #666; font-size: 14px;">
${L.sincerely}<br>
<strong style="color: ${primaryColor};">${escapeHtml(getCompanyPrimaryName(company))}</strong>
</p>
${company.org_number ? `
<p style="margin: 10px 0 0 0; color: #999; font-size: 12px;">
${L.orgNo} ${escapeHtml(company.org_number)}
${company.vat_number ? ` | ${L.vat} ${escapeHtml(company.vat_number)}` : ''}
${company.f_skatt ? ` | ${L.fSkatt}` : ''}
</p>
` : ''}
</div>
</div>
</body>
</html>
`
}
export function generatePaymentConfirmationEmailText(data: InvoiceEmailData): string {
const { invoice, customer, company } = data
const lang = resolveLang(customer)
const L = LABELS[lang]
const firstName = customer.name ? customer.name.split(' ')[0] : ''
const paidDate = paidDateForCustomer(invoice)
const number = invoice.invoice_number ?? ''
let text = `${L.confirmationHeading(getCompanyPrimaryName(company))}\n`
text += `${L.documentNumber(L.docInvoice)} ${number}\n\n`
text += `${L.greeting(firstName)}\n\n`
text += `${L.confirmationBody(number)}\n\n`
text += `---\n`
text += `${L.documentNumber(L.docInvoice)} ${number}\n`
text += `${L.documentDate(L.docInvoice)} ${formatDate(invoice.invoice_date)}\n`
if (paidDate) text += `${L.confirmationPaidOn} ${paidDate}\n`
text += `${L.confirmationPaidAmount} ${formatCurrencyForCustomer(paidAmountForCustomer(invoice, company), invoice.currency, lang)}\n`
text += `---\n\n`
text += `${L.confirmationQuestions}\n\n`
text += `${L.sincerely}\n`
text += `${getCompanyDisplayName(company)}\n`
if (company.org_number) {
text += `\n${L.orgNo} ${company.org_number}`
if (company.vat_number) text += ` | ${L.vat} ${company.vat_number}`
if (company.f_skatt) text += ` | ${L.fSkatt}`
text += `\n`
}
return text
}
+10
View File
@@ -1093,6 +1093,16 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
message_sv: 'Fakturan kan inte markeras som betald i nuvarande status.',
message_en: 'Invoice is not in a payable status.',
},
INVOICE_PAYMENT_CONFIRMATION_NOT_PAID: {
httpStatus: 409,
message_sv:
'En betalningsbekräftelse kan bara skapas för en faktura som är fullt betald.',
message_en: 'A payment confirmation can only be produced for a fully paid invoice.',
remediation: {
description:
'Register the payment first (POST /api/invoices/{id}/mark-paid) so the invoice reaches status paid; credit notes and proformas never qualify.',
},
},
INVOICE_PAID_LINES_UNBALANCED: {
httpStatus: 400,
message_sv: 'Verifikationsraderna är inte balanserade (debet ≠ kredit).',
@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest'
import {
invoiceDocumentCaveat,
invoicePaymentConfirmationUrl,
invoiceRerenderUrl,
paymentConfirmationPdfSource,
resolveInvoicePdfSource,
type InvoicePdfDelivery,
} from '@/lib/invoices/invoice-pdf-source'
@@ -231,3 +233,37 @@ describe('invoiceRerenderUrl', () => {
)
})
})
// #1693: the betalningsbekräftelse is always a re-render and always says so,
// however the delivery history looks. An archived delivery is the invoice as
// sent (unpaid); the paid copy is a different document and never replaces it.
describe('paymentConfirmationPdfSource', () => {
it('is a re-render of the paid variant with its own caveat', () => {
const source = paymentConfirmationPdfSource(INVOICE_ID)
expect(source).toEqual({
kind: 'rerender',
url: `/api/invoices/${INVOICE_ID}/pdf?variant=paid`,
reason: 'payment_confirmation',
})
expect(invoiceDocumentCaveat(source)).toBe('payment_confirmation')
})
it('never resolves to the archived delivery, even when one exists', () => {
const archived = resolveInvoicePdfSource({
invoiceId: INVOICE_ID,
invoiceStatus: 'paid',
deliveriesLoaded: true,
deliveries: [emailDelivery()],
})
expect(archived.kind).toBe('archived')
const confirmation = paymentConfirmationPdfSource(INVOICE_ID)
expect(confirmation.kind).toBe('rerender')
expect(confirmation.url).not.toContain('/api/documents/')
})
it('encodes the invoice id in the confirmation url', () => {
expect(invoicePaymentConfirmationUrl('a/b')).toBe('/api/invoices/a%2Fb/pdf?variant=paid')
})
})
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import { isPaymentConfirmationEligible } from '@/lib/invoices/payment-confirmation'
import { makeInvoice } from '@/tests/helpers'
describe('isPaymentConfirmationEligible', () => {
it('accepts a fully paid faktura', () => {
expect(isPaymentConfirmationEligible(makeInvoice({ status: 'paid' }))).toBe(true)
})
it.each(['draft', 'sent', 'overdue', 'partially_paid', 'cancelled', 'credited'] as const)(
'refuses status %s',
(status) => {
expect(isPaymentConfirmationEligible(makeInvoice({ status }))).toBe(false)
},
)
it('refuses credit notes even when marked paid', () => {
expect(
isPaymentConfirmationEligible(makeInvoice({ status: 'paid', credited_invoice_id: 'orig' })),
).toBe(false)
})
it.each(['proforma', 'delivery_note'] as const)('refuses document type %s', (document_type) => {
expect(isPaymentConfirmationEligible(makeInvoice({ status: 'paid', document_type }))).toBe(false)
})
it('treats a missing document_type as a regular invoice', () => {
expect(
isPaymentConfirmationEligible({ status: 'paid', credited_invoice_id: null, document_type: null }),
).toBe(true)
})
})
+17 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { invoicePdfFilename } from '../pdf-filename'
import { invoicePdfFilename, paymentConfirmationPdfFilename } from '../pdf-filename'
describe('invoicePdfFilename', () => {
it('includes company, customer, document type, number, and invoice date', () => {
@@ -66,3 +66,19 @@ describe('invoicePdfFilename', () => {
expect(filename).toMatch(/Faktura nr 2621 20260721\.pdf$/)
})
})
// #1693: the paid copy is named as a betalningsbekräftelse, never as the
// invoice, so the file cannot be mistaken for the one that was sent.
describe('paymentConfirmationPdfFilename', () => {
it('names the file after the invoice number', () => {
expect(paymentConfirmationPdfFilename('2621')).toBe('Betalningsbekraftelse-2621.pdf')
})
it('sanitizes unsafe characters and spaces in the number', () => {
expect(paymentConfirmationPdfFilename('F 2026/0042')).toBe('Betalningsbekraftelse-F-2026-0042.pdf')
})
it('falls back when the number is missing', () => {
expect(paymentConfirmationPdfFilename(null)).toBe('Betalningsbekraftelse-okand.pdf')
})
})
@@ -0,0 +1,186 @@
/**
* Betald-stämpel (#1693). A paid faktura re-renders with a BETALD banner and
* "Betalt / Att betala: 0" totals; a partly paid one gets the two rows without
* the banner; everything else renders exactly as before. Credit notes and
* proformas never carry a payment state.
*/
import { describe, expect, it } from 'vitest'
import type { ReactElement, ReactNode } from 'react'
import { InvoicePDF, resolvePdfPaidState, type InvoicePdfInvoice } from '@/lib/invoices/pdf-template'
import { makeCompanySettings, makeCustomer, makeInvoice } from '@/tests/helpers'
import type { InvoiceItem } from '@/types'
/** Every string leaf in the element tree, in document order. */
function textLeaves(node: ReactNode, out: string[] = []): string[] {
if (node === null || node === undefined || typeof node === 'boolean') return out
if (typeof node === 'string' || typeof node === 'number') {
out.push(String(node))
return out
}
if (Array.isArray(node)) {
for (const child of node) textLeaves(child, out)
return out
}
const element = node as ReactElement<{ children?: ReactNode }>
if (element.props) textLeaves(element.props.children, out)
return out
}
const items: InvoiceItem[] = [
{
id: 'item-1',
invoice_id: 'invoice-1',
sort_order: 0,
line_type: 'product',
description: 'Konsulttimmar',
quantity: 10,
unit: 'tim',
unit_price: 1000,
line_total: 10000,
vat_rate: 25,
vat_amount: 2500,
created_at: '2026-01-15T00:00:00Z',
},
]
function renderText(invoice: InvoicePdfInvoice, language?: 'sv' | 'en'): string {
const tree = InvoicePDF({
invoice,
customer: makeCustomer({ language: language ?? 'sv' }),
items,
company: makeCompanySettings(),
})
return textLeaves(tree).join('\n')
}
const paidInvoice = (overrides: Partial<InvoicePdfInvoice> = {}): InvoicePdfInvoice =>
makeInvoice({
id: 'invoice-1',
status: 'paid',
invoice_number: '2026-0042',
total: 12500,
paid_amount: 12500,
remaining_amount: 0,
paid_at: '2026-08-17T12:00:00+00:00',
...overrides,
})
describe('invoice PDF paid state (sv)', () => {
it('stamps a paid invoice BETALD with date and amount, and states Att betala 0', () => {
const text = renderText(paidInvoice())
expect(text).toContain('BETALD')
expect(text).toContain('Betald 2026-08-17 · 12\u00a0500,00 SEK')
expect(text).toContain('Betalt:')
expect(text).toContain('Att betala:')
// Betalt comes first, then Att betala 0.
const betalt = text.indexOf('Betalt:')
const attBetala = text.indexOf('Att betala:', betalt)
expect(betalt).toBeGreaterThan(-1)
expect(attBetala).toBeGreaterThan(betalt)
expect(text.slice(attBetala)).toContain('0,00 SEK')
})
it('shows Betalt and the remaining amount without a banner when partially paid', () => {
const text = renderText(
paidInvoice({ status: 'partially_paid', paid_amount: 5000, remaining_amount: 7500 }),
)
expect(text).not.toContain('BETALD')
expect(text).toContain('Betalt:')
expect(text).toContain('5\u00a0000,00 SEK')
expect(text).toContain('Att betala:')
expect(text).toContain('7\u00a0500,00 SEK')
})
it('renders an unpaid invoice exactly as before: no banner, no Betalt row', () => {
const text = renderText(paidInvoice({ status: 'sent', paid_amount: null, remaining_amount: 12500, paid_at: null }))
expect(text).not.toContain('BETALD')
expect(text).not.toContain('Betalt:')
expect(text).toContain('Att betala:')
expect(text).toContain('12\u00a0500,00 SEK')
})
it('falls back to the amount to pay when paid_amount was never recorded', () => {
const text = renderText(paidInvoice({ paid_amount: null, paid_at: null }))
expect(text).toContain('BETALD')
expect(text).toContain('Betald · 12\u00a0500,00 SEK')
expect(text).not.toContain('Betald null')
})
it('leaves credit notes and proformas alone even when their status is paid', () => {
const credit = renderText(paidInvoice({ credited_invoice_id: 'orig-1' }))
expect(credit).not.toContain('BETALD')
expect(credit).not.toContain('Betalt:')
expect(credit).toContain('Att kreditera:')
const proforma = renderText(paidInvoice({ document_type: 'proforma' }))
expect(proforma).not.toContain('BETALD')
expect(proforma).not.toContain('Betalt:')
})
})
describe('invoice PDF paid state (en)', () => {
it('stamps PAID with date and amount and states Total due 0', () => {
const text = renderText(paidInvoice(), 'en')
expect(text).toContain('PAID')
expect(text).toContain('Paid 2026-08-17 · 12,500.00 SEK')
expect(text).toContain('Paid:')
const paid = text.indexOf('Paid:')
const due = text.indexOf('Total due:', paid)
expect(due).toBeGreaterThan(paid)
expect(text.slice(due)).toContain('0.00 SEK')
})
it('shows Paid and the remainder without a banner when partially paid', () => {
const text = renderText(
paidInvoice({ status: 'partially_paid', paid_amount: 5000, remaining_amount: 7500 }),
'en',
)
expect(text).not.toContain('PAID')
expect(text).toContain('Paid:')
expect(text).toContain('5,000.00 SEK')
expect(text).toContain('Total due:')
expect(text).toContain('7,500.00 SEK')
})
it('renders an unpaid invoice without any paid wording', () => {
const text = renderText(paidInvoice({ status: 'overdue', paid_amount: null, paid_at: null }), 'en')
expect(text).not.toContain('PAID')
expect(text).not.toContain('Paid:')
expect(text).toContain('Total due:')
})
})
describe('resolvePdfPaidState', () => {
it('keeps the customer-paid amount rather than recomputing deductions', () => {
const state = resolvePdfPaidState(
makeInvoice({ status: 'paid', paid_amount: 7000, remaining_amount: 0, deduction_total: 3000 }),
'invoice',
false,
7000,
)
expect(state).toEqual({ kind: 'paid', paidAmount: 7000, remainingAmount: 0, paidDate: null })
})
it('derives the remainder for a partly paid row that lacks remaining_amount', () => {
const state = resolvePdfPaidState(
{ ...makeInvoice({ status: 'partially_paid', paid_amount: 4000.5 }), remaining_amount: undefined as unknown as number },
'invoice',
false,
12500,
)
expect(state).toMatchObject({ kind: 'partially_paid', paidAmount: 4000.5, remainingAmount: 8499.5 })
})
it('returns null for other statuses, credit notes and non-invoice documents', () => {
expect(resolvePdfPaidState(makeInvoice({ status: 'sent' }), 'invoice', false, 1)).toBeNull()
expect(resolvePdfPaidState(makeInvoice({ status: 'paid' }), 'invoice', true, 1)).toBeNull()
expect(resolvePdfPaidState(makeInvoice({ status: 'paid' }), 'delivery_note', false, 1)).toBeNull()
})
})
+31
View File
@@ -68,6 +68,12 @@ export type InvoicePdfRerenderReason =
| 'no_archived_copy'
/** An archived copy may exist but could not be retrieved; the user chose this. */
| 'archive_unreachable'
/**
* The betalningsbekräftelse (#1693): a fresh render of a paid invoice with
* the BETALD stamp. It is its own document, produced on request, and is never
* the file the customer was originally sent, however the archive looks.
*/
| 'payment_confirmation'
export type InvoicePdfSource =
| {
@@ -96,6 +102,31 @@ export function invoiceRerenderUrl(invoiceId: string, options?: { inline?: boole
return options?.inline ? `${base}?disposition=inline` : base
}
/**
* The betalningsbekräftelse endpoint: the same re-render route in its paid
* variant. The route refuses anything but a paid faktura and names the file
* accordingly; the archived original is not involved at any point.
*/
export function invoicePaymentConfirmationUrl(invoiceId: string): string {
return `${invoiceRerenderUrl(invoiceId)}?variant=paid`
}
/**
* The paid copy is always a re-render and is always labelled as one. There is
* no archived variant to look for: even when the delivery history holds the
* sent invoice, that file shows the invoice as it was before payment and is
* a different document from the betalningsbekräftelse.
*/
export function paymentConfirmationPdfSource(
invoiceId: string,
): Extract<InvoicePdfSource, { kind: 'rerender' }> {
return {
kind: 'rerender',
url: invoicePaymentConfirmationUrl(invoiceId),
reason: 'payment_confirmation',
}
}
/**
* Decide which file the download button may fetch.
*
+21
View File
@@ -0,0 +1,21 @@
import type { Invoice, InvoiceDocumentType } from '@/types'
/**
* Which invoices may be handed to the customer as a betalningsbekräftelse
* (#1693): a real faktura whose status is `paid`. Credit notes settle against
* their original and proformas are not payment requests, so neither has a
* "betald" state worth confirming. `partially_paid` is excluded on purpose:
* the customer still owes money, and a document stamped BETALD would say
* otherwise.
*
* Shared by the PDF route (`?variant=paid`), the send-confirmation route and
* the detail page, so the three can never disagree on who gets the button.
*/
export function isPaymentConfirmationEligible(
invoice: Pick<Invoice, 'status' | 'credited_invoice_id'> & { document_type?: InvoiceDocumentType | null },
): boolean {
if (invoice.credited_invoice_id) return false
const docType = invoice.document_type || 'invoice'
if (docType !== 'invoice') return false
return invoice.status === 'paid'
}
+13
View File
@@ -83,3 +83,16 @@ export function invoicePdfFilename({
return fitFilename(company, customer, suffix)
}
/**
* Filename for the betalningsbekräftelse (#1693): the paid re-render of a
* settled faktura. Deliberately not the invoice filename shape above, so the
* file can never be mistaken for the invoice that was sent. ASCII on purpose
* (no ä): the name travels as an email attachment to arbitrary mail clients.
*
* Example: `Betalningsbekraftelse-2621.pdf`.
*/
export function paymentConfirmationPdfFilename(invoiceNumber: string | null | undefined): string {
const number = safeFilenamePart(invoiceNumber, 'okand', MAX_NUMBER_PART_LENGTH).replace(/\s+/g, '-')
return `Betalningsbekraftelse-${number}.pdf`
}
+114 -9
View File
@@ -39,6 +39,9 @@ const LABELS = {
draftTitle: 'UTKAST: inte en giltig faktura',
draftWithNumber: 'Detta är ett utkast. Markera fakturan som skickad eller skicka via systemet för att göra den giltig som fakturaunderlag.',
draftNoNumber: 'Denna faktura saknar löpnummer och kan inte användas som fakturaunderlag enligt ML 17 kap 24§. Skicka fakturan via systemet för att tilldela ett nummer.',
paidTitle: 'BETALD',
paidBannerText: (date: string, amount: string) => `Betald ${date} · ${amount}`,
paidBannerNoDate: (amount: string) => `Betald · ${amount}`,
// Credit note reference
creditNoteRef: (n: string) => `Denna kreditfaktura avser och krediterar faktura nr ${n}`,
// Sections
@@ -77,6 +80,7 @@ const LABELS = {
deductionNotice: 'Köparen ansöker om utbetalning hos Skatteverket via fakturamodellen. Säljaren begär utbetalning för den del köparen inte betalat.',
toCredit: 'Att kreditera:',
toPay: 'Att betala:',
paidRow: 'Betalt:',
vatInSek: (rate: number | string) => `Moms i SEK (kurs ${rate}):`,
totalInSek: 'Totalt i SEK:',
// Proforma / exempt
@@ -119,6 +123,9 @@ const LABELS = {
draftTitle: 'DRAFT: not a valid invoice',
draftWithNumber: 'This is a draft. Mark the invoice as sent, or send it via the system, to make it a valid invoice.',
draftNoNumber: 'This invoice has no serial number and cannot be used as a valid invoice under ML 17 kap 24§ (Swedish VAT Act). Send the invoice via the system to assign a number.',
paidTitle: 'PAID',
paidBannerText: (date: string, amount: string) => `Paid ${date} · ${amount}`,
paidBannerNoDate: (amount: string) => `Paid · ${amount}`,
creditNoteRef: (n: string) => `This credit note credits invoice no. ${n}`,
invoiceInfoHeading: 'Invoice information',
billedToHeading: 'Billed to',
@@ -151,6 +158,7 @@ const LABELS = {
deductionNotice: 'The customer claims the deduction via fakturamodellen at Skatteverket. The seller requests payment from the agency for the portion not paid by the customer.',
toCredit: 'To credit:',
toPay: 'Total due:',
paidRow: 'Paid:',
vatInSek: (rate: number | string) => `VAT in SEK (rate ${rate}):`,
totalInSek: 'Total in SEK:',
proformaNotice: 'This is a proforma invoice and is not a request for payment.',
@@ -515,6 +523,26 @@ function createStyles(branding?: InvoiceBranding) {
color: '#721c24',
textAlign: 'center',
},
paidBanner: {
marginBottom: 16,
padding: 10,
backgroundColor: '#d4edda',
borderWidth: 2,
borderColor: '#155724',
borderRadius: 4,
},
paidBannerTitle: {
fontSize: 14,
fontWeight: 'bold',
color: '#155724',
textAlign: 'center',
marginBottom: 2,
},
paidBannerText: {
fontSize: 9,
color: '#155724',
textAlign: 'center',
},
footer: {
position: 'absolute',
bottom: 30,
@@ -647,6 +675,44 @@ function formatOrgNumber(orgNumber: string): string {
return orgNumber
}
/**
* Payment state the PDF prints for a real faktura (#1693): the BETALD stamp
* and the "Betalt / Att betala" rows. Null for every other document or status,
* so unpaid invoices, credit notes and proformas render exactly as before.
*
* `paid_amount` is what the customer actually paid; the deduction-aware amount
* to pay is only the fallback for legacy rows marked paid before paid_amount
* existed. `remaining_amount` is the row's own figure: 0 once fully paid.
*/
export interface PdfPaidState {
kind: 'paid' | 'partially_paid'
paidAmount: number
remainingAmount: number
/** ISO yyyy-MM-dd, or null when paid_at was never recorded. */
paidDate: string | null
}
export function resolvePdfPaidState(
invoice: Invoice,
docType: InvoiceDocumentType,
isCreditNote: boolean,
amountToPay: number,
): PdfPaidState | null {
if (isCreditNote || docType !== 'invoice') return null
if (invoice.status !== 'paid' && invoice.status !== 'partially_paid') return null
const paidAmount = invoice.paid_amount ?? (invoice.status === 'paid' ? amountToPay : 0)
const remainingAmount =
invoice.status === 'paid'
? 0
: invoice.remaining_amount ?? Math.max(0, Math.round((amountToPay - paidAmount) * 100) / 100)
return {
kind: invoice.status,
paidAmount,
remainingAmount,
paidDate: invoice.paid_at ? formatDate(invoice.paid_at) : null,
}
}
function getDocumentTitle(invoice: Invoice, lang: PdfLang): string {
const L = LABELS[lang]
if (invoice.credited_invoice_id) return L.titleCreditNote
@@ -727,6 +793,18 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
const isDeliveryNote = docType === 'delivery_note'
const isProforma = docType === 'proforma'
// Shared with the invoice email (lib/email/invoice-templates.ts) so the
// mail and the PDF always state the same "Att betala". Computed once here
// because the paid state below needs it too.
const amountToPay = getAmountToPay(invoice, company)
// Payment state (#1693). Only a real faktura carries it: credit notes are
// settled against their original, proformas are not a payment request. The
// paid amount is what the customer actually paid (paid_amount), not a
// recomputation of the deduction-aware total; the fallback to the amount to
// pay covers legacy rows marked paid before paid_amount was recorded.
const paidState = resolvePdfPaidState(invoice, docType, isCreditNote, amountToPay.toPay)
// Optional branding banner text. Rendered only when the company has set
// invoice_header_text: invisible chrome by default, so the byte-equivalence
// promise for un-branded callers holds.
@@ -758,7 +836,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
: L.cancelledNoNumber}
</Text>
</View>
) : isPreview ? null : (invoice.status === 'draft' || !invoice.invoice_number) && (
) : isPreview ? null : (invoice.status === 'draft' || !invoice.invoice_number) ? (
<View style={styles.draftBanner}>
<Text style={styles.draftBannerTitle}>{L.draftTitle}</Text>
<Text style={styles.draftBannerText}>
@@ -767,6 +845,18 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
: L.draftNoNumber}
</Text>
</View>
) : paidState?.kind === 'paid' && (
// BETALD stamp (#1693): the re-rendered copy of a settled faktura
// doubles as the betalningsbekräftelse the customer can be handed.
// partially_paid gets no banner, only the Betalt / Att betala rows.
<View style={styles.paidBanner}>
<Text style={styles.paidBannerTitle}>{L.paidTitle}</Text>
<Text style={styles.paidBannerText}>
{paidState.paidDate
? L.paidBannerText(paidState.paidDate, formatPdfCurrency(paidState.paidAmount, invoice.currency, lang))
: L.paidBannerNoDate(formatPdfCurrency(paidState.paidAmount, invoice.currency, lang))}
</Text>
</View>
)}
{/* Header */}
@@ -988,10 +1078,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
)
)}
{(() => {
// Shared with the invoice email (lib/email/invoice-templates.ts)
// so the mail and the PDF always state the same "Att betala".
const { rounding, deductionApplies: showDeduction, toPay: grandTotal } =
getAmountToPay(invoice, company)
const { rounding, deductionApplies: showDeduction, toPay: grandTotal } = amountToPay
return (
<>
{rounding.applies && (
@@ -1011,10 +1098,28 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
</Text>
</View>
)}
<View style={styles.grandTotal}>
<Text style={styles.grandTotalLabel}>{isCreditNote ? L.toCredit : L.toPay}</Text>
<Text style={styles.grandTotalValue}>{formatPdfCurrency(grandTotal, invoice.currency, lang)}</Text>
</View>
{paidState ? (
// Settled or partly settled faktura: state what was paid,
// then what is still due (0 when fully paid). The bold
// total row is the figure that matters to the reader:
// the paid amount on a betald faktura, the remainder on
// a partly paid one.
<>
<View style={paidState.kind === 'paid' ? styles.grandTotal : styles.totalRow}>
<Text style={paidState.kind === 'paid' ? styles.grandTotalLabel : styles.totalLabel}>{L.paidRow}</Text>
<Text style={paidState.kind === 'paid' ? styles.grandTotalValue : styles.totalValue}>{formatPdfCurrency(paidState.paidAmount, invoice.currency, lang)}</Text>
</View>
<View style={paidState.kind === 'paid' ? styles.totalRow : styles.grandTotal}>
<Text style={paidState.kind === 'paid' ? styles.totalLabel : styles.grandTotalLabel}>{L.toPay}</Text>
<Text style={paidState.kind === 'paid' ? styles.totalValue : styles.grandTotalValue}>{formatPdfCurrency(paidState.remainingAmount, invoice.currency, lang)}</Text>
</View>
</>
) : (
<View style={styles.grandTotal}>
<Text style={styles.grandTotalLabel}>{isCreditNote ? L.toCredit : L.toPay}</Text>
<Text style={styles.grandTotalValue}>{formatPdfCurrency(grandTotal, invoice.currency, lang)}</Text>
</View>
)}
</>
)
})()}