feat(invite): invite/refer-a-friend + tiered referral reward payout - #462
Merged
Conversation
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
Contributor
Author
🎤 Simon Review — backend: referral system + reward payoutAdversarial senior review. Findings traced against the actual code paths, not the diff alone. Verdict: ACCEPTABLEThe money core is genuinely sound — no reachable double-pay, correct cents units, idempotency layered three deep (webhook BlockingNone. Should fix
Notes
🤖 Simon-style adversarial review generated with Claude Code. Findings are advisory — verify before acting. |
- 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
bobodread876
approved these changes
Aug 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 isflash-mobile#feat/invite, and an ops monitoring page isfrappe-flash-admin#feat/referral-rewards-page.Referral system (create / redeem / preview + admin)
createInvite,redeemInvitemutations,invitePreviewquery. Admin:invitesList,inviteById+AdminInvitetype.src/domain/invite/), app layer (src/app/invite/), mongooseInvitemodel, 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
rewardsaccount role.referralRewardconfig block.rewardStatus) + per-partyinviter/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.rewardStatus/rewardAmountCents/rewardedAtonAdminInvite.Safety / rollout
referralReward.enableddefaults tofalse— nothing pays until ops assigns arewardswallet and flips it on. See activation steps below.rewardsaccount role added toAccountRoles, the mongoose enum, andAdminRole.Tests
tsc-checkclean across the backend; admin + public GraphQL SDL regenerated.test/flash/integrationinfra).Activation (post-deploy, when ready to go live)
db.accounts.updateOne({ _id: ObjectId("<accountId>") }, { $set: { role: "rewards" } })on the funding account (enum now permits it).referralReward.enabled: truein the yaml config (tiers tunable there).getflash.io/invitelanding redirect (see flash-mobile#679).Deploy note
The new unique partial index on
invites.redeemedByIdbuilds viasyncIndexes()at boot. On any environment that somehow already holds two invites with the sameredeemedById(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)
processingrow: the claim stampsrewardClaimedAtand any post-claim throw downgrades tofailedwith arewardError(sweep-discoverable).Pendingsends are now a distinct non-terminalpendingstatus (timestamps still set — fail-closed, never double-pays) for ops re-checks instead of being counted aspaid.🤖 Generated with Claude Code
https://claude.ai/code/session_01C7z7o9J18BWbUnYJcemMtg