@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/dist/index.mjs
CHANGED
|
@@ -1,112 +1,10 @@
|
|
|
1
|
-
import { a as PROGRAM_TYPES, c as TRIGGER_MODES, i as
|
|
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
|
+
import { Repository, multiTenantPlugin, withTransaction } from "@classytic/mongokit";
|
|
2
3
|
import { resolveTenantConfig } from "@classytic/primitives/tenant";
|
|
3
4
|
import { createEvent, matchEventPattern } from "@classytic/primitives/events";
|
|
4
|
-
import mongoose from "mongoose";
|
|
5
|
-
import { Repository, multiTenantPlugin } from "@classytic/mongokit";
|
|
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
|
-
var VoucherNotFoundError = class extends PromoError {
|
|
27
|
-
code = "VOUCHER_NOT_FOUND";
|
|
28
|
-
constructor(codeOrId) {
|
|
29
|
-
super(codeOrId ? `Voucher '${codeOrId}' not found` : "Voucher not found");
|
|
30
|
-
}
|
|
31
|
-
};
|
|
32
|
-
var InvalidTransitionError = class extends PromoError {
|
|
33
|
-
code = "INVALID_TRANSITION";
|
|
34
|
-
constructor(from, to) {
|
|
35
|
-
super(`Cannot transition from '${from}' to '${to}'`);
|
|
36
|
-
}
|
|
37
|
-
};
|
|
38
|
-
var VoucherExpiredError = class extends PromoError {
|
|
39
|
-
code = "VOUCHER_EXPIRED";
|
|
40
|
-
constructor(code) {
|
|
41
|
-
super(`Voucher '${code}' has expired`);
|
|
42
|
-
}
|
|
43
|
-
};
|
|
44
|
-
var VoucherExhaustedError = class extends PromoError {
|
|
45
|
-
code = "VOUCHER_EXHAUSTED";
|
|
46
|
-
constructor(code) {
|
|
47
|
-
super(`Voucher '${code}' has reached its usage limit`);
|
|
48
|
-
}
|
|
49
|
-
};
|
|
50
|
-
/**
|
|
51
|
-
* Thrown when a gift card's balance has been fully spent and its status
|
|
52
|
-
* flipped to `'used'`. Distinct from `VoucherExhaustedError` (usage-limit
|
|
53
|
-
* exhaustion on a discount voucher) so hosts can show "top up" vs "retry
|
|
54
|
-
* later" messaging.
|
|
55
|
-
*/
|
|
56
|
-
var GiftCardExhaustedError = class extends PromoError {
|
|
57
|
-
code = "GIFT_CARD_EXHAUSTED";
|
|
58
|
-
constructor(code) {
|
|
59
|
-
super(`Gift card '${code}' has been fully spent`);
|
|
60
|
-
}
|
|
61
|
-
};
|
|
62
|
-
/**
|
|
63
|
-
* Thrown when a MongoDB WriteConflict surfaces under voucher-spend
|
|
64
|
-
* contention. Losers see a stable domain shape rather than the raw
|
|
65
|
-
* `"Write conflict during plan execution"` string. Hosts can translate
|
|
66
|
-
* this to HTTP 409 + retry.
|
|
67
|
-
*/
|
|
68
|
-
var ConcurrencyConflictError = class extends PromoError {
|
|
69
|
-
code = "CONCURRENCY_CONFLICT";
|
|
70
|
-
status = 409;
|
|
71
|
-
constructor(resource, resourceId, cause) {
|
|
72
|
-
super(`Concurrent modification on ${resource} '${resourceId}'`);
|
|
73
|
-
this.resource = resource;
|
|
74
|
-
this.resourceId = resourceId;
|
|
75
|
-
this.cause = cause;
|
|
76
|
-
}
|
|
77
|
-
};
|
|
78
|
-
var InsufficientBalanceError = class extends PromoError {
|
|
79
|
-
code = "INSUFFICIENT_BALANCE";
|
|
80
|
-
constructor(code, available, requested) {
|
|
81
|
-
super(`Voucher '${code}' has insufficient balance: ${available} available, ${requested} requested`);
|
|
82
|
-
}
|
|
83
|
-
};
|
|
84
|
-
var DuplicateRedemptionError = class extends PromoError {
|
|
85
|
-
code = "DUPLICATE_REDEMPTION";
|
|
86
|
-
constructor(key) {
|
|
87
|
-
super(`Duplicate redemption detected for idempotency key '${key}'`);
|
|
88
|
-
}
|
|
89
|
-
};
|
|
90
|
-
var EvaluationNotFoundError = class extends PromoError {
|
|
91
|
-
code = "EVALUATION_NOT_FOUND";
|
|
92
|
-
constructor(id) {
|
|
93
|
-
super(`Evaluation '${id}' not found or already committed`);
|
|
94
|
-
}
|
|
95
|
-
};
|
|
96
|
-
/**
|
|
97
|
-
* Thrown by `EvaluationService.commit` when the cart hash provided by the
|
|
98
|
-
* caller does not match the cart hash computed at evaluation time. Guards
|
|
99
|
-
* against cart-tampering attacks where a user previews a large discount on
|
|
100
|
-
* a heavy cart, mutates the cart to something cheaper before committing, and
|
|
101
|
-
* tries to apply the stale discount to the altered order.
|
|
102
|
-
*/
|
|
103
|
-
var CartHashMismatchError = class extends PromoError {
|
|
104
|
-
code = "CART_HASH_MISMATCH";
|
|
105
|
-
constructor(evaluationId) {
|
|
106
|
-
super(`Evaluation '${evaluationId}' cart hash mismatch — the cart changed between evaluate and commit. Re-evaluate with the current cart.`);
|
|
107
|
-
}
|
|
108
|
-
};
|
|
109
|
-
//#endregion
|
|
110
8
|
//#region src/events/in-process-bus.ts
|
|
111
9
|
var InProcessPromoBus = class {
|
|
112
10
|
name = "in-process-promo";
|
|
@@ -169,10 +67,81 @@ function applyUserIndexes(schema, indexes, tenant) {
|
|
|
169
67
|
}
|
|
170
68
|
}
|
|
171
69
|
//#endregion
|
|
70
|
+
//#region src/models/schemas/pending-evaluation.schema.ts
|
|
71
|
+
/**
|
|
72
|
+
* Persisted snapshot of an `evaluate()` outcome, awaiting a follow-up
|
|
73
|
+
* `commit()` or `rollback()`.
|
|
74
|
+
*
|
|
75
|
+
* Why a Mongo collection (not a process-local Map): pending evaluations
|
|
76
|
+
* MUST survive process restart, horizontal scaling, serverless cold
|
|
77
|
+
* starts, and worker handoff. Stale snapshots auto-clean via the TTL
|
|
78
|
+
* index — same pattern cart uses for guest drafts (see
|
|
79
|
+
* cart/src/models/draft.model.ts:131-135).
|
|
80
|
+
*
|
|
81
|
+
* Stored fields are intentionally minimal — only what `commit()` and
|
|
82
|
+
* `rollback()` need:
|
|
83
|
+
* - the materialised result (returned to caller for telemetry)
|
|
84
|
+
* - per-program / per-voucher usages (incremented atomically at commit)
|
|
85
|
+
* - cartHash (anti-tamper guard between evaluate and commit)
|
|
86
|
+
* - expiresAt (TTL drives auto-cleanup; engine never relies on it for
|
|
87
|
+
* correctness — the atomic `findOneAndDelete` in `take()` is the
|
|
88
|
+
* authoritative single-commit guard)
|
|
89
|
+
*/
|
|
90
|
+
function createPendingEvaluationSchema() {
|
|
91
|
+
return new Schema({
|
|
92
|
+
evaluationId: {
|
|
93
|
+
type: String,
|
|
94
|
+
required: true,
|
|
95
|
+
unique: true,
|
|
96
|
+
index: true
|
|
97
|
+
},
|
|
98
|
+
result: {
|
|
99
|
+
type: Schema.Types.Mixed,
|
|
100
|
+
required: true
|
|
101
|
+
},
|
|
102
|
+
ctx: {
|
|
103
|
+
type: Schema.Types.Mixed,
|
|
104
|
+
required: true
|
|
105
|
+
},
|
|
106
|
+
customerId: {
|
|
107
|
+
type: String,
|
|
108
|
+
default: null
|
|
109
|
+
},
|
|
110
|
+
programUsages: {
|
|
111
|
+
type: [Schema.Types.Mixed],
|
|
112
|
+
default: []
|
|
113
|
+
},
|
|
114
|
+
voucherUsages: {
|
|
115
|
+
type: [Schema.Types.Mixed],
|
|
116
|
+
default: []
|
|
117
|
+
},
|
|
118
|
+
cartHash: {
|
|
119
|
+
type: String,
|
|
120
|
+
required: true,
|
|
121
|
+
index: true
|
|
122
|
+
},
|
|
123
|
+
expiresAt: {
|
|
124
|
+
type: Date,
|
|
125
|
+
required: true
|
|
126
|
+
}
|
|
127
|
+
}, {
|
|
128
|
+
timestamps: true,
|
|
129
|
+
minimize: false
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Wire the TTL index. Mongo evaluates `expireAfterSeconds: 0` against the
|
|
134
|
+
* `expiresAt` field's value — when `expiresAt < now`, the doc is purged
|
|
135
|
+
* by the background TTL monitor (~60s granularity).
|
|
136
|
+
*/
|
|
137
|
+
function applyPendingEvaluationIndexes(schema) {
|
|
138
|
+
schema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
|
139
|
+
}
|
|
140
|
+
//#endregion
|
|
172
141
|
//#region src/models/schemas/program.schema.ts
|
|
173
|
-
const { Schema: Schema$
|
|
142
|
+
const { Schema: Schema$4 } = mongoose;
|
|
174
143
|
function createProgramSchema() {
|
|
175
|
-
const schema = new Schema$
|
|
144
|
+
const schema = new Schema$4({
|
|
176
145
|
name: {
|
|
177
146
|
type: String,
|
|
178
147
|
required: true
|
|
@@ -217,7 +186,7 @@ function createProgramSchema() {
|
|
|
217
186
|
of: Number,
|
|
218
187
|
default: () => /* @__PURE__ */ new Map()
|
|
219
188
|
},
|
|
220
|
-
metadata: { type: Schema$
|
|
189
|
+
metadata: { type: Schema$4.Types.Mixed }
|
|
221
190
|
}, { timestamps: true });
|
|
222
191
|
schema.index({
|
|
223
192
|
status: 1,
|
|
@@ -238,14 +207,14 @@ function createProgramSchema() {
|
|
|
238
207
|
}
|
|
239
208
|
//#endregion
|
|
240
209
|
//#region src/models/schemas/reward.schema.ts
|
|
241
|
-
const { Schema: Schema$
|
|
210
|
+
const { Schema: Schema$3 } = mongoose;
|
|
242
211
|
function createRewardSchema() {
|
|
243
|
-
const schema = new Schema$
|
|
212
|
+
const schema = new Schema$3({
|
|
244
213
|
programId: {
|
|
245
|
-
type: Schema$
|
|
214
|
+
type: Schema$3.Types.ObjectId,
|
|
246
215
|
required: true
|
|
247
216
|
},
|
|
248
|
-
ruleId: { type: Schema$
|
|
217
|
+
ruleId: { type: Schema$3.Types.ObjectId },
|
|
249
218
|
rewardType: {
|
|
250
219
|
type: String,
|
|
251
220
|
enum: REWARD_TYPES,
|
|
@@ -270,7 +239,7 @@ function createRewardSchema() {
|
|
|
270
239
|
default: 1
|
|
271
240
|
},
|
|
272
241
|
giftCardAmount: { type: Number },
|
|
273
|
-
metadata: { type: Schema$
|
|
242
|
+
metadata: { type: Schema$3.Types.Mixed }
|
|
274
243
|
}, { timestamps: true });
|
|
275
244
|
schema.index({ programId: 1 });
|
|
276
245
|
schema.index({ ruleId: 1 }, { sparse: true });
|
|
@@ -278,11 +247,11 @@ function createRewardSchema() {
|
|
|
278
247
|
}
|
|
279
248
|
//#endregion
|
|
280
249
|
//#region src/models/schemas/rule.schema.ts
|
|
281
|
-
const { Schema: Schema$
|
|
250
|
+
const { Schema: Schema$2 } = mongoose;
|
|
282
251
|
function createRuleSchema() {
|
|
283
|
-
const schema = new Schema$
|
|
252
|
+
const schema = new Schema$2({
|
|
284
253
|
programId: {
|
|
285
|
-
type: Schema$
|
|
254
|
+
type: Schema$2.Types.ObjectId,
|
|
286
255
|
required: true
|
|
287
256
|
},
|
|
288
257
|
name: { type: String },
|
|
@@ -305,7 +274,7 @@ function createRuleSchema() {
|
|
|
305
274
|
},
|
|
306
275
|
startsAt: { type: Date },
|
|
307
276
|
endsAt: { type: Date },
|
|
308
|
-
metadata: { type: Schema$
|
|
277
|
+
metadata: { type: Schema$2.Types.Mixed }
|
|
309
278
|
}, { timestamps: true });
|
|
310
279
|
schema.index({ programId: 1 });
|
|
311
280
|
schema.index({ code: 1 }, { sparse: true });
|
|
@@ -313,11 +282,11 @@ function createRuleSchema() {
|
|
|
313
282
|
}
|
|
314
283
|
//#endregion
|
|
315
284
|
//#region src/models/schemas/voucher.schema.ts
|
|
316
|
-
const { Schema } = mongoose;
|
|
285
|
+
const { Schema: Schema$1 } = mongoose;
|
|
317
286
|
function createVoucherSchema() {
|
|
318
|
-
const schema = new Schema({
|
|
287
|
+
const schema = new Schema$1({
|
|
319
288
|
programId: {
|
|
320
|
-
type: Schema.Types.ObjectId,
|
|
289
|
+
type: Schema$1.Types.ObjectId,
|
|
321
290
|
required: true,
|
|
322
291
|
index: true
|
|
323
292
|
},
|
|
@@ -354,7 +323,8 @@ function createVoucherSchema() {
|
|
|
354
323
|
type: Date,
|
|
355
324
|
default: Date.now
|
|
356
325
|
},
|
|
357
|
-
idempotencyKey: { type: String }
|
|
326
|
+
idempotencyKey: { type: String },
|
|
327
|
+
organizationId: { type: String }
|
|
358
328
|
}],
|
|
359
329
|
expiresAt: { type: Date },
|
|
360
330
|
redemptions: [{
|
|
@@ -371,9 +341,10 @@ function createVoucherSchema() {
|
|
|
371
341
|
type: Date,
|
|
372
342
|
default: Date.now
|
|
373
343
|
},
|
|
374
|
-
idempotencyKey: { type: String }
|
|
344
|
+
idempotencyKey: { type: String },
|
|
345
|
+
organizationId: { type: String }
|
|
375
346
|
}],
|
|
376
|
-
metadata: { type: Schema.Types.Mixed }
|
|
347
|
+
metadata: { type: Schema$1.Types.Mixed }
|
|
377
348
|
}, { timestamps: true });
|
|
378
349
|
schema.index({ code: 1 }, { unique: true });
|
|
379
350
|
schema.index({
|
|
@@ -393,7 +364,8 @@ const MODEL_NAMES = [
|
|
|
393
364
|
"PromoProgram",
|
|
394
365
|
"PromoRule",
|
|
395
366
|
"PromoReward",
|
|
396
|
-
"PromoVoucher"
|
|
367
|
+
"PromoVoucher",
|
|
368
|
+
"PromoPendingEvaluation"
|
|
397
369
|
];
|
|
398
370
|
function applyAutoIndex(models, autoIndex) {
|
|
399
371
|
if (autoIndex === void 0) return;
|
|
@@ -413,10 +385,13 @@ function createModels(connection, tenant, indexes, autoIndex) {
|
|
|
413
385
|
const ruleSchema = createRuleSchema();
|
|
414
386
|
const rewardSchema = createRewardSchema();
|
|
415
387
|
const voucherSchema = createVoucherSchema();
|
|
388
|
+
const pendingEvaluationSchema = createPendingEvaluationSchema();
|
|
416
389
|
injectTenantField(programSchema, tenant);
|
|
417
390
|
injectTenantField(ruleSchema, tenant);
|
|
418
391
|
injectTenantField(rewardSchema, tenant);
|
|
419
392
|
injectTenantField(voucherSchema, tenant);
|
|
393
|
+
injectTenantField(pendingEvaluationSchema, tenant);
|
|
394
|
+
applyPendingEvaluationIndexes(pendingEvaluationSchema);
|
|
420
395
|
if (indexes?.program) applyUserIndexes(programSchema, indexes.program, tenant);
|
|
421
396
|
if (indexes?.rule) applyUserIndexes(ruleSchema, indexes.rule, tenant);
|
|
422
397
|
if (indexes?.reward) applyUserIndexes(rewardSchema, indexes.reward, tenant);
|
|
@@ -425,12 +400,94 @@ function createModels(connection, tenant, indexes, autoIndex) {
|
|
|
425
400
|
Program: connection.model("PromoProgram", programSchema),
|
|
426
401
|
Rule: connection.model("PromoRule", ruleSchema),
|
|
427
402
|
Reward: connection.model("PromoReward", rewardSchema),
|
|
428
|
-
Voucher: connection.model("PromoVoucher", voucherSchema)
|
|
403
|
+
Voucher: connection.model("PromoVoucher", voucherSchema),
|
|
404
|
+
PendingEvaluation: connection.model("PromoPendingEvaluation", pendingEvaluationSchema)
|
|
429
405
|
};
|
|
430
406
|
applyAutoIndex(result, autoIndex);
|
|
431
407
|
return result;
|
|
432
408
|
}
|
|
433
409
|
//#endregion
|
|
410
|
+
//#region src/repositories/pending-evaluation.repository.ts
|
|
411
|
+
/**
|
|
412
|
+
* Pending-evaluation repository. Extends mongokit's `Repository<TDoc>`
|
|
413
|
+
* directly per package rules (no service wrapper, no aliased verbs).
|
|
414
|
+
* Adds one custom domain method: `takeByEvaluationId` — atomic
|
|
415
|
+
* read-and-delete via raw `Model.findOneAndDelete`.
|
|
416
|
+
*
|
|
417
|
+
* **Why raw `findOneAndDelete` (escape from mongokit's `delete()`)**:
|
|
418
|
+
* mongokit's `Repository.delete()` returns `{success, message}` only —
|
|
419
|
+
* it doesn't surface the deleted document. `take` semantics require
|
|
420
|
+
* "atomically remove AND return", which is the canonical defence
|
|
421
|
+
* against double-commit on the same evaluationId at the storage layer
|
|
422
|
+
* (one caller wins the document, the other gets `null`). This is the
|
|
423
|
+
* narrow exception PACKAGE_RULES.md / order/CLAUDE.md sanction:
|
|
424
|
+
* *"Raw findOneAndUpdate/findOneAndDelete is allowed ONLY for atomic
|
|
425
|
+
* state-machine transitions — flag each one with a comment."*
|
|
426
|
+
*/
|
|
427
|
+
var PendingEvaluationRepository = class extends Repository {
|
|
428
|
+
/**
|
|
429
|
+
* The repository owns its tenant config so its raw-driver methods
|
|
430
|
+
* (`findOneAndDelete`, `deleteOne`) can apply the SAME scoping rule
|
|
431
|
+
* the mongokit hook pipeline would apply on standard methods —
|
|
432
|
+
* specifically using `tenant.tenantField` (host-configurable as
|
|
433
|
+
* `organizationId`, `branchId`, `tenantId`, etc.) NOT a hardcoded
|
|
434
|
+
* `organizationId`. Without this, deployments that configure custom
|
|
435
|
+
* tenant fields would silently lose isolation on the cache layer.
|
|
436
|
+
*/
|
|
437
|
+
tenant;
|
|
438
|
+
constructor(model, plugins = [], tenant) {
|
|
439
|
+
super(model, plugins);
|
|
440
|
+
this.tenant = tenant;
|
|
441
|
+
}
|
|
442
|
+
/** The host-configured tenant field name (or `undefined` if single-tenant). */
|
|
443
|
+
get tenantField() {
|
|
444
|
+
return this.tenant?.enabled ? this.tenant.tenantField : void 0;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Atomic read-and-delete by evaluationId. Two concurrent commit calls
|
|
448
|
+
* on the same id race here at the database layer — the winner gets
|
|
449
|
+
* the document, the loser gets `null` and the calling service throws
|
|
450
|
+
* `EvaluationNotFoundError`. No way both succeed.
|
|
451
|
+
*
|
|
452
|
+
* Honours `ctx.session` so the operation joins the caller's
|
|
453
|
+
* transaction. If the transaction aborts (transient DB error, cap
|
|
454
|
+
* exceeded, etc.) the delete rolls back and the snapshot stays in
|
|
455
|
+
* the store — letting the caller retry without re-evaluation.
|
|
456
|
+
*
|
|
457
|
+
* Tenant scoping uses the configured `tenant.tenantField` (NOT a
|
|
458
|
+
* hardcoded `organizationId`) so hosts on `branchId`/`tenantId`/etc.
|
|
459
|
+
* keep cross-tenant isolation on the cache layer too.
|
|
460
|
+
*/
|
|
461
|
+
async takeByEvaluationId(evaluationId, ctx) {
|
|
462
|
+
const filter = this.scopedFilter({ evaluationId }, ctx);
|
|
463
|
+
const session = ctx?.session ?? null;
|
|
464
|
+
return await this.Model.findOneAndDelete(filter).session(session).lean();
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Idempotent delete. Returns whether anything was actually removed,
|
|
468
|
+
* so callers can distinguish "we cleaned it" from "already gone".
|
|
469
|
+
*/
|
|
470
|
+
async deleteByEvaluationId(evaluationId, ctx) {
|
|
471
|
+
const filter = this.scopedFilter({ evaluationId }, ctx);
|
|
472
|
+
const session = ctx?.session ?? null;
|
|
473
|
+
return (await this.Model.deleteOne(filter).session(session)).deletedCount > 0;
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Build a query filter with the configured tenant scope appended,
|
|
477
|
+
* mirroring what mongokit's `multiTenantPlugin` injects on standard
|
|
478
|
+
* Repository methods. We re-implement here because raw driver calls
|
|
479
|
+
* (`findOneAndDelete`, `deleteOne`) bypass the plugin pipeline.
|
|
480
|
+
*/
|
|
481
|
+
scopedFilter(base, ctx) {
|
|
482
|
+
const field = this.tenantField;
|
|
483
|
+
if (!field || ctx?.tenantValue === void 0) return base;
|
|
484
|
+
return {
|
|
485
|
+
...base,
|
|
486
|
+
[field]: ctx.tenantValue
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
//#endregion
|
|
434
491
|
//#region src/events/dispatch.ts
|
|
435
492
|
/**
|
|
436
493
|
* Canonical P8 shape — save to outbox first (with `ctx.session` when
|
|
@@ -509,7 +566,7 @@ var ProgramRepository = class extends Repository {
|
|
|
509
566
|
...ctx
|
|
510
567
|
});
|
|
511
568
|
if (!program) throw new ProgramNotFoundError(id);
|
|
512
|
-
|
|
569
|
+
PROGRAM_MACHINE.assertTransition(String(program._id), program.status, targetStatus);
|
|
513
570
|
const updated = await this.update(id, { status: targetStatus }, {
|
|
514
571
|
throwOnNotFound: true,
|
|
515
572
|
lean: true,
|
|
@@ -538,6 +595,34 @@ var ProgramRepository = class extends Repository {
|
|
|
538
595
|
if (!updated) throw new ProgramNotFoundError(id);
|
|
539
596
|
return updated;
|
|
540
597
|
}
|
|
598
|
+
/**
|
|
599
|
+
* Atomic compare-and-set increment: succeeds only if the program either
|
|
600
|
+
* has no cap (`maxUsageTotal == null`) OR `usedCount < maxUsageTotal`.
|
|
601
|
+
* If the cap is already saturated, returns `null` so the caller can
|
|
602
|
+
* decide whether to throw (commit-time enforcement) or skip silently
|
|
603
|
+
* (best-effort eligibility check).
|
|
604
|
+
*
|
|
605
|
+
* Industry-standard primitive for "promo with finite supply" — without
|
|
606
|
+
* this, two evaluations both see the program as available and both
|
|
607
|
+
* commit, leading to oversell. The atomic filter on the same write
|
|
608
|
+
* eliminates the race entirely; concurrent losers see a `null` return
|
|
609
|
+
* and can downgrade their commit (apply order without the discount,
|
|
610
|
+
* surface "promo no longer available" to the user, etc.).
|
|
611
|
+
*
|
|
612
|
+
* Routes through mongokit's `update` so tenant scoping + hooks fire.
|
|
613
|
+
*/
|
|
614
|
+
async tryIncrementUsage(id, ctx) {
|
|
615
|
+
return await this.update(id, { $inc: { usedCount: 1 } }, {
|
|
616
|
+
query: { $or: [
|
|
617
|
+
{ maxUsageTotal: { $exists: false } },
|
|
618
|
+
{ maxUsageTotal: null },
|
|
619
|
+
{ $expr: { $lt: ["$usedCount", "$maxUsageTotal"] } }
|
|
620
|
+
] },
|
|
621
|
+
throwOnNotFound: false,
|
|
622
|
+
lean: true,
|
|
623
|
+
...ctx
|
|
624
|
+
}) ?? null;
|
|
625
|
+
}
|
|
541
626
|
async decrementUsage(id, ctx) {
|
|
542
627
|
const updated = await this.update(id, { $inc: { usedCount: -1 } }, {
|
|
543
628
|
throwOnNotFound: true,
|
|
@@ -607,20 +692,27 @@ var RuleRepository = class extends Repository {
|
|
|
607
692
|
var VoucherRepository = class extends Repository {
|
|
608
693
|
dispatchDeps;
|
|
609
694
|
tenantField;
|
|
610
|
-
|
|
695
|
+
tenantEnabled;
|
|
696
|
+
constructor(model, plugins = [], dispatchDeps = {}, tenantField = "organizationId", tenantEnabled = true) {
|
|
611
697
|
super(model, plugins);
|
|
612
698
|
this.dispatchDeps = dispatchDeps;
|
|
613
699
|
this.tenantField = tenantField;
|
|
700
|
+
this.tenantEnabled = tenantEnabled;
|
|
614
701
|
}
|
|
615
702
|
/**
|
|
616
703
|
* Copy the tenant id from `ctx` onto the write payload so the doc persists
|
|
617
704
|
* with the correct `organizationId` even when the host has opted OUT of the
|
|
618
|
-
* auto-wired `multiTenantPlugin`
|
|
619
|
-
*
|
|
620
|
-
* `organizationId` explicitly on the doc").
|
|
621
|
-
*
|
|
705
|
+
* auto-wired `multiTenantPlugin` but still scopes at its framework layer
|
|
706
|
+
* (e.g. arc's preset + `BaseController` — see `@classytic/order` CLAUDE.md:
|
|
707
|
+
* "Child repos set `organizationId` explicitly on the doc").
|
|
708
|
+
*
|
|
709
|
+
* Skipped entirely when the engine was configured with `tenant: false` —
|
|
710
|
+
* the host's intent is company-wide rows (no `organizationId` on disk),
|
|
711
|
+
* and injecting it from `ctx` would silently re-scope writes per branch
|
|
712
|
+
* while reads still look at the unscoped collection.
|
|
622
713
|
*/
|
|
623
714
|
_injectTenant(data, ctx) {
|
|
715
|
+
if (!this.tenantEnabled) return data;
|
|
624
716
|
const tenantValue = ctx?.[this.tenantField];
|
|
625
717
|
if (tenantValue != null && data[this.tenantField] == null) data[this.tenantField] = tenantValue;
|
|
626
718
|
return data;
|
|
@@ -722,11 +814,17 @@ var VoucherRepository = class extends Repository {
|
|
|
722
814
|
* (e.g. arc's preset + `BaseController`). Without this, a host that
|
|
723
815
|
* runs the plugin off would leak vouchers across branches at
|
|
724
816
|
* validate/redeem/spend/topUp call sites.
|
|
817
|
+
*
|
|
818
|
+
* When the engine is configured with `tenant: false`, the filter is
|
|
819
|
+
* code-only — vouchers are company-wide and the docs carry no
|
|
820
|
+
* `organizationId`, so injecting one would always miss.
|
|
725
821
|
*/
|
|
726
822
|
async getByCode(code, ctx) {
|
|
727
823
|
const filter = { code: code.toUpperCase() };
|
|
728
|
-
|
|
729
|
-
|
|
824
|
+
if (this.tenantEnabled) {
|
|
825
|
+
const tenantValue = ctx?.[this.tenantField];
|
|
826
|
+
if (tenantValue != null) filter[this.tenantField] = tenantValue;
|
|
827
|
+
}
|
|
730
828
|
return this.getByQuery(filter, {
|
|
731
829
|
throwOnNotFound: false,
|
|
732
830
|
lean: true,
|
|
@@ -758,12 +856,148 @@ function createRepositories(models, plugins, tenant, dispatchDeps = {}) {
|
|
|
758
856
|
program: new ProgramRepository(models.Program, [...tenantPlugins, ...plugins?.program ?? []], dispatchDeps),
|
|
759
857
|
rule: new RuleRepository(models.Rule, [...tenantPlugins, ...plugins?.rule ?? []]),
|
|
760
858
|
reward: new RewardRepository(models.Reward, [...tenantPlugins, ...plugins?.reward ?? []]),
|
|
761
|
-
voucher: new VoucherRepository(models.Voucher, [...tenantPlugins, ...plugins?.voucher ?? []], dispatchDeps, tenant?.tenantField)
|
|
859
|
+
voucher: new VoucherRepository(models.Voucher, [...tenantPlugins, ...plugins?.voucher ?? []], dispatchDeps, tenant?.tenantField, tenant?.enabled ?? false),
|
|
860
|
+
pendingEvaluation: new PendingEvaluationRepository(models.PendingEvaluation, [...tenantPlugins, ...plugins?.pendingEvaluation ?? []], tenant)
|
|
762
861
|
};
|
|
763
862
|
}
|
|
764
863
|
//#endregion
|
|
864
|
+
//#region src/adapters/mongo-evaluation-store.ts
|
|
865
|
+
/**
|
|
866
|
+
* Default Mongo-backed implementation of {@link EvaluationStore}. Translates
|
|
867
|
+
* between the domain snapshot shape and the persistence document shape,
|
|
868
|
+
* forwards atomic `take`/`delete` semantics to the repository, and
|
|
869
|
+
* applies the engine's configured tenant field on writes (the repo
|
|
870
|
+
* applies it on reads).
|
|
871
|
+
*
|
|
872
|
+
* Hosts that prefer a different backend (Redis, DynamoDB, in-memory for
|
|
873
|
+
* tests) can implement `EvaluationStore` directly and pass it via engine
|
|
874
|
+
* config — this class isn't load-bearing.
|
|
875
|
+
*/
|
|
876
|
+
var MongoEvaluationStore = class {
|
|
877
|
+
constructor(repo) {
|
|
878
|
+
this.repo = repo;
|
|
879
|
+
}
|
|
880
|
+
async put(id, snapshot, ttlSeconds, ctx) {
|
|
881
|
+
const expiresAt = new Date(Date.now() + ttlSeconds * 1e3);
|
|
882
|
+
const session = ctx?.session;
|
|
883
|
+
const tenantField = this.repo.tenantField;
|
|
884
|
+
const tenantValue = ctx?.tenantValue ?? snapshot.ctx.organizationId;
|
|
885
|
+
const filter = { evaluationId: id };
|
|
886
|
+
if (tenantField && tenantValue !== void 0) filter[tenantField] = tenantValue;
|
|
887
|
+
const update = {
|
|
888
|
+
evaluationId: id,
|
|
889
|
+
result: snapshot.result,
|
|
890
|
+
ctx: snapshot.ctx,
|
|
891
|
+
customerId: snapshot.customerId ?? null,
|
|
892
|
+
programUsages: snapshot.programUsages,
|
|
893
|
+
voucherUsages: snapshot.voucherUsages,
|
|
894
|
+
cartHash: snapshot.cartHash,
|
|
895
|
+
expiresAt
|
|
896
|
+
};
|
|
897
|
+
if (tenantField && tenantValue !== void 0) update[tenantField] = tenantValue;
|
|
898
|
+
await this.repo.Model.updateOne(filter, { $set: update }, {
|
|
899
|
+
upsert: true,
|
|
900
|
+
session
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
async take(id, ctx) {
|
|
904
|
+
const doc = await this.repo.takeByEvaluationId(id, {
|
|
905
|
+
tenantValue: ctx?.tenantValue,
|
|
906
|
+
session: ctx?.session
|
|
907
|
+
});
|
|
908
|
+
if (!doc) return null;
|
|
909
|
+
return {
|
|
910
|
+
result: doc.result,
|
|
911
|
+
ctx: doc.ctx,
|
|
912
|
+
customerId: doc.customerId ?? void 0,
|
|
913
|
+
programUsages: doc.programUsages,
|
|
914
|
+
voucherUsages: doc.voucherUsages,
|
|
915
|
+
cartHash: doc.cartHash,
|
|
916
|
+
createdAt: doc.createdAt
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
async delete(id, ctx) {
|
|
920
|
+
await this.repo.deleteByEvaluationId(id, {
|
|
921
|
+
tenantValue: ctx?.tenantValue,
|
|
922
|
+
session: ctx?.session
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
};
|
|
926
|
+
/**
|
|
927
|
+
* In-memory fallback. Useful for unit tests, single-process dev servers,
|
|
928
|
+
* and hosts that consciously opt out of persistence (knowing they lose
|
|
929
|
+
* pending evaluations on restart). NOT recommended for production
|
|
930
|
+
* topologies — see EvaluationStore docblock for why.
|
|
931
|
+
*
|
|
932
|
+
* Tenant scoping uses `ctx.tenantValue` as part of the in-memory key
|
|
933
|
+
* (the field-name layer doesn't matter here — we just namespace by
|
|
934
|
+
* value). No transaction semantics (in-memory has nothing to roll
|
|
935
|
+
* back), no TTL refinement beyond initial expiry check.
|
|
936
|
+
*/
|
|
937
|
+
var InMemoryEvaluationStore = class {
|
|
938
|
+
map = /* @__PURE__ */ new Map();
|
|
939
|
+
async put(id, snapshot, ttlSeconds, ctx) {
|
|
940
|
+
this.map.set(this.key(id, ctx), {
|
|
941
|
+
snapshot,
|
|
942
|
+
expiresAt: Date.now() + ttlSeconds * 1e3
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
async take(id, ctx) {
|
|
946
|
+
const k = this.key(id, ctx);
|
|
947
|
+
const entry = this.map.get(k);
|
|
948
|
+
if (!entry) return null;
|
|
949
|
+
this.map.delete(k);
|
|
950
|
+
if (entry.expiresAt < Date.now()) return null;
|
|
951
|
+
return entry.snapshot;
|
|
952
|
+
}
|
|
953
|
+
async delete(id, ctx) {
|
|
954
|
+
this.map.delete(this.key(id, ctx));
|
|
955
|
+
}
|
|
956
|
+
key(id, ctx) {
|
|
957
|
+
return ctx?.tenantValue ? `${ctx.tenantValue}:${id}` : id;
|
|
958
|
+
}
|
|
959
|
+
};
|
|
960
|
+
//#endregion
|
|
961
|
+
//#region src/utils/is-write-conflict.ts
|
|
962
|
+
/**
|
|
963
|
+
* Detect MongoDB's transient WriteConflict error across the shapes it
|
|
964
|
+
* arrives in (raw driver error, mongoose-wrapped VersionError, mongokit
|
|
965
|
+
* rethrows). Centralised so every contended write site (gift card spend,
|
|
966
|
+
* gift card top-up, evaluation commit) can opt into uniform mapping
|
|
967
|
+
* to the same typed `ConcurrencyConflictError` / `*UsageCapExceededError`
|
|
968
|
+
* shape — hosts catch one type, decide retry-or-409 once.
|
|
969
|
+
*
|
|
970
|
+
* Why detection is a layered match: under heavy contention MongoDB
|
|
971
|
+
* surfaces the same physical race in different ways depending on which
|
|
972
|
+
* layer caught it first (driver vs mongoose vs mongokit), and each
|
|
973
|
+
* wrapper preserves a slightly different subset of the original error
|
|
974
|
+
* fields. Matching against ALL of them avoids false negatives that
|
|
975
|
+
* would otherwise let the raw error escape to the host with no useful
|
|
976
|
+
* type information.
|
|
977
|
+
*/
|
|
978
|
+
function isWriteConflict(err) {
|
|
979
|
+
if (typeof err !== "object" || err === null) return false;
|
|
980
|
+
const e = err;
|
|
981
|
+
if (e.code === 112 || e.codeName === "WriteConflict") return true;
|
|
982
|
+
if (e.name === "MongoServerError" && typeof e.message === "string" && /write conflict/i.test(e.message)) return true;
|
|
983
|
+
if (Array.isArray(e.errorLabels) && e.errorLabels.includes("TransientTransactionError")) return true;
|
|
984
|
+
if (typeof e.message === "string" && /write conflict/i.test(e.message)) return true;
|
|
985
|
+
if (e.cause !== void 0) return isWriteConflict(e.cause);
|
|
986
|
+
return false;
|
|
987
|
+
}
|
|
988
|
+
//#endregion
|
|
765
989
|
//#region src/services/evaluation.service.ts
|
|
766
990
|
/**
|
|
991
|
+
* Default TTL for pending evaluation snapshots in the store. 30 minutes
|
|
992
|
+
* comfortably exceeds any realistic evaluate→commit window (typical
|
|
993
|
+
* checkout: seconds to a few minutes; long-tail: ~10 min) without
|
|
994
|
+
* hoarding abandoned evaluations forever. The Mongo TTL index purges
|
|
995
|
+
* expired entries; the engine itself never relies on the TTL for
|
|
996
|
+
* correctness — `store.take()` is atomic and returns `null` when an
|
|
997
|
+
* id was already consumed or never existed.
|
|
998
|
+
*/
|
|
999
|
+
const DEFAULT_PENDING_EVALUATION_TTL_SECONDS = 1800;
|
|
1000
|
+
/**
|
|
767
1001
|
* Deterministic, collision-resistant hash of the evaluated cart. The hash
|
|
768
1002
|
* covers everything the evaluation algorithm depends on: normalized line
|
|
769
1003
|
* items (sorted), subtotal, applied codes, and customer identity. Two
|
|
@@ -797,8 +1031,7 @@ function computeCartHash(input) {
|
|
|
797
1031
|
return createHash("sha256").update(canonical).digest("hex");
|
|
798
1032
|
}
|
|
799
1033
|
var EvaluationService = class {
|
|
800
|
-
|
|
801
|
-
constructor(programRepo, ruleRepo, rewardRepo, voucherRepo, unitOfWork, dispatchDeps, config) {
|
|
1034
|
+
constructor(programRepo, ruleRepo, rewardRepo, voucherRepo, unitOfWork, dispatchDeps, config, store) {
|
|
802
1035
|
this.programRepo = programRepo;
|
|
803
1036
|
this.ruleRepo = ruleRepo;
|
|
804
1037
|
this.rewardRepo = rewardRepo;
|
|
@@ -806,6 +1039,7 @@ var EvaluationService = class {
|
|
|
806
1039
|
this.unitOfWork = unitOfWork;
|
|
807
1040
|
this.dispatchDeps = dispatchDeps;
|
|
808
1041
|
this.config = config;
|
|
1042
|
+
this.store = store;
|
|
809
1043
|
}
|
|
810
1044
|
async evaluate(input, ctx) {
|
|
811
1045
|
return this.doEvaluate(input, ctx, false);
|
|
@@ -814,50 +1048,80 @@ var EvaluationService = class {
|
|
|
814
1048
|
return this.doEvaluate(input, ctx, true);
|
|
815
1049
|
}
|
|
816
1050
|
async commit(evaluationId, orderId, ctx, options = {}) {
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
await this.programRepo.incrementUsage(usage.programId, {
|
|
823
|
-
...ctx,
|
|
824
|
-
session
|
|
825
|
-
});
|
|
826
|
-
if (stored.customerId) await this.programRepo.incrementCustomerUsage(usage.programId, stored.customerId, {
|
|
827
|
-
...ctx,
|
|
1051
|
+
let snapshotForCapMapping = null;
|
|
1052
|
+
try {
|
|
1053
|
+
return await this.unitOfWork.withTransaction(async (session) => {
|
|
1054
|
+
const stored = await this.store.take(evaluationId, {
|
|
1055
|
+
tenantValue: ctx.organizationId,
|
|
828
1056
|
session
|
|
829
1057
|
});
|
|
1058
|
+
if (!stored) throw new EvaluationNotFoundError(evaluationId);
|
|
1059
|
+
if (options.cartHash !== void 0 && options.cartHash !== stored.cartHash) throw new CartHashMismatchError(evaluationId);
|
|
1060
|
+
snapshotForCapMapping = stored;
|
|
1061
|
+
return await this.commitInTransaction(stored, evaluationId, orderId, session, ctx);
|
|
1062
|
+
});
|
|
1063
|
+
} catch (err) {
|
|
1064
|
+
if (isWriteConflict(err) && snapshotForCapMapping) {
|
|
1065
|
+
const firstUsage = snapshotForCapMapping.programUsages[0];
|
|
1066
|
+
if (firstUsage) {
|
|
1067
|
+
const program = await this.programRepo.getById(firstUsage.programId, {
|
|
1068
|
+
...ctx,
|
|
1069
|
+
lean: true,
|
|
1070
|
+
throwOnNotFound: false
|
|
1071
|
+
});
|
|
1072
|
+
throw new ProgramUsageCapExceededError(firstUsage.programId, program?.maxUsageTotal ?? 0);
|
|
1073
|
+
}
|
|
830
1074
|
}
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
1075
|
+
throw err;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
async commitInTransaction(stored, evaluationId, orderId, session, ctx) {
|
|
1079
|
+
for (const usage of stored.programUsages) {
|
|
1080
|
+
if (!await this.programRepo.tryIncrementUsage(usage.programId, {
|
|
836
1081
|
...ctx,
|
|
837
1082
|
session
|
|
838
|
-
})
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
}
|
|
847
|
-
await
|
|
848
|
-
evaluationId,
|
|
849
|
-
orderId,
|
|
850
|
-
totalDiscount: stored.result.totalDiscount
|
|
851
|
-
}, ctx), {
|
|
1083
|
+
})) {
|
|
1084
|
+
const program = await this.programRepo.getById(usage.programId, {
|
|
1085
|
+
...ctx,
|
|
1086
|
+
session,
|
|
1087
|
+
lean: true,
|
|
1088
|
+
throwOnNotFound: false
|
|
1089
|
+
});
|
|
1090
|
+
throw new ProgramUsageCapExceededError(usage.programId, program?.maxUsageTotal ?? 0);
|
|
1091
|
+
}
|
|
1092
|
+
if (stored.customerId) await this.programRepo.incrementCustomerUsage(usage.programId, stored.customerId, {
|
|
852
1093
|
...ctx,
|
|
853
1094
|
session
|
|
854
1095
|
});
|
|
855
|
-
|
|
1096
|
+
}
|
|
1097
|
+
for (const usage of stored.voucherUsages) await this.voucherRepo.incrementUsage(usage.voucherId, {
|
|
1098
|
+
orderId,
|
|
1099
|
+
discountAmount: usage.discountAmount,
|
|
1100
|
+
redeemedAt: /* @__PURE__ */ new Date(),
|
|
1101
|
+
...ctx.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {}
|
|
1102
|
+
}, {
|
|
1103
|
+
...ctx,
|
|
1104
|
+
session
|
|
856
1105
|
});
|
|
1106
|
+
const commitResult = {
|
|
1107
|
+
evaluationId,
|
|
1108
|
+
orderId,
|
|
1109
|
+
totalDiscount: stored.result.totalDiscount,
|
|
1110
|
+
programsCommitted: stored.programUsages.length,
|
|
1111
|
+
vouchersUsed: stored.voucherUsages.length
|
|
1112
|
+
};
|
|
1113
|
+
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.EVALUATION_COMMITTED, {
|
|
1114
|
+
evaluationId,
|
|
1115
|
+
orderId,
|
|
1116
|
+
totalDiscount: stored.result.totalDiscount
|
|
1117
|
+
}, ctx), {
|
|
1118
|
+
...ctx,
|
|
1119
|
+
session
|
|
1120
|
+
});
|
|
1121
|
+
return commitResult;
|
|
857
1122
|
}
|
|
858
1123
|
async rollback(evaluationId, ctx) {
|
|
859
|
-
|
|
860
|
-
this.pendingEvaluations.delete(evaluationId);
|
|
1124
|
+
await this.store.delete(evaluationId, { tenantValue: ctx.organizationId });
|
|
861
1125
|
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.EVALUATION_ROLLED_BACK, { evaluationId }, ctx), ctx);
|
|
862
1126
|
}
|
|
863
1127
|
async doEvaluate(input, ctx, isPreview) {
|
|
@@ -970,15 +1234,18 @@ var EvaluationService = class {
|
|
|
970
1234
|
isPreview,
|
|
971
1235
|
programsApplied
|
|
972
1236
|
};
|
|
973
|
-
if (!isPreview)
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
1237
|
+
if (!isPreview) {
|
|
1238
|
+
const snapshot = {
|
|
1239
|
+
result,
|
|
1240
|
+
ctx,
|
|
1241
|
+
customerId: input.customerId,
|
|
1242
|
+
programUsages,
|
|
1243
|
+
voucherUsages,
|
|
1244
|
+
cartHash,
|
|
1245
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
1246
|
+
};
|
|
1247
|
+
await this.store.put(evaluationId, snapshot, DEFAULT_PENDING_EVALUATION_TTL_SECONDS, { tenantValue: ctx.organizationId });
|
|
1248
|
+
}
|
|
982
1249
|
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.EVALUATION_COMPLETED, {
|
|
983
1250
|
evaluationId,
|
|
984
1251
|
totalDiscount,
|
|
@@ -1267,14 +1534,18 @@ var VoucherService = class {
|
|
|
1267
1534
|
if (!voucher) throw new VoucherNotFoundError(input.code);
|
|
1268
1535
|
this.assertVoucherUsable(voucher);
|
|
1269
1536
|
if (input.idempotencyKey) {
|
|
1270
|
-
if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey
|
|
1537
|
+
if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey, {
|
|
1538
|
+
...ctx,
|
|
1539
|
+
session
|
|
1540
|
+
})) throw new DuplicateRedemptionError(input.idempotencyKey);
|
|
1271
1541
|
}
|
|
1272
1542
|
const redemption = {
|
|
1273
1543
|
orderId: input.orderId,
|
|
1274
1544
|
customerId: input.customerId,
|
|
1275
1545
|
discountAmount: input.discountAmount,
|
|
1276
1546
|
redeemedAt: /* @__PURE__ */ new Date(),
|
|
1277
|
-
...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}
|
|
1547
|
+
...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {},
|
|
1548
|
+
...ctx.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {}
|
|
1278
1549
|
};
|
|
1279
1550
|
const updated = await this.voucherRepo.incrementUsage(voucher._id, redemption, {
|
|
1280
1551
|
...ctx,
|
|
@@ -1330,14 +1601,18 @@ var VoucherService = class {
|
|
|
1330
1601
|
const balance = voucher.currentBalance ?? 0;
|
|
1331
1602
|
if (!this.config.giftCard.allowNegativeBalance && balance < input.amount) throw new InsufficientBalanceError(input.code, balance, input.amount);
|
|
1332
1603
|
if (input.idempotencyKey) {
|
|
1333
|
-
if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey
|
|
1604
|
+
if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey, {
|
|
1605
|
+
...ctx,
|
|
1606
|
+
session
|
|
1607
|
+
})) throw new DuplicateRedemptionError(input.idempotencyKey);
|
|
1334
1608
|
}
|
|
1335
1609
|
const entry = {
|
|
1336
1610
|
amount: -input.amount,
|
|
1337
1611
|
orderId: input.orderId,
|
|
1338
1612
|
description: input.description ?? `Spent on order ${input.orderId}`,
|
|
1339
1613
|
createdAt: /* @__PURE__ */ new Date(),
|
|
1340
|
-
...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}
|
|
1614
|
+
...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {},
|
|
1615
|
+
...ctx.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {}
|
|
1341
1616
|
};
|
|
1342
1617
|
const updated = await this.voucherRepo.addLedgerEntry(voucher._id, entry, -input.amount, {
|
|
1343
1618
|
...ctx,
|
|
@@ -1380,37 +1655,61 @@ var VoucherService = class {
|
|
|
1380
1655
|
}
|
|
1381
1656
|
async topUp(input, ctx) {
|
|
1382
1657
|
if (input.amount <= 0) throw new ValidationError("Top-up amount must be positive");
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey)) throw new DuplicateRedemptionError(input.idempotencyKey);
|
|
1658
|
+
try {
|
|
1659
|
+
return await this.topUpInTransaction(input, ctx);
|
|
1660
|
+
} catch (err) {
|
|
1661
|
+
if (isWriteConflict(err)) throw new ConcurrencyConflictError("voucher", input.code, err);
|
|
1662
|
+
throw err;
|
|
1389
1663
|
}
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1664
|
+
}
|
|
1665
|
+
async topUpInTransaction(input, ctx) {
|
|
1666
|
+
return this.unitOfWork.withTransaction(async (session) => {
|
|
1667
|
+
const voucher = await this.voucherRepo.getByCode(input.code, {
|
|
1668
|
+
...ctx,
|
|
1669
|
+
session
|
|
1670
|
+
});
|
|
1671
|
+
if (!voucher) throw new VoucherNotFoundError(input.code);
|
|
1672
|
+
const maxBalance = this.config.giftCard.maxBalance;
|
|
1673
|
+
if (maxBalance !== null && (voucher.currentBalance ?? 0) + input.amount > maxBalance) throw new ValidationError(`Top-up would exceed max balance of ${maxBalance}`);
|
|
1674
|
+
if (input.idempotencyKey) {
|
|
1675
|
+
if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey, {
|
|
1676
|
+
...ctx,
|
|
1677
|
+
session
|
|
1678
|
+
})) throw new DuplicateRedemptionError(input.idempotencyKey);
|
|
1679
|
+
}
|
|
1680
|
+
const entry = {
|
|
1681
|
+
amount: input.amount,
|
|
1682
|
+
description: input.description ?? "Top-up",
|
|
1683
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
1684
|
+
...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {},
|
|
1685
|
+
...ctx.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {}
|
|
1686
|
+
};
|
|
1687
|
+
const updated = await this.voucherRepo.addLedgerEntry(voucher._id, entry, input.amount, {
|
|
1688
|
+
...ctx,
|
|
1689
|
+
session
|
|
1690
|
+
});
|
|
1691
|
+
if (voucher.status === "used") await this.voucherRepo.update(voucher._id, { status: "active" }, {
|
|
1692
|
+
lean: true,
|
|
1693
|
+
...ctx,
|
|
1694
|
+
session
|
|
1695
|
+
});
|
|
1696
|
+
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.GIFT_CARD_TOPPED_UP, {
|
|
1697
|
+
voucherId: voucher._id,
|
|
1698
|
+
code: voucher.code,
|
|
1699
|
+
amount: input.amount,
|
|
1700
|
+
newBalance: updated.currentBalance ?? 0
|
|
1701
|
+
}, ctx), {
|
|
1702
|
+
...ctx,
|
|
1703
|
+
session
|
|
1704
|
+
});
|
|
1705
|
+
return {
|
|
1706
|
+
code: updated.code,
|
|
1707
|
+
initialBalance: updated.initialBalance ?? 0,
|
|
1708
|
+
currentBalance: updated.currentBalance ?? 0,
|
|
1709
|
+
spent: (updated.initialBalance ?? 0) - (updated.currentBalance ?? 0),
|
|
1710
|
+
voucherId: updated._id
|
|
1711
|
+
};
|
|
1400
1712
|
});
|
|
1401
|
-
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.GIFT_CARD_TOPPED_UP, {
|
|
1402
|
-
voucherId: voucher._id,
|
|
1403
|
-
code: voucher.code,
|
|
1404
|
-
amount: input.amount,
|
|
1405
|
-
newBalance: updated.currentBalance ?? 0
|
|
1406
|
-
}, ctx), ctx);
|
|
1407
|
-
return {
|
|
1408
|
-
code: updated.code,
|
|
1409
|
-
initialBalance: updated.initialBalance ?? 0,
|
|
1410
|
-
currentBalance: updated.currentBalance ?? 0,
|
|
1411
|
-
spent: (updated.initialBalance ?? 0) - (updated.currentBalance ?? 0),
|
|
1412
|
-
voucherId: updated._id
|
|
1413
|
-
};
|
|
1414
1713
|
}
|
|
1415
1714
|
assertVoucherUsable(voucher) {
|
|
1416
1715
|
if (voucher.status === "expired") throw new VoucherExpiredError(voucher.code);
|
|
@@ -1423,61 +1722,19 @@ var VoucherService = class {
|
|
|
1423
1722
|
if (voucher.usedCount >= voucher.usageLimit) throw new VoucherExhaustedError(voucher.code);
|
|
1424
1723
|
}
|
|
1425
1724
|
};
|
|
1426
|
-
/**
|
|
1427
|
-
* Detect MongoDB's transient WriteConflict error across the shapes it
|
|
1428
|
-
* arrives in (raw driver error, mongoose-wrapped VersionError, mongokit
|
|
1429
|
-
* rethrows). Centralised so every contended write site can opt-in.
|
|
1430
|
-
*/
|
|
1431
|
-
function isWriteConflict(err) {
|
|
1432
|
-
if (typeof err !== "object" || err === null) return false;
|
|
1433
|
-
const e = err;
|
|
1434
|
-
if (e.code === 112 || e.codeName === "WriteConflict") return true;
|
|
1435
|
-
if (e.name === "MongoServerError" && typeof e.message === "string" && /write conflict/i.test(e.message)) return true;
|
|
1436
|
-
if (Array.isArray(e.errorLabels) && e.errorLabels.includes("TransientTransactionError")) return true;
|
|
1437
|
-
if (typeof e.message === "string" && /write conflict/i.test(e.message)) return true;
|
|
1438
|
-
if (e.cause !== void 0) return isWriteConflict(e.cause);
|
|
1439
|
-
return false;
|
|
1440
|
-
}
|
|
1441
1725
|
//#endregion
|
|
1442
1726
|
//#region src/services/create-services.ts
|
|
1443
1727
|
function createServices(deps) {
|
|
1444
|
-
const { repositories, unitOfWork, dispatchDeps, config } = deps;
|
|
1728
|
+
const { repositories, unitOfWork, dispatchDeps, config, evaluationStore } = deps;
|
|
1729
|
+
const voucher = new VoucherService(repositories.voucher, repositories.program, unitOfWork, dispatchDeps, config);
|
|
1730
|
+
const store = evaluationStore ?? new MongoEvaluationStore(repositories.pendingEvaluation);
|
|
1445
1731
|
return {
|
|
1446
|
-
voucher
|
|
1447
|
-
evaluation: new EvaluationService(repositories.program, repositories.rule, repositories.reward, repositories.voucher, unitOfWork, dispatchDeps, config)
|
|
1732
|
+
voucher,
|
|
1733
|
+
evaluation: new EvaluationService(repositories.program, repositories.rule, repositories.reward, repositories.voucher, unitOfWork, dispatchDeps, config, store)
|
|
1448
1734
|
};
|
|
1449
1735
|
}
|
|
1450
1736
|
//#endregion
|
|
1451
1737
|
//#region src/events/promo-event-catalog.ts
|
|
1452
|
-
/**
|
|
1453
|
-
* Promo event catalog — Zod-source-of-truth definitions for every
|
|
1454
|
-
* `promo.*` event.
|
|
1455
|
-
*
|
|
1456
|
-
* Each definition exposes:
|
|
1457
|
-
* - `.zodSchema` — source of truth, used by host code's `.safeParse()`
|
|
1458
|
-
* - `.schema` — JSON Schema derived via `z.toJSONSchema()`, consumed
|
|
1459
|
-
* by Arc's EventRegistry + OpenAPI plugin
|
|
1460
|
-
* - `.create(...)` — DomainEvent envelope builder, structurally compatible
|
|
1461
|
-
* with `@classytic/arc`'s `EventDefinitionOutput`
|
|
1462
|
-
*
|
|
1463
|
-
* Structurally compatible with Arc 2.10's `EventRegistry` — hosts register
|
|
1464
|
-
* `promoEventDefinitions` directly, no adapter code. Promo does NOT import
|
|
1465
|
-
* from `@classytic/arc` (PACKAGE_RULES §11); compatibility is purely
|
|
1466
|
-
* structural.
|
|
1467
|
-
*
|
|
1468
|
-
* Payload schemas mirror the typed interfaces in
|
|
1469
|
-
* [event-payloads.ts](./event-payloads.ts). See PACKAGE_RULES §18.5 for
|
|
1470
|
-
* the pattern.
|
|
1471
|
-
*
|
|
1472
|
-
* @example Wiring into an Arc app
|
|
1473
|
-
* ```ts
|
|
1474
|
-
* import { createEventRegistry } from '@classytic/arc/events';
|
|
1475
|
-
* import { promoEventDefinitions } from '@classytic/promo';
|
|
1476
|
-
*
|
|
1477
|
-
* const registry = createEventRegistry();
|
|
1478
|
-
* for (const def of promoEventDefinitions) registry.register(def);
|
|
1479
|
-
* ```
|
|
1480
|
-
*/
|
|
1481
1738
|
function definePromoEvent(input) {
|
|
1482
1739
|
const { name, version = 1, description, zodSchema } = input;
|
|
1483
1740
|
const def = {
|
|
@@ -1727,23 +1984,62 @@ function resolveConfig(config) {
|
|
|
1727
1984
|
if (resolved.evaluation.maxStackablePromotions < 1) throw new ValidationError("maxStackablePromotions must be >= 1");
|
|
1728
1985
|
return resolved;
|
|
1729
1986
|
}
|
|
1987
|
+
/**
|
|
1988
|
+
* Thin adapter over `mongokit.withTransaction` so the engine's local
|
|
1989
|
+
* `UnitOfWork` port stays driver-agnostic while inheriting mongokit's
|
|
1990
|
+
* battle-tested transaction handling: auto-retry on
|
|
1991
|
+
* `TransientTransactionError` + `UnknownTransactionCommitResult`,
|
|
1992
|
+
* standalone-Mongo fallback for dev, consistent session lifecycle.
|
|
1993
|
+
*
|
|
1994
|
+
* Don't replace this with hand-rolled `session.withTransaction()` — the
|
|
1995
|
+
* underlying helper centralises retry semantics across every package
|
|
1996
|
+
* that wires it. See packages/mongokit/src/transaction.ts.
|
|
1997
|
+
*/
|
|
1730
1998
|
var MongoUnitOfWork = class {
|
|
1731
1999
|
constructor(connection) {
|
|
1732
2000
|
this.connection = connection;
|
|
1733
2001
|
}
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
try {
|
|
1737
|
-
let result;
|
|
1738
|
-
await session.withTransaction(async () => {
|
|
1739
|
-
result = await cb(session);
|
|
1740
|
-
});
|
|
1741
|
-
return result;
|
|
1742
|
-
} finally {
|
|
1743
|
-
await session.endSession();
|
|
1744
|
-
}
|
|
2002
|
+
withTransaction(cb) {
|
|
2003
|
+
return withTransaction(this.connection, cb);
|
|
1745
2004
|
}
|
|
1746
2005
|
};
|
|
2006
|
+
/**
|
|
2007
|
+
* Build the promo engine for a host application.
|
|
2008
|
+
*
|
|
2009
|
+
* **Index management — important for boot performance:**
|
|
2010
|
+
*
|
|
2011
|
+
* `createPromoEngine` itself is non-blocking. It registers Mongoose models
|
|
2012
|
+
* and returns immediately. Index creation is delegated to Mongoose's
|
|
2013
|
+
* standard lazy-init: with `autoIndex: true` (default in dev) Mongoose
|
|
2014
|
+
* builds indexes in the background after the first query touches each
|
|
2015
|
+
* model; with `autoIndex: false` (recommended for production) hosts
|
|
2016
|
+
* control index creation explicitly.
|
|
2017
|
+
*
|
|
2018
|
+
* The returned `engine.syncIndexes()` helper is opt-in and **MUST NOT be
|
|
2019
|
+
* `await`ed during Fastify plugin registration / boot** — Atlas index
|
|
2020
|
+
* creation can take 10s+ on fresh collections, longer than typical
|
|
2021
|
+
* plugin timeouts. Three safe patterns:
|
|
2022
|
+
*
|
|
2023
|
+
* 1. **Migration script** (recommended for production):
|
|
2024
|
+
* ```ts
|
|
2025
|
+
* // scripts/sync-indexes.ts
|
|
2026
|
+
* await engine.syncIndexes();
|
|
2027
|
+
* ```
|
|
2028
|
+
* Run before deploying / serving traffic.
|
|
2029
|
+
*
|
|
2030
|
+
* 2. **Background fire-and-log** during boot:
|
|
2031
|
+
* ```ts
|
|
2032
|
+
* engine.syncIndexes().catch((err) => log.warn({ err }, 'index sync'));
|
|
2033
|
+
* ```
|
|
2034
|
+
* App accepts traffic immediately; first queries may wait briefly
|
|
2035
|
+
* on still-building indexes.
|
|
2036
|
+
*
|
|
2037
|
+
* 3. **Lazy init** (`autoIndex: true` in dev): just don't call
|
|
2038
|
+
* `syncIndexes()` at all. Mongoose schedules creation on first
|
|
2039
|
+
* query per model.
|
|
2040
|
+
*
|
|
2041
|
+
* Production hosts should set `autoIndex: false` and use option (1).
|
|
2042
|
+
*/
|
|
1747
2043
|
function createPromoEngine(config) {
|
|
1748
2044
|
const resolvedConfig = resolveConfig(config);
|
|
1749
2045
|
const models = createModels(config.mongoose, resolvedConfig.tenant, config.indexes, config.autoIndex);
|
|
@@ -1761,7 +2057,8 @@ function createPromoEngine(config) {
|
|
|
1761
2057
|
repositories,
|
|
1762
2058
|
unitOfWork: new MongoUnitOfWork(config.mongoose),
|
|
1763
2059
|
dispatchDeps,
|
|
1764
|
-
config: resolvedConfig
|
|
2060
|
+
config: resolvedConfig,
|
|
2061
|
+
...config.evaluationStore !== void 0 ? { evaluationStore: config.evaluationStore } : {}
|
|
1765
2062
|
}),
|
|
1766
2063
|
events,
|
|
1767
2064
|
async syncIndexes() {
|
|
@@ -1770,4 +2067,4 @@ function createPromoEngine(config) {
|
|
|
1770
2067
|
};
|
|
1771
2068
|
}
|
|
1772
2069
|
//#endregion
|
|
1773
|
-
export { EvaluationCommitted, EvaluationCompleted, EvaluationRolledBack, GiftCardExhausted, GiftCardSpent, GiftCardToppedUp, ProgramActivated, ProgramArchived, ProgramCreated, ProgramPaused, ProgramRepository, PromoEvents, RewardAdded, RewardRemoved, RewardRepository, RewardUpdated, RuleAdded, RuleRemoved, RuleRepository, RuleUpdated, VoucherCancelled, VoucherExpired, VoucherGenerated, VoucherRedeemed, VoucherRepository, createPromoEngine, promoEventDefinitions, resolveConfig };
|
|
2070
|
+
export { CartHashMismatchError, ConcurrencyConflictError, DuplicateRedemptionError, EvaluationCommitted, EvaluationCompleted, EvaluationNotFoundError, EvaluationRolledBack, GiftCardExhausted, GiftCardExhaustedError, GiftCardSpent, GiftCardToppedUp, InMemoryEvaluationStore, InsufficientBalanceError, InvalidTransitionError, MongoEvaluationStore, PendingEvaluationRepository, ProgramActivated, ProgramArchived, ProgramCreated, ProgramNotFoundError, ProgramPaused, ProgramRepository, ProgramUsageCapExceededError, PromoError, PromoEvents, RewardAdded, RewardNotFoundError, RewardRemoved, RewardRepository, RewardUpdated, RuleAdded, RuleNotFoundError, RuleRemoved, RuleRepository, RuleUpdated, TenantIsolationError, ValidationError, VoucherCancelled, VoucherExhaustedError, VoucherExpired, VoucherExpiredError, VoucherGenerated, VoucherNotFoundError, VoucherRedeemed, VoucherRepository, createPromoEngine, promoEventDefinitions, resolveConfig };
|