Files
commerce/api/promo/coupon_test.go
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

543 lines
19 KiB
Go

package promo
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/auth"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/models/billingevent"
"github.com/hanzoai/commerce/models/coupon"
"github.com/hanzoai/commerce/models/couponredemption"
"github.com/hanzoai/commerce/models/plan"
"github.com/hanzoai/commerce/models/types/currency"
"github.com/hanzoai/commerce/util/bit"
"github.com/hanzoai/commerce/util/nscontext"
"github.com/hanzoai/commerce/util/permission"
"github.com/hanzoai/commerce/util/test/ae"
)
// warmCoupons opens the platform namespace's coupon kinds once, so the writes a
// test drives and the reads it checks share one handle, and puts the plans a
// coupon may name into the plan authority: three paid plans, a free one and a
// contact-sales one.
func warmCoupons(ctx context.Context) {
db := platform(ctx)
_, _ = coupon.Query(db).Count()
_, _ = couponredemption.Query(db).Count()
_, _ = billingevent.Query(db).Count()
adb := plan.AuthorityDB(ctx)
if n, _ := plan.Query(adb).Count(); n > 0 {
return
}
for _, p := range []struct {
slug string
price int64
sales bool
}{{"dev", 2000, false}, {"max-5x", 10000, false}, {"max-20x", 20000, false}, {"free", 0, false}, {"enterprise", 0, true}} {
row := plan.New(adb)
row.Slug, row.Name, row.Price, row.ContactSales = p.slug, p.slug, currency.Cents(p.price), p.sales
if err := row.Create(); err != nil {
panic(err)
}
}
}
func at(t time.Time) *time.Time { return &t }
// setCoupon stores a plan coupon through the one write path, failing the test
// on refusal.
func setCoupon(t *testing.T, ctx context.Context, code string, spec CouponSpec) *CouponView {
t.Helper()
v, err := SetCoupon(ctx, code, spec, "[email protected]")
if err != nil {
t.Fatalf("SetCoupon(%s): %v", code, err)
}
return v
}
func TestCouponCode(t *testing.T) {
cases := map[string]string{
"50off": "50OFF",
" 50OFF ": "50OFF",
"Spring_26": "SPRING_26",
"a-b": "A-B",
"": "",
" ": "",
"50 OFF": "",
"50%": "",
"ÉTÉ": "",
"x'; drop": "",
}
long := make([]byte, maxCouponCode+1)
for i := range long {
long[i] = 'A'
}
cases[string(long)] = ""
cases[string(long[:maxCouponCode])] = string(long[:maxCouponCode])
for in, want := range cases {
if got := CouponCode(in); got != want {
t.Errorf("CouponCode(%q) = %q, want %q", in, got, want)
}
}
}
// FindCoupon judges a stored coupon by one rule, and a coupon that is off, not
// yet open, or not a plan coupon reads exactly like one that does not exist.
func TestFindCoupon(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
warmCoupons(ctx)
now := time.Now().UTC()
setCoupon(t, ctx, "LIVE", CouponSpec{Percent: 50, Start: at(now.Add(-time.Hour)), End: at(now.Add(24 * time.Hour)), Limit: 5, Plans: []string{"dev", " MAX-5X ", "dev"}, Enabled: true})
setCoupon(t, ctx, "OPEN", CouponSpec{Percent: 10, Plans: []string{"max-20x"}, Enabled: true})
setCoupon(t, ctx, "OLD", CouponSpec{Percent: 50, Start: at(now.Add(-48 * time.Hour)), End: at(now.Add(-time.Hour)), Plans: []string{"dev"}, Enabled: true})
setCoupon(t, ctx, "SOON", CouponSpec{Percent: 50, Start: at(now.Add(time.Hour)), End: at(now.Add(48 * time.Hour)), Plans: []string{"dev"}, Enabled: true})
setCoupon(t, ctx, "OFF", CouponSpec{Percent: 50, Plans: []string{"dev"}, Enabled: false})
used := setCoupon(t, ctx, "GONE", CouponSpec{Percent: 50, Limit: 1, Plans: []string{"dev"}, Enabled: true})
if _, err := Reserve(ctx, used.Code, "org-gone", "fp:gone", now); err != nil {
t.Fatalf("use GONE: %v", err)
}
// A cart coupon in the same namespace is never honoured as a plan coupon.
db := platform(ctx)
cart := coupon.New(db)
cart.Code_ = "CART"
cart.Type = coupon.Percent
cart.Amount = 90
cart.Enabled = true
if err := cart.Create(); err != nil {
t.Fatalf("seed cart coupon: %v", err)
}
// Two rows under one code answer neither.
for i := 0; i < 2; i++ {
d := coupon.New(db)
d.Code_ = "TWICE"
d.Type = coupon.Percent
d.Filter = coupon.PlanFilter
d.Amount = 50 + i*40
d.Enabled = true
if err := d.Create(); err != nil {
t.Fatalf("seed duplicate coupon: %v", err)
}
}
cases := []struct {
code, reason string
percent int
}{
{"live", "", 50},
{" Live ", "", 50},
{"OPEN", "", 10},
{"OLD", CouponExpired, 0},
{"SOON", CouponUnknown, 0},
{"OFF", CouponUnknown, 0},
{"GONE", CouponExhausted, 0},
{"NOPE", CouponUnknown, 0},
{"CART", CouponUnknown, 0},
{"TWICE", CouponUnknown, 0},
{"", CouponUnknown, 0},
{"no spaces", CouponUnknown, 0},
}
for _, tc := range cases {
o, reason := FindCoupon(ctx, tc.code, now)
if reason != tc.reason {
t.Errorf("FindCoupon(%q) reason = %q, want %q", tc.code, reason, tc.reason)
continue
}
if tc.reason == "" && (o == nil || o.Percent != tc.percent) {
t.Errorf("FindCoupon(%q) = %+v, want %d%%", tc.code, o, tc.percent)
}
}
live, _ := FindCoupon(ctx, "LIVE", now)
for slug, want := range map[string]bool{"dev": true, "max-5x": true, "max-20x": false, "pro": false} {
if got := live.AppliesTo(slug); got != want {
t.Errorf("LIVE.AppliesTo(%q) = %v, want %v", slug, got, want)
}
}
if live.End == nil || live.Months != 1 || live.Coupon().Label() != "LIVE — 50% off first month" {
t.Errorf("LIVE end=%v months=%d label=%q", live.End, live.Months, live.Coupon().Label())
}
open, _ := FindCoupon(ctx, "OPEN", now)
if !open.AppliesTo("max-20x") || open.AppliesTo("dev") || open.End != nil {
t.Errorf("OPEN covers max-20x alone with no end; got end=%v", open.End)
}
// A plan coupon written with no plan list covers nothing, never everything.
bare := coupon.New(db)
bare.Code_ = "BARE"
bare.Type = coupon.Percent
bare.Filter = coupon.PlanFilter
bare.Amount = 50
bare.Enabled = true
if err := bare.Create(); err != nil {
t.Fatalf("seed bare coupon: %v", err)
}
if o, reason := FindCoupon(ctx, "BARE", now); reason != "" || o.AppliesTo("dev") || o.AppliesTo("max-20x") {
t.Errorf("a coupon naming no plan applies somewhere (reason %q)", reason)
}
}
// A coupon names the plans it covers, and each must be a paid plan on sale: an
// empty list, a typo, a free plan and a contact-sales plan are refused at the PUT.
func TestSetCoupon_PlansMustBePaidPlansOnSale(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
warmCoupons(ctx)
for _, plans := range [][]string{nil, {}, {" "}, {"pro"}, {"max"}, {"dev", "pro"}, {"free"}, {"enterprise"}} {
if _, err := SetCoupon(ctx, "PICKY", CouponSpec{Percent: 50, Plans: plans, Enabled: true}, "z"); !IsCouponSpecRefused(err) {
t.Errorf("plans %q = %v, want a spec refusal", plans, err)
}
}
if _, err := ReadCoupon(ctx, "PICKY"); !errors.Is(err, ErrCouponNotFound) {
t.Fatalf("a refused coupon was stored: %v", err)
}
v := setCoupon(t, ctx, "PICKY", CouponSpec{Percent: 50, Plans: []string{" DEV ", "max-5x", "dev"}, Enabled: true})
if len(v.Plans) != 2 || v.Plans[0] != "dev" || v.Plans[1] != "max-5x" {
t.Fatalf("plans stored as %v, want [dev max-5x]", v.Plans)
}
}
// A coupon switched off stays off when read back. The model defaults Enabled to
// true and a query-bound load re-applies defaults, so this pins the load path.
func TestSetCoupon_DisabledStaysDisabled(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
warmCoupons(ctx)
setCoupon(t, ctx, "FLIP", CouponSpec{Percent: 50, Plans: []string{"dev"}, Enabled: true})
setCoupon(t, ctx, "FLIP", CouponSpec{Percent: 50, Plans: []string{"dev"}, Enabled: false})
v, err := ReadCoupon(ctx, "flip")
if err != nil {
t.Fatalf("read: %v", err)
}
if v.Enabled {
t.Fatal("a disabled coupon read back enabled")
}
if _, reason := FindCoupon(ctx, "FLIP", time.Now()); reason != CouponUnknown {
t.Fatalf("disabled coupon reason = %q, want unknown", reason)
}
if _, err := Reserve(ctx, "FLIP", "acme", "fp:1", time.Now()); !errors.As(err, new(CouponRefusal)) {
t.Fatalf("reserve on a disabled coupon = %v, want a refusal", err)
}
}
// One use per org and one per card; a released use is given back whole.
func TestReserve_OncePerOrgAndCard(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
warmCoupons(ctx)
now := time.Now()
setCoupon(t, ctx, "ONCE", CouponSpec{Percent: 50, Limit: 10, Plans: []string{"dev"}, Enabled: true})
h, err := Reserve(ctx, "once", "acme", "fp:a", now)
if err != nil {
t.Fatalf("first use: %v", err)
}
if _, err := Reserve(ctx, "ONCE", "ACME", "fp:b", now); !errors.Is(err, ErrCouponRedeemedByOrg) {
t.Fatalf("same org, other card = %v, want ErrCouponRedeemedByOrg", err)
}
if _, err := Reserve(ctx, "ONCE", "other", "fp:a", now); !errors.Is(err, ErrCouponRedeemedByCard) {
t.Fatalf("other org, same card = %v, want ErrCouponRedeemedByCard", err)
}
if !Redeemed(ctx, "once", "acme") || Redeemed(ctx, "once", "other") {
t.Fatal("Redeemed disagrees with the reservation")
}
if v, _ := ReadCoupon(ctx, "ONCE"); v.Used != 1 {
t.Fatalf("used = %d after one use, want 1", v.Used)
}
h.Release()
h.Release() // the second release is a no-op
if v, _ := ReadCoupon(ctx, "ONCE"); v.Used != 0 {
t.Fatalf("used = %d after release, want 0", v.Used)
}
if Redeemed(ctx, "ONCE", "acme") {
t.Fatal("a released use still marks the org")
}
if _, err := Reserve(ctx, "ONCE", "acme", "fp:a", now); err != nil {
t.Fatalf("the org and card may redeem again after a release: %v", err)
}
if _, err := Reserve(ctx, "ONCE", "acme2", "", now); err == nil {
t.Fatal("a use with no card was reserved")
}
}
// A campaign is redeemed once per org and once per card across all its codes;
// a released use frees the campaign as well as the code.
func TestReserve_OncePerCampaign(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
warmCoupons(ctx)
now := time.Now()
a := setCoupon(t, ctx, "CAMP-A", CouponSpec{Percent: 50, Limit: 1, Plans: []string{"dev"}, Enabled: true, Campaign: "beta"})
setCoupon(t, ctx, "CAMP-B", CouponSpec{Percent: 50, Limit: 1, Plans: []string{"dev"}, Enabled: true, Campaign: "BETA"})
setCoupon(t, ctx, "LONE", CouponSpec{Percent: 50, Limit: 1, Plans: []string{"dev"}, Enabled: true})
if a.Campaign != "BETA" {
t.Fatalf("campaign stored as %q, want BETA", a.Campaign)
}
h, err := Reserve(ctx, "camp-a", "acme", "fp:a", now)
if err != nil {
t.Fatalf("first code: %v", err)
}
if _, err := Reserve(ctx, "CAMP-B", "acme", "fp:b", now); !errors.Is(err, ErrCouponCampaignRedeemed) {
t.Fatalf("same org, other code of the campaign = %v, want ErrCouponCampaignRedeemed", err)
}
if _, err := Reserve(ctx, "CAMP-B", "other", "fp:a", now); !errors.Is(err, ErrCouponCampaignRedeemed) {
t.Fatalf("same card, other code of the campaign = %v, want ErrCouponCampaignRedeemed", err)
}
if !Redeemed(ctx, "CAMP-B", "acme") {
t.Fatal("Redeemed does not see the org's use of the campaign")
}
if _, err := Reserve(ctx, "LONE", "acme", "fp:a", now); err != nil {
t.Fatalf("a code outside the campaign is the org's to use: %v", err)
}
if v, _ := ReadCoupon(ctx, "CAMP-B"); v.Used != 0 {
t.Fatalf("a refused code counted a use: %d", v.Used)
}
h.Release()
if Redeemed(ctx, "CAMP-B", "acme") {
t.Fatal("a released use still marks the campaign")
}
if _, err := Reserve(ctx, "CAMP-B", "acme", "fp:b", now); err != nil {
t.Fatalf("after a release the org may use the campaign's other code: %v", err)
}
}
// A minted code is fresh, single-use, in its campaign, and covers every paid
// plan on sale when it names none.
func TestMintCoupon(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
warmCoupons(ctx)
end := time.Now().Add(30 * 24 * time.Hour).UTC().Truncate(time.Second)
v, err := MintCoupon(ctx, "beta", CouponSpec{Percent: 50, End: &end, Limit: 9, Enabled: true, Campaign: "beta"}, "marketing")
if err != nil {
t.Fatalf("mint: %v", err)
}
if len(v.Code) != len("BETA-")+mintLength || v.Code[:5] != "BETA-" || CouponCode(v.Code) != v.Code {
t.Fatalf("code %q is not BETA- and %d symbols", v.Code, mintLength)
}
if v.Limit != 1 || v.Percent != 50 || !v.Enabled || v.Campaign != "BETA" || v.End == nil || !v.End.Equal(end) {
t.Fatalf("minted %+v", v)
}
if fmt.Sprint(v.Plans) != "[dev max-20x max-5x]" {
t.Fatalf("plans = %v, want every paid plan on sale", v.Plans)
}
w, err := MintCoupon(ctx, "beta", CouponSpec{Percent: 50, Enabled: true}, "marketing")
if err != nil || w.Code == v.Code {
t.Fatalf("second mint = %v, %v; want a different code", w, err)
}
if _, err := MintCoupon(ctx, "", CouponSpec{Percent: 50, Enabled: true}, "marketing"); !IsCouponSpecRefused(err) {
t.Fatalf("an empty prefix = %v, want refused", err)
}
}
// Concurrent buyers never take more uses than the limit.
func TestReserve_ConcurrentNeverExceedsLimit(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
warmCoupons(ctx)
const limit, buyers = 5, 40
setCoupon(t, ctx, "RACE", CouponSpec{Percent: 50, Limit: limit, Plans: []string{"dev"}, Enabled: true})
var wg sync.WaitGroup
var mu sync.Mutex
won, exhausted := 0, 0
start := make(chan struct{})
for i := 0; i < buyers; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
<-start
_, err := Reserve(ctx, "RACE", fmt.Sprintf("org-%d", i), fmt.Sprintf("fp:%d", i), time.Now())
mu.Lock()
defer mu.Unlock()
var cr CouponRefusal
switch {
case err == nil:
won++
case errors.As(err, &cr) && cr.Reason == CouponExhausted:
exhausted++
default:
t.Errorf("buyer %d: %v", i, err)
}
}(i)
}
close(start)
wg.Wait()
if won != limit || exhausted != buyers-limit {
t.Fatalf("won=%d exhausted=%d, want %d and %d", won, exhausted, limit, buyers-limit)
}
v, _ := ReadCoupon(ctx, "RACE")
if v.Used != limit {
t.Fatalf("used = %d, want %d", v.Used, limit)
}
n, _ := couponredemption.Query(platform(ctx)).Filter("CouponCode=", "RACE").Count()
if n != 2*limit {
t.Fatalf("redemption rows = %d, want %d (one per org and one per card)", n, 2*limit)
}
}
func couponApp(super bool) *zip.App {
a := zip.New(zip.Config{DisableStartupMessage: true})
a.Use(zip.H(func(c *zip.Ctx) error {
if super {
c.Locals("iam_authenticated", true)
c.Locals("permissions", bit.Field(permission.Admin|permission.Live))
c.Locals("iam_claims", &auth.IAMClaims{Owner: "admin", Sudo: true, Email: "[email protected]"})
} else {
c.Locals("iam_authenticated", true)
c.Locals("permissions", bit.Field(permission.Admin|permission.Live))
c.Locals("iam_claims", &auth.IAMClaims{Owner: "acme", IsAdmin: true})
}
return c.Next()
}))
a.Raw(http.MethodGet, "/v1/platform/coupons/:code", GetCoupon)
a.Raw(http.MethodPut, "/v1/platform/coupons/:code", PutCoupon)
return a
}
func couponDo(t *testing.T, a *zip.App, method, code, body string) (int, string) {
t.Helper()
var r *http.Request
if body != "" {
r = httptest.NewRequest(method, "/v1/platform/coupons/"+code, bytes.NewReader([]byte(body)))
r.Header.Set("Content-Type", "application/json")
} else {
r = httptest.NewRequest(method, "/v1/platform/coupons/"+code, nil)
}
resp, err := a.Test(r)
if err != nil {
t.Fatalf("%s coupon: %v", method, err)
}
raw, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(raw)
}
// The SuperAdmin surface: PUT creates and replaces, GET reads back, both are
// SuperAdmin only, a bad spec is the caller's 400, and every change is audited
// with who made it.
func TestCouponAdmin(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
warmCoupons(context.Background())
warmPlatformNS()
start := time.Now().UTC().Truncate(time.Second)
end := start.Add(24 * time.Hour)
body := fmt.Sprintf(`{"percent":50,"start":%q,"end":%q,"limit":5,"plans":["dev","max-5x","max-20x"],"enabled":true}`,
start.Format(time.RFC3339), end.Format(time.RFC3339))
if code, _ := couponDo(t, couponApp(false), http.MethodPut, "50OFF", body); code != 403 {
t.Fatalf("org admin PUT = %d, want 403", code)
}
if code, _ := couponDo(t, couponApp(false), http.MethodGet, "50OFF", ""); code != 403 {
t.Fatalf("org admin GET = %d, want 403", code)
}
a := couponApp(true)
code, raw := couponDo(t, a, http.MethodPut, "50off", body)
if code != 200 {
t.Fatalf("PUT = %d %s", code, raw)
}
var v CouponView
if err := json.Unmarshal([]byte(raw), &v); err != nil {
t.Fatalf("decode: %v (%s)", err, raw)
}
if v.Code != "50OFF" || v.Percent != 50 || v.Limit != 5 || !v.Enabled || v.Used != 0 ||
len(v.Plans) != 3 || v.Start == nil || !v.Start.Equal(start) || v.End == nil || !v.End.Equal(end) {
t.Fatalf("PUT answered %+v", v)
}
// A use, then an edit: the edit never resets Used.
if _, err := Reserve(context.Background(), "50OFF", "acme", "fp:1", time.Now()); err != nil {
t.Fatalf("use: %v", err)
}
if code, raw = couponDo(t, a, http.MethodPut, "50OFF", `{"percent":40,"limit":5,"plans":["dev"],"enabled":true}`); code != 200 {
t.Fatalf("second PUT = %d %s", code, raw)
}
code, raw = couponDo(t, a, http.MethodGet, "50OFF", "")
if code != 200 {
t.Fatalf("GET = %d %s", code, raw)
}
v = CouponView{}
_ = json.Unmarshal([]byte(raw), &v)
if v.Percent != 40 || v.Used != 1 || v.Start != nil || v.End != nil || len(v.Plans) != 1 {
t.Fatalf("after edit %+v, want 40%%, used 1, open window, one plan", v)
}
if code, _ := couponDo(t, a, http.MethodGet, "NONE", ""); code != 404 {
t.Fatalf("GET of an unknown code = %d, want 404", code)
}
for _, bad := range []string{
`{"percent":0,"enabled":true}`,
`{"percent":101,"enabled":true}`,
`{"percent":50,"limit":-1,"enabled":true}`,
fmt.Sprintf(`{"percent":50,"start":%q,"end":%q}`, end.Format(time.RFC3339), start.Format(time.RFC3339)),
`not json`,
} {
if code, raw := couponDo(t, a, http.MethodPut, "BAD", bad); code != 400 {
t.Errorf("PUT %s = %d %s, want 400", bad, code, raw)
}
}
if code, _ := couponDo(t, a, http.MethodPut, "no%20spaces", `{"percent":50}`); code != 400 {
t.Errorf("PUT of a malformed code = %d, want 400", code)
}
// The audit trail: one created, one updated, each naming who.
evts := make([]*billingevent.BillingEvent, 0)
if _, err := billingevent.Query(platform(context.Background())).Filter("ObjectId=", "50OFF").GetAll(&evts); err != nil {
t.Fatalf("audit query: %v", err)
}
types := map[string]int{}
for _, e := range evts {
types[e.Type]++
if e.Data["by"] != "[email protected]" {
t.Errorf("audit %s by = %v, want [email protected]", e.Type, e.Data["by"])
}
}
if types["coupon.created"] != 1 || types["coupon.updated"] != 1 {
t.Fatalf("audit events = %v, want one created and one updated", types)
}
}
// A code already held by a cart coupon is not taken over by a plan coupon PUT.
func TestSetCoupon_RefusesNonPlanCode(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
warmCoupons(ctx)
cart := coupon.New(datastore.New(nscontext.WithNamespace(ctx, platformNS)))
cart.Code_ = "STORE"
cart.Type = coupon.Flat
cart.Amount = 500
if err := cart.Create(); err != nil {
t.Fatalf("seed: %v", err)
}
if _, err := SetCoupon(ctx, "STORE", CouponSpec{Percent: 50, Plans: []string{"dev"}, Enabled: true}, "z"); !IsCouponSpecRefused(err) {
t.Fatalf("SetCoupon over a cart coupon = %v, want a spec refusal", err)
}
}