import { ColumnDefinition } from '@voltro/database'; import { Context } from 'effect'; import { DataStore } from '@voltro/database'; import { default as default_2 } from 'stripe'; import { Effect } from 'effect'; import { IncomingRequest } from '@voltro/plugin-webhooks'; import { IncomingResponse } from '@voltro/plugin-webhooks'; import { IncomingWebhookDescriptor } from '@voltro/plugin-webhooks'; import { Layer } from 'effect'; import { mountIncomingWebhook } from '@voltro/plugin-webhooks'; import { PluginEgressDeclaration } from '@voltro/protocol'; import { RpcInterceptor } from '@voltro/protocol'; import { RpcKind } from '@voltro/protocol'; import { Schema } from 'effect'; import { Subject } from '@voltro/protocol'; import { TableIndex } from '@voltro/database'; import { TableLike } from '@voltro/database'; import { VoltroPlugin } from '@voltro/protocol'; import { YieldableError } from 'effect/Cause'; export declare const assertBillingProductionReady: (input: { readonly env: NodeJS.ProcessEnv; readonly providerOption: BillingProviderName | BillingProvider | undefined; readonly resolvedName: string; readonly webhookSecret: string | null; readonly redirectOrigins: ReadonlyArray | undefined; }) => void; export declare const BILLING_CUSTOMERS_TABLE = "_voltro_billing_customers"; export declare const BILLING_DUNNING_NOTICES_TABLE = "_voltro_billing_dunning_notices"; export declare const BILLING_FLUSH_CLAIMS_TABLE = "_voltro_billing_flush_claims"; export declare const BILLING_INVOICES_TABLE = "_voltro_billing_invoices"; export declare const BILLING_SUBSCRIPTIONS_TABLE = "_voltro_billing_subscriptions"; export declare const BILLING_USAGE_TABLE = "_voltro_billing_usage"; /** The webhook id + the path the doc + plugin mount at. */ export declare const BILLING_WEBHOOK_ID = "billing"; export declare const BILLING_WEBHOOK_PATH = "/billing/webhook"; /** * What a billing call would do to what the tenant pays — the unit an app's * `admit` hook decides on. `checkout` starts paying, `change` moves a plan or * seat count, `resume` takes a cancellation back, `portal` opens the provider's * self-service page (which can do all of those), `cancel` stops paying. */ export declare type BillingAction = 'checkout' | 'change' | 'resume' | 'portal' | 'cancel'; /** * The shape every handler executor receives carries the resolved subject * under `ctx.request.subject` (mirrors `@voltro/plugin-rbac`'s * `RbacContext`). Kept structural so callers need no @voltro/runtime dep. */ export declare interface BillingContext { readonly request: { readonly subject: Subject; }; } /** tenant ↔ provider customer link. */ export declare const billingCustomersTable: BillingTable; /** * The sent-notice ledger. One row per `(tenant, episode, step)` — and the * UNIQUE over exactly those three is the send gate, not a report: a notice is * CLAIMED by `insertIgnore` before it is sent, so a duplicated webhook * delivery loses the race and sends nothing. */ export declare const billingDunningNoticesTable: TableLike & { readonly fields: Record>; readonly appliedIndexes: ReadonlyArray; }; /** * Where billing data leaves the installation, for `voltro compliance generate` * (egress.json): Stripe's API for the stripe provider (customers, their email * and the subscription items), nothing for the mock, and the provider's own * name for a custom one. */ export declare const billingEgressOf: (provider: Pick) => ReadonlyArray; /** * A billing failure. `transient: true` marks failures the service retries * (network blip talking to the provider); non-transient failures (a 4xx * from the provider, a misconfigured plan) are surfaced immediately. The * `billingPlugin` registers this via `errorSchemas` so it decodes TYPED on * the client rather than crossing as an untyped defect. */ export declare class BillingError extends BillingError_base { } declare const BillingError_base: Schema.Class, YieldableError>; /** * The normalized event union a `BillingProvider.normalizeEvent` produces * from a verified, decoded provider webhook payload. `BillingService` * applies these to the DB rows — switching provider swaps the adapter; * the service + rows + entitlement engine are unchanged. */ export declare type BillingEvent = { readonly _tag: 'subscriptionUpserted'; readonly tenantId: string; readonly providerSubscriptionId: string; readonly plan: PlanId; readonly status: SubscriptionStatus; /** Seat quantity from the provider event; defaults to 1 when absent. */ readonly quantity?: number; readonly currentPeriodStart?: Date | null; readonly currentPeriodEnd: Date | null; readonly cancelAt: Date | null; /** When the PROVIDER emitted this — the out-of-order guard. See * `occurredAt` on the union below. */ readonly occurredAt: Date | null; } | { readonly _tag: 'subscriptionCanceled'; readonly tenantId: string; readonly providerSubscriptionId: string; readonly occurredAt: Date | null; } | { readonly _tag: 'invoicePaid'; readonly tenantId: string; readonly providerInvoiceId: string; readonly amountMinor: number; readonly currency: string; readonly occurredAt: Date | null; } | { readonly _tag: 'invoicePaymentFailed'; readonly tenantId: string; readonly providerInvoiceId: string; readonly amountMinor: number; readonly currency: string; readonly occurredAt: Date | null; } | { readonly _tag: 'customerLinked'; readonly tenantId: string; readonly providerCustomerId: string; readonly occurredAt: Date | null; } | { /** The provider customer was deleted (e.g. in the provider's dashboard). * `tenantId` is null when the customer carried no tenant metadata — the * link is then resolved from our own customer table. */ readonly _tag: 'customerUnlinked'; readonly tenantId: string | null; readonly providerCustomerId: string; readonly occurredAt: Date | null; }; /** The event tag union — handy for `onEvent` keys. */ export declare type BillingEventTag = BillingEvent['_tag']; /** One row per flush window — `windowKey` UNIQUE makes the INSERT the gate. */ export declare const billingFlushClaimsTable: TableLike; /** Invoice history (money as integer minor units). */ export declare const billingInvoicesTable: BillingTable; export declare const billingPlugin: (options?: BillingPluginOptions) => VoltroPlugin; export declare interface BillingPluginOptions { /** `'stripe' | 'mock'` (built from env) or a `BillingProvider`. Default: * `'stripe'` when `STRIPE_SECRET_KEY` is set, else `'mock'`. */ readonly provider?: BillingProviderName | BillingProvider; /** Provider api key — server-only. Default `STRIPE_SECRET_KEY` env. */ readonly apiKey?: string; /** Provider webhook signing secret (`whsec_…`). Default `STRIPE_WEBHOOK_SECRET` env. */ readonly webhookSecret?: string; /** The single source of tier→limit truth. Keyed by plan id. */ readonly plans?: Readonly>; /** Per-rpc-tag entitlement enforcement (the doc's fictional `guards:` * replacement) — installs the interceptor when present. */ readonly enforce?: EnforceMap; /** Usage-based billing autopilot (round-2/02): derive per-tenant usage from * the graph's own telemetry (rpc calls / row writes / AI tokens) and flush * to the provider on a schedule — zero hand-wired counters. Keyed by the * meter (entitlement/usage) key reported to the provider. */ readonly metering?: MeteringConfig; /** Autopilot flush cadence in ms (default 60_000). `0` disables the * self-scheduled flush — call `billing.flushUsage()` from your own * `*.cron.tsx` instead (e.g. for cluster-coordinated flushing). */ readonly flushIntervalMs?: number; /** * Dunning: the past-due notification sequence, the grace clock, and the * lockout. Composed on the PROVIDER'S outcomes — nothing here retries a * payment or schedules one; Stripe owns the retry cadence and each attempt * it makes is the tick that evaluates whichever steps have come due. * * Defaults: enabled, a 7-day grace, a 3-step sequence (0h / 72h / 144h), a * hard lockout — and NO transport, so until `notify` is wired the sequence * only claims + logs. Every number is overridable here and by env * (`VOLTRO_BILLING_GRACE_HOURS`, `VOLTRO_BILLING_DUNNING_STEP_HOURS`, * `VOLTRO_BILLING_LOCKOUT`, `VOLTRO_BILLING_DUNNING`). */ readonly dunning?: DunningConfig; /** Typed per-event side effects, run AFTER the row is updated. */ readonly onEvent?: OnEventMap; /** Transient-failure retries on provider calls. Default 3. */ readonly attempts?: number; /** * Checkout behaviour, handed straight to the provider. * * `adjustableQuantity` renders STRIPE's seat stepper on STRIPE's checkout * page — set it for a per-seat plan so a customer can buy the number of * seats they want in one go. `trialDays` makes Stripe run the trial and * report `trialing`; we neither count trial days nor expire them. */ readonly checkout?: { readonly adjustableQuantity?: { readonly min: number; readonly max: number; }; readonly trialDays?: number; }; /** * Per-tenant entitlement-limit override — the seam for a cloud-issued license * snapshot. Pass `@voltro/plugin-licensing`'s `entitlementResolver` here to let * a signed license decide limits per-tenant; a `null` result falls back to the * static `plans` registry. Absent → static registry only. */ /** * The app's say on whether a tenant may change what it pays right now — * refuse a checkout, a plan change or the portal for an organization that is * deleted or suspended. Fail with a `BillingError` to refuse; the provider * is not called. The roles check (`billing:manage`) answers WHO may act; this * answers whether the TENANT may, which a scope cannot see. */ readonly admit?: (tenantId: string, action: BillingAction) => Effect.Effect; /** * Origins the provider may send the customer back to (`successUrl`, * `cancelUrl`, the portal's `returnUrl`) — e.g. `['https://app.example.com']`. * Without it any URL is accepted, which makes checkout a redirect to * anywhere; the plugin says so at boot when the Stripe provider is active. */ readonly redirectOrigins?: ReadonlyArray; /** * Whether the app has closed (deleted) a tenant — positive evidence only. * The next sync of a closed tenant that is still paying (any event, the * catch-up, a checkpoint) ends its subscriptions: the safety net for a * deletion path that did not call `closeAccount`. An unknown tenant is NOT closed (a subscription pointed at * the wrong database must never be ended by it). */ readonly isTenantClosed?: (tenantId: string) => Effect.Effect; readonly resolveEntitlementLimit?: (tenantId: string, key: string) => Effect.Effect; /** * Namespace for this plugin's rpc tags + inspect endpoints. Default `billing`. * * Set it when your app already publishes under that name — an exact tag * collision is fatal at codegen, and this is the way out. Orthogonal to * `name` below: `alias` REPLACES the namespace, `name` distinguishes two * installations within it. * * The dashboard panel follows: the plugin's inspect endpoints keep a mount * under its CANONICAL name alongside the aliased one, so aliasing does not * take the panel away. The one case it cannot cover is two installs of this * plugin — one canonical name, two panels — which get no shared mount at * all, on purpose. Read `inspectSlug` from `/_voltro/inspect/plugins` to * reach a specific install. */ readonly alias?: string; /** * Discriminator for a SECOND installation of this plugin, when one app runs * two (`@voltro/plugin-billing#analytics`). Not a rename — for that use * `alias`. */ readonly name?: string; } /** * Dumb adapter over a billing provider. Holds NO policy — checkout/portal * URL minting, usage push, and pure payload→event mapping only. The * `BillingService` wraps it with the entitlement engine, the DB rows, and * transient retry. * * `normalizeEvent` receives a payload whose signature was ALREADY verified * by @voltro/plugin-webhooks' mounter — this is pure mapping, never a * second signature check. */ export declare interface BillingProvider { readonly name: string; /** Whether the provider supports server-pushed metered usage. Stripe * does (subscription-item usage records); a flat-plan provider may not. * When false, `BillingService.reportUsage` records locally but never * calls `provider.reportUsage`. */ readonly supportsMeteredUsage: boolean; /** Whether a usage push must name a linked provider customer. Stripe * meters map events to a customer by id, so an unlinked tenant's usage * stays pending instead of being sent unattributable. */ readonly usageRequiresCustomer?: boolean; readonly createCheckoutSession: (input: CheckoutInput) => Effect.Effect<{ url: string; }, BillingError>; readonly createPortalSession: (input: PortalInput) => Effect.Effect<{ url: string; }, BillingError>; readonly reportUsage: (input: UsagePush) => Effect.Effect; /** * Apply a plan / seat change to the LIVE subscription. * * This is the call whose absence made `changeSeats` a lie: without it the * service patched its own table and the provider went on billing the old * quantity, so a customer could be granted seats nobody was charging for. * The provider — not us — computes and bills the proration. */ readonly updateSubscription: (input: SubscriptionUpdateInput) => Effect.Effect; /** * What WOULD a change cost, without applying it? Backed by the provider's * own invoice preview, so the number quoted to the customer is the number * they are charged — a locally-computed estimate can differ from the * provider's rounding, its tax, and its credit balance, and any difference * is a support ticket. */ readonly previewSubscriptionChange: (input: SubscriptionUpdateInput) => Effect.Effect; /** * The provider's CURRENT view of a subscription — a direct read, not an * event body. * * This is the method dunning refuses to lock a customer out without. An * `invoice.payment_failed` can arrive after the retry that succeeded, a * redelivery can arrive days late, and neither carries the state that * matters — the subscription's status right now does. Returns null when the * provider does not know the id. */ readonly fetchSubscription: (providerSubscriptionId: string) => Effect.Effect; /** * Every live (not yet ended) subscription at the provider, optionally for * one customer. The provider-side half of reconciliation: what it lists is * the truth our rows are corrected to. */ readonly listSubscriptions: (input: { readonly providerCustomerId?: string; }) => Effect.Effect, BillingError>; /** End a subscription — at period end (reversible) or immediately with a * final invoice. Returns the provider's state afterwards. */ readonly cancelSubscription: (input: { readonly providerSubscriptionId: string; readonly mode: CancelMode; }) => Effect.Effect; /** Undo a period-end cancellation that has not taken effect yet. */ readonly resumeSubscription: (providerSubscriptionId: string) => Effect.Effect; /** * The provider customer for a tenant, created when it has none — BEFORE the * first checkout, so every checkout names its customer. That is what lets * the provider refuse a second subscription for the same customer, and * lets open checkouts be found and expired. Must be idempotent per tenant: * two concurrent first checkouts get the same customer. */ readonly ensureCustomer: (input: { readonly tenantId: string; }) => Effect.Effect; /** Expire every checkout still open for a customer, so only the newest one * can be paid. Returns how many were expired. */ readonly expireOpenCheckouts: (input: { readonly providerCustomerId: string; }) => Effect.Effect; /** Add prices the subscription is missing (metered items), without * proration. Prices already present are left alone. */ readonly addSubscriptionPrices: (input: { readonly providerSubscriptionId: string; readonly priceIds: ReadonlyArray; }) => Effect.Effect; /** * The billing contact the PROVIDER has on file, when it has one. Used as the * dunning recipient of last resort — `dunning.resolveRecipient` wins when * the app keeps its own. Optional: a provider may model no customer email. */ readonly customerEmail?: (providerCustomerId: string) => Effect.Effect; /** The provider's invoice history. We mirror it for listing but never * render an invoice ourselves — `hostedUrl` / `pdfUrl` are theirs. */ readonly listInvoices: (input: { readonly providerCustomerId: string; readonly limit?: number; }) => Effect.Effect, BillingError>; /** * Verify a webhook signature and decode the payload, using the provider's * OWN verification. Returns `null` when this provider does not do its own * verification (the mount's generic check then stands alone). */ readonly verifyWebhook?: (input: { readonly rawBody: Uint8Array; readonly signatureHeader: string; readonly secret: string; }) => Effect.Effect; /** Map a verified, decoded webhook payload to a `BillingEvent`, or * `null` for an event type this provider doesn't model (handler * no-ops, still 200). */ readonly normalizeEvent: (raw: unknown) => Effect.Effect; /** * Who and when a verified payload is about: the provider customer it names * and when the provider created it. The webhook syncs that customer from a * fresh read instead of trusting the payload, and an event created long * before it arrives means deliveries were failing — the cue to catch up. * Optional: without it the webhook applies the normalized event as is. */ readonly eventContext?: (raw: unknown) => { readonly providerCustomerId: string | null; readonly createdAt: Date | null; }; /** * Events the provider could not deliver to the app's webhook, as far back as * it keeps them (Stripe: 30 days) — what an outage of the endpoint left * behind. Optional: a provider without it has no catch-up. */ readonly listUndeliveredEvents?: () => Effect.Effect, BillingError>; } export declare type BillingProviderName = 'stripe' | 'mock'; /** The billing service Tag — `const billing = yield* BillingService`. */ export declare class BillingService extends BillingService_base { } declare const BillingService_base: Context.ServiceClass; /** Wrap a built service in a `Layer` for the plugin's `services` field. */ export declare const billingServiceLayer: (service: BillingServiceShape) => Layer.Layer; export declare interface BillingServiceOptions { readonly provider: BillingProvider; readonly plans: PlanRegistry; /** Stores — memory by default; swapped to DataStore-backed via `bindDataStore`. */ readonly stores?: BillingStores; /** Transient-failure retries on provider calls. Default 3. */ readonly attempts?: number; /** Checkout behaviour handed straight to the provider — we render none of * it ourselves. */ readonly checkout?: { /** Seat stepper bounds on the provider's checkout page. */ readonly adjustableQuantity?: { readonly min: number; readonly max: number; }; /** Free-trial length. The provider runs the trial and reports `trialing`. */ readonly trialDays?: number; }; /** * The app's say on whether a tenant may change what it pays right now — * e.g. refuse a checkout for a deleted or suspended organization. Called * before the provider is; a failure refuses the call. Absent → every * authorized caller is admitted. */ readonly admit?: (tenantId: string, action: BillingAction) => Effect.Effect; /** * Origins a checkout's `successUrl` / `cancelUrl` and a portal's `returnUrl` * may point at. The provider redirects the customer there, so an arbitrary * URL would make our checkout a redirect to anywhere. Absent → any URL. */ readonly redirectOrigins?: ReadonlyArray; /** * Whether the app has closed (deleted) a tenant. The reconcile ends every * subscription of a closed tenant still paying — the safety net for a * deletion path that did not call `closeAccount`. Answer `true` only on * positive evidence (a row marked deleted): an unknown tenant is not proof, * and a failing check is treated as "not closed". */ readonly isTenantClosed?: (tenantId: string) => Effect.Effect; /** The app's per-event side effects, run for every correction a sync or * reconcile makes (the webhook runs them itself, and fails the delivery on * an error). */ readonly onEvent?: OnEventMap; /** Clock injection for deterministic period + grace windowing in tests. */ readonly now?: () => Date; /** * Dunning policy, already resolved against env by `billingPlugin`. Omitted * → the documented defaults (7-day grace, 3-step sequence, hard lockout, no * transport, so the sequence logs and sends nothing). */ readonly dunning?: ResolvedDunningConfig; /** * Optional per-tenant entitlement-limit override — the seam for a * cloud-issued license snapshot (e.g. `@voltro/plugin-licensing`'s * `entitlementResolver`). Called before the static plan registry: a non-null * result is used as the limit for `(tenantId, key)`; `null` falls back to the * tenant's plan tier. Absent → behavior is exactly the static registry. */ readonly resolveEntitlementLimit?: (tenantId: string, key: string) => Effect.Effect; } export declare interface BillingServiceShape { /** Read the tenant's subscription row; null when none exists. */ readonly subscription: (tenantId: string) => Effect.Effect; /** Resolve the tenant's plan id; defaults to `'free'` when no row. */ readonly plan: (tenantId: string) => Effect.Effect; /** * "Is this tenant entitled right now?" — the one truthful answer, dunning * state included. A PURE read of the local row (no provider call, no write), * so it is safe on a hot path: the grace clock is a column and the lockout * is derived from it, never a flag some job had to remember to set. */ readonly entitlementStatus: (tenantId: string) => Effect.Effect; /** * Re-derive dunning state for a tenant FROM THE PROVIDER, then fire whatever * notification steps are now due (each at most once per episode). * * Runs automatically after every subscription/invoice event, which is what * makes the provider's own retry cadence the schedule. Call it directly only * to force a re-check (e.g. from a support tool). It is idempotent, it never * charges anything, and it never asks the provider to retry. */ readonly reconcileDunning: (tenantId: string) => Effect.Effect; /** * Reconcile every past-due tenant. Optional — the event path already covers * the normal case; a sweep exists so a step configured for a moment the * provider happens not to emit an event (and a reconcile missed during a * provider outage) still lands. Wire it from your own `*.cron.tsx` if you * want that; nothing schedules it for you. Returns the number reconciled. */ readonly dunningSweep: () => Effect.Effect; /** Pure read — would `cost` units of `key` be allowed? No mutation. */ readonly checkEntitlement: (tenantId: string, key: string, cost: number) => Effect.Effect; /** Check + decrement, atomically. Fails `EntitlementExceeded` over-limit. */ readonly consumeEntitlement: (tenantId: string, key: string, cost: number) => Effect.Effect; /** Record metered usage locally; flushed to the provider in batches. */ readonly reportUsage: (tenantId: string, key: string, qty: number) => Effect.Effect; /** Flush all pending local usage counters to the provider. */ readonly flushUsage: () => Effect.Effect; /** What a plan/seat change WOULD cost, from the provider's own invoice * preview. Quote this, not a local estimate — the two differ by the * provider's rounding, tax and credit balance. */ readonly previewChange: (tenantId: string, next: { readonly plan?: PlanId; readonly quantity?: number; }) => Effect.Effect; /** The provider's invoice history for this tenant. */ readonly invoices: (tenantId: string) => Effect.Effect, BillingError>; /** Mint a provider-hosted checkout URL for `plan`. */ readonly startCheckout: (input: { readonly tenantId: string; readonly plan: PlanId; readonly successUrl: string; readonly cancelUrl: string; /** Seats to buy up front. Omit for 1. */ readonly quantity?: number; }) => Effect.Effect<{ url: string; }, BillingError>; /** Mint a provider-hosted billing-portal URL. */ readonly portalUrl: (tenantId: string, returnUrl: string) => Effect.Effect<{ url: string; }, BillingError>; /** * The customer's own cancellation: the subscription stays active until the * paid period ends, then ends. The provider applies it first; the row * mirrors its answer (`cancelAt`). Reversible with `resumeSubscription`. */ readonly cancelSubscription: (tenantId: string) => Effect.Effect; /** Take back a period-end cancellation before it takes effect. */ readonly resumeSubscription: (tenantId: string) => Effect.Effect; /** * End every provider subscription of a tenant — for deleting an * organization or account. Pending usage is flushed first; each subscription * then ends once that usage has settled on the meter (minutes, see * `CancelMode` 'close') with a final invoice for it and a proration credit. * The rows carry the end as `cancelAt` until the provider's deletion event * marks them canceled. The provider customer and its invoices are KEPT: they * are the accounting record. Fails without ending anything when the flush * fails, so no usage is lost; the caller retries. */ readonly closeAccount: (tenantId: string) => Effect.Effect; /** * Correct every local row from the provider: what the provider lists wins, * a local live row the provider no longer lists is re-read directly, missing * metered items are added back. Run it on a schedule — it heals whatever a * missed or failed webhook left behind. */ readonly reconcileFromProvider: () => Effect.Effect; /** * Bring one provider customer's rows to what the provider says now, and * return its current state as events (all of it, not just changes). The * webhook uses it for the customer an event names. Does not run `onEvent`. */ readonly syncCustomer: (providerCustomerId: string, hintTenantId?: string) => Effect.Effect<{ readonly tenantId: string | null; readonly events: ReadonlyArray; }, BillingError>; /** * The checkpoint: sync a tenant from the provider, run `onEvent` for what it * found, and answer with its current subscription. Call it where a stale row * would matter — the return from checkout or the portal, a billing page * load. Every call that changes what a tenant pays already runs it first. */ readonly syncTenant: (tenantId: string) => Effect.Effect; /** Apply a normalized provider event to the DB rows (idempotent upsert). */ readonly applyEvent: (event: BillingEvent) => Effect.Effect; /** * Change the tenant's plan mid-cycle. Computes a TIME-BASED prorated * settlement on the amount difference for the unused remainder of the * current period (positive = charge on upgrade, negative = credit on * downgrade), persists the new plan, and returns the settlement. A same-plan * call is a no-op with a zero delta. Fails `BillingError` when the tenant has * no subscription or the target plan is unknown. */ readonly changePlan: (tenantId: string, newPlan: PlanId, changeAt?: Date) => Effect.Effect; /** * Set the tenant's seat quantity mid-cycle. Prorates the amount delta * (`unitAmountMinor × Δquantity`) over the unused remainder of the period, * persists the new quantity, and returns the settlement. `quantity` must be a * positive integer. Fails `BillingError` on no subscription / bad quantity. */ readonly changeSeats: (tenantId: string, quantity: number, changeAt?: Date) => Effect.Effect; } export declare interface BillingStores { readonly customers: CustomerStore; readonly subscriptions: SubscriptionStore; readonly invoices: InvoiceStore; readonly usage: UsageStore; /** The dunning sent-notice ledger — the send gate, not a report. */ readonly dunningNotices: DunningNoticeStore; } /** One active subscription per tenant. */ export declare const billingSubscriptionsTable: BillingTable; /** * The slice of a built `Table` this package exposes. Annotated explicitly * because the full inferred `Table` type leaks @voltro/database's private * column-builder class across the package boundary (TS4094) — the public * surface only needs the table name, its columns, and its indexes. */ export declare interface BillingTable extends TableLike { readonly fields: Record>; readonly appliedIndexes: ReadonlyArray; } export declare const billingTables: () => ReadonlyArray; /** Per-tenant metered counters driving the entitlement engine. */ export declare const billingUsageTable: BillingTable; /** * The incoming-webhook descriptor for billing. `payload` is `Unknown` — * each provider's payload shape differs and `normalizeEvent` does the * narrowing; the signature + idempotency layers don't need a typed body. */ export declare const billingWebhookDescriptor: (options: BillingWebhookOptions) => IncomingWebhookDescriptor; declare interface BillingWebhookOptions { readonly provider: BillingProvider; readonly service: BillingServiceShape; readonly onEvent?: OnEventMap; /** The signing secret. It is BOTH the secret the mount's generic HMAC check * verifies with AND the one the provider's own verifier (Stripe's * `constructEvent`) uses. Null → the mount answers 503: an unverifiable * billing webhook must not reach subscription state. */ readonly webhookSecret?: string | null; /** Called when an event arrives long after it was created — deliveries were * failing; the plugin catches up on what the endpoint missed. */ readonly onLateDelivery?: () => void; } export declare const buildBillingService: (options: BillingServiceOptions) => { readonly service: BillingServiceShape; readonly holder: StoreHolder; }; /** * Build the interceptor for the `enforce` map. Before the executor runs, * for a matched rpc tag it consumes the rule's cost from the subject's * tenant — short-circuiting with `EntitlementExceeded` BEFORE the * executor when the quota is exhausted (the ratelimit precedent). * * The `service` is captured once at plugin-build time (the same memory/DB * service the `services` layer exposes), so the interceptor doesn't need * to resolve the Tag from a layer it isn't wired into. */ export declare const buildEnforceInterceptor: (enforce: EnforceMap, service: BillingServiceShape) => RpcInterceptor; /** Build the registry from `billingPlugin({ plans })`. */ export declare const buildPlanRegistry: (plans: Readonly>) => PlanRegistry; /** How a subscription ends. */ export declare type CancelMode = /** Stays active until the paid period ends, then ends — the customer's own * cancellation. Reversible with `resumeSubscription` until then. */ 'periodEnd' /** * Closing an account: ends as soon as the last usage has settled on the * meter — a few minutes, not a period — with a final invoice for that usage * and a proration credit for the unused time. * * Not "now", on purpose: meter events aggregate asynchronously, and a * subscription ended the moment its last usage was sent is invoiced for none * of it (measured: the meter showed 250, the final invoice 0, and a finalized * invoice never picks the usage up later). */ | 'close'; /** One finding. `apply` is the request that resolves it; absent → a human. */ export declare type CatalogFinding = { readonly kind: 'ok'; readonly object: string; } | { readonly kind: 'create' | 'update' | 'adopt'; readonly object: string; readonly detail: string; readonly apply: StripeRequest; } | { readonly kind: 'drift'; readonly object: string; readonly detail: string; readonly apply?: StripeRequest; } | { readonly kind: 'manual'; readonly object: string; readonly detail: string; }; export declare interface CheckoutInput { readonly tenantId: string; /** Provider price id to subscribe to. */ readonly priceId: string; /** Usage-based prices added next to `priceId` (no quantity). */ readonly meteredPriceIds?: ReadonlyArray; readonly successUrl: string; readonly cancelUrl: string; /** Optional existing provider customer id to attach the checkout to. */ readonly providerCustomerId?: string; /** Seats to buy up front. Defaults to 1. */ readonly quantity?: number; /** Let the buyer change the seat count on the checkout page itself. * Stripe renders the stepper; we neither build nor validate it. */ readonly adjustableQuantity?: { readonly min: number; readonly max: number; }; /** Days of free trial. Stripe runs the trial and emits `trialing`. */ readonly trialDays?: number; } /** * Try to claim the flush window. INSERT-wins: the first replica's row is the * winner; a racing replica's `insertIgnore` returns that same row, so it sees a * `claimedBy` that isn't its own and stands down. Returns true iff THIS replica * owns the window (→ it runs the flush). Fails OPEN on a store error (better a * possible double-flush — which markReported makes idempotent — than no flush). */ export declare const claimFlushWindow: (store: DataStore, windowKey: string, replicaId: string, nowMs: number) => Promise; /** What `closeAccount` did at the provider. */ export declare interface CloseAccountResult { readonly tenantId: string; /** Provider subscriptions ended (immediately, with a final invoice). */ readonly canceled: ReadonlyArray; } declare interface CustomerRecord { readonly tenantId: string; readonly provider: string; readonly providerCustomerId: string; } export declare interface CustomerStore { readonly upsert: (record: CustomerRecord) => Effect.Effect; readonly getByTenant: (tenantId: string) => Effect.Effect; readonly getByProviderCustomerId: (providerCustomerId: string) => Effect.Effect; /** Every linked customer — the provider reconcile's per-customer pass. */ readonly list: () => Effect.Effect, BillingError>; /** Drop the link to a provider customer that no longer exists. Returns the * tenant it belonged to, or null when it was not linked. */ readonly removeByProviderCustomerId: (providerCustomerId: string) => Effect.Effect; } export declare const dataStoreDunningNoticeStore: (store: DataStore) => DunningNoticeStore; export declare const dataStoreStores: (store: DataStore) => BillingStores; /** * The default sequence: on the first failure, again after 3 days, and a final * warning 24h before the default grace runs out. Every number here is a * default the framework picked on the app's behalf, so every one of them is * overridable in `app.config.ts` AND by env. */ export declare const DEFAULT_DUNNING_STEPS: ReadonlyArray; /** Default grace: 7 days from the first confirmed past-due. */ export declare const DEFAULT_GRACE_HOURS = 168; /** Default lockout once grace expires. */ export declare const DEFAULT_LOCKOUT: LockoutMode; /** The free/fallback plan id every app implicitly has. */ export declare const DEFAULT_PLAN: PlanId; /** * The default notice copy. Deliberately plain, unbranded, and link-light — * it exists so the sequence is usable the moment `notify` is wired, not so * apps ship it unchanged. Override by rendering from the structured fields in * a custom `notify`. */ export declare const defaultDunningEmail: (notice: { readonly stepId: string; readonly locked: boolean; readonly graceEndsAt: Date; readonly portalUrl: string | null; }) => { readonly subject: string; readonly html: string; readonly text: string; }; /** A meter: found by event name (Stripe keeps it unique per account). Only the * display name is editable; anything else is a human's call. */ export declare const diffMeter: (spec: StripeCatalogMeter, meters: ReadonlyArray) => { finding: CatalogFinding; id: string | null; }; /** The portal configuration: found by `metadata.voltro_key`, else adopted by * its name; must be the account default, since sessions name none. */ export declare const diffPortal: (portal: StripeCatalog["portal"], env: StripeCatalogEnvironment, plans: ReadonlyArray<{ readonly productId: string; readonly priceId: string; }> | null, configs: ReadonlyArray) => ReadonlyArray; /** * A price: found by `lookup_key`. A price's amount, currency, interval, meter * and tax behaviour cannot be edited at Stripe — a mismatch means a NEW price * that takes the key over, which is a pricing change and runs only when * `allowPriceChange` says so. Existing subscriptions stay on their old price * (Stripe does not move them); that is the customer-facing half of the * decision, and why it is never automatic. */ export declare const diffPrice: (spec: StripeCatalogPrice, productId: string | null, meterId: string | null, byLookup: StripePrice | null, productPrices: ReadonlyArray, allowPriceChange: boolean) => { finding: CatalogFinding; id: string | null; }; /** * A product: found by `metadata.voltro_key`; failing that, ADOPTED from an * active product carrying the same identifying metadata (one created before * the catalog existed) — only when exactly one qualifies, never by guessing. */ export declare const diffProduct: (spec: StripeCatalogProduct, products: ReadonlyArray) => { finding: CatalogFinding; id: string | null; }; export declare const diffTaxSettings: (tax: StripeCatalog["tax"], settings: StripeTaxSettings) => CatalogFinding; /** The webhook endpoint, where the environment has one: every event the plugin * maps, on the SDK's API version. Its signing secret exists only in the * response to its creation. */ export declare const diffWebhook: (env: StripeCatalogEnvironment, endpoints: ReadonlyArray) => CatalogFinding; /** The steps whose `afterHours` have elapsed. Ledger claims — not this * function — decide which of them actually SEND. */ export declare const dueSteps: (steps: ReadonlyArray, pastDueSince: Date, now: Date) => ReadonlyArray; /** The synthetic step fired the first time an episode is observed LOCKED. */ export declare const DUNNING_LOCK_STEP_ID = "locked"; export declare interface DunningConfig { /** Master switch. Default true — but with no `notify` the sequence only * logs, so nothing is sent until the app wires a transport. */ readonly enabled?: boolean; /** Hours of grace from the first PROVIDER-CONFIRMED past-due. Default 168. */ readonly graceHours?: number; /** The sequence. Default `DEFAULT_DUNNING_STEPS`. */ readonly steps?: ReadonlyArray; /** Hard (limits fall to free) or soft (report only). Default `'hard'`. */ readonly lockout?: LockoutMode; /** * Where a notice goes. Absent → the sequence is inert (it logs, claims the * ledger, and sends nothing), which is the correct default for an * irreversible action the framework cannot address on the app's behalf. * `dunningMailNotifier(mail)` bridges to `@voltro/plugin-mail`. */ readonly notify?: (notice: DunningNotice) => Effect.Effect; /** * The billing contact for a tenant. Takes precedence over the provider's * customer email — an app that keeps its own billing contact should say so * rather than have Stripe's copy of an address win. */ readonly resolveRecipient?: (tenantId: string) => Effect.Effect; /** When set, each notice carries a freshly minted provider portal URL that * returns here — the one link a dunning email actually needs. */ readonly portalReturnUrl?: string; } /** The slice of `@voltro/plugin-mail`'s `MailService` a notice needs. Typed * STRUCTURALLY so this package carries no dependency on the mail plugin (the * `@voltro/plugin-auth` `mailSender` precedent). */ export declare interface DunningMailLike { readonly send: (message: { readonly to: string; readonly subject: string; readonly html: string; readonly text?: string; }) => Effect.Effect; } /** * Bridge `notify` to `@voltro/plugin-mail`: * * ```ts * const mail = yield* MailService * billingPlugin({ dunning: { notify: dunningMailNotifier(mail) } }) * ``` * * A notice with no resolvable recipient is skipped rather than failing — the * ledger already recorded the step, and a hard failure here would only mean * the webhook that triggered it gets retried into the same dead end. */ export declare const dunningMailNotifier: (mail: DunningMailLike) => ((notice: DunningNotice) => Effect.Effect); /** The rendered notice handed to `notify`. */ export declare interface DunningNotice { /** The step's id, or `'locked'` for the lockout notice. */ readonly stepId: string; readonly tenantId: string; /** Resolved billing contact, or null when neither `resolveRecipient` nor the * provider could name one. A null `to` is still delivered to `notify` — an * app may route it in-product rather than by email. */ readonly to: string | null; /** The plan the tenant is SUBSCRIBED to (not the locked-down effective one). */ readonly plan: PlanId; readonly status: SubscriptionStatus; readonly pastDueSince: Date; readonly graceEndsAt: Date; /** True once grace has run out — i.e. this is (or follows) the lockout. */ readonly locked: boolean; readonly lockout: LockoutMode; /** Provider-hosted billing portal, when `portalReturnUrl` is configured. */ readonly portalUrl: string | null; /** Ready-to-send default copy. A custom `notify` may ignore all three. */ readonly subject: string; readonly html: string; readonly text: string; } export declare interface DunningNoticeRow { readonly tenantId: string; readonly episode: string; readonly stepId: string; readonly sentAt: Date | null; } export declare interface DunningNoticeStore { /** * Claim `(tenant, episode, step)`. `true` means THIS caller won and must * send; `false` means someone already claimed it — a duplicate delivery, a * second replica, or a re-reconcile. * * The claim happens BEFORE the send, deliberately. The failure mode of * claim-then-send is a notice that is claimed but never delivered (one * missing email); the failure mode of send-then-claim is a customer * receiving the same dunning email twice. Only one of those is a real-world * harm, so the ordering is not arbitrary. */ readonly claim: (tenantId: string, episode: string, stepId: string, at: Date) => Effect.Effect; /** Steps already claimed for an episode — assertion + inspect surface. */ readonly sentSteps: (tenantId: string, episode: string) => Effect.Effect, BillingError>; } /** * One step of the sequence. `afterHours` is measured from `pastDueSince` — * NOT from the previous step and NOT from a retry attempt, so a step cannot be * pulled forward or pushed back by how often the provider happens to retry. */ export declare interface DunningStep { /** Stable ledger key. Changing it re-opens the step for every live episode. */ readonly id: string; /** Hours after the tenant went past-due at which this step becomes due. */ readonly afterHours: number; } /** A registry with a single zero-quota `free` plan — the zero-config default. */ export declare const emptyPlanRegistry: () => PlanRegistry; export declare type EnforceMap = Readonly>; /** One per-tag enforcement rule from `billingPlugin({ enforce })`. */ export declare interface EnforceRule { /** The entitlement key the tag consumes. */ readonly entitlement: string; /** Units charged per call. Default 1. */ readonly cost?: number; } /** The result of a pure (non-mutating) entitlement check. */ export declare interface EntitlementDecision { /** Whether the call would be allowed. */ readonly allowed: boolean; /** The entitlement key checked. */ readonly entitlement: string; /** The static limit for the caller's plan (`Infinity` for `'unlimited'`). */ readonly limit: number; /** How much is already used in the current window. */ readonly used: number; /** The cost the call would charge. */ readonly cost: number; } /** * Raised when a caller has exhausted an entitlement (quota). Distinct from * the scope/permission `Forbidden` / `ScopeError` — those gate "may you * call this proc"; this gates "do you have quota left". Both axes compose. * `billingPlugin` registers it via `errorSchemas` so callers can * pattern-match on `_tag === 'EntitlementExceeded'`. */ export declare class EntitlementExceeded extends EntitlementExceeded_base { } declare const EntitlementExceeded_base: Schema.Class, YieldableError>; /** An entitlement limit: a finite quota or the unbounded `'unlimited'`. */ export declare type EntitlementLimit = number | 'unlimited'; /** The truthful "is this tenant entitled right now" answer. */ export declare interface EntitlementStatus { readonly tenantId: string; /** The plan whose LIMITS apply right now — `'free'` under a hard lockout. */ readonly plan: PlanId; /** The plan the tenant is subscribed to, lockout or not. */ readonly billedPlan: PlanId; readonly status: SubscriptionStatus | 'none'; /** * False ONLY when dunning has locked this tenant out. A canceled * subscription is not a lockout — it is simply the free tier, and reporting * it as "not entitled" would conflate "never paid" with "stopped paying". */ readonly entitled: boolean; /** Past due, but still inside the grace window. */ readonly inGrace: boolean; /** When grace runs out; null when the tenant is not past due — or when the * provider has not confirmed the past-due yet, in which case the tenant is * in grace with no expiry rather than locked. */ readonly graceEndsAt: Date | null; /** Non-null iff locked out — the instant grace expired. */ readonly lockedSince: Date | null; readonly lockout: LockoutMode; } /** * Why `path` must not receive secrets, or null when it may: a file inside a git * work tree that git would track (not ignored) is one `git add .` from being * committed with a live account's price ids and webhook secret in it. */ export declare const envOutRefusal: (path: string, git?: (args: ReadonlyArray, cwd: string) => boolean, realpath?: (p: string) => string) => string | null; /** The episode a `pastDueSince` identifies. Recovery clears the column, so the * next failure necessarily produces a different key. */ export declare const episodeKeyOf: (pastDueSince: Date) => string; /** * Derive the entitlement state from the subscription row. PURE — no I/O, no * writes, so every read path can call it on the hot path. * * The one subtlety worth stating out loud: a `pastDue` row whose * `pastDueSince` is null reports as IN GRACE, never locked. `pastDueSince` is * written only after the provider itself confirmed the past-due (see * `service.ts`'s `reconcileDunning`), so a `pastDue` status that arrived by * webhook alone — or one whose reconcile failed against an unreachable * provider — can never lock a customer out. Locking on unverified event data * is the one failure mode here with a real-world cost. */ export declare const evaluateBillingEntitlement: (sub: Subscription | null, config: Pick, now: Date, tenantId?: string) => EntitlementStatus; /** * Pure entitlement decision. `limit` is `Infinity` for an `'unlimited'` * plan. `'unlimited'` and any non-positive cost always allow without * touching the counter; otherwise allow iff `used + cost <= limit`. */ export declare const evaluateEntitlement: (entitlement: string, limit: number, used: number, cost: number) => EntitlementDecision; /** The window a `now` falls in, bucketed by the flush interval. Two replicas * flushing within the same interval land on the same key → one wins. */ export declare const flushWindowKey: (nowMs: number, intervalMs: number) => string; /** Stripe's form encoding: `a[b][0][c]=v`. `null` unsets a field (empty value). */ export declare const formEncode: (params: Readonly>) => string; /** When the grace for an episode runs out. */ export declare const graceEndOf: (pastDueSince: Date, graceHours: number) => Date; declare interface InvoiceRecord { readonly providerInvoiceId: string; readonly amountMinor: number; readonly currency: string; readonly status: string; readonly createdAt: Date | null; /** Provider-hosted invoice page — we never render one ourselves. */ readonly hostedUrl: string | null; readonly pdfUrl: string | null; } declare interface InvoiceRecord_2 { readonly tenantId: string; readonly provider: string; readonly providerInvoiceId: string; readonly amountMinor: number; readonly currency: string; /** 'paid' | 'open' | 'uncollectible' | 'void'. */ readonly status: string; /** Provider-event timestamp this `status` came from; null when undated. */ readonly statusEventAt: Date | null; } export declare interface InvoiceStore { /** * Upsert by `providerInvoiceId` (idempotent under webhook replay), with the * same stale-event guard the subscription row carries: a late-delivered * `payment_failed` must not flip an invoice that has since been paid back to * `'open'`, which is what a naive last-write-wins mirror does under Stripe's * unordered delivery. */ readonly upsert: (invoice: InvoiceRecord_2) => Effect.Effect; readonly listByTenant: (tenantId: string) => Effect.Effect, BillingError>; } /** Is this a real deployment boot? Only an explicitly dev-like NODE_ENV is not. */ export declare const isProductionBoot: (nodeEnv: string | undefined) => boolean; export declare const isStaleStatusEvent: (existing: Date | null, incoming: Date | null) => boolean; /** * What "locked" means once the grace period is over. * * - `'hard'` — entitlement limits fall to the `'free'` plan's. The app needs * no new code: every existing `requireEntitlement` / `enforce` check * starts answering with the free tier's numbers. * - `'soft'` — limits stay on the paid plan; only `entitlementStatus()` * reports the lockout, so the app decides what to withhold. */ export declare type LockoutMode = 'hard' | 'soft'; export declare const memoryDunningNoticeStore: () => DunningNoticeStore; export declare const memoryStores: () => BillingStores; /** Replace (or add) `NAME=value` lines; every other line is kept as it was. */ export declare const mergeEnvFile: (text: string, values: Readonly>) => string; /** `meterKey → source`. The meterKey is the entitlement/usage key reported to * the provider (`billing.reportUsage(tenant, meterKey, qty)`). */ declare type MeteringConfig = Readonly>; /** One meter's source — where its usage is derived from. */ declare type MeterSource = /** Count rpc calls whose tag matches (string = exact, RegExp = test). Optional * `kind` restricts to mutation/query/action. */ { readonly from: 'rpc'; readonly match: string | RegExp; readonly kind?: RpcKind; } /** Count row writes to `table` (default op `insert`). */ | { readonly from: 'cdc'; readonly table: string; readonly op?: 'insert' | 'update' | 'delete'; } /** Sum the AI usage ledger — `tokens` (input+output, default) or `costMicroUsd`. */ | { readonly from: 'ai'; readonly metric?: 'tokens' | 'costMicroUsd'; }; /** * In-memory provider with deterministic outputs. Checkout/portal URLs are * stable functions of the input so tests can assert on them; usage pushes * are recorded on `pushed` for assertion. `normalizeEvent` accepts the * already-normalized `BillingEvent` shape directly (the test feeds events * straight through) OR a `{ type, data }` envelope mirroring the Stripe * mapping, so the same webhook path exercises the mock end-to-end. */ export declare interface MockProvider extends BillingProvider { /** Recorded usage pushes — assertion surface for tests. */ readonly pushed: ReadonlyArray; /** Recorded subscription updates. The point of asserting on these is that * a change which never reaches the provider is a change the customer is * not billed for — the exact defect this contract was widened to fix. */ readonly updates: ReadonlyArray; /** * Set what the provider will report for `fetchSubscription`. This is the * mock's most load-bearing seam: dunning refuses to act on an event body and * reads the provider instead, so a test that wants "the retry actually * succeeded" says so HERE, not by crafting a webhook. */ readonly setSubscription: (providerSubscriptionId: string, state: ProviderSubscriptionState | null) => void; /** Set the customer email `customerEmail` will report. */ readonly setCustomerEmail: (providerCustomerId: string, email: string | null) => void; /** Every `fetchSubscription` id asked for — proves the reconcile happened. */ readonly fetched: ReadonlyArray; /** Put a subscription on the provider's LIST (and its direct read) — what * `listSubscriptions` returns and a reconcile corrects local rows to. */ readonly listSubscription: (snapshot: ProviderSubscriptionSnapshot) => void; /** Recorded cancellations, in order. */ readonly cancels: ReadonlyArray<{ readonly providerSubscriptionId: string; readonly mode: CancelMode; }>; /** Recorded resumptions. */ readonly resumes: ReadonlyArray; /** Recorded `addSubscriptionPrices` calls. */ readonly priceAdds: ReadonlyArray<{ readonly providerSubscriptionId: string; readonly priceIds: ReadonlyArray; }>; /** Recorded checkout sessions. */ readonly checkouts: ReadonlyArray; /** Customers created by `ensureCustomer`, by tenant. */ readonly customersCreated: ReadonlyArray; /** Every `expireOpenCheckouts` call, by customer. */ readonly expiredFor: ReadonlyArray; /** Set what `listUndeliveredEvents` reports — events the provider could * not deliver to the app's webhook. */ readonly setUndelivered: (events: ReadonlyArray) => void; /** How often `listUndeliveredEvents` was asked — proves a catch-up ran. */ readonly undeliveredReads: () => number; } export declare const mockProvider: (options?: { readonly baseUrl?: string; readonly supportsMeteredUsage?: boolean; }) => MockProvider; /** A monetary amount: integer minor units + ISO-4217 currency. */ export declare interface Money { /** Integer minor units (cents). Never a float. */ readonly amountMinor: number; /** ISO-4217 currency code, lowercased (`'usd'`, `'eur'`). */ readonly currency: string; } /** * Build the request handler for the billing webhook. The returned function * takes the transport-agnostic `{ method, headers, rawBody }` triple and * returns the typed `IncomingResponse`. */ export declare const mountBillingWebhook: (options: MountBillingWebhookOptions) => (request: IncomingRequest) => Promise; declare interface MountBillingWebhookOptions extends BillingWebhookOptions { /** The provider webhook signing secret (`whsec_…`). Null does NOT skip * verification — every delivery answers 503 until it is configured. */ readonly webhookSecret: string | null; /** Optional idempotency-cache override (tests pass a fresh cache). */ readonly idempotencyCache?: Parameters[1]['idempotencyCache']; } /** * Map a verified Stripe event to a `BillingEvent`, or null for a type we do * not model (the handler no-ops and still answers 200, which is what stops * Stripe retrying an event we will never care about). */ export declare const normalizeStripeEvent: (event: default_2.Event, resolvePrice?: (priceId: string) => string | null) => BillingEvent | null; /** Per-event side effect, run AFTER the row is updated. */ export declare type OnEventMap = Readonly Effect.Effect>>; /** Findings the account still differs by (after `apply`: what it could not fix). */ export declare const openFindings: (findings: ProvisionResult["findings"]) => (CatalogFinding & { readonly applied?: boolean; })[]; export declare interface ParsedProvisionArgs extends ProvisionOptions { readonly confirmLive: boolean; readonly strict: boolean; } /** Parse the command line; a string is the refusal. */ export declare const parseProvisionArgs: (argv: ReadonlyArray) => ParsedProvisionArgs | string; /** Calendar-month window key (`'YYYY-MM'`) for metered counters. */ export declare const periodKey: (now?: Date) => string; /** One plan/tier: its entitlement limits + (for paid plans) a provider price id. */ export declare interface PlanConfig { /** Provider price id (Stripe `price_…`). Absent for free/zero-cost plans. */ readonly priceId?: string; /** * Usage-based provider prices subscribed ALONGSIDE `priceId` (Stripe prices * with `recurring.meter`). Checkout adds each as a quantity-less line item, * so the subscription carries the metered item from its first invoice — * without it, meter events are recorded at Stripe but never billed. */ readonly meteredPriceIds?: ReadonlyArray; /** Per-entitlement-key limits. Values are `number | 'unlimited'`. */ readonly entitlements: Readonly>; /** The plan's per-period price in INTEGER minor units (cents) — the unit * amount for ONE seat. Multiplied by the subscription's seat `quantity` to * get the billed amount, and drives mid-cycle proration. Absent for free * plans (treated as 0). Never a float. */ readonly unitAmountMinor?: number; /** ISO-4217 currency for `unitAmountMinor`, lowercased (`'usd'`). Defaults * to `'usd'` when a `unitAmountMinor` is set without one. */ readonly currency?: string; } /** Plan id (the key in `billingPlugin({ plans })`). */ export declare type PlanId = string; export declare interface PlanRegistry { /** Whether `plan` is a known plan id. */ readonly has: (plan: PlanId) => boolean; /** * The static limit for `(plan, key)`. `Infinity` for `'unlimited'`, `0` * for an undeclared key. When `plan` is unknown the registry FAILS * CLOSED — returns `0` — never silently granting an unknown plan's quota. * So a redeploy that drops a live plan id DENIES that tenant's quota * rather than falling back to the free-tier limit. */ readonly entitlementLimit: (plan: PlanId, key: string) => number; /** The plan id whose `priceId` matches, or null. */ readonly planForPriceId: (priceId: string) => PlanId | null; /** The provider price id for a plan, or null (free plans have none). */ readonly priceIdFor: (plan: PlanId) => string | null; /** The usage-based price ids subscribed next to the plan price. */ readonly meteredPriceIdsFor: (plan: PlanId) => ReadonlyArray; /** The per-seat unit amount in integer minor units for a plan; `0` for a * free/undeclared plan. Multiplied by seat quantity for the billed amount. */ readonly unitAmountFor: (plan: PlanId) => number; /** The ISO-4217 currency for a plan's price, lowercased; `'usd'` default. */ readonly currencyFor: (plan: PlanId) => string; /** All declared plan ids. */ readonly planIds: () => ReadonlyArray; } export declare interface PortalInput { readonly tenantId: string; /** Provider customer id whose billing portal to open. */ readonly providerCustomerId: string; readonly returnUrl: string; } /** The portal configuration the plugin's sessions use (the account default). */ export declare const portalParams: (portal: StripeCatalog["portal"], env: StripeCatalogEnvironment, plans: ReadonlyArray<{ readonly productId: string; readonly priceId: string; }>) => Record; /** What `reconcileFromProvider` found and corrected. */ export declare interface ProviderReconcileResult { /** Subscriptions compared (provider-listed plus local rows it did not list). */ readonly checked: number; /** Tenants whose local rows changed because the provider said otherwise. */ readonly corrected: ReadonlyArray; /** Tenants with more than one live subscription at the provider — billed * twice. Never resolved automatically: refunding is a human decision. */ readonly duplicates: ReadonlyArray; /** Provider subscriptions naming no tenant; nothing to attribute them to. */ readonly unattributed: number; /** Subscriptions that had a plan's metered price added back. */ readonly meteredItemsRestored: number; /** Tenants the app reports closed whose subscriptions were still paying — * ended now (see `isTenantClosed`). */ readonly closedTenants: ReadonlyArray; /** Live rows the provider has never heard of that were NOT canceled, because * so many at once means the wrong account or provider (0 normally). */ readonly refusedUnknown: number; /** Every correction as the event it stands for, for the app's `onEvent` * projections; the plugin's scheduled reconcile dispatches them. */ readonly events: ReadonlyArray; } /** * One subscription as the provider lists it — the input of the provider-side * reconcile. Carries the ids a local row is matched by and the price ids a * plan's metered items are checked against. */ export declare interface ProviderSubscriptionSnapshot extends ProviderSubscriptionState { readonly providerSubscriptionId: string; readonly providerCustomerId: string | null; /** From the subscription's own metadata; null when the provider object was * not created through our checkout and names no tenant. */ readonly tenantId: string | null; /** Every price the subscription carries, licensed and metered. */ readonly priceIds: ReadonlyArray; } /** The provider's CURRENT view of a subscription — read directly, not from an * event body. `reconcileDunning` asks for this before it will lock anyone * out. */ export declare interface ProviderSubscriptionState { readonly status: SubscriptionStatus; /** Null when the provider cannot map the price to one of our plans. */ readonly plan: PlanId | null; readonly quantity: number; readonly currentPeriodStart: Date | null; readonly currentPeriodEnd: Date | null; readonly cancelAt: Date | null; } export declare interface ProvisionOptions { readonly env: StripeEnvironment; readonly apply: boolean; readonly allowPriceChange: boolean; /** Where `STRIPE_PRICE_*` ids and a created webhook's secret go; null → * nowhere (and a webhook endpoint is then not created). */ readonly envOut: string | null; } export declare interface ProvisionResult { readonly livemode: boolean; readonly findings: ReadonlyArray; /** Price ids by env name, and a created webhook's `STRIPE_WEBHOOK_SECRET`. */ readonly envOut: Readonly>; } /** * Read the account, diff it against the catalog, and (with `apply`) fix what * can be fixed — in dependency order: meters, products, prices, tax, portal, * webhook. */ export declare const provisionStripeCatalog: (rest: StripeRest, catalog: StripeCatalog, options: ProvisionOptions) => Promise; /** Stripe echoes a masked key in auth errors, and the mask ends in real characters. */ export declare const redactStripeKeys: (text: string) => string; /** * In-handler DUNNING guard: refuse when the tenant's grace period has run out. * * Orthogonal to `requireEntitlement`, which answers "do you have quota left". * This answers "did you pay". A tenant with plenty of quota can still be locked * out, and a tenant in grace is NOT locked out — that is the whole point of the * grace window. * * Under `lockout: 'hard'` the quota guards already degrade to the free plan's * limits on their own, so reach for this when a feature has no numeric quota to * degrade (an export, a webhook target, an admin action). * * ```ts * export default (input, ctx) => Effect.gen(function* () { * yield* requireEntitled(ctx) * // … * }) * ``` */ export declare const requireEntitled: (ctx: BillingContext) => Effect.Effect; /** * In-handler entitlement guard. Resolves `ctx.request.subject.tenantId`, * consumes `cost` units of `key`, and fails with the typed * `EntitlementExceeded` when the quota is exhausted. Effect-native. * * ```ts * export default (input, ctx) => Effect.gen(function* () { * yield* requireEntitlement(ctx, 'aiCalls', Math.ceil(input.tokens / 1000)) * // … * }) * ``` * * Reads `BillingService` from the per-request layer — declare it in the * effect's R channel (it's provided by `billingPlugin`'s `services`). */ export declare const requireEntitlement: (ctx: BillingContext, key: string, cost: number) => Effect.Effect; /** A `DunningConfig` with every default resolved and every number concrete. */ export declare interface ResolvedDunningConfig { readonly enabled: boolean; readonly graceHours: number; readonly steps: ReadonlyArray; readonly lockout: LockoutMode; readonly notify?: (notice: DunningNotice) => Effect.Effect; readonly resolveRecipient?: (tenantId: string) => Effect.Effect; readonly portalReturnUrl?: string; } /** * Resolve the config against `app.config.ts` first and env second. * * Env overrides exist because these numbers gate money-adjacent behaviour and * an operator must be able to widen the grace on a live incident without a * redeploy. They FAIL LOUD rather than falling back to a default: a typo'd * `VOLTRO_BILLING_GRACE_HOURS` that silently means "7 days" is precisely the * kind of quiet wrong number this repo has been bitten by. */ export declare const resolveDunningConfig: (config: DunningConfig | undefined, env: NodeJS.ProcessEnv) => ResolvedDunningConfig; /** * Resolve `options.provider` to a concrete `BillingProvider`. A passed * object is used verbatim. `'stripe'` needs an api key (option or * `STRIPE_SECRET_KEY`). `'mock'` (and the absent default) is the * zero-config in-memory provider. */ export declare const resolveProvider: (input: ResolveProviderInput) => BillingProvider; declare interface ResolveProviderInput { readonly provider?: BillingProviderName | BillingProvider; readonly apiKey?: string; readonly env: NodeJS.ProcessEnv; /** Price id → plan id, from the plugin's plan registry. */ readonly planForPriceId?: (priceId: string) => string | null; } /** The key for `env`, checked against its mode. */ export declare const resolveProvisionKey: (env: StripeEnvironment, vars: NodeJS.ProcessEnv, dotenvKey: string | undefined) => { key: string; } | { refusal: string; }; /** * The provisioning command. Exit code: 0 when the account matches the catalog * (after `--apply`: when everything it could fix is fixed); 1 when something * still differs; 2 on a refused invocation. `manual` items fail the run only * with `--strict` — the launch-readiness check. */ export declare const runStripeProvision: (catalog: StripeCatalog, argv: ReadonlyArray, options?: { readonly dotenv?: string; readonly rest?: (key: string) => StripeRest; readonly vars?: NodeJS.ProcessEnv; }) => Promise; /** What only a human can settle before an environment sells. */ export declare const sellingReadiness: (input: { readonly catalog: StripeCatalog; readonly env: StripeCatalogEnvironment; readonly chargesEnabled: boolean | null; readonly accountTaxIds: number | null; readonly activeRegistrations: ReadonlyArray; }) => ReadonlyArray; /** * A mutable holder so `bindDataStore` can swap the stores AFTER the service * layer is built. The service reads `holder.stores` on every call rather * than capturing a snapshot — identical to how storage rebinds its ref * store once the DataStore exists. */ declare interface StoreHolder { stores: BillingStores; } /** The API version every provisioning request and webhook endpoint uses — the * one the SDK the plugin bills with pins. */ export declare const STRIPE_API_VERSION: string; /** The Dashboard steps no API can check, which every Stripe billing app needs. */ export declare const STRIPE_DASHBOARD_REMINDERS: ReadonlyArray; /** * Every Stripe event type `normalizeStripeEvent` maps — the list a webhook * endpoint subscribes to. Subscribing to fewer loses state changes; a test * holds this list to the switch below. */ export declare const STRIPE_WEBHOOK_EVENTS: ReadonlyArray; export declare interface StripeCatalog { readonly products: ReadonlyArray; readonly meters: ReadonlyArray; readonly tax: { /** The business location Stripe Tax computes from. */ readonly headOffice: StripeCatalogAddress; readonly defaultTaxCode: string; readonly defaultTaxBehavior: 'exclusive' | 'inclusive'; /** Countries an active registration must exist for before tax is collected. */ readonly registrationCountries: ReadonlyArray; }; readonly portal: { readonly key: string; readonly name: string; readonly headline: string; readonly privacyPolicyUrl: string; readonly termsOfServiceUrl: string; }; readonly environments: Readonly>; /** Dashboard-only steps, listed as reminders where selling readiness is required. */ readonly dashboardReminders?: ReadonlyArray; } export declare interface StripeCatalogAddress { readonly line1: string; readonly line2?: string; readonly postal_code: string; readonly city: string; readonly state?: string; readonly country: string; } export declare interface StripeCatalogEnvironment { /** Where the customer portal returns to. */ readonly portalReturnUrl: string; /** The app's `POST /billing/webhook` URL, or null where `stripe listen` * forwards instead (local development against a sandbox). */ readonly webhookUrl: string | null; /** Whether account-level steps (activation, tax ids, Dashboard-only * settings) must be done before this environment sells. */ readonly requireSellingReadiness: boolean; } export declare interface StripeCatalogMeter { readonly eventName: string; readonly displayName: string; readonly formula: 'sum' | 'count' | 'last'; /** Payload key naming the customer — `stripe_customer_id` for this plugin. */ readonly customerPayloadKey: string; readonly valuePayloadKey: string; } export declare interface StripeCatalogPrice { readonly lookupKey: string; /** The env var the app reads this price's id from (`--env-out` writes it). */ readonly envName: string; readonly currency: string; readonly unitAmount: number; readonly interval: 'day' | 'week' | 'month' | 'year'; readonly usageType: 'licensed' | 'metered'; /** For a metered price: the event name of the catalog meter it bills. */ readonly meter?: string; readonly taxBehavior: 'exclusive' | 'inclusive'; } export declare interface StripeCatalogProduct { /** Stable identity, stored as `metadata.voltro_key`. */ readonly key: string; readonly name: string; readonly description: string; readonly unitLabel: string; readonly taxCode: string; /** Extra metadata beside `voltro_key` — also what a product created before * the catalog is ADOPTED by (exactly one active product must carry it). */ readonly metadata: Readonly>; readonly price: StripeCatalogPrice; /** Whether customers may switch to this product in the portal. */ readonly inPortal: boolean; } export declare type StripeEnvironment = 'sandbox' | 'live'; declare interface StripeMeter { readonly id: string; readonly status: string; readonly event_name: string; readonly display_name: string; readonly default_aggregation: { readonly formula: string; }; readonly customer_mapping: { readonly type: string; readonly event_payload_key: string; }; readonly value_settings: { readonly event_payload_key: string; }; } declare interface StripePortalConfiguration { readonly id: string; readonly active: boolean; readonly is_default: boolean; readonly name: string | null; readonly metadata: Readonly>; readonly default_return_url: string | null; readonly business_profile: Readonly>; readonly features: Readonly>; } declare interface StripePrice { readonly id: string; readonly active: boolean; readonly product: string; readonly currency: string; readonly unit_amount: number | null; readonly lookup_key: string | null; readonly tax_behavior: string | null; readonly recurring: { readonly interval: string; readonly usage_type: string; readonly meter: string | null; } | null; } declare interface StripeProduct { readonly id: string; readonly active: boolean; readonly name: string; readonly description: string | null; readonly unit_label: string | null; readonly tax_code: string | null | { readonly id: string; }; readonly metadata: Readonly>; } export declare const stripeProvider: (options: StripeProviderOptions) => BillingProvider; export declare interface StripeProviderOptions { readonly apiKey: string; /** Override metered-usage support (Stripe supports it by default). */ readonly supportsMeteredUsage?: boolean; /** * Map a Stripe price id to one of our plan ids. * * Without it, a subscription is only recognised when it carries * `metadata.plan` — which our own checkout sets, but a subscription created * in the Stripe Dashboard, by a sales-led flow, or by a migration does NOT. * Those events were previously dropped on the floor and the customer stayed * on `free` while paying. The plugin passes its plan registry here. */ readonly planForPriceId?: (priceId: string) => string | null; /** * How long a closed account's subscription runs on so its last metered usage * settles before the final invoice (seconds, default 600). Meter events * aggregate asynchronously; ending the subscription sooner invoices usage the * meter has not counted yet as zero. */ readonly closeSettleSeconds?: number; /** Injected in tests. */ readonly client?: default_2; } declare interface StripeRequest { readonly method: 'POST'; readonly path: string; readonly params: Readonly>; } export declare interface StripeRest { readonly get: (path: string, query?: Readonly>) => Promise; readonly post: (path: string, params: Readonly>) => Promise; /** Every page of a list endpoint. */ readonly list: (path: string, query?: Readonly>) => Promise>; } export declare const stripeRest: (apiKey: string, fetchImpl?: typeof fetch) => StripeRest; export declare class StripeRestError extends Error { readonly status: number; constructor(status: number, message: string); } declare interface StripeTaxSettings { readonly status: string; readonly livemode: boolean; readonly head_office: { readonly address: Readonly>; } | null; readonly defaults: { readonly tax_code: string | null; readonly tax_behavior: string | null; }; } declare interface StripeWebhookEndpoint { readonly id: string; readonly url: string; readonly status: string; readonly enabled_events: ReadonlyArray; readonly api_version: string | null; } /** A tenant's subscription row, normalized across providers. */ export declare interface Subscription { readonly tenantId: string; readonly provider: string; readonly providerSubscriptionId: string; readonly plan: PlanId; readonly status: SubscriptionStatus; /** Per-seat quantity — the billed amount is `plan.unitAmountMinor × quantity`. * Defaults to 1 for a single-seat subscription. */ readonly quantity: number; /** Start of the current paid period; null for plans without a period. * Anchors mid-cycle proration (the "used" side of the period). */ readonly currentPeriodStart: Date | null; /** When the current paid period ends; null for plans without a period. */ readonly currentPeriodEnd: Date | null; /** When the subscription is scheduled to cancel; null if not scheduled. */ readonly cancelAt: Date | null; /** * When this tenant went past due — the dunning grace clock, and the identity * of the current dunning episode. * * Written ONLY by a provider-confirmed reconcile, never straight off a * webhook: it is the value the lockout is derived from, and locking a * customer out on an event body alone is the one mistake here with a * real-world cost. Cleared the moment the provider reports the * subscription healthy again, which is also what cancels the rest of the * notification sequence. */ readonly pastDueSince: Date | null; /** * The provider-event timestamp the current `status` came from — the * out-of-order guard. Provider webhooks are at-least-once AND unordered, so * an event that is older than what the row already reflects is DROPPED * rather than applied (a stale `active` must not un-do a `pastDue`, and a * stale `pastDue` must not resurrect a resolved one). Null on rows written * before any dated event. */ readonly statusEventAt: Date | null; } /** The outcome of a mid-cycle plan/seat change — the prorated settlement. */ export declare interface SubscriptionChange { readonly tenantId: string; readonly plan: PlanId; readonly quantity: number; /** Integer minor-unit settlement: > 0 charge, < 0 credit, 0 at boundary. */ readonly prorationMinor: number; readonly currency: string; } /** What a provider reports back after (or before) a subscription change. */ declare interface SubscriptionChangeResult { readonly plan: PlanId; readonly quantity: number; /** The proration the PROVIDER computed — and, unless `proration: 'none'`, * will actually bill. Positive = owed, negative = credited. */ readonly prorationMinor: number; readonly currency: string; readonly currentPeriodStart: Date | null; readonly currentPeriodEnd: Date | null; } /** * Raised when dunning has locked a tenant out — the grace period after a * provider-confirmed past-due has expired. Distinct from * `EntitlementExceeded`: that one means "you used your quota", this one means * "you did not pay". Registered via `errorSchemas`, so a client can branch on * `_tag === 'SubscriptionLocked'` and route the user to the billing portal * rather than showing a generic failure. * * Dates cross the wire as ISO strings — a `Schema.DateFromSelf` would not * survive the JSON boundary the error union is encoded through. */ export declare class SubscriptionLocked extends SubscriptionLocked_base { } declare const SubscriptionLocked_base: Schema.Class, YieldableError>; /** Fields a mid-cycle / reconcile patch may set on the tenant's row. */ export declare interface SubscriptionPatch { readonly plan?: PlanId; readonly quantity?: number; readonly status?: SubscriptionStatus; readonly currentPeriodStart?: Date | null; readonly currentPeriodEnd?: Date | null; /** The dunning grace clock. `null` clears it (recovery). */ readonly pastDueSince?: Date | null; /** Provider-event (or direct-read) timestamp the `status` came from. */ readonly statusEventAt?: Date | null; } export declare type SubscriptionStatus = 'active' | 'trialing' | 'pastDue' | 'canceled' | 'incomplete'; export declare interface SubscriptionStore { /** * Upsert by `providerSubscriptionId` — the webhook's idempotency anchor. * * STALE-EVENT GUARD: when the incoming `statusEventAt` is OLDER than the one * already on the row, the whole upsert is skipped. Provider webhooks are * at-least-once and unordered, so without this a redelivered `active` from * before a decline silently un-does the past-due (and with it the dunning * clock the lockout is derived from). `pastDueSince` is never written here — * only a provider-confirmed reconcile sets it. */ readonly upsert: (sub: Subscription) => Effect.Effect; /** The tenant's CURRENT subscription: a live one when there is one, else * the most recently changed. An old canceled row never hides a live one. */ readonly getByTenant: (tenantId: string) => Effect.Effect; /** Every subscription row of a tenant, newest first. */ readonly listByTenant: (tenantId: string) => Effect.Effect, BillingError>; /** Every subscription in a given status — the dunning sweep's only query. */ readonly listByStatus: (status: SubscriptionStatus) => Effect.Effect, BillingError>; /** Mark a subscription canceled by provider id (no-op if absent). */ readonly markCanceled: (providerSubscriptionId: string) => Effect.Effect; /** Apply a mid-cycle plan/seat/status change to the tenant's row (no-op if * absent). Only the provided fields are patched. */ readonly patchByTenant: (tenantId: string, patch: SubscriptionPatch) => Effect.Effect; /** Set a subscription's status by provider id (no-op if absent). */ readonly setStatus: (providerSubscriptionId: string, status: SubscriptionStatus) => Effect.Effect; } /** Apply a plan / seat change to the LIVE subscription at the provider. */ declare interface SubscriptionUpdateInput { readonly providerSubscriptionId: string; /** New price to move to. Omit to keep the current one. */ readonly priceId?: string; /** New seat quantity. Omit to keep the current one. */ readonly quantity?: number; /** * How the provider should handle the mid-period money. * * - `'prorate'` — bill or credit the difference for the remaining period * (Stripe's `create_prorations`). The default, and the only option that * charges what the customer actually used. * - `'none'` — change takes effect with no adjustment. * - `'always_invoice'` — prorate AND invoice immediately rather than at * the next cycle. */ readonly proration?: 'prorate' | 'none' | 'always_invoice'; } /** An event the provider failed to deliver, reduced to what a catch-up needs. */ declare interface UndeliveredEvent { readonly id: string; readonly providerCustomerId: string | null; readonly createdAt: Date | null; } export declare interface UsagePush { readonly tenantId: string; readonly entitlementKey: string; /** Aggregate quantity to report for the current period. */ readonly quantity: number; /** End of the period the usage falls in (provider timestamp anchor). */ readonly periodEnd?: Date; /** The provider customer the usage is billed to, when one is linked. */ readonly providerCustomerId?: string; /** * Names THIS delta: the same flush replayed carries the same key (so the * provider can dedupe it), the next delta carries a new one. A key per * (tenant, key, period) alone would make every later delta a "duplicate". */ readonly idempotencyKey?: string; } declare interface UsageRow { readonly tenantId: string; readonly entitlementKey: string; readonly period: string; readonly used: number; readonly reportedToProvider: number; } export declare interface UsageStore { /** Current used count for `(tenant, key, period)`; 0 when no row. */ readonly used: (tenantId: string, key: string, period: string) => Effect.Effect; /** * Atomic check-and-increment — the entitlement quota gate. In ONE * critical section: read the current `used`, and increment by `cost` * IFF `used + cost <= limit`. Returns the decision; `allowed:false` * means NOTHING was incremented. This is the fix for the read-then-write * race the service layer had — the check and the increment can no longer * interleave with a concurrent caller (memory: one sync tick; DataStore: * a compare-and-set loop so it's safe across instances, not just one * process). `limit` is finite and `cost > 0` by the time this is called * (the service short-circuits unlimited / non-positive cost). */ readonly consume: (tenantId: string, key: string, period: string, cost: number, limit: number) => Effect.Effect; /** Add `delta` to the used count, returning the new total. */ readonly increment: (tenantId: string, key: string, period: string, delta: number) => Effect.Effect; /** All rows with un-flushed usage (`used > reportedToProvider`). */ readonly pending: () => Effect.Effect, BillingError>; /** Mark `(tenant, key, period)` flushed up to `used`. */ readonly markReported: (tenantId: string, key: string, period: string, reported: number) => Effect.Effect; } export { VoltroPlugin } export { }