mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
POST /v1/billing/subscribe/card takes an optional coupon code. The coupon is a models/coupon row (Filter "plan", Type percent) in the reserved platform namespace "admin", beside the platform promo. It must be enabled, inside [StartDate, EndDate], under Limit and cover the plan; an org and a card each redeem a code once (models/couponredemption rows), and the use is reserved under the code's lock before the card is charged and given back on a decline. Any refusal is a 400 or 409 before the card is touched. The percent comes off the first charge only, through engine.Discounted, the same arithmetic the first invoice uses, so charge == AmountDue. The first invoice names the coupon; the subscription never carries it, so renewals bill the catalog price (less only a promo the row carries). GET /v1/billing/plans?coupon=CODE quotes couponCode, couponPercent, couponFirstCents and couponEnds on covered paid rows and couponError (unknown, expired, exhausted, not_applicable) on the rest. Without the parameter the response is unchanged. SuperAdmin: GET/PUT /v1/platform/coupons/:code (RequirePlatformAdmin), audited as coupon.created/coupon.updated billing events naming the actor. middleware.Actor is the one actor-naming rule, moved from billing's minter. Co-authored-by: Hanzo Dev <[email protected]>
63 lines
2.3 KiB
Go
63 lines
2.3 KiB
Go
// Copyright (c) 2014-present Hanzo AI, Inc.
|
|
// Licensed under MIT OR Apache-2.0. See LICENSE-MIT and LICENSE-APACHE.
|
|
|
|
package engine
|
|
|
|
import "strconv"
|
|
|
|
// DiscountCents is the ONE place a percent-off becomes money.
|
|
//
|
|
// It exists because the charge and the invoice are computed in two different
|
|
// packages — the card path in api/billing prices the first period, the engine
|
|
// prices every period after it — and the invariant they must satisfy is that
|
|
// the amount charged EQUALS the invoice's AmountDue. Two copies of "subtract a
|
|
// percent" is how a customer gets billed one number and shown another; one
|
|
// function is how they cannot.
|
|
//
|
|
// Rounding is half-up on the discount, so a fractional cent lands in the
|
|
// customer's favour: they are never charged more than the advertised percent
|
|
// implies. A percent outside 1..100 discounts nothing (0 and negatives are "no
|
|
// promo"; >100 would invert the sale), and the result never exceeds the
|
|
// subtotal, so a discount cannot turn a charge into a refund.
|
|
func DiscountCents(subtotalCents int64, percent int) int64 {
|
|
if subtotalCents <= 0 || percent <= 0 {
|
|
return 0
|
|
}
|
|
if percent >= 100 {
|
|
return subtotalCents
|
|
}
|
|
d := (subtotalCents*int64(percent) + 50) / 100
|
|
if d > subtotalCents {
|
|
return subtotalCents
|
|
}
|
|
return d
|
|
}
|
|
|
|
// Discounted is fee less each percent in turn, every percent priced off what the
|
|
// one before it left: the subscription's promo, then a coupon. The card path and
|
|
// the invoice both call it with the same percents in the same order, so the
|
|
// amount charged equals the invoice's AmountDue however many discounts stack.
|
|
func Discounted(fee int64, percents ...int) int64 {
|
|
for _, p := range percents {
|
|
fee -= DiscountCents(fee, p)
|
|
}
|
|
return fee
|
|
}
|
|
|
|
// Coupon is a percent off ONE invoice: the first period of a subscription bought
|
|
// with a coupon code. It is handed to the invoice that period's charge paid and
|
|
// never stored on the subscription, so every later period bills at the plan's
|
|
// price, less only the promo the subscription carries.
|
|
type Coupon struct {
|
|
Code string
|
|
Percent int
|
|
}
|
|
|
|
// Label is how the coupon reads on the invoice it discounts.
|
|
func (c Coupon) Label() string {
|
|
if c.Code == "" || c.Percent <= 0 {
|
|
return ""
|
|
}
|
|
return c.Code + " — " + strconv.Itoa(c.Percent) + "% off first month"
|
|
}
|