affiliates

package
v1.799.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 12, 2026 License: Apache-2.0 Imports: 26 Imported by: 0

Documentation

Overview

Package affiliates mounts the Hanzo Cloud /v1/affiliates/* partner-commission surface: a native-Go, per-org affiliate program on Base/SQLite that pays partners an ONGOING COMMISSION on the metered spend of the customers they refer. It sits next to clients/referrals (a one-time credit for both sides) as the OTHER growth loop — the recurring, partner-revenue one — and mirrors its structure exactly: one SQLite store, server-side tenant isolation, one Mount, HIP-0106, and the SAME commerce ledger path (a credits payout is a grant, tag grant:affiliate).

The loop, end to end:

  1. An org APPLIES to be an affiliate (POST /v1/affiliates/apply), optionally requesting a vanity code. Staff APPROVE it (POST /v1/admin/affiliates/:id/ approve), which mints the code (vanity if free, else a derived slug) and sets a commission rate (default 20%). The affiliate now has a link https://<brand>/?aff=<code>.
  2. A new org signs up via the link → the console posts POST /v1/affiliates/ attribute with the code → we record referred_org↔affiliate (first-touch, one per referred org, self-attribution blocked).
  3. The ACCRUAL SWEEP (POST /v1/admin/affiliates/sweep, the cron path; also lazy on the affiliate's own dashboard read) folds over each affiliate's referred orgs: commission = the referred org's metered spend THIS PERIOD × the rate, accrued into the affiliate's balance as an affiliate_event. The accrual is LATCHED at-most-once per (affiliate, referred_org, period) — a re-run in the same period never double-accrues, mirroring the referral credit latch.
  4. Staff PAY OUT accrued commission (POST /v1/admin/affiliates/:id/payout): a "credits" method issues a commerce grant into the affiliate's wallet; cash methods (wire/paypal/…) are record-only. A payout can never exceed pending (accrued − paid), guarded atomically.

Surface:

GET  /v1/affiliates                        (org)          my status, code, link, referred count, accrued/pending/paid, payouts
POST /v1/affiliates/apply                  (org)          apply to the program (optional vanity code)
POST /v1/affiliates/attribute              (org=referred) record attribution from an ?aff code
GET  /v1/admin/affiliates                  (SuperAdmin) every affiliate + a summary
POST /v1/admin/affiliates/:id/approve      (SuperAdmin) approve + mint the code
POST /v1/admin/affiliates/:id/suspend      (SuperAdmin) suspend
POST /v1/admin/affiliates/:id/payout       (SuperAdmin) record a payout (credits → grant; cash → record-only)
POST /v1/admin/affiliates/sweep            (SuperAdmin) accrue commission for every referred org this period

serve.go auto-registers GET /v1/affiliates/health.

Index

Constants

View Source
const (
	StatusApplied   = "applied"
	StatusApproved  = "approved"
	StatusSuspended = "suspended"
)

Status values. An affiliate advances applied → approved (and can be suspended). Only an APPROVED affiliate has a code and accrues commission.

Variables

This section is empty.

Functions

func Mount

func Mount(app *zip.App, deps cloud.Deps) error

Mount wires the affiliates surface onto app per HIP-0106. Complex flavour: it holds a package-global (mounted) so Shutdown can release the store, so it constructs the Service value directly rather than via cloud.Mount.

func Shutdown

func Shutdown() error

Shutdown closes the affiliates store. Idempotent.

Types

type Accrual

type Accrual struct {
	ID              string `json:"id"`
	AffiliateID     string `json:"affiliateId"`
	ReferredOrg     string `json:"referredOrg"`
	Period          string `json:"period"`
	SpendCents      int64  `json:"spendCents"`
	CommissionCents int64  `json:"commissionCents"`
	CreatedAt       int64  `json:"createdAt"`
}

Accrual is one per-period commission event (the affiliate_event): the referred org's spend for that period × the affiliate's rate. UNIQUE(affiliate, referred, period) makes the sweep at-most-once per period — the commission latch.

type Affiliate

type Affiliate struct {
	ID            string `json:"id"`
	Org           string `json:"-"` // the affiliate's own org; admin view re-exposes it
	Code          string `json:"code"`
	RequestedCode string `json:"-"` // vanity code requested at apply, pending staff approval
	Status        string `json:"status"`
	RateBps       int64  `json:"rateBps"`
	AccruedCents  int64  `json:"accruedCents"` // lifetime commission accrued
	PaidCents     int64  `json:"paidCents"`    // lifetime commission paid out
	CreatedAt     int64  `json:"createdAt"`
	ApprovedAt    int64  `json:"approvedAt"`
	SuspendedAt   int64  `json:"suspendedAt"`
}

Affiliate is one partner org enrolled in the commission program. Org is UNIQUE (one affiliate per org); Code is UNIQUE across affiliates (minted on approval, vanity opt-in). RateBps is the commission rate in basis points (2000 = 20%).

func (Affiliate) PendingCents

func (a Affiliate) PendingCents() int64

PendingCents is the commission earned but not yet paid (never negative).

type AffiliateReferral

type AffiliateReferral struct {
	ID          string `json:"id"`
	AffiliateID string `json:"affiliateId"`
	ReferredOrg string `json:"referredOrg"`
	Code        string `json:"code"`
	CreatedAt   int64  `json:"createdAt"`
}

AffiliateReferral is one referred_org → affiliate attribution edge. ReferredOrg is UNIQUE across the table (an org is attributed to at most one affiliate, ever — first-touch), which is also the idempotency key for POST /v1/affiliates/attribute.

type Payout

type Payout struct {
	ID          string `json:"id"`
	AffiliateID string `json:"affiliateId"`
	AmountCents int64  `json:"amountCents"`
	Method      string `json:"method"`
	Reference   string `json:"reference"`
	Txn         string `json:"txn,omitempty"`
	CreatedAt   int64  `json:"createdAt"`
}

Payout is one recorded disbursement of accrued commission. A "credits" method issues a commerce grant (Txn set); cash methods (wire/paypal/…) are record-only.

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store is the affiliates database. ONE SQLite file holds every org's affiliate record, attribution edges, accrual events, and payouts. A code→affiliate lookup is a GLOBAL directory by design (a referred org presents a code minted by ANY affiliate); every /v1/affiliates read is scoped by the caller's org server-side.

func (*Store) AffiliateForCode

func (s *Store) AffiliateForCode(ctx context.Context, code string) (Affiliate, error)

AffiliateForCode reverse-resolves an affiliate code to its APPROVED owner (an un-approved affiliate has no code). Trims + lower-cases the client-supplied code.

func (*Store) Apply

func (s *Store) Apply(ctx context.Context, id, org, requestedCode string, rateBps int64) (Affiliate, bool, error)

Apply enrolls org as an affiliate at status=applied with the default rate, idempotently. requestedCode is the optional vanity code (validated + minted at approval). A repeat apply returns the EXISTING record (first apply wins). Returns (affiliate, created).

func (*Store) Approve

func (s *Store) Approve(ctx context.Context, id, wantCode string, now int64) (Affiliate, error)

Approve moves an affiliate to approved and mints its code: wantCode wins, else the requested vanity code, else a deterministic derived slug. A non-empty code is validated + uniqueness-enforced (errCodeTaken on collision with another affiliate). Idempotent for the same resolved code.

func (*Store) Attribute

func (s *Store) Attribute(ctx context.Context, id, affiliateID, referredOrg, affiliateOrg, code string) (AffiliateReferral, bool, error)

Attribute records a referredOrg → affiliate edge, idempotently. The referred org is the VALIDATED caller (never client-supplied). One-per-referred-org (UNIQUE) makes a repeat attribute a no-op returning the FIRST edge (first-touch wins). Self-attribution (an affiliate's own org) is refused. Returns (edge, created).

func (*Store) Close

func (s *Store) Close() error

Close closes the underlying database. Idempotent-safe via sql.DB.

func (*Store) CountReferrals

func (s *Store) CountReferrals(ctx context.Context, affiliateID string) (int, error)

CountReferrals returns how many orgs an affiliate has referred.

func (*Store) GetByID

func (s *Store) GetByID(ctx context.Context, id string) (Affiliate, error)

GetByID re-reads an affiliate by id (post-mutation refresh).

func (*Store) GetByOrg

func (s *Store) GetByOrg(ctx context.Context, org string) (Affiliate, error)

GetByOrg reads the affiliate record for org, or errNotFound.

func (*Store) LatchAccrual

func (s *Store) LatchAccrual(ctx context.Context, accrualID, affiliateID, referredOrg, period string, spendCents, commissionCents, now int64) (bool, error)

LatchAccrual atomically records ONE accrual for (affiliate, referredOrg, period) and adds the commission to the affiliate's accrued balance — in a single transaction. RowsAffected on the INSERT is the latch: a UNIQUE violation means this period was already accrued (returns false, no error, no double-accrual). Returns won=true only when THIS call created the accrual and moved the balance.

func (*Store) ListAll

func (s *Store) ListAll(ctx context.Context, limit int) ([]Affiliate, error)

ListAll returns every affiliate newest-first (the admin directory), bounded.

func (*Store) ListApproved

func (s *Store) ListApproved(ctx context.Context, limit int) ([]Affiliate, error)

ListApproved returns every approved affiliate (the sweep set), oldest-first.

func (*Store) ListPayouts

func (s *Store) ListPayouts(ctx context.Context, affiliateID string, limit int) ([]Payout, error)

ListPayouts returns an affiliate's payout history, newest-first, bounded.

func (*Store) ListReferrals

func (s *Store) ListReferrals(ctx context.Context, affiliateID string, limit int) ([]AffiliateReferral, error)

ListReferrals returns an affiliate's attribution edges (the orgs it referred), newest-first, bounded.

func (*Store) RecordPayout

func (s *Store) RecordPayout(ctx context.Context, payoutID, affiliateID string, amountCents int64, method, reference string, now int64) (Payout, error)

RecordPayout atomically RESERVES amountCents against the affiliate's pending commission (accrued − paid) and records a payout row — in one transaction. The WHERE guard `(accrued_cents − paid_cents) >= amount` makes it impossible to pay out more than is owed, even under concurrency (RowsAffected 0 → errInsufficient Pending). The commerce grant (for a credits payout) happens AFTER, outside the tx; SetPayoutTxn records the receipt. errNotFound if the affiliate is missing.

func (*Store) ReferralCountsByAffiliate

func (s *Store) ReferralCountsByAffiliate(ctx context.Context) (map[string]int, error)

ReferralCountsByAffiliate returns affiliate_id → referred-org count in ONE GROUP BY (the admin directory's per-row count, no N+1 fan-out).

func (*Store) SetPayoutTxn

func (s *Store) SetPayoutTxn(ctx context.Context, payoutID, txn string) error

SetPayoutTxn records the commerce ledger transaction id after a credits payout deposit lands (best-effort receipt; the pending reservation is the authority).

func (*Store) Suspend

func (s *Store) Suspend(ctx context.Context, id string, now int64) (Affiliate, error)

Suspend moves an affiliate to suspended (its code stops resolving for new attribution; earned commission is unaffected). errNotFound if missing.

func (*Store) VoidPayout

func (s *Store) VoidPayout(ctx context.Context, payoutID, affiliateID string, amountCents int64) error

VoidPayout reverses a RecordPayout that could not be BACKED by the treasury reserve: it deletes the payout row and restores the reserved amount to pending (paid_cents −= amount), in one transaction. It is the compensating action when the fund cannot cover a payout the pending-guard already reserved — so a blocked payout leaves the affiliate's pending intact, honestly, instead of silently burning it.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL