232 lines
7.2 KiB
TypeScript
232 lines
7.2 KiB
TypeScript
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 R1–R4 (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
|
||
}
|