@classytic/promo 0.2.5 → 0.4.0
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/CHANGELOG.md +127 -0
- package/README.md +68 -8
- package/dist/{constants-BB5O8zlN.mjs → constants-BE886vJk.mjs} +52 -34
- package/dist/events/promo-event-catalog.d.mts +233 -0
- package/dist/events/promo-event-catalog.mjs +2 -0
- package/dist/index.d.mts +260 -362
- package/dist/index.mjs +232 -363
- package/dist/promo-event-catalog-Dyh0xQ6w.mjs +250 -0
- package/dist/schemas/index.d.mts +4 -4
- package/dist/schemas/index.mjs +1 -1
- package/package.json +10 -6
- /package/dist/{constants-CrbSSQG5.d.mts → constants-hcMTDJml.d.mts} +0 -0
package/dist/index.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { C as
|
|
2
|
-
import {
|
|
1
|
+
import { C as TenantIsolationError, D as VoucherNotFoundError, E as VoucherExpiredError, O as PromoError, S as RuleNotFoundError, T as VoucherExhaustedError, _ as InvalidTransitionError, a as PROGRAM_TYPES, b as PromoModelCollisionError, c as TRIGGER_MODES, d as ConcurrencyConflictError, f as DuplicateRedemptionError, g as InsufficientBalanceError, h as GiftCardExhaustedError, i as PROGRAM_STATUSES, l as VOUCHER_STATUSES, m as EvaluationNotFoundError, n as DISCOUNT_SCOPES, o as REWARD_TYPES, p as DuplicateVoucherCodeError, r as PROGRAM_MACHINE, s as STACKING_MODES, t as DISCOUNT_MODES, u as CartHashMismatchError, v as ProgramNotFoundError, w as ValidationError, x as RewardNotFoundError, y as ProgramUsageCapExceededError } from "./constants-BE886vJk.mjs";
|
|
2
|
+
import { S as PromoEvents, _ as VoucherCancelled, a as GiftCardSpent, b as VoucherRedeemed, c as ProgramArchived, d as RewardAdded, f as RewardRemoved, g as RuleUpdated, h as RuleRemoved, i as GiftCardExhausted, l as ProgramCreated, m as RuleAdded, n as EvaluationCompleted, o as GiftCardToppedUp, p as RewardUpdated, r as EvaluationRolledBack, s as ProgramActivated, t as EvaluationCommitted, u as ProgramPaused, v as VoucherExpired, x as promoEventDefinitions, y as VoucherGenerated } from "./promo-event-catalog-Dyh0xQ6w.mjs";
|
|
3
|
+
import { Repository, isDuplicateKeyError, multiTenantPlugin, withTransaction } from "@classytic/mongokit";
|
|
3
4
|
import { resolveTenantConfig } from "@classytic/repo-core/tenant";
|
|
4
5
|
import { createEvent, matchEventPattern } from "@classytic/primitives/events";
|
|
5
6
|
import mongoose, { Schema } from "mongoose";
|
|
7
|
+
import { throwIfAborted } from "@classytic/repo-core/repository";
|
|
6
8
|
import { createHash, randomBytes } from "node:crypto";
|
|
7
|
-
import { z } from "zod";
|
|
8
9
|
//#region src/events/in-process-bus.ts
|
|
9
10
|
var InProcessPromoBus = class {
|
|
10
11
|
name = "in-process-promo";
|
|
@@ -367,6 +368,21 @@ const MODEL_NAMES = [
|
|
|
367
368
|
"PromoVoucher",
|
|
368
369
|
"PromoPendingEvaluation"
|
|
369
370
|
];
|
|
371
|
+
/**
|
|
372
|
+
* Explicit physical collection names (PACKAGE_RULES §20.1) — the values
|
|
373
|
+
* Mongoose's pluralizer produced before they were pinned, so existing
|
|
374
|
+
* deployments keep their data with zero migration. Passed as the third
|
|
375
|
+
* arg of `connection.model()` so a host that disables/changes the
|
|
376
|
+
* pluralizer can't silently rename collections. Changing any entry is a
|
|
377
|
+
* BREAKING change (renames the physical collection — ship a migration).
|
|
378
|
+
*/
|
|
379
|
+
const DEFAULT_COLLECTIONS = {
|
|
380
|
+
PromoProgram: "promoprograms",
|
|
381
|
+
PromoRule: "promorules",
|
|
382
|
+
PromoReward: "promorewards",
|
|
383
|
+
PromoVoucher: "promovouchers",
|
|
384
|
+
PromoPendingEvaluation: "promopendingevaluations"
|
|
385
|
+
};
|
|
370
386
|
function applyAutoIndex(models, autoIndex) {
|
|
371
387
|
if (autoIndex === void 0) return;
|
|
372
388
|
for (const [configKey, modelKey] of [
|
|
@@ -379,8 +395,11 @@ function applyAutoIndex(models, autoIndex) {
|
|
|
379
395
|
if (value !== void 0) models[modelKey].schema.set("autoIndex", value);
|
|
380
396
|
}
|
|
381
397
|
}
|
|
382
|
-
function createModels(connection, tenant,
|
|
383
|
-
|
|
398
|
+
function createModels(connection, tenant, options = {}) {
|
|
399
|
+
const { indexes, autoIndex } = options;
|
|
400
|
+
if (options.forceRecreate) {
|
|
401
|
+
for (const name of MODEL_NAMES) if (connection.models[name]) connection.deleteModel(name);
|
|
402
|
+
} else for (const name of MODEL_NAMES) if (connection.models[name]) throw new PromoModelCollisionError(name);
|
|
384
403
|
const programSchema = createProgramSchema();
|
|
385
404
|
const ruleSchema = createRuleSchema();
|
|
386
405
|
const rewardSchema = createRewardSchema();
|
|
@@ -396,12 +415,13 @@ function createModels(connection, tenant, indexes, autoIndex) {
|
|
|
396
415
|
if (indexes?.rule) applyUserIndexes(ruleSchema, indexes.rule, tenant);
|
|
397
416
|
if (indexes?.reward) applyUserIndexes(rewardSchema, indexes.reward, tenant);
|
|
398
417
|
if (indexes?.voucher) applyUserIndexes(voucherSchema, indexes.voucher, tenant);
|
|
418
|
+
const prefix = options.collectionPrefix ?? "";
|
|
399
419
|
const result = {
|
|
400
|
-
Program: connection.model("PromoProgram", programSchema),
|
|
401
|
-
Rule: connection.model("PromoRule", ruleSchema),
|
|
402
|
-
Reward: connection.model("PromoReward", rewardSchema),
|
|
403
|
-
Voucher: connection.model("PromoVoucher", voucherSchema),
|
|
404
|
-
PendingEvaluation: connection.model("PromoPendingEvaluation", pendingEvaluationSchema)
|
|
420
|
+
Program: connection.model("PromoProgram", programSchema, prefix + DEFAULT_COLLECTIONS.PromoProgram),
|
|
421
|
+
Rule: connection.model("PromoRule", ruleSchema, prefix + DEFAULT_COLLECTIONS.PromoRule),
|
|
422
|
+
Reward: connection.model("PromoReward", rewardSchema, prefix + DEFAULT_COLLECTIONS.PromoReward),
|
|
423
|
+
Voucher: connection.model("PromoVoucher", voucherSchema, prefix + DEFAULT_COLLECTIONS.PromoVoucher),
|
|
424
|
+
PendingEvaluation: connection.model("PromoPendingEvaluation", pendingEvaluationSchema, prefix + DEFAULT_COLLECTIONS.PromoPendingEvaluation)
|
|
405
425
|
};
|
|
406
426
|
applyAutoIndex(result, autoIndex);
|
|
407
427
|
return result;
|
|
@@ -490,8 +510,18 @@ var PendingEvaluationRepository = class extends Repository {
|
|
|
490
510
|
//#endregion
|
|
491
511
|
//#region src/events/dispatch.ts
|
|
492
512
|
/**
|
|
513
|
+
* Context key for the post-commit publish queue. Promo's transactional
|
|
514
|
+
* verbs attach it automatically when they own the transaction; hosts that
|
|
515
|
+
* own the outer transaction (`ctx.session`) MAY attach their own array and
|
|
516
|
+
* drain it with {@link flushPendingPromoEvents} after commit for strict
|
|
517
|
+
* no-ghost-event semantics. Symbol-keyed so it survives `{ ...ctx }`
|
|
518
|
+
* spreads but never collides with host context fields.
|
|
519
|
+
*/
|
|
520
|
+
const PENDING_PROMO_EVENTS = Symbol.for("@classytic/promo:pendingEvents");
|
|
521
|
+
/**
|
|
493
522
|
* Canonical P8 shape — save to outbox first (with `ctx.session` when
|
|
494
|
-
* present), then publish to transport
|
|
523
|
+
* present), then publish to transport (deferred to the pending queue when
|
|
524
|
+
* one is attached — see module doc for the three regimes).
|
|
495
525
|
*/
|
|
496
526
|
async function dispatchPromoEvent(deps, event, ctx) {
|
|
497
527
|
const logger = deps.logger ?? console;
|
|
@@ -503,47 +533,57 @@ async function dispatchPromoEvent(deps, event, ctx) {
|
|
|
503
533
|
logger.error(`[promo] outbox.save failed for ${event.type}:`, err);
|
|
504
534
|
throw err;
|
|
505
535
|
}
|
|
536
|
+
const pending = ctx?.[PENDING_PROMO_EVENTS];
|
|
537
|
+
if (pending) {
|
|
538
|
+
pending.push(event);
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
506
541
|
if (deps.events) try {
|
|
507
542
|
await deps.events.publish(event);
|
|
508
543
|
} catch (err) {
|
|
509
544
|
logger.error(`[promo] events.publish failed for ${event.type}:`, err);
|
|
510
545
|
}
|
|
511
546
|
}
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
GIFT_CARD_TOPPED_UP: "promo.gift_card.topped_up",
|
|
531
|
-
GIFT_CARD_EXHAUSTED: "promo.gift_card.exhausted",
|
|
532
|
-
EVALUATION_COMPLETED: "promo.evaluation.completed",
|
|
533
|
-
EVALUATION_COMMITTED: "promo.evaluation.committed",
|
|
534
|
-
EVALUATION_ROLLED_BACK: "promo.evaluation.rolled_back"
|
|
535
|
-
};
|
|
547
|
+
/**
|
|
548
|
+
* Drain a pending-events queue through the transport, swallowing per-event
|
|
549
|
+
* publish errors (durable delivery is the outbox's job). Promo's
|
|
550
|
+
* transactional verbs call this after commit; hosts that attached their own
|
|
551
|
+
* `[PENDING_PROMO_EVENTS]` queue call it after their outer transaction
|
|
552
|
+
* commits. The queue is emptied in place.
|
|
553
|
+
*/
|
|
554
|
+
async function flushPendingPromoEvents(deps, events) {
|
|
555
|
+
if (events.length === 0) return;
|
|
556
|
+
const logger = deps.logger ?? console;
|
|
557
|
+
const drained = events.splice(0, events.length);
|
|
558
|
+
if (!deps.events) return;
|
|
559
|
+
for (const event of drained) try {
|
|
560
|
+
await deps.events.publish(event);
|
|
561
|
+
} catch (err) {
|
|
562
|
+
logger.error(`[promo] events.publish failed for ${event.type}:`, err);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
536
565
|
//#endregion
|
|
537
566
|
//#region src/events/helpers.ts
|
|
538
567
|
function createEvent$1(type, payload, ctx, meta) {
|
|
539
568
|
return createEvent(type, payload, {
|
|
540
569
|
resource: "promo",
|
|
541
|
-
...ctx?.
|
|
570
|
+
...ctx?.actorRef !== void 0 ? { userId: ctx.actorRef } : {},
|
|
542
571
|
...ctx?.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {},
|
|
543
572
|
...meta
|
|
544
573
|
});
|
|
545
574
|
}
|
|
546
575
|
//#endregion
|
|
576
|
+
//#region src/repositories/repo-options.ts
|
|
577
|
+
function repoOptionsFromCtx(ctx) {
|
|
578
|
+
const out = {};
|
|
579
|
+
if (!ctx) return out;
|
|
580
|
+
for (const key of Object.keys(ctx)) {
|
|
581
|
+
const value = ctx[key];
|
|
582
|
+
if (value !== void 0) out[key] = value;
|
|
583
|
+
}
|
|
584
|
+
return out;
|
|
585
|
+
}
|
|
586
|
+
//#endregion
|
|
547
587
|
//#region src/repositories/program.repository.ts
|
|
548
588
|
var ProgramRepository = class extends Repository {
|
|
549
589
|
dispatchDeps;
|
|
@@ -564,14 +604,14 @@ var ProgramRepository = class extends Repository {
|
|
|
564
604
|
const program = await this.getById(id, {
|
|
565
605
|
throwOnNotFound: false,
|
|
566
606
|
lean: true,
|
|
567
|
-
...ctx
|
|
607
|
+
...repoOptionsFromCtx(ctx)
|
|
568
608
|
});
|
|
569
609
|
if (!program) throw new ProgramNotFoundError(id);
|
|
570
610
|
PROGRAM_MACHINE.assertTransition(String(program._id), program.status, targetStatus);
|
|
571
611
|
const updated = await this.update(id, { status: targetStatus }, {
|
|
572
612
|
throwOnNotFound: true,
|
|
573
613
|
lean: true,
|
|
574
|
-
...ctx
|
|
614
|
+
...repoOptionsFromCtx(ctx)
|
|
575
615
|
});
|
|
576
616
|
if (!updated) throw new ProgramNotFoundError(id);
|
|
577
617
|
const eventName = {
|
|
@@ -583,7 +623,7 @@ var ProgramRepository = class extends Repository {
|
|
|
583
623
|
programId: updated._id,
|
|
584
624
|
programType: updated.programType,
|
|
585
625
|
status: updated.status,
|
|
586
|
-
|
|
626
|
+
actorRef: ctx.actorRef
|
|
587
627
|
}, ctx), ctx);
|
|
588
628
|
return updated;
|
|
589
629
|
}
|
|
@@ -732,13 +772,13 @@ var VoucherRepository = class extends Repository {
|
|
|
732
772
|
const voucher = await this.getById(id, {
|
|
733
773
|
throwOnNotFound: false,
|
|
734
774
|
lean: true,
|
|
735
|
-
...ctx
|
|
775
|
+
...repoOptionsFromCtx(ctx)
|
|
736
776
|
});
|
|
737
777
|
if (!voucher) throw new VoucherNotFoundError(id);
|
|
738
778
|
const updated = await this.update(id, { status: "cancelled" }, {
|
|
739
779
|
throwOnNotFound: true,
|
|
740
780
|
lean: true,
|
|
741
|
-
...ctx
|
|
781
|
+
...repoOptionsFromCtx(ctx)
|
|
742
782
|
});
|
|
743
783
|
if (!updated) throw new VoucherNotFoundError(id);
|
|
744
784
|
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.VOUCHER_CANCELLED, {
|
|
@@ -798,11 +838,14 @@ var VoucherRepository = class extends Repository {
|
|
|
798
838
|
...ctx
|
|
799
839
|
});
|
|
800
840
|
let modified = 0;
|
|
801
|
-
for (const doc of docs)
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
841
|
+
for (const doc of docs) {
|
|
842
|
+
throwIfAborted(ctx?.signal);
|
|
843
|
+
if (await this.update(doc._id, { status: "expired" }, {
|
|
844
|
+
throwOnNotFound: false,
|
|
845
|
+
lean: true,
|
|
846
|
+
...ctx
|
|
847
|
+
})) modified++;
|
|
848
|
+
}
|
|
806
849
|
return modified;
|
|
807
850
|
}
|
|
808
851
|
/**
|
|
@@ -898,7 +941,7 @@ var MongoEvaluationStore = class {
|
|
|
898
941
|
if (tenantField && tenantValue !== void 0) update[tenantField] = tenantValue;
|
|
899
942
|
await this.repo.Model.updateOne(filter, { $set: update }, {
|
|
900
943
|
upsert: true,
|
|
901
|
-
session
|
|
944
|
+
...session !== void 0 ? { session } : {}
|
|
902
945
|
});
|
|
903
946
|
}
|
|
904
947
|
async take(id, ctx) {
|
|
@@ -1050,23 +1093,37 @@ var EvaluationService = class {
|
|
|
1050
1093
|
}
|
|
1051
1094
|
async commit(evaluationId, orderId, ctx, options = {}) {
|
|
1052
1095
|
let snapshotForCapMapping = null;
|
|
1096
|
+
const run = async (txCtx) => {
|
|
1097
|
+
const session = txCtx.session;
|
|
1098
|
+
const stored = await this.store.take(evaluationId, {
|
|
1099
|
+
tenantValue: ctx.organizationId,
|
|
1100
|
+
session
|
|
1101
|
+
});
|
|
1102
|
+
if (!stored) throw new EvaluationNotFoundError(evaluationId);
|
|
1103
|
+
if (options.cartHash !== void 0 && options.cartHash !== stored.cartHash) throw new CartHashMismatchError(evaluationId);
|
|
1104
|
+
snapshotForCapMapping = stored;
|
|
1105
|
+
return await this.commitInTransaction(stored, evaluationId, orderId, txCtx);
|
|
1106
|
+
};
|
|
1053
1107
|
try {
|
|
1054
|
-
return await
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1108
|
+
if (ctx.session) return await run(ctx);
|
|
1109
|
+
const pending = [];
|
|
1110
|
+
const result = await this.unitOfWork.withTransaction((session) => {
|
|
1111
|
+
pending.length = 0;
|
|
1112
|
+
return run({
|
|
1113
|
+
...ctx,
|
|
1114
|
+
session,
|
|
1115
|
+
[PENDING_PROMO_EVENTS]: pending
|
|
1058
1116
|
});
|
|
1059
|
-
if (!stored) throw new EvaluationNotFoundError(evaluationId);
|
|
1060
|
-
if (options.cartHash !== void 0 && options.cartHash !== stored.cartHash) throw new CartHashMismatchError(evaluationId);
|
|
1061
|
-
snapshotForCapMapping = stored;
|
|
1062
|
-
return await this.commitInTransaction(stored, evaluationId, orderId, session, ctx);
|
|
1063
1117
|
});
|
|
1118
|
+
await flushPendingPromoEvents(this.dispatchDeps, pending);
|
|
1119
|
+
return result;
|
|
1064
1120
|
} catch (err) {
|
|
1065
1121
|
if (isWriteConflict(err) && snapshotForCapMapping) {
|
|
1066
1122
|
const firstUsage = snapshotForCapMapping.programUsages[0];
|
|
1067
1123
|
if (firstUsage) {
|
|
1124
|
+
const { session: _aborted, ...readCtx } = ctx;
|
|
1068
1125
|
const program = await this.programRepo.getById(firstUsage.programId, {
|
|
1069
|
-
...
|
|
1126
|
+
...repoOptionsFromCtx(readCtx),
|
|
1070
1127
|
lean: true,
|
|
1071
1128
|
throwOnNotFound: false
|
|
1072
1129
|
});
|
|
@@ -1076,15 +1133,17 @@ var EvaluationService = class {
|
|
|
1076
1133
|
throw err;
|
|
1077
1134
|
}
|
|
1078
1135
|
}
|
|
1079
|
-
async commitInTransaction(stored, evaluationId, orderId,
|
|
1136
|
+
async commitInTransaction(stored, evaluationId, orderId, txCtx) {
|
|
1137
|
+
const ctx = txCtx;
|
|
1138
|
+
const session = txCtx.session;
|
|
1080
1139
|
for (const usage of stored.programUsages) {
|
|
1140
|
+
throwIfAborted(txCtx.signal);
|
|
1081
1141
|
if (!await this.programRepo.tryIncrementUsage(usage.programId, {
|
|
1082
1142
|
...ctx,
|
|
1083
1143
|
session
|
|
1084
1144
|
})) {
|
|
1085
1145
|
const program = await this.programRepo.getById(usage.programId, {
|
|
1086
|
-
...ctx,
|
|
1087
|
-
session,
|
|
1146
|
+
...repoOptionsFromCtx(ctx),
|
|
1088
1147
|
lean: true,
|
|
1089
1148
|
throwOnNotFound: false
|
|
1090
1149
|
});
|
|
@@ -1115,14 +1174,14 @@ var EvaluationService = class {
|
|
|
1115
1174
|
evaluationId,
|
|
1116
1175
|
orderId,
|
|
1117
1176
|
totalDiscount: stored.result.totalDiscount
|
|
1118
|
-
},
|
|
1119
|
-
...ctx,
|
|
1120
|
-
session
|
|
1121
|
-
});
|
|
1177
|
+
}, txCtx), txCtx);
|
|
1122
1178
|
return commitResult;
|
|
1123
1179
|
}
|
|
1124
1180
|
async rollback(evaluationId, ctx) {
|
|
1125
|
-
await this.store.delete(evaluationId, {
|
|
1181
|
+
await this.store.delete(evaluationId, {
|
|
1182
|
+
tenantValue: ctx.organizationId,
|
|
1183
|
+
session: ctx.session
|
|
1184
|
+
});
|
|
1126
1185
|
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.EVALUATION_ROLLED_BACK, { evaluationId }, ctx), ctx);
|
|
1127
1186
|
}
|
|
1128
1187
|
async doEvaluate(input, ctx, isPreview) {
|
|
@@ -1145,7 +1204,7 @@ var EvaluationService = class {
|
|
|
1145
1204
|
};
|
|
1146
1205
|
const programs = await this.programRepo.findActive(void 0, ctx);
|
|
1147
1206
|
const programIds = programs.map((p) => p._id);
|
|
1148
|
-
const [allRules, allRewards] = await Promise.all([this.ruleRepo.findAll({ programId: { $in: programIds } }, ctx), this.rewardRepo.findAll({ programId: { $in: programIds } }, ctx)]);
|
|
1207
|
+
const [allRules, allRewards] = await Promise.all([this.ruleRepo.findAll({ programId: { $in: programIds } }, repoOptionsFromCtx(ctx)), this.rewardRepo.findAll({ programId: { $in: programIds } }, repoOptionsFromCtx(ctx))]);
|
|
1149
1208
|
const rulesByProgram = groupBy(allRules, (r) => r.programId);
|
|
1150
1209
|
const rewardsByProgram = groupBy(allRewards, (r) => r.programId);
|
|
1151
1210
|
const voucherMap = /* @__PURE__ */ new Map();
|
|
@@ -1169,6 +1228,7 @@ var EvaluationService = class {
|
|
|
1169
1228
|
let exclusiveApplied = false;
|
|
1170
1229
|
let stackableCount = 0;
|
|
1171
1230
|
for (const program of programs) {
|
|
1231
|
+
throwIfAborted(ctx.signal);
|
|
1172
1232
|
if (program.maxUsageTotal && program.usedCount >= program.maxUsageTotal) continue;
|
|
1173
1233
|
if (!this.isCustomerEligible(program, input)) continue;
|
|
1174
1234
|
if (program.maxUsagePerCustomer && input.customerId) {
|
|
@@ -1252,16 +1312,20 @@ var EvaluationService = class {
|
|
|
1252
1312
|
programsApplied
|
|
1253
1313
|
};
|
|
1254
1314
|
if (!isPreview) {
|
|
1315
|
+
const { session: _liveSession, ...persistableCtx } = ctx;
|
|
1255
1316
|
const snapshot = {
|
|
1256
1317
|
result,
|
|
1257
|
-
ctx,
|
|
1318
|
+
ctx: persistableCtx,
|
|
1258
1319
|
customerId: input.customerId,
|
|
1259
1320
|
programUsages,
|
|
1260
1321
|
voucherUsages,
|
|
1261
1322
|
cartHash,
|
|
1262
1323
|
createdAt: /* @__PURE__ */ new Date()
|
|
1263
1324
|
};
|
|
1264
|
-
await this.store.put(evaluationId, snapshot, DEFAULT_PENDING_EVALUATION_TTL_SECONDS, {
|
|
1325
|
+
await this.store.put(evaluationId, snapshot, DEFAULT_PENDING_EVALUATION_TTL_SECONDS, {
|
|
1326
|
+
tenantValue: ctx.organizationId,
|
|
1327
|
+
session: ctx.session
|
|
1328
|
+
});
|
|
1265
1329
|
}
|
|
1266
1330
|
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.EVALUATION_COMPLETED, {
|
|
1267
1331
|
evaluationId,
|
|
@@ -1424,11 +1488,46 @@ var VoucherService = class {
|
|
|
1424
1488
|
this.dispatchDeps = dispatchDeps;
|
|
1425
1489
|
this.config = config;
|
|
1426
1490
|
}
|
|
1491
|
+
/**
|
|
1492
|
+
* Session threading — checkout-chain participation. When the host
|
|
1493
|
+
* already owns a transaction (e.g. the cart → order → flow → promo →
|
|
1494
|
+
* invoice chain runs as ONE atomic unit), it passes `ctx.session` and
|
|
1495
|
+
* the transactional body JOINS that session: every write commits or
|
|
1496
|
+
* aborts with the host's order. MongoDB has no nested transactions, so
|
|
1497
|
+
* we must NOT open our own in that case — the host owns commit/abort/
|
|
1498
|
+
* retry. Without a host session we own the transaction via the
|
|
1499
|
+
* UnitOfWork (mongokit retry semantics included).
|
|
1500
|
+
*
|
|
1501
|
+
* **Post-commit publish (§P8 phased variant).** When promo owns the
|
|
1502
|
+
* transaction, a pending queue is attached to the tx context so every
|
|
1503
|
+
* `dispatchPromoEvent` inside the body defers its transport publish; the
|
|
1504
|
+
* queue flushes only after the transaction commits — a rollback (or a
|
|
1505
|
+
* transient retry of the body) can't leak ghost events to in-process
|
|
1506
|
+
* subscribers. The queue resets at the start of each retry attempt.
|
|
1507
|
+
* When the HOST owns the session, promo can't know when the host
|
|
1508
|
+
* commits: with a host-attached `[PENDING_PROMO_EVENTS]` queue the
|
|
1509
|
+
* deferral still applies (host flushes); without one, publish happens
|
|
1510
|
+
* in-scope as the documented best effort (see events/dispatch.ts).
|
|
1511
|
+
*/
|
|
1512
|
+
async runTransactional(ctx, fn) {
|
|
1513
|
+
if (ctx.session) return fn(ctx);
|
|
1514
|
+
const pending = [];
|
|
1515
|
+
const result = await this.unitOfWork.withTransaction((session) => {
|
|
1516
|
+
pending.length = 0;
|
|
1517
|
+
return fn({
|
|
1518
|
+
...ctx,
|
|
1519
|
+
session,
|
|
1520
|
+
[PENDING_PROMO_EVENTS]: pending
|
|
1521
|
+
});
|
|
1522
|
+
});
|
|
1523
|
+
await flushPendingPromoEvents(this.dispatchDeps, pending);
|
|
1524
|
+
return result;
|
|
1525
|
+
}
|
|
1427
1526
|
async generateCodes(input, ctx) {
|
|
1428
1527
|
const program = await this.programRepo.getById(input.programId, {
|
|
1429
1528
|
throwOnNotFound: false,
|
|
1430
1529
|
lean: true,
|
|
1431
|
-
...ctx
|
|
1530
|
+
...repoOptionsFromCtx(ctx)
|
|
1432
1531
|
});
|
|
1433
1532
|
if (!program) throw new ProgramNotFoundError(input.programId);
|
|
1434
1533
|
const { codeLength, codePrefix } = this.config.voucher;
|
|
@@ -1451,13 +1550,19 @@ var VoucherService = class {
|
|
|
1451
1550
|
redemptions: [],
|
|
1452
1551
|
...input.metadata ? { metadata: input.metadata } : {}
|
|
1453
1552
|
}));
|
|
1454
|
-
|
|
1553
|
+
let vouchers;
|
|
1554
|
+
try {
|
|
1555
|
+
vouchers = await this.voucherRepo.createMany(data, repoOptionsFromCtx(ctx));
|
|
1556
|
+
} catch (err) {
|
|
1557
|
+
if (isDuplicateKeyError(err)) throw new DuplicateVoucherCodeError();
|
|
1558
|
+
throw err;
|
|
1559
|
+
}
|
|
1455
1560
|
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.VOUCHER_GENERATED, {
|
|
1456
1561
|
programId: input.programId,
|
|
1457
1562
|
voucherIds: vouchers.map((v) => v._id),
|
|
1458
1563
|
codes: vouchers.map((v) => v.code),
|
|
1459
1564
|
count: vouchers.length,
|
|
1460
|
-
|
|
1565
|
+
actorRef: ctx.actorRef
|
|
1461
1566
|
}, ctx), ctx);
|
|
1462
1567
|
return vouchers;
|
|
1463
1568
|
}
|
|
@@ -1465,7 +1570,7 @@ var VoucherService = class {
|
|
|
1465
1570
|
const program = await this.programRepo.getById(input.programId, {
|
|
1466
1571
|
throwOnNotFound: false,
|
|
1467
1572
|
lean: true,
|
|
1468
|
-
...ctx
|
|
1573
|
+
...repoOptionsFromCtx(ctx)
|
|
1469
1574
|
});
|
|
1470
1575
|
if (!program) throw new ProgramNotFoundError(input.programId);
|
|
1471
1576
|
const code = input.code?.toUpperCase() ?? generateCode(this.config.voucher.codeLength, this.config.voucher.codePrefix);
|
|
@@ -1488,13 +1593,19 @@ var VoucherService = class {
|
|
|
1488
1593
|
redemptions: [],
|
|
1489
1594
|
...input.metadata ? { metadata: input.metadata } : {}
|
|
1490
1595
|
};
|
|
1491
|
-
|
|
1596
|
+
let voucher;
|
|
1597
|
+
try {
|
|
1598
|
+
voucher = await this.voucherRepo.create(data, repoOptionsFromCtx(ctx));
|
|
1599
|
+
} catch (err) {
|
|
1600
|
+
if (isDuplicateKeyError(err)) throw new DuplicateVoucherCodeError(code);
|
|
1601
|
+
throw err;
|
|
1602
|
+
}
|
|
1492
1603
|
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.VOUCHER_GENERATED, {
|
|
1493
1604
|
programId: input.programId,
|
|
1494
1605
|
voucherIds: [voucher._id],
|
|
1495
1606
|
codes: [voucher.code],
|
|
1496
1607
|
count: 1,
|
|
1497
|
-
|
|
1608
|
+
actorRef: ctx.actorRef
|
|
1498
1609
|
}, ctx), ctx);
|
|
1499
1610
|
return voucher;
|
|
1500
1611
|
}
|
|
@@ -1527,7 +1638,7 @@ var VoucherService = class {
|
|
|
1527
1638
|
const program = await this.programRepo.getById(voucher.programId, {
|
|
1528
1639
|
throwOnNotFound: false,
|
|
1529
1640
|
lean: true,
|
|
1530
|
-
...ctx
|
|
1641
|
+
...repoOptionsFromCtx(ctx)
|
|
1531
1642
|
});
|
|
1532
1643
|
return {
|
|
1533
1644
|
valid: true,
|
|
@@ -1543,18 +1654,12 @@ var VoucherService = class {
|
|
|
1543
1654
|
};
|
|
1544
1655
|
}
|
|
1545
1656
|
async redeem(input, ctx) {
|
|
1546
|
-
return this.
|
|
1547
|
-
const voucher = await this.voucherRepo.getByCode(input.code,
|
|
1548
|
-
...ctx,
|
|
1549
|
-
session
|
|
1550
|
-
});
|
|
1657
|
+
return this.runTransactional(ctx, async (txCtx) => {
|
|
1658
|
+
const voucher = await this.voucherRepo.getByCode(input.code, txCtx);
|
|
1551
1659
|
if (!voucher) throw new VoucherNotFoundError(input.code);
|
|
1552
1660
|
this.assertVoucherUsable(voucher);
|
|
1553
1661
|
if (input.idempotencyKey) {
|
|
1554
|
-
if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey,
|
|
1555
|
-
...ctx,
|
|
1556
|
-
session
|
|
1557
|
-
})) throw new DuplicateRedemptionError(input.idempotencyKey);
|
|
1662
|
+
if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey, txCtx)) throw new DuplicateRedemptionError(input.idempotencyKey);
|
|
1558
1663
|
}
|
|
1559
1664
|
const redemption = {
|
|
1560
1665
|
orderId: input.orderId,
|
|
@@ -1564,14 +1669,10 @@ var VoucherService = class {
|
|
|
1564
1669
|
...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {},
|
|
1565
1670
|
...ctx.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {}
|
|
1566
1671
|
};
|
|
1567
|
-
const updated = await this.voucherRepo.incrementUsage(voucher._id, redemption,
|
|
1568
|
-
...ctx,
|
|
1569
|
-
session
|
|
1570
|
-
});
|
|
1672
|
+
const updated = await this.voucherRepo.incrementUsage(voucher._id, redemption, txCtx);
|
|
1571
1673
|
if (updated.usedCount >= updated.usageLimit) await this.voucherRepo.update(voucher._id, { status: "used" }, {
|
|
1572
1674
|
lean: true,
|
|
1573
|
-
...
|
|
1574
|
-
session
|
|
1675
|
+
...repoOptionsFromCtx(txCtx)
|
|
1575
1676
|
});
|
|
1576
1677
|
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.VOUCHER_REDEEMED, {
|
|
1577
1678
|
voucherId: voucher._id,
|
|
@@ -1579,10 +1680,7 @@ var VoucherService = class {
|
|
|
1579
1680
|
orderId: input.orderId,
|
|
1580
1681
|
discountAmount: input.discountAmount,
|
|
1581
1682
|
customerId: input.customerId
|
|
1582
|
-
},
|
|
1583
|
-
...ctx,
|
|
1584
|
-
session
|
|
1585
|
-
});
|
|
1683
|
+
}, txCtx), txCtx);
|
|
1586
1684
|
return updated;
|
|
1587
1685
|
});
|
|
1588
1686
|
}
|
|
@@ -1608,20 +1706,14 @@ var VoucherService = class {
|
|
|
1608
1706
|
}
|
|
1609
1707
|
}
|
|
1610
1708
|
async spendInTransaction(input, ctx) {
|
|
1611
|
-
return this.
|
|
1612
|
-
const voucher = await this.voucherRepo.getByCode(input.code,
|
|
1613
|
-
...ctx,
|
|
1614
|
-
session
|
|
1615
|
-
});
|
|
1709
|
+
return this.runTransactional(ctx, async (txCtx) => {
|
|
1710
|
+
const voucher = await this.voucherRepo.getByCode(input.code, txCtx);
|
|
1616
1711
|
if (!voucher) throw new VoucherNotFoundError(input.code);
|
|
1617
1712
|
this.assertVoucherUsable(voucher);
|
|
1618
1713
|
const balance = voucher.currentBalance ?? 0;
|
|
1619
1714
|
if (!this.config.giftCard.allowNegativeBalance && balance < input.amount) throw new InsufficientBalanceError(input.code, balance, input.amount);
|
|
1620
1715
|
if (input.idempotencyKey) {
|
|
1621
|
-
if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey,
|
|
1622
|
-
...ctx,
|
|
1623
|
-
session
|
|
1624
|
-
})) throw new DuplicateRedemptionError(input.idempotencyKey);
|
|
1716
|
+
if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey, txCtx)) throw new DuplicateRedemptionError(input.idempotencyKey);
|
|
1625
1717
|
}
|
|
1626
1718
|
const entry = {
|
|
1627
1719
|
amount: -input.amount,
|
|
@@ -1631,10 +1723,7 @@ var VoucherService = class {
|
|
|
1631
1723
|
...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {},
|
|
1632
1724
|
...ctx.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {}
|
|
1633
1725
|
};
|
|
1634
|
-
const updated = await this.voucherRepo.addLedgerEntry(voucher._id, entry, -input.amount,
|
|
1635
|
-
...ctx,
|
|
1636
|
-
session
|
|
1637
|
-
});
|
|
1726
|
+
const updated = await this.voucherRepo.addLedgerEntry(voucher._id, entry, -input.amount, txCtx);
|
|
1638
1727
|
const newBalance = updated.currentBalance ?? 0;
|
|
1639
1728
|
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.GIFT_CARD_SPENT, {
|
|
1640
1729
|
voucherId: voucher._id,
|
|
@@ -1642,24 +1731,17 @@ var VoucherService = class {
|
|
|
1642
1731
|
amount: input.amount,
|
|
1643
1732
|
remainingBalance: newBalance,
|
|
1644
1733
|
orderId: input.orderId
|
|
1645
|
-
},
|
|
1646
|
-
...ctx,
|
|
1647
|
-
session
|
|
1648
|
-
});
|
|
1734
|
+
}, txCtx), txCtx);
|
|
1649
1735
|
if (newBalance <= 0) {
|
|
1650
1736
|
await this.voucherRepo.update(voucher._id, { status: "used" }, {
|
|
1651
1737
|
lean: true,
|
|
1652
|
-
...
|
|
1653
|
-
session
|
|
1738
|
+
...repoOptionsFromCtx(txCtx)
|
|
1654
1739
|
});
|
|
1655
1740
|
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.GIFT_CARD_EXHAUSTED, {
|
|
1656
1741
|
voucherId: voucher._id,
|
|
1657
1742
|
code: voucher.code,
|
|
1658
1743
|
status: "used"
|
|
1659
|
-
},
|
|
1660
|
-
...ctx,
|
|
1661
|
-
session
|
|
1662
|
-
});
|
|
1744
|
+
}, txCtx), txCtx);
|
|
1663
1745
|
}
|
|
1664
1746
|
return {
|
|
1665
1747
|
code: updated.code,
|
|
@@ -1680,19 +1762,13 @@ var VoucherService = class {
|
|
|
1680
1762
|
}
|
|
1681
1763
|
}
|
|
1682
1764
|
async topUpInTransaction(input, ctx) {
|
|
1683
|
-
return this.
|
|
1684
|
-
const voucher = await this.voucherRepo.getByCode(input.code,
|
|
1685
|
-
...ctx,
|
|
1686
|
-
session
|
|
1687
|
-
});
|
|
1765
|
+
return this.runTransactional(ctx, async (txCtx) => {
|
|
1766
|
+
const voucher = await this.voucherRepo.getByCode(input.code, txCtx);
|
|
1688
1767
|
if (!voucher) throw new VoucherNotFoundError(input.code);
|
|
1689
1768
|
const maxBalance = this.config.giftCard.maxBalance;
|
|
1690
1769
|
if (maxBalance !== null && (voucher.currentBalance ?? 0) + input.amount > maxBalance) throw new ValidationError(`Top-up would exceed max balance of ${maxBalance}`);
|
|
1691
1770
|
if (input.idempotencyKey) {
|
|
1692
|
-
if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey,
|
|
1693
|
-
...ctx,
|
|
1694
|
-
session
|
|
1695
|
-
})) throw new DuplicateRedemptionError(input.idempotencyKey);
|
|
1771
|
+
if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey, txCtx)) throw new DuplicateRedemptionError(input.idempotencyKey);
|
|
1696
1772
|
}
|
|
1697
1773
|
const entry = {
|
|
1698
1774
|
amount: input.amount,
|
|
@@ -1701,24 +1777,17 @@ var VoucherService = class {
|
|
|
1701
1777
|
...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {},
|
|
1702
1778
|
...ctx.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {}
|
|
1703
1779
|
};
|
|
1704
|
-
const updated = await this.voucherRepo.addLedgerEntry(voucher._id, entry, input.amount,
|
|
1705
|
-
...ctx,
|
|
1706
|
-
session
|
|
1707
|
-
});
|
|
1780
|
+
const updated = await this.voucherRepo.addLedgerEntry(voucher._id, entry, input.amount, txCtx);
|
|
1708
1781
|
if (voucher.status === "used") await this.voucherRepo.update(voucher._id, { status: "active" }, {
|
|
1709
1782
|
lean: true,
|
|
1710
|
-
...
|
|
1711
|
-
session
|
|
1783
|
+
...repoOptionsFromCtx(txCtx)
|
|
1712
1784
|
});
|
|
1713
1785
|
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.GIFT_CARD_TOPPED_UP, {
|
|
1714
1786
|
voucherId: voucher._id,
|
|
1715
1787
|
code: voucher.code,
|
|
1716
1788
|
amount: input.amount,
|
|
1717
1789
|
newBalance: updated.currentBalance ?? 0
|
|
1718
|
-
},
|
|
1719
|
-
...ctx,
|
|
1720
|
-
session
|
|
1721
|
-
});
|
|
1790
|
+
}, txCtx), txCtx);
|
|
1722
1791
|
return {
|
|
1723
1792
|
code: updated.code,
|
|
1724
1793
|
initialBalance: updated.initialBalance ?? 0,
|
|
@@ -1751,229 +1820,6 @@ function createServices(deps) {
|
|
|
1751
1820
|
};
|
|
1752
1821
|
}
|
|
1753
1822
|
//#endregion
|
|
1754
|
-
//#region src/events/promo-event-catalog.ts
|
|
1755
|
-
function definePromoEvent(input) {
|
|
1756
|
-
const { name, version = 1, description, zodSchema } = input;
|
|
1757
|
-
const def = {
|
|
1758
|
-
name,
|
|
1759
|
-
version,
|
|
1760
|
-
schema: z.toJSONSchema(zodSchema),
|
|
1761
|
-
zodSchema,
|
|
1762
|
-
create(payload, meta) {
|
|
1763
|
-
return createEvent(name, payload, {
|
|
1764
|
-
source: "promo",
|
|
1765
|
-
...meta
|
|
1766
|
-
});
|
|
1767
|
-
}
|
|
1768
|
-
};
|
|
1769
|
-
if (description !== void 0) def.description = description;
|
|
1770
|
-
return def;
|
|
1771
|
-
}
|
|
1772
|
-
/** Mirrors `ProgramLifecyclePayload`. */
|
|
1773
|
-
const programLifecycleSchema = z.object({
|
|
1774
|
-
programId: z.string(),
|
|
1775
|
-
programType: z.string(),
|
|
1776
|
-
status: z.string(),
|
|
1777
|
-
actorId: z.string().optional()
|
|
1778
|
-
});
|
|
1779
|
-
/** Mirrors `RulePayload`. */
|
|
1780
|
-
const ruleSchema = z.object({
|
|
1781
|
-
programId: z.string(),
|
|
1782
|
-
ruleId: z.string(),
|
|
1783
|
-
actorId: z.string().optional()
|
|
1784
|
-
});
|
|
1785
|
-
/** Mirrors `RewardPayload`. */
|
|
1786
|
-
const rewardSchema = z.object({
|
|
1787
|
-
programId: z.string(),
|
|
1788
|
-
rewardId: z.string(),
|
|
1789
|
-
actorId: z.string().optional()
|
|
1790
|
-
});
|
|
1791
|
-
/** Mirrors `VoucherGeneratedPayload`. */
|
|
1792
|
-
const voucherGeneratedSchema = z.object({
|
|
1793
|
-
programId: z.string(),
|
|
1794
|
-
voucherIds: z.array(z.string()),
|
|
1795
|
-
codes: z.array(z.string()),
|
|
1796
|
-
count: z.number().int().nonnegative(),
|
|
1797
|
-
actorId: z.string().optional()
|
|
1798
|
-
});
|
|
1799
|
-
/** Mirrors `VoucherRedeemedPayload`. */
|
|
1800
|
-
const voucherRedeemedSchema = z.object({
|
|
1801
|
-
voucherId: z.string(),
|
|
1802
|
-
code: z.string(),
|
|
1803
|
-
orderId: z.string(),
|
|
1804
|
-
discountAmount: z.number(),
|
|
1805
|
-
customerId: z.string().optional()
|
|
1806
|
-
});
|
|
1807
|
-
/**
|
|
1808
|
-
* Mirrors `VoucherLifecyclePayload` — shared by VOUCHER_CANCELLED,
|
|
1809
|
-
* VOUCHER_EXPIRED, and GIFT_CARD_EXHAUSTED (repo emits `status: 'cancelled'`
|
|
1810
|
-
* / `'used'` / host-supplied terminal value).
|
|
1811
|
-
*/
|
|
1812
|
-
const voucherLifecycleSchema = z.object({
|
|
1813
|
-
voucherId: z.string(),
|
|
1814
|
-
code: z.string(),
|
|
1815
|
-
status: z.string()
|
|
1816
|
-
});
|
|
1817
|
-
/** Mirrors `GiftCardSpentPayload`. */
|
|
1818
|
-
const giftCardSpentSchema = z.object({
|
|
1819
|
-
voucherId: z.string(),
|
|
1820
|
-
code: z.string(),
|
|
1821
|
-
amount: z.number(),
|
|
1822
|
-
remainingBalance: z.number(),
|
|
1823
|
-
orderId: z.string()
|
|
1824
|
-
});
|
|
1825
|
-
/** Mirrors `GiftCardToppedUpPayload`. */
|
|
1826
|
-
const giftCardToppedUpSchema = z.object({
|
|
1827
|
-
voucherId: z.string(),
|
|
1828
|
-
code: z.string(),
|
|
1829
|
-
amount: z.number(),
|
|
1830
|
-
newBalance: z.number()
|
|
1831
|
-
});
|
|
1832
|
-
/** Mirrors `EvaluationCompletedPayload`. */
|
|
1833
|
-
const evaluationCompletedSchema = z.object({
|
|
1834
|
-
evaluationId: z.string(),
|
|
1835
|
-
totalDiscount: z.number(),
|
|
1836
|
-
programsApplied: z.number().int().nonnegative(),
|
|
1837
|
-
codesUsed: z.array(z.string()),
|
|
1838
|
-
isPreview: z.boolean()
|
|
1839
|
-
});
|
|
1840
|
-
/** Mirrors `EvaluationCommittedPayload`. */
|
|
1841
|
-
const evaluationCommittedSchema = z.object({
|
|
1842
|
-
evaluationId: z.string(),
|
|
1843
|
-
orderId: z.string(),
|
|
1844
|
-
totalDiscount: z.number()
|
|
1845
|
-
});
|
|
1846
|
-
/** Single-field rollback payload — emitted by `evaluation.service.ts`. */
|
|
1847
|
-
const evaluationRolledBackSchema = z.object({ evaluationId: z.string() });
|
|
1848
|
-
const ProgramCreated = definePromoEvent({
|
|
1849
|
-
name: PromoEvents.PROGRAM_CREATED,
|
|
1850
|
-
description: "A new promo program was created (starts in draft).",
|
|
1851
|
-
zodSchema: programLifecycleSchema
|
|
1852
|
-
});
|
|
1853
|
-
const ProgramActivated = definePromoEvent({
|
|
1854
|
-
name: PromoEvents.PROGRAM_ACTIVATED,
|
|
1855
|
-
description: "A draft or paused program was activated.",
|
|
1856
|
-
zodSchema: programLifecycleSchema
|
|
1857
|
-
});
|
|
1858
|
-
const ProgramPaused = definePromoEvent({
|
|
1859
|
-
name: PromoEvents.PROGRAM_PAUSED,
|
|
1860
|
-
description: "An active program was paused.",
|
|
1861
|
-
zodSchema: programLifecycleSchema
|
|
1862
|
-
});
|
|
1863
|
-
const ProgramArchived = definePromoEvent({
|
|
1864
|
-
name: PromoEvents.PROGRAM_ARCHIVED,
|
|
1865
|
-
description: "A program was archived (terminal — no further transitions).",
|
|
1866
|
-
zodSchema: programLifecycleSchema
|
|
1867
|
-
});
|
|
1868
|
-
const RuleAdded = definePromoEvent({
|
|
1869
|
-
name: PromoEvents.RULE_ADDED,
|
|
1870
|
-
description: "A new rule was added to a program.",
|
|
1871
|
-
zodSchema: ruleSchema
|
|
1872
|
-
});
|
|
1873
|
-
const RuleUpdated = definePromoEvent({
|
|
1874
|
-
name: PromoEvents.RULE_UPDATED,
|
|
1875
|
-
description: "An existing rule on a program was updated.",
|
|
1876
|
-
zodSchema: ruleSchema
|
|
1877
|
-
});
|
|
1878
|
-
const RuleRemoved = definePromoEvent({
|
|
1879
|
-
name: PromoEvents.RULE_REMOVED,
|
|
1880
|
-
description: "A rule was removed from a program.",
|
|
1881
|
-
zodSchema: ruleSchema
|
|
1882
|
-
});
|
|
1883
|
-
const RewardAdded = definePromoEvent({
|
|
1884
|
-
name: PromoEvents.REWARD_ADDED,
|
|
1885
|
-
description: "A reward was added to a program.",
|
|
1886
|
-
zodSchema: rewardSchema
|
|
1887
|
-
});
|
|
1888
|
-
const RewardUpdated = definePromoEvent({
|
|
1889
|
-
name: PromoEvents.REWARD_UPDATED,
|
|
1890
|
-
description: "A reward on a program was updated.",
|
|
1891
|
-
zodSchema: rewardSchema
|
|
1892
|
-
});
|
|
1893
|
-
const RewardRemoved = definePromoEvent({
|
|
1894
|
-
name: PromoEvents.REWARD_REMOVED,
|
|
1895
|
-
description: "A reward was removed from a program.",
|
|
1896
|
-
zodSchema: rewardSchema
|
|
1897
|
-
});
|
|
1898
|
-
const VoucherGenerated = definePromoEvent({
|
|
1899
|
-
name: PromoEvents.VOUCHER_GENERATED,
|
|
1900
|
-
description: "One or more vouchers were generated for a program.",
|
|
1901
|
-
zodSchema: voucherGeneratedSchema
|
|
1902
|
-
});
|
|
1903
|
-
const VoucherRedeemed = definePromoEvent({
|
|
1904
|
-
name: PromoEvents.VOUCHER_REDEEMED,
|
|
1905
|
-
description: "A voucher was redeemed against an order.",
|
|
1906
|
-
zodSchema: voucherRedeemedSchema
|
|
1907
|
-
});
|
|
1908
|
-
const VoucherCancelled = definePromoEvent({
|
|
1909
|
-
name: PromoEvents.VOUCHER_CANCELLED,
|
|
1910
|
-
description: "A voucher was cancelled by an operator.",
|
|
1911
|
-
zodSchema: voucherLifecycleSchema
|
|
1912
|
-
});
|
|
1913
|
-
const VoucherExpired = definePromoEvent({
|
|
1914
|
-
name: PromoEvents.VOUCHER_EXPIRED,
|
|
1915
|
-
description: "A voucher reached its expiry date and was transitioned to expired.",
|
|
1916
|
-
zodSchema: voucherLifecycleSchema
|
|
1917
|
-
});
|
|
1918
|
-
const GiftCardSpent = definePromoEvent({
|
|
1919
|
-
name: PromoEvents.GIFT_CARD_SPENT,
|
|
1920
|
-
description: "A gift-card voucher was debited against an order — remaining balance reported.",
|
|
1921
|
-
zodSchema: giftCardSpentSchema
|
|
1922
|
-
});
|
|
1923
|
-
const GiftCardToppedUp = definePromoEvent({
|
|
1924
|
-
name: PromoEvents.GIFT_CARD_TOPPED_UP,
|
|
1925
|
-
description: "A gift-card voucher received a top-up — new balance reported.",
|
|
1926
|
-
zodSchema: giftCardToppedUpSchema
|
|
1927
|
-
});
|
|
1928
|
-
const GiftCardExhausted = definePromoEvent({
|
|
1929
|
-
name: PromoEvents.GIFT_CARD_EXHAUSTED,
|
|
1930
|
-
description: "A gift-card voucher reached a zero balance and was marked used.",
|
|
1931
|
-
zodSchema: voucherLifecycleSchema
|
|
1932
|
-
});
|
|
1933
|
-
const EvaluationCompleted = definePromoEvent({
|
|
1934
|
-
name: PromoEvents.EVALUATION_COMPLETED,
|
|
1935
|
-
description: "A cart evaluation finished (preview or pre-commit) — totals and applied codes reported.",
|
|
1936
|
-
zodSchema: evaluationCompletedSchema
|
|
1937
|
-
});
|
|
1938
|
-
const EvaluationCommitted = definePromoEvent({
|
|
1939
|
-
name: PromoEvents.EVALUATION_COMMITTED,
|
|
1940
|
-
description: "A stored evaluation was committed against an order — usage counters moved.",
|
|
1941
|
-
zodSchema: evaluationCommittedSchema
|
|
1942
|
-
});
|
|
1943
|
-
const EvaluationRolledBack = definePromoEvent({
|
|
1944
|
-
name: PromoEvents.EVALUATION_ROLLED_BACK,
|
|
1945
|
-
description: "A stored evaluation was discarded without commit.",
|
|
1946
|
-
zodSchema: evaluationRolledBackSchema
|
|
1947
|
-
});
|
|
1948
|
-
/**
|
|
1949
|
-
* Every promo event defined in the package — pass to Arc's
|
|
1950
|
-
* `EventRegistry`. Hosts wire ONE array; the whole `promo.*` namespace
|
|
1951
|
-
* becomes introspectable via OpenAPI and auto-validated at publish time
|
|
1952
|
-
* when `eventPlugin({ validateMode: 'reject' })` is set.
|
|
1953
|
-
*/
|
|
1954
|
-
const promoEventDefinitions = [
|
|
1955
|
-
ProgramCreated,
|
|
1956
|
-
ProgramActivated,
|
|
1957
|
-
ProgramPaused,
|
|
1958
|
-
ProgramArchived,
|
|
1959
|
-
RuleAdded,
|
|
1960
|
-
RuleUpdated,
|
|
1961
|
-
RuleRemoved,
|
|
1962
|
-
RewardAdded,
|
|
1963
|
-
RewardUpdated,
|
|
1964
|
-
RewardRemoved,
|
|
1965
|
-
VoucherGenerated,
|
|
1966
|
-
VoucherRedeemed,
|
|
1967
|
-
VoucherCancelled,
|
|
1968
|
-
VoucherExpired,
|
|
1969
|
-
GiftCardSpent,
|
|
1970
|
-
GiftCardToppedUp,
|
|
1971
|
-
GiftCardExhausted,
|
|
1972
|
-
EvaluationCompleted,
|
|
1973
|
-
EvaluationCommitted,
|
|
1974
|
-
EvaluationRolledBack
|
|
1975
|
-
];
|
|
1976
|
-
//#endregion
|
|
1977
1823
|
//#region src/index.ts
|
|
1978
1824
|
function resolveConfig(config) {
|
|
1979
1825
|
const tenant = resolveTenantConfig(config.tenant);
|
|
@@ -2013,14 +1859,30 @@ function resolveConfig(config) {
|
|
|
2013
1859
|
* that wires it. See packages/mongokit/src/transaction.ts.
|
|
2014
1860
|
*/
|
|
2015
1861
|
var MongoUnitOfWork = class {
|
|
2016
|
-
constructor(connection) {
|
|
1862
|
+
constructor(connection, allowFallback = false) {
|
|
2017
1863
|
this.connection = connection;
|
|
1864
|
+
this.allowFallback = allowFallback;
|
|
2018
1865
|
}
|
|
2019
1866
|
withTransaction(cb) {
|
|
2020
|
-
return withTransaction(this.connection, cb);
|
|
1867
|
+
return withTransaction(this.connection, cb, this.allowFallback ? { allowFallback: true } : {});
|
|
2021
1868
|
}
|
|
2022
1869
|
};
|
|
2023
1870
|
/**
|
|
1871
|
+
* Boot-time capability gate (PACKAGE_RULES "Runtime capabilities").
|
|
1872
|
+
* commit / redeem / spend / topUp are transactional — assert the backend
|
|
1873
|
+
* declares `transactions` at engine creation so misconfigured deployments
|
|
1874
|
+
* fail loud at boot instead of with a cryptic error on the first checkout.
|
|
1875
|
+
* `allowNonTransactional: true` is the explicit opt-out for standalone
|
|
1876
|
+
* MongoDB (dev / CI) — the unit-of-work then falls back to
|
|
1877
|
+
* non-transactional execution.
|
|
1878
|
+
*
|
|
1879
|
+
* Exported for unit testing; hosts never call it directly.
|
|
1880
|
+
*/
|
|
1881
|
+
function assertPromoCapabilities(capabilities) {
|
|
1882
|
+
if (!capabilities) throw new Error("promo: backend declares no `capabilities` descriptor (repo-core 0.6 / mongokit 3.16+). Upgrade @classytic/mongokit, or pass `allowNonTransactional: true` to opt out.");
|
|
1883
|
+
if (capabilities.transactions !== true) throw new Error("promo: backend missing required capability: transactions. commit/redeem/spend/topUp are transactional — MongoDB must run as a replica set. Pass `allowNonTransactional: true` to accept best-effort atomicity (dev/CI only).");
|
|
1884
|
+
}
|
|
1885
|
+
/**
|
|
2024
1886
|
* Build the promo engine for a host application.
|
|
2025
1887
|
*
|
|
2026
1888
|
* **Index management — important for boot performance:**
|
|
@@ -2059,7 +1921,12 @@ var MongoUnitOfWork = class {
|
|
|
2059
1921
|
*/
|
|
2060
1922
|
function createPromoEngine(config) {
|
|
2061
1923
|
const resolvedConfig = resolveConfig(config);
|
|
2062
|
-
const models = createModels(config.mongoose, resolvedConfig.tenant,
|
|
1924
|
+
const models = createModels(config.mongoose, resolvedConfig.tenant, {
|
|
1925
|
+
indexes: config.indexes,
|
|
1926
|
+
autoIndex: config.autoIndex,
|
|
1927
|
+
collectionPrefix: config.collectionPrefix,
|
|
1928
|
+
forceRecreate: config.forceRecreate
|
|
1929
|
+
});
|
|
2063
1930
|
const events = config.events?.transport ?? new InProcessPromoBus();
|
|
2064
1931
|
const dispatchDeps = {
|
|
2065
1932
|
events,
|
|
@@ -2067,12 +1934,14 @@ function createPromoEngine(config) {
|
|
|
2067
1934
|
...config.logger !== void 0 ? { logger: config.logger } : {}
|
|
2068
1935
|
};
|
|
2069
1936
|
const repositories = createRepositories(models, config.plugins, resolvedConfig.tenant, dispatchDeps);
|
|
1937
|
+
const allowNonTransactional = config.allowNonTransactional ?? false;
|
|
1938
|
+
if (!allowNonTransactional) assertPromoCapabilities(repositories.program.capabilities);
|
|
2070
1939
|
return {
|
|
2071
1940
|
models,
|
|
2072
1941
|
repositories,
|
|
2073
1942
|
services: createServices({
|
|
2074
1943
|
repositories,
|
|
2075
|
-
unitOfWork: new MongoUnitOfWork(config.mongoose),
|
|
1944
|
+
unitOfWork: new MongoUnitOfWork(config.mongoose, allowNonTransactional),
|
|
2076
1945
|
dispatchDeps,
|
|
2077
1946
|
config: resolvedConfig,
|
|
2078
1947
|
...config.evaluationStore !== void 0 ? { evaluationStore: config.evaluationStore } : {}
|
|
@@ -2084,4 +1953,4 @@ function createPromoEngine(config) {
|
|
|
2084
1953
|
};
|
|
2085
1954
|
}
|
|
2086
1955
|
//#endregion
|
|
2087
|
-
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 };
|
|
1956
|
+
export { CartHashMismatchError, ConcurrencyConflictError, DEFAULT_COLLECTIONS, DuplicateRedemptionError, DuplicateVoucherCodeError, EvaluationCommitted, EvaluationCompleted, EvaluationNotFoundError, EvaluationRolledBack, GiftCardExhausted, GiftCardExhaustedError, GiftCardSpent, GiftCardToppedUp, InMemoryEvaluationStore, InsufficientBalanceError, InvalidTransitionError, MongoEvaluationStore, PENDING_PROMO_EVENTS, PendingEvaluationRepository, ProgramActivated, ProgramArchived, ProgramCreated, ProgramNotFoundError, ProgramPaused, ProgramRepository, ProgramUsageCapExceededError, PromoError, PromoEvents, PromoModelCollisionError, RewardAdded, RewardNotFoundError, RewardRemoved, RewardRepository, RewardUpdated, RuleAdded, RuleNotFoundError, RuleRemoved, RuleRepository, RuleUpdated, TenantIsolationError, ValidationError, VoucherCancelled, VoucherExhaustedError, VoucherExpired, VoucherExpiredError, VoucherGenerated, VoucherNotFoundError, VoucherRedeemed, VoucherRepository, assertPromoCapabilities, createPromoEngine, flushPendingPromoEvents, promoEventDefinitions, resolveConfig };
|