Invite, Affiliates & Rewards
Set up invite links, attribution, and configurable affiliate rewards (fixed and/or percent). Learn how cookies capture referrals, how attribution is finalized after login, and how rewards are computed on paid orders.
Overview
This template includes an invite + affiliate system you can turn on to reward users for bringing new users and purchases. It is privacy‑friendly and simple by default, and you can customize the rules without changing the database schema.
Concepts
- Invite link: a shareable URL like
/i/<inviteCode>
that attributes visits. - Attribution: we store who invited whom (
users.invited_by
) on first signup. - Affiliate record: rows in
affiliates
track meaningful events (signup or paid order) and the reward granted for that event. - Rewards: can be a fixed amount (e.g., $50) and/or a percent of order amount (e.g., 20%).
Data Model
-
users
(existing)invite_code
(string): user’s personal code (not a secret).invited_by
(string): UUID of the inviter (empty if none/self).is_affiliate
(boolean): optional toggle to highlight affiliate users in the UI.
-
affiliates
(existing)user_uuid
: the user who performed the action (registered or paid).invited_by
: the referrer’s user UUID.status
:pending
|completed
|canceled
|ignored
.paid_order_no
: order number for paid events (empty for signup-only rows).paid_amount
: order amount in minor units (e.g., cents).reward_percent
: percent used for this event (0 if unused).reward_amount
: final reward granted for this event (fixed amount in minor units).
This allows both “record at signup” and “reward at payment” without extra tables.
Default Program Rules
You can change these in src/data/affiliate.ts
.
- Program is enabled.
- Attribution model: first‑touch (first invite wins; we ignore later invites).
- Attribution window: 30 days (via cookie).
- Self‑referrals are ignored.
- Signup reward: off by default (record status only, no payout).
- Paid order reward: on by default
- Fixed: $50 per paid order.
- Percent: 20% (optional; if both fixed and percent are set, you decide how to combine them — see Implementation Notes).
User Flows
1) Generate a personal invite link
- Page:
/[locale]/my-invites
- The page shows the user’s
invite_code
and share URL:${NEXT_PUBLIC_WEB_URL}/i/<inviteCode>
. - If the code is missing, the user can generate it (persist via
updateUserInviteCode
).
2) Share link handling
- Route:
/i/[inviteCode]
- Behavior:
- Look up the inviter with
findUserByInviteCode(inviteCode)
. - If found, set a
ref
cookie with the inviter’suuid
for 30 days, then redirect to the landing page (localized home or signup). - If not found, redirect normally without cookie.
- Look up the inviter with
3) Signup attribution
- On successful signup:
- If the
ref
cookie exists and points to a different user, setusers.invited_by
to that UUID and clear the cookie. - Optionally, insert an
affiliates
row withstatus = pending
for the signup event and the configured signup reward (defaults to 0 so it’s just an audit row).
- If the
4) Paid order commission
- When an order transitions to
paid
:- Load the buyer; if
invited_by
is present and not self, checkaffiliates
for an existing row with the samepaid_order_no
. - If none exists, insert a row with
status = completed
, fillpaid_order_no
,paid_amount
, and computereward_amount
based on the config (fixed and/or percent). - Do not duplicate rewards for the same order.
- Subscriptions: each successful renewal that produces a
paid
order can generate its own affiliate row and reward (configurable if you want only the first payment to count).
- Load the buyer; if
5) Admin views & payout
- Admin page
/admin/affiliates
can list:- Invited users per referrer, signup vs paid counts, and total rewards.
- Export CSV for manual payouts.
- Online withdrawals are not implemented by default; payout is manual.
Implementation Notes
- Attribution policy: the simplest is first‑touch — if a user already has
invited_by
, ignore new cookies. - Cookie name:
ref
(customizable) and a 30‑day max age. - Anti‑abuse: ignore self‑referrals; dedupe by
paid_order_no
; allow admins to cancel rows (setstatus = canceled
). - Currencies:
paid_amount
andreward_amount
are stored in minor units (e.g., cents) and should match the order’s currency. - Reward math (suggested):
- If both percent and fixed are configured, either: (a) pay the greater of the two, or (b) pay both (hybrid). The template exposes a
CommissionMode
toggle to pick the strategy.
- If both percent and fixed are configured, either: (a) pay the greater of the two, or (b) pay both (hybrid). The template exposes a
Configuration Surface
All knobs live in src/data/affiliate.ts
:
- Program:
enabled
,attributionWindowDays
,allowSelfReferral
,attributionModel
. - Links:
sharePath
(default/i
),myInvitesPath
(/[locale]/my-invites
). - Rewards:
signup.fixed
,signup.percent
,paid.fixed
,paid.percent
,commissionMode
.- If
payoutType = "credits"
, treatreward_amount
as credits. In that case, prefer using fixed amounts (set percent to 0), or convert currency→credits in code.
- If
You can also mirror a subset to environment variables later if you want runtime toggles.
What’s Included (Code Map)
- Invite redirect route: sets cookie then redirects (localized)
src/app/[locale]/i/[inviteCode]/route.ts:1
- Signup attribution API: finalizes
invited_by
and records signup rowsrc/app/api/affiliate/update-invite/route.ts:1
- Invite code API: get or generate personal invite link
src/app/api/affiliate/invite-code/route.ts:1
- Affiliate service: reward math + dedupe per order
src/services/affiliate.ts:1
- Stripe integration: calls affiliate update after payment
src/services/stripe.ts:1
- User page: invite link, summary, and activity
src/app/[locale]/my-invites/page.tsx:1
- Components:
src/components/affiliate/invite-link.tsx:1
,src/components/affiliate/summary-cards.tsx:1
,src/components/affiliate/affiliate-table.tsx:1
- Admin page: global affiliate list
src/app/(admin)/admin/affiliates/page.tsx:1
- Client init: calls attribution API once per session after login/signup
src/providers/affiliate-init.tsx:1
- Included globally via:
src/providers/theme.tsx:1
End‑to‑End Walkthrough
- User visits your share link
- Format:
${NEXT_PUBLIC_WEB_URL}/i/<inviteCode>
- Server looks up the inviter and sets a
ref
cookie for 30 days, then redirects to your home/signup.
- User signs up or logs in
- A lightweight client hook runs once per session and POSTs to
/api/affiliate/update-invite
. - If the
ref
cookie is set and the user has noinvited_by
, we set it to the inviter’s UUID and record a signup row (no payout by default).
- User purchases
- When Stripe marks an order paid, we compute a reward using your config (fixed, percent, or hybrid) and insert a completed affiliate row.
- Dedupe by
paid_order_no
prevents double rewards.
- Users and Admins view results
- User page (
/[locale]/my-invites
) shows the share link, invited/paid counts, and reward activity. - Admin page lists all affiliate rows and totals for manual payout/export.
Customize The Defaults
- Commission math: edit
commissionMode
and the values inpaid
andsignup
insidesrc/data/affiliate.ts:1
. - Payout semantics: set
payoutType
tocash
(default; minor currency units) orcredits
(treatreward_amount
as in‑app credits to grant to the inviter). - Attribution window: change
attributionWindowDays
(cookie max‑age), andallowSelfReferral
to permit/deny self‑referrals. - Link structure: adjust
sharePath
(default/i
) andmyInvitesPath
. Share URLs useNEXT_PUBLIC_WEB_URL
as the base. - Subscriptions: by default, each paid period can reward; to pay only the first payment, add a guard in
src/services/affiliate.ts:1
(e.g., check buyer’s first paid order).
After edits, rebuild the app: pnpm build && pnpm start
.
Verify Locally
- Generate invite link: visit
/<locale>/my-invites
, copy link. - Open link in an incognito window, sign up, then refresh
my-invites
to see a new invited user (pending signup row). - Complete a test Stripe checkout; after the webhook updates the order, a completed affiliate row appears with the calculated reward.
Gotchas & Tips
- Base URL: set
NEXT_PUBLIC_WEB_URL
to match the origin you’re testing (e.g.,http://localhost:3000
). - Cookies: the referral cookie is set on the same host; incognito testing avoids existing sessions.
- Attribution timing: the client hook only marks success if the server returns 200; if the first call happens pre‑login it will retry after login.
- Dedupe: rewards are deduped by
paid_order_no
; reprocessing a webhook won’t double‑pay.
Affiliates & Referrals for Beginners
Set up invite links, 30‑day cookies, attribution at signup, reward models, and anti‑abuse basics for a beginner‑friendly SaaS affiliate/referral program.
Google for SaaS — Why it matters and how to use it
A beginner-friendly guide to using Google’s ecosystem (Search Console, GA4, Business Profile, Merchant Center, Trends) to get discovered, measure behavior, and grow a SaaS in 2025.