@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
package/dist/schema.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Promotional offers schema — three tables backing the promotions module.
|
|
3
|
+
*
|
|
4
|
+
* Per docs/architecture/promotions-architecture.md §4:
|
|
5
|
+
* - `promotional_offers` (root): the offer header (name, discount type/value,
|
|
6
|
+
* scope JSONB, conditions JSONB, validity window, optional code).
|
|
7
|
+
* - `promotional_offer_products` (link): denormalized materialization of the
|
|
8
|
+
* offer's product set for the product-shaped scopes (`products`,
|
|
9
|
+
* `categories`, `destinations`). Slice-shaped scopes (`global`, `markets`,
|
|
10
|
+
* `audiences`) leave this table empty for that offer.
|
|
11
|
+
* - `promotional_offer_redemptions` (audit): one row per (offer, booking).
|
|
12
|
+
* Aggregated by the redemption recorder when a booking spans multiple
|
|
13
|
+
* line-item snapshots that share an applied offer.
|
|
14
|
+
*
|
|
15
|
+
* Cross-module FK rules: `product_id` and `booking_id` are plain `text`
|
|
16
|
+
* columns with no `.references()` per the cross-module decoupling rule
|
|
17
|
+
* (see docs/architecture/schema-discipline.md). Cross-module integrity is
|
|
18
|
+
* enforced at the service layer.
|
|
19
|
+
*/
|
|
20
|
+
import { typeId } from "@voyantjs/db/lib/typeid-column";
|
|
21
|
+
import { sql } from "drizzle-orm";
|
|
22
|
+
import { boolean, index, integer, jsonb, numeric, pgEnum, pgTable, primaryKey, text, timestamp, uniqueIndex, } from "drizzle-orm/pg-core";
|
|
23
|
+
export const promotionalOfferDiscountTypeEnum = pgEnum("promotional_offer_discount_type", [
|
|
24
|
+
"percentage",
|
|
25
|
+
"fixed_amount",
|
|
26
|
+
]);
|
|
27
|
+
export const promotionalOffers = pgTable("promotional_offers", {
|
|
28
|
+
id: typeId("promotional_offers"),
|
|
29
|
+
name: text("name").notNull(),
|
|
30
|
+
slug: text("slug").notNull(),
|
|
31
|
+
description: text("description"),
|
|
32
|
+
discountType: promotionalOfferDiscountTypeEnum("discount_type").notNull(),
|
|
33
|
+
/** Required when `discountType = 'percentage'`. e.g. 20.00 → 20% off. */
|
|
34
|
+
discountPercent: numeric("discount_percent", { precision: 5, scale: 2 }),
|
|
35
|
+
/** Required when `discountType = 'fixed_amount'`. */
|
|
36
|
+
discountAmountCents: integer("discount_amount_cents"),
|
|
37
|
+
/** Required when `discountType = 'fixed_amount'`. ISO 4217. */
|
|
38
|
+
currency: text("currency"),
|
|
39
|
+
/** Discriminated union — see §3.2. Source of truth for editing. */
|
|
40
|
+
scope: jsonb("scope").$type().notNull(),
|
|
41
|
+
/** Typed JSONB; `{ minPax?: number }` in v1. */
|
|
42
|
+
conditions: jsonb("conditions").$type().notNull().default({}),
|
|
43
|
+
validFrom: timestamp("valid_from", { withTimezone: true }),
|
|
44
|
+
validUntil: timestamp("valid_until", { withTimezone: true }),
|
|
45
|
+
/** NULL = auto-applied. Non-NULL = code-gated; stored lowercase. */
|
|
46
|
+
code: text("code"),
|
|
47
|
+
stackable: boolean("stackable").notNull().default(false),
|
|
48
|
+
active: boolean("active").notNull().default(true),
|
|
49
|
+
metadata: jsonb("metadata"),
|
|
50
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
51
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
|
52
|
+
}, (table) => [
|
|
53
|
+
// Hot-path range scan for the rule evaluator: "all currently-valid active offers".
|
|
54
|
+
index("idx_promotional_offers_active_validity").on(table.active, table.validFrom, table.validUntil),
|
|
55
|
+
// Slugs unique across active rows; archiving frees up the slug for reuse.
|
|
56
|
+
uniqueIndex("uidx_promotional_offers_slug_active").on(table.slug).where(sql `active = true`),
|
|
57
|
+
// Code uniqueness: case-insensitive (stored lowercase, compared lowercase),
|
|
58
|
+
// active rows only. Archived offers can recycle their code.
|
|
59
|
+
uniqueIndex("uidx_promotional_offers_code_active")
|
|
60
|
+
.on(sql `lower(code)`)
|
|
61
|
+
.where(sql `code is not null and active = true`),
|
|
62
|
+
]);
|
|
63
|
+
/**
|
|
64
|
+
* Denormalized scope materialization. Populated by the service layer on
|
|
65
|
+
* offer create/update for `scope.kind ∈ {products, categories, destinations}`.
|
|
66
|
+
* The catalog projection joins against this table on the hot path.
|
|
67
|
+
*
|
|
68
|
+
* `product_id` is a plain text column — no Drizzle `.references()` per the
|
|
69
|
+
* cross-module decoupling rule.
|
|
70
|
+
*/
|
|
71
|
+
export const promotionalOfferProducts = pgTable("promotional_offer_products", {
|
|
72
|
+
offerId: text("offer_id")
|
|
73
|
+
.notNull()
|
|
74
|
+
.references(() => promotionalOffers.id, { onDelete: "cascade" }),
|
|
75
|
+
productId: text("product_id").notNull(),
|
|
76
|
+
}, (table) => [
|
|
77
|
+
primaryKey({ columns: [table.offerId, table.productId] }),
|
|
78
|
+
index("idx_pop_product").on(table.productId),
|
|
79
|
+
]);
|
|
80
|
+
/**
|
|
81
|
+
* Per-booking redemption record. ON DELETE RESTRICT on `offer_id` so an
|
|
82
|
+
* offer with redemptions cannot be deleted (operators must archive instead).
|
|
83
|
+
*
|
|
84
|
+
* The `(offer_id, booking_id)` unique constraint enforces "one row per
|
|
85
|
+
* (offer, booking)" — the redemption recorder aggregates `discount_applied_cents`
|
|
86
|
+
* across multiple line-item snapshots that share an offer before inserting,
|
|
87
|
+
* and the recorder upsert (`ON CONFLICT … DO UPDATE`) is idempotent against
|
|
88
|
+
* subscriber retries.
|
|
89
|
+
*/
|
|
90
|
+
export const promotionalOfferRedemptions = pgTable("promotional_offer_redemptions", {
|
|
91
|
+
id: typeId("promotional_offer_redemptions"),
|
|
92
|
+
offerId: text("offer_id")
|
|
93
|
+
.notNull()
|
|
94
|
+
.references(() => promotionalOffers.id, { onDelete: "restrict" }),
|
|
95
|
+
bookingId: text("booking_id").notNull(),
|
|
96
|
+
/** The literal code the customer entered (case preserved); NULL for auto-applied. */
|
|
97
|
+
codeUsed: text("code_used"),
|
|
98
|
+
discountAppliedCents: integer("discount_applied_cents").notNull(),
|
|
99
|
+
currency: text("currency").notNull(),
|
|
100
|
+
redeemedAt: timestamp("redeemed_at", { withTimezone: true }).notNull().defaultNow(),
|
|
101
|
+
}, (table) => [
|
|
102
|
+
index("idx_por_offer").on(table.offerId),
|
|
103
|
+
index("idx_por_booking").on(table.bookingId),
|
|
104
|
+
uniqueIndex("uidx_por_offer_booking").on(table.offerId, table.bookingId),
|
|
105
|
+
]);
|
|
106
|
+
/**
|
|
107
|
+
* Boundary-scheduler watermark — a single row tracking the last_tick the
|
|
108
|
+
* boundary scheduler observed. Per §9.2 of the architecture doc, the
|
|
109
|
+
* scheduler queries offers whose `valid_from` / `valid_until` falls
|
|
110
|
+
* BETWEEN `last_tick` and `now()` to detect lifecycle transitions.
|
|
111
|
+
*
|
|
112
|
+
* Single-row convention: rows are upserted with id = `BOUNDARY_SCHEDULER_STATE_ID`
|
|
113
|
+
* (typeid `pofs_default`). The unique constraint on `singleton_key` enforces
|
|
114
|
+
* "at most one row" defensively even if a future caller forgot the convention.
|
|
115
|
+
*/
|
|
116
|
+
export const promotionalOfferSchedulerState = pgTable("promotional_offer_scheduler_state", {
|
|
117
|
+
id: typeId("promotional_offer_scheduler_state"),
|
|
118
|
+
/**
|
|
119
|
+
* Sentinel column for single-row enforcement — always set to
|
|
120
|
+
* `'singleton'`. The unique index on this column means a second
|
|
121
|
+
* insert with a different id would still fail.
|
|
122
|
+
*/
|
|
123
|
+
singletonKey: text("singleton_key").notNull().default("singleton"),
|
|
124
|
+
lastTick: timestamp("last_tick", { withTimezone: true }).notNull(),
|
|
125
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
|
126
|
+
}, (table) => [uniqueIndex("uidx_pofs_singleton").on(table.singletonKey)]);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Booking-confirmed redemption subscriber — records one row per
|
|
3
|
+
* (offer, booking) in `promotional_offer_redemptions` after a booking
|
|
4
|
+
* commits, by reading `pricing_applied_offers` from `catalog_quotes`
|
|
5
|
+
* (joined to the booking via the existing `consumed_booking_id` column).
|
|
6
|
+
*
|
|
7
|
+
* Why a subscriber rather than a `BookEntityDeps` hook: `bookEntity`
|
|
8
|
+
* does sequential writes without an enclosing `db.transaction(...)`,
|
|
9
|
+
* and the owned `quickCreate` path opens its own transaction in
|
|
10
|
+
* `@voyantjs/finance`. There is no single commit transaction to be
|
|
11
|
+
* atomic with — claiming "atomic with commit" would be misleading.
|
|
12
|
+
*
|
|
13
|
+
* Per docs/architecture/promotions-architecture.md §3.6 + §7.3.
|
|
14
|
+
*
|
|
15
|
+
* The recorder reads from `catalog_quotes` (the source of truth, written
|
|
16
|
+
* by `quoteEntity`) NOT from `booking_catalog_snapshot` to avoid an
|
|
17
|
+
* ordering race with the catalog-bridge's `captureSnapshotGraph`
|
|
18
|
+
* subscriber (both fire on the same `booking.confirmed` event).
|
|
19
|
+
*
|
|
20
|
+
* Idempotent on retry: the unique `(offer_id, booking_id)` index on
|
|
21
|
+
* `promotional_offer_redemptions` (per §4.3) lets the upsert refresh the
|
|
22
|
+
* aggregate cleanly even if the subscriber is replayed.
|
|
23
|
+
*/
|
|
24
|
+
import type { AnyDrizzleDb } from "@voyantjs/db";
|
|
25
|
+
export interface RecordRedemptionsResult {
|
|
26
|
+
/** Number of distinct quotes scanned for this booking. */
|
|
27
|
+
quotesScanned: number;
|
|
28
|
+
/** Number of distinct offers aggregated across those quotes. */
|
|
29
|
+
offersFound: number;
|
|
30
|
+
/** Number of redemption rows upserted. Equals `offersFound` on success. */
|
|
31
|
+
rowsUpserted: number;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Aggregate `pricing_applied_offers` across every consumed quote for the
|
|
35
|
+
* booking and upsert one redemption row per offer.
|
|
36
|
+
*
|
|
37
|
+
* Aggregation rules (per §3.5):
|
|
38
|
+
* - Multiple snapshots in the same booking sharing the same offer →
|
|
39
|
+
* ONE redemption row with `discount_applied_cents` summed across all
|
|
40
|
+
* occurrences.
|
|
41
|
+
* - `code_used` defaults to the first non-null `appliedCode` seen for
|
|
42
|
+
* that offer (auto-applied + code-gated never share the same
|
|
43
|
+
* offer ID).
|
|
44
|
+
* - `currency` carried from the AppliedOffer row directly.
|
|
45
|
+
*/
|
|
46
|
+
export declare function recordPromotionRedemptionsForBooking(db: AnyDrizzleDb, bookingId: string): Promise<RecordRedemptionsResult>;
|
|
47
|
+
/**
|
|
48
|
+
* Wiring guidance for the operator template — the canonical subscriber
|
|
49
|
+
* pattern (mirrors how `catalog-bridge.ts` wires `captureSnapshotGraph`):
|
|
50
|
+
*
|
|
51
|
+
* eventBus.subscribe<BookingConfirmedEvent>("booking.confirmed", async ({ data }) => {
|
|
52
|
+
* await withDbFromEnv(env, async (db) => {
|
|
53
|
+
* try {
|
|
54
|
+
* await recordPromotionRedemptionsForBooking(db, data.bookingId)
|
|
55
|
+
* } catch (err) {
|
|
56
|
+
* // Failing here leaves the booking committed without a
|
|
57
|
+
* // redemption row. Ops can backfill from
|
|
58
|
+
* // `pricing_applied_offers` on the snapshot. Log so the gap
|
|
59
|
+
* // is visible.
|
|
60
|
+
* console.warn("[promotions] redemption subscriber failed", { …err })
|
|
61
|
+
* }
|
|
62
|
+
* })
|
|
63
|
+
* })
|
|
64
|
+
*
|
|
65
|
+
* The factory is intentionally kept in the template (not here) because:
|
|
66
|
+
* - The subscriber needs the template's `withDbFromEnv` from
|
|
67
|
+
* `templates/operator/src/api/lib/db.ts` (the Pool lifecycle helper
|
|
68
|
+
* introduced in #510 / #512). That helper isn't exported from a
|
|
69
|
+
* package.
|
|
70
|
+
* - Keeping the package package-side core (`recordPromotionRedemptionsForBooking`)
|
|
71
|
+
* and the wiring template-side mirrors the existing catalog-bridge
|
|
72
|
+
* subscriber pattern.
|
|
73
|
+
*/
|
|
74
|
+
export declare const __test__: {
|
|
75
|
+
recordPromotionRedemptionsForBooking: typeof recordPromotionRedemptionsForBooking;
|
|
76
|
+
};
|
|
77
|
+
//# sourceMappingURL=service-booking-confirmed.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service-booking-confirmed.d.ts","sourceRoot":"","sources":["../src/service-booking-confirmed.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAMhD,MAAM,WAAW,uBAAuB;IACtC,0DAA0D;IAC1D,aAAa,EAAE,MAAM,CAAA;IACrB,gEAAgE;IAChE,WAAW,EAAE,MAAM,CAAA;IACnB,2EAA2E;IAC3E,YAAY,EAAE,MAAM,CAAA;CACrB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,oCAAoC,CACxD,EAAE,EAAE,YAAY,EAChB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,uBAAuB,CAAC,CA0ElC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,eAAO,MAAM,QAAQ;;CAA2C,CAAA"}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Booking-confirmed redemption subscriber — records one row per
|
|
3
|
+
* (offer, booking) in `promotional_offer_redemptions` after a booking
|
|
4
|
+
* commits, by reading `pricing_applied_offers` from `catalog_quotes`
|
|
5
|
+
* (joined to the booking via the existing `consumed_booking_id` column).
|
|
6
|
+
*
|
|
7
|
+
* Why a subscriber rather than a `BookEntityDeps` hook: `bookEntity`
|
|
8
|
+
* does sequential writes without an enclosing `db.transaction(...)`,
|
|
9
|
+
* and the owned `quickCreate` path opens its own transaction in
|
|
10
|
+
* `@voyantjs/finance`. There is no single commit transaction to be
|
|
11
|
+
* atomic with — claiming "atomic with commit" would be misleading.
|
|
12
|
+
*
|
|
13
|
+
* Per docs/architecture/promotions-architecture.md §3.6 + §7.3.
|
|
14
|
+
*
|
|
15
|
+
* The recorder reads from `catalog_quotes` (the source of truth, written
|
|
16
|
+
* by `quoteEntity`) NOT from `booking_catalog_snapshot` to avoid an
|
|
17
|
+
* ordering race with the catalog-bridge's `captureSnapshotGraph`
|
|
18
|
+
* subscriber (both fire on the same `booking.confirmed` event).
|
|
19
|
+
*
|
|
20
|
+
* Idempotent on retry: the unique `(offer_id, booking_id)` index on
|
|
21
|
+
* `promotional_offer_redemptions` (per §4.3) lets the upsert refresh the
|
|
22
|
+
* aggregate cleanly even if the subscriber is replayed.
|
|
23
|
+
*/
|
|
24
|
+
import { catalogQuotesTable } from "@voyantjs/catalog/booking-engine";
|
|
25
|
+
import { eq, sql } from "drizzle-orm";
|
|
26
|
+
import { promotionalOfferRedemptions } from "./schema.js";
|
|
27
|
+
/**
|
|
28
|
+
* Aggregate `pricing_applied_offers` across every consumed quote for the
|
|
29
|
+
* booking and upsert one redemption row per offer.
|
|
30
|
+
*
|
|
31
|
+
* Aggregation rules (per §3.5):
|
|
32
|
+
* - Multiple snapshots in the same booking sharing the same offer →
|
|
33
|
+
* ONE redemption row with `discount_applied_cents` summed across all
|
|
34
|
+
* occurrences.
|
|
35
|
+
* - `code_used` defaults to the first non-null `appliedCode` seen for
|
|
36
|
+
* that offer (auto-applied + code-gated never share the same
|
|
37
|
+
* offer ID).
|
|
38
|
+
* - `currency` carried from the AppliedOffer row directly.
|
|
39
|
+
*/
|
|
40
|
+
export async function recordPromotionRedemptionsForBooking(db, bookingId) {
|
|
41
|
+
const rows = await db
|
|
42
|
+
.select({ pricing_applied_offers: catalogQuotesTable.pricing_applied_offers })
|
|
43
|
+
.from(catalogQuotesTable)
|
|
44
|
+
.where(eq(catalogQuotesTable.consumed_booking_id, bookingId));
|
|
45
|
+
if (rows.length === 0) {
|
|
46
|
+
return { quotesScanned: 0, offersFound: 0, rowsUpserted: 0 };
|
|
47
|
+
}
|
|
48
|
+
// Aggregate per offerId across all quotes.
|
|
49
|
+
const aggregated = new Map();
|
|
50
|
+
for (const row of rows) {
|
|
51
|
+
const offers = row.pricing_applied_offers ?? [];
|
|
52
|
+
for (const offer of offers) {
|
|
53
|
+
const existing = aggregated.get(offer.offerId);
|
|
54
|
+
if (existing) {
|
|
55
|
+
existing.discountAppliedCents += offer.discountAppliedCents;
|
|
56
|
+
// First non-null wins — the code-gated offer (if any) is
|
|
57
|
+
// typically a single occurrence.
|
|
58
|
+
if (existing.codeUsed == null && offer.appliedCode != null) {
|
|
59
|
+
existing.codeUsed = offer.appliedCode;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
aggregated.set(offer.offerId, {
|
|
64
|
+
discountAppliedCents: offer.discountAppliedCents,
|
|
65
|
+
currency: offer.currency,
|
|
66
|
+
codeUsed: offer.appliedCode,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (aggregated.size === 0) {
|
|
72
|
+
return { quotesScanned: rows.length, offersFound: 0, rowsUpserted: 0 };
|
|
73
|
+
}
|
|
74
|
+
const insertValues = Array.from(aggregated.entries()).map(([offerId, summary]) => ({
|
|
75
|
+
offerId,
|
|
76
|
+
bookingId,
|
|
77
|
+
codeUsed: summary.codeUsed,
|
|
78
|
+
discountAppliedCents: summary.discountAppliedCents,
|
|
79
|
+
currency: summary.currency,
|
|
80
|
+
}));
|
|
81
|
+
// ON CONFLICT DO UPDATE so subscriber retries refresh the aggregate
|
|
82
|
+
// cleanly — important because the event bus may replay this event.
|
|
83
|
+
// Cast: AnyDrizzleDb's union doesn't unify .insert().onConflictDoUpdate()
|
|
84
|
+
// across drivers at the type level (same workaround as the boundary
|
|
85
|
+
// scheduler).
|
|
86
|
+
await db
|
|
87
|
+
.insert(promotionalOfferRedemptions)
|
|
88
|
+
.values(insertValues)
|
|
89
|
+
.onConflictDoUpdate({
|
|
90
|
+
target: [promotionalOfferRedemptions.offerId, promotionalOfferRedemptions.bookingId],
|
|
91
|
+
// EXCLUDED refers to the would-be-inserted row — we want the freshly-
|
|
92
|
+
// computed aggregate (from `insertValues`) to overwrite any stale prior
|
|
93
|
+
// row, not a no-op self-assignment. Without `excluded.*` here, a partial
|
|
94
|
+
// earlier write would never get corrected on retry / replay despite
|
|
95
|
+
// this code path claiming idempotent refresh semantics.
|
|
96
|
+
set: {
|
|
97
|
+
discountAppliedCents: sql `excluded.discount_applied_cents`,
|
|
98
|
+
codeUsed: sql `excluded.code_used`,
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
return {
|
|
102
|
+
quotesScanned: rows.length,
|
|
103
|
+
offersFound: aggregated.size,
|
|
104
|
+
rowsUpserted: aggregated.size,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Wiring guidance for the operator template — the canonical subscriber
|
|
109
|
+
* pattern (mirrors how `catalog-bridge.ts` wires `captureSnapshotGraph`):
|
|
110
|
+
*
|
|
111
|
+
* eventBus.subscribe<BookingConfirmedEvent>("booking.confirmed", async ({ data }) => {
|
|
112
|
+
* await withDbFromEnv(env, async (db) => {
|
|
113
|
+
* try {
|
|
114
|
+
* await recordPromotionRedemptionsForBooking(db, data.bookingId)
|
|
115
|
+
* } catch (err) {
|
|
116
|
+
* // Failing here leaves the booking committed without a
|
|
117
|
+
* // redemption row. Ops can backfill from
|
|
118
|
+
* // `pricing_applied_offers` on the snapshot. Log so the gap
|
|
119
|
+
* // is visible.
|
|
120
|
+
* console.warn("[promotions] redemption subscriber failed", { …err })
|
|
121
|
+
* }
|
|
122
|
+
* })
|
|
123
|
+
* })
|
|
124
|
+
*
|
|
125
|
+
* The factory is intentionally kept in the template (not here) because:
|
|
126
|
+
* - The subscriber needs the template's `withDbFromEnv` from
|
|
127
|
+
* `templates/operator/src/api/lib/db.ts` (the Pool lifecycle helper
|
|
128
|
+
* introduced in #510 / #512). That helper isn't exported from a
|
|
129
|
+
* package.
|
|
130
|
+
* - Keeping the package package-side core (`recordPromotionRedemptionsForBooking`)
|
|
131
|
+
* and the wiring template-side mirrors the existing catalog-bridge
|
|
132
|
+
* subscriber pattern.
|
|
133
|
+
*/
|
|
134
|
+
export const __test__ = { recordPromotionRedemptionsForBooking };
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Promotions boundary scheduler — emits `promotion.changed` events when
|
|
3
|
+
* offers cross their `valid_from` / `valid_until` boundaries since the
|
|
4
|
+
* last tick.
|
|
5
|
+
*
|
|
6
|
+
* The catalog projection is `now()`-dependent (active offers fire at
|
|
7
|
+
* `valid_from`, expire at `valid_until`). Without a scheduled trigger,
|
|
8
|
+
* an indexed product document continues to show an expired discount
|
|
9
|
+
* until something else reindexes it. This scheduler is what guarantees
|
|
10
|
+
* the storefront eventually sees the boundary transition — within the
|
|
11
|
+
* cron interval (5 min by default in the operator template).
|
|
12
|
+
*
|
|
13
|
+
* Per docs/architecture/promotions-architecture.md §9.2.
|
|
14
|
+
*
|
|
15
|
+
* Operator template wires this to a Cloudflare Workers cron in
|
|
16
|
+
* `src/api/promotion-scheduled.ts` + `wrangler.jsonc`.
|
|
17
|
+
*
|
|
18
|
+
* Idempotent on retry: the scheduler's effect is "emit `promotion.changed`
|
|
19
|
+
* events for crossings since `last_tick`". Re-running with the same
|
|
20
|
+
* `last_tick` re-emits the same events; the catalog bridge's reindex
|
|
21
|
+
* subscriber is idempotent (reindexing the same product twice is a
|
|
22
|
+
* no-op modulo the eventual-consistency window).
|
|
23
|
+
*/
|
|
24
|
+
import type { EventBus } from "@voyantjs/core";
|
|
25
|
+
import type { AnyDrizzleDb } from "@voyantjs/db";
|
|
26
|
+
import { type PromotionChangedAffected, type PromotionChangedSource } from "./events.js";
|
|
27
|
+
export interface BoundarySchedulerOptions {
|
|
28
|
+
/**
|
|
29
|
+
* How far back to look on the very first tick (when no watermark row
|
|
30
|
+
* exists yet). Defaults to 24h — covers same-day deployments where the
|
|
31
|
+
* scheduler starts after some offers have already crossed their
|
|
32
|
+
* `valid_from`. Pin to a small value if you'd rather only catch
|
|
33
|
+
* forward-going crossings.
|
|
34
|
+
*/
|
|
35
|
+
initialLookbackMs?: number;
|
|
36
|
+
/** Override `now()` for testing. */
|
|
37
|
+
now?: () => Date;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* One detected boundary crossing. Returned in `result.crossings` so callers
|
|
41
|
+
* without an event bus (e.g., Cloudflare Workers cron handlers, where the
|
|
42
|
+
* in-process bus from the running app isn't reachable) can dispatch the
|
|
43
|
+
* reindex inline.
|
|
44
|
+
*/
|
|
45
|
+
export interface BoundaryCrossing {
|
|
46
|
+
offerId: string;
|
|
47
|
+
source: PromotionChangedSource;
|
|
48
|
+
affected: PromotionChangedAffected;
|
|
49
|
+
}
|
|
50
|
+
export interface BoundarySchedulerResult {
|
|
51
|
+
/** Wall clock at the start of this tick — the new watermark. */
|
|
52
|
+
tickedAt: Date;
|
|
53
|
+
/** Watermark used as the lower bound for the crossing scan. */
|
|
54
|
+
lastTick: Date;
|
|
55
|
+
/** Offers crossing `valid_from` in the window — emit with source="updated". */
|
|
56
|
+
validFromCrossings: number;
|
|
57
|
+
/** Offers crossing `valid_until` in the window — emit with source="expired". */
|
|
58
|
+
validUntilCrossings: number;
|
|
59
|
+
/** Total events emitted to the bus when one was provided (else 0). */
|
|
60
|
+
emitted: number;
|
|
61
|
+
/**
|
|
62
|
+
* Every crossing detected this tick. Always populated — independent of
|
|
63
|
+
* whether an event bus was supplied. Lets cron-style callers dispatch
|
|
64
|
+
* the reindex inline when no bus is reachable.
|
|
65
|
+
*/
|
|
66
|
+
crossings: BoundaryCrossing[];
|
|
67
|
+
}
|
|
68
|
+
export interface BoundarySchedulerDeps {
|
|
69
|
+
db: AnyDrizzleDb;
|
|
70
|
+
eventBus?: EventBus;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Run a single boundary-scheduler tick. Safe to call repeatedly; the
|
|
74
|
+
* watermark advances monotonically.
|
|
75
|
+
*/
|
|
76
|
+
export declare function runPromotionBoundaryScheduler(deps: BoundarySchedulerDeps, options?: BoundarySchedulerOptions): Promise<BoundarySchedulerResult>;
|
|
77
|
+
declare function readWatermark(db: AnyDrizzleDb, tickedAt: Date, initialLookbackMs: number): Promise<Date>;
|
|
78
|
+
declare function writeWatermark(db: AnyDrizzleDb, tickedAt: Date): Promise<void>;
|
|
79
|
+
export declare const __test__: {
|
|
80
|
+
SINGLETON_KEY: "singleton";
|
|
81
|
+
readWatermark: typeof readWatermark;
|
|
82
|
+
writeWatermark: typeof writeWatermark;
|
|
83
|
+
};
|
|
84
|
+
export {};
|
|
85
|
+
//# sourceMappingURL=service-boundary-scheduler.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service-boundary-scheduler.d.ts","sourceRoot":"","sources":["../src/service-boundary-scheduler.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AAC9C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAIhD,OAAO,EAEL,KAAK,wBAAwB,EAE7B,KAAK,sBAAsB,EAC5B,MAAM,aAAa,CAAA;AAYpB,MAAM,WAAW,wBAAwB;IACvC;;;;;;OAMG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,oCAAoC;IACpC,GAAG,CAAC,EAAE,MAAM,IAAI,CAAA;CACjB;AAED;;;;;GAKG;AACH,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,MAAM,CAAA;IACf,MAAM,EAAE,sBAAsB,CAAA;IAC9B,QAAQ,EAAE,wBAAwB,CAAA;CACnC;AAED,MAAM,WAAW,uBAAuB;IACtC,gEAAgE;IAChE,QAAQ,EAAE,IAAI,CAAA;IACd,+DAA+D;IAC/D,QAAQ,EAAE,IAAI,CAAA;IACd,+EAA+E;IAC/E,kBAAkB,EAAE,MAAM,CAAA;IAC1B,gFAAgF;IAChF,mBAAmB,EAAE,MAAM,CAAA;IAC3B,sEAAsE;IACtE,OAAO,EAAE,MAAM,CAAA;IACf;;;;OAIG;IACH,SAAS,EAAE,gBAAgB,EAAE,CAAA;CAC9B;AAED,MAAM,WAAW,qBAAqB;IACpC,EAAE,EAAE,YAAY,CAAA;IAChB,QAAQ,CAAC,EAAE,QAAQ,CAAA;CACpB;AAED;;;GAGG;AACH,wBAAsB,6BAA6B,CACjD,IAAI,EAAE,qBAAqB,EAC3B,OAAO,GAAE,wBAA6B,GACrC,OAAO,CAAC,uBAAuB,CAAC,CAiElC;AA+BD,iBAAe,aAAa,CAC1B,EAAE,EAAE,YAAY,EAChB,QAAQ,EAAE,IAAI,EACd,iBAAiB,EAAE,MAAM,GACxB,OAAO,CAAC,IAAI,CAAC,CASf;AAED,iBAAe,cAAc,CAAC,EAAE,EAAE,YAAY,EAAE,QAAQ,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CA0B7E;AAGD,eAAO,MAAM,QAAQ;;;;CAAmD,CAAA"}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Promotions boundary scheduler — emits `promotion.changed` events when
|
|
3
|
+
* offers cross their `valid_from` / `valid_until` boundaries since the
|
|
4
|
+
* last tick.
|
|
5
|
+
*
|
|
6
|
+
* The catalog projection is `now()`-dependent (active offers fire at
|
|
7
|
+
* `valid_from`, expire at `valid_until`). Without a scheduled trigger,
|
|
8
|
+
* an indexed product document continues to show an expired discount
|
|
9
|
+
* until something else reindexes it. This scheduler is what guarantees
|
|
10
|
+
* the storefront eventually sees the boundary transition — within the
|
|
11
|
+
* cron interval (5 min by default in the operator template).
|
|
12
|
+
*
|
|
13
|
+
* Per docs/architecture/promotions-architecture.md §9.2.
|
|
14
|
+
*
|
|
15
|
+
* Operator template wires this to a Cloudflare Workers cron in
|
|
16
|
+
* `src/api/promotion-scheduled.ts` + `wrangler.jsonc`.
|
|
17
|
+
*
|
|
18
|
+
* Idempotent on retry: the scheduler's effect is "emit `promotion.changed`
|
|
19
|
+
* events for crossings since `last_tick`". Re-running with the same
|
|
20
|
+
* `last_tick` re-emits the same events; the catalog bridge's reindex
|
|
21
|
+
* subscriber is idempotent (reindexing the same product twice is a
|
|
22
|
+
* no-op modulo the eventual-consistency window).
|
|
23
|
+
*/
|
|
24
|
+
import { and, eq, gt, lte } from "drizzle-orm";
|
|
25
|
+
import { PROMOTION_CHANGED_EVENT, } from "./events.js";
|
|
26
|
+
import { promotionalOfferSchedulerState, promotionalOffers, } from "./schema.js";
|
|
27
|
+
import { resolveScopeProductIds } from "./service.js";
|
|
28
|
+
/** Sentinel value for the single-row scheduler-state table's `singleton_key`. */
|
|
29
|
+
const SINGLETON_KEY = "singleton";
|
|
30
|
+
/**
|
|
31
|
+
* Run a single boundary-scheduler tick. Safe to call repeatedly; the
|
|
32
|
+
* watermark advances monotonically.
|
|
33
|
+
*/
|
|
34
|
+
export async function runPromotionBoundaryScheduler(deps, options = {}) {
|
|
35
|
+
const initialLookbackMs = options.initialLookbackMs ?? 24 * 60 * 60 * 1000;
|
|
36
|
+
const nowFn = options.now ?? (() => new Date());
|
|
37
|
+
const tickedAt = nowFn();
|
|
38
|
+
const lastTick = await readWatermark(deps.db, tickedAt, initialLookbackMs);
|
|
39
|
+
// Fetch every active offer whose `valid_from` OR `valid_until` falls in
|
|
40
|
+
// the (lastTick, tickedAt] window. Two passes (one per boundary) so we
|
|
41
|
+
// can attribute the right `source` per emission.
|
|
42
|
+
const validFromRows = await deps.db
|
|
43
|
+
.select()
|
|
44
|
+
.from(promotionalOffers)
|
|
45
|
+
.where(and(eq(promotionalOffers.active, true), gt(promotionalOffers.validFrom, lastTick), lte(promotionalOffers.validFrom, tickedAt)));
|
|
46
|
+
const validUntilRows = await deps.db
|
|
47
|
+
.select()
|
|
48
|
+
.from(promotionalOffers)
|
|
49
|
+
.where(and(eq(promotionalOffers.active, true), gt(promotionalOffers.validUntil, lastTick), lte(promotionalOffers.validUntil, tickedAt)));
|
|
50
|
+
// Resolve `affected` once per offer (each call hits the link table) so we
|
|
51
|
+
// can both populate the returned `crossings[]` and feed the optional
|
|
52
|
+
// event bus from the same payload.
|
|
53
|
+
const validFromCrossings = await buildCrossings(deps.db, validFromRows, "updated");
|
|
54
|
+
const validUntilCrossings = await buildCrossings(deps.db, validUntilRows, "expired");
|
|
55
|
+
const crossings = [...validFromCrossings, ...validUntilCrossings];
|
|
56
|
+
let emitted = 0;
|
|
57
|
+
if (deps.eventBus) {
|
|
58
|
+
for (const crossing of crossings) {
|
|
59
|
+
const payload = {
|
|
60
|
+
offerId: crossing.offerId,
|
|
61
|
+
source: crossing.source,
|
|
62
|
+
affected: crossing.affected,
|
|
63
|
+
};
|
|
64
|
+
await deps.eventBus.emit(PROMOTION_CHANGED_EVENT, payload, {
|
|
65
|
+
category: "domain",
|
|
66
|
+
source: "service",
|
|
67
|
+
});
|
|
68
|
+
emitted++;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
await writeWatermark(deps.db, tickedAt);
|
|
72
|
+
return {
|
|
73
|
+
tickedAt,
|
|
74
|
+
lastTick,
|
|
75
|
+
validFromCrossings: validFromRows.length,
|
|
76
|
+
validUntilCrossings: validUntilRows.length,
|
|
77
|
+
emitted,
|
|
78
|
+
crossings,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
async function buildCrossings(db, offers, source) {
|
|
82
|
+
const out = [];
|
|
83
|
+
for (const offer of offers) {
|
|
84
|
+
out.push({
|
|
85
|
+
offerId: offer.id,
|
|
86
|
+
source,
|
|
87
|
+
affected: await resolveAffected(db, offer.scope),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
async function resolveAffected(db, scope) {
|
|
93
|
+
// `resolveScopeProductIds` is typed against `PostgresJsDatabase` to match
|
|
94
|
+
// the rest of `service.ts`. The structural compatibility across drizzle
|
|
95
|
+
// driver flavors makes the cast safe at runtime — only `.select(...)` is
|
|
96
|
+
// used here.
|
|
97
|
+
const productIds = await resolveScopeProductIds(db, scope);
|
|
98
|
+
if (productIds === null)
|
|
99
|
+
return { kind: "all" };
|
|
100
|
+
return { kind: "products", productIds };
|
|
101
|
+
}
|
|
102
|
+
async function readWatermark(db, tickedAt, initialLookbackMs) {
|
|
103
|
+
const rows = await db
|
|
104
|
+
.select({ lastTick: promotionalOfferSchedulerState.lastTick })
|
|
105
|
+
.from(promotionalOfferSchedulerState)
|
|
106
|
+
.where(eq(promotionalOfferSchedulerState.singletonKey, SINGLETON_KEY))
|
|
107
|
+
.limit(1);
|
|
108
|
+
const existing = rows[0];
|
|
109
|
+
if (existing)
|
|
110
|
+
return existing.lastTick;
|
|
111
|
+
return new Date(tickedAt.getTime() - initialLookbackMs);
|
|
112
|
+
}
|
|
113
|
+
async function writeWatermark(db, tickedAt) {
|
|
114
|
+
// Upsert keyed on the singleton sentinel — first call inserts, every
|
|
115
|
+
// subsequent call advances the existing row's `last_tick`.
|
|
116
|
+
//
|
|
117
|
+
// Cast: `AnyDrizzleDb` is a union of three driver flavors whose
|
|
118
|
+
// `.insert(...).onConflictDoUpdate(...)` return types differ at the
|
|
119
|
+
// type level (NeonHttp returns a different QueryResult shape than
|
|
120
|
+
// postgres-js / NeonWs). Runtime is identical — drizzle's PgDatabase
|
|
121
|
+
// surface is structurally the same across drivers — so the cast is
|
|
122
|
+
// safe. Same pattern as elsewhere in the workspace where service code
|
|
123
|
+
// accepts the union but uses a write that only typechecks against one
|
|
124
|
+
// concrete driver.
|
|
125
|
+
await db
|
|
126
|
+
.insert(promotionalOfferSchedulerState)
|
|
127
|
+
.values({
|
|
128
|
+
singletonKey: SINGLETON_KEY,
|
|
129
|
+
lastTick: tickedAt,
|
|
130
|
+
updatedAt: tickedAt,
|
|
131
|
+
})
|
|
132
|
+
.onConflictDoUpdate({
|
|
133
|
+
target: promotionalOfferSchedulerState.singletonKey,
|
|
134
|
+
set: {
|
|
135
|
+
lastTick: tickedAt,
|
|
136
|
+
updatedAt: tickedAt,
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
// Exposed for tests so they can probe the watermark without recreating the SQL.
|
|
141
|
+
export const __test__ = { SINGLETON_KEY, readWatermark, writeWatermark };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog evaluator adapter — bridges `@voyantjs/catalog`'s
|
|
3
|
+
* `PromotionEvaluationInput` / `PromotionEvaluationOutput` contract to
|
|
4
|
+
* this package's internal evaluator.
|
|
5
|
+
*
|
|
6
|
+
* Wire via:
|
|
7
|
+
*
|
|
8
|
+
* const deps: QuoteEntityDeps = {
|
|
9
|
+
* // ...
|
|
10
|
+
* evaluatePromotions: createCatalogPromotionEvaluator(db),
|
|
11
|
+
* }
|
|
12
|
+
*
|
|
13
|
+
* Per docs/architecture/promotions-architecture.md §3.6 + §7.1.
|
|
14
|
+
*/
|
|
15
|
+
import type { PromotionEvaluationInput, PromotionEvaluationOutput } from "@voyantjs/catalog/booking-engine";
|
|
16
|
+
import type { AnyDrizzleDb } from "@voyantjs/db";
|
|
17
|
+
/**
|
|
18
|
+
* Build the `evaluatePromotions` hook the catalog booking engine wires
|
|
19
|
+
* onto `QuoteEntityDeps`. Closes over the request-scoped db.
|
|
20
|
+
*/
|
|
21
|
+
export declare function createCatalogPromotionEvaluator(db: AnyDrizzleDb): (input: PromotionEvaluationInput) => Promise<PromotionEvaluationOutput>;
|
|
22
|
+
//# sourceMappingURL=service-catalog-evaluator.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service-catalog-evaluator.d.ts","sourceRoot":"","sources":["../src/service-catalog-evaluator.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EACV,wBAAwB,EACxB,yBAAyB,EAC1B,MAAM,kCAAkC,CAAA;AACzC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAIhD;;;GAGG;AACH,wBAAgB,+BAA+B,CAC7C,EAAE,EAAE,YAAY,GACf,CAAC,KAAK,EAAE,wBAAwB,KAAK,OAAO,CAAC,yBAAyB,CAAC,CAazE"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog evaluator adapter — bridges `@voyantjs/catalog`'s
|
|
3
|
+
* `PromotionEvaluationInput` / `PromotionEvaluationOutput` contract to
|
|
4
|
+
* this package's internal evaluator.
|
|
5
|
+
*
|
|
6
|
+
* Wire via:
|
|
7
|
+
*
|
|
8
|
+
* const deps: QuoteEntityDeps = {
|
|
9
|
+
* // ...
|
|
10
|
+
* evaluatePromotions: createCatalogPromotionEvaluator(db),
|
|
11
|
+
* }
|
|
12
|
+
*
|
|
13
|
+
* Per docs/architecture/promotions-architecture.md §3.6 + §7.1.
|
|
14
|
+
*/
|
|
15
|
+
import { createDrizzleOfferDataSource, evaluateOffersForProduct } from "./service-evaluator.js";
|
|
16
|
+
/**
|
|
17
|
+
* Build the `evaluatePromotions` hook the catalog booking engine wires
|
|
18
|
+
* onto `QuoteEntityDeps`. Closes over the request-scoped db.
|
|
19
|
+
*/
|
|
20
|
+
export function createCatalogPromotionEvaluator(db) {
|
|
21
|
+
const source = createDrizzleOfferDataSource(db);
|
|
22
|
+
return async (input) => {
|
|
23
|
+
const result = await evaluateOffersForProduct(source, input);
|
|
24
|
+
// Shapes are 1:1 between the two `AppliedOffer` / `CodeStatus`
|
|
25
|
+
// declarations — catalog's contract intentionally mirrors the
|
|
26
|
+
// evaluator's so the bridge is just a structural pass-through.
|
|
27
|
+
return {
|
|
28
|
+
applied: result.applied,
|
|
29
|
+
total: result.total,
|
|
30
|
+
codeStatus: result.codeStatus,
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
}
|