diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts
index ec3e62c..77efb21 100644
--- a/src/lib/server/db/schema.ts
+++ b/src/lib/server/db/schema.ts
@@ -1298,6 +1298,7 @@ export const companyLogEventEnum = pgEnum('company_log_event', [
'invoice_paid',
'invoice_voided',
'expense_invoice_uploaded',
+ 'expense_updated',
'sale_created',
'sale_confirmed',
'sale_voided',
diff --git a/src/routes/(app)/companies/[companyId]/expenses/[expenseId]/+page.server.ts b/src/routes/(app)/companies/[companyId]/expenses/[expenseId]/+page.server.ts
new file mode 100644
index 0000000..b7b21ad
--- /dev/null
+++ b/src/routes/(app)/companies/[companyId]/expenses/[expenseId]/+page.server.ts
@@ -0,0 +1,246 @@
+import { error, fail } from '@sveltejs/kit';
+import type { Actions, PageServerLoad } from './$types';
+import { db } from '$lib/server/db/index.js';
+import {
+ expenses,
+ projects,
+ users,
+ categories,
+ parties,
+ companyAccounts,
+ invoices,
+ packages,
+ expensePackages
+} from '$lib/server/db/schema.js';
+import { and, asc, eq, isNull, ne } from 'drizzle-orm';
+import { requireCompanyRoleAny } from '$lib/server/authorization.js';
+import { logCompanyEvent } from '$lib/server/audit.js';
+import { formatCurrency } from '$lib/utils/currency.js';
+import {
+ postExpenseTransaction,
+ removeExpenseTransaction
+} from '$lib/server/accounts/ledger.js';
+
+function trimOrNull(v: FormDataEntryValue | null): string | null {
+ const s = v?.toString().trim();
+ return s ? s : null;
+}
+
+export const load: PageServerLoad = async ({ locals, params, parent }) => {
+ await requireCompanyRoleAny(locals, params.companyId, [
+ 'admin', 'manager', 'user', 'accountant', 'hr'
+ ]);
+ await parent();
+
+ const [row] = await db
+ .select({
+ id: expenses.id,
+ title: expenses.title,
+ description: expenses.description,
+ amount: expenses.amount,
+ currency: expenses.currency,
+ status: expenses.status,
+ expenseDate: expenses.expenseDate,
+ rejectionReason: expenses.rejectionReason,
+ reviewedAt: expenses.reviewedAt,
+ createdAt: expenses.createdAt,
+ updatedAt: expenses.updatedAt,
+ projectId: expenses.projectId,
+ projectName: projects.name,
+ partyId: expenses.partyId,
+ partyName: parties.name,
+ categoryId: expenses.categoryId,
+ categoryName: categories.name,
+ accountId: expenses.accountId,
+ accountName: companyAccounts.name,
+ invoiceId: expenses.invoiceId,
+ invoiceFileUrl: expenses.invoiceFileUrl,
+ invoiceFileName: expenses.invoiceFileName,
+ paperlessUrl: expenses.paperlessUrl,
+ submitterName: users.displayName,
+ submitterEmail: users.email
+ })
+ .from(expenses)
+ .innerJoin(projects, eq(expenses.projectId, projects.id))
+ .innerJoin(users, eq(expenses.submittedBy, users.id))
+ .leftJoin(categories, eq(expenses.categoryId, categories.id))
+ .leftJoin(parties, eq(expenses.partyId, parties.id))
+ .leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
+ .where(and(eq(expenses.id, params.expenseId), eq(projects.companyId, params.companyId)))
+ .limit(1);
+
+ if (!row) error(404, 'Expense not found');
+
+ const projectList = await db
+ .select({ id: projects.id, name: projects.name })
+ .from(projects)
+ .where(and(eq(projects.companyId, params.companyId), eq(projects.isActive, true)))
+ .orderBy(asc(projects.name));
+
+ const categoryList = await db
+ .select({ id: categories.id, name: categories.name })
+ .from(categories)
+ .where(eq(categories.companyId, params.companyId))
+ .orderBy(asc(categories.name));
+
+ const accountList = await db
+ .select({
+ id: companyAccounts.id,
+ name: companyAccounts.name,
+ currency: companyAccounts.currency
+ })
+ .from(companyAccounts)
+ .where(
+ and(
+ eq(companyAccounts.companyId, params.companyId),
+ eq(companyAccounts.isArchived, false),
+ isNull(companyAccounts.deletedAt)
+ )
+ )
+ .orderBy(companyAccounts.name);
+
+ const partyList = await db
+ .select({ id: parties.id, name: parties.name })
+ .from(parties)
+ .where(and(eq(parties.companyId, params.companyId), isNull(parties.deletedAt)))
+ .orderBy(asc(parties.name));
+
+ const invoiceList = await db
+ .select({
+ id: invoices.id,
+ invoiceNumber: invoices.invoiceNumber,
+ direction: invoices.direction
+ })
+ .from(invoices)
+ .where(
+ and(
+ eq(invoices.companyId, params.companyId),
+ ne(invoices.status, 'voided'),
+ ne(invoices.status, 'cancelled')
+ )
+ )
+ .orderBy(asc(invoices.invoiceNumber));
+
+ const linkedPackages = await db
+ .select({
+ id: packages.id,
+ trackingNumber: packages.trackingNumber,
+ carrier: packages.carrier,
+ direction: packages.direction,
+ status: packages.status
+ })
+ .from(expensePackages)
+ .innerJoin(packages, eq(expensePackages.packageId, packages.id))
+ .where(eq(expensePackages.expenseId, params.expenseId));
+
+ return {
+ expense: row,
+ projects: projectList,
+ categories: categoryList,
+ accounts: accountList,
+ parties: partyList,
+ invoices: invoiceList,
+ linkedPackages
+ };
+};
+
+export const actions: Actions = {
+ updateExpense: async ({ request, locals, params }) => {
+ const { user, roles } = await requireCompanyRoleAny(locals, params.companyId, [
+ 'admin', 'manager', 'accountant'
+ ]);
+ const canManage = roles.some((r) => r === 'admin' || r === 'manager' || r === 'accountant');
+ if (!canManage) return fail(403, { error: 'Not permitted' });
+
+ const fd = await request.formData();
+ const title = trimOrNull(fd.get('title'));
+ const amountStr = fd.get('amount')?.toString().trim();
+ const expenseDate = trimOrNull(fd.get('expenseDate'));
+ const description = trimOrNull(fd.get('description'));
+ const projectId = trimOrNull(fd.get('projectId'));
+ const categoryId = trimOrNull(fd.get('categoryId'));
+ const partyId = trimOrNull(fd.get('partyId'));
+ const accountId = trimOrNull(fd.get('accountId'));
+ const invoiceId = trimOrNull(fd.get('invoiceId'));
+
+ if (!title) return fail(400, { action: 'updateExpense', error: 'Title is required' });
+ if (!amountStr || isNaN(Number(amountStr)) || Number(amountStr) <= 0) {
+ return fail(400, { action: 'updateExpense', error: 'Valid positive amount required' });
+ }
+ if (!expenseDate) return fail(400, { action: 'updateExpense', error: 'Date is required' });
+ if (!projectId) return fail(400, { action: 'updateExpense', error: 'Project is required' });
+
+ // Verify current expense belongs to this company
+ const [existing] = await db
+ .select({
+ id: expenses.id,
+ title: expenses.title,
+ amount: expenses.amount,
+ status: expenses.status,
+ accountId: expenses.accountId
+ })
+ .from(expenses)
+ .innerJoin(projects, eq(expenses.projectId, projects.id))
+ .where(
+ and(eq(expenses.id, params.expenseId), eq(projects.companyId, params.companyId))
+ )
+ .limit(1);
+ if (!existing) error(404, 'Expense not found');
+
+ // Verify target project belongs to this company
+ const [proj] = await db
+ .select({ id: projects.id })
+ .from(projects)
+ .where(and(eq(projects.id, projectId), eq(projects.companyId, params.companyId)))
+ .limit(1);
+ if (!proj) return fail(400, { action: 'updateExpense', error: 'Project not in this company' });
+
+ const newAmount = Number(amountStr).toFixed(2);
+ const amountChanged = newAmount !== existing.amount;
+ const accountChanged = (accountId ?? null) !== (existing.accountId ?? null);
+
+ await db.transaction(async (tx) => {
+ await tx
+ .update(expenses)
+ .set({
+ title,
+ description,
+ amount: newAmount,
+ expenseDate,
+ projectId,
+ categoryId,
+ partyId,
+ accountId,
+ invoiceId,
+ updatedAt: new Date()
+ })
+ .where(eq(expenses.id, params.expenseId));
+
+ // Re-post ledger entry if approved and amount or account changed
+ if (existing.status === 'approved' && (amountChanged || accountChanged)) {
+ if (accountId) {
+ await postExpenseTransaction(params.expenseId, accountId, user.id, tx);
+ } else {
+ await removeExpenseTransaction(params.expenseId, tx);
+ }
+ }
+ });
+
+ await logCompanyEvent(
+ params.companyId,
+ user.id,
+ 'expense_updated',
+ `Expense "${title}" edited (was ${formatCurrency(existing.amount, 'THB')})`,
+ {
+ expenseId: params.expenseId,
+ previousTitle: existing.title,
+ previousAmount: existing.amount,
+ newAmount,
+ amountChanged,
+ accountChanged
+ }
+ );
+
+ return { success: true, action: 'updateExpense' };
+ }
+};
diff --git a/src/routes/(app)/companies/[companyId]/expenses/[expenseId]/+page.svelte b/src/routes/(app)/companies/[companyId]/expenses/[expenseId]/+page.svelte
new file mode 100644
index 0000000..cb963e1
--- /dev/null
+++ b/src/routes/(app)/companies/[companyId]/expenses/[expenseId]/+page.svelte
@@ -0,0 +1,211 @@
+
+
+
Amount
+{formatCurrency(data.expense.amount, data.expense.currency)}
+Project
+ +Category
+{data.expense.categoryName ?? '—'}
+Supplier
+{data.expense.partyName ?? '—'}
+Account
+{data.expense.accountName ?? '—'}
+Created
+{formatDate(data.expense.createdAt)}
+Description
+{data.expense.description}
+Rejected
+{data.expense.rejectionReason}
+