/** * Stripe Checkout, the path from a chosen tier to a hosted payment page * (GH #1057). * * This is the first money-handling code in the repo. Three rules shape it. * * **Price ids come from the environment, never from the request.** A caller * sends a tier name. This module turns that into a Price id. If the browser * could name a price, anyone could buy the $39 tier at a price of their own * making. Test mode and live mode also carry different ids, so hard-coding one * would make the deploy that flips modes a code change. * * **Return URLs are built here, never taken from the request.** A * caller-supplied `success_url` is an open redirect wearing a payment page as * a disguise. * * **The session carries the subject, not the tier.** `client_reference_id` * holds the Brains subject so the webhook in GH #1059 knows whose tier to * write. The tier itself is read back from the Price id on the completed * subscription, so a tampered session cannot grant a tier nobody paid for. */ import Stripe from "stripe"; import type { BillingTier } from "./tierStore.js"; /** The three tiers a customer can buy. The others are assigned by seedUsers. */ export declare const PAID_TIERS: readonly ["solo", "pro", "unlimited"]; export type PaidTier = (typeof PAID_TIERS)[number]; export declare function isPaidTier(value: unknown): value is PaidTier; /** Every paid tier widens to a BillingTier, so tierStore can store it unchanged. */ export declare function paidTierToBillingTier(tier: PaidTier): BillingTier; export interface StripeConfig { secretKey: string; prices: Record; } export type StripeConfigResult = { ok: true; config: StripeConfig; } | { ok: false; missing: string[]; }; /** * Read the Stripe configuration out of an environment. * * Returns the names of the variables that are missing rather than throwing. * The server must keep serving wiki traffic when billing is not configured, * which is the normal state of a local run and of any self-hosted instance. */ export declare function resolveStripeConfig(env?: NodeJS.ProcessEnv): StripeConfigResult; /** * Build the URLs Stripe sends the customer back to. * * `webUrl` is the server's own `WEB_URL`, so neither value can be influenced * by the caller. Stripe replaces `{CHECKOUT_SESSION_ID}` itself. */ export declare function checkoutReturnUrls(webUrl: string, tier: PaidTier): { successUrl: string; cancelUrl: string; }; export interface CheckoutSessionRequest { tier: PaidTier; /** The Brains subject. Travels on the session so the webhook can resolve it. */ subject: string; /** Prefills the Stripe payment page. Optional, because not every auth mode has one. */ email?: string; config: StripeConfig; webUrl: string; /** Injected in tests. Defaults to a real Stripe client. */ stripe?: Pick; } export interface CheckoutSessionResult { id: string; url: string; } /** * Create a Stripe Checkout Session and return the URL to send the browser to. * * Throws when Stripe rejects the call or returns a session with no URL. The * caller turns that into a 502, because a failure here is Stripe's side and * not the customer's mistake. */ export declare function createCheckoutSession(req: CheckoutSessionRequest): Promise; /** * Resolve the tier a Stripe subscription entitles its owner to. * * Status drives this before price does. A subscription whose price says "pro" * but whose status says `canceled` entitles nobody to anything. * * `past_due` deliberately keeps the paid tier. Stripe retries a failed payment * for days before giving up, and cutting a paying customer off at the first * failed charge is a worse outcome than a few days of unpaid access. Stripe * moves the subscription to `canceled` or `unpaid` when it stops retrying, and * that is where access ends. */ export declare function tierForSubscription(subscription: { status: string; items: { data: Array<{ price: { id: string; }; }>; }; }, prices: Record): BillingTier; /** The Brains subject a subscription belongs to, or null when it carries none. */ export declare function subjectForSubscription(subscription: { metadata?: Record | null; }): string | null; /** Event types that change a tier. Everything else is acknowledged and ignored. */ export declare const HANDLED_WEBHOOK_EVENTS: readonly ["checkout.session.completed", "customer.subscription.updated", "customer.subscription.deleted"]; export declare function isHandledWebhookEvent(type: string): boolean; /** * The subscription id an event points at, or null when there is none to read. * * A `checkout.session.completed` for a subscription carries the subscription as * an id or as an expanded object. The two subscription events carry the object * itself. */ export declare function subscriptionIdForEvent(event: { type: string; data: { object: unknown; }; }): string | null; /** * Find the Stripe customer id for a Brains subject. * * Brains stores no Stripe customer id. Rather than add a column and the * migration that comes with it, this searches subscriptions for the * `brains_subject` metadata that `createCheckoutSession` already writes, and * reads the customer off the match. The reasoning matches the webhook: a * column plus its writer in one change works on staging and fails on * production, because production applies migrations only on a manual deploy. * * Returns null when the subject has never subscribed. * * **Known lag.** Stripe's search index is eventually consistent and can trail * a new subscription by up to a minute. A customer who opens the portal * immediately after paying can get a null here. The caller turns that into a * "no subscription found, try again shortly" message rather than an error. */ export declare function findCustomerIdForSubject(subject: string, stripe: Pick): Promise; /** Where Stripe sends the customer when they leave the portal. */ export declare function portalReturnUrl(webUrl: string): string; export interface PortalSessionRequest { subject: string; config: StripeConfig; webUrl: string; stripe?: Pick; } /** * Create a Stripe Billing customer portal session. * * Returns null when the subject has no subscription to manage. The portal is * hosted by Stripe, so Brains builds no plan-change UI of its own. A change * made there arrives back as `customer.subscription.updated` or * `customer.subscription.deleted`, which the webhook already handles, so this * adds no second path that writes a tier. */ export declare function createPortalSession(req: PortalSessionRequest): Promise; //# sourceMappingURL=stripe.d.ts.map