Add invoice void with ledger reversal, required reason, and voided badge
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string, string[]> = {
|
||||
@@ -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('');
|
||||
</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">
|
||||
Download PDF
|
||||
</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>
|
||||
|
||||
{#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) -->
|
||||
{#if inv.direction === 'incoming' && !inv.expenseId}
|
||||
<div class="mt-4 border-t border-gray-100 dark:border-gray-700 pt-4">
|
||||
|
||||
Reference in New Issue
Block a user