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
@@ -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 } })
|
||||
|
||||
Reference in New Issue
Block a user