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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<number> {
|
||||||
|
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<number> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
+126
-1
@@ -127,6 +127,9 @@ export const expenses = pgTable(
|
|||||||
.references(() => projects.id, { onDelete: 'cascade' }),
|
.references(() => projects.id, { onDelete: 'cascade' }),
|
||||||
categoryId: uuid('category_id').references(() => categories.id, { onDelete: 'set null' }),
|
categoryId: uuid('category_id').references(() => categories.id, { onDelete: 'set null' }),
|
||||||
partyId: uuid('party_id').references((): any => parties.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')
|
submittedBy: text('submitted_by')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
@@ -452,6 +455,9 @@ export const invoices = pgTable(
|
|||||||
currency: text('currency').notNull().default('THB'),
|
currency: text('currency').notNull().default('THB'),
|
||||||
status: invoiceStatusEnum('status').notNull().default('draft'),
|
status: invoiceStatusEnum('status').notNull().default('draft'),
|
||||||
expenseId: uuid('expense_id').references(() => expenses.id, { onDelete: 'set null' }),
|
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'),
|
notes: text('notes'),
|
||||||
pdfPath: text('pdf_path'),
|
pdfPath: text('pdf_path'),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
@@ -826,6 +832,118 @@ export const cardBrandEnum = pgEnum('card_brand', [
|
|||||||
'other'
|
'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(
|
export const companyBankAccounts = pgTable(
|
||||||
'company_bank_accounts',
|
'company_bank_accounts',
|
||||||
{
|
{
|
||||||
@@ -960,7 +1078,14 @@ export const companyLogEventEnum = pgEnum('company_log_event', [
|
|||||||
'document_deleted',
|
'document_deleted',
|
||||||
'link_added',
|
'link_added',
|
||||||
'link_updated',
|
'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(
|
export const companyLog = pgTable(
|
||||||
|
|||||||
Reference in New Issue
Block a user