chore: seminarhof customer project

This commit is contained in:
e2e
2026-06-21 23:19:18 +02:00
commit dca4c76a13
161 changed files with 18517 additions and 0 deletions

View 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 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
}