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