From 0d4fdb6fd7c03b0b98de31775efdfed504b34218 Mon Sep 17 00:00:00 2001 From: grabowski Date: Thu, 16 Apr 2026 13:58:44 +0700 Subject: [PATCH] Add account detail page with transaction history, filters, and CSV export Co-Authored-By: Claude Opus 4.6 (1M context) --- .../accounts/[accountId]/+page.server.ts | 324 +++++++++++ .../accounts/[accountId]/+page.svelte | 540 ++++++++++++++++++ .../accounts/[accountId]/export/+server.ts | 133 +++++ 3 files changed, 997 insertions(+) create mode 100644 src/routes/(app)/companies/[companyId]/accounts/[accountId]/+page.server.ts create mode 100644 src/routes/(app)/companies/[companyId]/accounts/[accountId]/+page.svelte create mode 100644 src/routes/(app)/companies/[companyId]/accounts/[accountId]/export/+server.ts diff --git a/src/routes/(app)/companies/[companyId]/accounts/[accountId]/+page.server.ts b/src/routes/(app)/companies/[companyId]/accounts/[accountId]/+page.server.ts new file mode 100644 index 0000000..4f6a597 --- /dev/null +++ b/src/routes/(app)/companies/[companyId]/accounts/[accountId]/+page.server.ts @@ -0,0 +1,324 @@ +import { error, fail } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { db } from '$lib/server/db/index.js'; +import { + companyAccounts, + companyAccountTransactions, + users +} from '$lib/server/db/schema.js'; +import { requireCompanyRoleAny } from '$lib/server/authorization.js'; +import { logCompanyEvent } from '$lib/server/audit.js'; +import { postTransaction } from '$lib/server/accounts/ledger.js'; +import { alias } from 'drizzle-orm/pg-core'; +import { and, desc, eq, gte, ilike, isNull, lte, or, sql } from 'drizzle-orm'; + +const ALL_TYPES = [ + 'opening_balance', + 'expense', + 'invoice_payment', + 'transfer_in', + 'transfer_out', + 'deposit', + 'adjustment', + 'reconciliation' +] as const; +type TxnType = (typeof ALL_TYPES)[number]; + +const EDITABLE_TYPES: readonly TxnType[] = ['deposit', 'adjustment']; + +function trimOrNull(v: FormDataEntryValue | null): string | null { + const s = v?.toString().trim(); + return s ? s : null; +} + +function parseSignedAmount(v: FormDataEntryValue | null): string | null { + const s = v?.toString(); + if (!s) return null; + const n = Number(s); + if (!Number.isFinite(n) || n === 0) return null; + return n.toFixed(2); +} + +function parseDate(v: FormDataEntryValue | null): Date | null { + const s = v?.toString(); + if (!s) return null; + const d = new Date(s); + return Number.isNaN(d.getTime()) ? null : d; +} + +function parseTxnType(v: FormDataEntryValue | null): TxnType | null { + const s = v?.toString(); + if (!s) return null; + return (ALL_TYPES as readonly string[]).includes(s) ? (s as TxnType) : null; +} + +const PAGE_SIZE = 50; + +export const load: PageServerLoad = async ({ locals, params, parent, url }) => { + await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']); + await parent(); + + const [account] = await db + .select() + .from(companyAccounts) + .where( + and( + eq(companyAccounts.id, params.accountId), + eq(companyAccounts.companyId, params.companyId), + isNull(companyAccounts.deletedAt) + ) + ) + .limit(1); + if (!account) error(404, 'Account not found'); + + const fromParam = url.searchParams.get('from'); + const toParam = url.searchParams.get('to'); + const typeParam = url.searchParams.get('type'); + const qParam = url.searchParams.get('q'); + const page = Math.max(1, Number(url.searchParams.get('page') ?? '1') || 1); + + const conditions = [eq(companyAccountTransactions.accountId, params.accountId)]; + if (fromParam) conditions.push(gte(companyAccountTransactions.occurredAt, new Date(fromParam))); + if (toParam) { + const toDate = new Date(toParam); + toDate.setHours(23, 59, 59, 999); + conditions.push(lte(companyAccountTransactions.occurredAt, toDate)); + } + if (typeParam && (ALL_TYPES as readonly string[]).includes(typeParam)) { + conditions.push(eq(companyAccountTransactions.type, typeParam as TxnType)); + } + if (qParam && qParam.trim()) { + const pattern = `%${qParam.trim()}%`; + conditions.push( + or( + ilike(companyAccountTransactions.description, pattern), + ilike(companyAccountTransactions.reference, pattern) + )! + ); + } + + const [totalRow] = await db + .select({ count: sql`count(*)::int` }) + .from(companyAccountTransactions) + .where(and(...conditions)); + const totalCount = totalRow?.count ?? 0; + + const counterparty = alias(companyAccounts, 'counterparty'); + + const transactions = await db + .select({ + id: companyAccountTransactions.id, + type: companyAccountTransactions.type, + amount: companyAccountTransactions.amount, + currency: companyAccountTransactions.currency, + occurredAt: companyAccountTransactions.occurredAt, + description: companyAccountTransactions.description, + reference: companyAccountTransactions.reference, + counterpartyAccountId: companyAccountTransactions.counterpartyAccountId, + counterpartyName: counterparty.name, + sourceExpenseId: companyAccountTransactions.sourceExpenseId, + sourceInvoiceId: companyAccountTransactions.sourceInvoiceId, + sourceExternalTransactionId: companyAccountTransactions.sourceExternalTransactionId, + fxRate: companyAccountTransactions.fxRate, + fxAmount: companyAccountTransactions.fxAmount, + createdByName: users.displayName, + createdByEmail: users.email, + createdAt: companyAccountTransactions.createdAt + }) + .from(companyAccountTransactions) + .leftJoin(counterparty, eq(companyAccountTransactions.counterpartyAccountId, counterparty.id)) + .leftJoin(users, eq(companyAccountTransactions.createdBy, users.id)) + .where(and(...conditions)) + .orderBy(desc(companyAccountTransactions.occurredAt), desc(companyAccountTransactions.createdAt)) + .limit(PAGE_SIZE) + .offset((page - 1) * PAGE_SIZE); + + const [balanceRow] = await db + .select({ + total: sql`coalesce(sum(${companyAccountTransactions.amount}), '0')::text` + }) + .from(companyAccountTransactions) + .where(eq(companyAccountTransactions.accountId, params.accountId)); + + return { + account, + transactions, + balance: balanceRow?.total ?? '0', + totalCount, + page, + pageSize: PAGE_SIZE, + filters: { + from: fromParam ?? '', + to: toParam ?? '', + type: typeParam ?? '', + q: qParam ?? '' + } + }; +}; + +export const actions: Actions = { + addManualTransaction: async ({ request, locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', + 'manager', + 'accountant' + ]); + const fd = await request.formData(); + const type = parseTxnType(fd.get('type')); + const amount = parseSignedAmount(fd.get('amount')); + const occurredAt = parseDate(fd.get('occurredAt')); + const description = trimOrNull(fd.get('description')); + const reference = trimOrNull(fd.get('reference')); + + if (!type || !(EDITABLE_TYPES as readonly string[]).includes(type)) { + return fail(400, { + action: 'addManualTransaction', + error: 'Type must be deposit or adjustment' + }); + } + if (!amount) { + return fail(400, { + action: 'addManualTransaction', + error: 'Amount must be a non-zero number' + }); + } + if (!occurredAt) { + return fail(400, { action: 'addManualTransaction', error: 'Valid date is required' }); + } + + const [acct] = await db + .select({ + id: companyAccounts.id, + currency: companyAccounts.currency, + name: companyAccounts.name + }) + .from(companyAccounts) + .where( + and( + eq(companyAccounts.id, params.accountId), + eq(companyAccounts.companyId, params.companyId), + isNull(companyAccounts.deletedAt) + ) + ) + .limit(1); + if (!acct) error(404, 'Account not found'); + + await postTransaction(db, { + accountId: params.accountId, + companyId: params.companyId, + type, + amount, + currency: acct.currency, + occurredAt, + description, + reference, + createdBy: user.id + }); + + await logCompanyEvent( + params.companyId, + user.id, + 'account_transaction_added', + `${type} of ${amount} ${acct.currency} recorded on "${acct.name}"`, + { accountId: params.accountId, type, amount } + ); + + return { success: true, action: 'addManualTransaction' }; + }, + + editTransaction: async ({ request, locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', + 'manager', + 'accountant' + ]); + const fd = await request.formData(); + const id = trimOrNull(fd.get('id')); + const amount = parseSignedAmount(fd.get('amount')); + const occurredAt = parseDate(fd.get('occurredAt')); + const description = trimOrNull(fd.get('description')); + const reference = trimOrNull(fd.get('reference')); + + if (!id) return fail(400, { action: 'editTransaction', error: 'Transaction id is required' }); + if (!amount) { + return fail(400, { action: 'editTransaction', error: 'Amount must be a non-zero number' }); + } + if (!occurredAt) { + return fail(400, { action: 'editTransaction', error: 'Valid date is required' }); + } + + const [existing] = await db + .select({ id: companyAccountTransactions.id, type: companyAccountTransactions.type }) + .from(companyAccountTransactions) + .where( + and( + eq(companyAccountTransactions.id, id), + eq(companyAccountTransactions.accountId, params.accountId) + ) + ) + .limit(1); + if (!existing) error(404, 'Transaction not found'); + if (!(EDITABLE_TYPES as readonly string[]).includes(existing.type)) { + return fail(400, { + action: 'editTransaction', + error: 'This transaction type cannot be edited (auto-posted from expense/invoice/transfer)' + }); + } + + await db + .update(companyAccountTransactions) + .set({ amount, occurredAt, description, reference, updatedAt: new Date() }) + .where(eq(companyAccountTransactions.id, id)); + + await logCompanyEvent( + params.companyId, + user.id, + 'account_transaction_added', + `Transaction edited on account ${params.accountId}`, + { accountId: params.accountId, transactionId: id } + ); + + return { success: true, action: 'editTransaction' }; + }, + + deleteTransaction: async ({ request, locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', + 'manager', + 'accountant' + ]); + const fd = await request.formData(); + const id = trimOrNull(fd.get('id')); + if (!id) return fail(400, { action: 'deleteTransaction', error: 'Transaction id is required' }); + + const [existing] = await db + .select({ id: companyAccountTransactions.id, type: companyAccountTransactions.type }) + .from(companyAccountTransactions) + .where( + and( + eq(companyAccountTransactions.id, id), + eq(companyAccountTransactions.accountId, params.accountId) + ) + ) + .limit(1); + if (!existing) error(404, 'Transaction not found'); + if (!(EDITABLE_TYPES as readonly string[]).includes(existing.type)) { + return fail(400, { + action: 'deleteTransaction', + error: 'This transaction type cannot be deleted (auto-posted from expense/invoice/transfer)' + }); + } + + await db.delete(companyAccountTransactions).where(eq(companyAccountTransactions.id, id)); + + await logCompanyEvent( + params.companyId, + user.id, + 'account_transaction_added', + `Transaction deleted on account ${params.accountId}`, + { accountId: params.accountId, transactionId: id } + ); + + return { success: true, action: 'deleteTransaction' }; + } +}; diff --git a/src/routes/(app)/companies/[companyId]/accounts/[accountId]/+page.svelte b/src/routes/(app)/companies/[companyId]/accounts/[accountId]/+page.svelte new file mode 100644 index 0000000..870bfd9 --- /dev/null +++ b/src/routes/(app)/companies/[companyId]/accounts/[accountId]/+page.svelte @@ -0,0 +1,540 @@ + + + + {data.account.name} - Accounts - {data.company.name} + + +
+
+ +
+
+

{data.account.name}

+

+ {data.account.accountType} · {data.account.currency} + {#if data.account.isArchived} · Archived{/if} +

+
+
+

+ {formatAmount(data.balance, data.account.currency)} +

+

Current balance

+
+
+ + {#if data.account.accountType === 'credit_card' && data.account.creditLimit} + {@const pct = utilisation(data.balance, data.account.creditLimit)} +
+
+ + Credit limit: {formatAmount(data.account.creditLimit, data.account.currency)} + + {#if pct !== null} + {pct}% used + {/if} +
+ {#if pct !== null} +
+
+
+ {/if} + {#if data.account.statementCloseDay || data.account.paymentDueDay} +

+ {#if data.account.statementCloseDay}Statement closes day {data.account.statementCloseDay}{/if} + {#if data.account.statementCloseDay && data.account.paymentDueDay} · {/if} + {#if data.account.paymentDueDay}Payment due day {data.account.paymentDueDay}{/if} +

+ {/if} +
+ {/if} +
+ + {#if form?.error} +
+ {form.error} +
+ {/if} + +
+
+ + to + + + + +
+
+ + Export CSV + + +
+
+ + {#if showRecord} +
async ({ result, update, formElement }) => { + await update({ reset: false }); + if (result.type === 'success') { + showRecord = false; + formElement.reset(); + } + }} + class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800" + > +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ {/if} + + {#if data.transactions.length === 0} +
+

No transactions match the current filters.

+
+ {:else} +
+ + + + + + + + + + + + + {#each data.transactions as txn (txn.id)} + {@const amt = Number(txn.amount)} + {@const isDebit = amt < 0} + + + + + + + + + + {#if editingId === txn.id} + + + + {/if} + + {#if confirmDeleteId === txn.id} + + + + {/if} + {/each} + +
DateTypeDescriptionDebitCreditActions
+ {formatDate(txn.occurredAt)} + + + {TYPE_LABELS[txn.type as TxnType]} + + +
{txn.description ?? '—'}
+ {#if txn.reference} +
Ref: {txn.reference}
+ {/if} + {#if txn.counterpartyName} +
+ Counterparty: {txn.counterpartyName} +
+ {/if} + {#if txn.fxRate && txn.fxAmount} +
+ FX: {txn.fxAmount} @ {Number(txn.fxRate).toFixed(4)} +
+ {/if} + {#if txn.createdByName} +
By {txn.createdByName}
+ {/if} +
+ {#if isDebit}{formatAmount(Math.abs(amt).toFixed(2), txn.currency)}{/if} + + {#if !isDebit}{formatAmount(amt.toFixed(2), txn.currency)}{/if} + + {#if EDITABLE_TYPES.includes(txn.type as TxnType)} +
+ + +
+ {:else} + + Locked + + {/if} +
+
async ({ result, update }) => { + await update({ reset: false }); + if (result.type === 'success') editingId = null; + }} + class="grid grid-cols-1 gap-2 md:grid-cols-4" + > + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
async ({ update }) => { + await update({ reset: false }); + confirmDeleteId = null; + }} + class="flex items-center justify-between gap-2" + > + + + Delete this {txn.type} transaction? + +
+ + +
+
+
+
+ + {#if totalPages > 1} +
+

+ Page {data.page} of {totalPages} · {data.totalCount} transactions +

+
+ {#if data.page > 1} + + ← Prev + + {/if} + {#if data.page < totalPages} + + Next → + + {/if} +
+
+ {/if} + {/if} +
diff --git a/src/routes/(app)/companies/[companyId]/accounts/[accountId]/export/+server.ts b/src/routes/(app)/companies/[companyId]/accounts/[accountId]/export/+server.ts new file mode 100644 index 0000000..a801c2e --- /dev/null +++ b/src/routes/(app)/companies/[companyId]/accounts/[accountId]/export/+server.ts @@ -0,0 +1,133 @@ +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { db } from '$lib/server/db/index.js'; +import { companyAccounts, companyAccountTransactions, users } from '$lib/server/db/schema.js'; +import { requireCompanyRoleAny } from '$lib/server/authorization.js'; +import { csvBuild } from '$lib/utils/csv.js'; +import { alias } from 'drizzle-orm/pg-core'; +import { and, desc, eq, gte, ilike, isNull, lte, or } from 'drizzle-orm'; + +function withBom(s: string): string { + return '\uFEFF' + s; +} + +export const GET: RequestHandler = async ({ locals, params, url }) => { + await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']); + + const [account] = await db + .select({ + id: companyAccounts.id, + name: companyAccounts.name, + currency: companyAccounts.currency + }) + .from(companyAccounts) + .where( + and( + eq(companyAccounts.id, params.accountId), + eq(companyAccounts.companyId, params.companyId), + isNull(companyAccounts.deletedAt) + ) + ) + .limit(1); + if (!account) error(404, 'Account not found'); + + const fromParam = url.searchParams.get('from'); + const toParam = url.searchParams.get('to'); + const typeParam = url.searchParams.get('type'); + const qParam = url.searchParams.get('q'); + + const conditions = [eq(companyAccountTransactions.accountId, params.accountId)]; + if (fromParam) conditions.push(gte(companyAccountTransactions.occurredAt, new Date(fromParam))); + if (toParam) { + const toDate = new Date(toParam); + toDate.setHours(23, 59, 59, 999); + conditions.push(lte(companyAccountTransactions.occurredAt, toDate)); + } + if (typeParam) { + conditions.push(eq(companyAccountTransactions.type, typeParam as never)); + } + if (qParam && qParam.trim()) { + const pattern = `%${qParam.trim()}%`; + conditions.push( + or( + ilike(companyAccountTransactions.description, pattern), + ilike(companyAccountTransactions.reference, pattern) + )! + ); + } + + const counterparty = alias(companyAccounts, 'counterparty'); + + const rows = await db + .select({ + id: companyAccountTransactions.id, + occurredAt: companyAccountTransactions.occurredAt, + type: companyAccountTransactions.type, + amount: companyAccountTransactions.amount, + currency: companyAccountTransactions.currency, + description: companyAccountTransactions.description, + reference: companyAccountTransactions.reference, + counterpartyName: counterparty.name, + fxRate: companyAccountTransactions.fxRate, + fxAmount: companyAccountTransactions.fxAmount, + sourceExpenseId: companyAccountTransactions.sourceExpenseId, + sourceInvoiceId: companyAccountTransactions.sourceInvoiceId, + sourceExternalTransactionId: companyAccountTransactions.sourceExternalTransactionId, + createdByName: users.displayName, + createdAt: companyAccountTransactions.createdAt + }) + .from(companyAccountTransactions) + .leftJoin(counterparty, eq(companyAccountTransactions.counterpartyAccountId, counterparty.id)) + .leftJoin(users, eq(companyAccountTransactions.createdBy, users.id)) + .where(and(...conditions)) + .orderBy(desc(companyAccountTransactions.occurredAt), desc(companyAccountTransactions.createdAt)); + + const header = [ + 'id', + 'occurredAt', + 'type', + 'amount', + 'currency', + 'description', + 'reference', + 'counterparty', + 'fxRate', + 'fxAmount', + 'sourceExpenseId', + 'sourceInvoiceId', + 'sourceExternalTransactionId', + 'createdBy', + 'createdAt' + ]; + const csvRows: unknown[][] = [header]; + for (const r of rows) { + csvRows.push([ + r.id, + r.occurredAt.toISOString(), + r.type, + r.amount, + r.currency, + r.description ?? '', + r.reference ?? '', + r.counterpartyName ?? '', + r.fxRate ?? '', + r.fxAmount ?? '', + r.sourceExpenseId ?? '', + r.sourceInvoiceId ?? '', + r.sourceExternalTransactionId ?? '', + r.createdByName ?? '', + r.createdAt.toISOString() + ]); + } + + const safeName = account.name.replace(/[^a-zA-Z0-9_-]+/g, '_').slice(0, 60) || 'account'; + const filename = `${safeName}-transactions.csv`; + + return new Response(withBom(csvBuild(csvRows)), { + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Cache-Control': 'private, no-store' + } + }); +};