@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.cjs CHANGED
@@ -55,7 +55,25 @@ var LIMITS = {
55
55
  PAGINATION_DEFAULT_LIMIT: 50,
56
56
  PAGINATION_MAX_LIMIT: 200,
57
57
  NAME_MAX_LENGTH: 200,
58
- CURRENCY_LENGTH: 3
58
+ CURRENCY_LENGTH: 3,
59
+ /**
60
+ * [L] Caller-supplied identifier caps. Ids (coupon / campaign / cursor) are
61
+ * bounded by the audit log's `resourceId VarChar(64)`; `orderRef` by the
62
+ * redemption table's `VarChar(128)`. User / actor ids share the same bound
63
+ * so an unbounded string can never reach a Json or text column unchecked.
64
+ */
65
+ ID_MAX_LENGTH: 64,
66
+ USER_ID_MAX_LENGTH: 128,
67
+ ORDER_REF_MAX_LENGTH: 128,
68
+ /** [L] Upper bound on serialized `metadata` (bytes of JSON text). */
69
+ METADATA_MAX_BYTES: 16384,
70
+ /** [L] Per-list entry cap and per-entry length cap for eligibility rules. */
71
+ ELIGIBILITY_MAX_ENTRIES: 1e3,
72
+ ELIGIBILITY_ENTRY_MAX_LENGTH: 128,
73
+ /** [L] Length cap for `context.plan / productId / categoryId`. */
74
+ CONTEXT_VALUE_MAX_LENGTH: 128,
75
+ /** Upper bound for `duration.months` on a REPEATING discount. */
76
+ DURATION_MAX_MONTHS: 36
59
77
  };
60
78
  var AUDIT_ACTION = {
61
79
  COUPON_CREATED: "coupon.created",
@@ -264,6 +282,7 @@ function validateDiscountPolicy(policy) {
264
282
  if (policy.currency !== void 0) {
265
283
  ensureCurrency(policy.currency);
266
284
  }
285
+ validateDuration(policy.duration);
267
286
  return;
268
287
  }
269
288
  if (policy.kind === "FIXED") {
@@ -281,6 +300,7 @@ function validateDiscountPolicy(policy) {
281
300
  );
282
301
  }
283
302
  }
303
+ validateDuration(policy.duration);
284
304
  return;
285
305
  }
286
306
  throw new InvalidDiscountPolicyError(
@@ -353,6 +373,27 @@ function extractPolicyCurrency(policy) {
353
373
  if (policy.kind === "PERCENT") return policy.currency;
354
374
  return void 0;
355
375
  }
376
+ function validateDuration(duration) {
377
+ if (duration === void 0) return;
378
+ if (!duration || typeof duration !== "object") {
379
+ throw new InvalidDiscountPolicyError("duration must be an object");
380
+ }
381
+ const kind = duration.kind;
382
+ if (kind === "ONCE" || kind === "FOREVER") return;
383
+ if (kind === "REPEATING") {
384
+ const months = duration.months;
385
+ if (typeof months !== "number" || !Number.isInteger(months) || months < 1 || months > LIMITS.DURATION_MAX_MONTHS) {
386
+ throw new InvalidDiscountPolicyError(
387
+ `duration.months must be an integer in [1, ${LIMITS.DURATION_MAX_MONTHS}] (got ${String(months)})`
388
+ );
389
+ }
390
+ return;
391
+ }
392
+ throw new InvalidDiscountPolicyError(`unknown duration kind: ${String(kind)}`);
393
+ }
394
+ function resolveDuration(policy) {
395
+ return policy.duration ?? { kind: "ONCE" };
396
+ }
356
397
 
357
398
  // src/eligibility.ts
358
399
  function checkEligibility(input) {
@@ -570,6 +611,15 @@ function assertIdentifier(kind, key, value) {
570
611
  }
571
612
  return value;
572
613
  }
614
+ function resolveSchemaName(schema) {
615
+ if (schema === void 0) return void 0;
616
+ if (typeof schema !== "string" || !IDENTIFIER_RE.test(schema)) {
617
+ throw new InvalidModelNameError(
618
+ `Invalid schema name: ${JSON.stringify(schema)}. Expected a plain identifier matching ${IDENTIFIER_RE.source}.`
619
+ );
620
+ }
621
+ return schema;
622
+ }
573
623
  function resolveModelNames(overrides) {
574
624
  const out = { ...DEFAULT_MODEL_NAMES };
575
625
  for (const key of MODEL_KEYS) {
@@ -596,19 +646,21 @@ function pick(db, key, name) {
596
646
  }
597
647
  return delegate;
598
648
  }
599
- function createDelegateResolver(models, tables) {
649
+ function createDelegateResolver(models, tables, schema) {
600
650
  const resolver = {
601
651
  models,
602
- tables
652
+ tables,
653
+ schema
603
654
  };
604
655
  for (const key of MODEL_KEYS) {
605
656
  resolver[key] = (db) => pick(db, key, models[key]);
606
657
  }
607
658
  return resolver;
608
659
  }
609
- function lockCouponRow(tx, table, couponId, tenantId) {
660
+ function lockCouponRow(tx, table, couponId, tenantId, schema) {
661
+ const target = schema ? `"${schema}"."${table}"` : `"${table}"`;
610
662
  const parts = [
611
- `SELECT * FROM "${table}" WHERE "id" = `,
663
+ `SELECT * FROM ${target} WHERE "id" = `,
612
664
  ` AND "tenantId" = `,
613
665
  ` FOR UPDATE`
614
666
  ];
@@ -617,6 +669,40 @@ function lockCouponRow(tx, table, couponId, tenantId) {
617
669
  });
618
670
  return tx.$queryRaw(strings, couponId, tenantId);
619
671
  }
672
+ function assertOptionalString(name, value, max) {
673
+ if (value === void 0 || value === null) return;
674
+ if (typeof value !== "string" || value.length === 0) {
675
+ throw new ValidationError(`${name} must be a non-empty string`);
676
+ }
677
+ if (value.length > max) {
678
+ throw new ValidationError(`${name} length exceeds max (${max})`);
679
+ }
680
+ }
681
+ function assertRequiredString(name, value, max) {
682
+ if (value === void 0 || value === null) {
683
+ throw new ValidationError(`${name} is required`);
684
+ }
685
+ assertOptionalString(name, value, max);
686
+ }
687
+ function assertContext(context) {
688
+ if (context === void 0 || context === null) return;
689
+ if (typeof context !== "object" || Array.isArray(context)) {
690
+ throw new ValidationError("context must be a plain object");
691
+ }
692
+ const c = context;
693
+ assertOptionalString("context.plan", c.plan, LIMITS.CONTEXT_VALUE_MAX_LENGTH);
694
+ assertOptionalString("context.productId", c.productId, LIMITS.CONTEXT_VALUE_MAX_LENGTH);
695
+ assertOptionalString("context.categoryId", c.categoryId, LIMITS.CONTEXT_VALUE_MAX_LENGTH);
696
+ }
697
+ function assertCouponStatus(status) {
698
+ if (status === void 0) return;
699
+ const allowed = Object.values(COUPON_STATUS);
700
+ if (typeof status !== "string" || !allowed.includes(status)) {
701
+ throw new ValidationError(
702
+ `status must be one of ${allowed.join(", ")}`
703
+ );
704
+ }
705
+ }
620
706
  function assertTenantId(value) {
621
707
  if (typeof value !== "string" || value.length === 0) {
622
708
  throw new ValidationError("tenantId is required");
@@ -678,6 +764,17 @@ function assertMetadata(meta) {
678
764
  );
679
765
  }
680
766
  }
767
+ let serialized;
768
+ try {
769
+ serialized = JSON.stringify(rec);
770
+ } catch {
771
+ throw new ValidationError("metadata must be JSON-serializable");
772
+ }
773
+ if (Buffer.byteLength(serialized, "utf8") > LIMITS.METADATA_MAX_BYTES) {
774
+ throw new ValidationError(
775
+ `metadata exceeds max size (${LIMITS.METADATA_MAX_BYTES} bytes)`
776
+ );
777
+ }
681
778
  return rec;
682
779
  }
683
780
  function assertPaginationLimit(limit) {
@@ -729,6 +826,16 @@ function assertEligibility(rules) {
729
826
  `eligibility.${key} must be an array of non-empty strings`
730
827
  );
731
828
  }
829
+ if (v.length > LIMITS.ELIGIBILITY_MAX_ENTRIES) {
830
+ throw new ValidationError(
831
+ `eligibility.${key} exceeds max entries (${LIMITS.ELIGIBILITY_MAX_ENTRIES})`
832
+ );
833
+ }
834
+ if (v.some((x) => x.length > LIMITS.ELIGIBILITY_ENTRY_MAX_LENGTH)) {
835
+ throw new ValidationError(
836
+ `eligibility.${key} entry exceeds max length (${LIMITS.ELIGIBILITY_ENTRY_MAX_LENGTH})`
837
+ );
838
+ }
732
839
  out[key] = v;
733
840
  }
734
841
  return out;
@@ -745,6 +852,9 @@ function validateCreateCouponInput(input) {
745
852
  validateDiscountPolicy(input.discount);
746
853
  assertDateRange(input.startsAt ?? null, input.endsAt ?? null);
747
854
  assertMetadata(input.metadata);
855
+ assertCouponStatus(input.status);
856
+ assertOptionalString("campaignId", input.campaignId, LIMITS.ID_MAX_LENGTH);
857
+ assertOptionalString("actorId", input.actorId, LIMITS.USER_ID_MAX_LENGTH);
748
858
  if (input.maxRedemptions !== void 0 && input.maxRedemptions !== null) {
749
859
  if (typeof input.maxRedemptions !== "number" || !Number.isInteger(input.maxRedemptions) || input.maxRedemptions <= 0) {
750
860
  throw new ValidationError("maxRedemptions must be a positive integer or null");
@@ -780,14 +890,16 @@ function validateIssueBulkInput(input) {
780
890
  validateDiscountPolicy(input.discount);
781
891
  assertDateRange(input.startsAt ?? null, input.endsAt ?? null);
782
892
  assertMetadata(input.metadata);
893
+ assertOptionalString("campaignId", input.campaignId, LIMITS.ID_MAX_LENGTH);
894
+ assertOptionalString("actorId", input.actorId, LIMITS.USER_ID_MAX_LENGTH);
783
895
  }
784
896
  function validateIssueCouponInput(input) {
785
897
  if (!input || typeof input !== "object") {
786
898
  throw new ValidationError("input must be an object");
787
899
  }
788
- if (typeof input.couponId !== "string" || input.couponId.length === 0) {
789
- throw new ValidationError("couponId is required");
790
- }
900
+ assertRequiredString("couponId", input.couponId, LIMITS.ID_MAX_LENGTH);
901
+ assertOptionalString("issuedToUserId", input.issuedToUserId, LIMITS.USER_ID_MAX_LENGTH);
902
+ assertOptionalString("issuedBy", input.issuedBy, LIMITS.USER_ID_MAX_LENGTH);
791
903
  assertMetadata(input.metadata);
792
904
  }
793
905
  function validateValidateInput(input) {
@@ -797,6 +909,8 @@ function validateValidateInput(input) {
797
909
  if (typeof input.code !== "string" || input.code.length === 0) {
798
910
  throw new ValidationError("code is required");
799
911
  }
912
+ assertOptionalString("userId", input.userId, LIMITS.USER_ID_MAX_LENGTH);
913
+ assertContext(input.context);
800
914
  if (input.subtotal !== void 0) {
801
915
  if (typeof input.subtotal !== "number" || !Number.isInteger(input.subtotal) || input.subtotal < 0) {
802
916
  throw new ValidationError("subtotal must be a non-negative integer");
@@ -817,6 +931,9 @@ function validateRedeemInput(input) {
817
931
  throw new ValidationError("subtotal must be a non-negative integer");
818
932
  }
819
933
  assertCurrency(input.currency);
934
+ assertOptionalString("userId", input.userId, LIMITS.USER_ID_MAX_LENGTH);
935
+ assertOptionalString("orderRef", input.orderRef, LIMITS.ORDER_REF_MAX_LENGTH);
936
+ assertContext(input.context);
820
937
  assertMetadata(input.metadata);
821
938
  }
822
939
  function validateCreateCampaignInput(input) {
@@ -834,6 +951,7 @@ function validateCreateCampaignInput(input) {
834
951
  validateDiscountPolicy(input.discount);
835
952
  assertDateRange(input.startsAt ?? null, input.endsAt ?? null);
836
953
  assertMetadata(input.metadata);
954
+ assertOptionalString("actorId", input.actorId, LIMITS.USER_ID_MAX_LENGTH);
837
955
  if (input.maxCoupons !== void 0 && input.maxCoupons !== null) {
838
956
  if (typeof input.maxCoupons !== "number" || !Number.isInteger(input.maxCoupons) || input.maxCoupons <= 0) {
839
957
  throw new ValidationError(
@@ -847,19 +965,29 @@ function validateCreateCampaignInput(input) {
847
965
  }
848
966
  }
849
967
  }
968
+ var discountDurationSchema = zod.z.discriminatedUnion("kind", [
969
+ zod.z.object({ kind: zod.z.literal("ONCE") }),
970
+ zod.z.object({
971
+ kind: zod.z.literal("REPEATING"),
972
+ months: zod.z.number().int().min(1).max(LIMITS.DURATION_MAX_MONTHS)
973
+ }),
974
+ zod.z.object({ kind: zod.z.literal("FOREVER") })
975
+ ]);
850
976
  zod.z.discriminatedUnion("kind", [
851
977
  zod.z.object({
852
978
  kind: zod.z.literal("PERCENT"),
853
979
  percent: zod.z.number().gt(0).lte(100),
854
980
  maxDiscount: zod.z.number().int().nonnegative().optional(),
855
981
  minOrder: zod.z.number().int().nonnegative().optional(),
856
- currency: zod.z.string().length(3).optional()
982
+ currency: zod.z.string().length(3).optional(),
983
+ duration: discountDurationSchema.optional()
857
984
  }),
858
985
  zod.z.object({
859
986
  kind: zod.z.literal("FIXED"),
860
987
  amount: zod.z.number().int().positive(),
861
988
  currency: zod.z.string().length(3),
862
- minOrder: zod.z.number().int().nonnegative().optional()
989
+ minOrder: zod.z.number().int().nonnegative().optional(),
990
+ duration: discountDurationSchema.optional()
863
991
  })
864
992
  ]);
865
993
 
@@ -867,6 +995,9 @@ zod.z.discriminatedUnion("kind", [
867
995
  function w(v) {
868
996
  return v;
869
997
  }
998
+ function isUniqueViolation(err) {
999
+ return typeof err === "object" && err !== null && err.code === "P2002";
1000
+ }
870
1001
  var NOOP_LOGGER = {
871
1002
  debug() {
872
1003
  },
@@ -899,7 +1030,8 @@ function resolveConfig(config) {
899
1030
  // allow-now: default fallback when config.now is omitted
900
1031
  logger: config.logger ?? NOOP_LOGGER,
901
1032
  models,
902
- tables: resolveTableNames(models, config.tables)
1033
+ tables: resolveTableNames(models, config.tables),
1034
+ schema: resolveSchemaName(config.schema)
903
1035
  };
904
1036
  }
905
1037
  async function safeEmit(handler, logger, event) {
@@ -912,6 +1044,13 @@ async function safeEmit(handler, logger, event) {
912
1044
  });
913
1045
  }
914
1046
  }
1047
+ function assertUserIdForPerUserLimit(userId) {
1048
+ if (typeof userId !== "string" || userId.length === 0) {
1049
+ throw new ValidationError(
1050
+ "userId is required: this coupon enforces maxRedemptionsPerUser"
1051
+ );
1052
+ }
1053
+ }
915
1054
  async function resolveLazyStatus(delegates, tx, coupon, now) {
916
1055
  if (coupon.status === COUPON_STATUS.ARCHIVED) {
917
1056
  return { kind: "reject", reason: "ARCHIVED", coupon };
@@ -970,7 +1109,7 @@ function withIdTieBreak(orderBy) {
970
1109
  function createCouponClient(config) {
971
1110
  const cfg = resolveConfig(config);
972
1111
  const prisma = cfg.prisma;
973
- const delegates = createDelegateResolver(cfg.models, cfg.tables);
1112
+ const delegates = createDelegateResolver(cfg.models, cfg.tables, cfg.schema);
974
1113
  const tid = (v) => resolveTenantId(v, cfg.defaultTenantId);
975
1114
  async function paginate(delegate, where, limit, cursor, mapFn, orderBy = { createdAt: "desc" }) {
976
1115
  const take = limit + 1;
@@ -1003,20 +1142,21 @@ function createCouponClient(config) {
1003
1142
  const created = await delegates.coupon(tx).create({ data: row });
1004
1143
  return rowToCoupon(created);
1005
1144
  } catch (err) {
1145
+ if (!isUniqueViolation(err)) throw err;
1006
1146
  lastError = err;
1007
1147
  if (baseCode) {
1008
1148
  throw new ValidationError(
1009
1149
  `coupon code already exists in tenant`,
1010
- { prismaError: err instanceof Error ? err.message : String(err) },
1011
- { tenantId, couponCode: baseCode }
1150
+ void 0,
1151
+ { tenantId, couponCode: baseCode, cause: err }
1012
1152
  );
1013
1153
  }
1014
1154
  }
1015
1155
  }
1016
1156
  throw new ValidationError(
1017
1157
  `failed to create coupon after ${maxAttempts} attempts (unique code collision)`,
1018
- { lastError: lastError instanceof Error ? lastError.message : String(lastError) },
1019
- { tenantId }
1158
+ void 0,
1159
+ { tenantId, cause: lastError }
1020
1160
  );
1021
1161
  }
1022
1162
  const coupons = {
@@ -1121,15 +1261,25 @@ function createCouponClient(config) {
1121
1261
  let skipped = 0;
1122
1262
  let totalAttempts = 0;
1123
1263
  await prisma.$transaction(async (tx) => {
1264
+ const seen = /* @__PURE__ */ new Set();
1124
1265
  for (let i = 0; i < input.count; i++) {
1125
1266
  let placed = null;
1126
1267
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1127
1268
  totalAttempts++;
1128
- const code = cfg.codeGenerator({ length: codeLength });
1269
+ const canonical = canonicalizeCode(
1270
+ cfg.codeGenerator({ length: codeLength })
1271
+ );
1272
+ const collided = seen.has(canonical) || await delegates.coupon(tx).findFirst({
1273
+ where: { tenantId, code: canonical },
1274
+ select: { id: true }
1275
+ }) !== null;
1276
+ if (collided) {
1277
+ if (attempt === maxAttempts) skipped++;
1278
+ continue;
1279
+ }
1280
+ let row;
1129
1281
  try {
1130
- sanitizeCode(code);
1131
- const canonical = canonicalizeCode(code);
1132
- const row = await delegates.coupon(tx).create({
1282
+ row = await delegates.coupon(tx).create({
1133
1283
  data: {
1134
1284
  id: cfg.idGenerator(),
1135
1285
  tenantId,
@@ -1145,13 +1295,17 @@ function createCouponClient(config) {
1145
1295
  metadata: w(metadata ?? null)
1146
1296
  }
1147
1297
  });
1148
- placed = rowToCoupon(row);
1149
- break;
1150
1298
  } catch (err) {
1151
- if (attempt === maxAttempts) {
1152
- skipped++;
1153
- }
1299
+ if (!isUniqueViolation(err)) throw err;
1300
+ throw new ValidationError(
1301
+ "bulk issuance aborted: coupon code collided with a concurrent insert",
1302
+ void 0,
1303
+ { tenantId, couponCode: canonical, cause: err }
1304
+ );
1154
1305
  }
1306
+ seen.add(canonical);
1307
+ placed = rowToCoupon(row);
1308
+ break;
1155
1309
  }
1156
1310
  if (placed) {
1157
1311
  created.push(placed);
@@ -1244,9 +1398,7 @@ function createCouponClient(config) {
1244
1398
  },
1245
1399
  async get(args) {
1246
1400
  const tenantId = tid(args.tenantId);
1247
- if (!args.id || typeof args.id !== "string") {
1248
- throw new ValidationError("id is required");
1249
- }
1401
+ assertRequiredString("id", args.id, LIMITS.ID_MAX_LENGTH);
1250
1402
  const row = await delegates.coupon(prisma).findFirst({
1251
1403
  where: { id: args.id, tenantId }
1252
1404
  });
@@ -1255,6 +1407,8 @@ function createCouponClient(config) {
1255
1407
  async list(args) {
1256
1408
  const tenantId = tid(args.tenantId);
1257
1409
  const limit = assertPaginationLimit(args.limit);
1410
+ assertOptionalString("cursor", args.cursor, LIMITS.ID_MAX_LENGTH);
1411
+ assertOptionalString("campaignId", args.campaignId, LIMITS.ID_MAX_LENGTH);
1258
1412
  const where = { tenantId };
1259
1413
  if (args.campaignId) where.campaignId = args.campaignId;
1260
1414
  if (args.status) where.status = args.status;
@@ -1269,6 +1423,8 @@ function createCouponClient(config) {
1269
1423
  },
1270
1424
  async pause(args) {
1271
1425
  const tenantId = tid(args.tenantId);
1426
+ assertRequiredString("id", args.id, LIMITS.ID_MAX_LENGTH);
1427
+ assertOptionalString("actorId", args.actorId, LIMITS.USER_ID_MAX_LENGTH);
1272
1428
  const now = cfg.now();
1273
1429
  const coupon = await prisma.$transaction(async (tx) => {
1274
1430
  const row = await delegates.coupon(tx).findFirst({
@@ -1302,6 +1458,8 @@ function createCouponClient(config) {
1302
1458
  },
1303
1459
  async resume(args) {
1304
1460
  const tenantId = tid(args.tenantId);
1461
+ assertRequiredString("id", args.id, LIMITS.ID_MAX_LENGTH);
1462
+ assertOptionalString("actorId", args.actorId, LIMITS.USER_ID_MAX_LENGTH);
1305
1463
  const now = cfg.now();
1306
1464
  const coupon = await prisma.$transaction(async (tx) => {
1307
1465
  const row = await delegates.coupon(tx).findFirst({
@@ -1335,6 +1493,8 @@ function createCouponClient(config) {
1335
1493
  },
1336
1494
  async archive(args) {
1337
1495
  const tenantId = tid(args.tenantId);
1496
+ assertRequiredString("id", args.id, LIMITS.ID_MAX_LENGTH);
1497
+ assertOptionalString("actorId", args.actorId, LIMITS.USER_ID_MAX_LENGTH);
1338
1498
  const now = cfg.now();
1339
1499
  const coupon = await prisma.$transaction(async (tx) => {
1340
1500
  const row = await delegates.coupon(tx).findFirst({
@@ -1392,7 +1552,8 @@ function createCouponClient(config) {
1392
1552
  if (!elig.ok) {
1393
1553
  return { ok: false, reason: elig.reason };
1394
1554
  }
1395
- if (coupon.maxRedemptionsPerUser !== null && input.userId) {
1555
+ if (coupon.maxRedemptionsPerUser !== null) {
1556
+ assertUserIdForPerUserLimit(input.userId);
1396
1557
  const count = await delegates.couponRedemption(tx).count({
1397
1558
  where: {
1398
1559
  tenantId,
@@ -1434,7 +1595,8 @@ function createCouponClient(config) {
1434
1595
  tx,
1435
1596
  delegates.tables.coupon,
1436
1597
  couponId,
1437
- tenantId
1598
+ tenantId,
1599
+ delegates.schema
1438
1600
  );
1439
1601
  if (!Array.isArray(locked) || locked.length === 0) {
1440
1602
  throw new CouponNotFoundError("Coupon not found", { tenantId, couponId });
@@ -1476,7 +1638,8 @@ function createCouponClient(config) {
1476
1638
  if (!elig.ok) {
1477
1639
  throw eligibilityReasonToError(elig.reason);
1478
1640
  }
1479
- if (coupon.maxRedemptionsPerUser !== null && input.userId) {
1641
+ if (coupon.maxRedemptionsPerUser !== null) {
1642
+ assertUserIdForPerUserLimit(input.userId);
1480
1643
  const userCount = await delegates.couponRedemption(tx).count({
1481
1644
  where: {
1482
1645
  tenantId,
@@ -1550,6 +1713,7 @@ function createCouponClient(config) {
1550
1713
  });
1551
1714
  redemption = rowToRedemption(inserted);
1552
1715
  } catch (err) {
1716
+ if (!isUniqueViolation(err)) throw err;
1553
1717
  throw new CouponAlreadyRedeemedError(void 0, {
1554
1718
  tenantId,
1555
1719
  couponId: coupon.id,
@@ -1557,7 +1721,7 @@ function createCouponClient(config) {
1557
1721
  cause: err
1558
1722
  });
1559
1723
  }
1560
- if (coupon.maxRedemptionsPerUser !== null && input.userId) {
1724
+ if (coupon.maxRedemptionsPerUser !== null) {
1561
1725
  const userCountFinal = await delegates.couponRedemption(tx).count({
1562
1726
  where: {
1563
1727
  tenantId,
@@ -1657,20 +1821,30 @@ function createCouponClient(config) {
1657
1821
  const metadata = assertMetadata(input.metadata);
1658
1822
  const now = cfg.now();
1659
1823
  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
- });
1824
+ let row;
1825
+ try {
1826
+ row = await delegates.campaign(tx).create({
1827
+ data: {
1828
+ id: cfg.idGenerator(),
1829
+ tenantId,
1830
+ name: input.name,
1831
+ status: CAMPAIGN_STATUS.ACTIVE,
1832
+ discount: w(input.discount),
1833
+ eligibility: w(eligibility),
1834
+ startsAt: input.startsAt ?? null,
1835
+ endsAt: input.endsAt ?? null,
1836
+ maxCoupons: input.maxCoupons ?? null,
1837
+ metadata: w(metadata ?? null)
1838
+ }
1839
+ });
1840
+ } catch (err) {
1841
+ if (!isUniqueViolation(err)) throw err;
1842
+ throw new ValidationError(
1843
+ "campaign name already exists in tenant",
1844
+ void 0,
1845
+ { tenantId, cause: err }
1846
+ );
1847
+ }
1674
1848
  const campaign2 = rowToCampaign(row);
1675
1849
  await writeAudit(delegates.couponAuditLog(tx), {
1676
1850
  tenantId,
@@ -1692,7 +1866,7 @@ function createCouponClient(config) {
1692
1866
  },
1693
1867
  async get(args) {
1694
1868
  const tenantId = tid(args.tenantId);
1695
- if (!args.id) throw new ValidationError("id is required");
1869
+ assertRequiredString("id", args.id, LIMITS.ID_MAX_LENGTH);
1696
1870
  const row = await delegates.campaign(prisma).findFirst({
1697
1871
  where: { id: args.id, tenantId }
1698
1872
  });
@@ -1701,6 +1875,7 @@ function createCouponClient(config) {
1701
1875
  async list(args) {
1702
1876
  const tenantId = tid(args.tenantId);
1703
1877
  const limit = assertPaginationLimit(args.limit);
1878
+ assertOptionalString("cursor", args.cursor, LIMITS.ID_MAX_LENGTH);
1704
1879
  const where = { tenantId };
1705
1880
  if (args.status) where.status = args.status;
1706
1881
  return paginate(
@@ -1714,9 +1889,8 @@ function createCouponClient(config) {
1714
1889
  },
1715
1890
  async issue(args) {
1716
1891
  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
- }
1892
+ assertRequiredString("campaignId", args.campaignId, LIMITS.ID_MAX_LENGTH);
1893
+ assertOptionalString("actorId", args.actorId, LIMITS.USER_ID_MAX_LENGTH);
1720
1894
  if (typeof args.count !== "number" || !Number.isInteger(args.count) || args.count <= 0 || args.count > LIMITS.BULK_MAX_COUNT) {
1721
1895
  throw new ValidationError(
1722
1896
  `count must be a positive integer <= ${LIMITS.BULK_MAX_COUNT}`
@@ -1746,12 +1920,14 @@ function createCouponClient(config) {
1746
1920
  endsAt: campaign.endsAt,
1747
1921
  maxAttempts: args.maxAttempts,
1748
1922
  eligibility: campaign.eligibility,
1923
+ maxRedemptions: args.maxRedemptions,
1924
+ maxRedemptionsPerUser: args.maxRedemptionsPerUser,
1749
1925
  actorId: args.actorId
1750
1926
  });
1751
1927
  },
1752
1928
  async summary(args) {
1753
1929
  const tenantId = tid(args.tenantId);
1754
- if (!args.id) throw new ValidationError("id is required");
1930
+ assertRequiredString("id", args.id, LIMITS.ID_MAX_LENGTH);
1755
1931
  const campRow = await delegates.campaign(prisma).findFirst({
1756
1932
  where: { id: args.id, tenantId }
1757
1933
  });
@@ -1804,7 +1980,8 @@ function createCouponClient(config) {
1804
1980
  },
1805
1981
  async close(args) {
1806
1982
  const tenantId = tid(args.tenantId);
1807
- if (!args.id) throw new ValidationError("id is required");
1983
+ assertRequiredString("id", args.id, LIMITS.ID_MAX_LENGTH);
1984
+ assertOptionalString("actorId", args.actorId, LIMITS.USER_ID_MAX_LENGTH);
1808
1985
  const now = cfg.now();
1809
1986
  const campaign = await prisma.$transaction(async (tx) => {
1810
1987
  const row = await delegates.campaign(tx).findFirst({
@@ -1842,6 +2019,9 @@ function createCouponClient(config) {
1842
2019
  async listIssuances(args) {
1843
2020
  const tenantId = tid(args.tenantId);
1844
2021
  const limit = assertPaginationLimit(args.limit);
2022
+ assertOptionalString("cursor", args.cursor, LIMITS.ID_MAX_LENGTH);
2023
+ assertOptionalString("couponId", args.couponId, LIMITS.ID_MAX_LENGTH);
2024
+ assertOptionalString("issuedToUserId", args.issuedToUserId, LIMITS.USER_ID_MAX_LENGTH);
1845
2025
  const where = { tenantId };
1846
2026
  if (args.couponId) where.couponId = args.couponId;
1847
2027
  if (args.issuedToUserId) where.issuedToUserId = args.issuedToUserId;
@@ -1857,6 +2037,9 @@ function createCouponClient(config) {
1857
2037
  async listRedemptions(args) {
1858
2038
  const tenantId = tid(args.tenantId);
1859
2039
  const limit = assertPaginationLimit(args.limit);
2040
+ assertOptionalString("cursor", args.cursor, LIMITS.ID_MAX_LENGTH);
2041
+ assertOptionalString("couponId", args.couponId, LIMITS.ID_MAX_LENGTH);
2042
+ assertOptionalString("redeemedByUserId", args.redeemedByUserId, LIMITS.USER_ID_MAX_LENGTH);
1860
2043
  const where = { tenantId };
1861
2044
  if (args.couponId) where.couponId = args.couponId;
1862
2045
  if (args.redeemedByUserId) where.redeemedByUserId = args.redeemedByUserId;
@@ -1872,6 +2055,8 @@ function createCouponClient(config) {
1872
2055
  async listLogs(args) {
1873
2056
  const tenantId = tid(args.tenantId);
1874
2057
  const limit = assertPaginationLimit(args.limit);
2058
+ assertOptionalString("cursor", args.cursor, LIMITS.ID_MAX_LENGTH);
2059
+ assertOptionalString("resourceId", args.resourceId, LIMITS.ID_MAX_LENGTH);
1875
2060
  const where = { tenantId };
1876
2061
  if (args.action) where.action = args.action;
1877
2062
  if (args.resourceId) where.resourceId = args.resourceId;
@@ -1925,4 +2110,5 @@ exports.TenantMismatchError = TenantMismatchError;
1925
2110
  exports.ValidationError = ValidationError;
1926
2111
  exports.calculateDiscount = calculate;
1927
2112
  exports.createCouponClient = createCouponClient;
2113
+ exports.resolveDuration = resolveDuration;
1928
2114
  exports.validateDiscountPolicy = validateDiscountPolicy;