/*
RFC-0191: Client Lifecycle & Stripe Billing event contracts. A sibling catalog to the
RFC-0188 funnel event kinds — the post-sale lifecycle (subscriptions, invoices, dunning, churn)
carried on the same normalized IntegrationEvent. Stripe is the billing authority; these types are
the canonical mirror. Pure types + constants + a zod payload schema — no I/O, no astro:env, no
Stripe SDK.
- Do not import the Stripe SDK or astro:env — the adapter injects secrets and maps events.
- Do not let UChat or Pipedrive own billing state — Stripe is the authority, Lagebild the mirror.
- Do not reference Make.com anywhere in the billing path (RFC-0188/0191).
- RFC-0191: initial lifecycle event + payload contracts.
- RFC-0219: add SUBSCRIPTION_TRANSITIONS and SUBSCRIPTION_TRANSITION_TRIGGERS — subscription lifecycle graph for the state-chart generator.
*/
import { z } from "zod";
/** Closed catalog of post-sale lifecycle event kinds (RFC-0191). */
export const LIFECYCLE_EVENT_KINDS = [
"invoice.paid",
"invoice.payment_failed",
"subscription.created",
"subscription.updated",
"subscription.canceled",
"payment.refunded",
] as const;
export type LifecycleEventKind = (typeof LIFECYCLE_EVENT_KINDS)[number];
/** Subscription health states (mirror Stripe subscription.status, condensed). */
export const SUBSCRIPTION_STATUSES = ["active", "past_due", "canceled", "paused"] as const;
export type SubscriptionStatus = (typeof SUBSCRIPTION_STATUSES)[number];
/** What an invoice is for — drives the P4/lifecycle Pipedrive routing. */
export const INVOICE_KINDS = ["setup", "cycle", "change", "adhoc"] as const;
export type InvoiceKind = (typeof INVOICE_KINDS)[number];
/** Invoice payment state (mirror Stripe invoice.status, condensed). */
export const INVOICE_STATUSES = ["open", "paid", "uncollectible", "void"] as const;
export type InvoiceStatus = (typeof INVOICE_STATUSES)[number];
/** The plan a subscription runs on (mirrors the offer in business/offer.md). */
export const SUBSCRIPTION_PLANS = [
"digital_foundation_monthly",
"digital_foundation_yearly",
] as const;
export type SubscriptionPlan = (typeof SUBSCRIPTION_PLANS)[number];
/**
* Typed lifecycle payload, carried in `IntegrationEvent.payload.lifecycle`. The Stripe
* adapter (RFC-0191) maps each Stripe webhook event onto this shape; amounts are in the
* smallest currency unit (cents) — Stripe is the authority, the buffer mirrors cents and
* never recomputes prices. The Organization is resolved via `stripeCustomerId` (RFC-0190).
*/
export interface LifecycleEventPayload {
eventKind: LifecycleEventKind;
/** Stripe's own event id — the idempotency key (dedup on redelivery). */
stripeEventId: string;
stripeCustomerId: string;
stripeSubscriptionId?: string;
stripeInvoiceId?: string;
plan?: SubscriptionPlan;
subscriptionStatus?: SubscriptionStatus;
invoiceKind?: InvoiceKind;
invoiceStatus?: InvoiceStatus;
amountCents?: number;
currency?: string;
/** ISO-8601 end of the current billing period (for renewals). */
currentPeriodEnd?: string;
// Spec §08 (Stripe metadata contract): Lagebild refs resolved from the Stripe object's
// metadata so a lifecycle event maps back to the Organization (RFC-0190) and Deal.
lagebildOrganizationId?: string;
lagebildDealId?: string;
siteKey?: string;
/** Line-item price ids (for invoice_kind inference + module/MRR reasoning). */
priceIds?: string[];
/**
* True when a cycle invoice has no `lagebild_*` metadata of its own and must be resolved
* via its Subscription's metadata (spec §08).
*/
needsSubscriptionLookup?: boolean;
/** Monthly recurring revenue (cents), normalized from the subscription's items (spec §08). */
mrrCents?: number;
/** Per-cycle included-change allowance, from Subscription metadata `included_changes_per_cycle`. */
includedChangesPerCycle?: number;
}
/** Zod schema the Stripe adapter validates its mapping against before emitting. */
export const LifecycleEventPayloadSchema = z.object({
eventKind: z.enum(LIFECYCLE_EVENT_KINDS),
stripeEventId: z.string().min(1),
stripeCustomerId: z.string().min(1),
stripeSubscriptionId: z.string().optional(),
stripeInvoiceId: z.string().optional(),
plan: z.enum(SUBSCRIPTION_PLANS).optional(),
subscriptionStatus: z.enum(SUBSCRIPTION_STATUSES).optional(),
invoiceKind: z.enum(INVOICE_KINDS).optional(),
invoiceStatus: z.enum(INVOICE_STATUSES).optional(),
amountCents: z.number().int().nonnegative().optional(),
currency: z.string().optional(),
currentPeriodEnd: z.string().optional(),
lagebildOrganizationId: z.string().optional(),
lagebildDealId: z.string().optional(),
siteKey: z.string().optional(),
priceIds: z.array(z.string()).optional(),
needsSubscriptionLookup: z.boolean().optional(),
mrrCents: z.number().int().nonnegative().optional(),
includedChangesPerCycle: z.number().int().nonnegative().optional(),
});
/** True when `value` is a known lifecycle event kind. */
export function isLifecycleEventKind(value: string): value is LifecycleEventKind {
return (LIFECYCLE_EVENT_KINDS as readonly string[]).includes(value);
}
// ---------------------------------------------------------------------------
// Subscription lifecycle graph (RFC-0219)
// ---------------------------------------------------------------------------
/**
* Valid subscription status transitions (RFC-0219). Symmetric with `FUNNEL_TRANSITIONS`:
* the platform owns the allowed moves; Stripe is the billing authority that fires the
* events, but the transition graph itself is declared here.
*/
export const SUBSCRIPTION_TRANSITIONS: Readonly<
Record
> = {
active: ["past_due", "paused", "canceled"],
past_due: ["active", "canceled"],
paused: ["active", "canceled"],
canceled: [],
};
/**
* One entry per directed edge in `SUBSCRIPTION_TRANSITIONS` (RFC-0219). Labels each
* `(from, to)` pair with exactly one `LifecycleEventKind` trigger. The bijection
* invariant — checked by `funnel.statechart.validate` — keeps this overlay and the
* transition map in exact lock-step.
*/
export const SUBSCRIPTION_TRANSITION_TRIGGERS: ReadonlyArray<{
from: SubscriptionStatus;
to: SubscriptionStatus;
on: LifecycleEventKind;
}> = [
{ from: "active", to: "past_due", on: "invoice.payment_failed" },
{ from: "active", to: "paused", on: "subscription.updated" },
{ from: "active", to: "canceled", on: "subscription.canceled" },
{ from: "past_due", to: "active", on: "invoice.paid" },
{ from: "past_due", to: "canceled", on: "subscription.canceled" },
{ from: "paused", to: "active", on: "subscription.updated" },
{ from: "paused", to: "canceled", on: "subscription.canceled" },
];