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:
@@ -259,7 +259,8 @@ export async function postInvoicePaymentTransaction(
|
||||
total: invoices.total,
|
||||
currency: invoices.currency,
|
||||
issueDate: invoices.issueDate,
|
||||
invoiceNumber: invoices.invoiceNumber
|
||||
invoiceNumber: invoices.invoiceNumber,
|
||||
direction: invoices.direction
|
||||
})
|
||||
.from(invoices)
|
||||
.where(eq(invoices.id, invoiceId))
|
||||
@@ -273,6 +274,11 @@ export async function postInvoicePaymentTransaction(
|
||||
.limit(1);
|
||||
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
|
||||
.delete(companyAccountTransactions)
|
||||
.where(eq(companyAccountTransactions.sourceInvoiceId, invoiceId));
|
||||
@@ -281,7 +287,7 @@ export async function postInvoicePaymentTransaction(
|
||||
accountId: paymentAccountId,
|
||||
companyId: acct.companyId,
|
||||
type: 'invoice_payment',
|
||||
amount: Number(inv.total).toFixed(2),
|
||||
amount: signedAmount.toFixed(2),
|
||||
currency: inv.currency,
|
||||
occurredAt: new Date(inv.issueDate),
|
||||
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 { db } from '$lib/server/db/index.js';
|
||||
import { expenses, projects, users, categories } from '$lib/server/db/schema.js';
|
||||
import { eq, and, sql } from 'drizzle-orm';
|
||||
import { requireCompanyRole } from '$lib/server/authorization.js';
|
||||
import {
|
||||
expenses,
|
||||
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 { formatCurrency } from '$lib/utils/currency.js';
|
||||
import {
|
||||
postExpenseTransaction,
|
||||
removeExpenseTransaction
|
||||
} from '$lib/server/accounts/ledger.js';
|
||||
|
||||
export const load: PageServerLoad = async ({ parent, params, url }) => {
|
||||
await parent();
|
||||
|
||||
const status = url.searchParams.get('status') || 'all';
|
||||
|
||||
let query = db
|
||||
const expenseList = await db
|
||||
.select({
|
||||
id: expenses.id,
|
||||
title: expenses.title,
|
||||
@@ -28,12 +38,15 @@ export const load: PageServerLoad = async ({ parent, params, url }) => {
|
||||
projectId: projects.id,
|
||||
projectName: projects.name,
|
||||
categoryName: categories.name,
|
||||
accountId: expenses.accountId,
|
||||
accountName: companyAccounts.name,
|
||||
createdAt: expenses.createdAt
|
||||
})
|
||||
.from(expenses)
|
||||
.innerJoin(projects, eq(expenses.projectId, projects.id))
|
||||
.innerJoin(users, eq(expenses.submittedBy, users.id))
|
||||
.leftJoin(categories, eq(expenses.categoryId, categories.id))
|
||||
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
|
||||
.where(
|
||||
status === 'all'
|
||||
? eq(projects.companyId, params.companyId)
|
||||
@@ -45,9 +58,24 @@ export const load: PageServerLoad = async ({ parent, params, url }) => {
|
||||
.orderBy(sql`${expenses.createdAt} desc`)
|
||||
.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 = {
|
||||
@@ -58,14 +86,19 @@ export const actions: Actions = {
|
||||
|
||||
if (!expenseId) return fail(400, { error: 'Missing expense ID' });
|
||||
|
||||
// Get expense details for the log
|
||||
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)
|
||||
.where(eq(expenses.id, expenseId))
|
||||
.limit(1);
|
||||
|
||||
await db
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(expenses)
|
||||
.set({
|
||||
status: 'approved',
|
||||
@@ -75,10 +108,18 @@ export const actions: Actions = {
|
||||
})
|
||||
.where(and(eq(expenses.id, expenseId), eq(expenses.status, 'pending')));
|
||||
|
||||
if (expense?.accountId) {
|
||||
await postExpenseTransaction(expenseId, expense.accountId, user.id, tx);
|
||||
}
|
||||
});
|
||||
|
||||
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)}`,
|
||||
{ expenseId, amount: expense.amount }
|
||||
{ expenseId, amount: expense.amount, accountId: expense.accountId }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -99,7 +140,8 @@ export const actions: Actions = {
|
||||
.where(eq(expenses.id, expenseId))
|
||||
.limit(1);
|
||||
|
||||
await db
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(expenses)
|
||||
.set({
|
||||
status: 'rejected',
|
||||
@@ -110,13 +152,87 @@ export const actions: Actions = {
|
||||
})
|
||||
.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) {
|
||||
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}` : ''}`,
|
||||
{ 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 };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
const canApprove = $derived(
|
||||
data.companyRoles.includes('admin') || data.companyRoles.includes('manager')
|
||||
);
|
||||
const canAssignAccount = $derived(
|
||||
data.companyRoles.includes('admin') ||
|
||||
data.companyRoles.includes('manager') ||
|
||||
data.companyRoles.includes('accountant')
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -57,6 +62,15 @@
|
||||
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
By {expense.submitterName ?? expense.submitterEmail} · {expense.expenseDate}
|
||||
</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 class="text-right">
|
||||
<p class="text-lg font-semibold dark:text-white">{formatCurrency(expense.amount, expense.currency)}</p>
|
||||
@@ -80,7 +94,7 @@
|
||||
{/if}
|
||||
|
||||
{#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>
|
||||
<input type="hidden" name="expenseId" value={expense.id} />
|
||||
<button
|
||||
@@ -107,6 +121,37 @@
|
||||
</form>
|
||||
</div>
|
||||
{/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>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { invoices, parties } from '$lib/server/db/schema.js';
|
||||
import { eq, and, sql, gte, lte } from 'drizzle-orm';
|
||||
import { invoices, parties, companyAccounts } from '$lib/server/db/schema.js';
|
||||
import { eq, and, sql, gte, lte, isNull } from 'drizzle-orm';
|
||||
import { requireCompanyRoleAny } from '$lib/server/authorization.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 }) => {
|
||||
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`)
|
||||
.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 = {
|
||||
@@ -90,7 +118,10 @@ export const actions: Actions = {
|
||||
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']);
|
||||
const formData = await request.formData();
|
||||
const invoiceId = formData.get('invoiceId')?.toString();
|
||||
const paymentAccountId = formData.get('paymentAccountId')?.toString() || null;
|
||||
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
|
||||
.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' });
|
||||
|
||||
await db
|
||||
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: 'paid', updatedAt: new Date() })
|
||||
.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(
|
||||
params.companyId,
|
||||
user.id,
|
||||
'invoice_paid',
|
||||
`Marked invoice ${inv.invoiceNumber} as paid`,
|
||||
{ invoiceId }
|
||||
{ invoiceId, paymentAccountId }
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
|
||||
@@ -141,14 +141,30 @@
|
||||
</form>
|
||||
{/if}
|
||||
{#if inv.status === 'sent' || inv.status === 'overdue'}
|
||||
<form method="POST" action="?/markPaid" use:enhance>
|
||||
{#if data.accounts.length === 0}
|
||||
<span class="text-xs text-gray-400" title="Create an account first to mark invoices paid">
|
||||
No account
|
||||
</span>
|
||||
{:else}
|
||||
<form method="POST" action="?/markPaid" use:enhance class="flex items-center gap-1">
|
||||
<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}
|
||||
</div>
|
||||
</td>
|
||||
{/if}
|
||||
|
||||
@@ -7,11 +7,16 @@ import {
|
||||
parties,
|
||||
expenses,
|
||||
projects,
|
||||
packages
|
||||
packages,
|
||||
companyAccounts
|
||||
} from '$lib/server/db/schema.js';
|
||||
import { eq, and, isNull } from 'drizzle-orm';
|
||||
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
|
||||
import { logCompanyEvent } from '$lib/server/audit.js';
|
||||
import {
|
||||
postInvoicePaymentTransaction,
|
||||
removeInvoicePaymentTransaction
|
||||
} from '$lib/server/accounts/ledger.js';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'user']);
|
||||
@@ -30,6 +35,7 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
currency: invoices.currency,
|
||||
notes: invoices.notes,
|
||||
expenseId: invoices.expenseId,
|
||||
paymentAccountId: invoices.paymentAccountId,
|
||||
createdAt: invoices.createdAt,
|
||||
partyId: invoices.partyId,
|
||||
partyName: parties.name,
|
||||
@@ -49,6 +55,23 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
|
||||
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
|
||||
.select()
|
||||
.from(invoiceLineItems)
|
||||
@@ -72,7 +95,7 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
.where(eq(packages.invoiceId, params.invoiceId))
|
||||
.orderBy(packages.createdAt);
|
||||
|
||||
return { invoice, lineItems, projects: projectList, linkedPackages };
|
||||
return { invoice, lineItems, projects: projectList, linkedPackages, accounts: accountsList };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
@@ -86,6 +109,7 @@ export const actions: Actions = {
|
||||
| 'overdue'
|
||||
| 'cancelled'
|
||||
| undefined;
|
||||
const paymentAccountId = formData.get('paymentAccountId')?.toString() || null;
|
||||
|
||||
const validStatuses = ['draft', 'sent', 'paid', 'overdue', 'cancelled'];
|
||||
if (!newStatus || !validStatuses.includes(newStatus)) {
|
||||
@@ -93,22 +117,57 @@ export const actions: Actions = {
|
||||
}
|
||||
|
||||
const [inv] = await db
|
||||
.select({ invoiceNumber: invoices.invoiceNumber })
|
||||
.select({
|
||||
invoiceNumber: invoices.invoiceNumber,
|
||||
status: invoices.status,
|
||||
paymentAccountId: invoices.paymentAccountId
|
||||
})
|
||||
.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' });
|
||||
|
||||
await db
|
||||
if (newStatus === 'paid') {
|
||||
if (!paymentAccountId) {
|
||||
return fail(400, { error: 'Payment account is required to mark an invoice paid' });
|
||||
}
|
||||
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, updatedAt: new Date() })
|
||||
.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') {
|
||||
await logCompanyEvent(params.companyId, user.id, 'invoice_sent', `Marked invoice ${inv.invoiceNumber} as sent`, { invoiceId: params.invoiceId });
|
||||
} 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 };
|
||||
|
||||
@@ -202,8 +202,27 @@
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<!-- Status transitions -->
|
||||
{#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} />
|
||||
{#if targetStatus === 'paid'}
|
||||
{#if data.accounts.length === 0}
|
||||
<span class="text-xs text-gray-400" title="Create an account first to mark as paid">
|
||||
No account
|
||||
</span>
|
||||
{:else}
|
||||
<select
|
||||
name="paymentAccountId"
|
||||
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' :
|
||||
@@ -213,6 +232,7 @@
|
||||
'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>
|
||||
{/each}
|
||||
|
||||
|
||||
+34
-3
@@ -1,8 +1,15 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { expenses, categories, tags, expenseTags, projects } from '$lib/server/db/schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import {
|
||||
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 { logCompanyEvent } from '$lib/server/audit.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))
|
||||
.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
|
||||
const [project] = await db
|
||||
.select({ name: projects.name })
|
||||
@@ -29,7 +53,12 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
.where(eq(projects.id, params.projectId))
|
||||
.limit(1);
|
||||
|
||||
return { categories: categoryList, tags: tagList, projectName: project?.name };
|
||||
return {
|
||||
categories: categoryList,
|
||||
tags: tagList,
|
||||
accounts: accountList,
|
||||
projectName: project?.name
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
@@ -42,6 +71,7 @@ export const actions: Actions = {
|
||||
const amount = formData.get('amount')?.toString().trim();
|
||||
const expenseDate = formData.get('expenseDate')?.toString();
|
||||
const categoryId = formData.get('categoryId')?.toString() || null;
|
||||
const accountId = formData.get('accountId')?.toString() || null;
|
||||
const tagIds = formData.getAll('tagIds').map((t) => t.toString());
|
||||
|
||||
if (!title || !amount || !expenseDate) {
|
||||
@@ -69,6 +99,7 @@ export const actions: Actions = {
|
||||
.values({
|
||||
projectId: params.projectId,
|
||||
categoryId: categoryId || null,
|
||||
accountId: accountId || null,
|
||||
submittedBy: user.id,
|
||||
title,
|
||||
description,
|
||||
|
||||
@@ -83,6 +83,25 @@
|
||||
</select>
|
||||
</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}
|
||||
<div class="mb-4">
|
||||
<span class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Tags</span>
|
||||
|
||||
Reference in New Issue
Block a user