@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.
- package/README.md +38 -0
- package/dist/events.d.ts +38 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +25 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/routes-shared.d.ts +14 -0
- package/dist/routes-shared.d.ts.map +1 -0
- package/dist/routes-shared.js +3 -0
- package/dist/routes.d.ts +345 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +55 -0
- package/dist/schema.d.ts +655 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +126 -0
- package/dist/service-booking-confirmed.d.ts +77 -0
- package/dist/service-booking-confirmed.d.ts.map +1 -0
- package/dist/service-booking-confirmed.js +134 -0
- package/dist/service-boundary-scheduler.d.ts +85 -0
- package/dist/service-boundary-scheduler.d.ts.map +1 -0
- package/dist/service-boundary-scheduler.js +141 -0
- package/dist/service-catalog-evaluator.d.ts +22 -0
- package/dist/service-catalog-evaluator.d.ts.map +1 -0
- package/dist/service-catalog-evaluator.js +33 -0
- package/dist/service-catalog-plane-promotions.d.ts +72 -0
- package/dist/service-catalog-plane-promotions.d.ts.map +1 -0
- package/dist/service-catalog-plane-promotions.js +119 -0
- package/dist/service-evaluator.d.ts +111 -0
- package/dist/service-evaluator.d.ts.map +1 -0
- package/dist/service-evaluator.js +264 -0
- package/dist/service-storefront.d.ts +40 -0
- package/dist/service-storefront.d.ts.map +1 -0
- package/dist/service-storefront.js +146 -0
- package/dist/service.d.ts +120 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +296 -0
- package/dist/validation.d.ts +140 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +134 -0
- package/dist/workflow-bulk-reindex.d.ts +55 -0
- package/dist/workflow-bulk-reindex.d.ts.map +1 -0
- package/dist/workflow-bulk-reindex.js +58 -0
- 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":"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"}
|
|
@@ -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"}
|