@withwiz/coupon 0.2.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 CHANGED
@@ -54,7 +54,12 @@ const campaign = await coupon.campaigns.create({
54
54
  endsAt: new Date("2026-04-30"),
55
55
  maxCoupons: 10_000,
56
56
  });
57
- await coupon.campaigns.issue({ campaignId: campaign.id, count: 10_000, codeLength: 10 });
57
+ await coupon.campaigns.issue({
58
+ campaignId: campaign.id,
59
+ count: 10_000,
60
+ codeLength: 10,
61
+ maxRedemptions: 1, // single-use codes; omit for unlimited
62
+ });
58
63
 
59
64
  // 2) Customer applies a code at checkout (non-destructive preview)
60
65
  const preview = await coupon.validate({
@@ -142,10 +147,38 @@ const coupon = createCouponClient({
142
147
  });
143
148
  ```
144
149
 
145
- Both maps accept plain SQL identifiers only (`[A-Za-z_][A-Za-z0-9_]*`); a
150
+ ### Tables outside `public` with a driver adapter
151
+
152
+ The row lock is raw SQL, so it does not inherit the schema from your Prisma
153
+ datasource. With a driver adapter (Prisma 7, e.g. `@prisma/adapter-pg`) an
154
+ unqualified table name resolves through the connection's `search_path`, which
155
+ is usually `public`. If the coupon tables live in another schema, name it:
156
+
157
+ ```ts
158
+ const coupon = createCouponClient({ prisma, schema: "billing" });
159
+ ```
160
+
161
+ Without it, `redeem()` cannot see the coupon row and fails with `NOT_FOUND`.
162
+
163
+ `models`, `tables` and `schema` accept plain SQL identifiers only (`[A-Za-z_][A-Za-z0-9_]*`); a
146
164
  malformed name raises `InvalidModelNameError` when the client is created, and
147
165
  a name that matches no delegate raises it on the first call that needs it.
148
166
 
167
+ ## Discount duration
168
+
169
+ A policy may declare how many billing cycles it applies to:
170
+
171
+ ```ts
172
+ discount: { kind: "PERCENT", percent: 20, duration: { kind: "REPEATING", months: 3 } }
173
+ ```
174
+
175
+ `ONCE` (the default when `duration` is absent), `REPEATING` (1 to 36 months) or
176
+ `FOREVER`. The package stores and validates it and returns it inside
177
+ `coupon.discount` and `discount.appliedPolicy`; `resolveDuration(policy)` gives the
178
+ effective value. Redemption still happens once. To discount later cycles of a
179
+ subscription, keep the policy and call `calculateDiscount({ subtotal, currency, policy })`
180
+ for each cycle on your side.
181
+
149
182
  ## Errors
150
183
 
151
184
  All errors extend `CouponError` and carry a stable string `code`.
@@ -301,6 +334,16 @@ pnpm install --frozen-lockfile
301
334
  pnpm run prisma:generate && pnpm run db:push && pnpm run test
302
335
  ```
303
336
 
337
+ `examples/consumer-prisma7` runs the same kind of checks on Prisma 7 with
338
+ `@prisma/adapter-pg`, a renamed `CouponCampaign` model and a non-`public` schema:
339
+
340
+ ```bash
341
+ cd examples/consumer-prisma7
342
+ export DATABASE_URL="postgresql://postgres:postgres@localhost:13011/coupon_test?schema=consumer_prisma7"
343
+ pnpm install --frozen-lockfile
344
+ pnpm run prisma:generate && pnpm run db:push && pnpm run test
345
+ ```
346
+
304
347
  CI provisions its own Postgres on the same port 13011, so the workflow sets
305
348
  `DATABASE_URL` itself and does not use `docker-compose.yml`.
306
349
 
package/dist/index.cjs CHANGED
@@ -71,7 +71,9 @@ var LIMITS = {
71
71
  ELIGIBILITY_MAX_ENTRIES: 1e3,
72
72
  ELIGIBILITY_ENTRY_MAX_LENGTH: 128,
73
73
  /** [L] Length cap for `context.plan / productId / categoryId`. */
74
- CONTEXT_VALUE_MAX_LENGTH: 128
74
+ CONTEXT_VALUE_MAX_LENGTH: 128,
75
+ /** Upper bound for `duration.months` on a REPEATING discount. */
76
+ DURATION_MAX_MONTHS: 36
75
77
  };
76
78
  var AUDIT_ACTION = {
77
79
  COUPON_CREATED: "coupon.created",
@@ -280,6 +282,7 @@ function validateDiscountPolicy(policy) {
280
282
  if (policy.currency !== void 0) {
281
283
  ensureCurrency(policy.currency);
282
284
  }
285
+ validateDuration(policy.duration);
283
286
  return;
284
287
  }
285
288
  if (policy.kind === "FIXED") {
@@ -297,6 +300,7 @@ function validateDiscountPolicy(policy) {
297
300
  );
298
301
  }
299
302
  }
303
+ validateDuration(policy.duration);
300
304
  return;
301
305
  }
302
306
  throw new InvalidDiscountPolicyError(
@@ -369,6 +373,27 @@ function extractPolicyCurrency(policy) {
369
373
  if (policy.kind === "PERCENT") return policy.currency;
370
374
  return void 0;
371
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
+ }
372
397
 
373
398
  // src/eligibility.ts
374
399
  function checkEligibility(input) {
@@ -586,6 +611,15 @@ function assertIdentifier(kind, key, value) {
586
611
  }
587
612
  return value;
588
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
+ }
589
623
  function resolveModelNames(overrides) {
590
624
  const out = { ...DEFAULT_MODEL_NAMES };
591
625
  for (const key of MODEL_KEYS) {
@@ -612,19 +646,21 @@ function pick(db, key, name) {
612
646
  }
613
647
  return delegate;
614
648
  }
615
- function createDelegateResolver(models, tables) {
649
+ function createDelegateResolver(models, tables, schema) {
616
650
  const resolver = {
617
651
  models,
618
- tables
652
+ tables,
653
+ schema
619
654
  };
620
655
  for (const key of MODEL_KEYS) {
621
656
  resolver[key] = (db) => pick(db, key, models[key]);
622
657
  }
623
658
  return resolver;
624
659
  }
625
- function lockCouponRow(tx, table, couponId, tenantId) {
660
+ function lockCouponRow(tx, table, couponId, tenantId, schema) {
661
+ const target = schema ? `"${schema}"."${table}"` : `"${table}"`;
626
662
  const parts = [
627
- `SELECT * FROM "${table}" WHERE "id" = `,
663
+ `SELECT * FROM ${target} WHERE "id" = `,
628
664
  ` AND "tenantId" = `,
629
665
  ` FOR UPDATE`
630
666
  ];
@@ -929,19 +965,29 @@ function validateCreateCampaignInput(input) {
929
965
  }
930
966
  }
931
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
+ ]);
932
976
  zod.z.discriminatedUnion("kind", [
933
977
  zod.z.object({
934
978
  kind: zod.z.literal("PERCENT"),
935
979
  percent: zod.z.number().gt(0).lte(100),
936
980
  maxDiscount: zod.z.number().int().nonnegative().optional(),
937
981
  minOrder: zod.z.number().int().nonnegative().optional(),
938
- currency: zod.z.string().length(3).optional()
982
+ currency: zod.z.string().length(3).optional(),
983
+ duration: discountDurationSchema.optional()
939
984
  }),
940
985
  zod.z.object({
941
986
  kind: zod.z.literal("FIXED"),
942
987
  amount: zod.z.number().int().positive(),
943
988
  currency: zod.z.string().length(3),
944
- minOrder: zod.z.number().int().nonnegative().optional()
989
+ minOrder: zod.z.number().int().nonnegative().optional(),
990
+ duration: discountDurationSchema.optional()
945
991
  })
946
992
  ]);
947
993
 
@@ -984,7 +1030,8 @@ function resolveConfig(config) {
984
1030
  // allow-now: default fallback when config.now is omitted
985
1031
  logger: config.logger ?? NOOP_LOGGER,
986
1032
  models,
987
- tables: resolveTableNames(models, config.tables)
1033
+ tables: resolveTableNames(models, config.tables),
1034
+ schema: resolveSchemaName(config.schema)
988
1035
  };
989
1036
  }
990
1037
  async function safeEmit(handler, logger, event) {
@@ -1062,7 +1109,7 @@ function withIdTieBreak(orderBy) {
1062
1109
  function createCouponClient(config) {
1063
1110
  const cfg = resolveConfig(config);
1064
1111
  const prisma = cfg.prisma;
1065
- const delegates = createDelegateResolver(cfg.models, cfg.tables);
1112
+ const delegates = createDelegateResolver(cfg.models, cfg.tables, cfg.schema);
1066
1113
  const tid = (v) => resolveTenantId(v, cfg.defaultTenantId);
1067
1114
  async function paginate(delegate, where, limit, cursor, mapFn, orderBy = { createdAt: "desc" }) {
1068
1115
  const take = limit + 1;
@@ -1548,7 +1595,8 @@ function createCouponClient(config) {
1548
1595
  tx,
1549
1596
  delegates.tables.coupon,
1550
1597
  couponId,
1551
- tenantId
1598
+ tenantId,
1599
+ delegates.schema
1552
1600
  );
1553
1601
  if (!Array.isArray(locked) || locked.length === 0) {
1554
1602
  throw new CouponNotFoundError("Coupon not found", { tenantId, couponId });
@@ -1872,6 +1920,8 @@ function createCouponClient(config) {
1872
1920
  endsAt: campaign.endsAt,
1873
1921
  maxAttempts: args.maxAttempts,
1874
1922
  eligibility: campaign.eligibility,
1923
+ maxRedemptions: args.maxRedemptions,
1924
+ maxRedemptionsPerUser: args.maxRedemptionsPerUser,
1875
1925
  actorId: args.actorId
1876
1926
  });
1877
1927
  },
@@ -2060,4 +2110,5 @@ exports.TenantMismatchError = TenantMismatchError;
2060
2110
  exports.ValidationError = ValidationError;
2061
2111
  exports.calculateDiscount = calculate;
2062
2112
  exports.createCouponClient = createCouponClient;
2113
+ exports.resolveDuration = resolveDuration;
2063
2114
  exports.validateDiscountPolicy = validateDiscountPolicy;
package/dist/index.d.cts CHANGED
@@ -84,6 +84,8 @@ declare const LIMITS: {
84
84
  readonly ELIGIBILITY_ENTRY_MAX_LENGTH: 128;
85
85
  /** [L] Length cap for `context.plan / productId / categoryId`. */
86
86
  readonly CONTEXT_VALUE_MAX_LENGTH: 128;
87
+ /** Upper bound for `duration.months` on a REPEATING discount. */
88
+ readonly DURATION_MAX_MONTHS: 36;
87
89
  };
88
90
  /**
89
91
  * Audit log action strings (stable identifiers).
@@ -107,17 +109,32 @@ type AuditAction = (typeof AUDIT_ACTION)[keyof typeof AUDIT_ACTION];
107
109
  * No Prisma generated types imported here (see contract §10 PrismaLike rule).
108
110
  */
109
111
 
112
+ /**
113
+ * How many billing cycles a discount applies to. Absent = ONCE.
114
+ * The package records it; applying it to recurring charges is the consumer's job
115
+ * (re-run `calculateDiscount` per cycle with the stored policy).
116
+ */
117
+ type DiscountDuration = {
118
+ kind: "ONCE";
119
+ } | {
120
+ kind: "REPEATING";
121
+ months: number;
122
+ } | {
123
+ kind: "FOREVER";
124
+ };
110
125
  type DiscountPolicy = {
111
126
  kind: "PERCENT";
112
127
  percent: number;
113
128
  maxDiscount?: number;
114
129
  minOrder?: number;
115
130
  currency?: string;
131
+ duration?: DiscountDuration;
116
132
  } | {
117
133
  kind: "FIXED";
118
134
  amount: number;
119
135
  currency: string;
120
136
  minOrder?: number;
137
+ duration?: DiscountDuration;
121
138
  };
122
139
  type EligibilityRules = {
123
140
  allowedUserIds?: string[];
@@ -341,6 +358,13 @@ type CampaignIssueArgs = {
341
358
  codeLength?: number;
342
359
  maxAttempts?: number;
343
360
  actorId?: string;
361
+ /**
362
+ * Redemption limits stamped on each issued code. Unset = unlimited, which
363
+ * is rarely what a batch of unique codes wants: pass `maxRedemptions: 1`
364
+ * for single-use codes.
365
+ */
366
+ maxRedemptions?: number | null;
367
+ maxRedemptionsPerUser?: number | null;
344
368
  };
345
369
  type CampaignSummary = {
346
370
  id: string;
@@ -573,6 +597,15 @@ type CouponClientConfig = {
573
597
  * @example { coupon: "coupons" }
574
598
  */
575
599
  tables?: Partial<TableNameMap>;
600
+ /**
601
+ * Postgres schema that holds the coupon tables, used to qualify the
602
+ * `SELECT ... FOR UPDATE` row lock. Needed when the tables live outside
603
+ * `public` and the client uses a driver adapter (Prisma 7), whose raw
604
+ * queries resolve unqualified names through the connection's `search_path`.
605
+ *
606
+ * @example "billing"
607
+ */
608
+ schema?: string;
576
609
  };
577
610
  interface CouponClient {
578
611
  coupons: {
@@ -654,6 +687,8 @@ declare function validateDiscountPolicy(policy: DiscountPolicy): void;
654
687
  * Throws MinimumOrderNotMetError if subtotal < policy.minOrder.
655
688
  */
656
689
  declare function calculate(input: DiscountInput): DiscountBreakdown;
690
+ /** The effective duration of a policy. Policies without one apply ONCE. */
691
+ declare function resolveDuration(policy: DiscountPolicy): DiscountDuration;
657
692
 
658
693
  /**
659
694
  * Error taxonomy for @withwiz/coupon.
@@ -768,4 +803,4 @@ declare class TenantMismatchError extends CouponError {
768
803
  constructor(message?: string, ctx?: CouponErrorContext);
769
804
  }
770
805
 
771
- export { AUDIT_ACTION, type AuditAction, type AuditLog, type BulkIssuanceResult, CAMPAIGN_STATUS, COUPON_REJECT_REASON, COUPON_STATUS, type Campaign, CampaignCapacityExceededError, CampaignClosedError, type CampaignIssueArgs, type CampaignStatus, type CampaignSummary, type Coupon, CouponAlreadyRedeemedError, CouponArchivedError, type CouponClient, type CouponClientConfig, CouponError, type CouponEvent, type CouponEventHandler, CouponExhaustedError, CouponExpiredError, CouponInactiveError, type CouponLogger, type CouponModelKey, CouponNotFoundError, type CouponRejectReason, type CouponStatus, type CreateCampaignInput, type CreateCouponInput, CurrencyMismatchError, DEFAULT_MODEL_NAMES, type DiscountBreakdown, type DiscountInput, type DiscountPolicy, type EligibilityRules, IneligibleCategoryError, IneligiblePlanError, IneligibleProductError, IneligibleUserError, InvalidDateRangeError, InvalidDiscountPolicyError, InvalidModelNameError, type Issuance, type IssueBulkInput, type IssueCouponInput, LIMITS, type ListAuditLogsArgs, type ListCampaignsArgs, type ListCouponsArgs, type ListIssuancesArgs, type ListRedemptionsArgs, MinimumOrderNotMetError, type ModelNameMap, type Paged, PerUserLimitExceededError, type PrismaLike, type PrismaTxLike, type RedeemInput, type Redemption, type RedemptionResult, type TableNameMap, TenantMismatchError, type ValidateContext, type ValidateInput, ValidationError, type ValidationResult, calculate as calculateDiscount, createCouponClient, validateDiscountPolicy };
806
+ export { AUDIT_ACTION, type AuditAction, type AuditLog, type BulkIssuanceResult, CAMPAIGN_STATUS, COUPON_REJECT_REASON, COUPON_STATUS, type Campaign, CampaignCapacityExceededError, CampaignClosedError, type CampaignIssueArgs, type CampaignStatus, type CampaignSummary, type Coupon, CouponAlreadyRedeemedError, CouponArchivedError, type CouponClient, type CouponClientConfig, CouponError, type CouponEvent, type CouponEventHandler, CouponExhaustedError, CouponExpiredError, CouponInactiveError, type CouponLogger, type CouponModelKey, CouponNotFoundError, type CouponRejectReason, type CouponStatus, type CreateCampaignInput, type CreateCouponInput, CurrencyMismatchError, DEFAULT_MODEL_NAMES, type DiscountBreakdown, type DiscountDuration, type DiscountInput, type DiscountPolicy, type EligibilityRules, IneligibleCategoryError, IneligiblePlanError, IneligibleProductError, IneligibleUserError, InvalidDateRangeError, InvalidDiscountPolicyError, InvalidModelNameError, type Issuance, type IssueBulkInput, type IssueCouponInput, LIMITS, type ListAuditLogsArgs, type ListCampaignsArgs, type ListCouponsArgs, type ListIssuancesArgs, type ListRedemptionsArgs, MinimumOrderNotMetError, type ModelNameMap, type Paged, PerUserLimitExceededError, type PrismaLike, type PrismaTxLike, type RedeemInput, type Redemption, type RedemptionResult, type TableNameMap, TenantMismatchError, type ValidateContext, type ValidateInput, ValidationError, type ValidationResult, calculate as calculateDiscount, createCouponClient, resolveDuration, validateDiscountPolicy };
package/dist/index.d.ts CHANGED
@@ -84,6 +84,8 @@ declare const LIMITS: {
84
84
  readonly ELIGIBILITY_ENTRY_MAX_LENGTH: 128;
85
85
  /** [L] Length cap for `context.plan / productId / categoryId`. */
86
86
  readonly CONTEXT_VALUE_MAX_LENGTH: 128;
87
+ /** Upper bound for `duration.months` on a REPEATING discount. */
88
+ readonly DURATION_MAX_MONTHS: 36;
87
89
  };
88
90
  /**
89
91
  * Audit log action strings (stable identifiers).
@@ -107,17 +109,32 @@ type AuditAction = (typeof AUDIT_ACTION)[keyof typeof AUDIT_ACTION];
107
109
  * No Prisma generated types imported here (see contract §10 PrismaLike rule).
108
110
  */
109
111
 
112
+ /**
113
+ * How many billing cycles a discount applies to. Absent = ONCE.
114
+ * The package records it; applying it to recurring charges is the consumer's job
115
+ * (re-run `calculateDiscount` per cycle with the stored policy).
116
+ */
117
+ type DiscountDuration = {
118
+ kind: "ONCE";
119
+ } | {
120
+ kind: "REPEATING";
121
+ months: number;
122
+ } | {
123
+ kind: "FOREVER";
124
+ };
110
125
  type DiscountPolicy = {
111
126
  kind: "PERCENT";
112
127
  percent: number;
113
128
  maxDiscount?: number;
114
129
  minOrder?: number;
115
130
  currency?: string;
131
+ duration?: DiscountDuration;
116
132
  } | {
117
133
  kind: "FIXED";
118
134
  amount: number;
119
135
  currency: string;
120
136
  minOrder?: number;
137
+ duration?: DiscountDuration;
121
138
  };
122
139
  type EligibilityRules = {
123
140
  allowedUserIds?: string[];
@@ -341,6 +358,13 @@ type CampaignIssueArgs = {
341
358
  codeLength?: number;
342
359
  maxAttempts?: number;
343
360
  actorId?: string;
361
+ /**
362
+ * Redemption limits stamped on each issued code. Unset = unlimited, which
363
+ * is rarely what a batch of unique codes wants: pass `maxRedemptions: 1`
364
+ * for single-use codes.
365
+ */
366
+ maxRedemptions?: number | null;
367
+ maxRedemptionsPerUser?: number | null;
344
368
  };
345
369
  type CampaignSummary = {
346
370
  id: string;
@@ -573,6 +597,15 @@ type CouponClientConfig = {
573
597
  * @example { coupon: "coupons" }
574
598
  */
575
599
  tables?: Partial<TableNameMap>;
600
+ /**
601
+ * Postgres schema that holds the coupon tables, used to qualify the
602
+ * `SELECT ... FOR UPDATE` row lock. Needed when the tables live outside
603
+ * `public` and the client uses a driver adapter (Prisma 7), whose raw
604
+ * queries resolve unqualified names through the connection's `search_path`.
605
+ *
606
+ * @example "billing"
607
+ */
608
+ schema?: string;
576
609
  };
577
610
  interface CouponClient {
578
611
  coupons: {
@@ -654,6 +687,8 @@ declare function validateDiscountPolicy(policy: DiscountPolicy): void;
654
687
  * Throws MinimumOrderNotMetError if subtotal < policy.minOrder.
655
688
  */
656
689
  declare function calculate(input: DiscountInput): DiscountBreakdown;
690
+ /** The effective duration of a policy. Policies without one apply ONCE. */
691
+ declare function resolveDuration(policy: DiscountPolicy): DiscountDuration;
657
692
 
658
693
  /**
659
694
  * Error taxonomy for @withwiz/coupon.
@@ -768,4 +803,4 @@ declare class TenantMismatchError extends CouponError {
768
803
  constructor(message?: string, ctx?: CouponErrorContext);
769
804
  }
770
805
 
771
- export { AUDIT_ACTION, type AuditAction, type AuditLog, type BulkIssuanceResult, CAMPAIGN_STATUS, COUPON_REJECT_REASON, COUPON_STATUS, type Campaign, CampaignCapacityExceededError, CampaignClosedError, type CampaignIssueArgs, type CampaignStatus, type CampaignSummary, type Coupon, CouponAlreadyRedeemedError, CouponArchivedError, type CouponClient, type CouponClientConfig, CouponError, type CouponEvent, type CouponEventHandler, CouponExhaustedError, CouponExpiredError, CouponInactiveError, type CouponLogger, type CouponModelKey, CouponNotFoundError, type CouponRejectReason, type CouponStatus, type CreateCampaignInput, type CreateCouponInput, CurrencyMismatchError, DEFAULT_MODEL_NAMES, type DiscountBreakdown, type DiscountInput, type DiscountPolicy, type EligibilityRules, IneligibleCategoryError, IneligiblePlanError, IneligibleProductError, IneligibleUserError, InvalidDateRangeError, InvalidDiscountPolicyError, InvalidModelNameError, type Issuance, type IssueBulkInput, type IssueCouponInput, LIMITS, type ListAuditLogsArgs, type ListCampaignsArgs, type ListCouponsArgs, type ListIssuancesArgs, type ListRedemptionsArgs, MinimumOrderNotMetError, type ModelNameMap, type Paged, PerUserLimitExceededError, type PrismaLike, type PrismaTxLike, type RedeemInput, type Redemption, type RedemptionResult, type TableNameMap, TenantMismatchError, type ValidateContext, type ValidateInput, ValidationError, type ValidationResult, calculate as calculateDiscount, createCouponClient, validateDiscountPolicy };
806
+ export { AUDIT_ACTION, type AuditAction, type AuditLog, type BulkIssuanceResult, CAMPAIGN_STATUS, COUPON_REJECT_REASON, COUPON_STATUS, type Campaign, CampaignCapacityExceededError, CampaignClosedError, type CampaignIssueArgs, type CampaignStatus, type CampaignSummary, type Coupon, CouponAlreadyRedeemedError, CouponArchivedError, type CouponClient, type CouponClientConfig, CouponError, type CouponEvent, type CouponEventHandler, CouponExhaustedError, CouponExpiredError, CouponInactiveError, type CouponLogger, type CouponModelKey, CouponNotFoundError, type CouponRejectReason, type CouponStatus, type CreateCampaignInput, type CreateCouponInput, CurrencyMismatchError, DEFAULT_MODEL_NAMES, type DiscountBreakdown, type DiscountDuration, type DiscountInput, type DiscountPolicy, type EligibilityRules, IneligibleCategoryError, IneligiblePlanError, IneligibleProductError, IneligibleUserError, InvalidDateRangeError, InvalidDiscountPolicyError, InvalidModelNameError, type Issuance, type IssueBulkInput, type IssueCouponInput, LIMITS, type ListAuditLogsArgs, type ListCampaignsArgs, type ListCouponsArgs, type ListIssuancesArgs, type ListRedemptionsArgs, MinimumOrderNotMetError, type ModelNameMap, type Paged, PerUserLimitExceededError, type PrismaLike, type PrismaTxLike, type RedeemInput, type Redemption, type RedemptionResult, type TableNameMap, TenantMismatchError, type ValidateContext, type ValidateInput, ValidationError, type ValidationResult, calculate as calculateDiscount, createCouponClient, resolveDuration, validateDiscountPolicy };
package/dist/index.mjs CHANGED
@@ -69,7 +69,9 @@ var LIMITS = {
69
69
  ELIGIBILITY_MAX_ENTRIES: 1e3,
70
70
  ELIGIBILITY_ENTRY_MAX_LENGTH: 128,
71
71
  /** [L] Length cap for `context.plan / productId / categoryId`. */
72
- CONTEXT_VALUE_MAX_LENGTH: 128
72
+ CONTEXT_VALUE_MAX_LENGTH: 128,
73
+ /** Upper bound for `duration.months` on a REPEATING discount. */
74
+ DURATION_MAX_MONTHS: 36
73
75
  };
74
76
  var AUDIT_ACTION = {
75
77
  COUPON_CREATED: "coupon.created",
@@ -278,6 +280,7 @@ function validateDiscountPolicy(policy) {
278
280
  if (policy.currency !== void 0) {
279
281
  ensureCurrency(policy.currency);
280
282
  }
283
+ validateDuration(policy.duration);
281
284
  return;
282
285
  }
283
286
  if (policy.kind === "FIXED") {
@@ -295,6 +298,7 @@ function validateDiscountPolicy(policy) {
295
298
  );
296
299
  }
297
300
  }
301
+ validateDuration(policy.duration);
298
302
  return;
299
303
  }
300
304
  throw new InvalidDiscountPolicyError(
@@ -367,6 +371,27 @@ function extractPolicyCurrency(policy) {
367
371
  if (policy.kind === "PERCENT") return policy.currency;
368
372
  return void 0;
369
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
+ }
370
395
 
371
396
  // src/eligibility.ts
372
397
  function checkEligibility(input) {
@@ -584,6 +609,15 @@ function assertIdentifier(kind, key, value) {
584
609
  }
585
610
  return value;
586
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
+ }
587
621
  function resolveModelNames(overrides) {
588
622
  const out = { ...DEFAULT_MODEL_NAMES };
589
623
  for (const key of MODEL_KEYS) {
@@ -610,19 +644,21 @@ function pick(db, key, name) {
610
644
  }
611
645
  return delegate;
612
646
  }
613
- function createDelegateResolver(models, tables) {
647
+ function createDelegateResolver(models, tables, schema) {
614
648
  const resolver = {
615
649
  models,
616
- tables
650
+ tables,
651
+ schema
617
652
  };
618
653
  for (const key of MODEL_KEYS) {
619
654
  resolver[key] = (db) => pick(db, key, models[key]);
620
655
  }
621
656
  return resolver;
622
657
  }
623
- function lockCouponRow(tx, table, couponId, tenantId) {
658
+ function lockCouponRow(tx, table, couponId, tenantId, schema) {
659
+ const target = schema ? `"${schema}"."${table}"` : `"${table}"`;
624
660
  const parts = [
625
- `SELECT * FROM "${table}" WHERE "id" = `,
661
+ `SELECT * FROM ${target} WHERE "id" = `,
626
662
  ` AND "tenantId" = `,
627
663
  ` FOR UPDATE`
628
664
  ];
@@ -927,19 +963,29 @@ function validateCreateCampaignInput(input) {
927
963
  }
928
964
  }
929
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
+ ]);
930
974
  z.discriminatedUnion("kind", [
931
975
  z.object({
932
976
  kind: z.literal("PERCENT"),
933
977
  percent: z.number().gt(0).lte(100),
934
978
  maxDiscount: z.number().int().nonnegative().optional(),
935
979
  minOrder: z.number().int().nonnegative().optional(),
936
- currency: z.string().length(3).optional()
980
+ currency: z.string().length(3).optional(),
981
+ duration: discountDurationSchema.optional()
937
982
  }),
938
983
  z.object({
939
984
  kind: z.literal("FIXED"),
940
985
  amount: z.number().int().positive(),
941
986
  currency: z.string().length(3),
942
- minOrder: z.number().int().nonnegative().optional()
987
+ minOrder: z.number().int().nonnegative().optional(),
988
+ duration: discountDurationSchema.optional()
943
989
  })
944
990
  ]);
945
991
 
@@ -982,7 +1028,8 @@ function resolveConfig(config) {
982
1028
  // allow-now: default fallback when config.now is omitted
983
1029
  logger: config.logger ?? NOOP_LOGGER,
984
1030
  models,
985
- tables: resolveTableNames(models, config.tables)
1031
+ tables: resolveTableNames(models, config.tables),
1032
+ schema: resolveSchemaName(config.schema)
986
1033
  };
987
1034
  }
988
1035
  async function safeEmit(handler, logger, event) {
@@ -1060,7 +1107,7 @@ function withIdTieBreak(orderBy) {
1060
1107
  function createCouponClient(config) {
1061
1108
  const cfg = resolveConfig(config);
1062
1109
  const prisma = cfg.prisma;
1063
- const delegates = createDelegateResolver(cfg.models, cfg.tables);
1110
+ const delegates = createDelegateResolver(cfg.models, cfg.tables, cfg.schema);
1064
1111
  const tid = (v) => resolveTenantId(v, cfg.defaultTenantId);
1065
1112
  async function paginate(delegate, where, limit, cursor, mapFn, orderBy = { createdAt: "desc" }) {
1066
1113
  const take = limit + 1;
@@ -1546,7 +1593,8 @@ function createCouponClient(config) {
1546
1593
  tx,
1547
1594
  delegates.tables.coupon,
1548
1595
  couponId,
1549
- tenantId
1596
+ tenantId,
1597
+ delegates.schema
1550
1598
  );
1551
1599
  if (!Array.isArray(locked) || locked.length === 0) {
1552
1600
  throw new CouponNotFoundError("Coupon not found", { tenantId, couponId });
@@ -1870,6 +1918,8 @@ function createCouponClient(config) {
1870
1918
  endsAt: campaign.endsAt,
1871
1919
  maxAttempts: args.maxAttempts,
1872
1920
  eligibility: campaign.eligibility,
1921
+ maxRedemptions: args.maxRedemptions,
1922
+ maxRedemptionsPerUser: args.maxRedemptionsPerUser,
1873
1923
  actorId: args.actorId
1874
1924
  });
1875
1925
  },
@@ -2029,4 +2079,4 @@ function createCouponClient(config) {
2029
2079
  };
2030
2080
  }
2031
2081
 
2032
- 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.2.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",