diff --git a/Referral-doc.md b/Referral-doc.md new file mode 100644 index 000000000..559eb71c8 --- /dev/null +++ b/Referral-doc.md @@ -0,0 +1,108 @@ +# Referral System + +## Overview + +Referral rewards are paid to the referrer based on real successful payments from referred users. + +- Reward rate: `20%` of payment (`2000` basis points) +- Referred user reward: none +- Behavior flag: `RECURRING_REFERRAL_REWARD` + - `true`: reward every successful eligible payment + - `false`: reward only once per referral + +## Main Rules + +- Each user has one unique referral code. +- A user can apply a code only before their first confirmed payment. +- Self-referral is blocked (user id + identity fields). +- Rewarding is idempotent: + - same payment cannot reward twice + - non-recurring mode allows only one reward for that referral + +## Data Model + +### `ReferralCode` (`referralcodes`) + +- `userId` (unique) +- `referralCode` (unique) + +### `Referral` (`referrals`) + +- `referrerId` +- `referredUserId` (unique) +- `referralCode` +- `status`: `pending | converted` +- `sourceIp`, `deviceFingerprint`, `convertedAt` + +### `ReferralTransaction` (`referraltransactions`) + +- `referralId`, `referrerId`, `referredUserId` +- `sourcePaymentId`, `sourcePaymentGateway`, `sourcePaymentObjectId` +- `paymentAmountUsd`, `rewardAmountUsd`, `rewardRate` (Decimal128) +- `creditTransactionId` +- `idempotencyKey` (unique) + +## Migration + +Migration file: `src/migrations/1744200000000-referral-system.ts` + +What it does: + +- creates indexes for referral collections +- backfills missing referral codes for existing users +- backfills `Referral` records from legacy `user.referral.invitedBy` +- supports `users` and `tgusers` collections + +### Run migration (non-interactive) + +```bash +npx ts-migrate-mongoose up -f src/migrate.ts -a true +``` + +### Check migration status + +```bash +npm run migrate:list +``` + +### Roll back referral migration (if needed) + +```bash +npm run migrate:down +``` + +## Core Services + +- `src/services/referral/referralCodeService.ts` + - ensures and fetches user referral codes +- `src/services/referral/referralService.ts` + - apply code, enforce eligibility, return referral stats +- `src/services/referral/referralRewardService.ts` + - compute reward in cents, award credits, upsert audit transaction, mark referral converted + +## API + +- `GET /referral/stats` + - returns code, referral link, totals, and referral rows +- `POST /referral/apply` + - request: `{ "code": "ABCD1234", "deviceFingerprint": "optional" }` + - supports `x-device-fingerprint` header + +## Payment Integration + +Reward processing is triggered on successful payment flows in: + +- `src/controllers/payment/coinbase/webhook.ts` +- `src/controllers/payment/stripe/handleWebhook.ts` + +## Config + +`RECURRING_REFERRAL_REWARD` is read via `nconf`. Truthy values: `true`, `1`, `yes`. + +## Tests + +Key test: `src/services/referral/__tests__/referralRewardService.test.ts` + +- non-recurring behavior +- recurring behavior +- payment idempotency diff --git a/app/src/components/referral/ReferralRewardsSection.tsx b/app/src/components/referral/ReferralRewardsSection.tsx new file mode 100644 index 000000000..5699cd7e8 --- /dev/null +++ b/app/src/components/referral/ReferralRewardsSection.tsx @@ -0,0 +1,359 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { useUser } from '../../hooks/useUser'; +import { useCoreState } from '../../providers/CoreStateProvider'; +import { referralApi } from '../../services/api/referralApi'; +import type { ReferralRelationshipStatus, ReferralStats } from '../../types/referral'; + +function formatUsd(n: number): string { + return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n); +} + +/** Basis points → percent for display (100 bps = 1%). */ +function formatRewardRatePercentFromBps(bps: number): string { + return new Intl.NumberFormat('en-US', { + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }).format(bps / 100); +} + +function statusBadgeClass(status: ReferralRelationshipStatus): string { + switch (status) { + case 'converted': + return 'bg-sage-100 text-sage-800'; + case 'expired': + return 'bg-stone-100 text-stone-600'; + default: + return 'bg-amber-50 text-amber-800'; + } +} + +function statusLabel(status: ReferralRelationshipStatus): string { + switch (status) { + case 'converted': + return 'Completed'; + case 'expired': + return 'Expired'; + default: + return 'Joined'; + } +} + +const ReferralRewardsSection = () => { + const { user, refetch } = useUser(); + const { snapshot } = useCoreState(); + const token = snapshot.sessionToken; + + const [stats, setStats] = useState(null); + const [loadError, setLoadError] = useState(null); + const [loading, setLoading] = useState(false); + + const [applyCode, setApplyCode] = useState(''); + const [applyLoading, setApplyLoading] = useState(false); + const [applyError, setApplyError] = useState(null); + const [applySuccess, setApplySuccess] = useState(false); + const [copyHint, setCopyHint] = useState(null); + + const latestRequestIdRef = useRef(0); + + const loadStats = useCallback(async () => { + if (!token) { + latestRequestIdRef.current += 1; + setLoading(false); + return; + } + + latestRequestIdRef.current += 1; + const requestId = latestRequestIdRef.current; + + setLoading(true); + setLoadError(null); + try { + const s = await referralApi.getStats(); + if (requestId !== latestRequestIdRef.current) return; + setStats(s); + console.debug('[referral-ui] stats', { + codeLen: s.referralCode.length, + referrals: s.referrals.length, + }); + } catch (err) { + if (requestId !== latestRequestIdRef.current) return; + const msg = + err && typeof err === 'object' && 'error' in err + ? String((err as { error: string }).error) + : 'Could not load referral stats'; + setLoadError(msg); + console.debug('[referral-ui] stats error', msg); + } finally { + if (requestId === latestRequestIdRef.current) { + setLoading(false); + } + } + }, [token]); + + useEffect(() => { + void loadStats(); + }, [loadStats]); + + const shareOrCopyTarget = stats?.referralLink?.trim() || stats?.referralCode || ''; + + const handleCopy = async () => { + if (!shareOrCopyTarget) return; + try { + await navigator.clipboard.writeText(shareOrCopyTarget); + setCopyHint('Copied'); + setTimeout(() => setCopyHint(null), 2000); + } catch { + setCopyHint('Copy failed'); + setTimeout(() => setCopyHint(null), 2500); + } + }; + + const handleShare = async () => { + if (!shareOrCopyTarget) return; + try { + if (navigator.share) { + const url = stats?.referralLink?.trim(); + await navigator.share({ + title: 'OpenHuman', + text: url + ? 'Join me on OpenHuman' + : `Join me on OpenHuman — referral code: ${stats?.referralCode ?? ''}`, + ...(url ? { url } : {}), + }); + } else { + await handleCopy(); + } + } catch (e) { + if ((e as Error)?.name !== 'AbortError') { + await handleCopy(); + } + } + }; + + const handleApply = async () => { + const trimmed = applyCode.trim(); + if (!trimmed) return; + setApplyLoading(true); + setApplyError(null); + try { + await referralApi.applyCode(trimmed); + setApplySuccess(true); + setApplyCode(''); + await refetch(); + await loadStats(); + console.debug('[referral-ui] apply completed'); + } catch (err) { + const msg = + err && typeof err === 'object' && 'error' in err + ? String((err as { error: string }).error) + : 'Could not apply referral code'; + setApplyError(msg); + } finally { + setApplyLoading(false); + } + }; + + const hasAppliedFromProfile = !!user?.referral?.invitedBy || !!user?.referral?.invitedByCode; + const hasAppliedFromStats = + !!stats?.appliedReferralCode && stats.appliedReferralCode.trim() !== ''; + const showApplyForm = + stats && + stats.canApplyReferral !== false && + !hasAppliedFromStats && + !hasAppliedFromProfile && + !applySuccess; + + if (!token) { + return null; + } + + return ( +
+
+
+
+ Referral rewards +
+

Invite friends, earn credits

+

+ Share your personal link. When someone subscribes, you can earn a share of their + eligible payments as account credit. Self-referrals and duplicate rewards are blocked on + the server. +

+ {stats?.rewardRateBps ? ( +

+ Current reward rate: {formatRewardRatePercentFromBps(stats.rewardRateBps)}% of + eligible referred payments (basis points {stats.rewardRateBps}). +

+ ) : null} +
+
+ + {loading && !stats ? ( +

Loading referral program…

+ ) : null} + {loadError ? ( +
+ {loadError} + +
+ ) : null} + + {stats ? ( + <> +
+
+
+ Your code +
+
+ {stats.referralCode || '—'} +
+
+
+
+ Total earned +
+
+ {formatUsd(stats.totals.totalRewardUsd)} +
+
+
+
+ Pending referrals +
+
+ {stats.totals.pendingCount} +
+
+
+
+ Completed +
+
+ {stats.totals.convertedCount} +
+
+
+ +
+ + + {copyHint ? ( + {copyHint} + ) : null} +
+ + {stats.referralLink ? ( +

{stats.referralLink}

+ ) : null} + +
+

Referral activity

+ {stats.referrals.length === 0 ? ( +

+ No referrals yet. Share your link to get started. +

+ ) : ( +
+ + + + + + + + + + + {stats.referrals.map((row, idx) => ( + + + + + + + ))} + +
Referred userStatusRewardUpdated
+ {row.referredUserMasked || row.referredDisplayName || '—'} + + + {statusLabel(row.status)} + + + {row.rewardUsd != null && row.rewardUsd > 0 + ? formatUsd(row.rewardUsd) + : '—'} + + {row.status === 'converted' && row.convertedAt + ? new Date(row.convertedAt).toLocaleString() + : row.createdAt + ? new Date(row.createdAt).toLocaleString() + : '—'} +
+
+ )} +
+ + {showApplyForm ? ( +
+

Have a referral code?

+

+ Enter a friend's code if you haven't completed a paid subscription yet. + Eligibility is enforced by the server. +

+
+ setApplyCode(e.target.value.toUpperCase())} + onKeyDown={e => e.key === 'Enter' && void handleApply()} + placeholder="Referral code" + disabled={applyLoading} + className="flex-1 px-4 py-2.5 rounded-xl border border-stone-200 bg-white font-mono text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-primary-500/40" + /> + +
+ {applyError ?

{applyError}

: null} +
+ ) : null} + + {(hasAppliedFromStats || hasAppliedFromProfile || applySuccess) && !showApplyForm ? ( +

+ You're linked to a referral program + {stats.appliedReferralCode ? ` (code ${stats.appliedReferralCode})` : ''}. +

+ ) : null} + + ) : null} +
+ ); +}; + +export default ReferralRewardsSection; diff --git a/app/src/pages/Rewards.tsx b/app/src/pages/Rewards.tsx index 91c085eb5..424c8dea9 100644 --- a/app/src/pages/Rewards.tsx +++ b/app/src/pages/Rewards.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; +import ReferralRewardsSection from '../components/referral/ReferralRewardsSection'; import { useUser } from '../hooks/useUser'; import { useAppSelector } from '../store/hooks'; @@ -89,6 +90,8 @@ const Rewards = () => { return (
+ +
diff --git a/app/src/pages/onboarding/Onboarding.tsx b/app/src/pages/onboarding/Onboarding.tsx index c28d02ef0..43ea71608 100644 --- a/app/src/pages/onboarding/Onboarding.tsx +++ b/app/src/pages/onboarding/Onboarding.tsx @@ -1,8 +1,11 @@ -import { useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import ProgressIndicator from '../../components/ProgressIndicator'; import { useCoreState } from '../../providers/CoreStateProvider'; +import { referralApi } from '../../services/api/referralApi'; import { userApi } from '../../services/api/userApi'; import { getDefaultEnabledTools } from '../../utils/toolDefinitions'; +import ReferralApplyStep from './steps/ReferralApplyStep'; import ScreenPermissionsStep from './steps/ScreenPermissionsStep'; import SkillsStep from './steps/SkillsStep'; import WelcomeStep from './steps/WelcomeStep'; @@ -17,25 +20,115 @@ interface OnboardingDraft { connectedSources: string[]; } +function hasReferralFromProfile( + user: + | { referral?: { invitedBy?: string | null; invitedByCode?: string | null } } + | null + | undefined +): boolean { + return !!(user?.referral?.invitedBy || user?.referral?.invitedByCode); +} + +/** When referral is skipped, step index 1 (apply) is not shown — treat as screen permissions (2). */ +function resolveOnboardingStep(currentStep: number, skipReferralStep: boolean): number { + if (skipReferralStep && currentStep === 1) { + return 2; + } + return currentStep; +} + const Onboarding = ({ onComplete, onDefer }: OnboardingProps) => { - const { setOnboardingCompletedFlag, setOnboardingTasks } = useCoreState(); + const { setOnboardingCompletedFlag, setOnboardingTasks, snapshot } = useCoreState(); const [currentStep, setCurrentStep] = useState(0); const [draft, setDraft] = useState({ accessibilityPermissionGranted: false, connectedSources: [], }); - const totalSteps = 3; + /** Last session token for which referral stats prefetch finished (async path only). */ + const [referralStatsToken, setReferralStatsToken] = useState(null); + const [skipReferralFromStats, setSkipReferralFromStats] = useState(false); + const [referralAppliedThisSession, setReferralAppliedThisSession] = useState(false); + + const token = snapshot.sessionToken; + const currentUser = snapshot.currentUser; + + const profileAlreadyReferred = useMemo(() => hasReferralFromProfile(currentUser), [currentUser]); + const needsReferralStatsPrefetch = !!(token && !profileAlreadyReferred); + + useEffect(() => { + if (!needsReferralStatsPrefetch) { + return; + } + + let cancelled = false; + (async () => { + try { + const stats = await referralApi.getStats(); + const applied = + typeof stats.appliedReferralCode === 'string' && stats.appliedReferralCode.trim() !== ''; + if (!cancelled) { + setSkipReferralFromStats(applied); + setReferralStatsToken(token); + } + } catch { + console.debug('[onboarding] referral preflight failed; showing referral step'); + if (!cancelled) { + setSkipReferralFromStats(false); + setReferralStatsToken(token); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [needsReferralStatsPrefetch, token, profileAlreadyReferred]); + + const referralGateReady = !token || profileAlreadyReferred || referralStatsToken === token; + + const skipReferralStep = !token + ? false + : profileAlreadyReferred + ? true + : referralStatsToken === token && skipReferralFromStats; + + const resolvedStep = resolveOnboardingStep(currentStep, skipReferralStep); + + const totalSteps = skipReferralStep ? 3 : 4; + const progressCurrentStep = skipReferralStep + ? resolvedStep === 0 + ? 0 + : resolvedStep === 2 + ? 1 + : 2 + : resolvedStep; + + const handleWelcomeNext = () => { + if (skipReferralStep) { + setCurrentStep(2); + } else { + setCurrentStep(1); + } + }; const handleNext = () => { - if (currentStep < totalSteps - 1) { - setCurrentStep(currentStep + 1); + const logical = resolveOnboardingStep(currentStep, skipReferralStep); + if (logical < 3) { + setCurrentStep(logical + 1); } }; const handleBack = () => { - if (currentStep > 0) { - setCurrentStep(currentStep - 1); + const logical = resolveOnboardingStep(currentStep, skipReferralStep); + if (logical <= 0) return; + if ( + logical === 2 && + (skipReferralStep || profileAlreadyReferred || referralAppliedThisSession) + ) { + setCurrentStep(0); + return; } + setCurrentStep(logical - 1); }; const handleAccessibilityNext = (accessibilityPermissionGranted: boolean) => { @@ -73,12 +166,27 @@ const Onboarding = ({ onComplete, onDefer }: OnboardingProps) => { }; const renderStep = () => { - switch (currentStep) { + switch (resolvedStep) { case 0: - return ; + return ( + + ); case 1: - return ; + return ( + setReferralAppliedThisSession(true)} + /> + ); case 2: + return ; + case 3: return ; default: return null; @@ -97,7 +205,10 @@ const Onboarding = ({ onComplete, onDefer }: OnboardingProps) => {
)} -
{renderStep()}
+
+ + {renderStep()} +
); }; diff --git a/app/src/pages/onboarding/steps/ReferralApplyStep.tsx b/app/src/pages/onboarding/steps/ReferralApplyStep.tsx new file mode 100644 index 000000000..a715dbd5b --- /dev/null +++ b/app/src/pages/onboarding/steps/ReferralApplyStep.tsx @@ -0,0 +1,124 @@ +import { useState } from 'react'; + +import { useCoreState } from '../../../providers/CoreStateProvider'; +import { referralApi } from '../../../services/api/referralApi'; + +interface ReferralApplyStepProps { + onNext: () => void; + onBack: () => void; + /** Called after a successful apply so onboarding can skip showing this step when navigating back. */ + onApplied?: () => void; +} + +/** + * Optional step: attribute the signed-in user to a referrer via POST /referral/apply. + */ +const ReferralApplyStep = ({ onNext, onBack, onApplied }: ReferralApplyStepProps) => { + const { refresh } = useCoreState(); + const [code, setCode] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + const handleApply = async () => { + const trimmed = code.trim(); + if (!trimmed) return; + + setIsLoading(true); + setError(null); + + try { + await referralApi.applyCode(trimmed); + setSuccess(true); + try { + await refresh(); + } catch { + console.warn('[onboarding] referral apply: refresh after apply failed'); + } + onApplied?.(); + console.debug('[onboarding] referral code applied'); + setTimeout(() => onNext(), 1200); + } catch (err) { + const msg = + err && typeof err === 'object' && 'error' in err + ? String((err as { error: string }).error) + : 'Could not apply referral code'; + setError(msg); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+

Referral code

+

+ If a friend shared OpenHuman with you, enter their referral code here. You can skip—this + stays available on the Rewards page while you're eligible. +

+
+ + {success ? ( +
+
+ + + +
+

Referral code applied.

+
+ ) : ( + <> +
+ setCode(e.target.value.toUpperCase())} + onKeyDown={e => e.key === 'Enter' && void handleApply()} + placeholder="Enter referral code" + className="w-full px-4 py-3 bg-stone-50 border border-stone-200 rounded-xl text-center font-mono text-lg tracking-widest text-stone-900 placeholder:text-stone-400 placeholder:tracking-normal placeholder:font-sans placeholder:text-sm focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500/50 transition-all" + disabled={isLoading} + /> + {error ?

{error}

: null} +
+ +
+ + + +
+ + )} +
+ ); +}; + +export default ReferralApplyStep; diff --git a/app/src/pages/onboarding/steps/WelcomeStep.tsx b/app/src/pages/onboarding/steps/WelcomeStep.tsx index 25de4bbc9..b6c00fb58 100644 --- a/app/src/pages/onboarding/steps/WelcomeStep.tsx +++ b/app/src/pages/onboarding/steps/WelcomeStep.tsx @@ -5,6 +5,9 @@ import OnboardingNextButton from '../components/OnboardingNextButton'; interface WelcomeStepProps { onNext: () => void; + nextDisabled?: boolean; + nextLoading?: boolean; + nextLoadingLabel?: string; } const TOTAL_SLIDES = 3; @@ -63,7 +66,12 @@ const AutomationSlide = () => ( /* ------------------------------------------------------------------ */ /* WelcomeStep — auto-advancing carousel, button goes to next step */ /* ------------------------------------------------------------------ */ -const WelcomeStep = ({ onNext }: WelcomeStepProps) => { +const WelcomeStep = ({ + onNext, + nextDisabled = false, + nextLoading = false, + nextLoadingLabel, +}: WelcomeStepProps) => { const [slide, setSlide] = useState(0); useEffect(() => { @@ -83,7 +91,13 @@ const WelcomeStep = ({ onNext }: WelcomeStepProps) => {
- +
); }; diff --git a/app/src/services/api/__tests__/referralApi.test.ts b/app/src/services/api/__tests__/referralApi.test.ts new file mode 100644 index 000000000..d7ee1cf25 --- /dev/null +++ b/app/src/services/api/__tests__/referralApi.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { normalizeReferralStats, referralApi } from '../referralApi'; + +vi.mock('../../coreCommandClient', () => ({ callCoreCommand: vi.fn() })); + +describe('normalizeReferralStats', () => { + it('maps camelCase stats and referral rows', () => { + const stats = normalizeReferralStats({ + referralCode: 'ABC12', + referralLink: 'https://app.example/r/ABC12', + totals: { totalRewardUsd: 12.5, pendingCount: 1, convertedCount: 2 }, + referrals: [ + { referredUserId: 'u1', status: 'pending', createdAt: '2025-01-01' }, + { referredUserId: 'u2', status: 'converted', convertedAt: '2025-01-02', rewardUsd: 5 }, + ], + appliedReferralCode: null, + canApplyReferral: true, + rewardRateBps: 2000, + }); + expect(stats.referralCode).toBe('ABC12'); + expect(stats.referralLink).toBe('https://app.example/r/ABC12'); + expect(stats.totals).toEqual({ totalRewardUsd: 12.5, pendingCount: 1, convertedCount: 2 }); + expect(stats.referrals).toHaveLength(2); + expect(stats.referrals[0].status).toBe('pending'); + expect(stats.referrals[1].status).toBe('converted'); + expect(stats.referrals[1].rewardUsd).toBe(5); + expect(stats.appliedReferralCode).toBeNull(); + expect(stats.canApplyReferral).toBe(true); + expect(stats.rewardRateBps).toBe(2000); + }); + + it('maps snake_case and coerces unknown status to pending', () => { + const stats = normalizeReferralStats({ + code: 'X', + link: 'https://x', + summary: { total_reward_usd: '3.25', pending_referrals: 2, converted_referrals: 0 }, + referralRows: [{ status: 'weird', _id: 'r1' }], + }); + expect(stats.referralCode).toBe('X'); + expect(stats.totals.totalRewardUsd).toBe(3.25); + expect(stats.totals.pendingCount).toBe(2); + expect(stats.referrals[0].status).toBe('pending'); + expect(stats.referrals[0].id).toBe('r1'); + }); + + it('handles empty payload', () => { + const stats = normalizeReferralStats({}); + expect(stats.referralCode).toBe(''); + expect(stats.referrals).toEqual([]); + expect(stats.totals.totalRewardUsd).toBe(0); + }); + + it('maps completed status to converted and rewardAmountUsd', () => { + const stats = normalizeReferralStats({ + referrals: [{ status: 'Completed', rewardAmountUsd: 2.5, referredUserId: 'u1' }], + totals: { totalRewardUsd: 0, pendingCount: 0, convertedCount: 0 }, + }); + expect(stats.referrals[0].status).toBe('converted'); + expect(stats.referrals[0].rewardUsd).toBe(2.5); + expect(stats.totals.convertedCount).toBe(1); + expect(stats.totals.totalRewardUsd).toBe(2.5); + }); + + it('maps referralId, joinedAt, referredUserMasked, and Joined status', () => { + const stats = normalizeReferralStats({ + referrals: [ + { + referralId: 'ref-99', + status: 'Joined', + referredUserMasked: ' j***@gmail.com ', + joinedAt: '2026-04-01T12:00:00.000Z', + convertedAt: null, + }, + ], + }); + expect(stats.referrals[0].id).toBe('ref-99'); + expect(stats.referrals[0].referredUserMasked).toBe('j***@gmail.com'); + expect(stats.referrals[0].status).toBe('pending'); + expect(stats.referrals[0].createdAt).toBe('2026-04-01T12:00:00.000Z'); + }); + + it('maps referred_user_masked snake_case', () => { + const stats = normalizeReferralStats({ + referrals: [{ referred_user_masked: 'U***', status: 'Converted' }], + }); + expect(stats.referrals[0].referredUserMasked).toBe('U***'); + expect(stats.referrals[0].status).toBe('converted'); + }); + + it('reads Mongo-style Decimal128 and nested transactions', () => { + const stats = normalizeReferralStats({ + referrals: [ + { + status: 'converted', + referred_user_id: { $oid: '507f1f77bcf86cd799439011' }, + transactions: [ + { rewardAmountUsd: { $numberDecimal: '1.25' } }, + { reward_amount_usd: '0.75' }, + ], + }, + ], + }); + expect(stats.referrals[0].referredUserId).toBe('507f1f77bcf86cd799439011'); + expect(stats.referrals[0].rewardUsd).toBe(2); + expect(stats.totals.totalRewardUsd).toBe(2); + expect(stats.totals.convertedCount).toBe(1); + }); + + it('prefers explicit totals when backend sends them', () => { + const stats = normalizeReferralStats({ + totals: { totalRewardUsd: 10, pendingCount: 0, convertedCount: 2 }, + referrals: [{ status: 'converted', rewardUsd: 3 }], + }); + expect(stats.totals.totalRewardUsd).toBe(10); + expect(stats.totals.convertedCount).toBe(2); + }); + + it('maps totalRewardsEarnedUsd from backend stats payload', () => { + const stats = normalizeReferralStats({ + totals: { totalRewardsEarnedUsd: 4.5, pendingCount: 0, convertedCount: 1 }, + referrals: [{ status: 'converted' }], + }); + expect(stats.totals.totalRewardUsd).toBe(4.5); + }); +}); + +describe('referralApi', () => { + it('getStats normalizes core RPC payload', async () => { + const { callCoreCommand } = await import('../../coreCommandClient'); + vi.mocked(callCoreCommand).mockResolvedValueOnce({ + referralCode: 'Z9', + referralLink: 'https://z', + totals: { totalRewardUsd: 1, pendingCount: 0, convertedCount: 1 }, + referrals: [], + }); + const out = await referralApi.getStats(); + expect(callCoreCommand).toHaveBeenCalledWith('openhuman.referral_get_stats'); + expect(out.referralCode).toBe('Z9'); + }); + + it('applyCode calls core with trimmed code and fingerprint', async () => { + const { callCoreCommand } = await import('../../coreCommandClient'); + vi.mocked(callCoreCommand).mockResolvedValueOnce({}); + await referralApi.applyCode(' abcd '); + expect(callCoreCommand).toHaveBeenCalledWith( + 'openhuman.referral_apply', + expect.objectContaining({ code: 'abcd', deviceFingerprint: expect.any(String) }) + ); + }); + + it('getStats throws { success: false, error } when core rejects with Error', async () => { + const { callCoreCommand } = await import('../../coreCommandClient'); + vi.mocked(callCoreCommand).mockRejectedValueOnce(new Error('Core RPC HTTP 503')); + await expect(referralApi.getStats()).rejects.toEqual({ + success: false, + error: 'Core RPC HTTP 503', + }); + }); + + it('applyCode throws { success: false, error } preserving err.error string', async () => { + const { callCoreCommand } = await import('../../coreCommandClient'); + vi.mocked(callCoreCommand).mockRejectedValueOnce({ error: 'Code already used' }); + await expect(referralApi.applyCode('ABCD')).rejects.toEqual({ + success: false, + error: 'Code already used', + }); + }); +}); diff --git a/app/src/services/api/referralApi.ts b/app/src/services/api/referralApi.ts new file mode 100644 index 000000000..2a923f57c --- /dev/null +++ b/app/src/services/api/referralApi.ts @@ -0,0 +1,324 @@ +import type { + ReferralRelationshipStatus, + ReferralRow, + ReferralStats, + ReferralStatsTotals, +} from '../../types/referral'; +import { getOrCreateDeviceFingerprint } from '../../utils/deviceFingerprint'; +import { callCoreCommand } from '../coreCommandClient'; + +/** Shape thrown by {@link referralApi.getStats} / {@link referralApi.applyCode} on RPC failure. */ +export type ReferralRpcFailure = { success: false; error: string }; + +function referralRpcErrorMessage(err: unknown): string { + if (err && typeof err === 'object') { + const o = err as Record; + if (typeof o.error === 'string' && o.error.trim() !== '') { + return o.error; + } + if (typeof o.message === 'string' && o.message.trim() !== '') { + return o.message; + } + } + if (err instanceof Error && err.message) { + return err.message; + } + return String(err); +} + +function throwReferralRpcFailure(err: unknown): never { + const failure: ReferralRpcFailure = { success: false, error: referralRpcErrorMessage(err) }; + throw failure; +} + +function num(v: unknown): number { + if (typeof v === 'number' && Number.isFinite(v)) return v; + if (typeof v === 'string' && v.trim() !== '') { + const n = Number(v); + return Number.isFinite(n) ? n : 0; + } + return 0; +} + +/** Mongo Decimal128 in JSON (`{ $numberDecimal: "1.23" }`) and similar. */ +function coerceMoney(v: unknown): number { + if (v === null || v === undefined) return 0; + if (typeof v === 'number' && Number.isFinite(v)) return v; + if (typeof v === 'string' && v.trim() !== '') { + const n = Number(v); + return Number.isFinite(n) ? n : 0; + } + const o = asRecord(v); + if (o && typeof o.$numberDecimal === 'string') { + return num(o.$numberDecimal); + } + return 0; +} + +function coerceId(v: unknown): string | undefined { + if (typeof v === 'string' && v.trim()) return v.trim(); + const o = asRecord(v); + if (o && typeof o.$oid === 'string' && o.$oid.trim()) return o.$oid.trim(); + return undefined; +} + +function asRecord(v: unknown): Record | null { + return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : null; +} + +function normalizeStatus(raw: unknown): ReferralRelationshipStatus { + const s = typeof raw === 'string' ? raw.toLowerCase().trim() : ''; + if (s === 'converted' || s === 'completed' || s === 'complete') return 'converted'; + if (s === 'joined') return 'pending'; + if (s === 'pending' || s === 'expired') return s; + return 'pending'; +} + +function rowRewardUsd(r: Record): number { + const direct = coerceMoney( + r.rewardUsd ?? + r.reward_usd ?? + r.rewardAmountUsd ?? + r.reward_amount_usd ?? + r.totalRewardUsd ?? + r.total_reward_usd + ); + const cents = num(r.rewardCents ?? r.reward_cents); + const fromCents = cents > 0 ? cents / 100 : 0; + + const txs = r.transactions ?? r.referralTransactions ?? r.referral_transactions; + let txSum = 0; + if (Array.isArray(txs)) { + for (const t of txs) { + const tr = asRecord(t) ?? {}; + const m = coerceMoney(tr.rewardAmountUsd ?? tr.reward_amount_usd ?? tr.rewardUsd); + const c = num(tr.rewardCents ?? tr.reward_cents); + txSum += m > 0 ? m : c > 0 ? c / 100 : 0; + } + } + + if (txSum > 0) return txSum; + if (direct > 0) return direct; + return fromCents; +} + +function normalizeRow(entry: unknown): ReferralRow { + const r = asRecord(entry) ?? {}; + const refUser = asRecord(r.referredUser ?? r.referred_user ?? r.user); + const rewardUsd = rowRewardUsd(r); + const referredUserId = + (typeof r.referredUserId === 'string' && r.referredUserId) || + (typeof r.referred_user_id === 'string' && r.referred_user_id) || + (typeof r.refereeId === 'string' && r.refereeId) || + (typeof r.referee_id === 'string' && r.referee_id) || + coerceId(r.referredUserId) || + coerceId(r.referred_user_id) || + (refUser + ? (coerceId(refUser._id) ?? (typeof refUser.id === 'string' ? refUser.id : undefined)) + : undefined); + const referredDisplayName = + typeof r.referredDisplayName === 'string' + ? r.referredDisplayName + : typeof r.referred_display_name === 'string' + ? r.referred_display_name + : typeof r.referredUsername === 'string' + ? r.referredUsername + : refUser && typeof refUser.username === 'string' + ? refUser.username + : undefined; + + const referredUserMaskedRaw = + typeof r.referredUserMasked === 'string' + ? r.referredUserMasked + : typeof r.referred_user_masked === 'string' + ? r.referred_user_masked + : undefined; + const referredUserMasked = + referredUserMaskedRaw && referredUserMaskedRaw.trim() !== '' + ? referredUserMaskedRaw.trim() + : undefined; + + return { + id: + (typeof r.referralId === 'string' && r.referralId) || + (typeof r.referral_id === 'string' && r.referral_id) || + (typeof r.id === 'string' && r.id) || + (typeof r._id === 'string' && r._id) || + coerceId(r._id), + referredUserId, + status: normalizeStatus(r.status), + referralCode: + typeof r.referralCode === 'string' + ? r.referralCode + : typeof r.referral_code === 'string' + ? r.referral_code + : undefined, + createdAt: + typeof r.joinedAt === 'string' + ? r.joinedAt + : typeof r.joined_at === 'string' + ? r.joined_at + : typeof r.createdAt === 'string' + ? r.createdAt + : typeof r.created_at === 'string' + ? r.created_at + : undefined, + convertedAt: + r.convertedAt === null + ? null + : typeof r.convertedAt === 'string' + ? r.convertedAt + : typeof r.converted_at === 'string' + ? r.converted_at + : undefined, + rewardUsd: rewardUsd > 0 ? rewardUsd : undefined, + referredDisplayName, + referredUserMasked, + }; +} + +function deriveTotalsFromReferrals(referrals: ReferralRow[]): ReferralStatsTotals { + let totalRewardUsd = 0; + let pendingCount = 0; + let convertedCount = 0; + for (const row of referrals) { + if (row.rewardUsd != null && row.rewardUsd > 0) { + totalRewardUsd += row.rewardUsd; + } + if (row.status === 'pending') pendingCount += 1; + if (row.status === 'converted') convertedCount += 1; + } + return { totalRewardUsd, pendingCount, convertedCount }; +} + +/** + * Map backend `/referral/stats` payload (flexible field names) to UI types. + */ +export function normalizeReferralStats(raw: unknown): ReferralStats { + const r = asRecord(raw) ?? {}; + const code = String(r.referralCode ?? r.code ?? '').trim(); + const link = String(r.referralLink ?? r.link ?? '').trim(); + + const totalsRaw = asRecord(r.totals) ?? asRecord(r.summary) ?? {}; + const totalFromApi = Math.max( + coerceMoney( + totalsRaw.totalRewardsEarnedUsd ?? + totalsRaw.total_rewards_earned_usd ?? + totalsRaw.totalRewardUsd ?? + totalsRaw.total_reward_usd ?? + totalsRaw.lifetimeRewardUsd ?? + totalsRaw.lifetime_reward_usd + ), + coerceMoney( + r.totalRewardsEarnedUsd ?? + r.total_rewards_earned_usd ?? + r.totalRewardUsd ?? + r.total_reward_usd ?? + r.totalEarningsUsd ?? + r.total_earnings_usd ?? + r.earningsUsd ?? + r.earnings_usd ?? + r.rewardsTotalUsd ?? + r.rewards_total_usd + ) + ); + + const pendingFromApi = Math.round( + num( + totalsRaw.pendingCount ?? + totalsRaw.pending_count ?? + totalsRaw.pendingReferrals ?? + totalsRaw.pending_referrals ?? + r.pendingCount ?? + r.pending_count + ) + ); + const convertedFromApi = Math.round( + num( + totalsRaw.convertedCount ?? + totalsRaw.converted_count ?? + totalsRaw.convertedReferrals ?? + totalsRaw.converted_referrals ?? + r.convertedCount ?? + r.converted_count + ) + ); + + const listRaw = r.referrals ?? r.referralRows ?? r.rows; + const referrals: ReferralRow[] = Array.isArray(listRaw) ? listRaw.map(normalizeRow) : []; + + const derived = deriveTotalsFromReferrals(referrals); + const totals: ReferralStatsTotals = { + totalRewardUsd: totalFromApi > 0 ? totalFromApi : derived.totalRewardUsd, + pendingCount: pendingFromApi || derived.pendingCount, + convertedCount: convertedFromApi || derived.convertedCount, + }; + + const appliedReferralCode = + r.appliedReferralCode === null + ? null + : typeof r.appliedReferralCode === 'string' + ? r.appliedReferralCode + : typeof r.applied_referral_code === 'string' + ? r.applied_referral_code + : undefined; + + const canApplyReferral = + typeof r.canApplyReferral === 'boolean' + ? r.canApplyReferral + : typeof r.can_apply_referral === 'boolean' + ? r.can_apply_referral + : undefined; + + const rewardRateBps = num(r.rewardRateBps ?? r.reward_rate_bps); + + return { + referralCode: code, + referralLink: link, + totals, + referrals, + appliedReferralCode, + canApplyReferral, + rewardRateBps: rewardRateBps > 0 ? rewardRateBps : undefined, + }; +} + +export const referralApi = { + /** + * Referral stats via core RPC (`openhuman.referral_get_stats` → backend GET /referral/stats). + * Uses the sidecar HTTP client so the desktop WebView avoids direct `fetch` (fixes WKWebView "Load failed" / CORS to the API host). + */ + getStats: async (): Promise => { + try { + const data = await callCoreCommand('openhuman.referral_get_stats'); + console.debug('[referral] stats loaded via core', { + hasCode: !!(data && typeof data === 'object'), + }); + return normalizeReferralStats(data); + } catch (err) { + console.debug('[referral] getStats RPC failed', referralRpcErrorMessage(err)); + throwReferralRpcFailure(err); + } + }, + + /** + * Apply referral code via core RPC (`openhuman.referral_apply` → backend POST /referral/apply). + */ + applyCode: async (code: string): Promise => { + const trimmed = code.trim(); + if (!trimmed) { + throw { success: false as const, error: 'Referral code is required' }; + } + const deviceFingerprint = getOrCreateDeviceFingerprint(); + try { + await callCoreCommand('openhuman.referral_apply', { + code: trimmed, + deviceFingerprint, + }); + console.debug('[referral] apply succeeded', { codeLength: trimmed.length }); + } catch (err) { + console.debug('[referral] apply RPC failed', referralRpcErrorMessage(err)); + throwReferralRpcFailure(err); + } + }, +}; diff --git a/app/src/types/referral.ts b/app/src/types/referral.ts new file mode 100644 index 000000000..db113aa18 --- /dev/null +++ b/app/src/types/referral.ts @@ -0,0 +1,36 @@ +/** Normalized referral relationship status for UI (backend: pending | converted; expired reserved). */ +export type ReferralRelationshipStatus = 'pending' | 'converted' | 'expired'; + +export interface ReferralStatsTotals { + /** Total USD credited to the referrer from referral rewards */ + totalRewardUsd: number; + pendingCount: number; + convertedCount: number; +} + +export interface ReferralRow { + id?: string; + referredUserId?: string; + status: ReferralRelationshipStatus; + referralCode?: string; + createdAt?: string; + convertedAt?: string | null; + /** Reward amount in USD for this relationship when converted */ + rewardUsd?: number; + /** Optional display name from backend when user id is hidden */ + referredDisplayName?: string; + /** Masked identity from backend (e.g. j***@gmail.com) — preferred for display */ + referredUserMasked?: string; +} + +export interface ReferralStats { + referralCode: string; + referralLink: string; + totals: ReferralStatsTotals; + referrals: ReferralRow[]; + /** Code this user applied as referred (if any) */ + appliedReferralCode?: string | null; + /** When false, user likely cannot apply (e.g. already paid); optional from backend */ + canApplyReferral?: boolean; + rewardRateBps?: number; +} diff --git a/app/src/utils/deviceFingerprint.ts b/app/src/utils/deviceFingerprint.ts new file mode 100644 index 000000000..df2c78777 --- /dev/null +++ b/app/src/utils/deviceFingerprint.ts @@ -0,0 +1,20 @@ +const STORAGE_KEY = 'openhuman_device_fingerprint_v1'; + +/** + * Stable anonymous id for referral abuse signals (optional body/header on backend). + */ +export function getOrCreateDeviceFingerprint(): string { + try { + let v = localStorage.getItem(STORAGE_KEY); + if (!v) { + v = + typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : `fp_${Date.now()}_${Math.random().toString(36).slice(2, 12)}`; + localStorage.setItem(STORAGE_KEY, v); + } + return v; + } catch { + return `fp_ephemeral_${Date.now()}`; + } +} diff --git a/scripts/mock-api-core.mjs b/scripts/mock-api-core.mjs index 19f16c8a9..aac73cdfd 100644 --- a/scripts/mock-api-core.mjs +++ b/scripts/mock-api-core.mjs @@ -13,7 +13,8 @@ let mockTunnels = []; const CORS_HEADERS = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Access-Control-Allow-Headers": + "Content-Type, Authorization, x-device-fingerprint", "Access-Control-Max-Age": "86400", }; @@ -718,6 +719,50 @@ async function handleRequest(req, res) { json(res, 200, { success: true, data: { valid: true } }); return; } + + if (method === "GET" && /^\/referral\/stats\/?(\?.*)?$/.test(url)) { + const origin = requestOrigin(req); + json(res, 200, { + success: true, + data: { + referralCode: "MOCKREF1", + referralLink: `${origin}/#/rewards?ref=MOCKREF1`, + rewardRateBps: 2000, + totals: { + totalRewardUsd: 4.2, + pendingCount: 1, + convertedCount: 2, + }, + referrals: [ + { + id: "ref-row-1", + referredUserId: "user-456", + status: "pending", + createdAt: new Date(Date.now() - 86400000).toISOString(), + }, + { + id: "ref-row-2", + referredUserId: "user-789", + status: "converted", + createdAt: new Date(Date.now() - 172800000).toISOString(), + convertedAt: new Date().toISOString(), + rewardUsd: 2.1, + }, + ], + appliedReferralCode: null, + canApplyReferral: true, + }, + }); + return; + } + + if (method === "POST" && /^\/referral\/apply\/?$/.test(url)) { + json(res, 200, { + success: true, + data: { ok: true, message: "Referral applied" }, + }); + return; + } if ( method === "POST" && /^\/telegram\/settings\/onboarding-complete\/?$/.test(url) diff --git a/src/core/all.rs b/src/core/all.rs index fcfeca2ed..1846a0f4a 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -66,6 +66,7 @@ fn build_registered_controllers() -> Vec { controllers.extend(crate::openhuman::workspace::all_workspace_registered_controllers()); controllers.extend(crate::openhuman::tools::all_tools_registered_controllers()); controllers.extend(crate::openhuman::memory::all_memory_registered_controllers()); + controllers.extend(crate::openhuman::referral::all_referral_registered_controllers()); controllers.extend(crate::openhuman::billing::all_billing_registered_controllers()); controllers.extend(crate::openhuman::team::all_team_registered_controllers()); controllers.extend(crate::openhuman::text_input::all_text_input_registered_controllers()); @@ -106,6 +107,7 @@ fn build_declared_controller_schemas() -> Vec { schemas.extend(crate::openhuman::workspace::all_workspace_controller_schemas()); schemas.extend(crate::openhuman::tools::all_tools_controller_schemas()); schemas.extend(crate::openhuman::memory::all_memory_controller_schemas()); + schemas.extend(crate::openhuman::referral::all_referral_controller_schemas()); schemas.extend(crate::openhuman::billing::all_billing_controller_schemas()); schemas.extend(crate::openhuman::team::all_team_controller_schemas()); schemas.extend(crate::openhuman::text_input::all_text_input_controller_schemas()); @@ -150,6 +152,7 @@ pub fn namespace_description(namespace: &str) -> Option<&'static str> { "skills" => Some("Skill registry, runtime lifecycle, setup, tools, and sync."), "socket" => Some("Skills runtime socket bridge controls."), "memory" => Some("Document storage, vector search, key-value store, and knowledge graph."), + "referral" => Some("Referral codes, stats, and apply flows via the hosted backend API."), "billing" => Some("Subscription plan, payment links, and credit top-up via the backend."), "team" => Some("Team member management, invites, and role changes via the backend."), "voice" => Some("Speech-to-text and text-to-speech using local models."), diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index 1c06c3c2c..620f458c1 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -39,6 +39,7 @@ pub mod memory; pub mod migration; pub mod overlay; pub mod providers; +pub mod referral; pub mod screen_intelligence; pub mod security; pub mod service; diff --git a/src/openhuman/referral/mod.rs b/src/openhuman/referral/mod.rs new file mode 100644 index 000000000..1da5415d3 --- /dev/null +++ b/src/openhuman/referral/mod.rs @@ -0,0 +1,9 @@ +//! Referral program RPC adapters (hosted API). + +mod ops; +mod schemas; + +pub use ops::*; +pub use schemas::{ + all_referral_controller_schemas, all_referral_registered_controllers, referral_schemas, +}; diff --git a/src/openhuman/referral/ops.rs b/src/openhuman/referral/ops.rs new file mode 100644 index 000000000..09ed04c2d --- /dev/null +++ b/src/openhuman/referral/ops.rs @@ -0,0 +1,71 @@ +//! Referral program — authenticated calls to the hosted API (`/referral/*`). +//! +//! The desktop WebView `fetch` to the backend can fail with a generic "Load failed" +//! (CORS / TLS / WebKit). These ops reuse the same `reqwest` path as billing. + +use reqwest::Method; +use serde_json::{json, Map, Value}; + +use crate::api::config::effective_api_url; +use crate::api::jwt::get_session_token; +use crate::api::BackendOAuthClient; +use crate::openhuman::config::Config; +use crate::rpc::RpcOutcome; + +fn require_token(config: &Config) -> Result { + get_session_token(config)? + .and_then(|v| { + let t = v.trim().to_string(); + if t.is_empty() { + None + } else { + Some(t) + } + }) + .ok_or_else(|| "no backend session token; run auth_store_session first".to_string()) +} + +pub async fn get_stats(config: &Config) -> Result, String> { + let token = require_token(config)?; + let api_url = effective_api_url(&config.api_url); + let client = BackendOAuthClient::new(&api_url).map_err(|e| e.to_string())?; + let data = client + .authed_json(&token, Method::GET, "/referral/stats", None) + .await + .map_err(|e| e.to_string())?; + Ok(RpcOutcome::single_log( + data, + "referral stats fetched from backend GET /referral/stats", + )) +} + +pub async fn apply_code( + config: &Config, + code: &str, + device_fingerprint: Option<&str>, +) -> Result, String> { + let token = require_token(config)?; + let api_url = effective_api_url(&config.api_url); + let client = BackendOAuthClient::new(&api_url).map_err(|e| e.to_string())?; + + let mut body = Map::new(); + body.insert("code".to_string(), json!(code.trim())); + if let Some(fp) = device_fingerprint.map(str::trim).filter(|s| !s.is_empty()) { + body.insert("deviceFingerprint".to_string(), json!(fp)); + } + + let data = client + .authed_json( + &token, + Method::POST, + "/referral/apply", + Some(Value::Object(body)), + ) + .await + .map_err(|e| e.to_string())?; + + Ok(RpcOutcome::single_log( + data, + "referral apply accepted by backend POST /referral/apply", + )) +} diff --git a/src/openhuman/referral/schemas.rs b/src/openhuman/referral/schemas.rs new file mode 100644 index 000000000..b567a11be --- /dev/null +++ b/src/openhuman/referral/schemas.rs @@ -0,0 +1,125 @@ +use serde::de::DeserializeOwned; +use serde::Deserialize; +use serde_json::{Map, Value}; + +use crate::core::all::{ControllerFuture, RegisteredController}; +use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; +use crate::openhuman::config::rpc as config_rpc; +use crate::rpc::RpcOutcome; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ReferralApplyParams { + code: String, + #[serde(default)] + device_fingerprint: Option, +} + +pub fn all_referral_controller_schemas() -> Vec { + vec![ + referral_schemas("referral_get_stats"), + referral_schemas("referral_apply"), + ] +} + +pub fn all_referral_registered_controllers() -> Vec { + vec![ + RegisteredController { + schema: referral_schemas("referral_get_stats"), + handler: handle_referral_get_stats, + }, + RegisteredController { + schema: referral_schemas("referral_apply"), + handler: handle_referral_apply, + }, + ] +} + +pub fn referral_schemas(function: &str) -> ControllerSchema { + match function { + "referral_get_stats" => ControllerSchema { + namespace: "referral", + function: "get_stats", + description: + "Fetch referral code, link, totals, and referred-user rows from the backend.", + inputs: vec![], + outputs: vec![json_output( + "stats", + "Payload from GET /referral/stats (backend `data` field).", + )], + }, + "referral_apply" => ControllerSchema { + namespace: "referral", + function: "apply", + description: + "Apply a friend's referral code for the current user (backend eligibility rules).", + inputs: vec![ + FieldSchema { + name: "code", + ty: TypeSchema::String, + comment: "Referral code to apply.", + required: true, + }, + FieldSchema { + name: "deviceFingerprint", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: "Optional client fingerprint for abuse signals.", + required: false, + }, + ], + outputs: vec![json_output( + "result", + "Payload from POST /referral/apply (backend `data` field).", + )], + }, + _ => ControllerSchema { + namespace: "referral", + function: "unknown", + description: "Unknown referral controller.", + inputs: vec![], + outputs: vec![FieldSchema { + name: "error", + ty: TypeSchema::String, + comment: "Lookup error details.", + required: true, + }], + }, + } +} + +fn handle_referral_get_stats(_params: Map) -> ControllerFuture { + Box::pin(async move { + let config = config_rpc::load_config_with_timeout().await?; + to_json(crate::openhuman::referral::get_stats(&config).await?) + }) +} + +fn handle_referral_apply(params: Map) -> ControllerFuture { + Box::pin(async move { + let config = config_rpc::load_config_with_timeout().await?; + let payload = deserialize_params::(params)?; + let fp = payload + .device_fingerprint + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()); + to_json(crate::openhuman::referral::apply_code(&config, payload.code.trim(), fp).await?) + }) +} + +fn to_json(outcome: RpcOutcome) -> Result { + outcome.into_cli_compatible_json() +} + +fn deserialize_params(params: Map) -> Result { + serde_json::from_value(Value::Object(params)).map_err(|e| format!("invalid params: {e}")) +} + +fn json_output(name: &'static str, comment: &'static str) -> FieldSchema { + FieldSchema { + name, + ty: TypeSchema::Json, + comment, + required: true, + } +}