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 ──────────────────────────
@@ -1,10 +1,18 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { externalAccounts, externalTransactions, expenses, projects } from '$lib/server/db/schema.js';
import { eq, and, isNull, isNotNull, desc } from 'drizzle-orm';
import {
externalAccounts,
externalTransactions,
expenses,
projects,
companyAccounts,
companyAccountTransactions
} from '$lib/server/db/schema.js';
import { eq, and, isNull, isNotNull, desc, inArray } from 'drizzle-orm';
import { requireCompanyRole } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
import { postReconciliationTransaction } from '$lib/server/accounts/ledger.js';
export const load: PageServerLoad = async ({ locals, params, url }) => {
await requireCompanyRole(locals, params.companyId, 'admin');
@@ -82,11 +90,53 @@ export const load: PageServerLoad = async ({ locals, params, url }) => {
.orderBy(desc(expenses.createdAt))
.limit(200);
const accountsList = await db
.select({
id: companyAccounts.id,
name: companyAccounts.name,
currency: companyAccounts.currency,
accountType: companyAccounts.accountType
})
.from(companyAccounts)
.where(
and(
eq(companyAccounts.companyId, params.companyId),
eq(companyAccounts.isArchived, false),
isNull(companyAccounts.deletedAt)
)
)
.orderBy(companyAccounts.name);
// Determine which external transactions are already posted to a ledger account
const txIds = transactions.map((t) => t.id);
const postedMap: Record<string, { accountId: string; accountName: string | null }> = {};
if (txIds.length > 0) {
const postedRows = await db
.select({
sourceExternalTransactionId: companyAccountTransactions.sourceExternalTransactionId,
accountId: companyAccountTransactions.accountId,
accountName: companyAccounts.name
})
.from(companyAccountTransactions)
.leftJoin(companyAccounts, eq(companyAccountTransactions.accountId, companyAccounts.id))
.where(inArray(companyAccountTransactions.sourceExternalTransactionId, txIds));
for (const row of postedRows) {
if (row.sourceExternalTransactionId) {
postedMap[row.sourceExternalTransactionId] = {
accountId: row.accountId,
accountName: row.accountName
};
}
}
}
return {
transactions,
matchedExpenseTitles,
matchableExpenses,
matchedFilter: matched
matchedFilter: matched,
accounts: accountsList,
postedMap
};
};
@@ -144,6 +194,72 @@ export const actions: Actions = {
.set({ matchedExpenseId: null })
.where(eq(externalTransactions.id, txId));
return { success: true };
},
postToAccount: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
const formData = await request.formData();
const txId = formData.get('txId')?.toString();
const accountId = formData.get('accountId')?.toString();
if (!txId) return fail(400, { error: 'Transaction ID is required' });
if (!accountId) return fail(400, { error: 'Account is required' });
const [tx] = await db
.select({ id: externalTransactions.id })
.from(externalTransactions)
.where(
and(eq(externalTransactions.id, txId), eq(externalTransactions.companyId, params.companyId))
)
.limit(1);
if (!tx) return fail(404, { error: 'Transaction not found' });
const [acct] = await db
.select({ id: companyAccounts.id })
.from(companyAccounts)
.where(
and(
eq(companyAccounts.id, accountId),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
)
.limit(1);
if (!acct) return fail(400, { error: 'Invalid account' });
await postReconciliationTransaction(txId, accountId, params.companyId, user.id);
await logCompanyEvent(
params.companyId,
user.id,
'account_reconciled',
`External transaction posted to account`,
{ externalTransactionId: txId, accountId }
);
return { success: true };
},
unpostFromAccount: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
const formData = await request.formData();
const txId = formData.get('txId')?.toString();
if (!txId) return fail(400, { error: 'Transaction ID is required' });
await db
.delete(companyAccountTransactions)
.where(eq(companyAccountTransactions.sourceExternalTransactionId, txId));
await logCompanyEvent(
params.companyId,
user.id,
'account_reconciled',
`Reconciliation reversed for external transaction`,
{ externalTransactionId: txId }
);
return { success: true };
}
};
@@ -82,6 +82,7 @@
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Description</th>
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Counterparty</th>
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Matched Expense</th>
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Ledger Account</th>
</tr>
</thead>
<tbody>
@@ -152,6 +153,44 @@
</form>
{/if}
</td>
<td class="px-4 py-3">
{#if data.postedMap[tx.id]}
<div class="flex items-center gap-2">
<span class="truncate max-w-[140px] text-xs text-blue-700 dark:text-blue-300 font-medium" title={data.postedMap[tx.id].accountName ?? ''}>
{data.postedMap[tx.id].accountName ?? 'Account'}
</span>
<form method="POST" action="?/unpostFromAccount" use:enhance>
<input type="hidden" name="txId" value={tx.id} />
<button
type="submit"
class="text-xs text-gray-400 dark:text-gray-500 hover:text-red-600 dark:hover:text-red-400 whitespace-nowrap"
>
Unpost
</button>
</form>
</div>
{:else if data.accounts.length === 0}
<span class="text-xs text-gray-400">No accounts</span>
{:else}
<form method="POST" action="?/postToAccount" use:enhance>
<input type="hidden" name="txId" value={tx.id} />
<select
name="accountId"
onchange={(e) => {
if ((e.target as HTMLSelectElement).value) {
(e.target as HTMLSelectElement).closest('form')?.requestSubmit();
}
}}
class="rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-1.5 py-1 text-xs text-gray-700 dark:text-gray-300"
>
<option value="">Post to account…</option>
{#each data.accounts as acct}
<option value={acct.id}>{acct.name} ({acct.currency})</option>
{/each}
</select>
</form>
{/if}
</td>
</tr>
{/each}
</tbody>
@@ -1,300 +1,33 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import {
companyBankAccounts,
companyCards,
companyAddresses
} from '$lib/server/db/schema.js';
import { eq, and, desc, asc, sql } from 'drizzle-orm';
import { companyAddresses } from '$lib/server/db/schema.js';
import { eq, and, desc, asc } from 'drizzle-orm';
import { requireCompanyRole, requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
const ALL_ADDRESS_TYPES = ['legal', 'shipping', 'billing', 'other'] as const;
const ALL_CARD_BRANDS = [
'visa',
'mastercard',
'amex',
'jcb',
'unionpay',
'discover',
'other'
] as const;
type AddressType = (typeof ALL_ADDRESS_TYPES)[number];
type CardBrand = (typeof ALL_CARD_BRANDS)[number];
function trimOrNull(v: FormDataEntryValue | null): string | null {
const s = v?.toString().trim();
return s ? s : null;
}
function parseInt0(v: FormDataEntryValue | null): number | null {
const s = v?.toString().trim();
if (!s) return null;
const n = parseInt(s, 10);
return isNaN(n) ? null : n;
}
export const load: PageServerLoad = async ({ locals, params, parent }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
await parent();
const bankAccounts = await db
.select()
.from(companyBankAccounts)
.where(eq(companyBankAccounts.companyId, params.companyId))
.orderBy(desc(companyBankAccounts.isPrimary), asc(companyBankAccounts.bankName));
const cards = await db
.select({
id: companyCards.id,
brand: companyCards.brand,
last4: companyCards.last4,
cardholderName: companyCards.cardholderName,
expiryMonth: companyCards.expiryMonth,
expiryYear: companyCards.expiryYear,
nickname: companyCards.nickname,
isActive: companyCards.isActive,
notes: companyCards.notes,
bankAccountId: companyCards.bankAccountId,
bankAccountLabel: sql<string | null>`(
SELECT ${companyBankAccounts.bankName} || ' · ' || RIGHT(${companyBankAccounts.accountNumber}, 4)
FROM ${companyBankAccounts}
WHERE ${companyBankAccounts.id} = ${companyCards.bankAccountId}
)`,
createdAt: companyCards.createdAt
})
.from(companyCards)
.where(eq(companyCards.companyId, params.companyId))
.orderBy(asc(companyCards.brand));
const addresses = await db
.select()
.from(companyAddresses)
.where(eq(companyAddresses.companyId, params.companyId))
.orderBy(asc(companyAddresses.type), desc(companyAddresses.isDefault));
return { bankAccounts, cards, addresses };
return { addresses };
};
export const actions: Actions = {
addBankAccount: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
const fd = await request.formData();
const bankName = trimOrNull(fd.get('bankName'));
const accountName = trimOrNull(fd.get('accountName'));
const accountNumber = trimOrNull(fd.get('accountNumber'));
if (!bankName) return fail(400, { error: 'Bank name is required' });
if (!accountName) return fail(400, { error: 'Account name is required' });
if (!accountNumber) return fail(400, { error: 'Account number is required' });
const isPrimary = fd.get('isPrimary') === 'on';
// If marking primary, demote others first
if (isPrimary) {
await db
.update(companyBankAccounts)
.set({ isPrimary: false })
.where(eq(companyBankAccounts.companyId, params.companyId));
}
await db.insert(companyBankAccounts).values({
companyId: params.companyId,
bankName,
accountName,
accountNumber,
accountType: trimOrNull(fd.get('accountType')),
branch: trimOrNull(fd.get('branch')),
swiftBic: trimOrNull(fd.get('swiftBic')),
iban: trimOrNull(fd.get('iban')),
currency: trimOrNull(fd.get('currency')) ?? 'THB',
isPrimary,
notes: trimOrNull(fd.get('notes'))
});
await logCompanyEvent(
params.companyId,
user.id,
'bank_account_added',
`Bank account "${bankName}" added`
);
return { success: true };
},
updateBankAccount: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
const fd = await request.formData();
const id = fd.get('id')?.toString();
if (!id) return fail(400, { error: 'Missing ID' });
const bankName = trimOrNull(fd.get('bankName'));
const accountName = trimOrNull(fd.get('accountName'));
const accountNumber = trimOrNull(fd.get('accountNumber'));
if (!bankName || !accountName || !accountNumber) {
return fail(400, { error: 'Bank name, account name, and account number are required' });
}
await db
.update(companyBankAccounts)
.set({
bankName,
accountName,
accountNumber,
accountType: trimOrNull(fd.get('accountType')),
branch: trimOrNull(fd.get('branch')),
swiftBic: trimOrNull(fd.get('swiftBic')),
iban: trimOrNull(fd.get('iban')),
currency: trimOrNull(fd.get('currency')) ?? 'THB',
isActive: fd.get('isActive') === 'on',
notes: trimOrNull(fd.get('notes')),
updatedAt: new Date()
})
.where(
and(
eq(companyBankAccounts.id, id),
eq(companyBankAccounts.companyId, params.companyId)
)
);
await logCompanyEvent(
params.companyId,
user.id,
'bank_account_updated',
`Bank account "${bankName}" updated`
);
return { success: true };
},
setPrimaryBankAccount: async ({ request, locals, params }) => {
await requireCompanyRole(locals, params.companyId, 'admin');
const fd = await request.formData();
const id = fd.get('id')?.toString();
if (!id) return fail(400, { error: 'Missing ID' });
await db
.update(companyBankAccounts)
.set({ isPrimary: false })
.where(eq(companyBankAccounts.companyId, params.companyId));
await db
.update(companyBankAccounts)
.set({ isPrimary: true, updatedAt: new Date() })
.where(
and(
eq(companyBankAccounts.id, id),
eq(companyBankAccounts.companyId, params.companyId)
)
);
return { success: true };
},
removeBankAccount: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
const fd = await request.formData();
const id = fd.get('id')?.toString();
if (!id) return fail(400, { error: 'Missing ID' });
const [ba] = await db
.select({ bankName: companyBankAccounts.bankName })
.from(companyBankAccounts)
.where(
and(
eq(companyBankAccounts.id, id),
eq(companyBankAccounts.companyId, params.companyId)
)
)
.limit(1);
await db
.delete(companyBankAccounts)
.where(
and(
eq(companyBankAccounts.id, id),
eq(companyBankAccounts.companyId, params.companyId)
)
);
if (ba) {
await logCompanyEvent(
params.companyId,
user.id,
'bank_account_removed',
`Bank account "${ba.bankName}" removed`
);
}
return { success: true };
},
addCard: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
const fd = await request.formData();
const brand = fd.get('brand')?.toString() as CardBrand | undefined;
const last4 = fd.get('last4')?.toString().trim();
const cardholderName = trimOrNull(fd.get('cardholderName'));
if (!brand || !ALL_CARD_BRANDS.includes(brand)) {
return fail(400, { error: 'Card brand is required' });
}
if (!last4 || !/^\d{4}$/.test(last4)) {
return fail(400, { error: 'Last 4 digits must be exactly 4 numbers' });
}
if (!cardholderName) return fail(400, { error: 'Cardholder name is required' });
const bankAccountId = trimOrNull(fd.get('bankAccountId'));
await db.insert(companyCards).values({
companyId: params.companyId,
brand,
last4,
cardholderName,
expiryMonth: parseInt0(fd.get('expiryMonth')),
expiryYear: parseInt0(fd.get('expiryYear')),
nickname: trimOrNull(fd.get('nickname')),
bankAccountId,
notes: trimOrNull(fd.get('notes'))
});
await logCompanyEvent(
params.companyId,
user.id,
'card_added',
`Card ${brand.toUpperCase()} •••• ${last4} added`
);
return { success: true };
},
removeCard: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
const fd = await request.formData();
const id = fd.get('id')?.toString();
if (!id) return fail(400, { error: 'Missing ID' });
const [c] = await db
.select({ brand: companyCards.brand, last4: companyCards.last4 })
.from(companyCards)
.where(and(eq(companyCards.id, id), eq(companyCards.companyId, params.companyId)))
.limit(1);
await db
.delete(companyCards)
.where(and(eq(companyCards.id, id), eq(companyCards.companyId, params.companyId)));
if (c) {
await logCompanyEvent(
params.companyId,
user.id,
'card_removed',
`Card ${c.brand.toUpperCase()} •••• ${c.last4} removed`
);
}
return { success: true };
},
addAddress: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
const fd = await request.formData();
@@ -6,22 +6,9 @@
const isAdmin = $derived(data.companyRoles.includes('admin'));
let showAddBank = $state(false);
let editBankId = $state<string | null>(null);
let showAddCard = $state(false);
let showAddAddress = $state(false);
let editAddressId = $state<string | null>(null);
const BRAND_LABELS: Record<string, string> = {
visa: 'Visa',
mastercard: 'Mastercard',
amex: 'American Express',
jcb: 'JCB',
unionpay: 'UnionPay',
discover: 'Discover',
other: 'Other'
};
const ADDRESS_TYPE_LABELS: Record<string, string> = {
legal: 'Legal',
shipping: 'Shipping',
@@ -36,18 +23,7 @@
other: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
};
function maskAccount(n: string): string {
if (!n) return '';
if (n.length <= 4) return n;
return '••••' + n.slice(-4);
}
function formatExpiry(m: number | null, y: number | null): string {
if (!m || !y) return '—';
return `${String(m).padStart(2, '0')}/${String(y).slice(-2)}`;
}
function fullAddress(a: typeof data.addresses[number]): string {
function fullAddress(a: (typeof data.addresses)[number]): string {
return [
a.addressLine1,
a.addressLine2,
@@ -87,7 +63,10 @@
<header>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Company Profile</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Reference data for accounting, payments, and shipping. Visible to admin, manager, and accountant. Editing is admin-only.
Legal and shipping addresses. For bank accounts, cards, cash, and anything with a balance, use the <a
href={`/companies/${data.company.id}/accounts`}
class="font-medium text-blue-600 underline hover:text-blue-700 dark:text-blue-400">Accounts</a
> tab.
</p>
</header>
@@ -97,243 +76,30 @@
</div>
{/if}
<div
class="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-900/40 dark:bg-amber-900/20 dark:text-amber-200"
>
Bank accounts and cards are now managed in the <a
href={`/companies/${data.company.id}/accounts`}
class="font-medium underline hover:text-amber-900 dark:hover:text-amber-100">Accounts</a
> tab, where they also show rolling balances and transaction history. The sections below will be removed in an upcoming update.
</div>
<!-- ========== Bank Accounts ========== -->
<section class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<div class="mb-4 flex items-center justify-between">
<h2 class="font-semibold text-gray-900 dark:text-white">Bank Accounts</h2>
{#if isAdmin}
<button onclick={() => (showAddBank = !showAddBank)}
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
{showAddBank ? 'Cancel' : '+ Add Bank Account'}
</button>
{/if}
</div>
{#if showAddBank && isAdmin}
<form method="POST" action="?/addBankAccount"
use:enhance={() => async ({ update }) => { await update(); showAddBank = false; }}
class="mb-4 grid grid-cols-1 sm:grid-cols-2 gap-3 rounded-md border border-blue-200 bg-blue-50 p-4 dark:border-blue-700/50 dark:bg-blue-900/20">
<div><label for="bankName" class={labelCls}>Bank Name *</label><input id="bankName" name="bankName" required class={inputCls} /></div>
<div><label for="accountName" class={labelCls}>Account Name *</label><input id="accountName" name="accountName" required class={inputCls} /></div>
<div><label for="accountNumber" class={labelCls}>Account Number *</label><input id="accountNumber" name="accountNumber" required class={inputCls} /></div>
<div><label for="accountType" class={labelCls}>Type</label><input id="accountType" name="accountType" placeholder="savings / current" class={inputCls} /></div>
<div><label for="branch" class={labelCls}>Branch</label><input id="branch" name="branch" class={inputCls} /></div>
<div><label for="currency" class={labelCls}>Currency</label><input id="currency" name="currency" value="THB" maxlength="3" class={inputCls} /></div>
<div><label for="swiftBic" class={labelCls}>SWIFT/BIC</label><input id="swiftBic" name="swiftBic" class={inputCls} /></div>
<div><label for="iban" class={labelCls}>IBAN</label><input id="iban" name="iban" class={inputCls} /></div>
<div class="sm:col-span-2"><label for="ba-notes" class={labelCls}>Notes</label><textarea id="ba-notes" name="notes" rows="2" class={inputCls}></textarea></div>
<label class="sm:col-span-2 flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input type="checkbox" name="isPrimary" class="rounded" /> Mark as primary account
</label>
<div class="sm:col-span-2 flex justify-end">
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">Save</button>
</div>
</form>
{/if}
{#if data.bankAccounts.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">No bank accounts on file.</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-800/50">
<tr class="text-left text-gray-500 dark:text-gray-400">
<th class="px-3 py-2 font-medium">Bank</th>
<th class="px-3 py-2 font-medium">Account Name</th>
<th class="px-3 py-2 font-medium">Account Number</th>
<th class="px-3 py-2 font-medium">Type</th>
<th class="px-3 py-2 font-medium">Currency</th>
<th class="px-3 py-2 font-medium">Status</th>
{#if isAdmin}<th class="px-3 py-2 font-medium">Actions</th>{/if}
</tr>
</thead>
<tbody>
{#each data.bankAccounts as ba}
<tr class="border-t border-gray-100 dark:border-gray-700">
<td class="px-3 py-2 font-medium text-gray-900 dark:text-white">
{ba.bankName}
{#if ba.branch}<span class="ml-1 text-xs text-gray-400">({ba.branch})</span>{/if}
</td>
<td class="px-3 py-2 text-gray-700 dark:text-gray-300">{ba.accountName}</td>
<td class="px-3 py-2 font-mono text-gray-700 dark:text-gray-300">{maskAccount(ba.accountNumber)}</td>
<td class="px-3 py-2 text-gray-500 dark:text-gray-400">{ba.accountType ?? '—'}</td>
<td class="px-3 py-2 text-gray-700 dark:text-gray-300">{ba.currency}</td>
<td class="px-3 py-2">
{#if ba.isPrimary}<span class="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">Primary</span>{/if}
{#if !ba.isActive}<span class="ml-1 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-500 dark:bg-gray-700 dark:text-gray-400">Inactive</span>{/if}
</td>
{#if isAdmin}
<td class="px-3 py-2">
<div class="flex gap-2 text-xs">
<button type="button" onclick={() => (editBankId = editBankId === ba.id ? null : ba.id)}
class="text-blue-600 hover:text-blue-800 dark:text-blue-400">{editBankId === ba.id ? 'Close' : 'Edit'}</button>
{#if !ba.isPrimary}
<form method="POST" action="?/setPrimaryBankAccount" use:enhance={enhanceNoReset} class="inline">
<input type="hidden" name="id" value={ba.id} />
<button type="submit" class="text-gray-600 hover:text-gray-800 dark:text-gray-300">Set Primary</button>
</form>
{/if}
<form method="POST" action="?/removeBankAccount"
use:enhance={({ cancel }) => {
if (!confirm('Remove this bank account?')) { cancel(); return; }
return async ({ update }) => await update({ reset: false });
}} class="inline">
<input type="hidden" name="id" value={ba.id} />
<button type="submit" class="text-red-600 hover:text-red-800 dark:text-red-400">Remove</button>
</form>
</div>
</td>
{/if}
</tr>
{#if editBankId === ba.id && isAdmin}
<tr class="border-t border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<td colspan="7" class="px-3 py-3">
<form method="POST" action="?/updateBankAccount"
use:enhance={() => async ({ update }) => { await update({ reset: false }); editBankId = null; }}
class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<input type="hidden" name="id" value={ba.id} />
<div><span class={labelCls}>Bank Name *</span><input name="bankName" required value={ba.bankName} class={inputCls} /></div>
<div><span class={labelCls}>Account Name *</span><input name="accountName" required value={ba.accountName} class={inputCls} /></div>
<div><span class={labelCls}>Account Number *</span><input name="accountNumber" required value={ba.accountNumber} class={inputCls + ' font-mono'} /></div>
<div><span class={labelCls}>Type</span><input name="accountType" value={ba.accountType ?? ''} class={inputCls} /></div>
<div><span class={labelCls}>Branch</span><input name="branch" value={ba.branch ?? ''} class={inputCls} /></div>
<div><span class={labelCls}>Currency</span><input name="currency" value={ba.currency} maxlength="3" class={inputCls} /></div>
<div><span class={labelCls}>SWIFT/BIC</span><input name="swiftBic" value={ba.swiftBic ?? ''} class={inputCls} /></div>
<div><span class={labelCls}>IBAN</span><input name="iban" value={ba.iban ?? ''} class={inputCls} /></div>
<div class="sm:col-span-2"><span class={labelCls}>Notes</span><textarea name="notes" rows="2" class={inputCls}>{ba.notes ?? ''}</textarea></div>
<label class="sm:col-span-2 flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input type="checkbox" name="isActive" checked={ba.isActive} class="rounded" /> Active
</label>
<div class="sm:col-span-2 flex justify-end gap-2">
<button type="button" onclick={() => (editBankId = null)} class="rounded-md px-3 py-1.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">Cancel</button>
<button type="submit" class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">Save</button>
</div>
</form>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{/if}
</section>
<!-- ========== Cards ========== -->
<section class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<div class="mb-4 flex items-center justify-between">
<h2 class="font-semibold text-gray-900 dark:text-white">Credit / Debit Cards</h2>
{#if isAdmin}
<button onclick={() => (showAddCard = !showAddCard)}
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
{showAddCard ? 'Cancel' : '+ Add Card'}
</button>
{/if}
</div>
<div class="mb-4 rounded-md bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:bg-amber-900/30 dark:text-amber-200">
<strong>Last 4 digits only.</strong> Never enter a full card number — this app does not store full PANs.
</div>
{#if showAddCard && isAdmin}
<form method="POST" action="?/addCard"
use:enhance={() => async ({ update }) => { await update(); showAddCard = false; }}
class="mb-4 grid grid-cols-1 sm:grid-cols-3 gap-3 rounded-md border border-blue-200 bg-blue-50 p-4 dark:border-blue-700/50 dark:bg-blue-900/20">
<div>
<label for="brand" class={labelCls}>Brand *</label>
<select id="brand" name="brand" required class={inputCls}>
{#each Object.entries(BRAND_LABELS) as [val, label]}<option value={val}>{label}</option>{/each}
</select>
</div>
<div><label for="last4" class={labelCls}>Last 4 *</label><input id="last4" name="last4" required maxlength="4" minlength="4" pattern="[0-9]{'{'}4{'}'}" inputmode="numeric" placeholder="1234" class={inputCls + ' font-mono'} /></div>
<div><label for="cardholderName" class={labelCls}>Cardholder *</label><input id="cardholderName" name="cardholderName" required class={inputCls} /></div>
<div><label for="expiryMonth" class={labelCls}>Expiry Month</label><input id="expiryMonth" name="expiryMonth" type="number" min="1" max="12" placeholder="MM" class={inputCls} /></div>
<div><label for="expiryYear" class={labelCls}>Expiry Year</label><input id="expiryYear" name="expiryYear" type="number" min="2024" max="2099" placeholder="YYYY" class={inputCls} /></div>
<div><label for="nickname" class={labelCls}>Nickname</label><input id="nickname" name="nickname" placeholder="e.g. Ops Visa" class={inputCls} /></div>
<div class="sm:col-span-3">
<label for="bankAccountId" class={labelCls}>Linked Bank Account</label>
<select id="bankAccountId" name="bankAccountId" class={inputCls}>
<option value="">— None —</option>
{#each data.bankAccounts as ba}<option value={ba.id}>{ba.bankName} · {maskAccount(ba.accountNumber)}</option>{/each}
</select>
</div>
<div class="sm:col-span-3"><label for="card-notes" class={labelCls}>Notes</label><textarea id="card-notes" name="notes" rows="2" class={inputCls}></textarea></div>
<div class="sm:col-span-3 flex justify-end">
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">Save</button>
</div>
</form>
{/if}
{#if data.cards.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">No cards on file.</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-800/50">
<tr class="text-left text-gray-500 dark:text-gray-400">
<th class="px-3 py-2 font-medium">Brand</th>
<th class="px-3 py-2 font-medium">Card</th>
<th class="px-3 py-2 font-medium">Cardholder</th>
<th class="px-3 py-2 font-medium">Expiry</th>
<th class="px-3 py-2 font-medium">Linked Bank</th>
<th class="px-3 py-2 font-medium">Nickname</th>
{#if isAdmin}<th class="px-3 py-2 font-medium"></th>{/if}
</tr>
</thead>
<tbody>
{#each data.cards as c}
<tr class="border-t border-gray-100 dark:border-gray-700">
<td class="px-3 py-2 font-medium text-gray-900 dark:text-white">{BRAND_LABELS[c.brand] ?? c.brand}</td>
<td class="px-3 py-2 font-mono text-gray-700 dark:text-gray-300">•••• {c.last4}</td>
<td class="px-3 py-2 text-gray-700 dark:text-gray-300">{c.cardholderName}</td>
<td class="px-3 py-2 text-gray-500 dark:text-gray-400">{formatExpiry(c.expiryMonth, c.expiryYear)}</td>
<td class="px-3 py-2 text-gray-500 dark:text-gray-400">{c.bankAccountLabel ?? '—'}</td>
<td class="px-3 py-2 text-gray-500 dark:text-gray-400">{c.nickname ?? '—'}</td>
{#if isAdmin}
<td class="px-3 py-2 text-right">
<form method="POST" action="?/removeCard"
use:enhance={({ cancel }) => {
if (!confirm('Remove this card?')) { cancel(); return; }
return async ({ update }) => await update({ reset: false });
}} class="inline">
<input type="hidden" name="id" value={c.id} />
<button type="submit" class="text-xs text-red-600 hover:text-red-800 dark:text-red-400">Remove</button>
</form>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
<!-- ========== Addresses ========== -->
<section class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<div class="mb-4 flex items-center justify-between">
<h2 class="font-semibold text-gray-900 dark:text-white">Addresses</h2>
{#if isAdmin}
<button onclick={() => (showAddAddress = !showAddAddress)}
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
<button
onclick={() => (showAddAddress = !showAddAddress)}
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
{showAddAddress ? 'Cancel' : '+ Add Address'}
</button>
{/if}
</div>
{#if showAddAddress && isAdmin}
<form method="POST" action="?/addAddress"
use:enhance={() => async ({ update }) => { await update(); showAddAddress = false; }}
class="mb-4 grid grid-cols-1 sm:grid-cols-2 gap-3 rounded-md border border-blue-200 bg-blue-50 p-4 dark:border-blue-700/50 dark:bg-blue-900/20">
<form
method="POST"
action="?/addAddress"
use:enhance={() => async ({ update }) => {
await update();
showAddAddress = false;
}}
class="mb-4 grid grid-cols-1 sm:grid-cols-2 gap-3 rounded-md border border-blue-200 bg-blue-50 p-4 dark:border-blue-700/50 dark:bg-blue-900/20"
>
<div>
<label for="addr-type" class={labelCls}>Type *</label>
<select id="addr-type" name="type" required class={inputCls}>