From 5ff4f07ff49852f80266350b2056e3189597b8bb Mon Sep 17 00:00:00 2001 From: grabowski Date: Fri, 17 Apr 2026 15:18:35 +0700 Subject: [PATCH] Add invoice void with ledger reversal, required reason, and voided badge Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/server/db/schema.ts | 6 +- .../invoices/[invoiceId]/+page.server.ts | 48 +++++++++++++++ .../invoices/[invoiceId]/+page.svelte | 61 ++++++++++++++++++- 3 files changed, 112 insertions(+), 3 deletions(-) diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 6717597..a05a755 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -432,7 +432,8 @@ export const invoiceStatusEnum = pgEnum('invoice_status', [ 'sent', 'paid', 'overdue', - 'cancelled' + 'cancelled', + 'voided' ]); export const invoices = pgTable( @@ -460,6 +461,8 @@ export const invoices = pgTable( }), notes: text('notes'), pdfPath: text('pdf_path'), + 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() }, @@ -1199,6 +1202,7 @@ export const companyLogEventEnum = pgEnum('company_log_event', [ 'invoice_created', 'invoice_sent', 'invoice_paid', + 'invoice_voided', 'integration_connected', 'integration_disconnected', 'transaction_matched', diff --git a/src/routes/(app)/companies/[companyId]/invoices/[invoiceId]/+page.server.ts b/src/routes/(app)/companies/[companyId]/invoices/[invoiceId]/+page.server.ts index 1661ea2..2651249 100644 --- a/src/routes/(app)/companies/[companyId]/invoices/[invoiceId]/+page.server.ts +++ b/src/routes/(app)/companies/[companyId]/invoices/[invoiceId]/+page.server.ts @@ -34,6 +34,8 @@ export const load: PageServerLoad = async ({ locals, params }) => { total: invoices.total, currency: invoices.currency, notes: invoices.notes, + voidedAt: invoices.voidedAt, + voidReason: invoices.voidReason, expenseId: invoices.expenseId, paymentAccountId: invoices.paymentAccountId, createdAt: invoices.createdAt, @@ -173,6 +175,52 @@ export const actions: Actions = { return { success: true }; }, + voidInvoice: async ({ request, locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', + 'accountant' + ]); + const fd = await request.formData(); + const reason = fd.get('reason')?.toString().trim(); + + if (!reason) return fail(400, { error: 'Void reason is required' }); + + const [inv] = await db + .select({ invoiceNumber: invoices.invoiceNumber, status: invoices.status }) + .from(invoices) + .where(and(eq(invoices.id, params.invoiceId), eq(invoices.companyId, params.companyId))) + .limit(1); + + if (!inv) return fail(404, { error: 'Invoice not found' }); + if (inv.status === 'voided') return fail(400, { error: 'Invoice is already voided' }); + + await db.transaction(async (tx) => { + await tx + .update(invoices) + .set({ + status: 'voided', + voidedAt: new Date(), + voidReason: reason, + updatedAt: new Date() + }) + .where(and(eq(invoices.id, params.invoiceId), eq(invoices.companyId, params.companyId))); + + if (inv.status === 'paid') { + await removeInvoicePaymentTransaction(params.invoiceId, tx); + } + }); + + await logCompanyEvent( + params.companyId, + user.id, + 'invoice_voided', + `Invoice ${inv.invoiceNumber} voided: ${reason}`, + { invoiceId: params.invoiceId, reason } + ); + + return { success: true, voided: true }; + }, + linkExpense: async ({ request, locals, params }) => { const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']); const formData = await request.formData(); diff --git a/src/routes/(app)/companies/[companyId]/invoices/[invoiceId]/+page.svelte b/src/routes/(app)/companies/[companyId]/invoices/[invoiceId]/+page.svelte index f429e5e..c1168d5 100644 --- a/src/routes/(app)/companies/[companyId]/invoices/[invoiceId]/+page.svelte +++ b/src/routes/(app)/companies/[companyId]/invoices/[invoiceId]/+page.svelte @@ -16,7 +16,8 @@ sent: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', paid: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300', overdue: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300', - cancelled: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-500' + cancelled: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-500', + voided: 'bg-red-200 text-red-800 line-through dark:bg-red-900/50 dark:text-red-300' }; const nextStatuses: Record = { @@ -24,9 +25,17 @@ sent: ['paid', 'overdue', 'cancelled'], overdue: ['paid', 'cancelled'], paid: [], - cancelled: [] + cancelled: [], + voided: [] }; + const canVoid = $derived( + data.companyRoles.some((r: string) => r === 'admin' || r === 'accountant') && + inv.status !== 'voided' && + inv.status !== 'cancelled' + ); + let showVoidForm = $state(false); + let showLinkExpense = $state(false); let selectedProject = $state(''); @@ -241,8 +250,56 @@ class="rounded-md border border-gray-300 dark:border-gray-600 px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"> Download PDF + + {#if canVoid} + + {/if} + {#if showVoidForm} +
async ({ update }) => { + await update({ reset: false }); + showVoidForm = false; + }} + class="mt-4 rounded-md border border-red-200 bg-red-50 p-4 dark:border-red-700 dark:bg-red-900/20"> +

+ Void invoice {inv.invoiceNumber}? This will reverse any ledger entry and cannot be undone. +

+ + +
+ + +
+
+ {/if} + + {#if inv.status === 'voided' && inv.voidReason} +
+ Voided: + {inv.voidReason} +
+ {/if} + {#if inv.direction === 'incoming' && !inv.expenseId}