@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,382 @@
1
+ /**
2
+ * Projection extension that aggregates a "price from" amount across the
3
+ * product's future bookable rate-plan prices and contributes
4
+ * `priceFromAmountCents`, `priceFromCurrency`, and `hasPricing` to the
5
+ * product search document.
6
+ *
7
+ * Lives in `@voyant-travel/commerce` because:
8
+ * - The data lives here (`option_price_rules`, `option_unit_price_rules`,
9
+ * `price_catalogs`).
10
+ * - Product owns the document-builder implementation, while this package
11
+ * exposes a structural extension that satisfies that builder contract.
12
+ *
13
+ * Wire via `createProductDocumentBuilder({ extensions: [pricingExtension] })`
14
+ * after composing `productPricingCatalogPolicy` into the registry.
15
+ *
16
+ * Scope intentionally narrow:
17
+ * - **No schedule-aware rule resolution.** Only `is_default = true`
18
+ * rules contribute. Seasonal / promo rules with schedules don't
19
+ * surface here; they require per-slice rule evaluation beyond the
20
+ * future-departure presence check below.
21
+ * - **No per-departure overrides.** Same reason.
22
+ * - **Currency consistency.** Only rules whose catalog currency matches
23
+ * the product's `sellCurrency` (or whose catalog currency is null
24
+ * and therefore inherits the product's) are MIN'd together.
25
+ *
26
+ * Document churn: this projection is `now()`-dependent because it only
27
+ * considers future bookable departures. A product can move to "unpriced"
28
+ * once its final departure starts unless a row-level fallback remains.
29
+ */
30
+ import { sql } from "drizzle-orm";
31
+ const EMPTY_AGGREGATE = {
32
+ priceFromAmountCents: null,
33
+ priceFromCurrency: null,
34
+ hasPricing: false,
35
+ };
36
+ /**
37
+ * Pure aggregation kernel. Room prices take precedence over base/unit
38
+ * prices, which take precedence over the product-row fallback. Non-
39
+ * positive values are treated as absent so stale `0` caches don't block
40
+ * nullish fallbacks in catalog consumers.
41
+ */
42
+ function aggregatePricing(productPrice, currency, roomPrices, basePrices) {
43
+ const min = firstPositiveMin(roomPrices) ?? firstPositiveMin(basePrices) ?? positive(productPrice);
44
+ if (min === null) {
45
+ return { ...EMPTY_AGGREGATE, priceFromCurrency: currency };
46
+ }
47
+ return {
48
+ priceFromAmountCents: min,
49
+ priceFromCurrency: currency,
50
+ hasPricing: true,
51
+ };
52
+ }
53
+ /**
54
+ * Construct the pricing projection extension.
55
+ *
56
+ * Pass loaders in tests to stub DB reads; production uses raw SQL against
57
+ * the deployed schema.
58
+ */
59
+ export function createProductPricingProjectionExtension(options = {}) {
60
+ const loadProductPricing = options.loadProductPricing ?? defaultLoadProductPricing;
61
+ const loadRatePlanPricing = options.loadRatePlanPricing ?? defaultLoadRatePlanPricing;
62
+ return {
63
+ name: "products:pricing",
64
+ async project(db, productId, _slice) {
65
+ const product = await loadProductPricing(db, productId);
66
+ const currency = product.sellCurrency;
67
+ // Without a product row currency we can't safely filter rules by
68
+ // matching currency. Emit only the positive row-level fallback.
69
+ if (!currency) {
70
+ const out = aggregatePricing(product.sellAmountCents, null, [], []);
71
+ return toProjectionMap(out);
72
+ }
73
+ const ratePlans = await loadRatePlanPricing(db, productId, currency);
74
+ const out = aggregatePricing(product.sellAmountCents, currency, ratePlans.roomPrices, ratePlans.basePrices);
75
+ return toProjectionMap(out);
76
+ },
77
+ };
78
+ }
79
+ /**
80
+ * Resolve the same "from price" value emitted by the pricing projection.
81
+ * Promotion projection wiring uses this so strikethrough base prices
82
+ * follow the same rate-plan-first fallback chain.
83
+ */
84
+ export async function loadProductPriceFrom(db, productId) {
85
+ const product = await defaultLoadProductPricing(db, productId);
86
+ const currency = product.sellCurrency;
87
+ if (!currency) {
88
+ return { amountCents: positive(product.sellAmountCents), currency: null };
89
+ }
90
+ const ratePlans = await defaultLoadRatePlanPricing(db, productId, currency);
91
+ const amountCents = firstPositiveMin(ratePlans.roomPrices) ??
92
+ firstPositiveMin(ratePlans.basePrices) ??
93
+ positive(product.sellAmountCents);
94
+ return { amountCents, currency };
95
+ }
96
+ /**
97
+ * Read positive prices from active default rules that have at least one
98
+ * future bookable departure. Room prices are separated from base/unit
99
+ * fallbacks so per-room pricing wins even when the product row contains
100
+ * a stale zero or stale manual price.
101
+ */
102
+ async function defaultLoadRatePlanPricing(db, productId, productCurrency) {
103
+ try {
104
+ const [roomPrice, basePrice] = await Promise.all([
105
+ fetchBookableRoomPrice(db, productId, productCurrency),
106
+ fetchBookableBasePrice(db, productId, productCurrency),
107
+ ]);
108
+ return {
109
+ roomPrices: roomPrice == null ? [] : [roomPrice],
110
+ basePrices: basePrice == null ? [] : [basePrice],
111
+ };
112
+ }
113
+ catch (error) {
114
+ // Slim test fixtures may omit availability_slots/product_options/
115
+ // option_units. Keep reindex failure-isolated and fall back to the
116
+ // product row only for those expected schema gaps.
117
+ if (isMissingCatalogPricingDependencyError(error)) {
118
+ return { roomPrices: [], basePrices: [] };
119
+ }
120
+ throw error;
121
+ }
122
+ }
123
+ async function fetchBookableRoomPrice(db, productId, productCurrency) {
124
+ const rows = await executeRows(db, sql `
125
+ WITH active_rules AS (
126
+ SELECT opr.id
127
+ FROM option_price_rules opr
128
+ INNER JOIN price_catalogs pc ON pc.id = opr.price_catalog_id
129
+ WHERE opr.product_id = ${productId}
130
+ AND opr.active = true
131
+ AND opr.is_default = true
132
+ AND pc.active = true
133
+ AND (pc.currency_code = ${productCurrency} OR pc.currency_code IS NULL)
134
+ AND EXISTS (
135
+ SELECT 1
136
+ FROM product_options po
137
+ WHERE po.id = opr.option_id
138
+ AND po.product_id = opr.product_id
139
+ AND po.status = 'active'
140
+ )
141
+ AND EXISTS (
142
+ SELECT 1
143
+ FROM availability_slots slot
144
+ WHERE slot.product_id = opr.product_id
145
+ AND slot.starts_at >= NOW()
146
+ AND slot.status::text IN ('open', 'planned', 'confirmed')
147
+ AND (slot.option_id IS NULL OR slot.option_id = opr.option_id)
148
+ )
149
+ ),
150
+ candidates AS (
151
+ SELECT
152
+ unit_rule.sell_amount_cents AS price,
153
+ (
154
+ (
155
+ category.id IS NULL
156
+ OR category.category_type = 'adult'
157
+ OR (
158
+ category.category_type NOT IN ('child', 'infant', 'senior')
159
+ AND category.min_age IS NULL
160
+ AND category.max_age IS NULL
161
+ )
162
+ )
163
+ AND COALESCE(unit_rule.min_quantity, 0) <= 1
164
+ AND COALESCE(unit_rule.max_quantity, 0) = 0
165
+ ) AS standard_price
166
+ FROM active_rules rule
167
+ INNER JOIN option_unit_price_rules unit_rule
168
+ ON unit_rule.option_price_rule_id = rule.id
169
+ INNER JOIN option_units unit
170
+ ON unit.id = unit_rule.unit_id
171
+ LEFT JOIN pricing_categories category
172
+ ON category.id = unit_rule.pricing_category_id
173
+ WHERE unit_rule.active = true
174
+ AND unit.unit_type = 'room'
175
+ UNION ALL
176
+ SELECT
177
+ tier.sell_amount_cents AS price,
178
+ (
179
+ (
180
+ category.id IS NULL
181
+ OR category.category_type = 'adult'
182
+ OR (
183
+ category.category_type NOT IN ('child', 'infant', 'senior')
184
+ AND category.min_age IS NULL
185
+ AND category.max_age IS NULL
186
+ )
187
+ )
188
+ AND COALESCE(unit_rule.min_quantity, 0) <= 1
189
+ AND COALESCE(unit_rule.max_quantity, 0) = 0
190
+ AND tier.min_quantity <= 1
191
+ AND COALESCE(tier.max_quantity, 0) = 0
192
+ ) AS standard_price
193
+ FROM active_rules rule
194
+ INNER JOIN option_unit_price_rules unit_rule
195
+ ON unit_rule.option_price_rule_id = rule.id
196
+ INNER JOIN option_units unit
197
+ ON unit.id = unit_rule.unit_id
198
+ LEFT JOIN pricing_categories category
199
+ ON category.id = unit_rule.pricing_category_id
200
+ INNER JOIN option_unit_tiers tier
201
+ ON tier.option_unit_price_rule_id = unit_rule.id
202
+ AND tier.active = true
203
+ WHERE unit_rule.active = true
204
+ AND unit.unit_type = 'room'
205
+ )
206
+ SELECT COALESCE(
207
+ MIN(price) FILTER (WHERE standard_price),
208
+ MIN(price) FILTER (WHERE NOT standard_price)
209
+ )::int AS price
210
+ FROM candidates
211
+ WHERE price > 0
212
+ `);
213
+ return readNullableInt(rows[0], "price");
214
+ }
215
+ async function fetchBookableBasePrice(db, productId, productCurrency) {
216
+ const rows = await executeRows(db, sql `
217
+ WITH active_rules AS (
218
+ SELECT opr.id, opr.base_sell_amount_cents
219
+ FROM option_price_rules opr
220
+ INNER JOIN price_catalogs pc ON pc.id = opr.price_catalog_id
221
+ WHERE opr.product_id = ${productId}
222
+ AND opr.active = true
223
+ AND opr.is_default = true
224
+ AND pc.active = true
225
+ AND (pc.currency_code = ${productCurrency} OR pc.currency_code IS NULL)
226
+ AND EXISTS (
227
+ SELECT 1
228
+ FROM product_options po
229
+ WHERE po.id = opr.option_id
230
+ AND po.product_id = opr.product_id
231
+ AND po.status = 'active'
232
+ )
233
+ AND EXISTS (
234
+ SELECT 1
235
+ FROM availability_slots slot
236
+ WHERE slot.product_id = opr.product_id
237
+ AND slot.starts_at >= NOW()
238
+ AND slot.status::text IN ('open', 'planned', 'confirmed')
239
+ AND (slot.option_id IS NULL OR slot.option_id = opr.option_id)
240
+ )
241
+ ),
242
+ candidates AS (
243
+ SELECT base_sell_amount_cents AS price, true AS standard_price
244
+ FROM active_rules
245
+ UNION ALL
246
+ SELECT
247
+ unit_rule.sell_amount_cents AS price,
248
+ (
249
+ (
250
+ category.id IS NULL
251
+ OR category.category_type = 'adult'
252
+ OR (
253
+ category.category_type NOT IN ('child', 'infant', 'senior')
254
+ AND category.min_age IS NULL
255
+ AND category.max_age IS NULL
256
+ )
257
+ )
258
+ AND COALESCE(unit_rule.min_quantity, 0) <= 1
259
+ AND COALESCE(unit_rule.max_quantity, 0) = 0
260
+ ) AS standard_price
261
+ FROM active_rules rule
262
+ INNER JOIN option_unit_price_rules unit_rule
263
+ ON unit_rule.option_price_rule_id = rule.id
264
+ INNER JOIN option_units unit
265
+ ON unit.id = unit_rule.unit_id
266
+ LEFT JOIN pricing_categories category
267
+ ON category.id = unit_rule.pricing_category_id
268
+ WHERE unit_rule.active = true
269
+ AND unit.unit_type <> 'room'
270
+ UNION ALL
271
+ SELECT
272
+ tier.sell_amount_cents AS price,
273
+ (
274
+ (
275
+ category.id IS NULL
276
+ OR category.category_type = 'adult'
277
+ OR (
278
+ category.category_type NOT IN ('child', 'infant', 'senior')
279
+ AND category.min_age IS NULL
280
+ AND category.max_age IS NULL
281
+ )
282
+ )
283
+ AND COALESCE(unit_rule.min_quantity, 0) <= 1
284
+ AND COALESCE(unit_rule.max_quantity, 0) = 0
285
+ AND tier.min_quantity <= 1
286
+ AND COALESCE(tier.max_quantity, 0) = 0
287
+ ) AS standard_price
288
+ FROM active_rules rule
289
+ INNER JOIN option_unit_price_rules unit_rule
290
+ ON unit_rule.option_price_rule_id = rule.id
291
+ INNER JOIN option_units unit
292
+ ON unit.id = unit_rule.unit_id
293
+ LEFT JOIN pricing_categories category
294
+ ON category.id = unit_rule.pricing_category_id
295
+ INNER JOIN option_unit_tiers tier
296
+ ON tier.option_unit_price_rule_id = unit_rule.id
297
+ AND tier.active = true
298
+ WHERE unit_rule.active = true
299
+ AND unit.unit_type <> 'room'
300
+ )
301
+ SELECT COALESCE(
302
+ MIN(price) FILTER (WHERE standard_price),
303
+ MIN(price) FILTER (WHERE NOT standard_price)
304
+ )::int AS price
305
+ FROM candidates
306
+ WHERE price > 0
307
+ `);
308
+ return readNullableInt(rows[0], "price");
309
+ }
310
+ async function executeRows(db, query) {
311
+ // biome-ignore lint/suspicious/noExplicitAny: #1141 supports multiple drizzle driver result shapes
312
+ const result = await db.execute(query);
313
+ return Array.isArray(result) ? result : (result?.rows ?? []);
314
+ }
315
+ /**
316
+ * Default loader reads the products row via raw SQL so we don't pull
317
+ * the products schema into this file. The columns we read are stable
318
+ * enough that a rename would break far more than this.
319
+ */
320
+ async function defaultLoadProductPricing(db, productId) {
321
+ // biome-ignore lint/suspicious/noExplicitAny: #1141 keeps cross-package product lookup driver-agnostic
322
+ const dbAny = db;
323
+ const result = await dbAny.execute(
324
+ // agent-quality: raw-sql reviewed -- owner: pricing; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
325
+ sql `SELECT sell_amount_cents, sell_currency FROM products WHERE id = ${productId} LIMIT 1`);
326
+ // postgres-js returns rows as an array-like; node-postgres returns `{ rows: [...] }`.
327
+ const rows = Array.isArray(result) ? result : (result?.rows ?? []);
328
+ const first = rows[0];
329
+ if (!first)
330
+ return { sellAmountCents: null, sellCurrency: null };
331
+ return {
332
+ sellAmountCents: first.sell_amount_cents,
333
+ sellCurrency: first.sell_currency,
334
+ };
335
+ }
336
+ function positive(value) {
337
+ return typeof value === "number" && value > 0 ? value : null;
338
+ }
339
+ function firstPositiveMin(values) {
340
+ let min = null;
341
+ for (const value of values) {
342
+ if (value <= 0)
343
+ continue;
344
+ if (min === null || value < min)
345
+ min = value;
346
+ }
347
+ return min;
348
+ }
349
+ function readNullableInt(row, key) {
350
+ const value = row?.[key];
351
+ if (typeof value === "number")
352
+ return Number.isFinite(value) ? value : null;
353
+ if (typeof value === "string") {
354
+ const parsed = Number(value);
355
+ return Number.isFinite(parsed) ? parsed : null;
356
+ }
357
+ return null;
358
+ }
359
+ function isMissingCatalogPricingDependencyError(error) {
360
+ const err = error;
361
+ const code = typeof err?.code === "string" ? err.code : null;
362
+ if (code === "42P01" || code === "42703")
363
+ return true;
364
+ const message = typeof err?.message === "string" ? err.message.toLowerCase() : "";
365
+ return ((message.includes("relation") && message.includes("does not exist")) ||
366
+ message.includes("no such table") ||
367
+ message.includes("no such column"));
368
+ }
369
+ function toProjectionMap(a) {
370
+ return new Map([
371
+ ["priceFromAmountCents", a.priceFromAmountCents],
372
+ ["priceFromCurrency", a.priceFromCurrency],
373
+ ["hasPricing", a.hasPricing],
374
+ ]);
375
+ }
376
+ // Internal exports for unit tests - kept off the public surface.
377
+ export const __test__ = {
378
+ aggregatePricing,
379
+ EMPTY_AGGREGATE,
380
+ firstPositiveMin,
381
+ isMissingCatalogPricingDependencyError,
382
+ };
@@ -0,0 +1,139 @@
1
+ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
2
+ import type { CreatePriceCatalogInput, CreatePriceScheduleInput, PriceCatalogListQuery, PriceScheduleListQuery, UpdatePriceCatalogInput, UpdatePriceScheduleInput } from "./service-shared.js";
3
+ export declare function listPriceCatalogs(db: PostgresJsDatabase, query: PriceCatalogListQuery): Promise<{
4
+ data: {
5
+ id: string;
6
+ code: string;
7
+ name: string;
8
+ currencyCode: string | null;
9
+ catalogType: "internal" | "other" | "public" | "contract" | "net" | "gross" | "promo";
10
+ isDefault: boolean;
11
+ active: boolean;
12
+ notes: string | null;
13
+ metadata: Record<string, unknown> | null;
14
+ createdAt: Date;
15
+ updatedAt: Date;
16
+ }[];
17
+ total: number;
18
+ limit: number;
19
+ offset: number;
20
+ }>;
21
+ export declare function getPriceCatalogById(db: PostgresJsDatabase, id: string): Promise<{
22
+ id: string;
23
+ code: string;
24
+ name: string;
25
+ currencyCode: string | null;
26
+ catalogType: "internal" | "other" | "public" | "contract" | "net" | "gross" | "promo";
27
+ isDefault: boolean;
28
+ active: boolean;
29
+ notes: string | null;
30
+ metadata: Record<string, unknown> | null;
31
+ createdAt: Date;
32
+ updatedAt: Date;
33
+ } | null>;
34
+ export declare function createPriceCatalog(db: PostgresJsDatabase, data: CreatePriceCatalogInput): Promise<{
35
+ metadata: Record<string, unknown> | null;
36
+ id: string;
37
+ name: string;
38
+ createdAt: Date;
39
+ code: string;
40
+ notes: string | null;
41
+ updatedAt: Date;
42
+ active: boolean;
43
+ isDefault: boolean;
44
+ currencyCode: string | null;
45
+ catalogType: "internal" | "other" | "public" | "contract" | "net" | "gross" | "promo";
46
+ } | null>;
47
+ export declare function updatePriceCatalog(db: PostgresJsDatabase, id: string, data: UpdatePriceCatalogInput): Promise<{
48
+ id: string;
49
+ code: string;
50
+ name: string;
51
+ currencyCode: string | null;
52
+ catalogType: "internal" | "other" | "public" | "contract" | "net" | "gross" | "promo";
53
+ isDefault: boolean;
54
+ active: boolean;
55
+ notes: string | null;
56
+ metadata: Record<string, unknown> | null;
57
+ createdAt: Date;
58
+ updatedAt: Date;
59
+ } | null>;
60
+ export declare function deletePriceCatalog(db: PostgresJsDatabase, id: string): Promise<{
61
+ id: string;
62
+ } | null>;
63
+ export declare function listPriceSchedules(db: PostgresJsDatabase, query: PriceScheduleListQuery): Promise<{
64
+ data: {
65
+ id: string;
66
+ priceCatalogId: string;
67
+ code: string | null;
68
+ name: string;
69
+ recurrenceRule: string;
70
+ timezone: string | null;
71
+ validFrom: string | null;
72
+ validTo: string | null;
73
+ weekdays: string[] | null;
74
+ priority: number;
75
+ active: boolean;
76
+ notes: string | null;
77
+ metadata: Record<string, unknown> | null;
78
+ createdAt: Date;
79
+ updatedAt: Date;
80
+ }[];
81
+ total: number;
82
+ limit: number;
83
+ offset: number;
84
+ }>;
85
+ export declare function getPriceScheduleById(db: PostgresJsDatabase, id: string): Promise<{
86
+ id: string;
87
+ priceCatalogId: string;
88
+ code: string | null;
89
+ name: string;
90
+ recurrenceRule: string;
91
+ timezone: string | null;
92
+ validFrom: string | null;
93
+ validTo: string | null;
94
+ weekdays: string[] | null;
95
+ priority: number;
96
+ active: boolean;
97
+ notes: string | null;
98
+ metadata: Record<string, unknown> | null;
99
+ createdAt: Date;
100
+ updatedAt: Date;
101
+ } | null>;
102
+ export declare function createPriceSchedule(db: PostgresJsDatabase, data: CreatePriceScheduleInput): Promise<{
103
+ metadata: Record<string, unknown> | null;
104
+ id: string;
105
+ name: string;
106
+ createdAt: Date;
107
+ code: string | null;
108
+ priority: number;
109
+ notes: string | null;
110
+ updatedAt: Date;
111
+ active: boolean;
112
+ timezone: string | null;
113
+ priceCatalogId: string;
114
+ recurrenceRule: string;
115
+ validFrom: string | null;
116
+ validTo: string | null;
117
+ weekdays: string[] | null;
118
+ } | null>;
119
+ export declare function updatePriceSchedule(db: PostgresJsDatabase, id: string, data: UpdatePriceScheduleInput): Promise<{
120
+ id: string;
121
+ priceCatalogId: string;
122
+ code: string | null;
123
+ name: string;
124
+ recurrenceRule: string;
125
+ timezone: string | null;
126
+ validFrom: string | null;
127
+ validTo: string | null;
128
+ weekdays: string[] | null;
129
+ priority: number;
130
+ active: boolean;
131
+ notes: string | null;
132
+ metadata: Record<string, unknown> | null;
133
+ createdAt: Date;
134
+ updatedAt: Date;
135
+ } | null>;
136
+ export declare function deletePriceSchedule(db: PostgresJsDatabase, id: string): Promise<{
137
+ id: string;
138
+ } | null>;
139
+ //# sourceMappingURL=service-catalogs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service-catalogs.d.ts","sourceRoot":"","sources":["../../src/pricing/service-catalogs.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AAGjE,OAAO,KAAK,EACV,uBAAuB,EACvB,wBAAwB,EACxB,qBAAqB,EACrB,sBAAsB,EACtB,uBAAuB,EACvB,wBAAwB,EACzB,MAAM,qBAAqB,CAAA;AAG5B,wBAAsB,iBAAiB,CAAC,EAAE,EAAE,kBAAkB,EAAE,KAAK,EAAE,qBAAqB;;;;;;;;;;;;;;;;;GAuB3F;AAED,wBAAsB,mBAAmB,CAAC,EAAE,EAAE,kBAAkB,EAAE,EAAE,EAAE,MAAM;;;;;;;;;;;;UAG3E;AAED,wBAAsB,kBAAkB,CAAC,EAAE,EAAE,kBAAkB,EAAE,IAAI,EAAE,uBAAuB;;;;;;;;;;;;UAG7F;AAED,wBAAsB,kBAAkB,CACtC,EAAE,EAAE,kBAAkB,EACtB,EAAE,EAAE,MAAM,EACV,IAAI,EAAE,uBAAuB;;;;;;;;;;;;UAQ9B;AAED,wBAAsB,kBAAkB,CAAC,EAAE,EAAE,kBAAkB,EAAE,EAAE,EAAE,MAAM;;UAM1E;AAED,wBAAsB,kBAAkB,CAAC,EAAE,EAAE,kBAAkB,EAAE,KAAK,EAAE,sBAAsB;;;;;;;;;;;;;;;;;;;;;GAsB7F;AAED,wBAAsB,oBAAoB,CAAC,EAAE,EAAE,kBAAkB,EAAE,EAAE,EAAE,MAAM;;;;;;;;;;;;;;;;UAG5E;AAED,wBAAsB,mBAAmB,CAAC,EAAE,EAAE,kBAAkB,EAAE,IAAI,EAAE,wBAAwB;;;;;;;;;;;;;;;;UAG/F;AAED,wBAAsB,mBAAmB,CACvC,EAAE,EAAE,kBAAkB,EACtB,EAAE,EAAE,MAAM,EACV,IAAI,EAAE,wBAAwB;;;;;;;;;;;;;;;;UAQ/B;AAED,wBAAsB,mBAAmB,CAAC,EAAE,EAAE,kBAAkB,EAAE,EAAE,EAAE,MAAM;;UAM3E"}
@@ -0,0 +1,89 @@
1
+ import { and, asc, desc, eq, ilike, or, sql } from "drizzle-orm";
2
+ import { priceCatalogs, priceSchedules } from "./schema.js";
3
+ import { paginate } from "./service-shared.js";
4
+ export async function listPriceCatalogs(db, query) {
5
+ const conditions = [];
6
+ if (query.currencyCode)
7
+ conditions.push(eq(priceCatalogs.currencyCode, query.currencyCode));
8
+ if (query.catalogType)
9
+ conditions.push(eq(priceCatalogs.catalogType, query.catalogType));
10
+ if (query.active !== undefined)
11
+ conditions.push(eq(priceCatalogs.active, query.active));
12
+ if (query.search) {
13
+ const term = `%${query.search}%`;
14
+ conditions.push(or(ilike(priceCatalogs.name, term), ilike(priceCatalogs.code, term)));
15
+ }
16
+ const where = conditions.length ? and(...conditions) : undefined;
17
+ return paginate(db
18
+ .select()
19
+ .from(priceCatalogs)
20
+ .where(where)
21
+ .limit(query.limit)
22
+ .offset(query.offset)
23
+ .orderBy(asc(priceCatalogs.name)), db.select({ count: sql `count(*)::int` }).from(priceCatalogs).where(where), query.limit, query.offset);
24
+ }
25
+ export async function getPriceCatalogById(db, id) {
26
+ const [row] = await db.select().from(priceCatalogs).where(eq(priceCatalogs.id, id)).limit(1);
27
+ return row ?? null;
28
+ }
29
+ export async function createPriceCatalog(db, data) {
30
+ const [row] = await db.insert(priceCatalogs).values(data).returning();
31
+ return row ?? null;
32
+ }
33
+ export async function updatePriceCatalog(db, id, data) {
34
+ const [row] = await db
35
+ .update(priceCatalogs)
36
+ .set({ ...data, updatedAt: new Date() })
37
+ .where(eq(priceCatalogs.id, id))
38
+ .returning();
39
+ return row ?? null;
40
+ }
41
+ export async function deletePriceCatalog(db, id) {
42
+ const [row] = await db
43
+ .delete(priceCatalogs)
44
+ .where(eq(priceCatalogs.id, id))
45
+ .returning({ id: priceCatalogs.id });
46
+ return row ?? null;
47
+ }
48
+ export async function listPriceSchedules(db, query) {
49
+ const conditions = [];
50
+ if (query.priceCatalogId)
51
+ conditions.push(eq(priceSchedules.priceCatalogId, query.priceCatalogId));
52
+ if (query.active !== undefined)
53
+ conditions.push(eq(priceSchedules.active, query.active));
54
+ if (query.search) {
55
+ const term = `%${query.search}%`;
56
+ conditions.push(or(ilike(priceSchedules.name, term), ilike(priceSchedules.code, term)));
57
+ }
58
+ const where = conditions.length ? and(...conditions) : undefined;
59
+ return paginate(db
60
+ .select()
61
+ .from(priceSchedules)
62
+ .where(where)
63
+ .limit(query.limit)
64
+ .offset(query.offset)
65
+ .orderBy(desc(priceSchedules.priority), asc(priceSchedules.name)), db.select({ count: sql `count(*)::int` }).from(priceSchedules).where(where), query.limit, query.offset);
66
+ }
67
+ export async function getPriceScheduleById(db, id) {
68
+ const [row] = await db.select().from(priceSchedules).where(eq(priceSchedules.id, id)).limit(1);
69
+ return row ?? null;
70
+ }
71
+ export async function createPriceSchedule(db, data) {
72
+ const [row] = await db.insert(priceSchedules).values(data).returning();
73
+ return row ?? null;
74
+ }
75
+ export async function updatePriceSchedule(db, id, data) {
76
+ const [row] = await db
77
+ .update(priceSchedules)
78
+ .set({ ...data, updatedAt: new Date() })
79
+ .where(eq(priceSchedules.id, id))
80
+ .returning();
81
+ return row ?? null;
82
+ }
83
+ export async function deletePriceSchedule(db, id) {
84
+ const [row] = await db
85
+ .delete(priceSchedules)
86
+ .where(eq(priceSchedules.id, id))
87
+ .returning({ id: priceSchedules.id });
88
+ return row ?? null;
89
+ }