Affiliate & Referral Programme

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).

ColumnNotes
statuspending | approved | rejected. Merchant rows are born approved — no review queue.
kindpartner (default) | merchant — added by #337.
emailUnique 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).
codeUnguessable 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_idNULL 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_onsPer-affiliate override of the referred store’s signup bonus; NULL → global default.
custom_matrixPer-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.

ColumnNotes
mechanismlink (referral URL, 30-day last-touch) | code (in-app code, ≤14 days, code beats link).
first_paid_at / commission_window_ends_atThe store’s conversion moment and the partner’s 12-month commission window from it.
bonus_granted_atClaim column for the referred store’s signup bonus.
reward_granted_atAdded 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-commissions cron derives entries from tryon_billing_ledger/billing_events and 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/&lt;code&gt; link sets the same 30-day last-touch signed ttn_attr cookie described in Install-Source Attribution; the channel it stamps stays affiliate for 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_ons override) 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 existing grantOverageCredit, audited as a credit_granted billing event with metadata.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) worth merchant_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 back retryable, logged loudly as merchant_reward_deferred so 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:

KeyDefaultNotes
merchant_referral_reward_kindcreditscredits | discount
merchant_referral_reward_try_ons300Overage credits granted per conversion (credits kind)
merchant_referral_discount_percent10% 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.