Reconciliation link, account CSVs in export, drop legacy bank/card tables
Validate / validate (push) Successful in 31s

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 14:06:53 +07:00
parent 0d4fdb6fd7
commit 77c5d72e43
7 changed files with 249 additions and 735 deletions
-115
View File
@@ -1,115 +0,0 @@
/**
* 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);
});
-48
View File
@@ -944,54 +944,6 @@ export const companyAccountTransactions = pgTable(
]
);
export const companyBankAccounts = pgTable(
'company_bank_accounts',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
bankName: text('bank_name').notNull(),
accountName: text('account_name').notNull(),
accountNumber: text('account_number').notNull(),
accountType: text('account_type'),
branch: text('branch'),
swiftBic: text('swift_bic'),
iban: text('iban'),
currency: text('currency').notNull().default('THB'),
isPrimary: boolean('is_primary').notNull().default(false),
isActive: boolean('is_active').notNull().default(true),
notes: text('notes'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [index('company_bank_accounts_company_idx').on(table.companyId)]
);
export const companyCards = pgTable(
'company_cards',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
brand: cardBrandEnum('brand').notNull(),
last4: varchar('last4', { length: 4 }).notNull(),
cardholderName: text('cardholder_name').notNull(),
expiryMonth: integer('expiry_month'),
expiryYear: integer('expiry_year'),
nickname: text('nickname'),
bankAccountId: uuid('bank_account_id').references(() => companyBankAccounts.id, {
onDelete: 'set null'
}),
isActive: boolean('is_active').notNull().default(true),
notes: text('notes'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [index('company_cards_company_idx').on(table.companyId)]
);
export const companyAddresses = pgTable(
'company_addresses',
{
+70 -47
View File
@@ -19,9 +19,9 @@ import {
externalAccounts,
externalTransactions,
users,
companyBankAccounts,
companyCards,
companyAddresses,
companyAccounts,
companyAccountTransactions,
companyDocuments,
companyDocumentVersions
} from '../db/schema.js';
@@ -73,8 +73,8 @@ export async function buildFinancialExport(
``,
`Files:`,
` company.csv — company record`,
` company_bank_accounts.csv — company bank accounts`,
` company_cards.csv — company credit/debit cards (last 4 only)`,
` company_accounts.csv — unified ledger accounts (bank, card, cash, etc.)`,
` company_account_transactions.csv — ledger transactions in the selected year`,
` company_addresses.csv — legal/shipping/billing/other addresses`,
` company_documents.csv — uploaded document metadata (files not bundled)`,
` projects.csv — all projects (active + inactive)`,
@@ -119,70 +119,93 @@ export async function buildFinancialExport(
)
);
// ── company_bank_accounts.csv ──────────────────────
// ── company_accounts.csv ───────────────────────────
{
const bankRows = await db
const acctRows = await db
.select()
.from(companyBankAccounts)
.where(eq(companyBankAccounts.companyId, companyId))
.orderBy(asc(companyBankAccounts.bankName));
.from(companyAccounts)
.where(eq(companyAccounts.companyId, companyId))
.orderBy(asc(companyAccounts.accountType), asc(companyAccounts.name));
const rows: unknown[][] = [
[
'id', 'bankName', 'accountName', 'accountNumber', 'accountType', 'branch',
'swiftBic', 'iban', 'currency', 'isPrimary', 'isActive', 'notes',
'createdAt', 'updatedAt'
'id', 'accountType', 'name', 'currency', 'isActive', 'isArchived',
'bankName', 'accountNumber', 'branch', 'swiftBic', 'iban', 'accountHolderName',
'cardBrand', 'last4', 'cardholderName', 'expiryMonth', 'expiryYear',
'creditLimit', 'statementCloseDay', 'paymentDueDay',
'externalAccountId', 'notes', 'deletedAt', 'createdAt', 'updatedAt'
]
];
for (const b of bankRows) {
for (const a of acctRows) {
rows.push([
b.id, b.bankName, b.accountName, b.accountNumber, b.accountType ?? '',
b.branch ?? '', b.swiftBic ?? '', b.iban ?? '', b.currency,
b.isPrimary, b.isActive, b.notes ?? '',
b.createdAt.toISOString(), b.updatedAt.toISOString()
a.id, a.accountType, a.name, a.currency, a.isActive, a.isArchived,
a.bankName ?? '', a.accountNumber ?? '', a.branch ?? '', a.swiftBic ?? '',
a.iban ?? '', a.accountHolderName ?? '',
a.cardBrand ?? '', a.last4 ?? '', a.cardholderName ?? '',
a.expiryMonth ?? '', a.expiryYear ?? '',
a.creditLimit ?? '', a.statementCloseDay ?? '', a.paymentDueDay ?? '',
a.externalAccountId ?? '', a.notes ?? '',
a.deletedAt ? a.deletedAt.toISOString() : '',
a.createdAt.toISOString(), a.updatedAt.toISOString()
]);
}
zip.file('company_bank_accounts.csv', withBom(csvBuild(rows)));
zip.file('company_accounts.csv', withBom(csvBuild(rows)));
}
// ── company_cards.csv ──────────────────────────────
// ── company_account_transactions.csv ───────────────
{
const cardRows = await db
const yearStartDate = new Date(`${year}-01-01T00:00:00Z`);
const yearEndDate = new Date(`${year}-12-31T23:59:59.999Z`);
const txRows = await db
.select({
id: companyCards.id,
brand: companyCards.brand,
last4: companyCards.last4,
cardholderName: companyCards.cardholderName,
expiryMonth: companyCards.expiryMonth,
expiryYear: companyCards.expiryYear,
nickname: companyCards.nickname,
bankAccountId: companyCards.bankAccountId,
bankAccountName: companyBankAccounts.bankName,
isActive: companyCards.isActive,
notes: companyCards.notes,
createdAt: companyCards.createdAt,
updatedAt: companyCards.updatedAt
id: companyAccountTransactions.id,
accountId: companyAccountTransactions.accountId,
accountName: companyAccounts.name,
type: companyAccountTransactions.type,
amount: companyAccountTransactions.amount,
currency: companyAccountTransactions.currency,
occurredAt: companyAccountTransactions.occurredAt,
description: companyAccountTransactions.description,
reference: companyAccountTransactions.reference,
counterpartyAccountId: companyAccountTransactions.counterpartyAccountId,
sourceExpenseId: companyAccountTransactions.sourceExpenseId,
sourceInvoiceId: companyAccountTransactions.sourceInvoiceId,
sourceExternalTransactionId: companyAccountTransactions.sourceExternalTransactionId,
fxRate: companyAccountTransactions.fxRate,
fxAmount: companyAccountTransactions.fxAmount,
createdAt: companyAccountTransactions.createdAt
})
.from(companyCards)
.leftJoin(companyBankAccounts, eq(companyCards.bankAccountId, companyBankAccounts.id))
.where(eq(companyCards.companyId, companyId))
.orderBy(asc(companyCards.brand));
.from(companyAccountTransactions)
.innerJoin(companyAccounts, eq(companyAccountTransactions.accountId, companyAccounts.id))
.where(
and(
eq(companyAccountTransactions.companyId, companyId),
sql`${companyAccountTransactions.occurredAt} >= ${yearStartDate}`,
sql`${companyAccountTransactions.occurredAt} <= ${yearEndDate}`
)
)
.orderBy(
asc(companyAccountTransactions.occurredAt),
asc(companyAccountTransactions.createdAt)
);
const rows: unknown[][] = [
[
'id', 'brand', 'last4', 'cardholderName', 'expiryMonth', 'expiryYear',
'nickname', 'bankAccountId', 'bankAccountName', 'isActive', 'notes',
'createdAt', 'updatedAt'
'id', 'accountId', 'accountName', 'type', 'amount', 'currency',
'occurredAt', 'description', 'reference',
'counterpartyAccountId', 'sourceExpenseId', 'sourceInvoiceId',
'sourceExternalTransactionId', 'fxRate', 'fxAmount', 'createdAt'
]
];
for (const c of cardRows) {
for (const t of txRows) {
rows.push([
c.id, c.brand, c.last4, c.cardholderName,
c.expiryMonth ?? '', c.expiryYear ?? '',
c.nickname ?? '', c.bankAccountId ?? '', c.bankAccountName ?? '',
c.isActive, c.notes ?? '',
c.createdAt.toISOString(), c.updatedAt.toISOString()
t.id, t.accountId, t.accountName, t.type, t.amount, t.currency,
t.occurredAt.toISOString(), t.description ?? '', t.reference ?? '',
t.counterpartyAccountId ?? '', t.sourceExpenseId ?? '',
t.sourceInvoiceId ?? '', t.sourceExternalTransactionId ?? '',
t.fxRate ?? '', t.fxAmount ?? '',
t.createdAt.toISOString()
]);
}
zip.file('company_cards.csv', withBom(csvBuild(rows)));
zip.file('company_account_transactions.csv', withBom(csvBuild(rows)));
}
// ── company_addresses.csv ──────────────────────────