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

370 lines
9.8 KiB
Go

package referrer
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/delay"
"github.com/hanzoai/commerce/log"
"github.com/hanzoai/commerce/models/affiliate"
"github.com/hanzoai/commerce/models/mixin"
"github.com/hanzoai/commerce/models/referral"
"github.com/hanzoai/commerce/models/referralprogram"
"github.com/hanzoai/commerce/models/transaction"
"github.com/hanzoai/commerce/models/types/client"
"github.com/hanzoai/commerce/models/types/currency"
"github.com/hanzoai/commerce/util/json"
"github.com/hanzoai/commerce/util/rand"
"github.com/hanzoai/commerce/util/timeutil"
"github.com/hanzoai/orm"
. "github.com/hanzoai/commerce/types"
)
func init() { orm.Register[Referrer]("referrer") }
const (
// CodeLength is how many characters a minted referral code has.
CodeLength = 8
// codeAlphabet is what a referral code is made of: the uppercase
// alphanumerics, so it survives being read down a phone line.
codeAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
)
// NewCode mints a referral code. Callers that accept a caller-supplied code
// still have to check it is free — this only produces the shape.
func NewCode() string {
return rand.String(CodeLength, codeAlphabet)
}
// Is a link that can refer customers to buy products
type Referrer struct {
mixin.Model[Referrer]
Code string `json:"code"`
Program referralprogram.ReferralProgram `json:"program"`
ProgramId string `json:"programId"`
OrderId string `json:"orderId"`
UserId string `json:"userId"`
AffiliateId string `json:"affiliateId,omitempty"`
Affiliate affiliate.Affiliate `json:"affiliate,omitempty" datastore:"-"`
FirstReferredAt time.Time `json:"firstReferredAt"`
Client client.Client `json:"-"`
Blacklisted bool `json:"blacklisted,omitempty"`
Duplicate bool `json:"duplicate,omitempty"`
State Map `json:"state,omitempty" datastore:"-"`
State_ string `json:"-" datastore:",noindex"`
}
type Referrent interface {
Id() string
Kind() string
Total() currency.Cents
}
func (r *Referrer) Save() (ps []datastore.Property, err error) {
// Serialize unsupported properties
r.State_ = string(json.EncodeBytes(&r.State))
// Save properties
return datastore.SaveStruct(r)
}
func (r *Referrer) Load(ps []datastore.Property) (err error) {
// Ensure we're initialized
r.Defaults()
// Load supported properties
if err = datastore.LoadStruct(r, ps); err != nil {
return err
}
if len(r.State_) > 0 {
err = json.DecodeBytes([]byte(r.State_), &r.State)
}
return err
}
func (r *Referrer) SaveReferral(ctx context.Context, orgId string, event referral.Event, rfn Referrent, userId string, test bool) (*referral.Referral, error) {
if !test && userId == r.UserId {
return nil, errors.New("User cannot refer themselves")
}
log.Debug("Creating referral")
// Create new referral
rfl := referral.New(r.Datastore())
rfl.Type = event
rfl.Referrer.Id = r.Id()
rfl.Referrer.AffiliateId = r.AffiliateId
rfl.Referrer.UserId = r.UserId
// Save referrent's id
switch rfn.Kind() {
case "order":
log.Debug("Saving referral for new order")
rfl.OrderId = rfn.Id()
case "user":
log.Debug("Saving referral for new user")
rfl.UserId = rfn.Id()
}
log.JSON("Saving referral", rfl)
// Try to save referral
if err := rfl.Create(); err != nil {
return rfl, err
}
// If this is the first referral, update referrer
if timeutil.IsZero(r.FirstReferredAt) {
r.FirstReferredAt = time.Now()
r.Update()
}
if err := r.LoadReferralProgram(); err != nil {
return rfl, err
}
// Apply any program actions if applicable
if err := r.ApplyActions(ctx, orgId, event, &r.Program, rfn, rfl, test); err != nil {
return rfl, err
}
return rfl, nil
}
func (r *Referrer) LoadReferralProgram() error {
if r.ProgramId == "" {
return nil
}
prog := referralprogram.New(r.Datastore())
if err := prog.GetById(r.ProgramId); err != nil {
return err
}
r.Program = *prog
return nil
}
func (r *Referrer) LoadAffiliate() error {
if r.AffiliateId == "" {
return nil
}
aff := affiliate.New(r.Datastore())
if err := aff.GetById(r.AffiliateId); err != nil {
return err
}
r.Affiliate = *aff
return nil
}
func (r *Referrer) Referrals() ([]*referral.Referral, error) {
referrals := make([]*referral.Referral, 0)
_, err := referral.Query(r.Datastore()).Filter("ReferrerId=", r.Id()).GetAll(referrals)
return referrals, err
}
func (r *Referrer) Transactions() ([]*transaction.Transaction, error) {
transactions := make([]*transaction.Transaction, 0)
_, err := transaction.Query(r.Datastore()).Filter("ReferrerId=", r.Id()).GetAll(transactions)
return transactions, err
}
// Referral Program stuff
func (r *Referrer) TestTrigger(action referralprogram.Action, event referral.Event, test bool) (bool, error) {
trig := action.Trigger
if trig.Event != "" && event != trig.Event {
log.Debug("Event mismatch '%s' != '%s'", event, trig.Event)
return false, nil
}
switch trig.Type {
case referralprogram.CreditGreaterThanOrEquals:
// Get all transactions
trans := make([]*transaction.Transaction, 0)
if _, err := transaction.Query(r.Datastore()).Filter("DestinationId=", r.UserId).Filter("Currency=", trig.Currency).Filter("Test=", test).GetAll(&trans); err != nil {
return false, err
}
// Total balance
balance := 0
for _, t := range trans {
if t.Type == transaction.Withdraw {
balance -= int(t.Amount)
} else {
balance += int(t.Amount)
}
}
log.Debug("CreditGreaterThanOrEquals Trigger, Balance: '%d %s'", balance, trig.Currency, r.Context())
// 'Forward' any balance increments from this trigger executing
log.Debug("Looking at actions with credit to forward '%s': '%s' ? '%s'", action.Type, action.Currency, trig.Currency, r.Context())
if action.Type == referralprogram.StoreCredit && action.Currency == trig.Currency {
done, ok := r.State[action.Name+"_done"].(bool)
if action.Once && ok && done {
log.Debug("Don't forward since this was executed once")
} else {
balance += int(action.Amount)
log.Debug("Balance Amount %s", balance)
}
}
// Check trigger
if balance >= int(trig.CreditGreaterThanOrEquals) {
return true, nil
}
case referralprogram.ReferralsGreaterThanOrEquals:
log.Debug("ReferralsGreaterThanOrEquals Trigger")
// Count number of referrals
if count, err := referral.Query(r.Datastore()).Filter("Referrer.Id=", r.Id()).Count(); err != nil {
return false, err
// Check trigger
} else if count >= trig.ReferralsGreaterThanOrEquals {
return true, nil
}
return false, nil
case referralprogram.Always:
return true, nil
default:
log.Error("Unknown Trigger '%s'", trig.Type, r.Context())
return false, errors.New(fmt.Sprintf("Unknown Trigger '%s'", trig.Type))
}
return false, nil
}
func (r *Referrer) ApplyActions(ctx context.Context, orgId string, event referral.Event, p *referralprogram.ReferralProgram, rfn Referrent, rfl *referral.Referral, test bool) error {
domains := []string{}
old := len(r.Program.Triggers) > 0
if old {
log.Debug("Old Triggers")
} else {
log.Debug("New Triggers")
}
for _, action := range p.Actions {
if !old {
if ok, err := r.TestTrigger(action, event, test); !ok {
if err != nil {
return err
}
continue
}
}
// Only execute if state isn't done
done, ok := r.State[action.Name+"_done"].(bool)
if action.Once && ok && done {
log.Debug("This was executed once")
continue
}
switch action.Type {
case referralprogram.StoreCredit:
log.Info("Applying store credit.", r.Context())
if !done && action.Once {
r.State[action.Name+"_done"] = true
r.MustUpdate()
}
amount := action.Amount
if amount == 0 {
amount = rfn.Total()
}
log.Info("Saving store credit %v", rfn.Total(), r.Context())
if err := saveStoreCredit(r, amount, action.Currency, test); err != nil {
return err
}
// case referralprogram.Refund:
// return nil
case referralprogram.SendUserEmail:
if !done && action.Once {
r.State[action.Name+"_done"] = true
r.MustUpdate()
}
fn := delay.FuncByKey("referrer-send-user-email")
log.Debug("Sending Email Template '%s'", action.EmailTemplate, ctx)
fn.Call(ctx, orgId, action.EmailTemplate, r.UserId)
case referralprogram.SendWoopra:
if !done && action.Once {
r.State[action.Name+"_done"] = true
r.MustUpdate()
}
fn := delay.FuncByKey("referrer-send-woopra-event")
log.Debug("Sending Woopra Event '%s'", action.Domain, ctx)
domains = append(domains, action.Domain)
fn.Call(ctx, orgId, action.Domain, r.UserId, rfn.Id(), rfn.Kind())
default:
log.Error("Unknown Action '%s'", action.Type, r.Context())
return errors.New(fmt.Sprintf("Unknown Action '%s'", action.Type))
}
}
rfl.Referrer.WoopraDomains = strings.Join(domains, ",")
if err := rfl.Update(); err != nil {
return err
}
// No actions triggered for this referral
return nil
}
// Credit user with store credit by saving transaction
func saveStoreCredit(r *Referrer, amount currency.Cents, cur currency.Type, test bool) error {
trans := transaction.New(r.Datastore())
trans.Type = transaction.Deposit
trans.Amount = amount
trans.Currency = cur
trans.SourceId = r.Id()
trans.SourceKind = r.Kind()
trans.DestinationId = r.UserId
trans.DestinationKind = "user"
trans.Notes = "Deposit due to referral"
trans.Tags = "referral"
trans.Test = test
log.Debug("Deposit type: %v", trans.Currency)
log.Debug("Currency amount: %v", trans.Amount)
log.Debug("Destination ID: %v", trans.DestinationId)
return trans.Create()
}
func (r *Referrer) Defaults() {
r.State = make(Map)
}
func New(db *datastore.Datastore) *Referrer {
r := new(Referrer)
r.Init(db)
r.Defaults()
return r
}
func Query(db *datastore.Datastore) datastore.Query {
return db.Query("referrer")
}