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
26 changes: 12 additions & 14 deletions lib/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -807,23 +807,21 @@ export async function getCredits(userId: string) {

export async function deductCredit(userId: string, feature: FeatureKey): Promise<boolean> {
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 <col> > 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) {
Expand Down
41 changes: 41 additions & 0 deletions supabase/migration-atomic-deduct-credit.sql
Original file line number Diff line number Diff line change
@@ -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 <col> > 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;
$$;
Loading