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',
|
'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">
|
||||||
|
|||||||
Reference in New Issue
Block a user