@classytic/promo 0.2.0 → 0.2.1
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/index.d.mts +712 -174
- package/dist/index.mjs +599 -167
- package/dist/schemas/index.mjs +0 -1
- package/package.json +8 -7
package/dist/index.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
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";
|
|
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
8
|
//#region src/domain/errors/base.ts
|
|
@@ -23,6 +23,33 @@ var ProgramNotFoundError = class extends PromoError {
|
|
|
23
23
|
super(id ? `Program '${id}' not found` : "Program not found");
|
|
24
24
|
}
|
|
25
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
|
+
};
|
|
26
53
|
var VoucherNotFoundError = class extends PromoError {
|
|
27
54
|
code = "VOUCHER_NOT_FOUND";
|
|
28
55
|
constructor(codeOrId) {
|
|
@@ -81,6 +108,12 @@ var InsufficientBalanceError = class extends PromoError {
|
|
|
81
108
|
super(`Voucher '${code}' has insufficient balance: ${available} available, ${requested} requested`);
|
|
82
109
|
}
|
|
83
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
|
+
};
|
|
84
117
|
var DuplicateRedemptionError = class extends PromoError {
|
|
85
118
|
code = "DUPLICATE_REDEMPTION";
|
|
86
119
|
constructor(key) {
|
|
@@ -169,10 +202,81 @@ function applyUserIndexes(schema, indexes, tenant) {
|
|
|
169
202
|
}
|
|
170
203
|
}
|
|
171
204
|
//#endregion
|
|
205
|
+
//#region src/models/schemas/pending-evaluation.schema.ts
|
|
206
|
+
/**
|
|
207
|
+
* Persisted snapshot of an `evaluate()` outcome, awaiting a follow-up
|
|
208
|
+
* `commit()` or `rollback()`.
|
|
209
|
+
*
|
|
210
|
+
* Why a Mongo collection (not a process-local Map): pending evaluations
|
|
211
|
+
* MUST survive process restart, horizontal scaling, serverless cold
|
|
212
|
+
* starts, and worker handoff. Stale snapshots auto-clean via the TTL
|
|
213
|
+
* index — same pattern cart uses for guest drafts (see
|
|
214
|
+
* cart/src/models/draft.model.ts:131-135).
|
|
215
|
+
*
|
|
216
|
+
* Stored fields are intentionally minimal — only what `commit()` and
|
|
217
|
+
* `rollback()` need:
|
|
218
|
+
* - the materialised result (returned to caller for telemetry)
|
|
219
|
+
* - per-program / per-voucher usages (incremented atomically at commit)
|
|
220
|
+
* - cartHash (anti-tamper guard between evaluate and commit)
|
|
221
|
+
* - expiresAt (TTL drives auto-cleanup; engine never relies on it for
|
|
222
|
+
* correctness — the atomic `findOneAndDelete` in `take()` is the
|
|
223
|
+
* authoritative single-commit guard)
|
|
224
|
+
*/
|
|
225
|
+
function createPendingEvaluationSchema() {
|
|
226
|
+
return new Schema({
|
|
227
|
+
evaluationId: {
|
|
228
|
+
type: String,
|
|
229
|
+
required: true,
|
|
230
|
+
unique: true,
|
|
231
|
+
index: true
|
|
232
|
+
},
|
|
233
|
+
result: {
|
|
234
|
+
type: Schema.Types.Mixed,
|
|
235
|
+
required: true
|
|
236
|
+
},
|
|
237
|
+
ctx: {
|
|
238
|
+
type: Schema.Types.Mixed,
|
|
239
|
+
required: true
|
|
240
|
+
},
|
|
241
|
+
customerId: {
|
|
242
|
+
type: String,
|
|
243
|
+
default: null
|
|
244
|
+
},
|
|
245
|
+
programUsages: {
|
|
246
|
+
type: [Schema.Types.Mixed],
|
|
247
|
+
default: []
|
|
248
|
+
},
|
|
249
|
+
voucherUsages: {
|
|
250
|
+
type: [Schema.Types.Mixed],
|
|
251
|
+
default: []
|
|
252
|
+
},
|
|
253
|
+
cartHash: {
|
|
254
|
+
type: String,
|
|
255
|
+
required: true,
|
|
256
|
+
index: true
|
|
257
|
+
},
|
|
258
|
+
expiresAt: {
|
|
259
|
+
type: Date,
|
|
260
|
+
required: true
|
|
261
|
+
}
|
|
262
|
+
}, {
|
|
263
|
+
timestamps: true,
|
|
264
|
+
minimize: false
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Wire the TTL index. Mongo evaluates `expireAfterSeconds: 0` against the
|
|
269
|
+
* `expiresAt` field's value — when `expiresAt < now`, the doc is purged
|
|
270
|
+
* by the background TTL monitor (~60s granularity).
|
|
271
|
+
*/
|
|
272
|
+
function applyPendingEvaluationIndexes(schema) {
|
|
273
|
+
schema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
|
274
|
+
}
|
|
275
|
+
//#endregion
|
|
172
276
|
//#region src/models/schemas/program.schema.ts
|
|
173
|
-
const { Schema: Schema$
|
|
277
|
+
const { Schema: Schema$4 } = mongoose;
|
|
174
278
|
function createProgramSchema() {
|
|
175
|
-
const schema = new Schema$
|
|
279
|
+
const schema = new Schema$4({
|
|
176
280
|
name: {
|
|
177
281
|
type: String,
|
|
178
282
|
required: true
|
|
@@ -217,7 +321,7 @@ function createProgramSchema() {
|
|
|
217
321
|
of: Number,
|
|
218
322
|
default: () => /* @__PURE__ */ new Map()
|
|
219
323
|
},
|
|
220
|
-
metadata: { type: Schema$
|
|
324
|
+
metadata: { type: Schema$4.Types.Mixed }
|
|
221
325
|
}, { timestamps: true });
|
|
222
326
|
schema.index({
|
|
223
327
|
status: 1,
|
|
@@ -238,14 +342,14 @@ function createProgramSchema() {
|
|
|
238
342
|
}
|
|
239
343
|
//#endregion
|
|
240
344
|
//#region src/models/schemas/reward.schema.ts
|
|
241
|
-
const { Schema: Schema$
|
|
345
|
+
const { Schema: Schema$3 } = mongoose;
|
|
242
346
|
function createRewardSchema() {
|
|
243
|
-
const schema = new Schema$
|
|
347
|
+
const schema = new Schema$3({
|
|
244
348
|
programId: {
|
|
245
|
-
type: Schema$
|
|
349
|
+
type: Schema$3.Types.ObjectId,
|
|
246
350
|
required: true
|
|
247
351
|
},
|
|
248
|
-
ruleId: { type: Schema$
|
|
352
|
+
ruleId: { type: Schema$3.Types.ObjectId },
|
|
249
353
|
rewardType: {
|
|
250
354
|
type: String,
|
|
251
355
|
enum: REWARD_TYPES,
|
|
@@ -270,7 +374,7 @@ function createRewardSchema() {
|
|
|
270
374
|
default: 1
|
|
271
375
|
},
|
|
272
376
|
giftCardAmount: { type: Number },
|
|
273
|
-
metadata: { type: Schema$
|
|
377
|
+
metadata: { type: Schema$3.Types.Mixed }
|
|
274
378
|
}, { timestamps: true });
|
|
275
379
|
schema.index({ programId: 1 });
|
|
276
380
|
schema.index({ ruleId: 1 }, { sparse: true });
|
|
@@ -278,11 +382,11 @@ function createRewardSchema() {
|
|
|
278
382
|
}
|
|
279
383
|
//#endregion
|
|
280
384
|
//#region src/models/schemas/rule.schema.ts
|
|
281
|
-
const { Schema: Schema$
|
|
385
|
+
const { Schema: Schema$2 } = mongoose;
|
|
282
386
|
function createRuleSchema() {
|
|
283
|
-
const schema = new Schema$
|
|
387
|
+
const schema = new Schema$2({
|
|
284
388
|
programId: {
|
|
285
|
-
type: Schema$
|
|
389
|
+
type: Schema$2.Types.ObjectId,
|
|
286
390
|
required: true
|
|
287
391
|
},
|
|
288
392
|
name: { type: String },
|
|
@@ -305,7 +409,7 @@ function createRuleSchema() {
|
|
|
305
409
|
},
|
|
306
410
|
startsAt: { type: Date },
|
|
307
411
|
endsAt: { type: Date },
|
|
308
|
-
metadata: { type: Schema$
|
|
412
|
+
metadata: { type: Schema$2.Types.Mixed }
|
|
309
413
|
}, { timestamps: true });
|
|
310
414
|
schema.index({ programId: 1 });
|
|
311
415
|
schema.index({ code: 1 }, { sparse: true });
|
|
@@ -313,11 +417,11 @@ function createRuleSchema() {
|
|
|
313
417
|
}
|
|
314
418
|
//#endregion
|
|
315
419
|
//#region src/models/schemas/voucher.schema.ts
|
|
316
|
-
const { Schema } = mongoose;
|
|
420
|
+
const { Schema: Schema$1 } = mongoose;
|
|
317
421
|
function createVoucherSchema() {
|
|
318
|
-
const schema = new Schema({
|
|
422
|
+
const schema = new Schema$1({
|
|
319
423
|
programId: {
|
|
320
|
-
type: Schema.Types.ObjectId,
|
|
424
|
+
type: Schema$1.Types.ObjectId,
|
|
321
425
|
required: true,
|
|
322
426
|
index: true
|
|
323
427
|
},
|
|
@@ -354,7 +458,8 @@ function createVoucherSchema() {
|
|
|
354
458
|
type: Date,
|
|
355
459
|
default: Date.now
|
|
356
460
|
},
|
|
357
|
-
idempotencyKey: { type: String }
|
|
461
|
+
idempotencyKey: { type: String },
|
|
462
|
+
organizationId: { type: String }
|
|
358
463
|
}],
|
|
359
464
|
expiresAt: { type: Date },
|
|
360
465
|
redemptions: [{
|
|
@@ -371,9 +476,10 @@ function createVoucherSchema() {
|
|
|
371
476
|
type: Date,
|
|
372
477
|
default: Date.now
|
|
373
478
|
},
|
|
374
|
-
idempotencyKey: { type: String }
|
|
479
|
+
idempotencyKey: { type: String },
|
|
480
|
+
organizationId: { type: String }
|
|
375
481
|
}],
|
|
376
|
-
metadata: { type: Schema.Types.Mixed }
|
|
482
|
+
metadata: { type: Schema$1.Types.Mixed }
|
|
377
483
|
}, { timestamps: true });
|
|
378
484
|
schema.index({ code: 1 }, { unique: true });
|
|
379
485
|
schema.index({
|
|
@@ -393,7 +499,8 @@ const MODEL_NAMES = [
|
|
|
393
499
|
"PromoProgram",
|
|
394
500
|
"PromoRule",
|
|
395
501
|
"PromoReward",
|
|
396
|
-
"PromoVoucher"
|
|
502
|
+
"PromoVoucher",
|
|
503
|
+
"PromoPendingEvaluation"
|
|
397
504
|
];
|
|
398
505
|
function applyAutoIndex(models, autoIndex) {
|
|
399
506
|
if (autoIndex === void 0) return;
|
|
@@ -413,10 +520,13 @@ function createModels(connection, tenant, indexes, autoIndex) {
|
|
|
413
520
|
const ruleSchema = createRuleSchema();
|
|
414
521
|
const rewardSchema = createRewardSchema();
|
|
415
522
|
const voucherSchema = createVoucherSchema();
|
|
523
|
+
const pendingEvaluationSchema = createPendingEvaluationSchema();
|
|
416
524
|
injectTenantField(programSchema, tenant);
|
|
417
525
|
injectTenantField(ruleSchema, tenant);
|
|
418
526
|
injectTenantField(rewardSchema, tenant);
|
|
419
527
|
injectTenantField(voucherSchema, tenant);
|
|
528
|
+
injectTenantField(pendingEvaluationSchema, tenant);
|
|
529
|
+
applyPendingEvaluationIndexes(pendingEvaluationSchema);
|
|
420
530
|
if (indexes?.program) applyUserIndexes(programSchema, indexes.program, tenant);
|
|
421
531
|
if (indexes?.rule) applyUserIndexes(ruleSchema, indexes.rule, tenant);
|
|
422
532
|
if (indexes?.reward) applyUserIndexes(rewardSchema, indexes.reward, tenant);
|
|
@@ -425,12 +535,94 @@ function createModels(connection, tenant, indexes, autoIndex) {
|
|
|
425
535
|
Program: connection.model("PromoProgram", programSchema),
|
|
426
536
|
Rule: connection.model("PromoRule", ruleSchema),
|
|
427
537
|
Reward: connection.model("PromoReward", rewardSchema),
|
|
428
|
-
Voucher: connection.model("PromoVoucher", voucherSchema)
|
|
538
|
+
Voucher: connection.model("PromoVoucher", voucherSchema),
|
|
539
|
+
PendingEvaluation: connection.model("PromoPendingEvaluation", pendingEvaluationSchema)
|
|
429
540
|
};
|
|
430
541
|
applyAutoIndex(result, autoIndex);
|
|
431
542
|
return result;
|
|
432
543
|
}
|
|
433
544
|
//#endregion
|
|
545
|
+
//#region src/repositories/pending-evaluation.repository.ts
|
|
546
|
+
/**
|
|
547
|
+
* Pending-evaluation repository. Extends mongokit's `Repository<TDoc>`
|
|
548
|
+
* directly per package rules (no service wrapper, no aliased verbs).
|
|
549
|
+
* Adds one custom domain method: `takeByEvaluationId` — atomic
|
|
550
|
+
* read-and-delete via raw `Model.findOneAndDelete`.
|
|
551
|
+
*
|
|
552
|
+
* **Why raw `findOneAndDelete` (escape from mongokit's `delete()`)**:
|
|
553
|
+
* mongokit's `Repository.delete()` returns `{success, message}` only —
|
|
554
|
+
* it doesn't surface the deleted document. `take` semantics require
|
|
555
|
+
* "atomically remove AND return", which is the canonical defence
|
|
556
|
+
* against double-commit on the same evaluationId at the storage layer
|
|
557
|
+
* (one caller wins the document, the other gets `null`). This is the
|
|
558
|
+
* narrow exception PACKAGE_RULES.md / order/CLAUDE.md sanction:
|
|
559
|
+
* *"Raw findOneAndUpdate/findOneAndDelete is allowed ONLY for atomic
|
|
560
|
+
* state-machine transitions — flag each one with a comment."*
|
|
561
|
+
*/
|
|
562
|
+
var PendingEvaluationRepository = class extends Repository {
|
|
563
|
+
/**
|
|
564
|
+
* The repository owns its tenant config so its raw-driver methods
|
|
565
|
+
* (`findOneAndDelete`, `deleteOne`) can apply the SAME scoping rule
|
|
566
|
+
* the mongokit hook pipeline would apply on standard methods —
|
|
567
|
+
* specifically using `tenant.tenantField` (host-configurable as
|
|
568
|
+
* `organizationId`, `branchId`, `tenantId`, etc.) NOT a hardcoded
|
|
569
|
+
* `organizationId`. Without this, deployments that configure custom
|
|
570
|
+
* tenant fields would silently lose isolation on the cache layer.
|
|
571
|
+
*/
|
|
572
|
+
tenant;
|
|
573
|
+
constructor(model, plugins = [], tenant) {
|
|
574
|
+
super(model, plugins);
|
|
575
|
+
this.tenant = tenant;
|
|
576
|
+
}
|
|
577
|
+
/** The host-configured tenant field name (or `undefined` if single-tenant). */
|
|
578
|
+
get tenantField() {
|
|
579
|
+
return this.tenant?.enabled ? this.tenant.tenantField : void 0;
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Atomic read-and-delete by evaluationId. Two concurrent commit calls
|
|
583
|
+
* on the same id race here at the database layer — the winner gets
|
|
584
|
+
* the document, the loser gets `null` and the calling service throws
|
|
585
|
+
* `EvaluationNotFoundError`. No way both succeed.
|
|
586
|
+
*
|
|
587
|
+
* Honours `ctx.session` so the operation joins the caller's
|
|
588
|
+
* transaction. If the transaction aborts (transient DB error, cap
|
|
589
|
+
* exceeded, etc.) the delete rolls back and the snapshot stays in
|
|
590
|
+
* the store — letting the caller retry without re-evaluation.
|
|
591
|
+
*
|
|
592
|
+
* Tenant scoping uses the configured `tenant.tenantField` (NOT a
|
|
593
|
+
* hardcoded `organizationId`) so hosts on `branchId`/`tenantId`/etc.
|
|
594
|
+
* keep cross-tenant isolation on the cache layer too.
|
|
595
|
+
*/
|
|
596
|
+
async takeByEvaluationId(evaluationId, ctx) {
|
|
597
|
+
const filter = this.scopedFilter({ evaluationId }, ctx);
|
|
598
|
+
const session = ctx?.session ?? null;
|
|
599
|
+
return await this.Model.findOneAndDelete(filter).session(session).lean();
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Idempotent delete. Returns whether anything was actually removed,
|
|
603
|
+
* so callers can distinguish "we cleaned it" from "already gone".
|
|
604
|
+
*/
|
|
605
|
+
async deleteByEvaluationId(evaluationId, ctx) {
|
|
606
|
+
const filter = this.scopedFilter({ evaluationId }, ctx);
|
|
607
|
+
const session = ctx?.session ?? null;
|
|
608
|
+
return (await this.Model.deleteOne(filter).session(session)).deletedCount > 0;
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Build a query filter with the configured tenant scope appended,
|
|
612
|
+
* mirroring what mongokit's `multiTenantPlugin` injects on standard
|
|
613
|
+
* Repository methods. We re-implement here because raw driver calls
|
|
614
|
+
* (`findOneAndDelete`, `deleteOne`) bypass the plugin pipeline.
|
|
615
|
+
*/
|
|
616
|
+
scopedFilter(base, ctx) {
|
|
617
|
+
const field = this.tenantField;
|
|
618
|
+
if (!field || ctx?.tenantValue === void 0) return base;
|
|
619
|
+
return {
|
|
620
|
+
...base,
|
|
621
|
+
[field]: ctx.tenantValue
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
//#endregion
|
|
434
626
|
//#region src/events/dispatch.ts
|
|
435
627
|
/**
|
|
436
628
|
* Canonical P8 shape — save to outbox first (with `ctx.session` when
|
|
@@ -538,6 +730,34 @@ var ProgramRepository = class extends Repository {
|
|
|
538
730
|
if (!updated) throw new ProgramNotFoundError(id);
|
|
539
731
|
return updated;
|
|
540
732
|
}
|
|
733
|
+
/**
|
|
734
|
+
* Atomic compare-and-set increment: succeeds only if the program either
|
|
735
|
+
* has no cap (`maxUsageTotal == null`) OR `usedCount < maxUsageTotal`.
|
|
736
|
+
* If the cap is already saturated, returns `null` so the caller can
|
|
737
|
+
* decide whether to throw (commit-time enforcement) or skip silently
|
|
738
|
+
* (best-effort eligibility check).
|
|
739
|
+
*
|
|
740
|
+
* Industry-standard primitive for "promo with finite supply" — without
|
|
741
|
+
* this, two evaluations both see the program as available and both
|
|
742
|
+
* commit, leading to oversell. The atomic filter on the same write
|
|
743
|
+
* eliminates the race entirely; concurrent losers see a `null` return
|
|
744
|
+
* and can downgrade their commit (apply order without the discount,
|
|
745
|
+
* surface "promo no longer available" to the user, etc.).
|
|
746
|
+
*
|
|
747
|
+
* Routes through mongokit's `update` so tenant scoping + hooks fire.
|
|
748
|
+
*/
|
|
749
|
+
async tryIncrementUsage(id, ctx) {
|
|
750
|
+
return await this.update(id, { $inc: { usedCount: 1 } }, {
|
|
751
|
+
query: { $or: [
|
|
752
|
+
{ maxUsageTotal: { $exists: false } },
|
|
753
|
+
{ maxUsageTotal: null },
|
|
754
|
+
{ $expr: { $lt: ["$usedCount", "$maxUsageTotal"] } }
|
|
755
|
+
] },
|
|
756
|
+
throwOnNotFound: false,
|
|
757
|
+
lean: true,
|
|
758
|
+
...ctx
|
|
759
|
+
}) ?? null;
|
|
760
|
+
}
|
|
541
761
|
async decrementUsage(id, ctx) {
|
|
542
762
|
const updated = await this.update(id, { $inc: { usedCount: -1 } }, {
|
|
543
763
|
throwOnNotFound: true,
|
|
@@ -607,20 +827,27 @@ var RuleRepository = class extends Repository {
|
|
|
607
827
|
var VoucherRepository = class extends Repository {
|
|
608
828
|
dispatchDeps;
|
|
609
829
|
tenantField;
|
|
610
|
-
|
|
830
|
+
tenantEnabled;
|
|
831
|
+
constructor(model, plugins = [], dispatchDeps = {}, tenantField = "organizationId", tenantEnabled = true) {
|
|
611
832
|
super(model, plugins);
|
|
612
833
|
this.dispatchDeps = dispatchDeps;
|
|
613
834
|
this.tenantField = tenantField;
|
|
835
|
+
this.tenantEnabled = tenantEnabled;
|
|
614
836
|
}
|
|
615
837
|
/**
|
|
616
838
|
* Copy the tenant id from `ctx` onto the write payload so the doc persists
|
|
617
839
|
* 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
|
-
*
|
|
840
|
+
* auto-wired `multiTenantPlugin` but still scopes at its framework layer
|
|
841
|
+
* (e.g. arc's preset + `BaseController` — see `@classytic/order` CLAUDE.md:
|
|
842
|
+
* "Child repos set `organizationId` explicitly on the doc").
|
|
843
|
+
*
|
|
844
|
+
* Skipped entirely when the engine was configured with `tenant: false` —
|
|
845
|
+
* the host's intent is company-wide rows (no `organizationId` on disk),
|
|
846
|
+
* and injecting it from `ctx` would silently re-scope writes per branch
|
|
847
|
+
* while reads still look at the unscoped collection.
|
|
622
848
|
*/
|
|
623
849
|
_injectTenant(data, ctx) {
|
|
850
|
+
if (!this.tenantEnabled) return data;
|
|
624
851
|
const tenantValue = ctx?.[this.tenantField];
|
|
625
852
|
if (tenantValue != null && data[this.tenantField] == null) data[this.tenantField] = tenantValue;
|
|
626
853
|
return data;
|
|
@@ -722,11 +949,17 @@ var VoucherRepository = class extends Repository {
|
|
|
722
949
|
* (e.g. arc's preset + `BaseController`). Without this, a host that
|
|
723
950
|
* runs the plugin off would leak vouchers across branches at
|
|
724
951
|
* validate/redeem/spend/topUp call sites.
|
|
952
|
+
*
|
|
953
|
+
* When the engine is configured with `tenant: false`, the filter is
|
|
954
|
+
* code-only — vouchers are company-wide and the docs carry no
|
|
955
|
+
* `organizationId`, so injecting one would always miss.
|
|
725
956
|
*/
|
|
726
957
|
async getByCode(code, ctx) {
|
|
727
958
|
const filter = { code: code.toUpperCase() };
|
|
728
|
-
|
|
729
|
-
|
|
959
|
+
if (this.tenantEnabled) {
|
|
960
|
+
const tenantValue = ctx?.[this.tenantField];
|
|
961
|
+
if (tenantValue != null) filter[this.tenantField] = tenantValue;
|
|
962
|
+
}
|
|
730
963
|
return this.getByQuery(filter, {
|
|
731
964
|
throwOnNotFound: false,
|
|
732
965
|
lean: true,
|
|
@@ -758,12 +991,148 @@ function createRepositories(models, plugins, tenant, dispatchDeps = {}) {
|
|
|
758
991
|
program: new ProgramRepository(models.Program, [...tenantPlugins, ...plugins?.program ?? []], dispatchDeps),
|
|
759
992
|
rule: new RuleRepository(models.Rule, [...tenantPlugins, ...plugins?.rule ?? []]),
|
|
760
993
|
reward: new RewardRepository(models.Reward, [...tenantPlugins, ...plugins?.reward ?? []]),
|
|
761
|
-
voucher: new VoucherRepository(models.Voucher, [...tenantPlugins, ...plugins?.voucher ?? []], dispatchDeps, tenant?.tenantField)
|
|
994
|
+
voucher: new VoucherRepository(models.Voucher, [...tenantPlugins, ...plugins?.voucher ?? []], dispatchDeps, tenant?.tenantField, tenant?.enabled ?? false),
|
|
995
|
+
pendingEvaluation: new PendingEvaluationRepository(models.PendingEvaluation, [...tenantPlugins, ...plugins?.pendingEvaluation ?? []], tenant)
|
|
762
996
|
};
|
|
763
997
|
}
|
|
764
998
|
//#endregion
|
|
999
|
+
//#region src/adapters/mongo-evaluation-store.ts
|
|
1000
|
+
/**
|
|
1001
|
+
* Default Mongo-backed implementation of {@link EvaluationStore}. Translates
|
|
1002
|
+
* between the domain snapshot shape and the persistence document shape,
|
|
1003
|
+
* forwards atomic `take`/`delete` semantics to the repository, and
|
|
1004
|
+
* applies the engine's configured tenant field on writes (the repo
|
|
1005
|
+
* applies it on reads).
|
|
1006
|
+
*
|
|
1007
|
+
* Hosts that prefer a different backend (Redis, DynamoDB, in-memory for
|
|
1008
|
+
* tests) can implement `EvaluationStore` directly and pass it via engine
|
|
1009
|
+
* config — this class isn't load-bearing.
|
|
1010
|
+
*/
|
|
1011
|
+
var MongoEvaluationStore = class {
|
|
1012
|
+
constructor(repo) {
|
|
1013
|
+
this.repo = repo;
|
|
1014
|
+
}
|
|
1015
|
+
async put(id, snapshot, ttlSeconds, ctx) {
|
|
1016
|
+
const expiresAt = new Date(Date.now() + ttlSeconds * 1e3);
|
|
1017
|
+
const session = ctx?.session;
|
|
1018
|
+
const tenantField = this.repo.tenantField;
|
|
1019
|
+
const tenantValue = ctx?.tenantValue ?? snapshot.ctx.organizationId;
|
|
1020
|
+
const filter = { evaluationId: id };
|
|
1021
|
+
if (tenantField && tenantValue !== void 0) filter[tenantField] = tenantValue;
|
|
1022
|
+
const update = {
|
|
1023
|
+
evaluationId: id,
|
|
1024
|
+
result: snapshot.result,
|
|
1025
|
+
ctx: snapshot.ctx,
|
|
1026
|
+
customerId: snapshot.customerId ?? null,
|
|
1027
|
+
programUsages: snapshot.programUsages,
|
|
1028
|
+
voucherUsages: snapshot.voucherUsages,
|
|
1029
|
+
cartHash: snapshot.cartHash,
|
|
1030
|
+
expiresAt
|
|
1031
|
+
};
|
|
1032
|
+
if (tenantField && tenantValue !== void 0) update[tenantField] = tenantValue;
|
|
1033
|
+
await this.repo.Model.updateOne(filter, { $set: update }, {
|
|
1034
|
+
upsert: true,
|
|
1035
|
+
session
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
async take(id, ctx) {
|
|
1039
|
+
const doc = await this.repo.takeByEvaluationId(id, {
|
|
1040
|
+
tenantValue: ctx?.tenantValue,
|
|
1041
|
+
session: ctx?.session
|
|
1042
|
+
});
|
|
1043
|
+
if (!doc) return null;
|
|
1044
|
+
return {
|
|
1045
|
+
result: doc.result,
|
|
1046
|
+
ctx: doc.ctx,
|
|
1047
|
+
customerId: doc.customerId ?? void 0,
|
|
1048
|
+
programUsages: doc.programUsages,
|
|
1049
|
+
voucherUsages: doc.voucherUsages,
|
|
1050
|
+
cartHash: doc.cartHash,
|
|
1051
|
+
createdAt: doc.createdAt
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
async delete(id, ctx) {
|
|
1055
|
+
await this.repo.deleteByEvaluationId(id, {
|
|
1056
|
+
tenantValue: ctx?.tenantValue,
|
|
1057
|
+
session: ctx?.session
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
};
|
|
1061
|
+
/**
|
|
1062
|
+
* In-memory fallback. Useful for unit tests, single-process dev servers,
|
|
1063
|
+
* and hosts that consciously opt out of persistence (knowing they lose
|
|
1064
|
+
* pending evaluations on restart). NOT recommended for production
|
|
1065
|
+
* topologies — see EvaluationStore docblock for why.
|
|
1066
|
+
*
|
|
1067
|
+
* Tenant scoping uses `ctx.tenantValue` as part of the in-memory key
|
|
1068
|
+
* (the field-name layer doesn't matter here — we just namespace by
|
|
1069
|
+
* value). No transaction semantics (in-memory has nothing to roll
|
|
1070
|
+
* back), no TTL refinement beyond initial expiry check.
|
|
1071
|
+
*/
|
|
1072
|
+
var InMemoryEvaluationStore = class {
|
|
1073
|
+
map = /* @__PURE__ */ new Map();
|
|
1074
|
+
async put(id, snapshot, ttlSeconds, ctx) {
|
|
1075
|
+
this.map.set(this.key(id, ctx), {
|
|
1076
|
+
snapshot,
|
|
1077
|
+
expiresAt: Date.now() + ttlSeconds * 1e3
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
async take(id, ctx) {
|
|
1081
|
+
const k = this.key(id, ctx);
|
|
1082
|
+
const entry = this.map.get(k);
|
|
1083
|
+
if (!entry) return null;
|
|
1084
|
+
this.map.delete(k);
|
|
1085
|
+
if (entry.expiresAt < Date.now()) return null;
|
|
1086
|
+
return entry.snapshot;
|
|
1087
|
+
}
|
|
1088
|
+
async delete(id, ctx) {
|
|
1089
|
+
this.map.delete(this.key(id, ctx));
|
|
1090
|
+
}
|
|
1091
|
+
key(id, ctx) {
|
|
1092
|
+
return ctx?.tenantValue ? `${ctx.tenantValue}:${id}` : id;
|
|
1093
|
+
}
|
|
1094
|
+
};
|
|
1095
|
+
//#endregion
|
|
1096
|
+
//#region src/utils/is-write-conflict.ts
|
|
1097
|
+
/**
|
|
1098
|
+
* Detect MongoDB's transient WriteConflict error across the shapes it
|
|
1099
|
+
* arrives in (raw driver error, mongoose-wrapped VersionError, mongokit
|
|
1100
|
+
* rethrows). Centralised so every contended write site (gift card spend,
|
|
1101
|
+
* gift card top-up, evaluation commit) can opt into uniform mapping
|
|
1102
|
+
* to the same typed `ConcurrencyConflictError` / `*UsageCapExceededError`
|
|
1103
|
+
* shape — hosts catch one type, decide retry-or-409 once.
|
|
1104
|
+
*
|
|
1105
|
+
* Why detection is a layered match: under heavy contention MongoDB
|
|
1106
|
+
* surfaces the same physical race in different ways depending on which
|
|
1107
|
+
* layer caught it first (driver vs mongoose vs mongokit), and each
|
|
1108
|
+
* wrapper preserves a slightly different subset of the original error
|
|
1109
|
+
* fields. Matching against ALL of them avoids false negatives that
|
|
1110
|
+
* would otherwise let the raw error escape to the host with no useful
|
|
1111
|
+
* type information.
|
|
1112
|
+
*/
|
|
1113
|
+
function isWriteConflict(err) {
|
|
1114
|
+
if (typeof err !== "object" || err === null) return false;
|
|
1115
|
+
const e = err;
|
|
1116
|
+
if (e.code === 112 || e.codeName === "WriteConflict") return true;
|
|
1117
|
+
if (e.name === "MongoServerError" && typeof e.message === "string" && /write conflict/i.test(e.message)) return true;
|
|
1118
|
+
if (Array.isArray(e.errorLabels) && e.errorLabels.includes("TransientTransactionError")) return true;
|
|
1119
|
+
if (typeof e.message === "string" && /write conflict/i.test(e.message)) return true;
|
|
1120
|
+
if (e.cause !== void 0) return isWriteConflict(e.cause);
|
|
1121
|
+
return false;
|
|
1122
|
+
}
|
|
1123
|
+
//#endregion
|
|
765
1124
|
//#region src/services/evaluation.service.ts
|
|
766
1125
|
/**
|
|
1126
|
+
* Default TTL for pending evaluation snapshots in the store. 30 minutes
|
|
1127
|
+
* comfortably exceeds any realistic evaluate→commit window (typical
|
|
1128
|
+
* checkout: seconds to a few minutes; long-tail: ~10 min) without
|
|
1129
|
+
* hoarding abandoned evaluations forever. The Mongo TTL index purges
|
|
1130
|
+
* expired entries; the engine itself never relies on the TTL for
|
|
1131
|
+
* correctness — `store.take()` is atomic and returns `null` when an
|
|
1132
|
+
* id was already consumed or never existed.
|
|
1133
|
+
*/
|
|
1134
|
+
const DEFAULT_PENDING_EVALUATION_TTL_SECONDS = 1800;
|
|
1135
|
+
/**
|
|
767
1136
|
* Deterministic, collision-resistant hash of the evaluated cart. The hash
|
|
768
1137
|
* covers everything the evaluation algorithm depends on: normalized line
|
|
769
1138
|
* items (sorted), subtotal, applied codes, and customer identity. Two
|
|
@@ -797,8 +1166,7 @@ function computeCartHash(input) {
|
|
|
797
1166
|
return createHash("sha256").update(canonical).digest("hex");
|
|
798
1167
|
}
|
|
799
1168
|
var EvaluationService = class {
|
|
800
|
-
|
|
801
|
-
constructor(programRepo, ruleRepo, rewardRepo, voucherRepo, unitOfWork, dispatchDeps, config) {
|
|
1169
|
+
constructor(programRepo, ruleRepo, rewardRepo, voucherRepo, unitOfWork, dispatchDeps, config, store) {
|
|
802
1170
|
this.programRepo = programRepo;
|
|
803
1171
|
this.ruleRepo = ruleRepo;
|
|
804
1172
|
this.rewardRepo = rewardRepo;
|
|
@@ -806,6 +1174,7 @@ var EvaluationService = class {
|
|
|
806
1174
|
this.unitOfWork = unitOfWork;
|
|
807
1175
|
this.dispatchDeps = dispatchDeps;
|
|
808
1176
|
this.config = config;
|
|
1177
|
+
this.store = store;
|
|
809
1178
|
}
|
|
810
1179
|
async evaluate(input, ctx) {
|
|
811
1180
|
return this.doEvaluate(input, ctx, false);
|
|
@@ -814,50 +1183,80 @@ var EvaluationService = class {
|
|
|
814
1183
|
return this.doEvaluate(input, ctx, true);
|
|
815
1184
|
}
|
|
816
1185
|
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,
|
|
1186
|
+
let snapshotForCapMapping = null;
|
|
1187
|
+
try {
|
|
1188
|
+
return await this.unitOfWork.withTransaction(async (session) => {
|
|
1189
|
+
const stored = await this.store.take(evaluationId, {
|
|
1190
|
+
tenantValue: ctx.organizationId,
|
|
828
1191
|
session
|
|
829
1192
|
});
|
|
1193
|
+
if (!stored) throw new EvaluationNotFoundError(evaluationId);
|
|
1194
|
+
if (options.cartHash !== void 0 && options.cartHash !== stored.cartHash) throw new CartHashMismatchError(evaluationId);
|
|
1195
|
+
snapshotForCapMapping = stored;
|
|
1196
|
+
return await this.commitInTransaction(stored, evaluationId, orderId, session, ctx);
|
|
1197
|
+
});
|
|
1198
|
+
} catch (err) {
|
|
1199
|
+
if (isWriteConflict(err) && snapshotForCapMapping) {
|
|
1200
|
+
const firstUsage = snapshotForCapMapping.programUsages[0];
|
|
1201
|
+
if (firstUsage) {
|
|
1202
|
+
const program = await this.programRepo.getById(firstUsage.programId, {
|
|
1203
|
+
...ctx,
|
|
1204
|
+
lean: true,
|
|
1205
|
+
throwOnNotFound: false
|
|
1206
|
+
});
|
|
1207
|
+
throw new ProgramUsageCapExceededError(firstUsage.programId, program?.maxUsageTotal ?? 0);
|
|
1208
|
+
}
|
|
830
1209
|
}
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
1210
|
+
throw err;
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
async commitInTransaction(stored, evaluationId, orderId, session, ctx) {
|
|
1214
|
+
for (const usage of stored.programUsages) {
|
|
1215
|
+
if (!await this.programRepo.tryIncrementUsage(usage.programId, {
|
|
836
1216
|
...ctx,
|
|
837
1217
|
session
|
|
838
|
-
})
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
}
|
|
847
|
-
await
|
|
848
|
-
evaluationId,
|
|
849
|
-
orderId,
|
|
850
|
-
totalDiscount: stored.result.totalDiscount
|
|
851
|
-
}, ctx), {
|
|
1218
|
+
})) {
|
|
1219
|
+
const program = await this.programRepo.getById(usage.programId, {
|
|
1220
|
+
...ctx,
|
|
1221
|
+
session,
|
|
1222
|
+
lean: true,
|
|
1223
|
+
throwOnNotFound: false
|
|
1224
|
+
});
|
|
1225
|
+
throw new ProgramUsageCapExceededError(usage.programId, program?.maxUsageTotal ?? 0);
|
|
1226
|
+
}
|
|
1227
|
+
if (stored.customerId) await this.programRepo.incrementCustomerUsage(usage.programId, stored.customerId, {
|
|
852
1228
|
...ctx,
|
|
853
1229
|
session
|
|
854
1230
|
});
|
|
855
|
-
|
|
1231
|
+
}
|
|
1232
|
+
for (const usage of stored.voucherUsages) await this.voucherRepo.incrementUsage(usage.voucherId, {
|
|
1233
|
+
orderId,
|
|
1234
|
+
discountAmount: usage.discountAmount,
|
|
1235
|
+
redeemedAt: /* @__PURE__ */ new Date(),
|
|
1236
|
+
...ctx.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {}
|
|
1237
|
+
}, {
|
|
1238
|
+
...ctx,
|
|
1239
|
+
session
|
|
1240
|
+
});
|
|
1241
|
+
const commitResult = {
|
|
1242
|
+
evaluationId,
|
|
1243
|
+
orderId,
|
|
1244
|
+
totalDiscount: stored.result.totalDiscount,
|
|
1245
|
+
programsCommitted: stored.programUsages.length,
|
|
1246
|
+
vouchersUsed: stored.voucherUsages.length
|
|
1247
|
+
};
|
|
1248
|
+
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.EVALUATION_COMMITTED, {
|
|
1249
|
+
evaluationId,
|
|
1250
|
+
orderId,
|
|
1251
|
+
totalDiscount: stored.result.totalDiscount
|
|
1252
|
+
}, ctx), {
|
|
1253
|
+
...ctx,
|
|
1254
|
+
session
|
|
856
1255
|
});
|
|
1256
|
+
return commitResult;
|
|
857
1257
|
}
|
|
858
1258
|
async rollback(evaluationId, ctx) {
|
|
859
|
-
|
|
860
|
-
this.pendingEvaluations.delete(evaluationId);
|
|
1259
|
+
await this.store.delete(evaluationId, { tenantValue: ctx.organizationId });
|
|
861
1260
|
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.EVALUATION_ROLLED_BACK, { evaluationId }, ctx), ctx);
|
|
862
1261
|
}
|
|
863
1262
|
async doEvaluate(input, ctx, isPreview) {
|
|
@@ -970,15 +1369,18 @@ var EvaluationService = class {
|
|
|
970
1369
|
isPreview,
|
|
971
1370
|
programsApplied
|
|
972
1371
|
};
|
|
973
|
-
if (!isPreview)
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
1372
|
+
if (!isPreview) {
|
|
1373
|
+
const snapshot = {
|
|
1374
|
+
result,
|
|
1375
|
+
ctx,
|
|
1376
|
+
customerId: input.customerId,
|
|
1377
|
+
programUsages,
|
|
1378
|
+
voucherUsages,
|
|
1379
|
+
cartHash,
|
|
1380
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
1381
|
+
};
|
|
1382
|
+
await this.store.put(evaluationId, snapshot, DEFAULT_PENDING_EVALUATION_TTL_SECONDS, { tenantValue: ctx.organizationId });
|
|
1383
|
+
}
|
|
982
1384
|
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.EVALUATION_COMPLETED, {
|
|
983
1385
|
evaluationId,
|
|
984
1386
|
totalDiscount,
|
|
@@ -1267,14 +1669,18 @@ var VoucherService = class {
|
|
|
1267
1669
|
if (!voucher) throw new VoucherNotFoundError(input.code);
|
|
1268
1670
|
this.assertVoucherUsable(voucher);
|
|
1269
1671
|
if (input.idempotencyKey) {
|
|
1270
|
-
if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey
|
|
1672
|
+
if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey, {
|
|
1673
|
+
...ctx,
|
|
1674
|
+
session
|
|
1675
|
+
})) throw new DuplicateRedemptionError(input.idempotencyKey);
|
|
1271
1676
|
}
|
|
1272
1677
|
const redemption = {
|
|
1273
1678
|
orderId: input.orderId,
|
|
1274
1679
|
customerId: input.customerId,
|
|
1275
1680
|
discountAmount: input.discountAmount,
|
|
1276
1681
|
redeemedAt: /* @__PURE__ */ new Date(),
|
|
1277
|
-
...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}
|
|
1682
|
+
...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {},
|
|
1683
|
+
...ctx.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {}
|
|
1278
1684
|
};
|
|
1279
1685
|
const updated = await this.voucherRepo.incrementUsage(voucher._id, redemption, {
|
|
1280
1686
|
...ctx,
|
|
@@ -1330,14 +1736,18 @@ var VoucherService = class {
|
|
|
1330
1736
|
const balance = voucher.currentBalance ?? 0;
|
|
1331
1737
|
if (!this.config.giftCard.allowNegativeBalance && balance < input.amount) throw new InsufficientBalanceError(input.code, balance, input.amount);
|
|
1332
1738
|
if (input.idempotencyKey) {
|
|
1333
|
-
if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey
|
|
1739
|
+
if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey, {
|
|
1740
|
+
...ctx,
|
|
1741
|
+
session
|
|
1742
|
+
})) throw new DuplicateRedemptionError(input.idempotencyKey);
|
|
1334
1743
|
}
|
|
1335
1744
|
const entry = {
|
|
1336
1745
|
amount: -input.amount,
|
|
1337
1746
|
orderId: input.orderId,
|
|
1338
1747
|
description: input.description ?? `Spent on order ${input.orderId}`,
|
|
1339
1748
|
createdAt: /* @__PURE__ */ new Date(),
|
|
1340
|
-
...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}
|
|
1749
|
+
...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {},
|
|
1750
|
+
...ctx.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {}
|
|
1341
1751
|
};
|
|
1342
1752
|
const updated = await this.voucherRepo.addLedgerEntry(voucher._id, entry, -input.amount, {
|
|
1343
1753
|
...ctx,
|
|
@@ -1380,37 +1790,61 @@ var VoucherService = class {
|
|
|
1380
1790
|
}
|
|
1381
1791
|
async topUp(input, ctx) {
|
|
1382
1792
|
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);
|
|
1793
|
+
try {
|
|
1794
|
+
return await this.topUpInTransaction(input, ctx);
|
|
1795
|
+
} catch (err) {
|
|
1796
|
+
if (isWriteConflict(err)) throw new ConcurrencyConflictError("voucher", input.code, err);
|
|
1797
|
+
throw err;
|
|
1389
1798
|
}
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1799
|
+
}
|
|
1800
|
+
async topUpInTransaction(input, ctx) {
|
|
1801
|
+
return this.unitOfWork.withTransaction(async (session) => {
|
|
1802
|
+
const voucher = await this.voucherRepo.getByCode(input.code, {
|
|
1803
|
+
...ctx,
|
|
1804
|
+
session
|
|
1805
|
+
});
|
|
1806
|
+
if (!voucher) throw new VoucherNotFoundError(input.code);
|
|
1807
|
+
const maxBalance = this.config.giftCard.maxBalance;
|
|
1808
|
+
if (maxBalance !== null && (voucher.currentBalance ?? 0) + input.amount > maxBalance) throw new ValidationError(`Top-up would exceed max balance of ${maxBalance}`);
|
|
1809
|
+
if (input.idempotencyKey) {
|
|
1810
|
+
if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey, {
|
|
1811
|
+
...ctx,
|
|
1812
|
+
session
|
|
1813
|
+
})) throw new DuplicateRedemptionError(input.idempotencyKey);
|
|
1814
|
+
}
|
|
1815
|
+
const entry = {
|
|
1816
|
+
amount: input.amount,
|
|
1817
|
+
description: input.description ?? "Top-up",
|
|
1818
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
1819
|
+
...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {},
|
|
1820
|
+
...ctx.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {}
|
|
1821
|
+
};
|
|
1822
|
+
const updated = await this.voucherRepo.addLedgerEntry(voucher._id, entry, input.amount, {
|
|
1823
|
+
...ctx,
|
|
1824
|
+
session
|
|
1825
|
+
});
|
|
1826
|
+
if (voucher.status === "used") await this.voucherRepo.update(voucher._id, { status: "active" }, {
|
|
1827
|
+
lean: true,
|
|
1828
|
+
...ctx,
|
|
1829
|
+
session
|
|
1830
|
+
});
|
|
1831
|
+
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.GIFT_CARD_TOPPED_UP, {
|
|
1832
|
+
voucherId: voucher._id,
|
|
1833
|
+
code: voucher.code,
|
|
1834
|
+
amount: input.amount,
|
|
1835
|
+
newBalance: updated.currentBalance ?? 0
|
|
1836
|
+
}, ctx), {
|
|
1837
|
+
...ctx,
|
|
1838
|
+
session
|
|
1839
|
+
});
|
|
1840
|
+
return {
|
|
1841
|
+
code: updated.code,
|
|
1842
|
+
initialBalance: updated.initialBalance ?? 0,
|
|
1843
|
+
currentBalance: updated.currentBalance ?? 0,
|
|
1844
|
+
spent: (updated.initialBalance ?? 0) - (updated.currentBalance ?? 0),
|
|
1845
|
+
voucherId: updated._id
|
|
1846
|
+
};
|
|
1400
1847
|
});
|
|
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
1848
|
}
|
|
1415
1849
|
assertVoucherUsable(voucher) {
|
|
1416
1850
|
if (voucher.status === "expired") throw new VoucherExpiredError(voucher.code);
|
|
@@ -1423,61 +1857,19 @@ var VoucherService = class {
|
|
|
1423
1857
|
if (voucher.usedCount >= voucher.usageLimit) throw new VoucherExhaustedError(voucher.code);
|
|
1424
1858
|
}
|
|
1425
1859
|
};
|
|
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
1860
|
//#endregion
|
|
1442
1861
|
//#region src/services/create-services.ts
|
|
1443
1862
|
function createServices(deps) {
|
|
1444
|
-
const { repositories, unitOfWork, dispatchDeps, config } = deps;
|
|
1863
|
+
const { repositories, unitOfWork, dispatchDeps, config, evaluationStore } = deps;
|
|
1864
|
+
const voucher = new VoucherService(repositories.voucher, repositories.program, unitOfWork, dispatchDeps, config);
|
|
1865
|
+
const store = evaluationStore ?? new MongoEvaluationStore(repositories.pendingEvaluation);
|
|
1445
1866
|
return {
|
|
1446
|
-
voucher
|
|
1447
|
-
evaluation: new EvaluationService(repositories.program, repositories.rule, repositories.reward, repositories.voucher, unitOfWork, dispatchDeps, config)
|
|
1867
|
+
voucher,
|
|
1868
|
+
evaluation: new EvaluationService(repositories.program, repositories.rule, repositories.reward, repositories.voucher, unitOfWork, dispatchDeps, config, store)
|
|
1448
1869
|
};
|
|
1449
1870
|
}
|
|
1450
1871
|
//#endregion
|
|
1451
1872
|
//#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
1873
|
function definePromoEvent(input) {
|
|
1482
1874
|
const { name, version = 1, description, zodSchema } = input;
|
|
1483
1875
|
const def = {
|
|
@@ -1727,23 +2119,62 @@ function resolveConfig(config) {
|
|
|
1727
2119
|
if (resolved.evaluation.maxStackablePromotions < 1) throw new ValidationError("maxStackablePromotions must be >= 1");
|
|
1728
2120
|
return resolved;
|
|
1729
2121
|
}
|
|
2122
|
+
/**
|
|
2123
|
+
* Thin adapter over `mongokit.withTransaction` so the engine's local
|
|
2124
|
+
* `UnitOfWork` port stays driver-agnostic while inheriting mongokit's
|
|
2125
|
+
* battle-tested transaction handling: auto-retry on
|
|
2126
|
+
* `TransientTransactionError` + `UnknownTransactionCommitResult`,
|
|
2127
|
+
* standalone-Mongo fallback for dev, consistent session lifecycle.
|
|
2128
|
+
*
|
|
2129
|
+
* Don't replace this with hand-rolled `session.withTransaction()` — the
|
|
2130
|
+
* underlying helper centralises retry semantics across every package
|
|
2131
|
+
* that wires it. See packages/mongokit/src/transaction.ts.
|
|
2132
|
+
*/
|
|
1730
2133
|
var MongoUnitOfWork = class {
|
|
1731
2134
|
constructor(connection) {
|
|
1732
2135
|
this.connection = connection;
|
|
1733
2136
|
}
|
|
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
|
-
}
|
|
2137
|
+
withTransaction(cb) {
|
|
2138
|
+
return withTransaction(this.connection, cb);
|
|
1745
2139
|
}
|
|
1746
2140
|
};
|
|
2141
|
+
/**
|
|
2142
|
+
* Build the promo engine for a host application.
|
|
2143
|
+
*
|
|
2144
|
+
* **Index management — important for boot performance:**
|
|
2145
|
+
*
|
|
2146
|
+
* `createPromoEngine` itself is non-blocking. It registers Mongoose models
|
|
2147
|
+
* and returns immediately. Index creation is delegated to Mongoose's
|
|
2148
|
+
* standard lazy-init: with `autoIndex: true` (default in dev) Mongoose
|
|
2149
|
+
* builds indexes in the background after the first query touches each
|
|
2150
|
+
* model; with `autoIndex: false` (recommended for production) hosts
|
|
2151
|
+
* control index creation explicitly.
|
|
2152
|
+
*
|
|
2153
|
+
* The returned `engine.syncIndexes()` helper is opt-in and **MUST NOT be
|
|
2154
|
+
* `await`ed during Fastify plugin registration / boot** — Atlas index
|
|
2155
|
+
* creation can take 10s+ on fresh collections, longer than typical
|
|
2156
|
+
* plugin timeouts. Three safe patterns:
|
|
2157
|
+
*
|
|
2158
|
+
* 1. **Migration script** (recommended for production):
|
|
2159
|
+
* ```ts
|
|
2160
|
+
* // scripts/sync-indexes.ts
|
|
2161
|
+
* await engine.syncIndexes();
|
|
2162
|
+
* ```
|
|
2163
|
+
* Run before deploying / serving traffic.
|
|
2164
|
+
*
|
|
2165
|
+
* 2. **Background fire-and-log** during boot:
|
|
2166
|
+
* ```ts
|
|
2167
|
+
* engine.syncIndexes().catch((err) => log.warn({ err }, 'index sync'));
|
|
2168
|
+
* ```
|
|
2169
|
+
* App accepts traffic immediately; first queries may wait briefly
|
|
2170
|
+
* on still-building indexes.
|
|
2171
|
+
*
|
|
2172
|
+
* 3. **Lazy init** (`autoIndex: true` in dev): just don't call
|
|
2173
|
+
* `syncIndexes()` at all. Mongoose schedules creation on first
|
|
2174
|
+
* query per model.
|
|
2175
|
+
*
|
|
2176
|
+
* Production hosts should set `autoIndex: false` and use option (1).
|
|
2177
|
+
*/
|
|
1747
2178
|
function createPromoEngine(config) {
|
|
1748
2179
|
const resolvedConfig = resolveConfig(config);
|
|
1749
2180
|
const models = createModels(config.mongoose, resolvedConfig.tenant, config.indexes, config.autoIndex);
|
|
@@ -1761,7 +2192,8 @@ function createPromoEngine(config) {
|
|
|
1761
2192
|
repositories,
|
|
1762
2193
|
unitOfWork: new MongoUnitOfWork(config.mongoose),
|
|
1763
2194
|
dispatchDeps,
|
|
1764
|
-
config: resolvedConfig
|
|
2195
|
+
config: resolvedConfig,
|
|
2196
|
+
...config.evaluationStore !== void 0 ? { evaluationStore: config.evaluationStore } : {}
|
|
1765
2197
|
}),
|
|
1766
2198
|
events,
|
|
1767
2199
|
async syncIndexes() {
|
|
@@ -1770,4 +2202,4 @@ function createPromoEngine(config) {
|
|
|
1770
2202
|
};
|
|
1771
2203
|
}
|
|
1772
2204
|
//#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 };
|
|
2205
|
+
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 };
|