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]>
517 lines
14 KiB
Go
517 lines
14 KiB
Go
package organization
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hanzoai/orm"
|
|
|
|
"github.com/ryanuber/go-glob"
|
|
|
|
"github.com/hanzoai/commerce/datastore"
|
|
"github.com/hanzoai/commerce/log"
|
|
"github.com/hanzoai/commerce/mintauth"
|
|
"github.com/hanzoai/commerce/models/app"
|
|
"github.com/hanzoai/commerce/models/mixin"
|
|
"github.com/hanzoai/commerce/models/oauthtoken"
|
|
"github.com/hanzoai/commerce/models/store"
|
|
"github.com/hanzoai/commerce/models/types/analytics"
|
|
"github.com/hanzoai/commerce/models/types/currency"
|
|
"github.com/hanzoai/commerce/models/types/pricing"
|
|
"github.com/hanzoai/commerce/models/user"
|
|
"github.com/hanzoai/commerce/models/wallet"
|
|
"github.com/hanzoai/commerce/types/email"
|
|
"github.com/hanzoai/commerce/types/integration"
|
|
"github.com/hanzoai/commerce/types/socialmedia"
|
|
"github.com/hanzoai/commerce/types/website"
|
|
"github.com/hanzoai/commerce/util/json"
|
|
"github.com/hanzoai/commerce/util/nscontext"
|
|
"github.com/hanzoai/commerce/util/permission"
|
|
"github.com/hanzoai/commerce/util/val"
|
|
|
|
. "github.com/hanzoai/commerce/types"
|
|
)
|
|
|
|
var kind = "organization"
|
|
|
|
func init() { orm.Register[Organization]("organization") }
|
|
|
|
type Organization struct {
|
|
mixin.Model[Organization]
|
|
mixin.AccessTokens
|
|
wallet.WalletHolder
|
|
|
|
Name string `json:"name"`
|
|
FullName string `json:"fullName"`
|
|
Owners []string `json:"owners,omitempty" datastore:",noindex"`
|
|
Admins []string `json:"admins,omitempty" datastore:",noindex"`
|
|
Moderators []string `json:"moderators,omitempty" datastore:",noindex"`
|
|
Enabled bool `json:"enabled"`
|
|
|
|
BillingEmail string `json:"billingEmail,omitempty"`
|
|
Phone string `json:"phone,omitempty"`
|
|
Address Address `json:"address,omitempty"`
|
|
SocialMedia socialmedia.SocialMedia `json:"socialMedia,omitEmpty"`
|
|
Websites []website.Website `json:"websites,omitEmpty"`
|
|
WalletPassphrase string `json:"-"`
|
|
|
|
Timezone string `json:"timezone"`
|
|
|
|
Country string `json:"country"`
|
|
TaxId string `json:"taxId"`
|
|
|
|
// Used in generating email templates
|
|
LogoUrl string `json:"logoUrl"`
|
|
|
|
// Where is the user's dashboard?
|
|
DashboardUrl string `json:"dashboardUrl"`
|
|
|
|
// Fee structure for this organization
|
|
Fees pricing.Fees `json:"fees" datastore:",noindex"`
|
|
|
|
// What this org is charged to RECEIVE a crypto deposit, per chain, keyed by
|
|
// the deposit rail's chain names ("base", "ethereum", "solana", "ton",
|
|
// "xrpl"). Absent means no negotiated terms and the platform default applies;
|
|
// an entry that is PRESENT and zero means this org pays nothing, which is a
|
|
// different statement and must not be confused with the first.
|
|
//
|
|
// Separate from Fees above on purpose — see pricing.CryptoDeposit.
|
|
CryptoDeposit map[string]pricing.CryptoDeposit `json:"cryptoDeposit,omitempty" datastore:",noindex"`
|
|
|
|
// Partner fees (private, should be up to partner to disclose)
|
|
Partners []pricing.Partner `json:"-" datastore:",noindex"`
|
|
|
|
// Email settings
|
|
Email email.Settings `json:"email" datastore:",noindex"`
|
|
|
|
// Default Store
|
|
DefaultStore string `json:"defaultStore"`
|
|
|
|
// Default App
|
|
DefaultApp string `json:"defaultApp"`
|
|
|
|
// Plan settings
|
|
Plan struct {
|
|
PlanId string
|
|
StartDate time.Time
|
|
} `json:"-"`
|
|
|
|
// Affiliate configuration
|
|
Affiliate integration.Affiliate `json:"-" datastore:",noindex"`
|
|
|
|
// Signup options
|
|
SignUpOptions struct {
|
|
// Controls the enabled status of account after creation
|
|
AccountsEnabledByDefault bool `json:"accountsEnabledByDefault"`
|
|
|
|
// Allow direct affiliate sign up
|
|
AllowAffiliateSignup bool `json:"allowAffiliateSignup"`
|
|
|
|
// Turns off required backend checks
|
|
NoNameRequired bool `json:"noNameRequired"`
|
|
NoPasswordRequired bool `json:"noPasswordRequired"`
|
|
|
|
// Requires password set on create confirmation
|
|
TwoStageEnabled bool `json:"twoStageEnabled"`
|
|
ImmediateLogin bool `json:"immediateLogin"`
|
|
|
|
UsernameRequired bool `json:"usernameRequired"`
|
|
} `json:"signUpOptions" datastore:",noindex"`
|
|
|
|
// Whether we use live or test tokens, mostly applicable to stripe
|
|
Live bool `json:"live"`
|
|
|
|
// TODO: Remain to PaymentWhitelist for clarity
|
|
// List of comma deliminated email globs that result in charges of 50 cents
|
|
EmailWhitelist string `json:"emailWhitelist" datastore:",noindex"`
|
|
|
|
// integration
|
|
Integrations integration.Integrations `json:"-" datastore:"-"`
|
|
Integrations_ string `json:"-" datastore:",noindex"`
|
|
|
|
// integration (deprecated)
|
|
|
|
// Analytics config
|
|
Analytics analytics.Analytics `json:"analytics" datastore:",noindex"`
|
|
|
|
// Bitcoi settings
|
|
Bitcoin integration.Bitcoin `json:"-"`
|
|
|
|
// Ethereum settings
|
|
Ethereum integration.Ethereum `json:"-"`
|
|
|
|
// Mailchimp settings
|
|
Mailchimp integration.Mailchimp `json:"-"`
|
|
|
|
// Mandrill settings
|
|
Mandrill integration.Mandrill `json:"-"`
|
|
|
|
// Mercury bank connection
|
|
Mercury integration.Mercury `json:"-"`
|
|
|
|
// Netlify settings
|
|
Netlify integration.Netlify `json:"-"`
|
|
|
|
// Paypal connection
|
|
Paypal integration.Paypal `json:"-"`
|
|
|
|
// Plaid connection
|
|
Plaid integration.Plaid `json:"-"`
|
|
|
|
// ReAmaze settings
|
|
Reamaze integration.Reamaze `json:"-"`
|
|
|
|
Recaptcha integration.Recaptcha `json:"-" datastore:",noindex"`
|
|
|
|
// Salesforce settings
|
|
Salesforce integration.Salesforce `json:"-"`
|
|
|
|
// Shipwire settings
|
|
Shipwire integration.Shipwire `json:"-"`
|
|
|
|
// Square connection
|
|
Square integration.Square `json:"-"`
|
|
|
|
// Stripe connection
|
|
Stripe integration.Stripe `json:"-"`
|
|
|
|
// Wire transfer settings
|
|
Wire integration.WireTransfer `json:"-"`
|
|
|
|
// AuthorizeNet connection
|
|
AuthorizeNet integration.AuthorizeNet `json:"-"`
|
|
|
|
// Adyen connection
|
|
Adyen integration.Adyen `json:"-"`
|
|
|
|
// Braintree connection
|
|
Braintree integration.Braintree `json:"-"`
|
|
|
|
// Recurly connection
|
|
Recurly integration.Recurly `json:"-"`
|
|
|
|
// LemonSqueezy connection
|
|
LemonSqueezy integration.LemonSqueezy `json:"-"`
|
|
|
|
SecurityToken integration.SecurityToken `json:"-"`
|
|
|
|
Currency currency.Type `json:"currency"`
|
|
}
|
|
|
|
func (o *Organization) Load(ps []datastore.Property) (err error) {
|
|
// Ensure we're initialized
|
|
o.Defaults()
|
|
|
|
// Load supported properties
|
|
if err = datastore.LoadStruct(o, ps); err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(o.Integrations_) > 0 {
|
|
err = json.DecodeBytes([]byte(o.Integrations_), &o.Integrations)
|
|
}
|
|
|
|
for i, in := range o.Integrations {
|
|
err = integration.Decode(&in, &in)
|
|
o.Integrations[i] = in
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
func (o *Organization) Save() (ps []datastore.Property, err error) {
|
|
// Serialize unsupported properties
|
|
o.Integrations_ = string(json.EncodeBytes(o.Integrations))
|
|
|
|
// Save properties
|
|
return datastore.SaveStruct(o)
|
|
}
|
|
|
|
func (o Organization) GetStripeAccessToken(userId string) (string, error) {
|
|
if o.Stripe.Live.UserId == userId {
|
|
return o.Stripe.Live.AccessToken, nil
|
|
}
|
|
if o.Stripe.Test.UserId == userId {
|
|
return o.Stripe.Test.AccessToken, nil
|
|
}
|
|
return "", StripeAccessTokenNotFound{userId, o.Stripe.Live.UserId, o.Stripe.Test.UserId}
|
|
}
|
|
|
|
func (o *Organization) Validator() *val.Validator {
|
|
return val.New().Check("FullName").Exists()
|
|
}
|
|
|
|
// Old JWT / AccessToken AUTH
|
|
func (o *Organization) AddDefaultTokens() {
|
|
o.RemoveToken("live-secret-key")
|
|
o.RemoveToken("live-published-key")
|
|
o.RemoveToken("test-secret-key")
|
|
o.RemoveToken("test-published-key")
|
|
o.AddToken("live-secret-key", permission.Admin|permission.Live)
|
|
o.AddToken("live-published-key", permission.Published|permission.Live|permission.ReadCoupon|permission.ReadProduct|permission.WriteReferrer)
|
|
o.AddToken("test-secret-key", permission.Admin|permission.Test)
|
|
o.AddToken("test-published-key", permission.Published|permission.Test|permission.ReadCoupon|permission.ReadProduct|permission.WriteReferrer)
|
|
}
|
|
|
|
// New JWT / OAUTH
|
|
func (o *Organization) ResetReferenceToken(usr *user.User, claims oauthtoken.Claims) (*oauthtoken.Token, error) {
|
|
if usr.Key().Namespace() != "" {
|
|
return nil, UserNotTopLevel
|
|
}
|
|
|
|
if _, _, err := o.RevokeReferenceToken(usr); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
tok := oauthtoken.New(o.Datastore())
|
|
|
|
log.Info("Creating New Reference Token for user '%s' from org '%s'.", usr.Id(), o.Name, o.Context())
|
|
|
|
claims.OrganizationName = o.Name
|
|
claims.UserId = usr.Id()
|
|
claims.Type = oauthtoken.Reference
|
|
claims.JTI = tok.Id()
|
|
claims.IssuedAt = time.Now().Unix()
|
|
|
|
tok.Claims = claims
|
|
tok.AccessPeriod = 24 * 30
|
|
|
|
if _, err := tok.Encode(o.SecretKey); err != nil {
|
|
log.Info("Failed to create New Reference Token for user '%s' from org '%s': %s", usr.Id(), o.Name, err, o.Context())
|
|
return nil, err
|
|
}
|
|
|
|
tok.MustCreate()
|
|
|
|
return tok, nil
|
|
}
|
|
|
|
func (o *Organization) GetReferenceToken(usr *user.User) (*oauthtoken.Token, bool, error) {
|
|
if usr.Key().Namespace() != "" {
|
|
return nil, false, UserNotTopLevel
|
|
}
|
|
|
|
log.Info("Getting Reference Token for user '%s' from org '%s'.", usr.Id(), o.Name, o.Context())
|
|
|
|
tok := oauthtoken.New(o.Datastore())
|
|
|
|
if ok, err := tok.Query().Filter("Claims.OrganizationName=", o.Name).Filter("Claims.Type=", oauthtoken.Reference).Filter("Revoked=", false).Filter("Claims.UserId=", usr.Id()).Get(); !ok {
|
|
log.Info("Failed to get Reference Token for user '%s' from org '%s': %s", usr.Id(), o.Name, err, o.Context())
|
|
return nil, false, err
|
|
}
|
|
|
|
return tok, true, nil
|
|
}
|
|
|
|
func (o *Organization) RevokeReferenceToken(usr *user.User) (*oauthtoken.Token, bool, error) {
|
|
if usr.Key().Namespace() != "" {
|
|
return nil, false, UserNotTopLevel
|
|
}
|
|
|
|
log.Info("Revoking Reference Token for user '%s' from org '%s'.", usr.Id(), o.Name, o.Context())
|
|
|
|
if tok, ok, err := o.GetReferenceToken(usr); !ok {
|
|
log.Info("Failed to revoke Reference Token for user '%s' from org '%s': %s", usr.Id(), o.Name, err, o.Context())
|
|
return nil, false, err
|
|
} else {
|
|
tok.Revoke()
|
|
return tok, true, nil
|
|
}
|
|
}
|
|
|
|
func userId(userOrId interface{}) string {
|
|
userid := ""
|
|
switch v := userOrId.(type) {
|
|
case *user.User:
|
|
userid = v.Id()
|
|
case string:
|
|
userid = v
|
|
}
|
|
return userid
|
|
}
|
|
|
|
func (o Organization) IsAdmin(userOrId interface{}) bool {
|
|
userid := userId(userOrId)
|
|
|
|
for i := range o.Admins {
|
|
if o.Admins[i] == userid {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (o Organization) IsOwner(userOrId interface{}) bool {
|
|
userid := userId(userOrId)
|
|
|
|
for i := range o.Owners {
|
|
if o.Owners[i] == userid {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Add admin to organization
|
|
func (o *Organization) AddAdmin(userOrId string) {
|
|
userid := userId(userOrId)
|
|
|
|
if !o.IsAdmin(userid) {
|
|
o.Admins = append(o.Admins, userid)
|
|
}
|
|
}
|
|
|
|
// Add admin to organization
|
|
func (o *Organization) AddOwner(userOrId string) {
|
|
userid := userId(userOrId)
|
|
|
|
if !o.IsOwner(userid) {
|
|
o.Owners = append(o.Owners, userid)
|
|
}
|
|
}
|
|
|
|
// Namespace returns the datastore namespace for this organization.
|
|
//
|
|
// SECURITY: every org — without exception — is strictly scoped to its own
|
|
// name. There is NO org-name escape hatch. The legacy
|
|
// `Name=="platform" -> "" (global/cross-org namespace)` bypass was REMOVED:
|
|
// it keyed cross-org datastore access on an org-NAME string, fully decoupled
|
|
// from real platform-admin identity, so anyone who could land in (or forge)
|
|
// an org named "platform" read/wrote the global namespace where every org's
|
|
// records live (Red — privilege escalation via the empty namespace).
|
|
// Cross-org / superadmin datastore access is now gated EXCLUSIVELY on
|
|
// auth.IAMClaims.IsSuperAdmin() (owner=="admin")
|
|
// at the handler/middleware layer — never inferred from the org name here.
|
|
func (o Organization) Namespace() string {
|
|
return o.Name
|
|
}
|
|
|
|
// Namespaced returns a context scoped to this organization's namespace.
|
|
// An inbound HTTP request arrives already mint-gated (mintauth.WithGate) — the
|
|
// request middleware stamps the gate ONCE at the HTTP→datastore boundary, so a
|
|
// gated context IS the HTTP boundary signal (the single structural gate point).
|
|
// For such contexts we detach from the request lifecycle with
|
|
// context.WithoutCancel so the database context survives a browser disconnect or
|
|
// upstream proxy timeout mid-query — while PRESERVING the request's context
|
|
// values (trace and, critically, any mintauth grant applied by PlatformOnly /
|
|
// settled-payment middleware). Using WithoutCancel instead of
|
|
// context.Background() is what keeps the mint authorization (a context value)
|
|
// reachable through the detach.
|
|
func (o Organization) Namespaced(ctx context.Context) context.Context {
|
|
if mintauth.Gated(ctx) {
|
|
// Drop cancellation (survive HTTP disconnect) but keep values: the mint
|
|
// gate/grant and trace ride through unchanged.
|
|
ctx = context.WithoutCancel(ctx)
|
|
}
|
|
|
|
// Ungated callers are internal (crons, migrations, tests): NOT gated, so
|
|
// internal ledger writes are unaffected by the mint invariant.
|
|
return nscontext.WithNamespace(ctx, o.Namespace())
|
|
}
|
|
|
|
func (o Organization) SquareConfig(sandbox bool) integration.SquareConnection {
|
|
if sandbox {
|
|
return o.Square.Sandbox
|
|
}
|
|
return o.Square.Production
|
|
}
|
|
|
|
func (o Organization) StripeToken() string {
|
|
if !o.TestMode() {
|
|
return o.Stripe.Live.AccessToken
|
|
}
|
|
|
|
return o.Stripe.Test.AccessToken
|
|
}
|
|
|
|
func (o Organization) AuthorizeNetToken(sandbox bool) integration.AuthorizeNetConnection {
|
|
if sandbox {
|
|
return o.AuthorizeNet.Sandbox
|
|
}
|
|
|
|
return o.AuthorizeNet.Live
|
|
}
|
|
|
|
func (o Organization) IsTestEmail(email string) bool {
|
|
if email == "" || o.EmailWhitelist == "" {
|
|
return false
|
|
}
|
|
|
|
globs := strings.Split(strings.Replace(o.EmailWhitelist, " ", "", -1), ",")
|
|
|
|
for _, g := range globs {
|
|
if glob.Glob(g, email) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func (o Organization) Pricing() (*pricing.Fees, []pricing.Partner) {
|
|
// Ensure our id is set on fees used
|
|
fees := o.Fees
|
|
fees.Id = o.Id()
|
|
return &fees, o.Partners
|
|
}
|
|
|
|
// Return DefaultStore
|
|
func (o Organization) GetDefaultStore() (*store.Store, error) {
|
|
db := datastore.New(o.Namespaced(o.Context()))
|
|
s := store.New(db)
|
|
if err := s.GetById(o.DefaultStore); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// Return DefaultApp
|
|
func (o Organization) GetDefaultApp() (*app.App, error) {
|
|
db := datastore.New(o.Namespaced(o.Context()))
|
|
a := app.New(db)
|
|
if err := a.GetById(o.DefaultApp); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return a, nil
|
|
}
|
|
|
|
func (o *Organization) Defaults() {
|
|
o.Admins = make([]string, 0)
|
|
o.Moderators = make([]string, 0)
|
|
|
|
o.Enabled = true
|
|
|
|
o.Fees.Card.Flat = 50
|
|
o.Fees.Card.Percent = 0.05
|
|
o.Fees.Bitcoin.Flat = 0
|
|
o.Fees.Bitcoin.Percent = 0.05
|
|
o.Fees.Ethereum.Flat = 0
|
|
o.Fees.Ethereum.Percent = 0.05
|
|
o.Fees.Affiliate.Flat = 30
|
|
o.Fees.Affiliate.Percent = 0.30
|
|
|
|
o.Partners = make([]pricing.Partner, 0)
|
|
}
|
|
|
|
func (o *Organization) Init(db *datastore.Datastore) {
|
|
o.Model.Init(db)
|
|
o.AccessTokens.Init(o)
|
|
}
|
|
|
|
func New(db *datastore.Datastore) *Organization {
|
|
o := new(Organization)
|
|
o.Init(db)
|
|
o.Defaults()
|
|
return o
|
|
}
|
|
|
|
func Query(db *datastore.Datastore) datastore.Query {
|
|
return db.Query(kind)
|
|
}
|