@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/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 "${table}" WHERE "id" = `,
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
- if (typeof input.couponId !== "string" || input.couponId.length === 0) {
787
- throw new ValidationError("couponId is required");
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
- { prismaError: err instanceof Error ? err.message : String(err) },
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
- { lastError: lastError instanceof Error ? lastError.message : String(lastError) },
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 code = cfg.codeGenerator({ length: codeLength });
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
- sanitizeCode(code);
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 (attempt === maxAttempts) {
1150
- skipped++;
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
- if (!args.id || typeof args.id !== "string") {
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 && input.userId) {
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 && input.userId) {
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 && input.userId) {
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
- const row = await delegates.campaign(tx).create({
1659
- data: {
1660
- id: cfg.idGenerator(),
1661
- tenantId,
1662
- name: input.name,
1663
- status: CAMPAIGN_STATUS.ACTIVE,
1664
- discount: w(input.discount),
1665
- eligibility: w(eligibility),
1666
- startsAt: input.startsAt ?? null,
1667
- endsAt: input.endsAt ?? null,
1668
- maxCoupons: input.maxCoupons ?? null,
1669
- metadata: w(metadata ?? null)
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
- if (!args.id) throw new ValidationError("id is required");
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
- if (typeof args.campaignId !== "string" || !args.campaignId) {
1716
- throw new ValidationError("campaignId is required");
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
- if (!args.id) throw new ValidationError("id is required");
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
- if (!args.id) throw new ValidationError("id is required");
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.1.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
- "zod": "^3.23.0",
64
+ "@paralleldrive/cuid2": "^2.2.2",
65
65
  "nanoid": "^5.0.0",
66
- "@paralleldrive/cuid2": "^2.2.2"
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": "^20.12.0",
80
- "@vitest/coverage-v8": "^1.6.0",
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
- "vitest": "^1.6.0"
84
+ "vite": "^7.0.0",
85
+ "vitest": "^5.0.0"
85
86
  }
86
87
  }