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:
@@ -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),
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user