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:
@@ -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)');
|
||||
}
|
||||
Reference in New Issue
Block a user