import { createResolver, t } from "@tailor-platform/sdk"; import { Decimal } from "decimal.js"; import { getDB } from "@/generated/kysely-tailordb"; /** * BFF (backend-for-frontend) resolver: the trial balance report for a company. * * financial-accounting does not materialize GL balances, so this computes them * on the fly: sum the debit/credit amounts of every POSTED journal line for the * company (optionally scoped to one accounting period), grouped by GL account, * then classify each account into the Balance Sheet (ASSET/LIABILITY/EQUITY) or * Profit & Loss (REVENUE/EXPENSE) subtotals. Because posted entries always * balance, the grand total debit equals the grand total credit (`balanced`). */ type MainDbHandle = ReturnType>; type AccountType = "ASSET" | "LIABILITY" | "EQUITY" | "REVENUE" | "EXPENSE"; const BALANCE_SHEET_TYPES: ReadonlySet = new Set(["ASSET", "LIABILITY", "EQUITY"]); interface TrialBalanceLine { accountId: string; code: string; name: string; accountType: string; debit: string; credit: string; net: string; // debit − credit (positive = net debit balance) } interface TrialBalanceResult { totalDebit: string; totalCredit: string; balanced: boolean; balanceSheetDebit: string; balanceSheetCredit: string; profitAndLossDebit: string; profitAndLossCredit: string; lines: TrialBalanceLine[]; } async function computeTrialBalance( db: MainDbHandle, input: { companyId: string; accountingPeriodId?: string; includeZeroBalance: boolean }, ): Promise { let query = db .selectFrom("JournalLine") .innerJoin("JournalEntry", "JournalEntry.id", "JournalLine.journalEntryId") .innerJoin("Account", "Account.id", "JournalLine.accountId") .where("JournalEntry.status", "=", "POSTED") .where("JournalEntry.companyId", "=", input.companyId); if (input.accountingPeriodId) { query = query.where("JournalEntry.accountingPeriodId", "=", input.accountingPeriodId); } const rows = await query .select((eb) => [ "JournalLine.accountId as accountId", "Account.code as code", "Account.name as name", "Account.accountType as accountType", eb.fn.sum("JournalLine.debitAmount").as("debitTotal"), eb.fn.sum("JournalLine.creditAmount").as("creditTotal"), ]) .groupBy(["JournalLine.accountId", "Account.code", "Account.name", "Account.accountType"]) .execute(); let totalDebit = new Decimal(0); let totalCredit = new Decimal(0); let bsDebit = new Decimal(0); let bsCredit = new Decimal(0); let plDebit = new Decimal(0); let plCredit = new Decimal(0); const lines: TrialBalanceLine[] = []; for (const row of rows) { const debit = new Decimal(row.debitTotal ?? 0); const credit = new Decimal(row.creditTotal ?? 0); const net = debit.minus(credit); if (!input.includeZeroBalance && debit.isZero() && credit.isZero()) continue; totalDebit = totalDebit.plus(debit); totalCredit = totalCredit.plus(credit); if (BALANCE_SHEET_TYPES.has(row.accountType)) { bsDebit = bsDebit.plus(debit); bsCredit = bsCredit.plus(credit); } else { plDebit = plDebit.plus(debit); plCredit = plCredit.plus(credit); } lines.push({ accountId: row.accountId, code: row.code, name: row.name, accountType: row.accountType, debit: debit.toString(), credit: credit.toString(), net: net.toString(), }); } lines.sort((a, b) => a.code.localeCompare(b.code)); return { totalDebit: totalDebit.toString(), totalCredit: totalCredit.toString(), balanced: totalDebit.equals(totalCredit), balanceSheetDebit: bsDebit.toString(), balanceSheetCredit: bsCredit.toString(), profitAndLossDebit: plDebit.toString(), profitAndLossCredit: plCredit.toString(), lines, }; } export default createResolver({ name: "trialBalance", operation: "query", input: { companyId: t.string().description("Company to report on"), accountingPeriodId: t .string({ optional: true }) .description("Restrict to one accounting period; omit for all posted entries"), includeZeroBalance: t .bool({ optional: true }) .description("Include accounts whose debit and credit both net to zero (default false)"), }, body: async (context) => { const db = getDB("main-db"); return computeTrialBalance(db, { companyId: context.input.companyId, accountingPeriodId: context.input.accountingPeriodId ?? undefined, includeZeroBalance: context.input.includeZeroBalance ?? false, }); }, output: t .object({ totalDebit: t.string().description("Sum of all debit balances"), totalCredit: t.string().description("Sum of all credit balances"), balanced: t.bool().description("True when total debit equals total credit"), balanceSheetDebit: t.string().description("Debit subtotal for ASSET/LIABILITY/EQUITY"), balanceSheetCredit: t.string().description("Credit subtotal for ASSET/LIABILITY/EQUITY"), profitAndLossDebit: t.string().description("Debit subtotal for REVENUE/EXPENSE"), profitAndLossCredit: t.string().description("Credit subtotal for REVENUE/EXPENSE"), lines: t .object( { accountId: t.string().description("GL account ID"), code: t.string().description("Account code"), name: t.string().description("Account name"), accountType: t.string().description("ASSET | LIABILITY | EQUITY | REVENUE | EXPENSE"), debit: t.string().description("Total debits posted to the account"), credit: t.string().description("Total credits posted to the account"), net: t.string().description("debit − credit (positive = net debit balance)"), }, { array: true }, ) .description("Per-account balances, ordered by account code"), }) .description("Trial balance report"), });