@classytic/promo 0.2.1 → 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.
@@ -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 };
package/dist/index.mjs CHANGED
@@ -1,145 +1,10 @@
1
- import { a as PROGRAM_TYPES, c as TRIGGER_MODES, i as PROGRAM_TRANSITIONS, l as VOUCHER_STATUSES, n as DISCOUNT_SCOPES, o as REWARD_TYPES, r as PROGRAM_STATUSES, s as STACKING_MODES, t as DISCOUNT_MODES } from "./constants-D0Rntp2f.mjs";
1
+ import { C as VoucherExhaustedError, E as PromoError, S as ValidationError, T as VoucherNotFoundError, _ as ProgramNotFoundError, a as PROGRAM_TYPES, b as RuleNotFoundError, c as TRIGGER_MODES, d as ConcurrencyConflictError, f as DuplicateRedemptionError, g as InvalidTransitionError, h as InsufficientBalanceError, i as PROGRAM_STATUSES, l as VOUCHER_STATUSES, m as GiftCardExhaustedError, n as DISCOUNT_SCOPES, o as REWARD_TYPES, p as EvaluationNotFoundError, r as PROGRAM_MACHINE, s as STACKING_MODES, t as DISCOUNT_MODES, u as CartHashMismatchError, v as ProgramUsageCapExceededError, w as VoucherExpiredError, x as TenantIsolationError, y as RewardNotFoundError } from "./constants-BB5O8zlN.mjs";
2
2
  import { Repository, multiTenantPlugin, withTransaction } from "@classytic/mongokit";
3
3
  import { resolveTenantConfig } from "@classytic/primitives/tenant";
4
4
  import { createEvent, matchEventPattern } from "@classytic/primitives/events";
5
5
  import mongoose, { Schema } from "mongoose";
6
6
  import { createHash, randomBytes } from "node:crypto";
7
7
  import { z } from "zod";
8
- //#region src/domain/errors/base.ts
9
- var PromoError = class extends Error {
10
- constructor(message) {
11
- super(message);
12
- this.name = this.constructor.name;
13
- }
14
- };
15
- //#endregion
16
- //#region src/domain/errors/domain-errors.ts
17
- var ValidationError = class extends PromoError {
18
- code = "VALIDATION_ERROR";
19
- };
20
- var ProgramNotFoundError = class extends PromoError {
21
- code = "PROGRAM_NOT_FOUND";
22
- constructor(id) {
23
- super(id ? `Program '${id}' not found` : "Program not found");
24
- }
25
- };
26
- /**
27
- * Raised when a commit-time atomic CAS on `usedCount < maxUsageTotal`
28
- * fails — i.e. the program's cap was exhausted by another concurrent
29
- * commit between this caller's evaluate and its commit. The host should
30
- * surface this as a "promo no longer available" failure to the user; the
31
- * order itself can still proceed without the discount if the host wishes.
32
- */
33
- var ProgramUsageCapExceededError = class extends PromoError {
34
- code = "PROGRAM_USAGE_CAP_EXCEEDED";
35
- constructor(programId, maxUsageTotal) {
36
- super(`Program '${programId}' has reached its usage cap of ${maxUsageTotal}`);
37
- this.programId = programId;
38
- this.maxUsageTotal = maxUsageTotal;
39
- }
40
- };
41
- var RuleNotFoundError = class extends PromoError {
42
- code = "RULE_NOT_FOUND";
43
- constructor(id) {
44
- super(id ? `Rule '${id}' not found` : "Rule not found");
45
- }
46
- };
47
- var RewardNotFoundError = class extends PromoError {
48
- code = "REWARD_NOT_FOUND";
49
- constructor(id) {
50
- super(id ? `Reward '${id}' not found` : "Reward not found");
51
- }
52
- };
53
- var VoucherNotFoundError = class extends PromoError {
54
- code = "VOUCHER_NOT_FOUND";
55
- constructor(codeOrId) {
56
- super(codeOrId ? `Voucher '${codeOrId}' not found` : "Voucher not found");
57
- }
58
- };
59
- var InvalidTransitionError = class extends PromoError {
60
- code = "INVALID_TRANSITION";
61
- constructor(from, to) {
62
- super(`Cannot transition from '${from}' to '${to}'`);
63
- }
64
- };
65
- var VoucherExpiredError = class extends PromoError {
66
- code = "VOUCHER_EXPIRED";
67
- constructor(code) {
68
- super(`Voucher '${code}' has expired`);
69
- }
70
- };
71
- var VoucherExhaustedError = class extends PromoError {
72
- code = "VOUCHER_EXHAUSTED";
73
- constructor(code) {
74
- super(`Voucher '${code}' has reached its usage limit`);
75
- }
76
- };
77
- /**
78
- * Thrown when a gift card's balance has been fully spent and its status
79
- * flipped to `'used'`. Distinct from `VoucherExhaustedError` (usage-limit
80
- * exhaustion on a discount voucher) so hosts can show "top up" vs "retry
81
- * later" messaging.
82
- */
83
- var GiftCardExhaustedError = class extends PromoError {
84
- code = "GIFT_CARD_EXHAUSTED";
85
- constructor(code) {
86
- super(`Gift card '${code}' has been fully spent`);
87
- }
88
- };
89
- /**
90
- * Thrown when a MongoDB WriteConflict surfaces under voucher-spend
91
- * contention. Losers see a stable domain shape rather than the raw
92
- * `"Write conflict during plan execution"` string. Hosts can translate
93
- * this to HTTP 409 + retry.
94
- */
95
- var ConcurrencyConflictError = class extends PromoError {
96
- code = "CONCURRENCY_CONFLICT";
97
- status = 409;
98
- constructor(resource, resourceId, cause) {
99
- super(`Concurrent modification on ${resource} '${resourceId}'`);
100
- this.resource = resource;
101
- this.resourceId = resourceId;
102
- this.cause = cause;
103
- }
104
- };
105
- var InsufficientBalanceError = class extends PromoError {
106
- code = "INSUFFICIENT_BALANCE";
107
- constructor(code, available, requested) {
108
- super(`Voucher '${code}' has insufficient balance: ${available} available, ${requested} requested`);
109
- }
110
- };
111
- var TenantIsolationError = class extends PromoError {
112
- code = "TENANT_ISOLATION";
113
- constructor() {
114
- super("Tenant context is required but was not provided");
115
- }
116
- };
117
- var DuplicateRedemptionError = class extends PromoError {
118
- code = "DUPLICATE_REDEMPTION";
119
- constructor(key) {
120
- super(`Duplicate redemption detected for idempotency key '${key}'`);
121
- }
122
- };
123
- var EvaluationNotFoundError = class extends PromoError {
124
- code = "EVALUATION_NOT_FOUND";
125
- constructor(id) {
126
- super(`Evaluation '${id}' not found or already committed`);
127
- }
128
- };
129
- /**
130
- * Thrown by `EvaluationService.commit` when the cart hash provided by the
131
- * caller does not match the cart hash computed at evaluation time. Guards
132
- * against cart-tampering attacks where a user previews a large discount on
133
- * a heavy cart, mutates the cart to something cheaper before committing, and
134
- * tries to apply the stale discount to the altered order.
135
- */
136
- var CartHashMismatchError = class extends PromoError {
137
- code = "CART_HASH_MISMATCH";
138
- constructor(evaluationId) {
139
- super(`Evaluation '${evaluationId}' cart hash mismatch — the cart changed between evaluate and commit. Re-evaluate with the current cart.`);
140
- }
141
- };
142
- //#endregion
143
8
  //#region src/events/in-process-bus.ts
144
9
  var InProcessPromoBus = class {
145
10
  name = "in-process-promo";
@@ -701,7 +566,7 @@ var ProgramRepository = class extends Repository {
701
566
  ...ctx
702
567
  });
703
568
  if (!program) throw new ProgramNotFoundError(id);
704
- if (!PROGRAM_TRANSITIONS[program.status]?.includes(targetStatus)) throw new InvalidTransitionError(program.status, targetStatus);
569
+ PROGRAM_MACHINE.assertTransition(String(program._id), program.status, targetStatus);
705
570
  const updated = await this.update(id, { status: targetStatus }, {
706
571
  throwOnNotFound: true,
707
572
  lean: true,
@@ -1,4 +1,4 @@
1
- import { a as PROGRAM_TYPES, c as TRIGGER_MODES, l as VOUCHER_STATUSES, n as DISCOUNT_SCOPES, o as REWARD_TYPES, r as PROGRAM_STATUSES, s as STACKING_MODES, t as DISCOUNT_MODES } from "../constants-D0Rntp2f.mjs";
1
+ import { a as PROGRAM_TYPES, c as TRIGGER_MODES, i as PROGRAM_STATUSES, l as VOUCHER_STATUSES, n as DISCOUNT_SCOPES, o as REWARD_TYPES, s as STACKING_MODES, t as DISCOUNT_MODES } from "../constants-BB5O8zlN.mjs";
2
2
  import { z } from "zod";
3
3
  //#region src/schemas/index.ts
4
4
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/promo",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Production-grade promotion, coupon, and discount engine for MongoDB — programs, rules, rewards, vouchers, gift cards, buy-x-get-y",
5
5
  "author": "Classytic",
6
6
  "homepage": "https://www.npmjs.com/package/@classytic/promo",
@@ -58,7 +58,7 @@
58
58
  },
59
59
  "peerDependencies": {
60
60
  "@classytic/mongokit": ">=3.11.0",
61
- "@classytic/primitives": ">=0.1.1",
61
+ "@classytic/primitives": ">=0.2.0",
62
62
  "mongoose": ">=9.4.1",
63
63
  "zod": ">=4.0.0"
64
64
  },
@@ -66,7 +66,7 @@
66
66
  "@biomejs/biome": "^2.4.9",
67
67
  "@classytic/dev-tools": "^0.2.0",
68
68
  "@classytic/mongokit": ">=3.11.0",
69
- "@classytic/primitives": ">=0.1.1",
69
+ "@classytic/primitives": ">=0.2.0",
70
70
  "@types/node": "^25.5.0",
71
71
  "@vitest/coverage-v8": "^3.2.4",
72
72
  "knip": "^6.3.0",
@@ -1,43 +0,0 @@
1
- //#region src/constants.ts
2
- const PROGRAM_TYPES = [
3
- "promotion",
4
- "coupon",
5
- "discount_code",
6
- "buy_x_get_y",
7
- "gift_card"
8
- ];
9
- const TRIGGER_MODES = ["auto", "code"];
10
- const PROGRAM_STATUSES = [
11
- "draft",
12
- "active",
13
- "paused",
14
- "expired",
15
- "archived"
16
- ];
17
- const STACKING_MODES = ["exclusive", "stackable"];
18
- const REWARD_TYPES = ["discount", "free_product"];
19
- const DISCOUNT_MODES = ["percentage", "fixed"];
20
- const DISCOUNT_SCOPES = [
21
- "order",
22
- "cheapest",
23
- "specific_products"
24
- ];
25
- const VOUCHER_STATUSES = [
26
- "active",
27
- "used",
28
- "expired",
29
- "cancelled"
30
- ];
31
- const PROGRAM_TRANSITIONS = {
32
- draft: ["active", "archived"],
33
- active: [
34
- "paused",
35
- "expired",
36
- "archived"
37
- ],
38
- paused: ["active", "archived"],
39
- expired: ["archived"],
40
- archived: []
41
- };
42
- //#endregion
43
- export { PROGRAM_TYPES as a, TRIGGER_MODES as c, PROGRAM_TRANSITIONS as i, VOUCHER_STATUSES as l, DISCOUNT_SCOPES as n, REWARD_TYPES as o, PROGRAM_STATUSES as r, STACKING_MODES as s, DISCOUNT_MODES as t };