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 @@
- Full UI coming in the next phase. Use "Run Now" to post any bills past their due date. -
-+ 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. +
+Posted: {form.postedCount} · Skipped: {form.skippedCount} · Errors: {form.errorCount} @@ -48,4 +309,264 @@ {/if}
+ No {statusFilter === 'all' ? '' : statusFilter} bills yet. +
+| Name | +Amount | +Cycle | +Account | +Next Due | +Status | +Actions | +
|---|---|---|---|---|---|---|
|
+ {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'}
+
+
+ {:else if bill.status === 'paused'}
+
+ {/if}
+
+
+ |
+
| + + | +||||||
| + {@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 + )} + + | +||||||