@voyantjs/sellability 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1219 @@
1
+ import { availabilitySlots } from "@voyantjs/availability/schema";
2
+ import { channelInventoryAllotments, channelInventoryAllotmentTargets, channelInventoryReleaseRules, channels, } from "@voyantjs/distribution/schema";
3
+ import { exchangeRates, fxRateSets, marketChannelRules, marketPriceCatalogs, marketProductRules, markets, } from "@voyantjs/markets/schema";
4
+ import { optionPriceRules, optionStartTimeRules, optionUnitPriceRules, optionUnitTiers, pickupPriceRules, priceCatalogs, priceSchedules, pricingCategories, } from "@voyantjs/pricing/schema";
5
+ import { optionUnits, productOptions, products } from "@voyantjs/products/schema";
6
+ import { transactionsService } from "@voyantjs/transactions";
7
+ import { and, asc, desc, eq, inArray, sql } from "drizzle-orm";
8
+ import { offerExpirationEvents, offerRefreshRuns, sellabilityExplanations, sellabilityPolicies, sellabilityPolicyResults, sellabilitySnapshotItems, sellabilitySnapshots, } from "./schema.js";
9
+ function weekdayCandidates(dateLocal) {
10
+ const weekday = new Date(`${dateLocal}T00:00:00Z`).getUTCDay();
11
+ const names = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
12
+ const longNames = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
13
+ return [String(weekday), names[weekday], longNames[weekday]];
14
+ }
15
+ function scheduleMatches(schedule, dateLocal) {
16
+ if (!dateLocal)
17
+ return true;
18
+ if (schedule.validFrom && dateLocal < schedule.validFrom)
19
+ return false;
20
+ if (schedule.validTo && dateLocal > schedule.validTo)
21
+ return false;
22
+ if (!schedule.weekdays || schedule.weekdays.length === 0)
23
+ return true;
24
+ const candidates = weekdayCandidates(dateLocal);
25
+ return schedule.weekdays.some((entry) => candidates.includes(entry.toLowerCase()));
26
+ }
27
+ function applyAdjustment(total, adjustment) {
28
+ if (adjustment.adjustmentType === "fixed") {
29
+ return {
30
+ sellAmountCents: total.sellAmountCents + (adjustment.sellAdjustmentCents ?? 0),
31
+ costAmountCents: total.costAmountCents + (adjustment.costAdjustmentCents ?? 0),
32
+ };
33
+ }
34
+ if (adjustment.adjustmentType === "percentage" && adjustment.adjustmentBasisPoints) {
35
+ return {
36
+ sellAmountCents: total.sellAmountCents +
37
+ Math.round((total.sellAmountCents * adjustment.adjustmentBasisPoints) / 10_000),
38
+ costAmountCents: total.costAmountCents +
39
+ Math.round((total.costAmountCents * adjustment.adjustmentBasisPoints) / 10_000),
40
+ };
41
+ }
42
+ return total;
43
+ }
44
+ function computeUnitAmounts(request, details, unitRule, tier) {
45
+ const pricingMode = unitRule?.pricingMode ?? "per_unit";
46
+ const baseSell = tier?.sellAmountCents ?? unitRule?.sellAmountCents ?? 0;
47
+ const baseCost = tier?.costAmountCents ?? unitRule?.costAmountCents ?? 0;
48
+ if (pricingMode === "included" || pricingMode === "free") {
49
+ return {
50
+ requestRef: request.requestRef ?? null,
51
+ unitId: request.unitId ?? null,
52
+ unitName: details.unitName,
53
+ unitType: details.unitType,
54
+ pricingCategoryId: request.pricingCategoryId ?? null,
55
+ pricingCategoryName: details.pricingCategoryName,
56
+ quantity: request.quantity,
57
+ pricingMode,
58
+ sellAmountCents: 0,
59
+ costAmountCents: 0,
60
+ sourceRuleId: unitRule?.id ?? null,
61
+ tierId: tier?.id ?? null,
62
+ };
63
+ }
64
+ const multiplier = pricingMode === "per_booking" ? 1 : request.quantity;
65
+ return {
66
+ requestRef: request.requestRef ?? null,
67
+ unitId: request.unitId ?? null,
68
+ unitName: details.unitName,
69
+ unitType: details.unitType,
70
+ pricingCategoryId: request.pricingCategoryId ?? null,
71
+ pricingCategoryName: details.pricingCategoryName,
72
+ quantity: request.quantity,
73
+ pricingMode,
74
+ sellAmountCents: baseSell * multiplier,
75
+ costAmountCents: baseCost * multiplier,
76
+ sourceRuleId: unitRule?.id ?? null,
77
+ tierId: tier?.id ?? null,
78
+ };
79
+ }
80
+ function chooseBestScheduledRule(rows) {
81
+ return ([...rows].sort((a, b) => {
82
+ const scoreA = Number(Boolean(a.priceScheduleId)) * 10 + Number(Boolean(a.isDefault));
83
+ const scoreB = Number(Boolean(b.priceScheduleId)) * 10 + Number(Boolean(b.isDefault));
84
+ return scoreB - scoreA;
85
+ })[0] ?? null);
86
+ }
87
+ function chooseBestSpecificRule(rows) {
88
+ return ([...rows].sort((a, b) => Number(Boolean(b.optionId)) - Number(Boolean(a.optionId)))[0] ?? null);
89
+ }
90
+ function toNumeric(value) {
91
+ if (value === null || value === undefined)
92
+ return null;
93
+ return typeof value === "number" ? value : Number(value);
94
+ }
95
+ function compactObject(value) {
96
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
97
+ }
98
+ function formatSequenceNumber(prefix) {
99
+ const now = new Date();
100
+ const year = now.getUTCFullYear();
101
+ const month = String(now.getUTCMonth() + 1).padStart(2, "0");
102
+ const day = String(now.getUTCDate()).padStart(2, "0");
103
+ const time = String(now.getUTCHours()).padStart(2, "0") + String(now.getUTCMinutes()).padStart(2, "0");
104
+ const random = Math.random().toString(36).slice(2, 6).toUpperCase();
105
+ return `${prefix}-${year}${month}${day}-${time}${random}`;
106
+ }
107
+ function buildDefaultOfferTitle(candidate) {
108
+ if (candidate.slot.dateLocal) {
109
+ return `${candidate.product.name} · ${candidate.option.name} · ${candidate.slot.dateLocal}`;
110
+ }
111
+ return `${candidate.product.name} · ${candidate.option.name}`;
112
+ }
113
+ function isAssignableParticipantType(participantType) {
114
+ return participantType === "traveler" || participantType === "occupant";
115
+ }
116
+ function defaultItemParticipantRole(participant) {
117
+ if (participant.itemParticipantRole)
118
+ return participant.itemParticipantRole;
119
+ if (participant.participantType === "occupant")
120
+ return "occupant";
121
+ if (participant.participantType === "booker" || participant.participantType === "contact") {
122
+ return "primary_contact";
123
+ }
124
+ if (participant.participantType === "staff")
125
+ return "service_assignee";
126
+ return "traveler";
127
+ }
128
+ function offerItemTypeForComponent(component) {
129
+ if (component.kind === "pickup")
130
+ return "transport";
131
+ if (component.kind === "start_time_adjustment") {
132
+ return component.sellAmountCents < 0 ? "discount" : "adjustment";
133
+ }
134
+ if (component.kind === "base")
135
+ return "service";
136
+ if (component.unitType === "room")
137
+ return "accommodation";
138
+ if (component.unitType === "vehicle")
139
+ return "transport";
140
+ return "unit";
141
+ }
142
+ async function paginate(rowsQuery, countQuery, limit, offset) {
143
+ const [data, countResult] = await Promise.all([rowsQuery, countQuery]);
144
+ return { data, total: countResult[0]?.count ?? 0, limit, offset };
145
+ }
146
+ function normalizeDateTime(value) {
147
+ return value ? new Date(value) : null;
148
+ }
149
+ async function persistResolvedSnapshot(db, input) {
150
+ const primaryCandidate = input.resolved.data[0] ?? null;
151
+ const [snapshot] = await db
152
+ .insert(sellabilitySnapshots)
153
+ .values({
154
+ offerId: input.offerId ?? null,
155
+ marketId: primaryCandidate?.market?.id ?? input.query.marketId ?? null,
156
+ channelId: primaryCandidate?.channel?.id ?? input.query.channelId ?? null,
157
+ productId: primaryCandidate?.product.id ?? input.query.productId ?? null,
158
+ optionId: primaryCandidate?.option.id ?? input.query.optionId ?? null,
159
+ slotId: primaryCandidate?.slot.id ?? input.query.slotId ?? null,
160
+ requestedCurrencyCode: input.query.currencyCode ?? null,
161
+ sourceCurrencyCode: primaryCandidate?.pricing.currencyCode ?? null,
162
+ fxRateSetId: primaryCandidate?.pricing.fx?.fxRateSetId ?? null,
163
+ status: input.status ?? "resolved",
164
+ queryPayload: input.query,
165
+ pricingSummary: {
166
+ totalCandidates: input.resolved.meta.total,
167
+ selectedCandidateIndex: input.selectedCandidateIndex ?? null,
168
+ },
169
+ expiresAt: normalizeDateTime(input.expiresAt ?? null),
170
+ })
171
+ .returning();
172
+ if (!snapshot) {
173
+ throw new Error("Failed to persist sellability snapshot");
174
+ }
175
+ const itemValues = input.resolved.data.flatMap((candidate, candidateIndex) => {
176
+ const components = candidate.pricing.components ?? [];
177
+ const normalizedComponents = components.length > 0
178
+ ? components
179
+ : [
180
+ {
181
+ kind: "base",
182
+ title: candidate.option.name,
183
+ quantity: 1,
184
+ pricingMode: "per_booking",
185
+ sellAmountCents: candidate.pricing.sellAmountCents,
186
+ costAmountCents: candidate.pricing.costAmountCents,
187
+ unitId: null,
188
+ unitName: null,
189
+ unitType: null,
190
+ pricingCategoryId: null,
191
+ pricingCategoryName: null,
192
+ requestRef: null,
193
+ sourceRuleId: candidate.sources.optionPriceRuleId,
194
+ tierId: null,
195
+ },
196
+ ];
197
+ return normalizedComponents.map((component, componentIndex) => ({
198
+ snapshotId: snapshot.id,
199
+ candidateIndex,
200
+ componentIndex,
201
+ productId: candidate.product.id,
202
+ optionId: candidate.option.id,
203
+ slotId: candidate.slot.id,
204
+ unitId: component.unitId,
205
+ requestRef: component.requestRef,
206
+ componentKind: component.kind,
207
+ title: component.title,
208
+ quantity: component.quantity,
209
+ pricingMode: component.pricingMode,
210
+ pricingCategoryId: component.pricingCategoryId,
211
+ pricingCategoryName: component.pricingCategoryName,
212
+ unitName: component.unitName,
213
+ unitType: component.unitType,
214
+ currencyCode: candidate.pricing.currencyCode,
215
+ sellAmountCents: component.sellAmountCents,
216
+ costAmountCents: component.costAmountCents,
217
+ sourceRuleId: component.sourceRuleId,
218
+ tierId: component.tierId,
219
+ isSelected: input.selectedCandidateIndex === candidateIndex,
220
+ }));
221
+ });
222
+ if (itemValues.length > 0) {
223
+ await db.insert(sellabilitySnapshotItems).values(itemValues);
224
+ }
225
+ return snapshot;
226
+ }
227
+ export const sellabilityService = {
228
+ async constructOffer(db, input) {
229
+ const resolvedQuery = {
230
+ ...input.query,
231
+ limit: input.query.limit ?? 100,
232
+ };
233
+ const resolved = await sellabilityService.resolve(db, resolvedQuery);
234
+ const candidate = resolved.data.find((row) => row.slot.id === input.query.slotId &&
235
+ (!input.query.optionId || row.option.id === input.query.optionId) &&
236
+ (!input.query.productId || row.product.id === input.query.productId)) ?? null;
237
+ if (!candidate) {
238
+ return null;
239
+ }
240
+ const components = candidate.pricing.components;
241
+ const pricedComponents = components.length > 0
242
+ ? components
243
+ : [
244
+ {
245
+ kind: "base",
246
+ title: candidate.option.name,
247
+ quantity: 1,
248
+ pricingMode: "per_booking",
249
+ sellAmountCents: candidate.pricing.sellAmountCents,
250
+ costAmountCents: candidate.pricing.costAmountCents,
251
+ unitId: null,
252
+ unitName: null,
253
+ unitType: null,
254
+ pricingCategoryId: null,
255
+ pricingCategoryName: null,
256
+ requestRef: null,
257
+ sourceRuleId: candidate.sources.optionPriceRuleId,
258
+ tierId: null,
259
+ },
260
+ ];
261
+ const hasUnitComponents = pricedComponents.some((component) => component.kind === "unit");
262
+ const itemDrafts = pricedComponents.map((component, index) => {
263
+ const quantity = component.quantity > 0 ? component.quantity : 1;
264
+ return {
265
+ title: component.title,
266
+ description: null,
267
+ itemType: offerItemTypeForComponent(component),
268
+ status: "priced",
269
+ productId: candidate.product.id,
270
+ optionId: candidate.option.id,
271
+ unitId: component.unitId,
272
+ slotId: candidate.slot.id,
273
+ serviceDate: candidate.slot.dateLocal,
274
+ startsAt: candidate.slot.startsAt ? new Date(candidate.slot.startsAt).toISOString() : null,
275
+ endsAt: null,
276
+ quantity,
277
+ sellCurrency: candidate.pricing.currencyCode,
278
+ unitSellAmountCents: Math.round(component.sellAmountCents / quantity),
279
+ totalSellAmountCents: component.sellAmountCents,
280
+ taxAmountCents: null,
281
+ feeAmountCents: null,
282
+ costCurrency: candidate.pricing.currencyCode,
283
+ unitCostAmountCents: Math.round(component.costAmountCents / quantity),
284
+ totalCostAmountCents: component.costAmountCents,
285
+ notes: null,
286
+ metadata: compactObject({
287
+ componentKind: component.kind,
288
+ requestRef: component.requestRef,
289
+ unitName: component.unitName,
290
+ unitType: component.unitType,
291
+ pricingCategoryId: component.pricingCategoryId,
292
+ pricingCategoryName: component.pricingCategoryName,
293
+ sourceRuleId: component.sourceRuleId,
294
+ tierId: component.tierId,
295
+ sortOrder: index,
296
+ }),
297
+ requestRef: component.requestRef,
298
+ participantLinkable: component.kind === "unit" || (component.kind === "base" && !hasUnitComponents),
299
+ };
300
+ });
301
+ const bundleParticipants = input.participants.map((participant) => ({
302
+ personId: participant.personId ?? null,
303
+ participantType: participant.participantType,
304
+ travelerCategory: participant.travelerCategory ?? null,
305
+ firstName: participant.firstName,
306
+ lastName: participant.lastName,
307
+ email: participant.email ?? null,
308
+ phone: participant.phone ?? null,
309
+ preferredLanguage: participant.preferredLanguage ?? null,
310
+ dateOfBirth: participant.dateOfBirth ?? null,
311
+ nationality: participant.nationality ?? null,
312
+ isPrimary: participant.isPrimary,
313
+ notes: participant.notes ?? null,
314
+ }));
315
+ const linkableItemIndexes = itemDrafts
316
+ .map((item, index) => (item.participantLinkable ? index : -1))
317
+ .filter((index) => index >= 0);
318
+ const fallbackLinkableItemIndex = linkableItemIndexes[0] ?? (itemDrafts.length > 0 ? 0 : null);
319
+ const itemParticipants = [];
320
+ input.participants.forEach((participant, participantIndex) => {
321
+ const explicitRefs = new Set(participant.requestedUnitRefs);
322
+ let targetIndexes = itemDrafts
323
+ .map((item, itemIndex) => item.requestRef && explicitRefs.has(item.requestRef) ? itemIndex : -1)
324
+ .filter((itemIndex) => itemIndex >= 0);
325
+ if (targetIndexes.length === 0) {
326
+ if (participant.assignToAllItems ||
327
+ isAssignableParticipantType(participant.participantType)) {
328
+ targetIndexes = linkableItemIndexes;
329
+ }
330
+ else if (participant.isPrimary &&
331
+ (participant.participantType === "booker" || participant.participantType === "contact") &&
332
+ fallbackLinkableItemIndex !== null) {
333
+ targetIndexes = [fallbackLinkableItemIndex];
334
+ }
335
+ }
336
+ const dedupedIndexes = [...new Set(targetIndexes)];
337
+ dedupedIndexes.forEach((itemIndex, linkIndex) => {
338
+ itemParticipants.push({
339
+ itemIndex,
340
+ participantIndex,
341
+ role: defaultItemParticipantRole(participant),
342
+ isPrimary: Boolean(participant.isPrimary) && linkIndex === 0,
343
+ });
344
+ });
345
+ });
346
+ const selectedCandidateIndex = resolved.data.findIndex((row) => row.slot.id === candidate.slot.id);
347
+ const created = await transactionsService.createOfferBundle(db, {
348
+ offer: {
349
+ offerNumber: input.offer.offerNumber ?? formatSequenceNumber("OFF"),
350
+ title: input.offer.title ?? buildDefaultOfferTitle(candidate),
351
+ status: input.offer.status,
352
+ personId: input.offer.personId ?? null,
353
+ organizationId: input.offer.organizationId ?? null,
354
+ opportunityId: input.offer.opportunityId ?? null,
355
+ quoteId: input.offer.quoteId ?? null,
356
+ marketId: candidate.market?.id ?? input.query.marketId ?? null,
357
+ sourceChannelId: candidate.channel?.id ?? input.query.channelId ?? null,
358
+ currency: candidate.pricing.currencyCode,
359
+ baseCurrency: candidate.pricing.fx?.baseCurrency ?? null,
360
+ fxRateSetId: candidate.pricing.fx?.fxRateSetId ?? null,
361
+ subtotalAmountCents: candidate.pricing.sellAmountCents,
362
+ taxAmountCents: 0,
363
+ feeAmountCents: 0,
364
+ totalAmountCents: candidate.pricing.sellAmountCents,
365
+ costAmountCents: candidate.pricing.costAmountCents,
366
+ validFrom: input.offer.validFrom ?? null,
367
+ validUntil: input.offer.validUntil ?? null,
368
+ notes: input.offer.notes ?? null,
369
+ metadata: {
370
+ ...(input.offer.metadata ?? {}),
371
+ sellability: {
372
+ query: input.query,
373
+ resolution: candidate.sources,
374
+ onRequest: candidate.sellability.onRequest,
375
+ allotmentStatus: candidate.sellability.allotmentStatus,
376
+ selectedCandidateIndex: selectedCandidateIndex >= 0 ? selectedCandidateIndex : null,
377
+ },
378
+ },
379
+ },
380
+ participants: bundleParticipants,
381
+ items: itemDrafts.map(({ requestRef: _requestRef, participantLinkable: _participantLinkable, ...item }) => item),
382
+ itemParticipants,
383
+ });
384
+ if (!created) {
385
+ return null;
386
+ }
387
+ const snapshot = await persistResolvedSnapshot(db, {
388
+ query: resolvedQuery,
389
+ resolved,
390
+ selectedCandidateIndex: selectedCandidateIndex >= 0 ? selectedCandidateIndex : 0,
391
+ offerId: created.offer.id,
392
+ status: "offer_constructed",
393
+ expiresAt: input.offer.validUntil ?? null,
394
+ });
395
+ await transactionsService.updateOffer(db, created.offer.id, {
396
+ metadata: {
397
+ ...created.offer.metadata,
398
+ sellability: {
399
+ ...created.offer.metadata?.sellability,
400
+ snapshotId: snapshot.id,
401
+ },
402
+ },
403
+ });
404
+ return {
405
+ ...created,
406
+ resolution: candidate,
407
+ snapshot,
408
+ };
409
+ },
410
+ async persistSnapshot(db, input) {
411
+ const resolvedQuery = {
412
+ ...input.query,
413
+ limit: input.query.limit ?? 25,
414
+ };
415
+ const resolved = await sellabilityService.resolve(db, resolvedQuery);
416
+ const snapshot = await persistResolvedSnapshot(db, {
417
+ query: resolvedQuery,
418
+ resolved,
419
+ status: "resolved",
420
+ expiresAt: input.expiresAt ?? null,
421
+ });
422
+ return { snapshot, resolved };
423
+ },
424
+ async listSnapshots(db, query) {
425
+ const conditions = [];
426
+ if (query.offerId)
427
+ conditions.push(eq(sellabilitySnapshots.offerId, query.offerId));
428
+ if (query.marketId)
429
+ conditions.push(eq(sellabilitySnapshots.marketId, query.marketId));
430
+ if (query.channelId)
431
+ conditions.push(eq(sellabilitySnapshots.channelId, query.channelId));
432
+ if (query.productId)
433
+ conditions.push(eq(sellabilitySnapshots.productId, query.productId));
434
+ if (query.optionId)
435
+ conditions.push(eq(sellabilitySnapshots.optionId, query.optionId));
436
+ if (query.slotId)
437
+ conditions.push(eq(sellabilitySnapshots.slotId, query.slotId));
438
+ if (query.status)
439
+ conditions.push(eq(sellabilitySnapshots.status, query.status));
440
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
441
+ return paginate(db
442
+ .select()
443
+ .from(sellabilitySnapshots)
444
+ .where(where)
445
+ .limit(query.limit)
446
+ .offset(query.offset)
447
+ .orderBy(desc(sellabilitySnapshots.updatedAt)), db.select({ count: sql `count(*)::int` }).from(sellabilitySnapshots).where(where), query.limit, query.offset);
448
+ },
449
+ async getSnapshotById(db, id) {
450
+ const [row] = await db
451
+ .select()
452
+ .from(sellabilitySnapshots)
453
+ .where(eq(sellabilitySnapshots.id, id))
454
+ .limit(1);
455
+ return row ?? null;
456
+ },
457
+ async listSnapshotItems(db, query) {
458
+ const conditions = [];
459
+ if (query.snapshotId)
460
+ conditions.push(eq(sellabilitySnapshotItems.snapshotId, query.snapshotId));
461
+ if (query.productId)
462
+ conditions.push(eq(sellabilitySnapshotItems.productId, query.productId));
463
+ if (query.optionId)
464
+ conditions.push(eq(sellabilitySnapshotItems.optionId, query.optionId));
465
+ if (query.slotId)
466
+ conditions.push(eq(sellabilitySnapshotItems.slotId, query.slotId));
467
+ if (query.unitId)
468
+ conditions.push(eq(sellabilitySnapshotItems.unitId, query.unitId));
469
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
470
+ return paginate(db
471
+ .select()
472
+ .from(sellabilitySnapshotItems)
473
+ .where(where)
474
+ .limit(query.limit)
475
+ .offset(query.offset)
476
+ .orderBy(asc(sellabilitySnapshotItems.candidateIndex), asc(sellabilitySnapshotItems.componentIndex)), db.select({ count: sql `count(*)::int` }).from(sellabilitySnapshotItems).where(where), query.limit, query.offset);
477
+ },
478
+ async listPolicies(db, query) {
479
+ const conditions = [];
480
+ if (query.scope)
481
+ conditions.push(eq(sellabilityPolicies.scope, query.scope));
482
+ if (query.policyType)
483
+ conditions.push(eq(sellabilityPolicies.policyType, query.policyType));
484
+ if (query.productId)
485
+ conditions.push(eq(sellabilityPolicies.productId, query.productId));
486
+ if (query.optionId)
487
+ conditions.push(eq(sellabilityPolicies.optionId, query.optionId));
488
+ if (query.marketId)
489
+ conditions.push(eq(sellabilityPolicies.marketId, query.marketId));
490
+ if (query.channelId)
491
+ conditions.push(eq(sellabilityPolicies.channelId, query.channelId));
492
+ if (query.active !== undefined)
493
+ conditions.push(eq(sellabilityPolicies.active, query.active));
494
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
495
+ return paginate(db
496
+ .select()
497
+ .from(sellabilityPolicies)
498
+ .where(where)
499
+ .limit(query.limit)
500
+ .offset(query.offset)
501
+ .orderBy(desc(sellabilityPolicies.priority), asc(sellabilityPolicies.name)), db.select({ count: sql `count(*)::int` }).from(sellabilityPolicies).where(where), query.limit, query.offset);
502
+ },
503
+ async getPolicyById(db, id) {
504
+ const [row] = await db
505
+ .select()
506
+ .from(sellabilityPolicies)
507
+ .where(eq(sellabilityPolicies.id, id))
508
+ .limit(1);
509
+ return row ?? null;
510
+ },
511
+ async createPolicy(db, data) {
512
+ const [row] = await db.insert(sellabilityPolicies).values(data).returning();
513
+ return row ?? null;
514
+ },
515
+ async updatePolicy(db, id, data) {
516
+ const [row] = await db
517
+ .update(sellabilityPolicies)
518
+ .set({ ...data, updatedAt: new Date() })
519
+ .where(eq(sellabilityPolicies.id, id))
520
+ .returning();
521
+ return row ?? null;
522
+ },
523
+ async deletePolicy(db, id) {
524
+ const [row] = await db
525
+ .delete(sellabilityPolicies)
526
+ .where(eq(sellabilityPolicies.id, id))
527
+ .returning({ id: sellabilityPolicies.id });
528
+ return row ?? null;
529
+ },
530
+ async listPolicyResults(db, query) {
531
+ const conditions = [];
532
+ if (query.snapshotId)
533
+ conditions.push(eq(sellabilityPolicyResults.snapshotId, query.snapshotId));
534
+ if (query.snapshotItemId)
535
+ conditions.push(eq(sellabilityPolicyResults.snapshotItemId, query.snapshotItemId));
536
+ if (query.policyId)
537
+ conditions.push(eq(sellabilityPolicyResults.policyId, query.policyId));
538
+ if (query.status)
539
+ conditions.push(eq(sellabilityPolicyResults.status, query.status));
540
+ const where = conditions.length ? and(...conditions) : undefined;
541
+ return paginate(db
542
+ .select()
543
+ .from(sellabilityPolicyResults)
544
+ .where(where)
545
+ .limit(query.limit)
546
+ .offset(query.offset)
547
+ .orderBy(desc(sellabilityPolicyResults.createdAt)), db.select({ count: sql `count(*)::int` }).from(sellabilityPolicyResults).where(where), query.limit, query.offset);
548
+ },
549
+ async getPolicyResultById(db, id) {
550
+ const [row] = await db
551
+ .select()
552
+ .from(sellabilityPolicyResults)
553
+ .where(eq(sellabilityPolicyResults.id, id))
554
+ .limit(1);
555
+ return row ?? null;
556
+ },
557
+ async createPolicyResult(db, data) {
558
+ const [row] = await db.insert(sellabilityPolicyResults).values(data).returning();
559
+ return row ?? null;
560
+ },
561
+ async updatePolicyResult(db, id, data) {
562
+ const [row] = await db
563
+ .update(sellabilityPolicyResults)
564
+ .set(data)
565
+ .where(eq(sellabilityPolicyResults.id, id))
566
+ .returning();
567
+ return row ?? null;
568
+ },
569
+ async deletePolicyResult(db, id) {
570
+ const [row] = await db
571
+ .delete(sellabilityPolicyResults)
572
+ .where(eq(sellabilityPolicyResults.id, id))
573
+ .returning({ id: sellabilityPolicyResults.id });
574
+ return row ?? null;
575
+ },
576
+ async listOfferRefreshRuns(db, query) {
577
+ const conditions = [];
578
+ if (query.offerId)
579
+ conditions.push(eq(offerRefreshRuns.offerId, query.offerId));
580
+ if (query.snapshotId)
581
+ conditions.push(eq(offerRefreshRuns.snapshotId, query.snapshotId));
582
+ if (query.status)
583
+ conditions.push(eq(offerRefreshRuns.status, query.status));
584
+ const where = conditions.length ? and(...conditions) : undefined;
585
+ return paginate(db
586
+ .select()
587
+ .from(offerRefreshRuns)
588
+ .where(where)
589
+ .limit(query.limit)
590
+ .offset(query.offset)
591
+ .orderBy(desc(offerRefreshRuns.startedAt)), db.select({ count: sql `count(*)::int` }).from(offerRefreshRuns).where(where), query.limit, query.offset);
592
+ },
593
+ async getOfferRefreshRunById(db, id) {
594
+ const [row] = await db
595
+ .select()
596
+ .from(offerRefreshRuns)
597
+ .where(eq(offerRefreshRuns.id, id))
598
+ .limit(1);
599
+ return row ?? null;
600
+ },
601
+ async createOfferRefreshRun(db, data) {
602
+ const [row] = await db
603
+ .insert(offerRefreshRuns)
604
+ .values({
605
+ ...data,
606
+ startedAt: normalizeDateTime(data.startedAt) ?? new Date(),
607
+ completedAt: normalizeDateTime(data.completedAt),
608
+ })
609
+ .returning();
610
+ return row ?? null;
611
+ },
612
+ async updateOfferRefreshRun(db, id, data) {
613
+ const [row] = await db
614
+ .update(offerRefreshRuns)
615
+ .set({
616
+ ...data,
617
+ startedAt: data.startedAt ? new Date(data.startedAt) : undefined,
618
+ completedAt: data.completedAt ? new Date(data.completedAt) : undefined,
619
+ updatedAt: new Date(),
620
+ })
621
+ .where(eq(offerRefreshRuns.id, id))
622
+ .returning();
623
+ return row ?? null;
624
+ },
625
+ async deleteOfferRefreshRun(db, id) {
626
+ const [row] = await db
627
+ .delete(offerRefreshRuns)
628
+ .where(eq(offerRefreshRuns.id, id))
629
+ .returning({ id: offerRefreshRuns.id });
630
+ return row ?? null;
631
+ },
632
+ async listOfferExpirationEvents(db, query) {
633
+ const conditions = [];
634
+ if (query.offerId)
635
+ conditions.push(eq(offerExpirationEvents.offerId, query.offerId));
636
+ if (query.snapshotId)
637
+ conditions.push(eq(offerExpirationEvents.snapshotId, query.snapshotId));
638
+ if (query.status)
639
+ conditions.push(eq(offerExpirationEvents.status, query.status));
640
+ const where = conditions.length ? and(...conditions) : undefined;
641
+ return paginate(db
642
+ .select()
643
+ .from(offerExpirationEvents)
644
+ .where(where)
645
+ .limit(query.limit)
646
+ .offset(query.offset)
647
+ .orderBy(desc(offerExpirationEvents.expiresAt)), db.select({ count: sql `count(*)::int` }).from(offerExpirationEvents).where(where), query.limit, query.offset);
648
+ },
649
+ async getOfferExpirationEventById(db, id) {
650
+ const [row] = await db
651
+ .select()
652
+ .from(offerExpirationEvents)
653
+ .where(eq(offerExpirationEvents.id, id))
654
+ .limit(1);
655
+ return row ?? null;
656
+ },
657
+ async createOfferExpirationEvent(db, data) {
658
+ const [row] = await db
659
+ .insert(offerExpirationEvents)
660
+ .values({
661
+ ...data,
662
+ expiresAt: new Date(data.expiresAt),
663
+ expiredAt: normalizeDateTime(data.expiredAt),
664
+ })
665
+ .returning();
666
+ return row ?? null;
667
+ },
668
+ async updateOfferExpirationEvent(db, id, data) {
669
+ const [row] = await db
670
+ .update(offerExpirationEvents)
671
+ .set({
672
+ ...data,
673
+ expiresAt: data.expiresAt ? new Date(data.expiresAt) : undefined,
674
+ expiredAt: normalizeDateTime(data.expiredAt),
675
+ updatedAt: new Date(),
676
+ })
677
+ .where(eq(offerExpirationEvents.id, id))
678
+ .returning();
679
+ return row ?? null;
680
+ },
681
+ async deleteOfferExpirationEvent(db, id) {
682
+ const [row] = await db
683
+ .delete(offerExpirationEvents)
684
+ .where(eq(offerExpirationEvents.id, id))
685
+ .returning({ id: offerExpirationEvents.id });
686
+ return row ?? null;
687
+ },
688
+ async listExplanations(db, query) {
689
+ const conditions = [];
690
+ if (query.snapshotId)
691
+ conditions.push(eq(sellabilityExplanations.snapshotId, query.snapshotId));
692
+ if (query.snapshotItemId)
693
+ conditions.push(eq(sellabilityExplanations.snapshotItemId, query.snapshotItemId));
694
+ if (query.explanationType)
695
+ conditions.push(eq(sellabilityExplanations.explanationType, query.explanationType));
696
+ const where = conditions.length ? and(...conditions) : undefined;
697
+ return paginate(db
698
+ .select()
699
+ .from(sellabilityExplanations)
700
+ .where(where)
701
+ .limit(query.limit)
702
+ .offset(query.offset)
703
+ .orderBy(desc(sellabilityExplanations.createdAt)), db.select({ count: sql `count(*)::int` }).from(sellabilityExplanations).where(where), query.limit, query.offset);
704
+ },
705
+ async getExplanationById(db, id) {
706
+ const [row] = await db
707
+ .select()
708
+ .from(sellabilityExplanations)
709
+ .where(eq(sellabilityExplanations.id, id))
710
+ .limit(1);
711
+ return row ?? null;
712
+ },
713
+ async createExplanation(db, data) {
714
+ const [row] = await db.insert(sellabilityExplanations).values(data).returning();
715
+ return row ?? null;
716
+ },
717
+ async updateExplanation(db, id, data) {
718
+ const [row] = await db
719
+ .update(sellabilityExplanations)
720
+ .set(data)
721
+ .where(eq(sellabilityExplanations.id, id))
722
+ .returning();
723
+ return row ?? null;
724
+ },
725
+ async deleteExplanation(db, id) {
726
+ const [row] = await db
727
+ .delete(sellabilityExplanations)
728
+ .where(eq(sellabilityExplanations.id, id))
729
+ .returning({ id: sellabilityExplanations.id });
730
+ return row ?? null;
731
+ },
732
+ async resolve(db, query) {
733
+ const optionConditions = [eq(products.status, "active"), eq(productOptions.status, "active")];
734
+ if (query.productId)
735
+ optionConditions.push(eq(products.id, query.productId));
736
+ if (query.optionId)
737
+ optionConditions.push(eq(productOptions.id, query.optionId));
738
+ const optionRows = await db
739
+ .select({
740
+ productId: products.id,
741
+ productName: products.name,
742
+ productSellCurrency: products.sellCurrency,
743
+ optionId: productOptions.id,
744
+ optionName: productOptions.name,
745
+ optionCode: productOptions.code,
746
+ optionIsDefault: productOptions.isDefault,
747
+ optionAvailableFrom: productOptions.availableFrom,
748
+ optionAvailableTo: productOptions.availableTo,
749
+ })
750
+ .from(productOptions)
751
+ .innerJoin(products, eq(productOptions.productId, products.id))
752
+ .where(and(...optionConditions))
753
+ .orderBy(asc(products.name), asc(productOptions.sortOrder));
754
+ if (optionRows.length === 0) {
755
+ return { data: [], meta: { total: 0 } };
756
+ }
757
+ const optionIds = optionRows.map((row) => row.optionId);
758
+ const productIds = [...new Set(optionRows.map((row) => row.productId))];
759
+ const slotConditions = [
760
+ inArray(availabilitySlots.optionId, optionIds),
761
+ eq(availabilitySlots.status, "open"),
762
+ ];
763
+ if (query.slotId)
764
+ slotConditions.push(eq(availabilitySlots.id, query.slotId));
765
+ if (query.dateLocal)
766
+ slotConditions.push(eq(availabilitySlots.dateLocal, query.dateLocal));
767
+ if (query.startTimeId)
768
+ slotConditions.push(eq(availabilitySlots.startTimeId, query.startTimeId));
769
+ const slots = await db
770
+ .select({
771
+ id: availabilitySlots.id,
772
+ productId: availabilitySlots.productId,
773
+ optionId: availabilitySlots.optionId,
774
+ startTimeId: availabilitySlots.startTimeId,
775
+ dateLocal: availabilitySlots.dateLocal,
776
+ startsAt: availabilitySlots.startsAt,
777
+ timezone: availabilitySlots.timezone,
778
+ unlimited: availabilitySlots.unlimited,
779
+ remainingPax: availabilitySlots.remainingPax,
780
+ remainingPickups: availabilitySlots.remainingPickups,
781
+ pastCutoff: availabilitySlots.pastCutoff,
782
+ tooEarly: availabilitySlots.tooEarly,
783
+ })
784
+ .from(availabilitySlots)
785
+ .where(and(...slotConditions))
786
+ .orderBy(asc(availabilitySlots.startsAt))
787
+ .limit(query.limit);
788
+ const startTimeIds = [
789
+ ...new Set(slots.flatMap((slot) => (slot.startTimeId ? [slot.startTimeId] : []))),
790
+ ];
791
+ const slotIds = slots.map((slot) => slot.id);
792
+ const [marketRow, channelRow, marketProductRuleRows, marketChannelRuleRows, marketCatalogRows, catalogRows, optionPriceRuleRows, optionScheduleRows, unitPriceRuleRows, unitTierRows, unitRows, pricingCategoryRows, startTimeRuleRows, pickupRuleRows, allotmentRows, allotmentTargetRows, releaseRuleRows, exchangeRateRow,] = await Promise.all([
793
+ query.marketId
794
+ ? db
795
+ .select()
796
+ .from(markets)
797
+ .where(eq(markets.id, query.marketId))
798
+ .limit(1)
799
+ .then((rows) => rows[0] ?? null)
800
+ : Promise.resolve(null),
801
+ query.channelId
802
+ ? db
803
+ .select({ id: channels.id, kind: channels.kind })
804
+ .from(channels)
805
+ .where(eq(channels.id, query.channelId))
806
+ .limit(1)
807
+ .then((rows) => rows[0] ?? null)
808
+ : Promise.resolve(null),
809
+ query.marketId
810
+ ? db
811
+ .select()
812
+ .from(marketProductRules)
813
+ .where(and(eq(marketProductRules.marketId, query.marketId), eq(marketProductRules.active, true), inArray(marketProductRules.productId, productIds)))
814
+ : Promise.resolve([]),
815
+ query.marketId && query.channelId
816
+ ? db
817
+ .select()
818
+ .from(marketChannelRules)
819
+ .where(and(eq(marketChannelRules.marketId, query.marketId), eq(marketChannelRules.channelId, query.channelId), eq(marketChannelRules.active, true)))
820
+ : Promise.resolve([]),
821
+ query.marketId
822
+ ? db
823
+ .select({
824
+ id: marketPriceCatalogs.id,
825
+ marketId: marketPriceCatalogs.marketId,
826
+ priceCatalogId: marketPriceCatalogs.priceCatalogId,
827
+ isDefault: marketPriceCatalogs.isDefault,
828
+ priority: marketPriceCatalogs.priority,
829
+ active: marketPriceCatalogs.active,
830
+ })
831
+ .from(marketPriceCatalogs)
832
+ .where(and(eq(marketPriceCatalogs.marketId, query.marketId), eq(marketPriceCatalogs.active, true)))
833
+ : Promise.resolve([]),
834
+ db
835
+ .select({ id: priceCatalogs.id, currencyCode: priceCatalogs.currencyCode })
836
+ .from(priceCatalogs),
837
+ db
838
+ .select()
839
+ .from(optionPriceRules)
840
+ .where(and(inArray(optionPriceRules.optionId, optionIds), eq(optionPriceRules.active, true))),
841
+ db.select().from(priceSchedules),
842
+ db
843
+ .select()
844
+ .from(optionUnitPriceRules)
845
+ .where(and(inArray(optionUnitPriceRules.optionId, optionIds), eq(optionUnitPriceRules.active, true))),
846
+ db.select().from(optionUnitTiers).where(eq(optionUnitTiers.active, true)),
847
+ db
848
+ .select({ id: optionUnits.id, name: optionUnits.name, unitType: optionUnits.unitType })
849
+ .from(optionUnits),
850
+ db
851
+ .select({ id: pricingCategories.id, name: pricingCategories.name })
852
+ .from(pricingCategories)
853
+ .where(eq(pricingCategories.active, true)),
854
+ startTimeIds.length
855
+ ? db
856
+ .select()
857
+ .from(optionStartTimeRules)
858
+ .where(and(inArray(optionStartTimeRules.optionId, optionIds), inArray(optionStartTimeRules.startTimeId, startTimeIds), eq(optionStartTimeRules.active, true)))
859
+ : Promise.resolve([]),
860
+ query.pickupPointId
861
+ ? db
862
+ .select()
863
+ .from(pickupPriceRules)
864
+ .where(and(inArray(pickupPriceRules.optionId, optionIds), eq(pickupPriceRules.pickupPointId, query.pickupPointId), eq(pickupPriceRules.active, true)))
865
+ : Promise.resolve([]),
866
+ query.channelId
867
+ ? db
868
+ .select()
869
+ .from(channelInventoryAllotments)
870
+ .where(and(eq(channelInventoryAllotments.channelId, query.channelId), eq(channelInventoryAllotments.active, true), inArray(channelInventoryAllotments.productId, productIds)))
871
+ : Promise.resolve([]),
872
+ query.channelId && slotIds.length
873
+ ? db
874
+ .select()
875
+ .from(channelInventoryAllotmentTargets)
876
+ .where(and(inArray(channelInventoryAllotmentTargets.slotId, slotIds), eq(channelInventoryAllotmentTargets.active, true)))
877
+ : Promise.resolve([]),
878
+ query.channelId ? db.select().from(channelInventoryReleaseRules) : Promise.resolve([]),
879
+ query.currencyCode
880
+ ? db
881
+ .select({
882
+ rateDecimal: exchangeRates.rateDecimal,
883
+ baseCurrency: exchangeRates.baseCurrency,
884
+ quoteCurrency: exchangeRates.quoteCurrency,
885
+ fxRateSetId: exchangeRates.fxRateSetId,
886
+ effectiveAt: fxRateSets.effectiveAt,
887
+ })
888
+ .from(exchangeRates)
889
+ .innerJoin(fxRateSets, eq(exchangeRates.fxRateSetId, fxRateSets.id))
890
+ .where(eq(exchangeRates.quoteCurrency, query.currencyCode))
891
+ .orderBy(desc(fxRateSets.effectiveAt))
892
+ : Promise.resolve([]),
893
+ ]);
894
+ const optionMap = new Map(optionRows.map((row) => [row.optionId, row]));
895
+ const scheduleMap = new Map(optionScheduleRows.map((row) => [row.id, row]));
896
+ const marketCatalogMap = new Map(marketCatalogRows.map((row) => [row.id, row]));
897
+ const catalogMap = new Map(catalogRows.map((row) => [row.id, row]));
898
+ const unitMap = new Map(unitRows.map((row) => [row.id, row]));
899
+ const pricingCategoryMap = new Map(pricingCategoryRows.map((row) => [row.id, row]));
900
+ const candidates = [];
901
+ for (const slot of slots) {
902
+ if (!slot.optionId)
903
+ continue;
904
+ const option = optionMap.get(slot.optionId);
905
+ if (!option)
906
+ continue;
907
+ if (option.optionAvailableFrom && slot.dateLocal < option.optionAvailableFrom)
908
+ continue;
909
+ if (option.optionAvailableTo && slot.dateLocal > option.optionAvailableTo)
910
+ continue;
911
+ if (slot.pastCutoff || slot.tooEarly)
912
+ continue;
913
+ if (!slot.unlimited && (slot.remainingPax ?? 0) <= 0)
914
+ continue;
915
+ const marketRule = query.marketId
916
+ ? chooseBestSpecificRule(marketProductRuleRows.filter((row) => {
917
+ if (row.productId !== option.productId)
918
+ return false;
919
+ if (row.optionId && row.optionId !== option.optionId)
920
+ return false;
921
+ if (query.dateLocal && row.availableFrom && query.dateLocal < row.availableFrom)
922
+ return false;
923
+ if (query.dateLocal && row.availableTo && query.dateLocal > row.availableTo)
924
+ return false;
925
+ return true;
926
+ }))
927
+ : null;
928
+ if (marketRule?.sellability === "unavailable")
929
+ continue;
930
+ const channelRule = query.marketId && query.channelId
931
+ ? [...marketChannelRuleRows]
932
+ .sort((a, b) => b.priority - a.priority)
933
+ .find((row) => row.sellability !== "unavailable")
934
+ : null;
935
+ if (query.marketId && query.channelId && !channelRule && marketChannelRuleRows.length > 0) {
936
+ continue;
937
+ }
938
+ const catalogSelection = (channelRule?.priceCatalogId ? marketCatalogMap.get(channelRule.priceCatalogId) : null) ??
939
+ (marketRule?.priceCatalogId ? marketCatalogMap.get(marketRule.priceCatalogId) : null) ??
940
+ [...marketCatalogRows].sort((a, b) => {
941
+ const scoreA = Number(a.isDefault) * 10 - a.priority;
942
+ const scoreB = Number(b.isDefault) * 10 - b.priority;
943
+ return scoreB - scoreA;
944
+ })[0] ??
945
+ null;
946
+ const applicableRules = optionPriceRuleRows.filter((row) => {
947
+ if (row.optionId !== option.optionId || row.productId !== option.productId)
948
+ return false;
949
+ if (catalogSelection && row.priceCatalogId !== catalogSelection.priceCatalogId)
950
+ return false;
951
+ const schedule = row.priceScheduleId ? scheduleMap.get(row.priceScheduleId) : null;
952
+ return scheduleMatches({
953
+ validFrom: schedule?.validFrom ?? null,
954
+ validTo: schedule?.validTo ?? null,
955
+ weekdays: schedule?.weekdays ?? null,
956
+ }, slot.dateLocal);
957
+ });
958
+ const chosenRule = chooseBestScheduledRule(applicableRules);
959
+ if (!chosenRule)
960
+ continue;
961
+ const applicableStartRule = slot.startTimeId
962
+ ? (startTimeRuleRows.find((row) => row.optionPriceRuleId === chosenRule.id && row.startTimeId === slot.startTimeId) ?? null)
963
+ : null;
964
+ if (applicableStartRule?.ruleMode === "excluded")
965
+ continue;
966
+ const ruleUnitRows = unitPriceRuleRows.filter((row) => row.optionPriceRuleId === chosenRule.id);
967
+ const ruleTierRows = unitTierRows.filter((row) => ruleUnitRows.some((unitRule) => unitRule.id === row.optionUnitPriceRuleId));
968
+ const requestedUnits = query.requestedUnits.length > 0 ? query.requestedUnits : [];
969
+ const breakdown = [];
970
+ const components = [];
971
+ let sellAmountCents = chosenRule.baseSellAmountCents ?? 0;
972
+ let costAmountCents = chosenRule.baseCostAmountCents ?? 0;
973
+ let onRequest = chosenRule.pricingMode === "on_request";
974
+ if ((chosenRule.baseSellAmountCents ?? 0) !== 0 ||
975
+ (chosenRule.baseCostAmountCents ?? 0) !== 0) {
976
+ components.push({
977
+ kind: "base",
978
+ title: option.optionName,
979
+ quantity: 1,
980
+ pricingMode: chosenRule.pricingMode,
981
+ sellAmountCents: chosenRule.baseSellAmountCents ?? 0,
982
+ costAmountCents: chosenRule.baseCostAmountCents ?? 0,
983
+ unitId: null,
984
+ unitName: null,
985
+ unitType: null,
986
+ pricingCategoryId: null,
987
+ pricingCategoryName: null,
988
+ requestRef: null,
989
+ sourceRuleId: chosenRule.id,
990
+ tierId: null,
991
+ });
992
+ }
993
+ for (const request of requestedUnits) {
994
+ const candidateUnitRules = ruleUnitRows.filter((row) => {
995
+ if (request.unitId && row.unitId !== request.unitId)
996
+ return false;
997
+ if (request.pricingCategoryId &&
998
+ row.pricingCategoryId &&
999
+ row.pricingCategoryId !== request.pricingCategoryId)
1000
+ return false;
1001
+ if (row.minQuantity && request.quantity < row.minQuantity)
1002
+ return false;
1003
+ if (row.maxQuantity && request.quantity > row.maxQuantity)
1004
+ return false;
1005
+ return true;
1006
+ });
1007
+ const unitRule = [...candidateUnitRules].sort((a, b) => {
1008
+ const scoreA = Number(Boolean(request.unitId && a.unitId === request.unitId)) * 10 +
1009
+ Number(Boolean(request.pricingCategoryId && a.pricingCategoryId === request.pricingCategoryId));
1010
+ const scoreB = Number(Boolean(request.unitId && b.unitId === request.unitId)) * 10 +
1011
+ Number(Boolean(request.pricingCategoryId && b.pricingCategoryId === request.pricingCategoryId));
1012
+ return scoreB - scoreA;
1013
+ })[0] ?? null;
1014
+ const tier = unitRule
1015
+ ? ([...ruleTierRows]
1016
+ .filter((row) => row.optionUnitPriceRuleId === unitRule.id &&
1017
+ row.active &&
1018
+ request.quantity >= row.minQuantity &&
1019
+ (row.maxQuantity === null || request.quantity <= row.maxQuantity))
1020
+ .sort((a, b) => a.sortOrder - b.sortOrder)[0] ?? null)
1021
+ : null;
1022
+ if (unitRule?.pricingMode === "on_request")
1023
+ onRequest = true;
1024
+ const item = computeUnitAmounts(request, {
1025
+ unitName: request.unitId ? (unitMap.get(request.unitId)?.name ?? null) : null,
1026
+ unitType: request.unitId ? (unitMap.get(request.unitId)?.unitType ?? null) : null,
1027
+ pricingCategoryName: request.pricingCategoryId
1028
+ ? (pricingCategoryMap.get(request.pricingCategoryId)?.name ?? null)
1029
+ : null,
1030
+ }, unitRule, tier);
1031
+ breakdown.push(item);
1032
+ components.push({
1033
+ kind: "unit",
1034
+ title: [option.optionName, item.unitName, item.pricingCategoryName]
1035
+ .filter(Boolean)
1036
+ .join(" · ") || option.optionName,
1037
+ quantity: item.quantity,
1038
+ pricingMode: item.pricingMode,
1039
+ sellAmountCents: item.sellAmountCents,
1040
+ costAmountCents: item.costAmountCents,
1041
+ unitId: item.unitId,
1042
+ unitName: item.unitName,
1043
+ unitType: item.unitType,
1044
+ pricingCategoryId: item.pricingCategoryId,
1045
+ pricingCategoryName: item.pricingCategoryName,
1046
+ requestRef: item.requestRef,
1047
+ sourceRuleId: item.sourceRuleId,
1048
+ tierId: item.tierId,
1049
+ });
1050
+ sellAmountCents += item.sellAmountCents;
1051
+ costAmountCents += item.costAmountCents;
1052
+ }
1053
+ if (query.pickupPointId) {
1054
+ const pickupRule = pickupRuleRows.find((row) => row.optionPriceRuleId === chosenRule.id && row.pickupPointId === query.pickupPointId) ?? null;
1055
+ if (pickupRule) {
1056
+ const quantity = Math.max(1, query.requestedUnits.reduce((sum, unit) => sum + unit.quantity, 0));
1057
+ const pickupSellAmountCents = pickupRule.pricingMode === "per_person"
1058
+ ? (pickupRule.sellAmountCents ?? 0) * quantity
1059
+ : (pickupRule.sellAmountCents ?? 0);
1060
+ const pickupCostAmountCents = pickupRule.pricingMode === "per_person"
1061
+ ? (pickupRule.costAmountCents ?? 0) * quantity
1062
+ : (pickupRule.costAmountCents ?? 0);
1063
+ if (pickupRule.pricingMode === "on_request")
1064
+ onRequest = true;
1065
+ components.push({
1066
+ kind: "pickup",
1067
+ title: "Pickup",
1068
+ quantity: pickupRule.pricingMode === "per_person" ? quantity : 1,
1069
+ pricingMode: pickupRule.pricingMode,
1070
+ sellAmountCents: pickupSellAmountCents,
1071
+ costAmountCents: pickupCostAmountCents,
1072
+ unitId: null,
1073
+ unitName: null,
1074
+ unitType: null,
1075
+ pricingCategoryId: null,
1076
+ pricingCategoryName: null,
1077
+ requestRef: null,
1078
+ sourceRuleId: pickupRule.id,
1079
+ tierId: null,
1080
+ });
1081
+ if (pickupRule.pricingMode === "per_booking") {
1082
+ sellAmountCents += pickupRule.sellAmountCents ?? 0;
1083
+ costAmountCents += pickupRule.costAmountCents ?? 0;
1084
+ }
1085
+ else if (pickupRule.pricingMode === "per_person") {
1086
+ sellAmountCents += (pickupRule.sellAmountCents ?? 0) * quantity;
1087
+ costAmountCents += (pickupRule.costAmountCents ?? 0) * quantity;
1088
+ }
1089
+ }
1090
+ }
1091
+ const preAdjustmentTotal = { sellAmountCents, costAmountCents };
1092
+ const adjusted = applicableStartRule
1093
+ ? applyAdjustment(preAdjustmentTotal, {
1094
+ adjustmentType: applicableStartRule.adjustmentType,
1095
+ sellAdjustmentCents: applicableStartRule.sellAdjustmentCents,
1096
+ costAdjustmentCents: applicableStartRule.costAdjustmentCents,
1097
+ adjustmentBasisPoints: applicableStartRule.adjustmentBasisPoints,
1098
+ })
1099
+ : { sellAmountCents, costAmountCents };
1100
+ sellAmountCents = adjusted.sellAmountCents;
1101
+ costAmountCents = adjusted.costAmountCents;
1102
+ const startTimeAdjustmentSellAmountCents = adjusted.sellAmountCents - preAdjustmentTotal.sellAmountCents;
1103
+ const startTimeAdjustmentCostAmountCents = adjusted.costAmountCents - preAdjustmentTotal.costAmountCents;
1104
+ if (applicableStartRule &&
1105
+ (startTimeAdjustmentSellAmountCents !== 0 || startTimeAdjustmentCostAmountCents !== 0)) {
1106
+ components.push({
1107
+ kind: "start_time_adjustment",
1108
+ title: "Start time adjustment",
1109
+ quantity: 1,
1110
+ pricingMode: applicableStartRule.adjustmentType ?? "fixed",
1111
+ sellAmountCents: startTimeAdjustmentSellAmountCents,
1112
+ costAmountCents: startTimeAdjustmentCostAmountCents,
1113
+ unitId: null,
1114
+ unitName: null,
1115
+ unitType: null,
1116
+ pricingCategoryId: null,
1117
+ pricingCategoryName: null,
1118
+ requestRef: null,
1119
+ sourceRuleId: applicableStartRule.id,
1120
+ tierId: null,
1121
+ });
1122
+ }
1123
+ const relevantAllotments = allotmentRows.filter((row) => {
1124
+ if (row.productId !== option.productId)
1125
+ return false;
1126
+ if (row.optionId && row.optionId !== option.optionId)
1127
+ return false;
1128
+ if (row.startTimeId && row.startTimeId !== slot.startTimeId)
1129
+ return false;
1130
+ if (slot.dateLocal && row.validFrom && slot.dateLocal < row.validFrom)
1131
+ return false;
1132
+ if (slot.dateLocal && row.validTo && slot.dateLocal > row.validTo)
1133
+ return false;
1134
+ return true;
1135
+ });
1136
+ const relevantTargets = allotmentTargetRows.filter((row) => relevantAllotments.some((allotment) => allotment.id === row.allotmentId) &&
1137
+ (row.slotId === slot.id || (!row.slotId && row.startTimeId === slot.startTimeId)));
1138
+ let allotmentStatus = "not_applicable";
1139
+ let releaseRuleId = null;
1140
+ if (relevantAllotments.length > 0) {
1141
+ const remaining = relevantTargets.reduce((sum, row) => sum + Math.max(0, row.remainingCapacity ?? 0), 0);
1142
+ allotmentStatus = remaining > 0 ? "sellable" : "sold_out";
1143
+ const firstReleaseRule = releaseRuleRows.find((row) => relevantAllotments.some((allotment) => allotment.id === row.allotmentId));
1144
+ releaseRuleId = firstReleaseRule?.id ?? null;
1145
+ if (allotmentStatus === "sold_out")
1146
+ continue;
1147
+ }
1148
+ let displayCurrency = (catalogSelection ? catalogMap.get(catalogSelection.priceCatalogId)?.currencyCode : null) ??
1149
+ option.productSellCurrency;
1150
+ let convertedSellAmountCents = sellAmountCents;
1151
+ let convertedCostAmountCents = costAmountCents;
1152
+ let fx = null;
1153
+ if (query.currencyCode && query.currencyCode !== displayCurrency) {
1154
+ const fxRate = exchangeRateRow.find((row) => row.baseCurrency === displayCurrency && row.quoteCurrency === query.currencyCode);
1155
+ if (fxRate) {
1156
+ const rate = toNumeric(fxRate.rateDecimal) ?? 1;
1157
+ convertedSellAmountCents = Math.round(sellAmountCents * rate);
1158
+ convertedCostAmountCents = Math.round(costAmountCents * rate);
1159
+ fx = {
1160
+ fxRateSetId: fxRate.fxRateSetId,
1161
+ baseCurrency: displayCurrency,
1162
+ quoteCurrency: query.currencyCode,
1163
+ rateDecimal: rate,
1164
+ };
1165
+ displayCurrency = query.currencyCode;
1166
+ }
1167
+ }
1168
+ candidates.push({
1169
+ product: {
1170
+ id: option.productId,
1171
+ name: option.productName,
1172
+ },
1173
+ option: {
1174
+ id: option.optionId,
1175
+ name: option.optionName,
1176
+ code: option.optionCode,
1177
+ },
1178
+ slot,
1179
+ market: marketRow
1180
+ ? {
1181
+ id: marketRow.id,
1182
+ code: marketRow.code,
1183
+ name: marketRow.name,
1184
+ }
1185
+ : null,
1186
+ channel: channelRow,
1187
+ sellability: {
1188
+ mode: marketRule?.sellability ?? channelRule?.sellability ?? "sellable",
1189
+ onRequest,
1190
+ allotmentStatus,
1191
+ },
1192
+ pricing: {
1193
+ currencyCode: displayCurrency,
1194
+ sellAmountCents: convertedSellAmountCents,
1195
+ costAmountCents: convertedCostAmountCents,
1196
+ marginAmountCents: convertedSellAmountCents - convertedCostAmountCents,
1197
+ breakdown,
1198
+ components,
1199
+ fx,
1200
+ },
1201
+ sources: {
1202
+ marketProductRuleId: marketRule?.id ?? null,
1203
+ marketChannelRuleId: channelRule?.id ?? null,
1204
+ marketPriceCatalogId: catalogSelection?.id ?? null,
1205
+ optionPriceRuleId: chosenRule.id,
1206
+ optionStartTimeRuleId: applicableStartRule?.id ?? null,
1207
+ channelInventoryAllotmentIds: relevantAllotments.map((row) => row.id),
1208
+ channelInventoryReleaseRuleId: releaseRuleId,
1209
+ },
1210
+ });
1211
+ }
1212
+ return {
1213
+ data: candidates,
1214
+ meta: {
1215
+ total: candidates.length,
1216
+ },
1217
+ };
1218
+ },
1219
+ };