From 57e72e5b6c10d4d22af7ed847b6e631f5e9f445f Mon Sep 17 00:00:00 2001 From: grabowski Date: Thu, 16 Apr 2026 11:34:57 +0700 Subject: [PATCH] Add companyAccounts schema, ledger helper, legacy migration script Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/server/accounts/ledger.ts | 348 ++++++++++++++++++++++ src/lib/server/accounts/migrate-legacy.ts | 115 +++++++ src/lib/server/db/schema.ts | 127 +++++++- 3 files changed, 589 insertions(+), 1 deletion(-) create mode 100644 src/lib/server/accounts/ledger.ts create mode 100644 src/lib/server/accounts/migrate-legacy.ts diff --git a/src/lib/server/accounts/ledger.ts b/src/lib/server/accounts/ledger.ts new file mode 100644 index 0000000..5a790fc --- /dev/null +++ b/src/lib/server/accounts/ledger.ts @@ -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`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 + }); +} diff --git a/src/lib/server/accounts/migrate-legacy.ts b/src/lib/server/accounts/migrate-legacy.ts new file mode 100644 index 0000000..2f71a09 --- /dev/null +++ b/src/lib/server/accounts/migrate-legacy.ts @@ -0,0 +1,115 @@ +/** + * One-shot legacy migration: copy rows from companyBankAccounts and companyCards + * into the unified companyAccounts table. + * + * Usage: + * tsx src/lib/server/accounts/migrate-legacy.ts + * + * Safe to re-run: skips any company that already has a companyAccounts row matching + * the same legacy identifier (bank: accountNumber; card: last4 + cardholderName). + * + * Note: companyCards.bankAccountId linkage is dropped — each card becomes a standalone + * companyAccounts row. Revisit later if card→funding-bank linkage needs preserving. + */ +import { db } from '$lib/server/db/index.js'; +import { + companyAccounts, + companyBankAccounts, + companyCards +} from '$lib/server/db/schema.js'; +import { and, eq } from 'drizzle-orm'; + +async function migrateBankAccounts(): Promise { + const rows = await db.select().from(companyBankAccounts); + let inserted = 0; + for (const r of rows) { + // Skip if a row with same companyId + accountNumber already exists + const existing = await db + .select({ id: companyAccounts.id }) + .from(companyAccounts) + .where( + and( + eq(companyAccounts.companyId, r.companyId), + eq(companyAccounts.accountType, 'bank'), + eq(companyAccounts.accountNumber, r.accountNumber) + ) + ) + .limit(1); + if (existing.length > 0) continue; + + await db.insert(companyAccounts).values({ + companyId: r.companyId, + accountType: 'bank', + name: r.bankName + (r.accountNumber ? ` •••• ${r.accountNumber.slice(-4)}` : ''), + currency: r.currency, + isActive: r.isActive, + notes: r.notes, + bankName: r.bankName, + accountNumber: r.accountNumber, + branch: r.branch, + swiftBic: r.swiftBic, + iban: r.iban, + accountHolderName: r.accountName, + createdAt: r.createdAt, + updatedAt: r.updatedAt + }); + inserted++; + } + return inserted; +} + +async function migrateCards(): Promise { + const rows = await db.select().from(companyCards); + let inserted = 0; + for (const r of rows) { + const existing = await db + .select({ id: companyAccounts.id }) + .from(companyAccounts) + .where( + and( + eq(companyAccounts.companyId, r.companyId), + eq(companyAccounts.accountType, 'credit_card'), + eq(companyAccounts.last4, r.last4) + ) + ) + .limit(1); + if (existing.length > 0) continue; + + const displayName = + r.nickname ?? `${r.cardholderName} •••• ${r.last4}`; + + await db.insert(companyAccounts).values({ + companyId: r.companyId, + accountType: 'credit_card', + name: displayName, + currency: 'THB', + isActive: r.isActive, + notes: r.notes, + cardBrand: r.brand, + last4: r.last4, + cardholderName: r.cardholderName, + expiryMonth: r.expiryMonth, + expiryYear: r.expiryYear, + createdAt: r.createdAt, + updatedAt: r.updatedAt + }); + inserted++; + } + return inserted; +} + +async function main() { + console.log('Migrating legacy bank accounts and cards to companyAccounts…'); + const bankCount = await migrateBankAccounts(); + console.log(` bank accounts migrated: ${bankCount}`); + const cardCount = await migrateCards(); + console.log(` cards migrated: ${cardCount}`); + console.log('Done. Legacy tables left in place; Phase 6 will drop them.'); +} + +main() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + process.exit(1); + }); diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 5df1b34..4a1f748 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -127,6 +127,9 @@ export const expenses = pgTable( .references(() => projects.id, { onDelete: 'cascade' }), categoryId: uuid('category_id').references(() => categories.id, { onDelete: 'set null' }), partyId: uuid('party_id').references((): any => parties.id, { onDelete: 'set null' }), + accountId: uuid('account_id').references((): any => companyAccounts.id, { + onDelete: 'set null' + }), submittedBy: text('submitted_by') .notNull() .references(() => users.id), @@ -452,6 +455,9 @@ export const invoices = pgTable( currency: text('currency').notNull().default('THB'), status: invoiceStatusEnum('status').notNull().default('draft'), expenseId: uuid('expense_id').references(() => expenses.id, { onDelete: 'set null' }), + paymentAccountId: uuid('payment_account_id').references((): any => companyAccounts.id, { + onDelete: 'set null' + }), notes: text('notes'), pdfPath: text('pdf_path'), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), @@ -826,6 +832,118 @@ export const cardBrandEnum = pgEnum('card_brand', [ 'other' ]); +// ── Company Accounts (unified ledger) ────────────────── + +export const companyAccountTypeEnum = pgEnum('company_account_type', [ + 'bank', + 'credit_card', + 'cash', + 'mobile_money', + 'petty_cash', + 'loan', + 'other' +]); + +export const companyAccountTxnTypeEnum = pgEnum('company_account_txn_type', [ + 'opening_balance', + 'expense', + 'invoice_payment', + 'transfer_in', + 'transfer_out', + 'deposit', + 'adjustment', + 'reconciliation' +]); + +export const companyAccounts = pgTable( + 'company_accounts', + { + id: uuid('id').primaryKey().defaultRandom(), + companyId: uuid('company_id') + .notNull() + .references(() => companies.id, { onDelete: 'cascade' }), + accountType: companyAccountTypeEnum('account_type').notNull(), + name: text('name').notNull(), + currency: text('currency').notNull().default('THB'), + isActive: boolean('is_active').notNull().default(true), + isArchived: boolean('is_archived').notNull().default(false), + notes: text('notes'), + sortOrder: integer('sort_order').notNull().default(0), + createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }), + deletedAt: timestamp('deleted_at', { withTimezone: true }), + // Bank-specific + bankName: text('bank_name'), + accountNumber: text('account_number'), + branch: text('branch'), + swiftBic: text('swift_bic'), + iban: text('iban'), + accountHolderName: text('account_holder_name'), + // Card-specific + cardBrand: cardBrandEnum('card_brand'), + last4: varchar('last4', { length: 4 }), + cardholderName: text('cardholder_name'), + expiryMonth: integer('expiry_month'), + expiryYear: integer('expiry_year'), + creditLimit: numeric('credit_limit', { precision: 15, scale: 2 }), + statementCloseDay: integer('statement_close_day'), + paymentDueDay: integer('payment_due_day'), + // Banking integration link + externalAccountId: uuid('external_account_id').references(() => externalAccounts.id, { + onDelete: 'set null' + }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow() + }, + (table) => [ + index('company_accounts_company_type_idx').on(table.companyId, table.accountType), + index('company_accounts_company_archived_idx').on(table.companyId, table.isArchived) + ] +); + +export const companyAccountTransactions = pgTable( + 'company_account_transactions', + { + id: uuid('id').primaryKey().defaultRandom(), + accountId: uuid('account_id') + .notNull() + .references(() => companyAccounts.id, { onDelete: 'cascade' }), + companyId: uuid('company_id') + .notNull() + .references(() => companies.id, { onDelete: 'cascade' }), + type: companyAccountTxnTypeEnum('type').notNull(), + amount: numeric('amount', { precision: 15, scale: 2 }).notNull(), + currency: text('currency').notNull(), + occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(), + description: text('description'), + reference: text('reference'), + counterpartyAccountId: uuid('counterparty_account_id').references( + (): any => companyAccounts.id, + { onDelete: 'set null' } + ), + sourceExpenseId: uuid('source_expense_id').references(() => expenses.id, { + onDelete: 'set null' + }), + sourceInvoiceId: uuid('source_invoice_id').references(() => invoices.id, { + onDelete: 'set null' + }), + sourceExternalTransactionId: uuid('source_external_transaction_id').references( + () => externalTransactions.id, + { onDelete: 'set null' } + ), + fxRate: numeric('fx_rate', { precision: 18, scale: 8 }), + fxAmount: numeric('fx_amount', { precision: 15, scale: 2 }), + createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow() + }, + (table) => [ + index('company_account_txns_account_occurred_idx').on(table.accountId, table.occurredAt), + index('company_account_txns_company_occurred_idx').on(table.companyId, table.occurredAt), + index('company_account_txns_expense_idx').on(table.sourceExpenseId), + index('company_account_txns_invoice_idx').on(table.sourceInvoiceId) + ] +); + export const companyBankAccounts = pgTable( 'company_bank_accounts', { @@ -960,7 +1078,14 @@ export const companyLogEventEnum = pgEnum('company_log_event', [ 'document_deleted', 'link_added', 'link_updated', - 'link_deleted' + 'link_deleted', + 'account_created', + 'account_updated', + 'account_archived', + 'account_deleted', + 'account_transaction_added', + 'account_transfer_posted', + 'account_reconciled' ]); export const companyLog = pgTable(