money: a referral is attribution, so the mint on the read goes
GET /v1/referrals ran a "lazy qualify sweep" before listing, and that sweep reached commerce.Deposit. Loading your own referrals page minted platform credit — $10 to you, $5 to your referee — on a read. The middleware in front of the prefix gated writes by asking `method != POST`, so it waved the GET through; the two safeties behind it did not hold either. treasury.Reserve returns backed=true when treasury is unmounted, so the "backed by the reserve fund" claim was a passthrough in any deploy without it, and the at-most-once latch bounds the mint per referral, not in total. The precedent is set twice on main:41b23f12deleted the $5 starter grant and45b3b5cfdeleted finance.Deposit along with its imports. Same here. The deposit path is DELETED, not disabled — a flag-disabled money mint is one flag from an enabled one. What goes: the two bonus constants, the ledger currency + grant:referral tag, grant(), the treasury reservation and its import, LatchCredit, SetTxns, the grant/txn/credited_at columns, the `credited` status, and every cents field on the wire. A field that can only ever report zero is a lie about what the surface does, so creditsEarnedCents and the two bonus amounts go rather than freeze at 0. The commerce seam keeps ONE method, spendCents — it is a question, not an instruction — and TestCommerceSeamIsReadOnly fails if it grows a write. What stays, because it is the actual product: who referred whom, the stable code, the share link, and qualification. Qualification is a WRITE, so it now happens only on POST /v1/admin/referrals/sweep. A GET reports; it does not transition. The gate is fixed at the defect class, not the instance: it waves through GET and HEAD by name and requires an org for every other verb, including ones this package does not serve. "Not POST" meaning "harmless" is the reasoning that let a read reach a deposit. What a qualified referral is WORTH is not this package's question. That is an affiliate payable in hanzoai/commerce, settled by wire or to a connected wallet — never minted as platform credit. Tests: a GET in the exact state that used to pay leaves the row byte-identical and touches the money plane zero times; the real payout client against a stub commerce that fails on any write proves zero deposits at the wire. Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
+14
-16
@@ -6,35 +6,33 @@ import (
|
||||
"github.com/hanzoai/cloud/apps/payout"
|
||||
)
|
||||
|
||||
// commerce is the narrow money seam the referral loop needs: read a referee's
|
||||
// metered spend (the qualify signal) and grant a promo credit to a wallet (the
|
||||
// bonus, ledger tag grant:referral). It is an INTERFACE so the store/handler logic
|
||||
// is testable with a fake ledger; the production binding is clients/payout, reached
|
||||
// through the thin adapter below.
|
||||
// commerce is the ONE thing the referral loop asks of the money plane, and it is a
|
||||
// QUESTION, not an instruction: has this referee actually spent? That read is the
|
||||
// qualify signal. It is an INTERFACE so the sweep is testable against a fake.
|
||||
//
|
||||
// The S2S impl (COMMERCE_SERVICE_TOKEN path, X-Org-Id=<org> namespace, bare-org
|
||||
// `user` subject) was three byte-identical commerce.go copies; it now lives ONCE in
|
||||
// clients/payout. A referral bonus still lands in precisely the wallet the balance
|
||||
// panel reads, indistinguishable from an admin grant except by its grant:referral tag.
|
||||
// THERE IS NO DEPOSIT HERE, AND THERE IS NOT GOING TO BE ONE. This seam used to
|
||||
// carry `deposit`, which is how a GET on this surface came to mint platform credit:
|
||||
// the capability existed, so a caller eventually reached it. A referral reward is an
|
||||
// affiliate PAYABLE — tracked in hanzoai/commerce, settled by wire or to a connected
|
||||
// wallet — and platform credit is issued only by an admin grant or a credited
|
||||
// invite. Re-adding a write method here re-opens exactly the hole that was shut, so
|
||||
// the SHAPE of this interface is load-bearing and TestCommerceSeamIsReadOnly fails
|
||||
// if it ever grows one.
|
||||
type commerce interface {
|
||||
configured() bool
|
||||
deposit(ctx context.Context, org, user string, amountCents int64, currency, notes, tags string) (txnID string, err error)
|
||||
spendCents(ctx context.Context, org, user string) (int64, error)
|
||||
}
|
||||
|
||||
// errUnconfigured is the shared sentinel a deposit against an unwired commerce
|
||||
// returns, so the caller records an honest failure rather than a phantom grant.
|
||||
// errUnconfigured is the shared sentinel a read against an unwired commerce
|
||||
// returns, so a referral stays honestly pending rather than silently qualifying.
|
||||
var errUnconfigured = payout.ErrUnconfigured
|
||||
|
||||
// commerceSeam adapts the shared payout.Client onto this program's lowercase seam
|
||||
// (Go package-scoped interface methods cannot cross packages). Zero logic — pure
|
||||
// delegation; the money path lives in clients/payout.
|
||||
// delegation, and it delegates exactly one read.
|
||||
type commerceSeam struct{ c *payout.Client }
|
||||
|
||||
func (s commerceSeam) configured() bool { return s.c.Configured() }
|
||||
func (s commerceSeam) deposit(ctx context.Context, org, user string, amountCents int64, currency, notes, tags string) (string, error) {
|
||||
return s.c.Deposit(ctx, org, user, amountCents, currency, notes, tags)
|
||||
}
|
||||
func (s commerceSeam) spendCents(ctx context.Context, org, user string) (int64, error) {
|
||||
return s.c.SpendCents(ctx, org, user)
|
||||
}
|
||||
|
||||
+121
-221
@@ -1,13 +1,16 @@
|
||||
// Package referrals is credit for both sides when someone you refer actually spends.
|
||||
// Package referrals is referral ATTRIBUTION: who referred whom, and whether that
|
||||
// referee ever became a real customer.
|
||||
//
|
||||
// Every org has a stable code and share link, a new org claims it at signup,
|
||||
// and BOTH sides are granted cloud credit once the referee actually spends —
|
||||
// at-most-once, never on signup alone.
|
||||
// Every org has a stable code and share link, a new org claims it at signup, and
|
||||
// the edge advances signup → qualified once the referee actually makes metered
|
||||
// spend. That attribution record IS this package's product.
|
||||
//
|
||||
// It is the ONE-TIME-BONUS member of the three programs built on the same shape;
|
||||
// apps/affiliates is the ongoing partner commission and apps/authors the OSS
|
||||
// royalty. The grant rides the same commerce ledger path as an admin credit (the
|
||||
// trial/Credit bucket, tag grant:referral).
|
||||
// IT MOVES NO MONEY. There is no bonus, no grant, no deposit and no ledger write
|
||||
// here. Credit enters an org by exactly two doors — a manual per-org admin grant
|
||||
// against the auditable ledger, and an invite that carries credit to a new org —
|
||||
// and this package is neither. A referral REWARD is an affiliate PAYABLE: it is
|
||||
// tracked in hanzoai/commerce and settled by wire or to a connected wallet, never
|
||||
// minted as platform credit.
|
||||
//
|
||||
// The loop, end to end:
|
||||
//
|
||||
@@ -16,26 +19,25 @@
|
||||
// 2. A new org signs up via a link → the console posts POST /v1/referrals/claim
|
||||
// with the code → we record referrer↔referee at status signup. Self-referral
|
||||
// is blocked; one referral per referee ever (idempotent).
|
||||
// 3. When the referee QUALIFIES (the honest signal: they've made metered spend —
|
||||
// actually USED the product, not just claimed a welcome grant) we grant BOTH
|
||||
// sides trial credit: referrer +$10, referee +$5. The grant is LATCHED
|
||||
// at-most-once (credited_at) so no sweep and no concurrent read can double-pay.
|
||||
// The qualify check runs lazily when the referrer loads GET /v1/referrals AND
|
||||
// via the admin sweep (POST /v1/admin/referrals/sweep, the cron path).
|
||||
// 3. The referee QUALIFIES once they have made metered spend — the honest signal
|
||||
// that they used the product rather than merely signed up. Qualification is a
|
||||
// WRITE, so it happens on the admin sweep (POST /v1/admin/referrals/sweep, the
|
||||
// cron path) and NOWHERE else. GET /v1/referrals is a pure read: it reports
|
||||
// the attribution, it never advances it.
|
||||
//
|
||||
// Surface:
|
||||
//
|
||||
// GET /v1/referrals (org) my code, link, referrals, credits earned
|
||||
// GET /v1/referrals (org) my code, link, my referrals
|
||||
// POST /v1/referrals/claim (org=referee) record a referral from a ?ref code
|
||||
// GET /v1/admin/referrals/bonuses (SuperAdmin) every one-time bonus referral + a summary
|
||||
// GET /v1/admin/referrals/bonuses (SuperAdmin) every referral edge + a summary
|
||||
// POST /v1/admin/referrals/sweep (SuperAdmin) qualify-check every pending referral
|
||||
//
|
||||
// TWO PACKAGES SHARE ONE ADMIN PREFIX. The cross-tenant referral ANALYTICS board
|
||||
// (top referrers, conversion, multi-level accrual liability) is
|
||||
// GET /v1/admin/referrals, owned by apps/affiliates over the shared attribution
|
||||
// spine; this package owns the one-time-bonus ledger one segment deeper at
|
||||
// /v1/admin/referrals/bonuses. They do not collide, but /v1/admin/referrals/* has
|
||||
// no single owner — the merge that gives it one is the standing decision.
|
||||
// (top referrers, conversion) is GET /v1/admin/referrals, owned by apps/affiliates
|
||||
// over the shared attribution spine; this package owns the edge directory one
|
||||
// segment deeper at /v1/admin/referrals/bonuses. They do not collide, but
|
||||
// /v1/admin/referrals/* has no single owner — the merge that gives it one is the
|
||||
// standing decision, and it lands in commerce.
|
||||
//
|
||||
// serve.go auto-registers GET /v1/referrals/health.
|
||||
package referrals
|
||||
@@ -55,34 +57,15 @@ import (
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/apps/commerce/transport"
|
||||
"github.com/hanzoai/cloud/apps/principal"
|
||||
"github.com/hanzoai/cloud/apps/treasury"
|
||||
"github.com/hanzoai/cloud/audit"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
//go:generate go run github.com/zap-proto/zip/cmd/zipdoc
|
||||
|
||||
// The referral economy — ONE place, so the bonus amounts and the ledger bucket
|
||||
// are never re-defined. Amounts are USD minor units (cents); the grant lands in
|
||||
// the commerce Credit/trial bucket (grant:* → Credit per DepositKind) — promo
|
||||
// credit, NEVER refundable cash and never paid out, exactly like an admin comp.
|
||||
const (
|
||||
// referrerBonusCents is granted to the REFERRER when a referee qualifies.
|
||||
referrerBonusCents int64 = 1000 // $10
|
||||
// refereeBonusCents is granted to the REFEREE on qualification (on top of the
|
||||
// $5 welcome grant they already got at signup).
|
||||
refereeBonusCents int64 = 500 // $5
|
||||
// grantCurrency is the ledger currency for referral bonuses.
|
||||
grantCurrency = "usd"
|
||||
// grantTag classifies the deposit as a non-cash Credit (trial) in commerce's
|
||||
// DepositKind (grant:* → Credit), distinct from admin's grant:admin so the
|
||||
// ledger/audit can tell a referral bonus from a staff comp.
|
||||
grantTag = "grant:referral"
|
||||
)
|
||||
|
||||
const (
|
||||
// sweepLimit bounds one qualify sweep (admin sweep + lazy-on-read), so an
|
||||
// unbounded pending backlog can't wedge a single request.
|
||||
// sweepLimit bounds one qualify sweep, so an unbounded pending backlog can't
|
||||
// wedge a single request.
|
||||
sweepLimit = 500
|
||||
// listLimit / maxAdminLimit bound the read responses.
|
||||
listLimit = 500
|
||||
@@ -94,7 +77,7 @@ type state struct {
|
||||
store *Store
|
||||
commerce commerce
|
||||
linkBase string // https://hanzo.ai (brand host) — the ?ref link prefix
|
||||
auditStore *audit.Recorder // best-effort grant audit; nil disables it
|
||||
auditStore *audit.Recorder // best-effort qualification audit; nil disables it
|
||||
}
|
||||
|
||||
var mounted *cloud.Service[state]
|
||||
@@ -167,12 +150,20 @@ func routes(app cloud.Router, s *cloud.Service[state]) {
|
||||
// A typed op runs after the decode, so moving the identity check into the op would
|
||||
// answer 400 to an unauthenticated caller whose body is also malformed, where this
|
||||
// surface has always answered 403. The check therefore lives where the untyped
|
||||
// handler's ran: ahead of the body. It is scoped to writes because the read under
|
||||
// this prefix answers 403 from inside its own handler (no body to decode first),
|
||||
// and because the auto-registered GET /v1/referrals/health must stay probe-able.
|
||||
// handler's ran: ahead of the body.
|
||||
//
|
||||
// It gates by SAFE METHOD, not by naming one verb. The predecessor let everything
|
||||
// that was not POST through, which made the gate's correctness depend on nobody
|
||||
// ever adding a PUT/PATCH/DELETE — and, worse, taught the surface that "not POST"
|
||||
// means "harmless", which is exactly the reasoning that let a GET reach a deposit.
|
||||
// A method is waved through here only when it is READ-ONLY by definition (GET and
|
||||
// HEAD, so the auto-registered GET /v1/referrals/health stays probe-able); every
|
||||
// other verb, including ones this package does not serve today, must carry an org.
|
||||
// Being read-only is then enforced for real by the handlers: no GET in this package
|
||||
// writes.
|
||||
func requireOrgOnWrite() zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
if c.Method() != http.MethodPost {
|
||||
if isSafeMethod(c.Method()) {
|
||||
return c.Continue()
|
||||
}
|
||||
if _, ok := principal.Org(c); !ok {
|
||||
@@ -182,6 +173,14 @@ func requireOrgOnWrite() zip.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// isSafeMethod reports whether a method is read-only by definition (RFC 9110
|
||||
// "safe"). OPTIONS is deliberately absent: it is safe, but it is not served here,
|
||||
// and a gate that enumerates what it lets through fails closed on the verb nobody
|
||||
// thought about.
|
||||
func isSafeMethod(m string) bool {
|
||||
return m == http.MethodGet || m == http.MethodHead
|
||||
}
|
||||
|
||||
// requireAdmin is the SuperAdmin gate on the two /v1/admin leaves. SuperAdmin-ness
|
||||
// is a HEADER, which a typed op cannot see, so the check runs here — and running it
|
||||
// here also keeps the 403 ahead of the body decode, exactly where the untyped
|
||||
@@ -215,32 +214,24 @@ type myReferrals struct {
|
||||
Code string `json:"code"`
|
||||
// Counts tallies this org's referrals by status.
|
||||
Counts statusCounts `json:"counts"`
|
||||
// CreditsEarnedCents is the total promo credit this org has earned as the
|
||||
// REFERRER, in USD cents.
|
||||
CreditsEarnedCents int64 `json:"creditsEarnedCents"`
|
||||
// Link is the shareable signup link carrying the code, on the brand's own host.
|
||||
Link string `json:"link"`
|
||||
// RefereeBonusCents is what a referee is granted on qualification, in USD cents.
|
||||
RefereeBonusCents int64 `json:"refereeBonusCents"`
|
||||
// Referrals is one row per org that signed up with this code.
|
||||
Referrals []myReferralView `json:"referrals"`
|
||||
// ReferrerBonusCents is what the referrer is granted when a referee qualifies,
|
||||
// in USD cents.
|
||||
ReferrerBonusCents int64 `json:"referrerBonusCents"`
|
||||
}
|
||||
|
||||
// mine returns the caller's referral code, share link and the referrals they have made.
|
||||
//
|
||||
// The code is a stable, deterministic function of the org, so the link in this
|
||||
// response is the same one every time. Each row carries the referee, its status and
|
||||
// the credit this org earned from it; creditsEarnedCents is their sum.
|
||||
// response is the same one every time. Each row carries the referee and the status
|
||||
// of that attribution.
|
||||
//
|
||||
// The read is self-updating: before listing, it runs the qualify check over this
|
||||
// org's still-pending referees, so a referee who has since made metered spend is
|
||||
// credited by the act of the referrer loading their page. That check is
|
||||
// best-effort and bounded — a commerce hiccup leaves the referral pending for the
|
||||
// next check rather than failing the page — and the grant is latched at-most-once,
|
||||
// so this path and the admin sweep can never double-pay.
|
||||
// IT IS A PURE READ. It advances no referral, grants nothing and deposits nothing
|
||||
// — a GET reports state, it never changes it. Qualification is the admin sweep's
|
||||
// job (POST /v1/admin/referrals/sweep). The one row this handler can write is the
|
||||
// caller's OWN code-directory entry (EnsureCode), which materialises a value
|
||||
// deriveCode already computes deterministically from the org id so the code has an
|
||||
// O(1) reverse lookup; it carries no money, no referral state and no other tenant.
|
||||
func (o referralOps) mine(ctx context.Context, _ *noIn) (*myReferrals, error) {
|
||||
s := o.s
|
||||
org, ok := principal.OrgFrom(ctx)
|
||||
@@ -253,42 +244,26 @@ func (o referralOps) mine(ctx context.Context, _ *noIn) (*myReferrals, error) {
|
||||
return nil, zip.Errorf(http.StatusInternalServerError, "referral code: %v", err)
|
||||
}
|
||||
|
||||
// Lazy qualify sweep for MY referees (bounded, best-effort — a commerce hiccup
|
||||
// never fails the page; the referral simply stays pending for the next check).
|
||||
if pending, perr := s.State.store.ListPending(ctx, org, sweepLimit); perr == nil {
|
||||
for _, r := range pending {
|
||||
if _, gerr := qualifyAndGrant(s, ctx, r); gerr != nil {
|
||||
s.Log.Warn("referrals: lazy qualify check failed", "id", r.ID, "err", gerr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := s.State.store.ListByReferrer(ctx, org, listLimit)
|
||||
if err != nil {
|
||||
return nil, zip.Errorf(http.StatusInternalServerError, "list referrals: %v", err)
|
||||
}
|
||||
|
||||
views := make([]myReferralView, 0, len(rows))
|
||||
var earned int64
|
||||
counts := statusCounts{}
|
||||
for _, r := range rows {
|
||||
counts.add(r.Status)
|
||||
earned += r.ReferrerGrantCents
|
||||
views = append(views, myReferralView{
|
||||
ID: r.ID, Referee: r.RefereeOrg, Status: r.Status,
|
||||
CreditsCents: r.ReferrerGrantCents, CreatedAt: r.CreatedAt,
|
||||
QualifiedAt: r.QualifiedAt, CreditedAt: r.CreditedAt,
|
||||
CreatedAt: r.CreatedAt, QualifiedAt: r.QualifiedAt,
|
||||
})
|
||||
}
|
||||
|
||||
return &myReferrals{
|
||||
Code: code,
|
||||
Counts: counts,
|
||||
CreditsEarnedCents: earned,
|
||||
Link: s.State.linkBase + "/?ref=" + code,
|
||||
RefereeBonusCents: refereeBonusCents,
|
||||
Referrals: views,
|
||||
ReferrerBonusCents: referrerBonusCents,
|
||||
Code: code,
|
||||
Counts: counts,
|
||||
Link: s.State.linkBase + "/?ref=" + code,
|
||||
Referrals: views,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -332,9 +307,10 @@ type claimView struct {
|
||||
// call returns the referral already on file with created=false and 200, where the
|
||||
// first call answers 201.
|
||||
//
|
||||
// Recording a referral grants nothing. Both bonuses are granted later, when the
|
||||
// referee actually makes metered spend — see GET /v1/referrals and
|
||||
// POST /v1/admin/referrals/sweep.
|
||||
// Recording a referral grants nothing, and neither does anything downstream of it:
|
||||
// the edge later advances to qualified when the referee makes metered spend
|
||||
// (POST /v1/admin/referrals/sweep), and that is the end of it. No credit is ever
|
||||
// issued from this package.
|
||||
//
|
||||
// Example: {"code": "H4NZ0ABC"}
|
||||
func (o referralOps) claim(ctx context.Context, body *claimRequest) (*claimView, error) {
|
||||
@@ -400,9 +376,9 @@ type adminListIn struct {
|
||||
Limit string `json:"limit"`
|
||||
}
|
||||
|
||||
// adminBonusDirectory is the SuperAdmin view of the one-time-bonus ledger.
|
||||
// adminBonusDirectory is the SuperAdmin view of the referral edge directory.
|
||||
type adminBonusDirectory struct {
|
||||
// Referrals is every referral in the ledger, both orgs exposed.
|
||||
// Referrals is every referral in the directory, both orgs exposed.
|
||||
Referrals []adminReferralView `json:"referrals"`
|
||||
// Summary is the fleet tally across those referrals.
|
||||
Summary adminSummary `json:"summary"`
|
||||
@@ -420,13 +396,13 @@ type adminBonusesEnvelope struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// adminList returns every one-time referral bonus in the ledger with a fleet summary.
|
||||
// adminList returns every referral edge in the directory with a fleet summary.
|
||||
//
|
||||
// SuperAdmin only, fail-closed. This is the ONE-TIME BONUS ledger — who referred
|
||||
// whom, what each side was granted and which ledger transactions carried it. The
|
||||
// cross-tenant referral ANALYTICS board (top referrers, conversion, multi-level
|
||||
// accrual liability) is a different surface, GET /v1/admin/referrals, owned by the
|
||||
// affiliates subsystem over the shared attribution spine.
|
||||
// SuperAdmin only, fail-closed. This is the ATTRIBUTION directory — who referred
|
||||
// whom and whether that referee became a customer. It carries no amounts because
|
||||
// this package issues none. The cross-tenant referral ANALYTICS board (top
|
||||
// referrers, conversion) is a different surface, GET /v1/admin/referrals, owned by
|
||||
// the affiliates subsystem over the shared attribution spine.
|
||||
func (o referralOps) adminList(ctx context.Context, in *adminListIn) (*adminBonusesEnvelope, error) {
|
||||
s := o.s
|
||||
rows, err := s.State.store.ListAll(ctx, adminLimitOf(in.Limit))
|
||||
@@ -439,9 +415,7 @@ func (o referralOps) adminList(ctx context.Context, in *adminListIn) (*adminBonu
|
||||
sum.add(r)
|
||||
views = append(views, adminReferralView{
|
||||
ID: r.ID, ReferrerOrg: r.ReferrerOrg, RefereeOrg: r.RefereeOrg, Code: r.Code,
|
||||
Status: r.Status, ReferrerGrantCents: r.ReferrerGrantCents, RefereeGrantCents: r.RefereeGrantCents,
|
||||
ReferrerTxn: r.ReferrerTxn, RefereeTxn: r.RefereeTxn,
|
||||
CreatedAt: r.CreatedAt, QualifiedAt: r.QualifiedAt, CreditedAt: r.CreditedAt,
|
||||
Status: r.Status, CreatedAt: r.CreatedAt, QualifiedAt: r.QualifiedAt,
|
||||
})
|
||||
}
|
||||
// Envelope { status, msg, data } — the /v1/admin/* convention the console's
|
||||
@@ -451,9 +425,8 @@ func (o referralOps) adminList(ctx context.Context, in *adminListIn) (*adminBonu
|
||||
|
||||
// sweepResult counts what one qualify sweep did.
|
||||
type sweepResult struct {
|
||||
// Credited is how many of those referrals qualified on this pass and were
|
||||
// granted their bonuses.
|
||||
Credited int `json:"credited"`
|
||||
// Qualified is how many of those referrals qualified on this pass.
|
||||
Qualified int `json:"qualified"`
|
||||
// Swept is how many pending referrals were checked.
|
||||
Swept int `json:"swept"`
|
||||
}
|
||||
@@ -470,18 +443,17 @@ type sweepEnvelope struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// adminSweep qualify-checks every pending referral and grants the ones that now qualify.
|
||||
// adminSweep qualify-checks every pending referral and advances the ones that now qualify.
|
||||
//
|
||||
// SuperAdmin only, fail-closed. This is the cron path: a referee QUALIFIES once
|
||||
// they have made metered spend — the honest signal that they actually used the
|
||||
// product rather than merely claiming a welcome grant — and qualifying grants the
|
||||
// referrer and the referee their bonuses in one latched step.
|
||||
// SuperAdmin only, fail-closed. This is the cron path, and the ONLY path that
|
||||
// advances a referral: a referee QUALIFIES once they have made metered spend — the
|
||||
// honest signal that they actually used the product rather than merely signing up.
|
||||
//
|
||||
// The grant is backed against the platform reserve fund before it is latched, so
|
||||
// an empty fund leaves the referral honestly pending rather than minting unbacked
|
||||
// credit, and the latch makes it at-most-once: this sweep, a concurrent sweep and
|
||||
// the lazy check on GET /v1/referrals can never double-pay. One pass is bounded,
|
||||
// so a large backlog drains over several runs instead of wedging one request.
|
||||
// Qualifying moves NO money. It records that an attribution became a real customer;
|
||||
// what is owed for that is an affiliate payable in commerce, settled by wire or to a
|
||||
// connected wallet. One pass is bounded, so a large backlog drains over several runs
|
||||
// instead of wedging one request, and the latch makes the transition at-most-once
|
||||
// under a concurrent sweep.
|
||||
//
|
||||
// It reads nothing from the caller — the counters it returns are the whole result.
|
||||
func (o referralOps) adminSweep(ctx context.Context, _ *noIn) (*sweepEnvelope, error) {
|
||||
@@ -490,110 +462,72 @@ func (o referralOps) adminSweep(ctx context.Context, _ *noIn) (*sweepEnvelope, e
|
||||
if err != nil {
|
||||
return nil, zip.Errorf(http.StatusInternalServerError, "list pending: %v", err)
|
||||
}
|
||||
swept, credited := 0, 0
|
||||
swept, qualified := 0, 0
|
||||
for _, r := range pending {
|
||||
swept++
|
||||
after, gerr := qualifyAndGrant(s, ctx, r)
|
||||
after, gerr := qualify(s, ctx, r)
|
||||
if gerr != nil {
|
||||
s.Log.Warn("referrals: sweep qualify failed", "id", r.ID, "err", gerr)
|
||||
continue
|
||||
}
|
||||
if after.Status == StatusCredited && r.Status != StatusCredited {
|
||||
credited++
|
||||
if after.Status == StatusQualified && r.Status != StatusQualified {
|
||||
qualified++
|
||||
}
|
||||
}
|
||||
return &sweepEnvelope{Data: sweepResult{Credited: credited, Swept: swept}, Status: "ok"}, nil
|
||||
return &sweepEnvelope{Data: sweepResult{Qualified: qualified, Swept: swept}, Status: "ok"}, nil
|
||||
}
|
||||
|
||||
// ── qualify → grant core (the ONE credit path, shared by sweep + lazy read) ───
|
||||
// ── qualify (attribution only — there is no credit path) ─────────────────────
|
||||
|
||||
// qualifyAndGrant is the loop's heart: if the referral is still pending and the
|
||||
// referee has now made metered spend (the qualify signal), LATCH the one-time
|
||||
// grant and deposit BOTH bonuses. Idempotent by the credited_at latch — a re-run,
|
||||
// a concurrent read, and the sweep can never double-pay. A commerce read error
|
||||
// leaves the referral pending (retried next check); a deposit error after the
|
||||
// latch is logged loud (at-most-once is the safety priority for credit).
|
||||
func qualifyAndGrant(s *cloud.Service[state], ctx context.Context, ref Referral) (Referral, error) {
|
||||
if ref.Status == StatusCredited || ref.CreditedAt != 0 {
|
||||
// qualify advances a pending referral to qualified when the referee has made
|
||||
// metered spend. That is the whole operation: it records that an attribution
|
||||
// became a real customer, and it moves NO money.
|
||||
//
|
||||
// It is idempotent by the qualified_at latch, so a concurrent sweep observes one
|
||||
// transition rather than two. A commerce read error leaves the referral pending,
|
||||
// retried on the next sweep.
|
||||
//
|
||||
// This is deliberately NOT reachable from a GET. The reward owed for a qualified
|
||||
// referral is an affiliate payable in hanzoai/commerce, settled by wire or to a
|
||||
// connected wallet; it is never issued here as platform credit.
|
||||
func qualify(s *cloud.Service[state], ctx context.Context, ref Referral) (Referral, error) {
|
||||
if ref.Status != StatusSignup {
|
||||
return ref, nil
|
||||
}
|
||||
spent, err := s.State.commerce.spendCents(ctx, ref.RefereeOrg, orgSubject(ref.RefereeOrg))
|
||||
if err != nil {
|
||||
return ref, err // commerce hiccup — try again next check, stays pending
|
||||
return ref, err // commerce hiccup — try again next sweep, stays pending
|
||||
}
|
||||
if spent <= 0 {
|
||||
return ref, nil // not qualified yet — the referee hasn't used the product
|
||||
}
|
||||
|
||||
// BACK the bonus against the platform reserve fund BEFORE latching: the combined
|
||||
// bonus ($15) debits fund:reserve (double-entry fund→payout:referral), idempotent
|
||||
// by the referral id. Not backed → the fund is empty; leave the referral pending
|
||||
// (honest, retried on the next sweep/qualify check) rather than mint unbacked
|
||||
// credit. Idempotent by ref, so a concurrent qualify + the latch below can never
|
||||
// double-charge the fund. Unmounted treasury → passthrough (backed=true).
|
||||
backed, entryID, berr := treasury.Reserve(ctx, treasury.ProgramReferral, "referral:"+ref.ID,
|
||||
fmt.Sprintf("Referral bonus: %s qualified (code %s)", ref.RefereeOrg, ref.Code),
|
||||
referrerBonusCents+refereeBonusCents)
|
||||
if berr != nil {
|
||||
return ref, fmt.Errorf("reserve referral bonus: %w", berr) // stays pending, retried
|
||||
}
|
||||
if !backed {
|
||||
s.Log.Warn("referrals: bonus deferred — treasury reserve insufficient",
|
||||
"id", ref.ID, "neededCents", referrerBonusCents+refereeBonusCents)
|
||||
return ref, nil // honestly pending until the fund is replenished
|
||||
}
|
||||
_ = entryID // the fund debit is linked to this referral by its ref (referral:<id>)
|
||||
|
||||
won, err := s.State.store.LatchCredit(ctx, ref.ID, referrerBonusCents, refereeBonusCents, time.Now().Unix())
|
||||
won, err := s.State.store.LatchQualified(ctx, ref.ID, time.Now().Unix())
|
||||
if err != nil {
|
||||
return ref, err
|
||||
}
|
||||
if !won {
|
||||
// A concurrent sweep/read already claimed + granted it — never double-pay.
|
||||
return s.State.store.Get(ctx, ref.ID)
|
||||
if won {
|
||||
emitQualifyAudit(s, ctx, ref)
|
||||
}
|
||||
|
||||
referrerTxn, rerr := grant(s, ctx, ref.ReferrerOrg, referrerBonusCents,
|
||||
fmt.Sprintf("Referral bonus: %s qualified (code %s)", ref.RefereeOrg, ref.Code))
|
||||
refereeTxn, ferr := grant(s, ctx, ref.RefereeOrg, refereeBonusCents,
|
||||
fmt.Sprintf("Referral welcome bonus (code %s)", ref.Code))
|
||||
if err := s.State.store.SetTxns(ctx, ref.ID, referrerTxn, refereeTxn); err != nil {
|
||||
s.Log.Error("referrals: record txns failed", "id", ref.ID, "err", err)
|
||||
}
|
||||
if rerr != nil || ferr != nil {
|
||||
// The latch already fired, so this bonus is NOT retried (at-most-once). Loud,
|
||||
// never silent — an operator can reconcile from this + the audit row.
|
||||
s.Log.Error("referrals: bonus deposit failed (latched at-most-once; not retried)",
|
||||
"id", ref.ID, "referrerErr", rerr, "refereeErr", ferr)
|
||||
}
|
||||
emitGrantAudit(s, ctx, ref, referrerTxn, refereeTxn)
|
||||
return s.State.store.Get(ctx, ref.ID)
|
||||
}
|
||||
|
||||
// grant deposits a promo credit into org's wallet (Credit/trial bucket) and
|
||||
// returns the ledger transaction id. Subject == the bare org slug, exactly the
|
||||
// wallet the balance panel reads (symmetric with admin.grantCredit).
|
||||
func grant(s *cloud.Service[state], ctx context.Context, org string, cents int64, note string) (string, error) {
|
||||
return s.State.commerce.deposit(ctx, org, orgSubject(org), cents, grantCurrency, note, grantTag)
|
||||
}
|
||||
|
||||
// emitGrantAudit records a referral bonus in cloud's tamper-evident trail (action
|
||||
// referral.credit, distinct from admin.customer.credit). Best-effort; a nil store
|
||||
// is a no-op. The actor is the referral engine (a system grant, not a user).
|
||||
func emitGrantAudit(s *cloud.Service[state], ctx context.Context, ref Referral, referrerTxn, refereeTxn string) {
|
||||
// emitQualifyAudit records the attribution transition in cloud's tamper-evident
|
||||
// trail (action referral.qualified). Best-effort; a nil store is a no-op. There is
|
||||
// no money in this record because there is no money in this package — it attests
|
||||
// that a referee became a customer, nothing more.
|
||||
func emitQualifyAudit(s *cloud.Service[state], ctx context.Context, ref Referral) {
|
||||
if s.State.auditStore == nil {
|
||||
return
|
||||
}
|
||||
rec := audit.Record{
|
||||
Actor: audit.Actor{Org: ref.ReferrerOrg, Sub: "referrals"},
|
||||
Action: "referral.credit",
|
||||
Resource: audit.Resource{Type: "credit", ID: ref.ID},
|
||||
Action: "referral.qualified",
|
||||
Resource: audit.Resource{Type: "referral", ID: ref.ID},
|
||||
Auth: audit.AuthContext{Method: "service"},
|
||||
Outcome: audit.Outcome{Result: "success", Status: 200},
|
||||
After: audit.Redact(mustJSON(map[string]any{
|
||||
"referrerOrg": ref.ReferrerOrg, "refereeOrg": ref.RefereeOrg, "code": ref.Code,
|
||||
"referrerGrantCents": referrerBonusCents, "refereeGrantCents": refereeBonusCents,
|
||||
"referrerTxn": referrerTxn, "refereeTxn": refereeTxn,
|
||||
})),
|
||||
}
|
||||
if _, err := s.State.auditStore.Append(ctx, rec); err != nil {
|
||||
@@ -609,20 +543,14 @@ type myReferralView struct {
|
||||
ID string `json:"id"`
|
||||
// Referee is the org that signed up with my code.
|
||||
Referee string `json:"referee"`
|
||||
// Status is the referral's lifecycle state: "signup" until the referee
|
||||
// makes metered spend, then "qualified", then "credited".
|
||||
// Status is the referral's lifecycle state: "signup" until the referee makes
|
||||
// metered spend, then "qualified".
|
||||
Status string `json:"status"`
|
||||
// CreditsCents is what I earned from this referral, in USD cents. It is 0
|
||||
// until the referee qualifies.
|
||||
CreditsCents int64 `json:"creditsCents"`
|
||||
// CreatedAt is when the referral was recorded, as a Unix timestamp.
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
// QualifiedAt is when the referee first made metered spend, as a Unix
|
||||
// timestamp; 0 while the referral is still pending.
|
||||
QualifiedAt int64 `json:"qualifiedAt"`
|
||||
// CreditedAt is when the bonuses were latched and granted, as a Unix
|
||||
// timestamp; 0 until they are. It is the at-most-once latch.
|
||||
CreditedAt int64 `json:"creditedAt"`
|
||||
}
|
||||
|
||||
// adminReferralView is one row in the SuperAdmin directory (both orgs exposed).
|
||||
@@ -635,29 +563,13 @@ type adminReferralView struct {
|
||||
RefereeOrg string `json:"refereeOrg"`
|
||||
// Code is the referral code the referral was recorded against.
|
||||
Code string `json:"code"`
|
||||
// Status is the referral's lifecycle state: "signup", "qualified" or
|
||||
// "credited".
|
||||
// Status is the referral's lifecycle state: "signup" or "qualified".
|
||||
Status string `json:"status"`
|
||||
// ReferrerGrantCents is what the referrer was granted, in USD cents; 0 until
|
||||
// the referral is credited.
|
||||
ReferrerGrantCents int64 `json:"referrerGrantCents"`
|
||||
// RefereeGrantCents is what the referee was granted, in USD cents; 0 until the
|
||||
// referral is credited.
|
||||
RefereeGrantCents int64 `json:"refereeGrantCents"`
|
||||
// ReferrerTxn is the commerce ledger transaction that carried the referrer's
|
||||
// grant, omitted until one exists.
|
||||
ReferrerTxn string `json:"referrerTxn,omitempty"`
|
||||
// RefereeTxn is the commerce ledger transaction that carried the referee's
|
||||
// grant, omitted until one exists.
|
||||
RefereeTxn string `json:"refereeTxn,omitempty"`
|
||||
// CreatedAt is when the referral was recorded, as a Unix timestamp.
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
// QualifiedAt is when the referee first made metered spend, as a Unix
|
||||
// timestamp; 0 while still pending.
|
||||
QualifiedAt int64 `json:"qualifiedAt"`
|
||||
// CreditedAt is when the bonuses were latched and granted, as a Unix
|
||||
// timestamp; 0 until they are.
|
||||
CreditedAt int64 `json:"creditedAt"`
|
||||
}
|
||||
|
||||
// statusCounts is the customer-view tally of a referrer's referrals by status.
|
||||
@@ -666,10 +578,8 @@ type statusCounts struct {
|
||||
Total int `json:"total"`
|
||||
// Signup is how many referees have signed up but not yet spent.
|
||||
Signup int `json:"signup"`
|
||||
// Qualified is how many referees have spent but are not yet credited.
|
||||
// Qualified is how many referees have made metered spend.
|
||||
Qualified int `json:"qualified"`
|
||||
// Credited is how many referrals have paid both bonuses.
|
||||
Credited int `json:"credited"`
|
||||
}
|
||||
|
||||
func (s *statusCounts) add(status string) {
|
||||
@@ -679,25 +589,18 @@ func (s *statusCounts) add(status string) {
|
||||
s.Signup++
|
||||
case StatusQualified:
|
||||
s.Qualified++
|
||||
case StatusCredited:
|
||||
s.Credited++
|
||||
}
|
||||
}
|
||||
|
||||
// adminSummary is the fleet tally for the admin directory, including the total
|
||||
// promo credit granted across both sides.
|
||||
// adminSummary is the fleet tally for the admin directory. It carries no amount:
|
||||
// this package issues no credit, so there is no liability to total here.
|
||||
type adminSummary struct {
|
||||
// Total is every referral in the ledger.
|
||||
// Total is every referral in the directory.
|
||||
Total int `json:"total"`
|
||||
// Signup is how many are recorded but not yet qualified.
|
||||
Signup int `json:"signup"`
|
||||
// Qualified is how many have qualified but are not yet credited.
|
||||
// Qualified is how many referees have made metered spend.
|
||||
Qualified int `json:"qualified"`
|
||||
// Credited is how many have paid both bonuses.
|
||||
Credited int `json:"credited"`
|
||||
// GrantedCents is the promo credit granted across BOTH sides of every
|
||||
// referral, in USD cents — the program's total liability to date.
|
||||
GrantedCents int64 `json:"grantedCents"`
|
||||
}
|
||||
|
||||
func (a *adminSummary) add(r Referral) {
|
||||
@@ -707,10 +610,7 @@ func (a *adminSummary) add(r Referral) {
|
||||
a.Signup++
|
||||
case StatusQualified:
|
||||
a.Qualified++
|
||||
case StatusCredited:
|
||||
a.Credited++
|
||||
}
|
||||
a.GrantedCents += r.ReferrerGrantCents + r.RefereeGrantCents
|
||||
}
|
||||
|
||||
// orgSubject is the billing subject commerce keys an org's wallet on — the bare
|
||||
|
||||
+296
-134
@@ -7,7 +7,8 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -21,40 +22,27 @@ import (
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// fakeCommerce is an in-memory commerce ledger: it records deposits per org (the
|
||||
// wallet balance) and lets a test SET a referee's metered spend (the qualify
|
||||
// signal). It is the money-seam stand-in that lets the tests PROVE both balances
|
||||
// move on a qualifying referral — without a live commerce deployment.
|
||||
// fakeCommerce is an in-memory stand-in for the ONE question this package asks the
|
||||
// money plane: how much has this org spent? It cannot deposit, because the seam it
|
||||
// implements cannot deposit.
|
||||
type fakeCommerce struct {
|
||||
mu sync.Mutex
|
||||
balance map[string]int64 // org → deposited cents (the wallet)
|
||||
spend map[string]int64 // org → metered spend cents (qualify signal)
|
||||
deposits int // total deposit calls (idempotency proof)
|
||||
failDep bool // when true, deposit errors (to exercise the at-most-once log path)
|
||||
seq int
|
||||
mu sync.Mutex
|
||||
spend map[string]int64 // org → metered spend cents (qualify signal)
|
||||
reads int // spendCents calls (proves the sweep did the work)
|
||||
failOn string // org whose spend read errors, to exercise "stays pending"
|
||||
}
|
||||
|
||||
func newFakeCommerce() *fakeCommerce {
|
||||
return &fakeCommerce{balance: map[string]int64{}, spend: map[string]int64{}}
|
||||
}
|
||||
func newFakeCommerce() *fakeCommerce { return &fakeCommerce{spend: map[string]int64{}} }
|
||||
|
||||
func (f *fakeCommerce) configured() bool { return true }
|
||||
|
||||
func (f *fakeCommerce) deposit(_ context.Context, org, _ string, amountCents int64, _, _, _ string) (string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.failDep {
|
||||
return "", errUnconfigured
|
||||
}
|
||||
f.balance[org] += amountCents
|
||||
f.deposits++
|
||||
f.seq++
|
||||
return "txn_test_" + org + "_" + strconv.Itoa(f.seq), nil
|
||||
}
|
||||
|
||||
func (f *fakeCommerce) spendCents(_ context.Context, org, _ string) (int64, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.reads++
|
||||
if org == f.failOn {
|
||||
return 0, errUnconfigured
|
||||
}
|
||||
return f.spend[org], nil
|
||||
}
|
||||
|
||||
@@ -64,39 +52,41 @@ func (f *fakeCommerce) setSpend(org string, cents int64) {
|
||||
f.spend[org] = cents
|
||||
}
|
||||
|
||||
func (f *fakeCommerce) bal(org string) int64 {
|
||||
func (f *fakeCommerce) readCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.balance[org]
|
||||
return f.reads
|
||||
}
|
||||
|
||||
func (f *fakeCommerce) depositCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.deposits
|
||||
}
|
||||
|
||||
// mount builds a referrals app backed by a fresh store + the injected fake
|
||||
// commerce, returning the app and the fake for assertions.
|
||||
// mount builds a referrals app backed by a fresh store + the injected commerce
|
||||
// seam, returning the app and the fake for assertions.
|
||||
func mount(t *testing.T) (*zip.App, *cloud.Service[state], *fakeCommerce) {
|
||||
t.Helper()
|
||||
fc := newFakeCommerce()
|
||||
app, s := mountWith(t, fc)
|
||||
return app, s, fc
|
||||
}
|
||||
|
||||
// mountWith is mount over an arbitrary commerce seam, so a test can drive the real
|
||||
// payout client at a stub server instead of the in-memory fake.
|
||||
func mountWith(t *testing.T, c commerce) (*zip.App, *cloud.Service[state]) {
|
||||
t.Helper()
|
||||
store, err := openStore(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("openStore: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
fc := newFakeCommerce()
|
||||
s := &cloud.Service[state]{
|
||||
Base: cloud.NewBase(cloud.Deps{Logger: luxlog.New("test"), Brand: "hanzo"}, "referrals"),
|
||||
State: state{
|
||||
store: store,
|
||||
commerce: fc,
|
||||
commerce: c,
|
||||
linkBase: "https://hanzo.ai",
|
||||
},
|
||||
}
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
routes(app, s)
|
||||
return app, s, fc
|
||||
return app, s
|
||||
}
|
||||
|
||||
// req drives one HTTP request. org sets a VALIDATED principal (X-Org-Id +
|
||||
@@ -234,93 +224,249 @@ func TestClaimSelfAndIdempotent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestQualifyGrantsBothSidesOnceAndBalancesMove is the CORE proof: a referee that
|
||||
// qualifies (metered spend) triggers a DOUBLE grant — referrer +$10, referee +$5 —
|
||||
// and the grant is at-most-once (a re-sweep never double-pays). Balances are
|
||||
// asserted through the (fake) commerce ledger, exactly the balance API a live
|
||||
// proof reads.
|
||||
func TestQualifyGrantsBothSidesOnceAndBalancesMove(t *testing.T) {
|
||||
app, s, fc := mount(t)
|
||||
ctx := context.Background()
|
||||
aCode, _ := s.State.store.EnsureCode(ctx, "orgA")
|
||||
// ── the P0 proofs: a GET grants nothing and changes nothing ──────────────────
|
||||
|
||||
// orgB signs up via orgA's code.
|
||||
if code, _ := req(t, app, http.MethodPost, "/v1/referrals/claim", "orgB", false, map[string]any{"code": aCode}); code != http.StatusCreated {
|
||||
t.Fatalf("claim want 201, got %d", code)
|
||||
}
|
||||
|
||||
// Not yet qualified (no spend): an admin sweep grants NOTHING; balances stay 0.
|
||||
code, body := req(t, app, http.MethodPost, "/v1/admin/referrals/sweep", "admin", true, nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("sweep want 200, got %d (%s)", code, body)
|
||||
}
|
||||
if got := credited(body); got != 0 {
|
||||
t.Fatalf("pre-qualify sweep credited=%d, want 0", got)
|
||||
}
|
||||
if fc.bal("orgA") != 0 || fc.bal("orgB") != 0 || fc.depositCount() != 0 {
|
||||
t.Fatalf("pre-qualify balances moved: A=%d B=%d deposits=%d", fc.bal("orgA"), fc.bal("orgB"), fc.depositCount())
|
||||
}
|
||||
|
||||
// orgB makes metered spend → now qualifies.
|
||||
fc.setSpend("orgB", 42)
|
||||
|
||||
// Sweep → BOTH balances move: A +$10 (1000c), B +$5 (500c). Exactly 2 deposits.
|
||||
code, body = req(t, app, http.MethodPost, "/v1/admin/referrals/sweep", "admin", true, nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("qualify sweep want 200, got %d (%s)", code, body)
|
||||
}
|
||||
if got := credited(body); got != 1 {
|
||||
t.Fatalf("qualify sweep credited=%d, want 1", got)
|
||||
}
|
||||
if fc.bal("orgA") != referrerBonusCents {
|
||||
t.Fatalf("referrer balance = %d, want %d", fc.bal("orgA"), referrerBonusCents)
|
||||
}
|
||||
if fc.bal("orgB") != refereeBonusCents {
|
||||
t.Fatalf("referee balance = %d, want %d", fc.bal("orgB"), refereeBonusCents)
|
||||
}
|
||||
if fc.depositCount() != 2 {
|
||||
t.Fatalf("deposit count = %d, want 2 (one per side)", fc.depositCount())
|
||||
}
|
||||
|
||||
// IDEMPOTENT: a re-sweep + a referrer page load must NOT grant again.
|
||||
req(t, app, http.MethodPost, "/v1/admin/referrals/sweep", "admin", true, nil)
|
||||
req(t, app, http.MethodGet, "/v1/referrals", "orgA", false, nil) // lazy check re-runs
|
||||
if fc.bal("orgA") != referrerBonusCents || fc.bal("orgB") != refereeBonusCents {
|
||||
t.Fatalf("double-grant! A=%d B=%d", fc.bal("orgA"), fc.bal("orgB"))
|
||||
}
|
||||
if fc.depositCount() != 2 {
|
||||
t.Fatalf("idempotency broken: deposit count = %d, want 2", fc.depositCount())
|
||||
}
|
||||
}
|
||||
|
||||
// TestLazyQualifyOnReferrerRead proves the referrer's own GET /v1/referrals runs
|
||||
// the qualify check (self-updating page) — no admin sweep needed — and the view
|
||||
// reports the earned credit + credited status.
|
||||
func TestLazyQualifyOnReferrerRead(t *testing.T) {
|
||||
// TestGetIsPureReadAndAdvancesNothing is THE regression test for the live defect
|
||||
// this package shipped: GET /v1/referrals ran a "lazy qualify sweep" that reached
|
||||
// a deposit, so merely LOADING the referrals page minted platform credit.
|
||||
//
|
||||
// It sets up the exact state that used to mint — a claimed referral whose referee
|
||||
// HAS metered spend, i.e. one that qualifies — then loads the page as the referrer
|
||||
// and asserts the referral is untouched: still signup, qualifiedAt still 0. The GET
|
||||
// is a report, not a transition.
|
||||
func TestGetIsPureReadAndAdvancesNothing(t *testing.T) {
|
||||
app, s, fc := mount(t)
|
||||
ctx := context.Background()
|
||||
aCode, _ := s.State.store.EnsureCode(ctx, "orgA")
|
||||
req(t, app, http.MethodPost, "/v1/referrals/claim", "orgB", false, map[string]any{"code": aCode})
|
||||
fc.setSpend("orgB", 7) // qualifies
|
||||
fc.setSpend("orgB", 5000) // orgB WOULD qualify — this is the minting precondition
|
||||
|
||||
before, err := s.State.store.getByReferee(ctx, "orgB")
|
||||
if err != nil {
|
||||
t.Fatalf("getByReferee: %v", err)
|
||||
}
|
||||
|
||||
// Load the page repeatedly — the old code granted on every load.
|
||||
for i := 0; i < 3; i++ {
|
||||
code, body := req(t, app, http.MethodGet, "/v1/referrals", "orgA", false, nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("GET /v1/referrals want 200, got %d (%s)", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
after, err := s.State.store.getByReferee(ctx, "orgB")
|
||||
if err != nil {
|
||||
t.Fatalf("getByReferee: %v", err)
|
||||
}
|
||||
if after.Status != StatusSignup {
|
||||
t.Fatalf("GET advanced the referral: status %q → %q (a read must not transition state)", before.Status, after.Status)
|
||||
}
|
||||
if after.QualifiedAt != 0 {
|
||||
t.Fatalf("GET set qualifiedAt=%d — a read must not write", after.QualifiedAt)
|
||||
}
|
||||
if after != before {
|
||||
t.Fatalf("GET mutated the referral row:\n before %+v\n after %+v", before, after)
|
||||
}
|
||||
// The read never even ASKS the money plane: no qualify check, so no spend read.
|
||||
if n := fc.readCount(); n != 0 {
|
||||
t.Fatalf("GET made %d commerce read(s); a pure read touches the money plane 0 times", n)
|
||||
}
|
||||
|
||||
// And the capability is not lost — it moved to the gated write. One admin sweep
|
||||
// qualifies it, which is the ONLY door.
|
||||
if code, body := req(t, app, http.MethodPost, "/v1/admin/referrals/sweep", "admin", true, nil); code != http.StatusOK {
|
||||
t.Fatalf("sweep want 200, got %d (%s)", code, body)
|
||||
}
|
||||
swept, _ := s.State.store.getByReferee(ctx, "orgB")
|
||||
if swept.Status != StatusQualified || swept.QualifiedAt == 0 {
|
||||
t.Fatalf("admin sweep did not qualify: %+v", swept)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLedgerReceivesZeroDeposits proves the absence of the mint at the WIRE, not
|
||||
// at an interface a test could fake into agreement: the service is bound to the
|
||||
// REAL payout client, pointed at a stub commerce that fails the test if anything
|
||||
// ever posts to a money-in endpoint.
|
||||
//
|
||||
// It then drives every route on the surface, in the state that used to pay out.
|
||||
// The only request commerce may see is the read-only usage rollup.
|
||||
func TestLedgerReceivesZeroDeposits(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var hits []string
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
hits = append(hits, r.Method+" "+r.URL.Path)
|
||||
mu.Unlock()
|
||||
|
||||
// ANY write to the money plane is the bug. Fail loudly rather than 500 and
|
||||
// let a swallowed error look like success.
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("commerce received a WRITE the referrals surface must never make: %s %s", r.Method, r.URL.Path)
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if strings.Contains(r.URL.Path, "deposit") || strings.Contains(r.URL.Path, "credit") {
|
||||
t.Errorf("commerce received a money-in call: %s %s", r.Method, r.URL.Path)
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
// The qualify signal: orgB has spent.
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"consumedCents":4242}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
app, s := mountWith(t, newCommerceClient(srv.URL, "test-service-token"))
|
||||
ctx := context.Background()
|
||||
aCode, _ := s.State.store.EnsureCode(ctx, "orgA")
|
||||
|
||||
// Exercise the whole surface in the qualifying state.
|
||||
req(t, app, http.MethodPost, "/v1/referrals/claim", "orgB", false, map[string]any{"code": aCode})
|
||||
req(t, app, http.MethodGet, "/v1/referrals", "orgA", false, nil)
|
||||
req(t, app, http.MethodPost, "/v1/admin/referrals/sweep", "admin", true, nil)
|
||||
req(t, app, http.MethodGet, "/v1/referrals", "orgA", false, nil)
|
||||
req(t, app, http.MethodPost, "/v1/admin/referrals/sweep", "admin", true, nil)
|
||||
req(t, app, http.MethodGet, "/v1/admin/referrals/bonuses", "admin", true, nil)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for _, h := range hits {
|
||||
if !strings.HasPrefix(h, "GET /v1/billing/usage/rollup") {
|
||||
t.Fatalf("unexpected commerce call %q — the only call this surface may make is the usage rollup (all hits: %v)", h, hits)
|
||||
}
|
||||
}
|
||||
// The referral did qualify over that run, so the surface was genuinely exercised
|
||||
// in the state that used to pay — the zero above is not a vacuous zero.
|
||||
ref, err := s.State.store.getByReferee(ctx, "orgB")
|
||||
if err != nil {
|
||||
t.Fatalf("getByReferee: %v", err)
|
||||
}
|
||||
if ref.Status != StatusQualified {
|
||||
t.Fatalf("referral never qualified (%+v) — the no-deposit proof would be vacuous", ref)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCommerceSeamIsReadOnly pins the SHAPE of the money seam. The mint existed
|
||||
// because the seam carried a deposit method; with no write method on the interface,
|
||||
// reviving the mint cannot be a one-line call — it has to start by re-declaring the
|
||||
// capability here, in front of a test that says no.
|
||||
func TestCommerceSeamIsReadOnly(t *testing.T) {
|
||||
typ := reflect.TypeOf((*commerce)(nil)).Elem()
|
||||
banned := []string{"deposit", "credit", "grant", "mint", "transfer", "refund", "charge", "payout"}
|
||||
for i := 0; i < typ.NumMethod(); i++ {
|
||||
name := strings.ToLower(typ.Method(i).Name)
|
||||
for _, b := range banned {
|
||||
if strings.Contains(name, b) {
|
||||
t.Fatalf("commerce seam grew a money-moving method %q — referrals issues no credit; a referral reward is an affiliate payable in commerce, settled by wire or wallet", typ.Method(i).Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
// And it is exactly the read it claims to be.
|
||||
if got := typ.NumMethod(); got != 2 {
|
||||
t.Fatalf("commerce seam has %d methods, want 2 (configured, spendCents)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteMethodsRequireOrg proves the gate is scoped by SAFE METHOD rather than
|
||||
// by naming POST. The predecessor waved through everything that was not POST, which
|
||||
// is the reasoning that let a GET reach a deposit; a verb this package does not even
|
||||
// serve must still be refused without a principal, never silently allowed.
|
||||
func TestWriteMethodsRequireOrg(t *testing.T) {
|
||||
app, _, _ := mount(t)
|
||||
for _, m := range []string{http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} {
|
||||
code, _ := req(t, app, m, "/v1/referrals/claim", "", false, map[string]any{"code": "ZZZZZZZZ"})
|
||||
if code == http.StatusOK || code == http.StatusCreated {
|
||||
t.Fatalf("%s with no principal was allowed (got %d)", m, code)
|
||||
}
|
||||
}
|
||||
// GET stays open to the gate (its handler does its own 403) so health probes work.
|
||||
if code, _ := req(t, app, http.MethodGet, "/v1/referrals", "", false, nil); code != http.StatusForbidden {
|
||||
t.Fatalf("no-principal GET want 403 from the handler, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── qualification (the surviving capability) ─────────────────────────────────
|
||||
|
||||
// TestSweepQualifiesOnceAndIsIdempotent: the admin sweep advances a referee that
|
||||
// has spent, exactly once, and a re-sweep is a no-op.
|
||||
func TestSweepQualifiesOnceAndIsIdempotent(t *testing.T) {
|
||||
app, s, fc := mount(t)
|
||||
ctx := context.Background()
|
||||
aCode, _ := s.State.store.EnsureCode(ctx, "orgA")
|
||||
|
||||
if code, _ := req(t, app, http.MethodPost, "/v1/referrals/claim", "orgB", false, map[string]any{"code": aCode}); code != http.StatusCreated {
|
||||
t.Fatalf("claim want 201, got %d", code)
|
||||
}
|
||||
|
||||
// No spend yet → sweep qualifies nothing.
|
||||
code, body := req(t, app, http.MethodPost, "/v1/admin/referrals/sweep", "admin", true, nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("sweep want 200, got %d (%s)", code, body)
|
||||
}
|
||||
if got := qualifiedCount(body); got != 0 {
|
||||
t.Fatalf("pre-spend sweep qualified=%d, want 0", got)
|
||||
}
|
||||
|
||||
// orgB makes metered spend → now qualifies.
|
||||
fc.setSpend("orgB", 42)
|
||||
code, body = req(t, app, http.MethodPost, "/v1/admin/referrals/sweep", "admin", true, nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("qualify sweep want 200, got %d (%s)", code, body)
|
||||
}
|
||||
if got := qualifiedCount(body); got != 1 {
|
||||
t.Fatalf("qualify sweep qualified=%d, want 1", got)
|
||||
}
|
||||
first, _ := s.State.store.getByReferee(ctx, "orgB")
|
||||
if first.Status != StatusQualified || first.QualifiedAt == 0 {
|
||||
t.Fatalf("not qualified: %+v", first)
|
||||
}
|
||||
|
||||
// Re-sweep: already qualified, so it is no longer pending and nothing moves.
|
||||
_, body = req(t, app, http.MethodPost, "/v1/admin/referrals/sweep", "admin", true, nil)
|
||||
if got := qualifiedCount(body); got != 0 {
|
||||
t.Fatalf("re-sweep qualified=%d, want 0 (at-most-once)", got)
|
||||
}
|
||||
again, _ := s.State.store.getByReferee(ctx, "orgB")
|
||||
if again != first {
|
||||
t.Fatalf("re-sweep mutated the row:\n first %+v\n again %+v", first, again)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQualifyStaysPendingOnCommerceError: a money-plane hiccup leaves the referral
|
||||
// honestly pending rather than qualifying it on a failed read.
|
||||
func TestQualifyStaysPendingOnCommerceError(t *testing.T) {
|
||||
app, s, fc := mount(t)
|
||||
ctx := context.Background()
|
||||
aCode, _ := s.State.store.EnsureCode(ctx, "orgA")
|
||||
req(t, app, http.MethodPost, "/v1/referrals/claim", "orgB", false, map[string]any{"code": aCode})
|
||||
fc.setSpend("orgB", 99)
|
||||
fc.failOn = "orgB"
|
||||
|
||||
if code, _ := req(t, app, http.MethodPost, "/v1/admin/referrals/sweep", "admin", true, nil); code != http.StatusOK {
|
||||
t.Fatalf("sweep want 200")
|
||||
}
|
||||
ref, _ := s.State.store.getByReferee(ctx, "orgB")
|
||||
if ref.Status != StatusSignup {
|
||||
t.Fatalf("commerce error qualified the referral anyway: %+v", ref)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMyReferralsView: the customer read reports code, link and attribution — and
|
||||
// carries no money field, because there is no money.
|
||||
func TestMyReferralsView(t *testing.T) {
|
||||
app, s, fc := mount(t)
|
||||
ctx := context.Background()
|
||||
aCode, _ := s.State.store.EnsureCode(ctx, "orgA")
|
||||
req(t, app, http.MethodPost, "/v1/referrals/claim", "orgB", false, map[string]any{"code": aCode})
|
||||
fc.setSpend("orgB", 7)
|
||||
req(t, app, http.MethodPost, "/v1/admin/referrals/sweep", "admin", true, nil)
|
||||
|
||||
// orgA loads their referrals page → the lazy check grants both sides.
|
||||
code, body := req(t, app, http.MethodGet, "/v1/referrals", "orgA", false, nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("GET /v1/referrals want 200, got %d (%s)", code, body)
|
||||
}
|
||||
var view struct {
|
||||
Code string `json:"code"`
|
||||
Link string `json:"link"`
|
||||
CreditsEarnedCents int64 `json:"creditsEarnedCents"`
|
||||
Counts struct {
|
||||
Total, Credited int
|
||||
} `json:"counts"`
|
||||
Referrals []struct {
|
||||
Referee string `json:"referee"`
|
||||
Status string `json:"status"`
|
||||
CreditsCents int64 `json:"creditsCents"`
|
||||
} `json:"referrals"`
|
||||
}
|
||||
var view myReferrals
|
||||
if err := json.Unmarshal(body, &view); err != nil {
|
||||
t.Fatalf("decode: %v (%s)", err, body)
|
||||
}
|
||||
@@ -330,24 +476,22 @@ func TestLazyQualifyOnReferrerRead(t *testing.T) {
|
||||
if view.Link != "https://hanzo.ai/?ref="+aCode {
|
||||
t.Fatalf("link = %q", view.Link)
|
||||
}
|
||||
if view.CreditsEarnedCents != referrerBonusCents {
|
||||
t.Fatalf("creditsEarned = %d, want %d", view.CreditsEarnedCents, referrerBonusCents)
|
||||
if view.Counts.Total != 1 || view.Counts.Qualified != 1 {
|
||||
t.Fatalf("counts = %+v, want total=1 qualified=1", view.Counts)
|
||||
}
|
||||
if view.Counts.Credited != 1 || len(view.Referrals) != 1 || view.Referrals[0].Status != StatusCredited {
|
||||
t.Fatalf("view not credited: %+v", view)
|
||||
if len(view.Referrals) != 1 || view.Referrals[0].Status != StatusQualified {
|
||||
t.Fatalf("rows = %+v", view.Referrals)
|
||||
}
|
||||
if view.Referrals[0].CreditsCents != referrerBonusCents {
|
||||
t.Fatalf("row credits = %d, want %d", view.Referrals[0].CreditsCents, referrerBonusCents)
|
||||
}
|
||||
// Balances moved (via commerce).
|
||||
if fc.bal("orgA") != referrerBonusCents || fc.bal("orgB") != refereeBonusCents {
|
||||
t.Fatalf("balances: A=%d B=%d", fc.bal("orgA"), fc.bal("orgB"))
|
||||
if view.Referrals[0].Referee != "orgB" {
|
||||
t.Fatalf("referee = %q, want orgB", view.Referrals[0].Referee)
|
||||
}
|
||||
// No credit vocabulary survives on the wire.
|
||||
assertNoMoneyKeys(t, body)
|
||||
_ = ctx
|
||||
}
|
||||
|
||||
// TestAdminGateAndDirectory: /v1/admin/referrals/bonuses is SuperAdmin fail-closed,
|
||||
// and exposes both orgs + a summary.
|
||||
// and exposes both orgs + a summary with no amounts.
|
||||
func TestAdminGateAndDirectory(t *testing.T) {
|
||||
app, s, fc := mount(t)
|
||||
ctx := context.Background()
|
||||
@@ -383,24 +527,42 @@ func TestAdminGateAndDirectory(t *testing.T) {
|
||||
t.Fatalf("admin referrals len = %d, want 1", len(out.Referrals))
|
||||
}
|
||||
r0 := out.Referrals[0]
|
||||
if r0.ReferrerOrg != "orgA" || r0.RefereeOrg != "orgB" || r0.Status != StatusCredited {
|
||||
if r0.ReferrerOrg != "orgA" || r0.RefereeOrg != "orgB" || r0.Status != StatusQualified {
|
||||
t.Fatalf("admin row wrong: %+v", r0)
|
||||
}
|
||||
if out.Summary.Total != 1 || out.Summary.Credited != 1 || out.Summary.GrantedCents != referrerBonusCents+refereeBonusCents {
|
||||
if out.Summary.Total != 1 || out.Summary.Qualified != 1 {
|
||||
t.Fatalf("summary wrong: %+v", out.Summary)
|
||||
}
|
||||
assertNoMoneyKeys(t, body)
|
||||
_ = ctx
|
||||
}
|
||||
|
||||
// assertNoMoneyKeys fails if a response carries any credit/grant vocabulary. The
|
||||
// old wire advertised bonus amounts and granted cents; a field that can only ever
|
||||
// report zero is a lie about what this surface does, so none may survive.
|
||||
func assertNoMoneyKeys(t *testing.T, body []byte) {
|
||||
t.Helper()
|
||||
for _, k := range []string{
|
||||
"creditsEarnedCents", "creditsCents", "referrerBonusCents", "refereeBonusCents",
|
||||
"referrerGrantCents", "refereeGrantCents", "grantedCents", "referrerTxn", "refereeTxn",
|
||||
"creditedAt", "credited",
|
||||
} {
|
||||
if bytes.Contains(body, []byte(`"`+k+`"`)) {
|
||||
t.Fatalf("response still carries credit vocabulary %q: %s", k, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// credited pulls the "credited" count out of an ENVELOPED sweep response
|
||||
// ({status,msg,data:{swept,credited}}).
|
||||
func credited(body []byte) int {
|
||||
// qualifiedCount pulls the "qualified" count out of an ENVELOPED sweep response
|
||||
// ({status,msg,data:{swept,qualified}}).
|
||||
func qualifiedCount(body []byte) int {
|
||||
var out struct {
|
||||
Data struct {
|
||||
Credited int `json:"credited"`
|
||||
Qualified int `json:"qualified"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &out)
|
||||
return out.Data.Credited
|
||||
return out.Data.Qualified
|
||||
}
|
||||
|
||||
// lower is a tiny helper (avoid importing strings just for the test).
|
||||
|
||||
+47
-57
@@ -29,30 +29,31 @@ var (
|
||||
errUnknownCode = errors.New("referrals: unknown referral code")
|
||||
)
|
||||
|
||||
// Status values. A referral advances signup → qualified → credited. It never
|
||||
// moves backward; credited is terminal (the bonus was granted, once).
|
||||
// Status values. A referral advances signup → qualified. It never moves backward;
|
||||
// qualified is terminal (the referee became a real customer, once).
|
||||
//
|
||||
// There is deliberately no third state: the old "credited" existed only to record
|
||||
// that this package had minted platform credit, and it does not mint anything.
|
||||
const (
|
||||
StatusSignup = "signup"
|
||||
StatusQualified = "qualified"
|
||||
StatusCredited = "credited"
|
||||
)
|
||||
|
||||
// Referral is one referrer↔referee edge. RefereeOrg is UNIQUE across the table —
|
||||
// an org can be referred at most once, ever (first-touch attribution), which is
|
||||
// also the idempotency key for POST /v1/referrals/claim.
|
||||
//
|
||||
// It holds attribution and nothing else. No amount, no ledger transaction: what a
|
||||
// qualified referral is WORTH is an affiliate payable in commerce, and pricing it
|
||||
// here would be a second answer to a question that already has one.
|
||||
type Referral struct {
|
||||
ID string `json:"id"`
|
||||
ReferrerOrg string `json:"-"` // hidden: the code owner; never leak the other side's org to a referee
|
||||
RefereeOrg string `json:"-"` // hidden for the same reason (admin view re-exposes both)
|
||||
Code string `json:"code"` // the referrer code used at claim
|
||||
Status string `json:"status"`
|
||||
ReferrerGrantCents int64 `json:"referrerGrantCents"`
|
||||
RefereeGrantCents int64 `json:"refereeGrantCents"`
|
||||
ReferrerTxn string `json:"-"`
|
||||
RefereeTxn string `json:"-"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
QualifiedAt int64 `json:"qualifiedAt"`
|
||||
CreditedAt int64 `json:"creditedAt"`
|
||||
ID string `json:"id"`
|
||||
ReferrerOrg string `json:"-"` // hidden: the code owner; never leak the other side's org to a referee
|
||||
RefereeOrg string `json:"-"` // hidden for the same reason (admin view re-exposes both)
|
||||
Code string `json:"code"` // the referrer code used at claim
|
||||
Status string `json:"status"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
QualifiedAt int64 `json:"qualifiedAt"`
|
||||
}
|
||||
|
||||
// Store is the referrals database. ONE SQLite file holds every org's codes +
|
||||
@@ -86,24 +87,28 @@ CREATE TABLE IF NOT EXISTS referral_codes (
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS referrals (
|
||||
id TEXT PRIMARY KEY,
|
||||
referrer_org TEXT NOT NULL,
|
||||
referee_org TEXT NOT NULL UNIQUE,
|
||||
code TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
referrer_grant_cents INTEGER NOT NULL DEFAULT 0,
|
||||
referee_grant_cents INTEGER NOT NULL DEFAULT 0,
|
||||
referrer_txn TEXT NOT NULL DEFAULT '',
|
||||
referee_txn TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
qualified_at INTEGER NOT NULL DEFAULT 0,
|
||||
credited_at INTEGER NOT NULL DEFAULT 0
|
||||
id TEXT PRIMARY KEY,
|
||||
referrer_org TEXT NOT NULL,
|
||||
referee_org TEXT NOT NULL UNIQUE,
|
||||
code TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
qualified_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_referrals_referrer ON referrals(referrer_org, created_at);
|
||||
CREATE INDEX IF NOT EXISTS ix_referrals_status ON referrals(status);
|
||||
|
||||
-- One vocabulary: the pre-rename rows said 'signed_up'. Idempotent.
|
||||
UPDATE referrals SET status='signup' WHERE status='signed_up';
|
||||
|
||||
-- The credit era is over. Rows an earlier build marked 'credited' recorded that a
|
||||
-- referee had qualified AND that platform credit was minted for it; the first half
|
||||
-- is still true and is the state they keep, the second half is not a thing this
|
||||
-- table records any more. The grant/txn columns are left to rot in place on an
|
||||
-- existing file (SQLite DROP COLUMN is not portable across the versions in the
|
||||
-- fleet, and no code reads or writes them): NOT NULL DEFAULT means an insert that
|
||||
-- names only the live columns still succeeds. New files never get them.
|
||||
UPDATE referrals SET status='qualified' WHERE status='credited';
|
||||
`
|
||||
if _, err := s.db.Exec(ddl); err != nil {
|
||||
return fmt.Errorf("referrals migrate: %w", err)
|
||||
@@ -207,46 +212,31 @@ func (s *Store) Claim(ctx context.Context, id, referrerOrg, refereeOrg, code str
|
||||
return Referral{}, false, fmt.Errorf("claim: %w", err)
|
||||
}
|
||||
|
||||
// LatchCredit atomically CLAIMS the one-and-only grant for a referral: it sets
|
||||
// credited_at (+ status=credited, grant amounts, qualified_at if unset) ONLY when
|
||||
// credited_at is still 0. RowsAffected==1 means THIS caller won the race and must
|
||||
// perform the deposits; 0 means another sweep already granted (never double-pay).
|
||||
// credited_at is the idempotency latch — the money is guaranteed at-most-once.
|
||||
func (s *Store) LatchCredit(ctx context.Context, id string, referrerCents, refereeCents, now int64) (bool, error) {
|
||||
// LatchQualified atomically advances a referral to qualified: it sets status +
|
||||
// qualified_at ONLY while qualified_at is still 0. RowsAffected==1 means THIS
|
||||
// caller won the race and observed the transition; 0 means a concurrent sweep
|
||||
// already made it. qualified_at is the idempotency latch, so the edge qualifies
|
||||
// at-most-once and the audit trail records one transition, not two.
|
||||
func (s *Store) LatchQualified(ctx context.Context, id string, now int64) (bool, error) {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE referrals
|
||||
SET status='credited',
|
||||
qualified_at = CASE WHEN qualified_at=0 THEN ? ELSE qualified_at END,
|
||||
credited_at = ?,
|
||||
referrer_grant_cents = ?,
|
||||
referee_grant_cents = ?
|
||||
WHERE id=? AND credited_at=0`,
|
||||
now, now, referrerCents, refereeCents, id)
|
||||
SET status='qualified',
|
||||
qualified_at = ?
|
||||
WHERE id=? AND qualified_at=0`,
|
||||
now, id)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("latch credit: %w", err)
|
||||
return false, fmt.Errorf("latch qualified: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n == 1, nil
|
||||
}
|
||||
|
||||
// SetTxns records the two ledger transaction ids after the deposits land (best-
|
||||
// effort receipt; the latch, not this, is the idempotency authority).
|
||||
func (s *Store) SetTxns(ctx context.Context, id, referrerTxn, refereeTxn string) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE referrals SET referrer_txn=?, referee_txn=? WHERE id=?`, referrerTxn, refereeTxn, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set txns: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const referralCols = `id,referrer_org,referee_org,code,status,referrer_grant_cents,referee_grant_cents,referrer_txn,referee_txn,created_at,qualified_at,credited_at`
|
||||
const referralCols = `id,referrer_org,referee_org,code,status,created_at,qualified_at`
|
||||
|
||||
func scanReferral(sc interface{ Scan(...any) error }) (Referral, error) {
|
||||
var r Referral
|
||||
err := sc.Scan(&r.ID, &r.ReferrerOrg, &r.RefereeOrg, &r.Code, &r.Status,
|
||||
&r.ReferrerGrantCents, &r.RefereeGrantCents, &r.ReferrerTxn, &r.RefereeTxn,
|
||||
&r.CreatedAt, &r.QualifiedAt, &r.CreditedAt)
|
||||
&r.CreatedAt, &r.QualifiedAt)
|
||||
return r, err
|
||||
}
|
||||
|
||||
@@ -274,7 +264,7 @@ func (s *Store) getByReferee(ctx context.Context, refereeOrg string) (Referral,
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Get re-reads a referral by id (post-grant refresh).
|
||||
// Get re-reads a referral by id (post-transition refresh).
|
||||
func (s *Store) Get(ctx context.Context, id string) (Referral, error) { return s.get(ctx, id) }
|
||||
|
||||
// ListByReferrer returns an org's referrals (the people IT referred), newest
|
||||
@@ -284,7 +274,7 @@ func (s *Store) ListByReferrer(ctx context.Context, referrerOrg string, limit in
|
||||
}
|
||||
|
||||
// ListPending returns referrals still awaiting the qualify check (status
|
||||
// signup), oldest first, bounded — the sweep + the lazy-on-read check fold
|
||||
// signup), oldest first, bounded — the admin sweep folds
|
||||
// over this set. optReferrer scopes to one referrer ("" = all, the admin sweep).
|
||||
func (s *Store) ListPending(ctx context.Context, optReferrer string, limit int) ([]Referral, error) {
|
||||
if optReferrer == "" {
|
||||
|
||||
@@ -218,8 +218,11 @@ func TestAdminEnvelopesKeepTheirKeyOrder(t *testing.T) {
|
||||
if err := json.Unmarshal(body, &sweep); err != nil {
|
||||
t.Fatalf("sweep decode: %v", err)
|
||||
}
|
||||
if got := topKeys(t, sweep.Data); got != "credited,swept" {
|
||||
t.Errorf("sweep data keys = %s, want credited,swept", got)
|
||||
// "credited" is gone on purpose: the sweep no longer credits anything, it
|
||||
// qualifies. A counter named for a payment that never happens is a lie the
|
||||
// console would render.
|
||||
if got := topKeys(t, sweep.Data); got != "qualified,swept" {
|
||||
t.Errorf("sweep data keys = %s, want qualified,swept", got)
|
||||
}
|
||||
|
||||
code, body = req(t, app, http.MethodGet, "/v1/admin/referrals/bonuses", "admin", true, nil)
|
||||
@@ -241,13 +244,19 @@ func TestAdminEnvelopesKeepTheirKeyOrder(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestMyReferralsKeepsItsKeyOrder pins the customer dashboard body the same way.
|
||||
//
|
||||
// The three money keys this once pinned — creditsEarnedCents, refereeBonusCents,
|
||||
// referrerBonusCents — were REMOVED with the mint that populated them. Keeping them
|
||||
// as permanent zeroes would have been the more "compatible" move and the worse one:
|
||||
// they would advertise a bonus program that does not exist. What survives is the
|
||||
// alphabetical declaration order the map-to-struct conversion preserved.
|
||||
func TestMyReferralsKeepsItsKeyOrder(t *testing.T) {
|
||||
app, _, _ := mount(t)
|
||||
code, body := req(t, app, http.MethodGet, "/v1/referrals", "orgA", false, nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("mine: %d (%s)", code, body)
|
||||
}
|
||||
want := "code,counts,creditsEarnedCents,link,refereeBonusCents,referrals,referrerBonusCents"
|
||||
want := "code,counts,link,referrals"
|
||||
if got := topKeys(t, body); got != want {
|
||||
t.Errorf("GET /v1/referrals keys = %s, want %s", got, want)
|
||||
}
|
||||
|
||||
@@ -10,68 +10,55 @@ import (
|
||||
|
||||
func init() {
|
||||
zip.Describe("GET /v1/admin/referrals/bonuses", zip.Doc{
|
||||
Description: "Returns every one-time referral bonus in the ledger with a fleet summary.\n\nSuperAdmin only, fail-closed. This is the ONE-TIME BONUS ledger — who referred\nwhom, what each side was granted and which ledger transactions carried it. The\ncross-tenant referral ANALYTICS board (top referrers, conversion, multi-level\naccrual liability) is a different surface, GET /v1/admin/referrals, owned by the\naffiliates subsystem over the shared attribution spine.",
|
||||
Description: "Returns every referral edge in the directory with a fleet summary.\n\nSuperAdmin only, fail-closed. This is the ATTRIBUTION directory — who referred\nwhom and whether that referee became a customer. It carries no amounts because\nthis package issues none. The cross-tenant referral ANALYTICS board (top\nreferrers, conversion) is a different surface, GET /v1/admin/referrals, owned by\nthe affiliates subsystem over the shared attribution spine.",
|
||||
Fields: map[string]string{
|
||||
"adminBonusDirectory.referrals": "Referrals is every referral in the ledger, both orgs exposed.",
|
||||
"adminBonusDirectory.summary": "Summary is the fleet tally across those referrals.",
|
||||
"adminBonusesEnvelope.data": "Data is the directory itself.",
|
||||
"adminBonusesEnvelope.msg": "Msg is empty on success; the console surfaces it when status is not \"ok\".",
|
||||
"adminBonusesEnvelope.status": "Status is \"ok\" on success.",
|
||||
"adminListIn.limit": "Limit is how many referrals to return, as a decimal string in the `?limit=`\nquery. Absent, unparseable or non-positive means 500; over 1000 is clamped to\n1000. It is a string rather than a number because the parse that has always\nserved this route trims surrounding whitespace, and one parse rule is better\nthan two.",
|
||||
"adminReferralView.code": "Code is the referral code the referral was recorded against.",
|
||||
"adminReferralView.createdAt": "CreatedAt is when the referral was recorded, as a Unix timestamp.",
|
||||
"adminReferralView.creditedAt": "CreditedAt is when the bonuses were latched and granted, as a Unix\ntimestamp; 0 until they are.",
|
||||
"adminReferralView.id": "ID is the referral's handle.",
|
||||
"adminReferralView.qualifiedAt": "QualifiedAt is when the referee first made metered spend, as a Unix\ntimestamp; 0 while still pending.",
|
||||
"adminReferralView.refereeGrantCents": "RefereeGrantCents is what the referee was granted, in USD cents; 0 until the\nreferral is credited.",
|
||||
"adminReferralView.refereeOrg": "RefereeOrg is the org that signed up with it.",
|
||||
"adminReferralView.refereeTxn": "RefereeTxn is the commerce ledger transaction that carried the referee's\ngrant, omitted until one exists.",
|
||||
"adminReferralView.referrerGrantCents": "ReferrerGrantCents is what the referrer was granted, in USD cents; 0 until\nthe referral is credited.",
|
||||
"adminReferralView.referrerOrg": "ReferrerOrg is the org whose code was used.",
|
||||
"adminReferralView.referrerTxn": "ReferrerTxn is the commerce ledger transaction that carried the referrer's\ngrant, omitted until one exists.",
|
||||
"adminReferralView.status": "Status is the referral's lifecycle state: \"signup\", \"qualified\" or\n\"credited\".",
|
||||
"adminSummary.credited": "Credited is how many have paid both bonuses.",
|
||||
"adminSummary.grantedCents": "GrantedCents is the promo credit granted across BOTH sides of every\nreferral, in USD cents — the program's total liability to date.",
|
||||
"adminSummary.qualified": "Qualified is how many have qualified but are not yet credited.",
|
||||
"adminSummary.signup": "Signup is how many are recorded but not yet qualified.",
|
||||
"adminSummary.total": "Total is every referral in the ledger.",
|
||||
"adminBonusDirectory.referrals": "Referrals is every referral in the directory, both orgs exposed.",
|
||||
"adminBonusDirectory.summary": "Summary is the fleet tally across those referrals.",
|
||||
"adminBonusesEnvelope.data": "Data is the directory itself.",
|
||||
"adminBonusesEnvelope.msg": "Msg is empty on success; the console surfaces it when status is not \"ok\".",
|
||||
"adminBonusesEnvelope.status": "Status is \"ok\" on success.",
|
||||
"adminListIn.limit": "Limit is how many referrals to return, as a decimal string in the `?limit=`\nquery. Absent, unparseable or non-positive means 500; over 1000 is clamped to\n1000. It is a string rather than a number because the parse that has always\nserved this route trims surrounding whitespace, and one parse rule is better\nthan two.",
|
||||
"adminReferralView.code": "Code is the referral code the referral was recorded against.",
|
||||
"adminReferralView.createdAt": "CreatedAt is when the referral was recorded, as a Unix timestamp.",
|
||||
"adminReferralView.id": "ID is the referral's handle.",
|
||||
"adminReferralView.qualifiedAt": "QualifiedAt is when the referee first made metered spend, as a Unix\ntimestamp; 0 while still pending.",
|
||||
"adminReferralView.refereeOrg": "RefereeOrg is the org that signed up with it.",
|
||||
"adminReferralView.referrerOrg": "ReferrerOrg is the org whose code was used.",
|
||||
"adminReferralView.status": "Status is the referral's lifecycle state: \"signup\" or \"qualified\".",
|
||||
"adminSummary.qualified": "Qualified is how many referees have made metered spend.",
|
||||
"adminSummary.signup": "Signup is how many are recorded but not yet qualified.",
|
||||
"adminSummary.total": "Total is every referral in the directory.",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/referrals", zip.Doc{
|
||||
Description: "Returns the caller's referral code, share link and the referrals they have made.\n\nThe code is a stable, deterministic function of the org, so the link in this\nresponse is the same one every time. Each row carries the referee, its status and\nthe credit this org earned from it; creditsEarnedCents is their sum.\n\nThe read is self-updating: before listing, it runs the qualify check over this\norg's still-pending referees, so a referee who has since made metered spend is\ncredited by the act of the referrer loading their page. That check is\nbest-effort and bounded — a commerce hiccup leaves the referral pending for the\nnext check rather than failing the page — and the grant is latched at-most-once,\nso this path and the admin sweep can never double-pay.",
|
||||
Description: "Returns the caller's referral code, share link and the referrals they have made.\n\nThe code is a stable, deterministic function of the org, so the link in this\nresponse is the same one every time. Each row carries the referee and the status\nof that attribution.\n\nIT IS A PURE READ. It advances no referral, grants nothing and deposits nothing\n— a GET reports state, it never changes it. Qualification is the admin sweep's\njob (POST /v1/admin/referrals/sweep). The one row this handler can write is the\ncaller's OWN code-directory entry (EnsureCode), which materialises a value\nderiveCode already computes deterministically from the org id so the code has an\nO(1) reverse lookup; it carries no money, no referral state and no other tenant.",
|
||||
Fields: map[string]string{
|
||||
"myReferralView.createdAt": "CreatedAt is when the referral was recorded, as a Unix timestamp.",
|
||||
"myReferralView.creditedAt": "CreditedAt is when the bonuses were latched and granted, as a Unix\ntimestamp; 0 until they are. It is the at-most-once latch.",
|
||||
"myReferralView.creditsCents": "CreditsCents is what I earned from this referral, in USD cents. It is 0\nuntil the referee qualifies.",
|
||||
"myReferralView.id": "ID is the referral's handle.",
|
||||
"myReferralView.qualifiedAt": "QualifiedAt is when the referee first made metered spend, as a Unix\ntimestamp; 0 while the referral is still pending.",
|
||||
"myReferralView.referee": "Referee is the org that signed up with my code.",
|
||||
"myReferralView.status": "Status is the referral's lifecycle state: \"signup\" until the referee\nmakes metered spend, then \"qualified\", then \"credited\".",
|
||||
"myReferrals.code": "Code is the org's STABLE referral code — a deterministic function of the org\nid, so it never changes and never has to be stored to be reproduced.",
|
||||
"myReferrals.counts": "Counts tallies this org's referrals by status.",
|
||||
"myReferrals.creditsEarnedCents": "CreditsEarnedCents is the total promo credit this org has earned as the\nREFERRER, in USD cents.",
|
||||
"myReferrals.link": "Link is the shareable signup link carrying the code, on the brand's own host.",
|
||||
"myReferrals.refereeBonusCents": "RefereeBonusCents is what a referee is granted on qualification, in USD cents.",
|
||||
"myReferrals.referrals": "Referrals is one row per org that signed up with this code.",
|
||||
"myReferrals.referrerBonusCents": "ReferrerBonusCents is what the referrer is granted when a referee qualifies,\nin USD cents.",
|
||||
"statusCounts.credited": "Credited is how many referrals have paid both bonuses.",
|
||||
"statusCounts.qualified": "Qualified is how many referees have spent but are not yet credited.",
|
||||
"statusCounts.signup": "Signup is how many referees have signed up but not yet spent.",
|
||||
"statusCounts.total": "Total is every referral this org has made.",
|
||||
"myReferralView.createdAt": "CreatedAt is when the referral was recorded, as a Unix timestamp.",
|
||||
"myReferralView.id": "ID is the referral's handle.",
|
||||
"myReferralView.qualifiedAt": "QualifiedAt is when the referee first made metered spend, as a Unix\ntimestamp; 0 while the referral is still pending.",
|
||||
"myReferralView.referee": "Referee is the org that signed up with my code.",
|
||||
"myReferralView.status": "Status is the referral's lifecycle state: \"signup\" until the referee makes\nmetered spend, then \"qualified\".",
|
||||
"myReferrals.code": "Code is the org's STABLE referral code — a deterministic function of the org\nid, so it never changes and never has to be stored to be reproduced.",
|
||||
"myReferrals.counts": "Counts tallies this org's referrals by status.",
|
||||
"myReferrals.link": "Link is the shareable signup link carrying the code, on the brand's own host.",
|
||||
"myReferrals.referrals": "Referrals is one row per org that signed up with this code.",
|
||||
"statusCounts.qualified": "Qualified is how many referees have made metered spend.",
|
||||
"statusCounts.signup": "Signup is how many referees have signed up but not yet spent.",
|
||||
"statusCounts.total": "Total is every referral this org has made.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/admin/referrals/sweep", zip.Doc{
|
||||
Description: "Qualify-checks every pending referral and grants the ones that now qualify.\n\nSuperAdmin only, fail-closed. This is the cron path: a referee QUALIFIES once\nthey have made metered spend — the honest signal that they actually used the\nproduct rather than merely claiming a welcome grant — and qualifying grants the\nreferrer and the referee their bonuses in one latched step.\n\nThe grant is backed against the platform reserve fund before it is latched, so\nan empty fund leaves the referral honestly pending rather than minting unbacked\ncredit, and the latch makes it at-most-once: this sweep, a concurrent sweep and\nthe lazy check on GET /v1/referrals can never double-pay. One pass is bounded,\nso a large backlog drains over several runs instead of wedging one request.\n\nIt reads nothing from the caller — the counters it returns are the whole result.",
|
||||
Description: "Qualify-checks every pending referral and advances the ones that now qualify.\n\nSuperAdmin only, fail-closed. This is the cron path, and the ONLY path that\nadvances a referral: a referee QUALIFIES once they have made metered spend — the\nhonest signal that they actually used the product rather than merely signing up.\n\nQualifying moves NO money. It records that an attribution became a real customer;\nwhat is owed for that is an affiliate payable in commerce, settled by wire or to a\nconnected wallet. One pass is bounded, so a large backlog drains over several runs\ninstead of wedging one request, and the latch makes the transition at-most-once\nunder a concurrent sweep.\n\nIt reads nothing from the caller — the counters it returns are the whole result.",
|
||||
Fields: map[string]string{
|
||||
"sweepEnvelope.data": "Data is the sweep's counters.",
|
||||
"sweepEnvelope.msg": "Msg is empty on success; the console surfaces it when status is not \"ok\".",
|
||||
"sweepEnvelope.status": "Status is \"ok\" on success.",
|
||||
"sweepResult.credited": "Credited is how many of those referrals qualified on this pass and were\ngranted their bonuses.",
|
||||
"sweepResult.swept": "Swept is how many pending referrals were checked.",
|
||||
"sweepEnvelope.data": "Data is the sweep's counters.",
|
||||
"sweepEnvelope.msg": "Msg is empty on success; the console surfaces it when status is not \"ok\".",
|
||||
"sweepEnvelope.status": "Status is \"ok\" on success.",
|
||||
"sweepResult.qualified": "Qualified is how many of those referrals qualified on this pass.",
|
||||
"sweepResult.swept": "Swept is how many pending referrals were checked.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/referrals/claim", zip.Doc{
|
||||
Description: "Records that the caller's org signed up through a referral code.\n\nThe REFEREE is the validated caller, never a client field, and the referrer is\nresolved from the code — so a caller can only ever attach THEMSELVES to someone\nelse's code. Referring yourself is 400 and an unknown code is 404.\n\nIt is idempotent and first-touch: an org can be referred once, ever. A repeat\ncall returns the referral already on file with created=false and 200, where the\nfirst call answers 201.\n\nRecording a referral grants nothing. Both bonuses are granted later, when the\nreferee actually makes metered spend — see GET /v1/referrals and\nPOST /v1/admin/referrals/sweep.",
|
||||
Description: "Records that the caller's org signed up through a referral code.\n\nThe REFEREE is the validated caller, never a client field, and the referrer is\nresolved from the code — so a caller can only ever attach THEMSELVES to someone\nelse's code. Referring yourself is 400 and an unknown code is 404.\n\nIt is idempotent and first-touch: an org can be referred once, ever. A repeat\ncall returns the referral already on file with created=false and 200, where the\nfirst call answers 201.\n\nRecording a referral grants nothing, and neither does anything downstream of it:\nthe edge later advances to qualified when the referee makes metered spend\n(POST /v1/admin/referrals/sweep), and that is the end of it. No credit is ever\nissued from this package.",
|
||||
Fields: map[string]string{
|
||||
"claimRequest.code": "Code is the referrer's referral code, as it appeared in their ?ref= link.\nCase and surrounding whitespace do not matter.",
|
||||
"claimView.code": "Code is the referral code the referral was recorded against.",
|
||||
|
||||
Reference in New Issue
Block a user