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

32 lines
977 B
Go

// Copyright (c) 2014-present Hanzo AI, Inc.
// Licensed under MIT OR Apache-2.0. See LICENSE-MIT and LICENSE-APACHE.
package middleware
import (
"strings"
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/middleware/iammiddleware"
)
// Actor names the principal behind a privileged act — a mint, a comp, a balance
// adjustment, a platform coupon edit — for the row or audit record it writes.
// Every such route admits only the platform principal (MayMintMoney /
// RequirePlatformAdmin: owner "admin"), whose validated claims name them: the
// email, else owner/name, else the subject, else the owner.
func Actor(c *zip.Ctx) string {
claims := iammiddleware.GetIAMClaims(c)
if e := strings.TrimSpace(claims.Email); e != "" {
return e
}
if n := strings.TrimSpace(claims.Name); n != "" {
return strings.TrimSpace(claims.Owner) + "/" + n
}
if s := strings.TrimSpace(claims.Subject); s != "" {
return s
}
return strings.TrimSpace(claims.Owner)
}