From e029376c08d7688861827352cca849ac8875bcc6 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:58:38 +0200 Subject: [PATCH] fix(suppliers): per-currency stats and honest currency labels on the supplier page (#1177) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(suppliers): per-currency stats and honest currency labels on the supplier page Fixes #1169. The supplier detail page stamped "kr" on every invoice amount although supplier_invoices carry their own currency, and the stats endpoint summed remaining/paid amounts across currencies into one number: a EUR + SEK supplier got a meaningless mixed total. GET /api/suppliers/[id] now groups total_outstanding/total_paid per invoice currency (öre-rounded per the money-math rule) and the page renders one line per currency, "kr" only for SEK. Table cells and the mobile Kvar line carry the invoice's currency the same way. Co-Authored-By: Claude Fable 5 * fix(suppliers): use roundOre from lib/money (antipattern ratchet) The naive Math.round(x * 100) / 100 form is blocked by check:guards (subtly wrong on exact-half values); lib/money.roundOre is canonical. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- app/(dashboard)/suppliers/[id]/page.tsx | 38 +++++-- .../suppliers/[id]/__tests__/route.test.ts | 104 ++++++++++++++++++ app/api/suppliers/[id]/route.ts | 38 ++++--- messages/en.json | 2 +- messages/sv.json | 2 +- 5 files changed, 162 insertions(+), 22 deletions(-) create mode 100644 app/api/suppliers/[id]/__tests__/route.test.ts diff --git a/app/(dashboard)/suppliers/[id]/page.tsx b/app/(dashboard)/suppliers/[id]/page.tsx index 1bfa4496..21bbed32 100644 --- a/app/(dashboard)/suppliers/[id]/page.tsx +++ b/app/(dashboard)/suppliers/[id]/page.tsx @@ -23,13 +23,29 @@ function formatAmount(amount: number): string { return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) } +// Supplier invoices carry their own currency; "kr" is only correct for SEK. +function amountWithCurrency(amount: number, currency?: string | null): string { + return `${formatAmount(amount)} ${!currency || currency === 'SEK' ? 'kr' : currency}` +} + +interface SupplierCurrencyStats { + currency: string + total_outstanding: number + total_paid: number +} + +interface SupplierStats { + invoice_count: number + by_currency: SupplierCurrencyStats[] +} + export default function SupplierDetailPage() { const { canWrite } = useCanWrite() const params = useParams() const router = useRouter() const { toast } = useToast() const t = useTranslations('supplier_detail') - const [supplier, setSupplier] = useState(null) + const [supplier, setSupplier] = useState(null) const [invoices, setInvoices] = useState([]) const [isLoading, setIsLoading] = useState(true) const [isEditOpen, setIsEditOpen] = useState(false) @@ -191,7 +207,11 @@ export default function SupplierDetailPage() { {t('outstanding')} -

{formatAmount(supplier.stats?.total_outstanding || 0)} kr

+ {(supplier.stats?.by_currency?.length ? supplier.stats.by_currency : [{ currency: supplier.default_currency || 'SEK', total_outstanding: 0, total_paid: 0 }]).map((row) => ( +

+ {amountWithCurrency(row.total_outstanding, row.currency)} +

+ ))}
@@ -199,7 +219,11 @@ export default function SupplierDetailPage() { {t('total_paid')} -

{formatAmount(supplier.stats?.total_paid || 0)} kr

+ {(supplier.stats?.by_currency?.length ? supplier.stats.by_currency : [{ currency: supplier.default_currency || 'SEK', total_outstanding: 0, total_paid: 0 }]).map((row) => ( +

+ {amountWithCurrency(row.total_paid, row.currency)} +

+ ))}
@@ -285,8 +309,8 @@ export default function SupplierDetailPage() { {formatDate(inv.invoice_date)} {formatDate(inv.due_date)} - {formatAmount(inv.total)} kr - {formatAmount(inv.remaining_amount)} kr + {amountWithCurrency(inv.total, inv.currency)} + {amountWithCurrency(inv.remaining_amount, inv.currency)} {statusLabels[inv.status] || inv.status} @@ -311,11 +335,11 @@ export default function SupplierDetailPage() {
{formatDate(inv.invoice_date)} → {formatDate(inv.due_date)} - {formatAmount(inv.total)} kr + {amountWithCurrency(inv.total, inv.currency)}
{Number(inv.remaining_amount) > 0 && Number(inv.remaining_amount) !== Number(inv.total) && (
- {t('remaining_inline', { amount: formatAmount(inv.remaining_amount) })} + {t('remaining_inline', { amount: amountWithCurrency(inv.remaining_amount, inv.currency) })}
)} diff --git a/app/api/suppliers/[id]/__tests__/route.test.ts b/app/api/suppliers/[id]/__tests__/route.test.ts new file mode 100644 index 00000000..2a506d56 --- /dev/null +++ b/app/api/suppliers/[id]/__tests__/route.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + parseJsonResponse, + createMockRouteParams, + createQueuedMockSupabase, +} from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +import { GET } from '../route' + +const SUPPLIER = { + id: 'sup-1', + company_id: 'company-1', + name: 'Odin Aero GmbH', + default_currency: 'EUR', +} + +describe('GET /api/suppliers/[id]', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + }) + + it('returns 401 when not authenticated', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + + const request = new Request('http://localhost/api/suppliers/sup-1') + const response = await GET(request, createMockRouteParams({ id: 'sup-1' })) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(401) + }) + + it('returns 404 when the supplier does not exist', async () => { + enqueue({ data: null, error: { message: 'not found' } }) + + const request = new Request('http://localhost/api/suppliers/sup-1') + const response = await GET(request, createMockRouteParams({ id: 'sup-1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(404) + expect(body.error.code).toBe('SUPPLIER_NOT_FOUND') + }) + + it('groups stats per invoice currency instead of summing across currencies', async () => { + enqueue({ data: SUPPLIER, error: null }) + enqueue({ + data: [ + // Amounts are invoice-currency: a EUR + SEK mix must never collapse + // into one number. + { status: 'pending', total: 1000, remaining_amount: 600, paid_amount: 400, currency: 'EUR' }, + { status: 'paid', total: 500, remaining_amount: 0, paid_amount: 500, currency: 'EUR' }, + { status: 'pending', total: 2000, remaining_amount: 2000, paid_amount: 0, currency: 'SEK' }, + // Paid invoices contribute to total_paid but not outstanding. + { status: 'credited', total: 300, remaining_amount: 300, paid_amount: 0, currency: 'SEK' }, + ], + error: null, + }) + + const request = new Request('http://localhost/api/suppliers/sup-1') + const response = await GET(request, createMockRouteParams({ id: 'sup-1' })) + const { status, body } = await parseJsonResponse<{ + data: { stats: { invoice_count: number; by_currency: unknown[] } } + }>(response) + + expect(status).toBe(200) + expect(body.data.stats.invoice_count).toBe(4) + expect(body.data.stats.by_currency).toEqual([ + { currency: 'EUR', total_outstanding: 600, total_paid: 900 }, + { currency: 'SEK', total_outstanding: 2000, total_paid: 0 }, + ]) + }) + + it('defaults a missing invoice currency to SEK', async () => { + enqueue({ data: SUPPLIER, error: null }) + enqueue({ + data: [{ status: 'pending', total: 100, remaining_amount: 100.005, paid_amount: 0, currency: null }], + error: null, + }) + + const request = new Request('http://localhost/api/suppliers/sup-1') + const response = await GET(request, createMockRouteParams({ id: 'sup-1' })) + const { body } = await parseJsonResponse<{ + data: { stats: { by_currency: { currency: string; total_outstanding: number }[] } } + }>(response) + + // Also pins the öre rounding (roundOre from lib/money). + expect(body.data.stats.by_currency).toEqual([ + { currency: 'SEK', total_outstanding: 100.01, total_paid: 0 }, + ]) + }) +}) diff --git a/app/api/suppliers/[id]/route.ts b/app/api/suppliers/[id]/route.ts index 7b74cd62..ec08377c 100644 --- a/app/api/suppliers/[id]/route.ts +++ b/app/api/suppliers/[id]/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server' +import { roundOre } from '@/lib/money' import { validateBody } from '@/lib/api/validate' import { UpdateSupplierSchema } from '@/lib/api/schemas' import { withRouteContext } from '@/lib/api/with-route-context' @@ -25,24 +26,35 @@ export const GET = withRouteContext( const { data: invoices } = await supabase .from('supplier_invoices') - .select('status, total, remaining_amount, paid_amount') + .select('status, total, remaining_amount, paid_amount, currency') .eq('supplier_id', id) .eq('company_id', companyId) - const stats = { - total_outstanding: 0, - total_paid: 0, - invoice_count: 0, + // Amounts are in each invoice's own currency, so a single sum across a + // mixed-currency supplier would be meaningless: group per currency instead + // (nearly every supplier has exactly one). + const perCurrency = new Map() + if (invoices) { + for (const inv of invoices) { + const currency = inv.currency || 'SEK' + const row = perCurrency.get(currency) ?? { total_outstanding: 0, total_paid: 0 } + if (inv.status !== 'paid' && inv.status !== 'credited') { + row.total_outstanding += inv.remaining_amount || 0 + } + row.total_paid += inv.paid_amount || 0 + perCurrency.set(currency, row) + } } - if (invoices) { - stats.invoice_count = invoices.length - for (const inv of invoices) { - if (inv.status !== 'paid' && inv.status !== 'credited') { - stats.total_outstanding += inv.remaining_amount || 0 - } - stats.total_paid += inv.paid_amount || 0 - } + const stats = { + invoice_count: invoices?.length ?? 0, + by_currency: [...perCurrency.entries()] + .map(([currency, row]) => ({ + currency, + total_outstanding: roundOre(row.total_outstanding), + total_paid: roundOre(row.total_paid), + })) + .sort((a, b) => a.currency.localeCompare(b.currency)), } return NextResponse.json({ data: { ...supplier, stats } }) diff --git a/messages/en.json b/messages/en.json index 920d31d0..c4043ece 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3733,7 +3733,7 @@ "col_amount": "Amount", "col_remaining": "Remaining", "col_status": "Status", - "remaining_inline": "Remaining: {amount} kr", + "remaining_inline": "Remaining: {amount}", "status_registered": "Registered", "status_approved": "Approved", "status_paid": "Paid", diff --git a/messages/sv.json b/messages/sv.json index 161b0b0f..33b54fa1 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -3733,7 +3733,7 @@ "col_amount": "Belopp", "col_remaining": "Kvar", "col_status": "Status", - "remaining_inline": "Kvar: {amount} kr", + "remaining_inline": "Kvar: {amount}", "status_registered": "Registrerad", "status_approved": "Godkänd", "status_paid": "Betald",