From f34c777c437e2e66a8896d434493bdc94b9c5d8f Mon Sep 17 00:00:00 2001 From: RissRIce Date: Sat, 1 Aug 2026 10:50:20 -0600 Subject: [PATCH] fix: make referral rewards atomic --- src/app/api/auth/confirmed/route.test.ts | 64 ++++++++++- src/app/api/auth/confirmed/route.ts | 75 +++---------- ...20260801150000_atomic_referral_rewards.sql | 106 ++++++++++++++++++ 3 files changed, 181 insertions(+), 64 deletions(-) create mode 100644 supabase/migrations/20260801150000_atomic_referral_rewards.sql diff --git a/src/app/api/auth/confirmed/route.test.ts b/src/app/api/auth/confirmed/route.test.ts index c7dae08d..a20283aa 100644 --- a/src/app/api/auth/confirmed/route.test.ts +++ b/src/app/api/auth/confirmed/route.test.ts @@ -9,9 +9,11 @@ const mockUpdate = vi.fn(() => ({ eq: mockUpdateEq })); const mockSingle = vi.fn(); const mockSelectEq = vi.fn(() => ({ single: mockSingle })); const mockSelect = vi.fn(() => ({ eq: mockSelectEq })); +const mockInsert = vi.fn().mockResolvedValue({ error: null }); +const mockRpc = vi.fn().mockResolvedValue({ data: [], error: null }); const mockFrom = vi.fn((table: string) => { // update chain is only used for profiles DID update - return { select: mockSelect, update: mockUpdate }; + return { select: mockSelect, update: mockUpdate, insert: mockInsert }; }); vi.mock("@/lib/supabase/server", () => ({ @@ -19,7 +21,7 @@ vi.mock("@/lib/supabase/server", () => ({ })); vi.mock("@/lib/supabase/service", () => ({ - createServiceClient: vi.fn(() => ({ from: mockFrom })), + createServiceClient: vi.fn(() => ({ from: mockFrom, rpc: mockRpc })), })); // ── Email mock ───────────────────────────────────────────────────── @@ -111,6 +113,8 @@ describe("POST /api/auth/confirmed", () => { delete process.env.COINPAY_API_URL; delete process.env.COINPAY_REPUTATION_API_KEY; mockUpdateEq.mockResolvedValue({ error: null }); + mockInsert.mockResolvedValue({ error: null }); + mockRpc.mockResolvedValue({ data: [], error: null }); }); describe("email confirmation", () => { @@ -206,6 +210,62 @@ describe("POST /api/auth/confirmed", () => { }); }); + describe("referral rewards", () => { + it("pays through the atomic referral reward function", async () => { + mockSingle.mockResolvedValue({ + data: { + username: null, + full_name: "Referred User", + account_type: "human", + did: "did:key:zExistingDid", + }, + error: null, + }); + mockRpc.mockResolvedValueOnce({ + data: [{ + referrer_id: "referrer-123", + referrer_balance: 125, + referred_balance: 25, + }], + error: null, + }); + + const res = await POST(makeRequest(confirmationPayload())); + + expect(res.status).toBe(200); + expect(mockRpc).toHaveBeenCalledWith("pay_referral_reward", { + p_referred_user_id: "user-123", + p_reward_sats: 25, + }); + expect(mockInsert).toHaveBeenCalledWith( + expect.objectContaining({ + user_id: "referrer-123", + type: "referral_reward", + }) + ); + }); + + it("does not notify when the referral reward was already paid", async () => { + mockSingle.mockResolvedValue({ + data: { + username: null, + full_name: "Referred User", + account_type: "human", + did: "did:key:zExistingDid", + }, + error: null, + }); + + const res = await POST(makeRequest(confirmationPayload())); + + expect(res.status).toBe(200); + expect(mockRpc).toHaveBeenCalledTimes(1); + expect(mockInsert).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "referral_reward" }) + ); + }); + }); + describe("DID auto-generation", () => { it("generates and stores a did:key when user has no DID", async () => { mockSingle.mockResolvedValue({ diff --git a/src/app/api/auth/confirmed/route.ts b/src/app/api/auth/confirmed/route.ts index 89b9a10f..4a7cbbb1 100644 --- a/src/app/api/auth/confirmed/route.ts +++ b/src/app/api/auth/confirmed/route.ts @@ -141,72 +141,23 @@ export async function POST(request: NextRequest) { // ── Referral reward: 25 sats each on account activation ── try { - const { data: referral } = await (supabase as any) - .from("referrals") - .select("referrer_id, reward_paid") - .eq("referred_user_id", userId) - .eq("status", "registered") - .maybeSingle(); - - if (referral && !referral.reward_paid) { - const REFERRAL_REWARD = 25; - const referrerId = referral.referrer_id; - - // Credit referrer - const { data: referrerWallet } = await (supabase as any) - .from("wallets") - .select("balance_sats") - .eq("user_id", referrerId) - .single(); - - if (referrerWallet) { - await (supabase as any).from("wallets") - .update({ balance_sats: referrerWallet.balance_sats + REFERRAL_REWARD, updated_at: new Date().toISOString() }) - .eq("user_id", referrerId); - } else { - await (supabase as any).from("wallets") - .insert({ user_id: referrerId, balance_sats: REFERRAL_REWARD }); + const REFERRAL_REWARD = 25; + const { data: rewards, error: rewardError } = await (supabase as any).rpc( + "pay_referral_reward", + { + p_referred_user_id: userId, + p_reward_sats: REFERRAL_REWARD, } + ); - await (supabase as any).from("wallet_transactions").insert({ - user_id: referrerId, - type: "deposit", - amount_sats: REFERRAL_REWARD, - balance_after: (referrerWallet?.balance_sats ?? 0) + REFERRAL_REWARD, - status: "completed", - reference_id: userId, - }); - - // Credit new user - const { data: newUserWallet } = await (supabase as any) - .from("wallets") - .select("balance_sats") - .eq("user_id", userId) - .single(); - - if (newUserWallet) { - await (supabase as any).from("wallets") - .update({ balance_sats: newUserWallet.balance_sats + REFERRAL_REWARD, updated_at: new Date().toISOString() }) - .eq("user_id", userId); - } else { - await (supabase as any).from("wallets") - .insert({ user_id: userId, balance_sats: REFERRAL_REWARD }); - } + if (rewardError) { + throw rewardError; + } - await (supabase as any).from("wallet_transactions").insert({ - user_id: userId, - type: "deposit", - amount_sats: REFERRAL_REWARD, - balance_after: (newUserWallet?.balance_sats ?? 0) + REFERRAL_REWARD, - status: "completed", - reference_id: referrerId, - }); + const reward = Array.isArray(rewards) ? rewards[0] : rewards; - // Mark reward as paid - await (supabase as any).from("referrals") - .update({ reward_paid: true }) - .eq("referred_user_id", userId) - .eq("referrer_id", referrerId); + if (reward) { + const referrerId = reward.referrer_id; // Notify referrer await (supabase as any).from("notifications").insert({ diff --git a/supabase/migrations/20260801150000_atomic_referral_rewards.sql b/supabase/migrations/20260801150000_atomic_referral_rewards.sql new file mode 100644 index 00000000..4d720953 --- /dev/null +++ b/supabase/migrations/20260801150000_atomic_referral_rewards.sql @@ -0,0 +1,106 @@ +-- Pay referral rewards exactly once, even when confirmation webhooks overlap. +CREATE OR REPLACE FUNCTION public.pay_referral_reward( + p_referred_user_id uuid, + p_reward_sats bigint DEFAULT 25 +) +RETURNS TABLE( + referrer_id uuid, + referrer_balance bigint, + referred_balance bigint +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_referrer_id uuid; + v_referrer_balance bigint; + v_referred_balance bigint; +BEGIN + IF p_reward_sats <= 0 THEN + RAISE EXCEPTION 'Referral reward must be positive'; + END IF; + + -- Serialize attempts for the same referred user, including duplicate rows. + PERFORM pg_advisory_xact_lock(hashtextextended(p_referred_user_id::text, 0)); + + -- Any paid record means this user has already received the one-time reward. + IF EXISTS ( + SELECT 1 + FROM public.referrals r + WHERE r.referred_user_id = p_referred_user_id + AND r.status = 'registered' + AND r.reward_paid IS TRUE + ) THEN + RETURN; + END IF; + + SELECT r.referrer_id + INTO v_referrer_id + FROM public.referrals r + WHERE r.referred_user_id = p_referred_user_id + AND r.status = 'registered' + AND COALESCE(r.reward_paid, false) = false + ORDER BY r.registered_at NULLS LAST, r.created_at, r.id + LIMIT 1 + FOR UPDATE; + + IF NOT FOUND THEN + RETURN; + END IF; + + INSERT INTO public.wallets AS wallet (user_id, balance_sats) + VALUES (v_referrer_id, p_reward_sats) + ON CONFLICT (user_id) DO UPDATE + SET balance_sats = wallet.balance_sats + EXCLUDED.balance_sats, + updated_at = now() + RETURNING wallet.balance_sats INTO v_referrer_balance; + + INSERT INTO public.wallets AS wallet (user_id, balance_sats) + VALUES (p_referred_user_id, p_reward_sats) + ON CONFLICT (user_id) DO UPDATE + SET balance_sats = wallet.balance_sats + EXCLUDED.balance_sats, + updated_at = now() + RETURNING wallet.balance_sats INTO v_referred_balance; + + INSERT INTO public.wallet_transactions ( + user_id, + type, + amount_sats, + balance_after, + status, + reference_id + ) + VALUES + ( + v_referrer_id, + 'deposit', + p_reward_sats, + v_referrer_balance, + 'completed', + p_referred_user_id + ), + ( + p_referred_user_id, + 'deposit', + p_reward_sats, + v_referred_balance, + 'completed', + v_referrer_id + ); + + -- Mark every matching record so historical duplicates cannot be paid later. + UPDATE public.referrals r + SET reward_paid = true + WHERE r.referred_user_id = p_referred_user_id + AND r.status = 'registered'; + + RETURN QUERY + SELECT v_referrer_id, v_referrer_balance, v_referred_balance; +END; +$$; + +REVOKE ALL ON FUNCTION public.pay_referral_reward(uuid, bigint) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.pay_referral_reward(uuid, bigint) FROM anon; +REVOKE ALL ON FUNCTION public.pay_referral_reward(uuid, bigint) FROM authenticated; +GRANT EXECUTE ON FUNCTION public.pay_referral_reward(uuid, bigint) TO service_role;