@withwiz/coupon 0.1.0 → 0.3.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/README.md +67 -2
- package/dist/index.cjs +238 -52
- package/dist/index.d.cts +52 -1
- package/dist/index.d.ts +52 -1
- package/dist/index.mjs +238 -53
- package/package.json +7 -6
package/dist/index.mjs
CHANGED
|
@@ -53,7 +53,25 @@ var LIMITS = {
|
|
|
53
53
|
PAGINATION_DEFAULT_LIMIT: 50,
|
|
54
54
|
PAGINATION_MAX_LIMIT: 200,
|
|
55
55
|
NAME_MAX_LENGTH: 200,
|
|
56
|
-
CURRENCY_LENGTH: 3
|
|
56
|
+
CURRENCY_LENGTH: 3,
|
|
57
|
+
/**
|
|
58
|
+
* [L] Caller-supplied identifier caps. Ids (coupon / campaign / cursor) are
|
|
59
|
+
* bounded by the audit log's `resourceId VarChar(64)`; `orderRef` by the
|
|
60
|
+
* redemption table's `VarChar(128)`. User / actor ids share the same bound
|
|
61
|
+
* so an unbounded string can never reach a Json or text column unchecked.
|
|
62
|
+
*/
|
|
63
|
+
ID_MAX_LENGTH: 64,
|
|
64
|
+
USER_ID_MAX_LENGTH: 128,
|
|
65
|
+
ORDER_REF_MAX_LENGTH: 128,
|
|
66
|
+
/** [L] Upper bound on serialized `metadata` (bytes of JSON text). */
|
|
67
|
+
METADATA_MAX_BYTES: 16384,
|
|
68
|
+
/** [L] Per-list entry cap and per-entry length cap for eligibility rules. */
|
|
69
|
+
ELIGIBILITY_MAX_ENTRIES: 1e3,
|
|
70
|
+
ELIGIBILITY_ENTRY_MAX_LENGTH: 128,
|
|
71
|
+
/** [L] Length cap for `context.plan / productId / categoryId`. */
|
|
72
|
+
CONTEXT_VALUE_MAX_LENGTH: 128,
|
|
73
|
+
/** Upper bound for `duration.months` on a REPEATING discount. */
|
|
74
|
+
DURATION_MAX_MONTHS: 36
|
|
57
75
|
};
|
|
58
76
|
var AUDIT_ACTION = {
|
|
59
77
|
COUPON_CREATED: "coupon.created",
|
|
@@ -262,6 +280,7 @@ function validateDiscountPolicy(policy) {
|
|
|
262
280
|
if (policy.currency !== void 0) {
|
|
263
281
|
ensureCurrency(policy.currency);
|
|
264
282
|
}
|
|
283
|
+
validateDuration(policy.duration);
|
|
265
284
|
return;
|
|
266
285
|
}
|
|
267
286
|
if (policy.kind === "FIXED") {
|
|
@@ -279,6 +298,7 @@ function validateDiscountPolicy(policy) {
|
|
|
279
298
|
);
|
|
280
299
|
}
|
|
281
300
|
}
|
|
301
|
+
validateDuration(policy.duration);
|
|
282
302
|
return;
|
|
283
303
|
}
|
|
284
304
|
throw new InvalidDiscountPolicyError(
|
|
@@ -351,6 +371,27 @@ function extractPolicyCurrency(policy) {
|
|
|
351
371
|
if (policy.kind === "PERCENT") return policy.currency;
|
|
352
372
|
return void 0;
|
|
353
373
|
}
|
|
374
|
+
function validateDuration(duration) {
|
|
375
|
+
if (duration === void 0) return;
|
|
376
|
+
if (!duration || typeof duration !== "object") {
|
|
377
|
+
throw new InvalidDiscountPolicyError("duration must be an object");
|
|
378
|
+
}
|
|
379
|
+
const kind = duration.kind;
|
|
380
|
+
if (kind === "ONCE" || kind === "FOREVER") return;
|
|
381
|
+
if (kind === "REPEATING") {
|
|
382
|
+
const months = duration.months;
|
|
383
|
+
if (typeof months !== "number" || !Number.isInteger(months) || months < 1 || months > LIMITS.DURATION_MAX_MONTHS) {
|
|
384
|
+
throw new InvalidDiscountPolicyError(
|
|
385
|
+
`duration.months must be an integer in [1, ${LIMITS.DURATION_MAX_MONTHS}] (got ${String(months)})`
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
throw new InvalidDiscountPolicyError(`unknown duration kind: ${String(kind)}`);
|
|
391
|
+
}
|
|
392
|
+
function resolveDuration(policy) {
|
|
393
|
+
return policy.duration ?? { kind: "ONCE" };
|
|
394
|
+
}
|
|
354
395
|
|
|
355
396
|
// src/eligibility.ts
|
|
356
397
|
function checkEligibility(input) {
|
|
@@ -568,6 +609,15 @@ function assertIdentifier(kind, key, value) {
|
|
|
568
609
|
}
|
|
569
610
|
return value;
|
|
570
611
|
}
|
|
612
|
+
function resolveSchemaName(schema) {
|
|
613
|
+
if (schema === void 0) return void 0;
|
|
614
|
+
if (typeof schema !== "string" || !IDENTIFIER_RE.test(schema)) {
|
|
615
|
+
throw new InvalidModelNameError(
|
|
616
|
+
`Invalid schema name: ${JSON.stringify(schema)}. Expected a plain identifier matching ${IDENTIFIER_RE.source}.`
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
return schema;
|
|
620
|
+
}
|
|
571
621
|
function resolveModelNames(overrides) {
|
|
572
622
|
const out = { ...DEFAULT_MODEL_NAMES };
|
|
573
623
|
for (const key of MODEL_KEYS) {
|
|
@@ -594,19 +644,21 @@ function pick(db, key, name) {
|
|
|
594
644
|
}
|
|
595
645
|
return delegate;
|
|
596
646
|
}
|
|
597
|
-
function createDelegateResolver(models, tables) {
|
|
647
|
+
function createDelegateResolver(models, tables, schema) {
|
|
598
648
|
const resolver = {
|
|
599
649
|
models,
|
|
600
|
-
tables
|
|
650
|
+
tables,
|
|
651
|
+
schema
|
|
601
652
|
};
|
|
602
653
|
for (const key of MODEL_KEYS) {
|
|
603
654
|
resolver[key] = (db) => pick(db, key, models[key]);
|
|
604
655
|
}
|
|
605
656
|
return resolver;
|
|
606
657
|
}
|
|
607
|
-
function lockCouponRow(tx, table, couponId, tenantId) {
|
|
658
|
+
function lockCouponRow(tx, table, couponId, tenantId, schema) {
|
|
659
|
+
const target = schema ? `"${schema}"."${table}"` : `"${table}"`;
|
|
608
660
|
const parts = [
|
|
609
|
-
`SELECT * FROM
|
|
661
|
+
`SELECT * FROM ${target} WHERE "id" = `,
|
|
610
662
|
` AND "tenantId" = `,
|
|
611
663
|
` FOR UPDATE`
|
|
612
664
|
];
|
|
@@ -615,6 +667,40 @@ function lockCouponRow(tx, table, couponId, tenantId) {
|
|
|
615
667
|
});
|
|
616
668
|
return tx.$queryRaw(strings, couponId, tenantId);
|
|
617
669
|
}
|
|
670
|
+
function assertOptionalString(name, value, max) {
|
|
671
|
+
if (value === void 0 || value === null) return;
|
|
672
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
673
|
+
throw new ValidationError(`${name} must be a non-empty string`);
|
|
674
|
+
}
|
|
675
|
+
if (value.length > max) {
|
|
676
|
+
throw new ValidationError(`${name} length exceeds max (${max})`);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
function assertRequiredString(name, value, max) {
|
|
680
|
+
if (value === void 0 || value === null) {
|
|
681
|
+
throw new ValidationError(`${name} is required`);
|
|
682
|
+
}
|
|
683
|
+
assertOptionalString(name, value, max);
|
|
684
|
+
}
|
|
685
|
+
function assertContext(context) {
|
|
686
|
+
if (context === void 0 || context === null) return;
|
|
687
|
+
if (typeof context !== "object" || Array.isArray(context)) {
|
|
688
|
+
throw new ValidationError("context must be a plain object");
|
|
689
|
+
}
|
|
690
|
+
const c = context;
|
|
691
|
+
assertOptionalString("context.plan", c.plan, LIMITS.CONTEXT_VALUE_MAX_LENGTH);
|
|
692
|
+
assertOptionalString("context.productId", c.productId, LIMITS.CONTEXT_VALUE_MAX_LENGTH);
|
|
693
|
+
assertOptionalString("context.categoryId", c.categoryId, LIMITS.CONTEXT_VALUE_MAX_LENGTH);
|
|
694
|
+
}
|
|
695
|
+
function assertCouponStatus(status) {
|
|
696
|
+
if (status === void 0) return;
|
|
697
|
+
const allowed = Object.values(COUPON_STATUS);
|
|
698
|
+
if (typeof status !== "string" || !allowed.includes(status)) {
|
|
699
|
+
throw new ValidationError(
|
|
700
|
+
`status must be one of ${allowed.join(", ")}`
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
618
704
|
function assertTenantId(value) {
|
|
619
705
|
if (typeof value !== "string" || value.length === 0) {
|
|
620
706
|
throw new ValidationError("tenantId is required");
|
|
@@ -676,6 +762,17 @@ function assertMetadata(meta) {
|
|
|
676
762
|
);
|
|
677
763
|
}
|
|
678
764
|
}
|
|
765
|
+
let serialized;
|
|
766
|
+
try {
|
|
767
|
+
serialized = JSON.stringify(rec);
|
|
768
|
+
} catch {
|
|
769
|
+
throw new ValidationError("metadata must be JSON-serializable");
|
|
770
|
+
}
|
|
771
|
+
if (Buffer.byteLength(serialized, "utf8") > LIMITS.METADATA_MAX_BYTES) {
|
|
772
|
+
throw new ValidationError(
|
|
773
|
+
`metadata exceeds max size (${LIMITS.METADATA_MAX_BYTES} bytes)`
|
|
774
|
+
);
|
|
775
|
+
}
|
|
679
776
|
return rec;
|
|
680
777
|
}
|
|
681
778
|
function assertPaginationLimit(limit) {
|
|
@@ -727,6 +824,16 @@ function assertEligibility(rules) {
|
|
|
727
824
|
`eligibility.${key} must be an array of non-empty strings`
|
|
728
825
|
);
|
|
729
826
|
}
|
|
827
|
+
if (v.length > LIMITS.ELIGIBILITY_MAX_ENTRIES) {
|
|
828
|
+
throw new ValidationError(
|
|
829
|
+
`eligibility.${key} exceeds max entries (${LIMITS.ELIGIBILITY_MAX_ENTRIES})`
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
if (v.some((x) => x.length > LIMITS.ELIGIBILITY_ENTRY_MAX_LENGTH)) {
|
|
833
|
+
throw new ValidationError(
|
|
834
|
+
`eligibility.${key} entry exceeds max length (${LIMITS.ELIGIBILITY_ENTRY_MAX_LENGTH})`
|
|
835
|
+
);
|
|
836
|
+
}
|
|
730
837
|
out[key] = v;
|
|
731
838
|
}
|
|
732
839
|
return out;
|
|
@@ -743,6 +850,9 @@ function validateCreateCouponInput(input) {
|
|
|
743
850
|
validateDiscountPolicy(input.discount);
|
|
744
851
|
assertDateRange(input.startsAt ?? null, input.endsAt ?? null);
|
|
745
852
|
assertMetadata(input.metadata);
|
|
853
|
+
assertCouponStatus(input.status);
|
|
854
|
+
assertOptionalString("campaignId", input.campaignId, LIMITS.ID_MAX_LENGTH);
|
|
855
|
+
assertOptionalString("actorId", input.actorId, LIMITS.USER_ID_MAX_LENGTH);
|
|
746
856
|
if (input.maxRedemptions !== void 0 && input.maxRedemptions !== null) {
|
|
747
857
|
if (typeof input.maxRedemptions !== "number" || !Number.isInteger(input.maxRedemptions) || input.maxRedemptions <= 0) {
|
|
748
858
|
throw new ValidationError("maxRedemptions must be a positive integer or null");
|
|
@@ -778,14 +888,16 @@ function validateIssueBulkInput(input) {
|
|
|
778
888
|
validateDiscountPolicy(input.discount);
|
|
779
889
|
assertDateRange(input.startsAt ?? null, input.endsAt ?? null);
|
|
780
890
|
assertMetadata(input.metadata);
|
|
891
|
+
assertOptionalString("campaignId", input.campaignId, LIMITS.ID_MAX_LENGTH);
|
|
892
|
+
assertOptionalString("actorId", input.actorId, LIMITS.USER_ID_MAX_LENGTH);
|
|
781
893
|
}
|
|
782
894
|
function validateIssueCouponInput(input) {
|
|
783
895
|
if (!input || typeof input !== "object") {
|
|
784
896
|
throw new ValidationError("input must be an object");
|
|
785
897
|
}
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
898
|
+
assertRequiredString("couponId", input.couponId, LIMITS.ID_MAX_LENGTH);
|
|
899
|
+
assertOptionalString("issuedToUserId", input.issuedToUserId, LIMITS.USER_ID_MAX_LENGTH);
|
|
900
|
+
assertOptionalString("issuedBy", input.issuedBy, LIMITS.USER_ID_MAX_LENGTH);
|
|
789
901
|
assertMetadata(input.metadata);
|
|
790
902
|
}
|
|
791
903
|
function validateValidateInput(input) {
|
|
@@ -795,6 +907,8 @@ function validateValidateInput(input) {
|
|
|
795
907
|
if (typeof input.code !== "string" || input.code.length === 0) {
|
|
796
908
|
throw new ValidationError("code is required");
|
|
797
909
|
}
|
|
910
|
+
assertOptionalString("userId", input.userId, LIMITS.USER_ID_MAX_LENGTH);
|
|
911
|
+
assertContext(input.context);
|
|
798
912
|
if (input.subtotal !== void 0) {
|
|
799
913
|
if (typeof input.subtotal !== "number" || !Number.isInteger(input.subtotal) || input.subtotal < 0) {
|
|
800
914
|
throw new ValidationError("subtotal must be a non-negative integer");
|
|
@@ -815,6 +929,9 @@ function validateRedeemInput(input) {
|
|
|
815
929
|
throw new ValidationError("subtotal must be a non-negative integer");
|
|
816
930
|
}
|
|
817
931
|
assertCurrency(input.currency);
|
|
932
|
+
assertOptionalString("userId", input.userId, LIMITS.USER_ID_MAX_LENGTH);
|
|
933
|
+
assertOptionalString("orderRef", input.orderRef, LIMITS.ORDER_REF_MAX_LENGTH);
|
|
934
|
+
assertContext(input.context);
|
|
818
935
|
assertMetadata(input.metadata);
|
|
819
936
|
}
|
|
820
937
|
function validateCreateCampaignInput(input) {
|
|
@@ -832,6 +949,7 @@ function validateCreateCampaignInput(input) {
|
|
|
832
949
|
validateDiscountPolicy(input.discount);
|
|
833
950
|
assertDateRange(input.startsAt ?? null, input.endsAt ?? null);
|
|
834
951
|
assertMetadata(input.metadata);
|
|
952
|
+
assertOptionalString("actorId", input.actorId, LIMITS.USER_ID_MAX_LENGTH);
|
|
835
953
|
if (input.maxCoupons !== void 0 && input.maxCoupons !== null) {
|
|
836
954
|
if (typeof input.maxCoupons !== "number" || !Number.isInteger(input.maxCoupons) || input.maxCoupons <= 0) {
|
|
837
955
|
throw new ValidationError(
|
|
@@ -845,19 +963,29 @@ function validateCreateCampaignInput(input) {
|
|
|
845
963
|
}
|
|
846
964
|
}
|
|
847
965
|
}
|
|
966
|
+
var discountDurationSchema = z.discriminatedUnion("kind", [
|
|
967
|
+
z.object({ kind: z.literal("ONCE") }),
|
|
968
|
+
z.object({
|
|
969
|
+
kind: z.literal("REPEATING"),
|
|
970
|
+
months: z.number().int().min(1).max(LIMITS.DURATION_MAX_MONTHS)
|
|
971
|
+
}),
|
|
972
|
+
z.object({ kind: z.literal("FOREVER") })
|
|
973
|
+
]);
|
|
848
974
|
z.discriminatedUnion("kind", [
|
|
849
975
|
z.object({
|
|
850
976
|
kind: z.literal("PERCENT"),
|
|
851
977
|
percent: z.number().gt(0).lte(100),
|
|
852
978
|
maxDiscount: z.number().int().nonnegative().optional(),
|
|
853
979
|
minOrder: z.number().int().nonnegative().optional(),
|
|
854
|
-
currency: z.string().length(3).optional()
|
|
980
|
+
currency: z.string().length(3).optional(),
|
|
981
|
+
duration: discountDurationSchema.optional()
|
|
855
982
|
}),
|
|
856
983
|
z.object({
|
|
857
984
|
kind: z.literal("FIXED"),
|
|
858
985
|
amount: z.number().int().positive(),
|
|
859
986
|
currency: z.string().length(3),
|
|
860
|
-
minOrder: z.number().int().nonnegative().optional()
|
|
987
|
+
minOrder: z.number().int().nonnegative().optional(),
|
|
988
|
+
duration: discountDurationSchema.optional()
|
|
861
989
|
})
|
|
862
990
|
]);
|
|
863
991
|
|
|
@@ -865,6 +993,9 @@ z.discriminatedUnion("kind", [
|
|
|
865
993
|
function w(v) {
|
|
866
994
|
return v;
|
|
867
995
|
}
|
|
996
|
+
function isUniqueViolation(err) {
|
|
997
|
+
return typeof err === "object" && err !== null && err.code === "P2002";
|
|
998
|
+
}
|
|
868
999
|
var NOOP_LOGGER = {
|
|
869
1000
|
debug() {
|
|
870
1001
|
},
|
|
@@ -897,7 +1028,8 @@ function resolveConfig(config) {
|
|
|
897
1028
|
// allow-now: default fallback when config.now is omitted
|
|
898
1029
|
logger: config.logger ?? NOOP_LOGGER,
|
|
899
1030
|
models,
|
|
900
|
-
tables: resolveTableNames(models, config.tables)
|
|
1031
|
+
tables: resolveTableNames(models, config.tables),
|
|
1032
|
+
schema: resolveSchemaName(config.schema)
|
|
901
1033
|
};
|
|
902
1034
|
}
|
|
903
1035
|
async function safeEmit(handler, logger, event) {
|
|
@@ -910,6 +1042,13 @@ async function safeEmit(handler, logger, event) {
|
|
|
910
1042
|
});
|
|
911
1043
|
}
|
|
912
1044
|
}
|
|
1045
|
+
function assertUserIdForPerUserLimit(userId) {
|
|
1046
|
+
if (typeof userId !== "string" || userId.length === 0) {
|
|
1047
|
+
throw new ValidationError(
|
|
1048
|
+
"userId is required: this coupon enforces maxRedemptionsPerUser"
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
913
1052
|
async function resolveLazyStatus(delegates, tx, coupon, now) {
|
|
914
1053
|
if (coupon.status === COUPON_STATUS.ARCHIVED) {
|
|
915
1054
|
return { kind: "reject", reason: "ARCHIVED", coupon };
|
|
@@ -968,7 +1107,7 @@ function withIdTieBreak(orderBy) {
|
|
|
968
1107
|
function createCouponClient(config) {
|
|
969
1108
|
const cfg = resolveConfig(config);
|
|
970
1109
|
const prisma = cfg.prisma;
|
|
971
|
-
const delegates = createDelegateResolver(cfg.models, cfg.tables);
|
|
1110
|
+
const delegates = createDelegateResolver(cfg.models, cfg.tables, cfg.schema);
|
|
972
1111
|
const tid = (v) => resolveTenantId(v, cfg.defaultTenantId);
|
|
973
1112
|
async function paginate(delegate, where, limit, cursor, mapFn, orderBy = { createdAt: "desc" }) {
|
|
974
1113
|
const take = limit + 1;
|
|
@@ -1001,20 +1140,21 @@ function createCouponClient(config) {
|
|
|
1001
1140
|
const created = await delegates.coupon(tx).create({ data: row });
|
|
1002
1141
|
return rowToCoupon(created);
|
|
1003
1142
|
} catch (err) {
|
|
1143
|
+
if (!isUniqueViolation(err)) throw err;
|
|
1004
1144
|
lastError = err;
|
|
1005
1145
|
if (baseCode) {
|
|
1006
1146
|
throw new ValidationError(
|
|
1007
1147
|
`coupon code already exists in tenant`,
|
|
1008
|
-
|
|
1009
|
-
{ tenantId, couponCode: baseCode }
|
|
1148
|
+
void 0,
|
|
1149
|
+
{ tenantId, couponCode: baseCode, cause: err }
|
|
1010
1150
|
);
|
|
1011
1151
|
}
|
|
1012
1152
|
}
|
|
1013
1153
|
}
|
|
1014
1154
|
throw new ValidationError(
|
|
1015
1155
|
`failed to create coupon after ${maxAttempts} attempts (unique code collision)`,
|
|
1016
|
-
|
|
1017
|
-
{ tenantId }
|
|
1156
|
+
void 0,
|
|
1157
|
+
{ tenantId, cause: lastError }
|
|
1018
1158
|
);
|
|
1019
1159
|
}
|
|
1020
1160
|
const coupons = {
|
|
@@ -1119,15 +1259,25 @@ function createCouponClient(config) {
|
|
|
1119
1259
|
let skipped = 0;
|
|
1120
1260
|
let totalAttempts = 0;
|
|
1121
1261
|
await prisma.$transaction(async (tx) => {
|
|
1262
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1122
1263
|
for (let i = 0; i < input.count; i++) {
|
|
1123
1264
|
let placed = null;
|
|
1124
1265
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
1125
1266
|
totalAttempts++;
|
|
1126
|
-
const
|
|
1267
|
+
const canonical = canonicalizeCode(
|
|
1268
|
+
cfg.codeGenerator({ length: codeLength })
|
|
1269
|
+
);
|
|
1270
|
+
const collided = seen.has(canonical) || await delegates.coupon(tx).findFirst({
|
|
1271
|
+
where: { tenantId, code: canonical },
|
|
1272
|
+
select: { id: true }
|
|
1273
|
+
}) !== null;
|
|
1274
|
+
if (collided) {
|
|
1275
|
+
if (attempt === maxAttempts) skipped++;
|
|
1276
|
+
continue;
|
|
1277
|
+
}
|
|
1278
|
+
let row;
|
|
1127
1279
|
try {
|
|
1128
|
-
|
|
1129
|
-
const canonical = canonicalizeCode(code);
|
|
1130
|
-
const row = await delegates.coupon(tx).create({
|
|
1280
|
+
row = await delegates.coupon(tx).create({
|
|
1131
1281
|
data: {
|
|
1132
1282
|
id: cfg.idGenerator(),
|
|
1133
1283
|
tenantId,
|
|
@@ -1143,13 +1293,17 @@ function createCouponClient(config) {
|
|
|
1143
1293
|
metadata: w(metadata ?? null)
|
|
1144
1294
|
}
|
|
1145
1295
|
});
|
|
1146
|
-
placed = rowToCoupon(row);
|
|
1147
|
-
break;
|
|
1148
1296
|
} catch (err) {
|
|
1149
|
-
if (
|
|
1150
|
-
|
|
1151
|
-
|
|
1297
|
+
if (!isUniqueViolation(err)) throw err;
|
|
1298
|
+
throw new ValidationError(
|
|
1299
|
+
"bulk issuance aborted: coupon code collided with a concurrent insert",
|
|
1300
|
+
void 0,
|
|
1301
|
+
{ tenantId, couponCode: canonical, cause: err }
|
|
1302
|
+
);
|
|
1152
1303
|
}
|
|
1304
|
+
seen.add(canonical);
|
|
1305
|
+
placed = rowToCoupon(row);
|
|
1306
|
+
break;
|
|
1153
1307
|
}
|
|
1154
1308
|
if (placed) {
|
|
1155
1309
|
created.push(placed);
|
|
@@ -1242,9 +1396,7 @@ function createCouponClient(config) {
|
|
|
1242
1396
|
},
|
|
1243
1397
|
async get(args) {
|
|
1244
1398
|
const tenantId = tid(args.tenantId);
|
|
1245
|
-
|
|
1246
|
-
throw new ValidationError("id is required");
|
|
1247
|
-
}
|
|
1399
|
+
assertRequiredString("id", args.id, LIMITS.ID_MAX_LENGTH);
|
|
1248
1400
|
const row = await delegates.coupon(prisma).findFirst({
|
|
1249
1401
|
where: { id: args.id, tenantId }
|
|
1250
1402
|
});
|
|
@@ -1253,6 +1405,8 @@ function createCouponClient(config) {
|
|
|
1253
1405
|
async list(args) {
|
|
1254
1406
|
const tenantId = tid(args.tenantId);
|
|
1255
1407
|
const limit = assertPaginationLimit(args.limit);
|
|
1408
|
+
assertOptionalString("cursor", args.cursor, LIMITS.ID_MAX_LENGTH);
|
|
1409
|
+
assertOptionalString("campaignId", args.campaignId, LIMITS.ID_MAX_LENGTH);
|
|
1256
1410
|
const where = { tenantId };
|
|
1257
1411
|
if (args.campaignId) where.campaignId = args.campaignId;
|
|
1258
1412
|
if (args.status) where.status = args.status;
|
|
@@ -1267,6 +1421,8 @@ function createCouponClient(config) {
|
|
|
1267
1421
|
},
|
|
1268
1422
|
async pause(args) {
|
|
1269
1423
|
const tenantId = tid(args.tenantId);
|
|
1424
|
+
assertRequiredString("id", args.id, LIMITS.ID_MAX_LENGTH);
|
|
1425
|
+
assertOptionalString("actorId", args.actorId, LIMITS.USER_ID_MAX_LENGTH);
|
|
1270
1426
|
const now = cfg.now();
|
|
1271
1427
|
const coupon = await prisma.$transaction(async (tx) => {
|
|
1272
1428
|
const row = await delegates.coupon(tx).findFirst({
|
|
@@ -1300,6 +1456,8 @@ function createCouponClient(config) {
|
|
|
1300
1456
|
},
|
|
1301
1457
|
async resume(args) {
|
|
1302
1458
|
const tenantId = tid(args.tenantId);
|
|
1459
|
+
assertRequiredString("id", args.id, LIMITS.ID_MAX_LENGTH);
|
|
1460
|
+
assertOptionalString("actorId", args.actorId, LIMITS.USER_ID_MAX_LENGTH);
|
|
1303
1461
|
const now = cfg.now();
|
|
1304
1462
|
const coupon = await prisma.$transaction(async (tx) => {
|
|
1305
1463
|
const row = await delegates.coupon(tx).findFirst({
|
|
@@ -1333,6 +1491,8 @@ function createCouponClient(config) {
|
|
|
1333
1491
|
},
|
|
1334
1492
|
async archive(args) {
|
|
1335
1493
|
const tenantId = tid(args.tenantId);
|
|
1494
|
+
assertRequiredString("id", args.id, LIMITS.ID_MAX_LENGTH);
|
|
1495
|
+
assertOptionalString("actorId", args.actorId, LIMITS.USER_ID_MAX_LENGTH);
|
|
1336
1496
|
const now = cfg.now();
|
|
1337
1497
|
const coupon = await prisma.$transaction(async (tx) => {
|
|
1338
1498
|
const row = await delegates.coupon(tx).findFirst({
|
|
@@ -1390,7 +1550,8 @@ function createCouponClient(config) {
|
|
|
1390
1550
|
if (!elig.ok) {
|
|
1391
1551
|
return { ok: false, reason: elig.reason };
|
|
1392
1552
|
}
|
|
1393
|
-
if (coupon.maxRedemptionsPerUser !== null
|
|
1553
|
+
if (coupon.maxRedemptionsPerUser !== null) {
|
|
1554
|
+
assertUserIdForPerUserLimit(input.userId);
|
|
1394
1555
|
const count = await delegates.couponRedemption(tx).count({
|
|
1395
1556
|
where: {
|
|
1396
1557
|
tenantId,
|
|
@@ -1432,7 +1593,8 @@ function createCouponClient(config) {
|
|
|
1432
1593
|
tx,
|
|
1433
1594
|
delegates.tables.coupon,
|
|
1434
1595
|
couponId,
|
|
1435
|
-
tenantId
|
|
1596
|
+
tenantId,
|
|
1597
|
+
delegates.schema
|
|
1436
1598
|
);
|
|
1437
1599
|
if (!Array.isArray(locked) || locked.length === 0) {
|
|
1438
1600
|
throw new CouponNotFoundError("Coupon not found", { tenantId, couponId });
|
|
@@ -1474,7 +1636,8 @@ function createCouponClient(config) {
|
|
|
1474
1636
|
if (!elig.ok) {
|
|
1475
1637
|
throw eligibilityReasonToError(elig.reason);
|
|
1476
1638
|
}
|
|
1477
|
-
if (coupon.maxRedemptionsPerUser !== null
|
|
1639
|
+
if (coupon.maxRedemptionsPerUser !== null) {
|
|
1640
|
+
assertUserIdForPerUserLimit(input.userId);
|
|
1478
1641
|
const userCount = await delegates.couponRedemption(tx).count({
|
|
1479
1642
|
where: {
|
|
1480
1643
|
tenantId,
|
|
@@ -1548,6 +1711,7 @@ function createCouponClient(config) {
|
|
|
1548
1711
|
});
|
|
1549
1712
|
redemption = rowToRedemption(inserted);
|
|
1550
1713
|
} catch (err) {
|
|
1714
|
+
if (!isUniqueViolation(err)) throw err;
|
|
1551
1715
|
throw new CouponAlreadyRedeemedError(void 0, {
|
|
1552
1716
|
tenantId,
|
|
1553
1717
|
couponId: coupon.id,
|
|
@@ -1555,7 +1719,7 @@ function createCouponClient(config) {
|
|
|
1555
1719
|
cause: err
|
|
1556
1720
|
});
|
|
1557
1721
|
}
|
|
1558
|
-
if (coupon.maxRedemptionsPerUser !== null
|
|
1722
|
+
if (coupon.maxRedemptionsPerUser !== null) {
|
|
1559
1723
|
const userCountFinal = await delegates.couponRedemption(tx).count({
|
|
1560
1724
|
where: {
|
|
1561
1725
|
tenantId,
|
|
@@ -1655,20 +1819,30 @@ function createCouponClient(config) {
|
|
|
1655
1819
|
const metadata = assertMetadata(input.metadata);
|
|
1656
1820
|
const now = cfg.now();
|
|
1657
1821
|
const campaign = await prisma.$transaction(async (tx) => {
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1822
|
+
let row;
|
|
1823
|
+
try {
|
|
1824
|
+
row = await delegates.campaign(tx).create({
|
|
1825
|
+
data: {
|
|
1826
|
+
id: cfg.idGenerator(),
|
|
1827
|
+
tenantId,
|
|
1828
|
+
name: input.name,
|
|
1829
|
+
status: CAMPAIGN_STATUS.ACTIVE,
|
|
1830
|
+
discount: w(input.discount),
|
|
1831
|
+
eligibility: w(eligibility),
|
|
1832
|
+
startsAt: input.startsAt ?? null,
|
|
1833
|
+
endsAt: input.endsAt ?? null,
|
|
1834
|
+
maxCoupons: input.maxCoupons ?? null,
|
|
1835
|
+
metadata: w(metadata ?? null)
|
|
1836
|
+
}
|
|
1837
|
+
});
|
|
1838
|
+
} catch (err) {
|
|
1839
|
+
if (!isUniqueViolation(err)) throw err;
|
|
1840
|
+
throw new ValidationError(
|
|
1841
|
+
"campaign name already exists in tenant",
|
|
1842
|
+
void 0,
|
|
1843
|
+
{ tenantId, cause: err }
|
|
1844
|
+
);
|
|
1845
|
+
}
|
|
1672
1846
|
const campaign2 = rowToCampaign(row);
|
|
1673
1847
|
await writeAudit(delegates.couponAuditLog(tx), {
|
|
1674
1848
|
tenantId,
|
|
@@ -1690,7 +1864,7 @@ function createCouponClient(config) {
|
|
|
1690
1864
|
},
|
|
1691
1865
|
async get(args) {
|
|
1692
1866
|
const tenantId = tid(args.tenantId);
|
|
1693
|
-
|
|
1867
|
+
assertRequiredString("id", args.id, LIMITS.ID_MAX_LENGTH);
|
|
1694
1868
|
const row = await delegates.campaign(prisma).findFirst({
|
|
1695
1869
|
where: { id: args.id, tenantId }
|
|
1696
1870
|
});
|
|
@@ -1699,6 +1873,7 @@ function createCouponClient(config) {
|
|
|
1699
1873
|
async list(args) {
|
|
1700
1874
|
const tenantId = tid(args.tenantId);
|
|
1701
1875
|
const limit = assertPaginationLimit(args.limit);
|
|
1876
|
+
assertOptionalString("cursor", args.cursor, LIMITS.ID_MAX_LENGTH);
|
|
1702
1877
|
const where = { tenantId };
|
|
1703
1878
|
if (args.status) where.status = args.status;
|
|
1704
1879
|
return paginate(
|
|
@@ -1712,9 +1887,8 @@ function createCouponClient(config) {
|
|
|
1712
1887
|
},
|
|
1713
1888
|
async issue(args) {
|
|
1714
1889
|
if (!args || typeof args !== "object") throw new ValidationError("args required");
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
}
|
|
1890
|
+
assertRequiredString("campaignId", args.campaignId, LIMITS.ID_MAX_LENGTH);
|
|
1891
|
+
assertOptionalString("actorId", args.actorId, LIMITS.USER_ID_MAX_LENGTH);
|
|
1718
1892
|
if (typeof args.count !== "number" || !Number.isInteger(args.count) || args.count <= 0 || args.count > LIMITS.BULK_MAX_COUNT) {
|
|
1719
1893
|
throw new ValidationError(
|
|
1720
1894
|
`count must be a positive integer <= ${LIMITS.BULK_MAX_COUNT}`
|
|
@@ -1744,12 +1918,14 @@ function createCouponClient(config) {
|
|
|
1744
1918
|
endsAt: campaign.endsAt,
|
|
1745
1919
|
maxAttempts: args.maxAttempts,
|
|
1746
1920
|
eligibility: campaign.eligibility,
|
|
1921
|
+
maxRedemptions: args.maxRedemptions,
|
|
1922
|
+
maxRedemptionsPerUser: args.maxRedemptionsPerUser,
|
|
1747
1923
|
actorId: args.actorId
|
|
1748
1924
|
});
|
|
1749
1925
|
},
|
|
1750
1926
|
async summary(args) {
|
|
1751
1927
|
const tenantId = tid(args.tenantId);
|
|
1752
|
-
|
|
1928
|
+
assertRequiredString("id", args.id, LIMITS.ID_MAX_LENGTH);
|
|
1753
1929
|
const campRow = await delegates.campaign(prisma).findFirst({
|
|
1754
1930
|
where: { id: args.id, tenantId }
|
|
1755
1931
|
});
|
|
@@ -1802,7 +1978,8 @@ function createCouponClient(config) {
|
|
|
1802
1978
|
},
|
|
1803
1979
|
async close(args) {
|
|
1804
1980
|
const tenantId = tid(args.tenantId);
|
|
1805
|
-
|
|
1981
|
+
assertRequiredString("id", args.id, LIMITS.ID_MAX_LENGTH);
|
|
1982
|
+
assertOptionalString("actorId", args.actorId, LIMITS.USER_ID_MAX_LENGTH);
|
|
1806
1983
|
const now = cfg.now();
|
|
1807
1984
|
const campaign = await prisma.$transaction(async (tx) => {
|
|
1808
1985
|
const row = await delegates.campaign(tx).findFirst({
|
|
@@ -1840,6 +2017,9 @@ function createCouponClient(config) {
|
|
|
1840
2017
|
async listIssuances(args) {
|
|
1841
2018
|
const tenantId = tid(args.tenantId);
|
|
1842
2019
|
const limit = assertPaginationLimit(args.limit);
|
|
2020
|
+
assertOptionalString("cursor", args.cursor, LIMITS.ID_MAX_LENGTH);
|
|
2021
|
+
assertOptionalString("couponId", args.couponId, LIMITS.ID_MAX_LENGTH);
|
|
2022
|
+
assertOptionalString("issuedToUserId", args.issuedToUserId, LIMITS.USER_ID_MAX_LENGTH);
|
|
1843
2023
|
const where = { tenantId };
|
|
1844
2024
|
if (args.couponId) where.couponId = args.couponId;
|
|
1845
2025
|
if (args.issuedToUserId) where.issuedToUserId = args.issuedToUserId;
|
|
@@ -1855,6 +2035,9 @@ function createCouponClient(config) {
|
|
|
1855
2035
|
async listRedemptions(args) {
|
|
1856
2036
|
const tenantId = tid(args.tenantId);
|
|
1857
2037
|
const limit = assertPaginationLimit(args.limit);
|
|
2038
|
+
assertOptionalString("cursor", args.cursor, LIMITS.ID_MAX_LENGTH);
|
|
2039
|
+
assertOptionalString("couponId", args.couponId, LIMITS.ID_MAX_LENGTH);
|
|
2040
|
+
assertOptionalString("redeemedByUserId", args.redeemedByUserId, LIMITS.USER_ID_MAX_LENGTH);
|
|
1858
2041
|
const where = { tenantId };
|
|
1859
2042
|
if (args.couponId) where.couponId = args.couponId;
|
|
1860
2043
|
if (args.redeemedByUserId) where.redeemedByUserId = args.redeemedByUserId;
|
|
@@ -1870,6 +2053,8 @@ function createCouponClient(config) {
|
|
|
1870
2053
|
async listLogs(args) {
|
|
1871
2054
|
const tenantId = tid(args.tenantId);
|
|
1872
2055
|
const limit = assertPaginationLimit(args.limit);
|
|
2056
|
+
assertOptionalString("cursor", args.cursor, LIMITS.ID_MAX_LENGTH);
|
|
2057
|
+
assertOptionalString("resourceId", args.resourceId, LIMITS.ID_MAX_LENGTH);
|
|
1873
2058
|
const where = { tenantId };
|
|
1874
2059
|
if (args.action) where.action = args.action;
|
|
1875
2060
|
if (args.resourceId) where.resourceId = args.resourceId;
|
|
@@ -1894,4 +2079,4 @@ function createCouponClient(config) {
|
|
|
1894
2079
|
};
|
|
1895
2080
|
}
|
|
1896
2081
|
|
|
1897
|
-
export { AUDIT_ACTION, CAMPAIGN_STATUS, COUPON_REJECT_REASON, COUPON_STATUS, CampaignCapacityExceededError, CampaignClosedError, CouponAlreadyRedeemedError, CouponArchivedError, CouponError, CouponExhaustedError, CouponExpiredError, CouponInactiveError, CouponNotFoundError, CurrencyMismatchError, DEFAULT_MODEL_NAMES, IneligibleCategoryError, IneligiblePlanError, IneligibleProductError, IneligibleUserError, InvalidDateRangeError, InvalidDiscountPolicyError, InvalidModelNameError, LIMITS, MinimumOrderNotMetError, PerUserLimitExceededError, TenantMismatchError, ValidationError, calculate as calculateDiscount, createCouponClient, validateDiscountPolicy };
|
|
2082
|
+
export { AUDIT_ACTION, CAMPAIGN_STATUS, COUPON_REJECT_REASON, COUPON_STATUS, CampaignCapacityExceededError, CampaignClosedError, CouponAlreadyRedeemedError, CouponArchivedError, CouponError, CouponExhaustedError, CouponExpiredError, CouponInactiveError, CouponNotFoundError, CurrencyMismatchError, DEFAULT_MODEL_NAMES, IneligibleCategoryError, IneligiblePlanError, IneligibleProductError, IneligibleUserError, InvalidDateRangeError, InvalidDiscountPolicyError, InvalidModelNameError, LIMITS, MinimumOrderNotMetError, PerUserLimitExceededError, TenantMismatchError, ValidationError, calculate as calculateDiscount, createCouponClient, resolveDuration, validateDiscountPolicy };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"packageManager": "pnpm@11.10.0",
|
|
3
3
|
"name": "@withwiz/coupon",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.3.0",
|
|
5
5
|
"description": "Multi-tenant, concurrency-safe coupon domain core for Prisma + Node.js applications",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "withwiz",
|
|
@@ -61,9 +61,9 @@
|
|
|
61
61
|
"prepublishOnly": "pnpm run build"
|
|
62
62
|
},
|
|
63
63
|
"dependencies": {
|
|
64
|
-
"
|
|
64
|
+
"@paralleldrive/cuid2": "^2.2.2",
|
|
65
65
|
"nanoid": "^5.0.0",
|
|
66
|
-
"
|
|
66
|
+
"zod": "^3.23.0"
|
|
67
67
|
},
|
|
68
68
|
"peerDependencies": {
|
|
69
69
|
"@prisma/client": ">=5.0.0 <8.0.0",
|
|
@@ -76,11 +76,12 @@
|
|
|
76
76
|
},
|
|
77
77
|
"devDependencies": {
|
|
78
78
|
"@prisma/client": "^6.0.0",
|
|
79
|
-
"@types/node": "^
|
|
80
|
-
"@vitest/coverage-v8": "^
|
|
79
|
+
"@types/node": "^22.0.0",
|
|
80
|
+
"@vitest/coverage-v8": "^5.0.0",
|
|
81
81
|
"prisma": "^6.0.0",
|
|
82
82
|
"tsup": "^8.0.0",
|
|
83
83
|
"typescript": "^5.4.0",
|
|
84
|
-
"
|
|
84
|
+
"vite": "^7.0.0",
|
|
85
|
+
"vitest": "^5.0.0"
|
|
85
86
|
}
|
|
86
87
|
}
|