Files
commerce/api/billing/customer_balance.go
zooqueenandHanzo Dev 1e5279c146 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 11:38:09 -07:00

144 lines
3.9 KiB
Go

package billing
import (
"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/balancetransaction"
"github.com/hanzoai/commerce/models/types/currency"
"github.com/hanzoai/commerce/util/json/http"
)
// GetCustomerBalance retrieves the customer balance for a customer+currency.
//
// GET /v1/billing/customer-balance?customerId=...&currency=...
func GetCustomerBalance(c *zip.Ctx) error {
org := middleware.GetOrganization(c)
db := datastore.New(org.Namespaced(c.Context()))
customerId := c.Query("customerId")
if customerId == "" {
return http.Fail(c, 400, "customerId is required", nil)
}
curQ := c.Query("currency")
if curQ == "" {
curQ = "usd"
}
cur := currency.Type(curQ)
cb, err := engine.GetOrCreateCustomerBalance(db, customerId, cur)
if err != nil {
log.Error("Failed to get customer balance: %v", err, c)
return http.Fail(c, 500, "failed to get balance", err)
}
return c.JSON(200, map[string]any{
"customerId": cb.CustomerId,
"currency": cb.Currency,
"balance": cb.Balance,
})
}
type adjustBalanceRequest struct {
CustomerId string `json:"customerId"`
Amount int64 `json:"amount"` // positive = credit, negative = debit
Currency string `json:"currency,omitempty"`
Description string `json:"description,omitempty"`
}
// AdjustCustomerBalance manually adjusts a customer's balance.
//
// POST /v1/billing/customer-balance/adjustments
func AdjustCustomerBalance(c *zip.Ctx) error {
org := middleware.GetOrganization(c)
db := datastore.New(org.Namespaced(c.Context()))
var req adjustBalanceRequest
if err := c.Bind(&req); err != nil {
return http.Fail(c, 400, "invalid request body", err)
}
if req.CustomerId == "" {
return http.Fail(c, 400, "customerId is required", nil)
}
if req.Amount == 0 {
return http.Fail(c, 400, "amount must be non-zero", nil)
}
cur := currency.Type("usd")
if req.Currency != "" {
cur = currency.Type(req.Currency)
}
bt, err := engine.AdjustCustomerBalance(db, req.CustomerId, req.Amount, cur, "adjustment", req.Description, middleware.Actor(c))
if err != nil {
log.Error("Failed to adjust balance: %v", err, c)
return http.Fail(c, 500, "failed to adjust balance", err)
}
return c.JSON(200, balanceTransactionResponse(bt))
}
// ListBalanceTransactions lists balance transactions for a customer.
//
// GET /v1/billing/balance-transactions?customerId=...
func ListBalanceTransactions(c *zip.Ctx) error {
org := middleware.GetOrganization(c)
db := datastore.New(org.Namespaced(c.Context()))
rootKey := db.NewKey("synckey", "", 1, nil)
txns := make([]*balancetransaction.BalanceTransaction, 0)
q := balancetransaction.Query(db).Ancestor(rootKey)
if customerId := c.Query("customerId"); customerId != "" {
q = q.Filter("CustomerId=", customerId)
}
iter := q.Order("-Created").Run()
for {
bt := balancetransaction.New(db)
if _, err := iter.Next(bt); err != nil {
break
}
txns = append(txns, bt)
}
results := make([]map[string]interface{}, len(txns))
for i, bt := range txns {
results[i] = balanceTransactionResponse(bt)
}
return c.JSON(200, results)
}
func balanceTransactionResponse(bt *balancetransaction.BalanceTransaction) map[string]interface{} {
resp := map[string]interface{}{
"id": bt.Id(),
"customerId": bt.CustomerId,
"amount": bt.Amount,
"currency": bt.Currency,
"type": bt.Type,
"endingBalance": bt.EndingBalance,
"created": bt.CreatedAt,
}
if bt.Description != "" {
resp["description"] = bt.Description
}
if bt.InvoiceId != "" {
resp["invoiceId"] = bt.InvoiceId
}
if bt.CreditNoteId != "" {
resp["creditNoteId"] = bt.CreditNoteId
}
if bt.SourceRef != "" {
resp["sourceRef"] = bt.SourceRef
}
if bt.Metadata != nil {
resp["metadata"] = bt.Metadata
}
return resp
}