import type { Kysely } from 'kysely' import type { DB } from '../types/db.types.js' import type { EmailService } from '../services/email.js' import { applyBookingTransition } from './booking-status.js' /** * The booking lifecycle's time-driven rules, factored out so the daily cron, * the manual "run now" RPC, and `triggerContractEmail` all share one * implementation. All calendar math is Europe/Berlin local days; every rule is * idempotent (guarded by `*SentAt`/`flaggedAt`/`paidOn`), so running twice in a * day is a no-op. */ type LifecycleServices = { kysely: Kysely email: EmailService logger: { info: (message: string) => void } } /** Today as a Berlin-local `YYYY-MM-DD` (sv-SE locale emits ISO date form). */ export const berlinDate = (d: Date = new Date()): string => d.toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' }) /** The Berlin-local date portion of a stored ISO timestamp. */ export const berlinDateFromIso = (iso: string): string => berlinDate(new Date(iso)) /** Whole days from `fromYmd` to `toYmd` (both `YYYY-MM-DD`, UTC-anchored so DST never skews the count). */ export const daysBetween = (fromYmd: string, toYmd: string): number => Math.round((Date.parse(toYmd) - Date.parse(fromYmd)) / 86_400_000) /** `YYYY-MM-DD` plus `n` calendar days. */ export const addDays = (ymd: string, n: number): string => { const d = new Date(`${ymd}T00:00:00Z`) d.setUTCDate(d.getUTCDate() + n) return d.toISOString().slice(0, 10) } const DEPOSIT_WINDOW_DAYS = 14 const DEPOSIT_REMINDER_DAYS = 7 /** Placeholder deposit amount (cents) until a real per-booking calc lands. */ const DEFAULT_DEPOSIT_CENTS = 30000 /** * Lifecycle rule R1, for a single booking: ensure a deposit invoice exists, * send the contract+deposit email, then stamp `contractSentAt`. The * invoice-existence check (not `contractSentAt`) makes the whole sequence * safely re-entrant even if a prior run died after the email. */ export async function dispatchContract( { kysely, email }: Pick, booking: { bookingId: string; venueId: string }, todayYmd: string, ): Promise { const existing = await kysely .selectFrom('invoice') .where('bookingId', '=', booking.bookingId) .where('kind', '=', 'deposit') .select('invoiceId') .executeTakeFirst() if (!existing) { await kysely .insertInto('invoice') .values({ invoiceId: `inv_${booking.bookingId}_dep`, bookingId: booking.bookingId, kind: 'deposit', invoiceNumber: `DEP-${todayYmd}-${booking.bookingId}`, issuedOn: todayYmd, dueOn: addDays(todayYmd, DEPOSIT_WINDOW_DAYS), paidOn: null, amountCents: DEFAULT_DEPOSIT_CENTS, status: 'outstanding', }) .execute() } await email.sendContractAndDepositEmail(booking.bookingId) const now = new Date().toISOString() await kysely .updateTable('booking') .set({ contractSentAt: now }) .where('bookingId', '=', booking.bookingId) .execute() await kysely .insertInto('auditLog') .values({ auditId: globalThis.crypto?.randomUUID?.() ?? `audit_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, venueId: booking.venueId, userId: null, entity: 'booking', entityId: booking.bookingId, field: null, beforeValue: null, afterValue: 'sendContractAndDepositEmail', action: 'email_sent', }) .execute() } export type LifecycleSummary = { date: string contractsSent: number reminders: number flagged: number ended: number } /** * One daily sweep over active bookings, applying rules R1–R4 (see * docs/booking-lifecycle.md). Returns a counts summary for logging/dev. */ export async function runBookingLifecycle( services: LifecycleServices, ): Promise { const { kysely, email, logger } = services const today = berlinDate() const bookings = await kysely .selectFrom('booking') .where('status', 'in', ['reserved', 'confirmed']) .select([ 'bookingId', 'venueId', 'status', 'startDate', 'endDate', 'contractSentAt', 'depositReminderSentAt', 'flaggedAt', ]) .execute() const summary: LifecycleSummary = { date: today, contractsSent: 0, reminders: 0, flagged: 0, ended: 0, } for (const b of bookings) { // Lifecycle rules are all date-driven; a booking without dates (an // enquiry that hasn't been scheduled yet) has nothing to act on. if (b.startDate == null || b.endDate == null) continue // R4 — auto-end a confirmed booking once its event has ended. if (b.status === 'confirmed') { if (b.endDate < today) { await applyBookingTransition(services, { bookingId: b.bookingId, to: 'ended', userId: null, }) summary.ended++ } continue } // status === 'reserved' below. // R1 — dispatch the contract ~1yr before start (daily `<=`, not `==`). if (!b.contractSentAt) { if (daysBetween(today, b.startDate) <= 365) { await dispatchContract(services, b, today) summary.contractsSent++ } continue // freshly sent — reminders/flags begin on later ticks } // Contract sent → drive deposit reminders/flags off the deposit invoice. const deposit = await kysely .selectFrom('invoice') .where('bookingId', '=', b.bookingId) .where('kind', '=', 'deposit') .select(['dueOn', 'paidOn']) .executeTakeFirst() if (deposit?.paidOn) continue // deposit settled — nothing to chase // contractSentAt is a real Date via CoercionPlugin. const daysSinceContract = daysBetween(berlinDate(b.contractSentAt as Date), today) // R2 — day-7 reminder while the deposit is still unpaid. if (!b.depositReminderSentAt && daysSinceContract >= DEPOSIT_REMINDER_DAYS) { await email.sendDepositReminderEmail(b.bookingId) const reminderNow = new Date().toISOString() await kysely .updateTable('booking') .set({ depositReminderSentAt: reminderNow }) .where('bookingId', '=', b.bookingId) .execute() await kysely .insertInto('auditLog') .values({ auditId: globalThis.crypto?.randomUUID?.() ?? `audit_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, venueId: b.venueId, userId: null, entity: 'booking', entityId: b.bookingId, field: null, beforeValue: null, afterValue: 'sendDepositReminderEmail', action: 'email_sent', }) .execute() summary.reminders++ } // R3 — past due → FLAG ONLY (deadline = invoice.dueOn, admin-overridable). // dueOn is a Date via CoercionPlugin; convert to Berlin YYYY-MM-DD for comparison. if (!b.flaggedAt && deposit?.dueOn && berlinDate(deposit.dueOn as Date) < today) { await kysely .updateTable('booking') .set({ flaggedAt: new Date().toISOString(), flagReason: 'deposit_overdue' }) .where('bookingId', '=', b.bookingId) .execute() summary.flagged++ } } logger.info(`[booking-lifecycle] ${JSON.stringify(summary)}`) return summary }