fix(suppliers): per-currency stats and honest currency labels on the supplier page (#1177)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-25 12:58:38 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 4bc2093e51
commit e029376c08
5 changed files with 162 additions and 22 deletions
+31 -7
View File
@@ -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<Supplier & { stats?: { total_outstanding: number; total_paid: number; invoice_count: number } } | null>(null)
const [supplier, setSupplier] = useState<Supplier & { stats?: SupplierStats } | null>(null)
const [invoices, setInvoices] = useState<SupplierInvoice[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isEditOpen, setIsEditOpen] = useState(false)
@@ -191,7 +207,11 @@ export default function SupplierDetailPage() {
<CardTitle className="text-sm text-muted-foreground">{t('outstanding')}</CardTitle>
</CardHeader>
<CardContent>
<p className="font-display text-2xl tabular-nums">{formatAmount(supplier.stats?.total_outstanding || 0)} kr</p>
{(supplier.stats?.by_currency?.length ? supplier.stats.by_currency : [{ currency: supplier.default_currency || 'SEK', total_outstanding: 0, total_paid: 0 }]).map((row) => (
<p key={row.currency} className="font-display text-2xl tabular-nums">
{amountWithCurrency(row.total_outstanding, row.currency)}
</p>
))}
</CardContent>
</Card>
<Card>
@@ -199,7 +219,11 @@ export default function SupplierDetailPage() {
<CardTitle className="text-sm text-muted-foreground">{t('total_paid')}</CardTitle>
</CardHeader>
<CardContent>
<p className="font-display text-2xl tabular-nums">{formatAmount(supplier.stats?.total_paid || 0)} kr</p>
{(supplier.stats?.by_currency?.length ? supplier.stats.by_currency : [{ currency: supplier.default_currency || 'SEK', total_outstanding: 0, total_paid: 0 }]).map((row) => (
<p key={row.currency} className="font-display text-2xl tabular-nums">
{amountWithCurrency(row.total_paid, row.currency)}
</p>
))}
</CardContent>
</Card>
<Card>
@@ -285,8 +309,8 @@ export default function SupplierDetailPage() {
</TableCell>
<TableCell className="tabular-nums">{formatDate(inv.invoice_date)}</TableCell>
<TableCell className="tabular-nums">{formatDate(inv.due_date)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(inv.total)} kr</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(inv.remaining_amount)} kr</TableCell>
<TableCell className="text-right tabular-nums">{amountWithCurrency(inv.total, inv.currency)}</TableCell>
<TableCell className="text-right tabular-nums">{amountWithCurrency(inv.remaining_amount, inv.currency)}</TableCell>
<TableCell>
<Badge variant={statusVariants[inv.status] || 'secondary'}>
{statusLabels[inv.status] || inv.status}
@@ -311,11 +335,11 @@ export default function SupplierDetailPage() {
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground tabular-nums">{formatDate(inv.invoice_date)} → {formatDate(inv.due_date)}</span>
<span className="font-mono">{formatAmount(inv.total)} kr</span>
<span className="font-mono">{amountWithCurrency(inv.total, inv.currency)}</span>
</div>
{Number(inv.remaining_amount) > 0 && Number(inv.remaining_amount) !== Number(inv.total) && (
<div className="text-xs text-muted-foreground text-right">
{t('remaining_inline', { amount: formatAmount(inv.remaining_amount) })}
{t('remaining_inline', { amount: amountWithCurrency(inv.remaining_amount, inv.currency) })}
</div>
)}
</div>
@@ -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 },
])
})
})
+25 -13
View File
@@ -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<string, { total_outstanding: number; total_paid: number }>()
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 } })
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",