Files
zeekayandz cc9b2bc77e billing: a plan coupon discounts the months it names, not the first alone
A plan coupon carries Months (1..36; 0 reads as 1, the first month).
The sale grants the subscription the coupon from the period it sells
(on a trial, the first paid period after it). An invoice, the first
included, takes the percent off the plan fee while fewer than Months
billing cycles were invoiced since then, the row bills monthly, and the
coupon names its plan and every seat type sold.

A cycle is an invoice: engine.Billed counts the distinct plan-fee
periods invoiced since the first paid one, voided ones aside. Nothing
counts down, so a renewal re-run or a row not saved after its invoice
cannot spend a month twice, and days a gift adds or a late payment
that moves the period spend none.

50OFFBETA at three months bills Pro 1000, 1000, 1000, then 2000. A
renewal reads "50OFFBETA — 50% off, month 2 of 3" and records the
coupon, percent, cents and month on its invoice as the first month
does; the ledger draws the discounted amount. A year is never
discounted, and a plan the coupon does not name pays its price.

The plans quote and the sale receipt carry couponMonths. A coupon with
a trial now waits it out instead of being refused.

The staff renewal coupon stays its own fact: it is for the next
renewal whichever period that bills, monthly or yearly, and one row
can hold both, so it stacks off what the plan coupon left. Its invoice
metadata moves to renewalCoupon* so the two never share a key.

ExtendCoupon grants a coupon's current months to every live
subscription already bought with it, from its first paid period. It
never takes a month away, writes by Swap, reports cycles billed and
missed, dry-runs unless asked to apply, and records
subscription.coupon_extended in each org it changes.

Claude-Session: https://claude.ai/code/session_01BLLMyBFSvAfCt7ZGqYuiEz
2026-10-22 10:48:56 -07:00

770 lines
26 KiB
Go

// Copyright (c) 2014-present Hanzo AI, Inc.
// Licensed under MIT OR Apache-2.0. See LICENSE-MIT and LICENSE-APACHE.
package promo
// Plan coupons — a CODE a buyer types at checkout for a percent off the first
// MONTHS monthly periods of one plan purchase (one, a first-month coupon, unless
// a SuperAdmin says more). They are the per-purchase companion of the platform
// promo above and live beside it, in the reserved platform namespace:
//
// - The plan catalog is the platform's, not any tenant's, so the store that
// sells a plan is the platform itself.
// - "admin" is the org whose membership IS the SuperAdmin predicate, so the
// org-scoped coupon CRUD (/v1/coupon, which writes into the caller's own
// namespace) can only reach these rows for a SuperAdmin. A brand org or the
// "system" catalog namespace would be writable by whoever administers an
// org of that name.
// - The sale and the public plans quote read one place, as the promo does.
//
// A plan coupon is a models/coupon row with Filter == coupon.PlanFilter and
// Type == percent; nothing else in the namespace is honoured as one. The sale
// puts it on the subscription it opens as a window of months
// (billing/engine.Grant), and every invoice of a period starting inside the
// window takes the percent off, the first at the sale and the rest at renewal;
// what a SuperAdmin changes later moves no window already granted. It covers
// exactly the plan slugs it names — a coupon that names none covers nothing — and
// SetCoupon refuses a slug that is not a paid plan on sale. Each use
// is a models/couponredemption row keyed by (code, org) and by (code, card), so
// an org and a card each redeem a code once, and Used counts uses against
// Limit under the code's lock, so the limit cannot be raced past.
//
// A coupon may name a CAMPAIGN: one offer handed out as many single-use codes,
// one to each person (MintCoupon). Its uses are also keyed by (campaign, org)
// and (campaign, card), under the campaign's lock, so an org and a card each
// redeem ONE code of a campaign however many of its codes they hold.
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"slices"
"strings"
"sync"
"time"
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/billing/engine"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/log"
"github.com/hanzoai/commerce/middleware"
"github.com/hanzoai/commerce/models/coupon"
"github.com/hanzoai/commerce/models/couponredemption"
"github.com/hanzoai/commerce/models/plan"
"github.com/hanzoai/commerce/util/json/http"
"github.com/hanzoai/commerce/util/timeutil"
. "github.com/hanzoai/commerce/types"
)
// Why a coupon cannot be used, as the plans quote names it (couponError). A
// coupon that is disabled or not yet started answers unknown, so a campaign
// cannot be discovered before it opens.
const (
CouponUnknown = "unknown"
CouponExpired = "expired"
CouponExhausted = "exhausted"
CouponNotApplicable = "not_applicable"
)
// maxCouponCode bounds a code before it reaches a query.
const maxCouponCode = 64
// MaxCouponMonths is the most monthly periods one coupon discounts: three years.
// A longer run is a price, not a coupon.
const MaxCouponMonths = 36
// CouponCode is a code as it is stored and compared: trimmed and upper-cased,
// so codes are case-insensitive. A code that could not have been stored —
// empty, longer than maxCouponCode, or holding anything but A-Z, 0-9, '-' and
// '_' — is "".
func CouponCode(raw string) string {
c := strings.ToUpper(strings.TrimSpace(raw))
if c == "" || len(c) > maxCouponCode {
return ""
}
for _, r := range c {
if (r < 'A' || r > 'Z') && (r < '0' || r > '9') && r != '-' && r != '_' {
return ""
}
}
return c
}
// Offer is a plan coupon that may be redeemed now.
type Offer struct {
// Code is the canonical code (CouponCode).
Code string
// Percent is the whole percent off each month it discounts, 1..100.
Percent int
// Months is how many monthly periods it discounts, from the first paid one:
// 1 for a first-month coupon.
Months int
// End is when the coupon stops being redeemable, nil when it never does.
End *time.Time
// Plans is every slug the coupon names (Plans and ProductId).
Plans []string
}
// AppliesTo reports whether the coupon covers a plan slug: only a slug it NAMES.
// A coupon that names none covers nothing, so a row written without a plan list
// can never discount the whole catalog. Whether the plan charges at all is the
// caller's question too: a free plan has nothing to take a percent of.
func (o *Offer) AppliesTo(slug string) bool {
return slices.Contains(o.Plans, slug)
}
// Coupon is the offer as the invoice of the first month it discounts carries it.
func (o *Offer) Coupon() engine.Coupon {
return engine.Coupon{Code: o.Code, Percent: o.Percent, Month: 1, Months: o.Months}
}
// FindCoupon resolves raw to an Offer redeemable at now, or names why not
// (CouponUnknown, CouponExpired, CouponExhausted). It only reads.
func FindCoupon(ctx context.Context, raw string, now time.Time) (*Offer, string) {
code := CouponCode(raw)
if code == "" {
return nil, CouponUnknown
}
cpn, _, err := loadCoupon(platform(ctx), code)
if err != nil {
log.Error("plan coupon %s could not be read: %v", code, err)
return nil, CouponUnknown
}
if cpn == nil {
return nil, CouponUnknown
}
if reason := standing(cpn, now); reason != "" {
return nil, reason
}
return offerOf(cpn), ""
}
// standing is the one rule a stored coupon is judged by, at now: a plan coupon,
// percent-off within 1..100, enabled, inside [StartDate, EndDate], and not used
// up. "" means it stands.
func standing(c *coupon.Coupon, now time.Time) string {
switch {
case c.Filter != coupon.PlanFilter, c.Type != coupon.Percent, !c.Enabled,
c.Amount < 1, c.Amount > 100:
return CouponUnknown
case !timeutil.IsZero(c.StartDate) && now.Before(c.StartDate):
return CouponUnknown
case !timeutil.IsZero(c.EndDate) && now.After(c.EndDate):
return CouponExpired
case c.Limit > 0 && c.Used >= c.Limit:
return CouponExhausted
}
return ""
}
func offerOf(c *coupon.Coupon) *Offer {
o := &Offer{Code: c.Code_, Percent: c.Amount, Months: max(c.Months, 1)}
if !timeutil.IsZero(c.EndDate) {
end := c.EndDate.UTC()
o.End = &end
}
o.Plans = append(o.Plans, c.Plans...)
if p := strings.TrimSpace(c.ProductId); p != "" {
o.Plans = append(o.Plans, p)
}
return o
}
// loadCoupon reads the ONE plan-coupon row stored under code, bound for
// writing, with its key; (nil, nil, nil) when there is none.
//
// It finds the key by query and loads by key on purpose. A ModelQuery.Get
// re-runs Init after the load, and Init applies the model's tag defaults to
// every zero field — Enabled is `default:true`, so a coupon a SuperAdmin
// switched off would read back on. Loading by key applies the defaults to the
// empty struct first and lets the stored row overwrite them.
//
// Two rows under one code is refused rather than resolved by whichever the
// query returned first: which percent a buyer gets must not be an accident.
func loadCoupon(db *datastore.Datastore, code string) (*coupon.Coupon, datastore.Key, error) {
rows := make([]*coupon.Coupon, 0, 2)
keys, err := coupon.Query(db).Filter("Code_=", code).Limit(2).GetAll(&rows)
if err != nil {
return nil, nil, err
}
switch len(keys) {
case 0:
return nil, nil, nil
case 1:
default:
return nil, nil, fmt.Errorf("%d coupons are stored under code %s", len(keys), code)
}
cpn := coupon.New(db)
if err := cpn.Get(keys[0]); err != nil {
return nil, nil, err
}
return cpn, keys[0], nil
}
// couponLocks serializes every write to one code's rows — a reservation, its
// release, and a SuperAdmin edit — so Used is a read-modify-write nobody
// interleaves. Commerce runs as one process per store (replicas 1, Recreate, a
// ReadWriteOnce volume), so an in-process lock is the serialization point, as
// it is for api/coupon's redeemLocks and the gift-card ledger.
var couponLocks sync.Map // map[string]*sync.Mutex
func couponLock(code string) *sync.Mutex {
m, _ := couponLocks.LoadOrStore(code, &sync.Mutex{})
return m.(*sync.Mutex)
}
// A reservation refused because the org, or the card, has already redeemed the
// code. Each org and each card redeems a code once.
var (
ErrCouponRedeemedByOrg = errors.New("this organization has already redeemed this coupon")
ErrCouponRedeemedByCard = errors.New("this card has already redeemed this coupon")
// ErrCouponCampaignRedeemed is a code whose campaign the org or the card has
// already redeemed with another of its codes.
ErrCouponCampaignRedeemed = errors.New("this organization or card has already redeemed this offer")
)
// CouponRefusal is a reservation refused because the coupon no longer stands:
// Reason is CouponUnknown, CouponExpired or CouponExhausted.
type CouponRefusal struct{ Reason string }
func (e CouponRefusal) Error() string { return "coupon " + e.Reason }
// Hold is one reserved use of a coupon: its redemption rows written and Used
// counted. Release gives the use back when the sale it was reserved for did
// not take the money.
type Hold struct {
ctx context.Context
code string
org string
// ids are the redemption rows the hold wrote: (code, org), (code, card) and,
// for a campaign's code, (campaign, org) and (campaign, card).
ids []string
released bool
}
// Redeemed reports whether org has already redeemed code. It is the early,
// read-only answer, so a buyer is told before their card is vaulted; Reserve
// asks again under the lock and is the one that binds, which is also why a read
// that fails answers false here — Reserve fails closed on the same failure.
func Redeemed(ctx context.Context, raw, org string) bool {
code := CouponCode(raw)
if code == "" {
return false
}
db := platform(ctx)
ids := []string{couponredemption.DeterministicID(code, orgRedeemer(org))}
if cpn, _, err := loadCoupon(db, code); err == nil && cpn != nil && cpn.CampaignId != "" {
ids = append(ids, couponredemption.DeterministicID(campaignOf(cpn.CampaignId), orgRedeemer(org)))
}
for _, id := range ids {
if ok, _ := redemptionExists(db, id); ok {
return true
}
}
return false
}
// campaignOf is a campaign's name as its redemption rows and its lock key it,
// apart from every code's.
func campaignOf(campaign string) string { return "campaign:" + campaign }
// Reserve takes one use of code for org paying with instrument, before any
// money moves. Under the code's lock it re-reads the coupon and refuses one
// that no longer stands (CouponRefusal), refuses an org or a card that has
// redeemed the code already (ErrCouponRedeemedByOrg, ErrCouponRedeemedByCard), and
// otherwise writes both redemption rows and counts the use. instrument is the
// card's stable identity and is required: a use with no instrument cannot be
// held to the one-per-card rule.
func Reserve(ctx context.Context, raw, org, instrument string, now time.Time) (*Hold, error) {
code := CouponCode(raw)
org = strings.TrimSpace(org)
instrument = strings.TrimSpace(instrument)
if code == "" {
return nil, CouponRefusal{CouponUnknown}
}
if org == "" || instrument == "" {
return nil, errors.New("coupon: a use needs an org and a card")
}
mu := couponLock(code)
mu.Lock()
defer mu.Unlock()
db := platform(ctx)
cpn, _, err := loadCoupon(db, code)
if err != nil {
return nil, err
}
if cpn == nil {
return nil, CouponRefusal{CouponUnknown}
}
if reason := standing(cpn, now); reason != "" {
return nil, CouponRefusal{reason}
}
orgID := couponredemption.DeterministicID(code, orgRedeemer(org))
card := cardRedeemer(instrument)
cardID := couponredemption.DeterministicID(code, card)
if ok, err := redemptionExists(db, orgID); err != nil {
return nil, err
} else if ok {
return nil, ErrCouponRedeemedByOrg
}
if ok, err := redemptionExists(db, cardID); err != nil {
return nil, err
} else if ok {
return nil, ErrCouponRedeemedByCard
}
rows := [][2]string{{orgID, orgRedeemer(org)}, {cardID, card}}
if cpn.CampaignId != "" {
// The campaign's lock after the code's, always in that order, so two codes
// of one campaign reserved at once cannot both pass the check below.
key := campaignOf(cpn.CampaignId)
cmu := couponLock(key)
cmu.Lock()
defer cmu.Unlock()
for _, r := range [][2]string{{couponredemption.DeterministicID(key, orgRedeemer(org)), orgRedeemer(org)}, {couponredemption.DeterministicID(key, card), card}} {
if ok, err := redemptionExists(db, r[0]); err != nil {
return nil, err
} else if ok {
return nil, ErrCouponCampaignRedeemed
}
rows = append(rows, r)
}
}
// The hold outlives nothing but this sale, and its release must still run
// when the buyer has hung up, so it keeps the context's values and drops its
// cancellation.
h := &Hold{ctx: context.WithoutCancel(ctx), code: code, org: org}
for _, r := range rows {
if err := putRedemption(db, r[0], code, r[1]); err != nil {
h.undo(db)
return nil, err
}
h.ids = append(h.ids, r[0])
}
cpn.Used++
if err := cpn.Update(); err != nil {
h.undo(db)
return nil, err
}
return h, nil
}
// Release gives a reserved use back: the redemption rows are removed and Used
// counts one fewer. It is for a sale that took no money — a declined card — and
// is a no-op the second time.
func (h *Hold) Release() {
if h == nil || h.released {
return
}
h.released = true
mu := couponLock(h.code)
mu.Lock()
defer mu.Unlock()
db := platform(h.ctx)
h.undo(db)
cpn, _, err := loadCoupon(db, h.code)
if err != nil || cpn == nil {
log.Error("plan coupon %s: a released use was not uncounted (org %s): %v", h.code, h.org, err)
return
}
if cpn.Used > 0 {
cpn.Used--
}
if err := cpn.Update(); err != nil {
log.Error("plan coupon %s: a released use was not uncounted (org %s): %v", h.code, h.org, err)
}
}
// undo removes the hold's redemption rows. A row that cannot be removed leaves
// that org or card unable to redeem the code again — the safe direction.
func (h *Hold) undo(db *datastore.Datastore) {
for _, id := range h.ids {
removeRedemption(db, h.code, id)
}
}
// removeRedemption deletes one redemption row of code, if it is there.
func removeRedemption(db *datastore.Datastore, code, id string) {
r := couponredemption.New(db)
if err := r.Get(db.NewKey(r.Kind(), id, 0, nil)); err != nil {
return
}
if err := r.Delete(); err != nil {
log.Error("plan coupon %s: redemption %s was not removed: %v", code, id, err)
}
}
func orgRedeemer(org string) string { return "org:" + strings.ToLower(org) }
// cardRedeemer is a card's identity as a redemption row stores it: hashed, so
// the platform namespace holds no card facts.
func cardRedeemer(instrument string) string {
sum := sha256.Sum256([]byte(instrument))
return "card:" + hex.EncodeToString(sum[:16])
}
func redemptionExists(db *datastore.Datastore, id string) (bool, error) {
r := couponredemption.New(db)
err := r.Get(db.NewKey(r.Kind(), id, 0, nil))
switch {
case err == nil:
return true, nil
case errors.Is(err, datastore.ErrNoSuchEntity):
return false, nil
}
return false, err
}
func putRedemption(db *datastore.Datastore, id, code, redeemer string) error {
r := couponredemption.New(db)
r.SetId(id)
r.CouponCode = code
r.UserId = redeemer
return r.Create()
}
// Redeemers is every org holding a use of code, by name, sorted: the
// organizations its redemption rows name, the rows Reserve writes and a released
// use takes back. A code's own uses only; another code of its campaign answers
// for its own.
func Redeemers(ctx context.Context, raw string) ([]string, error) {
code := CouponCode(raw)
if code == "" {
return nil, ErrCouponNotFound
}
rows := make([]*couponredemption.CouponRedemption, 0)
if _, err := couponredemption.Query(platform(ctx)).Filter("CouponCode=", code).GetAll(&rows); err != nil {
return nil, err
}
orgs := make([]string, 0, len(rows))
for _, r := range rows {
if org, ok := strings.CutPrefix(r.UserId, "org:"); ok && org != "" && !slices.Contains(orgs, org) {
orgs = append(orgs, org)
}
}
slices.Sort(orgs)
return orgs, nil
}
// ---- SuperAdmin surface ---------------------------------------------------
// CouponSpec is a SuperAdmin's whole statement of one plan coupon, the body of
// PUT /v1/platform/coupons/{code}: percent off each month it discounts
// (1..100), how many monthly periods that is from the first paid one (1..36;
// absent or 0 is 1, a first-month coupon), the UTC window it is redeemable in
// (either end open when absent), how many uses it has in all (0 = no limit),
// the plan slugs it covers (required: at least one, each a paid plan on sale),
// and whether it is on.
type CouponSpec struct {
Percent int `json:"percent"`
Months int `json:"months"`
Start *time.Time `json:"start,omitempty"`
End *time.Time `json:"end,omitempty"`
Limit int `json:"limit"`
Plans []string `json:"plans"`
Enabled bool `json:"enabled"`
// Campaign names the offer the code is one of; an org and a card redeem one
// code of a campaign. Empty is a code on its own. Stored as CouponCode spells
// a code.
Campaign string `json:"campaign,omitempty"`
}
// CouponView is a plan coupon read back: its code, its spec and how many uses
// it has had.
type CouponView struct {
Code string `json:"code"`
CouponSpec
Used int `json:"used"`
}
func viewOf(c *coupon.Coupon) CouponView {
v := CouponView{
Code: c.Code_,
CouponSpec: CouponSpec{
Percent: c.Amount,
Months: max(c.Months, 1),
Limit: c.Limit,
Plans: append([]string{}, c.Plans...),
Enabled: c.Enabled,
Campaign: c.CampaignId,
},
Used: c.Used,
}
if !timeutil.IsZero(c.StartDate) {
s := c.StartDate.UTC()
v.Start = &s
}
if !timeutil.IsZero(c.EndDate) {
e := c.EndDate.UTC()
v.End = &e
}
return v
}
func (v CouponView) audit(by string) Map {
return Map{
"code": v.Code, "percent": v.Percent, "months": v.Months, "start": v.Start, "end": v.End,
"limit": v.Limit, "plans": v.Plans, "enabled": v.Enabled, "campaign": v.Campaign, "used": v.Used, "by": by,
}
}
// errCouponSpec is a spec the coupon cannot be stored as.
type errCouponSpec struct{ msg string }
func (e errCouponSpec) Error() string { return e.msg }
// IsCouponSpecRefused reports whether SetCoupon refused the request itself — a
// bad code, percent, months, window, limit or plan list — rather than failing to
// store it.
func IsCouponSpecRefused(err error) bool {
var e errCouponSpec
return errors.As(err, &e)
}
// ErrCouponNotFound is ReadCoupon of a code with no plan coupon.
var ErrCouponNotFound = errors.New("coupon not found")
// ReadCoupon returns the plan coupon stored under code.
func ReadCoupon(ctx context.Context, raw string) (*CouponView, error) {
code := CouponCode(raw)
if code == "" {
return nil, ErrCouponNotFound
}
cpn, _, err := loadCoupon(platform(ctx), code)
if err != nil {
return nil, err
}
if cpn == nil || cpn.Filter != coupon.PlanFilter {
return nil, ErrCouponNotFound
}
v := viewOf(cpn)
return &v, nil
}
// SetCoupon creates or replaces the plan coupon stored under code with spec, and
// records the change, with who made it (by), as a coupon.created or
// coupon.updated billing event in the platform namespace — the audit record. Used
// is never taken from the spec: it counts real uses.
//
// The audit row is written after the coupon. If it cannot be, SetCoupon fails
// even though the coupon was stored, so a change nobody can trace is never
// reported as done; the same PUT again stores the same coupon and records it.
func SetCoupon(ctx context.Context, raw string, spec CouponSpec, by string) (*CouponView, error) {
code := CouponCode(raw)
switch {
case code == "":
return nil, errCouponSpec{fmt.Sprintf("code must be 1-%d of A-Z, 0-9, '-' and '_'", maxCouponCode)}
case spec.Percent < 1 || spec.Percent > 100:
return nil, errCouponSpec{"percent must be between 1 and 100"}
case spec.Months < 0 || spec.Months > MaxCouponMonths:
return nil, errCouponSpec{fmt.Sprintf("months must be between 1 and %d (0 is 1, the first month)", MaxCouponMonths)}
case spec.Limit < 0:
return nil, errCouponSpec{"limit must not be negative (0 is no limit)"}
case spec.Start != nil && spec.End != nil && !spec.End.After(*spec.Start):
return nil, errCouponSpec{"end must be after start"}
}
campaign := CouponCode(spec.Campaign)
if strings.TrimSpace(spec.Campaign) != "" && campaign == "" {
return nil, errCouponSpec{fmt.Sprintf("campaign must be 1-%d of A-Z, 0-9, '-' and '_'", maxCouponCode)}
}
plans := make([]string, 0, len(spec.Plans))
seen := map[string]bool{}
for _, s := range spec.Plans {
s = strings.ToLower(strings.TrimSpace(s))
if s == "" || seen[s] {
continue
}
seen[s] = true
plans = append(plans, s)
}
if len(plans) == 0 {
return nil, errCouponSpec{"plans must name at least one paid plan, such as [\"dev\"]"}
}
paid, err := paidPlans(ctx)
if err != nil {
return nil, err
}
for _, s := range plans {
if !paid[s] {
return nil, errCouponSpec{fmt.Sprintf("plan %q is not a paid plan on sale", s)}
}
}
mu := couponLock(code)
mu.Lock()
defer mu.Unlock()
db := platform(ctx)
cpn, _, err := loadCoupon(db, code)
if err != nil {
return nil, err
}
if cpn != nil && cpn.Filter != coupon.PlanFilter {
return nil, errCouponSpec{fmt.Sprintf("code %s is held by a coupon that is not a plan coupon", code)}
}
var before Map
event := "coupon.created"
if cpn == nil {
cpn = coupon.New(db)
cpn.Code_ = code
} else {
before = viewOf(cpn).audit("")
event = "coupon.updated"
}
cpn.Name = code
cpn.Type = coupon.Percent
cpn.Filter = coupon.PlanFilter
cpn.Once = true
cpn.Amount = spec.Percent
cpn.Months = max(spec.Months, 1)
cpn.Limit = spec.Limit
cpn.Plans = plans
cpn.ProductId = ""
cpn.Enabled = spec.Enabled
cpn.CampaignId = campaign
cpn.StartDate, cpn.EndDate = time.Time{}, time.Time{}
if spec.Start != nil {
cpn.StartDate = spec.Start.UTC()
}
if spec.End != nil {
cpn.EndDate = spec.End.UTC()
}
if before == nil {
err = cpn.Create()
} else {
err = cpn.Update()
}
if err != nil {
return nil, err
}
v := viewOf(cpn)
if _, err := engine.EmitBillingEvent(db, event, "coupon", code, "", v.audit(by), before); err != nil {
return nil, fmt.Errorf("coupon %s was stored and its audit record was not: %w", code, err)
}
log.Info("plan coupon %s %s by %s: %d%% off for %d month(s), limit %d, plans %v, enabled %v", code, event, by, v.Percent, v.Months, v.Limit, v.Plans, v.Enabled)
return &v, nil
}
// paidPlans is every plan slug sold for money: the plan authority's listed rows
// with a price. A contact-sales plan is priced by a person, never by a code, and
// a free plan has nothing to discount. An authority holding none is an error,
// not an empty answer — a seed that did not run must not read as "no such plan".
// mintAlphabet is what a minted code's random part is drawn from: A-Z and 2-9,
// without the 0, 1, O and I a person misreads off a page.
const mintAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
// mintLength is a minted code's random part: 10 of 32 symbols, 50 bits, so a
// code cannot be guessed from another.
const mintLength = 10
// MintCoupon creates a new single-use plan coupon under a fresh code,
// prefix + "-" + 10 random symbols, from spec with Limit 1. Plans left empty
// are every paid plan on sale, read when it is minted. A code already held is
// never replaced: the draw is made again.
func MintCoupon(ctx context.Context, prefix string, spec CouponSpec, by string) (*CouponView, error) {
prefix = CouponCode(prefix)
if prefix == "" || len(prefix)+1+mintLength > maxCouponCode {
return nil, errCouponSpec{"prefix must be a short code of A-Z, 0-9, '-' and '_'"}
}
spec.Limit = 1
if len(spec.Plans) == 0 {
paid, err := paidPlans(ctx)
if err != nil {
return nil, err
}
for slug := range paid {
spec.Plans = append(spec.Plans, slug)
}
slices.Sort(spec.Plans)
}
for range 4 {
var b [mintLength]byte
if _, err := rand.Read(b[:]); err != nil {
return nil, err
}
for i := range b {
b[i] = mintAlphabet[int(b[i])%len(mintAlphabet)]
}
code := prefix + "-" + string(b[:])
mu := couponLock(code)
mu.Lock()
held, _, err := loadCoupon(platform(ctx), code)
mu.Unlock()
if err != nil {
return nil, err
}
if held != nil {
continue
}
return SetCoupon(ctx, code, spec, by)
}
return nil, errors.New("no free code was drawn")
}
func paidPlans(ctx context.Context) (map[string]bool, error) {
rows := make([]*plan.Plan, 0)
if _, err := plan.Query(plan.AuthorityDB(ctx)).GetAll(&rows); err != nil {
return nil, fmt.Errorf("the plan catalog could not be read: %w", err)
}
paid := map[string]bool{}
for _, p := range rows {
if p.Listed() && p.Price > 0 && !p.ContactSales {
paid[p.Slug] = true
}
}
if len(paid) == 0 {
return nil, errors.New("the plan catalog holds no paid plan on sale")
}
return paid, nil
}
// GetCoupon reads one plan coupon (SuperAdmin only).
//
// GET /v1/platform/coupons/:code
func GetCoupon(c *zip.Ctx) error {
if !middleware.RequirePlatformAdmin(c) {
return nil
}
v, err := ReadCoupon(c.Context(), c.Param("code"))
switch {
case errors.Is(err, ErrCouponNotFound):
return http.Fail(c, 404, "coupon not found", nil)
case err != nil:
return http.Fail(c, 500, "failed to load coupon", err)
}
return c.JSON(200, v)
}
// PutCoupon creates or replaces one plan coupon (SuperAdmin only), audited.
//
// PUT /v1/platform/coupons/:code
// {"percent":50,"months":3,"start":"…Z","end":"…Z","limit":5,"plans":["dev"],"enabled":true}
func PutCoupon(c *zip.Ctx) error {
if !middleware.RequirePlatformAdmin(c) {
return nil
}
var spec CouponSpec
if err := c.Bind(&spec); err != nil {
return http.Fail(c, 400, "invalid request body", err)
}
v, err := SetCoupon(c.Context(), c.Param("code"), spec, middleware.Actor(c))
switch {
case IsCouponSpecRefused(err):
return http.Fail(c, 400, err.Error(), nil)
case err != nil:
return http.Fail(c, 500, "failed to save coupon", err)
}
return c.JSON(200, v)
}