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

260 lines
8.6 KiB
Go

// Package promo is the platform PLAN promo — the admin-controlled "50% off for a
// limited time" offer, composed on the existing Promotion model (NOT a parallel
// model) so the discount that a customer sees on GET /v1/billing/plans is
// SuperAdmin-configured, never hardcoded in the catalog.
//
// It is a SINGLETON: one platform-wide plan promo, stored as one Promotion in the
// reserved platform ("admin") namespace, so the public plans endpoint can surface
// it without a per-org lookup and the SuperAdmin edits it in one place. percentOff
// + the applicable plan slugs ride the Promotion's Metadata; the window is the
// Promotion's StartsAt/EndsAt; `active` is its Status. This is the canonical
// {percentOff, start, end, plans, active} shape (openapi admin Promo).
//
// Beside it live the plan COUPONS (coupon.go): a code a buyer types for a percent
// off the first period of one plan purchase, stored in the same namespace and
// edited on the same SuperAdmin surface.
package promo
import (
"context"
nethttp "net/http"
"strconv"
"strings"
"time"
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/log"
"github.com/hanzoai/commerce/middleware"
promotionModel "github.com/hanzoai/commerce/models/promotion"
"github.com/hanzoai/commerce/util/json/http"
"github.com/hanzoai/commerce/util/nscontext"
"github.com/hanzoai/commerce/util/permission"
. "github.com/hanzoai/commerce/types"
)
// Route mounts the SuperAdmin plan-promo surface at /v1/platform/promo. Like the
// metrics god-view the route-level token gate is a NO-OP on the IAM path, so each
// handler re-enforces RequirePlatformAdmin — the real boundary. cloud's
// /v1/admin/promos (core.Guard) proxies here with the service token.
func Route(r *zip.Group, args ...zip.Handler) {
api := r.Group("platform")
api.Use(middleware.TokenRequired(permission.Admin))
api.Raw(nethttp.MethodGet, "/promo", GetPromo)
api.Raw(nethttp.MethodPut, "/promo", PutPromo)
api.Raw(nethttp.MethodGet, "/coupons/:code", GetCoupon)
api.Raw(nethttp.MethodPut, "/coupons/:code", PutCoupon)
}
const (
// platformNS is the reserved platform namespace the singleton promo lives in —
// the "admin" org (owner=="admin" is the SuperAdmin predicate everywhere), so a
// platform-wide promo is stored where only platform admins write and the public
// plans view reads.
platformNS = "admin"
// promoCode is the well-known code of the ONE platform plan promo, so load/save
// resolve the same singleton row every time.
promoCode = "platform-plan-promo"
// promoType marks the Promotion as the plan-scoped percent-off promo (distinct
// from cart/order promotions the store Evaluate path handles).
promoType = "plan_percent_off"
)
// Promo is the canonical wire shape: percentOff (whole percent 1..100), the UTC
// window [start,end], the applicable plan slugs (empty = ALL paid plans), and
// whether it is active. It maps 1:1 to the openapi admin Promo type.
type Promo struct {
PercentOff int `json:"percentOff"`
Start *time.Time `json:"start,omitempty"`
End *time.Time `json:"end,omitempty"`
Plans []string `json:"plans"`
Active bool `json:"active"`
}
// platformDB opens the reserved platform namespace, regardless of the caller's own
// org — the singleton promo and the plan coupons are platform-global, read by the
// public plans view and the sale, and written only by a SuperAdmin.
func platformDB(c *zip.Ctx) *datastore.Datastore { return platform(c.Context()) }
// platform is platformDB asked from a context alone, for the callers that hold
// no request: the plan catalog read over the internal plane, and the sale.
func platform(ctx context.Context) *datastore.Datastore {
return datastore.New(nscontext.WithNamespace(ctx, platformNS))
}
// load reads the singleton platform plan-promo, or nil when none is configured.
func load(db *datastore.Datastore) (*promotionModel.Promotion, error) {
p := promotionModel.New(db)
ok, err := p.Query().Filter("Code=", promoCode).Get()
if err != nil || !ok {
return nil, err
}
return p, nil
}
// toPromo projects a stored Promotion onto the wire shape.
func toPromo(p *promotionModel.Promotion) Promo {
pr := Promo{
Start: p.StartsAt,
End: p.EndsAt,
Active: strings.EqualFold(p.Status, "active"),
Plans: []string{},
}
if p.Metadata != nil {
if v, ok := p.Metadata["percentOff"].(float64); ok {
pr.PercentOff = int(v)
}
if raw, ok := p.Metadata["plans"].([]interface{}); ok {
for _, s := range raw {
if str, ok := s.(string); ok && strings.TrimSpace(str) != "" {
pr.Plans = append(pr.Plans, strings.TrimSpace(str))
}
}
}
}
return pr
}
// GetPromo returns the current platform plan promo (SuperAdmin only). When none is
// configured it returns a zeroed, inactive promo so the admin UI always has a shape
// to edit.
//
// GET /v1/platform/promo
func GetPromo(c *zip.Ctx) error {
if !middleware.RequirePlatformAdmin(c) {
return nil
}
db := platformDB(c)
p, err := load(db)
if err != nil {
return http.Fail(c, 500, "failed to load promo", err)
}
if p == nil {
return c.JSON(200, Promo{Plans: []string{}})
}
return c.JSON(200, toPromo(p))
}
// PutPromo upserts the singleton platform plan promo (SuperAdmin only). percentOff
// is clamped to (0,100]; the window/plans/active are stored on the one Promotion.
//
// PUT /v1/platform/promo
func PutPromo(c *zip.Ctx) error {
if !middleware.RequirePlatformAdmin(c) {
return nil
}
var req Promo
if err := c.Bind(&req); err != nil {
return http.Fail(c, 400, "invalid request body", err)
}
if req.PercentOff < 0 || req.PercentOff > 100 {
return http.Fail(c, 400, "percentOff must be between 0 and 100", nil)
}
if req.Start != nil && req.End != nil && req.End.Before(*req.Start) {
return http.Fail(c, 400, "end must not precede start", nil)
}
db := platformDB(c)
p, err := load(db)
if err != nil {
return http.Fail(c, 500, "failed to load promo", err)
}
if p == nil {
p = promotionModel.New(db)
p.Code = promoCode
}
p.Type = promoType
p.IsAutomatic = true
p.StartsAt = req.Start
p.EndsAt = req.End
if req.Active {
p.Status = "active"
} else {
p.Status = "draft"
}
plans := make([]interface{}, 0, len(req.Plans))
for _, s := range req.Plans {
if s = strings.TrimSpace(s); s != "" {
plans = append(plans, s)
}
}
p.Metadata = Map{"percentOff": req.PercentOff, "plans": plans}
var serr error
if p.Id() == "" {
serr = p.Create()
} else {
serr = p.Update()
}
if serr != nil {
log.Error("failed to save promo: %v", serr, c)
return http.Fail(c, 500, "failed to save promo", serr)
}
return c.JSON(200, toPromo(p))
}
// Active reports the platform plan promo IF it is active AND within its UTC window
// right now, else nil. This is the ONE gate both the plans view and any pricing
// path share, so a promo that is drafted or expired never discounts. Reads the
// reserved platform namespace so the public (org-less) plans endpoint resolves it.
//
// It is the request-shaped face of Current, and holds nothing of its own.
func Active(c *zip.Ctx) *Promo { return Current(c.Context()) }
// Current is the same offer, asked WITHOUT a request.
//
// The plan catalog is read over the internal plane by a peer that holds no
// store, and a catalog priced without the offer is a different catalog — so the
// question has to be askable from a context alone. Nothing here was ever
// request-shaped: the promo lives in the reserved PLATFORM namespace, which is
// the same row for every caller, which is also why the public org-less plans
// endpoint can resolve it.
func Current(ctx context.Context) *Promo {
p, err := load(platform(ctx))
if err != nil || p == nil {
return nil
}
pr := toPromo(p)
if !pr.Active || pr.PercentOff <= 0 {
return nil
}
now := time.Now().UTC()
if pr.Start != nil && now.Before(*pr.Start) {
return nil
}
if pr.End != nil && now.After(*pr.End) {
return nil
}
return &pr
}
// Name is how the promo reads on a receipt — the ONE place a percent becomes a
// label, so the line a customer sees on an invoice cannot drift from the offer
// the plans page advertised. Empty for a promo that discounts nothing, because a
// zero-value discount must not print a name beside it.
func (p *Promo) Name() string {
if p == nil || p.PercentOff <= 0 {
return ""
}
return strconv.Itoa(p.PercentOff) + "% off"
}
// AppliesTo reports whether the promo discounts a given plan slug: an empty Plans
// list means ALL plans, otherwise only the listed slugs.
func (p *Promo) AppliesTo(slug string) bool {
if len(p.Plans) == 0 {
return true
}
for _, s := range p.Plans {
if s == slug {
return true
}
}
return false
}