mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
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]>
144 lines
3.9 KiB
Go
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=...¤cy=...
|
|
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
|
|
}
|