Initial commit: FunZone accounting frontend with Sepidar-inspired RTL modules
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
210
src/utils/accounting.ts
Normal file
210
src/utils/accounting.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user