import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise'; import type { AccessProfile } from '../auth/rbac-repository.js'; import type { MySqlPool } from '../db/mysql.js'; import type { OrderActor } from './order-state-repository.js'; type PricingPolicy = 'CURRENT' | 'LOCKED'; type AdjustableStatus = 'PENDING_PAYMENT' | 'PAID' | 'RESERVED' | 'IN_PROGRESS'; interface OrderRow extends RowDataPacket { id: string; storeId: string; roomId: string; status: AdjustableStatus; startAt: Date; endAt: Date; totalAmountCents: number; adjustmentAmountCents: number; cancellationCutoffMinutes: number; cancellationFeeBps: number; } interface RoomRow extends RowDataPacket { id: string; storeId: string; basePriceCents: number; weekdayPriceCents: number; holidayPriceCents: number; timezone: string; operationalStatus: string; configurationStatus: string; } interface SnapshotRow extends RowDataPacket { unitPriceCents: number } interface DuplicateRow extends RowDataPacket { id: string; adjustmentType: string; amountDeltaCents: number; } export class OrderManagementError extends Error { constructor(public readonly code: string) { super(code); } } export class OrderManagementRepository { constructor(private readonly pool: MySqlPool) {} async renew(actor: OrderActor, orderId: string, input: { endAt: Date; pricingPolicy: PricingPolicy; reason: string; }) { return this.transaction(async (connection) => { const order = await this.loadOrder(connection, actor.tenantId, orderId); this.assertManager(actor.access, order.storeId); const duplicate = await this.duplicate(connection, actor, orderId); if (duplicate) return this.duplicateResult(orderId, duplicate); if (!['PAID', 'RESERVED', 'IN_PROGRESS'].includes(order.status)) { throw new OrderManagementError('ORDER_RENEW_STATUS_INVALID'); } if (input.endAt <= order.endAt) throw new OrderManagementError('ORDER_RENEW_END_INVALID'); await this.lockRooms(connection, actor.tenantId, [order.roomId]); await this.assertAvailable( connection, actor.tenantId, order.roomId, order.endAt, input.endAt, orderId ); const unitPrice = await this.resolveUnitPrice( connection, actor.tenantId, order, input.pricingPolicy ); const amountDeltaCents = Math.ceil( (input.endAt.getTime() - order.endAt.getTime()) / 3600000 ) * unitPrice; await connection.execute( `UPDATE qipai_orders SET end_at = ?, total_amount_cents = total_amount_cents + ?, adjustment_amount_cents = adjustment_amount_cents + ? WHERE tenant_id = ? AND id = ?`, [input.endAt, amountDeltaCents, amountDeltaCents, actor.tenantId, orderId] ); await connection.execute( `UPDATE qipai_room_reservations SET ends_at = ?, expires_at = GREATEST(expires_at, ?) WHERE tenant_id = ? AND order_id = ?`, [input.endAt, input.endAt, actor.tenantId, orderId] ); return this.record(connection, actor, order, 'RENEW', input.reason, { endAt: order.endAt }, { endAt: input.endAt, pricingPolicy: input.pricingPolicy }, amountDeltaCents); }); } async customerRenew(actor: OrderActor, orderId: string, input: { endAt: Date; pricingPolicy: PricingPolicy; reason: string; }) { return this.renewForActor(actor, orderId, input, async (connection, order) => { await this.assertOwner(connection, actor.tenantId, order.id, actor.userId); }); } async changeRoom(actor: OrderActor, orderId: string, input: { roomId: string; reason: string; }) { return this.transaction(async (connection) => { const order = await this.loadOrder(connection, actor.tenantId, orderId); this.assertManager(actor.access, order.storeId); const duplicate = await this.duplicate(connection, actor, orderId); if (duplicate) return this.duplicateResult(orderId, duplicate); if (!['PENDING_PAYMENT', 'PAID', 'RESERVED', 'IN_PROGRESS'].includes(order.status)) { throw new OrderManagementError('ORDER_ROOM_CHANGE_STATUS_INVALID'); } if (input.roomId === order.roomId) throw new OrderManagementError('ORDER_ROOM_UNCHANGED'); await this.lockRooms(connection, actor.tenantId, [order.roomId, input.roomId]); const target = await this.loadRoom(connection, actor.tenantId, input.roomId); this.assertManager(actor.access, target.storeId); await this.assertAvailable( connection, actor.tenantId, target.id, order.startAt, order.endAt, orderId ); const oldRoom = await this.loadRoom(connection, actor.tenantId, order.roomId); const hours = Math.ceil((order.endAt.getTime() - order.startAt.getTime()) / 3600000); const amountDeltaCents = hours * (target.basePriceCents - oldRoom.basePriceCents); await connection.execute( `UPDATE qipai_orders SET store_id = ?, room_id = ?, total_amount_cents = GREATEST(0, total_amount_cents + ?), adjustment_amount_cents = adjustment_amount_cents + ? WHERE tenant_id = ? AND id = ?`, [target.storeId, target.id, amountDeltaCents, amountDeltaCents, actor.tenantId, orderId] ); await connection.execute( `UPDATE qipai_room_reservations SET room_id = ? WHERE tenant_id = ? AND order_id = ?`, [target.id, actor.tenantId, orderId] ); return this.record(connection, actor, order, 'CHANGE_ROOM', input.reason, { storeId: order.storeId, roomId: order.roomId }, { storeId: target.storeId, roomId: target.id }, amountDeltaCents); }); } async customerChangeRoom(actor: OrderActor, orderId: string, input: { roomId: string; reason: string; }) { return this.changeRoomForActor(actor, orderId, input, async (connection, order, target) => { await this.assertOwner(connection, actor.tenantId, order.id, actor.userId); if (target.storeId !== order.storeId) { throw new OrderManagementError('ORDER_ROOM_CHANGE_STORE_INVALID'); } }); } async adjustTime(actor: OrderActor, orderId: string, input: { startAt?: Date; endAt?: Date; reason: string; }) { return this.transaction(async (connection) => { const order = await this.loadOrder(connection, actor.tenantId, orderId); this.assertManager(actor.access, order.storeId); const duplicate = await this.duplicate(connection, actor, orderId); if (duplicate) return this.duplicateResult(orderId, duplicate); const startAt = input.startAt ?? order.startAt; const endAt = input.endAt ?? order.endAt; if (endAt <= startAt) throw new OrderManagementError('ORDER_TIME_WINDOW_INVALID'); await this.lockRooms(connection, actor.tenantId, [order.roomId]); await this.assertAvailable( connection, actor.tenantId, order.roomId, startAt, endAt, orderId ); const oldHours = Math.ceil((order.endAt.getTime() - order.startAt.getTime()) / 3600000); const newHours = Math.ceil((endAt.getTime() - startAt.getTime()) / 3600000); const unitPrice = await this.resolveUnitPrice(connection, actor.tenantId, order, 'LOCKED'); const amountDeltaCents = (newHours - oldHours) * unitPrice; await connection.execute( `UPDATE qipai_orders SET start_at = ?, end_at = ?, total_amount_cents = GREATEST(0, total_amount_cents + ?), adjustment_amount_cents = adjustment_amount_cents + ? WHERE tenant_id = ? AND id = ?`, [startAt, endAt, amountDeltaCents, amountDeltaCents, actor.tenantId, orderId] ); await connection.execute( `UPDATE qipai_room_reservations SET starts_at = ?, ends_at = ?, expires_at = GREATEST(expires_at, ?) WHERE tenant_id = ? AND order_id = ?`, [startAt, endAt, endAt, actor.tenantId, orderId] ); return this.record(connection, actor, order, 'ADJUST_TIME', input.reason, { startAt: order.startAt, endAt: order.endAt }, { startAt, endAt }, amountDeltaCents); }); } async note(actor: OrderActor, orderId: string, note: string) { return this.transaction(async (connection) => { const order = await this.loadOrder(connection, actor.tenantId, orderId); this.assertManager(actor.access, order.storeId); const duplicate = await this.duplicate(connection, actor, orderId); if (duplicate) return this.duplicateResult(orderId, duplicate); await connection.execute( `UPDATE qipai_orders SET operator_note = ? WHERE tenant_id = ? AND id = ?`, [note.slice(0, 512), actor.tenantId, orderId] ); return this.record(connection, actor, order, 'NOTE', note, {}, { note }, 0); }); } async cancellationQuote(tenantId: string, userId: string, orderId: string) { return this.transaction(async (connection) => { const order = await this.loadOrder(connection, tenantId, orderId); await this.assertOwner(connection, tenantId, orderId, userId); const minutesBeforeStart = Math.floor((order.startAt.getTime() - Date.now()) / 60000); const feeCents = minutesBeforeStart >= order.cancellationCutoffMinutes ? 0 : Math.ceil(order.totalAmountCents * order.cancellationFeeBps / 10000); return { orderId, allowed: order.status !== 'IN_PROGRESS', cutoffMinutes: order.cancellationCutoffMinutes, feeCents, refundableCents: Math.max(0, order.totalAmountCents - feeCents) }; }); } private async record( connection: PoolConnection, actor: OrderActor, order: OrderRow, type: string, reason: string, before: object, after: object, amountDeltaCents: number ) { const [result] = await connection.execute( `INSERT INTO qipai_order_adjustments (tenant_id, order_id, adjustment_type, actor_id, source, trace_id, reason, before_values, after_values, amount_delta_cents) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [actor.tenantId, order.id, type, actor.userId, actor.source, actor.traceId, reason.slice(0, 512), JSON.stringify(before), JSON.stringify(after), amountDeltaCents] ); await connection.execute( `INSERT INTO qipai_audit_logs (tenant_id, actor_type, actor_id, action, resource_type, resource_id, trace_id, ip, user_agent, metadata) VALUES (?, 'USER', ?, 'ORDER_MANUALLY_ADJUSTED', 'ORDER', ?, ?, ?, ?, JSON_OBJECT('adjustmentType', ?, 'amountDeltaCents', ?))`, [actor.tenantId, actor.userId, order.id, actor.traceId, actor.ip, actor.userAgent.slice(0, 255), type, amountDeltaCents] ); return { orderId: order.id, adjustmentId: String(result.insertId), adjustmentType: type, amountDeltaCents, idempotent: false }; } private async renewForActor( actor: OrderActor, orderId: string, input: { endAt: Date; pricingPolicy: PricingPolicy; reason: string; }, authorize: (connection: PoolConnection, order: OrderRow) => Promise ) { return this.transaction(async (connection) => { const order = await this.loadOrder(connection, actor.tenantId, orderId); await authorize(connection, order); const duplicate = await this.duplicate(connection, actor, orderId); if (duplicate) return this.duplicateResult(orderId, duplicate); if (!['PAID', 'RESERVED', 'IN_PROGRESS'].includes(order.status)) { throw new OrderManagementError('ORDER_RENEW_STATUS_INVALID'); } if (input.endAt <= order.endAt) throw new OrderManagementError('ORDER_RENEW_END_INVALID'); await this.lockRooms(connection, actor.tenantId, [order.roomId]); await this.assertAvailable( connection, actor.tenantId, order.roomId, order.endAt, input.endAt, orderId ); const unitPrice = await this.resolveUnitPrice( connection, actor.tenantId, order, input.pricingPolicy ); const amountDeltaCents = Math.ceil( (input.endAt.getTime() - order.endAt.getTime()) / 3600000 ) * unitPrice; await connection.execute( `UPDATE qipai_orders SET end_at = ?, total_amount_cents = total_amount_cents + ?, adjustment_amount_cents = adjustment_amount_cents + ? WHERE tenant_id = ? AND id = ?`, [input.endAt, amountDeltaCents, amountDeltaCents, actor.tenantId, orderId] ); await connection.execute( `UPDATE qipai_room_reservations SET ends_at = ?, expires_at = GREATEST(expires_at, ?) WHERE tenant_id = ? AND order_id = ?`, [input.endAt, input.endAt, actor.tenantId, orderId] ); return this.record(connection, actor, order, 'RENEW', input.reason, { endAt: order.endAt }, { endAt: input.endAt, pricingPolicy: input.pricingPolicy }, amountDeltaCents); }); } private async changeRoomForActor( actor: OrderActor, orderId: string, input: { roomId: string; reason: string }, authorize: ( connection: PoolConnection, order: OrderRow, target: RoomRow ) => Promise ) { return this.transaction(async (connection) => { const order = await this.loadOrder(connection, actor.tenantId, orderId); const duplicate = await this.duplicate(connection, actor, orderId); if (duplicate) return this.duplicateResult(orderId, duplicate); if (!['PENDING_PAYMENT', 'PAID', 'RESERVED', 'IN_PROGRESS'].includes(order.status)) { throw new OrderManagementError('ORDER_ROOM_CHANGE_STATUS_INVALID'); } if (input.roomId === order.roomId) throw new OrderManagementError('ORDER_ROOM_UNCHANGED'); await this.lockRooms(connection, actor.tenantId, [order.roomId, input.roomId]); const target = await this.loadRoom(connection, actor.tenantId, input.roomId); await authorize(connection, order, target); await this.assertAvailable( connection, actor.tenantId, target.id, order.startAt, order.endAt, orderId ); const oldRoom = await this.loadRoom(connection, actor.tenantId, order.roomId); const hours = Math.ceil((order.endAt.getTime() - order.startAt.getTime()) / 3600000); const amountDeltaCents = hours * (target.basePriceCents - oldRoom.basePriceCents); await connection.execute( `UPDATE qipai_orders SET store_id = ?, room_id = ?, total_amount_cents = GREATEST(0, total_amount_cents + ?), adjustment_amount_cents = adjustment_amount_cents + ? WHERE tenant_id = ? AND id = ?`, [target.storeId, target.id, amountDeltaCents, amountDeltaCents, actor.tenantId, orderId] ); await connection.execute( `UPDATE qipai_room_reservations SET room_id = ? WHERE tenant_id = ? AND order_id = ?`, [target.id, actor.tenantId, orderId] ); return this.record(connection, actor, order, 'CHANGE_ROOM', input.reason, { storeId: order.storeId, roomId: order.roomId }, { storeId: target.storeId, roomId: target.id }, amountDeltaCents); }); } private async loadOrder(connection: PoolConnection, tenantId: string, orderId: string) { const [rows] = await connection.execute( `SELECT o.id, o.store_id AS storeId, o.room_id AS roomId, o.status, o.start_at AS startAt, o.end_at AS endAt, o.total_amount_cents AS totalAmountCents, o.adjustment_amount_cents AS adjustmentAmountCents, s.cancellation_cutoff_minutes AS cancellationCutoffMinutes, s.cancellation_fee_bps AS cancellationFeeBps FROM qipai_orders o INNER JOIN qipai_stores s ON s.tenant_id = o.tenant_id AND s.id = o.store_id WHERE o.tenant_id = ? AND o.id = ? AND o.deleted_at IS NULL FOR UPDATE`, [tenantId, orderId] ); if (!rows[0]) throw new OrderManagementError('ORDER_NOT_FOUND'); return rows[0]; } private async loadRoom(connection: PoolConnection, tenantId: string, roomId: string) { const [rows] = await connection.execute( `SELECT r.id, r.store_id AS storeId, r.base_price_cents AS basePriceCents, r.weekday_price_cents AS weekdayPriceCents, r.holiday_price_cents AS holidayPriceCents, r.operational_status AS operationalStatus, r.configuration_status AS configurationStatus, s.timezone FROM qipai_rooms r INNER JOIN qipai_stores s ON s.tenant_id = r.tenant_id AND s.id = r.store_id AND s.deleted_at IS NULL WHERE r.tenant_id = ? AND r.id = ? AND r.deleted_at IS NULL`, [tenantId, roomId] ); const room = rows[0]; if (!room) throw new OrderManagementError('ROOM_NOT_FOUND'); if (room.operationalStatus !== 'AVAILABLE' || room.configurationStatus !== 'ENABLED') { throw new OrderManagementError('ROOM_NOT_AVAILABLE'); } return room; } private async lockRooms(connection: PoolConnection, tenantId: string, roomIds: string[]) { const sorted = [...new Set(roomIds)].sort((a, b) => Number(a) - Number(b)); for (const roomId of sorted) { await connection.execute( `SELECT id FROM qipai_rooms WHERE tenant_id = ? AND id = ? FOR UPDATE`, [tenantId, roomId] ); } } private async assertAvailable( connection: PoolConnection, tenantId: string, roomId: string, startAt: Date, endAt: Date, excludingOrderId: string ) { const [disabled] = await connection.execute( `SELECT id FROM qipai_room_disabled_periods WHERE tenant_id = ? AND room_id = ? AND starts_at < ? AND ends_at > ? FOR UPDATE`, [tenantId, roomId, endAt, startAt] ); if (disabled[0]) throw new OrderManagementError('ROOM_DISABLED_PERIOD'); const [rows] = await connection.execute( `SELECT id FROM qipai_room_reservations WHERE tenant_id = ? AND room_id = ? AND order_id <> ? AND status IN ('HELD', 'CONSUMED') AND (status = 'CONSUMED' OR expires_at > UTC_TIMESTAMP(3)) AND starts_at < ? AND ends_at > ? FOR UPDATE`, [tenantId, roomId, excludingOrderId, endAt, startAt] ); if (rows[0]) throw new OrderManagementError('TIME_SLOT_CONFLICT'); } private async resolveUnitPrice( connection: PoolConnection, tenantId: string, order: OrderRow, policy: PricingPolicy ) { if (policy === 'LOCKED') { const [rows] = await connection.execute( `SELECT unit_price_cents AS unitPriceCents FROM qipai_order_price_snapshots WHERE tenant_id = ? AND order_id = ?`, [tenantId, order.id] ); if (!rows[0]) throw new OrderManagementError('ORDER_PRICE_SNAPSHOT_MISSING'); return Number(rows[0].unitPriceCents); } const room = await this.loadRoom(connection, tenantId, order.roomId); const localDate = new Intl.DateTimeFormat('en-CA', { timeZone: room.timezone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(order.endAt); const [holidayRows] = await connection.execute( `SELECT 1 FROM qipai_holiday_calendar WHERE tenant_id = ? AND holiday_date = ? LIMIT 1`, [tenantId, localDate] ); if (holidayRows[0] && room.holidayPriceCents > 0) return Number(room.holidayPriceCents); const weekdayName = new Intl.DateTimeFormat('en-US', { timeZone: room.timezone, weekday: 'short' }).format(order.endAt); if (['Mon', 'Tue', 'Wed', 'Thu', 'Fri'].includes(weekdayName) && room.weekdayPriceCents > 0) { return Number(room.weekdayPriceCents); } return Number(room.basePriceCents); } private async duplicate(connection: PoolConnection, actor: OrderActor, orderId: string) { const [rows] = await connection.execute( `SELECT id, adjustment_type AS adjustmentType, amount_delta_cents AS amountDeltaCents FROM qipai_order_adjustments WHERE tenant_id = ? AND order_id = ? AND trace_id = ? LIMIT 1`, [actor.tenantId, orderId, actor.traceId] ); return rows[0] ?? null; } private duplicateResult(orderId: string, duplicate: DuplicateRow) { return { orderId, adjustmentId: String(duplicate.id), adjustmentType: duplicate.adjustmentType, amountDeltaCents: Number(duplicate.amountDeltaCents), idempotent: true }; } private assertManager(access: AccessProfile | undefined, storeId: string) { if (!access || !(access.capabilities.includes('tenant.manage') || access.roles.includes('PLATFORM_ADMIN') || (access.capabilities.includes('store.operation.write') && access.storeIds.includes(storeId)))) { throw new OrderManagementError('ORDER_MANAGEMENT_FORBIDDEN'); } } private async assertOwner( connection: PoolConnection, tenantId: string, orderId: string, userId: string ) { const [rows] = await connection.execute( `SELECT 1 FROM qipai_order_user_access WHERE tenant_id = ? AND order_id = ? AND user_id = ?`, [tenantId, orderId, userId] ); if (!rows[0]) throw new OrderManagementError('ORDER_ACCESS_FORBIDDEN'); } 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(); } } }