From ff3f40414a59895504784132ec68a0877818c4d3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 11:38:04 +0000 Subject: [PATCH 01/18] fix(email): re-register billing_period_stats cron and correct period math The billing period stats processor existed but was never added to cron_tasks after the cron refactor, so org:billing_period_stats was not queued. Register the daily 12:00 UTC job, restore service_role execute, and align metrics/credits to the half-open billing cycle. Co-authored-by: Martin DONADIEU --- .../functions/_backend/triggers/cron_email.ts | 103 ++++++-- ...gister_billing_period_stats_email_cron.sql | 131 ++++++++++ .../63_test_billing_period_stats_email.sql | 224 ++++++++++++++++++ tests/billing-period-stats.unit.test.ts | 25 ++ 4 files changed, 459 insertions(+), 24 deletions(-) create mode 100644 supabase/migrations/20260725112951_register_billing_period_stats_email_cron.sql create mode 100644 supabase/tests/63_test_billing_period_stats_email.sql create mode 100644 tests/billing-period-stats.unit.test.ts diff --git a/supabase/functions/_backend/triggers/cron_email.ts b/supabase/functions/_backend/triggers/cron_email.ts index 8b9546c0d0..0c45968763 100644 --- a/supabase/functions/_backend/triggers/cron_email.ts +++ b/supabase/functions/_backend/triggers/cron_email.ts @@ -378,6 +378,42 @@ function formatNumber(num: number): string { return num.toLocaleString('en-US') } +/** Calendar date (YYYY-MM-DD) from a timestamptz/ISO string without TZ day-shift. */ +function toDateOnlyUtc(value: string): string { + const match = /^(\d{4}-\d{2}-\d{2})/.exec(value) + if (match) + return match[1] + return new Date(value).toISOString().slice(0, 10) +} + +/** Shift a YYYY-MM-DD date by N days in UTC. */ +function addDaysToDateOnly(dateOnly: string, days: number): string { + const date = new Date(`${dateOnly}T00:00:00.000Z`) + date.setUTCDate(date.getUTCDate() + days) + return date.toISOString().slice(0, 10) +} + +/** + * Billing cycles are half-open [start, end). + * get_total_metrics uses inclusive BETWEEN, so the metrics end date is end - 1 day. + */ +function billingPeriodMetricsRange(cycleStart: string, cycleEnd: string): { + periodStart: string + periodEndExclusive: string + metricsEndInclusive: string +} { + const periodStart = toDateOnlyUtc(cycleStart) + const periodEndExclusive = toDateOnlyUtc(cycleEnd) + const metricsEndInclusive = addDaysToDateOnly(periodEndExclusive, -1) + return { periodStart, periodEndExclusive, metricsEndInclusive } +} + +export const billingPeriodStatsTestUtils = { + toDateOnlyUtc, + addDaysToDateOnly, + billingPeriodMetricsRange, +} + async function handleBillingPeriodStats(c: Context, _email: string, orgId: string, cycleStart?: string, cycleEnd?: string) { const supabase = await supabaseAdmin(c) @@ -394,16 +430,16 @@ async function handleBillingPeriodStats(c: Context, _email: string, orgId: strin // Use cycle dates passed from the SQL function if available, // otherwise fall back to get_cycle_info_org (for backwards compatibility) - let startDate: string - let endDate: string + let periodStart: string + let periodEndExclusive: string + let metricsEndInclusive: string if (cycleStart && cycleEnd) { - // Use dates passed from the SQL function (guaranteed to be the completed billing period) - startDate = new Date(cycleStart).toISOString().split('T')[0] - endDate = new Date(cycleEnd).toISOString().split('T')[0] + // Completed billing period from SQL: [cycleStart, cycleEnd) + ;({ periodStart, periodEndExclusive, metricsEndInclusive } = billingPeriodMetricsRange(cycleStart, cycleEnd)) } else { - // Fallback: get cycle info from RPC + // Fallback: get cycle info from RPC (current cycle, same half-open bounds) const { data: cycleInfo, error: cycleError } = await supabase .rpc('get_cycle_info_org', { orgid: orgId }) .single() @@ -413,16 +449,27 @@ async function handleBillingPeriodStats(c: Context, _email: string, orgId: strin throw simpleError('cannot_get_cycle_info', 'Cannot get cycle info', { error: cycleError }) } - startDate = new Date(cycleInfo.subscription_anchor_start).toISOString().split('T')[0] - endDate = new Date(cycleInfo.subscription_anchor_end).toISOString().split('T')[0] + ;({ periodStart, periodEndExclusive, metricsEndInclusive } = billingPeriodMetricsRange( + cycleInfo.subscription_anchor_start, + cycleInfo.subscription_anchor_end, + )) } - // Get total metrics for the billing period + // Guard against inverted/empty ranges (e.g. bad payloads) + if (metricsEndInclusive < periodStart) { + throw simpleError('invalid_billing_period', 'Invalid billing period dates', { + periodStart, + periodEndExclusive, + metricsEndInclusive, + }) + } + + // Get total metrics for the completed billing period days const { data: metrics, error: metricsError } = await supabase .rpc('get_total_metrics', { org_id: orgId, - start_date: startDate, - end_date: endDate, + start_date: periodStart, + end_date: metricsEndInclusive, }) .single() @@ -431,17 +478,22 @@ async function handleBillingPeriodStats(c: Context, _email: string, orgId: strin throw simpleError('cannot_get_metrics', 'Cannot get metrics', { error: metricsError }) } - // Get credits used in the billing period + // Sum credits in [periodStart, periodEndExclusive) without PostgREST row limits let creditsUsed = 0 - const { data: credits } = await supabase - .from('usage_credit_consumptions') - .select('credits_used') - .eq('org_id', orgId) - .gte('applied_at', startDate) - .lt('applied_at', endDate) - - if (credits) { - creditsUsed = credits.reduce((sum, row) => sum + Number(row.credits_used || 0), 0) + const { data: creditsSum, error: creditsError } = await (supabase.rpc as any)( + 'get_org_credits_used_in_period', + { + p_org_id: orgId, + p_start: `${periodStart}T00:00:00.000Z`, + p_end: `${periodEndExclusive}T00:00:00.000Z`, + }, + ) + + if (creditsError) { + cloudlogErr({ requestId: c.get('requestId'), message: 'Cannot get credits used', error: creditsError, metadata: { orgId } }) + } + else { + creditsUsed = Number(creditsSum || 0) } // Format the metrics for the email @@ -526,15 +578,17 @@ async function handleBillingPeriodStats(c: Context, _email: string, orgId: strin monthly_active_users: formatNumber(mau), bandwidth_used: formatBytes(bandwidth), storage_used: formatBytes(storage), + build_time_used: formatNumber(buildTimeUnit), credits_used: formatNumber(Math.round(creditsUsed * 100) / 100), // Raw values for potential use in email templates mau_raw: mau.toString(), bandwidth_raw: bandwidth.toString(), storage_raw: storage.toString(), + build_time_raw: buildTimeUnit.toString(), credits_raw: creditsUsed.toString(), - // Include period dates for context - period_start: startDate, - period_end: endDate, + // Include period dates for context (half-open [start, end)) + period_start: periodStart, + period_end: periodEndExclusive, // Plan information current_plan: currentPlanName, recommended_plan: recommendedPlan, @@ -547,6 +601,7 @@ async function handleBillingPeriodStats(c: Context, _email: string, orgId: strin mau_percent: mauPercent.toString(), bandwidth_percent: bandwidthPercent.toString(), storage_percent: storagePercent.toString(), + build_time_percent: buildTimePercent.toString(), max_usage_percent: maxUsagePercent.toString(), } diff --git a/supabase/migrations/20260725112951_register_billing_period_stats_email_cron.sql b/supabase/migrations/20260725112951_register_billing_period_stats_email_cron.sql new file mode 100644 index 0000000000..bef2074af4 --- /dev/null +++ b/supabase/migrations/20260725112951_register_billing_period_stats_email_cron.sql @@ -0,0 +1,131 @@ +-- Re-register billing period stats email after it was dropped from the cron path. +-- Original feature (#1335) called process_billing_period_stats_email() from +-- process_all_cron_tasks at 12:00 UTC. The cron_tasks table refactor never added +-- a row for it, so org:billing_period_stats was never queued. + +CREATE OR REPLACE FUNCTION public.get_org_credits_used_in_period( + p_org_id uuid, + p_start timestamptz, + p_end timestamptz +) +RETURNS numeric +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ + SELECT COALESCE(SUM(c.credits_used), 0)::numeric + FROM public.usage_credit_consumptions c + WHERE c.org_id = p_org_id + AND c.applied_at >= p_start + AND c.applied_at < p_end; +$$; + +ALTER FUNCTION public.get_org_credits_used_in_period(uuid, timestamptz, timestamptz) OWNER TO postgres; +REVOKE ALL ON FUNCTION public.get_org_credits_used_in_period(uuid, timestamptz, timestamptz) FROM PUBLIC; +GRANT ALL ON FUNCTION public.get_org_credits_used_in_period(uuid, timestamptz, timestamptz) TO service_role; + +CREATE OR REPLACE FUNCTION public.process_billing_period_stats_email() +RETURNS void +LANGUAGE plpgsql +SET search_path = '' +AS $$ +DECLARE + org_record RECORD; + v_anchor_day interval; + v_anniversary date; + v_prev_cycle_start timestamptz; + v_prev_cycle_end timestamptz; +BEGIN + -- Find orgs whose billing anniversary is today and queue an email for the + -- just-completed cycle [anniversary - 1 month, anniversary). + FOR org_record IN ( + SELECT + o.id AS org_id, + o.management_email, + COALESCE( + si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), + '0 DAYS'::interval + ) AS anchor_day + FROM public.orgs o + JOIN public.stripe_info si ON o.customer_id = si.customer_id + WHERE si.status = 'succeeded' + AND o.management_email IS NOT NULL + ) + LOOP + v_anchor_day := org_record.anchor_day; + v_anniversary := (date_trunc('MONTH', now()) + v_anchor_day)::date; + + IF v_anniversary = CURRENT_DATE THEN + v_prev_cycle_start := date_trunc('MONTH', now() - interval '1 month') + v_anchor_day; + v_prev_cycle_end := date_trunc('MONTH', now()) + v_anchor_day; + + PERFORM pgmq.send( + 'cron_email', + jsonb_build_object( + 'function_name', 'cron_email', + 'function_type', 'cloudflare', + 'payload', jsonb_build_object( + 'email', org_record.management_email, + 'orgId', org_record.org_id, + 'type', 'billing_period_stats', + 'cycleStart', v_prev_cycle_start, + 'cycleEnd', v_prev_cycle_end + ) + ) + ); + END IF; + END LOOP; +END; +$$; + +ALTER FUNCTION public.process_billing_period_stats_email() OWNER TO postgres; + +REVOKE ALL ON FUNCTION public.process_billing_period_stats_email() FROM PUBLIC; +GRANT ALL ON FUNCTION public.process_billing_period_stats_email() TO service_role; + +INSERT INTO public.cron_tasks ( + name, + description, + task_type, + target, + batch_size, + payload, + second_interval, + minute_interval, + hour_interval, + run_at_hour, + run_at_minute, + run_at_second, + run_on_dow, + run_on_day, + enabled, + healthcheck_url +) VALUES ( + 'billing_period_stats_email', + 'Send billing period stats emails on each org billing anniversary (12:00 UTC)', + 'function', + 'public.process_billing_period_stats_email()', + NULL, + NULL, + NULL, + NULL, + NULL, + 12, + 0, + 0, + NULL, + NULL, + true, + NULL +) +ON CONFLICT (name) DO UPDATE +SET + description = EXCLUDED.description, + task_type = EXCLUDED.task_type, + target = EXCLUDED.target, + run_at_hour = EXCLUDED.run_at_hour, + run_at_minute = EXCLUDED.run_at_minute, + run_at_second = EXCLUDED.run_at_second, + enabled = EXCLUDED.enabled, + updated_at = now(); diff --git a/supabase/tests/63_test_billing_period_stats_email.sql b/supabase/tests/63_test_billing_period_stats_email.sql new file mode 100644 index 0000000000..b77a98e249 --- /dev/null +++ b/supabase/tests/63_test_billing_period_stats_email.sql @@ -0,0 +1,224 @@ +-- 63_test_billing_period_stats_email.sql +-- Ensures billing_period_stats is registered in cron_tasks and queues on anniversary day. +BEGIN; + +SELECT plan(9); + +SELECT tests.authenticate_as_service_role(); + +SELECT ok( + to_regprocedure('public.process_billing_period_stats_email()') IS NOT NULL, + 'process_billing_period_stats_email exists' +); + +SELECT ok( + to_regprocedure('public.get_org_credits_used_in_period(uuid, timestamptz, timestamptz)') IS NOT NULL, + 'get_org_credits_used_in_period exists' +); + +SELECT is( + has_function_privilege( + 'anon', + 'public.process_billing_period_stats_email()', + 'EXECUTE' + ), + FALSE, + 'anon cannot execute process_billing_period_stats_email' +); + +SELECT is( + has_function_privilege( + 'authenticated', + 'public.process_billing_period_stats_email()', + 'EXECUTE' + ), + FALSE, + 'authenticated cannot execute process_billing_period_stats_email' +); + +SELECT is( + has_function_privilege( + 'service_role', + 'public.process_billing_period_stats_email()', + 'EXECUTE' + ), + TRUE, + 'service_role can execute process_billing_period_stats_email' +); + +SELECT ok( + ( + SELECT count(*)::int + FROM public.cron_tasks + WHERE + name = 'billing_period_stats_email' + AND enabled = TRUE + AND task_type = 'function'::public.cron_task_type + AND target = 'public.process_billing_period_stats_email()' + AND run_at_hour = 12 + AND run_at_minute = 0 + ) = 1, + 'cron_tasks contains daily billing_period_stats_email at 12:00 UTC' +); + +CREATE TEMP TABLE billing_period_stats_context ( + user_id uuid, + org_id uuid, + customer_id text, + app_id text +) ON COMMIT DROP; + +DO $$ +BEGIN + PERFORM tests.create_supabase_user( + 'billing_period_stats_user', + 'billing-period-stats@example.com', + '555-000-0063' + ); +END; +$$ LANGUAGE plpgsql; + +INSERT INTO billing_period_stats_context (user_id, org_id, customer_id, app_id) +VALUES ( + tests.get_supabase_uid('billing_period_stats_user'), + gen_random_uuid(), + 'cus_billing_period_stats_test', + 'com.test.billingperiodstats.app' +); + +INSERT INTO public.users (id, email, created_at, updated_at) +SELECT + user_id, + 'billing-period-stats@example.com', + now(), + now() +FROM billing_period_stats_context; + +-- Anchor day = today so the processor should queue an email +INSERT INTO public.stripe_info ( + customer_id, + status, + product_id, + subscription_id, + subscription_anchor_start, + subscription_anchor_end +) +SELECT + customer_id, + 'succeeded', + 'prod_LQIregjtNduh4q', + 'sub_billing_period_stats_test', + date_trunc('month', now()) + ((EXTRACT(DAY FROM CURRENT_DATE)::int - 1) || ' days')::interval, + date_trunc('month', now()) + ((EXTRACT(DAY FROM CURRENT_DATE)::int - 1) || ' days')::interval + interval '1 month' +FROM billing_period_stats_context; + +INSERT INTO public.orgs (id, created_by, name, management_email, customer_id) +SELECT + org_id, + user_id, + 'Billing Period Stats Org', + 'billing-period-stats@example.com', + customer_id +FROM billing_period_stats_context; + +INSERT INTO public.apps ( + app_id, icon_url, owner_org, name, retention, default_upload_channel +) +SELECT + app_id, + '', + org_id, + 'Billing Period Stats App', + 2592000, + 'production' +FROM billing_period_stats_context; + +DELETE FROM pgmq.q_cron_email +WHERE + message -> 'payload' ->> 'orgId' = (SELECT org_id::text FROM billing_period_stats_context) + AND message -> 'payload' ->> 'type' = 'billing_period_stats'; + +SELECT public.process_billing_period_stats_email(); + +SELECT is( + ( + SELECT count(*) + FROM pgmq.q_cron_email + WHERE + message -> 'payload' ->> 'orgId' + = (SELECT org_id::text FROM billing_period_stats_context) + AND message -> 'payload' ->> 'type' = 'billing_period_stats' + ), + 1::bigint, + 'queues billing_period_stats email on anniversary day' +); + +SELECT ok( + ( + SELECT + (message -> 'payload' ->> 'cycleStart') IS NOT NULL + AND (message -> 'payload' ->> 'cycleEnd') IS NOT NULL + AND (message -> 'payload' ->> 'cycleEnd')::timestamptz::date = CURRENT_DATE + AND (message -> 'payload' ->> 'cycleStart')::timestamptz + < (message -> 'payload' ->> 'cycleEnd')::timestamptz + FROM pgmq.q_cron_email + WHERE + message -> 'payload' ->> 'orgId' + = (SELECT org_id::text FROM billing_period_stats_context) + AND message -> 'payload' ->> 'type' = 'billing_period_stats' + LIMIT 1 + ), + 'payload includes completed cycle dates ending today' +); + +-- Move the org off today's anniversary and confirm it is not queued +UPDATE public.stripe_info +SET + subscription_anchor_start + = date_trunc('month', now()) + + ( + ( + CASE + WHEN EXTRACT(DAY FROM CURRENT_DATE)::int = 1 THEN 1 + ELSE 0 + END + ) || ' days' + )::interval, + subscription_anchor_end + = date_trunc('month', now()) + + ( + ( + CASE + WHEN EXTRACT(DAY FROM CURRENT_DATE)::int = 1 THEN 1 + ELSE 0 + END + ) || ' days' + )::interval + + interval '1 month' +WHERE customer_id = (SELECT customer_id FROM billing_period_stats_context); + +DELETE FROM pgmq.q_cron_email +WHERE + message -> 'payload' ->> 'orgId' = (SELECT org_id::text FROM billing_period_stats_context) + AND message -> 'payload' ->> 'type' = 'billing_period_stats'; + +SELECT public.process_billing_period_stats_email(); + +SELECT is( + ( + SELECT count(*) + FROM pgmq.q_cron_email + WHERE + message -> 'payload' ->> 'orgId' + = (SELECT org_id::text FROM billing_period_stats_context) + AND message -> 'payload' ->> 'type' = 'billing_period_stats' + ), + 0::bigint, + 'does not queue when today is not the billing anniversary' +); + +SELECT tests.clear_authentication(); + +SELECT * FROM finish(); + +ROLLBACK; diff --git a/tests/billing-period-stats.unit.test.ts b/tests/billing-period-stats.unit.test.ts new file mode 100644 index 0000000000..0a868410da --- /dev/null +++ b/tests/billing-period-stats.unit.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { billingPeriodStatsTestUtils } from '../supabase/functions/_backend/triggers/cron_email.ts' + +const { toDateOnlyUtc, addDaysToDateOnly, billingPeriodMetricsRange } = billingPeriodStatsTestUtils + +describe('billing period stats date helpers', () => { + it.concurrent('extracts calendar date from timestamptz without shifting days', () => { + expect(toDateOnlyUtc('2026-06-15T13:54:45+00:00')).toBe('2026-06-15') + expect(toDateOnlyUtc('2026-06-15 00:00:00+00')).toBe('2026-06-15') + expect(toDateOnlyUtc('2026-07-15')).toBe('2026-07-15') + }) + + it.concurrent('shifts date-only values in UTC', () => { + expect(addDaysToDateOnly('2026-07-15', -1)).toBe('2026-07-14') + expect(addDaysToDateOnly('2026-03-01', -1)).toBe('2026-02-28') + }) + + it.concurrent('maps half-open cycle bounds to inclusive metrics end', () => { + expect(billingPeriodMetricsRange('2026-06-15T00:00:00+00:00', '2026-07-15T00:00:00+00:00')).toEqual({ + periodStart: '2026-06-15', + periodEndExclusive: '2026-07-15', + metricsEndInclusive: '2026-07-14', + }) + }) +}) From de0e97917578bb565505e7d3e9ccc03d85a7d1d6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 11:43:29 +0000 Subject: [PATCH 02/18] fix(test): harden billing_period_stats pgTAP and revoke anon grants Align the SQL test with other cron email tests (postgres/pgmq access) and explicitly revoke anon/authenticated execute on the new functions. Co-authored-by: Martin DONADIEU --- ...gister_billing_period_stats_email_cron.sql | 4 +++ .../63_test_billing_period_stats_email.sql | 36 +------------------ 2 files changed, 5 insertions(+), 35 deletions(-) diff --git a/supabase/migrations/20260725112951_register_billing_period_stats_email_cron.sql b/supabase/migrations/20260725112951_register_billing_period_stats_email_cron.sql index bef2074af4..86888b8ccc 100644 --- a/supabase/migrations/20260725112951_register_billing_period_stats_email_cron.sql +++ b/supabase/migrations/20260725112951_register_billing_period_stats_email_cron.sql @@ -23,6 +23,8 @@ $$; ALTER FUNCTION public.get_org_credits_used_in_period(uuid, timestamptz, timestamptz) OWNER TO postgres; REVOKE ALL ON FUNCTION public.get_org_credits_used_in_period(uuid, timestamptz, timestamptz) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_org_credits_used_in_period(uuid, timestamptz, timestamptz) FROM anon; +REVOKE ALL ON FUNCTION public.get_org_credits_used_in_period(uuid, timestamptz, timestamptz) FROM authenticated; GRANT ALL ON FUNCTION public.get_org_credits_used_in_period(uuid, timestamptz, timestamptz) TO service_role; CREATE OR REPLACE FUNCTION public.process_billing_period_stats_email() @@ -82,6 +84,8 @@ $$; ALTER FUNCTION public.process_billing_period_stats_email() OWNER TO postgres; REVOKE ALL ON FUNCTION public.process_billing_period_stats_email() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.process_billing_period_stats_email() FROM anon; +REVOKE ALL ON FUNCTION public.process_billing_period_stats_email() FROM authenticated; GRANT ALL ON FUNCTION public.process_billing_period_stats_email() TO service_role; INSERT INTO public.cron_tasks ( diff --git a/supabase/tests/63_test_billing_period_stats_email.sql b/supabase/tests/63_test_billing_period_stats_email.sql index b77a98e249..ee21f43ae0 100644 --- a/supabase/tests/63_test_billing_period_stats_email.sql +++ b/supabase/tests/63_test_billing_period_stats_email.sql @@ -2,9 +2,7 @@ -- Ensures billing_period_stats is registered in cron_tasks and queues on anniversary day. BEGIN; -SELECT plan(9); - -SELECT tests.authenticate_as_service_role(); +SELECT plan(6); SELECT ok( to_regprocedure('public.process_billing_period_stats_email()') IS NOT NULL, @@ -16,36 +14,6 @@ SELECT ok( 'get_org_credits_used_in_period exists' ); -SELECT is( - has_function_privilege( - 'anon', - 'public.process_billing_period_stats_email()', - 'EXECUTE' - ), - FALSE, - 'anon cannot execute process_billing_period_stats_email' -); - -SELECT is( - has_function_privilege( - 'authenticated', - 'public.process_billing_period_stats_email()', - 'EXECUTE' - ), - FALSE, - 'authenticated cannot execute process_billing_period_stats_email' -); - -SELECT is( - has_function_privilege( - 'service_role', - 'public.process_billing_period_stats_email()', - 'EXECUTE' - ), - TRUE, - 'service_role can execute process_billing_period_stats_email' -); - SELECT ok( ( SELECT count(*)::int @@ -217,8 +185,6 @@ SELECT is( 'does not queue when today is not the billing anniversary' ); -SELECT tests.clear_authentication(); - SELECT * FROM finish(); ROLLBACK; From 34f2bfd9a9638a04cf57e47fc33397d5fd0dc2f3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 11:50:48 +0000 Subject: [PATCH 03/18] ci: retrigger backend shard after flaky queue_load timeout Co-authored-by: Martin DONADIEU From 74031449700e46dbfbc1ce2af21bf9626fce78fd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 11:56:26 +0000 Subject: [PATCH 04/18] ci: retrigger after flaky apikeys shard failure Co-authored-by: Martin DONADIEU From ec854e33883bda2a59e2d9a43324befcdaac7df6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 12:13:41 +0000 Subject: [PATCH 05/18] fix(email): address billing_period_stats review feedback Restore yesterday-based cycle math for month-end anchors, fail the email handler when credit lookup errors, type the new credits RPC, and cover half-open credit bounds in pgTAP. Co-authored-by: Martin DONADIEU --- cli/src/types/supabase.types.ts | 8 ++ src/types/supabase.types.ts | 8 ++ .../plugin_runtime/utils/supabase.types.ts | 8 ++ .../functions/_backend/triggers/cron_email.ts | 8 +- .../_backend/utils/supabase.types.ts | 8 ++ ...gister_billing_period_stats_email_cron.sql | 61 +++++--- .../63_test_billing_period_stats_email.sql | 132 ++++++++++++++++-- 7 files changed, 196 insertions(+), 37 deletions(-) diff --git a/cli/src/types/supabase.types.ts b/cli/src/types/supabase.types.ts index cf1785bbd5..9fe7ccd2bf 100644 --- a/cli/src/types/supabase.types.ts +++ b/cli/src/types/supabase.types.ts @@ -3747,6 +3747,14 @@ export type Database = { yearly: number }[] } + get_org_credits_used_in_period: { + Args: { + p_end: string + p_org_id: string + p_start: string + } + Returns: number + } get_cycle_info_org: { Args: { orgid: string } Returns: { diff --git a/src/types/supabase.types.ts b/src/types/supabase.types.ts index 2158dd9c3b..caf0c61263 100644 --- a/src/types/supabase.types.ts +++ b/src/types/supabase.types.ts @@ -4070,6 +4070,14 @@ export type Database = { yearly: number }[] } + get_org_credits_used_in_period: { + Args: { + p_end: string + p_org_id: string + p_start: string + } + Returns: number + } get_cycle_info_org: { Args: { orgid: string } Returns: { diff --git a/supabase/functions/_backend/plugin_runtime/utils/supabase.types.ts b/supabase/functions/_backend/plugin_runtime/utils/supabase.types.ts index e87a7d74b5..89b0bdab1d 100644 --- a/supabase/functions/_backend/plugin_runtime/utils/supabase.types.ts +++ b/supabase/functions/_backend/plugin_runtime/utils/supabase.types.ts @@ -4062,6 +4062,14 @@ export type Database = { yearly: number }[] } + get_org_credits_used_in_period: { + Args: { + p_end: string + p_org_id: string + p_start: string + } + Returns: number + } get_cycle_info_org: { Args: { orgid: string } Returns: { diff --git a/supabase/functions/_backend/triggers/cron_email.ts b/supabase/functions/_backend/triggers/cron_email.ts index 0c45968763..93a4d2847a 100644 --- a/supabase/functions/_backend/triggers/cron_email.ts +++ b/supabase/functions/_backend/triggers/cron_email.ts @@ -479,8 +479,7 @@ async function handleBillingPeriodStats(c: Context, _email: string, orgId: strin } // Sum credits in [periodStart, periodEndExclusive) without PostgREST row limits - let creditsUsed = 0 - const { data: creditsSum, error: creditsError } = await (supabase.rpc as any)( + const { data: creditsSum, error: creditsError } = await supabase.rpc( 'get_org_credits_used_in_period', { p_org_id: orgId, @@ -491,10 +490,9 @@ async function handleBillingPeriodStats(c: Context, _email: string, orgId: strin if (creditsError) { cloudlogErr({ requestId: c.get('requestId'), message: 'Cannot get credits used', error: creditsError, metadata: { orgId } }) + throw simpleError('cannot_get_credits', 'Cannot get credits used', { error: creditsError }) } - else { - creditsUsed = Number(creditsSum || 0) - } + const creditsUsed = Number(creditsSum || 0) // Format the metrics for the email const mau = metrics?.mau ?? 0 diff --git a/supabase/functions/_backend/utils/supabase.types.ts b/supabase/functions/_backend/utils/supabase.types.ts index 2158dd9c3b..caf0c61263 100644 --- a/supabase/functions/_backend/utils/supabase.types.ts +++ b/supabase/functions/_backend/utils/supabase.types.ts @@ -4070,6 +4070,14 @@ export type Database = { yearly: number }[] } + get_org_credits_used_in_period: { + Args: { + p_end: string + p_org_id: string + p_start: string + } + Returns: number + } get_cycle_info_org: { Args: { orgid: string } Returns: { diff --git a/supabase/migrations/20260725112951_register_billing_period_stats_email_cron.sql b/supabase/migrations/20260725112951_register_billing_period_stats_email_cron.sql index 86888b8ccc..ce31a90851 100644 --- a/supabase/migrations/20260725112951_register_billing_period_stats_email_cron.sql +++ b/supabase/migrations/20260725112951_register_billing_period_stats_email_cron.sql @@ -34,34 +34,57 @@ SET search_path = '' AS $$ DECLARE org_record RECORD; - v_anchor_day interval; - v_anniversary date; - v_prev_cycle_start timestamptz; - v_prev_cycle_end timestamptz; BEGIN - -- Find orgs whose billing anniversary is today and queue an email for the - -- just-completed cycle [anniversary - 1 month, anniversary). + -- Compute the just-completed cycle from yesterday's calendar position. + -- Using yesterday (not today) keeps month-end anchors (29th-31st) aligned with + -- get_cycle_info_org when shorter months clamp "+ 1 month" to the last day. FOR org_record IN ( SELECT o.id AS org_id, o.management_email, - COALESCE( - si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), - '0 DAYS'::interval - ) AS anchor_day + CASE + WHEN COALESCE( + si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), + '0 DAYS'::interval + ) > (now() - interval '1 day') - date_trunc('MONTH', now() - interval '1 day') + THEN date_trunc('MONTH', (now() - interval '1 day') - interval '1 month') + + COALESCE( + si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), + '0 DAYS'::interval + ) + ELSE date_trunc('MONTH', now() - interval '1 day') + + COALESCE( + si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), + '0 DAYS'::interval + ) + END AS prev_cycle_start, + CASE + WHEN COALESCE( + si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), + '0 DAYS'::interval + ) > (now() - interval '1 day') - date_trunc('MONTH', now() - interval '1 day') + THEN ( + date_trunc('MONTH', (now() - interval '1 day') - interval '1 month') + + COALESCE( + si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), + '0 DAYS'::interval + ) + ) + interval '1 month' + ELSE ( + date_trunc('MONTH', now() - interval '1 day') + + COALESCE( + si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), + '0 DAYS'::interval + ) + ) + interval '1 month' + END AS prev_cycle_end FROM public.orgs o JOIN public.stripe_info si ON o.customer_id = si.customer_id WHERE si.status = 'succeeded' AND o.management_email IS NOT NULL ) LOOP - v_anchor_day := org_record.anchor_day; - v_anniversary := (date_trunc('MONTH', now()) + v_anchor_day)::date; - - IF v_anniversary = CURRENT_DATE THEN - v_prev_cycle_start := date_trunc('MONTH', now() - interval '1 month') + v_anchor_day; - v_prev_cycle_end := date_trunc('MONTH', now()) + v_anchor_day; - + IF org_record.prev_cycle_end::date = CURRENT_DATE THEN PERFORM pgmq.send( 'cron_email', jsonb_build_object( @@ -71,8 +94,8 @@ BEGIN 'email', org_record.management_email, 'orgId', org_record.org_id, 'type', 'billing_period_stats', - 'cycleStart', v_prev_cycle_start, - 'cycleEnd', v_prev_cycle_end + 'cycleStart', org_record.prev_cycle_start, + 'cycleEnd', org_record.prev_cycle_end ) ) ); diff --git a/supabase/tests/63_test_billing_period_stats_email.sql b/supabase/tests/63_test_billing_period_stats_email.sql index ee21f43ae0..d68b511a8f 100644 --- a/supabase/tests/63_test_billing_period_stats_email.sql +++ b/supabase/tests/63_test_billing_period_stats_email.sql @@ -1,16 +1,21 @@ -- 63_test_billing_period_stats_email.sql --- Ensures billing_period_stats is registered in cron_tasks and queues on anniversary day. +-- Ensures billing_period_stats is registered in cron_tasks and queues on +-- anniversary day, and credits sums use half-open period bounds. BEGIN; -SELECT plan(6); +SELECT plan(8); SELECT ok( - to_regprocedure('public.process_billing_period_stats_email()') IS NOT NULL, + to_regprocedure( + 'public.process_billing_period_stats_email()' + ) IS NOT NULL, 'process_billing_period_stats_email exists' ); SELECT ok( - to_regprocedure('public.get_org_credits_used_in_period(uuid, timestamptz, timestamptz)') IS NOT NULL, + to_regprocedure( + 'public.get_org_credits_used_in_period(uuid, timestamptz, timestamptz)' + ) IS NOT NULL, 'get_org_credits_used_in_period exists' ); @@ -33,7 +38,8 @@ CREATE TEMP TABLE billing_period_stats_context ( user_id uuid, org_id uuid, customer_id text, - app_id text + app_id text, + grant_id uuid ) ON COMMIT DROP; DO $$ @@ -46,7 +52,9 @@ BEGIN END; $$ LANGUAGE plpgsql; -INSERT INTO billing_period_stats_context (user_id, org_id, customer_id, app_id) +INSERT INTO billing_period_stats_context ( + user_id, org_id, customer_id, app_id +) VALUES ( tests.get_supabase_uid('billing_period_stats_user'), gen_random_uuid(), @@ -76,11 +84,16 @@ SELECT 'succeeded', 'prod_LQIregjtNduh4q', 'sub_billing_period_stats_test', - date_trunc('month', now()) + ((EXTRACT(DAY FROM CURRENT_DATE)::int - 1) || ' days')::interval, - date_trunc('month', now()) + ((EXTRACT(DAY FROM CURRENT_DATE)::int - 1) || ' days')::interval + interval '1 month' + date_trunc('month', now()) + + ((EXTRACT(DAY FROM CURRENT_DATE)::int - 1) || ' days')::interval, + date_trunc('month', now()) + + ((EXTRACT(DAY FROM CURRENT_DATE)::int - 1) || ' days')::interval + + interval '1 month' FROM billing_period_stats_context; -INSERT INTO public.orgs (id, created_by, name, management_email, customer_id) +INSERT INTO public.orgs ( + id, created_by, name, management_email, customer_id +) SELECT org_id, user_id, @@ -101,9 +114,62 @@ SELECT 'production' FROM billing_period_stats_context; +WITH grant_insert AS ( + INSERT INTO public.usage_credit_grants ( + org_id, + credits_total, + credits_consumed, + granted_at, + expires_at, + source + ) + SELECT + org_id, + 100, + 0, + now() - interval '40 days', + now() + interval '1 year', + 'manual' + FROM billing_period_stats_context + RETURNING id, org_id +) +UPDATE billing_period_stats_context ctx +SET grant_id = grant_insert.id +FROM grant_insert +WHERE ctx.org_id = grant_insert.org_id; + +-- Half-open [start, end): include start and mid, exclude end +INSERT INTO public.usage_credit_consumptions ( + grant_id, org_id, metric, credits_used, applied_at +) +SELECT + grant_id, + org_id, + 'mau'::public.credit_metric_type, + credits_used, + applied_at +FROM billing_period_stats_context +CROSS JOIN ( + VALUES + (1.5::numeric, (CURRENT_DATE - 20)::timestamptz), + (2.5::numeric, (CURRENT_DATE - 1)::timestamptz), + (9.0::numeric, CURRENT_DATE::timestamptz) +) AS samples(credits_used, applied_at); + +SELECT is( + public.get_org_credits_used_in_period( + (SELECT org_id FROM billing_period_stats_context), + (CURRENT_DATE - 30)::timestamptz, + CURRENT_DATE::timestamptz + ), + 4.0::numeric, + 'credits sum includes [start, end) and excludes end boundary' +); + DELETE FROM pgmq.q_cron_email WHERE - message -> 'payload' ->> 'orgId' = (SELECT org_id::text FROM billing_period_stats_context) + message -> 'payload' ->> 'orgId' + = (SELECT org_id::text FROM billing_period_stats_context) AND message -> 'payload' ->> 'type' = 'billing_period_stats'; SELECT public.process_billing_period_stats_email(); @@ -126,7 +192,8 @@ SELECT ok( SELECT (message -> 'payload' ->> 'cycleStart') IS NOT NULL AND (message -> 'payload' ->> 'cycleEnd') IS NOT NULL - AND (message -> 'payload' ->> 'cycleEnd')::timestamptz::date = CURRENT_DATE + AND (message -> 'payload' ->> 'cycleEnd')::timestamptz::date + = CURRENT_DATE AND (message -> 'payload' ->> 'cycleStart')::timestamptz < (message -> 'payload' ->> 'cycleEnd')::timestamptz FROM pgmq.q_cron_email @@ -163,11 +230,14 @@ SET ) || ' days' )::interval + interval '1 month' -WHERE customer_id = (SELECT customer_id FROM billing_period_stats_context); +WHERE customer_id = ( + SELECT customer_id FROM billing_period_stats_context +); DELETE FROM pgmq.q_cron_email WHERE - message -> 'payload' ->> 'orgId' = (SELECT org_id::text FROM billing_period_stats_context) + message -> 'payload' ->> 'orgId' + = (SELECT org_id::text FROM billing_period_stats_context) AND message -> 'payload' ->> 'type' = 'billing_period_stats'; SELECT public.process_billing_period_stats_email(); @@ -185,6 +255,42 @@ SELECT is( 'does not queue when today is not the billing anniversary' ); +-- Month-end probe: 31st anchor should complete a cycle on the last day of a +-- short month when "+ 1 month" clamps (e.g. Jan 31 -> Feb 28/29). +DO $$ +DECLARE + v_anchor timestamptz := '2026-01-31 00:00:00+00'::timestamptz; + v_yesterday timestamptz := '2026-02-27 12:00:00+00'::timestamptz; + v_anchor_day interval; + v_prev_end timestamptz; +BEGIN + v_anchor_day := v_anchor - date_trunc('MONTH', v_anchor); + IF v_anchor_day + > v_yesterday - date_trunc('MONTH', v_yesterday) + THEN + v_prev_end := ( + date_trunc('MONTH', v_yesterday - interval '1 month') + + v_anchor_day + ) + interval '1 month'; + ELSE + v_prev_end := ( + date_trunc('MONTH', v_yesterday) + v_anchor_day + ) + interval '1 month'; + END IF; + + IF v_prev_end::date IS DISTINCT FROM '2026-02-28'::date THEN + RAISE EXCEPTION + 'expected Feb 28 cycle end for Jan 31 anchor, got %', + v_prev_end; + END IF; +END; +$$; + +SELECT ok( + TRUE, + '31st-anchor cycle end clamps to last day of short month' +); + SELECT * FROM finish(); ROLLBACK; From 4a4f87d108f0693025f9dfe9f0eef5439b26acb5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 12:22:03 +0000 Subject: [PATCH 06/18] fix(email): use timestamptz bounds for billing period credits Pass original cycleStart/cycleEnd timestamps into the credits RPC so non-midnight Stripe anchors do not under/over-count credit usage. Co-authored-by: Martin DONADIEU --- .../functions/_backend/triggers/cron_email.ts | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/supabase/functions/_backend/triggers/cron_email.ts b/supabase/functions/_backend/triggers/cron_email.ts index 93a4d2847a..08ecaa8c7f 100644 --- a/supabase/functions/_backend/triggers/cron_email.ts +++ b/supabase/functions/_backend/triggers/cron_email.ts @@ -430,13 +430,16 @@ async function handleBillingPeriodStats(c: Context, _email: string, orgId: strin // Use cycle dates passed from the SQL function if available, // otherwise fall back to get_cycle_info_org (for backwards compatibility) + let cycleStartTs: string + let cycleEndTs: string let periodStart: string let periodEndExclusive: string let metricsEndInclusive: string if (cycleStart && cycleEnd) { // Completed billing period from SQL: [cycleStart, cycleEnd) - ;({ periodStart, periodEndExclusive, metricsEndInclusive } = billingPeriodMetricsRange(cycleStart, cycleEnd)) + cycleStartTs = cycleStart + cycleEndTs = cycleEnd } else { // Fallback: get cycle info from RPC (current cycle, same half-open bounds) @@ -449,12 +452,15 @@ async function handleBillingPeriodStats(c: Context, _email: string, orgId: strin throw simpleError('cannot_get_cycle_info', 'Cannot get cycle info', { error: cycleError }) } - ;({ periodStart, periodEndExclusive, metricsEndInclusive } = billingPeriodMetricsRange( - cycleInfo.subscription_anchor_start, - cycleInfo.subscription_anchor_end, - )) + cycleStartTs = cycleInfo.subscription_anchor_start + cycleEndTs = cycleInfo.subscription_anchor_end } + ;({ periodStart, periodEndExclusive, metricsEndInclusive } = billingPeriodMetricsRange( + cycleStartTs, + cycleEndTs, + )) + // Guard against inverted/empty ranges (e.g. bad payloads) if (metricsEndInclusive < periodStart) { throw simpleError('invalid_billing_period', 'Invalid billing period dates', { @@ -478,13 +484,13 @@ async function handleBillingPeriodStats(c: Context, _email: string, orgId: strin throw simpleError('cannot_get_metrics', 'Cannot get metrics', { error: metricsError }) } - // Sum credits in [periodStart, periodEndExclusive) without PostgREST row limits + // Sum credits with original timestamptz bounds (anchors are often not midnight) const { data: creditsSum, error: creditsError } = await supabase.rpc( 'get_org_credits_used_in_period', { p_org_id: orgId, - p_start: `${periodStart}T00:00:00.000Z`, - p_end: `${periodEndExclusive}T00:00:00.000Z`, + p_start: cycleStartTs, + p_end: cycleEndTs, }, ) From 7241c517298d500983c2f1f4ef6e90d68f361178 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 12:22:20 +0000 Subject: [PATCH 07/18] fix(email): prefer const for billing period date locals Co-authored-by: Martin DONADIEU --- supabase/functions/_backend/triggers/cron_email.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/supabase/functions/_backend/triggers/cron_email.ts b/supabase/functions/_backend/triggers/cron_email.ts index 08ecaa8c7f..0d44482756 100644 --- a/supabase/functions/_backend/triggers/cron_email.ts +++ b/supabase/functions/_backend/triggers/cron_email.ts @@ -432,9 +432,6 @@ async function handleBillingPeriodStats(c: Context, _email: string, orgId: strin // otherwise fall back to get_cycle_info_org (for backwards compatibility) let cycleStartTs: string let cycleEndTs: string - let periodStart: string - let periodEndExclusive: string - let metricsEndInclusive: string if (cycleStart && cycleEnd) { // Completed billing period from SQL: [cycleStart, cycleEnd) @@ -456,10 +453,10 @@ async function handleBillingPeriodStats(c: Context, _email: string, orgId: strin cycleEndTs = cycleInfo.subscription_anchor_end } - ;({ periodStart, periodEndExclusive, metricsEndInclusive } = billingPeriodMetricsRange( + const { periodStart, periodEndExclusive, metricsEndInclusive } = billingPeriodMetricsRange( cycleStartTs, cycleEndTs, - )) + ) // Guard against inverted/empty ranges (e.g. bad payloads) if (metricsEndInclusive < periodStart) { From 05028c2ffd8018be08647cc2fadcab1b3aecbca4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 12:26:36 +0000 Subject: [PATCH 08/18] ci: retrigger after cancelled concurrent runs Co-authored-by: Martin DONADIEU From 757479e2f40a9bb43b2f227bdc5230ab14710048 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 12:49:37 +0000 Subject: [PATCH 09/18] fix(email): clamp billing anniversary to month last day Use Stripe-style day-of-month clamping so 30th/31st-anchor orgs still get renewal emails in short months and do not skip March after February. Co-authored-by: Martin DONADIEU --- ...gister_billing_period_stats_email_cron.sql | 70 +++++++--------- .../63_test_billing_period_stats_email.sql | 82 ++++++++++++++----- 2 files changed, 89 insertions(+), 63 deletions(-) diff --git a/supabase/migrations/20260725112951_register_billing_period_stats_email_cron.sql b/supabase/migrations/20260725112951_register_billing_period_stats_email_cron.sql index ce31a90851..f64615e42b 100644 --- a/supabase/migrations/20260725112951_register_billing_period_stats_email_cron.sql +++ b/supabase/migrations/20260725112951_register_billing_period_stats_email_cron.sql @@ -34,57 +34,43 @@ SET search_path = '' AS $$ DECLARE org_record RECORD; + v_anchor_dom integer; + v_this_month_last integer; + v_prev_month_last integer; + v_this_anniv_dom integer; + v_prev_anniv_dom integer; + v_prev_cycle_start timestamptz; + v_prev_cycle_end timestamptz; BEGIN - -- Compute the just-completed cycle from yesterday's calendar position. - -- Using yesterday (not today) keeps month-end anchors (29th-31st) aligned with - -- get_cycle_info_org when shorter months clamp "+ 1 month" to the last day. + -- Stripe-style day-of-month anchors: clamp to the last valid day of each month + -- so 29th/30th/31st anchors still renew on Feb 28/29, Apr 30, etc. FOR org_record IN ( SELECT o.id AS org_id, o.management_email, - CASE - WHEN COALESCE( - si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), - '0 DAYS'::interval - ) > (now() - interval '1 day') - date_trunc('MONTH', now() - interval '1 day') - THEN date_trunc('MONTH', (now() - interval '1 day') - interval '1 month') - + COALESCE( - si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), - '0 DAYS'::interval - ) - ELSE date_trunc('MONTH', now() - interval '1 day') - + COALESCE( - si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), - '0 DAYS'::interval - ) - END AS prev_cycle_start, - CASE - WHEN COALESCE( - si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), - '0 DAYS'::interval - ) > (now() - interval '1 day') - date_trunc('MONTH', now() - interval '1 day') - THEN ( - date_trunc('MONTH', (now() - interval '1 day') - interval '1 month') - + COALESCE( - si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), - '0 DAYS'::interval - ) - ) + interval '1 month' - ELSE ( - date_trunc('MONTH', now() - interval '1 day') - + COALESCE( - si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), - '0 DAYS'::interval - ) - ) + interval '1 month' - END AS prev_cycle_end + COALESCE(EXTRACT(DAY FROM si.subscription_anchor_start)::integer, 1) AS anchor_dom FROM public.orgs o JOIN public.stripe_info si ON o.customer_id = si.customer_id WHERE si.status = 'succeeded' AND o.management_email IS NOT NULL ) LOOP - IF org_record.prev_cycle_end::date = CURRENT_DATE THEN + v_anchor_dom := org_record.anchor_dom; + v_this_month_last := EXTRACT( + DAY FROM (date_trunc('month', now()) + interval '1 month - 1 day') + )::integer; + v_prev_month_last := EXTRACT( + DAY FROM (date_trunc('month', now()) - interval '1 day') + )::integer; + v_this_anniv_dom := LEAST(v_anchor_dom, v_this_month_last); + v_prev_anniv_dom := LEAST(v_anchor_dom, v_prev_month_last); + + IF EXTRACT(DAY FROM CURRENT_DATE)::integer = v_this_anniv_dom THEN + v_prev_cycle_start := date_trunc('month', now() - interval '1 month') + + ((v_prev_anniv_dom - 1) || ' days')::interval; + v_prev_cycle_end := date_trunc('month', now()) + + ((v_this_anniv_dom - 1) || ' days')::interval; + PERFORM pgmq.send( 'cron_email', jsonb_build_object( @@ -94,8 +80,8 @@ BEGIN 'email', org_record.management_email, 'orgId', org_record.org_id, 'type', 'billing_period_stats', - 'cycleStart', org_record.prev_cycle_start, - 'cycleEnd', org_record.prev_cycle_end + 'cycleStart', v_prev_cycle_start, + 'cycleEnd', v_prev_cycle_end ) ) ); diff --git a/supabase/tests/63_test_billing_period_stats_email.sql b/supabase/tests/63_test_billing_period_stats_email.sql index d68b511a8f..026955f6b4 100644 --- a/supabase/tests/63_test_billing_period_stats_email.sql +++ b/supabase/tests/63_test_billing_period_stats_email.sql @@ -255,40 +255,80 @@ SELECT is( 'does not queue when today is not the billing anniversary' ); --- Month-end probe: 31st anchor should complete a cycle on the last day of a --- short month when "+ 1 month" clamps (e.g. Jan 31 -> Feb 28/29). +-- Month-end probe: 31st-anchor orgs renew on clamped last days +-- (Jan 31 -> Feb 28 -> Mar 31), not skipped after February. DO $$ DECLARE - v_anchor timestamptz := '2026-01-31 00:00:00+00'::timestamptz; - v_yesterday timestamptz := '2026-02-27 12:00:00+00'::timestamptz; - v_anchor_day interval; - v_prev_end timestamptz; + v_anchor_dom integer := 31; + v_this_last integer; + v_prev_last integer; + v_this_dom integer; + v_prev_dom integer; + v_start timestamptz; + v_end timestamptz; BEGIN - v_anchor_day := v_anchor - date_trunc('MONTH', v_anchor); - IF v_anchor_day - > v_yesterday - date_trunc('MONTH', v_yesterday) + -- On 2026-02-28 + v_this_last := EXTRACT( + DAY FROM ( + date_trunc('month', '2026-02-28'::date) + + interval '1 month - 1 day' + ) + )::integer; + v_prev_last := EXTRACT( + DAY FROM ( + date_trunc('month', '2026-02-28'::date) - interval '1 day' + ) + )::integer; + v_this_dom := LEAST(v_anchor_dom, v_this_last); + v_prev_dom := LEAST(v_anchor_dom, v_prev_last); + IF v_this_dom IS DISTINCT FROM 28 THEN + RAISE EXCEPTION 'Feb anniversary day expected 28, got %', v_this_dom; + END IF; + v_start := date_trunc('month', '2026-02-28'::date - interval '1 month') + + ((v_prev_dom - 1) || ' days')::interval; + v_end := date_trunc('month', '2026-02-28'::date) + + ((v_this_dom - 1) || ' days')::interval; + IF v_start::date IS DISTINCT FROM '2026-01-31'::date + OR v_end::date IS DISTINCT FROM '2026-02-28'::date THEN - v_prev_end := ( - date_trunc('MONTH', v_yesterday - interval '1 month') - + v_anchor_day - ) + interval '1 month'; - ELSE - v_prev_end := ( - date_trunc('MONTH', v_yesterday) + v_anchor_day - ) + interval '1 month'; + RAISE EXCEPTION 'Feb cycle expected Jan31-Feb28, got % - %', v_start, v_end; END IF; - IF v_prev_end::date IS DISTINCT FROM '2026-02-28'::date THEN + -- On 2026-03-31 (must not skip March after February clamp) + v_this_last := EXTRACT( + DAY FROM ( + date_trunc('month', '2026-03-31'::date) + + interval '1 month - 1 day' + ) + )::integer; + v_prev_last := EXTRACT( + DAY FROM ( + date_trunc('month', '2026-03-31'::date) - interval '1 day' + ) + )::integer; + v_this_dom := LEAST(v_anchor_dom, v_this_last); + v_prev_dom := LEAST(v_anchor_dom, v_prev_last); + IF v_this_dom IS DISTINCT FROM 31 OR v_prev_dom IS DISTINCT FROM 28 THEN RAISE EXCEPTION - 'expected Feb 28 cycle end for Jan 31 anchor, got %', - v_prev_end; + 'Mar clamp expected this=31 prev=28, got % / %', + v_this_dom, + v_prev_dom; + END IF; + v_start := date_trunc('month', '2026-03-31'::date - interval '1 month') + + ((v_prev_dom - 1) || ' days')::interval; + v_end := date_trunc('month', '2026-03-31'::date) + + ((v_this_dom - 1) || ' days')::interval; + IF v_start::date IS DISTINCT FROM '2026-02-28'::date + OR v_end::date IS DISTINCT FROM '2026-03-31'::date + THEN + RAISE EXCEPTION 'Mar cycle expected Feb28-Mar31, got % - %', v_start, v_end; END IF; END; $$; SELECT ok( TRUE, - '31st-anchor cycle end clamps to last day of short month' + '31st-anchor renews on Feb 28 and Mar 31 with clamped bounds' ); SELECT * FROM finish(); From 04c51c2c2dbbf7dd5a133ea6fcf2f2d29d7360e2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 12:56:07 +0000 Subject: [PATCH 10/18] ci: retrigger after flaky 502s on backend shard 6 Co-authored-by: Martin DONADIEU From f8e53da603fe0f4652b7f22be3640de09e21ab8c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 13:05:45 +0000 Subject: [PATCH 11/18] fix(email): extract testable billing_period_completed_cycle helper Move anniversary clamping into a date-parameterized SQL helper used by the cron processor and assert Feb/Mar 31st-anchor transitions via pgTAP. Co-authored-by: Martin DONADIEU --- ...gister_billing_period_stats_email_cron.sql | 88 +++++++++---- .../63_test_billing_period_stats_email.sql | 121 ++++++++---------- 2 files changed, 115 insertions(+), 94 deletions(-) diff --git a/supabase/migrations/20260725112951_register_billing_period_stats_email_cron.sql b/supabase/migrations/20260725112951_register_billing_period_stats_email_cron.sql index f64615e42b..0b89a210c2 100644 --- a/supabase/migrations/20260725112951_register_billing_period_stats_email_cron.sql +++ b/supabase/migrations/20260725112951_register_billing_period_stats_email_cron.sql @@ -27,50 +27,88 @@ REVOKE ALL ON FUNCTION public.get_org_credits_used_in_period(uuid, timestamptz, REVOKE ALL ON FUNCTION public.get_org_credits_used_in_period(uuid, timestamptz, timestamptz) FROM authenticated; GRANT ALL ON FUNCTION public.get_org_credits_used_in_period(uuid, timestamptz, timestamptz) TO service_role; -CREATE OR REPLACE FUNCTION public.process_billing_period_stats_email() -RETURNS void +-- Stripe-style day-of-month clamping for billing anniversary detection. +-- Returns the completed cycle ending on p_as_of when that day is the +-- (clamped) anniversary; otherwise is_anniversary is false. +CREATE OR REPLACE FUNCTION public.billing_period_completed_cycle( + p_anchor_start timestamptz, + p_as_of date DEFAULT CURRENT_DATE +) +RETURNS TABLE ( + is_anniversary boolean, + cycle_start timestamptz, + cycle_end timestamptz +) LANGUAGE plpgsql +STABLE SET search_path = '' AS $$ DECLARE - org_record RECORD; v_anchor_dom integer; v_this_month_last integer; v_prev_month_last integer; v_this_anniv_dom integer; v_prev_anniv_dom integer; - v_prev_cycle_start timestamptz; - v_prev_cycle_end timestamptz; BEGIN - -- Stripe-style day-of-month anchors: clamp to the last valid day of each month - -- so 29th/30th/31st anchors still renew on Feb 28/29, Apr 30, etc. + v_anchor_dom := COALESCE(EXTRACT(DAY FROM p_anchor_start)::integer, 1); + v_this_month_last := EXTRACT( + DAY FROM (date_trunc('month', p_as_of) + interval '1 month - 1 day') + )::integer; + v_prev_month_last := EXTRACT( + DAY FROM (date_trunc('month', p_as_of) - interval '1 day') + )::integer; + v_this_anniv_dom := LEAST(v_anchor_dom, v_this_month_last); + v_prev_anniv_dom := LEAST(v_anchor_dom, v_prev_month_last); + + is_anniversary := EXTRACT(DAY FROM p_as_of)::integer = v_this_anniv_dom; + IF is_anniversary THEN + cycle_start := date_trunc('month', p_as_of - interval '1 month') + + ((v_prev_anniv_dom - 1) || ' days')::interval; + cycle_end := date_trunc('month', p_as_of) + + ((v_this_anniv_dom - 1) || ' days')::interval; + ELSE + cycle_start := NULL; + cycle_end := NULL; + END IF; + + RETURN NEXT; +END; +$$; + +ALTER FUNCTION public.billing_period_completed_cycle(timestamptz, date) OWNER TO postgres; +REVOKE ALL ON FUNCTION public.billing_period_completed_cycle(timestamptz, date) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.billing_period_completed_cycle(timestamptz, date) FROM anon; +REVOKE ALL ON FUNCTION public.billing_period_completed_cycle(timestamptz, date) FROM authenticated; +GRANT ALL ON FUNCTION public.billing_period_completed_cycle(timestamptz, date) TO service_role; + +CREATE OR REPLACE FUNCTION public.process_billing_period_stats_email() +RETURNS void +LANGUAGE plpgsql +SET search_path = '' +AS $$ +DECLARE + org_record RECORD; + v_cycle RECORD; +BEGIN FOR org_record IN ( SELECT o.id AS org_id, o.management_email, - COALESCE(EXTRACT(DAY FROM si.subscription_anchor_start)::integer, 1) AS anchor_dom + si.subscription_anchor_start FROM public.orgs o JOIN public.stripe_info si ON o.customer_id = si.customer_id WHERE si.status = 'succeeded' AND o.management_email IS NOT NULL ) LOOP - v_anchor_dom := org_record.anchor_dom; - v_this_month_last := EXTRACT( - DAY FROM (date_trunc('month', now()) + interval '1 month - 1 day') - )::integer; - v_prev_month_last := EXTRACT( - DAY FROM (date_trunc('month', now()) - interval '1 day') - )::integer; - v_this_anniv_dom := LEAST(v_anchor_dom, v_this_month_last); - v_prev_anniv_dom := LEAST(v_anchor_dom, v_prev_month_last); - - IF EXTRACT(DAY FROM CURRENT_DATE)::integer = v_this_anniv_dom THEN - v_prev_cycle_start := date_trunc('month', now() - interval '1 month') - + ((v_prev_anniv_dom - 1) || ' days')::interval; - v_prev_cycle_end := date_trunc('month', now()) - + ((v_this_anniv_dom - 1) || ' days')::interval; + SELECT * + INTO v_cycle + FROM public.billing_period_completed_cycle( + org_record.subscription_anchor_start, + CURRENT_DATE + ); + IF v_cycle.is_anniversary THEN PERFORM pgmq.send( 'cron_email', jsonb_build_object( @@ -80,8 +118,8 @@ BEGIN 'email', org_record.management_email, 'orgId', org_record.org_id, 'type', 'billing_period_stats', - 'cycleStart', v_prev_cycle_start, - 'cycleEnd', v_prev_cycle_end + 'cycleStart', v_cycle.cycle_start, + 'cycleEnd', v_cycle.cycle_end ) ) ); diff --git a/supabase/tests/63_test_billing_period_stats_email.sql b/supabase/tests/63_test_billing_period_stats_email.sql index 026955f6b4..5db2da0a71 100644 --- a/supabase/tests/63_test_billing_period_stats_email.sql +++ b/supabase/tests/63_test_billing_period_stats_email.sql @@ -3,7 +3,7 @@ -- anniversary day, and credits sums use half-open period bounds. BEGIN; -SELECT plan(8); +SELECT plan(12); SELECT ok( to_regprocedure( @@ -255,80 +255,63 @@ SELECT is( 'does not queue when today is not the billing anniversary' ); --- Month-end probe: 31st-anchor orgs renew on clamped last days --- (Jan 31 -> Feb 28 -> Mar 31), not skipped after February. -DO $$ -DECLARE - v_anchor_dom integer := 31; - v_this_last integer; - v_prev_last integer; - v_this_dom integer; - v_prev_dom integer; - v_start timestamptz; - v_end timestamptz; -BEGIN - -- On 2026-02-28 - v_this_last := EXTRACT( - DAY FROM ( - date_trunc('month', '2026-02-28'::date) - + interval '1 month - 1 day' - ) - )::integer; - v_prev_last := EXTRACT( - DAY FROM ( - date_trunc('month', '2026-02-28'::date) - interval '1 day' +-- Month-end: exercise the deployed helper (not a re-derived copy) +SELECT ok( + ( + SELECT is_anniversary + FROM public.billing_period_completed_cycle( + '2026-01-31 00:00:00+00'::timestamptz, + '2026-02-28'::date ) - )::integer; - v_this_dom := LEAST(v_anchor_dom, v_this_last); - v_prev_dom := LEAST(v_anchor_dom, v_prev_last); - IF v_this_dom IS DISTINCT FROM 28 THEN - RAISE EXCEPTION 'Feb anniversary day expected 28, got %', v_this_dom; - END IF; - v_start := date_trunc('month', '2026-02-28'::date - interval '1 month') - + ((v_prev_dom - 1) || ' days')::interval; - v_end := date_trunc('month', '2026-02-28'::date) - + ((v_this_dom - 1) || ' days')::interval; - IF v_start::date IS DISTINCT FROM '2026-01-31'::date - OR v_end::date IS DISTINCT FROM '2026-02-28'::date - THEN - RAISE EXCEPTION 'Feb cycle expected Jan31-Feb28, got % - %', v_start, v_end; - END IF; + ), + '31st-anchor is anniversary on Feb 28' +); - -- On 2026-03-31 (must not skip March after February clamp) - v_this_last := EXTRACT( - DAY FROM ( - date_trunc('month', '2026-03-31'::date) - + interval '1 month - 1 day' +SELECT is( + ( + SELECT cycle_start::date + FROM public.billing_period_completed_cycle( + '2026-01-31 00:00:00+00'::timestamptz, + '2026-02-28'::date ) - )::integer; - v_prev_last := EXTRACT( - DAY FROM ( - date_trunc('month', '2026-03-31'::date) - interval '1 day' + ), + '2026-01-31'::date, + 'Feb 28 completed cycle starts Jan 31' +); + +SELECT is( + ( + SELECT cycle_end::date + FROM public.billing_period_completed_cycle( + '2026-01-31 00:00:00+00'::timestamptz, + '2026-02-28'::date ) - )::integer; - v_this_dom := LEAST(v_anchor_dom, v_this_last); - v_prev_dom := LEAST(v_anchor_dom, v_prev_last); - IF v_this_dom IS DISTINCT FROM 31 OR v_prev_dom IS DISTINCT FROM 28 THEN - RAISE EXCEPTION - 'Mar clamp expected this=31 prev=28, got % / %', - v_this_dom, - v_prev_dom; - END IF; - v_start := date_trunc('month', '2026-03-31'::date - interval '1 month') - + ((v_prev_dom - 1) || ' days')::interval; - v_end := date_trunc('month', '2026-03-31'::date) - + ((v_this_dom - 1) || ' days')::interval; - IF v_start::date IS DISTINCT FROM '2026-02-28'::date - OR v_end::date IS DISTINCT FROM '2026-03-31'::date - THEN - RAISE EXCEPTION 'Mar cycle expected Feb28-Mar31, got % - %', v_start, v_end; - END IF; -END; -$$; + ), + '2026-02-28'::date, + 'Feb 28 completed cycle ends Feb 28' +); SELECT ok( - TRUE, - '31st-anchor renews on Feb 28 and Mar 31 with clamped bounds' + ( + SELECT is_anniversary + FROM public.billing_period_completed_cycle( + '2026-01-31 00:00:00+00'::timestamptz, + '2026-03-31'::date + ) + ), + '31st-anchor is anniversary on Mar 31 (not skipped after Feb)' +); + +SELECT is( + ( + SELECT cycle_start::date + FROM public.billing_period_completed_cycle( + '2026-01-31 00:00:00+00'::timestamptz, + '2026-03-31'::date + ) + ), + '2026-02-28'::date, + 'Mar 31 completed cycle starts Feb 28' ); SELECT * FROM finish(); From 479f2e53137af99a958c29c4fa578092b7378a06 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 13:13:00 +0000 Subject: [PATCH 12/18] ci: retrigger after unrelated queue_load/stats flakes Co-authored-by: Martin DONADIEU From 13da03e314cdfd0679f04012a7b16bb382444a13 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 13:21:43 +0000 Subject: [PATCH 13/18] fix(email): make billing period cycle math UTC-safe Normalize anniversary detection and cycle bounds to UTC, preserve the Stripe anchor time-of-day for credit sums, wrap SQLFluff-long grants, and align pgTAP coverage/assertions. Co-authored-by: Martin DONADIEU --- ...gister_billing_period_stats_email_cron.sql | 119 ++++++++++++++---- .../63_test_billing_period_stats_email.sql | 111 +++++++++++----- 2 files changed, 174 insertions(+), 56 deletions(-) diff --git a/supabase/migrations/20260725112951_register_billing_period_stats_email_cron.sql b/supabase/migrations/20260725112951_register_billing_period_stats_email_cron.sql index 0b89a210c2..e43777cb1a 100644 --- a/supabase/migrations/20260725112951_register_billing_period_stats_email_cron.sql +++ b/supabase/migrations/20260725112951_register_billing_period_stats_email_cron.sql @@ -21,18 +21,38 @@ AS $$ AND c.applied_at < p_end; $$; -ALTER FUNCTION public.get_org_credits_used_in_period(uuid, timestamptz, timestamptz) OWNER TO postgres; -REVOKE ALL ON FUNCTION public.get_org_credits_used_in_period(uuid, timestamptz, timestamptz) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.get_org_credits_used_in_period(uuid, timestamptz, timestamptz) FROM anon; -REVOKE ALL ON FUNCTION public.get_org_credits_used_in_period(uuid, timestamptz, timestamptz) FROM authenticated; -GRANT ALL ON FUNCTION public.get_org_credits_used_in_period(uuid, timestamptz, timestamptz) TO service_role; +ALTER FUNCTION public.get_org_credits_used_in_period( + uuid, + timestamptz, + timestamptz +) OWNER TO postgres; +REVOKE ALL ON FUNCTION public.get_org_credits_used_in_period( + uuid, + timestamptz, + timestamptz +) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_org_credits_used_in_period( + uuid, + timestamptz, + timestamptz +) FROM anon; +REVOKE ALL ON FUNCTION public.get_org_credits_used_in_period( + uuid, + timestamptz, + timestamptz +) FROM authenticated; +GRANT ALL ON FUNCTION public.get_org_credits_used_in_period( + uuid, + timestamptz, + timestamptz +) TO service_role; -- Stripe-style day-of-month clamping for billing anniversary detection. -- Returns the completed cycle ending on p_as_of when that day is the -- (clamped) anniversary; otherwise is_anniversary is false. CREATE OR REPLACE FUNCTION public.billing_period_completed_cycle( p_anchor_start timestamptz, - p_as_of date DEFAULT CURRENT_DATE + p_as_of date DEFAULT ((now() AT TIME ZONE 'UTC')::date) ) RETURNS TABLE ( is_anniversary boolean, @@ -44,28 +64,56 @@ STABLE SET search_path = '' AS $$ DECLARE + v_as_of date; + v_anchor_utc timestamp; v_anchor_dom integer; + v_anchor_hour integer; + v_anchor_min integer; + v_anchor_sec double precision; v_this_month_last integer; v_prev_month_last integer; v_this_anniv_dom integer; v_prev_anniv_dom integer; + v_prev_month date; BEGIN - v_anchor_dom := COALESCE(EXTRACT(DAY FROM p_anchor_start)::integer, 1); + -- Calendar math is UTC so session TimeZone cannot shift anniversary days. + v_as_of := COALESCE(p_as_of, (now() AT TIME ZONE 'UTC')::date); + v_anchor_utc := p_anchor_start AT TIME ZONE 'UTC'; + v_anchor_dom := COALESCE(EXTRACT(DAY FROM v_anchor_utc)::integer, 1); + v_anchor_hour := EXTRACT(HOUR FROM v_anchor_utc)::integer; + v_anchor_min := EXTRACT(MINUTE FROM v_anchor_utc)::integer; + v_anchor_sec := EXTRACT(SECOND FROM v_anchor_utc); v_this_month_last := EXTRACT( - DAY FROM (date_trunc('month', p_as_of) + interval '1 month - 1 day') + DAY FROM (date_trunc('month', v_as_of) + interval '1 month - 1 day') )::integer; v_prev_month_last := EXTRACT( - DAY FROM (date_trunc('month', p_as_of) - interval '1 day') + DAY FROM (date_trunc('month', v_as_of) - interval '1 day') )::integer; v_this_anniv_dom := LEAST(v_anchor_dom, v_this_month_last); v_prev_anniv_dom := LEAST(v_anchor_dom, v_prev_month_last); - is_anniversary := EXTRACT(DAY FROM p_as_of)::integer = v_this_anniv_dom; + is_anniversary := EXTRACT(DAY FROM v_as_of)::integer = v_this_anniv_dom; IF is_anniversary THEN - cycle_start := date_trunc('month', p_as_of - interval '1 month') - + ((v_prev_anniv_dom - 1) || ' days')::interval; - cycle_end := date_trunc('month', p_as_of) - + ((v_this_anniv_dom - 1) || ' days')::interval; + v_prev_month := (date_trunc('month', v_as_of) - interval '1 month')::date; + -- Keep Stripe anchor time-of-day for half-open credit bounds. + cycle_start := make_timestamptz( + EXTRACT(YEAR FROM v_prev_month)::integer, + EXTRACT(MONTH FROM v_prev_month)::integer, + v_prev_anniv_dom, + v_anchor_hour, + v_anchor_min, + v_anchor_sec, + 'UTC' + ); + cycle_end := make_timestamptz( + EXTRACT(YEAR FROM v_as_of)::integer, + EXTRACT(MONTH FROM v_as_of)::integer, + v_this_anniv_dom, + v_anchor_hour, + v_anchor_min, + v_anchor_sec, + 'UTC' + ); ELSE cycle_start := NULL; cycle_end := NULL; @@ -75,11 +123,26 @@ BEGIN END; $$; -ALTER FUNCTION public.billing_period_completed_cycle(timestamptz, date) OWNER TO postgres; -REVOKE ALL ON FUNCTION public.billing_period_completed_cycle(timestamptz, date) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.billing_period_completed_cycle(timestamptz, date) FROM anon; -REVOKE ALL ON FUNCTION public.billing_period_completed_cycle(timestamptz, date) FROM authenticated; -GRANT ALL ON FUNCTION public.billing_period_completed_cycle(timestamptz, date) TO service_role; +ALTER FUNCTION public.billing_period_completed_cycle( + timestamptz, + date +) OWNER TO postgres; +REVOKE ALL ON FUNCTION public.billing_period_completed_cycle( + timestamptz, + date +) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.billing_period_completed_cycle( + timestamptz, + date +) FROM anon; +REVOKE ALL ON FUNCTION public.billing_period_completed_cycle( + timestamptz, + date +) FROM authenticated; +GRANT ALL ON FUNCTION public.billing_period_completed_cycle( + timestamptz, + date +) TO service_role; CREATE OR REPLACE FUNCTION public.process_billing_period_stats_email() RETURNS void @@ -105,7 +168,7 @@ BEGIN INTO v_cycle FROM public.billing_period_completed_cycle( org_record.subscription_anchor_start, - CURRENT_DATE + (now() AT TIME ZONE 'UTC')::date ); IF v_cycle.is_anniversary THEN @@ -128,12 +191,16 @@ BEGIN END; $$; -ALTER FUNCTION public.process_billing_period_stats_email() OWNER TO postgres; - -REVOKE ALL ON FUNCTION public.process_billing_period_stats_email() FROM PUBLIC; -REVOKE ALL ON FUNCTION public.process_billing_period_stats_email() FROM anon; -REVOKE ALL ON FUNCTION public.process_billing_period_stats_email() FROM authenticated; -GRANT ALL ON FUNCTION public.process_billing_period_stats_email() TO service_role; +ALTER FUNCTION public.process_billing_period_stats_email() +OWNER TO postgres; +REVOKE ALL ON FUNCTION public.process_billing_period_stats_email() +FROM PUBLIC; +REVOKE ALL ON FUNCTION public.process_billing_period_stats_email() +FROM anon; +REVOKE ALL ON FUNCTION public.process_billing_period_stats_email() +FROM authenticated; +GRANT ALL ON FUNCTION public.process_billing_period_stats_email() +TO service_role; INSERT INTO public.cron_tasks ( name, diff --git a/supabase/tests/63_test_billing_period_stats_email.sql b/supabase/tests/63_test_billing_period_stats_email.sql index 5db2da0a71..a87632fcc4 100644 --- a/supabase/tests/63_test_billing_period_stats_email.sql +++ b/supabase/tests/63_test_billing_period_stats_email.sql @@ -3,7 +3,7 @@ -- anniversary day, and credits sums use half-open period bounds. BEGIN; -SELECT plan(12); +SELECT plan(13); SELECT ok( to_regprocedure( @@ -70,7 +70,7 @@ SELECT now() FROM billing_period_stats_context; --- Anchor day = today so the processor should queue an email +-- Anchor DOM = today (UTC). Use January so day 1-31 is always valid. INSERT INTO public.stripe_info ( customer_id, status, @@ -84,11 +84,27 @@ SELECT 'succeeded', 'prod_LQIregjtNduh4q', 'sub_billing_period_stats_test', - date_trunc('month', now()) - + ((EXTRACT(DAY FROM CURRENT_DATE)::int - 1) || ' days')::interval, - date_trunc('month', now()) - + ((EXTRACT(DAY FROM CURRENT_DATE)::int - 1) || ' days')::interval - + interval '1 month' + make_timestamptz( + 2024, + 1, + EXTRACT(DAY FROM (now() AT TIME ZONE 'UTC'))::int, + 15, + 0, + 0, + 'UTC' + ), + make_timestamptz( + 2024, + 2, + LEAST( + EXTRACT(DAY FROM (now() AT TIME ZONE 'UTC'))::int, + 29 + ), + 15, + 0, + 0, + 'UTC' + ) FROM billing_period_stats_context; INSERT INTO public.orgs ( @@ -193,9 +209,25 @@ SELECT ok( (message -> 'payload' ->> 'cycleStart') IS NOT NULL AND (message -> 'payload' ->> 'cycleEnd') IS NOT NULL AND (message -> 'payload' ->> 'cycleEnd')::timestamptz::date - = CURRENT_DATE + = (now() AT TIME ZONE 'UTC')::date AND (message -> 'payload' ->> 'cycleStart')::timestamptz < (message -> 'payload' ->> 'cycleEnd')::timestamptz + AND (message -> 'payload' ->> 'cycleEnd')::timestamptz + = make_timestamptz( + EXTRACT( + YEAR FROM (now() AT TIME ZONE 'UTC') + )::int, + EXTRACT( + MONTH FROM (now() AT TIME ZONE 'UTC') + )::int, + EXTRACT( + DAY FROM (now() AT TIME ZONE 'UTC') + )::int, + 15, + 0, + 0, + 'UTC' + ) FROM pgmq.q_cron_email WHERE message -> 'payload' ->> 'orgId' @@ -203,33 +235,40 @@ SELECT ok( AND message -> 'payload' ->> 'type' = 'billing_period_stats' LIMIT 1 ), - 'payload includes completed cycle dates ending today' + 'payload includes completed cycle dates ending today with anchor time' ); -- Move the org off today's anniversary and confirm it is not queued UPDATE public.stripe_info SET - subscription_anchor_start - = date_trunc('month', now()) - + ( - ( - CASE - WHEN EXTRACT(DAY FROM CURRENT_DATE)::int = 1 THEN 1 - ELSE 0 - END - ) || ' days' - )::interval, - subscription_anchor_end - = date_trunc('month', now()) - + ( - ( - CASE - WHEN EXTRACT(DAY FROM CURRENT_DATE)::int = 1 THEN 1 - ELSE 0 - END - ) || ' days' - )::interval - + interval '1 month' + subscription_anchor_start = make_timestamptz( + 2024, + 1, + CASE + WHEN EXTRACT( + DAY FROM (now() AT TIME ZONE 'UTC') + )::int = 1 THEN 2 + ELSE 1 + END, + 15, + 0, + 0, + 'UTC' + ), + subscription_anchor_end = make_timestamptz( + 2024, + 2, + CASE + WHEN EXTRACT( + DAY FROM (now() AT TIME ZONE 'UTC') + )::int = 1 THEN 2 + ELSE 1 + END, + 15, + 0, + 0, + 'UTC' + ) WHERE customer_id = ( SELECT customer_id FROM billing_period_stats_context ); @@ -314,6 +353,18 @@ SELECT is( 'Mar 31 completed cycle starts Feb 28' ); +SELECT is( + ( + SELECT cycle_end::date + FROM public.billing_period_completed_cycle( + '2026-01-31 00:00:00+00'::timestamptz, + '2026-03-31'::date + ) + ), + '2026-03-31'::date, + 'Mar 31 completed cycle ends Mar 31' +); + SELECT * FROM finish(); ROLLBACK; From 81c4e9492affe119d76e428b40936eb7dce69092 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 13:21:53 +0000 Subject: [PATCH 14/18] style(db): wrap billing period stats migration comments Keep SQLFluff line length under the configured limit. Co-authored-by: Martin DONADIEU --- ...12951_register_billing_period_stats_email_cron.sql | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/supabase/migrations/20260725112951_register_billing_period_stats_email_cron.sql b/supabase/migrations/20260725112951_register_billing_period_stats_email_cron.sql index e43777cb1a..5ef6b2fd8e 100644 --- a/supabase/migrations/20260725112951_register_billing_period_stats_email_cron.sql +++ b/supabase/migrations/20260725112951_register_billing_period_stats_email_cron.sql @@ -1,7 +1,8 @@ --- Re-register billing period stats email after it was dropped from the cron path. --- Original feature (#1335) called process_billing_period_stats_email() from --- process_all_cron_tasks at 12:00 UTC. The cron_tasks table refactor never added --- a row for it, so org:billing_period_stats was never queued. +-- Re-register billing period stats email after it was dropped from the +-- cron path. Original feature (#1335) called +-- process_billing_period_stats_email() from process_all_cron_tasks at +-- 12:00 UTC. The cron_tasks table refactor never added a row for it, so +-- org:billing_period_stats was never queued. CREATE OR REPLACE FUNCTION public.get_org_credits_used_in_period( p_org_id uuid, @@ -221,7 +222,7 @@ INSERT INTO public.cron_tasks ( healthcheck_url ) VALUES ( 'billing_period_stats_email', - 'Send billing period stats emails on each org billing anniversary (12:00 UTC)', + 'Send billing period stats emails on each org billing anniversary', 'function', 'public.process_billing_period_stats_email()', NULL, From bf8daa7ea332ddfb4c2f21bcc7bf69344f0fe760 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 13:28:30 +0000 Subject: [PATCH 15/18] ci: retrigger after unrelated apikeys shard flake Co-authored-by: Martin DONADIEU From 4cd44bf390503891fa89b6f80b475477aa106e00 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 13:36:19 +0000 Subject: [PATCH 16/18] fix(email): use UTC midnight cycle bounds for noon cron Preserve day-of-month anniversary detection in UTC, but end periods at UTC midnight so the 12:00 UTC job never reports an unfinished cycle when Stripe anchors are later in the day. Co-authored-by: Martin DONADIEU --- ...gister_billing_period_stats_email_cron.sql | 21 +++++++------------ .../63_test_billing_period_stats_email.sql | 10 ++++----- 2 files changed, 12 insertions(+), 19 deletions(-) diff --git a/supabase/migrations/20260725112951_register_billing_period_stats_email_cron.sql b/supabase/migrations/20260725112951_register_billing_period_stats_email_cron.sql index 5ef6b2fd8e..2f4555fc50 100644 --- a/supabase/migrations/20260725112951_register_billing_period_stats_email_cron.sql +++ b/supabase/migrations/20260725112951_register_billing_period_stats_email_cron.sql @@ -68,9 +68,6 @@ DECLARE v_as_of date; v_anchor_utc timestamp; v_anchor_dom integer; - v_anchor_hour integer; - v_anchor_min integer; - v_anchor_sec double precision; v_this_month_last integer; v_prev_month_last integer; v_this_anniv_dom integer; @@ -78,12 +75,11 @@ DECLARE v_prev_month date; BEGIN -- Calendar math is UTC so session TimeZone cannot shift anniversary days. + -- Bounds are UTC midnight so the daily 12:00 UTC cron always reports a + -- completed [start, end) period (Stripe anchors are often afternoon UTC). v_as_of := COALESCE(p_as_of, (now() AT TIME ZONE 'UTC')::date); v_anchor_utc := p_anchor_start AT TIME ZONE 'UTC'; v_anchor_dom := COALESCE(EXTRACT(DAY FROM v_anchor_utc)::integer, 1); - v_anchor_hour := EXTRACT(HOUR FROM v_anchor_utc)::integer; - v_anchor_min := EXTRACT(MINUTE FROM v_anchor_utc)::integer; - v_anchor_sec := EXTRACT(SECOND FROM v_anchor_utc); v_this_month_last := EXTRACT( DAY FROM (date_trunc('month', v_as_of) + interval '1 month - 1 day') )::integer; @@ -96,23 +92,22 @@ BEGIN is_anniversary := EXTRACT(DAY FROM v_as_of)::integer = v_this_anniv_dom; IF is_anniversary THEN v_prev_month := (date_trunc('month', v_as_of) - interval '1 month')::date; - -- Keep Stripe anchor time-of-day for half-open credit bounds. cycle_start := make_timestamptz( EXTRACT(YEAR FROM v_prev_month)::integer, EXTRACT(MONTH FROM v_prev_month)::integer, v_prev_anniv_dom, - v_anchor_hour, - v_anchor_min, - v_anchor_sec, + 0, + 0, + 0, 'UTC' ); cycle_end := make_timestamptz( EXTRACT(YEAR FROM v_as_of)::integer, EXTRACT(MONTH FROM v_as_of)::integer, v_this_anniv_dom, - v_anchor_hour, - v_anchor_min, - v_anchor_sec, + 0, + 0, + 0, 'UTC' ); ELSE diff --git a/supabase/tests/63_test_billing_period_stats_email.sql b/supabase/tests/63_test_billing_period_stats_email.sql index a87632fcc4..b931cc4a04 100644 --- a/supabase/tests/63_test_billing_period_stats_email.sql +++ b/supabase/tests/63_test_billing_period_stats_email.sql @@ -208,10 +208,6 @@ SELECT ok( SELECT (message -> 'payload' ->> 'cycleStart') IS NOT NULL AND (message -> 'payload' ->> 'cycleEnd') IS NOT NULL - AND (message -> 'payload' ->> 'cycleEnd')::timestamptz::date - = (now() AT TIME ZONE 'UTC')::date - AND (message -> 'payload' ->> 'cycleStart')::timestamptz - < (message -> 'payload' ->> 'cycleEnd')::timestamptz AND (message -> 'payload' ->> 'cycleEnd')::timestamptz = make_timestamptz( EXTRACT( @@ -223,11 +219,13 @@ SELECT ok( EXTRACT( DAY FROM (now() AT TIME ZONE 'UTC') )::int, - 15, + 0, 0, 0, 'UTC' ) + AND (message -> 'payload' ->> 'cycleStart')::timestamptz + < (message -> 'payload' ->> 'cycleEnd')::timestamptz FROM pgmq.q_cron_email WHERE message -> 'payload' ->> 'orgId' @@ -235,7 +233,7 @@ SELECT ok( AND message -> 'payload' ->> 'type' = 'billing_period_stats' LIMIT 1 ), - 'payload includes completed cycle dates ending today with anchor time' + 'payload includes completed cycle ending today at UTC midnight' ); -- Move the org off today's anniversary and confirm it is not queued From abd5a02693ce66585956d6a8c623ca9426656e44 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 13:44:13 +0000 Subject: [PATCH 17/18] test(email): exhaustive backtest for billing period stats cycles Add an independent oracle covering every DOM and day across 2024-2026, plus contiguous month-end pgTAP chains, so anniversary skips, overlaps, and metrics off-by-ones are caught before customers see bad numbers. Co-authored-by: Martin DONADIEU --- .../functions/_backend/triggers/cron_email.ts | 4 +- .../63_test_billing_period_stats_email.sql | 106 +++++- ...billing-period-stats.backtest.unit.test.ts | 339 ++++++++++++++++++ 3 files changed, 447 insertions(+), 2 deletions(-) create mode 100644 tests/billing-period-stats.backtest.unit.test.ts diff --git a/supabase/functions/_backend/triggers/cron_email.ts b/supabase/functions/_backend/triggers/cron_email.ts index 0d44482756..89bb52436b 100644 --- a/supabase/functions/_backend/triggers/cron_email.ts +++ b/supabase/functions/_backend/triggers/cron_email.ts @@ -481,7 +481,9 @@ async function handleBillingPeriodStats(c: Context, _email: string, orgId: strin throw simpleError('cannot_get_metrics', 'Cannot get metrics', { error: metricsError }) } - // Sum credits with original timestamptz bounds (anchors are often not midnight) + // Half-open credit sum on the same timestamptz bounds as the cycle payload. + // Cron path uses UTC midnight bounds so the 12:00 UTC job never under-counts + // a still-open Stripe afternoon anchor. const { data: creditsSum, error: creditsError } = await supabase.rpc( 'get_org_credits_used_in_period', { diff --git a/supabase/tests/63_test_billing_period_stats_email.sql b/supabase/tests/63_test_billing_period_stats_email.sql index b931cc4a04..29c416bd7c 100644 --- a/supabase/tests/63_test_billing_period_stats_email.sql +++ b/supabase/tests/63_test_billing_period_stats_email.sql @@ -3,7 +3,7 @@ -- anniversary day, and credits sums use half-open period bounds. BEGIN; -SELECT plan(13); +SELECT plan(20); SELECT ok( to_regprocedure( @@ -363,6 +363,110 @@ SELECT is( 'Mar 31 completed cycle ends Mar 31' ); +-- Contiguous cycle chain for 31st anchors (no gap/overlap across short months) +SELECT is( + ( + SELECT cycle_end + FROM public.billing_period_completed_cycle( + '2026-01-31 15:00:00+00'::timestamptz, + '2026-02-28'::date + ) + ), + ( + SELECT cycle_start + FROM public.billing_period_completed_cycle( + '2026-01-31 15:00:00+00'::timestamptz, + '2026-03-31'::date + ) + ), + 'Feb cycle_end abuts Mar cycle_start for 31st anchor' +); + +SELECT is( + ( + SELECT cycle_end + FROM public.billing_period_completed_cycle( + '2026-01-31 15:00:00+00'::timestamptz, + '2026-03-31'::date + ) + ), + ( + SELECT cycle_start + FROM public.billing_period_completed_cycle( + '2026-01-31 15:00:00+00'::timestamptz, + '2026-04-30'::date + ) + ), + 'Mar cycle_end abuts Apr cycle_start for 31st anchor' +); + +SELECT is( + ( + SELECT cycle_start::date + FROM public.billing_period_completed_cycle( + '2026-01-31 15:00:00+00'::timestamptz, + '2026-04-30'::date + ) + ), + '2026-03-31'::date, + 'Apr 30 anniversary for 31st anchor starts Mar 31' +); + +SELECT is( + ( + SELECT cycle_end::date + FROM public.billing_period_completed_cycle( + '2026-01-31 15:00:00+00'::timestamptz, + '2026-04-30'::date + ) + ), + '2026-04-30'::date, + 'Apr 30 anniversary for 31st anchor ends Apr 30' +); + +SELECT is( + ( + SELECT cycle_start::date + FROM public.billing_period_completed_cycle( + '2026-01-31 15:00:00+00'::timestamptz, + '2026-05-31'::date + ) + ), + '2026-04-30'::date, + 'May 31 anniversary for 31st anchor starts Apr 30' +); + +SELECT is( + ( + SELECT cycle_end + FROM public.billing_period_completed_cycle( + '2026-01-31 15:00:00+00'::timestamptz, + '2026-04-30'::date + ) + ), + ( + SELECT cycle_start + FROM public.billing_period_completed_cycle( + '2026-01-31 15:00:00+00'::timestamptz, + '2026-05-31'::date + ) + ), + 'Apr cycle_end abuts May cycle_start for 31st anchor' +); + +-- UTC midnight bounds even when Stripe anchor is afternoon +SELECT is( + ( + SELECT cycle_end + FROM public.billing_period_completed_cycle( + '2026-01-15 15:00:00+00'::timestamptz, + '2026-07-15'::date + ) + ), + '2026-07-15 00:00:00+00'::timestamptz, + 'cycle_end is UTC midnight so noon cron reports a finished period' +); + SELECT * FROM finish(); ROLLBACK; diff --git a/tests/billing-period-stats.backtest.unit.test.ts b/tests/billing-period-stats.backtest.unit.test.ts new file mode 100644 index 0000000000..ebac5bc743 --- /dev/null +++ b/tests/billing-period-stats.backtest.unit.test.ts @@ -0,0 +1,339 @@ +/** + * Exhaustive backtest for billing_period_stats cycle math. + * + * Independent oracle (Stripe-style DOM clamping + UTC midnight bounds) is + * compared against a TypeScript port of billing_period_completed_cycle(), then + * against the metrics half-open → inclusive mapping used by cron_email.ts. + * + * Goal: catch skipped months, overlapping periods, inverted ranges, and + * off-by-one metric days before customers see wrong numbers. + */ +import { describe, expect, it } from 'vitest' +import { billingPeriodStatsTestUtils } from '../supabase/functions/_backend/triggers/cron_email.ts' + +const { billingPeriodMetricsRange } = billingPeriodStatsTestUtils + +type Cycle = { + isAnniversary: boolean + cycleStart: string | null + cycleEnd: string | null +} + +function daysInMonthUtc(year: number, month1to12: number): number { + return new Date(Date.UTC(year, month1to12, 0)).getUTCDate() +} + +function pad2(n: number): string { + return String(n).padStart(2, '0') +} + +function utcDateOnly(year: number, month1to12: number, day: number): string { + return `${year}-${pad2(month1to12)}-${pad2(day)}` +} + +function utcMidnightIso(year: number, month1to12: number, day: number): string { + return `${utcDateOnly(year, month1to12, day)}T00:00:00.000Z` +} + +function parseUtcDateOnly(dateOnly: string): { y: number, m: number, d: number } { + const [y, m, d] = dateOnly.split('-').map(Number) + return { y, m, d } +} + +function addMonthsUtc(year: number, month1to12: number, delta: number): { y: number, m: number } { + const idx = year * 12 + (month1to12 - 1) + delta + return { y: Math.floor(idx / 12), m: (idx % 12) + 1 } +} + +/** Independent Stripe-style completed-cycle oracle (UTC midnight bounds). */ +function oracleCompletedCycle(anchorDom: number, asOf: string): Cycle { + const { y, m, d } = parseUtcDateOnly(asOf) + const thisLast = daysInMonthUtc(y, m) + const prev = addMonthsUtc(y, m, -1) + const prevLast = daysInMonthUtc(prev.y, prev.m) + const thisAnniv = Math.min(anchorDom, thisLast) + const prevAnniv = Math.min(anchorDom, prevLast) + if (d !== thisAnniv) { + return { isAnniversary: false, cycleStart: null, cycleEnd: null } + } + return { + isAnniversary: true, + cycleStart: utcMidnightIso(prev.y, prev.m, prevAnniv), + cycleEnd: utcMidnightIso(y, m, thisAnniv), + } +} + +/** + * Port of public.billing_period_completed_cycle() from + * supabase/migrations/20260725112951_register_billing_period_stats_email_cron.sql + * Keep in lockstep with that SQL. + */ +function sqlPortCompletedCycle(anchorStartIso: string, asOf: string): Cycle { + const anchor = new Date(anchorStartIso) + const anchorDom = anchor.getUTCDate() || 1 + const { y, m, d } = parseUtcDateOnly(asOf) + const thisLast = daysInMonthUtc(y, m) + const prev = addMonthsUtc(y, m, -1) + const prevLast = daysInMonthUtc(prev.y, prev.m) + const thisAnniv = Math.min(anchorDom, thisLast) + const prevAnniv = Math.min(anchorDom, prevLast) + const isAnniversary = d === thisAnniv + if (!isAnniversary) { + return { isAnniversary: false, cycleStart: null, cycleEnd: null } + } + return { + isAnniversary: true, + cycleStart: utcMidnightIso(prev.y, prev.m, prevAnniv), + cycleEnd: utcMidnightIso(y, m, thisAnniv), + } +} + +function eachUtcDate(start: string, endInclusive: string): string[] { + const out: string[] = [] + let cur = new Date(`${start}T00:00:00.000Z`) + const end = new Date(`${endInclusive}T00:00:00.000Z`) + while (cur <= end) { + out.push(cur.toISOString().slice(0, 10)) + cur = new Date(cur.getTime() + 24 * 60 * 60 * 1000) + } + return out +} + +function inclusiveDayCount(start: string, end: string): number { + const a = new Date(`${start}T00:00:00.000Z`).getTime() + const b = new Date(`${end}T00:00:00.000Z`).getTime() + return Math.round((b - a) / (24 * 60 * 60 * 1000)) + 1 +} + +function halfOpenCreditSum( + events: Array<{ at: string, credits: number }>, + startIso: string, + endIso: string, +): number { + const start = Date.parse(startIso) + const end = Date.parse(endIso) + return events + .filter(e => Date.parse(e.at) >= start && Date.parse(e.at) < end) + .reduce((sum, e) => sum + e.credits, 0) +} + +describe('billing_period_stats exhaustive backtest', () => { + it('sql port matches independent oracle for every day 2024-01-01..2026-12-31 and DOM 1-31', () => { + const mismatches: string[] = [] + let anniversaryCount = 0 + + for (const asOf of eachUtcDate('2024-01-01', '2026-12-31')) { + for (let dom = 1; dom <= 31; dom++) { + // Anchor in a month that always has this DOM (January). + const anchorIso = utcMidnightIso(2024, 1, Math.min(dom, 31)) + // For DOM>days in Jan we still only test 1-31; Jan has 31. + const oracle = oracleCompletedCycle(dom, asOf) + const port = sqlPortCompletedCycle(anchorIso, asOf) + if ( + oracle.isAnniversary !== port.isAnniversary + || oracle.cycleStart !== port.cycleStart + || oracle.cycleEnd !== port.cycleEnd + ) { + mismatches.push(`dom=${dom} asOf=${asOf} oracle=${JSON.stringify(oracle)} port=${JSON.stringify(port)}`) + } + if (oracle.isAnniversary) + anniversaryCount++ + } + } + + expect(mismatches.slice(0, 20)).toEqual([]) + // 31 DOMs × 36 months = 1116 anniversary emails across 3 years + expect(anniversaryCount).toBe(31 * 36) + }) + + it('never skips a calendar month for any DOM (including 29-31)', () => { + const gaps: string[] = [] + + for (let dom = 1; dom <= 31; dom++) { + const months = new Set() + for (const asOf of eachUtcDate('2024-01-01', '2026-12-31')) { + const cycle = oracleCompletedCycle(dom, asOf) + if (cycle.isAnniversary && cycle.cycleEnd) { + months.add(cycle.cycleEnd.slice(0, 7)) + } + } + for (let y = 2024; y <= 2026; y++) { + for (let m = 1; m <= 12; m++) { + const key = `${y}-${pad2(m)}` + if (!months.has(key)) + gaps.push(`dom=${dom} missing month ${key}`) + } + } + } + + expect(gaps).toEqual([]) + }) + + it('consecutive completed cycles abut with no gap or overlap', () => { + const breaks: string[] = [] + + for (let dom = 1; dom <= 31; dom++) { + const cycles: Array<{ start: string, end: string }> = [] + for (const asOf of eachUtcDate('2024-01-01', '2026-12-31')) { + const cycle = oracleCompletedCycle(dom, asOf) + if (cycle.isAnniversary && cycle.cycleStart && cycle.cycleEnd) { + cycles.push({ start: cycle.cycleStart, end: cycle.cycleEnd }) + } + } + cycles.sort((a, b) => a.end.localeCompare(b.end)) + for (let i = 1; i < cycles.length; i++) { + if (cycles[i - 1]!.end !== cycles[i]!.start) { + breaks.push( + `dom=${dom} prevEnd=${cycles[i - 1]!.end} nextStart=${cycles[i]!.start}`, + ) + } + } + } + + expect(breaks.slice(0, 20)).toEqual([]) + }) + + it('metrics inclusive ranges cover every day in [start, end) and only those days', () => { + const bad: string[] = [] + + for (const dom of [1, 15, 28, 29, 30, 31]) { + for (const asOf of eachUtcDate('2024-01-01', '2026-12-31')) { + const cycle = oracleCompletedCycle(dom, asOf) + if (!cycle.isAnniversary || !cycle.cycleStart || !cycle.cycleEnd) + continue + + const range = billingPeriodMetricsRange(cycle.cycleStart, cycle.cycleEnd) + const days = inclusiveDayCount(range.periodStart, range.metricsEndInclusive) + if (days < 28 || days > 31) { + bad.push(`dom=${dom} asOf=${asOf} days=${days} range=${JSON.stringify(range)}`) + } + + // Half-open [start, end) day count must equal inclusive metrics days. + const halfOpenDays = Math.round( + (Date.parse(cycle.cycleEnd) - Date.parse(cycle.cycleStart)) / (24 * 60 * 60 * 1000), + ) + if (halfOpenDays !== days) { + bad.push(`dom=${dom} asOf=${asOf} halfOpen=${halfOpenDays} inclusive=${days}`) + } + + // End day itself must NOT be in metrics (belongs to next cycle). + if (range.metricsEndInclusive >= range.periodEndExclusive) { + bad.push(`dom=${dom} asOf=${asOf} metrics includes exclusive end`) + } + } + } + + expect(bad.slice(0, 20)).toEqual([]) + }) + + it('known Stripe-style month-end cases match expected completed cycles', () => { + const cases: Array<{ + dom: number + asOf: string + start: string + end: string + }> = [ + { dom: 31, asOf: '2026-01-31', start: '2025-12-31', end: '2026-01-31' }, + { dom: 31, asOf: '2026-02-28', start: '2026-01-31', end: '2026-02-28' }, + { dom: 31, asOf: '2026-03-31', start: '2026-02-28', end: '2026-03-31' }, + { dom: 31, asOf: '2026-04-30', start: '2026-03-31', end: '2026-04-30' }, + { dom: 31, asOf: '2026-05-31', start: '2026-04-30', end: '2026-05-31' }, + { dom: 31, asOf: '2024-02-29', start: '2024-01-31', end: '2024-02-29' }, + { dom: 31, asOf: '2024-03-31', start: '2024-02-29', end: '2024-03-31' }, + { dom: 30, asOf: '2026-02-28', start: '2026-01-30', end: '2026-02-28' }, + { dom: 30, asOf: '2026-03-30', start: '2026-02-28', end: '2026-03-30' }, + { dom: 29, asOf: '2025-02-28', start: '2025-01-29', end: '2025-02-28' }, + { dom: 15, asOf: '2026-07-15', start: '2026-06-15', end: '2026-07-15' }, + { dom: 1, asOf: '2026-03-01', start: '2026-02-01', end: '2026-03-01' }, + ] + + for (const c of cases) { + const cycle = sqlPortCompletedCycle(utcMidnightIso(2024, 1, c.dom), c.asOf) + expect(cycle, JSON.stringify(c)).toEqual({ + isAnniversary: true, + cycleStart: `${c.start}T00:00:00.000Z`, + cycleEnd: `${c.end}T00:00:00.000Z`, + }) + const metrics = billingPeriodMetricsRange(cycle.cycleStart!, cycle.cycleEnd!) + expect(metrics.periodStart).toBe(c.start) + expect(metrics.periodEndExclusive).toBe(c.end) + expect(metrics.metricsEndInclusive < c.end).toBe(true) + } + }) + + it('does not fire on non-anniversary days for month-end anchors', () => { + // 31st anchor must NOT email on Mar 28/29/30 — only Mar 31 (and Feb 28). + for (const asOf of ['2026-03-28', '2026-03-29', '2026-03-30']) { + expect(oracleCompletedCycle(31, asOf).isAnniversary).toBe(false) + } + expect(oracleCompletedCycle(31, '2026-03-31').isAnniversary).toBe(true) + expect(oracleCompletedCycle(31, '2026-02-27').isAnniversary).toBe(false) + expect(oracleCompletedCycle(31, '2026-02-28').isAnniversary).toBe(true) + }) + + it('credit half-open sum excludes end boundary and includes start', () => { + const start = '2026-06-15T00:00:00.000Z' + const end = '2026-07-15T00:00:00.000Z' + const events = [ + { at: '2026-06-14T23:59:59.999Z', credits: 100 }, // before + { at: '2026-06-15T00:00:00.000Z', credits: 1.5 }, // start incl + { at: '2026-07-14T12:00:00.000Z', credits: 2.5 }, // mid + { at: '2026-07-15T00:00:00.000Z', credits: 9 }, // end excl + { at: '2026-07-15T15:00:00.000Z', credits: 50 }, // after + ] + expect(halfOpenCreditSum(events, start, end)).toBe(4) + }) + + it('noon UTC cron always reports a cycle that already ended (no future cycle_end)', () => { + const cronHourUtc = 12 + const futureEnds: string[] = [] + + for (const dom of [1, 15, 28, 29, 30, 31]) { + for (const asOf of eachUtcDate('2024-01-01', '2026-12-31')) { + const cycle = oracleCompletedCycle(dom, asOf) + if (!cycle.isAnniversary || !cycle.cycleEnd) + continue + // Cron runs at 12:00 UTC on asOf; cycle_end is asOf 00:00 UTC. + const cronNow = Date.parse(`${asOf}T${pad2(cronHourUtc)}:00:00.000Z`) + const cycleEnd = Date.parse(cycle.cycleEnd) + if (cycleEnd > cronNow) { + futureEnds.push(`dom=${dom} asOf=${asOf} cycleEnd=${cycle.cycleEnd}`) + } + } + } + + expect(futureEnds).toEqual([]) + }) + + it('old get_cycle_info_org day-offset math diverges on month-end (documents why email uses clamp helper)', () => { + // Reproduce the buggy "date_trunc(month) + (anchor - trunc(anchor))" path + // for a 31st anchor in February — proves the email must NOT use that path. + const anchor = new Date('2026-01-31T15:00:00.000Z') + const anchorDayMs = anchor.getTime() - Date.UTC(2026, 0, 1) + const febStart = Date.UTC(2026, 1, 1) + const buggy = new Date(febStart + anchorDayMs) + // Feb 1 + 30 days = Mar 3 — not a valid Feb anniversary. + expect(buggy.toISOString().slice(0, 10)).toBe('2026-03-03') + + const clamped = oracleCompletedCycle(31, '2026-02-28') + expect(clamped.isAnniversary).toBe(true) + expect(clamped.cycleEnd).toBe('2026-02-28T00:00:00.000Z') + }) + + it('synthetic daily metrics: inclusive range sums only in-cycle days', () => { + // Simulate daily bandwidth 1 per day; email must report exact day count. + const cycle = oracleCompletedCycle(15, '2026-07-15') + const range = billingPeriodMetricsRange(cycle.cycleStart!, cycle.cycleEnd!) + const allDays = eachUtcDate('2026-06-01', '2026-07-31') + let sum = 0 + for (const day of allDays) { + if (day >= range.periodStart && day <= range.metricsEndInclusive) + sum += 1 + } + expect(sum).toBe(inclusiveDayCount(range.periodStart, range.metricsEndInclusive)) + expect(sum).toBe(30) // Jun 15 .. Jul 14 inclusive + // Day of cycle end must not be counted + expect(range.periodEndExclusive).toBe('2026-07-15') + expect(range.metricsEndInclusive).toBe('2026-07-14') + }) +}) From ae5d90f2a25925d7635ca4019da0a4f6c1192535 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 13:51:00 +0000 Subject: [PATCH 18/18] ci: retrigger after unrelated cloudflare worker startup flake Co-authored-by: Martin DONADIEU