Public Access
1
0

feat: launch Stripe billing flows with lifecycle hardening and analytics

add Stripe checkout, portal, webhook ingestion, and idempotent event persistence

add billing lifecycle state (grace/sync/timeline/admin visibility) and stronger entitlement handling

add analytics event tracking and admin summary APIs plus account/pricing UI integration
This commit is contained in:
pguerrerox
2026-05-22 22:55:04 +00:00
parent 94b8c357b4
commit 5508e15da1
35 changed files with 2851 additions and 50 deletions
+105
View File
@@ -0,0 +1,105 @@
import type { Pool, PoolClient } from 'pg';
import type { AnalyticsEventInput } from '../../../shared/analytics/events.js';
import type { AnalyticsMetricBucket } from '../../../shared/types.js';
type DbClient = Pool | PoolClient;
type AnalyticsBucketRow = {
key: string;
count: string;
};
export async function insertAnalyticsEvent(db: DbClient, input: AnalyticsEventInput) {
await db.query(
`
insert into public.analytics_events (
event_name,
event_source,
user_id,
workspace_id,
plan_code,
addon_code,
resource,
amount,
currency,
metadata_json,
occurred_at
)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, coalesce($11::timestamptz, now()))
`,
[
input.eventName,
input.eventSource,
input.userId ?? null,
input.workspaceId ?? null,
input.planCode ?? null,
input.addonCode ?? null,
input.resource ?? null,
input.amount ?? null,
input.currency ?? null,
input.metadata ?? {},
input.occurredAt ?? null,
],
);
}
export function listAnalyticsCountsByEvent(db: DbClient, sinceIso: string): Promise<AnalyticsMetricBucket[]> {
return listBuckets(db, sinceIso, 'event_name', `event_name`);
}
export function listPricingPlanSelectionCounts(db: DbClient, sinceIso: string): Promise<AnalyticsMetricBucket[]> {
return listBuckets(db, sinceIso, `coalesce(plan_code, metadata_json->>'planCode', 'unknown')`, `event_name = 'pricing_plan_selected'`);
}
export function listQuotaExhaustionCounts(db: DbClient, sinceIso: string): Promise<AnalyticsMetricBucket[]> {
return listBuckets(db, sinceIso, `coalesce(resource, metadata_json->>'resource', 'unknown')`, `event_name = 'quota_exhausted_blocked'`);
}
export function listUpgradeTriggerCounts(db: DbClient, sinceIso: string): Promise<AnalyticsMetricBucket[]> {
return listBuckets(
db,
sinceIso,
`coalesce(metadata_json->>'denialReason', event_name)`,
`event_name in ('quota_exhausted_blocked', 'feature_gate_encountered')`,
);
}
export function listAddonAttachCounts(db: DbClient, sinceIso: string): Promise<AnalyticsMetricBucket[]> {
return listBuckets(db, sinceIso, `coalesce(addon_code, metadata_json->>'addonCode', 'unknown')`, `event_name = 'addon_purchase_completed'`);
}
export function listPlanMixCounts(db: DbClient, sinceIso: string): Promise<AnalyticsMetricBucket[]> {
return listBuckets(db, sinceIso, `coalesce(plan_code, metadata_json->>'planCode', 'unknown')`, `event_name in ('checkout_completed', 'plan_changed')`);
}
export function listChurnSignalCounts(db: DbClient, sinceIso: string): Promise<AnalyticsMetricBucket[]> {
return listBuckets(db, sinceIso, `event_name`, `event_name in ('subscription_canceled', 'payment_failed')`);
}
export function listExpansionSignalCounts(db: DbClient, sinceIso: string): Promise<AnalyticsMetricBucket[]> {
return listBuckets(db, sinceIso, `event_name`, `event_name in ('checkout_completed', 'addon_purchase_completed', 'plan_changed')`);
}
async function listBuckets(
db: DbClient,
sinceIso: string,
keyExpression: string,
filterSql: string,
): Promise<AnalyticsMetricBucket[]> {
const result = await db.query<AnalyticsBucketRow>(
`
select ${keyExpression} as key, count(*)::text as count
from public.analytics_events
where occurred_at >= $1
and ${filterSql}
group by 1
order by count(*) desc, 1 asc
`,
[sinceIso],
);
return result.rows.map((row) => ({
key: row.key,
count: Number(row.count),
}));
}