From c570019fd807dbfc2533a62ea03a1e0931ed3f4a Mon Sep 17 00:00:00 2001 From: grabowski Date: Mon, 20 Apr 2026 16:56:03 +0700 Subject: [PATCH] Convert report amounts to base currency; add expense void action Reports: all three aggregations (byCategory, byProject, byMonth) left-join companyAccounts and multiply expense amounts by fxRateToBase before summing, so USD expenses show correctly. Expenses: new 'voided' status on expenseStatusEnum with voidedAt + voidReason columns. Void button on the detail page (admin/manager/ accountant) requires a reason, reverses the ledger entry, and writes an 'expense_voided' audit log entry. Status badge shows strikethrough red. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/server/db/schema.ts | 5 +- .../expenses/[expenseId]/+page.server.ts | 43 ++++++++++++++++ .../expenses/[expenseId]/+page.svelte | 51 +++++++++++++++++-- .../[companyId]/reports/+page.server.ts | 14 +++-- 4 files changed, 103 insertions(+), 10 deletions(-) diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 77efb21..4e64afb 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -18,7 +18,7 @@ import { // ── Enums ────────────────────────────────────────────── export const companyRoleEnum = pgEnum('company_role', ['admin', 'manager', 'user', 'viewer', 'hr', 'accountant']); -export const expenseStatusEnum = pgEnum('expense_status', ['pending', 'approved', 'rejected']); +export const expenseStatusEnum = pgEnum('expense_status', ['pending', 'approved', 'rejected', 'voided']); // ── Users ────────────────────────────────────────────── @@ -151,6 +151,8 @@ export const expenses = pgTable( invoiceFileName: text('invoice_file_name'), paperlessUrl: text('paperless_url'), paperlessDocumentId: integer('paperless_document_id'), + voidedAt: timestamp('voided_at', { withTimezone: true }), + voidReason: text('void_reason'), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow() }, @@ -1299,6 +1301,7 @@ export const companyLogEventEnum = pgEnum('company_log_event', [ 'invoice_voided', 'expense_invoice_uploaded', 'expense_updated', + 'expense_voided', '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 index a0287ab..4ecd036 100644 --- a/src/routes/(app)/companies/[companyId]/expenses/[expenseId]/+page.server.ts +++ b/src/routes/(app)/companies/[companyId]/expenses/[expenseId]/+page.server.ts @@ -59,6 +59,8 @@ export const load: PageServerLoad = async ({ locals, params, parent }) => { invoiceFileUrl: expenses.invoiceFileUrl, invoiceFileName: expenses.invoiceFileName, paperlessUrl: expenses.paperlessUrl, + voidedAt: expenses.voidedAt, + voidReason: expenses.voidReason, submitterName: users.displayName, submitterEmail: users.email }) @@ -390,6 +392,47 @@ export const actions: Actions = { return { success: true, action: 'linkPackage' }; }, + voidExpense: async ({ request, locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']); + const fd = await request.formData(); + const reason = fd.get('reason')?.toString().trim(); + if (!reason) return fail(400, { action: 'voidExpense', error: 'Void reason is required' }); + + const [existing] = await db + .select({ id: expenses.id, title: expenses.title, status: expenses.status }) + .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) return fail(404, { error: 'Expense not found' }); + if (existing.status === 'voided') return fail(400, { error: 'Expense is already voided' }); + + await db.transaction(async (tx) => { + await tx + .update(expenses) + .set({ + status: 'voided', + voidedAt: new Date(), + voidReason: reason, + updatedAt: new Date() + }) + .where(eq(expenses.id, params.expenseId)); + + // Reverse any ledger post for this expense + await removeExpenseTransaction(params.expenseId, tx); + }); + + await logCompanyEvent( + params.companyId, + user.id, + 'expense_voided', + `Expense "${existing.title}" voided: ${reason}`, + { expenseId: params.expenseId, reason, previousStatus: existing.status } + ); + + return { success: true, action: 'voidExpense' }; + }, + unlinkPackage: async ({ request, locals, params }) => { await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']); const fd = await request.formData(); diff --git a/src/routes/(app)/companies/[companyId]/expenses/[expenseId]/+page.svelte b/src/routes/(app)/companies/[companyId]/expenses/[expenseId]/+page.svelte index f70ec7b..6d3bd6a 100644 --- a/src/routes/(app)/companies/[companyId]/expenses/[expenseId]/+page.svelte +++ b/src/routes/(app)/companies/[companyId]/expenses/[expenseId]/+page.svelte @@ -15,9 +15,13 @@ const STATUS_BADGE: Record = { pending: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300', approved: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300', - rejected: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300' + rejected: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300', + voided: 'bg-red-200 text-red-800 line-through dark:bg-red-900/50 dark:text-red-300' }; + let showVoidForm = $state(false); + const canVoid = $derived(canManage && data.expense.status !== 'voided'); + const inputCls = 'w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white'; const labelCls = 'mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300'; @@ -41,10 +45,18 @@ {#if canManage && !editing} - +
+ + {#if canVoid} + + {/if} +
{/if} @@ -53,6 +65,35 @@
{form.error}
{/if} + {#if data.expense.status === 'voided' && data.expense.voidReason} +
+ Voided: + {data.expense.voidReason} +
+ {/if} + + {#if showVoidForm && canVoid} +
async ({ result, update }) => { + await update({ reset: false }); + if (result.type === 'success') showVoidForm = false; + }} + class="rounded-md border border-red-200 bg-red-50 p-4 dark:border-red-700 dark:bg-red-900/20"> +

+ Void this expense? This reverses any ledger entry. Cannot be undone. +

+ + +
+ + +
+
+ {/if} + {#if editing && canManage}
async ({ result, update }) => { diff --git a/src/routes/(app)/companies/[companyId]/reports/+page.server.ts b/src/routes/(app)/companies/[companyId]/reports/+page.server.ts index 5f12410..6085fdc 100644 --- a/src/routes/(app)/companies/[companyId]/reports/+page.server.ts +++ b/src/routes/(app)/companies/[companyId]/reports/+page.server.ts @@ -1,6 +1,6 @@ import type { PageServerLoad } from './$types'; import { db } from '$lib/server/db/index.js'; -import { expenses, projects, categories } from '$lib/server/db/schema.js'; +import { expenses, projects, categories, companyAccounts } from '$lib/server/db/schema.js'; import { eq, and, sql, gte, lte } from 'drizzle-orm'; export const load: PageServerLoad = async ({ parent, params, url }) => { @@ -9,16 +9,20 @@ export const load: PageServerLoad = async ({ parent, params, url }) => { const from = url.searchParams.get('from') || new Date(new Date().getFullYear(), 0, 1).toISOString().split('T')[0]; const to = url.searchParams.get('to') || new Date().toISOString().split('T')[0]; + // All amounts converted to company base currency via account FX rate. + const convertedAmount = sql`${expenses.amount} * coalesce(${companyAccounts.fxRateToBase}, 1)`; + // Spending by category const byCategory = await db .select({ categoryName: sql`coalesce(${categories.name}, 'Uncategorized')`, categoryColor: sql`coalesce(${categories.color}, '#9CA3AF')`, - total: sql`sum(${expenses.amount})` + total: sql`sum(${convertedAmount})::text` }) .from(expenses) .innerJoin(projects, eq(expenses.projectId, projects.id)) .leftJoin(categories, eq(expenses.categoryId, categories.id)) + .leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id)) .where( and( eq(projects.companyId, params.companyId), @@ -34,10 +38,11 @@ export const load: PageServerLoad = async ({ parent, params, url }) => { .select({ projectName: projects.name, allocated: projects.allocatedBudget, - spent: sql`sum(${expenses.amount})` + spent: sql`sum(${convertedAmount})::text` }) .from(expenses) .innerJoin(projects, eq(expenses.projectId, projects.id)) + .leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id)) .where( and( eq(projects.companyId, params.companyId), @@ -52,10 +57,11 @@ export const load: PageServerLoad = async ({ parent, params, url }) => { const byMonth = await db .select({ month: sql`to_char(${expenses.expenseDate}::date, 'YYYY-MM')`, - total: sql`sum(${expenses.amount})` + total: sql`sum(${convertedAmount})::text` }) .from(expenses) .innerJoin(projects, eq(expenses.projectId, projects.id)) + .leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id)) .where( and( eq(projects.companyId, params.companyId),