diff --git a/src/routes/(app)/companies/[companyId]/accounts/+page.server.ts b/src/routes/(app)/companies/[companyId]/accounts/+page.server.ts index c11174d..b29e2bf 100644 --- a/src/routes/(app)/companies/[companyId]/accounts/+page.server.ts +++ b/src/routes/(app)/companies/[companyId]/accounts/+page.server.ts @@ -8,8 +8,45 @@ import { } from '$lib/server/db/schema.js'; import { requireCompanyRoleAny } from '$lib/server/authorization.js'; import { logCompanyEvent } from '$lib/server/audit.js'; +import { + postTransaction, + postTransfer, + type CompanyAccountTxnType +} from '$lib/server/accounts/ledger.js'; import { and, asc, eq, isNull, sql } from 'drizzle-orm'; +const MANUAL_TXN_TYPES = ['deposit', 'adjustment'] as const; +type ManualTxnType = (typeof MANUAL_TXN_TYPES)[number]; + +function parseManualTxnType(v: FormDataEntryValue | null): ManualTxnType | null { + const s = v?.toString(); + if (!s) return null; + return (MANUAL_TXN_TYPES as readonly string[]).includes(s) ? (s as ManualTxnType) : null; +} + +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 parsePositiveAmount(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 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); +} + const ACCOUNT_TYPES = [ 'bank', 'credit_card', @@ -253,41 +290,61 @@ export const actions: Actions = { const f = parsed.fields; const sortOrder = await nextAccountSortOrder(params.companyId); + const openingBalance = parseSignedAmount(fd.get('openingBalance')); + const openingBalanceDate = + parseDate(fd.get('openingBalanceDate')) ?? new Date(); - 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 }); + const inserted = await db.transaction(async (tx) => { + const [row] = await tx + .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 }); + + if (openingBalance !== null) { + await postTransaction(tx, { + accountId: row.id, + companyId: params.companyId, + type: 'opening_balance', + amount: openingBalance, + currency: f.currency, + occurredAt: openingBalanceDate, + description: 'Opening balance', + createdBy: user.id + }); + } + + return row; + }); await logCompanyEvent( params.companyId, user.id, 'account_created', - `Account "${parsed.fields.name}" created`, - { accountId: inserted.id, accountType: parsed.fields.accountType } + `Account "${parsed.fields.name}" created${openingBalance !== null ? ` with opening balance ${openingBalance} ${f.currency}` : ''}`, + { accountId: inserted.id, accountType: parsed.fields.accountType, openingBalance } ); return { success: true, action: 'addAccount' }; @@ -502,5 +559,137 @@ export const actions: Actions = { }); return { success: true, action: 'reorderAccounts' }; + }, + + postTransfer: async ({ request, locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', + 'manager', + 'accountant' + ]); + const fd = await request.formData(); + const fromAccountId = trimOrNull(fd.get('fromAccountId')); + const toAccountId = trimOrNull(fd.get('toAccountId')); + const amount = parsePositiveAmount(fd.get('amount')); + const occurredAt = parseDate(fd.get('occurredAt')); + const description = trimOrNull(fd.get('description')); + const reference = trimOrNull(fd.get('reference')); + const fxRate = trimOrNull(fd.get('fxRate')); + const destinationAmount = trimOrNull(fd.get('destinationAmount')); + + if (!fromAccountId || !toAccountId) { + return fail(400, { action: 'postTransfer', error: 'Both from and to accounts are required' }); + } + if (fromAccountId === toAccountId) { + return fail(400, { action: 'postTransfer', error: 'From and to accounts must differ' }); + } + if (!amount) { + return fail(400, { action: 'postTransfer', error: 'Amount must be a positive number' }); + } + if (!occurredAt) { + return fail(400, { action: 'postTransfer', error: 'Valid date is required' }); + } + + try { + await postTransfer({ + fromAccountId, + toAccountId, + companyId: params.companyId, + amount, + occurredAt, + description, + reference, + fxRate, + destinationAmount, + createdBy: user.id + }); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Transfer failed'; + return fail(400, { action: 'postTransfer', error: msg }); + } + + await logCompanyEvent( + params.companyId, + user.id, + 'account_transfer_posted', + `Transferred ${amount} from account ${fromAccountId} to ${toAccountId}`, + { fromAccountId, toAccountId, amount, fxRate, destinationAmount } + ); + + return { success: true, action: 'postTransfer' }; + }, + + addManualTransaction: async ({ request, locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', + 'manager', + 'accountant' + ]); + const fd = await request.formData(); + const accountId = trimOrNull(fd.get('accountId')); + const type = parseManualTxnType(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 (!accountId) { + return fail(400, { action: 'addManualTransaction', error: 'Account is required' }); + } + if (!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, accountId), + eq(companyAccounts.companyId, params.companyId), + isNull(companyAccounts.deletedAt) + ) + ) + .limit(1); + if (!acct) error(404, 'Account not found'); + + const txnType: CompanyAccountTxnType = type; + await postTransaction(db, { + accountId, + companyId: params.companyId, + type: txnType, + 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, type, amount } + ); + + return { success: true, action: 'addManualTransaction' }; } }; diff --git a/src/routes/(app)/companies/[companyId]/accounts/+page.svelte b/src/routes/(app)/companies/[companyId]/accounts/+page.svelte index a7dfc4a..80a85a4 100644 --- a/src/routes/(app)/companies/[companyId]/accounts/+page.svelte +++ b/src/routes/(app)/companies/[companyId]/accounts/+page.svelte @@ -37,16 +37,57 @@ const ACCOUNT_TYPES = Object.keys(ACCOUNT_TYPE_LABELS) as AccountType[]; let showAddForm = $state(false); + let showTransferModal = $state(false); + let showManualTxnModal = $state(false); let editingId = $state(null); let confirmDeleteId = $state(null); let addType = $state('bank'); + let transferFrom = $state(''); + let transferTo = $state(''); + + const activeAccounts = $derived(data.accounts.filter((a) => !a.isArchived)); + const fromAccount = $derived(activeAccounts.find((a) => a.id === transferFrom)); + const toAccount = $derived(activeAccounts.find((a) => a.id === transferTo)); + const isCrossCurrency = $derived( + fromAccount && toAccount && fromAccount.currency !== toAccount.currency + ); function openAdd() { showAddForm = !showAddForm; + showTransferModal = false; + showManualTxnModal = false; editingId = null; confirmDeleteId = null; } + function openTransfer() { + showTransferModal = true; + showAddForm = false; + showManualTxnModal = false; + editingId = null; + confirmDeleteId = null; + if (activeAccounts.length >= 2) { + transferFrom = activeAccounts[0].id; + transferTo = activeAccounts[1].id; + } + } + + function openManualTxn() { + showManualTxnModal = true; + showAddForm = false; + showTransferModal = false; + editingId = null; + confirmDeleteId = null; + } + + function todayIso(): string { + const d = new Date(); + const yyyy = d.getFullYear(); + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + return `${yyyy}-${mm}-${dd}`; + } + function formatAmount(amount: string, currency: string): string { const n = Number(amount); const fmt = new Intl.NumberFormat(undefined, { @@ -326,13 +367,33 @@ expenses, invoice payments, transfers, and manual entries.

- +
+ {#if activeAccounts.length >= 1} + + {/if} + {#if activeAccounts.length >= 2} + + {/if} + +
{#if form?.error} @@ -377,6 +438,38 @@ {@render accountFields(addType, 'add')} +
+ + Opening balance (optional) + +
+
+ + +
+
+ + +
+
+
+
+ +
+ + {/if} + + {#if showManualTxnModal} +
async ({ result, update, formElement }) => { + await update({ reset: false }); + if (result.type === 'success') { + showManualTxnModal = false; + formElement.reset(); + } + }} + class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800" + > +

Record Transaction

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ {/if} + {#if data.accounts.length === 0}