import { db } from '$lib/server/db/index.js'; import { companyAccounts, companyAccountTransactions, expenses, invoices, externalTransactions } from '$lib/server/db/schema.js'; import { and, eq, sql } from 'drizzle-orm'; /** * Drizzle's tx inside db.transaction() has the same methods as db. * Use `any` to avoid importing the internal PgTransaction generic type. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any type Dbx = typeof db | any; export type CompanyAccountTxnType = | 'opening_balance' | 'expense' | 'invoice_payment' | 'transfer_in' | 'transfer_out' | 'deposit' | 'adjustment' | 'reconciliation'; export interface PostTxnInput { accountId: string; companyId: string; type: CompanyAccountTxnType; amount: string | number; // signed decimal; positive = credit, negative = debit currency: string; occurredAt: Date; description?: string | null; reference?: string | null; counterpartyAccountId?: string | null; sourceExpenseId?: string | null; sourceInvoiceId?: string | null; sourceExternalTransactionId?: string | null; fxRate?: string | number | null; fxAmount?: string | number | null; createdBy?: string | null; } function toDecimalString(v: string | number | null | undefined): string | null { if (v === null || v === undefined) return null; return typeof v === 'number' ? v.toString() : v; } export async function postTransaction(dbx: Dbx, input: PostTxnInput): Promise<{ id: string }> { const amountStr = toDecimalString(input.amount); if (amountStr === null) throw new Error('postTransaction: amount is required'); const [row] = await dbx .insert(companyAccountTransactions) .values({ accountId: input.accountId, companyId: input.companyId, type: input.type, amount: amountStr, currency: input.currency, occurredAt: input.occurredAt, description: input.description ?? null, reference: input.reference ?? null, counterpartyAccountId: input.counterpartyAccountId ?? null, sourceExpenseId: input.sourceExpenseId ?? null, sourceInvoiceId: input.sourceInvoiceId ?? null, sourceExternalTransactionId: input.sourceExternalTransactionId ?? null, fxRate: toDecimalString(input.fxRate), fxAmount: toDecimalString(input.fxAmount), createdBy: input.createdBy ?? null }) .returning({ id: companyAccountTransactions.id }); return row; } export interface PostTransferInput { fromAccountId: string; toAccountId: string; companyId: string; amount: string | number; // always positive — the sending side (debit) occurredAt: Date; description?: string | null; reference?: string | null; /** Cross-currency: set exactly one of fxRate OR destinationAmount. */ fxRate?: string | number | null; destinationAmount?: string | number | null; createdBy?: string | null; } export async function postTransfer( input: PostTransferInput ): Promise<{ fromTxnId: string; toTxnId: string }> { if (input.fromAccountId === input.toAccountId) { throw new Error('postTransfer: fromAccountId and toAccountId must differ'); } const absAmount = Number(input.amount); if (!Number.isFinite(absAmount) || absAmount <= 0) { throw new Error('postTransfer: amount must be a positive number'); } return await db.transaction(async (tx: Dbx) => { const accts = await tx .select({ id: companyAccounts.id, companyId: companyAccounts.companyId, currency: companyAccounts.currency }) .from(companyAccounts) .where( sql`${companyAccounts.id} IN (${input.fromAccountId}, ${input.toAccountId}) AND ${companyAccounts.companyId} = ${input.companyId} AND ${companyAccounts.deletedAt} IS NULL` ); const fromAcct = accts.find((a: { id: string }) => a.id === input.fromAccountId); const toAcct = accts.find((a: { id: string }) => a.id === input.toAccountId); if (!fromAcct || !toAcct) throw new Error('postTransfer: account not found or mismatched company'); const sameCurrency = fromAcct.currency === toAcct.currency; let fxRateStr: string | null = null; let destAmount: number; if (sameCurrency) { destAmount = absAmount; } else if (input.destinationAmount != null && input.destinationAmount !== '') { destAmount = Number(input.destinationAmount); if (!Number.isFinite(destAmount) || destAmount <= 0) { throw new Error('postTransfer: destinationAmount must be positive'); } fxRateStr = (destAmount / absAmount).toFixed(8); } else if (input.fxRate != null && input.fxRate !== '') { const rate = Number(input.fxRate); if (!Number.isFinite(rate) || rate <= 0) { throw new Error('postTransfer: fxRate must be positive'); } fxRateStr = rate.toFixed(8); destAmount = +(absAmount * rate).toFixed(2); } else { throw new Error( 'postTransfer: cross-currency transfer requires fxRate or destinationAmount' ); } const fxAmountStr = sameCurrency ? null : destAmount.toFixed(2); const fromTxn = await postTransaction(tx, { accountId: input.fromAccountId, companyId: input.companyId, type: 'transfer_out', amount: (-absAmount).toFixed(2), currency: fromAcct.currency, occurredAt: input.occurredAt, description: input.description ?? null, reference: input.reference ?? null, counterpartyAccountId: input.toAccountId, fxRate: fxRateStr, fxAmount: fxAmountStr, createdBy: input.createdBy ?? null }); const toTxn = await postTransaction(tx, { accountId: input.toAccountId, companyId: input.companyId, type: 'transfer_in', amount: destAmount.toFixed(2), currency: toAcct.currency, occurredAt: input.occurredAt, description: input.description ?? null, reference: input.reference ?? null, counterpartyAccountId: input.fromAccountId, fxRate: fxRateStr, fxAmount: fxAmountStr, createdBy: input.createdBy ?? null }); return { fromTxnId: fromTxn.id, toTxnId: toTxn.id }; }); } export async function getBalance(accountId: string): Promise<{ balance: string; currency: string }> { const [acct] = await db .select({ currency: companyAccounts.currency }) .from(companyAccounts) .where(eq(companyAccounts.id, accountId)) .limit(1); if (!acct) throw new Error(`getBalance: account ${accountId} not found`); const [row] = await db .select({ total: sql`coalesce(sum(${companyAccountTransactions.amount}), '0')::text` }) .from(companyAccountTransactions) .where(eq(companyAccountTransactions.accountId, accountId)); return { balance: row?.total ?? '0', currency: acct.currency }; } export async function postExpenseTransaction( expenseId: string, accountId: string, userId: string, dbx?: Dbx ): Promise { const dbOrTx = dbx ?? db; const [exp] = await dbOrTx .select({ id: expenses.id, amount: expenses.amount, currency: expenses.currency, expenseDate: expenses.expenseDate, title: expenses.title }) .from(expenses) .where(eq(expenses.id, expenseId)) .limit(1); if (!exp) throw new Error(`postExpenseTransaction: expense ${expenseId} not found`); const [acct] = await dbOrTx .select({ companyId: companyAccounts.companyId, currency: companyAccounts.currency }) .from(companyAccounts) .where(eq(companyAccounts.id, accountId)) .limit(1); if (!acct) throw new Error(`postExpenseTransaction: account ${accountId} not found`); // Idempotent: replace any prior post for this expense. await dbOrTx .delete(companyAccountTransactions) .where(eq(companyAccountTransactions.sourceExpenseId, expenseId)); await postTransaction(dbOrTx, { accountId, companyId: acct.companyId, type: 'expense', amount: (-Number(exp.amount)).toFixed(2), currency: exp.currency, occurredAt: new Date(exp.expenseDate), description: exp.title, sourceExpenseId: expenseId, createdBy: userId }); } export async function removeExpenseTransaction(expenseId: string, dbx?: Dbx): Promise { const dbOrTx = dbx ?? db; await dbOrTx .delete(companyAccountTransactions) .where(eq(companyAccountTransactions.sourceExpenseId, expenseId)); } export async function postInvoicePaymentTransaction( invoiceId: string, paymentAccountId: string, userId: string, dbx?: Dbx ): Promise { const dbOrTx = dbx ?? db; const [inv] = await dbOrTx .select({ id: invoices.id, total: invoices.total, currency: invoices.currency, issueDate: invoices.issueDate, invoiceNumber: invoices.invoiceNumber }) .from(invoices) .where(eq(invoices.id, invoiceId)) .limit(1); if (!inv) throw new Error(`postInvoicePaymentTransaction: invoice ${invoiceId} not found`); const [acct] = await dbOrTx .select({ companyId: companyAccounts.companyId, currency: companyAccounts.currency }) .from(companyAccounts) .where(eq(companyAccounts.id, paymentAccountId)) .limit(1); if (!acct) throw new Error(`postInvoicePaymentTransaction: account ${paymentAccountId} not found`); await dbOrTx .delete(companyAccountTransactions) .where(eq(companyAccountTransactions.sourceInvoiceId, invoiceId)); await postTransaction(dbOrTx, { accountId: paymentAccountId, companyId: acct.companyId, type: 'invoice_payment', amount: Number(inv.total).toFixed(2), currency: inv.currency, occurredAt: new Date(inv.issueDate), description: `Invoice ${inv.invoiceNumber}`, sourceInvoiceId: invoiceId, createdBy: userId }); } export async function removeInvoicePaymentTransaction( invoiceId: string, dbx?: Dbx ): Promise { const dbOrTx = dbx ?? db; await dbOrTx .delete(companyAccountTransactions) .where(eq(companyAccountTransactions.sourceInvoiceId, invoiceId)); } export async function postReconciliationTransaction( externalTransactionId: string, accountId: string, companyId: string, userId: string, dbx?: Dbx ): Promise { const dbOrTx = dbx ?? db; const [ext] = await dbOrTx .select({ id: externalTransactions.id, amount: externalTransactions.amount, currency: externalTransactions.currency, direction: externalTransactions.direction, occurredAt: externalTransactions.occurredAt, description: externalTransactions.description, counterparty: externalTransactions.counterparty }) .from(externalTransactions) .where( and( eq(externalTransactions.id, externalTransactionId), eq(externalTransactions.companyId, companyId) ) ) .limit(1); if (!ext) throw new Error(`postReconciliationTransaction: external txn ${externalTransactionId} not found`); const signedAmount = ext.direction === 'credit' ? Number(ext.amount) : -Number(ext.amount); await dbOrTx .delete(companyAccountTransactions) .where(eq(companyAccountTransactions.sourceExternalTransactionId, externalTransactionId)); await postTransaction(dbOrTx, { accountId, companyId, type: 'reconciliation', amount: signedAmount.toFixed(2), currency: ext.currency, occurredAt: ext.occurredAt, description: ext.description ?? ext.counterparty ?? 'Bank reconciliation', sourceExternalTransactionId: externalTransactionId, createdBy: userId }); }