From 17a2e650045abbb22a38ec0de7caef2e79884094 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 12:52:12 +0000 Subject: [PATCH] feat(referral): show the new referral spend rules and what still counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend now counts referral spend under anti-abuse rules: excluded merchants (cash, transfers, wallet top-ups, QR transfers, game credit, gambling…) and purchases under $2 don't count, a merchant counts toward the target only with $10+ spent there, and a qualified friend must make one more $10+ purchase before payout or the reward is cancelled. - Types: summary.qualification gains minPurchaseUsd, minMerchantSpendUsd and activityMinPurchaseUsd; each friend row gains excludedSpendUsd, merchantsBelowMinimum and awaitingActivity. All optional, so an older backend renders as before. - Friend row: a spending friend shows why progress reads low ("1 merchant needs $10+ to count · $212.67 doesn't count"); a qualified friend still owing the purchase shows "Needs one more $10+ purchase to unlock". - How-it-works copy (both referral sheets) names the per-merchant minimum, the excluded purchase types and the post-qualifying purchase — only when the backend reports the rule, so the app never promises one that is not enforced. - Cashback ineligible note now mentions wallet top-ups and gambling. - Test for the friend-row notes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YD4isVxJqr1GgGTR5diGCu --- components/Activity/CashbackDetailsCard.tsx | 3 +- components/Referral/ReferralFriendRow.tsx | 37 ++++++ .../Referral/ReferralProgramContent.tsx | 22 +++- .../Referral/ReferralProgramContentNew.tsx | 40 ++++++- .../__tests__/ReferralFriendRow.test.tsx | 108 ++++++++++++++++++ lib/types.ts | 23 ++++ 6 files changed, 226 insertions(+), 7 deletions(-) create mode 100644 components/Referral/__tests__/ReferralFriendRow.test.tsx diff --git a/components/Activity/CashbackDetailsCard.tsx b/components/Activity/CashbackDetailsCard.tsx index 9732a3694..f74216ee7 100644 --- a/components/Activity/CashbackDetailsCard.tsx +++ b/components/Activity/CashbackDetailsCard.tsx @@ -218,7 +218,8 @@ const CashbackDetailsCard = memo(function CashbackDetailsCard({ {isIneligible ? ( - Cash withdrawals, money transfers and government payments don't earn cashback + Cash withdrawals, money transfers, wallet top-ups, gambling and government payments + don't earn cashback ) : isPendingCharge ? ( diff --git a/components/Referral/ReferralFriendRow.tsx b/components/Referral/ReferralFriendRow.tsx index 7f7b93383..b239801c5 100644 --- a/components/Referral/ReferralFriendRow.tsx +++ b/components/Referral/ReferralFriendRow.tsx @@ -151,6 +151,10 @@ interface ReferralFriendRowProps { /** Program-level targets, for rows the API sends without their own bar. */ spendTargetUsd: number; merchantTarget: number; + /** Spend a merchant needs before it counts toward the target, when the program has one. */ + minMerchantSpendUsd?: number; + /** The purchase a qualified friend still needs to make before payout, when required. */ + activityMinPurchaseUsd?: number | null; /** Called when a countdown reaches zero, so the screen can re-fetch. */ onPayoutDue?: () => void; } @@ -160,6 +164,8 @@ export default function ReferralFriendRow({ index, spendTargetUsd: programSpendTargetUsd, merchantTarget: programMerchantTarget, + minMerchantSpendUsd, + activityMinPurchaseUsd, onPayoutDue, }: ReferralFriendRowProps) { // The row's own bar when the API sends one: a friend who qualified before the @@ -270,6 +276,31 @@ export default function ReferralFriendRow({ // was sent. const paidLine = item.stage === ReferralFriendStage.PAID ? formatPayoutToken(item) : null; + // Why the progress reads lower than what the friend has spent, so the + // referrer can tell them what still counts rather than guess. + const ruleNotes: string[] = []; + if (item.stage === ReferralFriendStage.SPENDING) { + const belowMinimum = item.merchantsBelowMinimum ?? 0; + if (belowMinimum > 0 && minMerchantSpendUsd) { + ruleNotes.push( + `${belowMinimum} ${belowMinimum === 1 ? 'merchant needs' : 'merchants need'} ${formatUsdWhole( + minMerchantSpendUsd, + )}+ to count`, + ); + } + if ((item.excludedSpendUsd ?? 0) >= 1) { + ruleNotes.push(`${formatUsdCompact(item.excludedSpendUsd ?? 0)} doesn't count`); + } + } + // Qualified, but the payout will be cancelled unless the friend makes one + // more purchase — the one thing the referrer can still do something about. + const activityNote = + item.stage === ReferralFriendStage.REWARD_UNLOCKING && item.awaitingActivity + ? activityMinPurchaseUsd + ? `Needs one more ${formatUsdWhole(activityMinPurchaseUsd)}+ purchase to unlock` + : 'Needs one more purchase to unlock' + : null; + const detailChip = presentation.detail ? ( {presentation.detail.label} ) : null; @@ -320,6 +351,12 @@ export default function ReferralFriendRow({ {formatJoined(item.signupAt)} {spentLine} + {ruleNotes.length > 0 ? ( + {ruleNotes.join(' · ')} + ) : null} + {activityNote ? ( + {activityNote} + ) : null} {paidLine ? ( Paid {paidLine} ) : null} diff --git a/components/Referral/ReferralProgramContent.tsx b/components/Referral/ReferralProgramContent.tsx index b1b8edc2b..ff5ef6281 100644 --- a/components/Referral/ReferralProgramContent.tsx +++ b/components/Referral/ReferralProgramContent.tsx @@ -129,6 +129,10 @@ export default function ReferralProgramContent({ onClose }: ReferralProgramConte const spendTarget = summary?.qualification.spendTargetUsd ?? 75; const merchantTarget = summary?.qualification.merchantTarget ?? 3; const windowDays = summary?.qualification.windowDays ?? 30; + // The anti-abuse rules: shown only when the backend reports them, since one + // that does not is not enforcing them. + const minMerchantSpendUsd = summary?.qualification.minMerchantSpendUsd; + const activityMinPurchaseUsd = summary?.qualification.activityMinPurchaseUsd ?? null; const hasActiveCard = summary?.hasActiveCard ?? false; const referrals = summary?.referrals ?? []; // The reward is quoted in dollars but settled in a token; name it rather than @@ -209,7 +213,11 @@ export default function ReferralProgramContent({ onClose }: ReferralProgramConte payments at different merchants within {windowDays} days. - Qualified spend excludes reversed or charged-back transactions. + Qualified spend excludes reversed or charged-back transactions + {summary?.qualification.minMerchantSpendUsd !== undefined + ? ', cash withdrawals, transfers, top-ups, game credit and gambling' + : ''} + . @@ -273,8 +281,18 @@ export default function ReferralProgramContent({ onClose }: ReferralProgramConte • Order and activate a Solid card. - • Spend {formatUsd(spendTarget)} across {merchantTarget}+ different merchants. + • Spend {formatUsd(spendTarget)} across {merchantTarget}+ different merchants + {minMerchantSpendUsd + ? `, with at least ${formatUsdWhole(minMerchantSpendUsd)} at each` + : ''} + . + {activityMinPurchaseUsd ? ( + + • Make one more purchase of {formatUsdWhole(activityMinPurchaseUsd)} or more before + the reward unlocks. + + ) : null} You get {formatUsd(referrerUsd)} and they get {formatUsd(newUserUsd)} {payoutToken ? ` in ${payoutToken}` : ''}, credited about 40 days after they qualify. diff --git a/components/Referral/ReferralProgramContentNew.tsx b/components/Referral/ReferralProgramContentNew.tsx index ede5a2454..bf8b91d60 100644 --- a/components/Referral/ReferralProgramContentNew.tsx +++ b/components/Referral/ReferralProgramContentNew.tsx @@ -113,6 +113,11 @@ export default function ReferralProgramContentNew({ const merchantTarget = summary?.qualification.merchantTarget ?? 3; const windowDays = summary?.qualification.windowDays ?? 30; const payoutDelayDays = summary?.qualification.payoutDelayDays ?? 30; + // The anti-abuse rules. No fallbacks: a backend that does not report one is + // not enforcing it, and the copy must not promise a rule nobody applies. + const minPurchaseUsd = summary?.qualification.minPurchaseUsd; + const minMerchantSpendUsd = summary?.qualification.minMerchantSpendUsd; + const activityMinPurchaseUsd = summary?.qualification.activityMinPurchaseUsd ?? null; const referrals = summary?.referrals ?? []; const totalRewardedUsd = summary?.totalRewardedUsd ?? 0; // The reward is quoted in dollars but settled in a token, so the "how it @@ -289,9 +294,13 @@ export default function ReferralProgramContentNew({ > Via bank deposit or crypto - - spend {formatUsdWhole(spendTarget)} across {merchantTarget}+ payments at different - merchants within {windowDays} days. + + {minMerchantSpendUsd + ? `spend ${formatUsdWhole(spendTarget)} within ${windowDays} days, with at least ${formatUsdWhole(minMerchantSpendUsd)} at each of ${merchantTarget}+ different merchants.` + : `spend ${formatUsdWhole(spendTarget)} across ${merchantTarget}+ payments at different merchants within ${windowDays} days.`} @@ -334,6 +343,8 @@ export default function ReferralProgramContentNew({ index={index} spendTargetUsd={spendTarget} merchantTarget={merchantTarget} + minMerchantSpendUsd={minMerchantSpendUsd} + activityMinPurchaseUsd={activityMinPurchaseUsd} onPayoutDue={handlePayoutDue} /> ))} @@ -371,14 +382,35 @@ export default function ReferralProgramContentNew({ • Deposit to their account via bank deposit or crypto. - • Spend {formatUsdWhole(spendTarget)} across {merchantTarget}+ different merchants. + • Spend {formatUsdWhole(spendTarget)} across {merchantTarget}+ different merchants + {minMerchantSpendUsd + ? `, with at least ${formatUsdWhole(minMerchantSpendUsd)} at each` + : ''} + . + {minPurchaseUsd !== undefined ? ( + + Only everyday purchases count, in the US dollars charged + {minPurchaseUsd > 0 + ? `. Purchases under ${formatUsdWhole(minPurchaseUsd)} don't count, and neither do` + : ' — not'}{' '} + cash withdrawals, money transfers, wallet or card top-ups, QR-code transfers, game + credit, gambling, or tax and government payments. + + ) : null} You get {formatUsdWhole(referrerUsd)} and they get {formatUsdWhole(newUserUsd)} {payoutToken ? ` in ${payoutToken}` : ''}, credited {payoutDelayDays} days after they qualify — that window covers refunds and disputes. You'll see the exact unlock date on each friend above. One reward per friend, no cap. + {activityMinPurchaseUsd ? ( + + To unlock the reward, your friend needs to keep using their card: one more purchase + of {formatUsdWhole(activityMinPurchaseUsd)} or more before the unlock date, or the + reward is cancelled. + + ) : null} {paysVaultShare && ( Rewards land in your FUSE savings, so they keep earning yield and count towards your diff --git a/components/Referral/__tests__/ReferralFriendRow.test.tsx b/components/Referral/__tests__/ReferralFriendRow.test.tsx new file mode 100644 index 000000000..745492ea2 --- /dev/null +++ b/components/Referral/__tests__/ReferralFriendRow.test.tsx @@ -0,0 +1,108 @@ +import React from 'react'; + +import ReferralFriendRow from '@/components/Referral/ReferralFriendRow'; +import { ReferralFriendStage, type ReferralRewardListItem } from '@/lib/types'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { act, create } = require('react-test-renderer'); + +jest.mock('@/components/ui/text', () => ({ Text: 'Text' })); +jest.mock('@/lib/utils', () => ({ + cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '), +})); +jest.mock('lucide-react-native', () => ({ ExternalLink: () => null })); +jest.mock('@/hooks/useReferralCountdown', () => ({ + useReferralCountdown: () => ({ label: '3d 4h', isElapsed: false }), +})); +jest.mock('react-native-reanimated', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { View } = require('react-native'); + const chain = { delay: () => chain, duration: () => chain }; + return { + __esModule: true, + default: { View }, + Easing: { bezier: () => () => 0, out: () => () => 0, cubic: () => 0 }, + FadeIn: chain, + useAnimatedStyle: () => ({}), + useSharedValue: (value: unknown) => ({ value }), + withDelay: (_delay: number, value: unknown) => value, + withTiming: (value: unknown) => value, + }; +}); + +const row = (overrides: Partial = {}): ReferralRewardListItem => ({ + referredUserId: 'friend-1', + username: 'friend', + stage: ReferralFriendStage.SPENDING, + status: null, + signupAt: '2026-10-01T10:00:00.000Z', + spendUsd: 68.15, + merchantCount: 2, + hasActiveCard: true, + rewardUsd: 25, + ...overrides, +}); + +function render(item: ReferralRewardListItem, props: Record = {}) { + let renderer: any; + act(() => { + renderer = create( + , + ); + }); + return renderer; +} + +const textOf = (renderer: any) => JSON.stringify(renderer.toJSON()); + +describe('ReferralFriendRow — the referral spend rules', () => { + it('says why spend and merchants are not counting yet', () => { + const text = textOf(render(row({ merchantsBelowMinimum: 1, excludedSpendUsd: 212.67 }))); + + expect(text).toContain('1 merchant needs $10+ to count'); + expect(text).toContain("$212.67 doesn't count"); + }); + + it('stays quiet when nothing was left out', () => { + const text = textOf(render(row({ merchantsBelowMinimum: 0, excludedSpendUsd: 0 }))); + + expect(text).not.toContain('to count'); + expect(text).not.toContain("doesn't count"); + }); + + it('tells the referrer a qualified friend still needs one more purchase', () => { + const text = textOf( + render( + row({ + stage: ReferralFriendStage.REWARD_UNLOCKING, + payoutEtaAt: '2026-11-01T02:00:00.000Z', + awaitingActivity: true, + }), + ), + ); + + expect(text).toContain('Needs one more $10+ purchase to unlock'); + }); + + it('does not nag once the friend has made it', () => { + const text = textOf( + render( + row({ + stage: ReferralFriendStage.REWARD_UNLOCKING, + payoutEtaAt: '2026-11-01T02:00:00.000Z', + awaitingActivity: false, + }), + ), + ); + + expect(text).not.toContain('Needs one more'); + }); +}); diff --git a/lib/types.ts b/lib/types.ts index c401afb64..9b0a3883b 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -3562,7 +3562,21 @@ export interface ReferralRewardListItem { payoutEtaAt?: string; paidAt?: string; spendUsd: number; + /** Merchants that count toward the target: those with the per-merchant minimum spent there. */ merchantCount: number; + /** + * Spend the referral rules left out — excluded merchants (cash, transfers, + * top-ups, game credit…) and purchases under the minimum. Absent on backends + * that predate the rules. + */ + excludedSpendUsd?: number; + /** Merchants the friend spent at that have not reached the per-merchant minimum yet. */ + merchantsBelowMinimum?: number; + /** + * Qualified, but the friend has not yet made the one more purchase the payout + * needs — the referrer's cue to nudge them before the reward unlocks. + */ + awaitingActivity?: boolean; /** * The bar this row is measured against. For a friend who has qualified it is * the bar they cleared, which can be lower than today's — the target moved @@ -3615,6 +3629,15 @@ export interface ReferralSummary { windowDays: number; /** Days between qualifying and the payout — the dispute/chargeback cover. */ payoutDelayDays: number; + /** Purchases under this (USD) count for nothing. Absent on older backends. */ + minPurchaseUsd?: number; + /** A merchant counts toward the target once this much (USD) is spent there. */ + minMerchantSpendUsd?: number; + /** + * The purchase (USD) the friend must make between qualifying and the + * payout; null when none is required, absent on older backends. + */ + activityMinPurchaseUsd?: number | null; }; totalRewardedUsd: number; friendsInvited: number;