Skip to content

feat(invite): invite/refer-a-friend + tiered referral reward payout - #462

Merged
islandbitcoin merged 12 commits into
mainfrom
feat/invite
Aug 1, 2026
Merged

feat(invite): invite/refer-a-friend + tiered referral reward payout#462
islandbitcoin merged 12 commits into
mainfrom
feat/invite

Conversation

@islandbitcoin

@islandbitcoin islandbitcoin commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Invite / refer-a-friend + tiered referral reward payout

The full invite/refer-a-friend feature, rebased onto current main, with the reward payout implemented, comprehensive tests, and regenerated SDL. This is the backend half; the mobile client is flash-mobile#feat/invite, and an ops monitoring page is frappe-flash-admin#feat/referral-rewards-page.

Referral system (create / redeem / preview + admin)

  • Public GraphQL: createInvite, redeemInvite mutations, invitePreview query. Admin: invitesList, inviteById + AdminInvite type.
  • Domain + validation (src/domain/invite/), app layer (src/app/invite/), mongoose Invite model, notifications, invite-code hashing, rate limits (create + per-target).

Reward payout (this is the new part)

Pays a tiered USD reward to both the inviter and the invitee, funded from a dedicated rewards account role.

  • Trigger: the invitee's Bridge KYC approval (once-only CAS transition in the Bridge KYC webhook) — they must have a US account before anything pays.
  • Tiered by an atomic global counter: referrals 1–100 → $5, 101–600 → $2.50, 601+ → $1 (per party). Ops-tunable via the new referralReward config block.
  • Idempotent + fail-closed: atomic claim on the invite (rewardStatus) + per-party inviter/inviteeRewardedAt. Never double-pays; the IBEX send path has no idempotency key, so the guard is at the record level. A failed/partial payout is recorded for reconciliation and never throws into or blocks KYC approval.
  • Admin visibility: rewardStatus / rewardAmountCents / rewardedAt on AdminInvite.

Safety / rollout

  • referralReward.enabled defaults to false — nothing pays until ops assigns a rewards wallet and flips it on. See activation steps below.
  • New rewards account role added to AccountRoles, the mongoose enum, and AdminRole.

Tests

  • 103 unit tests (fully mocked, no infra): 80 for the invite system (domain validation, hashing, create/redeem/rate-limits/queries/admin) + 23 for the reward payout (tier boundaries, idempotency, partial/failed, disabled, never-throw).
  • tsc-check clean across the backend; admin + public GraphQL SDL regenerated.
  • Not covered (follow-up): GraphQL resolver + Redis-backed rate-limiter integration tests (need test/flash/integration infra).

Activation (post-deploy, when ready to go live)

  1. db.accounts.updateOne({ _id: ObjectId("<accountId>") }, { $set: { role: "rewards" } }) on the funding account (enum now permits it).
  2. Fund that account's USDT wallet (the active cash wallet — payouts prefer USDT and fall back to USD; recipients are paid in the funding wallet's currency). Each top-tier referral costs $10 ($5 × 2 parties).
  3. Set referralReward.enabled: true in the yaml config (tiers tunable there).
  4. Mobile HTTPS invite links additionally require the getflash.io/invite landing redirect (see flash-mobile#679).

Deploy note

The new unique partial index on invites.redeemedById builds via syncIndexes() at boot. On any environment that somehow already holds two invites with the same redeemedById (none should — the feature has never shipped), the index build would fail and block startup: sweep duplicates first. Fresh/empty environments are unaffected.

Post-review hardening (Simon review, addressed)

  • A crash after the payout claim can no longer strand an invisible processing row: the claim stamps rewardClaimedAt and any post-claim throw downgrades to failed with a rewardError (sweep-discoverable).
  • IBEX Pending sends are now a distinct non-terminal pending status (timestamps still set — fail-closed, never double-pays) for ops re-checks instead of being counted as paid.
  • Payouts resolve the USDT-first active cash wallet with strict sender/recipient currency matching.
  • A tier schedule missing its unbounded sentinel now pays 0 past the last bound instead of silently over-paying forever.

🤖 Generated with Claude Code

https://claude.ai/code/session_01C7z7o9J18BWbUnYJcemMtg

islandbitcoin and others added 7 commits July 31, 2026 07:21
Add comprehensive invite-friend feature allowing users to invite friends via Email, SMS, or WhatsApp.

**User-Facing Features:**
- Create invites: Users can send invites via Email (SendGrid), SMS, or WhatsApp (Twilio)
- Redeem invites: New users can redeem invites within 1 hour of account creation
- Preview invites: Unauthenticated endpoint to preview invite before registration
- Rate limiting: 10 invites/day per user, 3 invites/day per target contact (Redis-based)
- 24-hour invite expiration with Firebase Dynamic Links support

**Admin Features:**
- View invite details with inviter/redeemer information
- List and filter invites by status and inviter
- Paginated invite queries

**Technical Implementation:**
- MongoDB schema for invite tracking with secure token hashing (SHA-256)
- Notification service supporting Email, SMS, and WhatsApp
- Contact validation for email/phone formats
- Deep linking support via Firebase Dynamic Links
- Comprehensive test coverage (unit & integration tests)

**Security:**
- Tokens are 40-character random strings with only SHA-256 hash stored
- Contact verification ensures invite sent to correct recipient
- Account age validation (< 1 hour) for new user redemption
- Self-redemption prevention
- Fix rate limit key inconsistency between admin functions and rate
  limiter service (use RateLimitPrefix constants)
- Refactor GraphQL createInvite mutation to use @app/invite layer
  instead of duplicating business logic
- Add index on redeemedById field in invite schema for query performance
- Make new-user invite redemption window configurable via
  NEW_USER_INVITE_WINDOW_HOURS constant (default 24 hours, was 1 hour)
- Standardize token generation to use 20-byte (40-char) tokens
- Add INVITE_TOKEN_LENGTH constant (40 chars) to domain
- Add InviteToken branded type with checkedToInviteToken validator
- Fix token length check in app/invite/redeem-invite.ts (was 64, should be 40)
- Replace magic number checks with typed validation in GraphQL mutations
- Use checkedToAccountId instead of unsafe `as AccountId` cast in invite-preview
…lidation deferral

- Add ValidationError import to redeem-invite.ts to fix TypeScript errors
- Document that email validation is deferred until email-only registration
  feature is available (see PR #212)
Rebasing feat/invite onto main took main's admin schema.graphql (--ours) during
conflict resolution; write-sdl regenerates it to include the invite admin types
(AdminInvite, invitesList, inviteById). Public SDL already carried the invite
types via clean auto-merge. Full `yarn build` compiled cleanly, verifying the
rebase conflict resolutions typecheck.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7z7o9J18BWbUnYJcemMtg
80 tests / 8 suites, fully mocked (no infra): domain validation + invite
constants/token checks, hash/token generation, app-layer create-invite,
redeem-invite, rate-limits, queries, and admin ops. Covers success + error
paths (invalid contact, rate-limited, duplicate, expired, self-redeem, etc.).

GraphQL resolver wiring + Redis-backed rate-limiter left for test/flash/integration
(need infra). Note: redeem-invite's reward-crediting is still a TODO (redemption
only flips status to ACCEPTED).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7z7o9J18BWbUnYJcemMtg
Implements the referral reward the invite feature only stubbed. When an invited
user's Bridge KYC is approved (they gain a US account), both the inviter and the
invitee are paid a tiered USD reward, funded from a dedicated 'rewards' wallet.

- New 'rewards' account role (AccountRoles, mongoose enum, AdminRole); assign it
  to the funding account via a direct mongo write. Resolved with
  AccountsRepository().findByRole.
- Tiered amount by global referral sequence (atomic counter): 1-100 -> $5,
  101-600 -> $2.50, 601+ -> $1. Ops-tunable via the new referralReward config
  block (default DISABLED, so nothing pays until a rewards wallet is assigned).
- Trigger: the once-only pending->approved transition in the Bridge KYC webhook
  (CAS-guarded). Payout via intraledgerPaymentSendWalletIdForUsdWallet.
- Idempotent + fail-closed: atomic claim on the invite, per-party
  inviter/inviteeRewardedAt, never double-pays; a failed/partial payout is
  recorded (rewardStatus) for manual reconciliation and never throws into or
  blocks KYC approval. Admin visibility via new AdminInvite reward fields.

Tests: 23 unit (9 tier boundaries + 14 payout paths incl. idempotency, partial,
failed, disabled). tsc-check clean; admin SDL regenerated.
(Also fixes a latent tsc-check type error in the admin invite spec's mock.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7z7o9J18BWbUnYJcemMtg
The invite feature was never CI-green (never merged), so PR #462 surfaced
several gaps plus two regressions from the reward payout:

- api-key scope-map: register createInvite (authed mutation) as BLOCKED so the
  deny-by-default completeness test passes. redeemInvite/invitePreview are in
  the unauthed schema block, so they are (correctly) not authed root fields.
- award-referral-reward: lazy-import send-intraledger inside payParty so merely
  importing @app/invite no longer pulls the IBEX client (baseLogger.child at
  init) — was breaking kyc.spec + create-invite.spec at module load.
- ops-events-hooks.spec @config mock: add getInviteCreateAttemptLimits/
  getInviteTargetAttemptLimits (domain/rate-limit evaluates them at load).
- prettier/eslint: format the never-linted invite files; drop unused imports
  (InviteToken; redeem-invite mutation dead imports); type two anys in
  services/notification.

Gates: eslint 0 errors, tsc-check + tsc-check-noimplicitany clean, full unit
suite 1304 passed / 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7z7o9J18BWbUnYJcemMtg
@islandbitcoin

Copy link
Copy Markdown
Contributor Author

🎤 Simon Review — backend: referral system + reward payout

Adversarial senior review. Findings traced against the actual code paths, not the diff alone.

Verdict: ACCEPTABLE

The money core is genuinely sound — no reachable double-pay, correct cents units, idempotency layered three deep (webhook event_id lock → CAS status transition → per-invite atomic claim), enabled:false default holds, and the tests assert real amounts at tier edges. It's mergeable because it ships disabled — but do not flip it on until the Should-fix items are closed, because they're all about what happens when money moves and something goes sideways.

Blocking

None.

Should fix

  1. src/app/invite/award-referral-reward.ts:57 (claim) vs :178 (catch) — a crash/throw after the claim leaves the invite rewardStatus:"processing" forever, silently unpaid, and invisible to reconciliation. If nextReferralRewardSeq(), wallet resolution, or a repo call throws after the claim (or the pod is OOM-killed mid-payout), the catch only logs; the re-selection query {rewardStatus:{$exists:false}} never picks it up again, and it isn't marked failed, so ops queries for failed/partial miss it. Both parties silently never paid. Stamp rewardClaimedAt in the claim and add a sweep (or on-catch revert) that flips stale processingfailed with a rewardError. This post-claim-throw path is untested.
  2. award-referral-reward.ts:139PaymentSendStatus.Pending is counted as paid and is terminal. IBEX returns Pending; if it later resolves to Failure, the money never lands but the invite says paid and nothing reconciles it. Treat Pending as a distinct non-terminal status a sweep re-checks, not paid.
  3. award-referral-reward.ts:14 findUsdWalletId pays the legacy currency==="USD" wallet, not the user's active USDT cash wallet — and the mirror risk: if ops funds the rewards account's USDT wallet, every reward records failed: rewards account has no USD wallet. Flash is USDT-forward; recipients may never see the credit, and the runbook's "fund the USD/USDT wallet" is ambiguous enough to break every payout. Resolve to the active cash wallet (intraledgerPaymentSendWalletIdForUsdWallet already supports includeUsdt), and make the runbook explicit about which wallet.

Notes

  • src/domain/invite/referral-reward.ts:20 — a tier config missing the upToCount:0 sentinel silently pays the top-tier amount forever past the last bound. Shipped default is correct; validate "last tier unbounded" at config load.
  • The global seq is consumed by failed/partial/zero payouts, so "first 100 referrals" is really "first 100 approval attempts." Accounting drift, not a bug — but the ops page (Add telemetry data to the Ibex client #57) inherits it in its "Referrals Paid" tile.
  • Verified non-issues: amount unit is cents (correct); redeemInvite in the unauthed block is not exploitable (resolver hard-guards on domainAccount); enabled:false can't be flipped by ajv defaults.

🤖 Simon-style adversarial review generated with Claude Code. Findings are advisory — verify before acting.

bobodread876 and others added 4 commits July 31, 2026 15:08
- Stuck-claim recovery: the atomic claim stamps rewardClaimedAt, and any
  unexpected throw after the claim downgrades it to 'failed' with a
  rewardError instead of stranding an invisible 'processing' row.
- IBEX Pending is a distinct non-terminal 'pending' rewardStatus (new enum
  value). Per-party timestamps are still set for pending parties — fail-closed,
  a re-run can never double-pay — but ops now sees it needs re-checking
  instead of it being counted as terminally paid.
- Payouts fund from the rewards account's USDT wallet first (the active cash
  wallet), falling back to USD, and recipients are resolved strictly in the
  funding wallet's currency (send-intraledger rejects cross-currency sends).
- Tier fail-safe: a schedule missing its unbounded sentinel pays 0 past the
  last bound instead of silently over-paying forever.

Tests: award spec 14->18 (post-claim throw, pending semantics, wallet
preference/currency-match, claim stamp), tier fail-safe boundaries.
Gates: scoped jest 130/130, tsc-check + noimplicitany + eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7z7o9J18BWbUnYJcemMtg
…ant + 8 more

F1 (blocking): one reward per invitee, enforced in three layers — redemption
rejects a second redemption per account, a unique partial index on redeemedById
closes the race (duplicate-key treated as already-redeemed), and the award path
skips accounts that already have any rewardStatus so a Bridge KYC
approved->under_review->approved flap can never pay a second accumulated invite.

F2: the post-claim catch preserves payment evidence — a throw mid-payout now
records partial with the paid party's timestamp instead of failed-with-nothing
(manual reconciliation can no longer double-pay a paid party).
F3: redeemInvite moved to the authed block + scope-map BLOCKED (was reachable
by read-scoped API keys via the unauthed shield gap). SDL unchanged.
F4: raw invite tokens and Twilio auth-token fragments no longer logged.
F5: revoked/EXPIRED-status invites rejected at redeem + preview, independent
of the date check.
F6: Timestamp scalar accepts ISO strings again (parseInt regression silently
turned admin cutover scheduledAt into 1970); pure digits = epoch seconds,
invalid input errors. Pinned by a new scalar spec.
F7: admin invitesList — ObjectId cast for the pipeline filter (was always
empty) and validated _id-cursor pagination (was parseInt(after,16) nonsense).
F8: a failed invite notification deletes the invite and returns an error
instead of burning the contact's 24h dup-window with nothing sent.
F9: dead app-layer redeem module deleted; the LIVE resolver now has a 14-case
spec (token/window/phone/EMAIL/self/race/revoked/success paths).

Full unit suite 157 suites / 1326 passed / 0 failed; tsc-check,
noimplicitany, eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7z7o9J18BWbUnYJcemMtg
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7z7o9J18BWbUnYJcemMtg
A post-expiry replay of an already-redeemed invite's token used to overwrite
ACCEPTED with EXPIRED — stranding the pending reward and, now that accounts
are limited to one redemption ever, permanently costing the account its
referral. Reorder the checks; regression test pins ACCEPTED + no save.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7z7o9J18BWbUnYJcemMtg
@islandbitcoin
islandbitcoin merged commit f744f5e into main Aug 1, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants