@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 +3 -3
- package/dist/constants-BB5O8zlN.mjs +192 -0
- package/dist/index.d.mts +712 -174
- package/dist/index.mjs +568 -271
- package/dist/schemas/index.mjs +1 -2
- package/package.json +8 -7
- package/dist/constants-D0Rntp2f.mjs +0 -43
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
|
|
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 +
|
|
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
|
|
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 };
|