You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq-web/src/statement.ts

79 lines
2.9 KiB
TypeScript

import type { Doc, Payment } from './api'
/**
* One line of a client account statement: an invoice (debit), a credit note (credit)
* or a payment (credit), with the running balance AFTER the line is applied. Kept as a
* pure data shape so the running-balance maths can be unit-tested without the DOM.
*/
export interface StatementRow {
date: string
kind: 'invoice' | 'credit_note' | 'payment'
particulars: string
ref: string
/** Charge that raises what the client owes (0 when this row is a credit). */
debitPaise: number
/** Credit that lowers what the client owes (0 when this row is a debit). */
creditPaise: number
/** Cumulative balance after this row — positive = owed by client, negative = in credit. */
balancePaise: number
}
export interface Statement {
rows: StatementRow[]
totalDebitPaise: number
totalCreditPaise: number
/** Final running balance: > 0 the client owes us, < 0 they are in credit (advance). */
closingPaise: number
}
/**
* Build a statement of account from the client's ledger. Only posted receivables move
* the balance: issued (numbered), non-cancelled INVOICEs are debits and CREDIT_NOTEs are
* credits; every payment is a credit (amount + TDS, matching how the ledger settles).
* Quotations, proformas, receipts and drafts are not account postings and are excluded.
* Rows are ordered by date, debits before credits on the same day (conventional).
*/
export function buildStatement(documents: Doc[], payments: Payment[]): Statement {
type Entry = { date: string; order: number; row: Omit<StatementRow, 'balancePaise'> }
const entries: Entry[] = []
for (const d of documents) {
if (d.docNo === null || d.status === 'cancelled') continue
if (d.docType === 'INVOICE') {
entries.push({
date: d.docDate, order: 0,
row: { date: d.docDate, kind: 'invoice', particulars: 'Invoice', ref: d.docNo, debitPaise: d.payablePaise, creditPaise: 0 },
})
} else if (d.docType === 'CREDIT_NOTE') {
entries.push({
date: d.docDate, order: 0,
row: { date: d.docDate, kind: 'credit_note', particulars: 'Credit note', ref: d.docNo, debitPaise: 0, creditPaise: d.payablePaise },
})
}
}
for (const p of payments) {
entries.push({
date: p.receivedOn, order: 1,
row: {
date: p.receivedOn, kind: 'payment',
particulars: `Payment (${p.mode})`, ref: p.reference,
debitPaise: 0, creditPaise: p.amountPaise + p.tdsPaise,
},
})
}
entries.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : a.order - b.order))
let balance = 0
let totalDebitPaise = 0
let totalCreditPaise = 0
const rows: StatementRow[] = entries.map((e) => {
balance += e.row.debitPaise - e.row.creditPaise
totalDebitPaise += e.row.debitPaise
totalCreditPaise += e.row.creditPaise
return { ...e.row, balancePaise: balance }
})
return { rows, totalDebitPaise, totalCreditPaise, closingPaise: balance }
}