Files
accounting-frontend/src/utils/accounting.ts
2026-07-01 00:29:25 +03:30

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
)
}