import { Context } from 'effect'; import { Effect } from 'effect'; import { Schema } from 'effect'; /** * 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.TaggedErrorClass; } & { /** Where the failure originated — provider name or an internal stage. */ source: typeof Schema.String; message: typeof Schema.String; /** Retryable? The service retries `transient` failures on a Schedule. */ transient: typeof Schema.Boolean; }>; /** * 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 event tag union — handy for `onEvent` keys. */ export declare type BillingEventTag = BillingEvent['_tag']; /** * 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; 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; /** * 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; } /** The billing service Tag — `const billing = yield* BillingService`. */ export declare class BillingService extends BillingService_base { } declare const BillingService_base: Context.TagClass; 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; }) => Effect.Effect<{ url: string; }, BillingError>; /** Mint a provider-hosted billing-portal URL. */ readonly portalUrl: (tenantId: string, returnUrl: string) => Effect.Effect<{ url: string; }, BillingError>; /** 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 CheckoutInput { readonly tenantId: string; /** Provider price id to subscribe to. */ readonly priceId: string; 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; } /** The free/fallback plan id every app implicitly has. */ export declare const DEFAULT_PLAN: PlanId; /** 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.TaggedErrorClass; } & { /** The entitlement key that was exhausted (`'aiCalls'`). */ entitlement: typeof Schema.String; /** The static limit for the caller's plan. */ limit: typeof Schema.Number; /** How much was already used in the current window. */ used: typeof Schema.Number; /** The cost the rejected call would have charged. */ cost: typeof Schema.Number; }>; /** 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; } export 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; } /** * 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'; /** 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; } /** 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; /** 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 PortalInput { readonly tenantId: string; /** Provider customer id whose billing portal to open. */ readonly providerCustomerId: string; readonly returnUrl: string; } /** 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; } /** 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. */ export 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.TaggedErrorClass; } & { tenantId: typeof Schema.String; /** The subscription status at the time of the refusal (`'pastDue'`). */ status: typeof Schema.String; /** ISO timestamp at which the grace period expired. */ lockedSince: typeof Schema.String; /** `'hard'` (limits fell to the free plan) or `'soft'` (report only). */ lockout: typeof Schema.String; }>; export declare type SubscriptionStatus = 'active' | 'trialing' | 'pastDue' | 'canceled' | 'incomplete'; /** Apply a plan / seat change to the LIVE subscription at the provider. */ export 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'; } 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; } export { }