fix(quota): spend overage credits against the weekly window and count 7 days, not 8
This commit is contained in:
parent
f4f9653361
commit
102f3ef934
5 changed files with 601 additions and 65 deletions
|
|
@ -0,0 +1,132 @@
|
|||
-- ============================================================================
|
||||
-- consume_quota: period-aware base/overage consumption
|
||||
-- ============================================================================
|
||||
-- The original consume_quota (20260412000001) compared only TODAY's
|
||||
-- daily_usage row with the base limit. For weekly policies (free tier:
|
||||
-- llm_haiku 250/week) that meant a user whose 7-day total had already reached
|
||||
-- the limit kept taking the 'base' branch as long as today's own count stayed
|
||||
-- under 250, so an overage credit (e.g. one rewarded ad) let every call through
|
||||
-- without ever being deducted.
|
||||
--
|
||||
-- This version takes the policy period and sums the same window the other SQL
|
||||
-- quota functions use (reserve_stt_quota, meeting-document generation):
|
||||
-- weekly -> CURRENT_DATE - 6 .. CURRENT_DATE (7 calendar days)
|
||||
-- daily -> CURRENT_DATE
|
||||
-- and chooses base vs overage from that window total, under a per-user/feature
|
||||
-- advisory lock plus the subscription row lock so concurrent calls cannot both
|
||||
-- take the last base unit or the last credit.
|
||||
--
|
||||
-- Additional corrections:
|
||||
-- * limit 0 ("not available") is denied instead of spending overage credits.
|
||||
-- * the overage branch only succeeds when a credit is actually left
|
||||
-- (`overage_credits > 0` in the UPDATE), so a race can never go negative.
|
||||
-- * 'current' is the window total after this call (equal to today's count
|
||||
-- for daily policies), matching what checkQuota reports.
|
||||
--
|
||||
-- p_period defaults to 'daily' so an edge function still calling the old
|
||||
-- three-argument form keeps its previous (daily) behaviour until redeployed.
|
||||
-- Apply this migration before deploying the quota.ts that passes p_period.
|
||||
|
||||
DROP FUNCTION IF EXISTS public.consume_quota(uuid, text, integer);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.consume_quota(
|
||||
p_user_id uuid,
|
||||
p_feature text,
|
||||
p_base_limit integer,
|
||||
p_period text DEFAULT 'daily'
|
||||
) RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public, pg_temp
|
||||
AS $$
|
||||
DECLARE
|
||||
v_window_start date;
|
||||
v_current integer := 0;
|
||||
v_overage integer := 0;
|
||||
v_new_overage integer;
|
||||
v_consumed_from text;
|
||||
BEGIN
|
||||
IF p_user_id IS NULL
|
||||
OR p_feature IS NULL
|
||||
OR p_base_limit IS NULL
|
||||
OR p_base_limit < -1
|
||||
OR p_period IS NULL
|
||||
OR p_period NOT IN ('daily', 'weekly') THEN
|
||||
RAISE EXCEPTION 'invalid_quota_consumption' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
-- Serialise read-then-increment for this user/feature.
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended(p_user_id::text || ':' || p_feature, 20260928));
|
||||
|
||||
SELECT coalesce(overage_credits, 0) INTO v_overage
|
||||
FROM public.subscriptions
|
||||
WHERE user_id = p_user_id
|
||||
FOR UPDATE;
|
||||
v_overage := coalesce(v_overage, 0);
|
||||
|
||||
-- Not available: never spend credits on a feature the tier does not include.
|
||||
IF p_base_limit = 0 THEN
|
||||
RETURN jsonb_build_object(
|
||||
'allowed', false,
|
||||
'current', 0,
|
||||
'limit', 0,
|
||||
'overage_credits', v_overage,
|
||||
'consumed_from', 'none'
|
||||
);
|
||||
END IF;
|
||||
|
||||
v_window_start := CASE WHEN p_period = 'weekly' THEN CURRENT_DATE - 6 ELSE CURRENT_DATE END;
|
||||
|
||||
SELECT coalesce(sum(count), 0)::integer INTO v_current
|
||||
FROM public.daily_usage
|
||||
WHERE user_id = p_user_id
|
||||
AND feature = p_feature
|
||||
AND date >= v_window_start
|
||||
AND date <= CURRENT_DATE;
|
||||
|
||||
IF p_base_limit = -1 THEN
|
||||
v_consumed_from := 'unlimited';
|
||||
ELSIF v_current < p_base_limit THEN
|
||||
v_consumed_from := 'base';
|
||||
ELSE
|
||||
UPDATE public.subscriptions
|
||||
SET overage_credits = overage_credits - 1,
|
||||
updated_at = now()
|
||||
WHERE user_id = p_user_id
|
||||
AND overage_credits > 0
|
||||
RETURNING overage_credits INTO v_new_overage;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object(
|
||||
'allowed', false,
|
||||
'current', v_current,
|
||||
'limit', p_base_limit,
|
||||
'overage_credits', 0,
|
||||
'consumed_from', 'none'
|
||||
);
|
||||
END IF;
|
||||
|
||||
v_overage := v_new_overage;
|
||||
v_consumed_from := 'overage';
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.daily_usage (user_id, date, feature, count)
|
||||
VALUES (p_user_id, CURRENT_DATE, p_feature, 1)
|
||||
ON CONFLICT (user_id, date, feature) DO UPDATE
|
||||
SET count = public.daily_usage.count + 1;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'allowed', true,
|
||||
'current', v_current + 1,
|
||||
'limit', p_base_limit,
|
||||
'overage_credits', v_overage,
|
||||
'consumed_from', v_consumed_from
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.consume_quota(uuid, text, integer, text) IS
|
||||
'Atomic quota consumption over the policy window (daily = today, weekly = CURRENT_DATE-6..CURRENT_DATE): base first, then one overage credit. Used by llm-proxy and realtime-token.';
|
||||
|
||||
REVOKE ALL ON FUNCTION public.consume_quota(uuid, text, integer, text) FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.consume_quota(uuid, text, integer, text) TO service_role;
|
||||
Loading…
Add table
Add a link
Reference in a new issue