Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
ff3f404
fix(email): re-register billing_period_stats cron and correct period …
cursoragent Jul 25, 2026
de0e979
fix(test): harden billing_period_stats pgTAP and revoke anon grants
cursoragent Jul 25, 2026
34f2bfd
ci: retrigger backend shard after flaky queue_load timeout
cursoragent Jul 25, 2026
7403144
ci: retrigger after flaky apikeys shard failure
cursoragent Jul 25, 2026
ec854e3
fix(email): address billing_period_stats review feedback
cursoragent Jul 25, 2026
4a4f87d
fix(email): use timestamptz bounds for billing period credits
cursoragent Jul 25, 2026
7241c51
fix(email): prefer const for billing period date locals
cursoragent Jul 25, 2026
05028c2
ci: retrigger after cancelled concurrent runs
cursoragent Jul 25, 2026
757479e
fix(email): clamp billing anniversary to month last day
cursoragent Jul 25, 2026
04c51c2
ci: retrigger after flaky 502s on backend shard 6
cursoragent Jul 25, 2026
f8e53da
fix(email): extract testable billing_period_completed_cycle helper
cursoragent Jul 25, 2026
479f2e5
ci: retrigger after unrelated queue_load/stats flakes
cursoragent Jul 25, 2026
13da03e
fix(email): make billing period cycle math UTC-safe
cursoragent Jul 25, 2026
81c4e94
style(db): wrap billing period stats migration comments
cursoragent Jul 25, 2026
bf8daa7
ci: retrigger after unrelated apikeys shard flake
cursoragent Jul 25, 2026
4cd44bf
fix(email): use UTC midnight cycle bounds for noon cron
cursoragent Jul 25, 2026
abd5a02
test(email): exhaustive backtest for billing period stats cycles
cursoragent Jul 25, 2026
ae5d90f
ci: retrigger after unrelated cloudflare worker startup flake
cursoragent Jul 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 79 additions & 24 deletions supabase/functions/_backend/triggers/cron_email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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()
Expand All @@ -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()

Expand All @@ -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`,
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
p_end: `${periodEndExclusive}T00:00:00.000Z`,
},
)

if (creditsError) {
Comment thread
cursor[bot] marked this conversation as resolved.
cloudlogErr({ requestId: c.get('requestId'), message: 'Cannot get credits used', error: creditsError, metadata: { orgId } })
}
else {
creditsUsed = Number(creditsSum || 0)
}
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

// Format the metrics for the email
Expand Down Expand Up @@ -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,
Expand All @@ -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(),
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
-- 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;

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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

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;
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 (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();
Loading
Loading