Payments
Declare plans, Stripe price mappings, grace rules, and payment effects in Inspire.
Payments are where Kizaki separates billing infrastructure from product logic.
The schema declares the billing model the platform should understand: plans, Stripe price mappings, trial windows, grace rules, seat-tracking, and payment-triggered effects. The platform then owns the dangerous integration work around checkout, customer mapping, subscription state, webhook verification, idempotency, and principal payment context.
Your app code still owns the business decision of what each plan actually unlocks.
What The payments Block Controls
The payments block is the payment infrastructure contract for your app. It tells Kizaki:
- which plans exist
- which Stripe Price IDs map to those plans
- whether any plan is billed per seat
- whether trials exist
- how long grace periods last after a failed payment
- which payment lifecycle events should trigger effects
In the current implementation, the managed provider is Stripe.
Syntax
payments {
@secret stripeKey
plan Free {}
plan Pro {
stripe: {
monthly: "price_pro_monthly",
annual: "price_pro_annual",
},
}
plan Team {
stripe: "price_team_monthly",
perSeat: true,
legacyPrices: ["price_team_v0"],
}
seatEntity: OrgMembership,
billingEntity: Organization,
trial: [Pro: 14d, Team: 7d],
gracePeriod: 7d,
graceAction: downgrade,
@on(payment.succeeded) effect sendReceipt {
retries: 5,
backoff: exponential(1s),
timeout: 30s,
}
@on(subscription.canceled) effect handleChurn {
retries: 3,
backoff: exponential(2s),
}
}The Mental Model
Use Kizaki payments when you want the platform to own the part developers routinely get wrong:
- mapping users to Stripe customers
- looking up the correct price for a plan
- tracking subscription state locally
- feeding
principal.planandprincipal.planStatus - handling Stripe webhooks safely
- deduplicating repeated webhook deliveries
- exposing a narrow server-side payment API instead of ad hoc billing code everywhere
What Kizaki does not decide for you is product strategy. Whether Pro unlocks exports, whether Team raises limits, whether a support rep temporarily grants access, or whether one feature should remain available during past_due is still your application logic.
Plans
Plans are declared in product terms, not provider IDs. Your schema and policies should talk about Free, Pro, and Team, while the platform handles the Stripe mapping underneath.
payments {
@secret stripeKey
plan Free {}
plan Pro {
stripe: {
monthly: "price_pro_monthly",
annual: "price_pro_annual",
},
}
}The first plan without a Stripe price is the default free plan. That is the plan the platform uses when there is no active subscription row.
Single-Price And Multi-Interval Plans
You can map a plan to either:
- a single Stripe Price ID with
stripe: "price_id" - a monthly and annual pair with
stripe: { monthly: "...", annual: "..." }
That lets the product keep one logical plan while Stripe handles the actual billing interval.
Legacy Prices
Use legacyPrices when existing subscribers may still be attached to older Stripe Prices that should resolve to the same current plan.
plan Pro {
stripe: {
monthly: "price_pro_monthly",
annual: "price_pro_annual",
},
legacyPrices: ["price_old_pro_v1", "price_old_pro_v2"],
}This is inbound mapping for webhook and refresh resolution. It lets you change Stripe pricing over time without losing the ability to interpret existing subscriptions correctly.
Secrets
The schema references the Stripe secret, but does not contain it.
payments {
@secret stripeKey
// ...
}Environments supply the real value through kizaki secrets. Treat the schema as the declaration of which secret is required, not the storage location for that secret.
Per-Seat Billing
Set perSeat: true on any plan whose quantity should follow a row-count in your data model.
plan Team {
stripe: "price_team_monthly",
perSeat: true,
}
seatEntity: OrgMembership,seatEntity tells Kizaki which entity represents a seat. In practice, this is usually a membership table such as OrgMembership, WorkspaceMember, or ProjectSeat.
This matters because seat billing should be derived from the actual product model, not from hand-maintained counters. The compiler also enforces that perSeat: true must have a seatEntity, which prevents an ambiguous setup from compiling.
Billing Entity
billingEntity declares which entity should be treated as the billable account in your model.
billingEntity: Organization,Use this when billing belongs to an account, organization, or tenant rather than to one individual person. That keeps the schema honest about who is actually paying.
If your app is straightforwardly per-user, you can usually omit it.
Trials
Trials declare which plans get a trial window and how long it lasts.
trial: [Pro: 14d, Team: 7d],This gives the payments system a first-class notion of trial eligibility and trial state instead of forcing the application to reinvent that state in custom entities.
Grace Periods
Grace periods are one of the most important parts of the payments model because they control what happens when billing fails.
gracePeriod: 7d,
graceAction: downgrade,gracePeriod says how long a subscription should remain in a paid-but-failed state before access changes. graceAction says what should happen when that window expires:
downgrademoves the user or account back to the default free plansuspendkeeps the payment relationship visible as suspended rather than silently treating it as free
The compiler requires a grace policy when you declare paid plans, because “what happens after payment failure?” is not optional product behavior.
Payment State In The Rest Of The App
Once payments are configured, the rest of your app can reason about payment state through principal fields and through the generated payment API.
The current runtime resolves:
principal.planprincipal.planStatus
That means policies and application code can talk about product plans directly rather than querying Stripe on demand.
In Policies
entity AnalyticsReport {
title: string,
@grant read where principal.plan in [Pro, Team]
}This is the correct pattern for plan-aware access in Inspire. Avoid inventing a separate plan(...) policy primitive. Payment-aware access flows through principal context.
You can also combine plan and status when a feature should be more strict than the default product posture:
entity LiveDashboard {
title: string,
@grant read
where principal.plan in [Pro, Team]
and principal.planStatus in [active, trialing]
}In Server Code
The current managed payment surface includes:
- checkout creation
- subscription lookup
- billing portal creation
- payment-state refresh from Stripe
- Stripe customer ID lookup for raw Stripe interop
Those are the runtime operations behind the payment model declared here.
Payment Effects
The payments block can emit the same kind of durable effects you use elsewhere in Kizaki, but tied to payment lifecycle events instead of row mutations.
@on(payment.failed) effect notifyPaymentFailure {
retries: 3,
backoff: exponential(1s),
}Available payment events in the current compiler are:
payment.succeededpayment.failedsubscription.createdsubscription.changedsubscription.canceledsubscription.trialEndingsubscription.pastDue
Use these for things like receipts, trial-expiry emails, churn handling, and internal notifications. The important distinction is that the payment platform owns the lifecycle event; your effect owns the business reaction.
Managed Payments Versus Raw Stripe
Kizaki payments are opt-in, not mandatory. If your billing model fits the managed surface, use it and let the platform own the risky mechanics. If you need a Stripe feature outside that surface, use Stripe directly and treat Kizaki as the source of local subscription state and policy context.
The current runtime explicitly supports a refresh bridge from Stripe back into managed payment state. That is what keeps “managed platform state” and “raw provider operations” from becoming two disconnected systems.
What The Compiler Validates
Payments configuration is not just descriptive. The compiler checks a few important correctness rules up front, including:
- paid plans without a grace policy
perSeat: truewithout aseatEntity- unknown plan names used in policy conditions
- unknown payment event names on effects
- overlapping
legacyPricesand current Stripe prices
These checks matter because payments failures are not harmless UI bugs. A wrong plan mapping or an undefined grace behavior becomes a business failure very quickly.
Dev And Operations Touchpoints
The local and operational surface already reflects the managed payments model:
kizaki dev --stripe-liveuses Stripe test mode instead of the local mockkizaki dev payments set-plan,set-status, andsimulatelet you test billing flows without Stripekizaki payments refreshandkizaki payments refresh --allare the current reconciliation commands
That split is intentional. Most day-to-day development should happen against the mock. Real Stripe should only be necessary when validating provider behavior.
Recommended Usage
- declare a small set of product-facing plans
- keep Stripe-specific identifiers inside the payments block
- gate features with
principal.planandprincipal.planStatus - use
legacyPriceswhen migrating pricing, instead of hand-written special cases - model seat billing from a real membership entity, not from counters in application code
- use payment effects for lifecycle reactions, not for primary state management
Related guide: Payments