@classytic/promo 0.2.0 → 0.2.2

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 CHANGED
@@ -151,7 +151,7 @@ if (result.appliedDiscounts.length > 0) {
151
151
  - stacking cap (`stackingMode: 'exclusive'` blocks subsequent programs; `maxStackablePromotions` caps the stackable chain).
152
152
  5. **Best-rule matching** — for each program, `matchBestRule` picks the highest-threshold rule whose gates all pass (code, date range, minimumAmount, minimumQuantity, product/category/SKU filters, `buyQuantity`). Scoring: `minimumAmount * 1000 + minimumQuantity`.
153
153
  6. **Reward computation** — linked rewards run through `computeDiscount` (scope-aware: `order`, `cheapest`, or `specific_products`; `maxDiscountAmount` capped; never exceeds running subtotal) or emit a `FreeProductLine`. The `runningSubtotal` decreases as each discount stacks.
154
- 7. **Cart-hash + evaluation stash** — a SHA-256 hash of the normalized items + subtotal + codes + customer identity is computed and returned on the `EvaluationResult`. Non-preview evaluations are stashed in an in-memory pending map keyed by `evaluationId` so `commit()` can replay program + voucher usage writes inside a single transaction.
154
+ 7. **Cart-hash + evaluation snapshot** — a SHA-256 hash of the normalized items + subtotal + codes + customer identity is computed and returned on the `EvaluationResult`. Non-preview evaluations are persisted via the configured `EvaluationStore` (Mongo by default; hosts can plug Redis / DynamoDB / custom by implementing the port) so `commit()` can replay program + voucher usage writes inside a single transaction. Snapshots survive process restart, horizontal scaling, serverless cold starts, and worker handoff. The default Mongo store applies a TTL index so abandoned snapshots auto-expire (default 30 min).
155
155
  8. **Dispatch `EVALUATION_COMPLETED`** via `dispatchPromoEvent` — routed through the configured `events.transport` and optionally persisted via the host `outbox` inside `ctx.session`.
156
156
 
157
157
  `commit(evaluationId, orderId, ctx, { cartHash })` (same service) consumes the stash: it optionally re-verifies the submitted `cartHash` (throws `CartHashMismatchError` on mismatch), opens a transaction, increments `program.usedCount` + per-customer usage on every applied program, increments voucher usage + appends a redemption record per applied voucher, emits `EVALUATION_COMMITTED`, and returns a `CommitResult`. `rollback(evaluationId, ctx)` drops the stash and emits `EVALUATION_ROLLED_BACK`.
@@ -171,7 +171,7 @@ All four repositories extend mongokit's `Repository<T>` directly — no wrapper
171
171
  | `reward` | (inherited only) |
172
172
  | `voucher` | `cancel`, `incrementUsage` (atomic `$inc` + `$push`), `addLedgerEntry` (atomic balance delta + ledger push), `expireByDate`, `getByCode`, `hasIdempotencyKey` |
173
173
 
174
- The two services (`engine.services.voucher`, `engine.services.evaluation`) exist because they coordinate multiple repositories + transactions + config: code generation + redemption + gift-card spend/top-up for vouchers, and the multi-program evaluation algorithm + in-memory pending-commit stash for evaluations.
174
+ The two services (`engine.services.voucher`, `engine.services.evaluation`) exist because they coordinate multiple repositories + transactions + config: code generation + redemption + gift-card spend/top-up for vouchers, and the multi-program evaluation algorithm + persistent evaluation snapshot store for evaluations.
175
175
 
176
176
  ---
177
177
 
@@ -203,7 +203,7 @@ engine.events.subscribe?.(PromoEvents.VOUCHER_REDEEMED, async (evt) => {
203
203
  });
204
204
  ```
205
205
 
206
- Representative event names: `promo:program.created` / `.activated` / `.paused` / `.archived`, `promo:rule.added` / `.updated` / `.removed`, `promo:reward.added` / `.updated` / `.removed`, `promo:voucher.generated` / `.redeemed` / `.cancelled` / `.expired`, `promo:gift_card.spent` / `.topped_up` / `.exhausted`, `promo:evaluation.completed` / `.committed` / `.rolled_back`.
206
+ Representative event names: `promo.program.created` / `.activated` / `.paused` / `.archived`, `promo.rule.added` / `.updated` / `.removed`, `promo.reward.added` / `.updated` / `.removed`, `promo.voucher.generated` / `.redeemed` / `.cancelled` / `.expired`, `promo.gift_card.spent` / `.topped_up` / `.exhausted`, `promo.evaluation.completed` / `.committed` / `.rolled_back`. The full canonical list is exported as `PromoEvents` from the package root.
207
207
 
208
208
  ---
209
209
 
@@ -0,0 +1,192 @@
1
+ import { defineStateMachine } from "@classytic/primitives/state-machine";
2
+ //#region src/domain/errors/base.ts
3
+ var PromoError = class extends Error {
4
+ constructor(message) {
5
+ super(message);
6
+ this.name = this.constructor.name;
7
+ }
8
+ };
9
+ //#endregion
10
+ //#region src/domain/errors/domain-errors.ts
11
+ var ValidationError = class extends PromoError {
12
+ code = "VALIDATION_ERROR";
13
+ };
14
+ var ProgramNotFoundError = class extends PromoError {
15
+ code = "PROGRAM_NOT_FOUND";
16
+ constructor(id) {
17
+ super(id ? `Program '${id}' not found` : "Program not found");
18
+ }
19
+ };
20
+ /**
21
+ * Raised when a commit-time atomic CAS on `usedCount < maxUsageTotal`
22
+ * fails — i.e. the program's cap was exhausted by another concurrent
23
+ * commit between this caller's evaluate and its commit. The host should
24
+ * surface this as a "promo no longer available" failure to the user; the
25
+ * order itself can still proceed without the discount if the host wishes.
26
+ */
27
+ var ProgramUsageCapExceededError = class extends PromoError {
28
+ code = "PROGRAM_USAGE_CAP_EXCEEDED";
29
+ constructor(programId, maxUsageTotal) {
30
+ super(`Program '${programId}' has reached its usage cap of ${maxUsageTotal}`);
31
+ this.programId = programId;
32
+ this.maxUsageTotal = maxUsageTotal;
33
+ }
34
+ };
35
+ var RuleNotFoundError = class extends PromoError {
36
+ code = "RULE_NOT_FOUND";
37
+ constructor(id) {
38
+ super(id ? `Rule '${id}' not found` : "Rule not found");
39
+ }
40
+ };
41
+ var RewardNotFoundError = class extends PromoError {
42
+ code = "REWARD_NOT_FOUND";
43
+ constructor(id) {
44
+ super(id ? `Reward '${id}' not found` : "Reward not found");
45
+ }
46
+ };
47
+ var VoucherNotFoundError = class extends PromoError {
48
+ code = "VOUCHER_NOT_FOUND";
49
+ constructor(codeOrId) {
50
+ super(codeOrId ? `Voucher '${codeOrId}' not found` : "Voucher not found");
51
+ }
52
+ };
53
+ var InvalidTransitionError = class extends PromoError {
54
+ code = "INVALID_TRANSITION";
55
+ constructor(from, to) {
56
+ super(`Cannot transition from '${from}' to '${to}'`);
57
+ }
58
+ };
59
+ var VoucherExpiredError = class extends PromoError {
60
+ code = "VOUCHER_EXPIRED";
61
+ constructor(code) {
62
+ super(`Voucher '${code}' has expired`);
63
+ }
64
+ };
65
+ var VoucherExhaustedError = class extends PromoError {
66
+ code = "VOUCHER_EXHAUSTED";
67
+ constructor(code) {
68
+ super(`Voucher '${code}' has reached its usage limit`);
69
+ }
70
+ };
71
+ /**
72
+ * Thrown when a gift card's balance has been fully spent and its status
73
+ * flipped to `'used'`. Distinct from `VoucherExhaustedError` (usage-limit
74
+ * exhaustion on a discount voucher) so hosts can show "top up" vs "retry
75
+ * later" messaging.
76
+ */
77
+ var GiftCardExhaustedError = class extends PromoError {
78
+ code = "GIFT_CARD_EXHAUSTED";
79
+ constructor(code) {
80
+ super(`Gift card '${code}' has been fully spent`);
81
+ }
82
+ };
83
+ /**
84
+ * Thrown when a MongoDB WriteConflict surfaces under voucher-spend
85
+ * contention. Losers see a stable domain shape rather than the raw
86
+ * `"Write conflict during plan execution"` string. Hosts can translate
87
+ * this to HTTP 409 + retry.
88
+ */
89
+ var ConcurrencyConflictError = class extends PromoError {
90
+ code = "CONCURRENCY_CONFLICT";
91
+ status = 409;
92
+ constructor(resource, resourceId, cause) {
93
+ super(`Concurrent modification on ${resource} '${resourceId}'`);
94
+ this.resource = resource;
95
+ this.resourceId = resourceId;
96
+ this.cause = cause;
97
+ }
98
+ };
99
+ var InsufficientBalanceError = class extends PromoError {
100
+ code = "INSUFFICIENT_BALANCE";
101
+ constructor(code, available, requested) {
102
+ super(`Voucher '${code}' has insufficient balance: ${available} available, ${requested} requested`);
103
+ }
104
+ };
105
+ var TenantIsolationError = class extends PromoError {
106
+ code = "TENANT_ISOLATION";
107
+ constructor() {
108
+ super("Tenant context is required but was not provided");
109
+ }
110
+ };
111
+ var DuplicateRedemptionError = class extends PromoError {
112
+ code = "DUPLICATE_REDEMPTION";
113
+ constructor(key) {
114
+ super(`Duplicate redemption detected for idempotency key '${key}'`);
115
+ }
116
+ };
117
+ var EvaluationNotFoundError = class extends PromoError {
118
+ code = "EVALUATION_NOT_FOUND";
119
+ constructor(id) {
120
+ super(`Evaluation '${id}' not found or already committed`);
121
+ }
122
+ };
123
+ /**
124
+ * Thrown by `EvaluationService.commit` when the cart hash provided by the
125
+ * caller does not match the cart hash computed at evaluation time. Guards
126
+ * against cart-tampering attacks where a user previews a large discount on
127
+ * a heavy cart, mutates the cart to something cheaper before committing, and
128
+ * tries to apply the stale discount to the altered order.
129
+ */
130
+ var CartHashMismatchError = class extends PromoError {
131
+ code = "CART_HASH_MISMATCH";
132
+ constructor(evaluationId) {
133
+ super(`Evaluation '${evaluationId}' cart hash mismatch — the cart changed between evaluate and commit. Re-evaluate with the current cart.`);
134
+ }
135
+ };
136
+ //#endregion
137
+ //#region src/constants.ts
138
+ const PROGRAM_TYPES = [
139
+ "promotion",
140
+ "coupon",
141
+ "discount_code",
142
+ "buy_x_get_y",
143
+ "gift_card"
144
+ ];
145
+ const TRIGGER_MODES = ["auto", "code"];
146
+ const PROGRAM_STATUSES = [
147
+ "draft",
148
+ "active",
149
+ "paused",
150
+ "expired",
151
+ "archived"
152
+ ];
153
+ const STACKING_MODES = ["exclusive", "stackable"];
154
+ const REWARD_TYPES = ["discount", "free_product"];
155
+ const DISCOUNT_MODES = ["percentage", "fixed"];
156
+ const DISCOUNT_SCOPES = [
157
+ "order",
158
+ "cheapest",
159
+ "specific_products"
160
+ ];
161
+ const VOUCHER_STATUSES = [
162
+ "active",
163
+ "used",
164
+ "expired",
165
+ "cancelled"
166
+ ];
167
+ /**
168
+ * Program status machine — single throw site, declarative table.
169
+ *
170
+ * Wires promo's domain `InvalidTransitionError` (carries `from` + `to`)
171
+ * via `errorFactory` so existing catch sites keep working. The
172
+ * primitive's `entityId` field is dropped at the boundary — promo's
173
+ * error doesn't include it. If a future caller needs structured entity
174
+ * fields, extend the error first.
175
+ */
176
+ const PROGRAM_MACHINE = defineStateMachine({
177
+ name: "Program",
178
+ transitions: {
179
+ draft: ["active", "archived"],
180
+ active: [
181
+ "paused",
182
+ "expired",
183
+ "archived"
184
+ ],
185
+ paused: ["active", "archived"],
186
+ expired: ["archived"],
187
+ archived: []
188
+ },
189
+ errorFactory: ({ from, to }) => new InvalidTransitionError(from, to)
190
+ });
191
+ //#endregion
192
+ export { VoucherExhaustedError as C, PromoError as E, ValidationError as S, VoucherNotFoundError as T, ProgramNotFoundError as _, PROGRAM_TYPES as a, RuleNotFoundError as b, TRIGGER_MODES as c, ConcurrencyConflictError as d, DuplicateRedemptionError as f, InvalidTransitionError as g, InsufficientBalanceError as h, PROGRAM_STATUSES as i, VOUCHER_STATUSES as l, GiftCardExhaustedError as m, DISCOUNT_SCOPES as n, REWARD_TYPES as o, EvaluationNotFoundError as p, PROGRAM_MACHINE as r, STACKING_MODES as s, DISCOUNT_MODES as t, CartHashMismatchError as u, ProgramUsageCapExceededError as v, VoucherExpiredError as w, TenantIsolationError as x, RewardNotFoundError as y };