Convert report amounts to base currency; add expense void action
Deploy to LXC / deploy (push) Successful in 1m57s
Validate / validate (push) Successful in 34s

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-20 16:56:03 +07:00
parent 8ef2ef7465
commit c570019fd8
4 changed files with 103 additions and 10 deletions
+4 -1
View File
@@ -18,7 +18,7 @@ import {
// ── Enums ────────────────────────────────────────────── // ── Enums ──────────────────────────────────────────────
export const companyRoleEnum = pgEnum('company_role', ['admin', 'manager', 'user', 'viewer', 'hr', 'accountant']); 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 ────────────────────────────────────────────── // ── Users ──────────────────────────────────────────────
@@ -151,6 +151,8 @@ export const expenses = pgTable(
invoiceFileName: text('invoice_file_name'), invoiceFileName: text('invoice_file_name'),
paperlessUrl: text('paperless_url'), paperlessUrl: text('paperless_url'),
paperlessDocumentId: integer('paperless_document_id'), paperlessDocumentId: integer('paperless_document_id'),
voidedAt: timestamp('voided_at', { withTimezone: true }),
voidReason: text('void_reason'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_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', 'invoice_voided',
'expense_invoice_uploaded', 'expense_invoice_uploaded',
'expense_updated', 'expense_updated',
'expense_voided',
'sale_created', 'sale_created',
'sale_confirmed', 'sale_confirmed',
'sale_voided', 'sale_voided',
@@ -59,6 +59,8 @@ export const load: PageServerLoad = async ({ locals, params, parent }) => {
invoiceFileUrl: expenses.invoiceFileUrl, invoiceFileUrl: expenses.invoiceFileUrl,
invoiceFileName: expenses.invoiceFileName, invoiceFileName: expenses.invoiceFileName,
paperlessUrl: expenses.paperlessUrl, paperlessUrl: expenses.paperlessUrl,
voidedAt: expenses.voidedAt,
voidReason: expenses.voidReason,
submitterName: users.displayName, submitterName: users.displayName,
submitterEmail: users.email submitterEmail: users.email
}) })
@@ -390,6 +392,47 @@ export const actions: Actions = {
return { success: true, action: 'linkPackage' }; 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 }) => { unlinkPackage: async ({ request, locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']); await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
const fd = await request.formData(); const fd = await request.formData();
@@ -15,9 +15,13 @@
const STATUS_BADGE: Record<string, string> = { const STATUS_BADGE: Record<string, string> = {
pending: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300', 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', 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 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'; const labelCls = 'mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300';
</script> </script>
@@ -41,10 +45,18 @@
</div> </div>
</div> </div>
{#if canManage && !editing} {#if canManage && !editing}
<button type="button" onclick={() => (editing = true)} <div class="flex gap-2">
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"> <button type="button" onclick={() => (editing = true)}
Edit class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
</button> Edit
</button>
{#if canVoid}
<button type="button" onclick={() => (showVoidForm = !showVoidForm)}
class="rounded-md border border-red-300 px-3 py-1.5 text-sm font-medium text-red-600 hover:bg-red-50 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/20">
Void
</button>
{/if}
</div>
{/if} {/if}
</div> </div>
</header> </header>
@@ -53,6 +65,35 @@
<div class="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">{form.error}</div> <div class="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">{form.error}</div>
{/if} {/if}
{#if data.expense.status === 'voided' && data.expense.voidReason}
<div class="rounded-md border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/20">
<span class="text-sm font-medium text-red-700 dark:text-red-300">Voided:</span>
<span class="text-sm text-red-600 dark:text-red-400">{data.expense.voidReason}</span>
</div>
{/if}
{#if showVoidForm && canVoid}
<form method="POST" action="?/voidExpense"
use:enhance={() => 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">
<p class="mb-2 text-sm font-medium text-red-700 dark:text-red-300">
Void this expense? This reverses any ledger entry. Cannot be undone.
</p>
<label for="void-reason" class={labelCls}>Reason <span class="text-red-500">*</span></label>
<textarea id="void-reason" name="reason" rows="2" required
placeholder="e.g. Duplicate, wrong supplier, incorrect amount"
class={inputCls}></textarea>
<div class="mt-2 flex justify-end gap-2">
<button type="button" onclick={() => (showVoidForm = false)}
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 dark:border-gray-600 dark:text-gray-200">Cancel</button>
<button type="submit" class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700">Confirm Void</button>
</div>
</form>
{/if}
{#if editing && canManage} {#if editing && canManage}
<form method="POST" action="?/updateExpense" <form method="POST" action="?/updateExpense"
use:enhance={() => async ({ result, update }) => { use:enhance={() => async ({ result, update }) => {
@@ -1,6 +1,6 @@
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js'; 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'; import { eq, and, sql, gte, lte } from 'drizzle-orm';
export const load: PageServerLoad = async ({ parent, params, url }) => { 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 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]; 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<string>`${expenses.amount} * coalesce(${companyAccounts.fxRateToBase}, 1)`;
// Spending by category // Spending by category
const byCategory = await db const byCategory = await db
.select({ .select({
categoryName: sql<string>`coalesce(${categories.name}, 'Uncategorized')`, categoryName: sql<string>`coalesce(${categories.name}, 'Uncategorized')`,
categoryColor: sql<string>`coalesce(${categories.color}, '#9CA3AF')`, categoryColor: sql<string>`coalesce(${categories.color}, '#9CA3AF')`,
total: sql<string>`sum(${expenses.amount})` total: sql<string>`sum(${convertedAmount})::text`
}) })
.from(expenses) .from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id)) .innerJoin(projects, eq(expenses.projectId, projects.id))
.leftJoin(categories, eq(expenses.categoryId, categories.id)) .leftJoin(categories, eq(expenses.categoryId, categories.id))
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
.where( .where(
and( and(
eq(projects.companyId, params.companyId), eq(projects.companyId, params.companyId),
@@ -34,10 +38,11 @@ export const load: PageServerLoad = async ({ parent, params, url }) => {
.select({ .select({
projectName: projects.name, projectName: projects.name,
allocated: projects.allocatedBudget, allocated: projects.allocatedBudget,
spent: sql<string>`sum(${expenses.amount})` spent: sql<string>`sum(${convertedAmount})::text`
}) })
.from(expenses) .from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id)) .innerJoin(projects, eq(expenses.projectId, projects.id))
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
.where( .where(
and( and(
eq(projects.companyId, params.companyId), eq(projects.companyId, params.companyId),
@@ -52,10 +57,11 @@ export const load: PageServerLoad = async ({ parent, params, url }) => {
const byMonth = await db const byMonth = await db
.select({ .select({
month: sql<string>`to_char(${expenses.expenseDate}::date, 'YYYY-MM')`, month: sql<string>`to_char(${expenses.expenseDate}::date, 'YYYY-MM')`,
total: sql<string>`sum(${expenses.amount})` total: sql<string>`sum(${convertedAmount})::text`
}) })
.from(expenses) .from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id)) .innerJoin(projects, eq(expenses.projectId, projects.id))
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
.where( .where(
and( and(
eq(projects.companyId, params.companyId), eq(projects.companyId, params.companyId),