Files
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

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.Live {
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)
}