From 0262eee0a4cfe80727458696423dc2f4944e7eca Mon Sep 17 00:00:00 2001 From: shaiksohelll Date: Sun, 17 May 2026 22:22:22 +0530 Subject: [PATCH 1/3] feat(wallet): test-mode top-up RPC + Add Money UI --- src/app/_actions/wallet.ts | 72 +++++++++ src/components/features/topup-dialog.tsx | 153 ++++++++++++++++++ src/components/features/wallet-view.tsx | 23 +-- src/lib/schemas/wallet.ts | 11 ++ .../20260517201500_topup_wallet.sql | 86 ++++++++++ 5 files changed, 335 insertions(+), 10 deletions(-) create mode 100644 src/app/_actions/wallet.ts create mode 100644 src/components/features/topup-dialog.tsx create mode 100644 src/lib/schemas/wallet.ts create mode 100644 supabase/migrations/20260517201500_topup_wallet.sql diff --git a/src/app/_actions/wallet.ts b/src/app/_actions/wallet.ts new file mode 100644 index 0000000..05633ca --- /dev/null +++ b/src/app/_actions/wallet.ts @@ -0,0 +1,72 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { createClient } from "@/lib/supabase/server"; +import { + topupWalletSchema, + type TopupWalletInput, +} from "@/lib/schemas/wallet"; +import type { ActionResult } from "./escrow"; + +// ── Helper ──────────────────────────────────────────────────────────────────── +async function getAuthUserId() { + const supabase = await createClient(); + const { + data: { user }, + error, + } = await supabase.auth.getUser(); + if (error || !user) throw new Error(error?.message ?? "Not authenticated"); + return { supabase, userId: user.id }; +} + +// ── Top up Wallet ───────────────────────────────────────────────────────────── +// external → wallet: calls topup_wallet() SECURITY DEFINER function (test mode) +export async function topupWalletAction( + raw: TopupWalletInput, +): Promise> { + try { + const parsed = topupWalletSchema.safeParse(raw); + if (!parsed.success) { + return { success: false, error: parsed.error.issues[0]?.message ?? "Invalid input" }; + } + + const { supabase } = await getAuthUserId(); + const { amount, idempotency_key } = parsed.data; + + const { data, error } = await supabase.rpc("topup_wallet", { + p_amount: amount, + p_idempotency_key: idempotency_key, + }); + + if (error) { + const msg = error.message.toLowerCase(); + if (msg.includes("invalid_amount")) { + return { success: false, error: "Amount must be between ₹100 and ₹1,00,000." }; + } + if (msg.includes("not_authenticated")) { + return { success: false, error: "You need to be signed in." }; + } + return { success: false, error: "Could not top up wallet. Please try again." }; + } + + const row = Array.isArray(data) ? data[0] : data; + if (!row) { + return { success: false, error: "Could not top up wallet. Please try again." }; + } + + revalidatePath("/client/wallet", "layout"); + revalidatePath("/worker/wallet", "layout"); + + return { + success: true, + data: { + availableBalance: Number(row.available_balance), + ledgerId: row.ledger_id as string, + }, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : "Unknown error"; + // TODO: Sentry.captureException(err); + return { success: false, error: msg }; + } +} \ No newline at end of file diff --git a/src/components/features/topup-dialog.tsx b/src/components/features/topup-dialog.tsx new file mode 100644 index 0000000..b68964a --- /dev/null +++ b/src/components/features/topup-dialog.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { useRef, useState, useTransition, useEffect } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Plus } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +import { topupWalletAction } from "@/app/_actions/wallet"; + +const QUICK_AMOUNTS = [1000, 5000, 10000, 25000]; + +export function TopUpDialog() { + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const [amountStr, setAmountStr] = useState(""); + const [isPending, startTransition] = useTransition(); + const inFlightRef = useRef(false); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + function handleTopUp() { + const amount = Number(amountStr); + if (!Number.isFinite(amount) || amount < 100) { + toast.error("Enter at least ₹100."); + return; + } + if (amount > 100000) { + toast.error("Maximum top-up is ₹1,00,000."); + return; + } + if (inFlightRef.current) return; + inFlightRef.current = true; + + startTransition(async () => { + try { + const result = await topupWalletAction({ + amount, + idempotency_key: crypto.randomUUID(), + }); + + if (!result.success) { + if (mountedRef.current) toast.error(result.error); + return; + } + + // Cache invalidation — safe to run regardless of mount state. + // Adjust query keys below if yours differ. + queryClient.invalidateQueries({ queryKey: ["wallet"] }); + + if (mountedRef.current) { + const newBalance = result.data?.availableBalance ?? 0; + toast.success( + `Added ₹${amount.toLocaleString("en-IN")}. New balance: ₹${newBalance.toLocaleString("en-IN")}.`, + ); + setAmountStr(""); + setOpen(false); + } + } finally { + inFlightRef.current = false; + } + }); + } + + return ( + <> + + !isPending && setOpen(o)}> + + + Add money to wallet + + Test-mode top-up. ₹100 – ₹1,00,000. + + + +
+
+ + setAmountStr(e.target.value)} + onWheel={(e) => (e.target as HTMLInputElement).blur()} + disabled={isPending} + /> +
+ +
+ {QUICK_AMOUNTS.map((amt) => ( + + ))} +
+
+ + + + + +
+
+ + ); +} \ No newline at end of file diff --git a/src/components/features/wallet-view.tsx b/src/components/features/wallet-view.tsx index 53cf194..72d757f 100644 --- a/src/components/features/wallet-view.tsx +++ b/src/components/features/wallet-view.tsx @@ -7,6 +7,7 @@ import { useUser } from "@/hooks/use-user"; import { StatusBadge } from "@/components/ui/status-badge"; import { Skeleton } from "@/components/ui/skeleton"; import { Separator } from "@/components/ui/separator"; +import { TopUpDialog } from "@/components/features/topup-dialog"; import { formatInr, relativeTime } from "@/lib/format"; // ── Types ───────────────────────────────────────────────────────────────────── @@ -162,6 +163,10 @@ export function WalletView({ role }: { role: "client" | "worker" }) { +
+ +
+ {/* ── Locked breakdown (client only) ── */} {role === "client" && lockedByJob.length > 0 && ( <> @@ -216,13 +221,12 @@ export function WalletView({ role }: { role: "client" | "worker" }) { >
{isIncoming ? ( @@ -251,9 +255,8 @@ export function WalletView({ role }: { role: "client" | "worker" }) {
{isIncoming ? "+" : "-"} {formatInr(entry.amount)} diff --git a/src/lib/schemas/wallet.ts b/src/lib/schemas/wallet.ts new file mode 100644 index 0000000..6628364 --- /dev/null +++ b/src/lib/schemas/wallet.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const topupWalletSchema = z.object({ + amount: z + .number({ invalid_type_error: "Amount must be a number." }) + .min(100, "Minimum top-up is ₹100.") + .max(100000, "Maximum top-up is ₹1,00,000."), + idempotency_key: z.string().uuid(), +}); + +export type TopupWalletInput = z.infer; \ No newline at end of file diff --git a/supabase/migrations/20260517201500_topup_wallet.sql b/supabase/migrations/20260517201500_topup_wallet.sql new file mode 100644 index 0000000..1c5b3a5 --- /dev/null +++ b/supabase/migrations/20260517201500_topup_wallet.sql @@ -0,0 +1,86 @@ +-- 1. Allow nullable job_id for non-escrow ledger entries (topup, withdraw). +-- The enum already permits these types; the NOT NULL was an oversight. +alter table public.escrow_ledger + alter column job_id drop not null; + +-- 2. RPC: top up the caller's wallet (test mode — no real payment). +create or replace function public.topup_wallet( + p_amount numeric, + p_idempotency_key uuid +) +returns table ( + available_balance numeric, + ledger_id uuid +) +language plpgsql +security definer +set search_path = public +as $$ +declare + v_profile_id uuid; + v_min_amount constant numeric := 100; + v_max_amount constant numeric := 100000; + v_ledger_id uuid; + v_new_balance numeric; +begin + -- 1. Auth check + v_profile_id := auth.uid(); + if v_profile_id is null then + raise exception 'not_authenticated' using errcode = '28000'; + end if; + + -- 2. Validate amount (test-mode bounds: ₹100 – ₹1,00,000) + if p_amount is null or p_amount < v_min_amount or p_amount > v_max_amount then + raise exception 'invalid_amount' using errcode = '22023'; + end if; + + -- 3. Idempotency: if a topup ledger row with this reference already exists, + -- return the current balance + existing ledger id without re-crediting. + select id into v_ledger_id + from escrow_ledger + where reference_id = p_idempotency_key + and type = 'topup' + limit 1; + + if v_ledger_id is not null then + select w.available_balance into v_new_balance + from wallets w + where w.profile_id = v_profile_id; + return query select v_new_balance, v_ledger_id; + return; + end if; + + -- 4. Defensive: ensure wallet exists (the create_wallet_for_profile trigger + -- should have already done this, but belt-and-suspenders). + insert into wallets (profile_id) + values (v_profile_id) + on conflict (profile_id) do nothing; + + -- 5. Credit the wallet and capture the new balance + update wallets + set available_balance = available_balance + p_amount + where profile_id = v_profile_id + returning available_balance into v_new_balance; + + -- 6. Audit row in escrow_ledger + -- job_id / milestone_id / from_wallet all null (external → wallet) + insert into escrow_ledger ( + job_id, milestone_id, from_wallet, to_wallet, amount, type, reference_id + ) + values ( + null, null, null, v_profile_id, p_amount, 'topup', p_idempotency_key + ) + returning id into v_ledger_id; + + return query select v_new_balance, v_ledger_id; +end; +$$; + +-- 3. Grants +revoke all on function public.topup_wallet(numeric, uuid) from public; +grant execute on function public.topup_wallet(numeric, uuid) to authenticated; + +-- 4. Index to keep the idempotency lookup fast +create index if not exists escrow_ledger_topup_reference_idx + on public.escrow_ledger (reference_id) + where type = 'topup'; \ No newline at end of file From dead6633821853d328787a993773972bb7d6b6a8 Mon Sep 17 00:00:00 2001 From: shaiksohelll Date: Mon, 18 May 2026 00:00:24 +0530 Subject: [PATCH 2/3] =?UTF-8?q?fix(wallet):=20review=20hardening=20?= =?UTF-8?q?=E2=80=94=20idempotency=20unique=20+=20race,=20RLS,=20labels,?= =?UTF-8?q?=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/_actions/wallet.ts | 101 ++++---- src/components/features/topup-dialog.tsx | 240 +++++++++--------- src/components/features/wallet-view.tsx | 48 ++-- src/lib/schemas/wallet.ts | 12 +- .../20260517223900_topup_wallet_hardening.sql | 115 +++++++++ 5 files changed, 313 insertions(+), 203 deletions(-) create mode 100644 supabase/migrations/20260517223900_topup_wallet_hardening.sql diff --git a/src/app/_actions/wallet.ts b/src/app/_actions/wallet.ts index 05633ca..9c38078 100644 --- a/src/app/_actions/wallet.ts +++ b/src/app/_actions/wallet.ts @@ -2,71 +2,68 @@ import { revalidatePath } from "next/cache"; import { createClient } from "@/lib/supabase/server"; -import { - topupWalletSchema, - type TopupWalletInput, -} from "@/lib/schemas/wallet"; +import { topupWalletSchema, type TopupWalletInput } from "@/lib/schemas/wallet"; import type { ActionResult } from "./escrow"; // ── Helper ──────────────────────────────────────────────────────────────────── async function getAuthUserId() { - const supabase = await createClient(); - const { - data: { user }, - error, - } = await supabase.auth.getUser(); - if (error || !user) throw new Error(error?.message ?? "Not authenticated"); - return { supabase, userId: user.id }; + const supabase = await createClient(); + const { + data: { user }, + error, + } = await supabase.auth.getUser(); + if (error || !user) throw new Error(error?.message ?? "Not authenticated"); + return { supabase, userId: user.id }; } // ── Top up Wallet ───────────────────────────────────────────────────────────── // external → wallet: calls topup_wallet() SECURITY DEFINER function (test mode) export async function topupWalletAction( - raw: TopupWalletInput, + raw: TopupWalletInput, ): Promise> { - try { - const parsed = topupWalletSchema.safeParse(raw); - if (!parsed.success) { - return { success: false, error: parsed.error.issues[0]?.message ?? "Invalid input" }; - } + try { + const parsed = topupWalletSchema.safeParse(raw); + if (!parsed.success) { + return { success: false, error: parsed.error.issues[0]?.message ?? "Invalid input" }; + } - const { supabase } = await getAuthUserId(); - const { amount, idempotency_key } = parsed.data; + const { supabase } = await getAuthUserId(); + const { amount, idempotency_key } = parsed.data; - const { data, error } = await supabase.rpc("topup_wallet", { - p_amount: amount, - p_idempotency_key: idempotency_key, - }); + const { data, error } = await supabase.rpc("topup_wallet", { + p_amount: amount, + p_idempotency_key: idempotency_key, + }); - if (error) { - const msg = error.message.toLowerCase(); - if (msg.includes("invalid_amount")) { - return { success: false, error: "Amount must be between ₹100 and ₹1,00,000." }; - } - if (msg.includes("not_authenticated")) { - return { success: false, error: "You need to be signed in." }; - } - return { success: false, error: "Could not top up wallet. Please try again." }; - } + if (error) { + const msg = error.message.toLowerCase(); + if (msg.includes("invalid_amount")) { + return { success: false, error: "Amount must be between ₹100 and ₹1,00,000." }; + } + if (msg.includes("not_authenticated")) { + return { success: false, error: "You need to be signed in." }; + } + return { success: false, error: "Could not top up wallet. Please try again." }; + } - const row = Array.isArray(data) ? data[0] : data; - if (!row) { - return { success: false, error: "Could not top up wallet. Please try again." }; - } + const row = Array.isArray(data) ? data[0] : data; + if (!row) { + return { success: false, error: "Could not top up wallet. Please try again." }; + } - revalidatePath("/client/wallet", "layout"); - revalidatePath("/worker/wallet", "layout"); + revalidatePath("/client/wallet", "layout"); + revalidatePath("/worker/wallet", "layout"); - return { - success: true, - data: { - availableBalance: Number(row.available_balance), - ledgerId: row.ledger_id as string, - }, - }; - } catch (err) { - const msg = err instanceof Error ? err.message : "Unknown error"; - // TODO: Sentry.captureException(err); - return { success: false, error: msg }; - } -} \ No newline at end of file + return { + success: true, + data: { + availableBalance: Number(row.available_balance), + ledgerId: row.ledger_id as string, + }, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : "Unknown error"; + // TODO: Sentry.captureException(err); + return { success: false, error: msg }; + } +} diff --git a/src/components/features/topup-dialog.tsx b/src/components/features/topup-dialog.tsx index b68964a..71d6d7d 100644 --- a/src/components/features/topup-dialog.tsx +++ b/src/components/features/topup-dialog.tsx @@ -1,5 +1,6 @@ "use client"; +import { formatInr } from "@/lib/format"; import { useRef, useState, useTransition, useEffect } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -7,12 +8,12 @@ import { Plus } from "lucide-react"; import { Button } from "@/components/ui/button"; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -22,132 +23,123 @@ import { topupWalletAction } from "@/app/_actions/wallet"; const QUICK_AMOUNTS = [1000, 5000, 10000, 25000]; export function TopUpDialog() { - const queryClient = useQueryClient(); - const [open, setOpen] = useState(false); - const [amountStr, setAmountStr] = useState(""); - const [isPending, startTransition] = useTransition(); - const inFlightRef = useRef(false); - const mountedRef = useRef(true); + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const [amountStr, setAmountStr] = useState(""); + const [isPending, startTransition] = useTransition(); + const inFlightRef = useRef(false); + const mountedRef = useRef(true); - useEffect(() => { - mountedRef.current = true; - return () => { - mountedRef.current = false; - }; - }, []); + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); - function handleTopUp() { - const amount = Number(amountStr); - if (!Number.isFinite(amount) || amount < 100) { - toast.error("Enter at least ₹100."); - return; - } - if (amount > 100000) { - toast.error("Maximum top-up is ₹1,00,000."); - return; + function handleTopUp() { + const amount = Number(amountStr); + if (!Number.isFinite(amount) || amount < 100) { + toast.error("Enter at least ₹100."); + return; + } + if (amount > 100000) { + toast.error("Maximum top-up is ₹1,00,000."); + return; + } + if (inFlightRef.current) return; + inFlightRef.current = true; + + startTransition(async () => { + try { + const result = await topupWalletAction({ + amount, + idempotency_key: crypto.randomUUID(), + }); + + if (!result.success) { + if (mountedRef.current) toast.error(result.error); + return; } - if (inFlightRef.current) return; - inFlightRef.current = true; - startTransition(async () => { - try { - const result = await topupWalletAction({ - amount, - idempotency_key: crypto.randomUUID(), - }); + // Cache invalidation — safe to run regardless of mount state. + // Adjust query keys below if yours differ. + queryClient.invalidateQueries({ queryKey: ["wallet"] }); + + if (mountedRef.current) { + const newBalance = result.data?.availableBalance ?? 0; + toast.success(`Added ${formatInr(amount)}. New balance: ${formatInr(newBalance)}`); + setAmountStr(""); + setOpen(false); + } + } finally { + inFlightRef.current = false; + } + }); + } - if (!result.success) { - if (mountedRef.current) toast.error(result.error); - return; - } + return ( + <> + + !isPending && setOpen(o)}> + + + Add money to wallet + Test-mode top-up. ₹100 – ₹1,00,000. + - // Cache invalidation — safe to run regardless of mount state. - // Adjust query keys below if yours differ. - queryClient.invalidateQueries({ queryKey: ["wallet"] }); +
+
+ + setAmountStr(e.target.value)} + onWheel={(e) => (e.target as HTMLInputElement).blur()} + disabled={isPending} + /> +
- if (mountedRef.current) { - const newBalance = result.data?.availableBalance ?? 0; - toast.success( - `Added ₹${amount.toLocaleString("en-IN")}. New balance: ₹${newBalance.toLocaleString("en-IN")}.`, - ); - setAmountStr(""); - setOpen(false); - } - } finally { - inFlightRef.current = false; - } - }); - } +
+ {QUICK_AMOUNTS.map((amt) => ( + + ))} +
+
- return ( - <> + - !isPending && setOpen(o)}> - - - Add money to wallet - - Test-mode top-up. ₹100 – ₹1,00,000. - - - -
-
- - setAmountStr(e.target.value)} - onWheel={(e) => (e.target as HTMLInputElement).blur()} - disabled={isPending} - /> -
- -
- {QUICK_AMOUNTS.map((amt) => ( - - ))} -
-
- - - - - -
-
- - ); -} \ No newline at end of file + +
+
+
+ + ); +} diff --git a/src/components/features/wallet-view.tsx b/src/components/features/wallet-view.tsx index 72d757f..65f6229 100644 --- a/src/components/features/wallet-view.tsx +++ b/src/components/features/wallet-view.tsx @@ -46,11 +46,13 @@ async function fetchWalletData(role: "client" | "worker", userId: string) { .single(), supabase .from("escrow_ledger") - .select(` + .select( + ` id,amount,type,created_at,from_wallet,to_wallet, jobs!escrow_ledger_job_id_fkey(title), milestones!escrow_ledger_milestone_id_fkey(title) - `) + `, + ) .or(`from_wallet.eq.${userId},to_wallet.eq.${userId}`) .order("created_at", { ascending: false }) .limit(50), @@ -156,9 +158,7 @@ export function WalletView({ role }: { role: "client" | "worker" }) { Locked

-

- {formatInr(wallet.locked_balance)} -

+

{formatInr(wallet.locked_balance)}

In active escrows

@@ -172,9 +172,7 @@ export function WalletView({ role }: { role: "client" | "worker" }) { <>
-

- Locked Breakdown -

+

Locked Breakdown

    {lockedByJob.map((job) => (
  • -

    - Recent Transactions -

    +

    Recent Transactions

    {ledger.length === 0 ? (
    No transactions yet. @@ -221,12 +217,13 @@ export function WalletView({ role }: { role: "client" | "worker" }) { >
    {isIncoming ? ( @@ -236,7 +233,11 @@ export function WalletView({ role }: { role: "client" | "worker" }) {

    - {entry.milestone_title ?? entry.job_title} + {entry.type === "topup" + ? "Wallet top-up" + : entry.type === "withdraw" + ? "Wallet withdrawal" + : (entry.milestone_title ?? entry.job_title)}

    {relativeTime(entry.created_at)} @@ -255,8 +260,9 @@ export function WalletView({ role }: { role: "client" | "worker" }) {
    {isIncoming ? "+" : "-"} {formatInr(entry.amount)} diff --git a/src/lib/schemas/wallet.ts b/src/lib/schemas/wallet.ts index 6628364..7ea0ba2 100644 --- a/src/lib/schemas/wallet.ts +++ b/src/lib/schemas/wallet.ts @@ -1,11 +1,11 @@ import { z } from "zod"; export const topupWalletSchema = z.object({ - amount: z - .number({ invalid_type_error: "Amount must be a number." }) - .min(100, "Minimum top-up is ₹100.") - .max(100000, "Maximum top-up is ₹1,00,000."), - idempotency_key: z.string().uuid(), + amount: z + .number({ invalid_type_error: "Amount must be a number." }) + .min(100, "Minimum top-up is ₹100.") + .max(100000, "Maximum top-up is ₹1,00,000."), + idempotency_key: z.string().uuid(), }); -export type TopupWalletInput = z.infer; \ No newline at end of file +export type TopupWalletInput = z.infer; diff --git a/supabase/migrations/20260517223900_topup_wallet_hardening.sql b/supabase/migrations/20260517223900_topup_wallet_hardening.sql new file mode 100644 index 0000000..d85d7e1 --- /dev/null +++ b/supabase/migrations/20260517223900_topup_wallet_hardening.sql @@ -0,0 +1,115 @@ +-- ────────────────────────────────────────────────────────────────────────── +-- Hardening for topup_wallet (PR #16 review) +-- ────────────────────────────────────────────────────────────────────────── + +-- 1. Replace non-unique partial index with UNIQUE partial index. +-- Rename to follow idx__ convention. +drop index if exists public.escrow_ledger_topup_reference_idx; + +create unique index if not exists idx_escrow_ledger_reference_id + on public.escrow_ledger (reference_id) + where type = 'topup'; + +-- 2. job_id may only be null for non-job-scoped ledger types. +alter table public.escrow_ledger + add constraint escrow_ledger_job_id_required_for_job_types + check (type in ('topup', 'withdraw') or job_id is not null); + +-- 3. Wallet owners can read their own non-job-scoped ledger rows. +-- Additive: stacks via OR with the existing is_job_participant(job_id) policy. +drop policy if exists "escrow_ledger_select_wallet_owner" on public.escrow_ledger; + +create policy "escrow_ledger_select_wallet_owner" + on public.escrow_ledger + for select + to authenticated + using ( + auth.uid() = from_wallet + or auth.uid() = to_wallet + ); + +-- 4. Hardened topup_wallet: +-- - null idempotency key rejected at DB boundary +-- - INSERT ledger row FIRST so the UNIQUE index catches concurrent racers +-- - exception when unique_violation re-reads the winning row +create or replace function public.topup_wallet( + p_amount numeric, + p_idempotency_key uuid +) +returns table (available_balance numeric, ledger_id uuid) +language plpgsql +security definer +set search_path = public +as $$ +declare + v_profile_id uuid := auth.uid(); + v_ledger_id uuid; + v_available numeric; +begin + if v_profile_id is null then + raise exception 'not_authenticated' using errcode = '42501'; + end if; + + if p_amount is null or p_amount < 100 or p_amount > 100000 then + raise exception 'invalid_amount' using errcode = '22023'; + end if; + + -- Defends against direct RPC callers bypassing the action-layer Zod check. + if p_idempotency_key is null then + raise exception 'invalid_idempotency_key' using errcode = '22023'; + end if; + + -- Ensure wallet row exists. + insert into public.wallets (profile_id) + values (v_profile_id) + on conflict (profile_id) do nothing; + + -- Fast path: idempotency hit. + select id into v_ledger_id + from public.escrow_ledger + where reference_id = p_idempotency_key + and type = 'topup' + limit 1; + + if found then + select w.available_balance into v_available + from public.wallets w + where w.profile_id = v_profile_id; + return query select v_available, v_ledger_id; + return; + end if; + + -- Write ledger BEFORE crediting. UNIQUE index serializes concurrent calls. + begin + insert into public.escrow_ledger ( + job_id, milestone_id, from_wallet, to_wallet, amount, type, reference_id + ) + values ( + null, null, null, v_profile_id, p_amount, 'topup', p_idempotency_key + ) + returning id into v_ledger_id; + exception when unique_violation then + -- Concurrent caller won. Re-read the winning row + current balance. + select id into v_ledger_id + from public.escrow_ledger + where reference_id = p_idempotency_key + and type = 'topup' + limit 1; + select w.available_balance into v_available + from public.wallets w + where w.profile_id = v_profile_id; + return query select v_available, v_ledger_id; + return; + end; + + -- Credit wallet. We own the ledger row → no double-credit possible. + update public.wallets + set available_balance = available_balance + p_amount + where profile_id = v_profile_id + returning available_balance into v_available; + + return query select v_available, v_ledger_id; +end; +$$; + +grant execute on function public.topup_wallet(numeric, uuid) to authenticated; \ No newline at end of file From 7ea4d9213d4d7b5b40961b2bb32fd7ba3ed7bda9 Mon Sep 17 00:00:00 2001 From: shaiksohelll Date: Mon, 18 May 2026 00:25:44 +0530 Subject: [PATCH 3/3] fix(wallet): scope top-up idempotency to recipient wallet --- ...2100_topup_wallet_per_user_idempotency.sql | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 supabase/migrations/20260518002100_topup_wallet_per_user_idempotency.sql diff --git a/supabase/migrations/20260518002100_topup_wallet_per_user_idempotency.sql b/supabase/migrations/20260518002100_topup_wallet_per_user_idempotency.sql new file mode 100644 index 0000000..f5b9a3e --- /dev/null +++ b/supabase/migrations/20260518002100_topup_wallet_per_user_idempotency.sql @@ -0,0 +1,100 @@ +-- ────────────────────────────────────────────────────────────────────────── +-- Scope top-up idempotency to the recipient wallet (PR #16 codeant re-review) +-- +-- The previous UNIQUE index and function lookups keyed on reference_id only, +-- meaning a UUID collision across two different users could suppress one of +-- their top-ups. v4 UUIDs make this statistically impossible, but the bot is +-- right that the data model should enforce per-user scoping as defense in +-- depth. Both the unique constraint and the function's two lookup paths now +-- include the recipient wallet (to_wallet = auth.uid()). +-- ────────────────────────────────────────────────────────────────────────── + +-- 1. Replace global unique index with per-recipient unique index. +drop index if exists public.idx_escrow_ledger_reference_id; + +create unique index if not exists idx_escrow_ledger_topup_owner_reference + on public.escrow_ledger (to_wallet, reference_id) + where type = 'topup'; + +-- 2. Update topup_wallet to include caller-ownership predicate in both +-- idempotency lookups (pre-check + unique_violation recovery). +create or replace function public.topup_wallet( + p_amount numeric, + p_idempotency_key uuid +) +returns table (available_balance numeric, ledger_id uuid) +language plpgsql +security definer +set search_path = public +as $$ +declare + v_profile_id uuid := auth.uid(); + v_ledger_id uuid; + v_available numeric; +begin + if v_profile_id is null then + raise exception 'not_authenticated' using errcode = '42501'; + end if; + + if p_amount is null or p_amount < 100 or p_amount > 100000 then + raise exception 'invalid_amount' using errcode = '22023'; + end if; + + if p_idempotency_key is null then + raise exception 'invalid_idempotency_key' using errcode = '22023'; + end if; + + insert into public.wallets (profile_id) + values (v_profile_id) + on conflict (profile_id) do nothing; + + -- Fast path: idempotency hit, scoped to the calling wallet. + select id into v_ledger_id + from public.escrow_ledger + where reference_id = p_idempotency_key + and type = 'topup' + and to_wallet = v_profile_id + limit 1; + + if found then + select w.available_balance into v_available + from public.wallets w + where w.profile_id = v_profile_id; + return query select v_available, v_ledger_id; + return; + end if; + + -- INSERT first; per-recipient UNIQUE index serializes concurrent calls. + begin + insert into public.escrow_ledger ( + job_id, milestone_id, from_wallet, to_wallet, amount, type, reference_id + ) + values ( + null, null, null, v_profile_id, p_amount, 'topup', p_idempotency_key + ) + returning id into v_ledger_id; + exception when unique_violation then + -- Re-read the winning row, scoped to the calling wallet. + select id into v_ledger_id + from public.escrow_ledger + where reference_id = p_idempotency_key + and type = 'topup' + and to_wallet = v_profile_id + limit 1; + select w.available_balance into v_available + from public.wallets w + where w.profile_id = v_profile_id; + return query select v_available, v_ledger_id; + return; + end; + + update public.wallets + set available_balance = available_balance + p_amount + where profile_id = v_profile_id + returning available_balance into v_available; + + return query select v_available, v_ledger_id; +end; +$$; + +grant execute on function public.topup_wallet(numeric, uuid) to authenticated; \ No newline at end of file