Reconciliation link, account CSVs in export, drop legacy bank/card tables
Validate / validate (push) Successful in 31s
Validate / validate (push) Successful in 31s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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 ──────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user