diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 6ab98e1..fd7472c 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,5 +1,8 @@ import { redirect, type Handle } from '@sveltejs/kit'; import { validateSession, setSessionCookie } from '$lib/server/auth/index.js'; +import { startScheduler } from '$lib/server/recurring-bills/scheduler.js'; + +startScheduler(); export const handle: Handle = async ({ event, resolve }) => { // Redirect implicit /favicon.ico requests to our SVG to avoid 404 noise diff --git a/src/lib/server/recurring-bills/poster.ts b/src/lib/server/recurring-bills/poster.ts new file mode 100644 index 0000000..6110c52 --- /dev/null +++ b/src/lib/server/recurring-bills/poster.ts @@ -0,0 +1,161 @@ +import { db } from '$lib/server/db/index.js'; +import { expenses, recurringBills } from '$lib/server/db/schema.js'; +import { postExpenseTransaction } from '$lib/server/accounts/ledger.js'; +import { logCompanyEvent } from '$lib/server/audit.js'; +import { addCycle, toIsoDate, type Cycle } from './cycle.js'; +import { and, eq, isNull, lte } from 'drizzle-orm'; + +export interface PostResult { + postedCount: number; + skippedCount: number; + errors: Array<{ billId: string; error: string }>; +} + +type BillRow = typeof recurringBills.$inferSelect; + +function fromIso(iso: string): Date { + return new Date(`${iso}T00:00:00Z`); +} + +function advanceDate(iso: string, cycle: Cycle, dayOfCycle: number | null): string { + return toIsoDate(addCycle(fromIso(iso), cycle, dayOfCycle)); +} + +async function processBill( + bill: BillRow, + nowDate: Date +): Promise<{ posted: number; skipped: number }> { + const nowIso = toIsoDate(nowDate); + let posted = 0; + let skipped = 0; + let nextDueDate = bill.nextDueDate; + let skipNext = bill.skipNext; + let currentOverride: string | null = bill.nextCycleAmount; + + while (nextDueDate <= nowIso) { + if (bill.endDate && nextDueDate > bill.endDate) break; + + if (skipNext) { + const advancedIso = advanceDate(nextDueDate, bill.cycle, bill.dayOfCycle); + await db + .update(recurringBills) + .set({ + skipNext: false, + nextDueDate: advancedIso, + updatedAt: nowDate + }) + .where(eq(recurringBills.id, bill.id)); + await logCompanyEvent( + bill.companyId, + bill.createdBy, + 'recurring_bill_skipped', + `Skipped ${nextDueDate} cycle for "${bill.name}"`, + { billId: bill.id, skippedDate: nextDueDate } + ); + skipNext = false; + nextDueDate = advancedIso; + skipped++; + continue; + } + + if (!bill.createdBy) { + throw new Error('Bill has no createdBy (user was deleted); cannot post expense'); + } + const createdBy = bill.createdBy; + const amountStr = currentOverride ?? bill.defaultAmount; + const postedDate = nextDueDate; + const advancedIso = advanceDate(postedDate, bill.cycle, bill.dayOfCycle); + const willBeEnded = bill.endDate !== null && advancedIso > bill.endDate; + + await db.transaction(async (tx) => { + const [exp] = await tx + .insert(expenses) + .values({ + projectId: bill.projectId, + accountId: bill.accountId, + categoryId: bill.categoryId, + partyId: bill.partyId, + submittedBy: createdBy, + approvedBy: createdBy, + title: bill.name, + description: bill.description, + amount: amountStr, + currency: bill.currency, + expenseDate: postedDate, + status: 'approved', + reviewedAt: nowDate + }) + .returning({ id: expenses.id }); + + await postExpenseTransaction(exp.id, bill.accountId, createdBy, tx); + + await tx + .update(recurringBills) + .set({ + lastPostedDate: postedDate, + nextDueDate: advancedIso, + nextCycleAmount: null, + status: willBeEnded ? 'ended' : 'active', + updatedAt: nowDate + }) + .where(eq(recurringBills.id, bill.id)); + + await logCompanyEvent( + bill.companyId, + createdBy, + 'recurring_bill_posted', + `Posted ${amountStr} ${bill.currency} for "${bill.name}" (${postedDate})`, + { billId: bill.id, expenseId: exp.id, amount: amountStr, postedFor: postedDate } + ); + }); + + currentOverride = null; + nextDueDate = advancedIso; + posted++; + + if (willBeEnded) break; + } + + return { posted, skipped }; +} + +export async function postBillsDue( + companyId?: string, + now?: Date +): Promise { + const nowDate = now ?? new Date(); + const nowIso = toIsoDate(nowDate); + + const scopeFilter = companyId ? eq(recurringBills.companyId, companyId) : undefined; + + const dueBills = await db + .select() + .from(recurringBills) + .where( + and( + eq(recurringBills.status, 'active'), + isNull(recurringBills.pausedAt), + isNull(recurringBills.deletedAt), + lte(recurringBills.nextDueDate, nowIso), + ...(scopeFilter ? [scopeFilter] : []) + ) + ); + + let postedCount = 0; + let skippedCount = 0; + const errors: Array<{ billId: string; error: string }> = []; + + for (const bill of dueBills) { + try { + const r = await processBill(bill, nowDate); + postedCount += r.posted; + skippedCount += r.skipped; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + errors.push({ billId: bill.id, error: msg }); + console.error(`[recurring-bills] failed to post bill ${bill.id}:`, err); + } + } + + return { postedCount, skippedCount, errors }; +} diff --git a/src/lib/server/recurring-bills/scheduler.ts b/src/lib/server/recurring-bills/scheduler.ts new file mode 100644 index 0000000..25a1c7f --- /dev/null +++ b/src/lib/server/recurring-bills/scheduler.ts @@ -0,0 +1,25 @@ +import { postBillsDue } from './poster.js'; + +const INTERVAL_MS = 15 * 60 * 1000; +const GUARD_KEY = '__b4lRecurringBillsScheduler'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type GlobalWithGuard = typeof globalThis & { [GUARD_KEY]?: NodeJS.Timeout }; + +export function startScheduler(): void { + const g = globalThis as GlobalWithGuard; + if (g[GUARD_KEY]) return; + + g[GUARD_KEY] = setInterval(async () => { + try { + const result = await postBillsDue(); + if (result.postedCount > 0 || result.errors.length > 0 || result.skippedCount > 0) { + console.log('[scheduler] recurring bills tick:', result); + } + } catch (err) { + console.error('[scheduler] recurring bills tick error:', err); + } + }, INTERVAL_MS); + + console.log('[scheduler] recurring bills started (interval: 15min)'); +} diff --git a/src/routes/(app)/companies/[companyId]/bills/+page.server.ts b/src/routes/(app)/companies/[companyId]/bills/+page.server.ts new file mode 100644 index 0000000..bbed60d --- /dev/null +++ b/src/routes/(app)/companies/[companyId]/bills/+page.server.ts @@ -0,0 +1,31 @@ +import type { Actions, PageServerLoad } from './$types'; +import { requireCompanyRoleAny } from '$lib/server/authorization.js'; +import { postBillsDue } from '$lib/server/recurring-bills/poster.js'; + +export const load: PageServerLoad = async ({ locals, params, parent }) => { + await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']); + await parent(); + + return { + bills: [], + accounts: [], + projects: [], + categories: [], + parties: [] + }; +}; + +export const actions: Actions = { + runBillsNow: async ({ locals, params }) => { + await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']); + const result = await postBillsDue(params.companyId, new Date()); + return { + success: true, + action: 'runBillsNow', + postedCount: result.postedCount, + skippedCount: result.skippedCount, + errorCount: result.errors.length, + errors: result.errors.map((e) => `${e.billId}: ${e.error}`) + }; + } +}; diff --git a/src/routes/(app)/companies/[companyId]/bills/+page.svelte b/src/routes/(app)/companies/[companyId]/bills/+page.svelte new file mode 100644 index 0000000..3385149 --- /dev/null +++ b/src/routes/(app)/companies/[companyId]/bills/+page.svelte @@ -0,0 +1,51 @@ + + + + Bills - {data.company.name} + + +
+
+

Recurring Bills

+

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

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

+ Posted: {form.postedCount} · Skipped: {form.skippedCount} · Errors: {form.errorCount} +

+ {#if form.errors && form.errors.length > 0} +
    + {#each form.errors as err (err)} +
  • {err}
  • + {/each} +
+ {/if} +
+ {/if} +