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]>
233 lines
8.6 KiB
Go
233 lines
8.6 KiB
Go
package billing
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/zap-proto/zip"
|
|
|
|
"github.com/hanzoai/commerce/billing/credit"
|
|
"github.com/hanzoai/commerce/datastore"
|
|
"github.com/hanzoai/commerce/log"
|
|
"github.com/hanzoai/commerce/middleware"
|
|
"github.com/hanzoai/commerce/models/idempotencykey"
|
|
"github.com/hanzoai/commerce/models/transaction"
|
|
"github.com/hanzoai/commerce/models/types/currency"
|
|
"github.com/hanzoai/commerce/util/json/http"
|
|
|
|
. "github.com/hanzoai/commerce/types"
|
|
)
|
|
|
|
// depositMaxCents is the server-authoritative ceiling on a SINGLE Deposit/Refund,
|
|
// defense-in-depth BEHIND the PlatformOnly gate: even a leaked service token or a
|
|
// fat-fingered internal call cannot mint an absurd balance in one shot. The
|
|
// default ($1,000,000) is generous so legitimate settlement/promo deposits are
|
|
// untouched; tune per-deploy with COMMERCE_DEPOSIT_MAX_CENTS (envCents fails safe
|
|
// to the default on unset/non-positive). Reuses the topup env helper — one way to
|
|
// read a cents bound.
|
|
func depositMaxCents() int64 {
|
|
return envCents("COMMERCE_DEPOSIT_MAX_CENTS", 100_000_000)
|
|
}
|
|
|
|
type depositRequest struct {
|
|
User string `json:"user"`
|
|
Currency string `json:"currency"`
|
|
Amount int64 `json:"amount"` // cents
|
|
Notes string `json:"notes"`
|
|
Tags string `json:"tags"`
|
|
// ExpiresIn is days until expiry. Unset takes credit.LifetimeDays. A caller
|
|
// may name a shorter promotion; nothing may outlive the policy.
|
|
ExpiresIn int `json:"expiresIn"`
|
|
}
|
|
|
|
// expiryDays resolves how long a credit lives: the caller's own number when it
|
|
// asked for one, the policy otherwise.
|
|
//
|
|
// A request may shorten a credit's life and may not extend it past the policy —
|
|
// so a caller cannot mint money that outlives the year by naming a bigger number,
|
|
// which is the one way an expiry becomes advisory.
|
|
func expiryDays(requested int) int {
|
|
if requested > 0 && requested < credit.LifetimeDays {
|
|
return requested
|
|
}
|
|
return credit.LifetimeDays
|
|
}
|
|
|
|
// Deposit creates a deposit (credit) transaction for an IAM user.
|
|
//
|
|
// POST /v1/billing/deposit
|
|
//
|
|
// Used by internal services to add funds to a user's account (payment
|
|
// processor settlement, manual credit, promotional grants, etc.).
|
|
func Deposit(c *zip.Ctx) error {
|
|
org := middleware.GetOrganization(c)
|
|
// ONE central commerce ledger, org-scoped by the namespace column (New binds
|
|
// the namespace from org.Namespaced(c.Context()) — the same store+scoping the LLM
|
|
// gateway prepaid gate and every balance read use: tier.go/zap.go/usage.go and
|
|
// models/transaction/util). NOT NewNamespaced: that split deposits into
|
|
// per-org SQLite FILES (/app/data/orgs/<org>/data.db) while the gate read the
|
|
// central store → every balance read $0 → 402 on all AI. Tenancy is a value
|
|
// (the row's org namespace + destinationId), not a place (a physical file).
|
|
db := datastore.New(org.Namespaced(c.Context()))
|
|
|
|
var req depositRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return http.Fail(c, 400, "invalid request body", err)
|
|
}
|
|
|
|
req.User = strings.ToLower(strings.TrimSpace(req.User))
|
|
if req.User == "" {
|
|
return http.Fail(c, 400, "user is required", nil)
|
|
}
|
|
|
|
if req.Amount <= 0 {
|
|
return http.Fail(c, 400, "amount must be positive", nil)
|
|
}
|
|
|
|
// Server-authoritative ceiling (H1). Bounds a single mint even for a trusted
|
|
// caller — no unbounded credit in one request.
|
|
if maxCents := depositMaxCents(); req.Amount > maxCents {
|
|
return http.Fail(c, 400, fmt.Sprintf("amount %d exceeds maximum deposit of %d cents", req.Amount, maxCents), nil)
|
|
}
|
|
|
|
cur := currency.Type(strings.ToLower(req.Currency))
|
|
if cur == "" {
|
|
cur = "usd"
|
|
}
|
|
|
|
// MONEY-IN NAMES THE EVENT THAT CAUSED IT. Required, on every path.
|
|
//
|
|
// A credit is caused by exactly one external fact — a settlement, an on-chain
|
|
// transfer, an admin's decision — and only the caller knows which. Without
|
|
// that name, a retry of settlement X and a second genuine settlement of the
|
|
// same amount are THE SAME REQUEST, and no server-side rule can separate them:
|
|
//
|
|
// additive by default -> a retried webhook funds the wallet twice. Every
|
|
// payment processor retries; this is not an edge case.
|
|
// dedupe on a window -> two real settlements of equal amount minutes apart
|
|
// collapse into one, and the customer is credited for
|
|
// one of the two payments they made.
|
|
//
|
|
// The first loses OUR money, the second loses THEIRS. Guessing picks a victim,
|
|
// so this endpoint does not guess — it refuses. That is why deposit does NOT
|
|
// use guardKey (idem.go), which windows a card money move on stable facts: a
|
|
// card charge is a request the customer just made and is watching, while a
|
|
// settlement is a machine retrying an event minutes or hours old. Same-shaped
|
|
// helper, different truth.
|
|
//
|
|
// The key IS the reference — use the settlement/payment id or the tx hash, so
|
|
// the guard is bound to the money move rather than to a caller's bookkeeping.
|
|
idemKey := strings.TrimSpace(c.Header("X-Idempotency-Key"))
|
|
if idemKey == "" {
|
|
return http.Fail(c, 400, "X-Idempotency-Key is required: send the settlement id, payment id, or tx hash that caused this credit", nil)
|
|
}
|
|
|
|
// Step 4: when the chain-backed ledger is enabled, a deposit MINTS on-chain
|
|
// HUSD to the org's derived address (bucketed by its tags) instead of writing
|
|
// a DB credit row — the value is created by the treasury key, not this process.
|
|
// The mint carries the SAME key the DB arm guards on, so a replay is refused
|
|
// identically whichever ledger is live.
|
|
if chainCreditEnabled() {
|
|
mintKey := "deposit:" + req.User + ":" + idemKey
|
|
reason := req.Notes
|
|
if reason == "" {
|
|
reason = "deposit"
|
|
}
|
|
rc, err := chainMintCredit(c.Context(), org, req.User, req.Amount, bucketForTags(req.Tags), mintedBy(c, reason), mintKey)
|
|
if err != nil {
|
|
log.Error("chain deposit mint failed for %s: %v", req.User, err, c)
|
|
return http.Fail(c, 502, "on-chain credit mint failed", err)
|
|
}
|
|
return c.JSON(201, map[string]any{
|
|
"transactionId": rc.TxHash,
|
|
"user": req.User,
|
|
"amount": req.Amount,
|
|
"currency": cur,
|
|
"type": "deposit",
|
|
"tags": req.Tags,
|
|
"onChain": true,
|
|
"txHash": rc.TxHash,
|
|
"replayed": rc.Replayed,
|
|
})
|
|
}
|
|
|
|
// The guard on the key required above: a completed key replays the stored
|
|
// body, an in-flight key 409s, so a retry credits AT MOST ONCE. Scoped to the
|
|
// subject so keys never collide across users; the datastore is already
|
|
// org-namespaced.
|
|
//
|
|
// A store that cannot answer FAILS CLOSED. It is the one moment the guard
|
|
// cannot tell a first attempt from a retry, and crediting anyway is how a
|
|
// blip becomes a duplicate mint — the same decision idemBegin's seam exists
|
|
// to keep honest on the card path.
|
|
var idemRec *idempotencykey.IdempotencyKey
|
|
rec, replay, gerr := idemBegin(db, "billing-deposit:"+req.User, idemKey)
|
|
switch {
|
|
case gerr != nil:
|
|
log.Error("deposit idempotency Begin failed (user=%s): %v", req.User, gerr, c)
|
|
return http.Fail(c, 503, "cannot verify this deposit is not a replay; retry with the same key", gerr)
|
|
case replay:
|
|
if rec.Status == idempotencykey.StatusCompleted && rec.Response != "" {
|
|
c.SetHeader("Content-Type", "application/json")
|
|
return c.Bytes(200, []byte(rec.Response))
|
|
}
|
|
return http.Fail(c, 409, "deposit already in progress", nil)
|
|
default:
|
|
idemRec = rec
|
|
}
|
|
|
|
notes := req.Notes
|
|
if notes == "" {
|
|
notes = fmt.Sprintf("Deposit: %d cents %s", req.Amount, cur)
|
|
}
|
|
|
|
trans := transaction.New(db)
|
|
trans.Type = transaction.Deposit
|
|
trans.DestinationId = req.User
|
|
trans.DestinationKind = "iam-user"
|
|
trans.Currency = cur
|
|
trans.Amount = currency.Cents(req.Amount)
|
|
trans.Notes = notes
|
|
trans.Tags = req.Tags
|
|
trans.Metadata = Map{"mintedBy": middleware.Actor(c)}
|
|
|
|
trans.ExpiresAt = time.Now().AddDate(0, 0, expiryDays(req.ExpiresIn))
|
|
|
|
if org.TestMode() {
|
|
trans.Test = true
|
|
}
|
|
|
|
if err := trans.Create(); err != nil {
|
|
// No balance moved — release the guard so a later retry is not wedged.
|
|
if idemRec != nil {
|
|
_ = idemRec.Delete()
|
|
}
|
|
log.Error("Failed to create deposit transaction: %v", err, c)
|
|
return http.Fail(c, 500, "failed to create deposit", err)
|
|
}
|
|
|
|
resp := map[string]any{
|
|
"transactionId": trans.Id(),
|
|
"user": req.User,
|
|
"amount": req.Amount,
|
|
"currency": cur,
|
|
"type": "deposit",
|
|
"tags": req.Tags,
|
|
}
|
|
if !trans.ExpiresAt.IsZero() {
|
|
resp["expiresAt"] = trans.ExpiresAt
|
|
}
|
|
|
|
// Seal the guard with the exact success body so a same-key retry replays it
|
|
// verbatim (no second credit).
|
|
if idemRec != nil {
|
|
if body, mErr := json.Marshal(resp); mErr == nil {
|
|
_ = idempotencykey.Complete(idemRec, string(body))
|
|
}
|
|
}
|
|
|
|
return c.JSON(201, resp)
|
|
}
|