Affiliate & Referral Programme (#326, #337)
Two kinds of referrer share one system: partners (#326) are external affiliates who earn cash
commission for every store they bring in; merchants (#337) are Tryvio’s own stores, referring
other stores for an in-kind reward. Both live in the same affiliates table, discriminated by
kind, and reuse the same referral link/code, attribution and self-referral rails end to end.
Data model
Migrations: 20260811c_affiliate_programme.sql (partners, #326), 20260811d_affiliate_custom_terms.sql
20260811e_affiliate_custom_matrix.sql(per-partner terms),20260815_merchant_referrals.sql(merchant referrers, #337).
affiliates
One row per referrer — a partner (kind = 'partner', the default) or a store’s own referral
profile (kind = 'merchant', added by #337).
| Column | Notes |
|---|---|
status | pending | approved | rejected. Merchant rows are born approved — no review queue. |
kind | partner (default) | merchant — added by #337. |
email | Unique among partners only (affiliates_partner_email_key, partial index) and nullable overall; affiliates_partner_email_required CHECKs a partner always has one. Merchant rows may share or lack an email (two stores can share an owner). |
code | Unguessable referral code — lib/affiliate/code.ts, 10 crypto-random chars from an unambiguous uppercase alphabet (no I/L/O/0/1). Same generator for both kinds. |
store_id | NULL for partners; the referring store for merchant rows. affiliates_store_id_key (partial UNIQUE, WHERE store_id IS NOT NULL) is the “one referral profile per store, ever” rule — added by #337. |
bonus_try_ons | Per-affiliate override of the referred store’s signup bonus; NULL → global default. |
custom_matrix | Per-partner commission matrix override (#326 round 2); irrelevant to merchant rows, which never accrue commission. |
affiliate_attributions
Which affiliate referred which store — UNIQUE(store_id) is the “one attribution per store, ever”
rule, enforced by the database.
| Column | Notes |
|---|---|
mechanism | link (referral URL, 30-day last-touch) | code (in-app code, ≤14 days, code beats link). |
first_paid_at / commission_window_ends_at | The store’s conversion moment and the partner’s 12-month commission window from it. |
bonus_granted_at | Claim column for the referred store’s signup bonus. |
reward_granted_at | Added by #337 — claim column for the referrer’s in-kind reward; same atomic-claim pattern (IS NULL-guarded UPDATE, claim-first-grant-second). |
affiliate_commission_entries, affiliate_payouts and affiliate_login_tokens are partner-only —
merchant-kind attributions never write a commission entry or a payout, and merchant rows never sign
into the partner portal (see Portal login below).
Partners (#326) — cash commission
Kept brief here — see lib/affiliate/commission.ts, lib/affiliate/config.ts and
src/app/api/cron/affiliate-commissions/route.ts for the full accrual mechanics (forward-only
tiering, refund reversals, per-partner custom matrices).
- An applicant applies (
POST /api/affiliate/apply), an operator approves/rejects in/admin/affiliates, and an approved partner signs into the portal via a magic link (affiliate_login_tokens, 15-min single-use). - Their referral link (
/r/<code>) and in-app code drive the same attribution rails #337 reuses, below. - Commission accrues on cumulative converted clients (“converted” = a store’s first successfully
paid charge, test stores excluded entirely) against a tiered matrix (
app_config.affiliate_matrix, default 10%→30% at 1/10/20/30/40 clients), forward-only — a partner climbing a tier never recomputes an already-written entry — for 12 months (affiliate_commission_months) per client. The daily/api/cron/affiliate-commissionscron derives entries fromtryon_billing_ledger/billing_eventsand reverses an entry when its charge is refunded.
Merchant-to-merchant referrals (#337)
Every store is a referrer too — not just approved partners. The first time a merchant opens the
“Refer a store” card (the top-level Referrals destination, /referrals), GET /api/shopify/merchant-referral lazily creates
their referral profile: a row in the same affiliates table with kind = 'merchant', keyed by
store_id (the partial UNIQUE above — one profile per store, ever), born approved, code from the
same generator as partners. Every later call returns the same row; a race between two first calls is
settled by the unique index, not application code. The card shows the link, the code, and a live
“X referred · Y converted” count plus the per-store history (who came through, what it earned —
read from the referrer’s own credit_granted billing events, never the current config).
The link origin is environment-aware (buildReferralUrl(code, getShareBaseUrl())):
prod → tryvio.ai/r/<code>, dev → dev.app.tryvio.ai/r/<code> — a code exists only in the
issuing environment’s database, so a cross-environment link would be dead. The /r landing is
env-aware too: prod → the App Store listing, non-prod → the deployment’s own origin.
Attribution — reuses #326 verbatim
Merchant-kind attribution rides the exact same rails as partner attribution — no parallel mechanism:
- The
/r/<code>link sets the same 30-day last-touch signedttn_attrcookie described in Install-Source Attribution; the channel it stamps staysaffiliatefor both kinds. - First-install attribution happens at the OAuth callback, same as #326.
- The in-app code path is redeemable up to 14 days after install and beats a link attribution if both are present.
- A referred store gets the standard signup bonus (global default 200 try-ons, or the referrer’s
bonus_try_onsoverride) exactly like a partner-referred store.
Self-referral guard — shared and pure
lib/affiliate/self-referral.ts (isSelfReferral) is used by both the link and the code
attribution paths, so they can never disagree. It refuses:
- the referrer’s own
store_id, and - a different store sharing the same owner email — two stores under one owner can’t farm the reward off each other.
Reward — in-kind, never cash
Merchant-kind attributions never write a commission entry. Instead, the daily
affiliate-commissions cron grants the referrer an in-kind reward at the referred store’s
conversion (first successfully paid charge, test charges excluded) — exactly once, via an atomic
claim on affiliate_attributions.reward_granted_at (claim first, grant second: a crash between the
two loses a support-recoverable reward, never doubles one). lib/server/merchant-referral-reward.ts
(grantMerchantReferralReward) resolves one of two kinds (app_config.merchant_referral_reward_kind,
default credits):
credits—merchant_referral_reward_try_ons(default 300) overage credits, granted via the existinggrantOverageCredit, audited as acredit_grantedbilling event withmetadata.reason = "merchant_referral_reward"— the same trail as the referred-store bonus and the admin’s manual credit action.discount— a Partner-API app credit (createPartnerAppCredit) worthmerchant_referral_discount_percent(default 10) % of the referrer’s live subscription base fee, in the subscription’s own currency (never assumed USD). Every prerequisite — Partner API config, an active subscription, a live access token, resolvable pricing — is checked before the claim; if any is missing the claim is never taken and the grant comes backretryable, logged loudly asmerchant_reward_deferredso the next cron run retries it.
Config
Same app_config table as the rest of the affiliate config — forward-only (a change never
recomputes an already-granted reward), malformed values fall back to the default:
| Key | Default | Notes |
|---|---|---|
merchant_referral_reward_kind | credits | credits | discount |
merchant_referral_reward_try_ons | 300 | Overage credits granted per conversion (credits kind) |
merchant_referral_discount_percent | 10 | % of the referrer’s live base fee (discount kind) |
Editable in /admin/affiliates, where merchant referrers are listed in a separate section from cash
partners (affiliates-client.tsx splits one API response by kind; partner-only sections — terms,
commission entries, payouts — are hidden for merchant rows). The admin route rejects selecting
discount while the Partner API env (SHOPIFY_PARTNERS_*) isn’t configured, rather than saving a
config the cron could never fulfil.
Portal login (partners only)
getAffiliateByEmail — the affiliate portal’s magic-link lookup — filters kind = 'partner', so a
merchant referral profile can never sign into the partner portal (and, since merchant rows may share
an owner’s email across stores, an unfiltered lookup could also break on returning more than one row).
Migration
20260815_merchant_referrals.sql — adds affiliates.kind / affiliates.store_id, the partial
unique indexes (affiliates_store_id_key, affiliates_partner_email_key) replacing the old
table-wide affiliates_email_key, and affiliate_attributions.reward_granted_at.