AkurAI Build
Menu

Public repository / d11e160f26a6dfe754f18d1060107de9d6b6b898

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;
}