diff --git a/src/app/_actions/wallet.ts b/src/app/_actions/wallet.ts new file mode 100644 index 0000000..9c38078 --- /dev/null +++ b/src/app/_actions/wallet.ts @@ -0,0 +1,69 @@ +"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 }; + } +} diff --git a/src/components/features/topup-dialog.tsx b/src/components/features/topup-dialog.tsx new file mode 100644 index 0000000..71d6d7d --- /dev/null +++ b/src/components/features/topup-dialog.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { formatInr } from "@/lib/format"; +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 ${formatInr(amount)}. New balance: ${formatInr(newBalance)}`); + 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) => ( + + ))} +
+
+ + + + + +
+
+ + ); +} diff --git a/src/components/features/wallet-view.tsx b/src/components/features/wallet-view.tsx index 53cf194..65f6229 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 ───────────────────────────────────────────────────────────────────── @@ -45,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), @@ -155,21 +158,21 @@ export function WalletView({ role }: { role: "client" | "worker" }) { Locked

-

- {formatInr(wallet.locked_balance)} -

+

{formatInr(wallet.locked_balance)}

In active escrows

+
+ +
+ {/* ── Locked breakdown (client only) ── */} {role === "client" && lockedByJob.length > 0 && ( <>
-

- Locked Breakdown -

+

Locked Breakdown

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

    - Recent Transactions -

    +

    Recent Transactions

    {ledger.length === 0 ? (
    No transactions yet. @@ -232,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)} diff --git a/src/lib/schemas/wallet.ts b/src/lib/schemas/wallet.ts new file mode 100644 index 0000000..7ea0ba2 --- /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; 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 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 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