feat(ar-ledger): enhance outstanding invoice calculations and add tests for credited invoices (#395)
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user