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;