mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
A plan coupon covers only the plan slugs it names. SetCoupon refuses an empty list and any slug that is not a paid plan on sale in the plan authority (listed, priced, not contact-sales), so a typo is refused at the PUT instead of at checkout, and a row written with no list covers nothing. GET /v1/billing/plans?coupon= answers Cache-Control and CDN-Cache-Control no-store: a quote is a code's standing now, and the catalog's hour of edge caching must never answer it. The sale notice's goroutine recovers a panic in the mail rail and logs it, so a sender fault never takes down the binary that embeds commerce. Co-authored-by: Hanzo Dev <[email protected]>
623 lines
20 KiB
Go
623 lines
20 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
|
|
// period of one plan purchase. 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. 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.
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"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
|
|
|
|
// 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 the first period, 1..100.
|
|
Percent 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 {
|
|
for _, s := range o.plans {
|
|
if s == slug {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Label is how the coupon reads on the invoice it discounts.
|
|
func (o *Offer) Label() string {
|
|
return engine.Coupon{Code: o.Code, Percent: o.Percent}.Label()
|
|
}
|
|
|
|
// 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}
|
|
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")
|
|
)
|
|
|
|
// 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
|
|
cardID string
|
|
orgID 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
|
|
}
|
|
ok, _ := redemptionExists(platform(ctx), couponredemption.DeterministicID(code, orgRedeemer(org)))
|
|
return ok
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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, orgID: orgID, cardID: cardID}
|
|
if err := putRedemption(db, orgID, code, orgRedeemer(org)); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := putRedemption(db, cardID, code, card); err != nil {
|
|
h.undo(db)
|
|
return nil, err
|
|
}
|
|
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 []string{h.orgID, h.cardID} {
|
|
r := couponredemption.New(db)
|
|
if err := r.Get(db.NewKey(r.Kind(), id, 0, nil)); err != nil {
|
|
continue
|
|
}
|
|
if err := r.Delete(); err != nil {
|
|
log.Error("plan coupon %s: redemption %s was not removed: %v", h.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()
|
|
}
|
|
|
|
// ---- SuperAdmin surface ---------------------------------------------------
|
|
|
|
// CouponSpec is a SuperAdmin's whole statement of one plan coupon, the body of
|
|
// PUT /v1/platform/coupons/{code}: percent off the first period (1..100), 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"`
|
|
Start *time.Time `json:"start,omitempty"`
|
|
End *time.Time `json:"end,omitempty"`
|
|
Limit int `json:"limit"`
|
|
Plans []string `json:"plans"`
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
|
|
// 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,
|
|
Limit: c.Limit,
|
|
Plans: append([]string{}, c.Plans...),
|
|
Enabled: c.Enabled,
|
|
},
|
|
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, "start": v.Start, "end": v.End,
|
|
"limit": v.Limit, "plans": v.Plans, "enabled": v.Enabled, "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, 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.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"}
|
|
}
|
|
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.Limit = spec.Limit
|
|
cpn.Plans = plans
|
|
cpn.ProductId = ""
|
|
cpn.Enabled = spec.Enabled
|
|
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, limit %d, plans %v, enabled %v", code, event, by, v.Percent, 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".
|
|
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,"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)
|
|
}
|