Add recurring bills poster, scheduler boot, and manual run stub
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<PostResult> {
|
||||
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 };
|
||||
}
|
||||
@@ -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)');
|
||||
}
|
||||
@@ -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}`)
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Bills - {data.company.name}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<header>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Recurring Bills</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Full UI coming in the next phase. Use "Run Now" to post any bills past their due date.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/runBillsNow"
|
||||
use:enhance={() => async ({ update }) => {
|
||||
await update({ reset: false });
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
Run Bills Now
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{#if form?.action === 'runBillsNow'}
|
||||
<div
|
||||
class="rounded-md border border-gray-200 bg-white p-4 text-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<p class="font-medium text-gray-900 dark:text-white">
|
||||
Posted: {form.postedCount} · Skipped: {form.skippedCount} · Errors: {form.errorCount}
|
||||
</p>
|
||||
{#if form.errors && form.errors.length > 0}
|
||||
<ul class="mt-2 list-inside list-disc text-xs text-red-600 dark:text-red-400">
|
||||
{#each form.errors as err (err)}
|
||||
<li>{err}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user