Add companyAccounts schema, ledger helper, legacy migration script
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
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<string>`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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user