diff --git a/src/routes/(app)/companies/[companyId]/+layout.svelte b/src/routes/(app)/companies/[companyId]/+layout.svelte index 8be4013..22fe6bc 100644 --- a/src/routes/(app)/companies/[companyId]/+layout.svelte +++ b/src/routes/(app)/companies/[companyId]/+layout.svelte @@ -36,6 +36,7 @@ ...(data.companyRoles.some((r) => r === 'admin' || r === 'manager' || r === 'accountant') ? [ { href: `/companies/${data.company.id}/accounts`, label: 'Accounts' }, + { href: `/companies/${data.company.id}/bills`, label: 'Bills' }, { href: `/companies/${data.company.id}/profile`, label: 'Profile' }, { href: `/companies/${data.company.id}/documents`, label: 'Documents' } ] diff --git a/src/routes/(app)/companies/[companyId]/bills/+page.server.ts b/src/routes/(app)/companies/[companyId]/bills/+page.server.ts index bbed60d..5b9341d 100644 --- a/src/routes/(app)/companies/[companyId]/bills/+page.server.ts +++ b/src/routes/(app)/companies/[companyId]/bills/+page.server.ts @@ -1,21 +1,540 @@ +import { error, fail } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; +import { db } from '$lib/server/db/index.js'; +import { + recurringBills, + companyAccounts, + projects, + categories, + parties +} from '$lib/server/db/schema.js'; import { requireCompanyRoleAny } from '$lib/server/authorization.js'; +import { logCompanyEvent } from '$lib/server/audit.js'; import { postBillsDue } from '$lib/server/recurring-bills/poster.js'; +import { computeNextDueDate, toIsoDate, type Cycle } from '$lib/server/recurring-bills/cycle.js'; +import { and, asc, eq, isNull } from 'drizzle-orm'; + +const CYCLES = ['weekly', 'monthly', 'quarterly', 'yearly'] as const; + +function trimOrNull(v: FormDataEntryValue | null): string | null { + const s = v?.toString().trim(); + return s ? s : null; +} + +function parseCycle(v: FormDataEntryValue | null): Cycle | null { + const s = v?.toString(); + if (!s) return null; + return (CYCLES as readonly string[]).includes(s) ? (s as Cycle) : null; +} + +function parseAmount(v: FormDataEntryValue | null): string | null { + const s = trimOrNull(v); + if (!s) return null; + const n = Number(s); + if (!Number.isFinite(n) || n < 0) return null; + return n.toFixed(2); +} + +function parseInt0(v: FormDataEntryValue | null): number | null { + const s = trimOrNull(v); + if (!s) return null; + const n = Number(s); + if (!Number.isInteger(n)) return null; + return n; +} + +function parseIsoDate(v: FormDataEntryValue | null): string | null { + const s = trimOrNull(v); + if (!s) return null; + if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null; + const d = new Date(`${s}T00:00:00Z`); + if (Number.isNaN(d.getTime())) return null; + return s; +} + +type BillFormFields = { + name: string; + amount: string; + cycle: Cycle; + accountId: string; + projectId: string; + categoryId: string | null; + partyId: string | null; + description: string | null; + currency: string; + startDate: string; + endDate: string | null; + dayOfCycle: number | null; +}; + +function extractFields(fd: FormData): BillFormFields | string { + const name = trimOrNull(fd.get('name')); + const amount = parseAmount(fd.get('amount')); + const cycle = parseCycle(fd.get('cycle')); + const accountId = trimOrNull(fd.get('accountId')); + const projectId = trimOrNull(fd.get('projectId')); + const startDate = parseIsoDate(fd.get('startDate')); + + if (!name) return 'Name is required'; + if (!amount) return 'Valid amount is required'; + if (!cycle) return 'Invalid cycle'; + if (!accountId) return 'Account is required'; + if (!projectId) return 'Project is required'; + if (!startDate) return 'Valid start date is required'; + + const currency = trimOrNull(fd.get('currency')) ?? 'THB'; + const endDate = parseIsoDate(fd.get('endDate')); + const endDateRaw = trimOrNull(fd.get('endDate')); + if (endDateRaw && !endDate) return 'Invalid end date'; + + const dayOfCycle = parseInt0(fd.get('dayOfCycle')); + if (fd.get('dayOfCycle') && dayOfCycle === null) return 'Invalid day of cycle'; + if (dayOfCycle !== null) { + if (cycle === 'weekly' && (dayOfCycle < 0 || dayOfCycle > 6)) { + return 'Weekly day must be 0 (Sun) – 6 (Sat)'; + } + if (cycle !== 'weekly' && (dayOfCycle < 1 || dayOfCycle > 31)) { + return 'Day of cycle must be 1 – 31'; + } + } + + return { + name, + amount, + cycle, + accountId, + projectId, + categoryId: trimOrNull(fd.get('categoryId')), + partyId: trimOrNull(fd.get('partyId')), + description: trimOrNull(fd.get('description')), + currency, + startDate, + endDate, + dayOfCycle + }; +} export const load: PageServerLoad = async ({ locals, params, parent }) => { await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']); await parent(); + const [billRows, accountRows, projectRows, categoryRows, partyRows] = await Promise.all([ + db + .select({ + id: recurringBills.id, + name: recurringBills.name, + description: recurringBills.description, + cycle: recurringBills.cycle, + defaultAmount: recurringBills.defaultAmount, + nextCycleAmount: recurringBills.nextCycleAmount, + currency: recurringBills.currency, + dayOfCycle: recurringBills.dayOfCycle, + startDate: recurringBills.startDate, + endDate: recurringBills.endDate, + nextDueDate: recurringBills.nextDueDate, + lastPostedDate: recurringBills.lastPostedDate, + status: recurringBills.status, + pausedAt: recurringBills.pausedAt, + skipNext: recurringBills.skipNext, + accountId: recurringBills.accountId, + accountName: companyAccounts.name, + projectId: recurringBills.projectId, + projectName: projects.name, + categoryId: recurringBills.categoryId, + categoryName: categories.name, + partyId: recurringBills.partyId, + partyName: parties.name, + createdAt: recurringBills.createdAt, + updatedAt: recurringBills.updatedAt + }) + .from(recurringBills) + .leftJoin(companyAccounts, eq(recurringBills.accountId, companyAccounts.id)) + .leftJoin(projects, eq(recurringBills.projectId, projects.id)) + .leftJoin(categories, eq(recurringBills.categoryId, categories.id)) + .leftJoin(parties, eq(recurringBills.partyId, parties.id)) + .where( + and(eq(recurringBills.companyId, params.companyId), isNull(recurringBills.deletedAt)) + ) + .orderBy(asc(recurringBills.status), asc(recurringBills.nextDueDate)), + + db + .select({ + id: companyAccounts.id, + name: companyAccounts.name, + currency: companyAccounts.currency, + accountType: companyAccounts.accountType + }) + .from(companyAccounts) + .where( + and( + eq(companyAccounts.companyId, params.companyId), + isNull(companyAccounts.deletedAt), + eq(companyAccounts.isArchived, false) + ) + ) + .orderBy(asc(companyAccounts.name)), + + db + .select({ id: projects.id, name: projects.name }) + .from(projects) + .where(and(eq(projects.companyId, params.companyId), eq(projects.isActive, true))) + .orderBy(asc(projects.name)), + + db + .select({ id: categories.id, name: categories.name }) + .from(categories) + .where(eq(categories.companyId, params.companyId)) + .orderBy(asc(categories.name)), + + db + .select({ id: parties.id, name: parties.name }) + .from(parties) + .where(and(eq(parties.companyId, params.companyId), isNull(parties.deletedAt))) + .orderBy(asc(parties.name)) + ]); + return { - bills: [], - accounts: [], - projects: [], - categories: [], - parties: [] + bills: billRows, + accounts: accountRows, + projects: projectRows, + categories: categoryRows, + parties: partyRows }; }; export const actions: Actions = { + createBill: async ({ request, locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', + 'manager', + 'accountant' + ]); + const fd = await request.formData(); + const parsed = extractFields(fd); + if (typeof parsed === 'string') { + return fail(400, { action: 'createBill', error: parsed }); + } + + // Verify account + project belong to this company + const [acct] = await db + .select({ id: companyAccounts.id }) + .from(companyAccounts) + .where( + and( + eq(companyAccounts.id, parsed.accountId), + eq(companyAccounts.companyId, params.companyId), + isNull(companyAccounts.deletedAt) + ) + ) + .limit(1); + if (!acct) return fail(400, { action: 'createBill', error: 'Account not found' }); + + const [proj] = await db + .select({ id: projects.id }) + .from(projects) + .where(and(eq(projects.id, parsed.projectId), eq(projects.companyId, params.companyId))) + .limit(1); + if (!proj) return fail(400, { action: 'createBill', error: 'Project not found' }); + + const dayOfCycle = + parsed.dayOfCycle ?? + (parsed.cycle === 'weekly' + ? new Date(`${parsed.startDate}T00:00:00Z`).getUTCDay() + : new Date(`${parsed.startDate}T00:00:00Z`).getUTCDate()); + + const nextDueDate = toIsoDate( + computeNextDueDate( + parsed.startDate, + parsed.cycle, + dayOfCycle, + new Date(`${parsed.startDate}T00:00:00Z`) + ) + ); + + const [inserted] = await db + .insert(recurringBills) + .values({ + companyId: params.companyId, + projectId: parsed.projectId, + accountId: parsed.accountId, + categoryId: parsed.categoryId, + partyId: parsed.partyId, + name: parsed.name, + description: parsed.description, + cycle: parsed.cycle, + defaultAmount: parsed.amount, + currency: parsed.currency, + dayOfCycle, + startDate: parsed.startDate, + endDate: parsed.endDate, + nextDueDate, + status: 'active', + createdBy: user.id + }) + .returning({ id: recurringBills.id }); + + await logCompanyEvent( + params.companyId, + user.id, + 'recurring_bill_created', + `Recurring bill "${parsed.name}" created (${parsed.cycle}, ${parsed.amount} ${parsed.currency})`, + { billId: inserted.id, cycle: parsed.cycle, amount: parsed.amount } + ); + + return { success: true, action: 'createBill' }; + }, + + updateBill: async ({ request, locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', + 'manager', + 'accountant' + ]); + const fd = await request.formData(); + const id = trimOrNull(fd.get('id')); + if (!id) return fail(400, { action: 'updateBill', error: 'Bill id is required' }); + + const [existing] = await db + .select() + .from(recurringBills) + .where( + and( + eq(recurringBills.id, id), + eq(recurringBills.companyId, params.companyId), + isNull(recurringBills.deletedAt) + ) + ) + .limit(1); + if (!existing) error(404, 'Bill not found'); + + const parsed = extractFields(fd); + if (typeof parsed === 'string') { + return fail(400, { action: 'updateBill', error: parsed }); + } + + const dayOfCycle = + parsed.dayOfCycle ?? + (parsed.cycle === 'weekly' + ? new Date(`${parsed.startDate}T00:00:00Z`).getUTCDay() + : new Date(`${parsed.startDate}T00:00:00Z`).getUTCDate()); + + const scheduleChanged = + existing.startDate !== parsed.startDate || + existing.cycle !== parsed.cycle || + existing.dayOfCycle !== dayOfCycle; + + const nextDueDate = + existing.lastPostedDate === null && scheduleChanged + ? toIsoDate( + computeNextDueDate( + parsed.startDate, + parsed.cycle, + dayOfCycle, + new Date(`${parsed.startDate}T00:00:00Z`) + ) + ) + : existing.nextDueDate; + + await db + .update(recurringBills) + .set({ + projectId: parsed.projectId, + accountId: parsed.accountId, + categoryId: parsed.categoryId, + partyId: parsed.partyId, + name: parsed.name, + description: parsed.description, + cycle: parsed.cycle, + defaultAmount: parsed.amount, + currency: parsed.currency, + dayOfCycle, + startDate: parsed.startDate, + endDate: parsed.endDate, + nextDueDate, + updatedAt: new Date() + }) + .where(eq(recurringBills.id, id)); + + await logCompanyEvent( + params.companyId, + user.id, + 'recurring_bill_updated', + `Recurring bill "${parsed.name}" updated`, + { billId: id } + ); + + return { success: true, action: 'updateBill' }; + }, + + deleteBill: async ({ request, locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', + 'manager', + 'accountant' + ]); + const fd = await request.formData(); + const id = trimOrNull(fd.get('id')); + if (!id) return fail(400, { action: 'deleteBill', error: 'Bill id is required' }); + + const [existing] = await db + .select({ id: recurringBills.id, name: recurringBills.name }) + .from(recurringBills) + .where( + and( + eq(recurringBills.id, id), + eq(recurringBills.companyId, params.companyId), + isNull(recurringBills.deletedAt) + ) + ) + .limit(1); + if (!existing) error(404, 'Bill not found'); + + await db + .update(recurringBills) + .set({ deletedAt: new Date(), updatedAt: new Date() }) + .where(eq(recurringBills.id, id)); + + await logCompanyEvent( + params.companyId, + user.id, + 'recurring_bill_deleted', + `Recurring bill "${existing.name}" deleted`, + { billId: id } + ); + + return { success: true, action: 'deleteBill' }; + }, + + pauseBill: async ({ request, locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', + 'manager', + 'accountant' + ]); + const fd = await request.formData(); + const id = trimOrNull(fd.get('id')); + if (!id) return fail(400, { action: 'pauseBill', error: 'Bill id is required' }); + + const result = await db + .update(recurringBills) + .set({ status: 'paused', pausedAt: new Date(), updatedAt: new Date() }) + .where( + and( + eq(recurringBills.id, id), + eq(recurringBills.companyId, params.companyId), + isNull(recurringBills.deletedAt) + ) + ) + .returning({ name: recurringBills.name }); + + if (result.length === 0) error(404, 'Bill not found'); + + await logCompanyEvent( + params.companyId, + user.id, + 'recurring_bill_paused', + `Recurring bill "${result[0].name}" paused`, + { billId: id } + ); + + return { success: true, action: 'pauseBill' }; + }, + + resumeBill: async ({ request, locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', + 'manager', + 'accountant' + ]); + const fd = await request.formData(); + const id = trimOrNull(fd.get('id')); + if (!id) return fail(400, { action: 'resumeBill', error: 'Bill id is required' }); + + const result = await db + .update(recurringBills) + .set({ status: 'active', pausedAt: null, updatedAt: new Date() }) + .where( + and( + eq(recurringBills.id, id), + eq(recurringBills.companyId, params.companyId), + isNull(recurringBills.deletedAt) + ) + ) + .returning({ name: recurringBills.name }); + + if (result.length === 0) error(404, 'Bill not found'); + + await logCompanyEvent( + params.companyId, + user.id, + 'recurring_bill_resumed', + `Recurring bill "${result[0].name}" resumed`, + { billId: id } + ); + + return { success: true, action: 'resumeBill' }; + }, + + skipNextBill: async ({ request, locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', + 'manager', + 'accountant' + ]); + const fd = await request.formData(); + const id = trimOrNull(fd.get('id')); + if (!id) return fail(400, { action: 'skipNextBill', error: 'Bill id is required' }); + + const result = await db + .update(recurringBills) + .set({ skipNext: true, updatedAt: new Date() }) + .where( + and( + eq(recurringBills.id, id), + eq(recurringBills.companyId, params.companyId), + isNull(recurringBills.deletedAt) + ) + ) + .returning({ name: recurringBills.name, nextDueDate: recurringBills.nextDueDate }); + + if (result.length === 0) error(404, 'Bill not found'); + + await logCompanyEvent( + params.companyId, + user.id, + 'recurring_bill_skipped', + `Next cycle (${result[0].nextDueDate}) of "${result[0].name}" will be skipped`, + { billId: id, intent: true } + ); + + return { success: true, action: 'skipNextBill' }; + }, + + setNextCycleAmount: async ({ request, locals, params }) => { + await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']); + const fd = await request.formData(); + const id = trimOrNull(fd.get('id')); + if (!id) return fail(400, { action: 'setNextCycleAmount', error: 'Bill id is required' }); + + const amountRaw = fd.get('amount'); + const cleared = amountRaw === null || amountRaw === ''; + const amount = cleared ? null : parseAmount(amountRaw); + if (!cleared && !amount) return fail(400, { action: 'setNextCycleAmount', error: 'Valid amount required' }); + + await db + .update(recurringBills) + .set({ nextCycleAmount: amount, updatedAt: new Date() }) + .where( + and( + eq(recurringBills.id, id), + eq(recurringBills.companyId, params.companyId), + isNull(recurringBills.deletedAt) + ) + ); + + return { success: true, action: 'setNextCycleAmount' }; + }, + runBillsNow: async ({ locals, params }) => { await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']); const result = await postBillsDue(params.companyId, new Date()); diff --git a/src/routes/(app)/companies/[companyId]/bills/+page.svelte b/src/routes/(app)/companies/[companyId]/bills/+page.svelte index 3385149..75be966 100644 --- a/src/routes/(app)/companies/[companyId]/bills/+page.svelte +++ b/src/routes/(app)/companies/[companyId]/bills/+page.svelte @@ -1,40 +1,301 @@ Bills - {data.company.name} -
-
-

Recurring Bills

-

- Full UI coming in the next phase. Use "Run Now" to post any bills past their due date. -

-
- +{#snippet billForm( + action: string, + values: { + name?: string; + amount?: string; + cycle?: string; + accountId?: string; + projectId?: string; + categoryId?: string; + partyId?: string; + description?: string; + currency?: string; + startDate?: string; + endDate?: string; + dayOfCycle?: string; + } = {}, + billId?: string +)}
async ({ update }) => { + {action} + use:enhance={() => async ({ result, update, formElement }) => { await update({ reset: false }); + if (result.type === 'success') { + if (!billId) { + showAddForm = false; + formElement.reset(); + } else { + editingBillId = null; + } + } }} + class="mt-4 grid grid-cols-1 gap-3 rounded-md bg-gray-50 p-4 dark:bg-gray-700/50 md:grid-cols-2" > - + {#if billId} + + {/if} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+{/snippet} + +
+
+
+

Recurring Bills

+

+ Auto-post rent, utilities, SaaS, and other recurring expenses on their due date. Scheduler + runs every 15 minutes; use "Run Now" to trigger an immediate pass. +

+
+
async ({ update }) => { + await update({ reset: false }); + }} + > + +
+
+ + {#if form?.error} +
+ {form.error} +
+ {/if} {#if form?.action === 'runBillsNow'}

Posted: {form.postedCount} · Skipped: {form.skippedCount} · Errors: {form.errorCount} @@ -48,4 +309,264 @@ {/if}

{/if} + +
+
+

Add Bill

+ +
+ {#if showAddForm} + {@render billForm('?/createBill', { currency: 'THB', startDate: todayIso, cycle: 'monthly' })} + {/if} +
+ +
+ {#each ['all', 'active', 'paused', 'ended'] as s (s)} + + {/each} +
+ + {#if filteredBills.length === 0} +
+

+ No {statusFilter === 'all' ? '' : statusFilter} bills yet. +

+
+ {:else} +
+ + + + + + + + + + + + + + {#each filteredBills as bill (bill.id)} + + + + + + + + + + {#if confirmDeleteId === bill.id} + + + + {/if} + {#if editingBillId === bill.id} + + + + {/if} + {/each} + +
NameAmountCycleAccountNext DueStatusActions
+
{bill.name}
+ {#if bill.projectName} +
+ Project: {bill.projectName} +
+ {/if} + {#if bill.partyName} +
+ Vendor: {bill.partyName} +
+ {/if} + {#if bill.status === 'paused' && bill.pausedAt} +
+ Paused since {formatDate(bill.pausedAt)} +
+ {/if} + {#if bill.skipNext} +
Next cycle will be skipped
+ {/if} +
+ {formatAmount(bill.defaultAmount, bill.currency)} + {#if bill.nextCycleAmount} +
+ Override: {formatAmount(bill.nextCycleAmount, bill.currency)} +
+ {/if} +
+ {CYCLE_LABELS[bill.cycle] ?? bill.cycle} + {#if bill.dayOfCycle !== null} +
day {bill.dayOfCycle}
+ {/if} +
+ {bill.accountName ?? '—'} + + + {bill.nextDueDate} + + {#if isOverdue(bill)} +
Overdue
+ {/if} + {#if bill.lastPostedDate} +
+ Last: {bill.lastPostedDate} +
+ {/if} +
+ + {bill.status} + + +
+ + {#if bill.status === 'active'} +
async ({ update }) => await update({ reset: false })} + > + + +
+
async ({ update }) => await update({ reset: false })} + > + + +
+ {:else if bill.status === 'paused'} +
async ({ update }) => await update({ reset: false })} + > + + +
+ {/if} + +
+
+
async ({ update }) => { + await update({ reset: false }); + confirmDeleteId = null; + }} + class="flex items-center justify-between gap-3 text-xs" + > + +

+ Delete "{bill.name}"? This soft-deletes the bill; already-posted expenses remain. +

+
+ + +
+
+
+ {@render billForm( + '?/updateBill', + { + name: bill.name, + amount: bill.defaultAmount, + cycle: bill.cycle, + accountId: bill.accountId, + projectId: bill.projectId, + categoryId: bill.categoryId ?? '', + partyId: bill.partyId ?? '', + description: bill.description ?? '', + currency: bill.currency, + startDate: bill.startDate, + endDate: bill.endDate ?? '', + dayOfCycle: bill.dayOfCycle?.toString() ?? '' + }, + bill.id + )} +
async ({ update }) => await update({ reset: false })} + class="mt-3 flex flex-wrap items-end gap-2 rounded-md bg-white p-3 text-xs dark:bg-gray-800" + > + +
+ + +
+ +
+
+
+ {/if}