@voyant-travel/commerce 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.
Files changed (210) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +145 -0
  3. package/dist/accepted-quote-version-reservation-golden-flow.test.d.ts +2 -0
  4. package/dist/accepted-quote-version-reservation-golden-flow.test.d.ts.map +1 -0
  5. package/dist/accepted-quote-version-reservation-golden-flow.test.js +398 -0
  6. package/dist/index.d.ts +15 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +14 -0
  9. package/dist/interface.d.ts +18 -0
  10. package/dist/interface.d.ts.map +1 -0
  11. package/dist/interface.js +246 -0
  12. package/dist/interface.test.d.ts +2 -0
  13. package/dist/interface.test.d.ts.map +1 -0
  14. package/dist/interface.test.js +357 -0
  15. package/dist/markets/index.d.ts +11 -0
  16. package/dist/markets/index.d.ts.map +1 -0
  17. package/dist/markets/index.js +12 -0
  18. package/dist/markets/routes.d.ts +1182 -0
  19. package/dist/markets/routes.d.ts.map +1 -0
  20. package/dist/markets/routes.js +209 -0
  21. package/dist/markets/schema.d.ts +1527 -0
  22. package/dist/markets/schema.d.ts.map +1 -0
  23. package/dist/markets/schema.js +240 -0
  24. package/dist/markets/service-core.d.ts +253 -0
  25. package/dist/markets/service-core.d.ts.map +1 -0
  26. package/dist/markets/service-core.js +242 -0
  27. package/dist/markets/service-rules.d.ts +191 -0
  28. package/dist/markets/service-rules.d.ts.map +1 -0
  29. package/dist/markets/service-rules.js +155 -0
  30. package/dist/markets/service-shared.d.ts +36 -0
  31. package/dist/markets/service-shared.d.ts.map +1 -0
  32. package/dist/markets/service-shared.js +7 -0
  33. package/dist/markets/service.d.ts +43 -0
  34. package/dist/markets/service.d.ts.map +1 -0
  35. package/dist/markets/service.js +42 -0
  36. package/dist/markets/validation.d.ts +451 -0
  37. package/dist/markets/validation.d.ts.map +1 -0
  38. package/dist/markets/validation.js +160 -0
  39. package/dist/pricing/events.d.ts +53 -0
  40. package/dist/pricing/events.d.ts.map +1 -0
  41. package/dist/pricing/events.js +28 -0
  42. package/dist/pricing/index.d.ts +15 -0
  43. package/dist/pricing/index.d.ts.map +1 -0
  44. package/dist/pricing/index.js +18 -0
  45. package/dist/pricing/routes-core.d.ts +981 -0
  46. package/dist/pricing/routes-core.d.ts.map +1 -0
  47. package/dist/pricing/routes-core.js +102 -0
  48. package/dist/pricing/routes-public.d.ts +136 -0
  49. package/dist/pricing/routes-public.d.ts.map +1 -0
  50. package/dist/pricing/routes-public.js +14 -0
  51. package/dist/pricing/routes-rules.d.ts +1339 -0
  52. package/dist/pricing/routes-rules.d.ts.map +1 -0
  53. package/dist/pricing/routes-rules.js +138 -0
  54. package/dist/pricing/routes-shared.d.ts +14 -0
  55. package/dist/pricing/routes-shared.d.ts.map +1 -0
  56. package/dist/pricing/routes-shared.js +3 -0
  57. package/dist/pricing/routes.d.ts +7 -0
  58. package/dist/pricing/routes.d.ts.map +1 -0
  59. package/dist/pricing/routes.js +6 -0
  60. package/dist/pricing/schema-catalogs.d.ts +467 -0
  61. package/dist/pricing/schema-catalogs.d.ts.map +1 -0
  62. package/dist/pricing/schema-catalogs.js +47 -0
  63. package/dist/pricing/schema-categories.d.ts +497 -0
  64. package/dist/pricing/schema-categories.d.ts.map +1 -0
  65. package/dist/pricing/schema-categories.js +54 -0
  66. package/dist/pricing/schema-departure-overrides.d.ts +228 -0
  67. package/dist/pricing/schema-departure-overrides.d.ts.map +1 -0
  68. package/dist/pricing/schema-departure-overrides.js +36 -0
  69. package/dist/pricing/schema-option-rules.d.ts +1770 -0
  70. package/dist/pricing/schema-option-rules.d.ts.map +1 -0
  71. package/dist/pricing/schema-option-rules.js +181 -0
  72. package/dist/pricing/schema-policies.d.ts +395 -0
  73. package/dist/pricing/schema-policies.d.ts.map +1 -0
  74. package/dist/pricing/schema-policies.js +41 -0
  75. package/dist/pricing/schema-relations.d.ts +59 -0
  76. package/dist/pricing/schema-relations.d.ts.map +1 -0
  77. package/dist/pricing/schema-relations.js +111 -0
  78. package/dist/pricing/schema-shared.d.ts +11 -0
  79. package/dist/pricing/schema-shared.d.ts.map +1 -0
  80. package/dist/pricing/schema-shared.js +67 -0
  81. package/dist/pricing/schema.d.ts +8 -0
  82. package/dist/pricing/schema.d.ts.map +1 -0
  83. package/dist/pricing/schema.js +7 -0
  84. package/dist/pricing/service-catalog-plane-pricing.d.ts +95 -0
  85. package/dist/pricing/service-catalog-plane-pricing.d.ts.map +1 -0
  86. package/dist/pricing/service-catalog-plane-pricing.js +382 -0
  87. package/dist/pricing/service-catalogs.d.ts +139 -0
  88. package/dist/pricing/service-catalogs.d.ts.map +1 -0
  89. package/dist/pricing/service-catalogs.js +89 -0
  90. package/dist/pricing/service-categories.d.ts +147 -0
  91. package/dist/pricing/service-categories.d.ts.map +1 -0
  92. package/dist/pricing/service-categories.js +105 -0
  93. package/dist/pricing/service-departure-overrides.d.ts +67 -0
  94. package/dist/pricing/service-departure-overrides.d.ts.map +1 -0
  95. package/dist/pricing/service-departure-overrides.js +54 -0
  96. package/dist/pricing/service-option-rules.d.ts +321 -0
  97. package/dist/pricing/service-option-rules.d.ts.map +1 -0
  98. package/dist/pricing/service-option-rules.js +340 -0
  99. package/dist/pricing/service-policies.d.ts +123 -0
  100. package/dist/pricing/service-policies.d.ts.map +1 -0
  101. package/dist/pricing/service-policies.js +95 -0
  102. package/dist/pricing/service-public.d.ts +89 -0
  103. package/dist/pricing/service-public.d.ts.map +1 -0
  104. package/dist/pricing/service-public.js +473 -0
  105. package/dist/pricing/service-rule-resolver.d.ts +67 -0
  106. package/dist/pricing/service-rule-resolver.d.ts.map +1 -0
  107. package/dist/pricing/service-rule-resolver.js +204 -0
  108. package/dist/pricing/service-shared.d.ts +53 -0
  109. package/dist/pricing/service-shared.d.ts.map +1 -0
  110. package/dist/pricing/service-shared.js +4 -0
  111. package/dist/pricing/service-transfer-rules.d.ts +211 -0
  112. package/dist/pricing/service-transfer-rules.d.ts.map +1 -0
  113. package/dist/pricing/service-transfer-rules.js +139 -0
  114. package/dist/pricing/service.d.ts +79 -0
  115. package/dist/pricing/service.d.ts.map +1 -0
  116. package/dist/pricing/service.js +78 -0
  117. package/dist/pricing/validation-public.d.ts +412 -0
  118. package/dist/pricing/validation-public.d.ts.map +1 -0
  119. package/dist/pricing/validation-public.js +111 -0
  120. package/dist/pricing/validation-shared.d.ts +71 -0
  121. package/dist/pricing/validation-shared.d.ts.map +1 -0
  122. package/dist/pricing/validation-shared.js +63 -0
  123. package/dist/pricing/validation.d.ts +987 -0
  124. package/dist/pricing/validation.d.ts.map +1 -0
  125. package/dist/pricing/validation.js +307 -0
  126. package/dist/promotions/events.d.ts +38 -0
  127. package/dist/promotions/events.d.ts.map +1 -0
  128. package/dist/promotions/events.js +25 -0
  129. package/dist/promotions/index.d.ts +12 -0
  130. package/dist/promotions/index.d.ts.map +1 -0
  131. package/dist/promotions/index.js +17 -0
  132. package/dist/promotions/routes-shared.d.ts +14 -0
  133. package/dist/promotions/routes-shared.d.ts.map +1 -0
  134. package/dist/promotions/routes-shared.js +3 -0
  135. package/dist/promotions/routes.d.ts +395 -0
  136. package/dist/promotions/routes.d.ts.map +1 -0
  137. package/dist/promotions/routes.js +55 -0
  138. package/dist/promotions/schema.d.ts +675 -0
  139. package/dist/promotions/schema.d.ts.map +1 -0
  140. package/dist/promotions/schema.js +126 -0
  141. package/dist/promotions/service-booking-confirmed.d.ts +77 -0
  142. package/dist/promotions/service-booking-confirmed.d.ts.map +1 -0
  143. package/dist/promotions/service-booking-confirmed.js +134 -0
  144. package/dist/promotions/service-boundary-scheduler.d.ts +85 -0
  145. package/dist/promotions/service-boundary-scheduler.d.ts.map +1 -0
  146. package/dist/promotions/service-boundary-scheduler.js +141 -0
  147. package/dist/promotions/service-catalog-evaluator.d.ts +22 -0
  148. package/dist/promotions/service-catalog-evaluator.d.ts.map +1 -0
  149. package/dist/promotions/service-catalog-evaluator.js +33 -0
  150. package/dist/promotions/service-catalog-plane-promotions.d.ts +73 -0
  151. package/dist/promotions/service-catalog-plane-promotions.d.ts.map +1 -0
  152. package/dist/promotions/service-catalog-plane-promotions.js +118 -0
  153. package/dist/promotions/service-evaluator.d.ts +134 -0
  154. package/dist/promotions/service-evaluator.d.ts.map +1 -0
  155. package/dist/promotions/service-evaluator.js +302 -0
  156. package/dist/promotions/service-storefront.d.ts +147 -0
  157. package/dist/promotions/service-storefront.d.ts.map +1 -0
  158. package/dist/promotions/service-storefront.js +326 -0
  159. package/dist/promotions/service.d.ts +143 -0
  160. package/dist/promotions/service.d.ts.map +1 -0
  161. package/dist/promotions/service.js +359 -0
  162. package/dist/promotions/validation.d.ts +195 -0
  163. package/dist/promotions/validation.d.ts.map +1 -0
  164. package/dist/promotions/validation.js +167 -0
  165. package/dist/promotions/workflow-bulk-reindex.d.ts +36 -0
  166. package/dist/promotions/workflow-bulk-reindex.d.ts.map +1 -0
  167. package/dist/promotions/workflow-bulk-reindex.js +53 -0
  168. package/dist/promotions/workflow-runtime.d.ts +17 -0
  169. package/dist/promotions/workflow-runtime.d.ts.map +1 -0
  170. package/dist/promotions/workflow-runtime.js +9 -0
  171. package/dist/runtime.d.ts +18 -0
  172. package/dist/runtime.d.ts.map +1 -0
  173. package/dist/runtime.js +27 -0
  174. package/dist/runtime.test.d.ts +2 -0
  175. package/dist/runtime.test.d.ts.map +1 -0
  176. package/dist/runtime.test.js +25 -0
  177. package/dist/schema.d.ts +5 -0
  178. package/dist/schema.d.ts.map +1 -0
  179. package/dist/schema.js +4 -0
  180. package/dist/sellability/index.d.ts +13 -0
  181. package/dist/sellability/index.d.ts.map +1 -0
  182. package/dist/sellability/index.js +17 -0
  183. package/dist/sellability/routes.d.ts +2332 -0
  184. package/dist/sellability/routes.d.ts.map +1 -0
  185. package/dist/sellability/routes.js +166 -0
  186. package/dist/sellability/schema.d.ts +1716 -0
  187. package/dist/sellability/schema.d.ts.map +1 -0
  188. package/dist/sellability/schema.js +278 -0
  189. package/dist/sellability/service-records.d.ts +316 -0
  190. package/dist/sellability/service-records.d.ts.map +1 -0
  191. package/dist/sellability/service-records.js +253 -0
  192. package/dist/sellability/service-resolve.d.ts +72 -0
  193. package/dist/sellability/service-resolve.d.ts.map +1 -0
  194. package/dist/sellability/service-resolve.js +580 -0
  195. package/dist/sellability/service-shared.d.ts +124 -0
  196. package/dist/sellability/service-shared.d.ts.map +1 -0
  197. package/dist/sellability/service-shared.js +96 -0
  198. package/dist/sellability/service-snapshots.d.ts +191 -0
  199. package/dist/sellability/service-snapshots.d.ts.map +1 -0
  200. package/dist/sellability/service-snapshots.js +153 -0
  201. package/dist/sellability/service.d.ts +1038 -0
  202. package/dist/sellability/service.d.ts.map +1 -0
  203. package/dist/sellability/service.js +17 -0
  204. package/dist/sellability/validation.d.ts +477 -0
  205. package/dist/sellability/validation.d.ts.map +1 -0
  206. package/dist/sellability/validation.js +192 -0
  207. package/dist/types.d.ts +239 -0
  208. package/dist/types.d.ts.map +1 -0
  209. package/dist/types.js +1 -0
  210. package/package.json +62 -0
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Projection extension that decorates the product search document with
3
+ * promotional-offer annotations declared by the Product catalog policy.
4
+ *
5
+ * Lives in `@voyant-travel/commerce` because:
6
+ * - The data lives here.
7
+ * - Product owns the document-builder implementation, while this package
8
+ * exposes a structural extension that satisfies that builder contract.
9
+ *
10
+ * Wire via `createProductDocumentBuilder({ extensions: [...promotionsExt] })`
11
+ * after composing `productPromotionsCatalogPolicy` into the registry.
12
+ *
13
+ * Annotation-only contract (per §3.7 of the architecture doc): this
14
+ * extension does NOT touch `priceFromAmountCents` (that's emitted by the
15
+ * pricing extension and the two extensions can't read each other's
16
+ * output). It only emits `bestOffer*` + `originalPriceFromAmountCents` +
17
+ * `conditionalOffer*`. Storefront consumers compute the effective price
18
+ * client-side.
19
+ *
20
+ * `originalPriceFromAmountCents` resolution: by default we read
21
+ * `products.sell_amount_cents` directly (works for simple products with
22
+ * row-level pricing). Operators with option-driven pricing should pass
23
+ * `loadOriginalPrice` to wire the same rate-plan-first resolver the
24
+ * pricing extension uses; otherwise the strikethrough may not match the
25
+ * customer-visible list price for option-driven products.
26
+ *
27
+ * Per docs/architecture/commerce-architecture.md §6.
28
+ */
29
+ import type { IndexerSlice } from "@voyant-travel/catalog";
30
+ import type { AnyDrizzleDb } from "@voyant-travel/db";
31
+ import { type AppliedOffer, type ConditionalOffer, type EvaluationResult } from "./service-evaluator.js";
32
+ export interface PromotionsProjectionOptions {
33
+ /**
34
+ * Resolve the un-discounted "from price" + currency for a product.
35
+ * The result drives `originalPriceFromAmountCents` + the evaluator's
36
+ * `basePriceCents` / `baseCurrency` inputs.
37
+ *
38
+ * Defaults to a direct read of `products.sell_amount_cents` +
39
+ * `products.sell_currency` — works for simple row-priced products.
40
+ * Operators with option-driven pricing should wire this to the same
41
+ * rate-plan-first resolver the pricing extension uses.
42
+ *
43
+ * Returns `null` for amountCents when the product has no configured
44
+ * base price (the extension then short-circuits to an empty projection
45
+ * since there's no base for the evaluator to discount).
46
+ */
47
+ loadOriginalPrice?: (db: AnyDrizzleDb, productId: string) => Promise<{
48
+ amountCents: number | null;
49
+ currency: string | null;
50
+ }>;
51
+ /** Override `now()` for testing. Defaults to wall-clock time at projection. */
52
+ now?: () => Date;
53
+ }
54
+ export interface ProductProjectionExtension {
55
+ readonly name: string;
56
+ project(db: AnyDrizzleDb, productId: string, slice: IndexerSlice): Promise<ReadonlyMap<string, unknown>>;
57
+ }
58
+ /**
59
+ * Map an `IndexerSlice.audience` (which can include `staff-admin`) onto the
60
+ * evaluator's narrower `Visibility` enum. Both `staff` and `staff-admin`
61
+ * map to `staff` for offer-evaluation purposes — both are operator-internal
62
+ * surfaces that should see the same promotional inventory.
63
+ */
64
+ declare function sliceAudience(slice: IndexerSlice): "staff" | "customer" | "partner" | "supplier";
65
+ export declare function createProductPromotionsProjectionExtension(options?: PromotionsProjectionOptions): ProductProjectionExtension;
66
+ declare function toProjectionMap(best: AppliedOffer | null, conditional: ConditionalOffer | null, originalPrice: number | null): ReadonlyMap<string, unknown>;
67
+ export declare const __test__: {
68
+ toProjectionMap: typeof toProjectionMap;
69
+ EMPTY_PROJECTION: ReadonlyMap<string, unknown>;
70
+ sliceAudience: typeof sliceAudience;
71
+ };
72
+ export type { EvaluationResult };
73
+ //# sourceMappingURL=service-catalog-plane-promotions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service-catalog-plane-promotions.d.ts","sourceRoot":"","sources":["../../src/promotions/service-catalog-plane-promotions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAA;AAC1D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAErD,OAAO,EACL,KAAK,YAAY,EACjB,KAAK,gBAAgB,EAErB,KAAK,gBAAgB,EAEtB,MAAM,wBAAwB,CAAA;AAE/B,MAAM,WAAW,2BAA2B;IAC1C;;;;;;;;;;;;;OAaG;IACH,iBAAiB,CAAC,EAAE,CAClB,EAAE,EAAE,YAAY,EAChB,SAAS,EAAE,MAAM,KACd,OAAO,CAAC;QAAE,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC,CAAA;IAErE,+EAA+E;IAC/E,GAAG,CAAC,EAAE,MAAM,IAAI,CAAA;CACjB;AAED,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,OAAO,CACL,EAAE,EAAE,YAAY,EAChB,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,YAAY,GAClB,OAAO,CAAC,WAAW,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;CACzC;AAED;;;;;GAKG;AACH,iBAAS,aAAa,CAAC,KAAK,EAAE,YAAY,GAAG,OAAO,GAAG,UAAU,GAAG,SAAS,GAAG,UAAU,CAGzF;AAID,wBAAgB,0CAA0C,CACxD,OAAO,GAAE,2BAAgC,GACxC,0BAA0B,CAkC5B;AAED,iBAAS,eAAe,CACtB,IAAI,EAAE,YAAY,GAAG,IAAI,EACzB,WAAW,EAAE,gBAAgB,GAAG,IAAI,EACpC,aAAa,EAAE,MAAM,GAAG,IAAI,GAC3B,WAAW,CAAC,MAAM,EAAE,OAAO,CAAC,CAuB9B;AA+BD,eAAO,MAAM,QAAQ;;;;CAAuD,CAAA;AAE5E,YAAY,EAAE,gBAAgB,EAAE,CAAA"}
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Projection extension that decorates the product search document with
3
+ * promotional-offer annotations declared by the Product catalog policy.
4
+ *
5
+ * Lives in `@voyant-travel/commerce` because:
6
+ * - The data lives here.
7
+ * - Product owns the document-builder implementation, while this package
8
+ * exposes a structural extension that satisfies that builder contract.
9
+ *
10
+ * Wire via `createProductDocumentBuilder({ extensions: [...promotionsExt] })`
11
+ * after composing `productPromotionsCatalogPolicy` into the registry.
12
+ *
13
+ * Annotation-only contract (per §3.7 of the architecture doc): this
14
+ * extension does NOT touch `priceFromAmountCents` (that's emitted by the
15
+ * pricing extension and the two extensions can't read each other's
16
+ * output). It only emits `bestOffer*` + `originalPriceFromAmountCents` +
17
+ * `conditionalOffer*`. Storefront consumers compute the effective price
18
+ * client-side.
19
+ *
20
+ * `originalPriceFromAmountCents` resolution: by default we read
21
+ * `products.sell_amount_cents` directly (works for simple products with
22
+ * row-level pricing). Operators with option-driven pricing should pass
23
+ * `loadOriginalPrice` to wire the same rate-plan-first resolver the
24
+ * pricing extension uses; otherwise the strikethrough may not match the
25
+ * customer-visible list price for option-driven products.
26
+ *
27
+ * Per docs/architecture/commerce-architecture.md §6.
28
+ */
29
+ import { createDrizzleOfferDataSource, evaluateOffersForProduct, } from "./service-evaluator.js";
30
+ /**
31
+ * Map an `IndexerSlice.audience` (which can include `staff-admin`) onto the
32
+ * evaluator's narrower `Visibility` enum. Both `staff` and `staff-admin`
33
+ * map to `staff` for offer-evaluation purposes — both are operator-internal
34
+ * surfaces that should see the same promotional inventory.
35
+ */
36
+ function sliceAudience(slice) {
37
+ if (slice.audience === "staff-admin")
38
+ return "staff";
39
+ return slice.audience;
40
+ }
41
+ const EMPTY_PROJECTION = toProjectionMap(null, null, null);
42
+ export function createProductPromotionsProjectionExtension(options = {}) {
43
+ const loadOriginalPrice = options.loadOriginalPrice ?? defaultLoadOriginalPrice;
44
+ const nowFn = options.now ?? (() => new Date());
45
+ return {
46
+ name: "promotions:offers",
47
+ async project(db, productId, slice) {
48
+ const { amountCents, currency } = await loadOriginalPrice(db, productId);
49
+ if (amountCents == null || currency == null) {
50
+ // No base price configured → no offer math to do. Returning the
51
+ // empty projection ensures consumers see explicit nulls instead
52
+ // of stale prior values from the doc.
53
+ return EMPTY_PROJECTION;
54
+ }
55
+ const source = createDrizzleOfferDataSource(db);
56
+ const evaluation = await evaluateOffersForProduct(source, {
57
+ productId,
58
+ slice: { audience: sliceAudience(slice), market: slice.market },
59
+ date: nowFn(),
60
+ basePriceCents: amountCents,
61
+ baseCurrency: currency,
62
+ // pax + code intentionally omitted: catalog plane never knows
63
+ // these. minPax-conditioned offers land in `result.conditional`.
64
+ });
65
+ const conditional = evaluation.conditional.find((offer) => offer.unmet.kind === "min_pax") ?? null;
66
+ // Surface `originalPriceFromAmountCents` ONLY when an offer applies —
67
+ // §3.7 keeps the doc lean by leaving it null otherwise.
68
+ const original = evaluation.best != null ? amountCents : null;
69
+ return toProjectionMap(evaluation.best, conditional, original);
70
+ },
71
+ };
72
+ }
73
+ function toProjectionMap(best, conditional, originalPrice) {
74
+ return new Map([
75
+ ["hasOffer", best != null],
76
+ ["bestOfferId", best?.offerId ?? null],
77
+ ["bestOfferName", best?.offerName ?? null],
78
+ ["bestOfferDiscountKind", best?.discountKind ?? null],
79
+ ["bestOfferDiscountPercent", best?.discountPercent ?? null],
80
+ ["bestOfferDiscountAmountCents", best?.discountAmountCents ?? null],
81
+ ["originalPriceFromAmountCents", originalPrice],
82
+ ["hasConditionalOffer", conditional != null],
83
+ ["conditionalOfferId", conditional?.offerId ?? null],
84
+ ["conditionalOfferName", conditional?.offerName ?? null],
85
+ ["conditionalOfferDiscountKind", conditional?.discountKind ?? null],
86
+ ["conditionalOfferDiscountPercent", conditional?.discountPercent ?? null],
87
+ ["conditionalOfferDiscountAmountCents", conditional?.discountAmountCents ?? null],
88
+ [
89
+ "conditionalOfferMinPax",
90
+ conditional != null && conditional.unmet.kind === "min_pax"
91
+ ? conditional.unmet.required
92
+ : null,
93
+ ],
94
+ ]);
95
+ }
96
+ /**
97
+ * Default loader — single-column read against `products` so we don't pull
98
+ * the products schema into this file (would deepen the coupling). The
99
+ * column shape is stable enough that string-keyed access is safe; a
100
+ * schema rename would break far more than this projection.
101
+ */
102
+ async function defaultLoadOriginalPrice(db, productId) {
103
+ // biome-ignore lint/suspicious/noExplicitAny: drizzle's typed sql is overkill for a single-row read -- owner: promotions; existing suppression is intentional pending typed cleanup.
104
+ const dbAny = db;
105
+ const { sql } = await import("drizzle-orm");
106
+ const result = await dbAny.execute(
107
+ // agent-quality: raw-sql reviewed -- owner: promotions; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
108
+ sql `SELECT sell_amount_cents, sell_currency FROM products WHERE id = ${productId} LIMIT 1`);
109
+ // postgres-js returns array-like; node-postgres returns `{ rows }`. Handle both.
110
+ const rows = Array.isArray(result) ? result : (result?.rows ?? []);
111
+ const first = rows[0];
112
+ return {
113
+ amountCents: first?.sell_amount_cents ?? null,
114
+ currency: first?.sell_currency ?? null,
115
+ };
116
+ }
117
+ // Internal exports for unit tests — kept off the public surface.
118
+ export const __test__ = { toProjectionMap, EMPTY_PROJECTION, sliceAudience };
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Promotions rule evaluator — the heart of §5.
3
+ *
4
+ * One pure function (`evaluateOffersForProduct`) used by both callers
5
+ * (catalog plane projection in PR3 + checkout quote in PR4). The evaluator
6
+ * doesn't bind to a database — it takes an `OfferDataSource` interface so
7
+ * the catalog projection can reuse one cached candidate set across many
8
+ * products in a slice, and unit tests can supply in-memory fixtures
9
+ * without a DB mock.
10
+ *
11
+ * The DB-backed `OfferDataSource` factory (`createDrizzleOfferDataSource`)
12
+ * is provided too — that's what PR3/PR4 wire up.
13
+ *
14
+ * Per docs/architecture/promotions-architecture.md §5.
15
+ *
16
+ * Not yet exported from the package barrel — PR3 wires it via the catalog
17
+ * plane adapter, PR4 via the checkout adapter.
18
+ */
19
+ import type { AnyDrizzleDb } from "@voyant-travel/db";
20
+ import { type PromotionalOffer } from "./schema.js";
21
+ export interface OfferEvaluationContext {
22
+ productId: string;
23
+ slice: {
24
+ audience: "staff" | "customer" | "partner" | "supplier";
25
+ market: string;
26
+ };
27
+ /** Optional booking-line fare code, used by fare-scoped offers. */
28
+ fareCode?: string | null;
29
+ /** Optional cabin grade code, used by cruise cabin-grade-scoped offers. */
30
+ cabinGradeCode?: string | null;
31
+ eligibility?: {
32
+ pastGuest?: boolean;
33
+ soloTraveler?: boolean;
34
+ hasChildTraveler?: boolean;
35
+ family?: boolean;
36
+ };
37
+ /** Total travelers. Absent at catalog-index time; supplied at checkout. */
38
+ pax?: number;
39
+ /** Defaults to `now()` when undefined. */
40
+ date?: Date;
41
+ /** Customer-typed promotion code; case-insensitive match. */
42
+ code?: string;
43
+ basePriceCents: number;
44
+ baseCurrency: string;
45
+ }
46
+ export interface AppliedOffer {
47
+ offerId: string;
48
+ offerName: string;
49
+ /** The actual cents off attributed to this offer. */
50
+ discountAppliedCents: number;
51
+ /** `basePriceCents - discountAppliedCents` (per-row, the price the offer alone would yield). */
52
+ discountedPriceCents: number;
53
+ /** Matches the surrounding `ctx.baseCurrency` — carried per-row so the redemption recorder can insert without context. */
54
+ currency: string;
55
+ discountKind: "percentage" | "fixed_amount";
56
+ discountPercent: number | null;
57
+ discountAmountCents: number | null;
58
+ /** The literal code the customer entered (case preserved); null for auto-applied. */
59
+ appliedCode: string | null;
60
+ stackable: boolean;
61
+ }
62
+ /**
63
+ * An offer that *would* apply if a missing input were supplied — typically
64
+ * a `minPax` condition the catalog-plane caller can't satisfy because pax
65
+ * isn't known at index time. Surfaced for storefront UI hints like
66
+ * "From 4 pax: extra 5% off".
67
+ */
68
+ export interface ConditionalOffer {
69
+ offerId: string;
70
+ offerName: string;
71
+ discountKind: "percentage" | "fixed_amount";
72
+ discountPercent: number | null;
73
+ discountAmountCents: number | null;
74
+ unmet: {
75
+ kind: "min_pax";
76
+ required: number;
77
+ } | {
78
+ kind: "past_guest";
79
+ } | {
80
+ kind: "solo_traveler";
81
+ } | {
82
+ kind: "child_traveler";
83
+ } | {
84
+ kind: "family";
85
+ };
86
+ }
87
+ /** Outcome of code validation when `ctx.code` is supplied. `null` when ctx.code was not set. */
88
+ export type CodeStatus = null | {
89
+ kind: "code_valid";
90
+ } | {
91
+ kind: "code_not_found";
92
+ } | {
93
+ kind: "code_expired";
94
+ } | {
95
+ kind: "code_not_yet_valid";
96
+ } | {
97
+ kind: "code_not_applicable";
98
+ reason: "scope" | "min_pax" | "eligibility" | "currency";
99
+ };
100
+ export interface EvaluationResult {
101
+ /** All applied offers (1+ when stacking; 0 when no offer applies). May include a code-gated offer alongside auto offers. */
102
+ applied: AppliedOffer[];
103
+ /** The single best offer (largest discount among the applied set), or null if none. Always references one row in `applied`. */
104
+ best: AppliedOffer | null;
105
+ /** Conditionally applicable — a missing input would make them apply. Only populated by the catalog-plane caller (no `ctx.pax`). Empty for checkout. */
106
+ conditional: ConditionalOffer[];
107
+ total: {
108
+ discountAppliedCents: number;
109
+ discountedPriceCents: number;
110
+ };
111
+ /** Set when `ctx.code` was supplied. Drives the checkout caller's `invalidReason` mapping (§7.2). */
112
+ codeStatus: CodeStatus;
113
+ }
114
+ /**
115
+ * Read-only data access the evaluator needs. Decoupled from drizzle so
116
+ * unit tests can supply in-memory fixtures and so the catalog projection
117
+ * can cache `fetchActiveAutoCandidates` once per slice.
118
+ */
119
+ export interface OfferDataSource {
120
+ /** All active offers whose validity window includes `date` AND `code IS NULL`. */
121
+ fetchActiveAutoCandidates(date: Date): Promise<PromotionalOffer[]>;
122
+ /** Active offer matching `lower(code) = lower(input)`, or null. */
123
+ findActiveOfferByCode(code: string): Promise<PromotionalOffer | null>;
124
+ /** Subset of `offerIds` whose `promotional_offer_products` table has a row for `productId`. */
125
+ productMatchesAnyScope(productId: string, offerIds: string[]): Promise<Set<string>>;
126
+ }
127
+ export declare function createDrizzleOfferDataSource(db: AnyDrizzleDb): OfferDataSource;
128
+ declare function activeAutoOfferPredicate(date: Date): import("drizzle-orm").SQL<unknown> | undefined;
129
+ export declare function evaluateOffersForProduct(source: OfferDataSource, ctx: OfferEvaluationContext): Promise<EvaluationResult>;
130
+ export declare const __test__: {
131
+ activeAutoOfferPredicate: typeof activeAutoOfferPredicate;
132
+ };
133
+ export {};
134
+ //# sourceMappingURL=service-evaluator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service-evaluator.d.ts","sourceRoot":"","sources":["../../src/promotions/service-evaluator.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Promotions rule evaluator — the heart of §5.
3
+ *
4
+ * One pure function (`evaluateOffersForProduct`) used by both callers
5
+ * (catalog plane projection in PR3 + checkout quote in PR4). The evaluator
6
+ * doesn't bind to a database — it takes an `OfferDataSource` interface so
7
+ * the catalog projection can reuse one cached candidate set across many
8
+ * products in a slice, and unit tests can supply in-memory fixtures
9
+ * without a DB mock.
10
+ *
11
+ * The DB-backed `OfferDataSource` factory (`createDrizzleOfferDataSource`)
12
+ * is provided too — that's what PR3/PR4 wire up.
13
+ *
14
+ * Per docs/architecture/promotions-architecture.md §5.
15
+ *
16
+ * Not yet exported from the package barrel — PR3 wires it via the catalog
17
+ * plane adapter, PR4 via the checkout adapter.
18
+ */
19
+ import { and, eq, gte, inArray, isNull, lte, or, sql } from "drizzle-orm";
20
+ import { promotionalOfferProducts, promotionalOffers } from "./schema.js";
21
+ // ---------- DB-backed source factory (used by PR3 + PR4) ----------
22
+ export function createDrizzleOfferDataSource(db) {
23
+ return {
24
+ async fetchActiveAutoCandidates(date) {
25
+ return db.select().from(promotionalOffers).where(activeAutoOfferPredicate(date));
26
+ },
27
+ async findActiveOfferByCode(code) {
28
+ const rows = await db
29
+ .select()
30
+ .from(promotionalOffers)
31
+ .where(and(eq(promotionalOffers.active, true),
32
+ // agent-quality: raw-sql reviewed -- owner: promotions; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
33
+ sql `lower(${promotionalOffers.code}) = ${code.toLowerCase()}`))
34
+ .limit(1);
35
+ return rows[0] ?? null;
36
+ },
37
+ async productMatchesAnyScope(productId, offerIds) {
38
+ if (offerIds.length === 0)
39
+ return new Set();
40
+ const rows = await db
41
+ .select({ offerId: promotionalOfferProducts.offerId })
42
+ .from(promotionalOfferProducts)
43
+ .where(and(eq(promotionalOfferProducts.productId, productId), inArray(promotionalOfferProducts.offerId, offerIds)));
44
+ return new Set(rows.map((r) => r.offerId));
45
+ },
46
+ };
47
+ }
48
+ function activeAutoOfferPredicate(date) {
49
+ return and(eq(promotionalOffers.active, true), isNull(promotionalOffers.code), or(isNull(promotionalOffers.validFrom), lte(promotionalOffers.validFrom, date)), or(isNull(promotionalOffers.validUntil), gte(promotionalOffers.validUntil, date)));
50
+ }
51
+ // ---------- Internal helpers ----------
52
+ function discountKind(offer) {
53
+ return offer.discountType;
54
+ }
55
+ function discountFields(offer) {
56
+ return {
57
+ discountKind: discountKind(offer),
58
+ discountPercent: offer.discountPercent != null ? Number(offer.discountPercent) : null,
59
+ discountAmountCents: offer.discountAmountCents,
60
+ };
61
+ }
62
+ /**
63
+ * Cents off a given base for a single offer. Caps fixed_amount at the
64
+ * available base so a discount can never exceed the price.
65
+ */
66
+ function computeDiscount(offer, basePriceCents) {
67
+ if (basePriceCents <= 0)
68
+ return 0;
69
+ if (offer.discountType === "percentage") {
70
+ if (offer.discountPercent == null)
71
+ return 0;
72
+ const pct = Number(offer.discountPercent);
73
+ return Math.round((basePriceCents * pct) / 100);
74
+ }
75
+ if (offer.discountAmountCents == null)
76
+ return 0;
77
+ return Math.min(offer.discountAmountCents, basePriceCents);
78
+ }
79
+ function matchesScope(scope, ctx, offerMatchesProduct) {
80
+ switch (scope.kind) {
81
+ case "global":
82
+ return true;
83
+ case "products":
84
+ case "categories":
85
+ case "destinations":
86
+ return offerMatchesProduct;
87
+ case "markets":
88
+ return scope.marketIds.includes(ctx.slice.market);
89
+ case "audiences":
90
+ return scope.audiences.includes(ctx.slice.audience);
91
+ case "fare_codes":
92
+ return ctx.fareCode != null && scope.fareCodes.includes(ctx.fareCode);
93
+ case "cabin_grades":
94
+ return ctx.cabinGradeCode != null && scope.cabinGradeCodes.includes(ctx.cabinGradeCode);
95
+ }
96
+ }
97
+ function evaluateConditions(conditions, ctx) {
98
+ if (conditions.minPax != null) {
99
+ if (ctx.pax === undefined) {
100
+ return { kind: "conditional", unmet: { kind: "min_pax", required: conditions.minPax } };
101
+ }
102
+ if (ctx.pax < conditions.minPax) {
103
+ return { kind: "excluded", reason: "min_pax" };
104
+ }
105
+ }
106
+ if (conditions.pastGuestOnly === true) {
107
+ const result = evaluateEligibilityFlag(ctx.eligibility?.pastGuest, { kind: "past_guest" });
108
+ if (result.kind !== "ok")
109
+ return result;
110
+ }
111
+ if (conditions.soloTravelerOnly === true) {
112
+ const soloTraveler = ctx.eligibility?.soloTraveler ?? (ctx.pax != null ? ctx.pax === 1 : undefined);
113
+ const result = evaluateEligibilityFlag(soloTraveler, { kind: "solo_traveler" });
114
+ if (result.kind !== "ok")
115
+ return result;
116
+ }
117
+ if (conditions.childTravelerOnly === true) {
118
+ const result = evaluateEligibilityFlag(ctx.eligibility?.hasChildTraveler, {
119
+ kind: "child_traveler",
120
+ });
121
+ if (result.kind !== "ok")
122
+ return result;
123
+ }
124
+ if (conditions.familyOnly === true) {
125
+ const result = evaluateEligibilityFlag(ctx.eligibility?.family, { kind: "family" });
126
+ if (result.kind !== "ok")
127
+ return result;
128
+ }
129
+ return { kind: "ok" };
130
+ }
131
+ function evaluateEligibilityFlag(value, unmet) {
132
+ if (value === true)
133
+ return { kind: "ok" };
134
+ if (value === false)
135
+ return { kind: "excluded", reason: "eligibility" };
136
+ return { kind: "conditional", unmet };
137
+ }
138
+ function currencyMatches(offer, ctx) {
139
+ if (offer.discountType !== "fixed_amount")
140
+ return true;
141
+ return offer.currency === ctx.baseCurrency;
142
+ }
143
+ /**
144
+ * Stacking pick (§5.2.6 + §3.3):
145
+ * - Pick the single best non-stackable offer (largest cents off the base).
146
+ * - Separately compose all `stackable` offers sequentially (deterministic
147
+ * order: by offerId ascending) — each stackable offer applies to the
148
+ * RUNNING base after prior stackables, which produces the multiplicative
149
+ * formula for percentage stackables and well-defined behavior for
150
+ * fixed_amount or mixed-type stackables.
151
+ * - Take whichever path yields the larger total discount. Ties → prefer
152
+ * the single non-stackable (simpler customer-facing receipt).
153
+ */
154
+ function pickStacking(applied, basePriceCents) {
155
+ const stackable = [];
156
+ const nonStackable = [];
157
+ for (const offer of applied) {
158
+ if (offer.stackable)
159
+ stackable.push(offer);
160
+ else
161
+ nonStackable.push(offer);
162
+ }
163
+ // Best single non-stackable
164
+ let bestNonStackable = null;
165
+ let bestNonStackableDiscount = 0;
166
+ for (const offer of nonStackable) {
167
+ const d = computeDiscount(offer, basePriceCents);
168
+ if (d > bestNonStackableDiscount) {
169
+ bestNonStackable = offer;
170
+ bestNonStackableDiscount = d;
171
+ }
172
+ }
173
+ // Composed stackables (sequential, sorted by offerId for determinism)
174
+ const sortedStackable = [...stackable].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
175
+ let stackableBase = basePriceCents;
176
+ const stackableRows = [];
177
+ for (const offer of sortedStackable) {
178
+ const d = computeDiscount(offer, stackableBase);
179
+ if (d <= 0)
180
+ continue;
181
+ stackableRows.push({ offer, appliedCents: d });
182
+ stackableBase -= d;
183
+ }
184
+ const stackableTotal = basePriceCents - stackableBase;
185
+ if (bestNonStackable && bestNonStackableDiscount >= stackableTotal) {
186
+ return {
187
+ rows: [{ offer: bestNonStackable, appliedCents: bestNonStackableDiscount }],
188
+ runningBase: basePriceCents - bestNonStackableDiscount,
189
+ };
190
+ }
191
+ return { rows: stackableRows, runningBase: stackableBase };
192
+ }
193
+ function toAppliedOffer(row, ctx, appliedCode) {
194
+ const fields = discountFields(row.offer);
195
+ return {
196
+ offerId: row.offer.id,
197
+ offerName: row.offer.name,
198
+ discountAppliedCents: row.appliedCents,
199
+ discountedPriceCents: ctx.basePriceCents - row.appliedCents,
200
+ currency: ctx.baseCurrency,
201
+ discountKind: fields.discountKind,
202
+ discountPercent: fields.discountPercent,
203
+ discountAmountCents: fields.discountAmountCents,
204
+ appliedCode: row.offer.code != null ? appliedCode : null,
205
+ stackable: row.offer.stackable,
206
+ };
207
+ }
208
+ // ---------- Public entry point ----------
209
+ export async function evaluateOffersForProduct(source, ctx) {
210
+ const date = ctx.date ?? new Date();
211
+ // Step 1: code lookup (when supplied) — classify validity AHEAD of the
212
+ // scope/conditions/currency filters so we can produce a precise
213
+ // `code_expired` / `code_not_yet_valid` reason instead of conflating
214
+ // them with `code_not_found`.
215
+ let codeOffer = null;
216
+ let preFilterCodeStatus = null;
217
+ if (ctx.code !== undefined) {
218
+ const found = await source.findActiveOfferByCode(ctx.code);
219
+ if (found == null) {
220
+ preFilterCodeStatus = { kind: "code_not_found" };
221
+ }
222
+ else if (found.validUntil != null && found.validUntil < date) {
223
+ preFilterCodeStatus = { kind: "code_expired" };
224
+ }
225
+ else if (found.validFrom != null && found.validFrom > date) {
226
+ preFilterCodeStatus = { kind: "code_not_yet_valid" };
227
+ }
228
+ else {
229
+ codeOffer = found;
230
+ // Tentatively valid; finalize after scope/conditions/currency filters.
231
+ }
232
+ }
233
+ // Step 2: auto-offer candidate fetch
234
+ const autoCandidates = await source.fetchActiveAutoCandidates(date);
235
+ const allCandidates = codeOffer ? [...autoCandidates, codeOffer] : autoCandidates;
236
+ // Pre-fetch product link membership in one query (§5.2.3 — uniform hot path).
237
+ const offerIds = allCandidates.map((o) => o.id);
238
+ const productMatchSet = await source.productMatchesAnyScope(ctx.productId, offerIds);
239
+ // Steps 3 + 4 + 5: scope / conditions / currency filter, partition.
240
+ const applied = [];
241
+ const conditional = [];
242
+ let codeOfferRejection = null;
243
+ for (const offer of allCandidates) {
244
+ if (!matchesScope(offer.scope, ctx, productMatchSet.has(offer.id))) {
245
+ if (offer === codeOffer)
246
+ codeOfferRejection = { kind: "scope" };
247
+ continue;
248
+ }
249
+ const cond = evaluateConditions(offer.conditions, ctx);
250
+ if (cond.kind === "conditional") {
251
+ conditional.push({
252
+ offerId: offer.id,
253
+ offerName: offer.name,
254
+ ...discountFields(offer),
255
+ unmet: cond.unmet,
256
+ });
257
+ continue;
258
+ }
259
+ if (cond.kind === "excluded") {
260
+ if (offer === codeOffer)
261
+ codeOfferRejection = { kind: cond.reason };
262
+ continue;
263
+ }
264
+ if (!currencyMatches(offer, ctx)) {
265
+ if (offer === codeOffer)
266
+ codeOfferRejection = { kind: "currency" };
267
+ continue;
268
+ }
269
+ applied.push(offer);
270
+ }
271
+ // Finalize codeStatus after the filter pass.
272
+ let codeStatus = preFilterCodeStatus;
273
+ if (ctx.code !== undefined && codeStatus === null) {
274
+ if (codeOfferRejection != null) {
275
+ codeStatus = { kind: "code_not_applicable", reason: codeOfferRejection.kind };
276
+ }
277
+ else {
278
+ codeStatus = { kind: "code_valid" };
279
+ }
280
+ }
281
+ // Step 6 + 7: stacking pick + assemble result.
282
+ const { rows, runningBase } = pickStacking(applied, ctx.basePriceCents);
283
+ const appliedRows = rows.map((r) => toAppliedOffer(r, ctx, r.offer === codeOffer ? (ctx.code ?? null) : null));
284
+ let best = null;
285
+ for (const row of appliedRows) {
286
+ if (best == null || row.discountAppliedCents > best.discountAppliedCents) {
287
+ best = row;
288
+ }
289
+ }
290
+ return {
291
+ applied: appliedRows,
292
+ best,
293
+ conditional,
294
+ total: {
295
+ discountAppliedCents: ctx.basePriceCents - runningBase,
296
+ discountedPriceCents: runningBase,
297
+ },
298
+ codeStatus,
299
+ };
300
+ }
301
+ // Internal exports for unit tests — kept off the public surface.
302
+ export const __test__ = { activeAutoOfferPredicate };