Files
seminarhof-e2e-mqoahn2h/packages/functions/src/lib/booking-lifecycle.ts
2026-06-21 23:19:18 +02:00

232 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<DB>
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<LifecycleServices, 'kysely' | 'email'>,
booking: { bookingId: string; venueId: string },
todayYmd: string,
): Promise<void> {
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 R1R4 (see
* docs/booking-lifecycle.md). Returns a counts summary for logging/dev.
*/
export async function runBookingLifecycle(
services: LifecycleServices,
): Promise<LifecycleSummary> {
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
}