Menu
Public repository / a24773c811c1b5b630eaf15189ac4d0dca60194e
AkurAI-Platform / payment/events.ts
Latest change 007e309b869763b2572a750997e9d9cb62ab172c - feat: define and emit canonical lifecycle and growth events (AKURAI-PLATFORM-24) by Ólafur Búi Ólafsson
/**
* payment/events.ts — Canonical customer lifecycle and growth events
*
* Defines versioned acquisition-to-expansion events emitted by the platform.
* Each event carries the full identity, attribution, consent, and timestamp
* context needed for the CRM growth read model.
*
* Event versioning: every event shape is pinned to a semantic schema version.
* Breaking changes increment the version; new optional fields preserve it.
*
* @version 1
*/
// ── Event version ──────────────────────────────────────────────────────
export const GROWTH_EVENT_SCHEMA_VERSION = 1;
// ── Canonical event names ──────────────────────────────────────────────
/**
* Canonical customer lifecycle events covering the entire acquisition-to-
* expansion lifecycle. Every event name is version-qualified for schema
* evolution — e.g. "paid/v1" — so consumers can reject shapes they don't
* understand.
*/
export type GrowthEventName =
| "visit/v1"
| "source/v1"
| "contact/v1"
| "demo_requested/v1"
| "waitlist_joined/v1"
| "qualified/v1"
| "trial_requested/v1"
| "provisioned/v1"
| "first_value/v1"
| "paid/v1"
| "retained/v1"
| "expanded/v1"
| "canceled/v1"
| "churned/v1";
// ── Lifecycle stage ────────────────────────────────────────────────────
export type GrowthStage =
| "acquisition"
| "activation"
| "retention"
| "expansion"
| "churn";
const EVENT_STAGE: Record<GrowthEventName, GrowthStage> = {
"visit/v1": "acquisition",
"source/v1": "acquisition",
"contact/v1": "acquisition",
"demo_requested/v1": "acquisition",
"waitlist_joined/v1": "acquisition",
"qualified/v1": "acquisition",
"trial_requested/v1": "activation",
"provisioned/v1": "activation",
"first_value/v1": "activation",
"paid/v1": "retention",
"retained/v1": "retention",
"expanded/v1": "expansion",
"canceled/v1": "churn",
"churned/v1": "churn",
};
export function growthStage(event: GrowthEventName): GrowthStage {
return EVENT_STAGE[event];
}
// ── Attribution ────────────────────────────────────────────────────────
/**
* Source and campaign attribution carried on acquisition-stage events.
* Reuses standard UTM parameters plus a referrer URL.
*/
export interface SourceAttribution {
utmSource?: string;
utmMedium?: string;
utmCampaign?: string;
utmTerm?: string;
utmContent?: string;
referrer?: string;
landingPage?: string;
}
// ── Consent record ─────────────────────────────────────────────────────
export interface ConsentRecord {
/** ISO date of consent */
consentedAt: string;
/** Version of the consent / privacy notice shown */
noticeVersion: string;
/** Specific grants — e.g. ["billing", "marketing", "analytics"] */
grants: string[];
/** True if consent was explicitly given (not implied) */
explicit: boolean;
}
// ── Revenue breakdown ──────────────────────────────────────────────────
/**
* Revenue events distinguish financial payment, entitlement grant,
* installation provisioning, and the moment the service is ready.
*
* When a value is unavailable (provider disabled, or not yet known),
* the field is `null` — never 0, which would conflate "free" with
* "unknown".
*/
export interface RevenueDetail {
/** Amount paid in EUR cents; null when payment data is unavailable */
amountCents: number | null;
/** ISO 4217 currency code */
currency: string;
/** Payment provider identifier */
provider: string;
/** Provider transaction reference */
providerRef?: string;
/** Number of products entitled by this payment */
entitlementCount: number;
/** Number of products installed (null until installation is known) */
installationCount: number | null;
/** Number of products ready for use (null until readiness is known) */
readyCount: number | null;
/** True when payment data cannot be determined (provider disabled) */
dataUnavailable: boolean;
}
// ── Canonical growth event ─────────────────────────────────────────────
/**
* A single versioned growth event emitted by the platform.
*
* Every event carries:
* - The canonical event name (version-qualified)
* - The lifecycle stage it belongs to
* - The primary entity IDs (organization, workspace, subscription)
* - Source and campaign attribution (when applicable)
* - Explicit consent record (when applicable)
* - Revenue detail (for paid/retained events)
* - The event's schema version allows consumers to reject unknown shapes
*/
export interface GrowthEvent {
/** Schema version for forward compatibility */
schemaVersion: number;
/** Unique event identifier (idempotency key) */
id: string;
/** Canonical event name, version-qualified */
name: GrowthEventName;
/** Lifecycle stage */
stage: GrowthStage;
/** When the event occurred (ISO 8601) */
occurredAt: string;
// ── Entity IDs ─────────────────────────────────────────────────
/** Account / organization identifier */
organizationId: string;
/** Workspace identifier, when scoped */
workspaceId?: string;
/** Subscription identifier, when subscription-scoped */
subscriptionId?: string;
// ── Identity ───────────────────────────────────────────────────
/** Customer email or user identifier */
customerEmail?: string;
/** Actor user id who triggered the event */
actorUserId?: string;
// ── Attribution (acquisition events) ───────────────────────────
attribution?: SourceAttribution;
// ── Consent ────────────────────────────────────────────────────
consent?: ConsentRecord;
// ── Revenue (payment events) ───────────────────────────────────
revenue?: RevenueDetail;
// ── Product context ────────────────────────────────────────────
/** App/product IDs involved (for trial, paid, expanded) */
productIds?: string[];
/** Billing interval (for subscription events) */
interval?: string;
// ── Metadata ───────────────────────────────────────────────────
/** Extra platform-specific context */
metadata?: Record<string, unknown>;
}
// ── Growh event payload stored in the growth_events table ──────────────
export interface GrowthEventRow {
id: string;
organization_id: string;
workspace_id: string | null;
name: string; // GrowthEventName stored as string
stage: string; // GrowthStage stored as string
occurred_at: string;
schema_version: number;
subscription_id: string | null;
customer_email: string | null;
actor_user_id: string | null;
attribution_json: string | null;
consent_json: string | null;
revenue_json: string | null;
product_ids_json: string | null;
interval: string | null;
metadata_json: string;
created_at: string;
}
// ── Public output shape (API-safe, no internal fields) ─────────────────
export interface PublicGrowthEvent {
id: string;
name: GrowthEventName;
stage: GrowthStage;
occurredAt: string;
schemaVersion: number;
organizationId: string;
workspaceId?: string;
subscriptionId?: string;
customerEmail?: string;
actorUserId?: string;
attribution?: SourceAttribution;
consent?: ConsentRecord;
revenue?: RevenueDetail;
productIds?: string[];
interval?: string;
metadata?: Record<string, unknown>;
}
export function publicGrowthEvent(row: GrowthEventRow): PublicGrowthEvent {
const event: PublicGrowthEvent = {
id: row.id,
name: row.name as GrowthEventName,
stage: row.stage as GrowthStage,
occurredAt: row.occurred_at,
schemaVersion: row.schema_version,
organizationId: row.organization_id,
};
if (row.workspace_id) event.workspaceId = row.workspace_id;
if (row.subscription_id) event.subscriptionId = row.subscription_id;
if (row.customer_email) event.customerEmail = row.customer_email;
if (row.actor_user_id) event.actorUserId = row.actor_user_id;
if (row.attribution_json) event.attribution = JSON.parse(row.attribution_json) as SourceAttribution;
if (row.consent_json) event.consent = JSON.parse(row.consent_json) as ConsentRecord;
if (row.revenue_json) event.revenue = JSON.parse(row.revenue_json) as RevenueDetail;
if (row.product_ids_json) event.productIds = JSON.parse(row.product_ids_json) as string[];
if (row.interval) event.interval = row.interval;
if (row.metadata_json !== "{}") event.metadata = JSON.parse(row.metadata_json) as Record<string, unknown>;
return event;
}