@voyantjs/promotions 0.28.3

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 (44) hide show
  1. package/README.md +38 -0
  2. package/dist/events.d.ts +38 -0
  3. package/dist/events.d.ts.map +1 -0
  4. package/dist/events.js +25 -0
  5. package/dist/index.d.ts +11 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +16 -0
  8. package/dist/routes-shared.d.ts +14 -0
  9. package/dist/routes-shared.d.ts.map +1 -0
  10. package/dist/routes-shared.js +3 -0
  11. package/dist/routes.d.ts +345 -0
  12. package/dist/routes.d.ts.map +1 -0
  13. package/dist/routes.js +55 -0
  14. package/dist/schema.d.ts +655 -0
  15. package/dist/schema.d.ts.map +1 -0
  16. package/dist/schema.js +126 -0
  17. package/dist/service-booking-confirmed.d.ts +77 -0
  18. package/dist/service-booking-confirmed.d.ts.map +1 -0
  19. package/dist/service-booking-confirmed.js +134 -0
  20. package/dist/service-boundary-scheduler.d.ts +85 -0
  21. package/dist/service-boundary-scheduler.d.ts.map +1 -0
  22. package/dist/service-boundary-scheduler.js +141 -0
  23. package/dist/service-catalog-evaluator.d.ts +22 -0
  24. package/dist/service-catalog-evaluator.d.ts.map +1 -0
  25. package/dist/service-catalog-evaluator.js +33 -0
  26. package/dist/service-catalog-plane-promotions.d.ts +72 -0
  27. package/dist/service-catalog-plane-promotions.d.ts.map +1 -0
  28. package/dist/service-catalog-plane-promotions.js +119 -0
  29. package/dist/service-evaluator.d.ts +111 -0
  30. package/dist/service-evaluator.d.ts.map +1 -0
  31. package/dist/service-evaluator.js +264 -0
  32. package/dist/service-storefront.d.ts +40 -0
  33. package/dist/service-storefront.d.ts.map +1 -0
  34. package/dist/service-storefront.js +146 -0
  35. package/dist/service.d.ts +120 -0
  36. package/dist/service.d.ts.map +1 -0
  37. package/dist/service.js +296 -0
  38. package/dist/validation.d.ts +140 -0
  39. package/dist/validation.d.ts.map +1 -0
  40. package/dist/validation.js +134 -0
  41. package/dist/workflow-bulk-reindex.d.ts +55 -0
  42. package/dist/workflow-bulk-reindex.d.ts.map +1 -0
  43. package/dist/workflow-bulk-reindex.js +58 -0
  44. package/package.json +120 -0
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Projection extension that decorates the product search document with
3
+ * promotional-offer annotations declared by `productPromotionsCatalogPolicy`
4
+ * (in `@voyantjs/products/catalog-policy-promotions`).
5
+ *
6
+ * Lives in `@voyantjs/promotions` because:
7
+ * - The data lives here.
8
+ * - `promotions` already depends on `@voyantjs/products` for the
9
+ * `product_category_products` / `product_destinations` link tables;
10
+ * importing the `ProductProjectionExtension` contract type from
11
+ * products is the same direction.
12
+ *
13
+ * Wire via `createProductDocumentBuilder({ extensions: [...promotionsExt] })`
14
+ * after composing `productPromotionsCatalogPolicy` into the registry.
15
+ *
16
+ * Annotation-only contract (per §3.7 of the architecture doc): this
17
+ * extension does NOT touch `priceFromAmountCents` (that's emitted by the
18
+ * pricing extension and the two extensions can't read each other's
19
+ * output). It only emits `bestOffer*` + `originalPriceFromAmountCents` +
20
+ * `conditionalOffer*`. Storefront consumers compute the effective price
21
+ * client-side.
22
+ *
23
+ * `originalPriceFromAmountCents` resolution: by default we read
24
+ * `products.sell_amount_cents` directly (works for simple products with
25
+ * row-level pricing). Operators with option-driven pricing should pass
26
+ * `loadOriginalPrice` to wire the same MIN-across-options resolver the
27
+ * pricing extension uses; otherwise the strikethrough may not match the
28
+ * customer-visible list price for option-driven products.
29
+ *
30
+ * Per docs/architecture/promotions-architecture.md §6.
31
+ */
32
+ import type { AnyDrizzleDb } from "@voyantjs/db";
33
+ import type { IndexerSlice, ProductProjectionExtension } from "@voyantjs/products/service-catalog-plane";
34
+ import { type AppliedOffer, type ConditionalOffer, type EvaluationResult } from "./service-evaluator.js";
35
+ export interface PromotionsProjectionOptions {
36
+ /**
37
+ * Resolve the un-discounted "from price" + currency for a product.
38
+ * The result drives `originalPriceFromAmountCents` + the evaluator's
39
+ * `basePriceCents` / `baseCurrency` inputs.
40
+ *
41
+ * Defaults to a direct read of `products.sell_amount_cents` +
42
+ * `products.sell_currency` — works for simple row-priced products.
43
+ * Operators with option-driven pricing should wire this to the same
44
+ * MIN-across-options resolver the pricing extension uses.
45
+ *
46
+ * Returns `null` for amountCents when the product has no configured
47
+ * base price (the extension then short-circuits to an empty projection
48
+ * since there's no base for the evaluator to discount).
49
+ */
50
+ loadOriginalPrice?: (db: AnyDrizzleDb, productId: string) => Promise<{
51
+ amountCents: number | null;
52
+ currency: string | null;
53
+ }>;
54
+ /** Override `now()` for testing. Defaults to wall-clock time at projection. */
55
+ now?: () => Date;
56
+ }
57
+ /**
58
+ * Map an `IndexerSlice.audience` (which can include `staff-admin`) onto the
59
+ * evaluator's narrower `Visibility` enum. Both `staff` and `staff-admin`
60
+ * map to `staff` for offer-evaluation purposes — both are operator-internal
61
+ * surfaces that should see the same promotional inventory.
62
+ */
63
+ declare function sliceAudience(slice: IndexerSlice): "staff" | "customer" | "partner" | "supplier";
64
+ export declare function createProductPromotionsProjectionExtension(options?: PromotionsProjectionOptions): ProductProjectionExtension;
65
+ declare function toProjectionMap(best: AppliedOffer | null, conditional: ConditionalOffer | null, originalPrice: number | null): ReadonlyMap<string, unknown>;
66
+ export declare const __test__: {
67
+ toProjectionMap: typeof toProjectionMap;
68
+ EMPTY_PROJECTION: ReadonlyMap<string, unknown>;
69
+ sliceAudience: typeof sliceAudience;
70
+ };
71
+ export type { EvaluationResult };
72
+ //# sourceMappingURL=service-catalog-plane-promotions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service-catalog-plane-promotions.d.ts","sourceRoot":"","sources":["../src/service-catalog-plane-promotions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAChD,OAAO,KAAK,EACV,YAAY,EACZ,0BAA0B,EAC3B,MAAM,0CAA0C,CAAA;AAEjD,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;;;;;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,CAiC5B;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;AA8BD,eAAO,MAAM,QAAQ;;;;CAAuD,CAAA;AAE5E,YAAY,EAAE,gBAAgB,EAAE,CAAA"}
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Projection extension that decorates the product search document with
3
+ * promotional-offer annotations declared by `productPromotionsCatalogPolicy`
4
+ * (in `@voyantjs/products/catalog-policy-promotions`).
5
+ *
6
+ * Lives in `@voyantjs/promotions` because:
7
+ * - The data lives here.
8
+ * - `promotions` already depends on `@voyantjs/products` for the
9
+ * `product_category_products` / `product_destinations` link tables;
10
+ * importing the `ProductProjectionExtension` contract type from
11
+ * products is the same direction.
12
+ *
13
+ * Wire via `createProductDocumentBuilder({ extensions: [...promotionsExt] })`
14
+ * after composing `productPromotionsCatalogPolicy` into the registry.
15
+ *
16
+ * Annotation-only contract (per §3.7 of the architecture doc): this
17
+ * extension does NOT touch `priceFromAmountCents` (that's emitted by the
18
+ * pricing extension and the two extensions can't read each other's
19
+ * output). It only emits `bestOffer*` + `originalPriceFromAmountCents` +
20
+ * `conditionalOffer*`. Storefront consumers compute the effective price
21
+ * client-side.
22
+ *
23
+ * `originalPriceFromAmountCents` resolution: by default we read
24
+ * `products.sell_amount_cents` directly (works for simple products with
25
+ * row-level pricing). Operators with option-driven pricing should pass
26
+ * `loadOriginalPrice` to wire the same MIN-across-options resolver the
27
+ * pricing extension uses; otherwise the strikethrough may not match the
28
+ * customer-visible list price for option-driven products.
29
+ *
30
+ * Per docs/architecture/promotions-architecture.md §6.
31
+ */
32
+ import { createDrizzleOfferDataSource, evaluateOffersForProduct, } from "./service-evaluator.js";
33
+ /**
34
+ * Map an `IndexerSlice.audience` (which can include `staff-admin`) onto the
35
+ * evaluator's narrower `Visibility` enum. Both `staff` and `staff-admin`
36
+ * map to `staff` for offer-evaluation purposes — both are operator-internal
37
+ * surfaces that should see the same promotional inventory.
38
+ */
39
+ function sliceAudience(slice) {
40
+ if (slice.audience === "staff-admin")
41
+ return "staff";
42
+ return slice.audience;
43
+ }
44
+ const EMPTY_PROJECTION = toProjectionMap(null, null, null);
45
+ export function createProductPromotionsProjectionExtension(options = {}) {
46
+ const loadOriginalPrice = options.loadOriginalPrice ?? defaultLoadOriginalPrice;
47
+ const nowFn = options.now ?? (() => new Date());
48
+ return {
49
+ name: "promotions:offers",
50
+ async project(db, productId, slice) {
51
+ const { amountCents, currency } = await loadOriginalPrice(db, productId);
52
+ if (amountCents == null || currency == null) {
53
+ // No base price configured → no offer math to do. Returning the
54
+ // empty projection ensures consumers see explicit nulls instead
55
+ // of stale prior values from the doc.
56
+ return EMPTY_PROJECTION;
57
+ }
58
+ const source = createDrizzleOfferDataSource(db);
59
+ const evaluation = await evaluateOffersForProduct(source, {
60
+ productId,
61
+ slice: { audience: sliceAudience(slice), market: slice.market },
62
+ date: nowFn(),
63
+ basePriceCents: amountCents,
64
+ baseCurrency: currency,
65
+ // pax + code intentionally omitted: catalog plane never knows
66
+ // these. minPax-conditioned offers land in `result.conditional`.
67
+ });
68
+ const conditional = evaluation.conditional[0] ?? null;
69
+ // Surface `originalPriceFromAmountCents` ONLY when an offer applies —
70
+ // §3.7 keeps the doc lean by leaving it null otherwise.
71
+ const original = evaluation.best != null ? amountCents : null;
72
+ return toProjectionMap(evaluation.best, conditional, original);
73
+ },
74
+ };
75
+ }
76
+ function toProjectionMap(best, conditional, originalPrice) {
77
+ return new Map([
78
+ ["hasOffer", best != null],
79
+ ["bestOfferId", best?.offerId ?? null],
80
+ ["bestOfferName", best?.offerName ?? null],
81
+ ["bestOfferDiscountKind", best?.discountKind ?? null],
82
+ ["bestOfferDiscountPercent", best?.discountPercent ?? null],
83
+ ["bestOfferDiscountAmountCents", best?.discountAmountCents ?? null],
84
+ ["originalPriceFromAmountCents", originalPrice],
85
+ ["hasConditionalOffer", conditional != null],
86
+ ["conditionalOfferId", conditional?.offerId ?? null],
87
+ ["conditionalOfferName", conditional?.offerName ?? null],
88
+ ["conditionalOfferDiscountKind", conditional?.discountKind ?? null],
89
+ ["conditionalOfferDiscountPercent", conditional?.discountPercent ?? null],
90
+ ["conditionalOfferDiscountAmountCents", conditional?.discountAmountCents ?? null],
91
+ [
92
+ "conditionalOfferMinPax",
93
+ conditional != null && conditional.unmet.kind === "min_pax"
94
+ ? conditional.unmet.required
95
+ : null,
96
+ ],
97
+ ]);
98
+ }
99
+ /**
100
+ * Default loader — single-column read against `products` so we don't pull
101
+ * the products schema into this file (would deepen the coupling). The
102
+ * column shape is stable enough that string-keyed access is safe; a
103
+ * schema rename would break far more than this projection.
104
+ */
105
+ async function defaultLoadOriginalPrice(db, productId) {
106
+ // biome-ignore lint/suspicious/noExplicitAny: drizzle's typed sql is overkill for a single-row read
107
+ const dbAny = db;
108
+ const { sql } = await import("drizzle-orm");
109
+ const result = await dbAny.execute(sql `SELECT sell_amount_cents, sell_currency FROM products WHERE id = ${productId} LIMIT 1`);
110
+ // postgres-js returns array-like; node-postgres returns `{ rows }`. Handle both.
111
+ const rows = Array.isArray(result) ? result : (result?.rows ?? []);
112
+ const first = rows[0];
113
+ return {
114
+ amountCents: first?.sell_amount_cents ?? null,
115
+ currency: first?.sell_currency ?? null,
116
+ };
117
+ }
118
+ // Internal exports for unit tests — kept off the public surface.
119
+ export const __test__ = { toProjectionMap, EMPTY_PROJECTION, sliceAudience };
@@ -0,0 +1,111 @@
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 "@voyantjs/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
+ /** Total travelers. Absent at catalog-index time; supplied at checkout. */
28
+ pax?: number;
29
+ /** Defaults to `now()` when undefined. */
30
+ date?: Date;
31
+ /** Customer-typed promotion code; case-insensitive match. */
32
+ code?: string;
33
+ basePriceCents: number;
34
+ baseCurrency: string;
35
+ }
36
+ export interface AppliedOffer {
37
+ offerId: string;
38
+ offerName: string;
39
+ /** The actual cents off attributed to this offer. */
40
+ discountAppliedCents: number;
41
+ /** `basePriceCents - discountAppliedCents` (per-row, the price the offer alone would yield). */
42
+ discountedPriceCents: number;
43
+ /** Matches the surrounding `ctx.baseCurrency` — carried per-row so the redemption recorder can insert without context. */
44
+ currency: string;
45
+ discountKind: "percentage" | "fixed_amount";
46
+ discountPercent: number | null;
47
+ discountAmountCents: number | null;
48
+ /** The literal code the customer entered (case preserved); null for auto-applied. */
49
+ appliedCode: string | null;
50
+ stackable: boolean;
51
+ }
52
+ /**
53
+ * An offer that *would* apply if a missing input were supplied — typically
54
+ * a `minPax` condition the catalog-plane caller can't satisfy because pax
55
+ * isn't known at index time. Surfaced for storefront UI hints like
56
+ * "From 4 pax: extra 5% off".
57
+ */
58
+ export interface ConditionalOffer {
59
+ offerId: string;
60
+ offerName: string;
61
+ discountKind: "percentage" | "fixed_amount";
62
+ discountPercent: number | null;
63
+ discountAmountCents: number | null;
64
+ unmet: {
65
+ kind: "min_pax";
66
+ required: number;
67
+ };
68
+ }
69
+ /** Outcome of code validation when `ctx.code` is supplied. `null` when ctx.code was not set. */
70
+ export type CodeStatus = null | {
71
+ kind: "code_valid";
72
+ } | {
73
+ kind: "code_not_found";
74
+ } | {
75
+ kind: "code_expired";
76
+ } | {
77
+ kind: "code_not_yet_valid";
78
+ } | {
79
+ kind: "code_not_applicable";
80
+ reason: "scope" | "min_pax" | "currency";
81
+ };
82
+ export interface EvaluationResult {
83
+ /** All applied offers (1+ when stacking; 0 when no offer applies). May include a code-gated offer alongside auto offers. */
84
+ applied: AppliedOffer[];
85
+ /** The single best offer (largest discount among the applied set), or null if none. Always references one row in `applied`. */
86
+ best: AppliedOffer | null;
87
+ /** Conditionally applicable — a missing input would make them apply. Only populated by the catalog-plane caller (no `ctx.pax`). Empty for checkout. */
88
+ conditional: ConditionalOffer[];
89
+ total: {
90
+ discountAppliedCents: number;
91
+ discountedPriceCents: number;
92
+ };
93
+ /** Set when `ctx.code` was supplied. Drives the checkout caller's `invalidReason` mapping (§7.2). */
94
+ codeStatus: CodeStatus;
95
+ }
96
+ /**
97
+ * Read-only data access the evaluator needs. Decoupled from drizzle so
98
+ * unit tests can supply in-memory fixtures and so the catalog projection
99
+ * can cache `fetchActiveAutoCandidates` once per slice.
100
+ */
101
+ export interface OfferDataSource {
102
+ /** All active offers whose validity window includes `date` AND `code IS NULL`. */
103
+ fetchActiveAutoCandidates(date: Date): Promise<PromotionalOffer[]>;
104
+ /** Active offer matching `lower(code) = lower(input)`, or null. */
105
+ findActiveOfferByCode(code: string): Promise<PromotionalOffer | null>;
106
+ /** Subset of `offerIds` whose `promotional_offer_products` table has a row for `productId`. */
107
+ productMatchesAnyScope(productId: string, offerIds: string[]): Promise<Set<string>>;
108
+ }
109
+ export declare function createDrizzleOfferDataSource(db: AnyDrizzleDb): OfferDataSource;
110
+ export declare function evaluateOffersForProduct(source: OfferDataSource, ctx: OfferEvaluationContext): Promise<EvaluationResult>;
111
+ //# sourceMappingURL=service-evaluator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service-evaluator.d.ts","sourceRoot":"","sources":["../src/service-evaluator.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAGhD,OAAO,EAAE,KAAK,gBAAgB,EAA+C,MAAM,aAAa,CAAA;AAKhG,MAAM,WAAW,sBAAsB;IACrC,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,EAAE;QACL,QAAQ,EAAE,OAAO,GAAG,UAAU,GAAG,SAAS,GAAG,UAAU,CAAA;QACvD,MAAM,EAAE,MAAM,CAAA;KACf,CAAA;IACD,2EAA2E;IAC3E,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,0CAA0C;IAC1C,IAAI,CAAC,EAAE,IAAI,CAAA;IACX,6DAA6D;IAC7D,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,cAAc,EAAE,MAAM,CAAA;IACtB,YAAY,EAAE,MAAM,CAAA;CACrB;AAED,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,MAAM,CAAA;IACf,SAAS,EAAE,MAAM,CAAA;IACjB,qDAAqD;IACrD,oBAAoB,EAAE,MAAM,CAAA;IAC5B,gGAAgG;IAChG,oBAAoB,EAAE,MAAM,CAAA;IAC5B,0HAA0H;IAC1H,QAAQ,EAAE,MAAM,CAAA;IAChB,YAAY,EAAE,YAAY,GAAG,cAAc,CAAA;IAC3C,eAAe,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,mBAAmB,EAAE,MAAM,GAAG,IAAI,CAAA;IAClC,qFAAqF;IACrF,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,SAAS,EAAE,OAAO,CAAA;CACnB;AAED;;;;;GAKG;AACH,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,MAAM,CAAA;IACf,SAAS,EAAE,MAAM,CAAA;IACjB,YAAY,EAAE,YAAY,GAAG,cAAc,CAAA;IAC3C,eAAe,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,mBAAmB,EAAE,MAAM,GAAG,IAAI,CAAA;IAClC,KAAK,EAAE;QAAE,IAAI,EAAE,SAAS,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAA;CAC7C;AAED,gGAAgG;AAChG,MAAM,MAAM,UAAU,GAClB,IAAI,GACJ;IAAE,IAAI,EAAE,YAAY,CAAA;CAAE,GACtB;IAAE,IAAI,EAAE,gBAAgB,CAAA;CAAE,GAC1B;IAAE,IAAI,EAAE,cAAc,CAAA;CAAE,GACxB;IAAE,IAAI,EAAE,oBAAoB,CAAA;CAAE,GAC9B;IAAE,IAAI,EAAE,qBAAqB,CAAC;IAAC,MAAM,EAAE,OAAO,GAAG,SAAS,GAAG,UAAU,CAAA;CAAE,CAAA;AAE7E,MAAM,WAAW,gBAAgB;IAC/B,4HAA4H;IAC5H,OAAO,EAAE,YAAY,EAAE,CAAA;IACvB,+HAA+H;IAC/H,IAAI,EAAE,YAAY,GAAG,IAAI,CAAA;IACzB,uJAAuJ;IACvJ,WAAW,EAAE,gBAAgB,EAAE,CAAA;IAC/B,KAAK,EAAE;QACL,oBAAoB,EAAE,MAAM,CAAA;QAC5B,oBAAoB,EAAE,MAAM,CAAA;KAC7B,CAAA;IACD,qGAAqG;IACrG,UAAU,EAAE,UAAU,CAAA;CACvB;AAED;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC9B,kFAAkF;IAClF,yBAAyB,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAAA;IAElE,mEAAmE;IACnE,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAAA;IAErE,+FAA+F;IAC/F,sBAAsB,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAA;CACpF;AAID,wBAAgB,4BAA4B,CAAC,EAAE,EAAE,YAAY,GAAG,eAAe,CA+C9E;AAgKD,wBAAsB,wBAAwB,CAC5C,MAAM,EAAE,eAAe,EACvB,GAAG,EAAE,sBAAsB,GAC1B,OAAO,CAAC,gBAAgB,CAAC,CAmG3B"}
@@ -0,0 +1,264 @@
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, 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
26
+ .select()
27
+ .from(promotionalOffers)
28
+ .where(and(eq(promotionalOffers.active, true), isNull(promotionalOffers.code), or(isNull(promotionalOffers.validFrom), lte(promotionalOffers.validFrom, date)), or(isNull(promotionalOffers.validUntil), sql `${promotionalOffers.validUntil} >= ${date}`)));
29
+ },
30
+ async findActiveOfferByCode(code) {
31
+ const rows = await db
32
+ .select()
33
+ .from(promotionalOffers)
34
+ .where(and(eq(promotionalOffers.active, true), sql `lower(${promotionalOffers.code}) = ${code.toLowerCase()}`))
35
+ .limit(1);
36
+ return rows[0] ?? null;
37
+ },
38
+ async productMatchesAnyScope(productId, offerIds) {
39
+ if (offerIds.length === 0)
40
+ return new Set();
41
+ const rows = await db
42
+ .select({ offerId: promotionalOfferProducts.offerId })
43
+ .from(promotionalOfferProducts)
44
+ .where(and(eq(promotionalOfferProducts.productId, productId), inArray(promotionalOfferProducts.offerId, offerIds)));
45
+ return new Set(rows.map((r) => r.offerId));
46
+ },
47
+ };
48
+ }
49
+ // ---------- Internal helpers ----------
50
+ function discountKind(offer) {
51
+ return offer.discountType;
52
+ }
53
+ function discountFields(offer) {
54
+ return {
55
+ discountKind: discountKind(offer),
56
+ discountPercent: offer.discountPercent != null ? Number(offer.discountPercent) : null,
57
+ discountAmountCents: offer.discountAmountCents,
58
+ };
59
+ }
60
+ /**
61
+ * Cents off a given base for a single offer. Caps fixed_amount at the
62
+ * available base so a discount can never exceed the price.
63
+ */
64
+ function computeDiscount(offer, basePriceCents) {
65
+ if (basePriceCents <= 0)
66
+ return 0;
67
+ if (offer.discountType === "percentage") {
68
+ if (offer.discountPercent == null)
69
+ return 0;
70
+ const pct = Number(offer.discountPercent);
71
+ return Math.round((basePriceCents * pct) / 100);
72
+ }
73
+ if (offer.discountAmountCents == null)
74
+ return 0;
75
+ return Math.min(offer.discountAmountCents, basePriceCents);
76
+ }
77
+ function matchesScope(scope, ctx, offerMatchesProduct) {
78
+ switch (scope.kind) {
79
+ case "global":
80
+ return true;
81
+ case "products":
82
+ case "categories":
83
+ case "destinations":
84
+ return offerMatchesProduct;
85
+ case "markets":
86
+ return scope.marketIds.includes(ctx.slice.market);
87
+ case "audiences":
88
+ return scope.audiences.includes(ctx.slice.audience);
89
+ }
90
+ }
91
+ function evaluateConditions(conditions, ctx) {
92
+ if (conditions.minPax != null) {
93
+ if (ctx.pax === undefined) {
94
+ return { kind: "conditional", unmet: { kind: "min_pax", required: conditions.minPax } };
95
+ }
96
+ if (ctx.pax < conditions.minPax) {
97
+ return { kind: "excluded", reason: "min_pax" };
98
+ }
99
+ }
100
+ return { kind: "ok" };
101
+ }
102
+ function currencyMatches(offer, ctx) {
103
+ if (offer.discountType !== "fixed_amount")
104
+ return true;
105
+ return offer.currency === ctx.baseCurrency;
106
+ }
107
+ /**
108
+ * Stacking pick (§5.2.6 + §3.3):
109
+ * - Pick the single best non-stackable offer (largest cents off the base).
110
+ * - Separately compose all `stackable` offers sequentially (deterministic
111
+ * order: by offerId ascending) — each stackable offer applies to the
112
+ * RUNNING base after prior stackables, which produces the multiplicative
113
+ * formula for percentage stackables and well-defined behavior for
114
+ * fixed_amount or mixed-type stackables.
115
+ * - Take whichever path yields the larger total discount. Ties → prefer
116
+ * the single non-stackable (simpler customer-facing receipt).
117
+ */
118
+ function pickStacking(applied, basePriceCents) {
119
+ const stackable = [];
120
+ const nonStackable = [];
121
+ for (const offer of applied) {
122
+ if (offer.stackable)
123
+ stackable.push(offer);
124
+ else
125
+ nonStackable.push(offer);
126
+ }
127
+ // Best single non-stackable
128
+ let bestNonStackable = null;
129
+ let bestNonStackableDiscount = 0;
130
+ for (const offer of nonStackable) {
131
+ const d = computeDiscount(offer, basePriceCents);
132
+ if (d > bestNonStackableDiscount) {
133
+ bestNonStackable = offer;
134
+ bestNonStackableDiscount = d;
135
+ }
136
+ }
137
+ // Composed stackables (sequential, sorted by offerId for determinism)
138
+ const sortedStackable = [...stackable].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
139
+ let stackableBase = basePriceCents;
140
+ const stackableRows = [];
141
+ for (const offer of sortedStackable) {
142
+ const d = computeDiscount(offer, stackableBase);
143
+ if (d <= 0)
144
+ continue;
145
+ stackableRows.push({ offer, appliedCents: d });
146
+ stackableBase -= d;
147
+ }
148
+ const stackableTotal = basePriceCents - stackableBase;
149
+ if (bestNonStackable && bestNonStackableDiscount >= stackableTotal) {
150
+ return {
151
+ rows: [{ offer: bestNonStackable, appliedCents: bestNonStackableDiscount }],
152
+ runningBase: basePriceCents - bestNonStackableDiscount,
153
+ };
154
+ }
155
+ return { rows: stackableRows, runningBase: stackableBase };
156
+ }
157
+ function toAppliedOffer(row, ctx, appliedCode) {
158
+ const fields = discountFields(row.offer);
159
+ return {
160
+ offerId: row.offer.id,
161
+ offerName: row.offer.name,
162
+ discountAppliedCents: row.appliedCents,
163
+ discountedPriceCents: ctx.basePriceCents - row.appliedCents,
164
+ currency: ctx.baseCurrency,
165
+ discountKind: fields.discountKind,
166
+ discountPercent: fields.discountPercent,
167
+ discountAmountCents: fields.discountAmountCents,
168
+ appliedCode: row.offer.code != null ? appliedCode : null,
169
+ stackable: row.offer.stackable,
170
+ };
171
+ }
172
+ // ---------- Public entry point ----------
173
+ export async function evaluateOffersForProduct(source, ctx) {
174
+ const date = ctx.date ?? new Date();
175
+ // Step 1: code lookup (when supplied) — classify validity AHEAD of the
176
+ // scope/conditions/currency filters so we can produce a precise
177
+ // `code_expired` / `code_not_yet_valid` reason instead of conflating
178
+ // them with `code_not_found`.
179
+ let codeOffer = null;
180
+ let preFilterCodeStatus = null;
181
+ if (ctx.code !== undefined) {
182
+ const found = await source.findActiveOfferByCode(ctx.code);
183
+ if (found == null) {
184
+ preFilterCodeStatus = { kind: "code_not_found" };
185
+ }
186
+ else if (found.validUntil != null && found.validUntil < date) {
187
+ preFilterCodeStatus = { kind: "code_expired" };
188
+ }
189
+ else if (found.validFrom != null && found.validFrom > date) {
190
+ preFilterCodeStatus = { kind: "code_not_yet_valid" };
191
+ }
192
+ else {
193
+ codeOffer = found;
194
+ // Tentatively valid; finalize after scope/conditions/currency filters.
195
+ }
196
+ }
197
+ // Step 2: auto-offer candidate fetch
198
+ const autoCandidates = await source.fetchActiveAutoCandidates(date);
199
+ const allCandidates = codeOffer ? [...autoCandidates, codeOffer] : autoCandidates;
200
+ // Pre-fetch product link membership in one query (§5.2.3 — uniform hot path).
201
+ const offerIds = allCandidates.map((o) => o.id);
202
+ const productMatchSet = await source.productMatchesAnyScope(ctx.productId, offerIds);
203
+ // Steps 3 + 4 + 5: scope / conditions / currency filter, partition.
204
+ const applied = [];
205
+ const conditional = [];
206
+ let codeOfferRejection = null;
207
+ for (const offer of allCandidates) {
208
+ if (!matchesScope(offer.scope, ctx, productMatchSet.has(offer.id))) {
209
+ if (offer === codeOffer)
210
+ codeOfferRejection = { kind: "scope" };
211
+ continue;
212
+ }
213
+ const cond = evaluateConditions(offer.conditions, ctx);
214
+ if (cond.kind === "conditional") {
215
+ conditional.push({
216
+ offerId: offer.id,
217
+ offerName: offer.name,
218
+ ...discountFields(offer),
219
+ unmet: cond.unmet,
220
+ });
221
+ continue;
222
+ }
223
+ if (cond.kind === "excluded") {
224
+ if (offer === codeOffer)
225
+ codeOfferRejection = { kind: "min_pax" };
226
+ continue;
227
+ }
228
+ if (!currencyMatches(offer, ctx)) {
229
+ if (offer === codeOffer)
230
+ codeOfferRejection = { kind: "currency" };
231
+ continue;
232
+ }
233
+ applied.push(offer);
234
+ }
235
+ // Finalize codeStatus after the filter pass.
236
+ let codeStatus = preFilterCodeStatus;
237
+ if (ctx.code !== undefined && codeStatus === null) {
238
+ if (codeOfferRejection != null) {
239
+ codeStatus = { kind: "code_not_applicable", reason: codeOfferRejection.kind };
240
+ }
241
+ else {
242
+ codeStatus = { kind: "code_valid" };
243
+ }
244
+ }
245
+ // Step 6 + 7: stacking pick + assemble result.
246
+ const { rows, runningBase } = pickStacking(applied, ctx.basePriceCents);
247
+ const appliedRows = rows.map((r) => toAppliedOffer(r, ctx, r.offer === codeOffer ? (ctx.code ?? null) : null));
248
+ let best = null;
249
+ for (const row of appliedRows) {
250
+ if (best == null || row.discountAppliedCents > best.discountAppliedCents) {
251
+ best = row;
252
+ }
253
+ }
254
+ return {
255
+ applied: appliedRows,
256
+ best,
257
+ conditional,
258
+ total: {
259
+ discountAppliedCents: ctx.basePriceCents - runningBase,
260
+ discountedPriceCents: runningBase,
261
+ },
262
+ codeStatus,
263
+ };
264
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Storefront resolvers — populate the previously-empty
3
+ * `/v1/public/products/:productId/offers` and `/v1/public/offers/:slug`
4
+ * endpoints in `@voyantjs/storefront` with real data.
5
+ *
6
+ * Wire via:
7
+ *
8
+ * const storefrontModule = createStorefrontHonoModule({
9
+ * offers: createPromotionsStorefrontResolvers(),
10
+ * })
11
+ *
12
+ * Per docs/architecture/promotions-architecture.md §8.
13
+ *
14
+ * V1 limitations:
15
+ * - Storefront calls don't carry an audience / market in the request
16
+ * context, so the resolver only filters by `products` + `global`
17
+ * scopes (the catalog plane handles audience-scoped projection).
18
+ * `markets` and `audiences` scoped offers don't appear in this
19
+ * listing endpoint.
20
+ * - Locale-aware offer names: the schema doesn't store translations
21
+ * yet (single-locale offer names per §12.1); `locale` is accepted
22
+ * but ignored. A `promotional_offer_translations` table mirrors
23
+ * `destinations_translations` if/when needed.
24
+ * - `applicableDepartureIds`: always empty per §12.7 — departure-
25
+ * scoped offers aren't modelled in v1.
26
+ * - Only auto-applied offers (no code) appear in `listApplicableOffers`;
27
+ * code-gated offers are still queryable via `getOfferBySlug`.
28
+ */
29
+ import type { StorefrontOfferResolvers, StorefrontPromotionalOffer } from "@voyantjs/storefront";
30
+ import { type PromotionalOffer } from "./schema.js";
31
+ import type { PromotionalOfferScope } from "./validation.js";
32
+ export declare function createPromotionsStorefrontResolvers(): StorefrontOfferResolvers;
33
+ declare function matchesProduct(scope: PromotionalOfferScope, inLinkTable: boolean): boolean;
34
+ declare function toStorefrontDto(offer: PromotionalOffer, applicableProductIds: string[]): StorefrontPromotionalOffer;
35
+ export declare const __test__: {
36
+ matchesProduct: typeof matchesProduct;
37
+ toStorefrontDto: typeof toStorefrontDto;
38
+ };
39
+ export {};
40
+ //# sourceMappingURL=service-storefront.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service-storefront.d.ts","sourceRoot":"","sources":["../src/service-storefront.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAGH,OAAO,KAAK,EACV,wBAAwB,EACxB,0BAA0B,EAE3B,MAAM,sBAAsB,CAAA;AAG7B,OAAO,EAAE,KAAK,gBAAgB,EAA+C,MAAM,aAAa,CAAA;AAChG,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAA;AAE5D,wBAAgB,mCAAmC,IAAI,wBAAwB,CAuE9E;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;AAED,eAAO,MAAM,QAAQ;;;CAAsC,CAAA"}