@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 +45 -2
- package/dist/index.cjs +61 -10
- package/dist/index.d.cts +36 -1
- package/dist/index.d.ts +36 -1
- package/dist/index.mjs +61 -11
- package/package.json +3 -3
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({
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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",
|