@classytic/promo 0.2.3 → 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 +180 -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 +250 -364
- 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;
|
|
@@ -501,6 +531,12 @@ async function dispatchPromoEvent(deps, event, ctx) {
|
|
|
501
531
|
await deps.outbox.save(event, saveOptions);
|
|
502
532
|
} catch (err) {
|
|
503
533
|
logger.error(`[promo] outbox.save failed for ${event.type}:`, err);
|
|
534
|
+
throw err;
|
|
535
|
+
}
|
|
536
|
+
const pending = ctx?.[PENDING_PROMO_EVENTS];
|
|
537
|
+
if (pending) {
|
|
538
|
+
pending.push(event);
|
|
539
|
+
return;
|
|
504
540
|
}
|
|
505
541
|
if (deps.events) try {
|
|
506
542
|
await deps.events.publish(event);
|
|
@@ -508,41 +544,46 @@ async function dispatchPromoEvent(deps, event, ctx) {
|
|
|
508
544
|
logger.error(`[promo] events.publish failed for ${event.type}:`, err);
|
|
509
545
|
}
|
|
510
546
|
}
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
GIFT_CARD_TOPPED_UP: "promo.gift_card.topped_up",
|
|
530
|
-
GIFT_CARD_EXHAUSTED: "promo.gift_card.exhausted",
|
|
531
|
-
EVALUATION_COMPLETED: "promo.evaluation.completed",
|
|
532
|
-
EVALUATION_COMMITTED: "promo.evaluation.committed",
|
|
533
|
-
EVALUATION_ROLLED_BACK: "promo.evaluation.rolled_back"
|
|
534
|
-
};
|
|
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
|
+
}
|
|
535
565
|
//#endregion
|
|
536
566
|
//#region src/events/helpers.ts
|
|
537
567
|
function createEvent$1(type, payload, ctx, meta) {
|
|
538
568
|
return createEvent(type, payload, {
|
|
539
569
|
resource: "promo",
|
|
540
|
-
...ctx?.
|
|
570
|
+
...ctx?.actorRef !== void 0 ? { userId: ctx.actorRef } : {},
|
|
541
571
|
...ctx?.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {},
|
|
542
572
|
...meta
|
|
543
573
|
});
|
|
544
574
|
}
|
|
545
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
|
|
546
587
|
//#region src/repositories/program.repository.ts
|
|
547
588
|
var ProgramRepository = class extends Repository {
|
|
548
589
|
dispatchDeps;
|
|
@@ -563,14 +604,14 @@ var ProgramRepository = class extends Repository {
|
|
|
563
604
|
const program = await this.getById(id, {
|
|
564
605
|
throwOnNotFound: false,
|
|
565
606
|
lean: true,
|
|
566
|
-
...ctx
|
|
607
|
+
...repoOptionsFromCtx(ctx)
|
|
567
608
|
});
|
|
568
609
|
if (!program) throw new ProgramNotFoundError(id);
|
|
569
610
|
PROGRAM_MACHINE.assertTransition(String(program._id), program.status, targetStatus);
|
|
570
611
|
const updated = await this.update(id, { status: targetStatus }, {
|
|
571
612
|
throwOnNotFound: true,
|
|
572
613
|
lean: true,
|
|
573
|
-
...ctx
|
|
614
|
+
...repoOptionsFromCtx(ctx)
|
|
574
615
|
});
|
|
575
616
|
if (!updated) throw new ProgramNotFoundError(id);
|
|
576
617
|
const eventName = {
|
|
@@ -582,7 +623,7 @@ var ProgramRepository = class extends Repository {
|
|
|
582
623
|
programId: updated._id,
|
|
583
624
|
programType: updated.programType,
|
|
584
625
|
status: updated.status,
|
|
585
|
-
|
|
626
|
+
actorRef: ctx.actorRef
|
|
586
627
|
}, ctx), ctx);
|
|
587
628
|
return updated;
|
|
588
629
|
}
|
|
@@ -731,13 +772,13 @@ var VoucherRepository = class extends Repository {
|
|
|
731
772
|
const voucher = await this.getById(id, {
|
|
732
773
|
throwOnNotFound: false,
|
|
733
774
|
lean: true,
|
|
734
|
-
...ctx
|
|
775
|
+
...repoOptionsFromCtx(ctx)
|
|
735
776
|
});
|
|
736
777
|
if (!voucher) throw new VoucherNotFoundError(id);
|
|
737
778
|
const updated = await this.update(id, { status: "cancelled" }, {
|
|
738
779
|
throwOnNotFound: true,
|
|
739
780
|
lean: true,
|
|
740
|
-
...ctx
|
|
781
|
+
...repoOptionsFromCtx(ctx)
|
|
741
782
|
});
|
|
742
783
|
if (!updated) throw new VoucherNotFoundError(id);
|
|
743
784
|
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.VOUCHER_CANCELLED, {
|
|
@@ -797,11 +838,14 @@ var VoucherRepository = class extends Repository {
|
|
|
797
838
|
...ctx
|
|
798
839
|
});
|
|
799
840
|
let modified = 0;
|
|
800
|
-
for (const doc of docs)
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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
|
+
}
|
|
805
849
|
return modified;
|
|
806
850
|
}
|
|
807
851
|
/**
|
|
@@ -897,7 +941,7 @@ var MongoEvaluationStore = class {
|
|
|
897
941
|
if (tenantField && tenantValue !== void 0) update[tenantField] = tenantValue;
|
|
898
942
|
await this.repo.Model.updateOne(filter, { $set: update }, {
|
|
899
943
|
upsert: true,
|
|
900
|
-
session
|
|
944
|
+
...session !== void 0 ? { session } : {}
|
|
901
945
|
});
|
|
902
946
|
}
|
|
903
947
|
async take(id, ctx) {
|
|
@@ -1049,23 +1093,37 @@ var EvaluationService = class {
|
|
|
1049
1093
|
}
|
|
1050
1094
|
async commit(evaluationId, orderId, ctx, options = {}) {
|
|
1051
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
|
+
};
|
|
1052
1107
|
try {
|
|
1053
|
-
return await
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
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
|
|
1057
1116
|
});
|
|
1058
|
-
if (!stored) throw new EvaluationNotFoundError(evaluationId);
|
|
1059
|
-
if (options.cartHash !== void 0 && options.cartHash !== stored.cartHash) throw new CartHashMismatchError(evaluationId);
|
|
1060
|
-
snapshotForCapMapping = stored;
|
|
1061
|
-
return await this.commitInTransaction(stored, evaluationId, orderId, session, ctx);
|
|
1062
1117
|
});
|
|
1118
|
+
await flushPendingPromoEvents(this.dispatchDeps, pending);
|
|
1119
|
+
return result;
|
|
1063
1120
|
} catch (err) {
|
|
1064
1121
|
if (isWriteConflict(err) && snapshotForCapMapping) {
|
|
1065
1122
|
const firstUsage = snapshotForCapMapping.programUsages[0];
|
|
1066
1123
|
if (firstUsage) {
|
|
1124
|
+
const { session: _aborted, ...readCtx } = ctx;
|
|
1067
1125
|
const program = await this.programRepo.getById(firstUsage.programId, {
|
|
1068
|
-
...
|
|
1126
|
+
...repoOptionsFromCtx(readCtx),
|
|
1069
1127
|
lean: true,
|
|
1070
1128
|
throwOnNotFound: false
|
|
1071
1129
|
});
|
|
@@ -1075,15 +1133,17 @@ var EvaluationService = class {
|
|
|
1075
1133
|
throw err;
|
|
1076
1134
|
}
|
|
1077
1135
|
}
|
|
1078
|
-
async commitInTransaction(stored, evaluationId, orderId,
|
|
1136
|
+
async commitInTransaction(stored, evaluationId, orderId, txCtx) {
|
|
1137
|
+
const ctx = txCtx;
|
|
1138
|
+
const session = txCtx.session;
|
|
1079
1139
|
for (const usage of stored.programUsages) {
|
|
1140
|
+
throwIfAborted(txCtx.signal);
|
|
1080
1141
|
if (!await this.programRepo.tryIncrementUsage(usage.programId, {
|
|
1081
1142
|
...ctx,
|
|
1082
1143
|
session
|
|
1083
1144
|
})) {
|
|
1084
1145
|
const program = await this.programRepo.getById(usage.programId, {
|
|
1085
|
-
...ctx,
|
|
1086
|
-
session,
|
|
1146
|
+
...repoOptionsFromCtx(ctx),
|
|
1087
1147
|
lean: true,
|
|
1088
1148
|
throwOnNotFound: false
|
|
1089
1149
|
});
|
|
@@ -1114,21 +1174,37 @@ var EvaluationService = class {
|
|
|
1114
1174
|
evaluationId,
|
|
1115
1175
|
orderId,
|
|
1116
1176
|
totalDiscount: stored.result.totalDiscount
|
|
1117
|
-
},
|
|
1118
|
-
...ctx,
|
|
1119
|
-
session
|
|
1120
|
-
});
|
|
1177
|
+
}, txCtx), txCtx);
|
|
1121
1178
|
return commitResult;
|
|
1122
1179
|
}
|
|
1123
1180
|
async rollback(evaluationId, ctx) {
|
|
1124
|
-
await this.store.delete(evaluationId, {
|
|
1181
|
+
await this.store.delete(evaluationId, {
|
|
1182
|
+
tenantValue: ctx.organizationId,
|
|
1183
|
+
session: ctx.session
|
|
1184
|
+
});
|
|
1125
1185
|
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.EVALUATION_ROLLED_BACK, { evaluationId }, ctx), ctx);
|
|
1126
1186
|
}
|
|
1127
1187
|
async doEvaluate(input, ctx, isPreview) {
|
|
1128
|
-
const submittedCodes = (input.codes ?? []).map((c) => c.toUpperCase());
|
|
1188
|
+
const submittedCodes = Array.from(new Set((input.codes ?? []).map((c) => c.trim().toUpperCase()).filter((c) => c.length > 0)));
|
|
1189
|
+
if (input.items.length === 0 || input.subtotal <= 0) return {
|
|
1190
|
+
evaluationId: randomBytes(16).toString("hex"),
|
|
1191
|
+
cartHash: computeCartHash(input),
|
|
1192
|
+
appliedDiscounts: [],
|
|
1193
|
+
freeProducts: [],
|
|
1194
|
+
totalDiscount: 0,
|
|
1195
|
+
subtotalAfterDiscount: Math.max(0, input.subtotal),
|
|
1196
|
+
appliedCodes: [],
|
|
1197
|
+
rejectedCodes: submittedCodes.map((code) => ({
|
|
1198
|
+
code,
|
|
1199
|
+
reason: "Cart is empty"
|
|
1200
|
+
})),
|
|
1201
|
+
warnings: [],
|
|
1202
|
+
isPreview,
|
|
1203
|
+
programsApplied: []
|
|
1204
|
+
};
|
|
1129
1205
|
const programs = await this.programRepo.findActive(void 0, ctx);
|
|
1130
1206
|
const programIds = programs.map((p) => p._id);
|
|
1131
|
-
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))]);
|
|
1132
1208
|
const rulesByProgram = groupBy(allRules, (r) => r.programId);
|
|
1133
1209
|
const rewardsByProgram = groupBy(allRewards, (r) => r.programId);
|
|
1134
1210
|
const voucherMap = /* @__PURE__ */ new Map();
|
|
@@ -1152,6 +1228,7 @@ var EvaluationService = class {
|
|
|
1152
1228
|
let exclusiveApplied = false;
|
|
1153
1229
|
let stackableCount = 0;
|
|
1154
1230
|
for (const program of programs) {
|
|
1231
|
+
throwIfAborted(ctx.signal);
|
|
1155
1232
|
if (program.maxUsageTotal && program.usedCount >= program.maxUsageTotal) continue;
|
|
1156
1233
|
if (!this.isCustomerEligible(program, input)) continue;
|
|
1157
1234
|
if (program.maxUsagePerCustomer && input.customerId) {
|
|
@@ -1235,16 +1312,20 @@ var EvaluationService = class {
|
|
|
1235
1312
|
programsApplied
|
|
1236
1313
|
};
|
|
1237
1314
|
if (!isPreview) {
|
|
1315
|
+
const { session: _liveSession, ...persistableCtx } = ctx;
|
|
1238
1316
|
const snapshot = {
|
|
1239
1317
|
result,
|
|
1240
|
-
ctx,
|
|
1318
|
+
ctx: persistableCtx,
|
|
1241
1319
|
customerId: input.customerId,
|
|
1242
1320
|
programUsages,
|
|
1243
1321
|
voucherUsages,
|
|
1244
1322
|
cartHash,
|
|
1245
1323
|
createdAt: /* @__PURE__ */ new Date()
|
|
1246
1324
|
};
|
|
1247
|
-
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
|
+
});
|
|
1248
1329
|
}
|
|
1249
1330
|
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.EVALUATION_COMPLETED, {
|
|
1250
1331
|
evaluationId,
|
|
@@ -1407,11 +1488,46 @@ var VoucherService = class {
|
|
|
1407
1488
|
this.dispatchDeps = dispatchDeps;
|
|
1408
1489
|
this.config = config;
|
|
1409
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
|
+
}
|
|
1410
1526
|
async generateCodes(input, ctx) {
|
|
1411
1527
|
const program = await this.programRepo.getById(input.programId, {
|
|
1412
1528
|
throwOnNotFound: false,
|
|
1413
1529
|
lean: true,
|
|
1414
|
-
...ctx
|
|
1530
|
+
...repoOptionsFromCtx(ctx)
|
|
1415
1531
|
});
|
|
1416
1532
|
if (!program) throw new ProgramNotFoundError(input.programId);
|
|
1417
1533
|
const { codeLength, codePrefix } = this.config.voucher;
|
|
@@ -1434,13 +1550,19 @@ var VoucherService = class {
|
|
|
1434
1550
|
redemptions: [],
|
|
1435
1551
|
...input.metadata ? { metadata: input.metadata } : {}
|
|
1436
1552
|
}));
|
|
1437
|
-
|
|
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
|
+
}
|
|
1438
1560
|
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.VOUCHER_GENERATED, {
|
|
1439
1561
|
programId: input.programId,
|
|
1440
1562
|
voucherIds: vouchers.map((v) => v._id),
|
|
1441
1563
|
codes: vouchers.map((v) => v.code),
|
|
1442
1564
|
count: vouchers.length,
|
|
1443
|
-
|
|
1565
|
+
actorRef: ctx.actorRef
|
|
1444
1566
|
}, ctx), ctx);
|
|
1445
1567
|
return vouchers;
|
|
1446
1568
|
}
|
|
@@ -1448,7 +1570,7 @@ var VoucherService = class {
|
|
|
1448
1570
|
const program = await this.programRepo.getById(input.programId, {
|
|
1449
1571
|
throwOnNotFound: false,
|
|
1450
1572
|
lean: true,
|
|
1451
|
-
...ctx
|
|
1573
|
+
...repoOptionsFromCtx(ctx)
|
|
1452
1574
|
});
|
|
1453
1575
|
if (!program) throw new ProgramNotFoundError(input.programId);
|
|
1454
1576
|
const code = input.code?.toUpperCase() ?? generateCode(this.config.voucher.codeLength, this.config.voucher.codePrefix);
|
|
@@ -1471,13 +1593,19 @@ var VoucherService = class {
|
|
|
1471
1593
|
redemptions: [],
|
|
1472
1594
|
...input.metadata ? { metadata: input.metadata } : {}
|
|
1473
1595
|
};
|
|
1474
|
-
|
|
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
|
+
}
|
|
1475
1603
|
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.VOUCHER_GENERATED, {
|
|
1476
1604
|
programId: input.programId,
|
|
1477
1605
|
voucherIds: [voucher._id],
|
|
1478
1606
|
codes: [voucher.code],
|
|
1479
1607
|
count: 1,
|
|
1480
|
-
|
|
1608
|
+
actorRef: ctx.actorRef
|
|
1481
1609
|
}, ctx), ctx);
|
|
1482
1610
|
return voucher;
|
|
1483
1611
|
}
|
|
@@ -1510,7 +1638,7 @@ var VoucherService = class {
|
|
|
1510
1638
|
const program = await this.programRepo.getById(voucher.programId, {
|
|
1511
1639
|
throwOnNotFound: false,
|
|
1512
1640
|
lean: true,
|
|
1513
|
-
...ctx
|
|
1641
|
+
...repoOptionsFromCtx(ctx)
|
|
1514
1642
|
});
|
|
1515
1643
|
return {
|
|
1516
1644
|
valid: true,
|
|
@@ -1526,18 +1654,12 @@ var VoucherService = class {
|
|
|
1526
1654
|
};
|
|
1527
1655
|
}
|
|
1528
1656
|
async redeem(input, ctx) {
|
|
1529
|
-
return this.
|
|
1530
|
-
const voucher = await this.voucherRepo.getByCode(input.code,
|
|
1531
|
-
...ctx,
|
|
1532
|
-
session
|
|
1533
|
-
});
|
|
1657
|
+
return this.runTransactional(ctx, async (txCtx) => {
|
|
1658
|
+
const voucher = await this.voucherRepo.getByCode(input.code, txCtx);
|
|
1534
1659
|
if (!voucher) throw new VoucherNotFoundError(input.code);
|
|
1535
1660
|
this.assertVoucherUsable(voucher);
|
|
1536
1661
|
if (input.idempotencyKey) {
|
|
1537
|
-
if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey,
|
|
1538
|
-
...ctx,
|
|
1539
|
-
session
|
|
1540
|
-
})) throw new DuplicateRedemptionError(input.idempotencyKey);
|
|
1662
|
+
if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey, txCtx)) throw new DuplicateRedemptionError(input.idempotencyKey);
|
|
1541
1663
|
}
|
|
1542
1664
|
const redemption = {
|
|
1543
1665
|
orderId: input.orderId,
|
|
@@ -1547,14 +1669,10 @@ var VoucherService = class {
|
|
|
1547
1669
|
...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {},
|
|
1548
1670
|
...ctx.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {}
|
|
1549
1671
|
};
|
|
1550
|
-
const updated = await this.voucherRepo.incrementUsage(voucher._id, redemption,
|
|
1551
|
-
...ctx,
|
|
1552
|
-
session
|
|
1553
|
-
});
|
|
1672
|
+
const updated = await this.voucherRepo.incrementUsage(voucher._id, redemption, txCtx);
|
|
1554
1673
|
if (updated.usedCount >= updated.usageLimit) await this.voucherRepo.update(voucher._id, { status: "used" }, {
|
|
1555
1674
|
lean: true,
|
|
1556
|
-
...
|
|
1557
|
-
session
|
|
1675
|
+
...repoOptionsFromCtx(txCtx)
|
|
1558
1676
|
});
|
|
1559
1677
|
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.VOUCHER_REDEEMED, {
|
|
1560
1678
|
voucherId: voucher._id,
|
|
@@ -1562,10 +1680,7 @@ var VoucherService = class {
|
|
|
1562
1680
|
orderId: input.orderId,
|
|
1563
1681
|
discountAmount: input.discountAmount,
|
|
1564
1682
|
customerId: input.customerId
|
|
1565
|
-
},
|
|
1566
|
-
...ctx,
|
|
1567
|
-
session
|
|
1568
|
-
});
|
|
1683
|
+
}, txCtx), txCtx);
|
|
1569
1684
|
return updated;
|
|
1570
1685
|
});
|
|
1571
1686
|
}
|
|
@@ -1591,20 +1706,14 @@ var VoucherService = class {
|
|
|
1591
1706
|
}
|
|
1592
1707
|
}
|
|
1593
1708
|
async spendInTransaction(input, ctx) {
|
|
1594
|
-
return this.
|
|
1595
|
-
const voucher = await this.voucherRepo.getByCode(input.code,
|
|
1596
|
-
...ctx,
|
|
1597
|
-
session
|
|
1598
|
-
});
|
|
1709
|
+
return this.runTransactional(ctx, async (txCtx) => {
|
|
1710
|
+
const voucher = await this.voucherRepo.getByCode(input.code, txCtx);
|
|
1599
1711
|
if (!voucher) throw new VoucherNotFoundError(input.code);
|
|
1600
1712
|
this.assertVoucherUsable(voucher);
|
|
1601
1713
|
const balance = voucher.currentBalance ?? 0;
|
|
1602
1714
|
if (!this.config.giftCard.allowNegativeBalance && balance < input.amount) throw new InsufficientBalanceError(input.code, balance, input.amount);
|
|
1603
1715
|
if (input.idempotencyKey) {
|
|
1604
|
-
if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey,
|
|
1605
|
-
...ctx,
|
|
1606
|
-
session
|
|
1607
|
-
})) throw new DuplicateRedemptionError(input.idempotencyKey);
|
|
1716
|
+
if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey, txCtx)) throw new DuplicateRedemptionError(input.idempotencyKey);
|
|
1608
1717
|
}
|
|
1609
1718
|
const entry = {
|
|
1610
1719
|
amount: -input.amount,
|
|
@@ -1614,10 +1723,7 @@ var VoucherService = class {
|
|
|
1614
1723
|
...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {},
|
|
1615
1724
|
...ctx.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {}
|
|
1616
1725
|
};
|
|
1617
|
-
const updated = await this.voucherRepo.addLedgerEntry(voucher._id, entry, -input.amount,
|
|
1618
|
-
...ctx,
|
|
1619
|
-
session
|
|
1620
|
-
});
|
|
1726
|
+
const updated = await this.voucherRepo.addLedgerEntry(voucher._id, entry, -input.amount, txCtx);
|
|
1621
1727
|
const newBalance = updated.currentBalance ?? 0;
|
|
1622
1728
|
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.GIFT_CARD_SPENT, {
|
|
1623
1729
|
voucherId: voucher._id,
|
|
@@ -1625,24 +1731,17 @@ var VoucherService = class {
|
|
|
1625
1731
|
amount: input.amount,
|
|
1626
1732
|
remainingBalance: newBalance,
|
|
1627
1733
|
orderId: input.orderId
|
|
1628
|
-
},
|
|
1629
|
-
...ctx,
|
|
1630
|
-
session
|
|
1631
|
-
});
|
|
1734
|
+
}, txCtx), txCtx);
|
|
1632
1735
|
if (newBalance <= 0) {
|
|
1633
1736
|
await this.voucherRepo.update(voucher._id, { status: "used" }, {
|
|
1634
1737
|
lean: true,
|
|
1635
|
-
...
|
|
1636
|
-
session
|
|
1738
|
+
...repoOptionsFromCtx(txCtx)
|
|
1637
1739
|
});
|
|
1638
1740
|
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.GIFT_CARD_EXHAUSTED, {
|
|
1639
1741
|
voucherId: voucher._id,
|
|
1640
1742
|
code: voucher.code,
|
|
1641
1743
|
status: "used"
|
|
1642
|
-
},
|
|
1643
|
-
...ctx,
|
|
1644
|
-
session
|
|
1645
|
-
});
|
|
1744
|
+
}, txCtx), txCtx);
|
|
1646
1745
|
}
|
|
1647
1746
|
return {
|
|
1648
1747
|
code: updated.code,
|
|
@@ -1663,19 +1762,13 @@ var VoucherService = class {
|
|
|
1663
1762
|
}
|
|
1664
1763
|
}
|
|
1665
1764
|
async topUpInTransaction(input, ctx) {
|
|
1666
|
-
return this.
|
|
1667
|
-
const voucher = await this.voucherRepo.getByCode(input.code,
|
|
1668
|
-
...ctx,
|
|
1669
|
-
session
|
|
1670
|
-
});
|
|
1765
|
+
return this.runTransactional(ctx, async (txCtx) => {
|
|
1766
|
+
const voucher = await this.voucherRepo.getByCode(input.code, txCtx);
|
|
1671
1767
|
if (!voucher) throw new VoucherNotFoundError(input.code);
|
|
1672
1768
|
const maxBalance = this.config.giftCard.maxBalance;
|
|
1673
1769
|
if (maxBalance !== null && (voucher.currentBalance ?? 0) + input.amount > maxBalance) throw new ValidationError(`Top-up would exceed max balance of ${maxBalance}`);
|
|
1674
1770
|
if (input.idempotencyKey) {
|
|
1675
|
-
if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey,
|
|
1676
|
-
...ctx,
|
|
1677
|
-
session
|
|
1678
|
-
})) throw new DuplicateRedemptionError(input.idempotencyKey);
|
|
1771
|
+
if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey, txCtx)) throw new DuplicateRedemptionError(input.idempotencyKey);
|
|
1679
1772
|
}
|
|
1680
1773
|
const entry = {
|
|
1681
1774
|
amount: input.amount,
|
|
@@ -1684,24 +1777,17 @@ var VoucherService = class {
|
|
|
1684
1777
|
...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {},
|
|
1685
1778
|
...ctx.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {}
|
|
1686
1779
|
};
|
|
1687
|
-
const updated = await this.voucherRepo.addLedgerEntry(voucher._id, entry, input.amount,
|
|
1688
|
-
...ctx,
|
|
1689
|
-
session
|
|
1690
|
-
});
|
|
1780
|
+
const updated = await this.voucherRepo.addLedgerEntry(voucher._id, entry, input.amount, txCtx);
|
|
1691
1781
|
if (voucher.status === "used") await this.voucherRepo.update(voucher._id, { status: "active" }, {
|
|
1692
1782
|
lean: true,
|
|
1693
|
-
...
|
|
1694
|
-
session
|
|
1783
|
+
...repoOptionsFromCtx(txCtx)
|
|
1695
1784
|
});
|
|
1696
1785
|
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.GIFT_CARD_TOPPED_UP, {
|
|
1697
1786
|
voucherId: voucher._id,
|
|
1698
1787
|
code: voucher.code,
|
|
1699
1788
|
amount: input.amount,
|
|
1700
1789
|
newBalance: updated.currentBalance ?? 0
|
|
1701
|
-
},
|
|
1702
|
-
...ctx,
|
|
1703
|
-
session
|
|
1704
|
-
});
|
|
1790
|
+
}, txCtx), txCtx);
|
|
1705
1791
|
return {
|
|
1706
1792
|
code: updated.code,
|
|
1707
1793
|
initialBalance: updated.initialBalance ?? 0,
|
|
@@ -1734,229 +1820,6 @@ function createServices(deps) {
|
|
|
1734
1820
|
};
|
|
1735
1821
|
}
|
|
1736
1822
|
//#endregion
|
|
1737
|
-
//#region src/events/promo-event-catalog.ts
|
|
1738
|
-
function definePromoEvent(input) {
|
|
1739
|
-
const { name, version = 1, description, zodSchema } = input;
|
|
1740
|
-
const def = {
|
|
1741
|
-
name,
|
|
1742
|
-
version,
|
|
1743
|
-
schema: z.toJSONSchema(zodSchema),
|
|
1744
|
-
zodSchema,
|
|
1745
|
-
create(payload, meta) {
|
|
1746
|
-
return createEvent(name, payload, {
|
|
1747
|
-
source: "promo",
|
|
1748
|
-
...meta
|
|
1749
|
-
});
|
|
1750
|
-
}
|
|
1751
|
-
};
|
|
1752
|
-
if (description !== void 0) def.description = description;
|
|
1753
|
-
return def;
|
|
1754
|
-
}
|
|
1755
|
-
/** Mirrors `ProgramLifecyclePayload`. */
|
|
1756
|
-
const programLifecycleSchema = z.object({
|
|
1757
|
-
programId: z.string(),
|
|
1758
|
-
programType: z.string(),
|
|
1759
|
-
status: z.string(),
|
|
1760
|
-
actorId: z.string().optional()
|
|
1761
|
-
});
|
|
1762
|
-
/** Mirrors `RulePayload`. */
|
|
1763
|
-
const ruleSchema = z.object({
|
|
1764
|
-
programId: z.string(),
|
|
1765
|
-
ruleId: z.string(),
|
|
1766
|
-
actorId: z.string().optional()
|
|
1767
|
-
});
|
|
1768
|
-
/** Mirrors `RewardPayload`. */
|
|
1769
|
-
const rewardSchema = z.object({
|
|
1770
|
-
programId: z.string(),
|
|
1771
|
-
rewardId: z.string(),
|
|
1772
|
-
actorId: z.string().optional()
|
|
1773
|
-
});
|
|
1774
|
-
/** Mirrors `VoucherGeneratedPayload`. */
|
|
1775
|
-
const voucherGeneratedSchema = z.object({
|
|
1776
|
-
programId: z.string(),
|
|
1777
|
-
voucherIds: z.array(z.string()),
|
|
1778
|
-
codes: z.array(z.string()),
|
|
1779
|
-
count: z.number().int().nonnegative(),
|
|
1780
|
-
actorId: z.string().optional()
|
|
1781
|
-
});
|
|
1782
|
-
/** Mirrors `VoucherRedeemedPayload`. */
|
|
1783
|
-
const voucherRedeemedSchema = z.object({
|
|
1784
|
-
voucherId: z.string(),
|
|
1785
|
-
code: z.string(),
|
|
1786
|
-
orderId: z.string(),
|
|
1787
|
-
discountAmount: z.number(),
|
|
1788
|
-
customerId: z.string().optional()
|
|
1789
|
-
});
|
|
1790
|
-
/**
|
|
1791
|
-
* Mirrors `VoucherLifecyclePayload` — shared by VOUCHER_CANCELLED,
|
|
1792
|
-
* VOUCHER_EXPIRED, and GIFT_CARD_EXHAUSTED (repo emits `status: 'cancelled'`
|
|
1793
|
-
* / `'used'` / host-supplied terminal value).
|
|
1794
|
-
*/
|
|
1795
|
-
const voucherLifecycleSchema = z.object({
|
|
1796
|
-
voucherId: z.string(),
|
|
1797
|
-
code: z.string(),
|
|
1798
|
-
status: z.string()
|
|
1799
|
-
});
|
|
1800
|
-
/** Mirrors `GiftCardSpentPayload`. */
|
|
1801
|
-
const giftCardSpentSchema = z.object({
|
|
1802
|
-
voucherId: z.string(),
|
|
1803
|
-
code: z.string(),
|
|
1804
|
-
amount: z.number(),
|
|
1805
|
-
remainingBalance: z.number(),
|
|
1806
|
-
orderId: z.string()
|
|
1807
|
-
});
|
|
1808
|
-
/** Mirrors `GiftCardToppedUpPayload`. */
|
|
1809
|
-
const giftCardToppedUpSchema = z.object({
|
|
1810
|
-
voucherId: z.string(),
|
|
1811
|
-
code: z.string(),
|
|
1812
|
-
amount: z.number(),
|
|
1813
|
-
newBalance: z.number()
|
|
1814
|
-
});
|
|
1815
|
-
/** Mirrors `EvaluationCompletedPayload`. */
|
|
1816
|
-
const evaluationCompletedSchema = z.object({
|
|
1817
|
-
evaluationId: z.string(),
|
|
1818
|
-
totalDiscount: z.number(),
|
|
1819
|
-
programsApplied: z.number().int().nonnegative(),
|
|
1820
|
-
codesUsed: z.array(z.string()),
|
|
1821
|
-
isPreview: z.boolean()
|
|
1822
|
-
});
|
|
1823
|
-
/** Mirrors `EvaluationCommittedPayload`. */
|
|
1824
|
-
const evaluationCommittedSchema = z.object({
|
|
1825
|
-
evaluationId: z.string(),
|
|
1826
|
-
orderId: z.string(),
|
|
1827
|
-
totalDiscount: z.number()
|
|
1828
|
-
});
|
|
1829
|
-
/** Single-field rollback payload — emitted by `evaluation.service.ts`. */
|
|
1830
|
-
const evaluationRolledBackSchema = z.object({ evaluationId: z.string() });
|
|
1831
|
-
const ProgramCreated = definePromoEvent({
|
|
1832
|
-
name: PromoEvents.PROGRAM_CREATED,
|
|
1833
|
-
description: "A new promo program was created (starts in draft).",
|
|
1834
|
-
zodSchema: programLifecycleSchema
|
|
1835
|
-
});
|
|
1836
|
-
const ProgramActivated = definePromoEvent({
|
|
1837
|
-
name: PromoEvents.PROGRAM_ACTIVATED,
|
|
1838
|
-
description: "A draft or paused program was activated.",
|
|
1839
|
-
zodSchema: programLifecycleSchema
|
|
1840
|
-
});
|
|
1841
|
-
const ProgramPaused = definePromoEvent({
|
|
1842
|
-
name: PromoEvents.PROGRAM_PAUSED,
|
|
1843
|
-
description: "An active program was paused.",
|
|
1844
|
-
zodSchema: programLifecycleSchema
|
|
1845
|
-
});
|
|
1846
|
-
const ProgramArchived = definePromoEvent({
|
|
1847
|
-
name: PromoEvents.PROGRAM_ARCHIVED,
|
|
1848
|
-
description: "A program was archived (terminal — no further transitions).",
|
|
1849
|
-
zodSchema: programLifecycleSchema
|
|
1850
|
-
});
|
|
1851
|
-
const RuleAdded = definePromoEvent({
|
|
1852
|
-
name: PromoEvents.RULE_ADDED,
|
|
1853
|
-
description: "A new rule was added to a program.",
|
|
1854
|
-
zodSchema: ruleSchema
|
|
1855
|
-
});
|
|
1856
|
-
const RuleUpdated = definePromoEvent({
|
|
1857
|
-
name: PromoEvents.RULE_UPDATED,
|
|
1858
|
-
description: "An existing rule on a program was updated.",
|
|
1859
|
-
zodSchema: ruleSchema
|
|
1860
|
-
});
|
|
1861
|
-
const RuleRemoved = definePromoEvent({
|
|
1862
|
-
name: PromoEvents.RULE_REMOVED,
|
|
1863
|
-
description: "A rule was removed from a program.",
|
|
1864
|
-
zodSchema: ruleSchema
|
|
1865
|
-
});
|
|
1866
|
-
const RewardAdded = definePromoEvent({
|
|
1867
|
-
name: PromoEvents.REWARD_ADDED,
|
|
1868
|
-
description: "A reward was added to a program.",
|
|
1869
|
-
zodSchema: rewardSchema
|
|
1870
|
-
});
|
|
1871
|
-
const RewardUpdated = definePromoEvent({
|
|
1872
|
-
name: PromoEvents.REWARD_UPDATED,
|
|
1873
|
-
description: "A reward on a program was updated.",
|
|
1874
|
-
zodSchema: rewardSchema
|
|
1875
|
-
});
|
|
1876
|
-
const RewardRemoved = definePromoEvent({
|
|
1877
|
-
name: PromoEvents.REWARD_REMOVED,
|
|
1878
|
-
description: "A reward was removed from a program.",
|
|
1879
|
-
zodSchema: rewardSchema
|
|
1880
|
-
});
|
|
1881
|
-
const VoucherGenerated = definePromoEvent({
|
|
1882
|
-
name: PromoEvents.VOUCHER_GENERATED,
|
|
1883
|
-
description: "One or more vouchers were generated for a program.",
|
|
1884
|
-
zodSchema: voucherGeneratedSchema
|
|
1885
|
-
});
|
|
1886
|
-
const VoucherRedeemed = definePromoEvent({
|
|
1887
|
-
name: PromoEvents.VOUCHER_REDEEMED,
|
|
1888
|
-
description: "A voucher was redeemed against an order.",
|
|
1889
|
-
zodSchema: voucherRedeemedSchema
|
|
1890
|
-
});
|
|
1891
|
-
const VoucherCancelled = definePromoEvent({
|
|
1892
|
-
name: PromoEvents.VOUCHER_CANCELLED,
|
|
1893
|
-
description: "A voucher was cancelled by an operator.",
|
|
1894
|
-
zodSchema: voucherLifecycleSchema
|
|
1895
|
-
});
|
|
1896
|
-
const VoucherExpired = definePromoEvent({
|
|
1897
|
-
name: PromoEvents.VOUCHER_EXPIRED,
|
|
1898
|
-
description: "A voucher reached its expiry date and was transitioned to expired.",
|
|
1899
|
-
zodSchema: voucherLifecycleSchema
|
|
1900
|
-
});
|
|
1901
|
-
const GiftCardSpent = definePromoEvent({
|
|
1902
|
-
name: PromoEvents.GIFT_CARD_SPENT,
|
|
1903
|
-
description: "A gift-card voucher was debited against an order — remaining balance reported.",
|
|
1904
|
-
zodSchema: giftCardSpentSchema
|
|
1905
|
-
});
|
|
1906
|
-
const GiftCardToppedUp = definePromoEvent({
|
|
1907
|
-
name: PromoEvents.GIFT_CARD_TOPPED_UP,
|
|
1908
|
-
description: "A gift-card voucher received a top-up — new balance reported.",
|
|
1909
|
-
zodSchema: giftCardToppedUpSchema
|
|
1910
|
-
});
|
|
1911
|
-
const GiftCardExhausted = definePromoEvent({
|
|
1912
|
-
name: PromoEvents.GIFT_CARD_EXHAUSTED,
|
|
1913
|
-
description: "A gift-card voucher reached a zero balance and was marked used.",
|
|
1914
|
-
zodSchema: voucherLifecycleSchema
|
|
1915
|
-
});
|
|
1916
|
-
const EvaluationCompleted = definePromoEvent({
|
|
1917
|
-
name: PromoEvents.EVALUATION_COMPLETED,
|
|
1918
|
-
description: "A cart evaluation finished (preview or pre-commit) — totals and applied codes reported.",
|
|
1919
|
-
zodSchema: evaluationCompletedSchema
|
|
1920
|
-
});
|
|
1921
|
-
const EvaluationCommitted = definePromoEvent({
|
|
1922
|
-
name: PromoEvents.EVALUATION_COMMITTED,
|
|
1923
|
-
description: "A stored evaluation was committed against an order — usage counters moved.",
|
|
1924
|
-
zodSchema: evaluationCommittedSchema
|
|
1925
|
-
});
|
|
1926
|
-
const EvaluationRolledBack = definePromoEvent({
|
|
1927
|
-
name: PromoEvents.EVALUATION_ROLLED_BACK,
|
|
1928
|
-
description: "A stored evaluation was discarded without commit.",
|
|
1929
|
-
zodSchema: evaluationRolledBackSchema
|
|
1930
|
-
});
|
|
1931
|
-
/**
|
|
1932
|
-
* Every promo event defined in the package — pass to Arc's
|
|
1933
|
-
* `EventRegistry`. Hosts wire ONE array; the whole `promo.*` namespace
|
|
1934
|
-
* becomes introspectable via OpenAPI and auto-validated at publish time
|
|
1935
|
-
* when `eventPlugin({ validateMode: 'reject' })` is set.
|
|
1936
|
-
*/
|
|
1937
|
-
const promoEventDefinitions = [
|
|
1938
|
-
ProgramCreated,
|
|
1939
|
-
ProgramActivated,
|
|
1940
|
-
ProgramPaused,
|
|
1941
|
-
ProgramArchived,
|
|
1942
|
-
RuleAdded,
|
|
1943
|
-
RuleUpdated,
|
|
1944
|
-
RuleRemoved,
|
|
1945
|
-
RewardAdded,
|
|
1946
|
-
RewardUpdated,
|
|
1947
|
-
RewardRemoved,
|
|
1948
|
-
VoucherGenerated,
|
|
1949
|
-
VoucherRedeemed,
|
|
1950
|
-
VoucherCancelled,
|
|
1951
|
-
VoucherExpired,
|
|
1952
|
-
GiftCardSpent,
|
|
1953
|
-
GiftCardToppedUp,
|
|
1954
|
-
GiftCardExhausted,
|
|
1955
|
-
EvaluationCompleted,
|
|
1956
|
-
EvaluationCommitted,
|
|
1957
|
-
EvaluationRolledBack
|
|
1958
|
-
];
|
|
1959
|
-
//#endregion
|
|
1960
1823
|
//#region src/index.ts
|
|
1961
1824
|
function resolveConfig(config) {
|
|
1962
1825
|
const tenant = resolveTenantConfig(config.tenant);
|
|
@@ -1996,14 +1859,30 @@ function resolveConfig(config) {
|
|
|
1996
1859
|
* that wires it. See packages/mongokit/src/transaction.ts.
|
|
1997
1860
|
*/
|
|
1998
1861
|
var MongoUnitOfWork = class {
|
|
1999
|
-
constructor(connection) {
|
|
1862
|
+
constructor(connection, allowFallback = false) {
|
|
2000
1863
|
this.connection = connection;
|
|
1864
|
+
this.allowFallback = allowFallback;
|
|
2001
1865
|
}
|
|
2002
1866
|
withTransaction(cb) {
|
|
2003
|
-
return withTransaction(this.connection, cb);
|
|
1867
|
+
return withTransaction(this.connection, cb, this.allowFallback ? { allowFallback: true } : {});
|
|
2004
1868
|
}
|
|
2005
1869
|
};
|
|
2006
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
|
+
/**
|
|
2007
1886
|
* Build the promo engine for a host application.
|
|
2008
1887
|
*
|
|
2009
1888
|
* **Index management — important for boot performance:**
|
|
@@ -2042,7 +1921,12 @@ var MongoUnitOfWork = class {
|
|
|
2042
1921
|
*/
|
|
2043
1922
|
function createPromoEngine(config) {
|
|
2044
1923
|
const resolvedConfig = resolveConfig(config);
|
|
2045
|
-
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
|
+
});
|
|
2046
1930
|
const events = config.events?.transport ?? new InProcessPromoBus();
|
|
2047
1931
|
const dispatchDeps = {
|
|
2048
1932
|
events,
|
|
@@ -2050,12 +1934,14 @@ function createPromoEngine(config) {
|
|
|
2050
1934
|
...config.logger !== void 0 ? { logger: config.logger } : {}
|
|
2051
1935
|
};
|
|
2052
1936
|
const repositories = createRepositories(models, config.plugins, resolvedConfig.tenant, dispatchDeps);
|
|
1937
|
+
const allowNonTransactional = config.allowNonTransactional ?? false;
|
|
1938
|
+
if (!allowNonTransactional) assertPromoCapabilities(repositories.program.capabilities);
|
|
2053
1939
|
return {
|
|
2054
1940
|
models,
|
|
2055
1941
|
repositories,
|
|
2056
1942
|
services: createServices({
|
|
2057
1943
|
repositories,
|
|
2058
|
-
unitOfWork: new MongoUnitOfWork(config.mongoose),
|
|
1944
|
+
unitOfWork: new MongoUnitOfWork(config.mongoose, allowNonTransactional),
|
|
2059
1945
|
dispatchDeps,
|
|
2060
1946
|
config: resolvedConfig,
|
|
2061
1947
|
...config.evaluationStore !== void 0 ? { evaluationStore: config.evaluationStore } : {}
|
|
@@ -2067,4 +1953,4 @@ function createPromoEngine(config) {
|
|
|
2067
1953
|
};
|
|
2068
1954
|
}
|
|
2069
1955
|
//#endregion
|
|
2070
|
-
export { CartHashMismatchError, ConcurrencyConflictError, DuplicateRedemptionError, EvaluationCommitted, EvaluationCompleted, EvaluationNotFoundError, EvaluationRolledBack, GiftCardExhausted, GiftCardExhaustedError, GiftCardSpent, GiftCardToppedUp, InMemoryEvaluationStore, InsufficientBalanceError, InvalidTransitionError, MongoEvaluationStore, PendingEvaluationRepository, ProgramActivated, ProgramArchived, ProgramCreated, ProgramNotFoundError, ProgramPaused, ProgramRepository, ProgramUsageCapExceededError, PromoError, PromoEvents, RewardAdded, RewardNotFoundError, RewardRemoved, RewardRepository, RewardUpdated, RuleAdded, RuleNotFoundError, RuleRemoved, RuleRepository, RuleUpdated, TenantIsolationError, ValidationError, VoucherCancelled, VoucherExhaustedError, VoucherExpired, VoucherExpiredError, VoucherGenerated, VoucherNotFoundError, VoucherRedeemed, VoucherRepository, createPromoEngine, promoEventDefinitions, resolveConfig };
|
|
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 };
|