@withwiz/coupon 0.1.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/LICENSE +21 -0
- package/README.md +298 -0
- package/dist/index.cjs +1928 -0
- package/dist/index.d.cts +755 -0
- package/dist/index.d.ts +755 -0
- package/dist/index.mjs +1897 -0
- package/package.json +86 -0
- package/prisma/fragment.prisma +122 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1928 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var cuid2 = require('@paralleldrive/cuid2');
|
|
4
|
+
var nanoid = require('nanoid');
|
|
5
|
+
var zod = require('zod');
|
|
6
|
+
|
|
7
|
+
// src/domain/constants.ts
|
|
8
|
+
var COUPON_STATUS = {
|
|
9
|
+
DRAFT: "DRAFT",
|
|
10
|
+
ACTIVE: "ACTIVE",
|
|
11
|
+
PAUSED: "PAUSED",
|
|
12
|
+
EXPIRED: "EXPIRED",
|
|
13
|
+
EXHAUSTED: "EXHAUSTED",
|
|
14
|
+
ARCHIVED: "ARCHIVED"
|
|
15
|
+
};
|
|
16
|
+
var CAMPAIGN_STATUS = {
|
|
17
|
+
ACTIVE: "ACTIVE",
|
|
18
|
+
CLOSED: "CLOSED"
|
|
19
|
+
};
|
|
20
|
+
var COUPON_REJECT_REASON = {
|
|
21
|
+
NOT_FOUND: "NOT_FOUND",
|
|
22
|
+
NOT_STARTED: "NOT_STARTED",
|
|
23
|
+
EXPIRED: "EXPIRED",
|
|
24
|
+
EXHAUSTED: "EXHAUSTED",
|
|
25
|
+
PER_USER_LIMIT: "PER_USER_LIMIT",
|
|
26
|
+
MIN_ORDER_NOT_MET: "MIN_ORDER_NOT_MET",
|
|
27
|
+
INELIGIBLE_USER: "INELIGIBLE_USER",
|
|
28
|
+
INELIGIBLE_PLAN: "INELIGIBLE_PLAN",
|
|
29
|
+
INELIGIBLE_PRODUCT: "INELIGIBLE_PRODUCT",
|
|
30
|
+
INELIGIBLE_CATEGORY: "INELIGIBLE_CATEGORY",
|
|
31
|
+
PAUSED: "PAUSED",
|
|
32
|
+
ARCHIVED: "ARCHIVED",
|
|
33
|
+
TENANT_MISMATCH: "TENANT_MISMATCH"
|
|
34
|
+
};
|
|
35
|
+
var LIMITS = {
|
|
36
|
+
CODE_MAX_LENGTH: 64,
|
|
37
|
+
CODE_MIN_LENGTH: 1,
|
|
38
|
+
/**
|
|
39
|
+
* Default character set for generated codes (confusing chars removed).
|
|
40
|
+
* Excludes `0/O`, `1/I/L` and lower-case to avoid user transcription errors.
|
|
41
|
+
*/
|
|
42
|
+
CODE_DEFAULT_ALPHABET: "ABCDEFGHJKMNPQRSTUVWXYZ23456789",
|
|
43
|
+
CODE_DEFAULT_LENGTH: 8,
|
|
44
|
+
/**
|
|
45
|
+
* [A] whitelist for accepted coupon codes (after NFKC normalization + upper-case).
|
|
46
|
+
* Only uppercase ASCII letters, digits, underscore, hyphen are allowed.
|
|
47
|
+
* SQL meta characters / quotes / spaces / CJK / emoji are all rejected.
|
|
48
|
+
*/
|
|
49
|
+
CODE_ALLOWED_REGEX: /^[A-Z0-9_-]+$/,
|
|
50
|
+
TENANT_ID_MAX_LENGTH: 64,
|
|
51
|
+
TENANT_ID_REGEX: /^[a-zA-Z0-9_\-:.]+$/,
|
|
52
|
+
BULK_MAX_COUNT: 1e4,
|
|
53
|
+
BULK_MAX_ATTEMPTS: 5,
|
|
54
|
+
BULK_DEFAULT_MAX_ATTEMPTS: 3,
|
|
55
|
+
PAGINATION_DEFAULT_LIMIT: 50,
|
|
56
|
+
PAGINATION_MAX_LIMIT: 200,
|
|
57
|
+
NAME_MAX_LENGTH: 200,
|
|
58
|
+
CURRENCY_LENGTH: 3
|
|
59
|
+
};
|
|
60
|
+
var AUDIT_ACTION = {
|
|
61
|
+
COUPON_CREATED: "coupon.created",
|
|
62
|
+
COUPON_PAUSED: "coupon.paused",
|
|
63
|
+
COUPON_RESUMED: "coupon.resumed",
|
|
64
|
+
COUPON_ARCHIVED: "coupon.archived",
|
|
65
|
+
COUPON_ISSUED: "coupon.issued",
|
|
66
|
+
COUPON_ISSUED_BULK: "coupon.issued.bulk",
|
|
67
|
+
COUPON_REDEEMED: "coupon.redeemed",
|
|
68
|
+
COUPON_REDEEM_REJECTED: "coupon.redeem.rejected",
|
|
69
|
+
CAMPAIGN_CREATED: "campaign.created",
|
|
70
|
+
CAMPAIGN_CLOSED: "campaign.closed"
|
|
71
|
+
};
|
|
72
|
+
var RESOURCE_TYPE = {
|
|
73
|
+
COUPON: "coupon",
|
|
74
|
+
CAMPAIGN: "campaign"
|
|
75
|
+
};
|
|
76
|
+
var FORBIDDEN_METADATA_KEYS = [
|
|
77
|
+
"__proto__",
|
|
78
|
+
"constructor",
|
|
79
|
+
"prototype"
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
// src/domain/errors.ts
|
|
83
|
+
var CouponError = class extends Error {
|
|
84
|
+
name;
|
|
85
|
+
code;
|
|
86
|
+
tenantId;
|
|
87
|
+
couponId;
|
|
88
|
+
couponCode;
|
|
89
|
+
cause;
|
|
90
|
+
constructor(message, code, ctx) {
|
|
91
|
+
super(message);
|
|
92
|
+
this.name = new.target.name;
|
|
93
|
+
this.code = code;
|
|
94
|
+
this.tenantId = ctx?.tenantId;
|
|
95
|
+
this.couponId = ctx?.couponId;
|
|
96
|
+
this.couponCode = ctx?.couponCode;
|
|
97
|
+
this.cause = ctx?.cause;
|
|
98
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
var ValidationError = class _ValidationError extends CouponError {
|
|
102
|
+
static code = "VALIDATION_ERROR";
|
|
103
|
+
details;
|
|
104
|
+
constructor(message, details, ctx) {
|
|
105
|
+
super(message, _ValidationError.code, ctx);
|
|
106
|
+
this.details = details;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
var InvalidDiscountPolicyError = class _InvalidDiscountPolicyError extends CouponError {
|
|
110
|
+
static code = "INVALID_DISCOUNT_POLICY";
|
|
111
|
+
constructor(message, ctx) {
|
|
112
|
+
super(message, _InvalidDiscountPolicyError.code, ctx);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
var InvalidDateRangeError = class _InvalidDateRangeError extends CouponError {
|
|
116
|
+
static code = "INVALID_DATE_RANGE";
|
|
117
|
+
constructor(message, ctx) {
|
|
118
|
+
super(message, _InvalidDateRangeError.code, ctx);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
var CurrencyMismatchError = class _CurrencyMismatchError extends CouponError {
|
|
122
|
+
static code = "CURRENCY_MISMATCH";
|
|
123
|
+
constructor(message, ctx) {
|
|
124
|
+
super(message, _CurrencyMismatchError.code, ctx);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
var InvalidModelNameError = class _InvalidModelNameError extends CouponError {
|
|
128
|
+
static code = "INVALID_MODEL_NAME";
|
|
129
|
+
constructor(message, ctx) {
|
|
130
|
+
super(message, _InvalidModelNameError.code, ctx);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
var CouponNotFoundError = class _CouponNotFoundError extends CouponError {
|
|
134
|
+
static code = "NOT_FOUND";
|
|
135
|
+
constructor(message = "Coupon not found", ctx) {
|
|
136
|
+
super(message, _CouponNotFoundError.code, ctx);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
var CouponArchivedError = class _CouponArchivedError extends CouponError {
|
|
140
|
+
static code = "ARCHIVED";
|
|
141
|
+
constructor(message = "Coupon is archived", ctx) {
|
|
142
|
+
super(message, _CouponArchivedError.code, ctx);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
var CouponInactiveError = class _CouponInactiveError extends CouponError {
|
|
146
|
+
static code = "PAUSED";
|
|
147
|
+
constructor(message = "Coupon is not active", ctx) {
|
|
148
|
+
super(message, _CouponInactiveError.code, ctx);
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
var CouponExpiredError = class _CouponExpiredError extends CouponError {
|
|
152
|
+
static code = "EXPIRED";
|
|
153
|
+
constructor(message = "Coupon is expired", ctx, codeOverride) {
|
|
154
|
+
super(message, codeOverride ?? _CouponExpiredError.code, ctx);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
var CouponExhaustedError = class _CouponExhaustedError extends CouponError {
|
|
158
|
+
static code = "EXHAUSTED";
|
|
159
|
+
constructor(message = "Coupon fully redeemed", ctx) {
|
|
160
|
+
super(message, _CouponExhaustedError.code, ctx);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
var CouponAlreadyRedeemedError = class _CouponAlreadyRedeemedError extends CouponError {
|
|
164
|
+
static code = "ALREADY_REDEEMED";
|
|
165
|
+
constructor(message = "Coupon already redeemed for this order", ctx) {
|
|
166
|
+
super(message, _CouponAlreadyRedeemedError.code, ctx);
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
var PerUserLimitExceededError = class _PerUserLimitExceededError extends CouponError {
|
|
170
|
+
static code = "PER_USER_LIMIT";
|
|
171
|
+
constructor(message = "Per-user redemption limit exceeded", ctx) {
|
|
172
|
+
super(message, _PerUserLimitExceededError.code, ctx);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
var IneligibleUserError = class _IneligibleUserError extends CouponError {
|
|
176
|
+
static code = "INELIGIBLE_USER";
|
|
177
|
+
constructor(message = "User is not eligible for this coupon", ctx) {
|
|
178
|
+
super(message, _IneligibleUserError.code, ctx);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
var IneligiblePlanError = class _IneligiblePlanError extends CouponError {
|
|
182
|
+
static code = "INELIGIBLE_PLAN";
|
|
183
|
+
constructor(message = "Plan is not eligible for this coupon", ctx) {
|
|
184
|
+
super(message, _IneligiblePlanError.code, ctx);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
var IneligibleProductError = class _IneligibleProductError extends CouponError {
|
|
188
|
+
static code = "INELIGIBLE_PRODUCT";
|
|
189
|
+
constructor(message = "Product is not eligible for this coupon", ctx) {
|
|
190
|
+
super(message, _IneligibleProductError.code, ctx);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
var IneligibleCategoryError = class _IneligibleCategoryError extends CouponError {
|
|
194
|
+
static code = "INELIGIBLE_CATEGORY";
|
|
195
|
+
constructor(message = "Category is not eligible for this coupon", ctx) {
|
|
196
|
+
super(message, _IneligibleCategoryError.code, ctx);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
var MinimumOrderNotMetError = class _MinimumOrderNotMetError extends CouponError {
|
|
200
|
+
static code = "MIN_ORDER_NOT_MET";
|
|
201
|
+
constructor(message = "Minimum order amount not met", ctx) {
|
|
202
|
+
super(message, _MinimumOrderNotMetError.code, ctx);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
var CampaignClosedError = class _CampaignClosedError extends CouponError {
|
|
206
|
+
static code = "CAMPAIGN_CLOSED";
|
|
207
|
+
constructor(message = "Campaign is closed", ctx) {
|
|
208
|
+
super(message, _CampaignClosedError.code, ctx);
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
var CampaignCapacityExceededError = class _CampaignCapacityExceededError extends CouponError {
|
|
212
|
+
static code = "CAMPAIGN_CAPACITY_EXCEEDED";
|
|
213
|
+
constructor(message = "Campaign capacity exceeded", ctx) {
|
|
214
|
+
super(message, _CampaignCapacityExceededError.code, ctx);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
var TenantMismatchError = class _TenantMismatchError extends CouponError {
|
|
218
|
+
static code = "TENANT_MISMATCH";
|
|
219
|
+
constructor(message = "Tenant mismatch", ctx) {
|
|
220
|
+
super(message, _TenantMismatchError.code, ctx);
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// src/discount.ts
|
|
225
|
+
function ensureNonNegativeInteger(name, value) {
|
|
226
|
+
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
|
|
227
|
+
throw new ValidationError(
|
|
228
|
+
`${name} must be a non-negative integer (got ${String(value)})`
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
function ensureCurrency(code) {
|
|
233
|
+
if (typeof code !== "string" || code.length !== 3) {
|
|
234
|
+
throw new ValidationError(
|
|
235
|
+
`currency must be a 3-letter ISO-4217 code (got ${String(code)})`
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function validateDiscountPolicy(policy) {
|
|
240
|
+
if (!policy || typeof policy !== "object") {
|
|
241
|
+
throw new InvalidDiscountPolicyError("discount policy must be an object");
|
|
242
|
+
}
|
|
243
|
+
if (policy.kind === "PERCENT") {
|
|
244
|
+
const { percent } = policy;
|
|
245
|
+
if (typeof percent !== "number" || !Number.isFinite(percent) || percent <= 0 || percent > 100) {
|
|
246
|
+
throw new InvalidDiscountPolicyError(
|
|
247
|
+
`PERCENT.percent must be in (0, 100] (got ${String(percent)})`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
if (policy.maxDiscount !== void 0) {
|
|
251
|
+
if (typeof policy.maxDiscount !== "number" || !Number.isInteger(policy.maxDiscount) || policy.maxDiscount < 0) {
|
|
252
|
+
throw new InvalidDiscountPolicyError(
|
|
253
|
+
"PERCENT.maxDiscount must be a non-negative integer"
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (policy.minOrder !== void 0) {
|
|
258
|
+
if (typeof policy.minOrder !== "number" || !Number.isInteger(policy.minOrder) || policy.minOrder < 0) {
|
|
259
|
+
throw new InvalidDiscountPolicyError(
|
|
260
|
+
"PERCENT.minOrder must be a non-negative integer"
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (policy.currency !== void 0) {
|
|
265
|
+
ensureCurrency(policy.currency);
|
|
266
|
+
}
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (policy.kind === "FIXED") {
|
|
270
|
+
const { amount, currency } = policy;
|
|
271
|
+
if (typeof amount !== "number" || !Number.isInteger(amount) || amount <= 0) {
|
|
272
|
+
throw new InvalidDiscountPolicyError(
|
|
273
|
+
`FIXED.amount must be a positive integer (got ${String(amount)})`
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
ensureCurrency(currency);
|
|
277
|
+
if (policy.minOrder !== void 0) {
|
|
278
|
+
if (typeof policy.minOrder !== "number" || !Number.isInteger(policy.minOrder) || policy.minOrder < 0) {
|
|
279
|
+
throw new InvalidDiscountPolicyError(
|
|
280
|
+
"FIXED.minOrder must be a non-negative integer"
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
throw new InvalidDiscountPolicyError(
|
|
287
|
+
`unknown discount policy kind: ${String(policy.kind)}`
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
function calculate(input) {
|
|
291
|
+
if (!input || typeof input !== "object") {
|
|
292
|
+
throw new ValidationError("discount input must be an object");
|
|
293
|
+
}
|
|
294
|
+
ensureNonNegativeInteger("subtotal", input.subtotal);
|
|
295
|
+
ensureCurrency(input.currency);
|
|
296
|
+
validateDiscountPolicy(input.policy);
|
|
297
|
+
const trace = [];
|
|
298
|
+
const policyCurrency = extractPolicyCurrency(input.policy);
|
|
299
|
+
if (policyCurrency && policyCurrency !== input.currency) {
|
|
300
|
+
throw new CurrencyMismatchError(
|
|
301
|
+
`currency mismatch: input=${input.currency} policy=${policyCurrency}`
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
const minOrder = input.policy.minOrder;
|
|
305
|
+
if (typeof minOrder === "number" && input.subtotal < minOrder) {
|
|
306
|
+
throw new MinimumOrderNotMetError(
|
|
307
|
+
`minimum order ${minOrder} not met (subtotal=${input.subtotal})`
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
let discountAmount = 0;
|
|
311
|
+
if (input.policy.kind === "PERCENT") {
|
|
312
|
+
const raw = Math.floor(input.subtotal * input.policy.percent / 100);
|
|
313
|
+
trace.push(`percent=${input.policy.percent}% raw=${raw}`);
|
|
314
|
+
if (typeof input.policy.maxDiscount === "number") {
|
|
315
|
+
const capped = Math.min(raw, input.policy.maxDiscount);
|
|
316
|
+
if (capped !== raw) {
|
|
317
|
+
trace.push(`capped by maxDiscount=${input.policy.maxDiscount} -> ${capped}`);
|
|
318
|
+
}
|
|
319
|
+
discountAmount = capped;
|
|
320
|
+
} else {
|
|
321
|
+
discountAmount = raw;
|
|
322
|
+
}
|
|
323
|
+
} else {
|
|
324
|
+
discountAmount = input.policy.amount;
|
|
325
|
+
trace.push(`fixed=${input.policy.amount}`);
|
|
326
|
+
}
|
|
327
|
+
if (discountAmount > input.subtotal) {
|
|
328
|
+
trace.push(`clamped discount ${discountAmount} to subtotal ${input.subtotal}`);
|
|
329
|
+
discountAmount = input.subtotal;
|
|
330
|
+
}
|
|
331
|
+
ensurePositiveIntegerIfNonZero(discountAmount);
|
|
332
|
+
const total = input.subtotal - discountAmount;
|
|
333
|
+
trace.push(`total=${total}`);
|
|
334
|
+
return {
|
|
335
|
+
subtotal: input.subtotal,
|
|
336
|
+
discountAmount,
|
|
337
|
+
total,
|
|
338
|
+
appliedPolicy: input.policy,
|
|
339
|
+
trace,
|
|
340
|
+
currency: input.currency
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
function ensurePositiveIntegerIfNonZero(value) {
|
|
344
|
+
if (value !== 0 && !Number.isInteger(value)) {
|
|
345
|
+
throw new ValidationError("discountAmount must be an integer");
|
|
346
|
+
}
|
|
347
|
+
if (value < 0) {
|
|
348
|
+
throw new ValidationError("discountAmount must be non-negative");
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
function extractPolicyCurrency(policy) {
|
|
352
|
+
if (policy.kind === "FIXED") return policy.currency;
|
|
353
|
+
if (policy.kind === "PERCENT") return policy.currency;
|
|
354
|
+
return void 0;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// src/eligibility.ts
|
|
358
|
+
function checkEligibility(input) {
|
|
359
|
+
const { rules, userId, context } = input;
|
|
360
|
+
if (!rules) {
|
|
361
|
+
return { ok: true };
|
|
362
|
+
}
|
|
363
|
+
if (rules.allowedUserIds && rules.allowedUserIds.length > 0) {
|
|
364
|
+
if (!userId || !rules.allowedUserIds.includes(userId)) {
|
|
365
|
+
return { ok: false, reason: COUPON_REJECT_REASON.INELIGIBLE_USER };
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (rules.allowedPlans && rules.allowedPlans.length > 0) {
|
|
369
|
+
const plan = context?.plan;
|
|
370
|
+
if (!plan || !rules.allowedPlans.includes(plan)) {
|
|
371
|
+
return { ok: false, reason: COUPON_REJECT_REASON.INELIGIBLE_PLAN };
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
if (rules.allowedProductIds && rules.allowedProductIds.length > 0) {
|
|
375
|
+
const productId = context?.productId;
|
|
376
|
+
if (!productId || !rules.allowedProductIds.includes(productId)) {
|
|
377
|
+
return { ok: false, reason: COUPON_REJECT_REASON.INELIGIBLE_PRODUCT };
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (rules.allowedCategories && rules.allowedCategories.length > 0) {
|
|
381
|
+
const categoryId = context?.categoryId;
|
|
382
|
+
if (!categoryId || !rules.allowedCategories.includes(categoryId)) {
|
|
383
|
+
return { ok: false, reason: COUPON_REJECT_REASON.INELIGIBLE_CATEGORY };
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return { ok: true };
|
|
387
|
+
}
|
|
388
|
+
function eligibilityReasonToError(reason) {
|
|
389
|
+
switch (reason) {
|
|
390
|
+
case COUPON_REJECT_REASON.INELIGIBLE_USER:
|
|
391
|
+
return new IneligibleUserError();
|
|
392
|
+
case COUPON_REJECT_REASON.INELIGIBLE_PLAN:
|
|
393
|
+
return new IneligiblePlanError();
|
|
394
|
+
case COUPON_REJECT_REASON.INELIGIBLE_PRODUCT:
|
|
395
|
+
return new IneligibleProductError();
|
|
396
|
+
case COUPON_REJECT_REASON.INELIGIBLE_CATEGORY:
|
|
397
|
+
return new IneligibleCategoryError();
|
|
398
|
+
default:
|
|
399
|
+
return new IneligibleUserError(`Unexpected eligibility reason: ${reason}`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
function defaultIdGenerator() {
|
|
403
|
+
return cuid2.createId();
|
|
404
|
+
}
|
|
405
|
+
function defaultCodeGenerator(opts) {
|
|
406
|
+
const alphabet = opts.alphabet ?? LIMITS.CODE_DEFAULT_ALPHABET;
|
|
407
|
+
const length = opts.length;
|
|
408
|
+
if (typeof length !== "number" || !Number.isInteger(length) || length < LIMITS.CODE_MIN_LENGTH || length > LIMITS.CODE_MAX_LENGTH) {
|
|
409
|
+
throw new ValidationError(
|
|
410
|
+
`code length must be an integer in [${LIMITS.CODE_MIN_LENGTH}, ${LIMITS.CODE_MAX_LENGTH}] (got ${String(length)})`
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
if (typeof alphabet !== "string" || alphabet.length === 0) {
|
|
414
|
+
throw new ValidationError("code alphabet must be a non-empty string");
|
|
415
|
+
}
|
|
416
|
+
const nano = nanoid.customAlphabet(alphabet, length);
|
|
417
|
+
return nano();
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// src/mapping.ts
|
|
421
|
+
function asString(v, field) {
|
|
422
|
+
if (typeof v !== "string") {
|
|
423
|
+
throw new Error(`mapping: expected string for ${field}, got ${typeof v}`);
|
|
424
|
+
}
|
|
425
|
+
return v;
|
|
426
|
+
}
|
|
427
|
+
function asStringOrNull(v) {
|
|
428
|
+
if (v === null || v === void 0) return null;
|
|
429
|
+
if (typeof v !== "string") return String(v);
|
|
430
|
+
return v;
|
|
431
|
+
}
|
|
432
|
+
function asNumber(v, field) {
|
|
433
|
+
if (typeof v !== "number") {
|
|
434
|
+
throw new Error(`mapping: expected number for ${field}, got ${typeof v}`);
|
|
435
|
+
}
|
|
436
|
+
return v;
|
|
437
|
+
}
|
|
438
|
+
function asNumberOrNull(v) {
|
|
439
|
+
if (v === null || v === void 0) return null;
|
|
440
|
+
if (typeof v !== "number") return Number(v);
|
|
441
|
+
return v;
|
|
442
|
+
}
|
|
443
|
+
function asDate(v, field) {
|
|
444
|
+
if (v instanceof Date) return v;
|
|
445
|
+
if (typeof v === "string" || typeof v === "number") return new Date(v);
|
|
446
|
+
throw new Error(`mapping: expected Date for ${field}, got ${typeof v}`);
|
|
447
|
+
}
|
|
448
|
+
function asDateOrNull(v) {
|
|
449
|
+
if (v === null || v === void 0) return null;
|
|
450
|
+
if (v instanceof Date) return v;
|
|
451
|
+
if (typeof v === "string" || typeof v === "number") return new Date(v);
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
function asJsonObject(v) {
|
|
455
|
+
if (v === null || v === void 0) return null;
|
|
456
|
+
if (typeof v !== "object") return null;
|
|
457
|
+
return v;
|
|
458
|
+
}
|
|
459
|
+
function jsonAs(v) {
|
|
460
|
+
return asJsonObject(v) ?? {};
|
|
461
|
+
}
|
|
462
|
+
function rowToCoupon(row) {
|
|
463
|
+
return {
|
|
464
|
+
id: asString(row.id, "id"),
|
|
465
|
+
tenantId: asString(row.tenantId, "tenantId"),
|
|
466
|
+
campaignId: asStringOrNull(row.campaignId),
|
|
467
|
+
code: asString(row.code, "code"),
|
|
468
|
+
status: asString(row.status, "status"),
|
|
469
|
+
discount: jsonAs(row.discount),
|
|
470
|
+
startsAt: asDateOrNull(row.startsAt),
|
|
471
|
+
endsAt: asDateOrNull(row.endsAt),
|
|
472
|
+
maxRedemptions: asNumberOrNull(row.maxRedemptions),
|
|
473
|
+
redeemedCount: asNumber(row.redeemedCount, "redeemedCount"),
|
|
474
|
+
maxRedemptionsPerUser: asNumberOrNull(row.maxRedemptionsPerUser),
|
|
475
|
+
eligibility: asJsonObject(row.eligibility) ?? {},
|
|
476
|
+
metadata: asJsonObject(row.metadata),
|
|
477
|
+
createdAt: asDate(row.createdAt, "createdAt"),
|
|
478
|
+
updatedAt: asDate(row.updatedAt, "updatedAt")
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
function rowToCampaign(row) {
|
|
482
|
+
return {
|
|
483
|
+
id: asString(row.id, "id"),
|
|
484
|
+
tenantId: asString(row.tenantId, "tenantId"),
|
|
485
|
+
name: asString(row.name, "name"),
|
|
486
|
+
status: asString(row.status, "status"),
|
|
487
|
+
discount: jsonAs(row.discount),
|
|
488
|
+
eligibility: asJsonObject(row.eligibility) ?? {},
|
|
489
|
+
startsAt: asDateOrNull(row.startsAt),
|
|
490
|
+
endsAt: asDateOrNull(row.endsAt),
|
|
491
|
+
maxCoupons: asNumberOrNull(row.maxCoupons),
|
|
492
|
+
issuedCount: asNumber(row.issuedCount, "issuedCount"),
|
|
493
|
+
metadata: asJsonObject(row.metadata),
|
|
494
|
+
createdAt: asDate(row.createdAt, "createdAt"),
|
|
495
|
+
updatedAt: asDate(row.updatedAt, "updatedAt")
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
function rowToIssuance(row) {
|
|
499
|
+
return {
|
|
500
|
+
id: asString(row.id, "id"),
|
|
501
|
+
tenantId: asString(row.tenantId, "tenantId"),
|
|
502
|
+
couponId: asString(row.couponId, "couponId"),
|
|
503
|
+
issuedToUserId: asStringOrNull(row.issuedToUserId),
|
|
504
|
+
issuedBy: asStringOrNull(row.issuedBy),
|
|
505
|
+
issuedAt: asDate(row.issuedAt, "issuedAt"),
|
|
506
|
+
metadata: asJsonObject(row.metadata)
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
function rowToRedemption(row) {
|
|
510
|
+
return {
|
|
511
|
+
id: asString(row.id, "id"),
|
|
512
|
+
tenantId: asString(row.tenantId, "tenantId"),
|
|
513
|
+
couponId: asString(row.couponId, "couponId"),
|
|
514
|
+
redeemedByUserId: asStringOrNull(row.redeemedByUserId),
|
|
515
|
+
orderRef: asStringOrNull(row.orderRef),
|
|
516
|
+
discountAmount: asNumber(row.discountAmount, "discountAmount"),
|
|
517
|
+
currency: asString(row.currency, "currency"),
|
|
518
|
+
redeemedAt: asDate(row.redeemedAt, "redeemedAt"),
|
|
519
|
+
metadata: asJsonObject(row.metadata)
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
function rowToAuditLog(row) {
|
|
523
|
+
return {
|
|
524
|
+
id: asString(row.id, "id"),
|
|
525
|
+
tenantId: asString(row.tenantId, "tenantId"),
|
|
526
|
+
action: asString(row.action, "action"),
|
|
527
|
+
resourceId: asStringOrNull(row.resourceId),
|
|
528
|
+
resourceType: asStringOrNull(row.resourceType),
|
|
529
|
+
actorId: asStringOrNull(row.actorId),
|
|
530
|
+
details: asJsonObject(row.details),
|
|
531
|
+
createdAt: asDate(row.createdAt, "createdAt")
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// src/audit.ts
|
|
536
|
+
async function writeAudit(auditLog, input) {
|
|
537
|
+
await auditLog.create({
|
|
538
|
+
data: {
|
|
539
|
+
tenantId: input.tenantId,
|
|
540
|
+
action: input.action,
|
|
541
|
+
resourceId: input.resourceId ?? null,
|
|
542
|
+
resourceType: input.resourceType ?? null,
|
|
543
|
+
actorId: input.actorId ?? null,
|
|
544
|
+
details: input.details ?? null
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// src/delegates.ts
|
|
550
|
+
var MODEL_KEYS = [
|
|
551
|
+
"coupon",
|
|
552
|
+
"campaign",
|
|
553
|
+
"couponIssuance",
|
|
554
|
+
"couponRedemption",
|
|
555
|
+
"couponAuditLog"
|
|
556
|
+
];
|
|
557
|
+
var DEFAULT_MODEL_NAMES = {
|
|
558
|
+
coupon: "coupon",
|
|
559
|
+
campaign: "campaign",
|
|
560
|
+
couponIssuance: "couponIssuance",
|
|
561
|
+
couponRedemption: "couponRedemption",
|
|
562
|
+
couponAuditLog: "couponAuditLog"
|
|
563
|
+
};
|
|
564
|
+
var IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
565
|
+
function assertIdentifier(kind, key, value) {
|
|
566
|
+
if (typeof value !== "string" || !IDENTIFIER_RE.test(value)) {
|
|
567
|
+
throw new InvalidModelNameError(
|
|
568
|
+
`Invalid ${kind} for model "${key}": ${JSON.stringify(value)}. Expected a plain identifier matching ${IDENTIFIER_RE.source}.`
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
return value;
|
|
572
|
+
}
|
|
573
|
+
function resolveModelNames(overrides) {
|
|
574
|
+
const out = { ...DEFAULT_MODEL_NAMES };
|
|
575
|
+
for (const key of MODEL_KEYS) {
|
|
576
|
+
const override = overrides?.[key];
|
|
577
|
+
if (override === void 0) continue;
|
|
578
|
+
out[key] = assertIdentifier("model name", key, override);
|
|
579
|
+
}
|
|
580
|
+
return out;
|
|
581
|
+
}
|
|
582
|
+
function resolveTableNames(models, overrides) {
|
|
583
|
+
const out = {};
|
|
584
|
+
for (const key of MODEL_KEYS) {
|
|
585
|
+
const override = overrides?.[key];
|
|
586
|
+
out[key] = override === void 0 ? models[key].charAt(0).toUpperCase() + models[key].slice(1) : assertIdentifier("table name", key, override);
|
|
587
|
+
}
|
|
588
|
+
return out;
|
|
589
|
+
}
|
|
590
|
+
function pick(db, key, name) {
|
|
591
|
+
const delegate = db[name];
|
|
592
|
+
if (!delegate || typeof delegate.findFirst !== "function") {
|
|
593
|
+
throw new InvalidModelNameError(
|
|
594
|
+
`Prisma delegate "${name}" (model "${key}") is missing on the client passed to createCouponClient. Either add the model to your schema or map it through config.models.${key}.`
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
return delegate;
|
|
598
|
+
}
|
|
599
|
+
function createDelegateResolver(models, tables) {
|
|
600
|
+
const resolver = {
|
|
601
|
+
models,
|
|
602
|
+
tables
|
|
603
|
+
};
|
|
604
|
+
for (const key of MODEL_KEYS) {
|
|
605
|
+
resolver[key] = (db) => pick(db, key, models[key]);
|
|
606
|
+
}
|
|
607
|
+
return resolver;
|
|
608
|
+
}
|
|
609
|
+
function lockCouponRow(tx, table, couponId, tenantId) {
|
|
610
|
+
const parts = [
|
|
611
|
+
`SELECT * FROM "${table}" WHERE "id" = `,
|
|
612
|
+
` AND "tenantId" = `,
|
|
613
|
+
` FOR UPDATE`
|
|
614
|
+
];
|
|
615
|
+
const strings = Object.assign([...parts], {
|
|
616
|
+
raw: [...parts]
|
|
617
|
+
});
|
|
618
|
+
return tx.$queryRaw(strings, couponId, tenantId);
|
|
619
|
+
}
|
|
620
|
+
function assertTenantId(value) {
|
|
621
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
622
|
+
throw new ValidationError("tenantId is required");
|
|
623
|
+
}
|
|
624
|
+
if (value.length > LIMITS.TENANT_ID_MAX_LENGTH) {
|
|
625
|
+
throw new ValidationError(
|
|
626
|
+
`tenantId length exceeds max (${LIMITS.TENANT_ID_MAX_LENGTH})`
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
if (!LIMITS.TENANT_ID_REGEX.test(value)) {
|
|
630
|
+
throw new ValidationError(
|
|
631
|
+
`tenantId contains invalid characters (allowed: [a-zA-Z0-9_-:.])`
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
function sanitizeCode(raw) {
|
|
636
|
+
if (typeof raw !== "string") {
|
|
637
|
+
throw new ValidationError("code must be a string");
|
|
638
|
+
}
|
|
639
|
+
const normalized = raw.normalize("NFKC");
|
|
640
|
+
if (normalized.length === 0 || normalized.length > LIMITS.CODE_MAX_LENGTH) {
|
|
641
|
+
throw new ValidationError(
|
|
642
|
+
`code length must be in [1, ${LIMITS.CODE_MAX_LENGTH}]`
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
646
|
+
const cp = normalized.charCodeAt(i);
|
|
647
|
+
if (cp === 0) {
|
|
648
|
+
throw new ValidationError("code contains NUL");
|
|
649
|
+
}
|
|
650
|
+
if (cp < 32 || cp === 127) {
|
|
651
|
+
throw new ValidationError("code contains control characters");
|
|
652
|
+
}
|
|
653
|
+
if (cp === 32 || cp === 9 || cp === 10 || cp === 13) {
|
|
654
|
+
throw new ValidationError("code contains whitespace");
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
const upper = normalized.toUpperCase();
|
|
658
|
+
if (!LIMITS.CODE_ALLOWED_REGEX.test(upper)) {
|
|
659
|
+
throw new ValidationError(
|
|
660
|
+
"coupon code contains invalid characters; allowed: [A-Z0-9_-]"
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
return upper;
|
|
664
|
+
}
|
|
665
|
+
function canonicalizeCode(raw) {
|
|
666
|
+
return sanitizeCode(raw);
|
|
667
|
+
}
|
|
668
|
+
function assertMetadata(meta) {
|
|
669
|
+
if (meta === void 0 || meta === null) return meta;
|
|
670
|
+
if (typeof meta !== "object" || Array.isArray(meta)) {
|
|
671
|
+
throw new ValidationError("metadata must be a plain object");
|
|
672
|
+
}
|
|
673
|
+
const rec = meta;
|
|
674
|
+
for (const key of Object.keys(rec)) {
|
|
675
|
+
if (FORBIDDEN_METADATA_KEYS.includes(key)) {
|
|
676
|
+
throw new ValidationError(
|
|
677
|
+
`metadata key "${key}" is forbidden (prototype pollution guard)`
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
return rec;
|
|
682
|
+
}
|
|
683
|
+
function assertPaginationLimit(limit) {
|
|
684
|
+
if (limit === void 0) return LIMITS.PAGINATION_DEFAULT_LIMIT;
|
|
685
|
+
if (typeof limit !== "number" || !Number.isInteger(limit) || limit <= 0 || limit > LIMITS.PAGINATION_MAX_LIMIT) {
|
|
686
|
+
throw new ValidationError(
|
|
687
|
+
`limit must be an integer in [1, ${LIMITS.PAGINATION_MAX_LIMIT}]`
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
return limit;
|
|
691
|
+
}
|
|
692
|
+
function assertCurrency(currency) {
|
|
693
|
+
if (typeof currency !== "string" || currency.length !== LIMITS.CURRENCY_LENGTH) {
|
|
694
|
+
throw new ValidationError(
|
|
695
|
+
`currency must be a ${LIMITS.CURRENCY_LENGTH}-letter ISO-4217 code`
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
function assertDateRange(startsAt, endsAt) {
|
|
700
|
+
if (startsAt && endsAt) {
|
|
701
|
+
if (!(startsAt instanceof Date) || !(endsAt instanceof Date)) {
|
|
702
|
+
throw new ValidationError("startsAt/endsAt must be Date objects");
|
|
703
|
+
}
|
|
704
|
+
if (startsAt.getTime() >= endsAt.getTime()) {
|
|
705
|
+
throw new InvalidDateRangeError(
|
|
706
|
+
"startsAt must be strictly less than endsAt"
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
function assertEligibility(rules) {
|
|
712
|
+
if (rules === void 0 || rules === null) return {};
|
|
713
|
+
if (typeof rules !== "object" || Array.isArray(rules)) {
|
|
714
|
+
throw new ValidationError("eligibility must be a plain object");
|
|
715
|
+
}
|
|
716
|
+
const r = rules;
|
|
717
|
+
const allowed = [
|
|
718
|
+
"allowedUserIds",
|
|
719
|
+
"allowedPlans",
|
|
720
|
+
"allowedProductIds",
|
|
721
|
+
"allowedCategories"
|
|
722
|
+
];
|
|
723
|
+
const out = {};
|
|
724
|
+
for (const key of allowed) {
|
|
725
|
+
const v = r[key];
|
|
726
|
+
if (v === void 0) continue;
|
|
727
|
+
if (!Array.isArray(v) || !v.every((x) => typeof x === "string" && x.length > 0)) {
|
|
728
|
+
throw new ValidationError(
|
|
729
|
+
`eligibility.${key} must be an array of non-empty strings`
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
out[key] = v;
|
|
733
|
+
}
|
|
734
|
+
return out;
|
|
735
|
+
}
|
|
736
|
+
function resolveTenantId(provided, defaultTenantId) {
|
|
737
|
+
const tid = provided ?? defaultTenantId;
|
|
738
|
+
assertTenantId(tid);
|
|
739
|
+
return tid;
|
|
740
|
+
}
|
|
741
|
+
function validateCreateCouponInput(input) {
|
|
742
|
+
if (!input || typeof input !== "object") {
|
|
743
|
+
throw new ValidationError("input must be an object");
|
|
744
|
+
}
|
|
745
|
+
validateDiscountPolicy(input.discount);
|
|
746
|
+
assertDateRange(input.startsAt ?? null, input.endsAt ?? null);
|
|
747
|
+
assertMetadata(input.metadata);
|
|
748
|
+
if (input.maxRedemptions !== void 0 && input.maxRedemptions !== null) {
|
|
749
|
+
if (typeof input.maxRedemptions !== "number" || !Number.isInteger(input.maxRedemptions) || input.maxRedemptions <= 0) {
|
|
750
|
+
throw new ValidationError("maxRedemptions must be a positive integer or null");
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
if (input.maxRedemptionsPerUser !== void 0 && input.maxRedemptionsPerUser !== null) {
|
|
754
|
+
if (typeof input.maxRedemptionsPerUser !== "number" || !Number.isInteger(input.maxRedemptionsPerUser) || input.maxRedemptionsPerUser <= 0) {
|
|
755
|
+
throw new ValidationError(
|
|
756
|
+
"maxRedemptionsPerUser must be a positive integer or null"
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
function validateIssueBulkInput(input) {
|
|
762
|
+
if (!input || typeof input !== "object") {
|
|
763
|
+
throw new ValidationError("input must be an object");
|
|
764
|
+
}
|
|
765
|
+
if (typeof input.count !== "number" || !Number.isInteger(input.count) || input.count <= 0) {
|
|
766
|
+
throw new ValidationError("count must be a positive integer");
|
|
767
|
+
}
|
|
768
|
+
if (input.count > LIMITS.BULK_MAX_COUNT) {
|
|
769
|
+
throw new ValidationError(
|
|
770
|
+
`count exceeds max bulk limit (${LIMITS.BULK_MAX_COUNT})`
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
if (input.maxAttempts !== void 0) {
|
|
774
|
+
if (typeof input.maxAttempts !== "number" || !Number.isInteger(input.maxAttempts) || input.maxAttempts <= 0 || input.maxAttempts > LIMITS.BULK_MAX_ATTEMPTS) {
|
|
775
|
+
throw new ValidationError(
|
|
776
|
+
`maxAttempts must be in [1, ${LIMITS.BULK_MAX_ATTEMPTS}]`
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
validateDiscountPolicy(input.discount);
|
|
781
|
+
assertDateRange(input.startsAt ?? null, input.endsAt ?? null);
|
|
782
|
+
assertMetadata(input.metadata);
|
|
783
|
+
}
|
|
784
|
+
function validateIssueCouponInput(input) {
|
|
785
|
+
if (!input || typeof input !== "object") {
|
|
786
|
+
throw new ValidationError("input must be an object");
|
|
787
|
+
}
|
|
788
|
+
if (typeof input.couponId !== "string" || input.couponId.length === 0) {
|
|
789
|
+
throw new ValidationError("couponId is required");
|
|
790
|
+
}
|
|
791
|
+
assertMetadata(input.metadata);
|
|
792
|
+
}
|
|
793
|
+
function validateValidateInput(input) {
|
|
794
|
+
if (!input || typeof input !== "object") {
|
|
795
|
+
throw new ValidationError("input must be an object");
|
|
796
|
+
}
|
|
797
|
+
if (typeof input.code !== "string" || input.code.length === 0) {
|
|
798
|
+
throw new ValidationError("code is required");
|
|
799
|
+
}
|
|
800
|
+
if (input.subtotal !== void 0) {
|
|
801
|
+
if (typeof input.subtotal !== "number" || !Number.isInteger(input.subtotal) || input.subtotal < 0) {
|
|
802
|
+
throw new ValidationError("subtotal must be a non-negative integer");
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
if (input.currency !== void 0) {
|
|
806
|
+
assertCurrency(input.currency);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
function validateRedeemInput(input) {
|
|
810
|
+
if (!input || typeof input !== "object") {
|
|
811
|
+
throw new ValidationError("input must be an object");
|
|
812
|
+
}
|
|
813
|
+
if (typeof input.code !== "string" || input.code.length === 0) {
|
|
814
|
+
throw new ValidationError("code is required");
|
|
815
|
+
}
|
|
816
|
+
if (typeof input.subtotal !== "number" || !Number.isInteger(input.subtotal) || input.subtotal < 0) {
|
|
817
|
+
throw new ValidationError("subtotal must be a non-negative integer");
|
|
818
|
+
}
|
|
819
|
+
assertCurrency(input.currency);
|
|
820
|
+
assertMetadata(input.metadata);
|
|
821
|
+
}
|
|
822
|
+
function validateCreateCampaignInput(input) {
|
|
823
|
+
if (!input || typeof input !== "object") {
|
|
824
|
+
throw new ValidationError("input must be an object");
|
|
825
|
+
}
|
|
826
|
+
if (typeof input.name !== "string" || input.name.length === 0) {
|
|
827
|
+
throw new ValidationError("campaign name is required");
|
|
828
|
+
}
|
|
829
|
+
if (input.name.length > LIMITS.NAME_MAX_LENGTH) {
|
|
830
|
+
throw new ValidationError(
|
|
831
|
+
`campaign name exceeds max length (${LIMITS.NAME_MAX_LENGTH})`
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
validateDiscountPolicy(input.discount);
|
|
835
|
+
assertDateRange(input.startsAt ?? null, input.endsAt ?? null);
|
|
836
|
+
assertMetadata(input.metadata);
|
|
837
|
+
if (input.maxCoupons !== void 0 && input.maxCoupons !== null) {
|
|
838
|
+
if (typeof input.maxCoupons !== "number" || !Number.isInteger(input.maxCoupons) || input.maxCoupons <= 0) {
|
|
839
|
+
throw new ValidationError(
|
|
840
|
+
"maxCoupons must be a positive integer or null"
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
if (input.maxCoupons > LIMITS.BULK_MAX_COUNT) {
|
|
844
|
+
throw new ValidationError(
|
|
845
|
+
`maxCoupons exceeds bulk limit (${LIMITS.BULK_MAX_COUNT})`
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
zod.z.discriminatedUnion("kind", [
|
|
851
|
+
zod.z.object({
|
|
852
|
+
kind: zod.z.literal("PERCENT"),
|
|
853
|
+
percent: zod.z.number().gt(0).lte(100),
|
|
854
|
+
maxDiscount: zod.z.number().int().nonnegative().optional(),
|
|
855
|
+
minOrder: zod.z.number().int().nonnegative().optional(),
|
|
856
|
+
currency: zod.z.string().length(3).optional()
|
|
857
|
+
}),
|
|
858
|
+
zod.z.object({
|
|
859
|
+
kind: zod.z.literal("FIXED"),
|
|
860
|
+
amount: zod.z.number().int().positive(),
|
|
861
|
+
currency: zod.z.string().length(3),
|
|
862
|
+
minOrder: zod.z.number().int().nonnegative().optional()
|
|
863
|
+
})
|
|
864
|
+
]);
|
|
865
|
+
|
|
866
|
+
// src/client.ts
|
|
867
|
+
function w(v) {
|
|
868
|
+
return v;
|
|
869
|
+
}
|
|
870
|
+
var NOOP_LOGGER = {
|
|
871
|
+
debug() {
|
|
872
|
+
},
|
|
873
|
+
info() {
|
|
874
|
+
},
|
|
875
|
+
warn() {
|
|
876
|
+
},
|
|
877
|
+
error() {
|
|
878
|
+
}
|
|
879
|
+
};
|
|
880
|
+
var NOOP_EVENTS = {
|
|
881
|
+
onEvent() {
|
|
882
|
+
}
|
|
883
|
+
};
|
|
884
|
+
function resolveConfig(config) {
|
|
885
|
+
if (!config || typeof config !== "object") {
|
|
886
|
+
throw new ValidationError("createCouponClient: config is required");
|
|
887
|
+
}
|
|
888
|
+
if (!config.prisma || typeof config.prisma !== "object") {
|
|
889
|
+
throw new ValidationError("createCouponClient: prisma client is required");
|
|
890
|
+
}
|
|
891
|
+
const models = resolveModelNames(config.models);
|
|
892
|
+
return {
|
|
893
|
+
prisma: config.prisma,
|
|
894
|
+
defaultTenantId: config.defaultTenantId,
|
|
895
|
+
idGenerator: config.idGenerator ?? defaultIdGenerator,
|
|
896
|
+
codeGenerator: config.codeGenerator ?? defaultCodeGenerator,
|
|
897
|
+
events: config.events ?? NOOP_EVENTS,
|
|
898
|
+
now: config.now ?? (() => /* @__PURE__ */ new Date()),
|
|
899
|
+
// allow-now: default fallback when config.now is omitted
|
|
900
|
+
logger: config.logger ?? NOOP_LOGGER,
|
|
901
|
+
models,
|
|
902
|
+
tables: resolveTableNames(models, config.tables)
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
async function safeEmit(handler, logger, event) {
|
|
906
|
+
try {
|
|
907
|
+
await handler.onEvent(event);
|
|
908
|
+
} catch (error) {
|
|
909
|
+
logger.warn("coupon.event.handler_failed", {
|
|
910
|
+
event: event.type,
|
|
911
|
+
error: error instanceof Error ? error.message : String(error)
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
async function resolveLazyStatus(delegates, tx, coupon, now) {
|
|
916
|
+
if (coupon.status === COUPON_STATUS.ARCHIVED) {
|
|
917
|
+
return { kind: "reject", reason: "ARCHIVED", coupon };
|
|
918
|
+
}
|
|
919
|
+
if (coupon.status === COUPON_STATUS.PAUSED) {
|
|
920
|
+
return { kind: "reject", reason: "PAUSED", coupon };
|
|
921
|
+
}
|
|
922
|
+
if (coupon.status === COUPON_STATUS.DRAFT) {
|
|
923
|
+
return { kind: "reject", reason: "PAUSED", coupon };
|
|
924
|
+
}
|
|
925
|
+
if (coupon.startsAt && now.getTime() < coupon.startsAt.getTime()) {
|
|
926
|
+
return { kind: "reject", reason: "NOT_STARTED", coupon };
|
|
927
|
+
}
|
|
928
|
+
if (coupon.endsAt && now.getTime() >= coupon.endsAt.getTime()) {
|
|
929
|
+
if (coupon.status !== COUPON_STATUS.EXPIRED) {
|
|
930
|
+
await delegates.coupon(tx).updateMany({
|
|
931
|
+
where: {
|
|
932
|
+
id: coupon.id,
|
|
933
|
+
tenantId: coupon.tenantId,
|
|
934
|
+
status: { notIn: [COUPON_STATUS.ARCHIVED, COUPON_STATUS.EXPIRED] }
|
|
935
|
+
},
|
|
936
|
+
data: { status: COUPON_STATUS.EXPIRED }
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
return {
|
|
940
|
+
kind: "reject",
|
|
941
|
+
reason: "EXPIRED",
|
|
942
|
+
coupon: { ...coupon, status: COUPON_STATUS.EXPIRED }
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
if (coupon.maxRedemptions !== null && coupon.redeemedCount >= coupon.maxRedemptions) {
|
|
946
|
+
if (coupon.status !== COUPON_STATUS.EXHAUSTED) {
|
|
947
|
+
await delegates.coupon(tx).updateMany({
|
|
948
|
+
where: {
|
|
949
|
+
id: coupon.id,
|
|
950
|
+
tenantId: coupon.tenantId,
|
|
951
|
+
status: { notIn: [COUPON_STATUS.ARCHIVED, COUPON_STATUS.EXHAUSTED] }
|
|
952
|
+
},
|
|
953
|
+
data: { status: COUPON_STATUS.EXHAUSTED }
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
return {
|
|
957
|
+
kind: "reject",
|
|
958
|
+
reason: "EXHAUSTED",
|
|
959
|
+
coupon: { ...coupon, status: COUPON_STATUS.EXHAUSTED }
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
return { kind: "ok", coupon };
|
|
963
|
+
}
|
|
964
|
+
function withIdTieBreak(orderBy) {
|
|
965
|
+
if ("id" in orderBy) return [orderBy];
|
|
966
|
+
const leading = Object.values(orderBy)[0];
|
|
967
|
+
const direction = leading === "asc" ? "asc" : "desc";
|
|
968
|
+
return [orderBy, { id: direction }];
|
|
969
|
+
}
|
|
970
|
+
function createCouponClient(config) {
|
|
971
|
+
const cfg = resolveConfig(config);
|
|
972
|
+
const prisma = cfg.prisma;
|
|
973
|
+
const delegates = createDelegateResolver(cfg.models, cfg.tables);
|
|
974
|
+
const tid = (v) => resolveTenantId(v, cfg.defaultTenantId);
|
|
975
|
+
async function paginate(delegate, where, limit, cursor, mapFn, orderBy = { createdAt: "desc" }) {
|
|
976
|
+
const take = limit + 1;
|
|
977
|
+
const findArgs = {
|
|
978
|
+
where,
|
|
979
|
+
take,
|
|
980
|
+
orderBy: withIdTieBreak(orderBy)
|
|
981
|
+
};
|
|
982
|
+
if (cursor) {
|
|
983
|
+
findArgs.cursor = { id: cursor };
|
|
984
|
+
findArgs.skip = 1;
|
|
985
|
+
}
|
|
986
|
+
const rows = await delegate.findMany(findArgs);
|
|
987
|
+
const hasMore = rows.length > limit;
|
|
988
|
+
const sliced = hasMore ? rows.slice(0, limit) : rows;
|
|
989
|
+
const items = sliced.map(mapFn);
|
|
990
|
+
const nextCursor = hasMore ? String(sliced[sliced.length - 1].id) : null;
|
|
991
|
+
return { items, nextCursor };
|
|
992
|
+
}
|
|
993
|
+
async function createOneCouponRow(tx, tenantId, data, maxAttempts) {
|
|
994
|
+
const baseCode = data.code ?? void 0;
|
|
995
|
+
const codeLength = data._codeLength ?? LIMITS.CODE_DEFAULT_LENGTH;
|
|
996
|
+
delete data._codeLength;
|
|
997
|
+
let lastError;
|
|
998
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
999
|
+
const code = baseCode ?? cfg.codeGenerator({ length: codeLength });
|
|
1000
|
+
sanitizeCode(code);
|
|
1001
|
+
const row = { ...data, code: canonicalizeCode(code), tenantId };
|
|
1002
|
+
try {
|
|
1003
|
+
const created = await delegates.coupon(tx).create({ data: row });
|
|
1004
|
+
return rowToCoupon(created);
|
|
1005
|
+
} catch (err) {
|
|
1006
|
+
lastError = err;
|
|
1007
|
+
if (baseCode) {
|
|
1008
|
+
throw new ValidationError(
|
|
1009
|
+
`coupon code already exists in tenant`,
|
|
1010
|
+
{ prismaError: err instanceof Error ? err.message : String(err) },
|
|
1011
|
+
{ tenantId, couponCode: baseCode }
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
throw new ValidationError(
|
|
1017
|
+
`failed to create coupon after ${maxAttempts} attempts (unique code collision)`,
|
|
1018
|
+
{ lastError: lastError instanceof Error ? lastError.message : String(lastError) },
|
|
1019
|
+
{ tenantId }
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
const coupons = {
|
|
1023
|
+
async create(input) {
|
|
1024
|
+
validateCreateCouponInput(input);
|
|
1025
|
+
const tenantId = tid(input.tenantId);
|
|
1026
|
+
const eligibility = assertEligibility(input.eligibility);
|
|
1027
|
+
const metadata = assertMetadata(input.metadata);
|
|
1028
|
+
const now = cfg.now();
|
|
1029
|
+
const initialStatus = input.status ?? COUPON_STATUS.ACTIVE;
|
|
1030
|
+
const coupon = await prisma.$transaction(async (tx) => {
|
|
1031
|
+
const coupon2 = await createOneCouponRow(
|
|
1032
|
+
tx,
|
|
1033
|
+
tenantId,
|
|
1034
|
+
{
|
|
1035
|
+
id: cfg.idGenerator(),
|
|
1036
|
+
code: input.code,
|
|
1037
|
+
_codeLength: input.codeLength,
|
|
1038
|
+
campaignId: input.campaignId ?? null,
|
|
1039
|
+
status: initialStatus,
|
|
1040
|
+
discount: w(input.discount),
|
|
1041
|
+
startsAt: input.startsAt ?? null,
|
|
1042
|
+
endsAt: input.endsAt ?? null,
|
|
1043
|
+
maxRedemptions: input.maxRedemptions ?? null,
|
|
1044
|
+
maxRedemptionsPerUser: input.maxRedemptionsPerUser ?? null,
|
|
1045
|
+
eligibility: w(eligibility),
|
|
1046
|
+
metadata: w(metadata ?? null)
|
|
1047
|
+
},
|
|
1048
|
+
1
|
|
1049
|
+
// user-supplied code: no retry; generator: also fine with 1 here but default generator inherently retries below if needed
|
|
1050
|
+
);
|
|
1051
|
+
await writeAudit(delegates.couponAuditLog(tx), {
|
|
1052
|
+
tenantId,
|
|
1053
|
+
action: AUDIT_ACTION.COUPON_CREATED,
|
|
1054
|
+
resourceId: coupon2.id,
|
|
1055
|
+
resourceType: RESOURCE_TYPE.COUPON,
|
|
1056
|
+
actorId: input.actorId,
|
|
1057
|
+
details: { code: coupon2.code }
|
|
1058
|
+
});
|
|
1059
|
+
return coupon2;
|
|
1060
|
+
});
|
|
1061
|
+
await safeEmit(cfg.events, cfg.logger, {
|
|
1062
|
+
type: "coupon.created",
|
|
1063
|
+
at: now,
|
|
1064
|
+
tenantId,
|
|
1065
|
+
coupon
|
|
1066
|
+
});
|
|
1067
|
+
return coupon;
|
|
1068
|
+
},
|
|
1069
|
+
async issue(input) {
|
|
1070
|
+
validateIssueCouponInput(input);
|
|
1071
|
+
const tenantId = tid(input.tenantId);
|
|
1072
|
+
const metadata = assertMetadata(input.metadata);
|
|
1073
|
+
const now = cfg.now();
|
|
1074
|
+
const issuance = await prisma.$transaction(async (tx) => {
|
|
1075
|
+
const couponRow = await delegates.coupon(tx).findFirst({
|
|
1076
|
+
where: { id: input.couponId, tenantId }
|
|
1077
|
+
});
|
|
1078
|
+
if (!couponRow) throw new CouponNotFoundError("Coupon not found", { tenantId, couponId: input.couponId });
|
|
1079
|
+
const coupon = rowToCoupon(couponRow);
|
|
1080
|
+
if (coupon.status === COUPON_STATUS.ARCHIVED) {
|
|
1081
|
+
throw new CouponArchivedError(void 0, { tenantId, couponId: coupon.id });
|
|
1082
|
+
}
|
|
1083
|
+
const row = await delegates.couponIssuance(tx).create({
|
|
1084
|
+
data: {
|
|
1085
|
+
id: cfg.idGenerator(),
|
|
1086
|
+
tenantId,
|
|
1087
|
+
couponId: coupon.id,
|
|
1088
|
+
issuedToUserId: input.issuedToUserId ?? null,
|
|
1089
|
+
issuedBy: input.issuedBy ?? null,
|
|
1090
|
+
metadata: w(metadata ?? null)
|
|
1091
|
+
}
|
|
1092
|
+
});
|
|
1093
|
+
const issuance2 = rowToIssuance(row);
|
|
1094
|
+
await writeAudit(delegates.couponAuditLog(tx), {
|
|
1095
|
+
tenantId,
|
|
1096
|
+
action: AUDIT_ACTION.COUPON_ISSUED,
|
|
1097
|
+
resourceId: coupon.id,
|
|
1098
|
+
resourceType: RESOURCE_TYPE.COUPON,
|
|
1099
|
+
details: { issuanceId: issuance2.id, userId: issuance2.issuedToUserId }
|
|
1100
|
+
});
|
|
1101
|
+
return issuance2;
|
|
1102
|
+
});
|
|
1103
|
+
await safeEmit(cfg.events, cfg.logger, {
|
|
1104
|
+
type: "coupon.issued",
|
|
1105
|
+
at: now,
|
|
1106
|
+
tenantId,
|
|
1107
|
+
issuance
|
|
1108
|
+
});
|
|
1109
|
+
return issuance;
|
|
1110
|
+
},
|
|
1111
|
+
async issueBulk(input) {
|
|
1112
|
+
validateIssueBulkInput(input);
|
|
1113
|
+
const tenantId = tid(input.tenantId);
|
|
1114
|
+
const eligibility = assertEligibility(input.eligibility);
|
|
1115
|
+
const metadata = assertMetadata(input.metadata);
|
|
1116
|
+
const now = cfg.now();
|
|
1117
|
+
const maxAttempts = input.maxAttempts ?? LIMITS.BULK_DEFAULT_MAX_ATTEMPTS;
|
|
1118
|
+
const codeLength = input.codeLength ?? LIMITS.CODE_DEFAULT_LENGTH;
|
|
1119
|
+
const created = [];
|
|
1120
|
+
const codes = [];
|
|
1121
|
+
let skipped = 0;
|
|
1122
|
+
let totalAttempts = 0;
|
|
1123
|
+
await prisma.$transaction(async (tx) => {
|
|
1124
|
+
for (let i = 0; i < input.count; i++) {
|
|
1125
|
+
let placed = null;
|
|
1126
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
1127
|
+
totalAttempts++;
|
|
1128
|
+
const code = cfg.codeGenerator({ length: codeLength });
|
|
1129
|
+
try {
|
|
1130
|
+
sanitizeCode(code);
|
|
1131
|
+
const canonical = canonicalizeCode(code);
|
|
1132
|
+
const row = await delegates.coupon(tx).create({
|
|
1133
|
+
data: {
|
|
1134
|
+
id: cfg.idGenerator(),
|
|
1135
|
+
tenantId,
|
|
1136
|
+
code: canonical,
|
|
1137
|
+
campaignId: input.campaignId ?? null,
|
|
1138
|
+
status: COUPON_STATUS.ACTIVE,
|
|
1139
|
+
discount: w(input.discount),
|
|
1140
|
+
startsAt: input.startsAt ?? null,
|
|
1141
|
+
endsAt: input.endsAt ?? null,
|
|
1142
|
+
maxRedemptions: input.maxRedemptions ?? null,
|
|
1143
|
+
maxRedemptionsPerUser: input.maxRedemptionsPerUser ?? null,
|
|
1144
|
+
eligibility: w(eligibility),
|
|
1145
|
+
metadata: w(metadata ?? null)
|
|
1146
|
+
}
|
|
1147
|
+
});
|
|
1148
|
+
placed = rowToCoupon(row);
|
|
1149
|
+
break;
|
|
1150
|
+
} catch (err) {
|
|
1151
|
+
if (attempt === maxAttempts) {
|
|
1152
|
+
skipped++;
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
if (placed) {
|
|
1157
|
+
created.push(placed);
|
|
1158
|
+
codes.push(placed.code);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
if (created.length === 0 && input.count > 0) {
|
|
1162
|
+
throw new ValidationError(
|
|
1163
|
+
"bulk issuance failed: exhausted maxAttempts without any successful row"
|
|
1164
|
+
);
|
|
1165
|
+
}
|
|
1166
|
+
if (input.campaignId && created.length > 0) {
|
|
1167
|
+
const campRow = await delegates.campaign(tx).findFirst({
|
|
1168
|
+
where: { id: input.campaignId, tenantId }
|
|
1169
|
+
});
|
|
1170
|
+
if (!campRow) {
|
|
1171
|
+
throw new ValidationError("campaign not found", void 0, {
|
|
1172
|
+
tenantId,
|
|
1173
|
+
couponId: input.campaignId
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
const campaign = rowToCampaign(campRow);
|
|
1177
|
+
if (campaign.status === CAMPAIGN_STATUS.CLOSED) {
|
|
1178
|
+
throw new CampaignClosedError(void 0, {
|
|
1179
|
+
tenantId,
|
|
1180
|
+
couponId: campaign.id
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
const upd = await delegates.campaign(tx).updateMany({
|
|
1184
|
+
where: {
|
|
1185
|
+
id: input.campaignId,
|
|
1186
|
+
tenantId,
|
|
1187
|
+
status: { not: CAMPAIGN_STATUS.CLOSED },
|
|
1188
|
+
// threshold: issuedCount <= maxCoupons - created.length
|
|
1189
|
+
// (equivalent to "issuedCount + created.length <= maxCoupons").
|
|
1190
|
+
// Omit the constraint entirely if maxCoupons is null (unbounded).
|
|
1191
|
+
...campaign.maxCoupons !== null ? { issuedCount: { lte: campaign.maxCoupons - created.length } } : {}
|
|
1192
|
+
},
|
|
1193
|
+
data: w({ issuedCount: { increment: created.length } })
|
|
1194
|
+
});
|
|
1195
|
+
if (upd.count === 0) {
|
|
1196
|
+
const post = await delegates.campaign(tx).findFirst({
|
|
1197
|
+
where: { id: input.campaignId, tenantId }
|
|
1198
|
+
});
|
|
1199
|
+
if (post) {
|
|
1200
|
+
const after = rowToCampaign(post);
|
|
1201
|
+
if (after.status === CAMPAIGN_STATUS.CLOSED) {
|
|
1202
|
+
throw new CampaignClosedError(void 0, {
|
|
1203
|
+
tenantId,
|
|
1204
|
+
couponId: after.id
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
throw new CampaignCapacityExceededError(void 0, {
|
|
1209
|
+
tenantId,
|
|
1210
|
+
couponId: campaign.id
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
await writeAudit(delegates.couponAuditLog(tx), {
|
|
1215
|
+
tenantId,
|
|
1216
|
+
action: AUDIT_ACTION.COUPON_ISSUED_BULK,
|
|
1217
|
+
resourceId: input.campaignId ?? null,
|
|
1218
|
+
resourceType: RESOURCE_TYPE.CAMPAIGN,
|
|
1219
|
+
actorId: input.actorId,
|
|
1220
|
+
details: {
|
|
1221
|
+
count: created.length,
|
|
1222
|
+
skipped,
|
|
1223
|
+
campaignId: input.campaignId ?? null
|
|
1224
|
+
}
|
|
1225
|
+
});
|
|
1226
|
+
});
|
|
1227
|
+
await safeEmit(cfg.events, cfg.logger, {
|
|
1228
|
+
type: "coupon.issued.bulk",
|
|
1229
|
+
at: now,
|
|
1230
|
+
tenantId,
|
|
1231
|
+
campaignId: input.campaignId,
|
|
1232
|
+
count: created.length
|
|
1233
|
+
});
|
|
1234
|
+
return { created, skipped, codes, attempts: totalAttempts };
|
|
1235
|
+
},
|
|
1236
|
+
async findByCode(args) {
|
|
1237
|
+
const tenantId = tid(args.tenantId);
|
|
1238
|
+
sanitizeCode(args.code);
|
|
1239
|
+
const canonical = canonicalizeCode(args.code);
|
|
1240
|
+
const row = await delegates.coupon(prisma).findFirst({
|
|
1241
|
+
where: { tenantId, code: canonical }
|
|
1242
|
+
});
|
|
1243
|
+
return row ? rowToCoupon(row) : null;
|
|
1244
|
+
},
|
|
1245
|
+
async get(args) {
|
|
1246
|
+
const tenantId = tid(args.tenantId);
|
|
1247
|
+
if (!args.id || typeof args.id !== "string") {
|
|
1248
|
+
throw new ValidationError("id is required");
|
|
1249
|
+
}
|
|
1250
|
+
const row = await delegates.coupon(prisma).findFirst({
|
|
1251
|
+
where: { id: args.id, tenantId }
|
|
1252
|
+
});
|
|
1253
|
+
return row ? rowToCoupon(row) : null;
|
|
1254
|
+
},
|
|
1255
|
+
async list(args) {
|
|
1256
|
+
const tenantId = tid(args.tenantId);
|
|
1257
|
+
const limit = assertPaginationLimit(args.limit);
|
|
1258
|
+
const where = { tenantId };
|
|
1259
|
+
if (args.campaignId) where.campaignId = args.campaignId;
|
|
1260
|
+
if (args.status) where.status = args.status;
|
|
1261
|
+
return paginate(
|
|
1262
|
+
delegates.coupon(prisma),
|
|
1263
|
+
where,
|
|
1264
|
+
limit,
|
|
1265
|
+
args.cursor,
|
|
1266
|
+
rowToCoupon,
|
|
1267
|
+
{ createdAt: "desc" }
|
|
1268
|
+
);
|
|
1269
|
+
},
|
|
1270
|
+
async pause(args) {
|
|
1271
|
+
const tenantId = tid(args.tenantId);
|
|
1272
|
+
const now = cfg.now();
|
|
1273
|
+
const coupon = await prisma.$transaction(async (tx) => {
|
|
1274
|
+
const row = await delegates.coupon(tx).findFirst({
|
|
1275
|
+
where: { id: args.id, tenantId }
|
|
1276
|
+
});
|
|
1277
|
+
if (!row) throw new CouponNotFoundError("Coupon not found", { tenantId, couponId: args.id });
|
|
1278
|
+
const existing = rowToCoupon(row);
|
|
1279
|
+
if (existing.status === COUPON_STATUS.ARCHIVED) {
|
|
1280
|
+
throw new CouponArchivedError(void 0, { tenantId, couponId: existing.id });
|
|
1281
|
+
}
|
|
1282
|
+
const updated = await delegates.coupon(tx).update({
|
|
1283
|
+
where: { id: args.id },
|
|
1284
|
+
data: { status: COUPON_STATUS.PAUSED }
|
|
1285
|
+
});
|
|
1286
|
+
await writeAudit(delegates.couponAuditLog(tx), {
|
|
1287
|
+
tenantId,
|
|
1288
|
+
action: AUDIT_ACTION.COUPON_PAUSED,
|
|
1289
|
+
resourceId: args.id,
|
|
1290
|
+
resourceType: RESOURCE_TYPE.COUPON,
|
|
1291
|
+
actorId: args.actorId
|
|
1292
|
+
});
|
|
1293
|
+
return rowToCoupon(updated);
|
|
1294
|
+
});
|
|
1295
|
+
await safeEmit(cfg.events, cfg.logger, {
|
|
1296
|
+
type: "coupon.paused",
|
|
1297
|
+
at: now,
|
|
1298
|
+
tenantId,
|
|
1299
|
+
couponId: coupon.id
|
|
1300
|
+
});
|
|
1301
|
+
return coupon;
|
|
1302
|
+
},
|
|
1303
|
+
async resume(args) {
|
|
1304
|
+
const tenantId = tid(args.tenantId);
|
|
1305
|
+
const now = cfg.now();
|
|
1306
|
+
const coupon = await prisma.$transaction(async (tx) => {
|
|
1307
|
+
const row = await delegates.coupon(tx).findFirst({
|
|
1308
|
+
where: { id: args.id, tenantId }
|
|
1309
|
+
});
|
|
1310
|
+
if (!row) throw new CouponNotFoundError("Coupon not found", { tenantId, couponId: args.id });
|
|
1311
|
+
const existing = rowToCoupon(row);
|
|
1312
|
+
if (existing.status === COUPON_STATUS.ARCHIVED) {
|
|
1313
|
+
throw new CouponArchivedError(void 0, { tenantId, couponId: existing.id });
|
|
1314
|
+
}
|
|
1315
|
+
const updated = await delegates.coupon(tx).update({
|
|
1316
|
+
where: { id: args.id },
|
|
1317
|
+
data: { status: COUPON_STATUS.ACTIVE }
|
|
1318
|
+
});
|
|
1319
|
+
await writeAudit(delegates.couponAuditLog(tx), {
|
|
1320
|
+
tenantId,
|
|
1321
|
+
action: AUDIT_ACTION.COUPON_RESUMED,
|
|
1322
|
+
resourceId: args.id,
|
|
1323
|
+
resourceType: RESOURCE_TYPE.COUPON,
|
|
1324
|
+
actorId: args.actorId
|
|
1325
|
+
});
|
|
1326
|
+
return rowToCoupon(updated);
|
|
1327
|
+
});
|
|
1328
|
+
await safeEmit(cfg.events, cfg.logger, {
|
|
1329
|
+
type: "coupon.resumed",
|
|
1330
|
+
at: now,
|
|
1331
|
+
tenantId,
|
|
1332
|
+
couponId: coupon.id
|
|
1333
|
+
});
|
|
1334
|
+
return coupon;
|
|
1335
|
+
},
|
|
1336
|
+
async archive(args) {
|
|
1337
|
+
const tenantId = tid(args.tenantId);
|
|
1338
|
+
const now = cfg.now();
|
|
1339
|
+
const coupon = await prisma.$transaction(async (tx) => {
|
|
1340
|
+
const row = await delegates.coupon(tx).findFirst({
|
|
1341
|
+
where: { id: args.id, tenantId }
|
|
1342
|
+
});
|
|
1343
|
+
if (!row) throw new CouponNotFoundError("Coupon not found", { tenantId, couponId: args.id });
|
|
1344
|
+
const existing = rowToCoupon(row);
|
|
1345
|
+
if (existing.status === COUPON_STATUS.ARCHIVED) {
|
|
1346
|
+
throw new CouponArchivedError(void 0, { tenantId, couponId: existing.id });
|
|
1347
|
+
}
|
|
1348
|
+
const updated = await delegates.coupon(tx).update({
|
|
1349
|
+
where: { id: args.id },
|
|
1350
|
+
data: { status: COUPON_STATUS.ARCHIVED }
|
|
1351
|
+
});
|
|
1352
|
+
await writeAudit(delegates.couponAuditLog(tx), {
|
|
1353
|
+
tenantId,
|
|
1354
|
+
action: AUDIT_ACTION.COUPON_ARCHIVED,
|
|
1355
|
+
resourceId: args.id,
|
|
1356
|
+
resourceType: RESOURCE_TYPE.COUPON,
|
|
1357
|
+
actorId: args.actorId
|
|
1358
|
+
});
|
|
1359
|
+
return rowToCoupon(updated);
|
|
1360
|
+
});
|
|
1361
|
+
await safeEmit(cfg.events, cfg.logger, {
|
|
1362
|
+
type: "coupon.archived",
|
|
1363
|
+
at: now,
|
|
1364
|
+
tenantId,
|
|
1365
|
+
couponId: coupon.id
|
|
1366
|
+
});
|
|
1367
|
+
return coupon;
|
|
1368
|
+
}
|
|
1369
|
+
};
|
|
1370
|
+
async function validate(input) {
|
|
1371
|
+
validateValidateInput(input);
|
|
1372
|
+
const tenantId = tid(input.tenantId);
|
|
1373
|
+
sanitizeCode(input.code);
|
|
1374
|
+
const canonical = canonicalizeCode(input.code);
|
|
1375
|
+
const now = cfg.now();
|
|
1376
|
+
return prisma.$transaction(async (tx) => {
|
|
1377
|
+
const row = await delegates.coupon(tx).findFirst({
|
|
1378
|
+
where: { tenantId, code: canonical }
|
|
1379
|
+
});
|
|
1380
|
+
if (!row) return { ok: false, reason: COUPON_REJECT_REASON.NOT_FOUND };
|
|
1381
|
+
let coupon = rowToCoupon(row);
|
|
1382
|
+
const lazy = await resolveLazyStatus(delegates, tx, coupon, now);
|
|
1383
|
+
if (lazy.kind === "reject") {
|
|
1384
|
+
return { ok: false, reason: COUPON_REJECT_REASON[lazy.reason] };
|
|
1385
|
+
}
|
|
1386
|
+
coupon = lazy.coupon;
|
|
1387
|
+
const elig = checkEligibility({
|
|
1388
|
+
rules: coupon.eligibility,
|
|
1389
|
+
userId: input.userId ?? null,
|
|
1390
|
+
context: input.context
|
|
1391
|
+
});
|
|
1392
|
+
if (!elig.ok) {
|
|
1393
|
+
return { ok: false, reason: elig.reason };
|
|
1394
|
+
}
|
|
1395
|
+
if (coupon.maxRedemptionsPerUser !== null && input.userId) {
|
|
1396
|
+
const count = await delegates.couponRedemption(tx).count({
|
|
1397
|
+
where: {
|
|
1398
|
+
tenantId,
|
|
1399
|
+
couponId: coupon.id,
|
|
1400
|
+
redeemedByUserId: input.userId
|
|
1401
|
+
}
|
|
1402
|
+
});
|
|
1403
|
+
if (count >= coupon.maxRedemptionsPerUser) {
|
|
1404
|
+
return { ok: false, reason: COUPON_REJECT_REASON.PER_USER_LIMIT };
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
let discountPreview = null;
|
|
1408
|
+
if (typeof input.subtotal === "number" && typeof input.currency === "string") {
|
|
1409
|
+
try {
|
|
1410
|
+
discountPreview = calculate({
|
|
1411
|
+
subtotal: input.subtotal,
|
|
1412
|
+
currency: input.currency,
|
|
1413
|
+
policy: coupon.discount
|
|
1414
|
+
});
|
|
1415
|
+
} catch (err) {
|
|
1416
|
+
if (err instanceof MinimumOrderNotMetError) {
|
|
1417
|
+
return { ok: false, reason: COUPON_REJECT_REASON.MIN_ORDER_NOT_MET };
|
|
1418
|
+
}
|
|
1419
|
+
throw err;
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
return { ok: true, coupon, discountPreview };
|
|
1423
|
+
});
|
|
1424
|
+
}
|
|
1425
|
+
async function runRedeem(tx, input, tenantId, now) {
|
|
1426
|
+
const canonical = canonicalizeCode(input.code);
|
|
1427
|
+
const lookup = await delegates.coupon(tx).findFirst({
|
|
1428
|
+
where: { tenantId, code: canonical },
|
|
1429
|
+
select: { id: true }
|
|
1430
|
+
});
|
|
1431
|
+
if (!lookup) throw new CouponNotFoundError("Coupon not found", { tenantId, couponCode: canonical });
|
|
1432
|
+
const couponId = String(lookup.id);
|
|
1433
|
+
const locked = await lockCouponRow(
|
|
1434
|
+
tx,
|
|
1435
|
+
delegates.tables.coupon,
|
|
1436
|
+
couponId,
|
|
1437
|
+
tenantId
|
|
1438
|
+
);
|
|
1439
|
+
if (!Array.isArray(locked) || locked.length === 0) {
|
|
1440
|
+
throw new CouponNotFoundError("Coupon not found", { tenantId, couponId });
|
|
1441
|
+
}
|
|
1442
|
+
let coupon = rowToCoupon(locked[0]);
|
|
1443
|
+
if (coupon.status === COUPON_STATUS.ARCHIVED) {
|
|
1444
|
+
throw new CouponArchivedError(void 0, { tenantId, couponId: coupon.id, couponCode: coupon.code });
|
|
1445
|
+
}
|
|
1446
|
+
if (coupon.status === COUPON_STATUS.PAUSED || coupon.status === COUPON_STATUS.DRAFT) {
|
|
1447
|
+
throw new CouponInactiveError(void 0, { tenantId, couponId: coupon.id, couponCode: coupon.code });
|
|
1448
|
+
}
|
|
1449
|
+
if (coupon.startsAt && now.getTime() < coupon.startsAt.getTime()) {
|
|
1450
|
+
throw new CouponExpiredError(
|
|
1451
|
+
"Coupon has not started yet",
|
|
1452
|
+
{ tenantId, couponId: coupon.id, couponCode: coupon.code },
|
|
1453
|
+
"NOT_STARTED"
|
|
1454
|
+
);
|
|
1455
|
+
}
|
|
1456
|
+
if (coupon.endsAt && now.getTime() >= coupon.endsAt.getTime()) {
|
|
1457
|
+
await delegates.coupon(tx).updateMany({
|
|
1458
|
+
where: {
|
|
1459
|
+
id: coupon.id,
|
|
1460
|
+
tenantId,
|
|
1461
|
+
status: { notIn: [COUPON_STATUS.ARCHIVED, COUPON_STATUS.EXPIRED] }
|
|
1462
|
+
},
|
|
1463
|
+
data: { status: COUPON_STATUS.EXPIRED }
|
|
1464
|
+
});
|
|
1465
|
+
throw new CouponExpiredError(void 0, {
|
|
1466
|
+
tenantId,
|
|
1467
|
+
couponId: coupon.id,
|
|
1468
|
+
couponCode: coupon.code
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1471
|
+
const elig = checkEligibility({
|
|
1472
|
+
rules: coupon.eligibility,
|
|
1473
|
+
userId: input.userId ?? null,
|
|
1474
|
+
context: input.context
|
|
1475
|
+
});
|
|
1476
|
+
if (!elig.ok) {
|
|
1477
|
+
throw eligibilityReasonToError(elig.reason);
|
|
1478
|
+
}
|
|
1479
|
+
if (coupon.maxRedemptionsPerUser !== null && input.userId) {
|
|
1480
|
+
const userCount = await delegates.couponRedemption(tx).count({
|
|
1481
|
+
where: {
|
|
1482
|
+
tenantId,
|
|
1483
|
+
couponId: coupon.id,
|
|
1484
|
+
redeemedByUserId: input.userId
|
|
1485
|
+
}
|
|
1486
|
+
});
|
|
1487
|
+
if (userCount >= coupon.maxRedemptionsPerUser) {
|
|
1488
|
+
throw new PerUserLimitExceededError(void 0, {
|
|
1489
|
+
tenantId,
|
|
1490
|
+
couponId: coupon.id,
|
|
1491
|
+
couponCode: coupon.code
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
const discount = calculate({
|
|
1496
|
+
subtotal: input.subtotal,
|
|
1497
|
+
currency: input.currency,
|
|
1498
|
+
policy: coupon.discount
|
|
1499
|
+
});
|
|
1500
|
+
const updWhere = {
|
|
1501
|
+
id: coupon.id,
|
|
1502
|
+
tenantId,
|
|
1503
|
+
status: { notIn: [COUPON_STATUS.ARCHIVED, COUPON_STATUS.EXPIRED, COUPON_STATUS.EXHAUSTED, COUPON_STATUS.PAUSED, COUPON_STATUS.DRAFT] }
|
|
1504
|
+
};
|
|
1505
|
+
if (coupon.maxRedemptions !== null) {
|
|
1506
|
+
updWhere.redeemedCount = { lt: coupon.maxRedemptions };
|
|
1507
|
+
}
|
|
1508
|
+
const upd = await delegates.coupon(tx).updateMany({
|
|
1509
|
+
where: updWhere,
|
|
1510
|
+
data: w({ redeemedCount: { increment: 1 } })
|
|
1511
|
+
});
|
|
1512
|
+
if (upd.count === 0) {
|
|
1513
|
+
throw new CouponExhaustedError(void 0, {
|
|
1514
|
+
tenantId,
|
|
1515
|
+
couponId: coupon.id,
|
|
1516
|
+
couponCode: coupon.code
|
|
1517
|
+
});
|
|
1518
|
+
}
|
|
1519
|
+
const refreshed = await delegates.coupon(tx).findFirst({
|
|
1520
|
+
where: { id: coupon.id, tenantId }
|
|
1521
|
+
});
|
|
1522
|
+
if (!refreshed) {
|
|
1523
|
+
throw new CouponNotFoundError("Coupon vanished mid-transaction", { tenantId, couponId: coupon.id });
|
|
1524
|
+
}
|
|
1525
|
+
coupon = rowToCoupon(refreshed);
|
|
1526
|
+
if (coupon.maxRedemptions !== null && coupon.redeemedCount >= coupon.maxRedemptions && coupon.status !== COUPON_STATUS.EXHAUSTED) {
|
|
1527
|
+
await delegates.coupon(tx).updateMany({
|
|
1528
|
+
where: {
|
|
1529
|
+
id: coupon.id,
|
|
1530
|
+
tenantId,
|
|
1531
|
+
status: { notIn: [COUPON_STATUS.ARCHIVED, COUPON_STATUS.EXHAUSTED] }
|
|
1532
|
+
},
|
|
1533
|
+
data: { status: COUPON_STATUS.EXHAUSTED }
|
|
1534
|
+
});
|
|
1535
|
+
coupon = { ...coupon, status: COUPON_STATUS.EXHAUSTED };
|
|
1536
|
+
}
|
|
1537
|
+
let redemption;
|
|
1538
|
+
try {
|
|
1539
|
+
const inserted = await delegates.couponRedemption(tx).create({
|
|
1540
|
+
data: {
|
|
1541
|
+
id: cfg.idGenerator(),
|
|
1542
|
+
tenantId,
|
|
1543
|
+
couponId: coupon.id,
|
|
1544
|
+
redeemedByUserId: input.userId ?? null,
|
|
1545
|
+
orderRef: input.orderRef ?? null,
|
|
1546
|
+
discountAmount: discount.discountAmount,
|
|
1547
|
+
currency: input.currency,
|
|
1548
|
+
metadata: w(input.metadata ?? null)
|
|
1549
|
+
}
|
|
1550
|
+
});
|
|
1551
|
+
redemption = rowToRedemption(inserted);
|
|
1552
|
+
} catch (err) {
|
|
1553
|
+
throw new CouponAlreadyRedeemedError(void 0, {
|
|
1554
|
+
tenantId,
|
|
1555
|
+
couponId: coupon.id,
|
|
1556
|
+
couponCode: coupon.code,
|
|
1557
|
+
cause: err
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1560
|
+
if (coupon.maxRedemptionsPerUser !== null && input.userId) {
|
|
1561
|
+
const userCountFinal = await delegates.couponRedemption(tx).count({
|
|
1562
|
+
where: {
|
|
1563
|
+
tenantId,
|
|
1564
|
+
couponId: coupon.id,
|
|
1565
|
+
redeemedByUserId: input.userId
|
|
1566
|
+
}
|
|
1567
|
+
});
|
|
1568
|
+
if (userCountFinal > coupon.maxRedemptionsPerUser) {
|
|
1569
|
+
throw new PerUserLimitExceededError(void 0, {
|
|
1570
|
+
tenantId,
|
|
1571
|
+
couponId: coupon.id,
|
|
1572
|
+
couponCode: coupon.code
|
|
1573
|
+
});
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
await writeAudit(delegates.couponAuditLog(tx), {
|
|
1577
|
+
tenantId,
|
|
1578
|
+
action: AUDIT_ACTION.COUPON_REDEEMED,
|
|
1579
|
+
resourceId: coupon.id,
|
|
1580
|
+
resourceType: RESOURCE_TYPE.COUPON,
|
|
1581
|
+
actorId: input.userId ?? null,
|
|
1582
|
+
details: {
|
|
1583
|
+
redemptionId: redemption.id,
|
|
1584
|
+
userId: redemption.redeemedByUserId,
|
|
1585
|
+
orderRef: redemption.orderRef,
|
|
1586
|
+
discountAmount: redemption.discountAmount,
|
|
1587
|
+
currency: redemption.currency
|
|
1588
|
+
}
|
|
1589
|
+
});
|
|
1590
|
+
return { redemption, coupon, discount };
|
|
1591
|
+
}
|
|
1592
|
+
async function redeem(input) {
|
|
1593
|
+
validateRedeemInput(input);
|
|
1594
|
+
const tenantId = tid(input.tenantId);
|
|
1595
|
+
const now = cfg.now();
|
|
1596
|
+
let result;
|
|
1597
|
+
try {
|
|
1598
|
+
if (input.tx) {
|
|
1599
|
+
result = await runRedeem(input.tx, input, tenantId, now);
|
|
1600
|
+
} else {
|
|
1601
|
+
result = await prisma.$transaction((tx) => runRedeem(tx, input, tenantId, now));
|
|
1602
|
+
}
|
|
1603
|
+
} catch (err) {
|
|
1604
|
+
if (err instanceof CouponError) {
|
|
1605
|
+
try {
|
|
1606
|
+
await delegates.couponAuditLog(prisma).create({
|
|
1607
|
+
data: {
|
|
1608
|
+
id: cfg.idGenerator(),
|
|
1609
|
+
tenantId,
|
|
1610
|
+
action: AUDIT_ACTION.COUPON_REDEEM_REJECTED,
|
|
1611
|
+
resourceId: err.couponId ?? null,
|
|
1612
|
+
resourceType: RESOURCE_TYPE.COUPON,
|
|
1613
|
+
actorId: input.userId ?? null,
|
|
1614
|
+
details: {
|
|
1615
|
+
reason: err.code,
|
|
1616
|
+
code: err.couponCode ?? canonicalizeCode(input.code),
|
|
1617
|
+
userId: input.userId ?? null,
|
|
1618
|
+
orderRef: input.orderRef ?? null
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
});
|
|
1622
|
+
} catch (auditErr) {
|
|
1623
|
+
cfg.logger.warn("coupon.redeem.rejected_audit_failed", {
|
|
1624
|
+
error: auditErr instanceof Error ? auditErr.message : String(auditErr)
|
|
1625
|
+
});
|
|
1626
|
+
}
|
|
1627
|
+
await safeEmit(cfg.events, cfg.logger, {
|
|
1628
|
+
type: "coupon.redeem.rejected",
|
|
1629
|
+
at: now,
|
|
1630
|
+
tenantId,
|
|
1631
|
+
code: canonicalizeCode(input.code),
|
|
1632
|
+
reason: err.code,
|
|
1633
|
+
userId: input.userId ?? null,
|
|
1634
|
+
orderRef: input.orderRef ?? null
|
|
1635
|
+
});
|
|
1636
|
+
}
|
|
1637
|
+
throw err;
|
|
1638
|
+
}
|
|
1639
|
+
await safeEmit(cfg.events, cfg.logger, {
|
|
1640
|
+
type: "coupon.redeemed",
|
|
1641
|
+
at: now,
|
|
1642
|
+
tenantId,
|
|
1643
|
+
redemption: result.redemption
|
|
1644
|
+
});
|
|
1645
|
+
return result;
|
|
1646
|
+
}
|
|
1647
|
+
const discountNs = {
|
|
1648
|
+
calculate(input) {
|
|
1649
|
+
return calculate(input);
|
|
1650
|
+
}
|
|
1651
|
+
};
|
|
1652
|
+
const campaigns = {
|
|
1653
|
+
async create(input) {
|
|
1654
|
+
validateCreateCampaignInput(input);
|
|
1655
|
+
const tenantId = tid(input.tenantId);
|
|
1656
|
+
const eligibility = assertEligibility(input.eligibility);
|
|
1657
|
+
const metadata = assertMetadata(input.metadata);
|
|
1658
|
+
const now = cfg.now();
|
|
1659
|
+
const campaign = await prisma.$transaction(async (tx) => {
|
|
1660
|
+
const row = await delegates.campaign(tx).create({
|
|
1661
|
+
data: {
|
|
1662
|
+
id: cfg.idGenerator(),
|
|
1663
|
+
tenantId,
|
|
1664
|
+
name: input.name,
|
|
1665
|
+
status: CAMPAIGN_STATUS.ACTIVE,
|
|
1666
|
+
discount: w(input.discount),
|
|
1667
|
+
eligibility: w(eligibility),
|
|
1668
|
+
startsAt: input.startsAt ?? null,
|
|
1669
|
+
endsAt: input.endsAt ?? null,
|
|
1670
|
+
maxCoupons: input.maxCoupons ?? null,
|
|
1671
|
+
metadata: w(metadata ?? null)
|
|
1672
|
+
}
|
|
1673
|
+
});
|
|
1674
|
+
const campaign2 = rowToCampaign(row);
|
|
1675
|
+
await writeAudit(delegates.couponAuditLog(tx), {
|
|
1676
|
+
tenantId,
|
|
1677
|
+
action: AUDIT_ACTION.CAMPAIGN_CREATED,
|
|
1678
|
+
resourceId: campaign2.id,
|
|
1679
|
+
resourceType: RESOURCE_TYPE.CAMPAIGN,
|
|
1680
|
+
actorId: input.actorId,
|
|
1681
|
+
details: { name: campaign2.name }
|
|
1682
|
+
});
|
|
1683
|
+
return campaign2;
|
|
1684
|
+
});
|
|
1685
|
+
await safeEmit(cfg.events, cfg.logger, {
|
|
1686
|
+
type: "campaign.created",
|
|
1687
|
+
at: now,
|
|
1688
|
+
tenantId,
|
|
1689
|
+
campaign
|
|
1690
|
+
});
|
|
1691
|
+
return campaign;
|
|
1692
|
+
},
|
|
1693
|
+
async get(args) {
|
|
1694
|
+
const tenantId = tid(args.tenantId);
|
|
1695
|
+
if (!args.id) throw new ValidationError("id is required");
|
|
1696
|
+
const row = await delegates.campaign(prisma).findFirst({
|
|
1697
|
+
where: { id: args.id, tenantId }
|
|
1698
|
+
});
|
|
1699
|
+
return row ? rowToCampaign(row) : null;
|
|
1700
|
+
},
|
|
1701
|
+
async list(args) {
|
|
1702
|
+
const tenantId = tid(args.tenantId);
|
|
1703
|
+
const limit = assertPaginationLimit(args.limit);
|
|
1704
|
+
const where = { tenantId };
|
|
1705
|
+
if (args.status) where.status = args.status;
|
|
1706
|
+
return paginate(
|
|
1707
|
+
delegates.campaign(prisma),
|
|
1708
|
+
where,
|
|
1709
|
+
limit,
|
|
1710
|
+
args.cursor,
|
|
1711
|
+
rowToCampaign,
|
|
1712
|
+
{ createdAt: "desc" }
|
|
1713
|
+
);
|
|
1714
|
+
},
|
|
1715
|
+
async issue(args) {
|
|
1716
|
+
if (!args || typeof args !== "object") throw new ValidationError("args required");
|
|
1717
|
+
if (typeof args.campaignId !== "string" || !args.campaignId) {
|
|
1718
|
+
throw new ValidationError("campaignId is required");
|
|
1719
|
+
}
|
|
1720
|
+
if (typeof args.count !== "number" || !Number.isInteger(args.count) || args.count <= 0 || args.count > LIMITS.BULK_MAX_COUNT) {
|
|
1721
|
+
throw new ValidationError(
|
|
1722
|
+
`count must be a positive integer <= ${LIMITS.BULK_MAX_COUNT}`
|
|
1723
|
+
);
|
|
1724
|
+
}
|
|
1725
|
+
const tenantId = tid(args.tenantId);
|
|
1726
|
+
const campRow = await delegates.campaign(prisma).findFirst({
|
|
1727
|
+
where: { id: args.campaignId, tenantId }
|
|
1728
|
+
});
|
|
1729
|
+
if (!campRow) {
|
|
1730
|
+
throw new ValidationError("campaign not found", void 0, {
|
|
1731
|
+
tenantId,
|
|
1732
|
+
couponId: args.campaignId
|
|
1733
|
+
});
|
|
1734
|
+
}
|
|
1735
|
+
const campaign = rowToCampaign(campRow);
|
|
1736
|
+
if (campaign.status === CAMPAIGN_STATUS.CLOSED) {
|
|
1737
|
+
throw new CampaignClosedError(void 0, { tenantId, couponId: campaign.id });
|
|
1738
|
+
}
|
|
1739
|
+
return coupons.issueBulk({
|
|
1740
|
+
tenantId,
|
|
1741
|
+
count: args.count,
|
|
1742
|
+
codeLength: args.codeLength,
|
|
1743
|
+
campaignId: campaign.id,
|
|
1744
|
+
discount: campaign.discount,
|
|
1745
|
+
startsAt: campaign.startsAt,
|
|
1746
|
+
endsAt: campaign.endsAt,
|
|
1747
|
+
maxAttempts: args.maxAttempts,
|
|
1748
|
+
eligibility: campaign.eligibility,
|
|
1749
|
+
actorId: args.actorId
|
|
1750
|
+
});
|
|
1751
|
+
},
|
|
1752
|
+
async summary(args) {
|
|
1753
|
+
const tenantId = tid(args.tenantId);
|
|
1754
|
+
if (!args.id) throw new ValidationError("id is required");
|
|
1755
|
+
const campRow = await delegates.campaign(prisma).findFirst({
|
|
1756
|
+
where: { id: args.id, tenantId }
|
|
1757
|
+
});
|
|
1758
|
+
if (!campRow) {
|
|
1759
|
+
throw new ValidationError("campaign not found", void 0, {
|
|
1760
|
+
tenantId,
|
|
1761
|
+
couponId: args.id
|
|
1762
|
+
});
|
|
1763
|
+
}
|
|
1764
|
+
const campaign = rowToCampaign(campRow);
|
|
1765
|
+
const couponRows = await delegates.coupon(prisma).findMany({
|
|
1766
|
+
where: { tenantId, campaignId: campaign.id }
|
|
1767
|
+
});
|
|
1768
|
+
const couponIds = couponRows.map((r) => String(r.id));
|
|
1769
|
+
let redeemedCount = 0;
|
|
1770
|
+
let totalDiscount = 0;
|
|
1771
|
+
let lastRedeemedAt = null;
|
|
1772
|
+
const uniqueUserSet = /* @__PURE__ */ new Set();
|
|
1773
|
+
if (couponIds.length > 0) {
|
|
1774
|
+
const reds = await delegates.couponRedemption(prisma).findMany({
|
|
1775
|
+
where: { tenantId, couponId: { in: couponIds } }
|
|
1776
|
+
});
|
|
1777
|
+
redeemedCount = reds.length;
|
|
1778
|
+
for (const r of reds) {
|
|
1779
|
+
const row = r;
|
|
1780
|
+
const amt = row.discountAmount;
|
|
1781
|
+
if (typeof amt === "number") totalDiscount += amt;
|
|
1782
|
+
const uid = row.redeemedByUserId;
|
|
1783
|
+
if (typeof uid === "string" && uid.length > 0) uniqueUserSet.add(uid);
|
|
1784
|
+
const ra = row.redeemedAt;
|
|
1785
|
+
const raDate = ra instanceof Date ? ra : typeof ra === "string" || typeof ra === "number" ? new Date(ra) : null;
|
|
1786
|
+
if (raDate && (!lastRedeemedAt || raDate.getTime() > lastRedeemedAt.getTime())) {
|
|
1787
|
+
lastRedeemedAt = raDate;
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
const remaining = campaign.maxCoupons === null ? null : Math.max(campaign.maxCoupons - campaign.issuedCount, 0);
|
|
1792
|
+
return {
|
|
1793
|
+
id: campaign.id,
|
|
1794
|
+
tenantId: campaign.tenantId,
|
|
1795
|
+
name: campaign.name,
|
|
1796
|
+
status: campaign.status,
|
|
1797
|
+
issuedCount: campaign.issuedCount,
|
|
1798
|
+
redeemedCount,
|
|
1799
|
+
remaining,
|
|
1800
|
+
totalDiscount,
|
|
1801
|
+
uniqueUsers: uniqueUserSet.size,
|
|
1802
|
+
lastRedeemedAt
|
|
1803
|
+
};
|
|
1804
|
+
},
|
|
1805
|
+
async close(args) {
|
|
1806
|
+
const tenantId = tid(args.tenantId);
|
|
1807
|
+
if (!args.id) throw new ValidationError("id is required");
|
|
1808
|
+
const now = cfg.now();
|
|
1809
|
+
const campaign = await prisma.$transaction(async (tx) => {
|
|
1810
|
+
const row = await delegates.campaign(tx).findFirst({
|
|
1811
|
+
where: { id: args.id, tenantId }
|
|
1812
|
+
});
|
|
1813
|
+
if (!row) {
|
|
1814
|
+
throw new ValidationError("campaign not found", void 0, {
|
|
1815
|
+
tenantId,
|
|
1816
|
+
couponId: args.id
|
|
1817
|
+
});
|
|
1818
|
+
}
|
|
1819
|
+
const updated = await delegates.campaign(tx).update({
|
|
1820
|
+
where: { id: args.id },
|
|
1821
|
+
data: { status: CAMPAIGN_STATUS.CLOSED }
|
|
1822
|
+
});
|
|
1823
|
+
await writeAudit(delegates.couponAuditLog(tx), {
|
|
1824
|
+
tenantId,
|
|
1825
|
+
action: AUDIT_ACTION.CAMPAIGN_CLOSED,
|
|
1826
|
+
resourceId: args.id,
|
|
1827
|
+
resourceType: RESOURCE_TYPE.CAMPAIGN,
|
|
1828
|
+
actorId: args.actorId
|
|
1829
|
+
});
|
|
1830
|
+
return rowToCampaign(updated);
|
|
1831
|
+
});
|
|
1832
|
+
await safeEmit(cfg.events, cfg.logger, {
|
|
1833
|
+
type: "campaign.closed",
|
|
1834
|
+
at: now,
|
|
1835
|
+
tenantId,
|
|
1836
|
+
campaignId: campaign.id
|
|
1837
|
+
});
|
|
1838
|
+
return campaign;
|
|
1839
|
+
}
|
|
1840
|
+
};
|
|
1841
|
+
const audit = {
|
|
1842
|
+
async listIssuances(args) {
|
|
1843
|
+
const tenantId = tid(args.tenantId);
|
|
1844
|
+
const limit = assertPaginationLimit(args.limit);
|
|
1845
|
+
const where = { tenantId };
|
|
1846
|
+
if (args.couponId) where.couponId = args.couponId;
|
|
1847
|
+
if (args.issuedToUserId) where.issuedToUserId = args.issuedToUserId;
|
|
1848
|
+
return paginate(
|
|
1849
|
+
delegates.couponIssuance(prisma),
|
|
1850
|
+
where,
|
|
1851
|
+
limit,
|
|
1852
|
+
args.cursor,
|
|
1853
|
+
rowToIssuance,
|
|
1854
|
+
{ issuedAt: "desc" }
|
|
1855
|
+
);
|
|
1856
|
+
},
|
|
1857
|
+
async listRedemptions(args) {
|
|
1858
|
+
const tenantId = tid(args.tenantId);
|
|
1859
|
+
const limit = assertPaginationLimit(args.limit);
|
|
1860
|
+
const where = { tenantId };
|
|
1861
|
+
if (args.couponId) where.couponId = args.couponId;
|
|
1862
|
+
if (args.redeemedByUserId) where.redeemedByUserId = args.redeemedByUserId;
|
|
1863
|
+
return paginate(
|
|
1864
|
+
delegates.couponRedemption(prisma),
|
|
1865
|
+
where,
|
|
1866
|
+
limit,
|
|
1867
|
+
args.cursor,
|
|
1868
|
+
rowToRedemption,
|
|
1869
|
+
{ redeemedAt: "desc" }
|
|
1870
|
+
);
|
|
1871
|
+
},
|
|
1872
|
+
async listLogs(args) {
|
|
1873
|
+
const tenantId = tid(args.tenantId);
|
|
1874
|
+
const limit = assertPaginationLimit(args.limit);
|
|
1875
|
+
const where = { tenantId };
|
|
1876
|
+
if (args.action) where.action = args.action;
|
|
1877
|
+
if (args.resourceId) where.resourceId = args.resourceId;
|
|
1878
|
+
if (args.resourceType) where.resourceType = args.resourceType;
|
|
1879
|
+
return paginate(
|
|
1880
|
+
delegates.couponAuditLog(prisma),
|
|
1881
|
+
where,
|
|
1882
|
+
limit,
|
|
1883
|
+
args.cursor,
|
|
1884
|
+
rowToAuditLog,
|
|
1885
|
+
{ createdAt: "desc" }
|
|
1886
|
+
);
|
|
1887
|
+
}
|
|
1888
|
+
};
|
|
1889
|
+
return {
|
|
1890
|
+
coupons,
|
|
1891
|
+
validate,
|
|
1892
|
+
redeem,
|
|
1893
|
+
discount: discountNs,
|
|
1894
|
+
campaigns,
|
|
1895
|
+
audit
|
|
1896
|
+
};
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
exports.AUDIT_ACTION = AUDIT_ACTION;
|
|
1900
|
+
exports.CAMPAIGN_STATUS = CAMPAIGN_STATUS;
|
|
1901
|
+
exports.COUPON_REJECT_REASON = COUPON_REJECT_REASON;
|
|
1902
|
+
exports.COUPON_STATUS = COUPON_STATUS;
|
|
1903
|
+
exports.CampaignCapacityExceededError = CampaignCapacityExceededError;
|
|
1904
|
+
exports.CampaignClosedError = CampaignClosedError;
|
|
1905
|
+
exports.CouponAlreadyRedeemedError = CouponAlreadyRedeemedError;
|
|
1906
|
+
exports.CouponArchivedError = CouponArchivedError;
|
|
1907
|
+
exports.CouponError = CouponError;
|
|
1908
|
+
exports.CouponExhaustedError = CouponExhaustedError;
|
|
1909
|
+
exports.CouponExpiredError = CouponExpiredError;
|
|
1910
|
+
exports.CouponInactiveError = CouponInactiveError;
|
|
1911
|
+
exports.CouponNotFoundError = CouponNotFoundError;
|
|
1912
|
+
exports.CurrencyMismatchError = CurrencyMismatchError;
|
|
1913
|
+
exports.DEFAULT_MODEL_NAMES = DEFAULT_MODEL_NAMES;
|
|
1914
|
+
exports.IneligibleCategoryError = IneligibleCategoryError;
|
|
1915
|
+
exports.IneligiblePlanError = IneligiblePlanError;
|
|
1916
|
+
exports.IneligibleProductError = IneligibleProductError;
|
|
1917
|
+
exports.IneligibleUserError = IneligibleUserError;
|
|
1918
|
+
exports.InvalidDateRangeError = InvalidDateRangeError;
|
|
1919
|
+
exports.InvalidDiscountPolicyError = InvalidDiscountPolicyError;
|
|
1920
|
+
exports.InvalidModelNameError = InvalidModelNameError;
|
|
1921
|
+
exports.LIMITS = LIMITS;
|
|
1922
|
+
exports.MinimumOrderNotMetError = MinimumOrderNotMetError;
|
|
1923
|
+
exports.PerUserLimitExceededError = PerUserLimitExceededError;
|
|
1924
|
+
exports.TenantMismatchError = TenantMismatchError;
|
|
1925
|
+
exports.ValidationError = ValidationError;
|
|
1926
|
+
exports.calculateDiscount = calculate;
|
|
1927
|
+
exports.createCouponClient = createCouponClient;
|
|
1928
|
+
exports.validateDiscountPolicy = validateDiscountPolicy;
|