From aead2bc1d1f1405da985ffe0359e3a0402de97b7 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:59:14 +0200 Subject: [PATCH] fix(ui): stop mislabeling unconverted FX amounts as kr in aggregates and toasts (#1182) Fixes #1173. invoices.total_sek stays NULL when the Riksbanken rate fetch fails at creation, and every `total_sek || total` fallback then treated a raw foreign amount as kronor: - lib/calendar/utils: new invoiceSekAmount() returns null for unconverted non-SEK invoices; period summaries and day totals skip them and PeriodSummary exposes unconvertedCount. PaymentSummaryCard shows a one-line note when invoices were excluded; CalendarDayView renders each invoice in its own currency instead. - Deadlines page: the overdue attn sum now skips unconverted FX invoices and appends "(+N i utlandsk valuta)" instead of adding EUR into a kr total. - Supplier-invoice payment toast formats the amount with the invoice's currency (key drops its hardcoded " kr" in both locales). - AR aging drill-down row labels Betalt with the invoice currency, mirroring the outstanding cell. - BankFileColumnMappingStep: comment pinning why SEK is safe there (generic-csv hardcodes it). Co-authored-by: Claude Fable 5 --- app/(dashboard)/deadlines/page.tsx | 27 ++++++--- .../supplier-invoices/[id]/page.tsx | 6 +- .../import/BankFileColumnMappingStep.tsx | 3 + components/reports/views/index.tsx | 2 +- .../calendar/components/CalendarDayView.tsx | 15 ++++- .../components/PaymentSummaryCard.tsx | 10 ++++ lib/calendar/__tests__/utils.test.ts | 57 +++++++++++++++++++ lib/calendar/utils.ts | 28 +++++++-- messages/en.json | 3 +- messages/sv.json | 3 +- 10 files changed, 133 insertions(+), 21 deletions(-) create mode 100644 lib/calendar/__tests__/utils.test.ts diff --git a/app/(dashboard)/deadlines/page.tsx b/app/(dashboard)/deadlines/page.tsx index 0de78744..1efcc1cf 100644 --- a/app/(dashboard)/deadlines/page.tsx +++ b/app/(dashboard)/deadlines/page.tsx @@ -30,7 +30,7 @@ export default function DeadlinesPage() { const { canWrite } = useCanWrite() const [deadlines, setDeadlines] = useState([]) const [customers, setCustomers] = useState<{ id: string; name: string }[]>([]) - const [overdueInvoices, setOverdueInvoices] = useState<{ count: number; total: number }>({ count: 0, total: 0 }) + const [overdueInvoices, setOverdueInvoices] = useState<{ count: number; total: number; unconverted: number }>({ count: 0, total: 0, unconverted: 0 }) const [isLoading, setIsLoading] = useState(true) const [isGenerating, setIsGenerating] = useState(false) const [showForm, setShowForm] = useState(false) @@ -68,10 +68,10 @@ export default function DeadlinesPage() { .order('id', { ascending: true }) .range(from, to), ), - fetchAllRows<{ total_sek: number | null; total: number | null }>(({ from, to }) => + fetchAllRows<{ total_sek: number | null; total: number | null; currency: string | null }>(({ from, to }) => supabase .from('invoices') - .select('total_sek, total') + .select('total_sek, total, currency') .eq('company_id', companyId) .in('status', ['sent', 'unpaid']) .lt('due_date', today) @@ -80,14 +80,20 @@ export default function DeadlinesPage() { ), ]) - const overdueTotal = overdueRows.reduce( - (sum, inv) => sum + (inv.total_sek || inv.total || 0), - 0 - ) + // Non-SEK invoices without a stored SEK conversion (rate fetch failed at + // creation) are excluded from the SEK sum rather than mixed in raw, and + // surfaced as a count in the attn line instead. + let overdueTotal = 0 + let unconvertedCount = 0 + for (const inv of overdueRows) { + if (inv.total_sek != null) overdueTotal += inv.total_sek + else if (!inv.currency || inv.currency === 'SEK') overdueTotal += inv.total || 0 + else unconvertedCount++ + } setDeadlines(deadlineRows) setCustomers(customerRows) - setOverdueInvoices({ count: overdueRows.length, total: overdueTotal }) + setOverdueInvoices({ count: overdueRows.length, total: overdueTotal, unconverted: unconvertedCount }) } catch { toast({ title: t('load_failed_title'), @@ -364,7 +370,10 @@ export default function DeadlinesPage() { action={{ label: t('overdue_invoices_action'), href: '/invoices?status=unpaid' }} > {t('overdue_invoices', { count: overdueInvoices.count })} ·{' '} - {formatCurrency(overdueInvoices.total)}. + {formatCurrency(overdueInvoices.total)} + {overdueInvoices.unconverted > 0 + ? ` ${t('overdue_invoices_fx', { count: overdueInvoices.unconverted })}` + : ''}. ) : null} diff --git a/app/(dashboard)/supplier-invoices/[id]/page.tsx b/app/(dashboard)/supplier-invoices/[id]/page.tsx index 86e68332..3b699c4f 100644 --- a/app/(dashboard)/supplier-invoices/[id]/page.tsx +++ b/app/(dashboard)/supplier-invoices/[id]/page.tsx @@ -340,7 +340,11 @@ export default function SupplierInvoiceDetailPage() { } else { toast({ title: result.status === 'paid' ? t('paid_title') : t('partial_payment_title'), - description: t('amount_registered_description', { amount: formatAmount(parseFloat(payAmount)) }), + // The paid amount is in the invoice's currency (the dialog's helper + // text says so): the toast must not relabel it as kr. + description: t('amount_registered_description', { + amount: formatCurrency(parseFloat(payAmount), invoice?.currency || 'SEK'), + }), }) setIsPayDialogOpen(false) setDuplicateCandidates(null) diff --git a/components/import/BankFileColumnMappingStep.tsx b/components/import/BankFileColumnMappingStep.tsx index f9174587..43be8337 100644 --- a/components/import/BankFileColumnMappingStep.tsx +++ b/components/import/BankFileColumnMappingStep.tsx @@ -408,6 +408,9 @@ export default function BankFileColumnMappingStep({ !isNaN(amount) && amount >= 0 ? 'text-success' : 'text-destructive' }`} > + {/* SEK is safe here ONLY because generic-csv hardcodes + currency: 'SEK' (formats/generic-csv.ts). If the + mapper ever gains a currency column, pass it. */} {!isNaN(amount) ? formatCurrency(amount) : amountStr} diff --git a/components/reports/views/index.tsx b/components/reports/views/index.tsx index 22936a55..f81bc319 100644 --- a/components/reports/views/index.tsx +++ b/components/reports/views/index.tsx @@ -2999,7 +2999,7 @@ function ARCustomerInvoiceRows({ {inv.days_overdue > 0 ? `${inv.days_overdue} dagar förfallen` : 'Ej förfallen'} - {inv.paid_amount > 0 ? `Betalt: ${formatAmount(inv.paid_amount)}` : ''} + {inv.paid_amount > 0 ? `Betalt: ${formatAmount(inv.paid_amount)} ${inv.currency}` : ''} diff --git a/extensions/general/calendar/components/CalendarDayView.tsx b/extensions/general/calendar/components/CalendarDayView.tsx index 3f4cee69..3bcb0d01 100644 --- a/extensions/general/calendar/components/CalendarDayView.tsx +++ b/extensions/general/calendar/components/CalendarDayView.tsx @@ -22,6 +22,17 @@ interface CalendarDayViewProps { onAddDeadline: (date: Date) => void } +// Per-invoice amount label. Shows the SEK conversion when one exists; +// otherwise the invoice's own amount in its own currency: total_sek is NULL +// for non-SEK invoices whose rate fetch failed, and labelling the raw foreign +// amount "kr" would misstate it. +function invoiceAmountLabel(invoice: Invoice): string { + if (invoice.total_sek != null || !invoice.currency || invoice.currency === 'SEK') { + return `${(invoice.total_sek ?? invoice.total).toLocaleString('sv-SE')} kr` + } + return `${invoice.total.toLocaleString('sv-SE')} ${invoice.currency}` +} + export function CalendarDayView({ date, invoices, @@ -105,7 +116,7 @@ export function CalendarDayView({ )}
- {invoice.customer?.name} • {(invoice.total_sek || invoice.total).toLocaleString('sv-SE')} kr + {invoice.customer?.name} • {invoiceAmountLabel(invoice)}
@@ -132,7 +143,7 @@ export function CalendarDayView({
- {invoice.customer?.name} • {(invoice.total_sek || invoice.total).toLocaleString('sv-SE')} kr + {invoice.customer?.name} • {invoiceAmountLabel(invoice)}
diff --git a/extensions/general/calendar/components/PaymentSummaryCard.tsx b/extensions/general/calendar/components/PaymentSummaryCard.tsx index 5dc9ddf7..b92243f3 100644 --- a/extensions/general/calendar/components/PaymentSummaryCard.tsx +++ b/extensions/general/calendar/components/PaymentSummaryCard.tsx @@ -92,6 +92,16 @@ export function PaymentSummaryCard({ invoices, year, month }: PaymentSummaryCard

)} + + {/* Foreign-currency invoices without a stored SEK conversion are + excluded from the sums above rather than silently mixed in. */} + {summary.unconvertedCount > 0 && ( +

+ {summary.unconvertedCount === 1 + ? '1 faktura i utländsk valuta utan växelkurs ingår inte i beloppen.' + : `${summary.unconvertedCount} fakturor i utländsk valuta utan växelkurs ingår inte i beloppen.`} +

+ )} ) diff --git a/lib/calendar/__tests__/utils.test.ts b/lib/calendar/__tests__/utils.test.ts new file mode 100644 index 00000000..406a1b9b --- /dev/null +++ b/lib/calendar/__tests__/utils.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest' +import { invoiceSekAmount, calculatePeriodSummary, createPaymentCalendarDay } from '../utils' +import { makeInvoice } from '@/tests/helpers' + +describe('invoiceSekAmount', () => { + it('prefers the stored SEK conversion', () => { + const inv = makeInvoice({ total: 100, total_sek: 1150, currency: 'EUR' }) + expect(invoiceSekAmount(inv)).toBe(1150) + }) + + it('uses total directly for SEK invoices without a conversion', () => { + const inv = makeInvoice({ total: 100, total_sek: null, currency: 'SEK' }) + expect(invoiceSekAmount(inv)).toBe(100) + }) + + it('returns null for a non-SEK invoice without a stored conversion', () => { + // total_sek stays NULL when the rate fetch failed at creation: the raw + // EUR total must never be treated as kronor. + const inv = makeInvoice({ total: 100, total_sek: null, currency: 'EUR' }) + expect(invoiceSekAmount(inv)).toBeNull() + }) +}) + +describe('calculatePeriodSummary', () => { + it('excludes unconverted foreign invoices from totals and counts them', () => { + const past = '2000-01-01' + const invoices = [ + makeInvoice({ status: 'sent', due_date: past, total: 1000, total_sek: null, currency: 'SEK' }), + // Unconverted EUR invoice: counted, never summed as kr. + makeInvoice({ status: 'sent', due_date: past, total: 500, total_sek: null, currency: 'EUR' }), + makeInvoice({ status: 'paid', total: 200, total_sek: 2300, currency: 'EUR' }), + ] + + const summary = calculatePeriodSummary(invoices) + + expect(summary.totalExpected).toBe(1000) + expect(summary.totalOverdue).toBe(1000) + expect(summary.totalPaid).toBe(2300) + expect(summary.pendingCount).toBe(2) + expect(summary.unconvertedCount).toBe(1) + }) +}) + +describe('createPaymentCalendarDay', () => { + it('skips unconverted foreign invoices in the day total', () => { + const date = '2026-07-25' + const invoices = [ + makeInvoice({ status: 'sent', due_date: date, total: 1000, total_sek: null, currency: 'SEK' }), + makeInvoice({ status: 'sent', due_date: date, total: 500, total_sek: null, currency: 'EUR' }), + ] + + const day = createPaymentCalendarDay(date, invoices) + + expect(day.totalExpected).toBe(1000) + expect(day.invoices).toHaveLength(2) + }) +}) diff --git a/lib/calendar/utils.ts b/lib/calendar/utils.ts index 8ca9d1d4..21d3429b 100644 --- a/lib/calendar/utils.ts +++ b/lib/calendar/utils.ts @@ -131,13 +131,24 @@ export function groupDeadlinesByDate(deadlines: Deadline[]): Map inv.due_date === date) const overdueCount = dayInvoices.filter(isInvoiceOverdue).length const totalExpected = dayInvoices .filter(inv => inv.status !== 'paid' && inv.status !== 'cancelled' && inv.status !== 'credited') - .reduce((sum, inv) => sum + (inv.total_sek || inv.total), 0) + .reduce((sum, inv) => sum + (invoiceSekAmount(inv) ?? 0), 0) return { date, @@ -171,6 +182,8 @@ export interface PeriodSummary { overdueCount: number pendingCount: number paidCount: number + /** Non-SEK invoices without a stored SEK conversion, excluded from the totals. */ + unconvertedCount: number } export function calculatePeriodSummary(invoices: Invoice[]): PeriodSummary { @@ -180,19 +193,21 @@ export function calculatePeriodSummary(invoices: Invoice[]): PeriodSummary { let overdueCount = 0 let pendingCount = 0 let paidCount = 0 + let unconvertedCount = 0 for (const invoice of invoices) { - const amount = invoice.total_sek || invoice.total + const amount = invoiceSekAmount(invoice) + if (amount == null) unconvertedCount++ if (invoice.status === 'paid') { - totalPaid += amount + totalPaid += amount ?? 0 paidCount++ } else if (invoice.status !== 'cancelled' && invoice.status !== 'credited') { - totalExpected += amount + totalExpected += amount ?? 0 pendingCount++ if (isInvoiceOverdue(invoice)) { - totalOverdue += amount + totalOverdue += amount ?? 0 overdueCount++ } } @@ -204,7 +219,8 @@ export function calculatePeriodSummary(invoices: Invoice[]): PeriodSummary { totalPaid, overdueCount, pendingCount, - paidCount + paidCount, + unconvertedCount } } diff --git a/messages/en.json b/messages/en.json index c4043ece..4f05a42c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -575,6 +575,7 @@ "generating": "Generating…", "help_text": "Deadlines for VAT, employer declarations and F-tax are generated automatically from your company's tax settings. Add your own with New deadline; click a row to edit it.", "overdue_invoices_action": "View the invoices", + "overdue_invoices_fx": "(+{count} in foreign currency)", "seg_all": "All", "seg_tax": "Tax", "seg_invoicing": "Invoicing", @@ -3601,7 +3602,7 @@ "payment_failed_title": "Payment failed", "paid_title": "Paid", "partial_payment_title": "Partial payment registered", - "amount_registered_description": "{amount} kr registered", + "amount_registered_description": "{amount} registered", "credit_confirm_title": "Register credit note", "credit_confirm_description": "A credit note is created that reverses the original invoice. This action cannot be undone.", "credit_confirm_label": "Register credit note", diff --git a/messages/sv.json b/messages/sv.json index 33b54fa1..d76c88c3 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -575,6 +575,7 @@ "generating": "Genererar…", "help_text": "Deadlines för moms, arbetsgivardeklaration och F-skatt skapas automatiskt från företagets skatteinställningar. Egna deadlines lägger du till med Ny deadline; klicka på en rad för att ändra den.", "overdue_invoices_action": "Visa fakturorna", + "overdue_invoices_fx": "(+{count} i utländsk valuta)", "seg_all": "Alla", "seg_tax": "Skatt", "seg_invoicing": "Fakturering", @@ -3601,7 +3602,7 @@ "payment_failed_title": "Betalning misslyckades", "paid_title": "Betald", "partial_payment_title": "Delbetalning registrerad", - "amount_registered_description": "{amount} kr registrerat", + "amount_registered_description": "{amount} registrerat", "credit_confirm_title": "Registrera kreditfaktura", "credit_confirm_description": "En kreditfaktura skapas som reverserar den ursprungliga fakturan. Denna åtgärd kan inte ångras.", "credit_confirm_label": "Registrera kreditfaktura",