211 lines
5.6 KiB
TypeScript
211 lines
5.6 KiB
TypeScript
import type {
|
|
Account,
|
|
AccountType,
|
|
Invoice,
|
|
InvoiceLine,
|
|
Payslip,
|
|
Voucher,
|
|
} from '../types'
|
|
|
|
export interface LineTotals {
|
|
gross: number
|
|
discount: number
|
|
taxable: number
|
|
tax: number
|
|
net: number
|
|
}
|
|
|
|
/** Computes the monetary breakdown of a single invoice line. */
|
|
export function lineTotals(line: InvoiceLine): LineTotals {
|
|
const gross = line.quantity * line.unitPrice
|
|
const discount = Math.min(line.discount, gross)
|
|
const taxable = gross - discount
|
|
const tax = (taxable * line.taxRate) / 100
|
|
return { gross, discount, taxable, tax, net: taxable + tax }
|
|
}
|
|
|
|
export interface InvoiceTotals {
|
|
gross: number
|
|
discount: number
|
|
taxable: number
|
|
tax: number
|
|
net: number
|
|
}
|
|
|
|
/** Aggregates all line totals of an invoice. */
|
|
export function invoiceTotals(invoice: Invoice): InvoiceTotals {
|
|
return invoice.lines.reduce<InvoiceTotals>(
|
|
(acc, line) => {
|
|
const t = lineTotals(line)
|
|
return {
|
|
gross: acc.gross + t.gross,
|
|
discount: acc.discount + t.discount,
|
|
taxable: acc.taxable + t.taxable,
|
|
tax: acc.tax + t.tax,
|
|
net: acc.net + t.net,
|
|
}
|
|
},
|
|
{ gross: 0, discount: 0, taxable: 0, tax: 0, net: 0 },
|
|
)
|
|
}
|
|
|
|
export interface VoucherBalance {
|
|
debit: number
|
|
credit: number
|
|
balanced: boolean
|
|
}
|
|
|
|
/** Sums debit/credit of a voucher and reports whether it balances. */
|
|
export function voucherBalance(voucher: Voucher): VoucherBalance {
|
|
const debit = voucher.lines.reduce((sum, l) => sum + l.debit, 0)
|
|
const credit = voucher.lines.reduce((sum, l) => sum + l.credit, 0)
|
|
return { debit, credit, balanced: Math.abs(debit - credit) < 0.001 && debit > 0 }
|
|
}
|
|
|
|
export interface AccountBalance {
|
|
account: Account
|
|
debit: number
|
|
credit: number
|
|
balance: number
|
|
}
|
|
|
|
const isDebitNature = (type: AccountType): boolean =>
|
|
type === 'asset' || type === 'expense'
|
|
|
|
/**
|
|
* Builds a trial balance from posted vouchers and account opening balances.
|
|
* Only leaf (non-group) accounts carry balances.
|
|
*/
|
|
export function trialBalance(
|
|
accounts: Account[],
|
|
vouchers: Voucher[],
|
|
): AccountBalance[] {
|
|
const totals = new Map<string, { debit: number; credit: number }>()
|
|
|
|
for (const account of accounts) {
|
|
if (account.isGroup) continue
|
|
const opening = account.openingBalance || 0
|
|
totals.set(account.id, {
|
|
debit: isDebitNature(account.type) ? Math.max(opening, 0) : Math.max(-opening, 0),
|
|
credit: isDebitNature(account.type) ? Math.max(-opening, 0) : Math.max(opening, 0),
|
|
})
|
|
}
|
|
|
|
for (const voucher of vouchers) {
|
|
if (voucher.status !== 'posted') continue
|
|
for (const line of voucher.lines) {
|
|
const entry = totals.get(line.accountId)
|
|
if (!entry) continue
|
|
entry.debit += line.debit
|
|
entry.credit += line.credit
|
|
}
|
|
}
|
|
|
|
return accounts
|
|
.filter((account) => !account.isGroup)
|
|
.map((account) => {
|
|
const entry = totals.get(account.id) ?? { debit: 0, credit: 0 }
|
|
const net = entry.debit - entry.credit
|
|
const balance = isDebitNature(account.type) ? net : -net
|
|
return { account, debit: entry.debit, credit: entry.credit, balance }
|
|
})
|
|
}
|
|
|
|
export interface LedgerRow {
|
|
voucherId: string
|
|
voucherNumber: number
|
|
date: string
|
|
description: string
|
|
debit: number
|
|
credit: number
|
|
running: number
|
|
}
|
|
|
|
/** Produces the running general-ledger movements for a single account. */
|
|
export function accountLedger(
|
|
accountId: string,
|
|
account: Account | undefined,
|
|
vouchers: Voucher[],
|
|
): LedgerRow[] {
|
|
let running = account?.openingBalance ?? 0
|
|
const rows: LedgerRow[] = []
|
|
|
|
const sorted = [...vouchers]
|
|
.filter((v) => v.status === 'posted')
|
|
.sort((a, b) => a.date.localeCompare(b.date) || a.number - b.number)
|
|
|
|
// Debit-nature accounts (asset, expense) increase with debits.
|
|
// Credit-nature accounts (liability, equity, income) increase with credits.
|
|
const debitNature = account ? isDebitNature(account.type) : true
|
|
|
|
for (const voucher of sorted) {
|
|
for (const line of voucher.lines) {
|
|
if (line.accountId !== accountId) continue
|
|
running += debitNature ? line.debit - line.credit : line.credit - line.debit
|
|
rows.push({
|
|
voucherId: voucher.id,
|
|
voucherNumber: voucher.number,
|
|
date: voucher.date,
|
|
description: line.description || voucher.description,
|
|
debit: line.debit,
|
|
credit: line.credit,
|
|
running,
|
|
})
|
|
}
|
|
}
|
|
|
|
return rows
|
|
}
|
|
|
|
export interface IncomeStatement {
|
|
income: number
|
|
expense: number
|
|
profit: number
|
|
}
|
|
|
|
export function incomeStatement(balances: AccountBalance[]): IncomeStatement {
|
|
const income = balances
|
|
.filter((b) => b.account.type === 'income')
|
|
.reduce((sum, b) => sum + b.balance, 0)
|
|
const expense = balances
|
|
.filter((b) => b.account.type === 'expense')
|
|
.reduce((sum, b) => sum + b.balance, 0)
|
|
return { income, expense, profit: income - expense }
|
|
}
|
|
|
|
export interface BalanceSheet {
|
|
assets: number
|
|
liabilities: number
|
|
equity: number
|
|
retainedEarnings: number
|
|
}
|
|
|
|
export function balanceSheet(balances: AccountBalance[]): BalanceSheet {
|
|
const sumByType = (type: AccountType): number =>
|
|
balances
|
|
.filter((b) => b.account.type === type)
|
|
.reduce((sum, b) => sum + b.balance, 0)
|
|
|
|
const income = sumByType('income')
|
|
const expense = sumByType('expense')
|
|
|
|
return {
|
|
assets: sumByType('asset'),
|
|
liabilities: sumByType('liability'),
|
|
equity: sumByType('equity'),
|
|
retainedEarnings: income - expense,
|
|
}
|
|
}
|
|
|
|
/** Net pay of a payslip: earnings minus all deductions. */
|
|
export function payslipNet(payslip: Payslip): number {
|
|
return (
|
|
payslip.baseSalary +
|
|
payslip.overtime +
|
|
payslip.bonus -
|
|
payslip.deductions -
|
|
payslip.insurance -
|
|
payslip.tax
|
|
)
|
|
}
|