Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
108 changes: 83 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 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()
Expand All @@ -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()

Expand All @@ -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) {
Comment thread
cursor[bot] marked this conversation as 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 +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,
Expand All @@ -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(),
}

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
Loading
Loading