mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
EmbedConfig.Ceiling is declared by the host as Born is: Test where the network holds no real money, "" or Live for no cap, and any other value caps at Test. Under a Test ceiling TestMode is true for every org, a record marked Live included, so it charges sandbox credentials, writes the test books and publishes the sandbox application. TestMode is the one reading of the mode: the ledger, balance, transaction, library, account and Authorize.Net paths, StripeToken and PayPal read it instead of org.Live, so the ceiling holds on each of them. Co-authored-by: Hanzo Dev <[email protected]>
144 lines
5.5 KiB
Go
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.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)
|
|
}
|
|
}
|