first commit
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
-- Fix free-tier daily token reset.
|
||||
--
|
||||
-- Bug 1: reset_free_tier_tokens() set expires_at = now() + 1 day. Because pg_cron
|
||||
-- fires with small execution jitter, each day's stored expires_at crept
|
||||
-- slightly ahead of the next cron's now(), and the gate
|
||||
-- "expires_at < now()" made the cron skip its own prior resets.
|
||||
--
|
||||
-- Bug 2: user_extradata() and deduct_tokens() zero the displayed balance the
|
||||
-- instant expires_at < now(). So any window between expiry and the next
|
||||
-- (possibly skipped) cron caused users to see "You've used all your tokens"
|
||||
-- with their real balance still sitting unchanged in the DB.
|
||||
--
|
||||
-- Fix: add a just-in-time reset on every read/deduct. The cron stays as a
|
||||
-- backstop and is rewritten to pin expires_at to the next UTC day boundary so
|
||||
-- it no longer drifts or skips.
|
||||
|
||||
CREATE OR REPLACE FUNCTION "public"."ensure_free_tier_fresh"("p_user_id" uuid)
|
||||
RETURNS void
|
||||
LANGUAGE "plpgsql"
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_is_free boolean;
|
||||
v_caller uuid;
|
||||
BEGIN
|
||||
-- Only allow service role (server-side edge functions) or the user acting
|
||||
-- on themselves. auth.role() = 'anon' with uid = NULL would otherwise slip
|
||||
-- past a uid-only check. Silent return, not an exception, so that a
|
||||
-- legitimate caller's outer flow (user_extradata, deduct_tokens) continues
|
||||
-- on its own fallback path rather than erroring to the client.
|
||||
v_caller := auth.uid();
|
||||
IF auth.role() IS DISTINCT FROM 'service_role' AND v_caller IS DISTINCT FROM p_user_id THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
SELECT NOT EXISTS (
|
||||
SELECT 1 FROM public.subscriptions
|
||||
WHERE user_id = p_user_id
|
||||
AND status IN ('active', 'trialing')
|
||||
) INTO v_is_free;
|
||||
|
||||
IF NOT v_is_free THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.token_balances (user_id, source, balance, expires_at, updated_at)
|
||||
VALUES (
|
||||
p_user_id,
|
||||
'subscription',
|
||||
50,
|
||||
date_trunc('day', now()) + interval '1 day',
|
||||
now()
|
||||
)
|
||||
ON CONFLICT (user_id, source) DO UPDATE
|
||||
SET balance = 50,
|
||||
expires_at = date_trunc('day', now()) + interval '1 day',
|
||||
updated_at = now()
|
||||
WHERE token_balances.expires_at IS NULL
|
||||
OR token_balances.expires_at <= now();
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION "public"."deduct_tokens"(
|
||||
"p_user_id" uuid,
|
||||
"p_operation" "public"."token_operation_type",
|
||||
"p_reference_id" text DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE "plpgsql"
|
||||
AS $$
|
||||
DECLARE
|
||||
v_cost integer;
|
||||
v_sub_balance integer;
|
||||
v_pur_balance integer;
|
||||
v_sub_expires timestamptz;
|
||||
v_remaining integer;
|
||||
v_sub_deduct integer;
|
||||
v_pur_deduct integer;
|
||||
BEGIN
|
||||
SELECT cost INTO v_cost FROM public.token_costs WHERE operation = p_operation;
|
||||
IF v_cost IS NULL THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'unknown_operation');
|
||||
END IF;
|
||||
|
||||
PERFORM public.ensure_free_tier_fresh(p_user_id);
|
||||
|
||||
SELECT balance, expires_at INTO v_sub_balance, v_sub_expires
|
||||
FROM public.token_balances
|
||||
WHERE user_id = p_user_id AND source = 'subscription'
|
||||
FOR UPDATE;
|
||||
|
||||
-- Expired subscription tokens count as 0. For free-tier users this is
|
||||
-- unreachable after ensure_free_tier_fresh (expires_at is always future).
|
||||
-- It is load-bearing for paid users whose billing-cycle sub has lapsed,
|
||||
-- and for the edge case where ensure_free_tier_fresh silently bailed
|
||||
-- (auth-mismatch). Don't remove without preserving both cases.
|
||||
IF v_sub_expires IS NOT NULL AND v_sub_expires < now() THEN
|
||||
v_sub_balance := 0;
|
||||
END IF;
|
||||
|
||||
SELECT balance INTO v_pur_balance
|
||||
FROM public.token_balances
|
||||
WHERE user_id = p_user_id AND source = 'purchased'
|
||||
FOR UPDATE;
|
||||
|
||||
v_sub_balance := COALESCE(v_sub_balance, 0);
|
||||
v_pur_balance := COALESCE(v_pur_balance, 0);
|
||||
|
||||
IF (v_sub_balance + v_pur_balance) < v_cost THEN
|
||||
RETURN jsonb_build_object(
|
||||
'success', false,
|
||||
'error', 'insufficient_tokens',
|
||||
'tokensRequired', v_cost,
|
||||
'tokensAvailable', v_sub_balance + v_pur_balance
|
||||
);
|
||||
END IF;
|
||||
|
||||
v_sub_deduct := LEAST(v_cost, v_sub_balance);
|
||||
v_pur_deduct := v_cost - v_sub_deduct;
|
||||
|
||||
IF v_sub_deduct > 0 THEN
|
||||
UPDATE public.token_balances
|
||||
SET balance = balance - v_sub_deduct, updated_at = now()
|
||||
WHERE user_id = p_user_id AND source = 'subscription';
|
||||
END IF;
|
||||
|
||||
IF v_pur_deduct > 0 THEN
|
||||
UPDATE public.token_balances
|
||||
SET balance = balance - v_pur_deduct, updated_at = now()
|
||||
WHERE user_id = p_user_id AND source = 'purchased';
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.token_transactions (
|
||||
user_id, operation, amount, source, reference_id,
|
||||
subscription_balance_after, purchased_balance_after
|
||||
) VALUES (
|
||||
p_user_id, p_operation, -v_cost,
|
||||
CASE WHEN v_sub_deduct > 0 THEN 'subscription'::public.token_source_type ELSE 'purchased'::public.token_source_type END,
|
||||
p_reference_id,
|
||||
v_sub_balance - v_sub_deduct,
|
||||
v_pur_balance - v_pur_deduct
|
||||
);
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'success', true,
|
||||
'tokensDeducted', v_cost,
|
||||
'subscriptionBalance', v_sub_balance - v_sub_deduct,
|
||||
'purchasedBalance', v_pur_balance - v_pur_deduct,
|
||||
'totalBalance', (v_sub_balance - v_sub_deduct) + (v_pur_balance - v_pur_deduct)
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION "public"."reset_free_tier_tokens"()
|
||||
RETURNS void
|
||||
LANGUAGE "plpgsql"
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE public.token_balances tb
|
||||
SET balance = 50,
|
||||
expires_at = date_trunc('day', now()) + interval '1 day',
|
||||
updated_at = now()
|
||||
WHERE tb.source = 'subscription'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.subscriptions s
|
||||
WHERE s.user_id = tb.user_id
|
||||
AND s.status IN ('active', 'trialing')
|
||||
)
|
||||
-- Idempotency guard: don't re-credit users whose period is still live.
|
||||
-- With expires_at pinned to the day boundary, the scheduled midnight run
|
||||
-- satisfies `<=` exactly; manual or retried mid-day runs are safely no-ops.
|
||||
AND (tb.expires_at IS NULL OR tb.expires_at <= now());
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION "public"."user_extradata"("user_id_input" "uuid")
|
||||
RETURNS "public"."user_data"
|
||||
LANGUAGE "plpgsql"
|
||||
AS $$
|
||||
DECLARE
|
||||
hasTrialed boolean;
|
||||
userlevel public.subscriptions.level%TYPE;
|
||||
userstatus public.subscriptions.status%TYPE;
|
||||
v_sub_balance integer;
|
||||
v_pur_balance integer;
|
||||
v_sub_expires timestamptz;
|
||||
v_sub_limit integer;
|
||||
ret user_data;
|
||||
BEGIN
|
||||
PERFORM public.ensure_free_tier_fresh(user_id_input);
|
||||
|
||||
SELECT (
|
||||
(SELECT count(*) FROM public.trial_users WHERE user_id = user_id_input) > 0
|
||||
) INTO hasTrialed;
|
||||
|
||||
SELECT STATUS, LEVEL INTO userstatus, userlevel
|
||||
FROM public.subscriptions
|
||||
WHERE user_id = user_id_input;
|
||||
|
||||
SELECT balance, expires_at INTO v_sub_balance, v_sub_expires
|
||||
FROM public.token_balances
|
||||
WHERE user_id = user_id_input AND source = 'subscription';
|
||||
|
||||
SELECT balance INTO v_pur_balance
|
||||
FROM public.token_balances
|
||||
WHERE user_id = user_id_input AND source = 'purchased';
|
||||
|
||||
v_sub_balance := COALESCE(v_sub_balance, 0);
|
||||
v_pur_balance := COALESCE(v_pur_balance, 0);
|
||||
|
||||
-- See matching comment in deduct_tokens: this branch is load-bearing for
|
||||
-- paid users whose billing-cycle sub has lapsed and as a fallback if
|
||||
-- ensure_free_tier_fresh silently bailed. Do not remove.
|
||||
IF v_sub_expires IS NOT NULL AND v_sub_expires < now() THEN
|
||||
v_sub_balance := 0;
|
||||
END IF;
|
||||
|
||||
ret."hasTrialed" = hasTrialed;
|
||||
|
||||
IF (userstatus = 'active') THEN
|
||||
ret."sublevel" = userlevel;
|
||||
ELSIF (userstatus = 'trialing') THEN
|
||||
ret."sublevel" = 'pro';
|
||||
ELSE
|
||||
ret."sublevel" = 'free';
|
||||
END IF;
|
||||
|
||||
v_sub_limit := public.get_subscription_token_limit(user_id_input);
|
||||
|
||||
ret."subscriptionTokens" = v_sub_balance;
|
||||
ret."purchasedTokens" = v_pur_balance;
|
||||
ret."totalTokens" = v_sub_balance + v_pur_balance;
|
||||
ret."subscriptionTokenLimit" = v_sub_limit;
|
||||
ret."subscriptionExpiresAt" = v_sub_expires;
|
||||
|
||||
RETURN ret;
|
||||
|
||||
EXCEPTION
|
||||
WHEN others THEN
|
||||
RAISE EXCEPTION 'An error occurred in function user_extradata(): %', SQLERRM;
|
||||
END;
|
||||
$$;
|
||||
Reference in New Issue
Block a user