@krizaka /优惠券
折扣券和信用券由行政管理,对于每一个Krizaka产品(决策: adr - 002设计: coupons.md). 产品的数据库是真理的源泉;支付提供商从该模块提供的不可变条款中应用折扣;信用券授予有限额的信用 @krizaka/credits. 它发展现有的东西:它不启动第二个计费系统。
免费提供的总是有上限的信用券,而不是100%的折扣。 折扣止于99.99%和管理员
coupons.maxPercentOff;固定的金额不会永远持续下去,也不会超过价格的那一部分;信用券上总是有积分、有效期以及用完后会发生什么。
两种,一个代码(字母和数字,不带大小写,空格或连字符):«summer-2026»是 SUMMER2026):
- 折扣:一个百分比(基点)或与其货币挂钩的固定金额,一个期限(
once,repeatingN个月,forever)、计划范围和最低限度。在结帐时应用。 - 信贷:
credits当然,可选capPerDay/capPerMonth,creditsValidDays,onExhausted(stop或者fallback_paid). 在产品兑换积分的地方兑换。
两个: startsAt / endsAt, maxRedemptions 整体和 maxPerAccount, newCustomersOnly目的;目的;partner, promo, demo, support),对于合作伙伴,则是与它一起销售的合作伙伴。
import { createCoupons } from "@krizaka/coupons";
import { readCouponSettings } from "@krizaka/coupons/settings";
const coupons = createCoupons({ clock, settings: () => readCouponSettings((key) => settings.get(key)), audit, rateLimit: limiter, credits, outbox });
// 1. the person types the code in « Code promo » (read-only; counts one attempt on the limiter, before any lookup)
const v = await coupons.validate(query, typed, { mode: "checkout", accountId, plan: "pro", amountMinor: 3900, currency: "cad", hasPaidBefore });
// v = { ok: true, effect: { discountMinor, payableMinor, … } } or { ok: false, code: "COUPON_EXPIRED" }
// 2. « Pay »: three steps, NO network call inside a transaction or under the coupon's row lock
const applied = await transaction(connect, (tx) => coupons.apply(tx, { code: typed, accountId, plan: "pro", amountMinor: 3900, currency: "cad", idempotencyKey: attemptId })); // reserves a use
const session = await payments.checkout({ …, discount: applied.discount, idempotencyKey: applied.redemptionId, metadata: { redemption_id: applied.redemptionId }, expiresAt }); // @krizaka/payments
await transaction(connect, (tx) => coupons.attachCheckout(tx, applied.redemptionId, session.checkoutRef, session.expiresAt));
// if the session fails: coupons.release(tx, { redemptionId })
// 3. the payment webhook, in the event's transaction (applyOnceInTransaction of @krizaka/payments)
payment_paid: (e, tx) => coupons.redeem(tx, { redemptionId: e.metadata.redemption_id, checkoutRef: e.checkoutRef, paymentRef: e.paymentRef ?? e.invoiceRef, amountPaidMinor: e.amountTotal, amountDiscountMinor: e.amountDiscount, currency: e.currency })
checkout_expired: (e, tx) => coupons.release(tx, { checkoutRef: e.checkoutRef })
payment_refunded: (e, tx) => coupons.refund(tx, { paymentRef: e.paymentRef, invoiceRef: e.invoiceRef, amountRefunded: e.amountRefunded })
// a credit code: the same rules, then a capped grant in the same transaction
await transaction(connect, (tx) => coupons.redeemCredit(tx, { code: typed, accountId, idempotencyKey: attemptId }));
规则
- 一次使用按一条规则计算赎回是一种使用,当它是
confirmed或者revoked而不是use_restored,或者reserved而且没有超过最后期限。一个released一个永远不重要。总体限制和每个帐户限制都适用它。“过期”、“耗尽”和“未启动”将被计算,因此不需要cron。 - 预定,确认,释放。
apply锁定优惠券行(SELECT … FOR UPDATE),重新检查所有内容,插入一个reserved有期限的赎回(coupons.reservationMinutes)和提交:最后一次使用的两个请求给一个(在PostgreSQL上测试,50轮)。redeem在支付事件的事务中运行;它的谨慎过渡(UPDATE … WHERE status IN ('reserved', 'released'))和独一无二的payment_ref意味着一次使用被确认,不管交付的次数是多少。webhook通过metadata.redemption_id即使attachCheckout从来没有跑。 - 逾期付款受到尊重。 对于已释放或过期的预订的付款无论如何都会得到确认并标记
over_limit顾客是善意支付折扣价的。它被审计并显示在管理员中。 - 退款 加起来(金额是累积的),永远不要改变状态。部分退款保留使用;一个满的人只有在
coupons.refundRestoresUse开启(默认关闭),因此退款不能重置一次性代码。 - 一旦任何人使用了优惠券,条款就不会改变 (
COUPON_TERMS_LOCKED):提供者的对象是由它们组成的termsVersion. 日期,限制,计划,状态和合作伙伴保持可编辑。当一个计划的价格发生变化时,admin.checkPrices(prices)暂停现在将超过上限的固定金额息票。 - 税是供应商的。 条款是
@krizaka/payments,其适配器将折扣一起发送automatic_tax:分条税按折扣金额计算GST和QST。这些数字effect是信息。这里没有写税。 - 滥用。
validate,apply和redeemCredit每人在限位器上尝试一次(COUPON_ATTEMPT_ACTION,限制了coupons.attemptsPerAccountPerHour/attemptsPerIpPerHour)进行查找;超过极限,他们回答COUPON_RATE_LIMITED与retryAfterSeconds. - 拒绝 键入(
COUPON_CODE):UNKNOWN,NOT_STARTED,EXPIRED,PAUSED,EXHAUSTED,ACCOUNT_LIMIT,NEW_CUSTOMERS_ONLY,NOT_FOR_PLAN,MINIMUM,CURRENCY,TOO_LARGE,KIND,PROVIDER,RATE_LIMITED,DISABLED.CouponError包含代码和HTTPstatus(404、422、429……)。
合伙人(第25号决议)
合作伙伴是为我们进行销售的人:一个显示名称和一个可选的电子邮件,用于一个目的(归因于销售),仅对管理员可见。 admin.partners.report 给,每张优惠券和货币,兑换,折扣,收入(支付减去退款)和信用授予,从来没有客户的身份;每次读取都被审计。 export 返回记录及其聚合; erase 清空名称和E-mail并保留汇总(赎回是会计记录)。 佣金超出了范围:钩子是 evt.coupon.redeemed.v1,它携带 partnerId,金额和积分,供后面的模块从发件箱中消费。
你提供什么,它就会给予什么
- 依赖关系 都是结构端口,所以这个包不导入它们:
audit(createAudit的@krizaka/audit),rateLimit(createLimiter的@krizaka/rate-limit),credits(createCredits的@krizaka/credits),outbox(createOutbox的@krizaka/outbox)。 - 事件 通过发件箱,在呼叫者的交易中,id衍生出以下事实:
evt.coupon.redeemed.v1,evt.coupon.released.v1,evt.coupon.refunded.v1,evt.coupon.exhausted.v1,每个都有一个JSON模式(COUPON_EVENT_SCHEMAS)。 - 设置 (
@krizaka/coupons/settings(由设置服务审核的每个更改):coupons.enabled(掉),reservationMinutes(60,40 - 1440:提供商的会话窗口),attemptsPerAccountPerHour(10),attemptsPerIpPerHour(30),minCodeLength(8),generatedCodeLength(10),maxPercentOff(50, 1 - 99: 默认是Oussama的呼叫krizaka-ts # 61)refundRestoresUse(掉)。credits.timeZone是@krizaka/credits”。环境中没有任何新内容:提供程序密钥保留了下来PAYMENTS_*. - 表 (
couponsSchema、Postgres模式coupons):partner,coupon,redemption,使用重复规则的支票(不能存储免费折扣)。被删除的帐户会留下其赎回金额(ON DELETE SET NULL)。 couponsContract(@krizaka/coupons/testkit)是每个商店的合同;memoryCouponStore()是用于单元测试的(它不锁定任何东西:并发性在PostgreSQL上得到了证明)。
去生活
代码只能在提供程序的测试模式下运行。在实时模式下,一个产品接一个产品地打开优惠券是所有者的决定(krizaka-ts#61),保留兑换记录和免费积分的税收状况也是如此(krizaka-ts#62,对会计来说):这里没有任何假设。