diff --git a/lib/database.ts b/lib/database.ts index dc24a0e..6acd84b 100644 --- a/lib/database.ts +++ b/lib/database.ts @@ -807,23 +807,21 @@ export async function getCredits(userId: string) { export async function deductCredit(userId: string, feature: FeatureKey): Promise { const admin = await getSupabaseAdmin() - const credits = await getCredits(userId) - if (!credits) return false + // Atomic, conditional decrement via the deduct_credit() SQL function. + // The DB-side WHERE > 0 guard makes concurrent calls on the last credit + // resolve to exactly one success and one failure (no read-then-write race). + const { data, error } = await admin.rpc("deduct_credit", { + p_user_id: userId, + p_feature: feature, + }) - const remainingKey = `${feature}_remaining` as keyof Credits - const remaining = Number(credits[remainingKey] ?? 0) - - if (remaining <= 0) return false - - const { error } = await admin - .from("credits") - .update({ - [remainingKey]: remaining - 1, - }) - .eq("user_id", userId) + if (error) { + console.error("[deductCredit] RPC error:", error.message) + return false + } - return !error + return data === true } export async function refillCreditsForTrial(userId: string) { diff --git a/supabase/migration-atomic-deduct-credit.sql b/supabase/migration-atomic-deduct-credit.sql new file mode 100644 index 0000000..d343c5d --- /dev/null +++ b/supabase/migration-atomic-deduct-credit.sql @@ -0,0 +1,41 @@ +-- Atomic, conditional credit decrement. +-- +-- Replaces the previous read-then-write in deductCredit(), which let two +-- concurrent requests for the same user both read the same remaining value +-- (e.g. 1) and each write 0, permitting two operations on a single credit. +-- +-- The conditional UPDATE (... WHERE > 0) is evaluated atomically under +-- row-level locking: when two calls race on the last credit, the first commits +-- the decrement to 0 and the second re-checks the WHERE against the updated row, +-- matches nothing, and reports 0 affected rows -> returns FALSE. +CREATE OR REPLACE FUNCTION public.deduct_credit(p_user_id UUID, p_feature TEXT) +RETURNS BOOLEAN +LANGUAGE plpgsql +AS $$ +DECLARE + v_updated INTEGER; +BEGIN + IF p_feature = 'ai_chat' THEN + UPDATE public.credits + SET ai_chat_remaining = ai_chat_remaining - 1, + updated_at = NOW() + WHERE user_id = p_user_id AND ai_chat_remaining > 0; + ELSIF p_feature = 'flashcards' THEN + UPDATE public.credits + SET flashcards_remaining = flashcards_remaining - 1, + updated_at = NOW() + WHERE user_id = p_user_id AND flashcards_remaining > 0; + ELSIF p_feature = 'study_plan' THEN + UPDATE public.credits + SET study_plan_remaining = study_plan_remaining - 1, + updated_at = NOW() + WHERE user_id = p_user_id AND study_plan_remaining > 0; + ELSE + -- Unknown feature key: nothing to deduct. + RETURN FALSE; + END IF; + + GET DIAGNOSTICS v_updated = ROW_COUNT; + RETURN v_updated > 0; +END; +$$;