388 lines
15 KiB
TypeScript
388 lines
15 KiB
TypeScript
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<MarketingBenefitService, 'reserveForOrderInTransaction'>
|
|
) {}
|
|
|
|
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<ResultSetHeader>(
|
|
`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<ResultSetHeader>(
|
|
`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<MySqlPool, 'execute'> | 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<CountRow[]>(
|
|
`SELECT COUNT(*) AS total FROM qipai_room_reservations r
|
|
WHERE ${filters.join(' AND ')}`,
|
|
params
|
|
);
|
|
await connection.execute<ResultSetHeader>(
|
|
`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<ResultSetHeader>(
|
|
`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<MySqlPool, 'execute'> | PoolConnection,
|
|
tenantId: string,
|
|
roomId: string,
|
|
lock = false
|
|
) {
|
|
const [rows] = await connection.execute<RoomPricingRow[]>(
|
|
`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<MySqlPool, 'execute'> | PoolConnection,
|
|
input: QuoteInput,
|
|
lock: boolean
|
|
) {
|
|
const [disabled] = await connection.execute<CountRow[]>(
|
|
`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<CountRow[]>(
|
|
`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<MySqlPool, 'execute'> | PoolConnection,
|
|
tenantId: string,
|
|
date: Date,
|
|
timezone: string
|
|
) {
|
|
const [rows] = await connection.execute<CountRow[]>(
|
|
`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<T>(work: (connection: PoolConnection) => Promise<T>) {
|
|
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);
|
|
}
|