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:
co-authored by
Claude Fable 5
parent
4bc2093e51
commit
e029376c08
@@ -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 },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user