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 { redirect, type Handle } from '@sveltejs/kit';
|
||||||
import { validateSession, setSessionCookie } from '$lib/server/auth/index.js';
|
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 }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
// Redirect implicit /favicon.ico requests to our SVG to avoid 404 noise
|
// 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