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) <noreply@anthropic.com>
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -15,9 +15,13 @@
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
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';
|
||||
</script>
|
||||
@@ -41,10 +45,18 @@
|
||||
</div>
|
||||
</div>
|
||||
{#if canManage && !editing}
|
||||
<div class="flex gap-2">
|
||||
<button type="button" onclick={() => (editing = true)}
|
||||
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
|
||||
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}
|
||||
</div>
|
||||
</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>
|
||||
{/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}
|
||||
<form method="POST" action="?/updateExpense"
|
||||
use:enhance={() => async ({ result, update }) => {
|
||||
|
||||
@@ -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<string>`${expenses.amount} * coalesce(${companyAccounts.fxRateToBase}, 1)`;
|
||||
|
||||
// Spending by category
|
||||
const byCategory = await db
|
||||
.select({
|
||||
categoryName: sql<string>`coalesce(${categories.name}, 'Uncategorized')`,
|
||||
categoryColor: sql<string>`coalesce(${categories.color}, '#9CA3AF')`,
|
||||
total: sql<string>`sum(${expenses.amount})`
|
||||
total: sql<string>`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<string>`sum(${expenses.amount})`
|
||||
spent: sql<string>`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<string>`to_char(${expenses.expenseDate}::date, 'YYYY-MM')`,
|
||||
total: sql<string>`sum(${expenses.amount})`
|
||||
total: sql<string>`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),
|
||||
|
||||
Reference in New Issue
Block a user