@voyantjs/promotions 0.41.3 → 0.44.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.
- package/README.md +14 -0
- package/dist/service-storefront.d.ts +13 -1
- package/dist/service-storefront.d.ts.map +1 -1
- package/dist/service-storefront.js +183 -7
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -4,6 +4,19 @@ Promotional offers for Voyant — auto-applied catalog discounts (badges, strike
|
|
|
4
4
|
|
|
5
5
|
PR1 ships the schema + admin CRUD only. Catalog plane wiring lands in PR3, booking-engine integration in PR4. See `docs/architecture/promotions-architecture.md` for the full design.
|
|
6
6
|
|
|
7
|
+
Storefront runtimes can wire `createPromotionsStorefrontResolvers()` into
|
|
8
|
+
`@voyantjs/storefront` to expose:
|
|
9
|
+
|
|
10
|
+
- `GET /v1/public/products/:productId/offers`
|
|
11
|
+
- `GET /v1/public/offers/:slug`
|
|
12
|
+
- `POST /v1/public/offers/:slug/apply`
|
|
13
|
+
- `POST /v1/public/offers/redeem`
|
|
14
|
+
|
|
15
|
+
Manual and code-gated offers use the same evaluator as quote-time pricing:
|
|
16
|
+
best non-stackable discount wins, and explicitly stackable offers compose when
|
|
17
|
+
the selected path is stackable. Public mutation responses include conflict
|
|
18
|
+
metadata without leaking internal rule details.
|
|
19
|
+
|
|
7
20
|
## Install
|
|
8
21
|
|
|
9
22
|
```bash
|
|
@@ -32,6 +45,7 @@ const app = createApp({
|
|
|
32
45
|
| `./routes` | Hono admin routes mounted at `/v1/admin/promotions/*` |
|
|
33
46
|
| `./events` | `PROMOTION_CHANGED_EVENT` + payload types |
|
|
34
47
|
| `./service` | `promotionsService` (CRUD + scope materialization) |
|
|
48
|
+
| `./service-storefront` | Storefront offer discovery, apply, and redeem resolver factory |
|
|
35
49
|
|
|
36
50
|
## License
|
|
37
51
|
|
|
@@ -26,13 +26,25 @@
|
|
|
26
26
|
* - Only auto-applied offers (no code) appear in `listApplicableOffers`;
|
|
27
27
|
* code-gated offers are still queryable via `getOfferBySlug`.
|
|
28
28
|
*/
|
|
29
|
-
import type { StorefrontOfferResolvers, StorefrontPromotionalOffer } from "@voyantjs/storefront";
|
|
29
|
+
import type { StorefrontOfferMutationResult, StorefrontOfferResolvers, StorefrontPromotionalOffer } from "@voyantjs/storefront";
|
|
30
30
|
import { type PromotionalOffer } from "./schema.js";
|
|
31
|
+
import { type CodeStatus } from "./service-evaluator.js";
|
|
31
32
|
import type { PromotionalOfferScope } from "./validation.js";
|
|
32
33
|
export declare function createPromotionsStorefrontResolvers(): StorefrontOfferResolvers;
|
|
33
34
|
declare function matchesProduct(scope: PromotionalOfferScope, inLinkTable: boolean): boolean;
|
|
34
35
|
declare function toStorefrontDto(offer: PromotionalOffer, applicableProductIds: string[]): StorefrontPromotionalOffer;
|
|
36
|
+
declare function currentValidityStatus(offer: PromotionalOffer, now: Date): "offer_expired" | "offer_not_yet_valid" | null;
|
|
37
|
+
declare function codeStatusToReason(status: CodeStatus): NonNullable<StorefrontOfferMutationResult["reason"]> | null;
|
|
38
|
+
declare function buildConflict(input: {
|
|
39
|
+
autoAppliedOfferIds: string[];
|
|
40
|
+
manualOfferId: string | null;
|
|
41
|
+
selectedOfferIds: string[];
|
|
42
|
+
manualSelected: boolean;
|
|
43
|
+
}): StorefrontOfferMutationResult["conflict"];
|
|
35
44
|
export declare const __test__: {
|
|
45
|
+
buildConflict: typeof buildConflict;
|
|
46
|
+
codeStatusToReason: typeof codeStatusToReason;
|
|
47
|
+
currentValidityStatus: typeof currentValidityStatus;
|
|
36
48
|
matchesProduct: typeof matchesProduct;
|
|
37
49
|
toStorefrontDto: typeof toStorefrontDto;
|
|
38
50
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"service-storefront.d.ts","sourceRoot":"","sources":["../src/service-storefront.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAGH,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"service-storefront.d.ts","sourceRoot":"","sources":["../src/service-storefront.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAGH,OAAO,KAAK,EAGV,6BAA6B,EAE7B,wBAAwB,EACxB,0BAA0B,EAE3B,MAAM,sBAAsB,CAAA;AAG7B,OAAO,EAAE,KAAK,gBAAgB,EAA+C,MAAM,aAAa,CAAA;AAChG,OAAO,EACL,KAAK,UAAU,EAGhB,MAAM,wBAAwB,CAAA;AAC/B,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAA;AAE5D,wBAAgB,mCAAmC,IAAI,wBAAwB,CAoG9E;AAMD,iBAAS,cAAc,CAAC,KAAK,EAAE,qBAAqB,EAAE,WAAW,EAAE,OAAO,GAAG,OAAO,CAenF;AAuBD,iBAAS,eAAe,CACtB,KAAK,EAAE,gBAAgB,EACvB,oBAAoB,EAAE,MAAM,EAAE,GAC7B,0BAA0B,CA+B5B;AAkCD,iBAAS,qBAAqB,CAC5B,KAAK,EAAE,gBAAgB,EACvB,GAAG,EAAE,IAAI,GACR,eAAe,GAAG,qBAAqB,GAAG,IAAI,CAIhD;AAiGD,iBAAS,kBAAkB,CACzB,MAAM,EAAE,UAAU,GACjB,WAAW,CAAC,6BAA6B,CAAC,QAAQ,CAAC,CAAC,GAAG,IAAI,CAI7D;AA0BD,iBAAS,aAAa,CAAC,KAAK,EAAE;IAC5B,mBAAmB,EAAE,MAAM,EAAE,CAAA;IAC7B,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;IAC5B,gBAAgB,EAAE,MAAM,EAAE,CAAA;IAC1B,cAAc,EAAE,OAAO,CAAA;CACxB,GAAG,6BAA6B,CAAC,UAAU,CAAC,CAc5C;AAED,eAAO,MAAM,QAAQ;;;;;;CAMpB,CAAA"}
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
*/
|
|
29
29
|
import { and, eq, gte, inArray, isNull, lte, or } from "drizzle-orm";
|
|
30
30
|
import { promotionalOfferProducts, promotionalOffers } from "./schema.js";
|
|
31
|
+
import { createDrizzleOfferDataSource, evaluateOffersForProduct, } from "./service-evaluator.js";
|
|
31
32
|
export function createPromotionsStorefrontResolvers() {
|
|
32
33
|
return {
|
|
33
34
|
async listApplicableOffers(input) {
|
|
@@ -63,17 +64,44 @@ export function createPromotionsStorefrontResolvers() {
|
|
|
63
64
|
const db = resolveDb(input);
|
|
64
65
|
if (!db)
|
|
65
66
|
return null;
|
|
66
|
-
const
|
|
67
|
-
.select()
|
|
68
|
-
.from(promotionalOffers)
|
|
69
|
-
.where(and(eq(promotionalOffers.slug, input.slug), eq(promotionalOffers.active, true)))
|
|
70
|
-
.limit(1);
|
|
71
|
-
const offer = rows[0];
|
|
67
|
+
const offer = await findActiveOfferBySlug(db, input.slug);
|
|
72
68
|
if (!offer)
|
|
73
69
|
return null;
|
|
74
70
|
const links = await loadApplicableProductIds(db, [offer.id]);
|
|
75
71
|
return toStorefrontDto(offer, links.get(offer.id) ?? []);
|
|
76
72
|
},
|
|
73
|
+
async applyOffer(input) {
|
|
74
|
+
const db = resolveDb(input);
|
|
75
|
+
if (!db)
|
|
76
|
+
return notConfiguredResult(input.body);
|
|
77
|
+
const offer = await findActiveOfferBySlug(db, input.slug);
|
|
78
|
+
if (!offer) {
|
|
79
|
+
return emptyResult(input.body, "invalid", "offer_not_found", null);
|
|
80
|
+
}
|
|
81
|
+
if (offer.code != null) {
|
|
82
|
+
return emptyResult(input.body, "invalid", "code_required", await dtoForOffer(db, offer));
|
|
83
|
+
}
|
|
84
|
+
const validity = currentValidityStatus(offer, new Date());
|
|
85
|
+
if (validity !== null) {
|
|
86
|
+
return emptyResult(input.body, "invalid", validity, await dtoForOffer(db, offer));
|
|
87
|
+
}
|
|
88
|
+
return evaluateStorefrontMutation(db, input.body, {
|
|
89
|
+
manualOffer: offer,
|
|
90
|
+
code: undefined,
|
|
91
|
+
offer: await dtoForOffer(db, offer),
|
|
92
|
+
});
|
|
93
|
+
},
|
|
94
|
+
async redeemOffer(input) {
|
|
95
|
+
const db = resolveDb(input);
|
|
96
|
+
if (!db)
|
|
97
|
+
return notConfiguredResult(input.body);
|
|
98
|
+
const offer = await findActiveOfferByCode(db, input.body.code);
|
|
99
|
+
return evaluateStorefrontMutation(db, input.body, {
|
|
100
|
+
manualOffer: offer,
|
|
101
|
+
code: input.body.code,
|
|
102
|
+
offer: offer ? await dtoForOffer(db, offer) : null,
|
|
103
|
+
});
|
|
104
|
+
},
|
|
77
105
|
};
|
|
78
106
|
}
|
|
79
107
|
function resolveDb(input) {
|
|
@@ -143,4 +171,152 @@ function toStorefrontDto(offer, applicableProductIds) {
|
|
|
143
171
|
updatedAt: offer.updatedAt.toISOString(),
|
|
144
172
|
};
|
|
145
173
|
}
|
|
146
|
-
|
|
174
|
+
async function findActiveOfferBySlug(db, slug) {
|
|
175
|
+
const rows = await db
|
|
176
|
+
.select()
|
|
177
|
+
.from(promotionalOffers)
|
|
178
|
+
.where(and(eq(promotionalOffers.slug, slug), eq(promotionalOffers.active, true)))
|
|
179
|
+
.limit(1);
|
|
180
|
+
return rows[0] ?? null;
|
|
181
|
+
}
|
|
182
|
+
async function findActiveOfferByCode(db, code) {
|
|
183
|
+
const rows = await db
|
|
184
|
+
.select()
|
|
185
|
+
.from(promotionalOffers)
|
|
186
|
+
.where(and(eq(promotionalOffers.active, true), eq(promotionalOffers.code, code.toLowerCase())))
|
|
187
|
+
.limit(1);
|
|
188
|
+
return rows[0] ?? null;
|
|
189
|
+
}
|
|
190
|
+
async function dtoForOffer(db, offer) {
|
|
191
|
+
const links = await loadApplicableProductIds(db, [offer.id]);
|
|
192
|
+
return toStorefrontDto(offer, links.get(offer.id) ?? []);
|
|
193
|
+
}
|
|
194
|
+
function currentValidityStatus(offer, now) {
|
|
195
|
+
if (offer.validUntil != null && offer.validUntil < now)
|
|
196
|
+
return "offer_expired";
|
|
197
|
+
if (offer.validFrom != null && offer.validFrom > now)
|
|
198
|
+
return "offer_not_yet_valid";
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
function emptyResult(input, status, reason, offer) {
|
|
202
|
+
return {
|
|
203
|
+
status,
|
|
204
|
+
reason,
|
|
205
|
+
offer,
|
|
206
|
+
target: targetFromInput(input),
|
|
207
|
+
pricing: {
|
|
208
|
+
basePriceCents: input.basePriceCents,
|
|
209
|
+
currency: input.currency,
|
|
210
|
+
discountAppliedCents: 0,
|
|
211
|
+
discountedPriceCents: input.basePriceCents,
|
|
212
|
+
},
|
|
213
|
+
appliedOffers: [],
|
|
214
|
+
conflict: null,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
function notConfiguredResult(input) {
|
|
218
|
+
return emptyResult(input, "invalid", "offer_not_found", null);
|
|
219
|
+
}
|
|
220
|
+
async function evaluateStorefrontMutation(db, input, options) {
|
|
221
|
+
const evaluation = await evaluateOffersForProduct(createDrizzleOfferDataSource(db), {
|
|
222
|
+
productId: input.productId,
|
|
223
|
+
slice: {
|
|
224
|
+
audience: input.audience,
|
|
225
|
+
market: input.market,
|
|
226
|
+
},
|
|
227
|
+
pax: input.pax,
|
|
228
|
+
code: options.code,
|
|
229
|
+
basePriceCents: input.basePriceCents,
|
|
230
|
+
baseCurrency: input.currency,
|
|
231
|
+
});
|
|
232
|
+
const codeReason = codeStatusToReason(evaluation.codeStatus);
|
|
233
|
+
if (codeReason != null) {
|
|
234
|
+
return emptyResult(input, "invalid", codeReason, options.offer);
|
|
235
|
+
}
|
|
236
|
+
const appliedOffers = evaluation.applied.map(toStorefrontAppliedOffer);
|
|
237
|
+
const manualOfferId = options.manualOffer?.id ?? null;
|
|
238
|
+
const selectedOfferIds = appliedOffers.map((offer) => offer.offerId);
|
|
239
|
+
const manualSelected = manualOfferId ? selectedOfferIds.includes(manualOfferId) : false;
|
|
240
|
+
const autoAppliedOfferIds = appliedOffers
|
|
241
|
+
.filter((offer) => offer.appliedCode == null && offer.offerId !== manualOfferId)
|
|
242
|
+
.map((offer) => offer.offerId);
|
|
243
|
+
const conflict = manualOfferId
|
|
244
|
+
? buildConflict({
|
|
245
|
+
autoAppliedOfferIds,
|
|
246
|
+
manualOfferId,
|
|
247
|
+
selectedOfferIds,
|
|
248
|
+
manualSelected,
|
|
249
|
+
})
|
|
250
|
+
: null;
|
|
251
|
+
if (evaluation.total.discountAppliedCents <= 0) {
|
|
252
|
+
return {
|
|
253
|
+
...emptyResult(input, "not_applicable", "no_discount", options.offer),
|
|
254
|
+
conflict,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
const status = manualOfferId && !manualSelected ? "conflict" : "applied";
|
|
258
|
+
return {
|
|
259
|
+
status,
|
|
260
|
+
reason: status === "conflict" ? "conflict" : null,
|
|
261
|
+
offer: options.offer,
|
|
262
|
+
target: targetFromInput(input),
|
|
263
|
+
pricing: {
|
|
264
|
+
basePriceCents: input.basePriceCents,
|
|
265
|
+
currency: input.currency,
|
|
266
|
+
discountAppliedCents: evaluation.total.discountAppliedCents,
|
|
267
|
+
discountedPriceCents: evaluation.total.discountedPriceCents,
|
|
268
|
+
},
|
|
269
|
+
appliedOffers,
|
|
270
|
+
conflict,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
function codeStatusToReason(status) {
|
|
274
|
+
if (status == null || status.kind === "code_valid")
|
|
275
|
+
return null;
|
|
276
|
+
if (status.kind === "code_not_applicable")
|
|
277
|
+
return status.reason;
|
|
278
|
+
return status.kind;
|
|
279
|
+
}
|
|
280
|
+
function toStorefrontAppliedOffer(offer) {
|
|
281
|
+
return {
|
|
282
|
+
offerId: offer.offerId,
|
|
283
|
+
offerName: offer.offerName,
|
|
284
|
+
discountAppliedCents: offer.discountAppliedCents,
|
|
285
|
+
discountedPriceCents: offer.discountedPriceCents,
|
|
286
|
+
currency: offer.currency,
|
|
287
|
+
discountKind: offer.discountKind,
|
|
288
|
+
discountPercent: offer.discountPercent,
|
|
289
|
+
discountAmountCents: offer.discountAmountCents,
|
|
290
|
+
appliedCode: offer.appliedCode,
|
|
291
|
+
stackable: offer.stackable,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
function targetFromInput(input) {
|
|
295
|
+
return {
|
|
296
|
+
bookingId: input.bookingId ?? null,
|
|
297
|
+
sessionId: input.sessionId ?? null,
|
|
298
|
+
productId: input.productId,
|
|
299
|
+
departureId: input.departureId ?? null,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
function buildConflict(input) {
|
|
303
|
+
if (input.autoAppliedOfferIds.length === 0 && input.manualSelected)
|
|
304
|
+
return null;
|
|
305
|
+
const policy = input.manualSelected ? "stackable_compose" : "best_discount_wins";
|
|
306
|
+
return {
|
|
307
|
+
policy,
|
|
308
|
+
autoAppliedOfferIds: input.autoAppliedOfferIds,
|
|
309
|
+
manualOfferId: input.manualOfferId,
|
|
310
|
+
selectedOfferIds: input.selectedOfferIds,
|
|
311
|
+
message: policy === "stackable_compose"
|
|
312
|
+
? "The manually applied offer composes with selected auto-applied offers because every selected offer is stackable."
|
|
313
|
+
: "The best discount wins when a manually applied or code offer competes with non-stackable auto-applied offers.",
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
export const __test__ = {
|
|
317
|
+
buildConflict,
|
|
318
|
+
codeStatusToReason,
|
|
319
|
+
currentValidityStatus,
|
|
320
|
+
matchesProduct,
|
|
321
|
+
toStorefrontDto,
|
|
322
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voyantjs/promotions",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.44.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -64,13 +64,13 @@
|
|
|
64
64
|
"drizzle-orm": "^0.45.2",
|
|
65
65
|
"hono": "^4.12.10",
|
|
66
66
|
"zod": "^4.3.6",
|
|
67
|
-
"@voyantjs/catalog": "0.
|
|
68
|
-
"@voyantjs/core": "0.
|
|
69
|
-
"@voyantjs/db": "0.
|
|
70
|
-
"@voyantjs/hono": "0.
|
|
71
|
-
"@voyantjs/products": "0.
|
|
72
|
-
"@voyantjs/storefront": "0.
|
|
73
|
-
"@voyantjs/workflows": "0.
|
|
67
|
+
"@voyantjs/catalog": "0.44.0",
|
|
68
|
+
"@voyantjs/core": "0.44.0",
|
|
69
|
+
"@voyantjs/db": "0.44.0",
|
|
70
|
+
"@voyantjs/hono": "0.44.0",
|
|
71
|
+
"@voyantjs/products": "0.44.0",
|
|
72
|
+
"@voyantjs/storefront": "0.44.0",
|
|
73
|
+
"@voyantjs/workflows": "0.44.0"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
76
|
"typescript": "^6.0.2",
|