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 8b9546c0d0..89bb52436b 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 cycleStartTs: string + let cycleEndTs: 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) + cycleStartTs = cycleStart + cycleEndTs = 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,30 @@ 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] + cycleStartTs = cycleInfo.subscription_anchor_start + cycleEndTs = cycleInfo.subscription_anchor_end + } + + const { 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', { + periodStart, + periodEndExclusive, + metricsEndInclusive, + }) } - // Get total metrics for the billing period + // 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,18 +481,23 @@ 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 - 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) + // 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', + { + p_org_id: orgId, + p_start: cycleStartTs, + p_end: cycleEndTs, + }, + ) + + 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 }) } + const creditsUsed = Number(creditsSum || 0) // Format the metrics for the email const mau = metrics?.mau ?? 0 @@ -526,15 +581,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 +604,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/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 new file mode 100644 index 0000000000..2f4555fc50 --- /dev/null +++ b/supabase/migrations/20260725112951_register_billing_period_stats_email_cron.sql @@ -0,0 +1,245 @@ +-- 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; +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 ((now() AT TIME ZONE 'UTC')::date) +) +RETURNS TABLE ( + is_anniversary boolean, + cycle_start timestamptz, + cycle_end timestamptz +) +LANGUAGE plpgsql +STABLE +SET search_path = '' +AS $$ +DECLARE + v_as_of date; + v_anchor_utc timestamp; + 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_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_this_month_last := EXTRACT( + DAY FROM (date_trunc('month', v_as_of) + interval '1 month - 1 day') + )::integer; + v_prev_month_last := EXTRACT( + 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 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; + cycle_start := make_timestamptz( + EXTRACT(YEAR FROM v_prev_month)::integer, + EXTRACT(MONTH FROM v_prev_month)::integer, + v_prev_anniv_dom, + 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, + 0, + 0, + 0, + 'UTC' + ); + 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, + 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 + SELECT * + INTO v_cycle + FROM public.billing_period_completed_cycle( + org_record.subscription_anchor_start, + (now() AT TIME ZONE 'UTC')::date + ); + + IF v_cycle.is_anniversary THEN + 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_cycle.cycle_start, + 'cycleEnd', v_cycle.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; +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, + 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', + '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..29c416bd7c --- /dev/null +++ b/supabase/tests/63_test_billing_period_stats_email.sql @@ -0,0 +1,472 @@ +-- 63_test_billing_period_stats_email.sql +-- 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(20); + +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 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, + grant_id uuid +) 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 DOM = today (UTC). Use January so day 1-31 is always valid. +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', + 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 ( + 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; + +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) + 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 + = 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, + 0, + 0, + 0, + 'UTC' + ) + 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 ending today at UTC midnight' +); + +-- Move the org off today's anniversary and confirm it is not queued +UPDATE public.stripe_info +SET + 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 +); + +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' +); + +-- 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 + ) + ), + '31st-anchor is anniversary on Feb 28' +); + +SELECT is( + ( + SELECT cycle_start::date + FROM public.billing_period_completed_cycle( + '2026-01-31 00:00:00+00'::timestamptz, + '2026-02-28'::date + ) + ), + '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 + ) + ), + '2026-02-28'::date, + 'Feb 28 completed cycle ends Feb 28' +); + +SELECT ok( + ( + 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 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' +); + +-- 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') + }) +}) 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', + }) + }) +})