@classytic/promo 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +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 +763 -10
- package/dist/index.mjs +1721 -34
- package/dist/schemas/index.d.mts +253 -0
- package/dist/schemas/index.mjs +135 -0
- package/package.json +20 -35
- 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,1712 @@
|
|
|
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 { resolveTenantConfig } from "@classytic/primitives/tenant";
|
|
3
|
+
import { createEvent, matchEventPattern } from "@classytic/primitives/events";
|
|
4
|
+
import mongoose from "mongoose";
|
|
5
|
+
import { Repository, multiTenantPlugin } from "@classytic/mongokit";
|
|
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
|
+
var VoucherNotFoundError = class extends PromoError {
|
|
27
|
+
code = "VOUCHER_NOT_FOUND";
|
|
28
|
+
constructor(codeOrId) {
|
|
29
|
+
super(codeOrId ? `Voucher '${codeOrId}' not found` : "Voucher not found");
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
var InvalidTransitionError = class extends PromoError {
|
|
33
|
+
code = "INVALID_TRANSITION";
|
|
34
|
+
constructor(from, to) {
|
|
35
|
+
super(`Cannot transition from '${from}' to '${to}'`);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
var VoucherExpiredError = class extends PromoError {
|
|
39
|
+
code = "VOUCHER_EXPIRED";
|
|
40
|
+
constructor(code) {
|
|
41
|
+
super(`Voucher '${code}' has expired`);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
var VoucherExhaustedError = class extends PromoError {
|
|
45
|
+
code = "VOUCHER_EXHAUSTED";
|
|
46
|
+
constructor(code) {
|
|
47
|
+
super(`Voucher '${code}' has reached its usage limit`);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Thrown when a gift card's balance has been fully spent and its status
|
|
52
|
+
* flipped to `'used'`. Distinct from `VoucherExhaustedError` (usage-limit
|
|
53
|
+
* exhaustion on a discount voucher) so hosts can show "top up" vs "retry
|
|
54
|
+
* later" messaging.
|
|
55
|
+
*/
|
|
56
|
+
var GiftCardExhaustedError = class extends PromoError {
|
|
57
|
+
code = "GIFT_CARD_EXHAUSTED";
|
|
58
|
+
constructor(code) {
|
|
59
|
+
super(`Gift card '${code}' has been fully spent`);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Thrown when a MongoDB WriteConflict surfaces under voucher-spend
|
|
64
|
+
* contention. Losers see a stable domain shape rather than the raw
|
|
65
|
+
* `"Write conflict during plan execution"` string. Hosts can translate
|
|
66
|
+
* this to HTTP 409 + retry.
|
|
67
|
+
*/
|
|
68
|
+
var ConcurrencyConflictError = class extends PromoError {
|
|
69
|
+
code = "CONCURRENCY_CONFLICT";
|
|
70
|
+
status = 409;
|
|
71
|
+
constructor(resource, resourceId, cause) {
|
|
72
|
+
super(`Concurrent modification on ${resource} '${resourceId}'`);
|
|
73
|
+
this.resource = resource;
|
|
74
|
+
this.resourceId = resourceId;
|
|
75
|
+
this.cause = cause;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
var InsufficientBalanceError = class extends PromoError {
|
|
79
|
+
code = "INSUFFICIENT_BALANCE";
|
|
80
|
+
constructor(code, available, requested) {
|
|
81
|
+
super(`Voucher '${code}' has insufficient balance: ${available} available, ${requested} requested`);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
var DuplicateRedemptionError = class extends PromoError {
|
|
85
|
+
code = "DUPLICATE_REDEMPTION";
|
|
86
|
+
constructor(key) {
|
|
87
|
+
super(`Duplicate redemption detected for idempotency key '${key}'`);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
var EvaluationNotFoundError = class extends PromoError {
|
|
91
|
+
code = "EVALUATION_NOT_FOUND";
|
|
92
|
+
constructor(id) {
|
|
93
|
+
super(`Evaluation '${id}' not found or already committed`);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Thrown by `EvaluationService.commit` when the cart hash provided by the
|
|
98
|
+
* caller does not match the cart hash computed at evaluation time. Guards
|
|
99
|
+
* against cart-tampering attacks where a user previews a large discount on
|
|
100
|
+
* a heavy cart, mutates the cart to something cheaper before committing, and
|
|
101
|
+
* tries to apply the stale discount to the altered order.
|
|
102
|
+
*/
|
|
103
|
+
var CartHashMismatchError = class extends PromoError {
|
|
104
|
+
code = "CART_HASH_MISMATCH";
|
|
105
|
+
constructor(evaluationId) {
|
|
106
|
+
super(`Evaluation '${evaluationId}' cart hash mismatch — the cart changed between evaluate and commit. Re-evaluate with the current cart.`);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
//#endregion
|
|
110
|
+
//#region src/events/in-process-bus.ts
|
|
111
|
+
var InProcessPromoBus = class {
|
|
112
|
+
name = "in-process-promo";
|
|
113
|
+
handlers = /* @__PURE__ */ new Map();
|
|
114
|
+
logger;
|
|
115
|
+
constructor(options = {}) {
|
|
116
|
+
this.logger = options.logger ?? console;
|
|
117
|
+
}
|
|
118
|
+
async publish(event) {
|
|
119
|
+
const matched = /* @__PURE__ */ new Set();
|
|
120
|
+
for (const [pattern, set] of this.handlers.entries()) if (matchEventPattern(pattern, event.type)) for (const h of set) matched.add(h);
|
|
121
|
+
for (const handler of matched) try {
|
|
122
|
+
await handler(event);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
this.logger.error(`[promo] handler error for ${event.type}:`, err);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async subscribe(pattern, handler) {
|
|
128
|
+
let set = this.handlers.get(pattern);
|
|
129
|
+
if (!set) {
|
|
130
|
+
set = /* @__PURE__ */ new Set();
|
|
131
|
+
this.handlers.set(pattern, set);
|
|
132
|
+
}
|
|
133
|
+
set.add(handler);
|
|
134
|
+
return () => {
|
|
135
|
+
const s = this.handlers.get(pattern);
|
|
136
|
+
if (!s) return;
|
|
137
|
+
s.delete(handler);
|
|
138
|
+
if (s.size === 0) this.handlers.delete(pattern);
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
async close() {
|
|
142
|
+
this.handlers.clear();
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
//#endregion
|
|
146
|
+
//#region src/models/create-model.ts
|
|
147
|
+
function injectTenantField(schema, tenant) {
|
|
148
|
+
schema.add({ [tenant.tenantField]: {
|
|
149
|
+
type: tenant.fieldType === "objectId" ? mongoose.Schema.Types.ObjectId : String,
|
|
150
|
+
index: true,
|
|
151
|
+
...tenant.ref ? { ref: tenant.ref } : {}
|
|
152
|
+
} });
|
|
153
|
+
if (!tenant.enabled) return;
|
|
154
|
+
const existingIndexes = schema._indexes;
|
|
155
|
+
if (existingIndexes && existingIndexes.length > 0) for (const indexEntry of existingIndexes) {
|
|
156
|
+
const fields = indexEntry[0];
|
|
157
|
+
const newFields = { [tenant.tenantField]: 1 };
|
|
158
|
+
for (const [key, val] of Object.entries(fields)) newFields[key] = val;
|
|
159
|
+
indexEntry[0] = newFields;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function applyUserIndexes(schema, indexes, tenant) {
|
|
163
|
+
for (const def of indexes) {
|
|
164
|
+
const fields = tenant.enabled ? {
|
|
165
|
+
[tenant.tenantField]: 1,
|
|
166
|
+
...def.fields
|
|
167
|
+
} : { ...def.fields };
|
|
168
|
+
schema.index(fields, def.options ?? {});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
//#endregion
|
|
172
|
+
//#region src/models/schemas/program.schema.ts
|
|
173
|
+
const { Schema: Schema$3 } = mongoose;
|
|
174
|
+
function createProgramSchema() {
|
|
175
|
+
const schema = new Schema$3({
|
|
176
|
+
name: {
|
|
177
|
+
type: String,
|
|
178
|
+
required: true
|
|
179
|
+
},
|
|
180
|
+
description: { type: String },
|
|
181
|
+
programType: {
|
|
182
|
+
type: String,
|
|
183
|
+
enum: PROGRAM_TYPES,
|
|
184
|
+
required: true
|
|
185
|
+
},
|
|
186
|
+
triggerMode: {
|
|
187
|
+
type: String,
|
|
188
|
+
enum: TRIGGER_MODES,
|
|
189
|
+
required: true
|
|
190
|
+
},
|
|
191
|
+
status: {
|
|
192
|
+
type: String,
|
|
193
|
+
enum: PROGRAM_STATUSES,
|
|
194
|
+
default: "draft"
|
|
195
|
+
},
|
|
196
|
+
stackingMode: {
|
|
197
|
+
type: String,
|
|
198
|
+
enum: STACKING_MODES,
|
|
199
|
+
default: "exclusive"
|
|
200
|
+
},
|
|
201
|
+
priority: {
|
|
202
|
+
type: Number,
|
|
203
|
+
default: 0
|
|
204
|
+
},
|
|
205
|
+
startsAt: { type: Date },
|
|
206
|
+
endsAt: { type: Date },
|
|
207
|
+
maxUsageTotal: { type: Number },
|
|
208
|
+
usedCount: {
|
|
209
|
+
type: Number,
|
|
210
|
+
default: 0
|
|
211
|
+
},
|
|
212
|
+
maxUsagePerCustomer: { type: Number },
|
|
213
|
+
applicableCustomerIds: [{ type: String }],
|
|
214
|
+
applicableCustomerTags: [{ type: String }],
|
|
215
|
+
customerUsageCounts: {
|
|
216
|
+
type: Map,
|
|
217
|
+
of: Number,
|
|
218
|
+
default: () => /* @__PURE__ */ new Map()
|
|
219
|
+
},
|
|
220
|
+
metadata: { type: Schema$3.Types.Mixed }
|
|
221
|
+
}, { timestamps: true });
|
|
222
|
+
schema.index({
|
|
223
|
+
status: 1,
|
|
224
|
+
programType: 1,
|
|
225
|
+
priority: -1
|
|
226
|
+
});
|
|
227
|
+
schema.index({
|
|
228
|
+
status: 1,
|
|
229
|
+
startsAt: 1,
|
|
230
|
+
endsAt: 1
|
|
231
|
+
});
|
|
232
|
+
schema.index({
|
|
233
|
+
status: 1,
|
|
234
|
+
priority: -1,
|
|
235
|
+
_id: -1
|
|
236
|
+
});
|
|
237
|
+
return schema;
|
|
238
|
+
}
|
|
239
|
+
//#endregion
|
|
240
|
+
//#region src/models/schemas/reward.schema.ts
|
|
241
|
+
const { Schema: Schema$2 } = mongoose;
|
|
242
|
+
function createRewardSchema() {
|
|
243
|
+
const schema = new Schema$2({
|
|
244
|
+
programId: {
|
|
245
|
+
type: Schema$2.Types.ObjectId,
|
|
246
|
+
required: true
|
|
247
|
+
},
|
|
248
|
+
ruleId: { type: Schema$2.Types.ObjectId },
|
|
249
|
+
rewardType: {
|
|
250
|
+
type: String,
|
|
251
|
+
enum: REWARD_TYPES,
|
|
252
|
+
required: true
|
|
253
|
+
},
|
|
254
|
+
discountMode: {
|
|
255
|
+
type: String,
|
|
256
|
+
enum: DISCOUNT_MODES
|
|
257
|
+
},
|
|
258
|
+
discountAmount: { type: Number },
|
|
259
|
+
maxDiscountAmount: { type: Number },
|
|
260
|
+
discountScope: {
|
|
261
|
+
type: String,
|
|
262
|
+
enum: DISCOUNT_SCOPES,
|
|
263
|
+
default: "order"
|
|
264
|
+
},
|
|
265
|
+
applicableProductIds: [{ type: String }],
|
|
266
|
+
freeProductId: { type: String },
|
|
267
|
+
freeProductSku: { type: String },
|
|
268
|
+
freeQuantity: {
|
|
269
|
+
type: Number,
|
|
270
|
+
default: 1
|
|
271
|
+
},
|
|
272
|
+
giftCardAmount: { type: Number },
|
|
273
|
+
metadata: { type: Schema$2.Types.Mixed }
|
|
274
|
+
}, { timestamps: true });
|
|
275
|
+
schema.index({ programId: 1 });
|
|
276
|
+
schema.index({ ruleId: 1 }, { sparse: true });
|
|
277
|
+
return schema;
|
|
278
|
+
}
|
|
279
|
+
//#endregion
|
|
280
|
+
//#region src/models/schemas/rule.schema.ts
|
|
281
|
+
const { Schema: Schema$1 } = mongoose;
|
|
282
|
+
function createRuleSchema() {
|
|
283
|
+
const schema = new Schema$1({
|
|
284
|
+
programId: {
|
|
285
|
+
type: Schema$1.Types.ObjectId,
|
|
286
|
+
required: true
|
|
287
|
+
},
|
|
288
|
+
name: { type: String },
|
|
289
|
+
minimumAmount: {
|
|
290
|
+
type: Number,
|
|
291
|
+
default: 0
|
|
292
|
+
},
|
|
293
|
+
minimumQuantity: {
|
|
294
|
+
type: Number,
|
|
295
|
+
default: 0
|
|
296
|
+
},
|
|
297
|
+
applicableProductIds: [{ type: String }],
|
|
298
|
+
applicableCategories: [{ type: String }],
|
|
299
|
+
applicableSkus: [{ type: String }],
|
|
300
|
+
buyQuantity: { type: Number },
|
|
301
|
+
code: {
|
|
302
|
+
type: String,
|
|
303
|
+
uppercase: true,
|
|
304
|
+
trim: true
|
|
305
|
+
},
|
|
306
|
+
startsAt: { type: Date },
|
|
307
|
+
endsAt: { type: Date },
|
|
308
|
+
metadata: { type: Schema$1.Types.Mixed }
|
|
309
|
+
}, { timestamps: true });
|
|
310
|
+
schema.index({ programId: 1 });
|
|
311
|
+
schema.index({ code: 1 }, { sparse: true });
|
|
312
|
+
return schema;
|
|
313
|
+
}
|
|
314
|
+
//#endregion
|
|
315
|
+
//#region src/models/schemas/voucher.schema.ts
|
|
316
|
+
const { Schema } = mongoose;
|
|
317
|
+
function createVoucherSchema() {
|
|
318
|
+
const schema = new Schema({
|
|
319
|
+
programId: {
|
|
320
|
+
type: Schema.Types.ObjectId,
|
|
321
|
+
required: true,
|
|
322
|
+
index: true
|
|
323
|
+
},
|
|
324
|
+
code: {
|
|
325
|
+
type: String,
|
|
326
|
+
required: true,
|
|
327
|
+
uppercase: true,
|
|
328
|
+
trim: true
|
|
329
|
+
},
|
|
330
|
+
status: {
|
|
331
|
+
type: String,
|
|
332
|
+
enum: VOUCHER_STATUSES,
|
|
333
|
+
default: "active"
|
|
334
|
+
},
|
|
335
|
+
customerId: { type: String },
|
|
336
|
+
usageLimit: {
|
|
337
|
+
type: Number,
|
|
338
|
+
default: 1
|
|
339
|
+
},
|
|
340
|
+
usedCount: {
|
|
341
|
+
type: Number,
|
|
342
|
+
default: 0
|
|
343
|
+
},
|
|
344
|
+
initialBalance: { type: Number },
|
|
345
|
+
currentBalance: { type: Number },
|
|
346
|
+
balanceLedger: [{
|
|
347
|
+
amount: {
|
|
348
|
+
type: Number,
|
|
349
|
+
required: true
|
|
350
|
+
},
|
|
351
|
+
orderId: { type: String },
|
|
352
|
+
description: { type: String },
|
|
353
|
+
createdAt: {
|
|
354
|
+
type: Date,
|
|
355
|
+
default: Date.now
|
|
356
|
+
},
|
|
357
|
+
idempotencyKey: { type: String }
|
|
358
|
+
}],
|
|
359
|
+
expiresAt: { type: Date },
|
|
360
|
+
redemptions: [{
|
|
361
|
+
orderId: {
|
|
362
|
+
type: String,
|
|
363
|
+
required: true
|
|
364
|
+
},
|
|
365
|
+
customerId: { type: String },
|
|
366
|
+
discountAmount: {
|
|
367
|
+
type: Number,
|
|
368
|
+
required: true
|
|
369
|
+
},
|
|
370
|
+
redeemedAt: {
|
|
371
|
+
type: Date,
|
|
372
|
+
default: Date.now
|
|
373
|
+
},
|
|
374
|
+
idempotencyKey: { type: String }
|
|
375
|
+
}],
|
|
376
|
+
metadata: { type: Schema.Types.Mixed }
|
|
377
|
+
}, { timestamps: true });
|
|
378
|
+
schema.index({ code: 1 }, { unique: true });
|
|
379
|
+
schema.index({
|
|
380
|
+
programId: 1,
|
|
381
|
+
status: 1
|
|
382
|
+
});
|
|
383
|
+
schema.index({
|
|
384
|
+
customerId: 1,
|
|
385
|
+
status: 1
|
|
386
|
+
}, { sparse: true });
|
|
387
|
+
schema.index({ expiresAt: 1 }, { sparse: true });
|
|
388
|
+
return schema;
|
|
389
|
+
}
|
|
390
|
+
//#endregion
|
|
391
|
+
//#region src/models/create-models.ts
|
|
392
|
+
const MODEL_NAMES = [
|
|
393
|
+
"PromoProgram",
|
|
394
|
+
"PromoRule",
|
|
395
|
+
"PromoReward",
|
|
396
|
+
"PromoVoucher"
|
|
397
|
+
];
|
|
398
|
+
function applyAutoIndex(models, autoIndex) {
|
|
399
|
+
if (autoIndex === void 0) return;
|
|
400
|
+
for (const [configKey, modelKey] of [
|
|
401
|
+
["program", "Program"],
|
|
402
|
+
["rule", "Rule"],
|
|
403
|
+
["reward", "Reward"],
|
|
404
|
+
["voucher", "Voucher"]
|
|
405
|
+
]) {
|
|
406
|
+
const value = typeof autoIndex === "boolean" ? autoIndex : autoIndex[configKey] ?? void 0;
|
|
407
|
+
if (value !== void 0) models[modelKey].schema.set("autoIndex", value);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
function createModels(connection, tenant, indexes, autoIndex) {
|
|
411
|
+
for (const name of MODEL_NAMES) if (connection.models[name]) connection.deleteModel(name);
|
|
412
|
+
const programSchema = createProgramSchema();
|
|
413
|
+
const ruleSchema = createRuleSchema();
|
|
414
|
+
const rewardSchema = createRewardSchema();
|
|
415
|
+
const voucherSchema = createVoucherSchema();
|
|
416
|
+
injectTenantField(programSchema, tenant);
|
|
417
|
+
injectTenantField(ruleSchema, tenant);
|
|
418
|
+
injectTenantField(rewardSchema, tenant);
|
|
419
|
+
injectTenantField(voucherSchema, tenant);
|
|
420
|
+
if (indexes?.program) applyUserIndexes(programSchema, indexes.program, tenant);
|
|
421
|
+
if (indexes?.rule) applyUserIndexes(ruleSchema, indexes.rule, tenant);
|
|
422
|
+
if (indexes?.reward) applyUserIndexes(rewardSchema, indexes.reward, tenant);
|
|
423
|
+
if (indexes?.voucher) applyUserIndexes(voucherSchema, indexes.voucher, tenant);
|
|
424
|
+
const result = {
|
|
425
|
+
Program: connection.model("PromoProgram", programSchema),
|
|
426
|
+
Rule: connection.model("PromoRule", ruleSchema),
|
|
427
|
+
Reward: connection.model("PromoReward", rewardSchema),
|
|
428
|
+
Voucher: connection.model("PromoVoucher", voucherSchema)
|
|
16
429
|
};
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
430
|
+
applyAutoIndex(result, autoIndex);
|
|
431
|
+
return result;
|
|
432
|
+
}
|
|
433
|
+
//#endregion
|
|
434
|
+
//#region src/events/dispatch.ts
|
|
435
|
+
/**
|
|
436
|
+
* Canonical P8 shape — save to outbox first (with `ctx.session` when
|
|
437
|
+
* present), then publish to transport. Both paths run independently.
|
|
438
|
+
*/
|
|
439
|
+
async function dispatchPromoEvent(deps, event, ctx) {
|
|
440
|
+
const logger = deps.logger ?? console;
|
|
441
|
+
if (deps.outbox) try {
|
|
442
|
+
const saveOptions = {};
|
|
443
|
+
if (ctx?.session !== void 0) saveOptions.session = ctx.session;
|
|
444
|
+
await deps.outbox.save(event, saveOptions);
|
|
445
|
+
} catch (err) {
|
|
446
|
+
logger.error(`[promo] outbox.save failed for ${event.type}:`, err);
|
|
447
|
+
}
|
|
448
|
+
if (deps.events) try {
|
|
449
|
+
await deps.events.publish(event);
|
|
450
|
+
} catch (err) {
|
|
451
|
+
logger.error(`[promo] events.publish failed for ${event.type}:`, err);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
//#endregion
|
|
455
|
+
//#region src/events/event-constants.ts
|
|
456
|
+
const PromoEvents = {
|
|
457
|
+
PROGRAM_CREATED: "promo.program.created",
|
|
458
|
+
PROGRAM_ACTIVATED: "promo.program.activated",
|
|
459
|
+
PROGRAM_PAUSED: "promo.program.paused",
|
|
460
|
+
PROGRAM_ARCHIVED: "promo.program.archived",
|
|
461
|
+
RULE_ADDED: "promo.rule.added",
|
|
462
|
+
RULE_UPDATED: "promo.rule.updated",
|
|
463
|
+
RULE_REMOVED: "promo.rule.removed",
|
|
464
|
+
REWARD_ADDED: "promo.reward.added",
|
|
465
|
+
REWARD_UPDATED: "promo.reward.updated",
|
|
466
|
+
REWARD_REMOVED: "promo.reward.removed",
|
|
467
|
+
VOUCHER_GENERATED: "promo.voucher.generated",
|
|
468
|
+
VOUCHER_REDEEMED: "promo.voucher.redeemed",
|
|
469
|
+
VOUCHER_CANCELLED: "promo.voucher.cancelled",
|
|
470
|
+
VOUCHER_EXPIRED: "promo.voucher.expired",
|
|
471
|
+
GIFT_CARD_SPENT: "promo.gift_card.spent",
|
|
472
|
+
GIFT_CARD_TOPPED_UP: "promo.gift_card.topped_up",
|
|
473
|
+
GIFT_CARD_EXHAUSTED: "promo.gift_card.exhausted",
|
|
474
|
+
EVALUATION_COMPLETED: "promo.evaluation.completed",
|
|
475
|
+
EVALUATION_COMMITTED: "promo.evaluation.committed",
|
|
476
|
+
EVALUATION_ROLLED_BACK: "promo.evaluation.rolled_back"
|
|
477
|
+
};
|
|
478
|
+
//#endregion
|
|
479
|
+
//#region src/events/helpers.ts
|
|
480
|
+
function createEvent$1(type, payload, ctx, meta) {
|
|
481
|
+
return createEvent(type, payload, {
|
|
482
|
+
resource: "promo",
|
|
483
|
+
...ctx?.actorId !== void 0 ? { userId: ctx.actorId } : {},
|
|
484
|
+
...ctx?.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {},
|
|
485
|
+
...meta
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
//#endregion
|
|
489
|
+
//#region src/repositories/program.repository.ts
|
|
490
|
+
var ProgramRepository = class extends Repository {
|
|
491
|
+
dispatchDeps;
|
|
492
|
+
constructor(model, plugins = [], dispatchDeps = {}) {
|
|
493
|
+
super(model, plugins);
|
|
494
|
+
this.dispatchDeps = dispatchDeps;
|
|
495
|
+
}
|
|
496
|
+
async activate(id, ctx) {
|
|
497
|
+
return this._transition(id, "active", ctx);
|
|
498
|
+
}
|
|
499
|
+
async pause(id, ctx) {
|
|
500
|
+
return this._transition(id, "paused", ctx);
|
|
501
|
+
}
|
|
502
|
+
async archive(id, ctx) {
|
|
503
|
+
return this._transition(id, "archived", ctx);
|
|
504
|
+
}
|
|
505
|
+
async _transition(id, targetStatus, ctx) {
|
|
506
|
+
const program = await this.getById(id, {
|
|
507
|
+
throwOnNotFound: false,
|
|
508
|
+
lean: true,
|
|
509
|
+
...ctx
|
|
510
|
+
});
|
|
511
|
+
if (!program) throw new ProgramNotFoundError(id);
|
|
512
|
+
if (!PROGRAM_TRANSITIONS[program.status]?.includes(targetStatus)) throw new InvalidTransitionError(program.status, targetStatus);
|
|
513
|
+
const updated = await this.update(id, { status: targetStatus }, {
|
|
514
|
+
throwOnNotFound: true,
|
|
515
|
+
lean: true,
|
|
516
|
+
...ctx
|
|
517
|
+
});
|
|
518
|
+
if (!updated) throw new ProgramNotFoundError(id);
|
|
519
|
+
const eventName = {
|
|
520
|
+
active: PromoEvents.PROGRAM_ACTIVATED,
|
|
521
|
+
paused: PromoEvents.PROGRAM_PAUSED,
|
|
522
|
+
archived: PromoEvents.PROGRAM_ARCHIVED
|
|
523
|
+
}[targetStatus];
|
|
524
|
+
if (eventName) await dispatchPromoEvent(this.dispatchDeps, createEvent$1(eventName, {
|
|
525
|
+
programId: updated._id,
|
|
526
|
+
programType: updated.programType,
|
|
527
|
+
status: updated.status,
|
|
528
|
+
actorId: ctx.actorId
|
|
529
|
+
}, ctx), ctx);
|
|
530
|
+
return updated;
|
|
531
|
+
}
|
|
532
|
+
async incrementUsage(id, ctx) {
|
|
533
|
+
const updated = await this.update(id, { $inc: { usedCount: 1 } }, {
|
|
534
|
+
throwOnNotFound: true,
|
|
535
|
+
lean: true,
|
|
536
|
+
...ctx
|
|
537
|
+
});
|
|
538
|
+
if (!updated) throw new ProgramNotFoundError(id);
|
|
539
|
+
return updated;
|
|
540
|
+
}
|
|
541
|
+
async decrementUsage(id, ctx) {
|
|
542
|
+
const updated = await this.update(id, { $inc: { usedCount: -1 } }, {
|
|
543
|
+
throwOnNotFound: true,
|
|
544
|
+
lean: true,
|
|
545
|
+
...ctx
|
|
546
|
+
});
|
|
547
|
+
if (!updated) throw new ProgramNotFoundError(id);
|
|
548
|
+
return updated;
|
|
549
|
+
}
|
|
550
|
+
async getCustomerUsage(id, customerId, ctx) {
|
|
551
|
+
const doc = await this.getById(id, {
|
|
552
|
+
throwOnNotFound: false,
|
|
553
|
+
lean: true,
|
|
554
|
+
...ctx
|
|
555
|
+
});
|
|
556
|
+
if (!doc) return 0;
|
|
557
|
+
const counts = doc.customerUsageCounts;
|
|
558
|
+
if (!counts) return 0;
|
|
559
|
+
if (counts instanceof Map) return counts.get(customerId) ?? 0;
|
|
560
|
+
return counts[customerId] ?? 0;
|
|
561
|
+
}
|
|
562
|
+
async incrementCustomerUsage(id, customerId, ctx) {
|
|
563
|
+
const updated = await this.update(id, { $inc: { [`customerUsageCounts.${customerId}`]: 1 } }, {
|
|
564
|
+
throwOnNotFound: true,
|
|
565
|
+
lean: true,
|
|
566
|
+
...ctx
|
|
567
|
+
});
|
|
568
|
+
if (!updated) throw new ProgramNotFoundError(id);
|
|
569
|
+
return updated;
|
|
570
|
+
}
|
|
571
|
+
async findActive(now, ctx) {
|
|
572
|
+
const currentDate = now ?? /* @__PURE__ */ new Date();
|
|
573
|
+
const filters = {
|
|
574
|
+
status: "active",
|
|
575
|
+
$or: [
|
|
576
|
+
{ startsAt: { $lte: currentDate } },
|
|
577
|
+
{ startsAt: { $exists: false } },
|
|
578
|
+
{ startsAt: null }
|
|
579
|
+
]
|
|
580
|
+
};
|
|
581
|
+
return (await this.findAll(filters, {
|
|
582
|
+
sort: {
|
|
583
|
+
priority: -1,
|
|
584
|
+
_id: -1
|
|
585
|
+
},
|
|
586
|
+
lean: true,
|
|
587
|
+
...ctx
|
|
588
|
+
})).filter((p) => !p.endsAt || p.endsAt >= currentDate).slice(0, 200);
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
//#endregion
|
|
592
|
+
//#region src/repositories/reward.repository.ts
|
|
593
|
+
var RewardRepository = class extends Repository {
|
|
594
|
+
constructor(model, plugins = []) {
|
|
595
|
+
super(model, plugins);
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
//#endregion
|
|
599
|
+
//#region src/repositories/rule.repository.ts
|
|
600
|
+
var RuleRepository = class extends Repository {
|
|
601
|
+
constructor(model, plugins = []) {
|
|
602
|
+
super(model, plugins);
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
//#endregion
|
|
606
|
+
//#region src/repositories/voucher.repository.ts
|
|
607
|
+
var VoucherRepository = class extends Repository {
|
|
608
|
+
dispatchDeps;
|
|
609
|
+
tenantField;
|
|
610
|
+
constructor(model, plugins = [], dispatchDeps = {}, tenantField = "organizationId") {
|
|
611
|
+
super(model, plugins);
|
|
612
|
+
this.dispatchDeps = dispatchDeps;
|
|
613
|
+
this.tenantField = tenantField;
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Copy the tenant id from `ctx` onto the write payload so the doc persists
|
|
617
|
+
* with the correct `organizationId` even when the host has opted OUT of the
|
|
618
|
+
* auto-wired `multiTenantPlugin` (e.g. arc hosts that scope at the
|
|
619
|
+
* framework layer — see `@classytic/order` CLAUDE.md: "Child repos set
|
|
620
|
+
* `organizationId` explicitly on the doc"). No-op when `ctx` omits the
|
|
621
|
+
* tenant id or the payload already carries it.
|
|
622
|
+
*/
|
|
623
|
+
_injectTenant(data, ctx) {
|
|
624
|
+
const tenantValue = ctx?.[this.tenantField];
|
|
625
|
+
if (tenantValue != null && data[this.tenantField] == null) data[this.tenantField] = tenantValue;
|
|
626
|
+
return data;
|
|
627
|
+
}
|
|
628
|
+
async create(data, options) {
|
|
629
|
+
this._injectTenant(data, options);
|
|
630
|
+
return super.create(data, options);
|
|
631
|
+
}
|
|
632
|
+
async createMany(docs, options) {
|
|
633
|
+
const ctx = options;
|
|
634
|
+
for (const doc of docs) this._injectTenant(doc, ctx);
|
|
635
|
+
return super.createMany(docs, options);
|
|
636
|
+
}
|
|
637
|
+
/** Domain verb: cancel a voucher (status transition + event). */
|
|
638
|
+
async cancel(id, ctx) {
|
|
639
|
+
const voucher = await this.getById(id, {
|
|
640
|
+
throwOnNotFound: false,
|
|
641
|
+
lean: true,
|
|
642
|
+
...ctx
|
|
643
|
+
});
|
|
644
|
+
if (!voucher) throw new VoucherNotFoundError(id);
|
|
645
|
+
const updated = await this.update(id, { status: "cancelled" }, {
|
|
646
|
+
throwOnNotFound: true,
|
|
647
|
+
lean: true,
|
|
648
|
+
...ctx
|
|
649
|
+
});
|
|
650
|
+
if (!updated) throw new VoucherNotFoundError(id);
|
|
651
|
+
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.VOUCHER_CANCELLED, {
|
|
652
|
+
voucherId: id,
|
|
653
|
+
code: voucher.code,
|
|
654
|
+
status: "cancelled"
|
|
655
|
+
}, ctx), ctx);
|
|
656
|
+
return updated;
|
|
657
|
+
}
|
|
658
|
+
/** Atomic usage increment + push redemption record. */
|
|
659
|
+
async incrementUsage(id, redemption, ctx) {
|
|
660
|
+
const updated = await this.update(id, {
|
|
661
|
+
$inc: { usedCount: 1 },
|
|
662
|
+
$push: { redemptions: redemption }
|
|
663
|
+
}, {
|
|
664
|
+
throwOnNotFound: true,
|
|
665
|
+
lean: true,
|
|
666
|
+
...ctx
|
|
667
|
+
});
|
|
668
|
+
if (!updated) throw new VoucherNotFoundError(id);
|
|
669
|
+
return updated;
|
|
670
|
+
}
|
|
671
|
+
/** Atomic balance delta + push ledger entry. */
|
|
672
|
+
async addLedgerEntry(id, entry, balanceDelta, ctx) {
|
|
673
|
+
const updated = await this.update(id, {
|
|
674
|
+
$inc: { currentBalance: balanceDelta },
|
|
675
|
+
$push: { balanceLedger: entry }
|
|
676
|
+
}, {
|
|
677
|
+
throwOnNotFound: true,
|
|
678
|
+
lean: true,
|
|
679
|
+
...ctx
|
|
680
|
+
});
|
|
681
|
+
if (!updated) throw new VoucherNotFoundError(id);
|
|
682
|
+
return updated;
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Batch expire by date.
|
|
686
|
+
*
|
|
687
|
+
* Routes through `findAll` + per-doc `update` instead of a raw
|
|
688
|
+
* `this.Model.updateMany`. Rationale:
|
|
689
|
+
* 1. `findAll` casts the filter through the schema (so an ObjectId
|
|
690
|
+
* tenant field on a write-time String context doesn't silently
|
|
691
|
+
* return 0 rows the way a raw `$match` would).
|
|
692
|
+
* 2. `update` fires the full hook pipeline (multi-tenant, audit,
|
|
693
|
+
* soft-delete) on every expiration — a bulk `updateMany` at the
|
|
694
|
+
* Model layer bypasses all of them.
|
|
695
|
+
* Volume is bounded per tenant (active + past-expiry vouchers), so an
|
|
696
|
+
* N+1 loop is acceptable for correctness.
|
|
697
|
+
*/
|
|
698
|
+
async expireByDate(before, ctx) {
|
|
699
|
+
const filter = {
|
|
700
|
+
status: "active",
|
|
701
|
+
expiresAt: { $lte: before }
|
|
702
|
+
};
|
|
703
|
+
const docs = await this.findAll(filter, {
|
|
704
|
+
lean: true,
|
|
705
|
+
...ctx
|
|
706
|
+
});
|
|
707
|
+
let modified = 0;
|
|
708
|
+
for (const doc of docs) if (await this.update(doc._id, { status: "expired" }, {
|
|
709
|
+
throwOnNotFound: false,
|
|
710
|
+
lean: true,
|
|
711
|
+
...ctx
|
|
712
|
+
})) modified++;
|
|
713
|
+
return modified;
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* Lookup voucher by unique code, scoped to the tenant in `ctx` when
|
|
717
|
+
* tenant scoping is configured.
|
|
718
|
+
*
|
|
719
|
+
* Manual tenant injection mirrors `expireByDate` and `ProgramRepository.findActive`
|
|
720
|
+
* — it makes the method work whether the host opts into the auto-wired
|
|
721
|
+
* `multiTenantPlugin` or enforces scoping at its own framework layer
|
|
722
|
+
* (e.g. arc's preset + `BaseController`). Without this, a host that
|
|
723
|
+
* runs the plugin off would leak vouchers across branches at
|
|
724
|
+
* validate/redeem/spend/topUp call sites.
|
|
725
|
+
*/
|
|
726
|
+
async getByCode(code, ctx) {
|
|
727
|
+
const filter = { code: code.toUpperCase() };
|
|
728
|
+
const tenantValue = ctx?.[this.tenantField];
|
|
729
|
+
if (tenantValue != null) filter[this.tenantField] = tenantValue;
|
|
730
|
+
return this.getByQuery(filter, {
|
|
731
|
+
throwOnNotFound: false,
|
|
732
|
+
lean: true,
|
|
733
|
+
...ctx
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Idempotency check on nested arrays. Routes through mongokit's `count`
|
|
738
|
+
* so `multiTenantPlugin` injects the configured tenant field from
|
|
739
|
+
* `ctx` — a non-tenant-scoped check would cross organization boundaries.
|
|
740
|
+
*/
|
|
741
|
+
async hasIdempotencyKey(id, key, ctx) {
|
|
742
|
+
const filter = {
|
|
743
|
+
_id: id,
|
|
744
|
+
$or: [{ "redemptions.idempotencyKey": key }, { "balanceLedger.idempotencyKey": key }]
|
|
745
|
+
};
|
|
746
|
+
return await this.count(filter, ctx) > 0;
|
|
747
|
+
}
|
|
748
|
+
};
|
|
749
|
+
//#endregion
|
|
750
|
+
//#region src/repositories/create-repositories.ts
|
|
751
|
+
function createRepositories(models, plugins, tenant, dispatchDeps = {}) {
|
|
752
|
+
const tenantPlugins = tenant?.enabled ? [multiTenantPlugin({
|
|
753
|
+
tenantField: tenant.tenantField,
|
|
754
|
+
contextKey: tenant.contextKey,
|
|
755
|
+
fieldType: tenant.fieldType
|
|
756
|
+
})] : [];
|
|
757
|
+
return {
|
|
758
|
+
program: new ProgramRepository(models.Program, [...tenantPlugins, ...plugins?.program ?? []], dispatchDeps),
|
|
759
|
+
rule: new RuleRepository(models.Rule, [...tenantPlugins, ...plugins?.rule ?? []]),
|
|
760
|
+
reward: new RewardRepository(models.Reward, [...tenantPlugins, ...plugins?.reward ?? []]),
|
|
761
|
+
voucher: new VoucherRepository(models.Voucher, [...tenantPlugins, ...plugins?.voucher ?? []], dispatchDeps, tenant?.tenantField)
|
|
22
762
|
};
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
763
|
+
}
|
|
764
|
+
//#endregion
|
|
765
|
+
//#region src/services/evaluation.service.ts
|
|
766
|
+
/**
|
|
767
|
+
* Deterministic, collision-resistant hash of the evaluated cart. The hash
|
|
768
|
+
* covers everything the evaluation algorithm depends on: normalized line
|
|
769
|
+
* items (sorted), subtotal, applied codes, and customer identity. Two
|
|
770
|
+
* evaluations over an identical cart yield the same hash; any mutation —
|
|
771
|
+
* quantity, unit price, added/removed line, different code, different
|
|
772
|
+
* customer — yields a different hash.
|
|
773
|
+
*/
|
|
774
|
+
function computeCartHash(input) {
|
|
775
|
+
const items = [...input.items].map((item) => ({
|
|
776
|
+
productId: item.productId ?? "",
|
|
777
|
+
sku: item.sku ?? "",
|
|
778
|
+
categoryId: item.categoryId ?? "",
|
|
779
|
+
quantity: item.quantity,
|
|
780
|
+
unitPrice: item.unitPrice,
|
|
781
|
+
lineTotal: item.lineTotal ?? item.quantity * item.unitPrice
|
|
782
|
+
})).sort((a, b) => {
|
|
783
|
+
if (a.productId !== b.productId) return a.productId < b.productId ? -1 : 1;
|
|
784
|
+
if (a.sku !== b.sku) return a.sku < b.sku ? -1 : 1;
|
|
785
|
+
if (a.unitPrice !== b.unitPrice) return a.unitPrice - b.unitPrice;
|
|
786
|
+
return a.quantity - b.quantity;
|
|
787
|
+
});
|
|
788
|
+
const codes = [...input.codes ?? []].map((c) => c.toUpperCase()).sort();
|
|
789
|
+
const tags = [...input.customerTags ?? []].sort();
|
|
790
|
+
const canonical = JSON.stringify({
|
|
791
|
+
items,
|
|
792
|
+
subtotal: input.subtotal,
|
|
793
|
+
codes,
|
|
794
|
+
customerId: input.customerId ?? null,
|
|
795
|
+
customerTags: tags
|
|
796
|
+
});
|
|
797
|
+
return createHash("sha256").update(canonical).digest("hex");
|
|
798
|
+
}
|
|
799
|
+
var EvaluationService = class {
|
|
800
|
+
pendingEvaluations = /* @__PURE__ */ new Map();
|
|
801
|
+
constructor(programRepo, ruleRepo, rewardRepo, voucherRepo, unitOfWork, dispatchDeps, config) {
|
|
802
|
+
this.programRepo = programRepo;
|
|
803
|
+
this.ruleRepo = ruleRepo;
|
|
804
|
+
this.rewardRepo = rewardRepo;
|
|
805
|
+
this.voucherRepo = voucherRepo;
|
|
806
|
+
this.unitOfWork = unitOfWork;
|
|
807
|
+
this.dispatchDeps = dispatchDeps;
|
|
808
|
+
this.config = config;
|
|
809
|
+
}
|
|
810
|
+
async evaluate(input, ctx) {
|
|
811
|
+
return this.doEvaluate(input, ctx, false);
|
|
812
|
+
}
|
|
813
|
+
async preview(input, ctx) {
|
|
814
|
+
return this.doEvaluate(input, ctx, true);
|
|
815
|
+
}
|
|
816
|
+
async commit(evaluationId, orderId, ctx, options = {}) {
|
|
817
|
+
const stored = this.pendingEvaluations.get(evaluationId);
|
|
818
|
+
if (!stored) throw new EvaluationNotFoundError(evaluationId);
|
|
819
|
+
if (options.cartHash !== void 0 && options.cartHash !== stored.cartHash) throw new CartHashMismatchError(evaluationId);
|
|
820
|
+
return this.unitOfWork.withTransaction(async (session) => {
|
|
821
|
+
for (const usage of stored.programUsages) {
|
|
822
|
+
await this.programRepo.incrementUsage(usage.programId, {
|
|
823
|
+
...ctx,
|
|
824
|
+
session
|
|
825
|
+
});
|
|
826
|
+
if (stored.customerId) await this.programRepo.incrementCustomerUsage(usage.programId, stored.customerId, {
|
|
827
|
+
...ctx,
|
|
828
|
+
session
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
for (const usage of stored.voucherUsages) await this.voucherRepo.incrementUsage(usage.voucherId, {
|
|
832
|
+
orderId,
|
|
833
|
+
discountAmount: usage.discountAmount,
|
|
834
|
+
redeemedAt: /* @__PURE__ */ new Date()
|
|
835
|
+
}, {
|
|
836
|
+
...ctx,
|
|
837
|
+
session
|
|
838
|
+
});
|
|
839
|
+
this.pendingEvaluations.delete(evaluationId);
|
|
840
|
+
const commitResult = {
|
|
841
|
+
evaluationId,
|
|
842
|
+
orderId,
|
|
843
|
+
totalDiscount: stored.result.totalDiscount,
|
|
844
|
+
programsCommitted: stored.programUsages.length,
|
|
845
|
+
vouchersUsed: stored.voucherUsages.length
|
|
846
|
+
};
|
|
847
|
+
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.EVALUATION_COMMITTED, {
|
|
848
|
+
evaluationId,
|
|
849
|
+
orderId,
|
|
850
|
+
totalDiscount: stored.result.totalDiscount
|
|
851
|
+
}, ctx), {
|
|
852
|
+
...ctx,
|
|
853
|
+
session
|
|
854
|
+
});
|
|
855
|
+
return commitResult;
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
async rollback(evaluationId, ctx) {
|
|
859
|
+
if (!this.pendingEvaluations.get(evaluationId)) return;
|
|
860
|
+
this.pendingEvaluations.delete(evaluationId);
|
|
861
|
+
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.EVALUATION_ROLLED_BACK, { evaluationId }, ctx), ctx);
|
|
862
|
+
}
|
|
863
|
+
async doEvaluate(input, ctx, isPreview) {
|
|
864
|
+
const submittedCodes = (input.codes ?? []).map((c) => c.toUpperCase());
|
|
865
|
+
const programs = await this.programRepo.findActive(void 0, ctx);
|
|
866
|
+
const programIds = programs.map((p) => p._id);
|
|
867
|
+
const [allRules, allRewards] = await Promise.all([this.ruleRepo.findAll({ programId: { $in: programIds } }, ctx), this.rewardRepo.findAll({ programId: { $in: programIds } }, ctx)]);
|
|
868
|
+
const rulesByProgram = groupBy(allRules, (r) => r.programId);
|
|
869
|
+
const rewardsByProgram = groupBy(allRewards, (r) => r.programId);
|
|
870
|
+
const voucherMap = /* @__PURE__ */ new Map();
|
|
871
|
+
for (const code of submittedCodes) {
|
|
872
|
+
const voucher = await this.voucherRepo.getByCode(code, ctx);
|
|
873
|
+
if (voucher && voucher.status === "active" && voucher.usedCount < voucher.usageLimit) voucherMap.set(code, {
|
|
874
|
+
voucherId: voucher._id,
|
|
875
|
+
programId: voucher.programId,
|
|
876
|
+
balance: voucher.currentBalance
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
const appliedDiscounts = [];
|
|
880
|
+
const freeProducts = [];
|
|
881
|
+
const appliedCodes = [];
|
|
882
|
+
const rejectedCodes = [];
|
|
883
|
+
const warnings = [];
|
|
884
|
+
const programsApplied = [];
|
|
885
|
+
const programUsages = [];
|
|
886
|
+
const voucherUsages = [];
|
|
887
|
+
let runningSubtotal = input.subtotal;
|
|
888
|
+
let exclusiveApplied = false;
|
|
889
|
+
let stackableCount = 0;
|
|
890
|
+
for (const program of programs) {
|
|
891
|
+
if (program.maxUsageTotal && program.usedCount >= program.maxUsageTotal) continue;
|
|
892
|
+
if (!this.isCustomerEligible(program, input)) continue;
|
|
893
|
+
if (program.maxUsagePerCustomer && input.customerId) {
|
|
894
|
+
if (await this.programRepo.getCustomerUsage(program._id, input.customerId, ctx) >= program.maxUsagePerCustomer) {
|
|
895
|
+
warnings.push(`Customer has reached max usage (${program.maxUsagePerCustomer}) for "${program.name}"`);
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
if (program.stackingMode === "exclusive" && exclusiveApplied) continue;
|
|
900
|
+
if (program.stackingMode === "stackable" && stackableCount >= this.config.evaluation.maxStackablePromotions) {
|
|
901
|
+
warnings.push(`Max stackable promotions (${this.config.evaluation.maxStackablePromotions}) reached`);
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
904
|
+
const rules = rulesByProgram.get(String(program._id)) ?? [];
|
|
905
|
+
const rewards = rewardsByProgram.get(String(program._id)) ?? [];
|
|
906
|
+
if (rules.length === 0 || rewards.length === 0) continue;
|
|
907
|
+
const matchResult = this.matchBestRule(program, rules, input.items, input.subtotal, submittedCodes, voucherMap);
|
|
908
|
+
if (!matchResult.matched) {
|
|
909
|
+
if (matchResult.rejectedCode) rejectedCodes.push(matchResult.rejectedCode);
|
|
910
|
+
continue;
|
|
911
|
+
}
|
|
912
|
+
const applicableRewards = this.filterRewardsByRule(rewards, matchResult.matchedRuleId);
|
|
913
|
+
for (const reward of applicableRewards) if (reward.rewardType === "discount") {
|
|
914
|
+
const discount = this.computeDiscount(reward, input.items, runningSubtotal);
|
|
915
|
+
if (discount <= 0) continue;
|
|
916
|
+
appliedDiscounts.push({
|
|
917
|
+
programId: program._id,
|
|
918
|
+
programName: program.name,
|
|
919
|
+
rewardId: reward._id,
|
|
920
|
+
type: reward.discountMode ?? "fixed",
|
|
921
|
+
scope: reward.discountScope,
|
|
922
|
+
amount: discount,
|
|
923
|
+
description: this.describeDiscount(reward, program),
|
|
924
|
+
voucherCode: matchResult.matchedCode
|
|
925
|
+
});
|
|
926
|
+
runningSubtotal = Math.max(0, runningSubtotal - discount);
|
|
927
|
+
} else if (reward.rewardType === "free_product") freeProducts.push({
|
|
928
|
+
programId: program._id,
|
|
929
|
+
programName: program.name,
|
|
930
|
+
rewardId: reward._id,
|
|
931
|
+
productId: reward.freeProductId,
|
|
932
|
+
productSku: reward.freeProductSku,
|
|
933
|
+
quantity: reward.freeQuantity,
|
|
934
|
+
description: `Free product from "${program.name}"`
|
|
935
|
+
});
|
|
936
|
+
programsApplied.push(program._id);
|
|
937
|
+
programUsages.push({ programId: program._id });
|
|
938
|
+
if (matchResult.matchedCode) {
|
|
939
|
+
appliedCodes.push(matchResult.matchedCode);
|
|
940
|
+
const voucherInfo = voucherMap.get(matchResult.matchedCode);
|
|
941
|
+
if (voucherInfo) {
|
|
942
|
+
const totalProgramDiscount = appliedDiscounts.filter((d) => d.programId === program._id).reduce((sum, d) => sum + d.amount, 0);
|
|
943
|
+
voucherUsages.push({
|
|
944
|
+
voucherId: voucherInfo.voucherId,
|
|
945
|
+
code: matchResult.matchedCode,
|
|
946
|
+
discountAmount: totalProgramDiscount
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
if (program.stackingMode === "exclusive") exclusiveApplied = true;
|
|
951
|
+
else stackableCount++;
|
|
952
|
+
}
|
|
953
|
+
for (const code of submittedCodes) if (!appliedCodes.includes(code) && !rejectedCodes.some((r) => r.code === code)) rejectedCodes.push({
|
|
954
|
+
code,
|
|
955
|
+
reason: "No matching active program found for this code"
|
|
956
|
+
});
|
|
957
|
+
const totalDiscount = appliedDiscounts.reduce((sum, d) => sum + d.amount, 0);
|
|
958
|
+
const evaluationId = randomBytes(16).toString("hex");
|
|
959
|
+
const cartHash = computeCartHash(input);
|
|
960
|
+
const result = {
|
|
961
|
+
evaluationId,
|
|
962
|
+
cartHash,
|
|
963
|
+
appliedDiscounts,
|
|
964
|
+
freeProducts,
|
|
965
|
+
totalDiscount,
|
|
966
|
+
subtotalAfterDiscount: Math.max(0, input.subtotal - totalDiscount),
|
|
967
|
+
appliedCodes,
|
|
968
|
+
rejectedCodes,
|
|
969
|
+
warnings,
|
|
970
|
+
isPreview,
|
|
971
|
+
programsApplied
|
|
972
|
+
};
|
|
973
|
+
if (!isPreview) this.pendingEvaluations.set(evaluationId, {
|
|
974
|
+
result,
|
|
975
|
+
ctx,
|
|
976
|
+
customerId: input.customerId,
|
|
977
|
+
programUsages,
|
|
978
|
+
voucherUsages,
|
|
979
|
+
createdAt: Date.now(),
|
|
980
|
+
cartHash
|
|
981
|
+
});
|
|
982
|
+
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.EVALUATION_COMPLETED, {
|
|
983
|
+
evaluationId,
|
|
984
|
+
totalDiscount,
|
|
985
|
+
programsApplied: programsApplied.length,
|
|
986
|
+
codesUsed: appliedCodes,
|
|
987
|
+
isPreview
|
|
988
|
+
}, ctx), ctx);
|
|
989
|
+
return result;
|
|
990
|
+
}
|
|
991
|
+
isCustomerEligible(program, input) {
|
|
992
|
+
if (program.applicableCustomerIds.length > 0) {
|
|
993
|
+
if (!input.customerId) return false;
|
|
994
|
+
if (!program.applicableCustomerIds.includes(input.customerId)) return false;
|
|
995
|
+
}
|
|
996
|
+
if (program.applicableCustomerTags.length > 0) {
|
|
997
|
+
const customerTags = input.customerTags ?? [];
|
|
998
|
+
if (customerTags.length === 0) return false;
|
|
999
|
+
if (!program.applicableCustomerTags.some((tag) => customerTags.includes(tag))) return false;
|
|
1000
|
+
}
|
|
1001
|
+
return true;
|
|
1002
|
+
}
|
|
1003
|
+
matchBestRule(program, rules, items, subtotal, submittedCodes, voucherMap) {
|
|
1004
|
+
let bestMatch = { matched: false };
|
|
1005
|
+
let bestThreshold = -1;
|
|
1006
|
+
let lastRejected;
|
|
1007
|
+
for (const rule of rules) {
|
|
1008
|
+
const singleResult = this.matchSingleRule(program, rule, items, subtotal, submittedCodes, voucherMap);
|
|
1009
|
+
if (singleResult.rejectedCode) lastRejected = singleResult.rejectedCode;
|
|
1010
|
+
if (!singleResult.matched) continue;
|
|
1011
|
+
const threshold = (rule.minimumAmount ?? 0) * 1e3 + (rule.minimumQuantity ?? 0);
|
|
1012
|
+
if (threshold > bestThreshold) {
|
|
1013
|
+
bestThreshold = threshold;
|
|
1014
|
+
bestMatch = singleResult;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
if (!bestMatch.matched && lastRejected) bestMatch.rejectedCode = lastRejected;
|
|
1018
|
+
return bestMatch;
|
|
1019
|
+
}
|
|
1020
|
+
matchSingleRule(program, rule, items, subtotal, submittedCodes, voucherMap) {
|
|
1021
|
+
if (program.triggerMode === "code") {
|
|
1022
|
+
if (rule.code) {
|
|
1023
|
+
if (!submittedCodes.includes(rule.code)) return { matched: false };
|
|
1024
|
+
if (program.programType === "coupon" || program.programType === "discount_code") {
|
|
1025
|
+
const voucherInfo = voucherMap.get(rule.code);
|
|
1026
|
+
if (!voucherInfo || String(voucherInfo.programId) !== String(program._id)) return {
|
|
1027
|
+
matched: false,
|
|
1028
|
+
rejectedCode: {
|
|
1029
|
+
code: rule.code,
|
|
1030
|
+
reason: "Invalid or exhausted voucher"
|
|
1031
|
+
}
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
} else if (![...voucherMap.entries()].find(([code, info]) => String(info.programId) === String(program._id) && submittedCodes.includes(code))) return { matched: false };
|
|
1035
|
+
}
|
|
1036
|
+
const now = /* @__PURE__ */ new Date();
|
|
1037
|
+
if (rule.startsAt && rule.startsAt > now) return { matched: false };
|
|
1038
|
+
if (rule.endsAt && rule.endsAt < now) return { matched: false };
|
|
1039
|
+
if (rule.minimumAmount > 0 && subtotal < rule.minimumAmount) return { matched: false };
|
|
1040
|
+
if (rule.minimumQuantity > 0) {
|
|
1041
|
+
if (items.reduce((sum, item) => sum + item.quantity, 0) < rule.minimumQuantity) return { matched: false };
|
|
1042
|
+
}
|
|
1043
|
+
if (rule.applicableProductIds.length > 0) {
|
|
1044
|
+
if (!items.some((item) => rule.applicableProductIds.includes(item.productId))) return { matched: false };
|
|
1045
|
+
}
|
|
1046
|
+
if (rule.applicableCategories.length > 0) {
|
|
1047
|
+
if (!items.some((item) => item.categoryId && rule.applicableCategories.includes(item.categoryId))) return { matched: false };
|
|
1048
|
+
}
|
|
1049
|
+
if (rule.applicableSkus.length > 0) {
|
|
1050
|
+
if (!items.some((item) => item.sku && rule.applicableSkus.includes(item.sku))) return { matched: false };
|
|
1051
|
+
}
|
|
1052
|
+
if (rule.buyQuantity && rule.buyQuantity > 0) {
|
|
1053
|
+
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 };
|
|
1054
|
+
}
|
|
1055
|
+
let matchedCode;
|
|
1056
|
+
if (program.triggerMode === "code") if (rule.code && submittedCodes.includes(rule.code)) matchedCode = rule.code;
|
|
1057
|
+
else matchedCode = [...voucherMap.entries()].find(([code, info]) => String(info.programId) === String(program._id) && submittedCodes.includes(code))?.[0];
|
|
1058
|
+
return {
|
|
1059
|
+
matched: true,
|
|
1060
|
+
matchedCode,
|
|
1061
|
+
matchedRuleId: rule._id
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
filterRewardsByRule(rewards, matchedRuleId) {
|
|
1065
|
+
if (!matchedRuleId) return rewards;
|
|
1066
|
+
if (rewards.filter((r) => r.ruleId).length === 0) return rewards;
|
|
1067
|
+
return rewards.filter((r) => !r.ruleId || String(r.ruleId) === String(matchedRuleId));
|
|
1068
|
+
}
|
|
1069
|
+
computeDiscount(reward, items, runningSubtotal) {
|
|
1070
|
+
if (!reward.discountAmount || reward.discountAmount <= 0) return 0;
|
|
1071
|
+
let discount;
|
|
1072
|
+
if (reward.discountScope === "cheapest") {
|
|
1073
|
+
const eligibleItems = this.getEligibleItems(reward, items);
|
|
1074
|
+
if (eligibleItems.length === 0) return 0;
|
|
1075
|
+
const cheapest = Math.min(...eligibleItems.map((item) => item.unitPrice));
|
|
1076
|
+
discount = reward.discountMode === "percentage" ? cheapest * (reward.discountAmount / 100) : Math.min(reward.discountAmount, cheapest);
|
|
1077
|
+
} else if (reward.discountScope === "specific_products") {
|
|
1078
|
+
const eligibleTotal = this.getEligibleItems(reward, items).reduce((sum, item) => sum + (item.lineTotal ?? item.unitPrice * item.quantity), 0);
|
|
1079
|
+
if (eligibleTotal <= 0) return 0;
|
|
1080
|
+
discount = reward.discountMode === "percentage" ? eligibleTotal * (reward.discountAmount / 100) : Math.min(reward.discountAmount, eligibleTotal);
|
|
1081
|
+
} else discount = reward.discountMode === "percentage" ? runningSubtotal * (reward.discountAmount / 100) : Math.min(reward.discountAmount, runningSubtotal);
|
|
1082
|
+
if (reward.maxDiscountAmount && discount > reward.maxDiscountAmount) discount = reward.maxDiscountAmount;
|
|
1083
|
+
return Math.min(Math.round(discount * 100) / 100, runningSubtotal);
|
|
1084
|
+
}
|
|
1085
|
+
getEligibleItems(reward, items) {
|
|
1086
|
+
if (reward.applicableProductIds.length === 0) return items;
|
|
1087
|
+
return items.filter((item) => reward.applicableProductIds.includes(item.productId));
|
|
1088
|
+
}
|
|
1089
|
+
describeDiscount(reward, program) {
|
|
1090
|
+
if (reward.discountMode === "percentage") return `${reward.discountAmount}% off from "${program.name}"`;
|
|
1091
|
+
return `${reward.discountAmount} off from "${program.name}"`;
|
|
1092
|
+
}
|
|
1093
|
+
};
|
|
1094
|
+
function groupBy(items, keyFn) {
|
|
1095
|
+
const map = /* @__PURE__ */ new Map();
|
|
1096
|
+
for (const item of items) {
|
|
1097
|
+
const key = String(keyFn(item));
|
|
1098
|
+
const group = map.get(key);
|
|
1099
|
+
if (group) group.push(item);
|
|
1100
|
+
else map.set(key, [item]);
|
|
1101
|
+
}
|
|
1102
|
+
return map;
|
|
1103
|
+
}
|
|
1104
|
+
//#endregion
|
|
1105
|
+
//#region src/utils/code-generator.ts
|
|
1106
|
+
const CHARSET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
1107
|
+
function generateCode(length, prefix = "") {
|
|
1108
|
+
const bytes = randomBytes(length);
|
|
1109
|
+
let code = "";
|
|
1110
|
+
for (let i = 0; i < length; i++) code += CHARSET[bytes[i] % 32];
|
|
1111
|
+
return prefix ? `${prefix}${code}` : code;
|
|
1112
|
+
}
|
|
1113
|
+
function generateCodes(count, length, prefix = "") {
|
|
1114
|
+
const codes = /* @__PURE__ */ new Set();
|
|
1115
|
+
let attempts = 0;
|
|
1116
|
+
const maxAttempts = count * 10;
|
|
1117
|
+
while (codes.size < count && attempts < maxAttempts) {
|
|
1118
|
+
codes.add(generateCode(length, prefix));
|
|
1119
|
+
attempts++;
|
|
1120
|
+
}
|
|
1121
|
+
return [...codes];
|
|
1122
|
+
}
|
|
1123
|
+
//#endregion
|
|
1124
|
+
//#region src/services/voucher.service.ts
|
|
1125
|
+
/**
|
|
1126
|
+
* VoucherService — multi-repo domain orchestration only.
|
|
1127
|
+
*
|
|
1128
|
+
* Code generation, redemption (transactional + idempotency), gift card
|
|
1129
|
+
* spend/topUp, and validation live here because they need programRepo +
|
|
1130
|
+
* voucherRepo + config + transactions.
|
|
1131
|
+
*
|
|
1132
|
+
* Simple lookups (getById, getByCode, getAll) and the cancel domain verb
|
|
1133
|
+
* live on VoucherRepository directly — callers use the repo. §2/§3.
|
|
1134
|
+
*/
|
|
1135
|
+
var VoucherService = class {
|
|
1136
|
+
constructor(voucherRepo, programRepo, unitOfWork, dispatchDeps, config) {
|
|
1137
|
+
this.voucherRepo = voucherRepo;
|
|
1138
|
+
this.programRepo = programRepo;
|
|
1139
|
+
this.unitOfWork = unitOfWork;
|
|
1140
|
+
this.dispatchDeps = dispatchDeps;
|
|
1141
|
+
this.config = config;
|
|
1142
|
+
}
|
|
1143
|
+
async generateCodes(input, ctx) {
|
|
1144
|
+
const program = await this.programRepo.getById(input.programId, {
|
|
1145
|
+
throwOnNotFound: false,
|
|
1146
|
+
lean: true,
|
|
1147
|
+
...ctx
|
|
1148
|
+
});
|
|
1149
|
+
if (!program) throw new ProgramNotFoundError(input.programId);
|
|
1150
|
+
const { codeLength, codePrefix } = this.config.voucher;
|
|
1151
|
+
const codes = generateCodes(input.count, codeLength, codePrefix);
|
|
1152
|
+
const expiresAt = input.expiresAt ?? (this.config.voucher.defaultExpiryDays ? new Date(Date.now() + this.config.voucher.defaultExpiryDays * 864e5) : void 0);
|
|
1153
|
+
const isGiftCard = program.programType === "gift_card";
|
|
1154
|
+
const data = codes.map((code) => ({
|
|
1155
|
+
programId: input.programId,
|
|
1156
|
+
code,
|
|
1157
|
+
status: "active",
|
|
1158
|
+
customerId: input.customerId,
|
|
1159
|
+
usageLimit: isGiftCard ? 999999 : 1,
|
|
1160
|
+
usedCount: 0,
|
|
1161
|
+
...isGiftCard ? {
|
|
1162
|
+
initialBalance: 0,
|
|
1163
|
+
currentBalance: 0,
|
|
1164
|
+
balanceLedger: []
|
|
1165
|
+
} : {},
|
|
1166
|
+
...expiresAt ? { expiresAt } : {},
|
|
1167
|
+
redemptions: [],
|
|
1168
|
+
...input.metadata ? { metadata: input.metadata } : {}
|
|
1169
|
+
}));
|
|
1170
|
+
const vouchers = await this.voucherRepo.createMany(data, ctx);
|
|
1171
|
+
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.VOUCHER_GENERATED, {
|
|
1172
|
+
programId: input.programId,
|
|
1173
|
+
voucherIds: vouchers.map((v) => v._id),
|
|
1174
|
+
codes: vouchers.map((v) => v.code),
|
|
1175
|
+
count: vouchers.length,
|
|
1176
|
+
actorId: ctx.actorId
|
|
1177
|
+
}, ctx), ctx);
|
|
1178
|
+
return vouchers;
|
|
1179
|
+
}
|
|
1180
|
+
async generateSingleCode(input, ctx) {
|
|
1181
|
+
const program = await this.programRepo.getById(input.programId, {
|
|
1182
|
+
throwOnNotFound: false,
|
|
1183
|
+
lean: true,
|
|
1184
|
+
...ctx
|
|
1185
|
+
});
|
|
1186
|
+
if (!program) throw new ProgramNotFoundError(input.programId);
|
|
1187
|
+
const code = input.code?.toUpperCase() ?? generateCode(this.config.voucher.codeLength, this.config.voucher.codePrefix);
|
|
1188
|
+
const isGiftCard = program.programType === "gift_card";
|
|
1189
|
+
const balance = input.initialBalance ?? 0;
|
|
1190
|
+
const expiresAt = input.expiresAt ?? (this.config.voucher.defaultExpiryDays ? new Date(Date.now() + this.config.voucher.defaultExpiryDays * 864e5) : void 0);
|
|
1191
|
+
const data = {
|
|
1192
|
+
programId: input.programId,
|
|
1193
|
+
code,
|
|
1194
|
+
status: "active",
|
|
1195
|
+
customerId: input.customerId,
|
|
1196
|
+
usageLimit: isGiftCard ? 999999 : 1,
|
|
1197
|
+
usedCount: 0,
|
|
1198
|
+
...isGiftCard ? {
|
|
1199
|
+
initialBalance: balance,
|
|
1200
|
+
currentBalance: balance,
|
|
1201
|
+
balanceLedger: []
|
|
1202
|
+
} : {},
|
|
1203
|
+
...expiresAt ? { expiresAt } : {},
|
|
1204
|
+
redemptions: [],
|
|
1205
|
+
...input.metadata ? { metadata: input.metadata } : {}
|
|
1206
|
+
};
|
|
1207
|
+
const voucher = await this.voucherRepo.create(data, ctx);
|
|
1208
|
+
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.VOUCHER_GENERATED, {
|
|
1209
|
+
programId: input.programId,
|
|
1210
|
+
voucherIds: [voucher._id],
|
|
1211
|
+
codes: [voucher.code],
|
|
1212
|
+
count: 1,
|
|
1213
|
+
actorId: ctx.actorId
|
|
1214
|
+
}, ctx), ctx);
|
|
1215
|
+
return voucher;
|
|
1216
|
+
}
|
|
1217
|
+
async validateCode(code, ctx) {
|
|
1218
|
+
const voucher = await this.voucherRepo.getByCode(code, ctx);
|
|
1219
|
+
if (!voucher) return {
|
|
1220
|
+
valid: false,
|
|
1221
|
+
error: "Voucher not found"
|
|
1222
|
+
};
|
|
1223
|
+
if (voucher.status === "expired") return {
|
|
1224
|
+
valid: false,
|
|
1225
|
+
error: "Voucher has expired"
|
|
1226
|
+
};
|
|
1227
|
+
if (voucher.status === "cancelled") return {
|
|
1228
|
+
valid: false,
|
|
1229
|
+
error: "Voucher has been cancelled"
|
|
1230
|
+
};
|
|
1231
|
+
if (voucher.status === "used") return {
|
|
1232
|
+
valid: false,
|
|
1233
|
+
error: "Voucher has already been used"
|
|
1234
|
+
};
|
|
1235
|
+
if (voucher.expiresAt && voucher.expiresAt < /* @__PURE__ */ new Date()) return {
|
|
1236
|
+
valid: false,
|
|
1237
|
+
error: "Voucher has expired"
|
|
1238
|
+
};
|
|
1239
|
+
if (voucher.usedCount >= voucher.usageLimit) return {
|
|
1240
|
+
valid: false,
|
|
1241
|
+
error: "Voucher has reached its usage limit"
|
|
1242
|
+
};
|
|
1243
|
+
const program = await this.programRepo.getById(voucher.programId, {
|
|
1244
|
+
throwOnNotFound: false,
|
|
1245
|
+
lean: true,
|
|
1246
|
+
...ctx
|
|
1247
|
+
});
|
|
1248
|
+
return {
|
|
1249
|
+
valid: true,
|
|
1250
|
+
voucher: {
|
|
1251
|
+
code: voucher.code,
|
|
1252
|
+
programId: voucher.programId,
|
|
1253
|
+
programType: program?.programType ?? "unknown",
|
|
1254
|
+
status: voucher.status,
|
|
1255
|
+
remainingUses: voucher.usageLimit - voucher.usedCount,
|
|
1256
|
+
...voucher.currentBalance !== void 0 ? { currentBalance: voucher.currentBalance } : {},
|
|
1257
|
+
...voucher.expiresAt ? { expiresAt: voucher.expiresAt } : {}
|
|
1258
|
+
}
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
async redeem(input, ctx) {
|
|
1262
|
+
return this.unitOfWork.withTransaction(async (session) => {
|
|
1263
|
+
const voucher = await this.voucherRepo.getByCode(input.code, {
|
|
1264
|
+
...ctx,
|
|
1265
|
+
session
|
|
1266
|
+
});
|
|
1267
|
+
if (!voucher) throw new VoucherNotFoundError(input.code);
|
|
1268
|
+
this.assertVoucherUsable(voucher);
|
|
1269
|
+
if (input.idempotencyKey) {
|
|
1270
|
+
if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey)) throw new DuplicateRedemptionError(input.idempotencyKey);
|
|
1271
|
+
}
|
|
1272
|
+
const redemption = {
|
|
1273
|
+
orderId: input.orderId,
|
|
1274
|
+
customerId: input.customerId,
|
|
1275
|
+
discountAmount: input.discountAmount,
|
|
1276
|
+
redeemedAt: /* @__PURE__ */ new Date(),
|
|
1277
|
+
...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}
|
|
1278
|
+
};
|
|
1279
|
+
const updated = await this.voucherRepo.incrementUsage(voucher._id, redemption, {
|
|
1280
|
+
...ctx,
|
|
1281
|
+
session
|
|
1282
|
+
});
|
|
1283
|
+
if (updated.usedCount >= updated.usageLimit) await this.voucherRepo.update(voucher._id, { status: "used" }, {
|
|
1284
|
+
lean: true,
|
|
1285
|
+
...ctx,
|
|
1286
|
+
session
|
|
1287
|
+
});
|
|
1288
|
+
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.VOUCHER_REDEEMED, {
|
|
1289
|
+
voucherId: voucher._id,
|
|
1290
|
+
code: voucher.code,
|
|
1291
|
+
orderId: input.orderId,
|
|
1292
|
+
discountAmount: input.discountAmount,
|
|
1293
|
+
customerId: input.customerId
|
|
1294
|
+
}, ctx), {
|
|
1295
|
+
...ctx,
|
|
1296
|
+
session
|
|
1297
|
+
});
|
|
1298
|
+
return updated;
|
|
1299
|
+
});
|
|
1300
|
+
}
|
|
1301
|
+
async getBalance(code, ctx) {
|
|
1302
|
+
const voucher = await this.voucherRepo.getByCode(code, ctx);
|
|
1303
|
+
if (!voucher) throw new VoucherNotFoundError(code);
|
|
1304
|
+
if (voucher.initialBalance === void 0) throw new ValidationError("Voucher is not a gift card");
|
|
1305
|
+
return {
|
|
1306
|
+
code: voucher.code,
|
|
1307
|
+
initialBalance: voucher.initialBalance ?? 0,
|
|
1308
|
+
currentBalance: voucher.currentBalance ?? 0,
|
|
1309
|
+
spent: (voucher.initialBalance ?? 0) - (voucher.currentBalance ?? 0),
|
|
1310
|
+
voucherId: voucher._id
|
|
1311
|
+
};
|
|
1312
|
+
}
|
|
1313
|
+
async spend(input, ctx) {
|
|
1314
|
+
if (input.amount <= 0) throw new ValidationError("Spend amount must be positive");
|
|
1315
|
+
try {
|
|
1316
|
+
return await this.spendInTransaction(input, ctx);
|
|
1317
|
+
} catch (err) {
|
|
1318
|
+
if (isWriteConflict(err)) throw new ConcurrencyConflictError("voucher", input.code, err);
|
|
1319
|
+
throw err;
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
async spendInTransaction(input, ctx) {
|
|
1323
|
+
return this.unitOfWork.withTransaction(async (session) => {
|
|
1324
|
+
const voucher = await this.voucherRepo.getByCode(input.code, {
|
|
1325
|
+
...ctx,
|
|
1326
|
+
session
|
|
1327
|
+
});
|
|
1328
|
+
if (!voucher) throw new VoucherNotFoundError(input.code);
|
|
1329
|
+
this.assertVoucherUsable(voucher);
|
|
1330
|
+
const balance = voucher.currentBalance ?? 0;
|
|
1331
|
+
if (!this.config.giftCard.allowNegativeBalance && balance < input.amount) throw new InsufficientBalanceError(input.code, balance, input.amount);
|
|
1332
|
+
if (input.idempotencyKey) {
|
|
1333
|
+
if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey)) throw new DuplicateRedemptionError(input.idempotencyKey);
|
|
1334
|
+
}
|
|
1335
|
+
const entry = {
|
|
1336
|
+
amount: -input.amount,
|
|
1337
|
+
orderId: input.orderId,
|
|
1338
|
+
description: input.description ?? `Spent on order ${input.orderId}`,
|
|
1339
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
1340
|
+
...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}
|
|
1341
|
+
};
|
|
1342
|
+
const updated = await this.voucherRepo.addLedgerEntry(voucher._id, entry, -input.amount, {
|
|
1343
|
+
...ctx,
|
|
1344
|
+
session
|
|
1345
|
+
});
|
|
1346
|
+
const newBalance = updated.currentBalance ?? 0;
|
|
1347
|
+
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.GIFT_CARD_SPENT, {
|
|
1348
|
+
voucherId: voucher._id,
|
|
1349
|
+
code: voucher.code,
|
|
1350
|
+
amount: input.amount,
|
|
1351
|
+
remainingBalance: newBalance,
|
|
1352
|
+
orderId: input.orderId
|
|
1353
|
+
}, ctx), {
|
|
1354
|
+
...ctx,
|
|
1355
|
+
session
|
|
1356
|
+
});
|
|
1357
|
+
if (newBalance <= 0) {
|
|
1358
|
+
await this.voucherRepo.update(voucher._id, { status: "used" }, {
|
|
1359
|
+
lean: true,
|
|
1360
|
+
...ctx,
|
|
1361
|
+
session
|
|
1362
|
+
});
|
|
1363
|
+
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.GIFT_CARD_EXHAUSTED, {
|
|
1364
|
+
voucherId: voucher._id,
|
|
1365
|
+
code: voucher.code,
|
|
1366
|
+
status: "used"
|
|
1367
|
+
}, ctx), {
|
|
1368
|
+
...ctx,
|
|
1369
|
+
session
|
|
1370
|
+
});
|
|
1371
|
+
}
|
|
1372
|
+
return {
|
|
1373
|
+
code: updated.code,
|
|
1374
|
+
initialBalance: updated.initialBalance ?? 0,
|
|
1375
|
+
currentBalance: newBalance,
|
|
1376
|
+
spent: (updated.initialBalance ?? 0) - newBalance,
|
|
1377
|
+
voucherId: updated._id
|
|
1378
|
+
};
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
async topUp(input, ctx) {
|
|
1382
|
+
if (input.amount <= 0) throw new ValidationError("Top-up amount must be positive");
|
|
1383
|
+
const voucher = await this.voucherRepo.getByCode(input.code, ctx);
|
|
1384
|
+
if (!voucher) throw new VoucherNotFoundError(input.code);
|
|
1385
|
+
const maxBalance = this.config.giftCard.maxBalance;
|
|
1386
|
+
if (maxBalance !== null && (voucher.currentBalance ?? 0) + input.amount > maxBalance) throw new ValidationError(`Top-up would exceed max balance of ${maxBalance}`);
|
|
1387
|
+
if (input.idempotencyKey) {
|
|
1388
|
+
if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey)) throw new DuplicateRedemptionError(input.idempotencyKey);
|
|
1389
|
+
}
|
|
1390
|
+
const entry = {
|
|
1391
|
+
amount: input.amount,
|
|
1392
|
+
description: input.description ?? "Top-up",
|
|
1393
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
1394
|
+
...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}
|
|
1395
|
+
};
|
|
1396
|
+
const updated = await this.voucherRepo.addLedgerEntry(voucher._id, entry, input.amount, ctx);
|
|
1397
|
+
if (voucher.status === "used") await this.voucherRepo.update(voucher._id, { status: "active" }, {
|
|
1398
|
+
lean: true,
|
|
1399
|
+
...ctx
|
|
1400
|
+
});
|
|
1401
|
+
await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.GIFT_CARD_TOPPED_UP, {
|
|
1402
|
+
voucherId: voucher._id,
|
|
1403
|
+
code: voucher.code,
|
|
1404
|
+
amount: input.amount,
|
|
1405
|
+
newBalance: updated.currentBalance ?? 0
|
|
1406
|
+
}, ctx), ctx);
|
|
1407
|
+
return {
|
|
1408
|
+
code: updated.code,
|
|
1409
|
+
initialBalance: updated.initialBalance ?? 0,
|
|
1410
|
+
currentBalance: updated.currentBalance ?? 0,
|
|
1411
|
+
spent: (updated.initialBalance ?? 0) - (updated.currentBalance ?? 0),
|
|
1412
|
+
voucherId: updated._id
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1415
|
+
assertVoucherUsable(voucher) {
|
|
1416
|
+
if (voucher.status === "expired") throw new VoucherExpiredError(voucher.code);
|
|
1417
|
+
if (voucher.status === "cancelled") throw new VoucherExpiredError(voucher.code);
|
|
1418
|
+
if (voucher.status === "used") {
|
|
1419
|
+
if (voucher.initialBalance !== void 0) throw new GiftCardExhaustedError(voucher.code);
|
|
1420
|
+
throw new VoucherExhaustedError(voucher.code);
|
|
1421
|
+
}
|
|
1422
|
+
if (voucher.expiresAt && voucher.expiresAt < /* @__PURE__ */ new Date()) throw new VoucherExpiredError(voucher.code);
|
|
1423
|
+
if (voucher.usedCount >= voucher.usageLimit) throw new VoucherExhaustedError(voucher.code);
|
|
1424
|
+
}
|
|
1425
|
+
};
|
|
1426
|
+
/**
|
|
1427
|
+
* Detect MongoDB's transient WriteConflict error across the shapes it
|
|
1428
|
+
* arrives in (raw driver error, mongoose-wrapped VersionError, mongokit
|
|
1429
|
+
* rethrows). Centralised so every contended write site can opt-in.
|
|
1430
|
+
*/
|
|
1431
|
+
function isWriteConflict(err) {
|
|
1432
|
+
if (typeof err !== "object" || err === null) return false;
|
|
1433
|
+
const e = err;
|
|
1434
|
+
if (e.code === 112 || e.codeName === "WriteConflict") return true;
|
|
1435
|
+
if (e.name === "MongoServerError" && typeof e.message === "string" && /write conflict/i.test(e.message)) return true;
|
|
1436
|
+
if (Array.isArray(e.errorLabels) && e.errorLabels.includes("TransientTransactionError")) return true;
|
|
1437
|
+
if (typeof e.message === "string" && /write conflict/i.test(e.message)) return true;
|
|
1438
|
+
if (e.cause !== void 0) return isWriteConflict(e.cause);
|
|
1439
|
+
return false;
|
|
1440
|
+
}
|
|
1441
|
+
//#endregion
|
|
1442
|
+
//#region src/services/create-services.ts
|
|
1443
|
+
function createServices(deps) {
|
|
1444
|
+
const { repositories, unitOfWork, dispatchDeps, config } = deps;
|
|
1445
|
+
return {
|
|
1446
|
+
voucher: new VoucherService(repositories.voucher, repositories.program, unitOfWork, dispatchDeps, config),
|
|
1447
|
+
evaluation: new EvaluationService(repositories.program, repositories.rule, repositories.reward, repositories.voucher, unitOfWork, dispatchDeps, config)
|
|
29
1448
|
};
|
|
1449
|
+
}
|
|
1450
|
+
//#endregion
|
|
1451
|
+
//#region src/events/promo-event-catalog.ts
|
|
1452
|
+
/**
|
|
1453
|
+
* Promo event catalog — Zod-source-of-truth definitions for every
|
|
1454
|
+
* `promo.*` event.
|
|
1455
|
+
*
|
|
1456
|
+
* Each definition exposes:
|
|
1457
|
+
* - `.zodSchema` — source of truth, used by host code's `.safeParse()`
|
|
1458
|
+
* - `.schema` — JSON Schema derived via `z.toJSONSchema()`, consumed
|
|
1459
|
+
* by Arc's EventRegistry + OpenAPI plugin
|
|
1460
|
+
* - `.create(...)` — DomainEvent envelope builder, structurally compatible
|
|
1461
|
+
* with `@classytic/arc`'s `EventDefinitionOutput`
|
|
1462
|
+
*
|
|
1463
|
+
* Structurally compatible with Arc 2.10's `EventRegistry` — hosts register
|
|
1464
|
+
* `promoEventDefinitions` directly, no adapter code. Promo does NOT import
|
|
1465
|
+
* from `@classytic/arc` (PACKAGE_RULES §11); compatibility is purely
|
|
1466
|
+
* structural.
|
|
1467
|
+
*
|
|
1468
|
+
* Payload schemas mirror the typed interfaces in
|
|
1469
|
+
* [event-payloads.ts](./event-payloads.ts). See PACKAGE_RULES §18.5 for
|
|
1470
|
+
* the pattern.
|
|
1471
|
+
*
|
|
1472
|
+
* @example Wiring into an Arc app
|
|
1473
|
+
* ```ts
|
|
1474
|
+
* import { createEventRegistry } from '@classytic/arc/events';
|
|
1475
|
+
* import { promoEventDefinitions } from '@classytic/promo';
|
|
1476
|
+
*
|
|
1477
|
+
* const registry = createEventRegistry();
|
|
1478
|
+
* for (const def of promoEventDefinitions) registry.register(def);
|
|
1479
|
+
* ```
|
|
1480
|
+
*/
|
|
1481
|
+
function definePromoEvent(input) {
|
|
1482
|
+
const { name, version = 1, description, zodSchema } = input;
|
|
1483
|
+
const def = {
|
|
1484
|
+
name,
|
|
1485
|
+
version,
|
|
1486
|
+
schema: z.toJSONSchema(zodSchema),
|
|
1487
|
+
zodSchema,
|
|
1488
|
+
create(payload, meta) {
|
|
1489
|
+
return createEvent(name, payload, {
|
|
1490
|
+
source: "promo",
|
|
1491
|
+
...meta
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
};
|
|
1495
|
+
if (description !== void 0) def.description = description;
|
|
1496
|
+
return def;
|
|
1497
|
+
}
|
|
1498
|
+
/** Mirrors `ProgramLifecyclePayload`. */
|
|
1499
|
+
const programLifecycleSchema = z.object({
|
|
1500
|
+
programId: z.string(),
|
|
1501
|
+
programType: z.string(),
|
|
1502
|
+
status: z.string(),
|
|
1503
|
+
actorId: z.string().optional()
|
|
1504
|
+
});
|
|
1505
|
+
/** Mirrors `RulePayload`. */
|
|
1506
|
+
const ruleSchema = z.object({
|
|
1507
|
+
programId: z.string(),
|
|
1508
|
+
ruleId: z.string(),
|
|
1509
|
+
actorId: z.string().optional()
|
|
1510
|
+
});
|
|
1511
|
+
/** Mirrors `RewardPayload`. */
|
|
1512
|
+
const rewardSchema = z.object({
|
|
1513
|
+
programId: z.string(),
|
|
1514
|
+
rewardId: z.string(),
|
|
1515
|
+
actorId: z.string().optional()
|
|
1516
|
+
});
|
|
1517
|
+
/** Mirrors `VoucherGeneratedPayload`. */
|
|
1518
|
+
const voucherGeneratedSchema = z.object({
|
|
1519
|
+
programId: z.string(),
|
|
1520
|
+
voucherIds: z.array(z.string()),
|
|
1521
|
+
codes: z.array(z.string()),
|
|
1522
|
+
count: z.number().int().nonnegative(),
|
|
1523
|
+
actorId: z.string().optional()
|
|
1524
|
+
});
|
|
1525
|
+
/** Mirrors `VoucherRedeemedPayload`. */
|
|
1526
|
+
const voucherRedeemedSchema = z.object({
|
|
1527
|
+
voucherId: z.string(),
|
|
1528
|
+
code: z.string(),
|
|
1529
|
+
orderId: z.string(),
|
|
1530
|
+
discountAmount: z.number(),
|
|
1531
|
+
customerId: z.string().optional()
|
|
1532
|
+
});
|
|
1533
|
+
/**
|
|
1534
|
+
* Mirrors `VoucherLifecyclePayload` — shared by VOUCHER_CANCELLED,
|
|
1535
|
+
* VOUCHER_EXPIRED, and GIFT_CARD_EXHAUSTED (repo emits `status: 'cancelled'`
|
|
1536
|
+
* / `'used'` / host-supplied terminal value).
|
|
1537
|
+
*/
|
|
1538
|
+
const voucherLifecycleSchema = z.object({
|
|
1539
|
+
voucherId: z.string(),
|
|
1540
|
+
code: z.string(),
|
|
1541
|
+
status: z.string()
|
|
1542
|
+
});
|
|
1543
|
+
/** Mirrors `GiftCardSpentPayload`. */
|
|
1544
|
+
const giftCardSpentSchema = z.object({
|
|
1545
|
+
voucherId: z.string(),
|
|
1546
|
+
code: z.string(),
|
|
1547
|
+
amount: z.number(),
|
|
1548
|
+
remainingBalance: z.number(),
|
|
1549
|
+
orderId: z.string()
|
|
1550
|
+
});
|
|
1551
|
+
/** Mirrors `GiftCardToppedUpPayload`. */
|
|
1552
|
+
const giftCardToppedUpSchema = z.object({
|
|
1553
|
+
voucherId: z.string(),
|
|
1554
|
+
code: z.string(),
|
|
1555
|
+
amount: z.number(),
|
|
1556
|
+
newBalance: z.number()
|
|
1557
|
+
});
|
|
1558
|
+
/** Mirrors `EvaluationCompletedPayload`. */
|
|
1559
|
+
const evaluationCompletedSchema = z.object({
|
|
1560
|
+
evaluationId: z.string(),
|
|
1561
|
+
totalDiscount: z.number(),
|
|
1562
|
+
programsApplied: z.number().int().nonnegative(),
|
|
1563
|
+
codesUsed: z.array(z.string()),
|
|
1564
|
+
isPreview: z.boolean()
|
|
1565
|
+
});
|
|
1566
|
+
/** Mirrors `EvaluationCommittedPayload`. */
|
|
1567
|
+
const evaluationCommittedSchema = z.object({
|
|
1568
|
+
evaluationId: z.string(),
|
|
1569
|
+
orderId: z.string(),
|
|
1570
|
+
totalDiscount: z.number()
|
|
1571
|
+
});
|
|
1572
|
+
/** Single-field rollback payload — emitted by `evaluation.service.ts`. */
|
|
1573
|
+
const evaluationRolledBackSchema = z.object({ evaluationId: z.string() });
|
|
1574
|
+
const ProgramCreated = definePromoEvent({
|
|
1575
|
+
name: PromoEvents.PROGRAM_CREATED,
|
|
1576
|
+
description: "A new promo program was created (starts in draft).",
|
|
1577
|
+
zodSchema: programLifecycleSchema
|
|
1578
|
+
});
|
|
1579
|
+
const ProgramActivated = definePromoEvent({
|
|
1580
|
+
name: PromoEvents.PROGRAM_ACTIVATED,
|
|
1581
|
+
description: "A draft or paused program was activated.",
|
|
1582
|
+
zodSchema: programLifecycleSchema
|
|
1583
|
+
});
|
|
1584
|
+
const ProgramPaused = definePromoEvent({
|
|
1585
|
+
name: PromoEvents.PROGRAM_PAUSED,
|
|
1586
|
+
description: "An active program was paused.",
|
|
1587
|
+
zodSchema: programLifecycleSchema
|
|
1588
|
+
});
|
|
1589
|
+
const ProgramArchived = definePromoEvent({
|
|
1590
|
+
name: PromoEvents.PROGRAM_ARCHIVED,
|
|
1591
|
+
description: "A program was archived (terminal — no further transitions).",
|
|
1592
|
+
zodSchema: programLifecycleSchema
|
|
1593
|
+
});
|
|
1594
|
+
const RuleAdded = definePromoEvent({
|
|
1595
|
+
name: PromoEvents.RULE_ADDED,
|
|
1596
|
+
description: "A new rule was added to a program.",
|
|
1597
|
+
zodSchema: ruleSchema
|
|
1598
|
+
});
|
|
1599
|
+
const RuleUpdated = definePromoEvent({
|
|
1600
|
+
name: PromoEvents.RULE_UPDATED,
|
|
1601
|
+
description: "An existing rule on a program was updated.",
|
|
1602
|
+
zodSchema: ruleSchema
|
|
1603
|
+
});
|
|
1604
|
+
const RuleRemoved = definePromoEvent({
|
|
1605
|
+
name: PromoEvents.RULE_REMOVED,
|
|
1606
|
+
description: "A rule was removed from a program.",
|
|
1607
|
+
zodSchema: ruleSchema
|
|
1608
|
+
});
|
|
1609
|
+
const RewardAdded = definePromoEvent({
|
|
1610
|
+
name: PromoEvents.REWARD_ADDED,
|
|
1611
|
+
description: "A reward was added to a program.",
|
|
1612
|
+
zodSchema: rewardSchema
|
|
1613
|
+
});
|
|
1614
|
+
const RewardUpdated = definePromoEvent({
|
|
1615
|
+
name: PromoEvents.REWARD_UPDATED,
|
|
1616
|
+
description: "A reward on a program was updated.",
|
|
1617
|
+
zodSchema: rewardSchema
|
|
1618
|
+
});
|
|
1619
|
+
const RewardRemoved = definePromoEvent({
|
|
1620
|
+
name: PromoEvents.REWARD_REMOVED,
|
|
1621
|
+
description: "A reward was removed from a program.",
|
|
1622
|
+
zodSchema: rewardSchema
|
|
1623
|
+
});
|
|
1624
|
+
const VoucherGenerated = definePromoEvent({
|
|
1625
|
+
name: PromoEvents.VOUCHER_GENERATED,
|
|
1626
|
+
description: "One or more vouchers were generated for a program.",
|
|
1627
|
+
zodSchema: voucherGeneratedSchema
|
|
1628
|
+
});
|
|
1629
|
+
const VoucherRedeemed = definePromoEvent({
|
|
1630
|
+
name: PromoEvents.VOUCHER_REDEEMED,
|
|
1631
|
+
description: "A voucher was redeemed against an order.",
|
|
1632
|
+
zodSchema: voucherRedeemedSchema
|
|
1633
|
+
});
|
|
1634
|
+
const VoucherCancelled = definePromoEvent({
|
|
1635
|
+
name: PromoEvents.VOUCHER_CANCELLED,
|
|
1636
|
+
description: "A voucher was cancelled by an operator.",
|
|
1637
|
+
zodSchema: voucherLifecycleSchema
|
|
1638
|
+
});
|
|
1639
|
+
const VoucherExpired = definePromoEvent({
|
|
1640
|
+
name: PromoEvents.VOUCHER_EXPIRED,
|
|
1641
|
+
description: "A voucher reached its expiry date and was transitioned to expired.",
|
|
1642
|
+
zodSchema: voucherLifecycleSchema
|
|
1643
|
+
});
|
|
1644
|
+
const GiftCardSpent = definePromoEvent({
|
|
1645
|
+
name: PromoEvents.GIFT_CARD_SPENT,
|
|
1646
|
+
description: "A gift-card voucher was debited against an order — remaining balance reported.",
|
|
1647
|
+
zodSchema: giftCardSpentSchema
|
|
1648
|
+
});
|
|
1649
|
+
const GiftCardToppedUp = definePromoEvent({
|
|
1650
|
+
name: PromoEvents.GIFT_CARD_TOPPED_UP,
|
|
1651
|
+
description: "A gift-card voucher received a top-up — new balance reported.",
|
|
1652
|
+
zodSchema: giftCardToppedUpSchema
|
|
1653
|
+
});
|
|
1654
|
+
const GiftCardExhausted = definePromoEvent({
|
|
1655
|
+
name: PromoEvents.GIFT_CARD_EXHAUSTED,
|
|
1656
|
+
description: "A gift-card voucher reached a zero balance and was marked used.",
|
|
1657
|
+
zodSchema: voucherLifecycleSchema
|
|
1658
|
+
});
|
|
1659
|
+
const EvaluationCompleted = definePromoEvent({
|
|
1660
|
+
name: PromoEvents.EVALUATION_COMPLETED,
|
|
1661
|
+
description: "A cart evaluation finished (preview or pre-commit) — totals and applied codes reported.",
|
|
1662
|
+
zodSchema: evaluationCompletedSchema
|
|
1663
|
+
});
|
|
1664
|
+
const EvaluationCommitted = definePromoEvent({
|
|
1665
|
+
name: PromoEvents.EVALUATION_COMMITTED,
|
|
1666
|
+
description: "A stored evaluation was committed against an order — usage counters moved.",
|
|
1667
|
+
zodSchema: evaluationCommittedSchema
|
|
1668
|
+
});
|
|
1669
|
+
const EvaluationRolledBack = definePromoEvent({
|
|
1670
|
+
name: PromoEvents.EVALUATION_ROLLED_BACK,
|
|
1671
|
+
description: "A stored evaluation was discarded without commit.",
|
|
1672
|
+
zodSchema: evaluationRolledBackSchema
|
|
1673
|
+
});
|
|
1674
|
+
/**
|
|
1675
|
+
* Every promo event defined in the package — pass to Arc's
|
|
1676
|
+
* `EventRegistry`. Hosts wire ONE array; the whole `promo.*` namespace
|
|
1677
|
+
* becomes introspectable via OpenAPI and auto-validated at publish time
|
|
1678
|
+
* when `eventPlugin({ validateMode: 'reject' })` is set.
|
|
1679
|
+
*/
|
|
1680
|
+
const promoEventDefinitions = [
|
|
1681
|
+
ProgramCreated,
|
|
1682
|
+
ProgramActivated,
|
|
1683
|
+
ProgramPaused,
|
|
1684
|
+
ProgramArchived,
|
|
1685
|
+
RuleAdded,
|
|
1686
|
+
RuleUpdated,
|
|
1687
|
+
RuleRemoved,
|
|
1688
|
+
RewardAdded,
|
|
1689
|
+
RewardUpdated,
|
|
1690
|
+
RewardRemoved,
|
|
1691
|
+
VoucherGenerated,
|
|
1692
|
+
VoucherRedeemed,
|
|
1693
|
+
VoucherCancelled,
|
|
1694
|
+
VoucherExpired,
|
|
1695
|
+
GiftCardSpent,
|
|
1696
|
+
GiftCardToppedUp,
|
|
1697
|
+
GiftCardExhausted,
|
|
1698
|
+
EvaluationCompleted,
|
|
1699
|
+
EvaluationCommitted,
|
|
1700
|
+
EvaluationRolledBack
|
|
1701
|
+
];
|
|
1702
|
+
//#endregion
|
|
1703
|
+
//#region src/index.ts
|
|
1704
|
+
function resolveConfig(config) {
|
|
1705
|
+
const tenant = resolveTenantConfig(config.tenant);
|
|
1706
|
+
if (typeof config.tenant === "object" && config.tenant !== null) {
|
|
1707
|
+
const explicit = config.tenant.contextKey ?? config.tenant.tenantField;
|
|
1708
|
+
if (explicit) tenant.contextKey = explicit;
|
|
1709
|
+
}
|
|
30
1710
|
const resolved = {
|
|
31
1711
|
evaluation: {
|
|
32
1712
|
maxStackablePromotions: config.evaluation?.maxStackablePromotions ?? 5,
|
|
@@ -66,21 +1746,28 @@ var MongoUnitOfWork = class {
|
|
|
66
1746
|
};
|
|
67
1747
|
function createPromoEngine(config) {
|
|
68
1748
|
const resolvedConfig = resolveConfig(config);
|
|
69
|
-
const models = createModels(config.mongoose, resolvedConfig.tenant, config.indexes);
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
|
|
1749
|
+
const models = createModels(config.mongoose, resolvedConfig.tenant, config.indexes, config.autoIndex);
|
|
1750
|
+
const events = config.events?.transport ?? new InProcessPromoBus();
|
|
1751
|
+
const dispatchDeps = {
|
|
1752
|
+
events,
|
|
1753
|
+
...config.outbox !== void 0 ? { outbox: config.outbox } : {},
|
|
1754
|
+
...config.logger !== void 0 ? { logger: config.logger } : {}
|
|
1755
|
+
};
|
|
1756
|
+
const repositories = createRepositories(models, config.plugins, resolvedConfig.tenant, dispatchDeps);
|
|
73
1757
|
return {
|
|
74
1758
|
models,
|
|
75
1759
|
repositories,
|
|
76
1760
|
services: createServices({
|
|
77
1761
|
repositories,
|
|
78
|
-
unitOfWork,
|
|
79
|
-
|
|
1762
|
+
unitOfWork: new MongoUnitOfWork(config.mongoose),
|
|
1763
|
+
dispatchDeps,
|
|
80
1764
|
config: resolvedConfig
|
|
81
1765
|
}),
|
|
82
|
-
events
|
|
1766
|
+
events,
|
|
1767
|
+
async syncIndexes() {
|
|
1768
|
+
await Promise.all(Object.values(models).map((m) => m.createIndexes()));
|
|
1769
|
+
}
|
|
83
1770
|
};
|
|
84
1771
|
}
|
|
85
1772
|
//#endregion
|
|
86
|
-
export { PromoEvents, createPromoEngine, resolveConfig };
|
|
1773
|
+
export { EvaluationCommitted, EvaluationCompleted, EvaluationRolledBack, GiftCardExhausted, GiftCardSpent, GiftCardToppedUp, ProgramActivated, ProgramArchived, ProgramCreated, ProgramPaused, ProgramRepository, PromoEvents, RewardAdded, RewardRemoved, RewardRepository, RewardUpdated, RuleAdded, RuleRemoved, RuleRepository, RuleUpdated, VoucherCancelled, VoucherExpired, VoucherGenerated, VoucherRedeemed, VoucherRepository, createPromoEngine, promoEventDefinitions, resolveConfig };
|