@krizaka/coupons 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-ECCGFGSV.js → chunk-V2HCEF26.js} +346 -62
- package/dist/chunk-V2HCEF26.js.map +1 -0
- package/dist/{chunk-JMQO6PTK.js → chunk-XXFDAM5G.js} +43 -13
- package/dist/chunk-XXFDAM5G.js.map +1 -0
- package/dist/index.js +2 -2
- package/dist/settings.js +1 -1
- package/dist/settings.js.map +1 -1
- package/dist/testkit.js +270 -47
- package/dist/testkit.js.map +1 -1
- package/package.json +10 -10
- package/dist/chunk-ECCGFGSV.js.map +0 -1
- package/dist/chunk-JMQO6PTK.js.map +0 -1
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/port.ts","../src/rules.ts"],"sourcesContent":["/**\n * The ports of « coupons » (ADR-002): discount and credit coupons that the admin manages, with the product's database as the source\n * of truth. Generic: no product import, no vendor, no database client (a `Query` comes in), no wall clock (a `Clock` comes in), no\n * network. The provider is `@krizaka/payments`' port (this module hands it immutable DiscountTerms); credits are\n * `@krizaka/credits`' ledger; the audit trail, the outbox and the limiter are structural ports, so this package depends on none of them.\n */\nimport type { Clock, Query } from \"@krizaka/db\";\n\nexport type { Clock, Query };\n\nexport const KIND = { discount: \"discount\", credit: \"credit\" } as const;\nexport type Kind = (typeof KIND)[keyof typeof KIND];\n\n/** Why the coupon exists: a person who sells for us, a public promotion, a demo, a support gesture. */\nexport const PURPOSE = { partner: \"partner\", promo: \"promo\", demo: \"demo\", support: \"support\" } as const;\nexport type Purpose = (typeof PURPOSE)[keyof typeof PURPOSE];\n\n/** Stored states. « Not started », « expired » and « exhausted » are COMPUTED from the dates and the uses, so no cron is needed. */\nexport const COUPON_STATUS = { active: \"active\", paused: \"paused\", archived: \"archived\" } as const;\nexport type CouponStatus = (typeof COUPON_STATUS)[keyof typeof COUPON_STATUS];\n\nexport const REDEMPTION_STATUS = { reserved: \"reserved\", confirmed: \"confirmed\", released: \"released\", revoked: \"revoked\" } as const;\nexport type RedemptionStatus = (typeof REDEMPTION_STATUS)[keyof typeof REDEMPTION_STATUS];\n\nexport const DURATION = { once: \"once\", repeating: \"repeating\", forever: \"forever\" } as const;\nexport type Duration = (typeof DURATION)[keyof typeof DURATION];\n\nexport const ON_EXHAUSTED = { stop: \"stop\", fallbackPaid: \"fallback_paid\" } as const;\nexport type OnExhausted = (typeof ON_EXHAUSTED)[keyof typeof ON_EXHAUSTED];\n\n/** The terms a provider adapter needs to apply a discount: vendor-free, IMMUTABLE per `(couponId, termsVersion)` (`@krizaka/payments`' `DiscountTerms`). */\nexport interface DiscountTerms {\n couponId: string;\n termsVersion: number;\n percentOffBp?: number;\n amountOffMinor?: number;\n currency?: string;\n duration: Duration;\n durationMonths?: number;\n}\n\nexport interface Coupon {\n id: string;\n /** As the admin typed it. */\n code: string;\n /** Normalised (upper case, no spaces or hyphens): the lookup key, unique in the product. */\n codeKey: string;\n kind: Kind;\n purpose: Purpose;\n partnerId: string | null;\n status: CouponStatus;\n startsAt: Date;\n endsAt: Date | null;\n maxRedemptions: number | null;\n maxPerAccount: number;\n newCustomersOnly: boolean;\n /** Plan kinds of the product; empty = all. */\n plans: string[];\n percentOffBp: number | null;\n amountOffMinor: number | null;\n currency: string | null;\n duration: Duration | null;\n durationMonths: number | null;\n minimumMinor: number | null;\n credits: number | null;\n capPerDay: number | null;\n capPerMonth: number | null;\n creditsValidDays: number | null;\n onExhausted: OnExhausted | null;\n termsVersion: number;\n createdAt: Date;\n createdBy: string | null;\n updatedAt: Date;\n}\n\nexport interface Partner {\n id: string;\n /** Null once erased (Loi 25). */\n displayName: string | null;\n contactEmail: string | null;\n status: \"active\" | \"archived\";\n createdAt: Date;\n createdBy: string | null;\n erasedAt: Date | null;\n}\n\nexport interface Redemption {\n id: string;\n couponId: string;\n /** Null once the account was deleted: the use still counts and the revenue stays. */\n accountId: string | null;\n /** Copied at redemption: attribution never moves. */\n partnerId: string | null;\n status: RedemptionStatus;\n idempotencyKey: string;\n reservedUntil: Date | null;\n checkoutRef: string | null;\n paymentRef: string | null;\n amountPaidMinor: number | null;\n amountDiscountMinor: number | null;\n /** CUMULATIVE, partial or full. */\n amountRefundedMinor: number;\n /** A full refund gave the use back (the setting). */\n useRestored: boolean;\n currency: string | null;\n creditGrantId: string | null;\n /** A late payment honoured past a limit. */\n overLimit: boolean;\n createdAt: Date;\n confirmedAt: Date | null;\n endedAt: Date | null;\n}\n\n/** What a guarded update may change. */\nexport type RedemptionPatch = Partial<Pick<Redemption, \"status\" | \"reservedUntil\" | \"checkoutRef\" | \"paymentRef\" | \"amountPaidMinor\" | \"amountDiscountMinor\" | \"amountRefundedMinor\" | \"useRestored\" | \"currency\" | \"creditGrantId\" | \"overLimit\" | \"confirmedAt\" | \"endedAt\">>;\n\n/** What a coupon's terms and rules may be changed to (the admin's update). */\nexport type CouponPatch = Partial<Omit<Coupon, \"id\" | \"codeKey\" | \"createdAt\" | \"createdBy\" | \"updatedAt\" | \"kind\">>;\n\nexport interface Usage {\n /** Uses that count overall. */\n total: number;\n /** Uses that count for the account. */\n account: number;\n}\n\nexport interface CouponFilter {\n status?: CouponStatus;\n kind?: Kind;\n purpose?: Purpose;\n partnerId?: string;\n limit: number;\n offset: number;\n}\n\nexport interface RedemptionFilter {\n couponId?: string;\n partnerId?: string;\n status?: RedemptionStatus;\n from?: Date;\n to?: Date;\n limit: number;\n offset: number;\n}\n\n/** One row of the partner view: a coupon's uses in a period, in one currency. Never a customer's identity. */\nexport interface PartnerRow {\n couponId: string;\n code: string;\n currency: string | null;\n redemptions: number;\n discountMinor: number;\n /** Paid minus refunded. */\n revenueMinor: number;\n creditsGranted: number;\n}\n\n/** Where coupons live. Every adapter runs `couponsContract` (`@krizaka/coupons/testkit`). Times come from the caller. */\nexport interface CouponStore {\n insertCoupon(coupon: Coupon): Promise<void>;\n /** `forUpdate` locks the row until the transaction ends: the last use is taken by one request at a time. */\n couponById(id: string, forUpdate?: boolean): Promise<Coupon | null>;\n couponByKey(codeKey: string, forUpdate?: boolean): Promise<Coupon | null>;\n updateCoupon(id: string, patch: CouponPatch, at: Date): Promise<Coupon | null>;\n listCoupons(filter: CouponFilter): Promise<Coupon[]>;\n /** Whether any redemption (even released) exists: the terms are locked once there is one. */\n hasRedemptions(couponId: string): Promise<boolean>;\n insertPartner(partner: Partner): Promise<void>;\n partnerById(id: string): Promise<Partner | null>;\n updatePartner(id: string, patch: Partial<Pick<Partner, \"displayName\" | \"contactEmail\" | \"status\" | \"erasedAt\">>): Promise<Partner | null>;\n listPartners(options: { includeArchived: boolean; limit: number; offset: number }): Promise<Partner[]>;\n insertRedemption(redemption: Redemption): Promise<void>;\n redemptionById(id: string): Promise<Redemption | null>;\n redemptionByKey(idempotencyKey: string): Promise<Redemption | null>;\n redemptionByCheckoutRef(checkoutRef: string): Promise<Redemption | null>;\n redemptionByPaymentRef(paymentRef: string): Promise<Redemption | null>;\n /** 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. */\n updateRedemption(id: string, from: readonly RedemptionStatus[] | null, patch: RedemptionPatch): Promise<Redemption | null>;\n /** The uses that count (see `countsAsUse`), overall and for the account, at `now`. */\n usage(couponId: string, accountId: string | null, now: Date): Promise<Usage>;\n listRedemptions(filter: RedemptionFilter): Promise<Redemption[]>;\n /** Releases the reservations past `reserved_until` and answers them. */\n releaseExpired(now: Date, limit: number): Promise<Redemption[]>;\n partnerReport(partnerId: string, period: { from?: Date; to?: Date }, now: Date): Promise<PartnerRow[]>;\n /** Active coupons with a fixed amount (for `checkPrices`). */\n activeAmountCoupons(): Promise<Coupon[]>;\n}\n\n/** The knobs of the module, read from the admin's settings (`@krizaka/coupons/settings`). */\nexport interface CouponSettings {\n /** Shows the field and accepts codes. */\n enabled: boolean;\n /** How long a checkout holds a use (40 to 1440 minutes: the provider's session window). */\n reservationMinutes: number;\n attemptsPerAccountPerHour: number;\n attemptsPerIpPerHour: number;\n minCodeLength: number;\n generatedCodeLength: number;\n /** The highest percent a discount may have (1 to 99). */\n maxPercentOff: number;\n /** A full refund gives the use back. */\n refundRestoresUse: boolean;\n}\n\n/** Who acts (for the audit trail). */\nexport interface Actor {\n id: string | null;\n label: string;\n}\n\n/** The trail (`@krizaka/audit`'s `Audit`), structurally. */\nexport interface AuditPort {\n record(query: Query, entry: { actorId: string | null; actorLabel: string; action: string; target?: { type: string; id: string } | null; detail?: Record<string, unknown> }): Promise<void>;\n}\n\n/** The outbox (`@krizaka/outbox`'s `Outbox`), structurally. */\nexport interface OutboxPort {\n append(tx: Query, message: { topic: string; type: string; payload: unknown; id?: string }): Promise<{ id: string; appended: boolean }>;\n}\n\n/** The limiter (`@krizaka/rate-limit`'s `Limiter`), structurally. */\nexport interface RateLimitPort {\n check(action: string, keys: { ip?: string | null | undefined; subject?: string | null | undefined }): Promise<{ ok: true } | { ok: false; retryAfterSeconds: number }>;\n}\n\n/** The credits ledger (`@krizaka/credits`' `Credits`), structurally. */\nexport interface CreditsPort {\n grant(tx: Query, input: { accountId: string; amount: number; expiresAt: Date; capPerDay?: number | null; capPerMonth?: number | null; onExhausted: OnExhausted; source: string; idempotencyKey: string }): Promise<{ grantId: string; replayed: boolean }>;\n expire(tx: Query, grantId: string): Promise<{ expired: number }>;\n}\n\n/** The refusals of validation and redemption (typed, `as const`). */\nexport const COUPON_CODE = {\n unknown: \"COUPON_UNKNOWN\",\n notStarted: \"COUPON_NOT_STARTED\",\n expired: \"COUPON_EXPIRED\",\n paused: \"COUPON_PAUSED\",\n exhausted: \"COUPON_EXHAUSTED\",\n accountLimit: \"COUPON_ACCOUNT_LIMIT\",\n newCustomersOnly: \"COUPON_NEW_CUSTOMERS_ONLY\",\n notForPlan: \"COUPON_NOT_FOR_PLAN\",\n minimum: \"COUPON_MINIMUM\",\n currency: \"COUPON_CURRENCY\",\n tooLarge: \"COUPON_TOO_LARGE\",\n kind: \"COUPON_KIND\",\n provider: \"COUPON_PROVIDER\",\n rateLimited: \"COUPON_RATE_LIMITED\",\n disabled: \"COUPON_DISABLED\",\n} as const;\nexport type CouponRefusal = (typeof COUPON_CODE)[keyof typeof COUPON_CODE];\n\n/** The errors of the admin and of the flows (not refusals of a customer's code). */\nexport const COUPON_ERROR = {\n badInput: \"COUPON_BAD_INPUT\",\n codeTaken: \"COUPON_CODE_TAKEN\",\n termsLocked: \"COUPON_TERMS_LOCKED\",\n notFound: \"COUPON_NOT_FOUND\",\n redemptionNotFound: \"COUPON_REDEMPTION_NOT_FOUND\",\n partnerNotFound: \"COUPON_PARTNER_NOT_FOUND\",\n keyReused: \"COUPON_KEY_REUSED\",\n notRevocable: \"COUPON_NOT_REVOCABLE\",\n badSettings: \"COUPON_BAD_SETTINGS\",\n} as const;\nexport type CouponErrorCode = (typeof COUPON_ERROR)[keyof typeof COUPON_ERROR];\n\n/** The HTTP status a route may answer for each refusal or error. */\nexport const COUPON_STATUS_CODE: Record<CouponRefusal | CouponErrorCode, number> = {\n COUPON_UNKNOWN: 404,\n COUPON_NOT_STARTED: 422,\n COUPON_EXPIRED: 422,\n COUPON_PAUSED: 422,\n COUPON_EXHAUSTED: 422,\n COUPON_ACCOUNT_LIMIT: 422,\n COUPON_NEW_CUSTOMERS_ONLY: 422,\n COUPON_NOT_FOR_PLAN: 422,\n COUPON_MINIMUM: 422,\n COUPON_CURRENCY: 422,\n COUPON_TOO_LARGE: 422,\n COUPON_KIND: 422,\n COUPON_PROVIDER: 422,\n COUPON_RATE_LIMITED: 429,\n COUPON_DISABLED: 404,\n COUPON_BAD_INPUT: 400,\n COUPON_CODE_TAKEN: 409,\n COUPON_TERMS_LOCKED: 409,\n COUPON_NOT_FOUND: 404,\n COUPON_REDEMPTION_NOT_FOUND: 404,\n COUPON_PARTNER_NOT_FOUND: 404,\n COUPON_KEY_REUSED: 409,\n COUPON_NOT_REVOCABLE: 409,\n COUPON_BAD_SETTINGS: 500,\n};\n\n/** A refusal thrown by a flow (`apply`, `redeemCredit`): a stable `code`, the HTTP `status`, and `retryAfterSeconds` for a rate limit. */\nexport class CouponError extends Error {\n readonly status: number;\n constructor(\n readonly code: CouponRefusal | CouponErrorCode,\n message: string = code,\n readonly retryAfterSeconds?: number,\n ) {\n super(message);\n this.name = \"CouponError\";\n this.status = COUPON_STATUS_CODE[code];\n }\n}\n","import { randomInt } from \"node:crypto\";\n\nimport { isAfter } from \"@krizaka/time\";\n\nimport {\n type Coupon,\n COUPON_CODE,\n COUPON_ERROR,\n COUPON_STATUS,\n CouponError,\n type CouponRefusal,\n type CouponSettings,\n type DiscountTerms,\n DURATION,\n KIND,\n ON_EXHAUSTED,\n PURPOSE,\n type Redemption,\n REDEMPTION_STATUS,\n type Usage,\n} from \"./port\";\n\n/** Defaults and bounds of the settings (the admin changes them without a redeploy). `maxPercentOff`'s default is Oussama's call (krizaka-ts#61). */\nexport const COUPON_DEFAULTS: CouponSettings = {\n enabled: false,\n reservationMinutes: 60,\n attemptsPerAccountPerHour: 10,\n attemptsPerIpPerHour: 30,\n minCodeLength: 8,\n generatedCodeLength: 10,\n maxPercentOff: 50,\n refundRestoresUse: false,\n};\n\n/** The action name of the limiter for attempts with a code: each `validate`, `apply` and `redeemCredit` counts one hit. */\nexport const COUPON_ATTEMPT_ACTION = \"couponAttempt\";\n\n/** [min, max] of each numeric setting. */\nexport const COUPON_BOUNDS = {\n reservationMinutes: [40, 1440],\n attemptsPerAccountPerHour: [1, 100],\n attemptsPerIpPerHour: [1, 500],\n minCodeLength: [4, 40],\n generatedCodeLength: [8, 20],\n maxPercentOff: [1, 99],\n} as const satisfies Record<string, readonly [number, number]>;\n\n/** Checks the settings the service was given. @throws CouponError `COUPON_BAD_SETTINGS` */\nexport function checkSettings(s: CouponSettings): CouponSettings {\n for (const [key, [min, max]] of Object.entries(COUPON_BOUNDS)) {\n const value = s[key as keyof typeof COUPON_BOUNDS];\n if (!Number.isSafeInteger(value) || value < min || value > max) throw new CouponError(COUPON_ERROR.badSettings, `${key} is ${min} to ${max}, got ${value}`);\n }\n return s;\n}\n\nconst bad = (message: string) => new CouponError(COUPON_ERROR.badInput, message);\n\n// @fixed: the longest normalised code the table accepts (its CHECK)\nconst MAX_CODE = 40;\n// @fixed: the shortest normalised code the table accepts (its CHECK); the admin's `coupons.minCodeLength` raises it\nconst MIN_CODE = 4;\n\n/**\n * The lookup key of a code: upper case, without spaces, hyphens or underscores, so « summer-2026 », « SUMMER 2026 » and « Summer2026 »\n * are one code. A code is then letters and digits only.\n * @returns the key, or null when what is left is empty, too long or not letters and digits\n */\nexport function normalizeCode(input: string): string | null {\n const key = input.normalize(\"NFKC\").replace(/[\\s\\-_]+/g, \"\").toUpperCase();\n return /^[A-Z0-9]+$/.test(key) && key.length >= 1 && key.length <= MAX_CODE ? key : null;\n}\n\n// Crockford's base32 without I, L, O and U: nothing to confuse when read out loud or copied by hand.\nconst ALPHABET = \"0123456789ABCDEFGHJKMNPQRSTVWXYZ\";\n\n/** A random code of `length` characters from an alphabet without I, L, O, U (about 5 bits each). */\nexport function generateCode(length: number, pick: (max: number) => number = (max) => randomInt(max)): string {\n return Array.from({ length }, () => ALPHABET[pick(ALPHABET.length)]).join(\"\");\n}\n\n/** Whether a redemption consumes a use at `now`: confirmed or revoked and not restored, or reserved and not yet past its deadline. */\nexport function countsAsUse(r: Pick<Redemption, \"status\" | \"useRestored\" | \"reservedUntil\">, now: Date): boolean {\n if (r.status === REDEMPTION_STATUS.confirmed || r.status === REDEMPTION_STATUS.revoked) return !r.useRestored;\n return r.status === REDEMPTION_STATUS.reserved && r.reservedUntil !== null && isAfter(r.reservedUntil, now);\n}\n\n/** What the person typed and where they are: a checkout (discount codes) or a place that redeems credits (credit codes). */\nexport interface CheckoutContext {\n mode: \"checkout\" | \"redeem\";\n accountId: string;\n /** The plan kind being bought. Checkout only. */\n plan?: string;\n /** The price before the discount, in minor units. Checkout only. */\n amountMinor?: number;\n currency?: string;\n /** Whether the account has paid before (from `payments`). */\n hasPaidBefore?: boolean;\n /** False when the product's provider cannot apply discounts (CCBill, Segpay). Default true. */\n providerSupportsDiscounts?: boolean;\n ip?: string | null;\n}\n\nexport interface DiscountEffect {\n kind: typeof KIND.discount;\n percentOffBp: number | null;\n amountOffMinor: number | null;\n currency: string | null;\n duration: NonNullable<Coupon[\"duration\"]>;\n durationMonths: number | null;\n /** What the first payment loses, in minor units. */\n discountMinor: number;\n payableMinor: number;\n}\n\nexport interface CreditEffect {\n kind: typeof KIND.credit;\n credits: number;\n capPerDay: number | null;\n capPerMonth: number | null;\n validDays: number;\n onExhausted: NonNullable<Coupon[\"onExhausted\"]>;\n}\n\n/** What a code gives, in numbers. Informational for a discount: the provider computes the real amount and its taxes. */\nexport function effectOf(coupon: Coupon, amountMinor: number): DiscountEffect | CreditEffect {\n if (coupon.kind === KIND.credit) {\n return { kind: KIND.credit, credits: coupon.credits!, capPerDay: coupon.capPerDay, capPerMonth: coupon.capPerMonth, validDays: coupon.creditsValidDays!, onExhausted: coupon.onExhausted! };\n }\n const raw = coupon.percentOffBp !== null ? Math.round((amountMinor * coupon.percentOffBp) / 10_000) : coupon.amountOffMinor!;\n const discountMinor = Math.min(raw, amountMinor);\n return { kind: KIND.discount, percentOffBp: coupon.percentOffBp, amountOffMinor: coupon.amountOffMinor, currency: coupon.currency, duration: coupon.duration!, durationMonths: coupon.durationMonths, discountMinor, payableMinor: amountMinor - discountMinor };\n}\n\n/** The immutable terms a provider adapter turns into its object. */\nexport function termsOf(coupon: Coupon): DiscountTerms {\n return {\n couponId: coupon.id,\n termsVersion: coupon.termsVersion,\n ...(coupon.percentOffBp !== null ? { percentOffBp: coupon.percentOffBp } : { amountOffMinor: coupon.amountOffMinor!, currency: coupon.currency! }),\n duration: coupon.duration!,\n ...(coupon.durationMonths !== null ? { durationMonths: coupon.durationMonths } : {}),\n };\n}\n\n/** The window, the status and the limits: what any coupon is checked against. */\nfunction commonRefusal(coupon: Coupon, ctx: CheckoutContext, usage: Usage, now: Date): CouponRefusal | null {\n if (isAfter(coupon.startsAt, now)) return COUPON_CODE.notStarted;\n if (coupon.endsAt !== null && !isAfter(coupon.endsAt, now)) return COUPON_CODE.expired;\n if (coupon.status !== COUPON_STATUS.active) return COUPON_CODE.paused;\n if (coupon.maxRedemptions !== null && usage.total >= coupon.maxRedemptions) return COUPON_CODE.exhausted;\n if (usage.account >= coupon.maxPerAccount) return COUPON_CODE.accountLimit;\n if (coupon.newCustomersOnly && ctx.hasPaidBefore) return COUPON_CODE.newCustomersOnly;\n return null;\n}\n\n/** What only a discount is checked against: the provider, the plan, the minimum, the currency and the bound on its size. */\nfunction discountRefusal(coupon: Coupon, ctx: CheckoutContext, settings: CouponSettings): CouponRefusal | null {\n const amount = ctx.amountMinor ?? 0;\n if (ctx.providerSupportsDiscounts === false) return COUPON_CODE.provider;\n if (coupon.plans.length > 0 && !coupon.plans.includes(ctx.plan ?? \"\")) return COUPON_CODE.notForPlan;\n if (coupon.minimumMinor !== null && amount < coupon.minimumMinor) return COUPON_CODE.minimum;\n if (coupon.currency !== null && coupon.currency !== ctx.currency) return COUPON_CODE.currency;\n const tooLarge = coupon.percentOffBp !== null ? coupon.percentOffBp > settings.maxPercentOff * 100 : coupon.amountOffMinor! * 100 > amount * settings.maxPercentOff;\n return tooLarge ? COUPON_CODE.tooLarge : null;\n}\n\n/** The first rule a code breaks for a context at `now`, or none. The order is the order of the refusals' table. */\nexport function refusalOf(coupon: Coupon, ctx: CheckoutContext, usage: Usage, now: Date, settings: CouponSettings): CouponRefusal | null {\n const discount = coupon.kind === KIND.discount;\n if (discount !== (ctx.mode === \"checkout\")) return COUPON_CODE.kind;\n return commonRefusal(coupon, ctx, usage, now) ?? (discount ? discountRefusal(coupon, ctx, settings) : null);\n}\n\n/** What the admin gives to create a coupon (the kind decides which terms are required). */\nexport interface NewCoupon {\n code: string;\n kind: Coupon[\"kind\"];\n purpose: Coupon[\"purpose\"];\n partnerId?: string | null;\n startsAt: Date;\n endsAt?: Date | null;\n maxRedemptions?: number | null;\n maxPerAccount?: number;\n newCustomersOnly?: boolean;\n plans?: string[];\n percentOffBp?: number | null;\n amountOffMinor?: number | null;\n currency?: string | null;\n duration?: Coupon[\"duration\"];\n durationMonths?: number | null;\n minimumMinor?: number | null;\n credits?: number | null;\n capPerDay?: number | null;\n capPerMonth?: number | null;\n creditsValidDays?: number | null;\n onExhausted?: Coupon[\"onExhausted\"];\n}\n\nconst pos = (n: number | null | undefined, what: string, max = Number.MAX_SAFE_INTEGER) => {\n if (n === null || n === undefined) return;\n if (!Number.isSafeInteger(n) || n < 1 || n > max) throw bad(`${what} is a whole number from 1 to ${max}, got ${n}`);\n};\n\n/**\n * Checks a coupon against the rules of the table and of the product's settings, so a bad one never reaches the database.\n * A free offer is always a capped credit coupon: a discount is never 100 %, a fixed amount never runs forever.\n * @throws CouponError `COUPON_BAD_INPUT`\n */\nexport function checkCoupon(c: Coupon | (NewCoupon & { codeKey?: string }), settings: CouponSettings): void {\n const code = c.code.trim();\n const key = normalizeCode(code);\n if (!key || key.length < Math.max(MIN_CODE, settings.minCodeLength)) throw bad(`A code is letters and digits, ${Math.max(MIN_CODE, settings.minCodeLength)} to ${MAX_CODE} characters`);\n if (c.kind !== KIND.discount && c.kind !== KIND.credit) throw bad(\"A coupon is a discount or a credit\");\n if (!Object.values(PURPOSE).includes(c.purpose)) throw bad(\"A purpose is partner, promo, demo or support\");\n if ((c.purpose === PURPOSE.partner) !== Boolean(c.partnerId)) throw bad(\"A partner coupon names its partner, and only a partner coupon does\");\n if (!(c.startsAt instanceof Date) || Number.isNaN(c.startsAt.valueOf())) throw bad(\"A coupon starts on a date\");\n if (c.endsAt && !isAfter(c.endsAt, c.startsAt)) throw bad(\"A coupon ends after it starts\");\n pos(c.maxRedemptions, \"maxRedemptions\");\n pos(c.maxPerAccount, \"maxPerAccount\");\n if (c.kind === KIND.discount) return checkDiscount(c, settings);\n return checkCredit(c);\n}\n\nfunction checkDiscount(c: NewCoupon, settings: CouponSettings): void {\n if (c.credits != null || c.capPerDay != null || c.capPerMonth != null || c.creditsValidDays != null || c.onExhausted != null) throw bad(\"A discount carries no credit terms\");\n const percent = c.percentOffBp != null;\n const amount = c.amountOffMinor != null;\n if (percent === amount) throw bad(\"A discount is a percentage or a fixed amount, never both\");\n if (percent) pos(c.percentOffBp, \"percentOffBp\", Math.min(9999, settings.maxPercentOff * 100));\n if (amount) {\n pos(c.amountOffMinor, \"amountOffMinor\");\n if (!/^[a-z]{3}$/.test(c.currency ?? \"\")) throw bad(\"A fixed amount has a lowercase currency\");\n }\n if (!c.duration || !Object.values(DURATION).includes(c.duration)) throw bad(\"A discount has a duration: once, repeating or forever\");\n if ((c.duration === DURATION.repeating) !== (c.durationMonths != null)) throw bad(\"Only a repeating discount names its months\");\n pos(c.durationMonths, \"durationMonths\", 36);\n if (amount && c.duration === DURATION.forever) throw bad(\"A fixed amount never runs forever\");\n if (c.minimumMinor != null && (!Number.isSafeInteger(c.minimumMinor) || c.minimumMinor < 0)) throw bad(\"A minimum is a non-negative whole number\");\n if (c.currency != null && !/^[a-z]{3}$/.test(c.currency)) throw bad(\"A currency is three lowercase letters\");\n}\n\nfunction checkCredit(c: NewCoupon): void {\n if (c.percentOffBp != null || c.amountOffMinor != null || c.duration != null || c.durationMonths != null || c.minimumMinor != null) throw bad(\"A credit coupon carries no discount terms\");\n pos(c.credits, \"credits\");\n if (c.credits == null) throw bad(\"A credit coupon grants credits: a free offer is always a capped credit coupon\");\n pos(c.capPerDay, \"capPerDay\");\n pos(c.capPerMonth, \"capPerMonth\");\n if (c.creditsValidDays == null) throw bad(\"Every grant expires: set creditsValidDays\");\n pos(c.creditsValidDays, \"creditsValidDays\", 3650);\n if (c.onExhausted !== ON_EXHAUSTED.stop && c.onExhausted !== ON_EXHAUSTED.fallbackPaid) throw bad(\"onExhausted is stop or fallback_paid\");\n}\n"],"mappings":"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","names":[]}
|