chore: seminarhof customer project
This commit is contained in:
231
packages/functions/src/lib/booking-lifecycle.ts
Normal file
231
packages/functions/src/lib/booking-lifecycle.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user