import { updateBillingProfile } from "./billing-profile.js"; import { setCustomerTaxId } from "./tax-ids.js"; import { createCardSetupCheckoutSession, createCardSetupIntent } from "./payment-methods.js"; import { cancelPlan, changePlan, previewPlanChange } from "./subscription.js"; import { grantTopUp } from "./topup.js"; import { closeWorkspace, findOrphanedSubscriptions } from "./close-workspace.js"; import { completeCheckout } from "./complete-checkout.js"; import type { InvitationService } from "./invitations.js"; import type { Notify } from "./notifications/index.js"; import { markPlanQuoteAccepted, quotePlanRequest } from "./plan-request.js"; import { isSatisfied } from "./plan-request.js"; import { resolveAllowance } from "./allowance.js"; import { memberUsage, usageSummary } from "./usage.js"; import { type PlanCatalog } from "./plans.js"; import type { UsageLedger } from "./usage-ledger.js"; import type { BillingAdapter, ResolvedConfig } from "./types.js"; /** What `createBilling` already resolved, and what everything below closes over. */ export interface BoundApiDeps { adapter: BillingAdapter; config: ResolvedConfig; plans?: PlanCatalog; ledger?: UsageLedger; /** How to find the org's current plan key. The meter's resolver, so a usage read * and the gate that refused a call agree about which plan is in force. */ resolvePlan?: (orgId: string) => Promise; /** The invitation service (`createWorkOSInvitations`). Without it `members.invite` and the * two invitation reads throw rather than pretending — there is nowhere to put the record. */ invitations?: InvitationService; /** * Fire-and-forget notifications, from `createBilling`'s emitter. * * The bound API and the TOOLS are two doors to the same functions, so both pass it: a * top-up asked for through a server action and one asked for through MCP are the same * event, and telling the admins only about one of them is the parity rule failing quietly. */ notify?: Notify; } type Caller = { kind: "user" | "api"; id?: string; seatType?: string; }; export declare function createBoundApi(deps: BoundApiDeps): { /** The org's Stripe customer, created on first use. */ customerId: (orgId: string, email?: string) => Promise; /** The org's Stripe customer if it already has one, else null — no creation. */ customerIdIfAny: (orgId: string) => Promise; /** Unmetered internal org (a domain in `config.internalDomains`). */ isInternal: (orgId: string) => Promise; /** Which plan the org is on, by the same resolver the meter uses. */ plan: (orgId: string) => Promise; profile: { get: (orgId: string) => Promise; update: (orgId: string, patch: Parameters[2]) => Promise; }; taxIds: { list: (orgId: string) => Promise; set: (orgId: string, input: Parameters[2]) => Promise; }; cards: { list: (orgId: string) => Promise; setDefault: (orgId: string, paymentMethodId: string) => Promise; remove: (orgId: string, paymentMethodId: string) => Promise; setupIntent: (orgId: string, opts: Parameters[2]) => Promise<{ clientSecret: string; customerId: string; }>; setupCheckout: (orgId: string, opts: Parameters[2]) => Promise<{ clientSecret: string; sessionId: string; }>; /** * Finish a save: the FIRST card becomes the default, asked for or not. * * A customer with one card and no default has a card on file and nothing chargeable — * every invoice and every auto-reload reads the default — so this is not a * convenience. Consumers were doing it themselves in whatever callback their checkout * returned to, which is one place per flow. */ attached: (orgId: string, paymentMethodId: string, opts?: { setDefault?: boolean; }) => Promise<{ madeDefault: boolean; }>; /** Least-recently-used eviction down to `config.paymentMethods.maxCards`, never * touching the default. Call after attaching. */ prune: (orgId: string, max?: number) => Promise; /** Stamp a card as just-charged, so the prune above can be LRU rather than oldest. */ touch: (paymentMethodId: string) => Promise; }; invoices: { list: (orgId: string, limit?: number) => Promise; get: (orgId: string, invoiceId: string) => Promise; pdfUrl: (orgId: string, invoiceId: string) => Promise; }; subscription: { get: (orgId: string) => Promise<{ plan: string | null; status: string | null; subscriptionId: string | null; periodStart?: string | null; periodEnd: string | null; seats?: number | null; seatCounts?: Record | null; }>; change: (orgId: string, to: Parameters[2]["to"], opts?: Omit[2], "plans" | "to" | "config" | "currency">) => Promise; /** Quote a change. Pass the SAME `timing`/`proration` you will pass to * `change`, or you are quoting a policy the app does not apply. */ preview: (orgId: string, to: Parameters[2]["to"], opts?: Omit[2], "plans" | "to" | "currency">) => Promise; /** * The OTHER ask: move the workspace up a tier. * * Queued, never applied — approving does not charge anybody. `change_plan` is the * upgrade, and it takes a payment, which is not something a member's request may * trigger on an owner's behalf. */ requests: { list: (orgId: string) => Promise; /** That member's open ask, or null once the workspace has reached the plan anyway. */ pending: (orgId: string, memberId: string) => Promise; /** * `metadata` and `contact` are what a QUOTE-only target needs: whatever the form * collected, and who to answer. The contact is taken from the signed-in user by the * consumer — it is never typed into a form, because the account already knows it. */ ask: (orgId: string, memberId: string, opts?: { plan?: string; note?: string; /** Whatever the consumer's own form collected. The library acts on none of it. */ metadata?: Record; contact?: { firstName: string; lastName: string; email: string; }; }) => Promise<{ ok: boolean; id?: string; plan?: string; pending?: import("./ladder.js").PlanRequest; reason?: "no_upgrade" | "unknown_plan" | "already_pending" | "already_on_it" | "queue_full"; }>; /** Ask for a bigger SEAT — the right ask while one exists above them. */ askSeat: (orgId: string, memberId: string, opts?: { seatType?: string; note?: string; }) => Promise<{ ok: boolean; id?: string; seatType?: string; pending?: import("./ladder.js").PlanRequest; reason?: "no_upgrade" | "unknown_plan" | "already_pending" | "already_on_it" | "queue_full"; }>; /** * WHICH ask to offer this person: a bigger seat, more usage, or a plan change. * * One call so a screen cannot invent its own ladder — a Standard member offered a * top-up gets a few days and is in the same place next week, which is the mistake * this exists to prevent. */ next: (orgId: string, memberId: string, actor?: { isAdmin?: boolean; }) => Promise<{ rung: "seat" | "credits" | "usage" | "plan"; to: string; actor: "self" | "admin"; action: import("./ladder.js").UsageActionTool; ask: "seat"; } | { rung: "seat" | "credits" | "usage" | "plan"; to?: string; actor: "self" | "admin"; action: import("./ladder.js").UsageActionTool; ask: "credits"; } | { rung: "seat" | "credits" | "usage" | "plan"; to?: string; actor: "self" | "admin"; action: import("./ladder.js").UsageActionTool; ask: "usage"; } | { rung: "seat" | "credits" | "usage" | "plan"; to: string; actor: "self" | "admin"; action: import("./ladder.js").UsageActionTool; ask: "plan"; } | null>; resolve: (orgId: string, requestId: string, decision: "done" | "denied") => Promise; /** Has the workspace already reached what this asked for? */ satisfied: (orgId: string, request: Parameters[0]) => Promise; }; cancel: (orgId: string, opts?: Omit[2], "plans" | "currency">) => Promise; }; seats: { list: (orgId: string) => Promise<{ [x: string]: string; }>; get: (orgId: string, memberId: string) => Promise; /** * WHICH seat a member draws when nobody has assigned them one. * * A real seat — the plan's cheapest non-shared type — not an absence, which is why a * picker must not offer "default" as a fourth choice beside the ones the plan sells: * that names a seat type no config declares. Measured on a consumer: a member with no * assignment was badged "Predefinito" on its members page while its usage page, reading * the same person through `MemberUsage.seatType`, said "Standard". Both were rendering * the same fact and only one of them had asked the library for it. */ defaultType: (orgId: string) => Promise; /** Drop a workspace's entries from these members' own metadata — what `workspace.close` * calls, and what removing a single member should call for that member. */ clearRecords: (orgId: string, memberIds: readonly string[]) => Promise; /** * Assign a seat, refusing a `memberId` that is not in this workspace. * * The check lives HERE as well as in the `assign_seat_type` tool, because a consumer * that writes its own server action bypasses the tool entirely — scartoffie's does, and * so its admin path had no membership check at all while the tool's was being fixed. * That is the failure this file exists to prevent: the logic a hand-written wrapper ends * up owning is exactly the logic that must not be duplicated. * * `assignSeatType` itself stays a pure storage write, so an app that deliberately seats * a not-yet-active invitee can still call it directly. */ assign: (orgId: string, memberId: string, seatType: string | null) => Promise; /** Room left on a seat type: `{ assigned, purchased, max, remaining }`, `remaining: null` * when nothing declares a ceiling. What a picker needs before it offers the option. */ capacity: (orgId: string, seatType: string) => Promise<{ seatType: string; assigned: number; purchased: number | null; max: number | null; remaining: number | null; }>; /** The ladder for this org's plan, cheapest rung first, shared seats excluded. */ ladder: (orgId: string) => Promise; /** The raw write, no membership check — for seating an invitee who has not accepted. */ assignUnchecked: (orgId: string, memberId: string, seatType: string | null) => Promise; }; usage: { /** The whole workspace: each member against their own cap, who is over it, and the * team average. `plan` and `ledger` bound. */ org: (orgId: string, members: readonly { id: string; kind?: "user" | "api"; }[], opts?: { now?: number; }) => Promise; /** Every window that applies, plus pool/pack/wallet. `plan` and `ledger` bound. */ summary: (orgId: string, opts?: { caller?: Caller; locale?: Parameters[2]["locale"]; now?: number; }) => Promise; /** Per-member breakdown for an admin view. N ledger reads — cache it. */ byMember: (orgId: string, members: Parameters[2]["members"], opts?: { now?: number; }) => Promise; /** * The raw allowance state the meter gates on. * * The caller's SEAT is resolved here, exactly as `summary` and * `subscription.requests.next` resolve it — a caller carrying only an id matches no * seat-typed window, so `state.limits` came back without the very window that was * refusing them and `topUpTargetOf` read "nothing is blocked". Measured on a Premium * member sitting at 100% of their week: the ladder (which resolves the seat) offered * extra usage while this read (which did not) reported no window to raise, so the * screen fell through to a plan upgrade nobody needed. Whoever asks the question, the * answer has to be about the same person. */ allowance: (orgId: string, opts?: Omit[2], "orgId" | "plans" | "plan" | "ledger">) => Promise; /** * The cycle key anything filed against a cycle must use. * * Exposed because getting it wrong is silent: a grant written under a * calendar month that the meter reads as a subscription period grants * nothing, and nothing errors. */ cycle: (orgId: string, opts?: { now?: number; }) => Promise; }; topUps: { list: (orgId: string) => Promise; /** * File a request. The cycle is resolved here rather than taken from the * caller, which is the whole point — see `usage.cycle`. */ request: (orgId: string, req: { memberId: string; amount: number; id?: string; cycle?: string; }) => Promise<{ id: string; cycle: string; }>; /** * Ask WITHOUT naming an amount — the plan's share of that member's seat pack. * * What a "request more usage" button calls: the person pressing it knows they are out, * not what a reasonable top-up is. Refuses a second open ask for the same cycle and * returns the one already waiting, so the button can render as pending rather than * queueing the same question again. */ requestExtra: (orgId: string, memberId: string, opts?: { percent?: number; amount?: number; id?: string; }) => Promise<{ ok: boolean; id?: string; amount?: number; packSize?: number; cycle?: string; pending?: import("./topup.js").TopUpRequest; reason?: "invalid_amount" | "not_capped" | "already_pending" | "limit_reached" | "not_blocked"; }>; /** * CAN this plan take a grant at all, and if not, why. * * `grantExtraAllowance` answers `not_capped` for a plan with no per-seat pack — a pool * has nothing per-member to raise and the meter would ignore one. A screen needs that * BEFORE it renders the control, and both consumers re-derived it from * `cap.kind === "per_seat"`: a second reading of the same rule, in the place least * likely to be updated when the rule moves. */ grantable: (orgId: string) => Promise<{ ok: boolean; reason?: "not_capped"; }>; /** That member's own ask still waiting on an answer, or null. */ pending: (orgId: string, memberId: string, cycle: string) => Promise; approve: (orgId: string, requestId: string) => Promise<{ ok: boolean; reason?: "not_found"; }>; deny: (orgId: string, requestId: string) => Promise<{ ok: boolean; reason?: "not_found"; }>; /** Grant outright, in credits, against a cycle you name. */ grant: (orgId: string, input: Parameters[2]) => Promise<{ ok: boolean; total: number; reason?: "invalid_amount" | "duplicate"; }>; /** * Grant as a percentage of that member's own seat pack (default 25%). * * Membership-checked for the same reason `seats.assign` is: an admin screen passes a * `memberId` straight from its own UI, and being an admin of YOUR workspace says nothing * about whether that user is in it. scartoffie's grant control reaches the library * through exactly this function. */ grantExtra: (orgId: string, memberId: string, opts?: { percent?: number; grantedBy?: string; id?: string; }) => Promise<{ ok: boolean; total?: number; granted?: number; packSize?: number; cycle?: string; reason?: "invalid_amount" | "not_capped" | "duplicate"; }>; /** Extra already granted to a member for a cycle. */ granted: (orgId: string, memberId: string, cycle: string) => Promise; }; /** * WHO is in the workspace. The two rules — the plan's member limit and the last admin — * are in `members.ts`, so a consumer's own server action refuses exactly as the tool does. * That is the whole reason this group exists rather than each app calling WorkOS: both * consumers wrote the counting and the admin check themselves, which means both of them * owned a rule the library advertises. */ members: { list: (orgId: string) => Promise; /** Seats taken and left, counting PENDING invitations — a promise is a seat. */ seats: (orgId: string) => Promise; /** `null` when it cannot be answered, which callers must treat as "refuse" — see * `isLastAdmin`. */ isLastAdmin: (orgId: string, userId: string) => Promise; /** The list-shaped read for a members table: one call, null when there is no * sole admin (or roles are unreadable — the write path still refuses). */ lastAdminId: (orgId: string) => Promise; invite: (orgId: string, input: { email: string; roleSlug?: string; inviterUserId?: string; /** Seat to put them on — checked against this org's plan and its purchased * seats before the invitation goes out. See `inviteMember`. */ seatType?: string | null; }) => Promise<{ ok: true; invitation: import("./invitations.js").Invitation; seats: import("./members.js").MemberSeats; seatType?: string | null; } | { ok: false; reason: import("./members.js").MemberRefusal; seats: import("./members.js").MemberSeats; }>; invitations: { list: (orgId: string) => Promise; revoke: (orgId: string, invitationId: string) => Promise; /** Accepting needs the invited person's verified identity, which an org key does not * carry — which is why this is here and is NOT an MCP tool. */ accept: (invitationId: string, user: Parameters[1]) => Promise<{ orgId: string; }>; }; setRole: (orgId: string, userId: string, roleSlug: string) => Promise<{ ok: true; roleSlug: string; } | { ok: false; reason: import("./members.js").MemberRefusal; }>; /** Clears their records for this workspace BEFORE the membership — see `removeMember`. */ remove: (orgId: string, userId: string) => Promise<{ ok: true; cleared: number; } | { ok: false; reason: import("./members.js").MemberRefusal; }>; }; checkout: { /** * AFTER the payment — verify, attach the customer, stamp `org_id`, mirror the plan, and * restore the billing address Checkout overwrote with the payer's. * * Opening a session was always the library's and finishing one was every consumer's, so * the first app to do it wrote this three times (signup, plan change, top-up), each copy * a different subset. Idempotent, because a return URL is a page a browser reloads. */ complete: (sessionId: string, opts?: Parameters[2]) => Promise; }; workspace: { /** * Close a workspace: stop its billing, KEEP its invoices, return each member's metadata * budget, then remove it — in that order, and it refuses to remove one whose billing is * still live. The old one-call deletion left a subscription charging a card for a * workspace that no longer existed, with the pointer to it destroyed. */ close: (orgId: string, opts?: Parameters[2]) => Promise; /** Live subscriptions whose org no longer resolves — the wreckage of the old way. */ orphans: (opts?: Parameters[1]) => Promise<{ subscriptionId: string; customerId: string | null; orgId: string | null; amount: number | null; }[]>; }; /** * The gates, for a surface that is not an MCP tool. * * A server action has a session rather than an API key, so it wraps the call in * `runWithPrincipal` and these read the same AsyncLocalStorage the tools do — * which is why they must come from the same module instance, and why binding * them here rather than re-importing is the safe way round. */ /** * Custom pricing: the ask is `plans.ask` (`request_plan_change`) — the same verb as any * other upgrade — and these are the two halves that carry a PRICE on the answer. * * `resolve` is NOT gated here, and that is deliberate. The bound API is server-side code * holding the deployment's own credentials — the same trust level as the env var that * lists the operators — which is what makes the ops CLI able to answer a quote at all. * The TOOL is where `enforceOperator` stands, because that is the surface a customer's * key can reach. */ quotes: { /** Every ask this workspace has filed, with any price on it — what a screen offering * "accept" reads. Same store as `plans.pending`, listed rather than filtered. */ list: (orgId: string) => Promise; /** Price an open request. The operator's half; `enforceOperator` gates the TOOL. */ send: (orgId: string, input: Parameters[2]) => Promise<{ ok: true; request: import("./ladder.js").PlanRequest; } | { ok: false; reason: "not_found" | "invalid_amount" | "queue_full"; }>; /** Record that an admin took it, once the charge has been arranged. */ accepted: (orgId: string, input: Parameters[2]) => Promise; }; auth: { access: () => Promise; admin: (action: string) => Promise; /** Refuse a `member_id` that is not in this workspace. Any surface taking one from a * caller needs it: the org gate answers "which workspace", never "is this person in * it". */ member: (orgId: string, memberId: string, action: string) => Promise; credits: (orgId: string, toolName: string, cost: number) => Promise; }; }; export type BillingApi = ReturnType; export {}; //# sourceMappingURL=bound-api.d.ts.map