Files
zooqueenandHanzo Dev abab16618d feat(billing): plan coupon for a percent off the first month
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]>
2026-10-09 12:18:05 -07:00

175 lines
4.2 KiB
Go

package coupon
import (
"strings"
"time"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/log"
"github.com/hanzoai/commerce/models/mixin"
"github.com/hanzoai/commerce/util/hashid"
"github.com/hanzoai/commerce/util/timeutil"
"github.com/hanzoai/orm"
)
func init() { orm.Register[Coupon]("coupon") }
type Type string
const (
Flat Type = "flat"
Percent = "percent"
FreeShipping = "free-shipping"
FreeItem = "free-item"
)
var Types = []Type{Flat, Percent, FreeShipping}
// PlanFilter marks a coupon that discounts a subscription PLAN purchase rather
// than a cart: the first period of a plan bought with its code.
const PlanFilter = "plan"
type Coupon struct {
mixin.Model[Coupon]
Name string `json:"name"`
// Possible values: flat, percent, free_shipping.
Type Type `json:"type"`
// Coupon code (must be unique).
Code_ string `json:"code" datastore:"Code"`
RawCode string `json:"-" datastore:"-"`
// Indicates whether or not the Code is dynamically checked (for something like user-generated coupons)
Dynamic bool `json:"dynamic"`
CampaignId string `json:"campaignId,omitempty"`
ReferrerId string `json:"referrerId,omitempty"`
// Range in which coupon is valid
StartDate time.Time `json:"startDate"`
EndDate time.Time `json:"endDate"`
// Possible values: order, product, plan (PlanFilter).
Filter string `json:"filter"`
// Indicates whether this coupon may be applied once or more than once at checkout.
Once bool `json:"once"`
// The number of times this coupon can be used before it is used up and useless. 0 = unlimited
Limit int `json:"limit"`
// Product id for product-specific coupons.
ProductId string `json:"productId,omitempty"`
// Plans are the plan slugs a plan coupon applies to, beside ProductId. Both
// empty means every paid plan.
Plans []string `json:"plans,omitempty"`
// Whether coupon is valid.
Enabled bool `json:"enabled" orm:"default:true"`
// Coupon amount. $5 should be 500 (prices in basic currency unit, like cents). 10% should be 10.
// TODO: This needs to be currency.Cents in Hanzo.
Amount int `json:"amount"`
// Number of times coupon was redeemed.
Used int `json:"used"`
// Free product with coupon
FreeProductId string `json:"freeProductId"`
FreeVariantId string `json:"freeVariantId"`
FreeQuantity int `json:"freeQuantity"`
// List of buyer email addresses who have redeemed coupon.
// Buyers []string `json:"buyers"`
}
func (co *Coupon) Load(ps []datastore.Property) (err error) {
// Load supported properties
if err = datastore.LoadStruct(co, ps); err != nil {
return err
}
return err
}
func (co *Coupon) Save() (ps []datastore.Property, err error) {
co.Code_ = strings.ToUpper(co.Code_)
// Save properties
return datastore.SaveStruct(co)
}
func (c Coupon) Code() string {
if c.RawCode != "" && c.RawCode != c.Code_ {
return c.RawCode
} else {
return c.Code_
}
}
func (c Coupon) DynamicCode() string {
if c.RawCode == c.Code_ {
return ""
}
return c.RawCode
}
func (c *Coupon) CodeFromId(uniqueid string) string {
cid := c.Key()
uid, err := hashid.DecodeKey(c.Context(), uniqueid)
if err != nil {
return ""
}
// Normal kind id for coupon is 3, this is 3333 to prevent accidental
// decoding as normal hashid
return hashid.Encode(3333, int(cid.IntID()), int(uid.IntID()))
}
func (c Coupon) ValidFor(t time.Time) bool {
if !c.Enabled {
log.Warn("Coupon Not Enabled", c.Context())
return false // currently active, no need to check?
}
if !timeutil.IsZero(c.StartDate) && c.StartDate.After(t) {
log.Warn("Coupon not yet Usable: %v > %v", c.StartDate.Unix(), t, c.Context())
return false
}
if !timeutil.IsZero(c.EndDate) && c.EndDate.Before(t) {
log.Warn("Coupon is Expired: %v < %v", c.EndDate.Unix(), t, c.Context())
return false
}
return true
}
func (c Coupon) ItemId() string {
if c.ProductId != "" {
return c.ProductId
}
if c.FreeProductId != "" {
return c.FreeProductId
}
if c.FreeVariantId != "" {
return c.FreeProductId
}
return ""
}
func New(db *datastore.Datastore) *Coupon {
c := new(Coupon)
c.Init(db)
return c
}
func Query(db *datastore.Datastore) datastore.Query {
return db.Query("coupon")
}