Add opening balance, manual transactions, and cross-currency transfers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 11:54:10 +07:00
parent aea6dbc06e
commit d75fe6ed95
2 changed files with 533 additions and 36 deletions
@@ -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,8 +290,12 @@ 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
const inserted = await db.transaction(async (tx) => {
const [row] = await tx
.insert(companyAccounts)
.values({
companyId: params.companyId,
@@ -282,12 +323,28 @@ export const actions: Actions = {
})
.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' };
}
};
@@ -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<string | null>(null);
let confirmDeleteId = $state<string | null>(null);
let addType = $state<AccountType>('bank');
let transferFrom = $state<string>('');
let transferTo = $state<string>('');
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,6 +367,25 @@
expenses, invoice payments, transfers, and manual entries.
</p>
</div>
<div class="flex flex-wrap gap-2">
{#if activeAccounts.length >= 1}
<button
type="button"
onclick={openManualTxn}
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
Record Transaction
</button>
{/if}
{#if activeAccounts.length >= 2}
<button
type="button"
onclick={openTransfer}
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
Transfer
</button>
{/if}
<button
type="button"
onclick={openAdd}
@@ -333,6 +393,7 @@
>
{showAddForm ? 'Cancel' : '+ New Account'}
</button>
</div>
</header>
{#if form?.error}
@@ -377,6 +438,38 @@
{@render accountFields(addType, 'add')}
<fieldset class="mt-4 rounded-md border border-gray-200 p-3 dark:border-gray-600">
<legend class="px-2 text-xs font-medium text-gray-500 dark:text-gray-400">
Opening balance (optional)
</legend>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label for="add-openingBalance" class={labelCls}>
Opening Balance
<span class="ml-1 text-xs text-gray-400">(negative for credit-card debt)</span>
</label>
<input
id="add-openingBalance"
name="openingBalance"
type="number"
step="0.01"
placeholder="0.00"
class={inputCls}
/>
</div>
<div>
<label for="add-openingBalanceDate" class={labelCls}>As of Date</label>
<input
id="add-openingBalanceDate"
name="openingBalanceDate"
type="date"
value={todayIso()}
class={inputCls}
/>
</div>
</div>
</fieldset>
<div class="mt-4 flex justify-end gap-2">
<button
type="button"
@@ -395,6 +488,221 @@
</form>
{/if}
{#if showTransferModal}
<form
method="POST"
action="?/postTransfer"
use:enhance={() => async ({ result, update, formElement }) => {
await update({ reset: false });
if (result.type === 'success') {
showTransferModal = false;
formElement.reset();
}
}}
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
>
<h2 class="mb-4 font-semibold text-gray-900 dark:text-white">Transfer Between Accounts</h2>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label for="xfer-from" class={labelCls}>From <span class="text-red-500">*</span></label>
<select id="xfer-from" name="fromAccountId" required bind:value={transferFrom} class={inputCls}>
{#each activeAccounts as a (a.id)}
<option value={a.id}>{a.name} ({a.currency})</option>
{/each}
</select>
</div>
<div>
<label for="xfer-to" class={labelCls}>To <span class="text-red-500">*</span></label>
<select id="xfer-to" name="toAccountId" required bind:value={transferTo} class={inputCls}>
{#each activeAccounts as a (a.id)}
<option value={a.id}>{a.name} ({a.currency})</option>
{/each}
</select>
</div>
<div>
<label for="xfer-amount" class={labelCls}>
Amount <span class="text-red-500">*</span>
{#if fromAccount}<span class="ml-1 text-xs text-gray-400">({fromAccount.currency})</span>{/if}
</label>
<input
id="xfer-amount"
name="amount"
type="number"
step="0.01"
min="0.01"
required
class={inputCls}
/>
</div>
<div>
<label for="xfer-date" class={labelCls}>Date <span class="text-red-500">*</span></label>
<input
id="xfer-date"
name="occurredAt"
type="date"
value={todayIso()}
required
class={inputCls}
/>
</div>
{#if isCrossCurrency}
<div class="md:col-span-2">
<p class="mb-2 text-xs text-amber-600 dark:text-amber-400">
Cross-currency transfer: enter either an FX rate OR a destination amount.
</p>
</div>
<div>
<label for="xfer-rate" class={labelCls}>
FX Rate
{#if fromAccount && toAccount}
<span class="ml-1 text-xs text-gray-400"
>(1 {fromAccount.currency} = ? {toAccount.currency})</span
>
{/if}
</label>
<input
id="xfer-rate"
name="fxRate"
type="number"
step="0.00000001"
min="0"
placeholder="e.g. 36.5"
class={inputCls}
/>
</div>
<div>
<label for="xfer-destAmt" class={labelCls}>
— or — Destination Amount
{#if toAccount}<span class="ml-1 text-xs text-gray-400">({toAccount.currency})</span>{/if}
</label>
<input
id="xfer-destAmt"
name="destinationAmount"
type="number"
step="0.01"
min="0"
placeholder="e.g. 36500.00"
class={inputCls}
/>
</div>
{/if}
<div class="md:col-span-2">
<label for="xfer-description" class={labelCls}>Description</label>
<input
id="xfer-description"
name="description"
type="text"
placeholder="Optional note"
class={inputCls}
/>
</div>
<div class="md:col-span-2">
<label for="xfer-reference" class={labelCls}>Reference</label>
<input id="xfer-reference" name="reference" type="text" class={inputCls} />
</div>
</div>
<div class="mt-4 flex justify-end gap-2">
<button
type="button"
onclick={() => (showTransferModal = false)}
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Post Transfer
</button>
</div>
</form>
{/if}
{#if showManualTxnModal}
<form
method="POST"
action="?/addManualTransaction"
use:enhance={() => 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"
>
<h2 class="mb-4 font-semibold text-gray-900 dark:text-white">Record Transaction</h2>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label for="mtxn-account" class={labelCls}>Account <span class="text-red-500">*</span></label>
<select id="mtxn-account" name="accountId" required class={inputCls}>
{#each activeAccounts as a (a.id)}
<option value={a.id}>{a.name} ({a.currency})</option>
{/each}
</select>
</div>
<div>
<label for="mtxn-type" class={labelCls}>Type <span class="text-red-500">*</span></label>
<select id="mtxn-type" name="type" required class={inputCls}>
<option value="deposit">Deposit (credit)</option>
<option value="adjustment">Adjustment (debit or credit)</option>
</select>
</div>
<div>
<label for="mtxn-amount" class={labelCls}>
Amount <span class="text-red-500">*</span>
<span class="ml-1 text-xs text-gray-400">(positive = credit, negative = debit)</span>
</label>
<input
id="mtxn-amount"
name="amount"
type="number"
step="0.01"
required
class={inputCls}
/>
</div>
<div>
<label for="mtxn-date" class={labelCls}>Date <span class="text-red-500">*</span></label>
<input
id="mtxn-date"
name="occurredAt"
type="date"
value={todayIso()}
required
class={inputCls}
/>
</div>
<div class="md:col-span-2">
<label for="mtxn-description" class={labelCls}>Description</label>
<input id="mtxn-description" name="description" type="text" class={inputCls} />
</div>
<div class="md:col-span-2">
<label for="mtxn-reference" class={labelCls}>Reference</label>
<input id="mtxn-reference" name="reference" type="text" class={inputCls} />
</div>
</div>
<div class="mt-4 flex justify-end gap-2">
<button
type="button"
onclick={() => (showManualTxnModal = false)}
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Record
</button>
</div>
</form>
{/if}
{#if data.accounts.length === 0}
<div
class="rounded-lg border border-dashed border-gray-300 bg-white p-10 text-center dark:border-gray-700 dark:bg-gray-800"