@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,359 @@
1
+ /**
2
+ * Promotions service — CRUD over `promotional_offers` plus link-table
3
+ * materialization for product-shaped scopes.
4
+ *
5
+ * Per docs/architecture/promotions-architecture.md §13 (PR1 scope):
6
+ * - listOffers / getOfferById / createOffer / updateOffer / archiveOffer / deleteOffer
7
+ * - recomputeOfferLinks (rebuilds `promotional_offer_products` from current scope)
8
+ * - emit `promotion.changed` (when an `eventBus` is supplied via the runtime arg)
9
+ *
10
+ * The evaluator (PR2) and catalog-plane / booking-engine / scheduler wiring
11
+ * (PR3 + PR4 + PR3.boundary) are NOT in this PR.
12
+ *
13
+ * Cross-module reads:
14
+ * - `categories` scope expands via the Product-owned `product_category_products` table
15
+ * - `destinations` scope expands via the Product-owned `product_destinations` table
16
+ * through a resolver seam or raw SQL fallback. Promotions does not import
17
+ * Product schemas at runtime.
18
+ */
19
+ import { and, count, desc, eq, gte, ilike, isNotNull, isNull, lte, or, sql } from "drizzle-orm";
20
+ import { PROMOTION_CHANGED_EVENT, } from "./events.js";
21
+ import { promotionalOfferProducts, promotionalOfferRedemptions, promotionalOffers, } from "./schema.js";
22
+ /** Fields whose change does NOT affect projection or evaluation — safe to skip emit. */
23
+ const NON_PROJECTION_FIELDS = new Set(["description", "metadata"]);
24
+ function shouldEmitForUpdate(patch) {
25
+ const keys = Object.keys(patch);
26
+ return keys.some((key) => !NON_PROJECTION_FIELDS.has(key));
27
+ }
28
+ /**
29
+ * Expand an offer's scope to the product set it covers. Used by
30
+ * `recomputeOfferLinks` and by the event emitter to populate
31
+ * `affected.productIds`.
32
+ *
33
+ * Returns `null` for slice-shaped or checkout-shaped scopes (`global`,
34
+ * `markets`, `audiences`, `fare_codes`, `cabin_grades`)
35
+ * to signal that the link table should be empty AND that the event payload
36
+ * should fall back to `affected: { kind: "all" }` — these scopes can match
37
+ * an unbounded product set and we don't enumerate them at write time
38
+ * (per §9.1's resolution rules).
39
+ */
40
+ export async function resolveScopeProductIds(db, scope, resolver) {
41
+ switch (scope.kind) {
42
+ case "products":
43
+ return [...new Set(scope.productIds)];
44
+ case "categories": {
45
+ if (scope.categoryIds.length === 0)
46
+ return [];
47
+ return resolver ? resolver(db, scope) : loadProductIdsForCategoryScope(db, scope.categoryIds);
48
+ }
49
+ case "destinations": {
50
+ if (scope.destinationIds.length === 0)
51
+ return [];
52
+ return resolver
53
+ ? resolver(db, scope)
54
+ : loadProductIdsForDestinationScope(db, scope.destinationIds);
55
+ }
56
+ case "global":
57
+ case "markets":
58
+ case "audiences":
59
+ case "fare_codes":
60
+ case "cabin_grades":
61
+ return null;
62
+ }
63
+ }
64
+ function readProductIdRows(result) {
65
+ const rows = Array.isArray(result)
66
+ ? result
67
+ : Array.isArray(result?.rows)
68
+ ? result.rows
69
+ : [];
70
+ return rows
71
+ .map((row) => row.product_id)
72
+ .filter((productId) => typeof productId === "string");
73
+ }
74
+ async function loadProductIdsForCategoryScope(db, categoryIds) {
75
+ const dbAny = db;
76
+ const ids = sql.join(categoryIds.map((categoryId) => sql `${categoryId}`), sql `, `);
77
+ const result = await dbAny.execute(
78
+ // agent-quality: raw-sql reviewed -- owner: promotions; Product owns this link table, and ids are parameter-bound through Drizzle.
79
+ sql `SELECT DISTINCT product_id FROM product_category_products WHERE category_id IN (${ids})`);
80
+ return readProductIdRows(result);
81
+ }
82
+ async function loadProductIdsForDestinationScope(db, destinationIds) {
83
+ const dbAny = db;
84
+ const ids = sql.join(destinationIds.map((destinationId) => sql `${destinationId}`), sql `, `);
85
+ const result = await dbAny.execute(
86
+ // agent-quality: raw-sql reviewed -- owner: promotions; Product owns this link table, and ids are parameter-bound through Drizzle.
87
+ sql `SELECT DISTINCT product_id FROM product_destinations WHERE destination_id IN (${ids})`);
88
+ return readProductIdRows(result);
89
+ }
90
+ /**
91
+ * Rebuild the `promotional_offer_products` rows for an offer from its
92
+ * current scope. Idempotent: deletes any prior rows, inserts the
93
+ * freshly-resolved set. Slice-shaped scopes leave the table empty.
94
+ */
95
+ export async function recomputeOfferLinks(db, offerId, scope, runtime = {}) {
96
+ const productIds = await resolveScopeProductIds(db, scope, runtime.resolveScopeProductIds);
97
+ await db.delete(promotionalOfferProducts).where(eq(promotionalOfferProducts.offerId, offerId));
98
+ if (productIds && productIds.length > 0) {
99
+ await db
100
+ .insert(promotionalOfferProducts)
101
+ .values(productIds.map((productId) => ({ offerId, productId })));
102
+ }
103
+ return { productIds };
104
+ }
105
+ function toAffected(productIds) {
106
+ if (productIds === null)
107
+ return { kind: "all" };
108
+ return { kind: "products", productIds };
109
+ }
110
+ function unionAffectedProductIds(previousProductIds, nextProductIds) {
111
+ if (previousProductIds === null || nextProductIds === null)
112
+ return null;
113
+ return [...new Set([...previousProductIds, ...nextProductIds])];
114
+ }
115
+ async function emitChange(runtime, payload) {
116
+ const eventBus = runtime.eventBus;
117
+ if (!eventBus)
118
+ return;
119
+ await eventBus.emit(PROMOTION_CHANGED_EVENT, payload, {
120
+ category: "domain",
121
+ source: "service",
122
+ });
123
+ }
124
+ function normalizeCode(code) {
125
+ if (code == null)
126
+ return null;
127
+ return code.toLowerCase();
128
+ }
129
+ function toRowValues(input) {
130
+ return {
131
+ name: input.name,
132
+ slug: input.slug,
133
+ description: input.description ?? null,
134
+ discountType: input.discountType,
135
+ discountPercent: input.discountPercent != null ? String(input.discountPercent) : null,
136
+ discountAmountCents: input.discountAmountCents ?? null,
137
+ currency: input.currency ?? null,
138
+ scope: input.scope,
139
+ conditions: input.conditions ?? {},
140
+ validFrom: input.validFrom ?? null,
141
+ validUntil: input.validUntil ?? null,
142
+ code: normalizeCode(input.code),
143
+ stackable: input.stackable ?? false,
144
+ active: input.active ?? true,
145
+ metadata: input.metadata ?? null,
146
+ };
147
+ }
148
+ function toUpdateValues(patch) {
149
+ const out = {
150
+ updatedAt: new Date(),
151
+ };
152
+ if (patch.name !== undefined)
153
+ out.name = patch.name;
154
+ if (patch.slug !== undefined)
155
+ out.slug = patch.slug;
156
+ if (patch.description !== undefined)
157
+ out.description = patch.description ?? null;
158
+ if (patch.discountType !== undefined)
159
+ out.discountType = patch.discountType;
160
+ if (patch.discountPercent !== undefined) {
161
+ out.discountPercent = patch.discountPercent != null ? String(patch.discountPercent) : null;
162
+ }
163
+ if (patch.discountAmountCents !== undefined) {
164
+ out.discountAmountCents = patch.discountAmountCents ?? null;
165
+ }
166
+ if (patch.currency !== undefined)
167
+ out.currency = patch.currency ?? null;
168
+ if (patch.scope !== undefined)
169
+ out.scope = patch.scope;
170
+ if (patch.conditions !== undefined)
171
+ out.conditions = patch.conditions;
172
+ if (patch.validFrom !== undefined)
173
+ out.validFrom = patch.validFrom ?? null;
174
+ if (patch.validUntil !== undefined)
175
+ out.validUntil = patch.validUntil ?? null;
176
+ if (patch.code !== undefined)
177
+ out.code = normalizeCode(patch.code);
178
+ if (patch.stackable !== undefined)
179
+ out.stackable = patch.stackable;
180
+ if (patch.active !== undefined)
181
+ out.active = patch.active;
182
+ if (patch.metadata !== undefined)
183
+ out.metadata = patch.metadata ?? null;
184
+ return out;
185
+ }
186
+ async function listOffers(db, query) {
187
+ const where = [];
188
+ if (query.active !== undefined)
189
+ where.push(eq(promotionalOffers.active, query.active));
190
+ if (query.code !== undefined)
191
+ where.push(eq(promotionalOffers.code, query.code.toLowerCase()));
192
+ if (query.search !== undefined) {
193
+ const term = `%${query.search}%`;
194
+ where.push(or(ilike(promotionalOffers.name, term), ilike(promotionalOffers.slug, term), ilike(promotionalOffers.description, term), ilike(promotionalOffers.code, term)));
195
+ }
196
+ if (query.applicationMode === "auto")
197
+ where.push(isNull(promotionalOffers.code));
198
+ if (query.applicationMode === "code")
199
+ where.push(isNotNull(promotionalOffers.code));
200
+ if (query.scopeKind !== undefined) {
201
+ // agent-quality: raw-sql reviewed -- owner: promotions; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
202
+ where.push(sql `${promotionalOffers.scope}->>'kind' = ${query.scopeKind}`);
203
+ }
204
+ if (query.status !== undefined) {
205
+ const now = new Date();
206
+ if (query.status === "archived") {
207
+ where.push(eq(promotionalOffers.active, false));
208
+ }
209
+ else if (query.status === "scheduled") {
210
+ where.push(and(eq(promotionalOffers.active, true), gte(promotionalOffers.validFrom, now)));
211
+ }
212
+ else if (query.status === "expired") {
213
+ where.push(and(eq(promotionalOffers.active, true), lte(promotionalOffers.validUntil, now)));
214
+ }
215
+ else {
216
+ where.push(and(eq(promotionalOffers.active, true), or(isNull(promotionalOffers.validFrom), lte(promotionalOffers.validFrom, now)), or(isNull(promotionalOffers.validUntil), gte(promotionalOffers.validUntil, now))));
217
+ }
218
+ }
219
+ if (query.validFrom !== undefined) {
220
+ where.push(or(isNull(promotionalOffers.validUntil), gte(promotionalOffers.validUntil, startOfDay(query.validFrom))));
221
+ }
222
+ if (query.validUntil !== undefined) {
223
+ where.push(or(isNull(promotionalOffers.validFrom), lte(promotionalOffers.validFrom, endOfDay(query.validUntil))));
224
+ }
225
+ const filter = where.length > 0 ? and(...where) : undefined;
226
+ const limit = query.limit ?? 50;
227
+ const offset = query.offset ?? 0;
228
+ const [totalRow] = await db
229
+ .select({ total: count() })
230
+ .from(promotionalOffers)
231
+ .where(filter ?? sql `true`);
232
+ const total = totalRow?.total ?? 0;
233
+ const data = await db
234
+ .select()
235
+ .from(promotionalOffers)
236
+ .where(filter ?? sql `true`)
237
+ .orderBy(desc(promotionalOffers.createdAt))
238
+ .limit(limit)
239
+ .offset(offset);
240
+ return { data, total, limit, offset };
241
+ }
242
+ function startOfDay(isoDate) {
243
+ return new Date(`${isoDate}T00:00:00.000Z`);
244
+ }
245
+ function endOfDay(isoDate) {
246
+ return new Date(`${isoDate}T23:59:59.999Z`);
247
+ }
248
+ async function getOfferById(db, id) {
249
+ const [row] = await db
250
+ .select()
251
+ .from(promotionalOffers)
252
+ .where(eq(promotionalOffers.id, id))
253
+ .limit(1);
254
+ return row ?? null;
255
+ }
256
+ async function createOffer(db, input, runtime = {}) {
257
+ const [row] = await db.insert(promotionalOffers).values(toRowValues(input)).returning();
258
+ if (!row)
259
+ throw new Error("createOffer: insert returned no row");
260
+ const { productIds } = await recomputeOfferLinks(db, row.id, input.scope, runtime);
261
+ await emitChange(runtime, {
262
+ offerId: row.id,
263
+ source: runtime.source ?? "created",
264
+ affected: toAffected(productIds),
265
+ });
266
+ return row;
267
+ }
268
+ async function updateOffer(db, id, patch, runtime = {}) {
269
+ const updateValues = toUpdateValues(patch);
270
+ const previousScope = patch.scope !== undefined && shouldEmitForUpdate(patch)
271
+ ? (await getOfferById(db, id))?.scope
272
+ : null;
273
+ if (patch.scope !== undefined && previousScope === undefined)
274
+ return null;
275
+ const [row] = await db
276
+ .update(promotionalOffers)
277
+ .set(updateValues)
278
+ .where(eq(promotionalOffers.id, id))
279
+ .returning();
280
+ if (!row)
281
+ return null;
282
+ // Re-materialize links if the scope changed. The link table reflects
283
+ // the current scope at all times, so any scope edit (including a
284
+ // category-id list edit) requires a rebuild.
285
+ let productIds;
286
+ if (patch.scope !== undefined) {
287
+ productIds = (await recomputeOfferLinks(db, id, patch.scope, runtime)).productIds;
288
+ }
289
+ else {
290
+ productIds = await resolveScopeProductIds(db, row.scope, runtime.resolveScopeProductIds);
291
+ }
292
+ if (shouldEmitForUpdate(patch)) {
293
+ const affectedProductIds = patch.scope !== undefined && previousScope
294
+ ? unionAffectedProductIds(await resolveScopeProductIds(db, previousScope, runtime.resolveScopeProductIds), productIds)
295
+ : productIds;
296
+ await emitChange(runtime, {
297
+ offerId: row.id,
298
+ source: runtime.source ?? "updated",
299
+ affected: toAffected(affectedProductIds),
300
+ });
301
+ }
302
+ return row;
303
+ }
304
+ async function archiveOffer(db, id, runtime = {}) {
305
+ const [row] = await db
306
+ .update(promotionalOffers)
307
+ .set({ active: false, updatedAt: new Date() })
308
+ .where(eq(promotionalOffers.id, id))
309
+ .returning();
310
+ if (!row)
311
+ return null;
312
+ const productIds = await resolveScopeProductIds(db, row.scope, runtime.resolveScopeProductIds);
313
+ await emitChange(runtime, {
314
+ offerId: row.id,
315
+ source: runtime.source ?? "updated",
316
+ affected: toAffected(productIds),
317
+ });
318
+ return row;
319
+ }
320
+ async function deleteOffer(db, id, runtime = {}) {
321
+ // Check redemptions FIRST so the caller gets a clearer error than the raw
322
+ // FK-violation that the RESTRICT would surface from the delete attempt.
323
+ const [redemptionCountRow] = await db
324
+ .select({ total: count() })
325
+ .from(promotionalOfferRedemptions)
326
+ .where(eq(promotionalOfferRedemptions.offerId, id));
327
+ const redemptionCount = redemptionCountRow?.total ?? 0;
328
+ if (redemptionCount > 0) {
329
+ throw new Error(`cannot delete offer ${id}: ${redemptionCount} redemption(s) exist; archive (set active = false) instead`);
330
+ }
331
+ // Capture the resolved product set BEFORE delete so we can emit the
332
+ // affected list after CASCADE wipes `promotional_offer_products`.
333
+ const existing = await getOfferById(db, id);
334
+ if (!existing)
335
+ return null;
336
+ const productIds = await resolveScopeProductIds(db, existing.scope, runtime.resolveScopeProductIds);
337
+ const [deleted] = await db
338
+ .delete(promotionalOffers)
339
+ .where(eq(promotionalOffers.id, id))
340
+ .returning({ id: promotionalOffers.id });
341
+ if (!deleted)
342
+ return null;
343
+ await emitChange(runtime, {
344
+ offerId: deleted.id,
345
+ source: runtime.source ?? "deleted",
346
+ affected: toAffected(productIds),
347
+ });
348
+ return deleted;
349
+ }
350
+ export const promotionsService = {
351
+ listOffers,
352
+ getOfferById,
353
+ createOffer,
354
+ updateOffer,
355
+ archiveOffer,
356
+ deleteOffer,
357
+ recomputeOfferLinks,
358
+ resolveScopeProductIds,
359
+ };
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Validation schemas for the promotions module.
3
+ *
4
+ * Per docs/architecture/commerce-architecture.md §3.2 (scope), §4.1 (offer
5
+ * fields), §11 (currency rules), §12.1 (conditions schema).
6
+ *
7
+ * The scope discriminated union is the source of truth for what an offer
8
+ * applies to; the materialized `promotional_offer_products` link table (§4.2)
9
+ * is rebuilt from it on every create / update.
10
+ */
11
+ import { z } from "zod";
12
+ export declare const promotionalOfferScopeSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
13
+ kind: z.ZodLiteral<"global">;
14
+ }, z.core.$strip>, z.ZodObject<{
15
+ kind: z.ZodLiteral<"products">;
16
+ productIds: z.ZodArray<z.ZodString>;
17
+ }, z.core.$strip>, z.ZodObject<{
18
+ kind: z.ZodLiteral<"categories">;
19
+ categoryIds: z.ZodArray<z.ZodString>;
20
+ }, z.core.$strip>, z.ZodObject<{
21
+ kind: z.ZodLiteral<"destinations">;
22
+ destinationIds: z.ZodArray<z.ZodString>;
23
+ }, z.core.$strip>, z.ZodObject<{
24
+ kind: z.ZodLiteral<"markets">;
25
+ marketIds: z.ZodArray<z.ZodString>;
26
+ }, z.core.$strip>, z.ZodObject<{
27
+ kind: z.ZodLiteral<"audiences">;
28
+ audiences: z.ZodArray<z.ZodEnum<{
29
+ staff: "staff";
30
+ customer: "customer";
31
+ partner: "partner";
32
+ supplier: "supplier";
33
+ }>>;
34
+ }, z.core.$strip>, z.ZodObject<{
35
+ kind: z.ZodLiteral<"fare_codes">;
36
+ fareCodes: z.ZodArray<z.ZodString>;
37
+ }, z.core.$strip>, z.ZodObject<{
38
+ kind: z.ZodLiteral<"cabin_grades">;
39
+ cabinGradeCodes: z.ZodArray<z.ZodString>;
40
+ }, z.core.$strip>], "kind">;
41
+ export type PromotionalOfferScope = z.infer<typeof promotionalOfferScopeSchema>;
42
+ export type PromotionalOfferScopeKind = PromotionalOfferScope["kind"];
43
+ export declare const promotionalOfferConditionsSchema: z.ZodObject<{
44
+ minPax: z.ZodOptional<z.ZodNumber>;
45
+ pastGuestOnly: z.ZodOptional<z.ZodBoolean>;
46
+ soloTravelerOnly: z.ZodOptional<z.ZodBoolean>;
47
+ childTravelerOnly: z.ZodOptional<z.ZodBoolean>;
48
+ familyOnly: z.ZodOptional<z.ZodBoolean>;
49
+ }, z.core.$strip>;
50
+ export type PromotionalOfferConditions = z.infer<typeof promotionalOfferConditionsSchema>;
51
+ export declare const insertPromotionalOfferSchema: z.ZodObject<{
52
+ name: z.ZodString;
53
+ slug: z.ZodString;
54
+ description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
55
+ discountType: z.ZodEnum<{
56
+ percentage: "percentage";
57
+ fixed_amount: "fixed_amount";
58
+ }>;
59
+ discountPercent: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
60
+ discountAmountCents: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
61
+ currency: z.ZodOptional<z.ZodNullable<z.ZodString>>;
62
+ scope: z.ZodDiscriminatedUnion<[z.ZodObject<{
63
+ kind: z.ZodLiteral<"global">;
64
+ }, z.core.$strip>, z.ZodObject<{
65
+ kind: z.ZodLiteral<"products">;
66
+ productIds: z.ZodArray<z.ZodString>;
67
+ }, z.core.$strip>, z.ZodObject<{
68
+ kind: z.ZodLiteral<"categories">;
69
+ categoryIds: z.ZodArray<z.ZodString>;
70
+ }, z.core.$strip>, z.ZodObject<{
71
+ kind: z.ZodLiteral<"destinations">;
72
+ destinationIds: z.ZodArray<z.ZodString>;
73
+ }, z.core.$strip>, z.ZodObject<{
74
+ kind: z.ZodLiteral<"markets">;
75
+ marketIds: z.ZodArray<z.ZodString>;
76
+ }, z.core.$strip>, z.ZodObject<{
77
+ kind: z.ZodLiteral<"audiences">;
78
+ audiences: z.ZodArray<z.ZodEnum<{
79
+ staff: "staff";
80
+ customer: "customer";
81
+ partner: "partner";
82
+ supplier: "supplier";
83
+ }>>;
84
+ }, z.core.$strip>, z.ZodObject<{
85
+ kind: z.ZodLiteral<"fare_codes">;
86
+ fareCodes: z.ZodArray<z.ZodString>;
87
+ }, z.core.$strip>, z.ZodObject<{
88
+ kind: z.ZodLiteral<"cabin_grades">;
89
+ cabinGradeCodes: z.ZodArray<z.ZodString>;
90
+ }, z.core.$strip>], "kind">;
91
+ conditions: z.ZodDefault<z.ZodOptional<z.ZodObject<{
92
+ minPax: z.ZodOptional<z.ZodNumber>;
93
+ pastGuestOnly: z.ZodOptional<z.ZodBoolean>;
94
+ soloTravelerOnly: z.ZodOptional<z.ZodBoolean>;
95
+ childTravelerOnly: z.ZodOptional<z.ZodBoolean>;
96
+ familyOnly: z.ZodOptional<z.ZodBoolean>;
97
+ }, z.core.$strip>>>;
98
+ validFrom: z.ZodOptional<z.ZodNullable<z.ZodCoercedDate<unknown>>>;
99
+ validUntil: z.ZodOptional<z.ZodNullable<z.ZodCoercedDate<unknown>>>;
100
+ code: z.ZodOptional<z.ZodNullable<z.ZodString>>;
101
+ stackable: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
102
+ active: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
103
+ metadata: z.ZodOptional<z.ZodNullable<z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
104
+ }, z.core.$strip>;
105
+ export declare const updatePromotionalOfferSchema: z.ZodObject<{
106
+ discountType: z.ZodOptional<z.ZodEnum<{
107
+ percentage: "percentage";
108
+ fixed_amount: "fixed_amount";
109
+ }>>;
110
+ scope: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
111
+ kind: z.ZodLiteral<"global">;
112
+ }, z.core.$strip>, z.ZodObject<{
113
+ kind: z.ZodLiteral<"products">;
114
+ productIds: z.ZodArray<z.ZodString>;
115
+ }, z.core.$strip>, z.ZodObject<{
116
+ kind: z.ZodLiteral<"categories">;
117
+ categoryIds: z.ZodArray<z.ZodString>;
118
+ }, z.core.$strip>, z.ZodObject<{
119
+ kind: z.ZodLiteral<"destinations">;
120
+ destinationIds: z.ZodArray<z.ZodString>;
121
+ }, z.core.$strip>, z.ZodObject<{
122
+ kind: z.ZodLiteral<"markets">;
123
+ marketIds: z.ZodArray<z.ZodString>;
124
+ }, z.core.$strip>, z.ZodObject<{
125
+ kind: z.ZodLiteral<"audiences">;
126
+ audiences: z.ZodArray<z.ZodEnum<{
127
+ staff: "staff";
128
+ customer: "customer";
129
+ partner: "partner";
130
+ supplier: "supplier";
131
+ }>>;
132
+ }, z.core.$strip>, z.ZodObject<{
133
+ kind: z.ZodLiteral<"fare_codes">;
134
+ fareCodes: z.ZodArray<z.ZodString>;
135
+ }, z.core.$strip>, z.ZodObject<{
136
+ kind: z.ZodLiteral<"cabin_grades">;
137
+ cabinGradeCodes: z.ZodArray<z.ZodString>;
138
+ }, z.core.$strip>], "kind">>;
139
+ name: z.ZodOptional<z.ZodString>;
140
+ slug: z.ZodOptional<z.ZodString>;
141
+ description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
142
+ discountPercent: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
143
+ discountAmountCents: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
144
+ currency: z.ZodOptional<z.ZodNullable<z.ZodString>>;
145
+ conditions: z.ZodDefault<z.ZodOptional<z.ZodObject<{
146
+ minPax: z.ZodOptional<z.ZodNumber>;
147
+ pastGuestOnly: z.ZodOptional<z.ZodBoolean>;
148
+ soloTravelerOnly: z.ZodOptional<z.ZodBoolean>;
149
+ childTravelerOnly: z.ZodOptional<z.ZodBoolean>;
150
+ familyOnly: z.ZodOptional<z.ZodBoolean>;
151
+ }, z.core.$strip>>>;
152
+ validFrom: z.ZodOptional<z.ZodNullable<z.ZodCoercedDate<unknown>>>;
153
+ validUntil: z.ZodOptional<z.ZodNullable<z.ZodCoercedDate<unknown>>>;
154
+ code: z.ZodOptional<z.ZodNullable<z.ZodString>>;
155
+ stackable: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
156
+ active: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
157
+ metadata: z.ZodOptional<z.ZodNullable<z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
158
+ }, z.core.$strip>;
159
+ export declare const promotionalOfferListQuerySchema: z.ZodObject<{
160
+ active: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodLiteral<"true">, z.ZodLiteral<"false">]>, z.ZodTransform<boolean, "true" | "false">>>;
161
+ code: z.ZodOptional<z.ZodString>;
162
+ search: z.ZodOptional<z.ZodString>;
163
+ applicationMode: z.ZodOptional<z.ZodEnum<{
164
+ code: "code";
165
+ auto: "auto";
166
+ }>>;
167
+ status: z.ZodOptional<z.ZodEnum<{
168
+ expired: "expired";
169
+ active: "active";
170
+ archived: "archived";
171
+ scheduled: "scheduled";
172
+ }>>;
173
+ scopeKind: z.ZodOptional<z.ZodEnum<{
174
+ products: "products";
175
+ markets: "markets";
176
+ destinations: "destinations";
177
+ categories: "categories";
178
+ global: "global";
179
+ audiences: "audiences";
180
+ fare_codes: "fare_codes";
181
+ cabin_grades: "cabin_grades";
182
+ }>>;
183
+ validFrom: z.ZodOptional<z.ZodString>;
184
+ validUntil: z.ZodOptional<z.ZodString>;
185
+ limit: z.ZodDefault<z.ZodOptional<z.ZodCoercedNumber<unknown>>>;
186
+ offset: z.ZodDefault<z.ZodOptional<z.ZodCoercedNumber<unknown>>>;
187
+ }, z.core.$strip>;
188
+ export type InsertPromotionalOfferInput = z.input<typeof insertPromotionalOfferSchema>;
189
+ export type InsertPromotionalOffer = z.infer<typeof insertPromotionalOfferSchema>;
190
+ export type UpdatePromotionalOfferInput = z.input<typeof updatePromotionalOfferSchema>;
191
+ export type UpdatePromotionalOffer = z.infer<typeof updatePromotionalOfferSchema>;
192
+ export type PromotionalOfferListQuery = z.infer<typeof promotionalOfferListQuerySchema>;
193
+ export type PromotionalOfferApplicationMode = NonNullable<PromotionalOfferListQuery["applicationMode"]>;
194
+ export type PromotionalOfferListStatus = NonNullable<PromotionalOfferListQuery["status"]>;
195
+ //# sourceMappingURL=validation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../../src/promotions/validation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAUvB,eAAO,MAAM,2BAA2B;;;;;;;;;;;;;;;;;;;;;;;;;;;;2BA8BtC,CAAA;AAEF,MAAM,MAAM,qBAAqB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,2BAA2B,CAAC,CAAA;AAC/E,MAAM,MAAM,yBAAyB,GAAG,qBAAqB,CAAC,MAAM,CAAC,CAAA;AAOrE,eAAO,MAAM,gCAAgC;;;;;;iBAa3C,CAAA;AAEF,MAAM,MAAM,0BAA0B,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gCAAgC,CAAC,CAAA;AAqFzF,eAAO,MAAM,4BAA4B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAAmD,CAAA;AAE5F,eAAO,MAAM,4BAA4B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAQxC,CAAA;AAED,eAAO,MAAM,+BAA+B;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAyB1C,CAAA;AAEF,MAAM,MAAM,2BAA2B,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,4BAA4B,CAAC,CAAA;AACtF,MAAM,MAAM,sBAAsB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,4BAA4B,CAAC,CAAA;AACjF,MAAM,MAAM,2BAA2B,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,4BAA4B,CAAC,CAAA;AACtF,MAAM,MAAM,sBAAsB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,4BAA4B,CAAC,CAAA;AACjF,MAAM,MAAM,yBAAyB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,+BAA+B,CAAC,CAAA;AACvF,MAAM,MAAM,+BAA+B,GAAG,WAAW,CACvD,yBAAyB,CAAC,iBAAiB,CAAC,CAC7C,CAAA;AACD,MAAM,MAAM,0BAA0B,GAAG,WAAW,CAAC,yBAAyB,CAAC,QAAQ,CAAC,CAAC,CAAA"}