Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
8 changes: 8 additions & 0 deletions cli/src/types/supabase.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
8 changes: 8 additions & 0 deletions src/types/supabase.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
103 changes: 78 additions & 25 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,
))
}

// 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()

Expand All @@ -431,18 +478,21 @@ 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)
// Sum credits in [periodStart, periodEndExclusive) without PostgREST row limits
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`,
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
p_end: `${periodEndExclusive}T00:00:00.000Z`,
},
)

if (creditsError) {
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
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
Expand Down Expand Up @@ -526,15 +576,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 +599,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
8 changes: 8 additions & 0 deletions supabase/functions/_backend/utils/supabase.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
-- 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;
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.
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')
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
+ 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
IF org_record.prev_cycle_end::date = CURRENT_DATE 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', org_record.prev_cycle_start,
'cycleEnd', org_record.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