Files
commerce/api/transaction/create.go
zeekay f7aabf72f7 SuperAdmin is the identity edge's verdict, and commerce compares no org
IsSuperAdmin folded the case of the home org and fell back to the owner
claim, the org of the application that minted the token, and the money
bits were granted on X-User-Owner == admin. Cloud writes X-User-Owner for
an admin-org application and an admin-org person's API key as well as for
a person, so either could mint money and edit the catalog here while
being nobody in the rest of the same process. Every gate now reads
X-User-IsAdmin, which the edge strips and writes from authz.Claims.Sudo
alone; the standalone EdgeAuth decides it with that predicate over the
verified token's signed membership set. X-User-IsAdmin no longer doubles
as the org-admin bit, which is X-User-IsOrgAdmin. No machine is
SuperAdmin. Global admin is SuperAdmin wherever it named this predicate.
2026-10-18 22:08:47 -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 / SuperAdmin) — 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.TestMode() {
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.TestMode())
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)
}
}