Files
commerce/api/transaction/create.go
zeekay 1943f1c093 Hanzo Commerce
Multi-tenant commerce and billing: orders, subscriptions, invoices, plans,
usage metering and a credit ledger. One Go binary. MIT OR Apache-2.0.

Claude-Session: https://claude.ai/code/session_01SAtaDFm8VDiWrhYHNNtxMw
2026-09-23 21:52:16 -07:00

144 lines
5.5 KiB
Go

package transaction
import (
"errors"
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/log"
"github.com/hanzoai/commerce/middleware"
"github.com/hanzoai/commerce/mintauth"
"github.com/hanzoai/commerce/models/transaction"
"github.com/hanzoai/commerce/models/transaction/util"
"github.com/hanzoai/commerce/models/types/currency"
"github.com/hanzoai/commerce/util/json"
"github.com/hanzoai/commerce/util/json/http"
)
func Create(c *zip.Ctx) error {
// Money move (deposit/withdraw/transfer on the ledger): admin-only, enforced
// inside the handler (route middleware no-ops on the IAM path — Red HIGH-4).
if !middleware.RequireAdmin(c) {
return nil
}
org := middleware.GetOrganization(c)
db := datastore.NewNamespaced(org.Namespaced(c.Context()))
trans := transaction.New(db)
// Decode response body to create new transaction
if err := json.DecodeBytes(c.Body(), trans); err != nil {
return http.Fail(c, 400, err.Error(), err)
}
if trans.Id_ != "" {
log.Warn("Id_ should not be set, set to '%v', setting to ''", trans.Id_, c)
trans.Id_ = ""
}
if trans.Type == transaction.Hold || trans.Type == transaction.HoldRemoved {
log.Error("Transaction type should not be a hold: '%v'", trans.Type, c)
return http.Fail(c, 500, ErrorUseHoldApi.Error(), ErrorUseHoldApi)
}
if trans.Type != transaction.Deposit && trans.Type != transaction.Withdraw && trans.Type != transaction.Transfer {
log.Error("Transaction type is invalid: '%v'", trans.Type, c)
return http.Fail(c, 500, ErrorInvalidType.Error(), ErrorInvalidType)
}
if trans.SourceId == trans.DestinationId && trans.SourceKind == trans.DestinationKind {
log.Error("SourceId, SourceKind should not equal DestinationID, DestinationKind, set to '%v','%v'", trans.SourceId, trans.SourceKind, c)
return http.Fail(c, 500, ErrorCircularTransaction.Error(), ErrorCircularTransaction)
}
if trans.Amount == currency.Cents(0) {
log.Error(ErrorPointlessTransaction.Error(), c)
return http.Fail(c, 500, ErrorPointlessTransaction.Error(), ErrorPointlessTransaction)
}
if trans.Currency == "" {
log.Error(ErrorCurrencyRequired.Error(), c)
return http.Fail(c, 500, ErrorCurrencyRequired.Error(), ErrorCurrencyRequired)
}
// C1-b: this generic ledger endpoint reaches the SAME mint sink as
// /v1/billing/deposit. A Deposit (no funded source) or a credit into the
// gateway-spendable IAM-user wallet MINTS money, so the mint case is gated on
// MayMintMoney (internal service token / platform global admin) — NOT the
// org-level RequireAdmin above, which admits any org owner. Withdraws and
// transfers between the org's OWN funded accounts are not mints and remain
// org-admin. mintauth.Enforce at the datastore sink is the fail-closed
// backstop; this gate returns a clean 403 and authorizes the legitimate write.
if trans.MintRequiresAuthorization() {
if !middleware.MayMintMoney(c) {
return http.Fail(c, 403,
"minting spendable balance requires platform-administrator or internal-service credentials",
errors.New("transaction mint: caller is not a platform admin"))
}
trans.SetContext(mintauth.WithAuthorized(trans.Context()))
}
if !org.Live {
log.Info("Transaction created in test mode.", c)
trans.Test = true
}
// Same read-check-write as Hold, and the same reason it needs serializing:
// a Transfer or Withdraw decides against a balance it read a moment earlier,
// and datastore.RunInTransaction opens no transaction. Taken for every type
// rather than only the spending ones, so a Credit landing concurrently
// cannot move the balance out from under a check in flight.
defer lockFunds(trans.SourceKind, trans.SourceId, string(trans.Currency), trans.Test)()
err := db.RunInTransaction(func(db *datastore.Datastore) error {
if trans.Type == transaction.Transfer || trans.Type == transaction.Withdraw {
datas, err := util.GetTransactionsByCurrency(db.Context, trans.SourceId, trans.SourceKind, trans.Currency, !org.Live)
if err != nil {
return err
}
if trans.SourceId == "" || trans.SourceKind == "" {
log.Error("SourceId and SourceKind are required, provided with '%v', '%v'", trans.SourceId, trans.SourceKind, c)
return ErrorSourceRequired
}
if trans.Type == transaction.Transfer && (trans.DestinationId == "" || trans.DestinationKind == "") {
log.Error("DestinationId and DestinationKind are required, provided with '%v', '%v'", trans.DestinationId, trans.DestinationKind, c)
return ErrorDestinationRequired
} else if trans.Type == transaction.Withdraw {
// Withdraw has no destination
trans.DestinationId = ""
trans.DestinationKind = ""
}
data := datas.Data[trans.Currency]
if data == nil {
log.Error("Source has no funds'%v'", c)
return ErrorInsufficientFunds
}
if data.Balance-data.Holds < trans.Amount {
log.Error("Source has insufficient funds '%v' - '%v' < '%v'", data.Balance, data.Holds, trans.Amount, c)
return ErrorInsufficientFunds
}
} else if trans.Type == transaction.Deposit {
trans.SourceId = ""
trans.SourceKind = ""
if trans.DestinationId == "" || trans.DestinationKind == "" {
log.Error("DestinationId and DestinationKind are required, provided with '%v', '%v'", trans.DestinationId, trans.DestinationKind, c)
return ErrorDestinationRequired
}
}
return trans.Create()
}, nil)
if err != nil {
return http.Fail(c, 500, err.Error(), err)
} else {
c.SetHeader("Location", c.Path()+"/"+trans.Id())
return http.Render(c, 201, trans)
}
}