diff --git a/src/routes/(app)/companies/[companyId]/+layout.svelte b/src/routes/(app)/companies/[companyId]/+layout.svelte index f7ec192..8be4013 100644 --- a/src/routes/(app)/companies/[companyId]/+layout.svelte +++ b/src/routes/(app)/companies/[companyId]/+layout.svelte @@ -35,6 +35,7 @@ : []), ...(data.companyRoles.some((r) => r === 'admin' || r === 'manager' || r === 'accountant') ? [ + { href: `/companies/${data.company.id}/accounts`, label: 'Accounts' }, { href: `/companies/${data.company.id}/profile`, label: 'Profile' }, { href: `/companies/${data.company.id}/documents`, label: 'Documents' } ] diff --git a/src/routes/(app)/companies/[companyId]/accounts/+page.server.ts b/src/routes/(app)/companies/[companyId]/accounts/+page.server.ts new file mode 100644 index 0000000..c11174d --- /dev/null +++ b/src/routes/(app)/companies/[companyId]/accounts/+page.server.ts @@ -0,0 +1,506 @@ +import { error, fail } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { db } from '$lib/server/db/index.js'; +import { + companyAccounts, + companyAccountTransactions, + externalAccounts +} from '$lib/server/db/schema.js'; +import { requireCompanyRoleAny } from '$lib/server/authorization.js'; +import { logCompanyEvent } from '$lib/server/audit.js'; +import { and, asc, eq, isNull, sql } from 'drizzle-orm'; + +const ACCOUNT_TYPES = [ + 'bank', + 'credit_card', + 'cash', + 'mobile_money', + 'petty_cash', + 'loan', + 'other' +] as const; +type AccountType = (typeof ACCOUNT_TYPES)[number]; + +const CARD_BRANDS = ['visa', 'mastercard', 'amex', 'jcb', 'unionpay', 'discover', 'other'] as const; +type CardBrand = (typeof CARD_BRANDS)[number]; + +function trimOrNull(v: FormDataEntryValue | null): string | null { + const s = v?.toString().trim(); + return s ? s : null; +} + +function parseIntOrNull(v: FormDataEntryValue | null): number | null { + const s = trimOrNull(v); + if (s === null) return null; + const n = Number.parseInt(s, 10); + return Number.isFinite(n) ? n : null; +} + +function parseDecimalOrNull(v: FormDataEntryValue | null): string | null { + const s = trimOrNull(v); + if (s === null) return null; + const n = Number(s); + if (!Number.isFinite(n)) return null; + return n.toFixed(2); +} + +function parseAccountType(v: FormDataEntryValue | null): AccountType | null { + const s = v?.toString(); + if (!s) return null; + return (ACCOUNT_TYPES as readonly string[]).includes(s) ? (s as AccountType) : null; +} + +function parseCardBrand(v: FormDataEntryValue | null): CardBrand | null { + const s = v?.toString(); + if (!s) return null; + return (CARD_BRANDS as readonly string[]).includes(s) ? (s as CardBrand) : null; +} + +type AccountFields = { + accountType: AccountType; + name: string; + currency: string; + notes: string | null; + bankName: string | null; + accountNumber: string | null; + branch: string | null; + swiftBic: string | null; + iban: string | null; + accountHolderName: string | null; + cardBrand: CardBrand | null; + last4: string | null; + cardholderName: string | null; + expiryMonth: number | null; + expiryYear: number | null; + creditLimit: string | null; + statementCloseDay: number | null; + paymentDueDay: number | null; + externalAccountId: string | null; +}; + +function extractAccountFields(fd: FormData): + | { ok: true; fields: AccountFields } + | { ok: false; error: string } { + const accountType = parseAccountType(fd.get('accountType')); + if (!accountType) return { ok: false, error: 'Account type is required' }; + + const name = trimOrNull(fd.get('name')); + if (!name) return { ok: false, error: 'Name is required' }; + + const currency = trimOrNull(fd.get('currency'))?.toUpperCase() ?? 'THB'; + if (!/^[A-Z]{3}$/.test(currency)) return { ok: false, error: 'Currency must be a 3-letter code' }; + + const last4 = trimOrNull(fd.get('last4')); + if (last4 !== null && !/^\d{4}$/.test(last4)) { + return { ok: false, error: 'Last 4 must be exactly 4 digits' }; + } + + const expiryMonth = parseIntOrNull(fd.get('expiryMonth')); + if (expiryMonth !== null && (expiryMonth < 1 || expiryMonth > 12)) { + return { ok: false, error: 'Expiry month must be 1-12' }; + } + const expiryYear = parseIntOrNull(fd.get('expiryYear')); + if (expiryYear !== null && (expiryYear < 2000 || expiryYear > 2100)) { + return { ok: false, error: 'Expiry year is out of range' }; + } + + const statementCloseDay = parseIntOrNull(fd.get('statementCloseDay')); + if (statementCloseDay !== null && (statementCloseDay < 1 || statementCloseDay > 31)) { + return { ok: false, error: 'Statement close day must be 1-31' }; + } + const paymentDueDay = parseIntOrNull(fd.get('paymentDueDay')); + if (paymentDueDay !== null && (paymentDueDay < 1 || paymentDueDay > 31)) { + return { ok: false, error: 'Payment due day must be 1-31' }; + } + + return { + ok: true, + fields: { + accountType, + name, + currency, + notes: trimOrNull(fd.get('notes')), + bankName: trimOrNull(fd.get('bankName')), + accountNumber: trimOrNull(fd.get('accountNumber')), + branch: trimOrNull(fd.get('branch')), + swiftBic: trimOrNull(fd.get('swiftBic')), + iban: trimOrNull(fd.get('iban')), + accountHolderName: trimOrNull(fd.get('accountHolderName')), + cardBrand: parseCardBrand(fd.get('cardBrand')), + last4, + cardholderName: trimOrNull(fd.get('cardholderName')), + expiryMonth, + expiryYear, + creditLimit: parseDecimalOrNull(fd.get('creditLimit')), + statementCloseDay, + paymentDueDay, + externalAccountId: trimOrNull(fd.get('externalAccountId')) + } + }; +} + +type OrderPayload = { id: string; sortOrder: number }; + +function parseOrderPayload(raw: FormDataEntryValue | null): OrderPayload[] | null { + if (!raw) return null; + let parsed: unknown; + try { + parsed = JSON.parse(raw.toString()); + } catch { + return null; + } + if (!Array.isArray(parsed)) return null; + const out: OrderPayload[] = []; + for (const row of parsed) { + if (!row || typeof row !== 'object') return null; + const r = row as Record; + if (typeof r.id !== 'string' || typeof r.sortOrder !== 'number') return null; + out.push({ id: r.id, sortOrder: r.sortOrder }); + } + return out; +} + +export const load: PageServerLoad = async ({ locals, params, parent, url }) => { + const { roles } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', + 'manager', + 'accountant' + ]); + await parent(); + + const showArchived = url.searchParams.get('archived') === '1'; + + const accountsWithBalance = await db + .select({ + id: companyAccounts.id, + accountType: companyAccounts.accountType, + name: companyAccounts.name, + currency: companyAccounts.currency, + isActive: companyAccounts.isActive, + isArchived: companyAccounts.isArchived, + notes: companyAccounts.notes, + sortOrder: companyAccounts.sortOrder, + bankName: companyAccounts.bankName, + accountNumber: companyAccounts.accountNumber, + branch: companyAccounts.branch, + swiftBic: companyAccounts.swiftBic, + iban: companyAccounts.iban, + accountHolderName: companyAccounts.accountHolderName, + cardBrand: companyAccounts.cardBrand, + last4: companyAccounts.last4, + cardholderName: companyAccounts.cardholderName, + expiryMonth: companyAccounts.expiryMonth, + expiryYear: companyAccounts.expiryYear, + creditLimit: companyAccounts.creditLimit, + statementCloseDay: companyAccounts.statementCloseDay, + paymentDueDay: companyAccounts.paymentDueDay, + externalAccountId: companyAccounts.externalAccountId, + createdAt: companyAccounts.createdAt, + balance: sql`coalesce(( + select sum(${companyAccountTransactions.amount}) + from ${companyAccountTransactions} + where ${companyAccountTransactions.accountId} = ${companyAccounts.id} + ), '0')::text` + }) + .from(companyAccounts) + .where( + and(eq(companyAccounts.companyId, params.companyId), isNull(companyAccounts.deletedAt)) + ) + .orderBy(asc(companyAccounts.isArchived), asc(companyAccounts.sortOrder), asc(companyAccounts.name)); + + const visibleAccounts = showArchived + ? accountsWithBalance + : accountsWithBalance.filter((a) => !a.isArchived); + + const externalAccountsList = await db + .select({ + id: externalAccounts.id, + displayName: externalAccounts.displayName, + provider: externalAccounts.provider + }) + .from(externalAccounts) + .where(and(eq(externalAccounts.companyId, params.companyId), eq(externalAccounts.isActive, true))); + + const canDelete = roles.includes('admin'); + + return { + accounts: visibleAccounts, + archivedCount: accountsWithBalance.filter((a) => a.isArchived).length, + showArchived, + externalAccounts: externalAccountsList, + canDelete + }; +}; + +async function nextAccountSortOrder(companyId: string): Promise { + const [row] = await db + .select({ max: sql`coalesce(max(${companyAccounts.sortOrder}), -1)::int` }) + .from(companyAccounts) + .where(and(eq(companyAccounts.companyId, companyId), isNull(companyAccounts.deletedAt))); + return (row?.max ?? -1) + 1; +} + +export const actions: Actions = { + addAccount: async ({ request, locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', + 'manager', + 'accountant' + ]); + const fd = await request.formData(); + const parsed = extractAccountFields(fd); + if (!parsed.ok) return fail(400, { action: 'addAccount', error: parsed.error }); + const f = parsed.fields; + + const sortOrder = await nextAccountSortOrder(params.companyId); + + const [inserted] = await db + .insert(companyAccounts) + .values({ + companyId: params.companyId, + sortOrder, + createdBy: user.id, + accountType: f.accountType, + name: f.name, + currency: f.currency, + notes: f.notes, + bankName: f.bankName, + accountNumber: f.accountNumber, + branch: f.branch, + swiftBic: f.swiftBic, + iban: f.iban, + accountHolderName: f.accountHolderName, + cardBrand: f.cardBrand, + last4: f.last4, + cardholderName: f.cardholderName, + expiryMonth: f.expiryMonth, + expiryYear: f.expiryYear, + creditLimit: f.creditLimit, + statementCloseDay: f.statementCloseDay, + paymentDueDay: f.paymentDueDay, + externalAccountId: f.externalAccountId + }) + .returning({ id: companyAccounts.id }); + + await logCompanyEvent( + params.companyId, + user.id, + 'account_created', + `Account "${parsed.fields.name}" created`, + { accountId: inserted.id, accountType: parsed.fields.accountType } + ); + + return { success: true, action: 'addAccount' }; + }, + + updateAccount: 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: 'updateAccount', error: 'Account id is required' }); + + const [existing] = await db + .select({ id: companyAccounts.id }) + .from(companyAccounts) + .where( + and( + eq(companyAccounts.id, id), + eq(companyAccounts.companyId, params.companyId), + isNull(companyAccounts.deletedAt) + ) + ) + .limit(1); + if (!existing) error(404, 'Account not found'); + + const parsed = extractAccountFields(fd); + if (!parsed.ok) return fail(400, { action: 'updateAccount', error: parsed.error }); + const f = parsed.fields; + + await db + .update(companyAccounts) + .set({ + accountType: f.accountType, + name: f.name, + currency: f.currency, + notes: f.notes, + bankName: f.bankName, + accountNumber: f.accountNumber, + branch: f.branch, + swiftBic: f.swiftBic, + iban: f.iban, + accountHolderName: f.accountHolderName, + cardBrand: f.cardBrand, + last4: f.last4, + cardholderName: f.cardholderName, + expiryMonth: f.expiryMonth, + expiryYear: f.expiryYear, + creditLimit: f.creditLimit, + statementCloseDay: f.statementCloseDay, + paymentDueDay: f.paymentDueDay, + externalAccountId: f.externalAccountId, + updatedAt: new Date() + }) + .where(eq(companyAccounts.id, id)); + + await logCompanyEvent( + params.companyId, + user.id, + 'account_updated', + `Account "${parsed.fields.name}" updated`, + { accountId: id } + ); + + return { success: true, action: 'updateAccount' }; + }, + + archiveAccount: async ({ request, locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']); + const fd = await request.formData(); + const id = trimOrNull(fd.get('id')); + if (!id) return fail(400, { action: 'archiveAccount', error: 'Account id is required' }); + + const [existing] = await db + .select({ id: companyAccounts.id, name: companyAccounts.name }) + .from(companyAccounts) + .where( + and( + eq(companyAccounts.id, id), + eq(companyAccounts.companyId, params.companyId), + isNull(companyAccounts.deletedAt) + ) + ) + .limit(1); + if (!existing) error(404, 'Account not found'); + + await db + .update(companyAccounts) + .set({ isArchived: true, updatedAt: new Date() }) + .where(eq(companyAccounts.id, id)); + + await logCompanyEvent( + params.companyId, + user.id, + 'account_archived', + `Account "${existing.name}" archived`, + { accountId: id } + ); + + return { success: true, action: 'archiveAccount' }; + }, + + unarchiveAccount: async ({ request, locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']); + const fd = await request.formData(); + const id = trimOrNull(fd.get('id')); + if (!id) return fail(400, { action: 'unarchiveAccount', error: 'Account id is required' }); + + const [existing] = await db + .select({ id: companyAccounts.id, name: companyAccounts.name }) + .from(companyAccounts) + .where( + and( + eq(companyAccounts.id, id), + eq(companyAccounts.companyId, params.companyId), + isNull(companyAccounts.deletedAt) + ) + ) + .limit(1); + if (!existing) error(404, 'Account not found'); + + await db + .update(companyAccounts) + .set({ isArchived: false, updatedAt: new Date() }) + .where(eq(companyAccounts.id, id)); + + await logCompanyEvent( + params.companyId, + user.id, + 'account_updated', + `Account "${existing.name}" unarchived`, + { accountId: id } + ); + + return { success: true, action: 'unarchiveAccount' }; + }, + + deleteAccount: async ({ request, locals, params }) => { + const { user, roles } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', + 'manager', + 'accountant' + ]); + if (!roles.includes('admin')) { + return fail(403, { action: 'deleteAccount', error: 'Only admins can delete accounts' }); + } + const fd = await request.formData(); + const id = trimOrNull(fd.get('id')); + if (!id) return fail(400, { action: 'deleteAccount', error: 'Account id is required' }); + + const [existing] = await db + .select({ id: companyAccounts.id, name: companyAccounts.name }) + .from(companyAccounts) + .where( + and( + eq(companyAccounts.id, id), + eq(companyAccounts.companyId, params.companyId), + isNull(companyAccounts.deletedAt) + ) + ) + .limit(1); + if (!existing) error(404, 'Account not found'); + + const [txnCount] = await db + .select({ count: sql`count(*)::int` }) + .from(companyAccountTransactions) + .where(eq(companyAccountTransactions.accountId, id)); + if ((txnCount?.count ?? 0) > 0) { + return fail(409, { + action: 'deleteAccount', + error: 'Cannot delete an account that has transactions. Archive it instead.' + }); + } + + await db + .update(companyAccounts) + .set({ deletedAt: new Date(), updatedAt: new Date() }) + .where(eq(companyAccounts.id, id)); + + await logCompanyEvent( + params.companyId, + user.id, + 'account_deleted', + `Account "${existing.name}" deleted`, + { accountId: id } + ); + + return { success: true, action: 'deleteAccount' }; + }, + + reorderAccounts: async ({ request, locals, params }) => { + await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']); + const fd = await request.formData(); + const payload = parseOrderPayload(fd.get('orders')); + if (!payload) return fail(400, { action: 'reorderAccounts', error: 'Invalid order payload' }); + + await db.transaction(async (tx) => { + for (const { id, sortOrder } of payload) { + await tx + .update(companyAccounts) + .set({ sortOrder, updatedAt: new Date() }) + .where( + and( + eq(companyAccounts.id, id), + eq(companyAccounts.companyId, params.companyId), + isNull(companyAccounts.deletedAt) + ) + ); + } + }); + + return { success: true, action: 'reorderAccounts' }; + } +}; diff --git a/src/routes/(app)/companies/[companyId]/accounts/+page.svelte b/src/routes/(app)/companies/[companyId]/accounts/+page.svelte new file mode 100644 index 0000000..a7dfc4a --- /dev/null +++ b/src/routes/(app)/companies/[companyId]/accounts/+page.svelte @@ -0,0 +1,620 @@ + + + + Accounts - {data.company.name} + + +{#snippet accountFields( + type: AccountType, + prefix: string, + prefill: { + name?: string; + currency?: string; + notes?: string | null; + bankName?: string | null; + accountNumber?: string | null; + branch?: string | null; + swiftBic?: string | null; + iban?: string | null; + accountHolderName?: string | null; + cardBrand?: string | null; + last4?: string | null; + cardholderName?: string | null; + expiryMonth?: number | null; + expiryYear?: number | null; + creditLimit?: string | null; + statementCloseDay?: number | null; + paymentDueDay?: number | null; + externalAccountId?: string | null; + } = {} +)} +
+
+ + +
+
+ + +
+
+ + {#if type === 'bank'} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {#if data.externalAccounts.length > 0} +
+ + +
+ {/if} + {/if} + + {#if type === 'credit_card'} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+ {/if} + +
+ + +
+
+{/snippet} + +
+
+
+

Accounts

+

+ Bank accounts, cards, cash, and any other fund source. Balances update automatically from + expenses, invoice payments, transfers, and manual entries. +

+
+ +
+ + {#if form?.error} +
+ {form.error} +
+ {/if} + + {#if showAddForm} +
async ({ result, update, formElement }) => { + await update({ reset: false }); + if (result.type === 'success') { + showAddForm = false; + formElement.reset(); + } + }} + class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800" + > +

New Account

+ +
+ + +
+ + {@render accountFields(addType, 'add')} + +
+ + +
+
+ {/if} + + {#if data.accounts.length === 0} +
+

+ No accounts yet. Click "+ New Account" to add one. +

+
+ {:else} +
+ {#each data.accounts as acct (acct.id)} +
+
+
+

+ + {acct.name} + +

+
+ + {ACCOUNT_TYPE_LABELS[acct.accountType]} + + {#if acct.isArchived} + + Archived + + {/if} +
+
+
+ +

+ {formatAmount(acct.balance, acct.currency)} +

+ + {#if acct.accountType === 'credit_card' && acct.creditLimit} + {@const pct = utilisation(acct.balance, acct.creditLimit)} +
+ Limit {formatAmount(acct.creditLimit, acct.currency)} + {#if pct !== null} + · {pct}% used +
+
+
+ {/if} +
+ {/if} + + {#if acct.accountType === 'credit_card' && (acct.statementCloseDay || acct.paymentDueDay)} +

+ {#if acct.statementCloseDay}Closes day {acct.statementCloseDay}{/if} + {#if acct.statementCloseDay && acct.paymentDueDay} · {/if} + {#if acct.paymentDueDay}Due day {acct.paymentDueDay}{/if} +

+ {/if} + + {#if acct.accountType === 'bank' && (acct.bankName || acct.accountNumber)} +

+ {acct.bankName ?? ''}{#if acct.accountNumber} · {acct.accountNumber}{/if} +

+ {/if} + +
+ + {#if acct.isArchived} +
+ + +
+ {:else} +
+ + +
+ {/if} + {#if data.canDelete} + + {/if} +
+ + {#if confirmDeleteId === acct.id} +
async ({ update }) => { + await update({ reset: false }); + confirmDeleteId = null; + }} + class="mt-2 rounded-md bg-red-50 p-2 text-xs dark:bg-red-900/30" + > + +

+ Delete "{acct.name}"? This only works if the account has zero transactions. +

+
+ + +
+
+ {/if} + + {#if editingId === acct.id} +
async ({ result, update }) => { + await update({ reset: false }); + if (result.type === 'success') editingId = null; + }} + class="mt-2 rounded-md bg-gray-50 p-3 dark:bg-gray-700/50" + > + + + {@render accountFields(acct.accountType, 'edit-' + acct.id, { + name: acct.name, + currency: acct.currency, + notes: acct.notes, + bankName: acct.bankName, + accountNumber: acct.accountNumber, + branch: acct.branch, + swiftBic: acct.swiftBic, + iban: acct.iban, + accountHolderName: acct.accountHolderName, + cardBrand: acct.cardBrand, + last4: acct.last4, + cardholderName: acct.cardholderName, + expiryMonth: acct.expiryMonth, + expiryYear: acct.expiryYear, + creditLimit: acct.creditLimit, + statementCloseDay: acct.statementCloseDay, + paymentDueDay: acct.paymentDueDay, + externalAccountId: acct.externalAccountId + })} +
+ + +
+
+ {/if} +
+ {/each} +
+ {/if} + + {#if data.archivedCount > 0} + + {/if} +
diff --git a/src/routes/(app)/companies/[companyId]/profile/+page.svelte b/src/routes/(app)/companies/[companyId]/profile/+page.svelte index 131f20f..a4e16f7 100644 --- a/src/routes/(app)/companies/[companyId]/profile/+page.svelte +++ b/src/routes/(app)/companies/[companyId]/profile/+page.svelte @@ -97,6 +97,15 @@ {/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. +
+