From 77c5d72e4364a3525a62855a87a63381f7b84a5f Mon Sep 17 00:00:00 2001 From: grabowski Date: Thu, 16 Apr 2026 14:06:53 +0700 Subject: [PATCH] Reconciliation link, account CSVs in export, drop legacy bank/card tables Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/server/accounts/migrate-legacy.ts | 115 -------- src/lib/server/db/schema.ts | 48 --- src/lib/server/export/financial.ts | 117 +++++--- .../integrations/transactions/+page.server.ts | 122 +++++++- .../integrations/transactions/+page.svelte | 39 +++ .../[companyId]/profile/+page.server.ts | 273 +----------------- .../[companyId]/profile/+page.svelte | 270 ++--------------- 7 files changed, 249 insertions(+), 735 deletions(-) delete mode 100644 src/lib/server/accounts/migrate-legacy.ts diff --git a/src/lib/server/accounts/migrate-legacy.ts b/src/lib/server/accounts/migrate-legacy.ts deleted file mode 100644 index 2f71a09..0000000 --- a/src/lib/server/accounts/migrate-legacy.ts +++ /dev/null @@ -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 { - const rows = await db.select().from(companyBankAccounts); - let inserted = 0; - for (const r of rows) { - // Skip if a row with same companyId + accountNumber already exists - const existing = await db - .select({ id: companyAccounts.id }) - .from(companyAccounts) - .where( - and( - eq(companyAccounts.companyId, r.companyId), - eq(companyAccounts.accountType, 'bank'), - eq(companyAccounts.accountNumber, r.accountNumber) - ) - ) - .limit(1); - if (existing.length > 0) continue; - - await db.insert(companyAccounts).values({ - companyId: r.companyId, - accountType: 'bank', - name: r.bankName + (r.accountNumber ? ` •••• ${r.accountNumber.slice(-4)}` : ''), - currency: r.currency, - isActive: r.isActive, - notes: r.notes, - bankName: r.bankName, - accountNumber: r.accountNumber, - branch: r.branch, - swiftBic: r.swiftBic, - iban: r.iban, - accountHolderName: r.accountName, - createdAt: r.createdAt, - updatedAt: r.updatedAt - }); - inserted++; - } - return inserted; -} - -async function migrateCards(): Promise { - const rows = await db.select().from(companyCards); - let inserted = 0; - for (const r of rows) { - const existing = await db - .select({ id: companyAccounts.id }) - .from(companyAccounts) - .where( - and( - eq(companyAccounts.companyId, r.companyId), - eq(companyAccounts.accountType, 'credit_card'), - eq(companyAccounts.last4, r.last4) - ) - ) - .limit(1); - if (existing.length > 0) continue; - - const displayName = - r.nickname ?? `${r.cardholderName} •••• ${r.last4}`; - - await db.insert(companyAccounts).values({ - companyId: r.companyId, - accountType: 'credit_card', - name: displayName, - currency: 'THB', - isActive: r.isActive, - notes: r.notes, - cardBrand: r.brand, - last4: r.last4, - cardholderName: r.cardholderName, - expiryMonth: r.expiryMonth, - expiryYear: r.expiryYear, - createdAt: r.createdAt, - updatedAt: r.updatedAt - }); - inserted++; - } - return inserted; -} - -async function main() { - console.log('Migrating legacy bank accounts and cards to companyAccounts…'); - const bankCount = await migrateBankAccounts(); - console.log(` bank accounts migrated: ${bankCount}`); - const cardCount = await migrateCards(); - console.log(` cards migrated: ${cardCount}`); - console.log('Done. Legacy tables left in place; Phase 6 will drop them.'); -} - -main() - .then(() => process.exit(0)) - .catch((err) => { - console.error(err); - process.exit(1); - }); diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 4a1f748..7d03f83 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -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', { diff --git a/src/lib/server/export/financial.ts b/src/lib/server/export/financial.ts index 2d03ca9..5fd85a1 100644 --- a/src/lib/server/export/financial.ts +++ b/src/lib/server/export/financial.ts @@ -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 ────────────────────────── diff --git a/src/routes/(app)/companies/[companyId]/integrations/transactions/+page.server.ts b/src/routes/(app)/companies/[companyId]/integrations/transactions/+page.server.ts index a9ba80b..e4053e3 100644 --- a/src/routes/(app)/companies/[companyId]/integrations/transactions/+page.server.ts +++ b/src/routes/(app)/companies/[companyId]/integrations/transactions/+page.server.ts @@ -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 = {}; + 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 }; } }; diff --git a/src/routes/(app)/companies/[companyId]/integrations/transactions/+page.svelte b/src/routes/(app)/companies/[companyId]/integrations/transactions/+page.svelte index 6ab0349..eeaead1 100644 --- a/src/routes/(app)/companies/[companyId]/integrations/transactions/+page.svelte +++ b/src/routes/(app)/companies/[companyId]/integrations/transactions/+page.svelte @@ -82,6 +82,7 @@ Description Counterparty Matched Expense + Ledger Account @@ -152,6 +153,44 @@ {/if} + + {#if data.postedMap[tx.id]} +
+ + {data.postedMap[tx.id].accountName ?? 'Account'} + +
+ + +
+
+ {:else if data.accounts.length === 0} + No accounts + {:else} +
+ + +
+ {/if} + {/each} diff --git a/src/routes/(app)/companies/[companyId]/profile/+page.server.ts b/src/routes/(app)/companies/[companyId]/profile/+page.server.ts index 519d047..a0a4c84 100644 --- a/src/routes/(app)/companies/[companyId]/profile/+page.server.ts +++ b/src/routes/(app)/companies/[companyId]/profile/+page.server.ts @@ -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`( - 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(); diff --git a/src/routes/(app)/companies/[companyId]/profile/+page.svelte b/src/routes/(app)/companies/[companyId]/profile/+page.svelte index a4e16f7..154b610 100644 --- a/src/routes/(app)/companies/[companyId]/profile/+page.svelte +++ b/src/routes/(app)/companies/[companyId]/profile/+page.svelte @@ -6,22 +6,9 @@ const isAdmin = $derived(data.companyRoles.includes('admin')); - let showAddBank = $state(false); - let editBankId = $state(null); - let showAddCard = $state(false); let showAddAddress = $state(false); let editAddressId = $state(null); - const BRAND_LABELS: Record = { - visa: 'Visa', - mastercard: 'Mastercard', - amex: 'American Express', - jcb: 'JCB', - unionpay: 'UnionPay', - discover: 'Discover', - other: 'Other' - }; - const ADDRESS_TYPE_LABELS: Record = { 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 @@

Company Profile

- 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 Accounts tab.

@@ -97,243 +76,30 @@ {/if} -
- Bank accounts and cards are now managed in the Accounts tab, where they also show rolling balances and transaction history. The sections below will be removed in an upcoming update. -
- - -
-
-

Bank Accounts

- {#if isAdmin} - - {/if} -
- - {#if showAddBank && isAdmin} -
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"> -
-
-
-
-
-
-
-
-
- -
- -
-
- {/if} - - {#if data.bankAccounts.length === 0} -

No bank accounts on file.

- {:else} -
- - - - - - - - - - {#if isAdmin}{/if} - - - - {#each data.bankAccounts as ba} - - - - - - - - {#if isAdmin} - - {/if} - - {#if editBankId === ba.id && isAdmin} - - - - {/if} - {/each} - -
BankAccount NameAccount NumberTypeCurrencyStatusActions
- {ba.bankName} - {#if ba.branch}({ba.branch}){/if} - {ba.accountName}{maskAccount(ba.accountNumber)}{ba.accountType ?? '—'}{ba.currency} - {#if ba.isPrimary}Primary{/if} - {#if !ba.isActive}Inactive{/if} - -
- - {#if !ba.isPrimary} -
- - -
- {/if} -
{ - if (!confirm('Remove this bank account?')) { cancel(); return; } - return async ({ update }) => await update({ reset: false }); - }} class="inline"> - - -
-
-
-
async ({ update }) => { await update({ reset: false }); editBankId = null; }} - class="grid grid-cols-1 sm:grid-cols-2 gap-3"> - -
Bank Name *
-
Account Name *
-
Account Number *
-
Type
-
Branch
-
Currency
-
SWIFT/BIC
-
IBAN
-
Notes
- -
- - -
-
-
-
- {/if} -
- - -
-
-

Credit / Debit Cards

- {#if isAdmin} - - {/if} -
- -
- Last 4 digits only. Never enter a full card number — this app does not store full PANs. -
- - {#if showAddCard && isAdmin} -
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"> -
- - -
-
-
-
-
-
-
- - -
-
-
- -
-
- {/if} - - {#if data.cards.length === 0} -

No cards on file.

- {:else} -
- - - - - - - - - - {#if isAdmin}{/if} - - - - {#each data.cards as c} - - - - - - - - {#if isAdmin} - - {/if} - - {/each} - -
BrandCardCardholderExpiryLinked BankNickname
{BRAND_LABELS[c.brand] ?? c.brand}•••• {c.last4}{c.cardholderName}{formatExpiry(c.expiryMonth, c.expiryYear)}{c.bankAccountLabel ?? '—'}{c.nickname ?? '—'} -
{ - if (!confirm('Remove this card?')) { cancel(); return; } - return async ({ update }) => await update({ reset: false }); - }} class="inline"> - - -
-
-
- {/if} -
-

Addresses

{#if isAdmin} - {/if}
{#if showAddAddress && isAdmin} -
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"> + 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" + >