From e31e7567f4f18a757d855d93533072b6f7ca941a Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Tue, 5 May 2026 21:22:26 +0200 Subject: [PATCH] feat(ar-ledger): enhance outstanding invoice calculations and add tests for credited invoices (#395) --- app/(dashboard)/invoices/page.tsx | 8 ++- lib/reports/__tests__/ar-ledger.test.ts | 88 +++++++++++++++++++++++++ lib/reports/ar-ledger.ts | 33 ++++++---- 3 files changed, 113 insertions(+), 16 deletions(-) diff --git a/app/(dashboard)/invoices/page.tsx b/app/(dashboard)/invoices/page.tsx index 58252d5c..895af75d 100644 --- a/app/(dashboard)/invoices/page.tsx +++ b/app/(dashboard)/invoices/page.tsx @@ -104,12 +104,14 @@ export default function InvoicesPage() { return matchesSearch && matchesTab }) + const isOutstandingReceivable = (i: Invoice) => + ['sent', 'overdue'].includes(i.status) && !i.credited_invoice_id const stats = { - unpaid: invoices.filter((i) => ['sent', 'overdue'].includes(i.status)).length, + unpaid: invoices.filter(isOutstandingReceivable).length, unpaidAmount: invoices - .filter((i) => ['sent', 'overdue'].includes(i.status)) + .filter(isOutstandingReceivable) .reduce((sum, i) => sum + Number(i.total_sek || i.total), 0), - overdue: invoices.filter((i) => i.status === 'overdue').length, + overdue: invoices.filter((i) => i.status === 'overdue' && !i.credited_invoice_id).length, } return ( diff --git a/lib/reports/__tests__/ar-ledger.test.ts b/lib/reports/__tests__/ar-ledger.test.ts index 6bab7855..d6adf978 100644 --- a/lib/reports/__tests__/ar-ledger.test.ts +++ b/lib/reports/__tests__/ar-ledger.test.ts @@ -222,6 +222,94 @@ describe('generateARLedger', () => { expect(report.total_outstanding).toBe(66.77) }) + it('nets a credited invoice with its credit note to zero outstanding', async () => { + // Original was sent (unpaid) and then fully credited. + // Journal-level AR is 0; the ledger should match. + results = [ + { + data: [ + { + id: 'inv-1', + customer_id: 'cust-a', + customer: { id: 'cust-a', name: 'Test AB' }, + invoice_number: '2026001', + invoice_date: '2026-05-05', + due_date: '2026-06-05', + total: 1241.25, + paid_amount: 0, + currency: 'SEK', + status: 'credited', + }, + { + id: 'inv-2', + customer_id: 'cust-a', + customer: { id: 'cust-a', name: 'Test AB' }, + invoice_number: 'KR-2026001', + invoice_date: '2026-05-05', + due_date: '2026-05-05', + total: -1241.25, + paid_amount: 0, + currency: 'SEK', + status: 'sent', + credited_invoice_id: 'inv-1', + }, + ], + error: null, + }, + ] + + const report = await generateARLedger(supabase, 'company-1', '2026-05-05') + + expect(report.entries).toEqual([]) + expect(report.total_outstanding).toBe(0) + expect(report.total_current).toBe(0) + expect(report.total_overdue).toBe(0) + expect(report.unpaid_count).toBe(0) + }) + + it('keeps a credit note outstanding when it offsets an already-paid invoice', async () => { + // Original was paid in full, then credited — we owe the customer the refund. + results = [ + { + data: [ + { + id: 'inv-1', + customer_id: 'cust-a', + customer: { id: 'cust-a', name: 'Test AB' }, + invoice_number: '2026001', + invoice_date: '2026-04-01', + due_date: '2026-05-01', + total: 1000, + paid_amount: 1000, + currency: 'SEK', + status: 'credited', + }, + { + id: 'inv-2', + customer_id: 'cust-a', + customer: { id: 'cust-a', name: 'Test AB' }, + invoice_number: 'KR-2026001', + invoice_date: '2026-05-05', + due_date: '2026-05-05', + total: -1000, + paid_amount: 0, + currency: 'SEK', + status: 'sent', + credited_invoice_id: 'inv-1', + }, + ], + error: null, + }, + ] + + const report = await generateARLedger(supabase, 'company-1', '2026-05-05') + + expect(report.entries).toHaveLength(1) + expect(report.entries[0].total_outstanding).toBe(-1000) + expect(report.total_outstanding).toBe(-1000) + expect(report.unpaid_count).toBe(1) + }) + it('handles missing customer name gracefully', async () => { results = [ { diff --git a/lib/reports/ar-ledger.ts b/lib/reports/ar-ledger.ts index 96800e24..c9998a0c 100644 --- a/lib/reports/ar-ledger.ts +++ b/lib/reports/ar-ledger.ts @@ -53,7 +53,7 @@ export async function generateARLedger( .from('invoices') .select('*, customer:customers(id, name)') .eq('company_id', companyId) - .in('status', ['sent', 'overdue']) + .in('status', ['sent', 'overdue', 'credited']) .range(from, to) ) } catch { @@ -123,17 +123,20 @@ export async function generateARLedger( entry.total_outstanding += outstanding } - // Round all amounts and sort invoices within each customer - const entries = Array.from(byCustomer.values()).map((entry) => ({ - ...entry, - invoices: entry.invoices.sort((a, b) => a.due_date.localeCompare(b.due_date)), - current: Math.round(entry.current * 100) / 100, - days_1_30: Math.round(entry.days_1_30 * 100) / 100, - days_31_60: Math.round(entry.days_31_60 * 100) / 100, - days_61_90: Math.round(entry.days_61_90 * 100) / 100, - days_90_plus: Math.round(entry.days_90_plus * 100) / 100, - total_outstanding: Math.round(entry.total_outstanding * 100) / 100, - })) + // Round all amounts and sort invoices within each customer. + // Drop customers whose credit notes fully offset their open invoices (net 0). + const entries = Array.from(byCustomer.values()) + .map((entry) => ({ + ...entry, + invoices: entry.invoices.sort((a, b) => a.due_date.localeCompare(b.due_date)), + current: Math.round(entry.current * 100) / 100, + days_1_30: Math.round(entry.days_1_30 * 100) / 100, + days_31_60: Math.round(entry.days_31_60 * 100) / 100, + days_61_90: Math.round(entry.days_61_90 * 100) / 100, + days_90_plus: Math.round(entry.days_90_plus * 100) / 100, + total_outstanding: Math.round(entry.total_outstanding * 100) / 100, + })) + .filter((entry) => entry.total_outstanding !== 0) // Sort by total outstanding descending entries.sort((a, b) => b.total_outstanding - a.total_outstanding) @@ -141,12 +144,16 @@ export async function generateARLedger( const total_outstanding = entries.reduce((sum, e) => sum + e.total_outstanding, 0) const total_current = entries.reduce((sum, e) => sum + e.current, 0) const total_overdue = total_outstanding - total_current + const unpaid_count = entries.reduce( + (sum, e) => sum + e.invoices.filter((i) => i.outstanding !== 0).length, + 0 + ) return { entries, total_outstanding: Math.round(total_outstanding * 100) / 100, total_current: Math.round(total_current * 100) / 100, total_overdue: Math.round(total_overdue * 100) / 100, - unpaid_count: invoices.length, + unpaid_count, } }