5508e15da1
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
106 lines
3.5 KiB
TypeScript
106 lines
3.5 KiB
TypeScript
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),
|
|
}));
|
|
}
|