Add invoice void with ledger reversal, required reason, and voided badge
Deploy to LXC / deploy (push) Successful in 1m56s
Validate / validate (push) Successful in 48s

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-17 15:18:35 +07:00
parent 0906a448b3
commit 5ff4f07ff4
3 changed files with 112 additions and 3 deletions
+5 -1
View File
@@ -432,7 +432,8 @@ export const invoiceStatusEnum = pgEnum('invoice_status', [
'sent', 'sent',
'paid', 'paid',
'overdue', 'overdue',
'cancelled' 'cancelled',
'voided'
]); ]);
export const invoices = pgTable( export const invoices = pgTable(
@@ -460,6 +461,8 @@ export const invoices = pgTable(
}), }),
notes: text('notes'), notes: text('notes'),
pdfPath: text('pdf_path'), pdfPath: text('pdf_path'),
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()
}, },
@@ -1199,6 +1202,7 @@ export const companyLogEventEnum = pgEnum('company_log_event', [
'invoice_created', 'invoice_created',
'invoice_sent', 'invoice_sent',
'invoice_paid', 'invoice_paid',
'invoice_voided',
'integration_connected', 'integration_connected',
'integration_disconnected', 'integration_disconnected',
'transaction_matched', 'transaction_matched',
@@ -34,6 +34,8 @@ export const load: PageServerLoad = async ({ locals, params }) => {
total: invoices.total, total: invoices.total,
currency: invoices.currency, currency: invoices.currency,
notes: invoices.notes, notes: invoices.notes,
voidedAt: invoices.voidedAt,
voidReason: invoices.voidReason,
expenseId: invoices.expenseId, expenseId: invoices.expenseId,
paymentAccountId: invoices.paymentAccountId, paymentAccountId: invoices.paymentAccountId,
createdAt: invoices.createdAt, createdAt: invoices.createdAt,
@@ -173,6 +175,52 @@ export const actions: Actions = {
return { success: true }; 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 }) => { linkExpense: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']); const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']);
const formData = await request.formData(); const formData = await request.formData();
@@ -16,7 +16,8 @@
sent: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', 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', 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', 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<string, string[]> = { const nextStatuses: Record<string, string[]> = {
@@ -24,9 +25,17 @@
sent: ['paid', 'overdue', 'cancelled'], sent: ['paid', 'overdue', 'cancelled'],
overdue: ['paid', 'cancelled'], overdue: ['paid', 'cancelled'],
paid: [], 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 showLinkExpense = $state(false);
let selectedProject = $state(''); let selectedProject = $state('');
</script> </script>
@@ -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"> 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 Download PDF
</a> </a>
{#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 Invoice
</button>
{/if}
</div> </div>
{#if showVoidForm}
<form method="POST" action="?/voidInvoice"
use:enhance={() => 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">
<p class="mb-2 text-sm font-medium text-red-700 dark:text-red-300">
Void invoice {inv.invoiceNumber}? This will reverse any ledger entry and cannot be undone.
</p>
<label for="void-reason" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Reason <span class="text-red-500">*</span>
</label>
<textarea
id="void-reason"
name="reason"
rows="2"
required
placeholder="e.g. Duplicate invoice, incorrect amount, wrong vendor"
class="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"
></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 inv.status === 'voided' && inv.voidReason}
<div class="mt-4 rounded-md border border-red-200 bg-red-50 p-3 text-sm dark:border-red-700 dark:bg-red-900/20">
<span class="font-medium text-red-700 dark:text-red-300">Voided:</span>
<span class="text-red-600 dark:text-red-400">{inv.voidReason}</span>
</div>
{/if}
<!-- Link expense (incoming only, not already linked) --> <!-- Link expense (incoming only, not already linked) -->
{#if inv.direction === 'incoming' && !inv.expenseId} {#if inv.direction === 'incoming' && !inv.expenseId}
<div class="mt-4 border-t border-gray-100 dark:border-gray-700 pt-4"> <div class="mt-4 border-t border-gray-100 dark:border-gray-700 pt-4">