Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 62 additions & 2 deletions src/app/api/auth/confirmed/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,19 @@ 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", () => ({
createClient: vi.fn(() => Promise.resolve({ from: mockFrom })),
}));

vi.mock("@/lib/supabase/service", () => ({
createServiceClient: vi.fn(() => ({ from: mockFrom })),
createServiceClient: vi.fn(() => ({ from: mockFrom, rpc: mockRpc })),
}));

// ── Email mock ─────────────────────────────────────────────────────
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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({
Expand Down
75 changes: 13 additions & 62 deletions src/app/api/auth/confirmed/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
106 changes: 106 additions & 0 deletions supabase/migrations/20260801150000_atomic_referral_rewards.sql
Original file line number Diff line number Diff line change
@@ -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;
Loading