import { randomBytes } from 'node:crypto'; import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise'; import type { MySqlPool } from '../db/mysql.js'; import type { MarketingBenefitService } from '../wallets/marketing-benefit-service.js'; export type PricingMode = 'HOURLY' | 'OVERNIGHT' | 'FULL_DAY'; export interface PricingAdjustment { discountCents?: number; packageCreditCents?: number; } export interface QuoteInput { tenantId: string; roomId: string; startAt: Date; endAt: Date; pricingMode: PricingMode; adjustment?: PricingAdjustment; } export interface OrderBenefitInput { couponGrantId?: string | null; packageHoldingId?: string | null; packageMinutes?: number; packageCreditCents?: number; clientRequestId?: string; } interface RoomPricingRow extends RowDataPacket { id: string; storeId: string; timezone: string; configurationStatus: string; operationalStatus: string; basePriceCents: number; weekdayPriceCents: number; holidayPriceCents: number; overnightPriceCents: number; fullDayPriceCents: number; minimumSpendCents: number; depositCents: number; minimumMinutes: number; maxAdvanceDays: number; roomCategoryId: string | null; } interface CountRow extends RowDataPacket { total: number } export class PricingError extends Error { constructor(public readonly code: string) { super(code); } } export class PricingRepository { constructor( private readonly pool: MySqlPool, private readonly benefits?: Pick ) {} async quote(input: QuoteInput) { const room = await this.loadRoom(this.pool, input.tenantId, input.roomId); await this.releaseExpired(input.tenantId, input.roomId); await this.assertAvailable(this.pool, input, false); const isHoliday = await this.isHoliday(this.pool, input.tenantId, input.startAt, room.timezone); return this.calculate(room, input, isHoliday); } async reserve(input: QuoteInput & { userId: string; holdMinutes?: number; allowedStoreIds?: string[] | null; benefits?: OrderBenefitInput | null; }) { return this.transaction(async (connection) => { const room = await this.loadRoom(connection, input.tenantId, input.roomId, true); if (input.allowedStoreIds && !input.allowedStoreIds.includes(String(room.storeId))) { throw new PricingError('STORE_SCOPE_FORBIDDEN'); } await this.releaseExpired(input.tenantId, input.roomId, connection); await this.assertAvailable(connection, input, true); const isHoliday = await this.isHoliday( connection, input.tenantId, input.startAt, room.timezone ); let quote = this.calculate(room, input, isHoliday); const holdMinutes = Math.min(Math.max(input.holdMinutes ?? 15, 5), 30); const orderNo = `QP${Date.now()}${randomBytes(4).toString('hex').toUpperCase()}`; const [orderResult] = await connection.execute( `INSERT INTO qipai_orders (tenant_id, store_id, room_id, order_no, status, start_at, end_at, hold_expires_at, total_amount_cents) VALUES (?, ?, ?, ?, 'PENDING_PAYMENT', ?, ?, DATE_ADD(UTC_TIMESTAMP(3), INTERVAL ? MINUTE), ?)`, [input.tenantId, room.storeId, input.roomId, orderNo, input.startAt, input.endAt, holdMinutes, quote.totalCents] ); const orderId = String(orderResult.insertId); let benefitReservation = null; const requestedBenefits = input.benefits; if (this.benefits && requestedBenefits && (requestedBenefits.couponGrantId || requestedBenefits.packageHoldingId)) { if (!requestedBenefits.clientRequestId) { throw new PricingError('BENEFIT_CLIENT_REQUEST_ID_REQUIRED'); } benefitReservation = await this.benefits.reserveForOrderInTransaction(connection, { tenantId: input.tenantId, userId: input.userId, orderId, storeId: String(room.storeId), roomId: input.roomId, roomCategoryId: room.roomCategoryId, orderAmountCents: quote.totalCents, orderStartAt: input.startAt, isHoliday, couponGrantId: requestedBenefits.couponGrantId ?? null, packageHoldingId: requestedBenefits.packageHoldingId ?? null, packageMinutes: requestedBenefits.packageMinutes, packageCreditCents: requestedBenefits.packageCreditCents, clientRequestId: requestedBenefits.clientRequestId, traceId: requestedBenefits.clientRequestId }); quote = this.calculate(room, { ...input, adjustment: { discountCents: benefitReservation.discountCents, packageCreditCents: benefitReservation.packageCreditCents } }, isHoliday); await connection.execute( `UPDATE qipai_orders SET total_amount_cents = ? WHERE tenant_id = ? AND id = ?`, [quote.totalCents, input.tenantId, orderId] ); } await connection.execute( `INSERT INTO qipai_order_price_snapshots (tenant_id, order_id, pricing_mode, duration_minutes, unit_price_cents, subtotal_cents, minimum_spend_cents, deposit_cents, discount_cents, package_credit_cents, total_cents, rules) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [input.tenantId, orderId, input.pricingMode, quote.durationMinutes, quote.unitPriceCents, quote.subtotalCents, quote.minimumSpendCents, quote.depositCents, quote.discountCents, quote.packageCreditCents, quote.totalCents, JSON.stringify(quote.rules)] ); const [reservationResult] = await connection.execute( `INSERT INTO qipai_room_reservations (tenant_id, order_id, room_id, starts_at, ends_at, expires_at) VALUES (?, ?, ?, ?, ?, DATE_ADD(UTC_TIMESTAMP(3), INTERVAL ? MINUTE))`, [input.tenantId, orderId, input.roomId, input.startAt, input.endAt, holdMinutes] ); await connection.execute( `INSERT INTO qipai_order_user_access (tenant_id, order_id, user_id) VALUES (?, ?, ?)`, [input.tenantId, orderId, input.userId] ); await connection.execute( `INSERT INTO qipai_order_status_history (tenant_id, order_id, from_status, to_status, action, actor_type, actor_id, source, reason, trace_id, metadata) VALUES (?, ?, NULL, 'PENDING_PAYMENT', 'CREATED', 'USER', ?, 'APP', 'Room hold created', ?, JSON_OBJECT('statusVersion', 1))`, [input.tenantId, orderId, input.userId, `order-created-${orderId}`] ); return { orderId, orderNo, storeId: String(room.storeId), reservationId: String(reservationResult.insertId), holdMinutes, quote, benefitReservation }; }); } async releaseExpired( tenantId?: string, roomId?: string, connection: Pick | PoolConnection = this.pool ) { const filters = [`r.status = 'HELD'`, 'r.expires_at <= UTC_TIMESTAMP(3)']; const params: string[] = []; if (tenantId) { filters.push('r.tenant_id = ?'); params.push(tenantId); } if (roomId) { filters.push('r.room_id = ?'); params.push(roomId); } const [counts] = await connection.execute( `SELECT COUNT(*) AS total FROM qipai_room_reservations r WHERE ${filters.join(' AND ')}`, params ); await connection.execute( `INSERT IGNORE INTO qipai_order_status_history (tenant_id, order_id, from_status, to_status, action, actor_type, actor_id, source, reason, trace_id, metadata) SELECT o.tenant_id, o.id, o.status, 'CLOSED', 'EXPIRED', 'SYSTEM', NULL, 'WORKER', 'Payment hold expired', CONCAT('hold-expired-', o.id), JSON_OBJECT('statusVersion', o.status_version + 1) FROM qipai_room_reservations r INNER JOIN qipai_orders o ON o.id = r.order_id AND o.tenant_id = r.tenant_id WHERE ${filters.join(' AND ')}`, params ); await connection.execute( `UPDATE qipai_room_reservations r INNER JOIN qipai_orders o ON o.id = r.order_id AND o.tenant_id = r.tenant_id SET r.status = 'RELEASED', r.released_at = UTC_TIMESTAMP(3), o.status = 'CLOSED', o.status_version = o.status_version + 1, o.status_updated_at = UTC_TIMESTAMP(3) WHERE ${filters.join(' AND ')}`, params ); return { released: Number(counts[0]?.total ?? 0) }; } private calculate(room: RoomPricingRow, input: QuoteInput, isHoliday: boolean) { this.validateWindow(room, input); const durationMinutes = Math.ceil( (input.endAt.getTime() - input.startAt.getTime()) / 60000 ); const localDate = localDateKey(input.startAt, room.timezone); const weekday = localWeekday(input.startAt, room.timezone); const adjustment = input.adjustment ?? {}; let unitPriceCents = room.basePriceCents; let priceSource = 'base'; if (input.pricingMode === 'OVERNIGHT') { unitPriceCents = room.overnightPriceCents || room.basePriceCents; priceSource = 'overnight'; } else if (input.pricingMode === 'FULL_DAY') { unitPriceCents = room.fullDayPriceCents || room.basePriceCents * 24; priceSource = 'fullDay'; } else if (isHoliday && room.holidayPriceCents > 0) { unitPriceCents = room.holidayPriceCents; priceSource = 'holiday'; } else if (weekday >= 1 && weekday <= 5 && room.weekdayPriceCents > 0) { unitPriceCents = room.weekdayPriceCents; priceSource = 'weekday'; } const subtotalCents = input.pricingMode === 'HOURLY' ? Math.ceil(durationMinutes / 60) * unitPriceCents : unitPriceCents; const minimumSpendCents = room.minimumSpendCents; const billableCents = Math.max(subtotalCents, minimumSpendCents); const discountCents = clampAdjustment(adjustment.discountCents, billableCents); const afterDiscount = billableCents - discountCents; const packageCreditCents = clampAdjustment( adjustment.packageCreditCents, afterDiscount ); const totalCents = afterDiscount - packageCreditCents + room.depositCents; return { durationMinutes, unitPriceCents, subtotalCents, minimumSpendCents, depositCents: room.depositCents, discountCents, packageCreditCents, totalCents, rules: { priceSource, isHoliday, localDate, timezone: room.timezone, pricingMode: input.pricingMode, minimumMinutes: room.minimumMinutes } }; } private validateWindow(room: RoomPricingRow, input: QuoteInput) { if (input.endAt <= input.startAt) throw new PricingError('INVALID_TIME_WINDOW'); const durationMinutes = (input.endAt.getTime() - input.startAt.getTime()) / 60000; if (durationMinutes < room.minimumMinutes) throw new PricingError('MINIMUM_DURATION_NOT_MET'); if (input.startAt.getTime() > Date.now() + room.maxAdvanceDays * 86400000) { throw new PricingError('ADVANCE_WINDOW_EXCEEDED'); } if (room.configurationStatus !== 'ENABLED' || room.operationalStatus !== 'AVAILABLE') { throw new PricingError('ROOM_NOT_AVAILABLE'); } } private async loadRoom( connection: Pick | PoolConnection, tenantId: string, roomId: string, lock = false ) { const [rows] = await connection.execute( `SELECT r.id, r.store_id AS storeId, s.timezone, r.configuration_status AS configurationStatus, r.operational_status AS operationalStatus, r.base_price_cents AS basePriceCents, r.weekday_price_cents AS weekdayPriceCents, r.holiday_price_cents AS holidayPriceCents, r.overnight_price_cents AS overnightPriceCents, r.full_day_price_cents AS fullDayPriceCents, r.minimum_spend_cents AS minimumSpendCents, r.deposit_cents AS depositCents, r.minimum_minutes AS minimumMinutes, r.max_advance_days AS maxAdvanceDays, r.room_category_id AS roomCategoryId FROM qipai_rooms r INNER JOIN qipai_stores s ON s.id = r.store_id AND s.tenant_id = r.tenant_id AND s.deleted_at IS NULL WHERE r.tenant_id = ? AND r.id = ? AND r.deleted_at IS NULL ${lock ? 'FOR UPDATE' : ''}`, [tenantId, roomId] ); if (!rows[0]) throw new PricingError('ROOM_NOT_FOUND'); return rows[0]; } private async assertAvailable( connection: Pick | PoolConnection, input: QuoteInput, lock: boolean ) { const [disabled] = await connection.execute( `SELECT COUNT(*) AS total FROM qipai_room_disabled_periods WHERE tenant_id = ? AND room_id = ? AND starts_at < ? AND ends_at > ?`, [input.tenantId, input.roomId, input.endAt, input.startAt] ); if (Number(disabled[0]?.total ?? 0) > 0) throw new PricingError('ROOM_DISABLED_PERIOD'); const [reserved] = await connection.execute( `SELECT COUNT(*) AS total FROM qipai_room_reservations WHERE tenant_id = ? AND room_id = ? AND status IN ('HELD', 'CONSUMED') AND (status = 'CONSUMED' OR expires_at > UTC_TIMESTAMP(3)) AND starts_at < ? AND ends_at > ? ${lock ? 'FOR UPDATE' : ''}`, [input.tenantId, input.roomId, input.endAt, input.startAt] ); if (Number(reserved[0]?.total ?? 0) > 0) throw new PricingError('TIME_SLOT_CONFLICT'); } private async isHoliday( connection: Pick | PoolConnection, tenantId: string, date: Date, timezone: string ) { const [rows] = await connection.execute( `SELECT COUNT(*) AS total FROM qipai_holiday_calendar WHERE tenant_id = ? AND holiday_date = ?`, [tenantId, localDateKey(date, timezone)] ); return Number(rows[0]?.total ?? 0) > 0; } private async transaction(work: (connection: PoolConnection) => Promise) { const connection = await this.pool.getConnection(); try { await connection.beginTransaction(); const result = await work(connection); await connection.commit(); return result; } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } } } function clampAdjustment(value: number | undefined, maximum: number) { if (!value || value < 0) return 0; return Math.min(Math.trunc(value), maximum); } function localDateKey(date: Date, timezone: string) { return new Intl.DateTimeFormat('en-CA', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(date); } function localWeekday(date: Date, timezone: string) { const day = new Intl.DateTimeFormat('en-US', { timeZone: timezone, weekday: 'short' }).format(date); return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(day); }