Auto-post expenses and invoice payments to accounts ledger

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 12:04:15 +07:00
parent d75fe6ed95
commit 3a095851e9
9 changed files with 430 additions and 70 deletions
+8 -2
View File
@@ -259,7 +259,8 @@ export async function postInvoicePaymentTransaction(
total: invoices.total, total: invoices.total,
currency: invoices.currency, currency: invoices.currency,
issueDate: invoices.issueDate, issueDate: invoices.issueDate,
invoiceNumber: invoices.invoiceNumber invoiceNumber: invoices.invoiceNumber,
direction: invoices.direction
}) })
.from(invoices) .from(invoices)
.where(eq(invoices.id, invoiceId)) .where(eq(invoices.id, invoiceId))
@@ -273,6 +274,11 @@ export async function postInvoicePaymentTransaction(
.limit(1); .limit(1);
if (!acct) throw new Error(`postInvoicePaymentTransaction: account ${paymentAccountId} not found`); if (!acct) throw new Error(`postInvoicePaymentTransaction: account ${paymentAccountId} not found`);
// outgoing = we billed a customer → cash in (credit).
// incoming = we owe a supplier → cash out (debit).
const sign = inv.direction === 'outgoing' ? 1 : -1;
const signedAmount = sign * Number(inv.total);
await dbOrTx await dbOrTx
.delete(companyAccountTransactions) .delete(companyAccountTransactions)
.where(eq(companyAccountTransactions.sourceInvoiceId, invoiceId)); .where(eq(companyAccountTransactions.sourceInvoiceId, invoiceId));
@@ -281,7 +287,7 @@ export async function postInvoicePaymentTransaction(
accountId: paymentAccountId, accountId: paymentAccountId,
companyId: acct.companyId, companyId: acct.companyId,
type: 'invoice_payment', type: 'invoice_payment',
amount: Number(inv.total).toFixed(2), amount: signedAmount.toFixed(2),
currency: inv.currency, currency: inv.currency,
occurredAt: new Date(inv.issueDate), occurredAt: new Date(inv.issueDate),
description: `Invoice ${inv.invoiceNumber}`, description: `Invoice ${inv.invoiceNumber}`,
@@ -1,18 +1,28 @@
import { fail } from '@sveltejs/kit'; import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
import { expenses, projects, users, categories } from '$lib/server/db/schema.js'; import {
import { eq, and, sql } from 'drizzle-orm'; expenses,
import { requireCompanyRole } from '$lib/server/authorization.js'; projects,
users,
categories,
companyAccounts
} from '$lib/server/db/schema.js';
import { eq, and, sql, isNull } from 'drizzle-orm';
import { requireCompanyRole, requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js'; import { logCompanyEvent } from '$lib/server/audit.js';
import { formatCurrency } from '$lib/utils/currency.js'; import { formatCurrency } from '$lib/utils/currency.js';
import {
postExpenseTransaction,
removeExpenseTransaction
} from '$lib/server/accounts/ledger.js';
export const load: PageServerLoad = async ({ parent, params, url }) => { export const load: PageServerLoad = async ({ parent, params, url }) => {
await parent(); await parent();
const status = url.searchParams.get('status') || 'all'; const status = url.searchParams.get('status') || 'all';
let query = db const expenseList = await db
.select({ .select({
id: expenses.id, id: expenses.id,
title: expenses.title, title: expenses.title,
@@ -28,12 +38,15 @@ export const load: PageServerLoad = async ({ parent, params, url }) => {
projectId: projects.id, projectId: projects.id,
projectName: projects.name, projectName: projects.name,
categoryName: categories.name, categoryName: categories.name,
accountId: expenses.accountId,
accountName: companyAccounts.name,
createdAt: expenses.createdAt createdAt: expenses.createdAt
}) })
.from(expenses) .from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id)) .innerJoin(projects, eq(expenses.projectId, projects.id))
.innerJoin(users, eq(expenses.submittedBy, users.id)) .innerJoin(users, eq(expenses.submittedBy, users.id))
.leftJoin(categories, eq(expenses.categoryId, categories.id)) .leftJoin(categories, eq(expenses.categoryId, categories.id))
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
.where( .where(
status === 'all' status === 'all'
? eq(projects.companyId, params.companyId) ? eq(projects.companyId, params.companyId)
@@ -45,9 +58,24 @@ export const load: PageServerLoad = async ({ parent, params, url }) => {
.orderBy(sql`${expenses.createdAt} desc`) .orderBy(sql`${expenses.createdAt} desc`)
.limit(100); .limit(100);
const expenseList = await query; const accountsList = await db
.select({
id: companyAccounts.id,
name: companyAccounts.name,
currency: companyAccounts.currency,
accountType: companyAccounts.accountType
})
.from(companyAccounts)
.where(
and(
eq(companyAccounts.companyId, params.companyId),
eq(companyAccounts.isArchived, false),
isNull(companyAccounts.deletedAt)
)
)
.orderBy(companyAccounts.name);
return { expenses: expenseList, statusFilter: status }; return { expenses: expenseList, statusFilter: status, accounts: accountsList };
}; };
export const actions: Actions = { export const actions: Actions = {
@@ -58,27 +86,40 @@ export const actions: Actions = {
if (!expenseId) return fail(400, { error: 'Missing expense ID' }); if (!expenseId) return fail(400, { error: 'Missing expense ID' });
// Get expense details for the log
const [expense] = await db const [expense] = await db
.select({ title: expenses.title, amount: expenses.amount, currency: expenses.currency }) .select({
title: expenses.title,
amount: expenses.amount,
currency: expenses.currency,
accountId: expenses.accountId
})
.from(expenses) .from(expenses)
.where(eq(expenses.id, expenseId)) .where(eq(expenses.id, expenseId))
.limit(1); .limit(1);
await db await db.transaction(async (tx) => {
.update(expenses) await tx
.set({ .update(expenses)
status: 'approved', .set({
approvedBy: user.id, status: 'approved',
reviewedAt: new Date(), approvedBy: user.id,
updatedAt: new Date() reviewedAt: new Date(),
}) updatedAt: new Date()
.where(and(eq(expenses.id, expenseId), eq(expenses.status, 'pending'))); })
.where(and(eq(expenses.id, expenseId), eq(expenses.status, 'pending')));
if (expense?.accountId) {
await postExpenseTransaction(expenseId, expense.accountId, user.id, tx);
}
});
if (expense) { if (expense) {
await logCompanyEvent(params.companyId, user.id, 'expense_approved', await logCompanyEvent(
params.companyId,
user.id,
'expense_approved',
`Approved expense "${expense.title}" for ${formatCurrency(expense.amount, expense.currency)}`, `Approved expense "${expense.title}" for ${formatCurrency(expense.amount, expense.currency)}`,
{ expenseId, amount: expense.amount } { expenseId, amount: expense.amount, accountId: expense.accountId }
); );
} }
@@ -99,24 +140,99 @@ export const actions: Actions = {
.where(eq(expenses.id, expenseId)) .where(eq(expenses.id, expenseId))
.limit(1); .limit(1);
await db await db.transaction(async (tx) => {
.update(expenses) await tx
.set({ .update(expenses)
status: 'rejected', .set({
approvedBy: user.id, status: 'rejected',
reviewedAt: new Date(), approvedBy: user.id,
rejectionReason: reason, reviewedAt: new Date(),
updatedAt: new Date() rejectionReason: reason,
}) updatedAt: new Date()
.where(and(eq(expenses.id, expenseId), eq(expenses.status, 'pending'))); })
.where(and(eq(expenses.id, expenseId), eq(expenses.status, 'pending')));
// Defensive: remove any prior ledger post (e.g. if this expense was previously approved then reopened)
await removeExpenseTransaction(expenseId, tx);
});
if (expense) { if (expense) {
await logCompanyEvent(params.companyId, user.id, 'expense_rejected', await logCompanyEvent(
params.companyId,
user.id,
'expense_rejected',
`Rejected expense "${expense.title}" (${formatCurrency(expense.amount, expense.currency)})${reason ? `${reason}` : ''}`, `Rejected expense "${expense.title}" (${formatCurrency(expense.amount, expense.currency)})${reason ? `${reason}` : ''}`,
{ expenseId, amount: expense.amount, reason } { expenseId, amount: expense.amount, reason }
); );
} }
return { success: true };
},
updateExpenseAccount: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
const formData = await request.formData();
const expenseId = formData.get('expenseId')?.toString();
const rawAccountId = formData.get('accountId')?.toString().trim() ?? '';
const accountId = rawAccountId === '' ? null : rawAccountId;
if (!expenseId) return fail(400, { error: 'Missing expense ID' });
const [expense] = await db
.select({
id: expenses.id,
status: expenses.status,
title: expenses.title,
accountId: expenses.accountId,
projectCompanyId: projects.companyId
})
.from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id))
.where(eq(expenses.id, expenseId))
.limit(1);
if (!expense) error(404, 'Expense not found');
if (expense.projectCompanyId !== params.companyId) error(403, 'Forbidden');
if (accountId) {
const [acct] = await db
.select({ id: companyAccounts.id })
.from(companyAccounts)
.where(
and(
eq(companyAccounts.id, accountId),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
)
.limit(1);
if (!acct) return fail(400, { error: 'Invalid account' });
}
await db.transaction(async (tx) => {
await tx.update(expenses).set({ accountId, updatedAt: new Date() }).where(eq(expenses.id, expenseId));
// Only post to ledger if the expense is approved. Otherwise leave ledger untouched.
if (expense.status === 'approved') {
if (accountId) {
await postExpenseTransaction(expenseId, accountId, user.id, tx);
} else {
await removeExpenseTransaction(expenseId, tx);
}
}
});
await logCompanyEvent(
params.companyId,
user.id,
'account_transaction_added',
`Expense "${expense.title}" ${accountId ? 'assigned to account' : 'unassigned from account'}`,
{ expenseId, accountId, previousAccountId: expense.accountId }
);
return { success: true }; return { success: true };
} }
}; };
@@ -10,6 +10,11 @@
const canApprove = $derived( const canApprove = $derived(
data.companyRoles.includes('admin') || data.companyRoles.includes('manager') data.companyRoles.includes('admin') || data.companyRoles.includes('manager')
); );
const canAssignAccount = $derived(
data.companyRoles.includes('admin') ||
data.companyRoles.includes('manager') ||
data.companyRoles.includes('accountant')
);
</script> </script>
<svelte:head> <svelte:head>
@@ -57,6 +62,15 @@
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500"> <p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
By {expense.submitterName ?? expense.submitterEmail} · {expense.expenseDate} By {expense.submitterName ?? expense.submitterEmail} · {expense.expenseDate}
</p> </p>
{#if expense.accountName}
<p class="mt-1 text-xs">
<span
class="rounded-full bg-blue-100 px-2 py-0.5 font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-300"
>
Account: {expense.accountName}
</span>
</p>
{/if}
</div> </div>
<div class="text-right"> <div class="text-right">
<p class="text-lg font-semibold dark:text-white">{formatCurrency(expense.amount, expense.currency)}</p> <p class="text-lg font-semibold dark:text-white">{formatCurrency(expense.amount, expense.currency)}</p>
@@ -80,7 +94,7 @@
{/if} {/if}
{#if canApprove && expense.status === 'pending'} {#if canApprove && expense.status === 'pending'}
<div class="mt-3 flex gap-2 border-t border-gray-100 dark:border-gray-700 pt-3"> <div class="mt-3 flex flex-wrap gap-2 border-t border-gray-100 dark:border-gray-700 pt-3">
<form method="POST" action="?/approve" use:enhance> <form method="POST" action="?/approve" use:enhance>
<input type="hidden" name="expenseId" value={expense.id} /> <input type="hidden" name="expenseId" value={expense.id} />
<button <button
@@ -107,6 +121,37 @@
</form> </form>
</div> </div>
{/if} {/if}
{#if canAssignAccount && data.accounts.length > 0}
<form
method="POST"
action="?/updateExpenseAccount"
use:enhance
class="mt-3 flex flex-wrap items-center gap-2 border-t border-gray-100 pt-3 text-sm dark:border-gray-700"
>
<input type="hidden" name="expenseId" value={expense.id} />
<label class="text-xs text-gray-500 dark:text-gray-400" for="acct-{expense.id}">
Assign to account:
</label>
<select
id="acct-{expense.id}"
name="accountId"
value={expense.accountId ?? ''}
class="rounded-md border border-gray-300 bg-white px-2 py-1 text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="">— none —</option>
{#each data.accounts as acct (acct.id)}
<option value={acct.id}>{acct.name} ({acct.currency})</option>
{/each}
</select>
<button
type="submit"
class="rounded-md border border-gray-300 bg-white px-2 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
>
Save
</button>
</form>
{/if}
</div> </div>
{/each} {/each}
</div> </div>
@@ -1,10 +1,14 @@
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
import { invoices, parties } from '$lib/server/db/schema.js'; import { invoices, parties, companyAccounts } from '$lib/server/db/schema.js';
import { eq, and, sql, gte, lte } from 'drizzle-orm'; import { eq, and, sql, gte, lte, isNull } from 'drizzle-orm';
import { requireCompanyRoleAny } from '$lib/server/authorization.js'; import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js'; import { logCompanyEvent } from '$lib/server/audit.js';
import {
postInvoicePaymentTransaction,
removeInvoicePaymentTransaction
} from '$lib/server/accounts/ledger.js';
export const load: PageServerLoad = async ({ locals, params, url }) => { export const load: PageServerLoad = async ({ locals, params, url }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'user']); await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'user']);
@@ -52,7 +56,31 @@ export const load: PageServerLoad = async ({ locals, params, url }) => {
.orderBy(sql`${invoices.issueDate} desc`) .orderBy(sql`${invoices.issueDate} desc`)
.limit(200); .limit(200);
return { invoices: invoiceList, directionFilter, statusFilter, fromDate, toDate }; const accountsList = await db
.select({
id: companyAccounts.id,
name: companyAccounts.name,
currency: companyAccounts.currency,
accountType: companyAccounts.accountType
})
.from(companyAccounts)
.where(
and(
eq(companyAccounts.companyId, params.companyId),
eq(companyAccounts.isArchived, false),
isNull(companyAccounts.deletedAt)
)
)
.orderBy(companyAccounts.name);
return {
invoices: invoiceList,
directionFilter,
statusFilter,
fromDate,
toDate,
accounts: accountsList
};
}; };
export const actions: Actions = { export const actions: Actions = {
@@ -90,7 +118,10 @@ export const actions: Actions = {
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();
const invoiceId = formData.get('invoiceId')?.toString(); const invoiceId = formData.get('invoiceId')?.toString();
const paymentAccountId = formData.get('paymentAccountId')?.toString() || null;
if (!invoiceId) return fail(400, { error: 'Missing invoice ID' }); if (!invoiceId) return fail(400, { error: 'Missing invoice ID' });
if (!paymentAccountId)
return fail(400, { error: 'Payment account is required to mark an invoice paid' });
const [inv] = await db const [inv] = await db
.select({ invoiceNumber: invoices.invoiceNumber, total: invoices.total, currency: invoices.currency }) .select({ invoiceNumber: invoices.invoiceNumber, total: invoices.total, currency: invoices.currency })
@@ -100,17 +131,34 @@ export const actions: Actions = {
if (!inv) return fail(404, { error: 'Invoice not found' }); if (!inv) return fail(404, { error: 'Invoice not found' });
await db const [acct] = await db
.update(invoices) .select({ id: companyAccounts.id })
.set({ status: 'paid', updatedAt: new Date() }) .from(companyAccounts)
.where(and(eq(invoices.id, invoiceId), eq(invoices.companyId, params.companyId))); .where(
and(
eq(companyAccounts.id, paymentAccountId),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
)
.limit(1);
if (!acct) return fail(400, { error: 'Invalid payment account' });
await db.transaction(async (tx) => {
await tx
.update(invoices)
.set({ status: 'paid', paymentAccountId, updatedAt: new Date() })
.where(and(eq(invoices.id, invoiceId), eq(invoices.companyId, params.companyId)));
await postInvoicePaymentTransaction(invoiceId, paymentAccountId, user.id, tx);
});
await logCompanyEvent( await logCompanyEvent(
params.companyId, params.companyId,
user.id, user.id,
'invoice_paid', 'invoice_paid',
`Marked invoice ${inv.invoiceNumber} as paid`, `Marked invoice ${inv.invoiceNumber} as paid`,
{ invoiceId } { invoiceId, paymentAccountId }
); );
return { success: true }; return { success: true };
@@ -141,13 +141,29 @@
</form> </form>
{/if} {/if}
{#if inv.status === 'sent' || inv.status === 'overdue'} {#if inv.status === 'sent' || inv.status === 'overdue'}
<form method="POST" action="?/markPaid" use:enhance> {#if data.accounts.length === 0}
<input type="hidden" name="invoiceId" value={inv.id} /> <span class="text-xs text-gray-400" title="Create an account first to mark invoices paid">
<button type="submit" No account
class="rounded px-2 py-1 text-xs font-medium bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900/50"> </span>
Mark Paid {:else}
</button> <form method="POST" action="?/markPaid" use:enhance class="flex items-center gap-1">
</form> <input type="hidden" name="invoiceId" value={inv.id} />
<select
name="paymentAccountId"
required
class="rounded border border-gray-300 bg-white px-1 py-0.5 text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="">— Account —</option>
{#each data.accounts as acct (acct.id)}
<option value={acct.id}>{acct.name}</option>
{/each}
</select>
<button type="submit"
class="rounded px-2 py-1 text-xs font-medium bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900/50">
Mark Paid
</button>
</form>
{/if}
{/if} {/if}
</div> </div>
</td> </td>
@@ -7,11 +7,16 @@ import {
parties, parties,
expenses, expenses,
projects, projects,
packages packages,
companyAccounts
} from '$lib/server/db/schema.js'; } from '$lib/server/db/schema.js';
import { eq, and, isNull } from 'drizzle-orm'; import { eq, and, isNull } from 'drizzle-orm';
import { requireCompanyRoleAny } from '$lib/server/authorization.js'; import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js'; import { logCompanyEvent } from '$lib/server/audit.js';
import {
postInvoicePaymentTransaction,
removeInvoicePaymentTransaction
} from '$lib/server/accounts/ledger.js';
export const load: PageServerLoad = async ({ locals, params }) => { export const load: PageServerLoad = async ({ locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'user']); await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'user']);
@@ -30,6 +35,7 @@ export const load: PageServerLoad = async ({ locals, params }) => {
currency: invoices.currency, currency: invoices.currency,
notes: invoices.notes, notes: invoices.notes,
expenseId: invoices.expenseId, expenseId: invoices.expenseId,
paymentAccountId: invoices.paymentAccountId,
createdAt: invoices.createdAt, createdAt: invoices.createdAt,
partyId: invoices.partyId, partyId: invoices.partyId,
partyName: parties.name, partyName: parties.name,
@@ -49,6 +55,23 @@ export const load: PageServerLoad = async ({ locals, params }) => {
if (!invoice) error(404, 'Invoice not found'); if (!invoice) error(404, 'Invoice not found');
const accountsList = await db
.select({
id: companyAccounts.id,
name: companyAccounts.name,
currency: companyAccounts.currency,
accountType: companyAccounts.accountType
})
.from(companyAccounts)
.where(
and(
eq(companyAccounts.companyId, params.companyId),
eq(companyAccounts.isArchived, false),
isNull(companyAccounts.deletedAt)
)
)
.orderBy(companyAccounts.name);
const lineItems = await db const lineItems = await db
.select() .select()
.from(invoiceLineItems) .from(invoiceLineItems)
@@ -72,7 +95,7 @@ export const load: PageServerLoad = async ({ locals, params }) => {
.where(eq(packages.invoiceId, params.invoiceId)) .where(eq(packages.invoiceId, params.invoiceId))
.orderBy(packages.createdAt); .orderBy(packages.createdAt);
return { invoice, lineItems, projects: projectList, linkedPackages }; return { invoice, lineItems, projects: projectList, linkedPackages, accounts: accountsList };
}; };
export const actions: Actions = { export const actions: Actions = {
@@ -86,6 +109,7 @@ export const actions: Actions = {
| 'overdue' | 'overdue'
| 'cancelled' | 'cancelled'
| undefined; | undefined;
const paymentAccountId = formData.get('paymentAccountId')?.toString() || null;
const validStatuses = ['draft', 'sent', 'paid', 'overdue', 'cancelled']; const validStatuses = ['draft', 'sent', 'paid', 'overdue', 'cancelled'];
if (!newStatus || !validStatuses.includes(newStatus)) { if (!newStatus || !validStatuses.includes(newStatus)) {
@@ -93,22 +117,57 @@ export const actions: Actions = {
} }
const [inv] = await db const [inv] = await db
.select({ invoiceNumber: invoices.invoiceNumber }) .select({
invoiceNumber: invoices.invoiceNumber,
status: invoices.status,
paymentAccountId: invoices.paymentAccountId
})
.from(invoices) .from(invoices)
.where(and(eq(invoices.id, params.invoiceId), eq(invoices.companyId, params.companyId))) .where(and(eq(invoices.id, params.invoiceId), eq(invoices.companyId, params.companyId)))
.limit(1); .limit(1);
if (!inv) return fail(404, { error: 'Invoice not found' }); if (!inv) return fail(404, { error: 'Invoice not found' });
await db if (newStatus === 'paid') {
.update(invoices) if (!paymentAccountId) {
.set({ status: newStatus, updatedAt: new Date() }) return fail(400, { error: 'Payment account is required to mark an invoice paid' });
.where(and(eq(invoices.id, params.invoiceId), eq(invoices.companyId, params.companyId))); }
const [acct] = await db
.select({ id: companyAccounts.id })
.from(companyAccounts)
.where(
and(
eq(companyAccounts.id, paymentAccountId),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
)
.limit(1);
if (!acct) return fail(400, { error: 'Invalid payment account' });
}
await db.transaction(async (tx) => {
await tx
.update(invoices)
.set({
status: newStatus,
paymentAccountId: newStatus === 'paid' ? paymentAccountId : null,
updatedAt: new Date()
})
.where(and(eq(invoices.id, params.invoiceId), eq(invoices.companyId, params.companyId)));
if (newStatus === 'paid' && paymentAccountId) {
await postInvoicePaymentTransaction(params.invoiceId, paymentAccountId, user.id, tx);
} else if (inv.status === 'paid') {
// Status moved away from paid — remove ledger post
await removeInvoicePaymentTransaction(params.invoiceId, tx);
}
});
if (newStatus === 'sent') { if (newStatus === 'sent') {
await logCompanyEvent(params.companyId, user.id, 'invoice_sent', `Marked invoice ${inv.invoiceNumber} as sent`, { invoiceId: params.invoiceId }); await logCompanyEvent(params.companyId, user.id, 'invoice_sent', `Marked invoice ${inv.invoiceNumber} as sent`, { invoiceId: params.invoiceId });
} else if (newStatus === 'paid') { } else if (newStatus === 'paid') {
await logCompanyEvent(params.companyId, user.id, 'invoice_paid', `Marked invoice ${inv.invoiceNumber} as paid`, { invoiceId: params.invoiceId }); await logCompanyEvent(params.companyId, user.id, 'invoice_paid', `Marked invoice ${inv.invoiceNumber} as paid`, { invoiceId: params.invoiceId, paymentAccountId });
} }
return { success: true }; return { success: true };
@@ -202,17 +202,37 @@
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<!-- Status transitions --> <!-- Status transitions -->
{#each nextStatuses[inv.status] ?? [] as targetStatus} {#each nextStatuses[inv.status] ?? [] as targetStatus}
<form method="POST" action="?/updateStatus" use:enhance> <form method="POST" action="?/updateStatus" use:enhance class="flex items-center gap-1">
<input type="hidden" name="status" value={targetStatus} /> <input type="hidden" name="status" value={targetStatus} />
<button type="submit" {#if targetStatus === 'paid'}
class="rounded-md px-3 py-1.5 text-sm font-medium transition-colors {#if data.accounts.length === 0}
{targetStatus === 'paid' ? 'bg-green-600 text-white hover:bg-green-700' : <span class="text-xs text-gray-400" title="Create an account first to mark as paid">
targetStatus === 'sent' ? 'bg-blue-600 text-white hover:bg-blue-700' : No account
targetStatus === 'cancelled' ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-900/50' : </span>
targetStatus === 'overdue' ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300 hover:bg-amber-200' : {:else}
'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'}"> <select
Mark {targetStatus.charAt(0).toUpperCase() + targetStatus.slice(1)} name="paymentAccountId"
</button> required
class="rounded border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="">— Account —</option>
{#each data.accounts as acct (acct.id)}
<option value={acct.id}>{acct.name} ({acct.currency})</option>
{/each}
</select>
{/if}
{/if}
{#if targetStatus !== 'paid' || data.accounts.length > 0}
<button type="submit"
class="rounded-md px-3 py-1.5 text-sm font-medium transition-colors
{targetStatus === 'paid' ? 'bg-green-600 text-white hover:bg-green-700' :
targetStatus === 'sent' ? 'bg-blue-600 text-white hover:bg-blue-700' :
targetStatus === 'cancelled' ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-900/50' :
targetStatus === 'overdue' ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300 hover:bg-amber-200' :
'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'}">
Mark {targetStatus.charAt(0).toUpperCase() + targetStatus.slice(1)}
</button>
{/if}
</form> </form>
{/each} {/each}
@@ -1,8 +1,15 @@
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
import { expenses, categories, tags, expenseTags, projects } from '$lib/server/db/schema.js'; import {
import { eq, and } from 'drizzle-orm'; expenses,
categories,
tags,
expenseTags,
projects,
companyAccounts
} from '$lib/server/db/schema.js';
import { eq, and, isNull } from 'drizzle-orm';
import { requireCompanyRole } from '$lib/server/authorization.js'; import { requireCompanyRole } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js'; import { logCompanyEvent } from '$lib/server/audit.js';
import { formatCurrency } from '$lib/utils/currency.js'; import { formatCurrency } from '$lib/utils/currency.js';
@@ -22,6 +29,23 @@ export const load: PageServerLoad = async ({ locals, params }) => {
.where(eq(tags.companyId, params.companyId)) .where(eq(tags.companyId, params.companyId))
.orderBy(tags.name); .orderBy(tags.name);
const accountList = await db
.select({
id: companyAccounts.id,
name: companyAccounts.name,
currency: companyAccounts.currency,
accountType: companyAccounts.accountType
})
.from(companyAccounts)
.where(
and(
eq(companyAccounts.companyId, params.companyId),
eq(companyAccounts.isArchived, false),
isNull(companyAccounts.deletedAt)
)
)
.orderBy(companyAccounts.name);
// Get project info for the currency // Get project info for the currency
const [project] = await db const [project] = await db
.select({ name: projects.name }) .select({ name: projects.name })
@@ -29,7 +53,12 @@ export const load: PageServerLoad = async ({ locals, params }) => {
.where(eq(projects.id, params.projectId)) .where(eq(projects.id, params.projectId))
.limit(1); .limit(1);
return { categories: categoryList, tags: tagList, projectName: project?.name }; return {
categories: categoryList,
tags: tagList,
accounts: accountList,
projectName: project?.name
};
}; };
export const actions: Actions = { export const actions: Actions = {
@@ -42,6 +71,7 @@ export const actions: Actions = {
const amount = formData.get('amount')?.toString().trim(); const amount = formData.get('amount')?.toString().trim();
const expenseDate = formData.get('expenseDate')?.toString(); const expenseDate = formData.get('expenseDate')?.toString();
const categoryId = formData.get('categoryId')?.toString() || null; const categoryId = formData.get('categoryId')?.toString() || null;
const accountId = formData.get('accountId')?.toString() || null;
const tagIds = formData.getAll('tagIds').map((t) => t.toString()); const tagIds = formData.getAll('tagIds').map((t) => t.toString());
if (!title || !amount || !expenseDate) { if (!title || !amount || !expenseDate) {
@@ -69,6 +99,7 @@ export const actions: Actions = {
.values({ .values({
projectId: params.projectId, projectId: params.projectId,
categoryId: categoryId || null, categoryId: categoryId || null,
accountId: accountId || null,
submittedBy: user.id, submittedBy: user.id,
title, title,
description, description,
@@ -83,6 +83,25 @@
</select> </select>
</div> </div>
{#if data.accounts.length > 0}
<div class="mb-4">
<label for="accountId" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Funding Account
<span class="ml-1 text-xs text-gray-400">(posts on approval)</span>
</label>
<select
id="accountId"
name="accountId"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
>
<option value="">None — assign later</option>
{#each data.accounts as acct}
<option value={acct.id}>{acct.name} ({acct.currency})</option>
{/each}
</select>
</div>
{/if}
{#if data.tags.length > 0} {#if data.tags.length > 0}
<div class="mb-4"> <div class="mb-4">
<span class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Tags</span> <span class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Tags</span>