feat(currency): sweep remaining concat call sites to formatCurrency

Builds on the centralised formatter shipped in ee2da8f. Replaces
\`\${currency} \${amount}\` style concatenations across the dashboard
revenue tooltip, command-search invoice/expense fallback labels,
expense-duplicate banner, and the invoice + expense PDF templates.
Drops the duplicate \`currencySymbol\` helper inside expense-pdf.service
in favour of the shared util; the two PDF helpers (renderReceiptHeader,
addReceiptErrorPage) now take a currency code instead of a pre-rendered
symbol so the formatter is the single source for spacing + thousands
separators. Also re-runs Prettier on the files where the prior commit
shipped without it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 18:35:34 +02:00
parent 7804e9bb17
commit 43191659e6
10 changed files with 53 additions and 71 deletions

View File

@@ -1,5 +1,7 @@
import type { Template } from '@pdfme/common';
import { formatCurrency } from '@/lib/utils/currency';
export const invoiceTemplate: Template = {
basePdf: 'BLANK_PDF' as unknown as string,
schemas: [
@@ -90,21 +92,22 @@ export function buildInvoiceInputs(
lineItems: Record<string, unknown>[],
port: Record<string, unknown>,
): Record<string, string> {
const currency = (invoice.currency as string) ?? 'USD';
const itemLines = lineItems
.map(
(li, i) =>
`${i + 1}. ${li.description} | Qty: ${li.quantity} | Unit: ${invoice.currency} ${Number(li.unitPrice).toFixed(2)} | Total: ${invoice.currency} ${Number(li.total).toFixed(2)}`,
`${i + 1}. ${li.description} | Qty: ${li.quantity} | Unit: ${formatCurrency(Number(li.unitPrice), currency)} | Total: ${formatCurrency(Number(li.total), currency)}`,
)
.join('\n');
let totalsText = `Subtotal: ${invoice.currency} ${Number(invoice.subtotal).toFixed(2)}`;
let totalsText = `Subtotal: ${formatCurrency(Number(invoice.subtotal), currency)}`;
if (Number(invoice.discountAmount) > 0) {
totalsText += `\nDiscount (${invoice.discountPct}%): -${invoice.currency} ${Number(invoice.discountAmount).toFixed(2)}`;
totalsText += `\nDiscount (${invoice.discountPct}%): -${formatCurrency(Number(invoice.discountAmount), currency)}`;
}
if (Number(invoice.feeAmount) > 0) {
totalsText += `\nFee (${invoice.feePct}%): +${invoice.currency} ${Number(invoice.feeAmount).toFixed(2)}`;
totalsText += `\nFee (${invoice.feePct}%): +${formatCurrency(Number(invoice.feeAmount), currency)}`;
}
totalsText += `\n─────────────\nTOTAL: ${invoice.currency} ${Number(invoice.total).toFixed(2)}`;
totalsText += `\n─────────────\nTOTAL: ${formatCurrency(Number(invoice.total), currency)}`;
return {
portName: (port?.name as string) ?? 'Port Nimara',
@@ -112,7 +115,8 @@ export function buildInvoiceInputs(
invoiceNumber: invoice.invoiceNumber as string,
invoiceDate: `Date: ${new Date(invoice.createdAt as string | Date).toLocaleDateString('en-GB')}`,
dueDate: `Due: ${invoice.dueDate}`,
clientInfo: `${invoice.clientName}\n${invoice.billingEmail ?? ''}\n${invoice.billingAddress ?? ''}`.trim(),
clientInfo:
`${invoice.clientName}\n${invoice.billingEmail ?? ''}\n${invoice.billingAddress ?? ''}`.trim(),
lineItems: itemLines || 'No line items',
totals: totalsText,
notes: invoice.notes ? `Notes: ${invoice.notes}` : '',

View File

@@ -42,6 +42,7 @@ import { db } from '@/lib/db';
import { expenses } from '@/lib/db/schema/financial';
import { files } from '@/lib/db/schema/documents';
import { getRate } from '@/lib/services/currency';
import { formatCurrency } from '@/lib/utils/currency';
import { getStorageBackend } from '@/lib/storage';
import { logger } from '@/lib/logger';
@@ -308,21 +309,6 @@ function pageDims(format: PageFormat): { width: number; height: number } {
}
}
// ─── Symbol helper ──────────────────────────────────────────────────────────
function currencySymbol(c: string): string {
switch (c.toUpperCase()) {
case 'USD':
return '$';
case 'EUR':
return '€';
case 'GBP':
return '£';
default:
return c.toUpperCase() + ' ';
}
}
// ─── Grouping ───────────────────────────────────────────────────────────────
function groupKey(row: ProcessedExpense, by: GroupBy): string {
@@ -574,8 +560,7 @@ function addSummaryBox(
totals: Totals,
opts: { includeProcessingFee: boolean; groupBy: GroupBy },
) {
const sym = currencySymbol(totals.targetCurrency);
const otherSym = totals.targetCurrency === 'USD' ? '€' : '$';
const otherCurrency = totals.targetCurrency === 'USD' ? 'EUR' : 'USD';
const otherTotal = totals.targetCurrency === 'USD' ? totals.eurTotal : totals.usdTotal;
doc.fontSize(14).font('Helvetica-Bold').text('Summary');
@@ -584,12 +569,14 @@ function addSummaryBox(
const lineY = doc.y;
const lines = [
`Total expenses: ${totals.count}`,
`Subtotal (${totals.targetCurrency}): ${sym}${totals.targetTotal.toFixed(2)}`,
`${totals.targetCurrency === 'USD' ? 'EUR' : 'USD'} equivalent: ${otherSym}${otherTotal.toFixed(2)}`,
`Subtotal (${totals.targetCurrency}): ${formatCurrency(totals.targetTotal, totals.targetCurrency)}`,
`${otherCurrency} equivalent: ${formatCurrency(otherTotal, otherCurrency)}`,
];
if (opts.includeProcessingFee) {
lines.push(`Processing fee (5%): ${sym}${totals.processingFee.toFixed(2)}`);
lines.push(`Final total: ${sym}${totals.finalTotal.toFixed(2)}`);
lines.push(
`Processing fee (5%): ${formatCurrency(totals.processingFee, totals.targetCurrency)}`,
);
lines.push(`Final total: ${formatCurrency(totals.finalTotal, totals.targetCurrency)}`);
}
if (opts.groupBy !== 'none') lines.push(`Grouping: by ${opts.groupBy}`);
@@ -600,7 +587,7 @@ function addSummaryBox(
const warningLines = showNoReceiptWarning
? [
`WARNING: ${totals.noReceiptCount} expense${totals.noReceiptCount === 1 ? '' : 's'} on this report ${totals.noReceiptCount === 1 ? 'has' : 'have'} no receipt attached`,
`(${sym}${totals.noReceiptAmount.toFixed(2)} at risk of being denied reimbursement).`,
`(${formatCurrency(totals.noReceiptAmount, totals.targetCurrency)} at risk of being denied reimbursement).`,
]
: [];
@@ -663,7 +650,6 @@ function addExpenseTable(
doc.fontSize(14).font('Helvetica-Bold').text('Expense details');
doc.moveDown(0.4);
const sym = currencySymbol(opts.targetCurrency);
const baseColumns: Column[] = [
{ header: 'Date', width: 60, x: 60 },
{ header: 'Establishment', width: 110, x: 120 },
@@ -707,7 +693,7 @@ function addExpenseTable(
}
doc.fillColor('#000000').fontSize(8).font('Helvetica');
const date = row.expenseDate.toISOString().slice(0, 10);
const amount = `${sym}${row.amountTarget.toFixed(2)}`;
const amount = formatCurrency(row.amountTarget, opts.targetCurrency);
// Annotate the establishment cell with a red "(no receipt)" marker
// when the rep created the expense without proof. This keeps the
// warning glanceable per row without adding a new column.
@@ -760,7 +746,7 @@ function addExpenseTable(
.fontSize(9)
.font('Helvetica-Bold')
.text(
`${group.key} (${group.rows.length} expense${group.rows.length === 1 ? '' : 's'}${sym}${groupTotal.toFixed(2)})`,
`${group.key} (${group.rows.length} expense${group.rows.length === 1 ? '' : 's'}${formatCurrency(groupTotal, opts.targetCurrency)})`,
65,
doc.y + 5,
{ width: doc.page.width - 130 },
@@ -791,7 +777,6 @@ async function addReceiptPages(
);
const backend = await getStorageBackend();
const sym = currencySymbol(opts.targetCurrency);
let receiptCounter = 0;
let resizedCount = 0;
@@ -818,7 +803,7 @@ async function addReceiptPages(
expense,
receiptCounter,
totalReceipts,
sym,
opts.targetCurrency,
'Receipt file metadata missing',
);
continue;
@@ -837,7 +822,7 @@ async function addReceiptPages(
// Page header
doc.addPage();
renderReceiptHeader(doc, expense, file, receiptCounter, totalReceipts, sym);
renderReceiptHeader(doc, expense, file, receiptCounter, totalReceipts, opts.targetCurrency);
// Embed the image full-bleed in the remaining vertical space.
const margin = 60;
@@ -881,7 +866,7 @@ async function addReceiptPages(
expense,
receiptCounter,
totalReceipts,
sym,
opts.targetCurrency,
(err as Error).message ?? 'Receipt could not be loaded from storage',
);
} finally {
@@ -909,7 +894,7 @@ function renderReceiptHeader(
file: ResolvedFile,
index: number,
total: number,
sym: string,
currency: string,
) {
const margin = 60;
const headerH = 90;
@@ -936,7 +921,7 @@ function renderReceiptHeader(
.fontSize(11)
.font('Helvetica-Bold')
.text(
`${expense.establishmentName ?? '—'} ${sym}${expense.amountTarget.toFixed(2)}`,
`${expense.establishmentName ?? '—'} ${formatCurrency(expense.amountTarget, currency)}`,
margin + 10,
baseY + 36,
);
@@ -962,7 +947,7 @@ function addReceiptErrorPage(
expense: ProcessedExpense,
index: number,
total: number,
sym: string,
currency: string,
message: string,
) {
doc.addPage();
@@ -970,9 +955,10 @@ function addReceiptErrorPage(
doc
.fontSize(11)
.font('Helvetica')
.text(`${expense.establishmentName ?? '—'} ${sym}${expense.amountTarget.toFixed(2)}`, {
align: 'center',
});
.text(
`${expense.establishmentName ?? '—'} ${formatCurrency(expense.amountTarget, currency)}`,
{ align: 'center' },
);
doc.moveDown(2);
doc.fontSize(11).fillColor('#dc3545').text(message, { align: 'center' });
doc.fillColor('#000000');