@classytic/promo 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +128 -0
- package/README.md +226 -22
- package/dist/{index-J5BC20DN.d.mts → constants-CrbSSQG5.d.mts} +1 -3
- package/dist/{constants-BVajdyL3.mjs → constants-D0Rntp2f.mjs} +1 -3
- package/dist/index.d.mts +1301 -10
- package/dist/index.mjs +2165 -46
- package/dist/schemas/index.d.mts +253 -0
- package/dist/schemas/index.mjs +134 -0
- package/package.json +23 -37
- package/dist/config-iZjn_8pp.d.mts +0 -71
- package/dist/domain/enums/index.d.mts +0 -2
- package/dist/domain/enums/index.mjs +0 -2
- package/dist/domain/index.d.mts +0 -61
- package/dist/domain/index.mjs +0 -4
- package/dist/domain-errors-BEkXvy5O.mjs +0 -80
- package/dist/event-emitter.port-DaodlJSG.d.mts +0 -8
- package/dist/event-types-CsTV1FKX.mjs +0 -25
- package/dist/events/index.d.mts +0 -2
- package/dist/events/index.mjs +0 -3
- package/dist/events-CprEWlN7.mjs +0 -25
- package/dist/index-B7lLH19a.d.mts +0 -13
- package/dist/index-C52zSBkI.d.mts +0 -96
- package/dist/index-Cu9iwy4v.d.mts +0 -99
- package/dist/index-l09KqnlE.d.mts +0 -81
- package/dist/models/index.d.mts +0 -2
- package/dist/models/index.mjs +0 -2
- package/dist/models-DdBNae7h.mjs +0 -277
- package/dist/repositories/index.d.mts +0 -2
- package/dist/repositories/index.mjs +0 -2
- package/dist/repositories-DgZIY9wD.mjs +0 -295
- package/dist/results-Ca5ZCNbN.d.mts +0 -218
- package/dist/services/index.d.mts +0 -2
- package/dist/services/index.mjs +0 -2
- package/dist/services-Cz0gHrmX.mjs +0 -815
- package/dist/types/index.d.mts +0 -3
- package/dist/types/index.mjs +0 -1
- package/dist/unit-of-work.port-DaMW8WZK.d.mts +0 -7
- package/dist/voucher.port-yxfb3MHJ.d.mts +0 -146
package/dist/index.mjs
CHANGED
|
@@ -1,32 +1,2104 @@
|
|
|
1
|
-
import { n as
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
//#region src/
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
1
|
+
import { a as PROGRAM_TYPES, c as TRIGGER_MODES, i as PROGRAM_TRANSITIONS, l as VOUCHER_STATUSES, n as DISCOUNT_SCOPES, o as REWARD_TYPES, r as PROGRAM_STATUSES, s as STACKING_MODES, t as DISCOUNT_MODES } from "./constants-D0Rntp2f.mjs";
|
|
2
|
+
import { Repository, multiTenantPlugin, withTransaction } from "@classytic/mongokit";
|
|
3
|
+
import { resolveTenantConfig } from "@classytic/primitives/tenant";
|
|
4
|
+
import { createEvent, matchEventPattern } from "@classytic/primitives/events";
|
|
5
|
+
import mongoose, { Schema } from "mongoose";
|
|
6
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
//#region src/domain/errors/base.ts
|
|
9
|
+
var PromoError = class extends Error {
|
|
10
|
+
constructor(message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = this.constructor.name;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
//#endregion
|
|
16
|
+
//#region src/domain/errors/domain-errors.ts
|
|
17
|
+
var ValidationError = class extends PromoError {
|
|
18
|
+
code = "VALIDATION_ERROR";
|
|
19
|
+
};
|
|
20
|
+
var ProgramNotFoundError = class extends PromoError {
|
|
21
|
+
code = "PROGRAM_NOT_FOUND";
|
|
22
|
+
constructor(id) {
|
|
23
|
+
super(id ? `Program '${id}' not found` : "Program not found");
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Raised when a commit-time atomic CAS on `usedCount < maxUsageTotal`
|
|
28
|
+
* fails — i.e. the program's cap was exhausted by another concurrent
|
|
29
|
+
* commit between this caller's evaluate and its commit. The host should
|
|
30
|
+
* surface this as a "promo no longer available" failure to the user; the
|
|
31
|
+
* order itself can still proceed without the discount if the host wishes.
|
|
32
|
+
*/
|
|
33
|
+
var ProgramUsageCapExceededError = class extends PromoError {
|
|
34
|
+
code = "PROGRAM_USAGE_CAP_EXCEEDED";
|
|
35
|
+
constructor(programId, maxUsageTotal) {
|
|
36
|
+
super(`Program '${programId}' has reached its usage cap of ${maxUsageTotal}`);
|
|
37
|
+
this.programId = programId;
|
|
38
|
+
this.maxUsageTotal = maxUsageTotal;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
var RuleNotFoundError = class extends PromoError {
|
|
42
|
+
code = "RULE_NOT_FOUND";
|
|
43
|
+
constructor(id) {
|
|
44
|
+
super(id ? `Rule '${id}' not found` : "Rule not found");
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
var RewardNotFoundError = class extends PromoError {
|
|
48
|
+
code = "REWARD_NOT_FOUND";
|
|
49
|
+
constructor(id) {
|
|
50
|
+
super(id ? `Reward '${id}' not found` : "Reward not found");
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
var VoucherNotFoundError = class extends PromoError {
|
|
54
|
+
code = "VOUCHER_NOT_FOUND";
|
|
55
|
+
constructor(codeOrId) {
|
|
56
|
+
super(codeOrId ? `Voucher '${codeOrId}' not found` : "Voucher not found");
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
var InvalidTransitionError = class extends PromoError {
|
|
60
|
+
code = "INVALID_TRANSITION";
|
|
61
|
+
constructor(from, to) {
|
|
62
|
+
super(`Cannot transition from '${from}' to '${to}'`);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
var VoucherExpiredError = class extends PromoError {
|
|
66
|
+
code = "VOUCHER_EXPIRED";
|
|
67
|
+
constructor(code) {
|
|
68
|
+
super(`Voucher '${code}' has expired`);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
var VoucherExhaustedError = class extends PromoError {
|
|
72
|
+
code = "VOUCHER_EXHAUSTED";
|
|
73
|
+
constructor(code) {
|
|
74
|
+
super(`Voucher '${code}' has reached its usage limit`);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Thrown when a gift card's balance has been fully spent and its status
|
|
79
|
+
* flipped to `'used'`. Distinct from `VoucherExhaustedError` (usage-limit
|
|
80
|
+
* exhaustion on a discount voucher) so hosts can show "top up" vs "retry
|
|
81
|
+
* later" messaging.
|
|
82
|
+
*/
|
|
83
|
+
var GiftCardExhaustedError = class extends PromoError {
|
|
84
|
+
code = "GIFT_CARD_EXHAUSTED";
|
|
85
|
+
constructor(code) {
|
|
86
|
+
super(`Gift card '${code}' has been fully spent`);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Thrown when a MongoDB WriteConflict surfaces under voucher-spend
|
|
91
|
+
* contention. Losers see a stable domain shape rather than the raw
|
|
92
|
+
* `"Write conflict during plan execution"` string. Hosts can translate
|
|
93
|
+
* this to HTTP 409 + retry.
|
|
94
|
+
*/
|
|
95
|
+
var ConcurrencyConflictError = class extends PromoError {
|
|
96
|
+
code = "CONCURRENCY_CONFLICT";
|
|
97
|
+
status = 409;
|
|
98
|
+
constructor(resource, resourceId, cause) {
|
|
99
|
+
super(`Concurrent modification on ${resource} '${resourceId}'`);
|
|
100
|
+
this.resource = resource;
|
|
101
|
+
this.resourceId = resourceId;
|
|
102
|
+
this.cause = cause;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
var InsufficientBalanceError = class extends PromoError {
|
|
106
|
+
code = "INSUFFICIENT_BALANCE";
|
|
107
|
+
constructor(code, available, requested) {
|
|
108
|
+
super(`Voucher '${code}' has insufficient balance: ${available} available, ${requested} requested`);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
var TenantIsolationError = class extends PromoError {
|
|
112
|
+
code = "TENANT_ISOLATION";
|
|
113
|
+
constructor() {
|
|
114
|
+
super("Tenant context is required but was not provided");
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
var DuplicateRedemptionError = class extends PromoError {
|
|
118
|
+
code = "DUPLICATE_REDEMPTION";
|
|
119
|
+
constructor(key) {
|
|
120
|
+
super(`Duplicate redemption detected for idempotency key '${key}'`);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
var EvaluationNotFoundError = class extends PromoError {
|
|
124
|
+
code = "EVALUATION_NOT_FOUND";
|
|
125
|
+
constructor(id) {
|
|
126
|
+
super(`Evaluation '${id}' not found or already committed`);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
/**
|
|
130
|
+
* Thrown by `EvaluationService.commit` when the cart hash provided by the
|
|
131
|
+
* caller does not match the cart hash computed at evaluation time. Guards
|
|
132
|
+
* against cart-tampering attacks where a user previews a large discount on
|
|
133
|
+
* a heavy cart, mutates the cart to something cheaper before committing, and
|
|
134
|
+
* tries to apply the stale discount to the altered order.
|
|
135
|
+
*/
|
|
136
|
+
var CartHashMismatchError = class extends PromoError {
|
|
137
|
+
code = "CART_HASH_MISMATCH";
|
|
138
|
+
constructor(evaluationId) {
|
|
139
|
+
super(`Evaluation '${evaluationId}' cart hash mismatch — the cart changed between evaluate and commit. Re-evaluate with the current cart.`);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
//#endregion
|
|
143
|
+
//#region src/events/in-process-bus.ts
|
|
144
|
+
var InProcessPromoBus = class {
|
|
145
|
+
name = "in-process-promo";
|
|
146
|
+
handlers = /* @__PURE__ */ new Map();
|
|
147
|
+
logger;
|
|
148
|
+
constructor(options = {}) {
|
|
149
|
+
this.logger = options.logger ?? console;
|
|
150
|
+
}
|
|
151
|
+
async publish(event) {
|
|
152
|
+
const matched = /* @__PURE__ */ new Set();
|
|
153
|
+
for (const [pattern, set] of this.handlers.entries()) if (matchEventPattern(pattern, event.type)) for (const h of set) matched.add(h);
|
|
154
|
+
for (const handler of matched) try {
|
|
155
|
+
await handler(event);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
this.logger.error(`[promo] handler error for ${event.type}:`, err);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
async subscribe(pattern, handler) {
|
|
161
|
+
let set = this.handlers.get(pattern);
|
|
162
|
+
if (!set) {
|
|
163
|
+
set = /* @__PURE__ */ new Set();
|
|
164
|
+
this.handlers.set(pattern, set);
|
|
165
|
+
}
|
|
166
|
+
set.add(handler);
|
|
167
|
+
return () => {
|
|
168
|
+
const s = this.handlers.get(pattern);
|
|
169
|
+
if (!s) return;
|
|
170
|
+
s.delete(handler);
|
|
171
|
+
if (s.size === 0) this.handlers.delete(pattern);
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
async close() {
|
|
175
|
+
this.handlers.clear();
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
//#endregion
|
|
179
|
+
//#region src/models/create-model.ts
|
|
180
|
+
function injectTenantField(schema, tenant) {
|
|
181
|
+
schema.add({ [tenant.tenantField]: {
|
|
182
|
+
type: tenant.fieldType === "objectId" ? mongoose.Schema.Types.ObjectId : String,
|
|
183
|
+
index: true,
|
|
184
|
+
...tenant.ref ? { ref: tenant.ref } : {}
|
|
185
|
+
} });
|
|
186
|
+
if (!tenant.enabled) return;
|
|
187
|
+
const existingIndexes = schema._indexes;
|
|
188
|
+
if (existingIndexes && existingIndexes.length > 0) for (const indexEntry of existingIndexes) {
|
|
189
|
+
const fields = indexEntry[0];
|
|
190
|
+
const newFields = { [tenant.tenantField]: 1 };
|
|
191
|
+
for (const [key, val] of Object.entries(fields)) newFields[key] = val;
|
|
192
|
+
indexEntry[0] = newFields;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
function applyUserIndexes(schema, indexes, tenant) {
|
|
196
|
+
for (const def of indexes) {
|
|
197
|
+
const fields = tenant.enabled ? {
|
|
198
|
+
[tenant.tenantField]: 1,
|
|
199
|
+
...def.fields
|
|
200
|
+
} : { ...def.fields };
|
|
201
|
+
schema.index(fields, def.options ?? {});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
//#endregion
|
|
205
|
+
//#region src/models/schemas/pending-evaluation.schema.ts
|
|
206
|
+
/**
|
|
207
|
+
* Persisted snapshot of an `evaluate()` outcome, awaiting a follow-up
|
|
208
|
+
* `commit()` or `rollback()`.
|
|
209
|
+
*
|
|
210
|
+
* Why a Mongo collection (not a process-local Map): pending evaluations
|
|
211
|
+
* MUST survive process restart, horizontal scaling, serverless cold
|
|
212
|
+
* starts, and worker handoff. Stale snapshots auto-clean via the TTL
|
|
213
|
+
* index — same pattern cart uses for guest drafts (see
|
|
214
|
+
* cart/src/models/draft.model.ts:131-135).
|
|
215
|
+
*
|
|
216
|
+
* Stored fields are intentionally minimal — only what `commit()` and
|
|
217
|
+
* `rollback()` need:
|
|
218
|
+
* - the materialised result (returned to caller for telemetry)
|
|
219
|
+
* - per-program / per-voucher usages (incremented atomically at commit)
|
|
220
|
+
* - cartHash (anti-tamper guard between evaluate and commit)
|
|
221
|
+
* - expiresAt (TTL drives auto-cleanup; engine never relies on it for
|
|
222
|
+
* correctness — the atomic `findOneAndDelete` in `take()` is the
|
|
223
|
+
* authoritative single-commit guard)
|
|
224
|
+
*/
|
|
225
|
+
function createPendingEvaluationSchema() {
|
|
226
|
+
return new Schema({
|
|
227
|
+
evaluationId: {
|
|
228
|
+
type: String,
|
|
229
|
+
required: true,
|
|
230
|
+
unique: true,
|
|
231
|
+
index: true
|
|
232
|
+
},
|
|
233
|
+
result: {
|
|
234
|
+
type: Schema.Types.Mixed,
|
|
235
|
+
required: true
|
|
236
|
+
},
|
|
237
|
+
ctx: {
|
|
238
|
+
type: Schema.Types.Mixed,
|
|
239
|
+
required: true
|
|
240
|
+
},
|
|
241
|
+
customerId: {
|
|
242
|
+
type: String,
|
|
243
|
+
default: null
|
|
244
|
+
},
|
|
245
|
+
programUsages: {
|
|
246
|
+
type: [Schema.Types.Mixed],
|
|
247
|
+
default: []
|
|
248
|
+
},
|
|
249
|
+
voucherUsages: {
|
|
250
|
+
type: [Schema.Types.Mixed],
|
|
251
|
+
default: []
|
|
252
|
+
},
|
|
253
|
+
cartHash: {
|
|
254
|
+
type: String,
|
|
255
|
+
required: true,
|
|
256
|
+
index: true
|
|
257
|
+
},
|
|
258
|
+
expiresAt: {
|
|
259
|
+
type: Date,
|
|
260
|
+
required: true
|
|
261
|
+
}
|
|
262
|
+
}, {
|
|
263
|
+
timestamps: true,
|
|
264
|
+
minimize: false
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Wire the TTL index. Mongo evaluates `expireAfterSeconds: 0` against the
|
|
269
|
+
* `expiresAt` field's value — when `expiresAt < now`, the doc is purged
|
|
270
|
+
* by the background TTL monitor (~60s granularity).
|
|
271
|
+
*/
|
|
272
|
+
function applyPendingEvaluationIndexes(schema) {
|
|
273
|
+
schema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
|
274
|
+
}
|
|
275
|
+
//#endregion
|
|
276
|
+
//#region src/models/schemas/program.schema.ts
|
|
277
|
+
const { Schema: Schema$4 } = mongoose;
|
|
278
|
+
function createProgramSchema() {
|
|
279
|
+
const schema = new Schema$4({
|
|
280
|
+
name: {
|
|
281
|
+
type: String,
|
|
282
|
+
required: true
|
|
283
|
+
},
|
|
284
|
+
description: { type: String },
|
|
285
|
+
programType: {
|
|
286
|
+
type: String,
|
|
287
|
+
enum: PROGRAM_TYPES,
|
|
288
|
+
required: true
|
|
289
|
+
},
|
|
290
|
+
triggerMode: {
|
|
291
|
+
type: String,
|
|
292
|
+
enum: TRIGGER_MODES,
|
|
293
|
+
required: true
|
|
294
|
+
},
|
|
295
|
+
status: {
|
|
296
|
+
type: String,
|
|
297
|
+
enum: PROGRAM_STATUSES,
|
|
298
|
+
default: "draft"
|
|
299
|
+
},
|
|
300
|
+
stackingMode: {
|
|
301
|
+
type: String,
|
|
302
|
+
enum: STACKING_MODES,
|
|
303
|
+
default: "exclusive"
|
|
304
|
+
},
|
|
305
|
+
priority: {
|
|
306
|
+
type: Number,
|
|
307
|
+
default: 0
|
|
308
|
+
},
|
|
309
|
+
startsAt: { type: Date },
|
|
310
|
+
endsAt: { type: Date },
|
|
311
|
+
maxUsageTotal: { type: Number },
|
|
312
|
+
usedCount: {
|
|
313
|
+
type: Number,
|
|
314
|
+
default: 0
|
|
315
|
+
},
|
|
316
|
+
maxUsagePerCustomer: { type: Number },
|
|
317
|
+
applicableCustomerIds: [{ type: String }],
|
|
318
|
+
applicableCustomerTags: [{ type: String }],
|
|
319
|
+
customerUsageCounts: {
|
|
320
|
+
type: Map,
|
|
321
|
+
of: Number,
|
|
322
|
+
default: () => /* @__PURE__ */ new Map()
|
|
323
|
+
},
|
|
324
|
+
metadata: { type: Schema$4.Types.Mixed }
|
|
325
|
+
}, { timestamps: true });
|
|
326
|
+
schema.index({
|
|
327
|
+
status: 1,
|
|
328
|
+
programType: 1,
|
|
329
|
+
priority: -1
|
|
330
|
+
});
|
|
331
|
+
schema.index({
|
|
332
|
+
status: 1,
|
|
333
|
+
startsAt: 1,
|
|
334
|
+
endsAt: 1
|
|
335
|
+
});
|
|
336
|
+
schema.index({
|
|
337
|
+
status: 1,
|
|
338
|
+
priority: -1,
|
|
339
|
+
_id: -1
|
|
340
|
+
});
|
|
341
|
+
return schema;
|
|
342
|
+
}
|
|
343
|
+
//#endregion
|
|
344
|
+
//#region src/models/schemas/reward.schema.ts
|
|
345
|
+
const { Schema: Schema$3 } = mongoose;
|
|
346
|
+
function createRewardSchema() {
|
|
347
|
+
const schema = new Schema$3({
|
|
348
|
+
programId: {
|
|
349
|
+
type: Schema$3.Types.ObjectId,
|
|
350
|
+
required: true
|
|
351
|
+
},
|
|
352
|
+
ruleId: { type: Schema$3.Types.ObjectId },
|
|
353
|
+
rewardType: {
|
|
354
|
+
type: String,
|
|
355
|
+
enum: REWARD_TYPES,
|
|
356
|
+
required: true
|
|
357
|
+
},
|
|
358
|
+
discountMode: {
|
|
359
|
+
type: String,
|
|
360
|
+
enum: DISCOUNT_MODES
|
|
361
|
+
},
|
|
362
|
+
discountAmount: { type: Number },
|
|
363
|
+
maxDiscountAmount: { type: Number },
|
|
364
|
+
discountScope: {
|
|
365
|
+
type: String,
|
|
366
|
+
enum: DISCOUNT_SCOPES,
|
|
367
|
+
default: "order"
|
|
368
|
+
},
|
|
369
|
+
applicableProductIds: [{ type: String }],
|
|
370
|
+
freeProductId: { type: String },
|
|
371
|
+
freeProductSku: { type: String },
|
|
372
|
+
freeQuantity: {
|
|
373
|
+
type: Number,
|
|
374
|
+
default: 1
|
|
375
|
+
},
|
|
376
|
+
giftCardAmount: { type: Number },
|
|
377
|
+
metadata: { type: Schema$3.Types.Mixed }
|
|
378
|
+
}, { timestamps: true });
|
|
379
|
+
schema.index({ programId: 1 });
|
|
380
|
+
schema.index({ ruleId: 1 }, { sparse: true });
|
|
381
|
+
return schema;
|
|
382
|
+
}
|
|
383
|
+
//#endregion
|
|
384
|
+
//#region src/models/schemas/rule.schema.ts
|
|
385
|
+
const { Schema: Schema$2 } = mongoose;
|
|
386
|
+
function createRuleSchema() {
|
|
387
|
+
const schema = new Schema$2({
|
|
388
|
+
programId: {
|
|
389
|
+
type: Schema$2.Types.ObjectId,
|
|
390
|
+
required: true
|
|
391
|
+
},
|
|
392
|
+
name: { type: String },
|
|
393
|
+
minimumAmount: {
|
|
394
|
+
type: Number,
|
|
395
|
+
default: 0
|
|
396
|
+
},
|
|
397
|
+
minimumQuantity: {
|
|
398
|
+
type: Number,
|
|
399
|
+
default: 0
|
|
400
|
+
},
|
|
401
|
+
applicableProductIds: [{ type: String }],
|
|
402
|
+
applicableCategories: [{ type: String }],
|
|
403
|
+
applicableSkus: [{ type: String }],
|
|
404
|
+
buyQuantity: { type: Number },
|
|
405
|
+
code: {
|
|
406
|
+
type: String,
|
|
407
|
+
uppercase: true,
|
|
408
|
+
trim: true
|
|
409
|
+
},
|
|
410
|
+
startsAt: { type: Date },
|
|
411
|
+
endsAt: { type: Date },
|
|
412
|
+
metadata: { type: Schema$2.Types.Mixed }
|
|
413
|
+
}, { timestamps: true });
|
|
414
|
+
schema.index({ programId: 1 });
|
|
415
|
+
schema.index({ code: 1 }, { sparse: true });
|
|
416
|
+
return schema;
|
|
417
|
+
}
|
|
418
|
+
//#endregion
|
|
419
|
+
//#region src/models/schemas/voucher.schema.ts
|
|
420
|
+
const { Schema: Schema$1 } = mongoose;
|
|
421
|
+
function createVoucherSchema() {
|
|
422
|
+
const schema = new Schema$1({
|
|
423
|
+
programId: {
|
|
424
|
+
type: Schema$1.Types.ObjectId,
|
|
425
|
+
required: true,
|
|
426
|
+
index: true
|
|
427
|
+
},
|
|
428
|
+
code: {
|
|
429
|
+
type: String,
|
|
430
|
+
required: true,
|
|
431
|
+
uppercase: true,
|
|
432
|
+
trim: true
|
|
433
|
+
},
|
|
434
|
+
status: {
|
|
435
|
+
type: String,
|
|
436
|
+
enum: VOUCHER_STATUSES,
|
|
437
|
+
default: "active"
|
|
438
|
+
},
|
|
439
|
+
customerId: { type: String },
|
|
440
|
+
usageLimit: {
|
|
441
|
+
type: Number,
|
|
442
|
+
default: 1
|
|
443
|
+
},
|
|
444
|
+
usedCount: {
|
|
445
|
+
type: Number,
|
|
446
|
+
default: 0
|
|
447
|
+
},
|
|
448
|
+
initialBalance: { type: Number },
|
|
449
|
+
currentBalance: { type: Number },
|
|
450
|
+
balanceLedger: [{
|
|
451
|
+
amount: {
|
|
452
|
+
type: Number,
|
|
453
|
+
required: true
|
|
454
|
+
},
|
|
455
|
+
orderId: { type: String },
|
|
456
|
+
description: { type: String },
|
|
457
|
+
createdAt: {
|
|
458
|
+
type: Date,
|
|
459
|
+
default: Date.now
|
|
460
|
+
},
|
|
461
|
+
idempotencyKey: { type: String },
|
|
462
|
+
organizationId: { type: String }
|
|
463
|
+
}],
|
|
464
|
+
expiresAt: { type: Date },
|
|
465
|
+
redemptions: [{
|
|
466
|
+
orderId: {
|
|
467
|
+
type: String,
|
|
468
|
+
required: true
|
|
469
|
+
},
|
|
470
|
+
customerId: { type: String },
|
|
471
|
+
discountAmount: {
|
|
472
|
+
type: Number,
|
|
473
|
+
required: true
|
|
474
|
+
},
|
|
475
|
+
redeemedAt: {
|
|
476
|
+
type: Date,
|
|
477
|
+
default: Date.now
|
|
478
|
+
},
|
|
479
|
+
idempotencyKey: { type: String },
|
|
480
|
+
organizationId: { type: String }
|
|
481
|
+
}],
|
|
482
|
+
metadata: { type: Schema$1.Types.Mixed }
|
|
483
|
+
}, { timestamps: true });
|
|
484
|
+
schema.index({ code: 1 }, { unique: true });
|
|
485
|
+
schema.index({
|
|
486
|
+
programId: 1,
|
|
487
|
+
status: 1
|
|
488
|
+
});
|
|
489
|
+
schema.index({
|
|
490
|
+
customerId: 1,
|
|
491
|
+
status: 1
|
|
492
|
+
}, { sparse: true });
|
|
493
|
+
schema.index({ expiresAt: 1 }, { sparse: true });
|
|
494
|
+
return schema;
|
|
495
|
+
}
|
|
496
|
+
//#endregion
|
|
497
|
+
//#region src/models/create-models.ts
|
|
498
|
+
const MODEL_NAMES = [
|
|
499
|
+
"PromoProgram",
|
|
500
|
+
"PromoRule",
|
|
501
|
+
"PromoReward",
|
|
502
|
+
"PromoVoucher",
|
|
503
|
+
"PromoPendingEvaluation"
|
|
504
|
+
];
|
|
505
|
+
function applyAutoIndex(models, autoIndex) {
|
|
506
|
+
if (autoIndex === void 0) return;
|
|
507
|
+
for (const [configKey, modelKey] of [
|
|
508
|
+
["program", "Program"],
|
|
509
|
+
["rule", "Rule"],
|
|
510
|
+
["reward", "Reward"],
|
|
511
|
+
["voucher", "Voucher"]
|
|
512
|
+
]) {
|
|
513
|
+
const value = typeof autoIndex === "boolean" ? autoIndex : autoIndex[configKey] ?? void 0;
|
|
514
|
+
if (value !== void 0) models[modelKey].schema.set("autoIndex", value);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
function createModels(connection, tenant, indexes, autoIndex) {
|
|
518
|
+
for (const name of MODEL_NAMES) if (connection.models[name]) connection.deleteModel(name);
|
|
519
|
+
const programSchema = createProgramSchema();
|
|
520
|
+
const ruleSchema = createRuleSchema();
|
|
521
|
+
const rewardSchema = createRewardSchema();
|
|
522
|
+
const voucherSchema = createVoucherSchema();
|
|
523
|
+
const pendingEvaluationSchema = createPendingEvaluationSchema();
|
|
524
|
+
injectTenantField(programSchema, tenant);
|
|
525
|
+
injectTenantField(ruleSchema, tenant);
|
|
526
|
+
injectTenantField(rewardSchema, tenant);
|
|
527
|
+
injectTenantField(voucherSchema, tenant);
|
|
528
|
+
injectTenantField(pendingEvaluationSchema, tenant);
|
|
529
|
+
applyPendingEvaluationIndexes(pendingEvaluationSchema);
|
|
530
|
+
if (indexes?.program) applyUserIndexes(programSchema, indexes.program, tenant);
|
|
531
|
+
if (indexes?.rule) applyUserIndexes(ruleSchema, indexes.rule, tenant);
|
|
532
|
+
if (indexes?.reward) applyUserIndexes(rewardSchema, indexes.reward, tenant);
|
|
533
|
+
if (indexes?.voucher) applyUserIndexes(voucherSchema, indexes.voucher, tenant);
|
|
534
|
+
const result = {
|
|
535
|
+
Program: connection.model("PromoProgram", programSchema),
|
|
536
|
+
Rule: connection.model("PromoRule", ruleSchema),
|
|
537
|
+
Reward: connection.model("PromoReward", rewardSchema),
|
|
538
|
+
Voucher: connection.model("PromoVoucher", voucherSchema),
|
|
539
|
+
PendingEvaluation: connection.model("PromoPendingEvaluation", pendingEvaluationSchema)
|
|
16
540
|
};
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
541
|
+
applyAutoIndex(result, autoIndex);
|
|
542
|
+
return result;
|
|
543
|
+
}
|
|
544
|
+
//#endregion
|
|
545
|
+
//#region src/repositories/pending-evaluation.repository.ts
|
|
546
|
+
/**
|
|
547
|
+
* Pending-evaluation repository. Extends mongokit's `Repository<TDoc>`
|
|
548
|
+
* directly per package rules (no service wrapper, no aliased verbs).
|
|
549
|
+
* Adds one custom domain method: `takeByEvaluationId` — atomic
|
|
550
|
+
* read-and-delete via raw `Model.findOneAndDelete`.
|
|
551
|
+
*
|
|
552
|
+
* **Why raw `findOneAndDelete` (escape from mongokit's `delete()`)**:
|
|
553
|
+
* mongokit's `Repository.delete()` returns `{success, message}` only —
|
|
554
|
+
* it doesn't surface the deleted document. `take` semantics require
|
|
555
|
+
* "atomically remove AND return", which is the canonical defence
|
|
556
|
+
* against double-commit on the same evaluationId at the storage layer
|
|
557
|
+
* (one caller wins the document, the other gets `null`). This is the
|
|
558
|
+
* narrow exception PACKAGE_RULES.md / order/CLAUDE.md sanction:
|
|
559
|
+
* *"Raw findOneAndUpdate/findOneAndDelete is allowed ONLY for atomic
|
|
560
|
+
* state-machine transitions — flag each one with a comment."*
|
|
561
|
+
*/
|
|
562
|
+
var PendingEvaluationRepository = class extends Repository {
|
|
563
|
+
/**
|
|
564
|
+
* The repository owns its tenant config so its raw-driver methods
|
|
565
|
+
* (`findOneAndDelete`, `deleteOne`) can apply the SAME scoping rule
|
|
566
|
+
* the mongokit hook pipeline would apply on standard methods —
|
|
567
|
+
* specifically using `tenant.tenantField` (host-configurable as
|
|
568
|
+
* `organizationId`, `branchId`, `tenantId`, etc.) NOT a hardcoded
|
|
569
|
+
* `organizationId`. Without this, deployments that configure custom
|
|
570
|
+
* tenant fields would silently lose isolation on the cache layer.
|
|
571
|
+
*/
|
|
572
|
+
tenant;
|
|
573
|
+
constructor(model, plugins = [], tenant) {
|
|
574
|
+
super(model, plugins);
|
|
575
|
+
this.tenant = tenant;
|
|
576
|
+
}
|
|
577
|
+
/** The host-configured tenant field name (or `undefined` if single-tenant). */
|
|
578
|
+
get tenantField() {
|
|
579
|
+
return this.tenant?.enabled ? this.tenant.tenantField : void 0;
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Atomic read-and-delete by evaluationId. Two concurrent commit calls
|
|
583
|
+
* on the same id race here at the database layer — the winner gets
|
|
584
|
+
* the document, the loser gets `null` and the calling service throws
|
|
585
|
+
* `EvaluationNotFoundError`. No way both succeed.
|
|
586
|
+
*
|
|
587
|
+
* Honours `ctx.session` so the operation joins the caller's
|
|
588
|
+
* transaction. If the transaction aborts (transient DB error, cap
|
|
589
|
+
* exceeded, etc.) the delete rolls back and the snapshot stays in
|
|
590
|
+
* the store — letting the caller retry without re-evaluation.
|
|
591
|
+
*
|
|
592
|
+
* Tenant scoping uses the configured `tenant.tenantField` (NOT a
|
|
593
|
+
* hardcoded `organizationId`) so hosts on `branchId`/`tenantId`/etc.
|
|
594
|
+
* keep cross-tenant isolation on the cache layer too.
|
|
595
|
+
*/
|
|
596
|
+
async takeByEvaluationId(evaluationId, ctx) {
|
|
597
|
+
const filter = this.scopedFilter({ evaluationId }, ctx);
|
|
598
|
+
const session = ctx?.session ?? null;
|
|
599
|
+
return await this.Model.findOneAndDelete(filter).session(session).lean();
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Idempotent delete. Returns whether anything was actually removed,
|
|
603
|
+
* so callers can distinguish "we cleaned it" from "already gone".
|
|
604
|
+
*/
|
|
605
|
+
async deleteByEvaluationId(evaluationId, ctx) {
|
|
606
|
+
const filter = this.scopedFilter({ evaluationId }, ctx);
|
|
607
|
+
const session = ctx?.session ?? null;
|
|
608
|
+
return (await this.Model.deleteOne(filter).session(session)).deletedCount > 0;
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Build a query filter with the configured tenant scope appended,
|
|
612
|
+
* mirroring what mongokit's `multiTenantPlugin` injects on standard
|
|
613
|
+
* Repository methods. We re-implement here because raw driver calls
|
|
614
|
+
* (`findOneAndDelete`, `deleteOne`) bypass the plugin pipeline.
|
|
615
|
+
*/
|
|
616
|
+
scopedFilter(base, ctx) {
|
|
617
|
+
const field = this.tenantField;
|
|
618
|
+
if (!field || ctx?.tenantValue === void 0) return base;
|
|
619
|
+
return {
|
|
620
|
+
...base,
|
|
621
|
+
[field]: ctx.tenantValue
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
//#endregion
|
|
626
|
+
//#region src/events/dispatch.ts
|
|
627
|
+
/**
|
|
628
|
+
* Canonical P8 shape — save to outbox first (with `ctx.session` when
|
|
629
|
+
* present), then publish to transport. Both paths run independently.
|
|
630
|
+
*/
|
|
631
|
+
async function dispatchPromoEvent(deps, event, ctx) {
|
|
632
|
+
const logger = deps.logger ?? console;
|
|
633
|
+
if (deps.outbox) try {
|
|
634
|
+
const saveOptions = {};
|
|
635
|
+
if (ctx?.session !== void 0) saveOptions.session = ctx.session;
|
|
636
|
+
await deps.outbox.save(event, saveOptions);
|
|
637
|
+
} catch (err) {
|
|
638
|
+
logger.error(`[promo] outbox.save failed for ${event.type}:`, err);
|
|
639
|
+
}
|
|
640
|
+
if (deps.events) try {
|
|
641
|
+
await deps.events.publish(event);
|
|
642
|
+
} catch (err) {
|
|
643
|
+
logger.error(`[promo] events.publish failed for ${event.type}:`, err);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
//#endregion
|
|
647
|
+
//#region src/events/event-constants.ts
|
|
648
|
+
const PromoEvents = {
|
|
649
|
+
PROGRAM_CREATED: "promo.program.created",
|
|
650
|
+
PROGRAM_ACTIVATED: "promo.program.activated",
|
|
651
|
+
PROGRAM_PAUSED: "promo.program.paused",
|
|
652
|
+
PROGRAM_ARCHIVED: "promo.program.archived",
|
|
653
|
+
RULE_ADDED: "promo.rule.added",
|
|
654
|
+
RULE_UPDATED: "promo.rule.updated",
|
|
655
|
+
RULE_REMOVED: "promo.rule.removed",
|
|
656
|
+
REWARD_ADDED: "promo.reward.added",
|
|
657
|
+
REWARD_UPDATED: "promo.reward.updated",
|
|
658
|
+
REWARD_REMOVED: "promo.reward.removed",
|
|
659
|
+
VOUCHER_GENERATED: "promo.voucher.generated",
|
|
660
|
+
VOUCHER_REDEEMED: "promo.voucher.redeemed",
|
|
661
|
+
VOUCHER_CANCELLED: "promo.voucher.cancelled",
|
|
662
|
+
VOUCHER_EXPIRED: "promo.voucher.expired",
|
|
663
|
+
GIFT_CARD_SPENT: "promo.gift_card.spent",
|
|
664
|
+
GIFT_CARD_TOPPED_UP: "promo.gift_card.topped_up",
|
|
665
|
+
GIFT_CARD_EXHAUSTED: "promo.gift_card.exhausted",
|
|
666
|
+
EVALUATION_COMPLETED: "promo.evaluation.completed",
|
|
667
|
+
EVALUATION_COMMITTED: "promo.evaluation.committed",
|
|
668
|
+
EVALUATION_ROLLED_BACK: "promo.evaluation.rolled_back"
|
|
669
|
+
};
|
|
670
|
+
//#endregion
|
|
671
|
+
//#region src/events/helpers.ts
|
|
672
|
+
function createEvent$1(type, payload, ctx, meta) {
|
|
673
|
+
return createEvent(type, payload, {
|
|
674
|
+
resource: "promo",
|
|
675
|
+
...ctx?.actorId !== void 0 ? { userId: ctx.actorId } : {},
|
|
676
|
+
...ctx?.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {},
|
|
677
|
+
...meta
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
//#endregion
|
|
681
|
+
//#region src/repositories/program.repository.ts
|
|
682
|
+
var ProgramRepository = class extends Repository {
|
|
683
|
+
dispatchDeps;
|
|
684
|
+
constructor(model, plugins = [], dispatchDeps = {}) {
|
|
685
|
+
super(model, plugins);
|
|
686
|
+
this.dispatchDeps = dispatchDeps;
|
|
687
|
+
}
|
|
688
|
+
async activate(id, ctx) {
|
|
689
|
+
return this._transition(id, "active", ctx);
|
|
690
|
+
}
|
|
691
|
+
async pause(id, ctx) {
|
|
692
|
+
return this._transition(id, "paused", ctx);
|
|
693
|
+
}
|
|
694
|
+
async archive(id, ctx) {
|
|
695
|
+
return this._transition(id, "archived", ctx);
|
|
696
|
+
}
|
|
697
|
+
async _transition(id, targetStatus, ctx) {
|
|
698
|
+
const program = await this.getById(id, {
|
|
699
|
+
throwOnNotFound: false,
|
|
700
|
+
lean: true,
|
|
701
|
+
...ctx
|
|
702
|
+
});
|
|
703
|
+
if (!program) throw new ProgramNotFoundError(id);
|
|
704
|
+
if (!PROGRAM_TRANSITIONS[program.status]?.includes(targetStatus)) throw new InvalidTransitionError(program.status, targetStatus);
|
|
705
|
+
const updated = await this.update(id, { status: targetStatus }, {
|
|
706
|
+
throwOnNotFound: true,
|
|
707
|
+
lean: true,
|
|
708
|
+
...ctx
|
|
709
|
+
});
|
|
710
|
+
if (!updated) throw new ProgramNotFoundError(id);
|
|
711
|
+
const eventName = {
|
|
712
|
+
active: PromoEvents.PROGRAM_ACTIVATED,
|
|
713
|
+
paused: PromoEvents.PROGRAM_PAUSED,
|
|
714
|
+
archived: PromoEvents.PROGRAM_ARCHIVED
|
|
715
|
+
}[targetStatus];
|
|
716
|
+
if (eventName) await dispatchPromoEvent(this.dispatchDeps, createEvent$1(eventName, {
|
|
717
|
+
programId: updated._id,
|
|
718
|
+
programType: updated.programType,
|
|
719
|
+
status: updated.status,
|
|
720
|
+
actorId: ctx.actorId
|
|
721
|
+
}, ctx), ctx);
|
|
722
|
+
return updated;
|
|
723
|
+
}
|
|
724
|
+
async incrementUsage(id, ctx) {
|
|
725
|
+
const updated = await this.update(id, { $inc: { usedCount: 1 } }, {
|
|
726
|
+
throwOnNotFound: true,
|
|
727
|
+
lean: true,
|
|
728
|
+
...ctx
|
|
729
|
+
});
|
|
730
|
+
if (!updated) throw new ProgramNotFoundError(id);
|
|
731
|
+
return updated;
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Atomic compare-and-set increment: succeeds only if the program either
|
|
735
|
+
* has no cap (`maxUsageTotal == null`) OR `usedCount < maxUsageTotal`.
|
|
736
|
+
* If the cap is already saturated, returns `null` so the caller can
|
|
737
|
+
* decide whether to throw (commit-time enforcement) or skip silently
|
|
738
|
+
* (best-effort eligibility check).
|
|
739
|
+
*
|
|
740
|
+
* Industry-standard primitive for "promo with finite supply" — without
|
|
741
|
+
* this, two evaluations both see the program as available and both
|
|
742
|
+
* commit, leading to oversell. The atomic filter on the same write
|
|
743
|
+
* eliminates the race entirely; concurrent losers see a `null` return
|
|
744
|
+
* and can downgrade their commit (apply order without the discount,
|
|
745
|
+
* surface "promo no longer available" to the user, etc.).
|
|
746
|
+
*
|
|
747
|
+
* Routes through mongokit's `update` so tenant scoping + hooks fire.
|
|
748
|
+
*/
|
|
749
|
+
async tryIncrementUsage(id, ctx) {
|
|
750
|
+
return await this.update(id, { $inc: { usedCount: 1 } }, {
|
|
751
|
+
query: { $or: [
|
|
752
|
+
{ maxUsageTotal: { $exists: false } },
|
|
753
|
+
{ maxUsageTotal: null },
|
|
754
|
+
{ $expr: { $lt: ["$usedCount", "$maxUsageTotal"] } }
|
|
755
|
+
] },
|
|
756
|
+
throwOnNotFound: false,
|
|
757
|
+
lean: true,
|
|
758
|
+
...ctx
|
|
759
|
+
}) ?? null;
|
|
760
|
+
}
|
|
761
|
+
async decrementUsage(id, ctx) {
|
|
762
|
+
const updated = await this.update(id, { $inc: { usedCount: -1 } }, {
|
|
763
|
+
throwOnNotFound: true,
|
|
764
|
+
lean: true,
|
|
765
|
+
...ctx
|
|
766
|
+
});
|
|
767
|
+
if (!updated) throw new ProgramNotFoundError(id);
|
|
768
|
+
return updated;
|
|
769
|
+
}
|
|
770
|
+
async getCustomerUsage(id, customerId, ctx) {
|
|
771
|
+
const doc = await this.getById(id, {
|
|
772
|
+
throwOnNotFound: false,
|
|
773
|
+
lean: true,
|
|
774
|
+
...ctx
|
|
775
|
+
});
|
|
776
|
+
if (!doc) return 0;
|
|
777
|
+
const counts = doc.customerUsageCounts;
|
|
778
|
+
if (!counts) return 0;
|
|
779
|
+
if (counts instanceof Map) return counts.get(customerId) ?? 0;
|
|
780
|
+
return counts[customerId] ?? 0;
|
|
781
|
+
}
|
|
782
|
+
async incrementCustomerUsage(id, customerId, ctx) {
|
|
783
|
+
const updated = await this.update(id, { $inc: { [`customerUsageCounts.${customerId}`]: 1 } }, {
|
|
784
|
+
throwOnNotFound: true,
|
|
785
|
+
lean: true,
|
|
786
|
+
...ctx
|
|
787
|
+
});
|
|
788
|
+
if (!updated) throw new ProgramNotFoundError(id);
|
|
789
|
+
return updated;
|
|
790
|
+
}
|
|
791
|
+
async findActive(now, ctx) {
|
|
792
|
+
const currentDate = now ?? /* @__PURE__ */ new Date();
|
|
793
|
+
const filters = {
|
|
794
|
+
status: "active",
|
|
795
|
+
$or: [
|
|
796
|
+
{ startsAt: { $lte: currentDate } },
|
|
797
|
+
{ startsAt: { $exists: false } },
|
|
798
|
+
{ startsAt: null }
|
|
799
|
+
]
|
|
800
|
+
};
|
|
801
|
+
return (await this.findAll(filters, {
|
|
802
|
+
sort: {
|
|
803
|
+
priority: -1,
|
|
804
|
+
_id: -1
|
|
805
|
+
},
|
|
806
|
+
lean: true,
|
|
807
|
+
...ctx
|
|
808
|
+
})).filter((p) => !p.endsAt || p.endsAt >= currentDate).slice(0, 200);
|
|
809
|
+
}
|
|
810
|
+
};
|
|
811
|
+
//#endregion
|
|
812
|
+
//#region src/repositories/reward.repository.ts
|
|
813
|
+
var RewardRepository = class extends Repository {
|
|
814
|
+
constructor(model, plugins = []) {
|
|
815
|
+
super(model, plugins);
|
|
816
|
+
}
|
|
817
|
+
};
|
|
818
|
+
//#endregion
|
|
819
|
+
//#region src/repositories/rule.repository.ts
|
|
820
|
+
var RuleRepository = class extends Repository {
|
|
821
|
+
constructor(model, plugins = []) {
|
|
822
|
+
super(model, plugins);
|
|
823
|
+
}
|
|
824
|
+
};
|
|
825
|
+
//#endregion
|
|
826
|
+
//#region src/repositories/voucher.repository.ts
|
|
827
|
+
var VoucherRepository = class extends Repository {
|
|
828
|
+
dispatchDeps;
|
|
829
|
+
tenantField;
|
|
830
|
+
tenantEnabled;
|
|
831
|
+
constructor(model, plugins = [], dispatchDeps = {}, tenantField = "organizationId", tenantEnabled = true) {
|
|
832
|
+
super(model, plugins);
|
|
833
|
+
this.dispatchDeps = dispatchDeps;
|
|
834
|
+
this.tenantField = tenantField;
|
|
835
|
+
this.tenantEnabled = tenantEnabled;
|
|
836
|
+
}
|
|
837
|
+
/**
|
|
838
|
+
* Copy the tenant id from `ctx` onto the write payload so the doc persists
|
|
839
|
+
* with the correct `organizationId` even when the host has opted OUT of the
|
|
840
|
+
* auto-wired `multiTenantPlugin` but still scopes at its framework layer
|
|
841
|
+
* (e.g. arc's preset + `BaseController` — see `@classytic/order` CLAUDE.md:
|
|
842
|
+
* "Child repos set `organizationId` explicitly on the doc").
|
|
843
|
+
*
|
|
844
|
+
* Skipped entirely when the engine was configured with `tenant: false` —
|
|
845
|
+
* the host's intent is company-wide rows (no `organizationId` on disk),
|
|
846
|
+
* and injecting it from `ctx` would silently re-scope writes per branch
|
|
847
|
+
* while reads still look at the unscoped collection.
|
|
848
|
+
*/
|
|
849
|
+
_injectTenant(data, ctx) {
|
|
850
|
+
if (!this.tenantEnabled) return data;
|
|
851
|
+
const tenantValue = ctx?.[this.tenantField];
|
|
852
|
+
if (tenantValue != null && data[this.tenantField] == null) data[this.tenantField] = tenantValue;
|
|
853
|
+
return data;
|
|
854
|
+
}
|
|
855
|
+
async create(data, options) {
|
|
856
|
+
this._injectTenant(data, options);
|
|
857
|
+
return super.create(data, options);
|
|
858
|
+
}
|
|
859
|
+
async createMany(docs, options) {
|
|
860
|
+
const ctx = options;
|
|
861
|
+
for (const doc of docs) this._injectTenant(doc, ctx);
|
|
862
|
+
return super.createMany(docs, options);
|
|
863
|
+
}
|
|
864
|
+
/** Domain verb: cancel a voucher (status transition + event). */
|
|
865
|
+
async cancel(id, ctx) {
|
|
866
|
+
const voucher = await this.getById(id, {
|
|
867
|
+
throwOnNotFound: false,
|
|
868
|
+
lean: true,
|
|
869
|
+
...ctx
|
|
870
|
+
});
|
|
871
|
+
if (!voucher) throw new VoucherNotFoundError(id);
|
|
872
|
+
const updated = await this.update(id, { status: "cancelled" }, {
|
|
873
|
+
throwOnNotFound: true,
|
|
874
|
+
lean: true,
|
|
875
|
+
...ctx
|
|
876
|
+
});
|
|
877
|
+
if (!updated) throw new VoucherNotFoundError(id);
|
|
878
|
+
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.VOUCHER_CANCELLED, {
|
|
879
|
+
voucherId: id,
|
|
880
|
+
code: voucher.code,
|
|
881
|
+
status: "cancelled"
|
|
882
|
+
}, ctx), ctx);
|
|
883
|
+
return updated;
|
|
884
|
+
}
|
|
885
|
+
/** Atomic usage increment + push redemption record. */
|
|
886
|
+
async incrementUsage(id, redemption, ctx) {
|
|
887
|
+
const updated = await this.update(id, {
|
|
888
|
+
$inc: { usedCount: 1 },
|
|
889
|
+
$push: { redemptions: redemption }
|
|
890
|
+
}, {
|
|
891
|
+
throwOnNotFound: true,
|
|
892
|
+
lean: true,
|
|
893
|
+
...ctx
|
|
894
|
+
});
|
|
895
|
+
if (!updated) throw new VoucherNotFoundError(id);
|
|
896
|
+
return updated;
|
|
897
|
+
}
|
|
898
|
+
/** Atomic balance delta + push ledger entry. */
|
|
899
|
+
async addLedgerEntry(id, entry, balanceDelta, ctx) {
|
|
900
|
+
const updated = await this.update(id, {
|
|
901
|
+
$inc: { currentBalance: balanceDelta },
|
|
902
|
+
$push: { balanceLedger: entry }
|
|
903
|
+
}, {
|
|
904
|
+
throwOnNotFound: true,
|
|
905
|
+
lean: true,
|
|
906
|
+
...ctx
|
|
907
|
+
});
|
|
908
|
+
if (!updated) throw new VoucherNotFoundError(id);
|
|
909
|
+
return updated;
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* Batch expire by date.
|
|
913
|
+
*
|
|
914
|
+
* Routes through `findAll` + per-doc `update` instead of a raw
|
|
915
|
+
* `this.Model.updateMany`. Rationale:
|
|
916
|
+
* 1. `findAll` casts the filter through the schema (so an ObjectId
|
|
917
|
+
* tenant field on a write-time String context doesn't silently
|
|
918
|
+
* return 0 rows the way a raw `$match` would).
|
|
919
|
+
* 2. `update` fires the full hook pipeline (multi-tenant, audit,
|
|
920
|
+
* soft-delete) on every expiration — a bulk `updateMany` at the
|
|
921
|
+
* Model layer bypasses all of them.
|
|
922
|
+
* Volume is bounded per tenant (active + past-expiry vouchers), so an
|
|
923
|
+
* N+1 loop is acceptable for correctness.
|
|
924
|
+
*/
|
|
925
|
+
async expireByDate(before, ctx) {
|
|
926
|
+
const filter = {
|
|
927
|
+
status: "active",
|
|
928
|
+
expiresAt: { $lte: before }
|
|
929
|
+
};
|
|
930
|
+
const docs = await this.findAll(filter, {
|
|
931
|
+
lean: true,
|
|
932
|
+
...ctx
|
|
933
|
+
});
|
|
934
|
+
let modified = 0;
|
|
935
|
+
for (const doc of docs) if (await this.update(doc._id, { status: "expired" }, {
|
|
936
|
+
throwOnNotFound: false,
|
|
937
|
+
lean: true,
|
|
938
|
+
...ctx
|
|
939
|
+
})) modified++;
|
|
940
|
+
return modified;
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Lookup voucher by unique code, scoped to the tenant in `ctx` when
|
|
944
|
+
* tenant scoping is configured.
|
|
945
|
+
*
|
|
946
|
+
* Manual tenant injection mirrors `expireByDate` and `ProgramRepository.findActive`
|
|
947
|
+
* — it makes the method work whether the host opts into the auto-wired
|
|
948
|
+
* `multiTenantPlugin` or enforces scoping at its own framework layer
|
|
949
|
+
* (e.g. arc's preset + `BaseController`). Without this, a host that
|
|
950
|
+
* runs the plugin off would leak vouchers across branches at
|
|
951
|
+
* validate/redeem/spend/topUp call sites.
|
|
952
|
+
*
|
|
953
|
+
* When the engine is configured with `tenant: false`, the filter is
|
|
954
|
+
* code-only — vouchers are company-wide and the docs carry no
|
|
955
|
+
* `organizationId`, so injecting one would always miss.
|
|
956
|
+
*/
|
|
957
|
+
async getByCode(code, ctx) {
|
|
958
|
+
const filter = { code: code.toUpperCase() };
|
|
959
|
+
if (this.tenantEnabled) {
|
|
960
|
+
const tenantValue = ctx?.[this.tenantField];
|
|
961
|
+
if (tenantValue != null) filter[this.tenantField] = tenantValue;
|
|
962
|
+
}
|
|
963
|
+
return this.getByQuery(filter, {
|
|
964
|
+
throwOnNotFound: false,
|
|
965
|
+
lean: true,
|
|
966
|
+
...ctx
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* Idempotency check on nested arrays. Routes through mongokit's `count`
|
|
971
|
+
* so `multiTenantPlugin` injects the configured tenant field from
|
|
972
|
+
* `ctx` — a non-tenant-scoped check would cross organization boundaries.
|
|
973
|
+
*/
|
|
974
|
+
async hasIdempotencyKey(id, key, ctx) {
|
|
975
|
+
const filter = {
|
|
976
|
+
_id: id,
|
|
977
|
+
$or: [{ "redemptions.idempotencyKey": key }, { "balanceLedger.idempotencyKey": key }]
|
|
978
|
+
};
|
|
979
|
+
return await this.count(filter, ctx) > 0;
|
|
980
|
+
}
|
|
981
|
+
};
|
|
982
|
+
//#endregion
|
|
983
|
+
//#region src/repositories/create-repositories.ts
|
|
984
|
+
function createRepositories(models, plugins, tenant, dispatchDeps = {}) {
|
|
985
|
+
const tenantPlugins = tenant?.enabled ? [multiTenantPlugin({
|
|
986
|
+
tenantField: tenant.tenantField,
|
|
987
|
+
contextKey: tenant.contextKey,
|
|
988
|
+
fieldType: tenant.fieldType
|
|
989
|
+
})] : [];
|
|
990
|
+
return {
|
|
991
|
+
program: new ProgramRepository(models.Program, [...tenantPlugins, ...plugins?.program ?? []], dispatchDeps),
|
|
992
|
+
rule: new RuleRepository(models.Rule, [...tenantPlugins, ...plugins?.rule ?? []]),
|
|
993
|
+
reward: new RewardRepository(models.Reward, [...tenantPlugins, ...plugins?.reward ?? []]),
|
|
994
|
+
voucher: new VoucherRepository(models.Voucher, [...tenantPlugins, ...plugins?.voucher ?? []], dispatchDeps, tenant?.tenantField, tenant?.enabled ?? false),
|
|
995
|
+
pendingEvaluation: new PendingEvaluationRepository(models.PendingEvaluation, [...tenantPlugins, ...plugins?.pendingEvaluation ?? []], tenant)
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
//#endregion
|
|
999
|
+
//#region src/adapters/mongo-evaluation-store.ts
|
|
1000
|
+
/**
|
|
1001
|
+
* Default Mongo-backed implementation of {@link EvaluationStore}. Translates
|
|
1002
|
+
* between the domain snapshot shape and the persistence document shape,
|
|
1003
|
+
* forwards atomic `take`/`delete` semantics to the repository, and
|
|
1004
|
+
* applies the engine's configured tenant field on writes (the repo
|
|
1005
|
+
* applies it on reads).
|
|
1006
|
+
*
|
|
1007
|
+
* Hosts that prefer a different backend (Redis, DynamoDB, in-memory for
|
|
1008
|
+
* tests) can implement `EvaluationStore` directly and pass it via engine
|
|
1009
|
+
* config — this class isn't load-bearing.
|
|
1010
|
+
*/
|
|
1011
|
+
var MongoEvaluationStore = class {
|
|
1012
|
+
constructor(repo) {
|
|
1013
|
+
this.repo = repo;
|
|
1014
|
+
}
|
|
1015
|
+
async put(id, snapshot, ttlSeconds, ctx) {
|
|
1016
|
+
const expiresAt = new Date(Date.now() + ttlSeconds * 1e3);
|
|
1017
|
+
const session = ctx?.session;
|
|
1018
|
+
const tenantField = this.repo.tenantField;
|
|
1019
|
+
const tenantValue = ctx?.tenantValue ?? snapshot.ctx.organizationId;
|
|
1020
|
+
const filter = { evaluationId: id };
|
|
1021
|
+
if (tenantField && tenantValue !== void 0) filter[tenantField] = tenantValue;
|
|
1022
|
+
const update = {
|
|
1023
|
+
evaluationId: id,
|
|
1024
|
+
result: snapshot.result,
|
|
1025
|
+
ctx: snapshot.ctx,
|
|
1026
|
+
customerId: snapshot.customerId ?? null,
|
|
1027
|
+
programUsages: snapshot.programUsages,
|
|
1028
|
+
voucherUsages: snapshot.voucherUsages,
|
|
1029
|
+
cartHash: snapshot.cartHash,
|
|
1030
|
+
expiresAt
|
|
1031
|
+
};
|
|
1032
|
+
if (tenantField && tenantValue !== void 0) update[tenantField] = tenantValue;
|
|
1033
|
+
await this.repo.Model.updateOne(filter, { $set: update }, {
|
|
1034
|
+
upsert: true,
|
|
1035
|
+
session
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
async take(id, ctx) {
|
|
1039
|
+
const doc = await this.repo.takeByEvaluationId(id, {
|
|
1040
|
+
tenantValue: ctx?.tenantValue,
|
|
1041
|
+
session: ctx?.session
|
|
1042
|
+
});
|
|
1043
|
+
if (!doc) return null;
|
|
1044
|
+
return {
|
|
1045
|
+
result: doc.result,
|
|
1046
|
+
ctx: doc.ctx,
|
|
1047
|
+
customerId: doc.customerId ?? void 0,
|
|
1048
|
+
programUsages: doc.programUsages,
|
|
1049
|
+
voucherUsages: doc.voucherUsages,
|
|
1050
|
+
cartHash: doc.cartHash,
|
|
1051
|
+
createdAt: doc.createdAt
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
async delete(id, ctx) {
|
|
1055
|
+
await this.repo.deleteByEvaluationId(id, {
|
|
1056
|
+
tenantValue: ctx?.tenantValue,
|
|
1057
|
+
session: ctx?.session
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
};
|
|
1061
|
+
/**
|
|
1062
|
+
* In-memory fallback. Useful for unit tests, single-process dev servers,
|
|
1063
|
+
* and hosts that consciously opt out of persistence (knowing they lose
|
|
1064
|
+
* pending evaluations on restart). NOT recommended for production
|
|
1065
|
+
* topologies — see EvaluationStore docblock for why.
|
|
1066
|
+
*
|
|
1067
|
+
* Tenant scoping uses `ctx.tenantValue` as part of the in-memory key
|
|
1068
|
+
* (the field-name layer doesn't matter here — we just namespace by
|
|
1069
|
+
* value). No transaction semantics (in-memory has nothing to roll
|
|
1070
|
+
* back), no TTL refinement beyond initial expiry check.
|
|
1071
|
+
*/
|
|
1072
|
+
var InMemoryEvaluationStore = class {
|
|
1073
|
+
map = /* @__PURE__ */ new Map();
|
|
1074
|
+
async put(id, snapshot, ttlSeconds, ctx) {
|
|
1075
|
+
this.map.set(this.key(id, ctx), {
|
|
1076
|
+
snapshot,
|
|
1077
|
+
expiresAt: Date.now() + ttlSeconds * 1e3
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
async take(id, ctx) {
|
|
1081
|
+
const k = this.key(id, ctx);
|
|
1082
|
+
const entry = this.map.get(k);
|
|
1083
|
+
if (!entry) return null;
|
|
1084
|
+
this.map.delete(k);
|
|
1085
|
+
if (entry.expiresAt < Date.now()) return null;
|
|
1086
|
+
return entry.snapshot;
|
|
1087
|
+
}
|
|
1088
|
+
async delete(id, ctx) {
|
|
1089
|
+
this.map.delete(this.key(id, ctx));
|
|
1090
|
+
}
|
|
1091
|
+
key(id, ctx) {
|
|
1092
|
+
return ctx?.tenantValue ? `${ctx.tenantValue}:${id}` : id;
|
|
1093
|
+
}
|
|
1094
|
+
};
|
|
1095
|
+
//#endregion
|
|
1096
|
+
//#region src/utils/is-write-conflict.ts
|
|
1097
|
+
/**
|
|
1098
|
+
* Detect MongoDB's transient WriteConflict error across the shapes it
|
|
1099
|
+
* arrives in (raw driver error, mongoose-wrapped VersionError, mongokit
|
|
1100
|
+
* rethrows). Centralised so every contended write site (gift card spend,
|
|
1101
|
+
* gift card top-up, evaluation commit) can opt into uniform mapping
|
|
1102
|
+
* to the same typed `ConcurrencyConflictError` / `*UsageCapExceededError`
|
|
1103
|
+
* shape — hosts catch one type, decide retry-or-409 once.
|
|
1104
|
+
*
|
|
1105
|
+
* Why detection is a layered match: under heavy contention MongoDB
|
|
1106
|
+
* surfaces the same physical race in different ways depending on which
|
|
1107
|
+
* layer caught it first (driver vs mongoose vs mongokit), and each
|
|
1108
|
+
* wrapper preserves a slightly different subset of the original error
|
|
1109
|
+
* fields. Matching against ALL of them avoids false negatives that
|
|
1110
|
+
* would otherwise let the raw error escape to the host with no useful
|
|
1111
|
+
* type information.
|
|
1112
|
+
*/
|
|
1113
|
+
function isWriteConflict(err) {
|
|
1114
|
+
if (typeof err !== "object" || err === null) return false;
|
|
1115
|
+
const e = err;
|
|
1116
|
+
if (e.code === 112 || e.codeName === "WriteConflict") return true;
|
|
1117
|
+
if (e.name === "MongoServerError" && typeof e.message === "string" && /write conflict/i.test(e.message)) return true;
|
|
1118
|
+
if (Array.isArray(e.errorLabels) && e.errorLabels.includes("TransientTransactionError")) return true;
|
|
1119
|
+
if (typeof e.message === "string" && /write conflict/i.test(e.message)) return true;
|
|
1120
|
+
if (e.cause !== void 0) return isWriteConflict(e.cause);
|
|
1121
|
+
return false;
|
|
1122
|
+
}
|
|
1123
|
+
//#endregion
|
|
1124
|
+
//#region src/services/evaluation.service.ts
|
|
1125
|
+
/**
|
|
1126
|
+
* Default TTL for pending evaluation snapshots in the store. 30 minutes
|
|
1127
|
+
* comfortably exceeds any realistic evaluate→commit window (typical
|
|
1128
|
+
* checkout: seconds to a few minutes; long-tail: ~10 min) without
|
|
1129
|
+
* hoarding abandoned evaluations forever. The Mongo TTL index purges
|
|
1130
|
+
* expired entries; the engine itself never relies on the TTL for
|
|
1131
|
+
* correctness — `store.take()` is atomic and returns `null` when an
|
|
1132
|
+
* id was already consumed or never existed.
|
|
1133
|
+
*/
|
|
1134
|
+
const DEFAULT_PENDING_EVALUATION_TTL_SECONDS = 1800;
|
|
1135
|
+
/**
|
|
1136
|
+
* Deterministic, collision-resistant hash of the evaluated cart. The hash
|
|
1137
|
+
* covers everything the evaluation algorithm depends on: normalized line
|
|
1138
|
+
* items (sorted), subtotal, applied codes, and customer identity. Two
|
|
1139
|
+
* evaluations over an identical cart yield the same hash; any mutation —
|
|
1140
|
+
* quantity, unit price, added/removed line, different code, different
|
|
1141
|
+
* customer — yields a different hash.
|
|
1142
|
+
*/
|
|
1143
|
+
function computeCartHash(input) {
|
|
1144
|
+
const items = [...input.items].map((item) => ({
|
|
1145
|
+
productId: item.productId ?? "",
|
|
1146
|
+
sku: item.sku ?? "",
|
|
1147
|
+
categoryId: item.categoryId ?? "",
|
|
1148
|
+
quantity: item.quantity,
|
|
1149
|
+
unitPrice: item.unitPrice,
|
|
1150
|
+
lineTotal: item.lineTotal ?? item.quantity * item.unitPrice
|
|
1151
|
+
})).sort((a, b) => {
|
|
1152
|
+
if (a.productId !== b.productId) return a.productId < b.productId ? -1 : 1;
|
|
1153
|
+
if (a.sku !== b.sku) return a.sku < b.sku ? -1 : 1;
|
|
1154
|
+
if (a.unitPrice !== b.unitPrice) return a.unitPrice - b.unitPrice;
|
|
1155
|
+
return a.quantity - b.quantity;
|
|
1156
|
+
});
|
|
1157
|
+
const codes = [...input.codes ?? []].map((c) => c.toUpperCase()).sort();
|
|
1158
|
+
const tags = [...input.customerTags ?? []].sort();
|
|
1159
|
+
const canonical = JSON.stringify({
|
|
1160
|
+
items,
|
|
1161
|
+
subtotal: input.subtotal,
|
|
1162
|
+
codes,
|
|
1163
|
+
customerId: input.customerId ?? null,
|
|
1164
|
+
customerTags: tags
|
|
1165
|
+
});
|
|
1166
|
+
return createHash("sha256").update(canonical).digest("hex");
|
|
1167
|
+
}
|
|
1168
|
+
var EvaluationService = class {
|
|
1169
|
+
constructor(programRepo, ruleRepo, rewardRepo, voucherRepo, unitOfWork, dispatchDeps, config, store) {
|
|
1170
|
+
this.programRepo = programRepo;
|
|
1171
|
+
this.ruleRepo = ruleRepo;
|
|
1172
|
+
this.rewardRepo = rewardRepo;
|
|
1173
|
+
this.voucherRepo = voucherRepo;
|
|
1174
|
+
this.unitOfWork = unitOfWork;
|
|
1175
|
+
this.dispatchDeps = dispatchDeps;
|
|
1176
|
+
this.config = config;
|
|
1177
|
+
this.store = store;
|
|
1178
|
+
}
|
|
1179
|
+
async evaluate(input, ctx) {
|
|
1180
|
+
return this.doEvaluate(input, ctx, false);
|
|
1181
|
+
}
|
|
1182
|
+
async preview(input, ctx) {
|
|
1183
|
+
return this.doEvaluate(input, ctx, true);
|
|
1184
|
+
}
|
|
1185
|
+
async commit(evaluationId, orderId, ctx, options = {}) {
|
|
1186
|
+
let snapshotForCapMapping = null;
|
|
1187
|
+
try {
|
|
1188
|
+
return await this.unitOfWork.withTransaction(async (session) => {
|
|
1189
|
+
const stored = await this.store.take(evaluationId, {
|
|
1190
|
+
tenantValue: ctx.organizationId,
|
|
1191
|
+
session
|
|
1192
|
+
});
|
|
1193
|
+
if (!stored) throw new EvaluationNotFoundError(evaluationId);
|
|
1194
|
+
if (options.cartHash !== void 0 && options.cartHash !== stored.cartHash) throw new CartHashMismatchError(evaluationId);
|
|
1195
|
+
snapshotForCapMapping = stored;
|
|
1196
|
+
return await this.commitInTransaction(stored, evaluationId, orderId, session, ctx);
|
|
1197
|
+
});
|
|
1198
|
+
} catch (err) {
|
|
1199
|
+
if (isWriteConflict(err) && snapshotForCapMapping) {
|
|
1200
|
+
const firstUsage = snapshotForCapMapping.programUsages[0];
|
|
1201
|
+
if (firstUsage) {
|
|
1202
|
+
const program = await this.programRepo.getById(firstUsage.programId, {
|
|
1203
|
+
...ctx,
|
|
1204
|
+
lean: true,
|
|
1205
|
+
throwOnNotFound: false
|
|
1206
|
+
});
|
|
1207
|
+
throw new ProgramUsageCapExceededError(firstUsage.programId, program?.maxUsageTotal ?? 0);
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
throw err;
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
async commitInTransaction(stored, evaluationId, orderId, session, ctx) {
|
|
1214
|
+
for (const usage of stored.programUsages) {
|
|
1215
|
+
if (!await this.programRepo.tryIncrementUsage(usage.programId, {
|
|
1216
|
+
...ctx,
|
|
1217
|
+
session
|
|
1218
|
+
})) {
|
|
1219
|
+
const program = await this.programRepo.getById(usage.programId, {
|
|
1220
|
+
...ctx,
|
|
1221
|
+
session,
|
|
1222
|
+
lean: true,
|
|
1223
|
+
throwOnNotFound: false
|
|
1224
|
+
});
|
|
1225
|
+
throw new ProgramUsageCapExceededError(usage.programId, program?.maxUsageTotal ?? 0);
|
|
1226
|
+
}
|
|
1227
|
+
if (stored.customerId) await this.programRepo.incrementCustomerUsage(usage.programId, stored.customerId, {
|
|
1228
|
+
...ctx,
|
|
1229
|
+
session
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
for (const usage of stored.voucherUsages) await this.voucherRepo.incrementUsage(usage.voucherId, {
|
|
1233
|
+
orderId,
|
|
1234
|
+
discountAmount: usage.discountAmount,
|
|
1235
|
+
redeemedAt: /* @__PURE__ */ new Date(),
|
|
1236
|
+
...ctx.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {}
|
|
1237
|
+
}, {
|
|
1238
|
+
...ctx,
|
|
1239
|
+
session
|
|
1240
|
+
});
|
|
1241
|
+
const commitResult = {
|
|
1242
|
+
evaluationId,
|
|
1243
|
+
orderId,
|
|
1244
|
+
totalDiscount: stored.result.totalDiscount,
|
|
1245
|
+
programsCommitted: stored.programUsages.length,
|
|
1246
|
+
vouchersUsed: stored.voucherUsages.length
|
|
1247
|
+
};
|
|
1248
|
+
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.EVALUATION_COMMITTED, {
|
|
1249
|
+
evaluationId,
|
|
1250
|
+
orderId,
|
|
1251
|
+
totalDiscount: stored.result.totalDiscount
|
|
1252
|
+
}, ctx), {
|
|
1253
|
+
...ctx,
|
|
1254
|
+
session
|
|
1255
|
+
});
|
|
1256
|
+
return commitResult;
|
|
1257
|
+
}
|
|
1258
|
+
async rollback(evaluationId, ctx) {
|
|
1259
|
+
await this.store.delete(evaluationId, { tenantValue: ctx.organizationId });
|
|
1260
|
+
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.EVALUATION_ROLLED_BACK, { evaluationId }, ctx), ctx);
|
|
1261
|
+
}
|
|
1262
|
+
async doEvaluate(input, ctx, isPreview) {
|
|
1263
|
+
const submittedCodes = (input.codes ?? []).map((c) => c.toUpperCase());
|
|
1264
|
+
const programs = await this.programRepo.findActive(void 0, ctx);
|
|
1265
|
+
const programIds = programs.map((p) => p._id);
|
|
1266
|
+
const [allRules, allRewards] = await Promise.all([this.ruleRepo.findAll({ programId: { $in: programIds } }, ctx), this.rewardRepo.findAll({ programId: { $in: programIds } }, ctx)]);
|
|
1267
|
+
const rulesByProgram = groupBy(allRules, (r) => r.programId);
|
|
1268
|
+
const rewardsByProgram = groupBy(allRewards, (r) => r.programId);
|
|
1269
|
+
const voucherMap = /* @__PURE__ */ new Map();
|
|
1270
|
+
for (const code of submittedCodes) {
|
|
1271
|
+
const voucher = await this.voucherRepo.getByCode(code, ctx);
|
|
1272
|
+
if (voucher && voucher.status === "active" && voucher.usedCount < voucher.usageLimit) voucherMap.set(code, {
|
|
1273
|
+
voucherId: voucher._id,
|
|
1274
|
+
programId: voucher.programId,
|
|
1275
|
+
balance: voucher.currentBalance
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
const appliedDiscounts = [];
|
|
1279
|
+
const freeProducts = [];
|
|
1280
|
+
const appliedCodes = [];
|
|
1281
|
+
const rejectedCodes = [];
|
|
1282
|
+
const warnings = [];
|
|
1283
|
+
const programsApplied = [];
|
|
1284
|
+
const programUsages = [];
|
|
1285
|
+
const voucherUsages = [];
|
|
1286
|
+
let runningSubtotal = input.subtotal;
|
|
1287
|
+
let exclusiveApplied = false;
|
|
1288
|
+
let stackableCount = 0;
|
|
1289
|
+
for (const program of programs) {
|
|
1290
|
+
if (program.maxUsageTotal && program.usedCount >= program.maxUsageTotal) continue;
|
|
1291
|
+
if (!this.isCustomerEligible(program, input)) continue;
|
|
1292
|
+
if (program.maxUsagePerCustomer && input.customerId) {
|
|
1293
|
+
if (await this.programRepo.getCustomerUsage(program._id, input.customerId, ctx) >= program.maxUsagePerCustomer) {
|
|
1294
|
+
warnings.push(`Customer has reached max usage (${program.maxUsagePerCustomer}) for "${program.name}"`);
|
|
1295
|
+
continue;
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
if (program.stackingMode === "exclusive" && exclusiveApplied) continue;
|
|
1299
|
+
if (program.stackingMode === "stackable" && stackableCount >= this.config.evaluation.maxStackablePromotions) {
|
|
1300
|
+
warnings.push(`Max stackable promotions (${this.config.evaluation.maxStackablePromotions}) reached`);
|
|
1301
|
+
continue;
|
|
1302
|
+
}
|
|
1303
|
+
const rules = rulesByProgram.get(String(program._id)) ?? [];
|
|
1304
|
+
const rewards = rewardsByProgram.get(String(program._id)) ?? [];
|
|
1305
|
+
if (rules.length === 0 || rewards.length === 0) continue;
|
|
1306
|
+
const matchResult = this.matchBestRule(program, rules, input.items, input.subtotal, submittedCodes, voucherMap);
|
|
1307
|
+
if (!matchResult.matched) {
|
|
1308
|
+
if (matchResult.rejectedCode) rejectedCodes.push(matchResult.rejectedCode);
|
|
1309
|
+
continue;
|
|
1310
|
+
}
|
|
1311
|
+
const applicableRewards = this.filterRewardsByRule(rewards, matchResult.matchedRuleId);
|
|
1312
|
+
for (const reward of applicableRewards) if (reward.rewardType === "discount") {
|
|
1313
|
+
const discount = this.computeDiscount(reward, input.items, runningSubtotal);
|
|
1314
|
+
if (discount <= 0) continue;
|
|
1315
|
+
appliedDiscounts.push({
|
|
1316
|
+
programId: program._id,
|
|
1317
|
+
programName: program.name,
|
|
1318
|
+
rewardId: reward._id,
|
|
1319
|
+
type: reward.discountMode ?? "fixed",
|
|
1320
|
+
scope: reward.discountScope,
|
|
1321
|
+
amount: discount,
|
|
1322
|
+
description: this.describeDiscount(reward, program),
|
|
1323
|
+
voucherCode: matchResult.matchedCode
|
|
1324
|
+
});
|
|
1325
|
+
runningSubtotal = Math.max(0, runningSubtotal - discount);
|
|
1326
|
+
} else if (reward.rewardType === "free_product") freeProducts.push({
|
|
1327
|
+
programId: program._id,
|
|
1328
|
+
programName: program.name,
|
|
1329
|
+
rewardId: reward._id,
|
|
1330
|
+
productId: reward.freeProductId,
|
|
1331
|
+
productSku: reward.freeProductSku,
|
|
1332
|
+
quantity: reward.freeQuantity,
|
|
1333
|
+
description: `Free product from "${program.name}"`
|
|
1334
|
+
});
|
|
1335
|
+
programsApplied.push(program._id);
|
|
1336
|
+
programUsages.push({ programId: program._id });
|
|
1337
|
+
if (matchResult.matchedCode) {
|
|
1338
|
+
appliedCodes.push(matchResult.matchedCode);
|
|
1339
|
+
const voucherInfo = voucherMap.get(matchResult.matchedCode);
|
|
1340
|
+
if (voucherInfo) {
|
|
1341
|
+
const totalProgramDiscount = appliedDiscounts.filter((d) => d.programId === program._id).reduce((sum, d) => sum + d.amount, 0);
|
|
1342
|
+
voucherUsages.push({
|
|
1343
|
+
voucherId: voucherInfo.voucherId,
|
|
1344
|
+
code: matchResult.matchedCode,
|
|
1345
|
+
discountAmount: totalProgramDiscount
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
if (program.stackingMode === "exclusive") exclusiveApplied = true;
|
|
1350
|
+
else stackableCount++;
|
|
1351
|
+
}
|
|
1352
|
+
for (const code of submittedCodes) if (!appliedCodes.includes(code) && !rejectedCodes.some((r) => r.code === code)) rejectedCodes.push({
|
|
1353
|
+
code,
|
|
1354
|
+
reason: "No matching active program found for this code"
|
|
1355
|
+
});
|
|
1356
|
+
const totalDiscount = appliedDiscounts.reduce((sum, d) => sum + d.amount, 0);
|
|
1357
|
+
const evaluationId = randomBytes(16).toString("hex");
|
|
1358
|
+
const cartHash = computeCartHash(input);
|
|
1359
|
+
const result = {
|
|
1360
|
+
evaluationId,
|
|
1361
|
+
cartHash,
|
|
1362
|
+
appliedDiscounts,
|
|
1363
|
+
freeProducts,
|
|
1364
|
+
totalDiscount,
|
|
1365
|
+
subtotalAfterDiscount: Math.max(0, input.subtotal - totalDiscount),
|
|
1366
|
+
appliedCodes,
|
|
1367
|
+
rejectedCodes,
|
|
1368
|
+
warnings,
|
|
1369
|
+
isPreview,
|
|
1370
|
+
programsApplied
|
|
1371
|
+
};
|
|
1372
|
+
if (!isPreview) {
|
|
1373
|
+
const snapshot = {
|
|
1374
|
+
result,
|
|
1375
|
+
ctx,
|
|
1376
|
+
customerId: input.customerId,
|
|
1377
|
+
programUsages,
|
|
1378
|
+
voucherUsages,
|
|
1379
|
+
cartHash,
|
|
1380
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
1381
|
+
};
|
|
1382
|
+
await this.store.put(evaluationId, snapshot, DEFAULT_PENDING_EVALUATION_TTL_SECONDS, { tenantValue: ctx.organizationId });
|
|
1383
|
+
}
|
|
1384
|
+
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.EVALUATION_COMPLETED, {
|
|
1385
|
+
evaluationId,
|
|
1386
|
+
totalDiscount,
|
|
1387
|
+
programsApplied: programsApplied.length,
|
|
1388
|
+
codesUsed: appliedCodes,
|
|
1389
|
+
isPreview
|
|
1390
|
+
}, ctx), ctx);
|
|
1391
|
+
return result;
|
|
1392
|
+
}
|
|
1393
|
+
isCustomerEligible(program, input) {
|
|
1394
|
+
if (program.applicableCustomerIds.length > 0) {
|
|
1395
|
+
if (!input.customerId) return false;
|
|
1396
|
+
if (!program.applicableCustomerIds.includes(input.customerId)) return false;
|
|
1397
|
+
}
|
|
1398
|
+
if (program.applicableCustomerTags.length > 0) {
|
|
1399
|
+
const customerTags = input.customerTags ?? [];
|
|
1400
|
+
if (customerTags.length === 0) return false;
|
|
1401
|
+
if (!program.applicableCustomerTags.some((tag) => customerTags.includes(tag))) return false;
|
|
1402
|
+
}
|
|
1403
|
+
return true;
|
|
1404
|
+
}
|
|
1405
|
+
matchBestRule(program, rules, items, subtotal, submittedCodes, voucherMap) {
|
|
1406
|
+
let bestMatch = { matched: false };
|
|
1407
|
+
let bestThreshold = -1;
|
|
1408
|
+
let lastRejected;
|
|
1409
|
+
for (const rule of rules) {
|
|
1410
|
+
const singleResult = this.matchSingleRule(program, rule, items, subtotal, submittedCodes, voucherMap);
|
|
1411
|
+
if (singleResult.rejectedCode) lastRejected = singleResult.rejectedCode;
|
|
1412
|
+
if (!singleResult.matched) continue;
|
|
1413
|
+
const threshold = (rule.minimumAmount ?? 0) * 1e3 + (rule.minimumQuantity ?? 0);
|
|
1414
|
+
if (threshold > bestThreshold) {
|
|
1415
|
+
bestThreshold = threshold;
|
|
1416
|
+
bestMatch = singleResult;
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
if (!bestMatch.matched && lastRejected) bestMatch.rejectedCode = lastRejected;
|
|
1420
|
+
return bestMatch;
|
|
1421
|
+
}
|
|
1422
|
+
matchSingleRule(program, rule, items, subtotal, submittedCodes, voucherMap) {
|
|
1423
|
+
if (program.triggerMode === "code") {
|
|
1424
|
+
if (rule.code) {
|
|
1425
|
+
if (!submittedCodes.includes(rule.code)) return { matched: false };
|
|
1426
|
+
if (program.programType === "coupon" || program.programType === "discount_code") {
|
|
1427
|
+
const voucherInfo = voucherMap.get(rule.code);
|
|
1428
|
+
if (!voucherInfo || String(voucherInfo.programId) !== String(program._id)) return {
|
|
1429
|
+
matched: false,
|
|
1430
|
+
rejectedCode: {
|
|
1431
|
+
code: rule.code,
|
|
1432
|
+
reason: "Invalid or exhausted voucher"
|
|
1433
|
+
}
|
|
1434
|
+
};
|
|
1435
|
+
}
|
|
1436
|
+
} else if (![...voucherMap.entries()].find(([code, info]) => String(info.programId) === String(program._id) && submittedCodes.includes(code))) return { matched: false };
|
|
1437
|
+
}
|
|
1438
|
+
const now = /* @__PURE__ */ new Date();
|
|
1439
|
+
if (rule.startsAt && rule.startsAt > now) return { matched: false };
|
|
1440
|
+
if (rule.endsAt && rule.endsAt < now) return { matched: false };
|
|
1441
|
+
if (rule.minimumAmount > 0 && subtotal < rule.minimumAmount) return { matched: false };
|
|
1442
|
+
if (rule.minimumQuantity > 0) {
|
|
1443
|
+
if (items.reduce((sum, item) => sum + item.quantity, 0) < rule.minimumQuantity) return { matched: false };
|
|
1444
|
+
}
|
|
1445
|
+
if (rule.applicableProductIds.length > 0) {
|
|
1446
|
+
if (!items.some((item) => rule.applicableProductIds.includes(item.productId))) return { matched: false };
|
|
1447
|
+
}
|
|
1448
|
+
if (rule.applicableCategories.length > 0) {
|
|
1449
|
+
if (!items.some((item) => item.categoryId && rule.applicableCategories.includes(item.categoryId))) return { matched: false };
|
|
1450
|
+
}
|
|
1451
|
+
if (rule.applicableSkus.length > 0) {
|
|
1452
|
+
if (!items.some((item) => item.sku && rule.applicableSkus.includes(item.sku))) return { matched: false };
|
|
1453
|
+
}
|
|
1454
|
+
if (rule.buyQuantity && rule.buyQuantity > 0) {
|
|
1455
|
+
if (items.filter((item) => rule.applicableProductIds.length === 0 || rule.applicableProductIds.includes(item.productId)).reduce((sum, item) => sum + item.quantity, 0) < rule.buyQuantity) return { matched: false };
|
|
1456
|
+
}
|
|
1457
|
+
let matchedCode;
|
|
1458
|
+
if (program.triggerMode === "code") if (rule.code && submittedCodes.includes(rule.code)) matchedCode = rule.code;
|
|
1459
|
+
else matchedCode = [...voucherMap.entries()].find(([code, info]) => String(info.programId) === String(program._id) && submittedCodes.includes(code))?.[0];
|
|
1460
|
+
return {
|
|
1461
|
+
matched: true,
|
|
1462
|
+
matchedCode,
|
|
1463
|
+
matchedRuleId: rule._id
|
|
1464
|
+
};
|
|
1465
|
+
}
|
|
1466
|
+
filterRewardsByRule(rewards, matchedRuleId) {
|
|
1467
|
+
if (!matchedRuleId) return rewards;
|
|
1468
|
+
if (rewards.filter((r) => r.ruleId).length === 0) return rewards;
|
|
1469
|
+
return rewards.filter((r) => !r.ruleId || String(r.ruleId) === String(matchedRuleId));
|
|
1470
|
+
}
|
|
1471
|
+
computeDiscount(reward, items, runningSubtotal) {
|
|
1472
|
+
if (!reward.discountAmount || reward.discountAmount <= 0) return 0;
|
|
1473
|
+
let discount;
|
|
1474
|
+
if (reward.discountScope === "cheapest") {
|
|
1475
|
+
const eligibleItems = this.getEligibleItems(reward, items);
|
|
1476
|
+
if (eligibleItems.length === 0) return 0;
|
|
1477
|
+
const cheapest = Math.min(...eligibleItems.map((item) => item.unitPrice));
|
|
1478
|
+
discount = reward.discountMode === "percentage" ? cheapest * (reward.discountAmount / 100) : Math.min(reward.discountAmount, cheapest);
|
|
1479
|
+
} else if (reward.discountScope === "specific_products") {
|
|
1480
|
+
const eligibleTotal = this.getEligibleItems(reward, items).reduce((sum, item) => sum + (item.lineTotal ?? item.unitPrice * item.quantity), 0);
|
|
1481
|
+
if (eligibleTotal <= 0) return 0;
|
|
1482
|
+
discount = reward.discountMode === "percentage" ? eligibleTotal * (reward.discountAmount / 100) : Math.min(reward.discountAmount, eligibleTotal);
|
|
1483
|
+
} else discount = reward.discountMode === "percentage" ? runningSubtotal * (reward.discountAmount / 100) : Math.min(reward.discountAmount, runningSubtotal);
|
|
1484
|
+
if (reward.maxDiscountAmount && discount > reward.maxDiscountAmount) discount = reward.maxDiscountAmount;
|
|
1485
|
+
return Math.min(Math.round(discount * 100) / 100, runningSubtotal);
|
|
1486
|
+
}
|
|
1487
|
+
getEligibleItems(reward, items) {
|
|
1488
|
+
if (reward.applicableProductIds.length === 0) return items;
|
|
1489
|
+
return items.filter((item) => reward.applicableProductIds.includes(item.productId));
|
|
1490
|
+
}
|
|
1491
|
+
describeDiscount(reward, program) {
|
|
1492
|
+
if (reward.discountMode === "percentage") return `${reward.discountAmount}% off from "${program.name}"`;
|
|
1493
|
+
return `${reward.discountAmount} off from "${program.name}"`;
|
|
1494
|
+
}
|
|
1495
|
+
};
|
|
1496
|
+
function groupBy(items, keyFn) {
|
|
1497
|
+
const map = /* @__PURE__ */ new Map();
|
|
1498
|
+
for (const item of items) {
|
|
1499
|
+
const key = String(keyFn(item));
|
|
1500
|
+
const group = map.get(key);
|
|
1501
|
+
if (group) group.push(item);
|
|
1502
|
+
else map.set(key, [item]);
|
|
1503
|
+
}
|
|
1504
|
+
return map;
|
|
1505
|
+
}
|
|
1506
|
+
//#endregion
|
|
1507
|
+
//#region src/utils/code-generator.ts
|
|
1508
|
+
const CHARSET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
1509
|
+
function generateCode(length, prefix = "") {
|
|
1510
|
+
const bytes = randomBytes(length);
|
|
1511
|
+
let code = "";
|
|
1512
|
+
for (let i = 0; i < length; i++) code += CHARSET[bytes[i] % 32];
|
|
1513
|
+
return prefix ? `${prefix}${code}` : code;
|
|
1514
|
+
}
|
|
1515
|
+
function generateCodes(count, length, prefix = "") {
|
|
1516
|
+
const codes = /* @__PURE__ */ new Set();
|
|
1517
|
+
let attempts = 0;
|
|
1518
|
+
const maxAttempts = count * 10;
|
|
1519
|
+
while (codes.size < count && attempts < maxAttempts) {
|
|
1520
|
+
codes.add(generateCode(length, prefix));
|
|
1521
|
+
attempts++;
|
|
1522
|
+
}
|
|
1523
|
+
return [...codes];
|
|
1524
|
+
}
|
|
1525
|
+
//#endregion
|
|
1526
|
+
//#region src/services/voucher.service.ts
|
|
1527
|
+
/**
|
|
1528
|
+
* VoucherService — multi-repo domain orchestration only.
|
|
1529
|
+
*
|
|
1530
|
+
* Code generation, redemption (transactional + idempotency), gift card
|
|
1531
|
+
* spend/topUp, and validation live here because they need programRepo +
|
|
1532
|
+
* voucherRepo + config + transactions.
|
|
1533
|
+
*
|
|
1534
|
+
* Simple lookups (getById, getByCode, getAll) and the cancel domain verb
|
|
1535
|
+
* live on VoucherRepository directly — callers use the repo. §2/§3.
|
|
1536
|
+
*/
|
|
1537
|
+
var VoucherService = class {
|
|
1538
|
+
constructor(voucherRepo, programRepo, unitOfWork, dispatchDeps, config) {
|
|
1539
|
+
this.voucherRepo = voucherRepo;
|
|
1540
|
+
this.programRepo = programRepo;
|
|
1541
|
+
this.unitOfWork = unitOfWork;
|
|
1542
|
+
this.dispatchDeps = dispatchDeps;
|
|
1543
|
+
this.config = config;
|
|
1544
|
+
}
|
|
1545
|
+
async generateCodes(input, ctx) {
|
|
1546
|
+
const program = await this.programRepo.getById(input.programId, {
|
|
1547
|
+
throwOnNotFound: false,
|
|
1548
|
+
lean: true,
|
|
1549
|
+
...ctx
|
|
1550
|
+
});
|
|
1551
|
+
if (!program) throw new ProgramNotFoundError(input.programId);
|
|
1552
|
+
const { codeLength, codePrefix } = this.config.voucher;
|
|
1553
|
+
const codes = generateCodes(input.count, codeLength, codePrefix);
|
|
1554
|
+
const expiresAt = input.expiresAt ?? (this.config.voucher.defaultExpiryDays ? new Date(Date.now() + this.config.voucher.defaultExpiryDays * 864e5) : void 0);
|
|
1555
|
+
const isGiftCard = program.programType === "gift_card";
|
|
1556
|
+
const data = codes.map((code) => ({
|
|
1557
|
+
programId: input.programId,
|
|
1558
|
+
code,
|
|
1559
|
+
status: "active",
|
|
1560
|
+
customerId: input.customerId,
|
|
1561
|
+
usageLimit: isGiftCard ? 999999 : 1,
|
|
1562
|
+
usedCount: 0,
|
|
1563
|
+
...isGiftCard ? {
|
|
1564
|
+
initialBalance: 0,
|
|
1565
|
+
currentBalance: 0,
|
|
1566
|
+
balanceLedger: []
|
|
1567
|
+
} : {},
|
|
1568
|
+
...expiresAt ? { expiresAt } : {},
|
|
1569
|
+
redemptions: [],
|
|
1570
|
+
...input.metadata ? { metadata: input.metadata } : {}
|
|
1571
|
+
}));
|
|
1572
|
+
const vouchers = await this.voucherRepo.createMany(data, ctx);
|
|
1573
|
+
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.VOUCHER_GENERATED, {
|
|
1574
|
+
programId: input.programId,
|
|
1575
|
+
voucherIds: vouchers.map((v) => v._id),
|
|
1576
|
+
codes: vouchers.map((v) => v.code),
|
|
1577
|
+
count: vouchers.length,
|
|
1578
|
+
actorId: ctx.actorId
|
|
1579
|
+
}, ctx), ctx);
|
|
1580
|
+
return vouchers;
|
|
1581
|
+
}
|
|
1582
|
+
async generateSingleCode(input, ctx) {
|
|
1583
|
+
const program = await this.programRepo.getById(input.programId, {
|
|
1584
|
+
throwOnNotFound: false,
|
|
1585
|
+
lean: true,
|
|
1586
|
+
...ctx
|
|
1587
|
+
});
|
|
1588
|
+
if (!program) throw new ProgramNotFoundError(input.programId);
|
|
1589
|
+
const code = input.code?.toUpperCase() ?? generateCode(this.config.voucher.codeLength, this.config.voucher.codePrefix);
|
|
1590
|
+
const isGiftCard = program.programType === "gift_card";
|
|
1591
|
+
const balance = input.initialBalance ?? 0;
|
|
1592
|
+
const expiresAt = input.expiresAt ?? (this.config.voucher.defaultExpiryDays ? new Date(Date.now() + this.config.voucher.defaultExpiryDays * 864e5) : void 0);
|
|
1593
|
+
const data = {
|
|
1594
|
+
programId: input.programId,
|
|
1595
|
+
code,
|
|
1596
|
+
status: "active",
|
|
1597
|
+
customerId: input.customerId,
|
|
1598
|
+
usageLimit: isGiftCard ? 999999 : 1,
|
|
1599
|
+
usedCount: 0,
|
|
1600
|
+
...isGiftCard ? {
|
|
1601
|
+
initialBalance: balance,
|
|
1602
|
+
currentBalance: balance,
|
|
1603
|
+
balanceLedger: []
|
|
1604
|
+
} : {},
|
|
1605
|
+
...expiresAt ? { expiresAt } : {},
|
|
1606
|
+
redemptions: [],
|
|
1607
|
+
...input.metadata ? { metadata: input.metadata } : {}
|
|
1608
|
+
};
|
|
1609
|
+
const voucher = await this.voucherRepo.create(data, ctx);
|
|
1610
|
+
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.VOUCHER_GENERATED, {
|
|
1611
|
+
programId: input.programId,
|
|
1612
|
+
voucherIds: [voucher._id],
|
|
1613
|
+
codes: [voucher.code],
|
|
1614
|
+
count: 1,
|
|
1615
|
+
actorId: ctx.actorId
|
|
1616
|
+
}, ctx), ctx);
|
|
1617
|
+
return voucher;
|
|
1618
|
+
}
|
|
1619
|
+
async validateCode(code, ctx) {
|
|
1620
|
+
const voucher = await this.voucherRepo.getByCode(code, ctx);
|
|
1621
|
+
if (!voucher) return {
|
|
1622
|
+
valid: false,
|
|
1623
|
+
error: "Voucher not found"
|
|
1624
|
+
};
|
|
1625
|
+
if (voucher.status === "expired") return {
|
|
1626
|
+
valid: false,
|
|
1627
|
+
error: "Voucher has expired"
|
|
1628
|
+
};
|
|
1629
|
+
if (voucher.status === "cancelled") return {
|
|
1630
|
+
valid: false,
|
|
1631
|
+
error: "Voucher has been cancelled"
|
|
1632
|
+
};
|
|
1633
|
+
if (voucher.status === "used") return {
|
|
1634
|
+
valid: false,
|
|
1635
|
+
error: "Voucher has already been used"
|
|
1636
|
+
};
|
|
1637
|
+
if (voucher.expiresAt && voucher.expiresAt < /* @__PURE__ */ new Date()) return {
|
|
1638
|
+
valid: false,
|
|
1639
|
+
error: "Voucher has expired"
|
|
1640
|
+
};
|
|
1641
|
+
if (voucher.usedCount >= voucher.usageLimit) return {
|
|
1642
|
+
valid: false,
|
|
1643
|
+
error: "Voucher has reached its usage limit"
|
|
1644
|
+
};
|
|
1645
|
+
const program = await this.programRepo.getById(voucher.programId, {
|
|
1646
|
+
throwOnNotFound: false,
|
|
1647
|
+
lean: true,
|
|
1648
|
+
...ctx
|
|
1649
|
+
});
|
|
1650
|
+
return {
|
|
1651
|
+
valid: true,
|
|
1652
|
+
voucher: {
|
|
1653
|
+
code: voucher.code,
|
|
1654
|
+
programId: voucher.programId,
|
|
1655
|
+
programType: program?.programType ?? "unknown",
|
|
1656
|
+
status: voucher.status,
|
|
1657
|
+
remainingUses: voucher.usageLimit - voucher.usedCount,
|
|
1658
|
+
...voucher.currentBalance !== void 0 ? { currentBalance: voucher.currentBalance } : {},
|
|
1659
|
+
...voucher.expiresAt ? { expiresAt: voucher.expiresAt } : {}
|
|
1660
|
+
}
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
async redeem(input, ctx) {
|
|
1664
|
+
return this.unitOfWork.withTransaction(async (session) => {
|
|
1665
|
+
const voucher = await this.voucherRepo.getByCode(input.code, {
|
|
1666
|
+
...ctx,
|
|
1667
|
+
session
|
|
1668
|
+
});
|
|
1669
|
+
if (!voucher) throw new VoucherNotFoundError(input.code);
|
|
1670
|
+
this.assertVoucherUsable(voucher);
|
|
1671
|
+
if (input.idempotencyKey) {
|
|
1672
|
+
if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey, {
|
|
1673
|
+
...ctx,
|
|
1674
|
+
session
|
|
1675
|
+
})) throw new DuplicateRedemptionError(input.idempotencyKey);
|
|
1676
|
+
}
|
|
1677
|
+
const redemption = {
|
|
1678
|
+
orderId: input.orderId,
|
|
1679
|
+
customerId: input.customerId,
|
|
1680
|
+
discountAmount: input.discountAmount,
|
|
1681
|
+
redeemedAt: /* @__PURE__ */ new Date(),
|
|
1682
|
+
...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {},
|
|
1683
|
+
...ctx.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {}
|
|
1684
|
+
};
|
|
1685
|
+
const updated = await this.voucherRepo.incrementUsage(voucher._id, redemption, {
|
|
1686
|
+
...ctx,
|
|
1687
|
+
session
|
|
1688
|
+
});
|
|
1689
|
+
if (updated.usedCount >= updated.usageLimit) await this.voucherRepo.update(voucher._id, { status: "used" }, {
|
|
1690
|
+
lean: true,
|
|
1691
|
+
...ctx,
|
|
1692
|
+
session
|
|
1693
|
+
});
|
|
1694
|
+
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.VOUCHER_REDEEMED, {
|
|
1695
|
+
voucherId: voucher._id,
|
|
1696
|
+
code: voucher.code,
|
|
1697
|
+
orderId: input.orderId,
|
|
1698
|
+
discountAmount: input.discountAmount,
|
|
1699
|
+
customerId: input.customerId
|
|
1700
|
+
}, ctx), {
|
|
1701
|
+
...ctx,
|
|
1702
|
+
session
|
|
1703
|
+
});
|
|
1704
|
+
return updated;
|
|
1705
|
+
});
|
|
1706
|
+
}
|
|
1707
|
+
async getBalance(code, ctx) {
|
|
1708
|
+
const voucher = await this.voucherRepo.getByCode(code, ctx);
|
|
1709
|
+
if (!voucher) throw new VoucherNotFoundError(code);
|
|
1710
|
+
if (voucher.initialBalance === void 0) throw new ValidationError("Voucher is not a gift card");
|
|
1711
|
+
return {
|
|
1712
|
+
code: voucher.code,
|
|
1713
|
+
initialBalance: voucher.initialBalance ?? 0,
|
|
1714
|
+
currentBalance: voucher.currentBalance ?? 0,
|
|
1715
|
+
spent: (voucher.initialBalance ?? 0) - (voucher.currentBalance ?? 0),
|
|
1716
|
+
voucherId: voucher._id
|
|
1717
|
+
};
|
|
1718
|
+
}
|
|
1719
|
+
async spend(input, ctx) {
|
|
1720
|
+
if (input.amount <= 0) throw new ValidationError("Spend amount must be positive");
|
|
1721
|
+
try {
|
|
1722
|
+
return await this.spendInTransaction(input, ctx);
|
|
1723
|
+
} catch (err) {
|
|
1724
|
+
if (isWriteConflict(err)) throw new ConcurrencyConflictError("voucher", input.code, err);
|
|
1725
|
+
throw err;
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
async spendInTransaction(input, ctx) {
|
|
1729
|
+
return this.unitOfWork.withTransaction(async (session) => {
|
|
1730
|
+
const voucher = await this.voucherRepo.getByCode(input.code, {
|
|
1731
|
+
...ctx,
|
|
1732
|
+
session
|
|
1733
|
+
});
|
|
1734
|
+
if (!voucher) throw new VoucherNotFoundError(input.code);
|
|
1735
|
+
this.assertVoucherUsable(voucher);
|
|
1736
|
+
const balance = voucher.currentBalance ?? 0;
|
|
1737
|
+
if (!this.config.giftCard.allowNegativeBalance && balance < input.amount) throw new InsufficientBalanceError(input.code, balance, input.amount);
|
|
1738
|
+
if (input.idempotencyKey) {
|
|
1739
|
+
if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey, {
|
|
1740
|
+
...ctx,
|
|
1741
|
+
session
|
|
1742
|
+
})) throw new DuplicateRedemptionError(input.idempotencyKey);
|
|
1743
|
+
}
|
|
1744
|
+
const entry = {
|
|
1745
|
+
amount: -input.amount,
|
|
1746
|
+
orderId: input.orderId,
|
|
1747
|
+
description: input.description ?? `Spent on order ${input.orderId}`,
|
|
1748
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
1749
|
+
...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {},
|
|
1750
|
+
...ctx.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {}
|
|
1751
|
+
};
|
|
1752
|
+
const updated = await this.voucherRepo.addLedgerEntry(voucher._id, entry, -input.amount, {
|
|
1753
|
+
...ctx,
|
|
1754
|
+
session
|
|
1755
|
+
});
|
|
1756
|
+
const newBalance = updated.currentBalance ?? 0;
|
|
1757
|
+
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.GIFT_CARD_SPENT, {
|
|
1758
|
+
voucherId: voucher._id,
|
|
1759
|
+
code: voucher.code,
|
|
1760
|
+
amount: input.amount,
|
|
1761
|
+
remainingBalance: newBalance,
|
|
1762
|
+
orderId: input.orderId
|
|
1763
|
+
}, ctx), {
|
|
1764
|
+
...ctx,
|
|
1765
|
+
session
|
|
1766
|
+
});
|
|
1767
|
+
if (newBalance <= 0) {
|
|
1768
|
+
await this.voucherRepo.update(voucher._id, { status: "used" }, {
|
|
1769
|
+
lean: true,
|
|
1770
|
+
...ctx,
|
|
1771
|
+
session
|
|
1772
|
+
});
|
|
1773
|
+
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.GIFT_CARD_EXHAUSTED, {
|
|
1774
|
+
voucherId: voucher._id,
|
|
1775
|
+
code: voucher.code,
|
|
1776
|
+
status: "used"
|
|
1777
|
+
}, ctx), {
|
|
1778
|
+
...ctx,
|
|
1779
|
+
session
|
|
1780
|
+
});
|
|
1781
|
+
}
|
|
1782
|
+
return {
|
|
1783
|
+
code: updated.code,
|
|
1784
|
+
initialBalance: updated.initialBalance ?? 0,
|
|
1785
|
+
currentBalance: newBalance,
|
|
1786
|
+
spent: (updated.initialBalance ?? 0) - newBalance,
|
|
1787
|
+
voucherId: updated._id
|
|
1788
|
+
};
|
|
1789
|
+
});
|
|
1790
|
+
}
|
|
1791
|
+
async topUp(input, ctx) {
|
|
1792
|
+
if (input.amount <= 0) throw new ValidationError("Top-up amount must be positive");
|
|
1793
|
+
try {
|
|
1794
|
+
return await this.topUpInTransaction(input, ctx);
|
|
1795
|
+
} catch (err) {
|
|
1796
|
+
if (isWriteConflict(err)) throw new ConcurrencyConflictError("voucher", input.code, err);
|
|
1797
|
+
throw err;
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
async topUpInTransaction(input, ctx) {
|
|
1801
|
+
return this.unitOfWork.withTransaction(async (session) => {
|
|
1802
|
+
const voucher = await this.voucherRepo.getByCode(input.code, {
|
|
1803
|
+
...ctx,
|
|
1804
|
+
session
|
|
1805
|
+
});
|
|
1806
|
+
if (!voucher) throw new VoucherNotFoundError(input.code);
|
|
1807
|
+
const maxBalance = this.config.giftCard.maxBalance;
|
|
1808
|
+
if (maxBalance !== null && (voucher.currentBalance ?? 0) + input.amount > maxBalance) throw new ValidationError(`Top-up would exceed max balance of ${maxBalance}`);
|
|
1809
|
+
if (input.idempotencyKey) {
|
|
1810
|
+
if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey, {
|
|
1811
|
+
...ctx,
|
|
1812
|
+
session
|
|
1813
|
+
})) throw new DuplicateRedemptionError(input.idempotencyKey);
|
|
1814
|
+
}
|
|
1815
|
+
const entry = {
|
|
1816
|
+
amount: input.amount,
|
|
1817
|
+
description: input.description ?? "Top-up",
|
|
1818
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
1819
|
+
...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {},
|
|
1820
|
+
...ctx.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {}
|
|
1821
|
+
};
|
|
1822
|
+
const updated = await this.voucherRepo.addLedgerEntry(voucher._id, entry, input.amount, {
|
|
1823
|
+
...ctx,
|
|
1824
|
+
session
|
|
1825
|
+
});
|
|
1826
|
+
if (voucher.status === "used") await this.voucherRepo.update(voucher._id, { status: "active" }, {
|
|
1827
|
+
lean: true,
|
|
1828
|
+
...ctx,
|
|
1829
|
+
session
|
|
1830
|
+
});
|
|
1831
|
+
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.GIFT_CARD_TOPPED_UP, {
|
|
1832
|
+
voucherId: voucher._id,
|
|
1833
|
+
code: voucher.code,
|
|
1834
|
+
amount: input.amount,
|
|
1835
|
+
newBalance: updated.currentBalance ?? 0
|
|
1836
|
+
}, ctx), {
|
|
1837
|
+
...ctx,
|
|
1838
|
+
session
|
|
1839
|
+
});
|
|
1840
|
+
return {
|
|
1841
|
+
code: updated.code,
|
|
1842
|
+
initialBalance: updated.initialBalance ?? 0,
|
|
1843
|
+
currentBalance: updated.currentBalance ?? 0,
|
|
1844
|
+
spent: (updated.initialBalance ?? 0) - (updated.currentBalance ?? 0),
|
|
1845
|
+
voucherId: updated._id
|
|
1846
|
+
};
|
|
1847
|
+
});
|
|
1848
|
+
}
|
|
1849
|
+
assertVoucherUsable(voucher) {
|
|
1850
|
+
if (voucher.status === "expired") throw new VoucherExpiredError(voucher.code);
|
|
1851
|
+
if (voucher.status === "cancelled") throw new VoucherExpiredError(voucher.code);
|
|
1852
|
+
if (voucher.status === "used") {
|
|
1853
|
+
if (voucher.initialBalance !== void 0) throw new GiftCardExhaustedError(voucher.code);
|
|
1854
|
+
throw new VoucherExhaustedError(voucher.code);
|
|
1855
|
+
}
|
|
1856
|
+
if (voucher.expiresAt && voucher.expiresAt < /* @__PURE__ */ new Date()) throw new VoucherExpiredError(voucher.code);
|
|
1857
|
+
if (voucher.usedCount >= voucher.usageLimit) throw new VoucherExhaustedError(voucher.code);
|
|
1858
|
+
}
|
|
1859
|
+
};
|
|
1860
|
+
//#endregion
|
|
1861
|
+
//#region src/services/create-services.ts
|
|
1862
|
+
function createServices(deps) {
|
|
1863
|
+
const { repositories, unitOfWork, dispatchDeps, config, evaluationStore } = deps;
|
|
1864
|
+
const voucher = new VoucherService(repositories.voucher, repositories.program, unitOfWork, dispatchDeps, config);
|
|
1865
|
+
const store = evaluationStore ?? new MongoEvaluationStore(repositories.pendingEvaluation);
|
|
1866
|
+
return {
|
|
1867
|
+
voucher,
|
|
1868
|
+
evaluation: new EvaluationService(repositories.program, repositories.rule, repositories.reward, repositories.voucher, unitOfWork, dispatchDeps, config, store)
|
|
22
1869
|
};
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
1870
|
+
}
|
|
1871
|
+
//#endregion
|
|
1872
|
+
//#region src/events/promo-event-catalog.ts
|
|
1873
|
+
function definePromoEvent(input) {
|
|
1874
|
+
const { name, version = 1, description, zodSchema } = input;
|
|
1875
|
+
const def = {
|
|
1876
|
+
name,
|
|
1877
|
+
version,
|
|
1878
|
+
schema: z.toJSONSchema(zodSchema),
|
|
1879
|
+
zodSchema,
|
|
1880
|
+
create(payload, meta) {
|
|
1881
|
+
return createEvent(name, payload, {
|
|
1882
|
+
source: "promo",
|
|
1883
|
+
...meta
|
|
1884
|
+
});
|
|
1885
|
+
}
|
|
29
1886
|
};
|
|
1887
|
+
if (description !== void 0) def.description = description;
|
|
1888
|
+
return def;
|
|
1889
|
+
}
|
|
1890
|
+
/** Mirrors `ProgramLifecyclePayload`. */
|
|
1891
|
+
const programLifecycleSchema = z.object({
|
|
1892
|
+
programId: z.string(),
|
|
1893
|
+
programType: z.string(),
|
|
1894
|
+
status: z.string(),
|
|
1895
|
+
actorId: z.string().optional()
|
|
1896
|
+
});
|
|
1897
|
+
/** Mirrors `RulePayload`. */
|
|
1898
|
+
const ruleSchema = z.object({
|
|
1899
|
+
programId: z.string(),
|
|
1900
|
+
ruleId: z.string(),
|
|
1901
|
+
actorId: z.string().optional()
|
|
1902
|
+
});
|
|
1903
|
+
/** Mirrors `RewardPayload`. */
|
|
1904
|
+
const rewardSchema = z.object({
|
|
1905
|
+
programId: z.string(),
|
|
1906
|
+
rewardId: z.string(),
|
|
1907
|
+
actorId: z.string().optional()
|
|
1908
|
+
});
|
|
1909
|
+
/** Mirrors `VoucherGeneratedPayload`. */
|
|
1910
|
+
const voucherGeneratedSchema = z.object({
|
|
1911
|
+
programId: z.string(),
|
|
1912
|
+
voucherIds: z.array(z.string()),
|
|
1913
|
+
codes: z.array(z.string()),
|
|
1914
|
+
count: z.number().int().nonnegative(),
|
|
1915
|
+
actorId: z.string().optional()
|
|
1916
|
+
});
|
|
1917
|
+
/** Mirrors `VoucherRedeemedPayload`. */
|
|
1918
|
+
const voucherRedeemedSchema = z.object({
|
|
1919
|
+
voucherId: z.string(),
|
|
1920
|
+
code: z.string(),
|
|
1921
|
+
orderId: z.string(),
|
|
1922
|
+
discountAmount: z.number(),
|
|
1923
|
+
customerId: z.string().optional()
|
|
1924
|
+
});
|
|
1925
|
+
/**
|
|
1926
|
+
* Mirrors `VoucherLifecyclePayload` — shared by VOUCHER_CANCELLED,
|
|
1927
|
+
* VOUCHER_EXPIRED, and GIFT_CARD_EXHAUSTED (repo emits `status: 'cancelled'`
|
|
1928
|
+
* / `'used'` / host-supplied terminal value).
|
|
1929
|
+
*/
|
|
1930
|
+
const voucherLifecycleSchema = z.object({
|
|
1931
|
+
voucherId: z.string(),
|
|
1932
|
+
code: z.string(),
|
|
1933
|
+
status: z.string()
|
|
1934
|
+
});
|
|
1935
|
+
/** Mirrors `GiftCardSpentPayload`. */
|
|
1936
|
+
const giftCardSpentSchema = z.object({
|
|
1937
|
+
voucherId: z.string(),
|
|
1938
|
+
code: z.string(),
|
|
1939
|
+
amount: z.number(),
|
|
1940
|
+
remainingBalance: z.number(),
|
|
1941
|
+
orderId: z.string()
|
|
1942
|
+
});
|
|
1943
|
+
/** Mirrors `GiftCardToppedUpPayload`. */
|
|
1944
|
+
const giftCardToppedUpSchema = z.object({
|
|
1945
|
+
voucherId: z.string(),
|
|
1946
|
+
code: z.string(),
|
|
1947
|
+
amount: z.number(),
|
|
1948
|
+
newBalance: z.number()
|
|
1949
|
+
});
|
|
1950
|
+
/** Mirrors `EvaluationCompletedPayload`. */
|
|
1951
|
+
const evaluationCompletedSchema = z.object({
|
|
1952
|
+
evaluationId: z.string(),
|
|
1953
|
+
totalDiscount: z.number(),
|
|
1954
|
+
programsApplied: z.number().int().nonnegative(),
|
|
1955
|
+
codesUsed: z.array(z.string()),
|
|
1956
|
+
isPreview: z.boolean()
|
|
1957
|
+
});
|
|
1958
|
+
/** Mirrors `EvaluationCommittedPayload`. */
|
|
1959
|
+
const evaluationCommittedSchema = z.object({
|
|
1960
|
+
evaluationId: z.string(),
|
|
1961
|
+
orderId: z.string(),
|
|
1962
|
+
totalDiscount: z.number()
|
|
1963
|
+
});
|
|
1964
|
+
/** Single-field rollback payload — emitted by `evaluation.service.ts`. */
|
|
1965
|
+
const evaluationRolledBackSchema = z.object({ evaluationId: z.string() });
|
|
1966
|
+
const ProgramCreated = definePromoEvent({
|
|
1967
|
+
name: PromoEvents.PROGRAM_CREATED,
|
|
1968
|
+
description: "A new promo program was created (starts in draft).",
|
|
1969
|
+
zodSchema: programLifecycleSchema
|
|
1970
|
+
});
|
|
1971
|
+
const ProgramActivated = definePromoEvent({
|
|
1972
|
+
name: PromoEvents.PROGRAM_ACTIVATED,
|
|
1973
|
+
description: "A draft or paused program was activated.",
|
|
1974
|
+
zodSchema: programLifecycleSchema
|
|
1975
|
+
});
|
|
1976
|
+
const ProgramPaused = definePromoEvent({
|
|
1977
|
+
name: PromoEvents.PROGRAM_PAUSED,
|
|
1978
|
+
description: "An active program was paused.",
|
|
1979
|
+
zodSchema: programLifecycleSchema
|
|
1980
|
+
});
|
|
1981
|
+
const ProgramArchived = definePromoEvent({
|
|
1982
|
+
name: PromoEvents.PROGRAM_ARCHIVED,
|
|
1983
|
+
description: "A program was archived (terminal — no further transitions).",
|
|
1984
|
+
zodSchema: programLifecycleSchema
|
|
1985
|
+
});
|
|
1986
|
+
const RuleAdded = definePromoEvent({
|
|
1987
|
+
name: PromoEvents.RULE_ADDED,
|
|
1988
|
+
description: "A new rule was added to a program.",
|
|
1989
|
+
zodSchema: ruleSchema
|
|
1990
|
+
});
|
|
1991
|
+
const RuleUpdated = definePromoEvent({
|
|
1992
|
+
name: PromoEvents.RULE_UPDATED,
|
|
1993
|
+
description: "An existing rule on a program was updated.",
|
|
1994
|
+
zodSchema: ruleSchema
|
|
1995
|
+
});
|
|
1996
|
+
const RuleRemoved = definePromoEvent({
|
|
1997
|
+
name: PromoEvents.RULE_REMOVED,
|
|
1998
|
+
description: "A rule was removed from a program.",
|
|
1999
|
+
zodSchema: ruleSchema
|
|
2000
|
+
});
|
|
2001
|
+
const RewardAdded = definePromoEvent({
|
|
2002
|
+
name: PromoEvents.REWARD_ADDED,
|
|
2003
|
+
description: "A reward was added to a program.",
|
|
2004
|
+
zodSchema: rewardSchema
|
|
2005
|
+
});
|
|
2006
|
+
const RewardUpdated = definePromoEvent({
|
|
2007
|
+
name: PromoEvents.REWARD_UPDATED,
|
|
2008
|
+
description: "A reward on a program was updated.",
|
|
2009
|
+
zodSchema: rewardSchema
|
|
2010
|
+
});
|
|
2011
|
+
const RewardRemoved = definePromoEvent({
|
|
2012
|
+
name: PromoEvents.REWARD_REMOVED,
|
|
2013
|
+
description: "A reward was removed from a program.",
|
|
2014
|
+
zodSchema: rewardSchema
|
|
2015
|
+
});
|
|
2016
|
+
const VoucherGenerated = definePromoEvent({
|
|
2017
|
+
name: PromoEvents.VOUCHER_GENERATED,
|
|
2018
|
+
description: "One or more vouchers were generated for a program.",
|
|
2019
|
+
zodSchema: voucherGeneratedSchema
|
|
2020
|
+
});
|
|
2021
|
+
const VoucherRedeemed = definePromoEvent({
|
|
2022
|
+
name: PromoEvents.VOUCHER_REDEEMED,
|
|
2023
|
+
description: "A voucher was redeemed against an order.",
|
|
2024
|
+
zodSchema: voucherRedeemedSchema
|
|
2025
|
+
});
|
|
2026
|
+
const VoucherCancelled = definePromoEvent({
|
|
2027
|
+
name: PromoEvents.VOUCHER_CANCELLED,
|
|
2028
|
+
description: "A voucher was cancelled by an operator.",
|
|
2029
|
+
zodSchema: voucherLifecycleSchema
|
|
2030
|
+
});
|
|
2031
|
+
const VoucherExpired = definePromoEvent({
|
|
2032
|
+
name: PromoEvents.VOUCHER_EXPIRED,
|
|
2033
|
+
description: "A voucher reached its expiry date and was transitioned to expired.",
|
|
2034
|
+
zodSchema: voucherLifecycleSchema
|
|
2035
|
+
});
|
|
2036
|
+
const GiftCardSpent = definePromoEvent({
|
|
2037
|
+
name: PromoEvents.GIFT_CARD_SPENT,
|
|
2038
|
+
description: "A gift-card voucher was debited against an order — remaining balance reported.",
|
|
2039
|
+
zodSchema: giftCardSpentSchema
|
|
2040
|
+
});
|
|
2041
|
+
const GiftCardToppedUp = definePromoEvent({
|
|
2042
|
+
name: PromoEvents.GIFT_CARD_TOPPED_UP,
|
|
2043
|
+
description: "A gift-card voucher received a top-up — new balance reported.",
|
|
2044
|
+
zodSchema: giftCardToppedUpSchema
|
|
2045
|
+
});
|
|
2046
|
+
const GiftCardExhausted = definePromoEvent({
|
|
2047
|
+
name: PromoEvents.GIFT_CARD_EXHAUSTED,
|
|
2048
|
+
description: "A gift-card voucher reached a zero balance and was marked used.",
|
|
2049
|
+
zodSchema: voucherLifecycleSchema
|
|
2050
|
+
});
|
|
2051
|
+
const EvaluationCompleted = definePromoEvent({
|
|
2052
|
+
name: PromoEvents.EVALUATION_COMPLETED,
|
|
2053
|
+
description: "A cart evaluation finished (preview or pre-commit) — totals and applied codes reported.",
|
|
2054
|
+
zodSchema: evaluationCompletedSchema
|
|
2055
|
+
});
|
|
2056
|
+
const EvaluationCommitted = definePromoEvent({
|
|
2057
|
+
name: PromoEvents.EVALUATION_COMMITTED,
|
|
2058
|
+
description: "A stored evaluation was committed against an order — usage counters moved.",
|
|
2059
|
+
zodSchema: evaluationCommittedSchema
|
|
2060
|
+
});
|
|
2061
|
+
const EvaluationRolledBack = definePromoEvent({
|
|
2062
|
+
name: PromoEvents.EVALUATION_ROLLED_BACK,
|
|
2063
|
+
description: "A stored evaluation was discarded without commit.",
|
|
2064
|
+
zodSchema: evaluationRolledBackSchema
|
|
2065
|
+
});
|
|
2066
|
+
/**
|
|
2067
|
+
* Every promo event defined in the package — pass to Arc's
|
|
2068
|
+
* `EventRegistry`. Hosts wire ONE array; the whole `promo.*` namespace
|
|
2069
|
+
* becomes introspectable via OpenAPI and auto-validated at publish time
|
|
2070
|
+
* when `eventPlugin({ validateMode: 'reject' })` is set.
|
|
2071
|
+
*/
|
|
2072
|
+
const promoEventDefinitions = [
|
|
2073
|
+
ProgramCreated,
|
|
2074
|
+
ProgramActivated,
|
|
2075
|
+
ProgramPaused,
|
|
2076
|
+
ProgramArchived,
|
|
2077
|
+
RuleAdded,
|
|
2078
|
+
RuleUpdated,
|
|
2079
|
+
RuleRemoved,
|
|
2080
|
+
RewardAdded,
|
|
2081
|
+
RewardUpdated,
|
|
2082
|
+
RewardRemoved,
|
|
2083
|
+
VoucherGenerated,
|
|
2084
|
+
VoucherRedeemed,
|
|
2085
|
+
VoucherCancelled,
|
|
2086
|
+
VoucherExpired,
|
|
2087
|
+
GiftCardSpent,
|
|
2088
|
+
GiftCardToppedUp,
|
|
2089
|
+
GiftCardExhausted,
|
|
2090
|
+
EvaluationCompleted,
|
|
2091
|
+
EvaluationCommitted,
|
|
2092
|
+
EvaluationRolledBack
|
|
2093
|
+
];
|
|
2094
|
+
//#endregion
|
|
2095
|
+
//#region src/index.ts
|
|
2096
|
+
function resolveConfig(config) {
|
|
2097
|
+
const tenant = resolveTenantConfig(config.tenant);
|
|
2098
|
+
if (typeof config.tenant === "object" && config.tenant !== null) {
|
|
2099
|
+
const explicit = config.tenant.contextKey ?? config.tenant.tenantField;
|
|
2100
|
+
if (explicit) tenant.contextKey = explicit;
|
|
2101
|
+
}
|
|
30
2102
|
const resolved = {
|
|
31
2103
|
evaluation: {
|
|
32
2104
|
maxStackablePromotions: config.evaluation?.maxStackablePromotions ?? 5,
|
|
@@ -47,40 +2119,87 @@ function resolveConfig(config) {
|
|
|
47
2119
|
if (resolved.evaluation.maxStackablePromotions < 1) throw new ValidationError("maxStackablePromotions must be >= 1");
|
|
48
2120
|
return resolved;
|
|
49
2121
|
}
|
|
2122
|
+
/**
|
|
2123
|
+
* Thin adapter over `mongokit.withTransaction` so the engine's local
|
|
2124
|
+
* `UnitOfWork` port stays driver-agnostic while inheriting mongokit's
|
|
2125
|
+
* battle-tested transaction handling: auto-retry on
|
|
2126
|
+
* `TransientTransactionError` + `UnknownTransactionCommitResult`,
|
|
2127
|
+
* standalone-Mongo fallback for dev, consistent session lifecycle.
|
|
2128
|
+
*
|
|
2129
|
+
* Don't replace this with hand-rolled `session.withTransaction()` — the
|
|
2130
|
+
* underlying helper centralises retry semantics across every package
|
|
2131
|
+
* that wires it. See packages/mongokit/src/transaction.ts.
|
|
2132
|
+
*/
|
|
50
2133
|
var MongoUnitOfWork = class {
|
|
51
2134
|
constructor(connection) {
|
|
52
2135
|
this.connection = connection;
|
|
53
2136
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
try {
|
|
57
|
-
let result;
|
|
58
|
-
await session.withTransaction(async () => {
|
|
59
|
-
result = await cb(session);
|
|
60
|
-
});
|
|
61
|
-
return result;
|
|
62
|
-
} finally {
|
|
63
|
-
await session.endSession();
|
|
64
|
-
}
|
|
2137
|
+
withTransaction(cb) {
|
|
2138
|
+
return withTransaction(this.connection, cb);
|
|
65
2139
|
}
|
|
66
2140
|
};
|
|
2141
|
+
/**
|
|
2142
|
+
* Build the promo engine for a host application.
|
|
2143
|
+
*
|
|
2144
|
+
* **Index management — important for boot performance:**
|
|
2145
|
+
*
|
|
2146
|
+
* `createPromoEngine` itself is non-blocking. It registers Mongoose models
|
|
2147
|
+
* and returns immediately. Index creation is delegated to Mongoose's
|
|
2148
|
+
* standard lazy-init: with `autoIndex: true` (default in dev) Mongoose
|
|
2149
|
+
* builds indexes in the background after the first query touches each
|
|
2150
|
+
* model; with `autoIndex: false` (recommended for production) hosts
|
|
2151
|
+
* control index creation explicitly.
|
|
2152
|
+
*
|
|
2153
|
+
* The returned `engine.syncIndexes()` helper is opt-in and **MUST NOT be
|
|
2154
|
+
* `await`ed during Fastify plugin registration / boot** — Atlas index
|
|
2155
|
+
* creation can take 10s+ on fresh collections, longer than typical
|
|
2156
|
+
* plugin timeouts. Three safe patterns:
|
|
2157
|
+
*
|
|
2158
|
+
* 1. **Migration script** (recommended for production):
|
|
2159
|
+
* ```ts
|
|
2160
|
+
* // scripts/sync-indexes.ts
|
|
2161
|
+
* await engine.syncIndexes();
|
|
2162
|
+
* ```
|
|
2163
|
+
* Run before deploying / serving traffic.
|
|
2164
|
+
*
|
|
2165
|
+
* 2. **Background fire-and-log** during boot:
|
|
2166
|
+
* ```ts
|
|
2167
|
+
* engine.syncIndexes().catch((err) => log.warn({ err }, 'index sync'));
|
|
2168
|
+
* ```
|
|
2169
|
+
* App accepts traffic immediately; first queries may wait briefly
|
|
2170
|
+
* on still-building indexes.
|
|
2171
|
+
*
|
|
2172
|
+
* 3. **Lazy init** (`autoIndex: true` in dev): just don't call
|
|
2173
|
+
* `syncIndexes()` at all. Mongoose schedules creation on first
|
|
2174
|
+
* query per model.
|
|
2175
|
+
*
|
|
2176
|
+
* Production hosts should set `autoIndex: false` and use option (1).
|
|
2177
|
+
*/
|
|
67
2178
|
function createPromoEngine(config) {
|
|
68
2179
|
const resolvedConfig = resolveConfig(config);
|
|
69
|
-
const models = createModels(config.mongoose, resolvedConfig.tenant, config.indexes);
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
|
|
2180
|
+
const models = createModels(config.mongoose, resolvedConfig.tenant, config.indexes, config.autoIndex);
|
|
2181
|
+
const events = config.events?.transport ?? new InProcessPromoBus();
|
|
2182
|
+
const dispatchDeps = {
|
|
2183
|
+
events,
|
|
2184
|
+
...config.outbox !== void 0 ? { outbox: config.outbox } : {},
|
|
2185
|
+
...config.logger !== void 0 ? { logger: config.logger } : {}
|
|
2186
|
+
};
|
|
2187
|
+
const repositories = createRepositories(models, config.plugins, resolvedConfig.tenant, dispatchDeps);
|
|
73
2188
|
return {
|
|
74
2189
|
models,
|
|
75
2190
|
repositories,
|
|
76
2191
|
services: createServices({
|
|
77
2192
|
repositories,
|
|
78
|
-
unitOfWork,
|
|
79
|
-
|
|
80
|
-
config: resolvedConfig
|
|
2193
|
+
unitOfWork: new MongoUnitOfWork(config.mongoose),
|
|
2194
|
+
dispatchDeps,
|
|
2195
|
+
config: resolvedConfig,
|
|
2196
|
+
...config.evaluationStore !== void 0 ? { evaluationStore: config.evaluationStore } : {}
|
|
81
2197
|
}),
|
|
82
|
-
events
|
|
2198
|
+
events,
|
|
2199
|
+
async syncIndexes() {
|
|
2200
|
+
await Promise.all(Object.values(models).map((m) => m.createIndexes()));
|
|
2201
|
+
}
|
|
83
2202
|
};
|
|
84
2203
|
}
|
|
85
2204
|
//#endregion
|
|
86
|
-
export { PromoEvents, createPromoEngine, resolveConfig };
|
|
2205
|
+
export { CartHashMismatchError, ConcurrencyConflictError, DuplicateRedemptionError, EvaluationCommitted, EvaluationCompleted, EvaluationNotFoundError, EvaluationRolledBack, GiftCardExhausted, GiftCardExhaustedError, GiftCardSpent, GiftCardToppedUp, InMemoryEvaluationStore, InsufficientBalanceError, InvalidTransitionError, MongoEvaluationStore, PendingEvaluationRepository, ProgramActivated, ProgramArchived, ProgramCreated, ProgramNotFoundError, ProgramPaused, ProgramRepository, ProgramUsageCapExceededError, PromoError, PromoEvents, RewardAdded, RewardNotFoundError, RewardRemoved, RewardRepository, RewardUpdated, RuleAdded, RuleNotFoundError, RuleRemoved, RuleRepository, RuleUpdated, TenantIsolationError, ValidationError, VoucherCancelled, VoucherExhaustedError, VoucherExpired, VoucherExpiredError, VoucherGenerated, VoucherNotFoundError, VoucherRedeemed, VoucherRepository, createPromoEngine, promoEventDefinitions, resolveConfig };
|