@krizaka/coupons 0.0.0-stage → 0.1.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/LICENSE +201 -0
- package/README.md +63 -2
- package/dist/chunk-ECCGFGSV.js +940 -0
- package/dist/chunk-ECCGFGSV.js.map +1 -0
- package/dist/chunk-JMQO6PTK.js +228 -0
- package/dist/chunk-JMQO6PTK.js.map +1 -0
- package/dist/coupons-BF-3HFdw.d.ts +172 -0
- package/dist/index.d.ts +135 -0
- package/dist/index.js +63 -0
- package/dist/index.js.map +1 -0
- package/dist/rules-CCd1HhOA.d.ts +408 -0
- package/dist/settings.d.ts +34 -0
- package/dist/settings.js +52 -0
- package/dist/settings.js.map +1 -0
- package/dist/testkit.d.ts +60 -0
- package/dist/testkit.js +427 -0
- package/dist/testkit.js.map +1 -0
- package/package.json +88 -3
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import { Query } from '@krizaka/db';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The ports of « coupons » (ADR-002): discount and credit coupons that the admin manages, with the product's database as the source
|
|
5
|
+
* of truth. Generic: no product import, no vendor, no database client (a `Query` comes in), no wall clock (a `Clock` comes in), no
|
|
6
|
+
* network. The provider is `@krizaka/payments`' port (this module hands it immutable DiscountTerms); credits are
|
|
7
|
+
* `@krizaka/credits`' ledger; the audit trail, the outbox and the limiter are structural ports, so this package depends on none of them.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
declare const KIND: {
|
|
11
|
+
readonly discount: "discount";
|
|
12
|
+
readonly credit: "credit";
|
|
13
|
+
};
|
|
14
|
+
type Kind = (typeof KIND)[keyof typeof KIND];
|
|
15
|
+
/** Why the coupon exists: a person who sells for us, a public promotion, a demo, a support gesture. */
|
|
16
|
+
declare const PURPOSE: {
|
|
17
|
+
readonly partner: "partner";
|
|
18
|
+
readonly promo: "promo";
|
|
19
|
+
readonly demo: "demo";
|
|
20
|
+
readonly support: "support";
|
|
21
|
+
};
|
|
22
|
+
type Purpose = (typeof PURPOSE)[keyof typeof PURPOSE];
|
|
23
|
+
/** Stored states. « Not started », « expired » and « exhausted » are COMPUTED from the dates and the uses, so no cron is needed. */
|
|
24
|
+
declare const COUPON_STATUS: {
|
|
25
|
+
readonly active: "active";
|
|
26
|
+
readonly paused: "paused";
|
|
27
|
+
readonly archived: "archived";
|
|
28
|
+
};
|
|
29
|
+
type CouponStatus = (typeof COUPON_STATUS)[keyof typeof COUPON_STATUS];
|
|
30
|
+
declare const REDEMPTION_STATUS: {
|
|
31
|
+
readonly reserved: "reserved";
|
|
32
|
+
readonly confirmed: "confirmed";
|
|
33
|
+
readonly released: "released";
|
|
34
|
+
readonly revoked: "revoked";
|
|
35
|
+
};
|
|
36
|
+
type RedemptionStatus = (typeof REDEMPTION_STATUS)[keyof typeof REDEMPTION_STATUS];
|
|
37
|
+
declare const DURATION: {
|
|
38
|
+
readonly once: "once";
|
|
39
|
+
readonly repeating: "repeating";
|
|
40
|
+
readonly forever: "forever";
|
|
41
|
+
};
|
|
42
|
+
type Duration = (typeof DURATION)[keyof typeof DURATION];
|
|
43
|
+
declare const ON_EXHAUSTED: {
|
|
44
|
+
readonly stop: "stop";
|
|
45
|
+
readonly fallbackPaid: "fallback_paid";
|
|
46
|
+
};
|
|
47
|
+
type OnExhausted = (typeof ON_EXHAUSTED)[keyof typeof ON_EXHAUSTED];
|
|
48
|
+
/** The terms a provider adapter needs to apply a discount: vendor-free, IMMUTABLE per `(couponId, termsVersion)` (`@krizaka/payments`' `DiscountTerms`). */
|
|
49
|
+
interface DiscountTerms {
|
|
50
|
+
couponId: string;
|
|
51
|
+
termsVersion: number;
|
|
52
|
+
percentOffBp?: number;
|
|
53
|
+
amountOffMinor?: number;
|
|
54
|
+
currency?: string;
|
|
55
|
+
duration: Duration;
|
|
56
|
+
durationMonths?: number;
|
|
57
|
+
}
|
|
58
|
+
interface Coupon {
|
|
59
|
+
id: string;
|
|
60
|
+
/** As the admin typed it. */
|
|
61
|
+
code: string;
|
|
62
|
+
/** Normalised (upper case, no spaces or hyphens): the lookup key, unique in the product. */
|
|
63
|
+
codeKey: string;
|
|
64
|
+
kind: Kind;
|
|
65
|
+
purpose: Purpose;
|
|
66
|
+
partnerId: string | null;
|
|
67
|
+
status: CouponStatus;
|
|
68
|
+
startsAt: Date;
|
|
69
|
+
endsAt: Date | null;
|
|
70
|
+
maxRedemptions: number | null;
|
|
71
|
+
maxPerAccount: number;
|
|
72
|
+
newCustomersOnly: boolean;
|
|
73
|
+
/** Plan kinds of the product; empty = all. */
|
|
74
|
+
plans: string[];
|
|
75
|
+
percentOffBp: number | null;
|
|
76
|
+
amountOffMinor: number | null;
|
|
77
|
+
currency: string | null;
|
|
78
|
+
duration: Duration | null;
|
|
79
|
+
durationMonths: number | null;
|
|
80
|
+
minimumMinor: number | null;
|
|
81
|
+
credits: number | null;
|
|
82
|
+
capPerDay: number | null;
|
|
83
|
+
capPerMonth: number | null;
|
|
84
|
+
creditsValidDays: number | null;
|
|
85
|
+
onExhausted: OnExhausted | null;
|
|
86
|
+
termsVersion: number;
|
|
87
|
+
createdAt: Date;
|
|
88
|
+
createdBy: string | null;
|
|
89
|
+
updatedAt: Date;
|
|
90
|
+
}
|
|
91
|
+
interface Partner {
|
|
92
|
+
id: string;
|
|
93
|
+
/** Null once erased (Loi 25). */
|
|
94
|
+
displayName: string | null;
|
|
95
|
+
contactEmail: string | null;
|
|
96
|
+
status: "active" | "archived";
|
|
97
|
+
createdAt: Date;
|
|
98
|
+
createdBy: string | null;
|
|
99
|
+
erasedAt: Date | null;
|
|
100
|
+
}
|
|
101
|
+
interface Redemption {
|
|
102
|
+
id: string;
|
|
103
|
+
couponId: string;
|
|
104
|
+
/** Null once the account was deleted: the use still counts and the revenue stays. */
|
|
105
|
+
accountId: string | null;
|
|
106
|
+
/** Copied at redemption: attribution never moves. */
|
|
107
|
+
partnerId: string | null;
|
|
108
|
+
status: RedemptionStatus;
|
|
109
|
+
idempotencyKey: string;
|
|
110
|
+
reservedUntil: Date | null;
|
|
111
|
+
checkoutRef: string | null;
|
|
112
|
+
paymentRef: string | null;
|
|
113
|
+
amountPaidMinor: number | null;
|
|
114
|
+
amountDiscountMinor: number | null;
|
|
115
|
+
/** CUMULATIVE, partial or full. */
|
|
116
|
+
amountRefundedMinor: number;
|
|
117
|
+
/** A full refund gave the use back (the setting). */
|
|
118
|
+
useRestored: boolean;
|
|
119
|
+
currency: string | null;
|
|
120
|
+
creditGrantId: string | null;
|
|
121
|
+
/** A late payment honoured past a limit. */
|
|
122
|
+
overLimit: boolean;
|
|
123
|
+
createdAt: Date;
|
|
124
|
+
confirmedAt: Date | null;
|
|
125
|
+
endedAt: Date | null;
|
|
126
|
+
}
|
|
127
|
+
/** What a guarded update may change. */
|
|
128
|
+
type RedemptionPatch = Partial<Pick<Redemption, "status" | "reservedUntil" | "checkoutRef" | "paymentRef" | "amountPaidMinor" | "amountDiscountMinor" | "amountRefundedMinor" | "useRestored" | "currency" | "creditGrantId" | "overLimit" | "confirmedAt" | "endedAt">>;
|
|
129
|
+
/** What a coupon's terms and rules may be changed to (the admin's update). */
|
|
130
|
+
type CouponPatch = Partial<Omit<Coupon, "id" | "codeKey" | "createdAt" | "createdBy" | "updatedAt" | "kind">>;
|
|
131
|
+
interface Usage {
|
|
132
|
+
/** Uses that count overall. */
|
|
133
|
+
total: number;
|
|
134
|
+
/** Uses that count for the account. */
|
|
135
|
+
account: number;
|
|
136
|
+
}
|
|
137
|
+
interface CouponFilter {
|
|
138
|
+
status?: CouponStatus;
|
|
139
|
+
kind?: Kind;
|
|
140
|
+
purpose?: Purpose;
|
|
141
|
+
partnerId?: string;
|
|
142
|
+
limit: number;
|
|
143
|
+
offset: number;
|
|
144
|
+
}
|
|
145
|
+
interface RedemptionFilter {
|
|
146
|
+
couponId?: string;
|
|
147
|
+
partnerId?: string;
|
|
148
|
+
status?: RedemptionStatus;
|
|
149
|
+
from?: Date;
|
|
150
|
+
to?: Date;
|
|
151
|
+
limit: number;
|
|
152
|
+
offset: number;
|
|
153
|
+
}
|
|
154
|
+
/** One row of the partner view: a coupon's uses in a period, in one currency. Never a customer's identity. */
|
|
155
|
+
interface PartnerRow {
|
|
156
|
+
couponId: string;
|
|
157
|
+
code: string;
|
|
158
|
+
currency: string | null;
|
|
159
|
+
redemptions: number;
|
|
160
|
+
discountMinor: number;
|
|
161
|
+
/** Paid minus refunded. */
|
|
162
|
+
revenueMinor: number;
|
|
163
|
+
creditsGranted: number;
|
|
164
|
+
}
|
|
165
|
+
/** Where coupons live. Every adapter runs `couponsContract` (`@krizaka/coupons/testkit`). Times come from the caller. */
|
|
166
|
+
interface CouponStore {
|
|
167
|
+
insertCoupon(coupon: Coupon): Promise<void>;
|
|
168
|
+
/** `forUpdate` locks the row until the transaction ends: the last use is taken by one request at a time. */
|
|
169
|
+
couponById(id: string, forUpdate?: boolean): Promise<Coupon | null>;
|
|
170
|
+
couponByKey(codeKey: string, forUpdate?: boolean): Promise<Coupon | null>;
|
|
171
|
+
updateCoupon(id: string, patch: CouponPatch, at: Date): Promise<Coupon | null>;
|
|
172
|
+
listCoupons(filter: CouponFilter): Promise<Coupon[]>;
|
|
173
|
+
/** Whether any redemption (even released) exists: the terms are locked once there is one. */
|
|
174
|
+
hasRedemptions(couponId: string): Promise<boolean>;
|
|
175
|
+
insertPartner(partner: Partner): Promise<void>;
|
|
176
|
+
partnerById(id: string): Promise<Partner | null>;
|
|
177
|
+
updatePartner(id: string, patch: Partial<Pick<Partner, "displayName" | "contactEmail" | "status" | "erasedAt">>): Promise<Partner | null>;
|
|
178
|
+
listPartners(options: {
|
|
179
|
+
includeArchived: boolean;
|
|
180
|
+
limit: number;
|
|
181
|
+
offset: number;
|
|
182
|
+
}): Promise<Partner[]>;
|
|
183
|
+
insertRedemption(redemption: Redemption): Promise<void>;
|
|
184
|
+
redemptionById(id: string): Promise<Redemption | null>;
|
|
185
|
+
redemptionByKey(idempotencyKey: string): Promise<Redemption | null>;
|
|
186
|
+
redemptionByCheckoutRef(checkoutRef: string): Promise<Redemption | null>;
|
|
187
|
+
redemptionByPaymentRef(paymentRef: string): Promise<Redemption | null>;
|
|
188
|
+
/** The guarded transition: applies the patch only if the redemption is in one of `from`. Answers the new row, or null when the guard failed. */
|
|
189
|
+
updateRedemption(id: string, from: readonly RedemptionStatus[] | null, patch: RedemptionPatch): Promise<Redemption | null>;
|
|
190
|
+
/** The uses that count (see `countsAsUse`), overall and for the account, at `now`. */
|
|
191
|
+
usage(couponId: string, accountId: string | null, now: Date): Promise<Usage>;
|
|
192
|
+
listRedemptions(filter: RedemptionFilter): Promise<Redemption[]>;
|
|
193
|
+
/** Releases the reservations past `reserved_until` and answers them. */
|
|
194
|
+
releaseExpired(now: Date, limit: number): Promise<Redemption[]>;
|
|
195
|
+
partnerReport(partnerId: string, period: {
|
|
196
|
+
from?: Date;
|
|
197
|
+
to?: Date;
|
|
198
|
+
}, now: Date): Promise<PartnerRow[]>;
|
|
199
|
+
/** Active coupons with a fixed amount (for `checkPrices`). */
|
|
200
|
+
activeAmountCoupons(): Promise<Coupon[]>;
|
|
201
|
+
}
|
|
202
|
+
/** The knobs of the module, read from the admin's settings (`@krizaka/coupons/settings`). */
|
|
203
|
+
interface CouponSettings {
|
|
204
|
+
/** Shows the field and accepts codes. */
|
|
205
|
+
enabled: boolean;
|
|
206
|
+
/** How long a checkout holds a use (40 to 1440 minutes: the provider's session window). */
|
|
207
|
+
reservationMinutes: number;
|
|
208
|
+
attemptsPerAccountPerHour: number;
|
|
209
|
+
attemptsPerIpPerHour: number;
|
|
210
|
+
minCodeLength: number;
|
|
211
|
+
generatedCodeLength: number;
|
|
212
|
+
/** The highest percent a discount may have (1 to 99). */
|
|
213
|
+
maxPercentOff: number;
|
|
214
|
+
/** A full refund gives the use back. */
|
|
215
|
+
refundRestoresUse: boolean;
|
|
216
|
+
}
|
|
217
|
+
/** Who acts (for the audit trail). */
|
|
218
|
+
interface Actor {
|
|
219
|
+
id: string | null;
|
|
220
|
+
label: string;
|
|
221
|
+
}
|
|
222
|
+
/** The trail (`@krizaka/audit`'s `Audit`), structurally. */
|
|
223
|
+
interface AuditPort {
|
|
224
|
+
record(query: Query, entry: {
|
|
225
|
+
actorId: string | null;
|
|
226
|
+
actorLabel: string;
|
|
227
|
+
action: string;
|
|
228
|
+
target?: {
|
|
229
|
+
type: string;
|
|
230
|
+
id: string;
|
|
231
|
+
} | null;
|
|
232
|
+
detail?: Record<string, unknown>;
|
|
233
|
+
}): Promise<void>;
|
|
234
|
+
}
|
|
235
|
+
/** The outbox (`@krizaka/outbox`'s `Outbox`), structurally. */
|
|
236
|
+
interface OutboxPort {
|
|
237
|
+
append(tx: Query, message: {
|
|
238
|
+
topic: string;
|
|
239
|
+
type: string;
|
|
240
|
+
payload: unknown;
|
|
241
|
+
id?: string;
|
|
242
|
+
}): Promise<{
|
|
243
|
+
id: string;
|
|
244
|
+
appended: boolean;
|
|
245
|
+
}>;
|
|
246
|
+
}
|
|
247
|
+
/** The limiter (`@krizaka/rate-limit`'s `Limiter`), structurally. */
|
|
248
|
+
interface RateLimitPort {
|
|
249
|
+
check(action: string, keys: {
|
|
250
|
+
ip?: string | null | undefined;
|
|
251
|
+
subject?: string | null | undefined;
|
|
252
|
+
}): Promise<{
|
|
253
|
+
ok: true;
|
|
254
|
+
} | {
|
|
255
|
+
ok: false;
|
|
256
|
+
retryAfterSeconds: number;
|
|
257
|
+
}>;
|
|
258
|
+
}
|
|
259
|
+
/** The credits ledger (`@krizaka/credits`' `Credits`), structurally. */
|
|
260
|
+
interface CreditsPort {
|
|
261
|
+
grant(tx: Query, input: {
|
|
262
|
+
accountId: string;
|
|
263
|
+
amount: number;
|
|
264
|
+
expiresAt: Date;
|
|
265
|
+
capPerDay?: number | null;
|
|
266
|
+
capPerMonth?: number | null;
|
|
267
|
+
onExhausted: OnExhausted;
|
|
268
|
+
source: string;
|
|
269
|
+
idempotencyKey: string;
|
|
270
|
+
}): Promise<{
|
|
271
|
+
grantId: string;
|
|
272
|
+
replayed: boolean;
|
|
273
|
+
}>;
|
|
274
|
+
expire(tx: Query, grantId: string): Promise<{
|
|
275
|
+
expired: number;
|
|
276
|
+
}>;
|
|
277
|
+
}
|
|
278
|
+
/** The refusals of validation and redemption (typed, `as const`). */
|
|
279
|
+
declare const COUPON_CODE: {
|
|
280
|
+
readonly unknown: "COUPON_UNKNOWN";
|
|
281
|
+
readonly notStarted: "COUPON_NOT_STARTED";
|
|
282
|
+
readonly expired: "COUPON_EXPIRED";
|
|
283
|
+
readonly paused: "COUPON_PAUSED";
|
|
284
|
+
readonly exhausted: "COUPON_EXHAUSTED";
|
|
285
|
+
readonly accountLimit: "COUPON_ACCOUNT_LIMIT";
|
|
286
|
+
readonly newCustomersOnly: "COUPON_NEW_CUSTOMERS_ONLY";
|
|
287
|
+
readonly notForPlan: "COUPON_NOT_FOR_PLAN";
|
|
288
|
+
readonly minimum: "COUPON_MINIMUM";
|
|
289
|
+
readonly currency: "COUPON_CURRENCY";
|
|
290
|
+
readonly tooLarge: "COUPON_TOO_LARGE";
|
|
291
|
+
readonly kind: "COUPON_KIND";
|
|
292
|
+
readonly provider: "COUPON_PROVIDER";
|
|
293
|
+
readonly rateLimited: "COUPON_RATE_LIMITED";
|
|
294
|
+
readonly disabled: "COUPON_DISABLED";
|
|
295
|
+
};
|
|
296
|
+
type CouponRefusal = (typeof COUPON_CODE)[keyof typeof COUPON_CODE];
|
|
297
|
+
/** The errors of the admin and of the flows (not refusals of a customer's code). */
|
|
298
|
+
declare const COUPON_ERROR: {
|
|
299
|
+
readonly badInput: "COUPON_BAD_INPUT";
|
|
300
|
+
readonly codeTaken: "COUPON_CODE_TAKEN";
|
|
301
|
+
readonly termsLocked: "COUPON_TERMS_LOCKED";
|
|
302
|
+
readonly notFound: "COUPON_NOT_FOUND";
|
|
303
|
+
readonly redemptionNotFound: "COUPON_REDEMPTION_NOT_FOUND";
|
|
304
|
+
readonly partnerNotFound: "COUPON_PARTNER_NOT_FOUND";
|
|
305
|
+
readonly keyReused: "COUPON_KEY_REUSED";
|
|
306
|
+
readonly notRevocable: "COUPON_NOT_REVOCABLE";
|
|
307
|
+
readonly badSettings: "COUPON_BAD_SETTINGS";
|
|
308
|
+
};
|
|
309
|
+
type CouponErrorCode = (typeof COUPON_ERROR)[keyof typeof COUPON_ERROR];
|
|
310
|
+
/** The HTTP status a route may answer for each refusal or error. */
|
|
311
|
+
declare const COUPON_STATUS_CODE: Record<CouponRefusal | CouponErrorCode, number>;
|
|
312
|
+
/** A refusal thrown by a flow (`apply`, `redeemCredit`): a stable `code`, the HTTP `status`, and `retryAfterSeconds` for a rate limit. */
|
|
313
|
+
declare class CouponError extends Error {
|
|
314
|
+
readonly code: CouponRefusal | CouponErrorCode;
|
|
315
|
+
readonly retryAfterSeconds?: number | undefined;
|
|
316
|
+
readonly status: number;
|
|
317
|
+
constructor(code: CouponRefusal | CouponErrorCode, message?: string, retryAfterSeconds?: number | undefined);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Defaults and bounds of the settings (the admin changes them without a redeploy). `maxPercentOff`'s default is Oussama's call (krizaka-ts#61). */
|
|
321
|
+
declare const COUPON_DEFAULTS: CouponSettings;
|
|
322
|
+
/** The action name of the limiter for attempts with a code: each `validate`, `apply` and `redeemCredit` counts one hit. */
|
|
323
|
+
declare const COUPON_ATTEMPT_ACTION = "couponAttempt";
|
|
324
|
+
/** [min, max] of each numeric setting. */
|
|
325
|
+
declare const COUPON_BOUNDS: {
|
|
326
|
+
readonly reservationMinutes: readonly [40, 1440];
|
|
327
|
+
readonly attemptsPerAccountPerHour: readonly [1, 100];
|
|
328
|
+
readonly attemptsPerIpPerHour: readonly [1, 500];
|
|
329
|
+
readonly minCodeLength: readonly [4, 40];
|
|
330
|
+
readonly generatedCodeLength: readonly [8, 20];
|
|
331
|
+
readonly maxPercentOff: readonly [1, 99];
|
|
332
|
+
};
|
|
333
|
+
/**
|
|
334
|
+
* The lookup key of a code: upper case, without spaces, hyphens or underscores, so « summer-2026 », « SUMMER 2026 » and « Summer2026 »
|
|
335
|
+
* are one code. A code is then letters and digits only.
|
|
336
|
+
* @returns the key, or null when what is left is empty, too long or not letters and digits
|
|
337
|
+
*/
|
|
338
|
+
declare function normalizeCode(input: string): string | null;
|
|
339
|
+
/** A random code of `length` characters from an alphabet without I, L, O, U (about 5 bits each). */
|
|
340
|
+
declare function generateCode(length: number, pick?: (max: number) => number): string;
|
|
341
|
+
/** Whether a redemption consumes a use at `now`: confirmed or revoked and not restored, or reserved and not yet past its deadline. */
|
|
342
|
+
declare function countsAsUse(r: Pick<Redemption, "status" | "useRestored" | "reservedUntil">, now: Date): boolean;
|
|
343
|
+
/** What the person typed and where they are: a checkout (discount codes) or a place that redeems credits (credit codes). */
|
|
344
|
+
interface CheckoutContext {
|
|
345
|
+
mode: "checkout" | "redeem";
|
|
346
|
+
accountId: string;
|
|
347
|
+
/** The plan kind being bought. Checkout only. */
|
|
348
|
+
plan?: string;
|
|
349
|
+
/** The price before the discount, in minor units. Checkout only. */
|
|
350
|
+
amountMinor?: number;
|
|
351
|
+
currency?: string;
|
|
352
|
+
/** Whether the account has paid before (from `payments`). */
|
|
353
|
+
hasPaidBefore?: boolean;
|
|
354
|
+
/** False when the product's provider cannot apply discounts (CCBill, Segpay). Default true. */
|
|
355
|
+
providerSupportsDiscounts?: boolean;
|
|
356
|
+
ip?: string | null;
|
|
357
|
+
}
|
|
358
|
+
interface DiscountEffect {
|
|
359
|
+
kind: typeof KIND.discount;
|
|
360
|
+
percentOffBp: number | null;
|
|
361
|
+
amountOffMinor: number | null;
|
|
362
|
+
currency: string | null;
|
|
363
|
+
duration: NonNullable<Coupon["duration"]>;
|
|
364
|
+
durationMonths: number | null;
|
|
365
|
+
/** What the first payment loses, in minor units. */
|
|
366
|
+
discountMinor: number;
|
|
367
|
+
payableMinor: number;
|
|
368
|
+
}
|
|
369
|
+
interface CreditEffect {
|
|
370
|
+
kind: typeof KIND.credit;
|
|
371
|
+
credits: number;
|
|
372
|
+
capPerDay: number | null;
|
|
373
|
+
capPerMonth: number | null;
|
|
374
|
+
validDays: number;
|
|
375
|
+
onExhausted: NonNullable<Coupon["onExhausted"]>;
|
|
376
|
+
}
|
|
377
|
+
/** What a code gives, in numbers. Informational for a discount: the provider computes the real amount and its taxes. */
|
|
378
|
+
declare function effectOf(coupon: Coupon, amountMinor: number): DiscountEffect | CreditEffect;
|
|
379
|
+
/** The immutable terms a provider adapter turns into its object. */
|
|
380
|
+
declare function termsOf(coupon: Coupon): DiscountTerms;
|
|
381
|
+
/** The first rule a code breaks for a context at `now`, or none. The order is the order of the refusals' table. */
|
|
382
|
+
declare function refusalOf(coupon: Coupon, ctx: CheckoutContext, usage: Usage, now: Date, settings: CouponSettings): CouponRefusal | null;
|
|
383
|
+
/** What the admin gives to create a coupon (the kind decides which terms are required). */
|
|
384
|
+
interface NewCoupon {
|
|
385
|
+
code: string;
|
|
386
|
+
kind: Coupon["kind"];
|
|
387
|
+
purpose: Coupon["purpose"];
|
|
388
|
+
partnerId?: string | null;
|
|
389
|
+
startsAt: Date;
|
|
390
|
+
endsAt?: Date | null;
|
|
391
|
+
maxRedemptions?: number | null;
|
|
392
|
+
maxPerAccount?: number;
|
|
393
|
+
newCustomersOnly?: boolean;
|
|
394
|
+
plans?: string[];
|
|
395
|
+
percentOffBp?: number | null;
|
|
396
|
+
amountOffMinor?: number | null;
|
|
397
|
+
currency?: string | null;
|
|
398
|
+
duration?: Coupon["duration"];
|
|
399
|
+
durationMonths?: number | null;
|
|
400
|
+
minimumMinor?: number | null;
|
|
401
|
+
credits?: number | null;
|
|
402
|
+
capPerDay?: number | null;
|
|
403
|
+
capPerMonth?: number | null;
|
|
404
|
+
creditsValidDays?: number | null;
|
|
405
|
+
onExhausted?: Coupon["onExhausted"];
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export { type Actor as A, type PartnerRow as B, type CouponStore as C, DURATION as D, type Purpose as E, type RateLimitPort as F, type Redemption as G, type RedemptionFilter as H, type RedemptionPatch as I, type RedemptionStatus as J, KIND as K, countsAsUse as L, effectOf as M, type NewCoupon as N, ON_EXHAUSTED as O, PURPOSE as P, generateCode as Q, REDEMPTION_STATUS as R, normalizeCode as S, refusalOf as T, type Usage as U, termsOf as V, type AuditPort as a, COUPON_ATTEMPT_ACTION as b, COUPON_BOUNDS as c, COUPON_CODE as d, COUPON_DEFAULTS as e, COUPON_ERROR as f, COUPON_STATUS as g, COUPON_STATUS_CODE as h, type CheckoutContext as i, type Coupon as j, CouponError as k, type CouponErrorCode as l, type CouponFilter as m, type CouponPatch as n, type CouponRefusal as o, type CouponSettings as p, type CouponStatus as q, type CreditEffect as r, type CreditsPort as s, type DiscountEffect as t, type DiscountTerms as u, type Duration as v, type Kind as w, type OnExhausted as x, type OutboxPort as y, type Partner as z };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import * as _krizaka_settings from '@krizaka/settings';
|
|
2
|
+
import { p as CouponSettings } from './rules-CCd1HhOA.js';
|
|
3
|
+
export { b as COUPON_ATTEMPT_ACTION } from './rules-CCd1HhOA.js';
|
|
4
|
+
import '@krizaka/db';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The settings of « coupons », a fragment the product adds to its registry (`@krizaka/coupons/settings`): the admin changes them
|
|
8
|
+
* without a redeploy, every change audited by the settings service. Global scope only. The zone of the credit caps is
|
|
9
|
+
* `credits.timeZone` (`@krizaka/credits/settings`).
|
|
10
|
+
*/
|
|
11
|
+
declare const couponSettings: {
|
|
12
|
+
readonly "coupons.enabled": _krizaka_settings.Def<boolean>;
|
|
13
|
+
readonly "coupons.reservationMinutes": _krizaka_settings.Def<number>;
|
|
14
|
+
readonly "coupons.attemptsPerAccountPerHour": _krizaka_settings.Def<number>;
|
|
15
|
+
readonly "coupons.attemptsPerIpPerHour": _krizaka_settings.Def<number>;
|
|
16
|
+
readonly "coupons.minCodeLength": _krizaka_settings.Def<number>;
|
|
17
|
+
readonly "coupons.generatedCodeLength": _krizaka_settings.Def<number>;
|
|
18
|
+
readonly "coupons.maxPercentOff": _krizaka_settings.Def<number>;
|
|
19
|
+
readonly "coupons.refundRestoresUse": _krizaka_settings.Def<boolean>;
|
|
20
|
+
};
|
|
21
|
+
type Reader = (key: keyof typeof couponSettings) => boolean | number | undefined | Promise<boolean | number | undefined>;
|
|
22
|
+
/**
|
|
23
|
+
* The module's settings as `createCoupons` takes them, from the product's accessor (`(key) => service.get(key, ctx)`).
|
|
24
|
+
* A key the accessor does not know falls back to the default.
|
|
25
|
+
*/
|
|
26
|
+
declare function readCouponSettings(read: Reader): Promise<CouponSettings>;
|
|
27
|
+
|
|
28
|
+
declare const couponAttemptLimit: (s: Pick<CouponSettings, "attemptsPerAccountPerHour" | "attemptsPerIpPerHour">) => {
|
|
29
|
+
perIp: number;
|
|
30
|
+
perSubject: number;
|
|
31
|
+
windowSeconds: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export { couponAttemptLimit, couponSettings, readCouponSettings };
|
package/dist/settings.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import {
|
|
2
|
+
COUPON_ATTEMPT_ACTION,
|
|
3
|
+
COUPON_BOUNDS,
|
|
4
|
+
COUPON_DEFAULTS
|
|
5
|
+
} from "./chunk-JMQO6PTK.js";
|
|
6
|
+
|
|
7
|
+
// src/settings.ts
|
|
8
|
+
import { flag, integer } from "@krizaka/settings";
|
|
9
|
+
var int = (key, unit) => integer("coupons", COUPON_DEFAULTS[key], { min: COUPON_BOUNDS[key][0], max: COUPON_BOUNDS[key][1], unit });
|
|
10
|
+
var couponSettings = {
|
|
11
|
+
"coupons.enabled": flag("coupons", COUPON_DEFAULTS.enabled),
|
|
12
|
+
"coupons.reservationMinutes": int("reservationMinutes", "minutes"),
|
|
13
|
+
"coupons.attemptsPerAccountPerHour": int("attemptsPerAccountPerHour", "attempts"),
|
|
14
|
+
"coupons.attemptsPerIpPerHour": int("attemptsPerIpPerHour", "attempts"),
|
|
15
|
+
"coupons.minCodeLength": int("minCodeLength", "characters"),
|
|
16
|
+
"coupons.generatedCodeLength": int("generatedCodeLength", "characters"),
|
|
17
|
+
"coupons.maxPercentOff": int("maxPercentOff", "percent"),
|
|
18
|
+
"coupons.refundRestoresUse": flag("coupons", COUPON_DEFAULTS.refundRestoresUse)
|
|
19
|
+
};
|
|
20
|
+
async function readCouponSettings(read) {
|
|
21
|
+
const number = async (key) => {
|
|
22
|
+
const value = await read(`coupons.${key}`);
|
|
23
|
+
return typeof value === "number" ? value : COUPON_DEFAULTS[key];
|
|
24
|
+
};
|
|
25
|
+
const bool = async (key) => {
|
|
26
|
+
const value = await read(`coupons.${key}`);
|
|
27
|
+
return typeof value === "boolean" ? value : COUPON_DEFAULTS[key];
|
|
28
|
+
};
|
|
29
|
+
return {
|
|
30
|
+
enabled: await bool("enabled"),
|
|
31
|
+
reservationMinutes: await number("reservationMinutes"),
|
|
32
|
+
attemptsPerAccountPerHour: await number("attemptsPerAccountPerHour"),
|
|
33
|
+
attemptsPerIpPerHour: await number("attemptsPerIpPerHour"),
|
|
34
|
+
minCodeLength: await number("minCodeLength"),
|
|
35
|
+
generatedCodeLength: await number("generatedCodeLength"),
|
|
36
|
+
maxPercentOff: await number("maxPercentOff"),
|
|
37
|
+
refundRestoresUse: await bool("refundRestoresUse")
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
var couponAttemptLimit = (s) => ({
|
|
41
|
+
perIp: s.attemptsPerIpPerHour,
|
|
42
|
+
perSubject: s.attemptsPerAccountPerHour,
|
|
43
|
+
// @fixed: the settings are « per hour », so the window is an hour
|
|
44
|
+
windowSeconds: 3600
|
|
45
|
+
});
|
|
46
|
+
export {
|
|
47
|
+
COUPON_ATTEMPT_ACTION,
|
|
48
|
+
couponAttemptLimit,
|
|
49
|
+
couponSettings,
|
|
50
|
+
readCouponSettings
|
|
51
|
+
};
|
|
52
|
+
//# sourceMappingURL=settings.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/settings.ts"],"sourcesContent":["import { flag, integer, type Registry } from \"@krizaka/settings\";\n\nimport type { CouponSettings } from \"./port\";\nimport { COUPON_ATTEMPT_ACTION, COUPON_BOUNDS, COUPON_DEFAULTS } from \"./rules\";\n\nconst int = <K extends keyof typeof COUPON_BOUNDS>(key: K, unit: string) => integer(\"coupons\", COUPON_DEFAULTS[key], { min: COUPON_BOUNDS[key][0], max: COUPON_BOUNDS[key][1], unit });\n\n/**\n * The settings of « coupons », a fragment the product adds to its registry (`@krizaka/coupons/settings`): the admin changes them\n * without a redeploy, every change audited by the settings service. Global scope only. The zone of the credit caps is\n * `credits.timeZone` (`@krizaka/credits/settings`).\n */\nexport const couponSettings = {\n \"coupons.enabled\": flag(\"coupons\", COUPON_DEFAULTS.enabled),\n \"coupons.reservationMinutes\": int(\"reservationMinutes\", \"minutes\"),\n \"coupons.attemptsPerAccountPerHour\": int(\"attemptsPerAccountPerHour\", \"attempts\"),\n \"coupons.attemptsPerIpPerHour\": int(\"attemptsPerIpPerHour\", \"attempts\"),\n \"coupons.minCodeLength\": int(\"minCodeLength\", \"characters\"),\n \"coupons.generatedCodeLength\": int(\"generatedCodeLength\", \"characters\"),\n \"coupons.maxPercentOff\": int(\"maxPercentOff\", \"percent\"),\n \"coupons.refundRestoresUse\": flag(\"coupons\", COUPON_DEFAULTS.refundRestoresUse),\n} as const satisfies Registry;\n\ntype Reader = (key: keyof typeof couponSettings) => boolean | number | undefined | Promise<boolean | number | undefined>;\n\n/**\n * The module's settings as `createCoupons` takes them, from the product's accessor (`(key) => service.get(key, ctx)`).\n * A key the accessor does not know falls back to the default.\n */\nexport async function readCouponSettings(read: Reader): Promise<CouponSettings> {\n const number = async (key: keyof typeof COUPON_BOUNDS): Promise<number> => {\n const value = await read(`coupons.${key}`);\n return typeof value === \"number\" ? value : COUPON_DEFAULTS[key];\n };\n const bool = async (key: \"enabled\" | \"refundRestoresUse\"): Promise<boolean> => {\n const value = await read(`coupons.${key}`);\n return typeof value === \"boolean\" ? value : COUPON_DEFAULTS[key];\n };\n return {\n enabled: await bool(\"enabled\"),\n reservationMinutes: await number(\"reservationMinutes\"),\n attemptsPerAccountPerHour: await number(\"attemptsPerAccountPerHour\"),\n attemptsPerIpPerHour: await number(\"attemptsPerIpPerHour\"),\n minCodeLength: await number(\"minCodeLength\"),\n generatedCodeLength: await number(\"generatedCodeLength\"),\n maxPercentOff: await number(\"maxPercentOff\"),\n refundRestoresUse: await bool(\"refundRestoresUse\"),\n };\n}\n\n/** The limit the settings give the attempts action (hourly, per address and per account): `limits: (action) => action === COUPON_ATTEMPT_ACTION ? couponAttemptLimit(settings) : …`. */\nexport { COUPON_ATTEMPT_ACTION };\nexport const couponAttemptLimit = (s: Pick<CouponSettings, \"attemptsPerAccountPerHour\" | \"attemptsPerIpPerHour\">) => ({\n perIp: s.attemptsPerIpPerHour,\n perSubject: s.attemptsPerAccountPerHour,\n // @fixed: the settings are « per hour », so the window is an hour\n windowSeconds: 3600,\n});\n"],"mappings":";;;;;;;AAAA,SAAS,MAAM,eAA8B;AAK7C,IAAM,MAAM,CAAuC,KAAQ,SAAiB,QAAQ,WAAW,gBAAgB,GAAG,GAAG,EAAE,KAAK,cAAc,GAAG,EAAE,CAAC,GAAG,KAAK,cAAc,GAAG,EAAE,CAAC,GAAG,KAAK,CAAC;AAO9K,IAAM,iBAAiB;AAAA,EAC5B,mBAAmB,KAAK,WAAW,gBAAgB,OAAO;AAAA,EAC1D,8BAA8B,IAAI,sBAAsB,SAAS;AAAA,EACjE,qCAAqC,IAAI,6BAA6B,UAAU;AAAA,EAChF,gCAAgC,IAAI,wBAAwB,UAAU;AAAA,EACtE,yBAAyB,IAAI,iBAAiB,YAAY;AAAA,EAC1D,+BAA+B,IAAI,uBAAuB,YAAY;AAAA,EACtE,yBAAyB,IAAI,iBAAiB,SAAS;AAAA,EACvD,6BAA6B,KAAK,WAAW,gBAAgB,iBAAiB;AAChF;AAQA,eAAsB,mBAAmB,MAAuC;AAC9E,QAAM,SAAS,OAAO,QAAqD;AACzE,UAAM,QAAQ,MAAM,KAAK,WAAW,GAAG,EAAE;AACzC,WAAO,OAAO,UAAU,WAAW,QAAQ,gBAAgB,GAAG;AAAA,EAChE;AACA,QAAM,OAAO,OAAO,QAA2D;AAC7E,UAAM,QAAQ,MAAM,KAAK,WAAW,GAAG,EAAE;AACzC,WAAO,OAAO,UAAU,YAAY,QAAQ,gBAAgB,GAAG;AAAA,EACjE;AACA,SAAO;AAAA,IACL,SAAS,MAAM,KAAK,SAAS;AAAA,IAC7B,oBAAoB,MAAM,OAAO,oBAAoB;AAAA,IACrD,2BAA2B,MAAM,OAAO,2BAA2B;AAAA,IACnE,sBAAsB,MAAM,OAAO,sBAAsB;AAAA,IACzD,eAAe,MAAM,OAAO,eAAe;AAAA,IAC3C,qBAAqB,MAAM,OAAO,qBAAqB;AAAA,IACvD,eAAe,MAAM,OAAO,eAAe;AAAA,IAC3C,mBAAmB,MAAM,KAAK,mBAAmB;AAAA,EACnD;AACF;AAIO,IAAM,qBAAqB,CAAC,OAAmF;AAAA,EACpH,OAAO,EAAE;AAAA,EACT,YAAY,EAAE;AAAA;AAAA,EAEd,eAAe;AACjB;","names":[]}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { c as Coupons } from './coupons-BF-3HFdw.js';
|
|
2
|
+
import { p as CouponSettings, s as CreditsPort, N as NewCoupon } from './rules-CCd1HhOA.js';
|
|
3
|
+
export { e as COUPON_DEFAULTS } from './rules-CCd1HhOA.js';
|
|
4
|
+
import { Query } from '@krizaka/db';
|
|
5
|
+
|
|
6
|
+
/** What a store (through the module) is run against: the module on that store, a clock the test moves, and fakes for its neighbours. */
|
|
7
|
+
interface CouponsHarness {
|
|
8
|
+
coupons: Coupons;
|
|
9
|
+
/** The query every call goes through (the transaction; a no-op for memory). */
|
|
10
|
+
tx: Query;
|
|
11
|
+
/** Moves the injected clock. */
|
|
12
|
+
setNow(instant: Date): void;
|
|
13
|
+
/** Changes the settings the module reads at each call. */
|
|
14
|
+
setSettings(patch: Partial<CouponSettings>): void;
|
|
15
|
+
/** A fresh account id (a row in the owner table, when the store has one). */
|
|
16
|
+
newAccount(): Promise<string>;
|
|
17
|
+
/** The messages appended to the outbox so far. */
|
|
18
|
+
announced(): {
|
|
19
|
+
type: string;
|
|
20
|
+
id: string | undefined;
|
|
21
|
+
payload: Record<string, unknown>;
|
|
22
|
+
}[];
|
|
23
|
+
/** The audit actions recorded so far, with their target. */
|
|
24
|
+
audited(): {
|
|
25
|
+
action: string;
|
|
26
|
+
target: {
|
|
27
|
+
type: string;
|
|
28
|
+
id: string;
|
|
29
|
+
} | null | undefined;
|
|
30
|
+
detail: Record<string, unknown> | undefined;
|
|
31
|
+
}[];
|
|
32
|
+
/** The credits ledger's calls (a fake, or the real one wrapped to record). */
|
|
33
|
+
credits: {
|
|
34
|
+
granted: Parameters<CreditsPort["grant"]>[1][];
|
|
35
|
+
expired: string[];
|
|
36
|
+
};
|
|
37
|
+
/** The limiter: the attempts counted, and a way to refuse the next ones. */
|
|
38
|
+
limiter: {
|
|
39
|
+
attempts: {
|
|
40
|
+
action: string;
|
|
41
|
+
ip: string | null | undefined;
|
|
42
|
+
subject: string | null | undefined;
|
|
43
|
+
}[];
|
|
44
|
+
refuse(retryAfterSeconds: number | null): void;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** Midday on 26 October 2026 in Toronto: the day the scenarios start on. */
|
|
48
|
+
declare const T0: Date;
|
|
49
|
+
/** A discount of 20 % for three months on a monthly plan. */
|
|
50
|
+
declare const discount: (over?: Partial<NewCoupon>) => NewCoupon;
|
|
51
|
+
/** 20 free audits, at most 5 a day, valid 30 days. */
|
|
52
|
+
declare const credit: (over?: Partial<NewCoupon>) => NewCoupon;
|
|
53
|
+
/**
|
|
54
|
+
* What every coupons store must do, behind the same module: the window and the refusals, the counting rule of the uses, reservation,
|
|
55
|
+
* confirmation and release, refunds, credit coupons, partners, the admin. `make` gives an EMPTY module, the clock at {@link T0},
|
|
56
|
+
* `coupons.enabled` on.
|
|
57
|
+
*/
|
|
58
|
+
declare function couponsContract(name: string, make: () => Promise<CouponsHarness>): void;
|
|
59
|
+
|
|
60
|
+
export { type CouponsHarness, T0, couponsContract, credit, discount };
|