feat(ar-ledger): enhance outstanding invoice calculations and add tests for credited invoices (#395)

This commit is contained in:
Mattsson
2026-05-05 21:22:26 +02:00
committed by GitHub
parent b05f0e59b9
commit e31e7567f4
3 changed files with 113 additions and 16 deletions
+5 -3
View File
@@ -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 (
+88
View File
@@ -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 = [
{
+20 -13
View File
@@ -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,
}
}