* 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>
187 lines
6.7 KiB
TypeScript
187 lines
6.7 KiB
TypeScript
/**
|
|
* 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()
|
|
})
|
|
})
|