@tangle-network/agent-integrations 0.27.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,812 @@
1
+ import { I as IntegrationRuntimeError, a as IntegrationUserAction } from '../errors-Bg3_rxnQ.js';
2
+ import { a as WebhookEnvelope } from '../router-BncoovUh.js';
3
+
4
+ /**
5
+ * Subscription state machine.
6
+ *
7
+ * Stripe ships eight terminal states on `Subscription.status`. We model
8
+ * them verbatim — never normalize, never collapse — so the state
9
+ * persisted in product DBs round-trips Stripe webhooks losslessly.
10
+ *
11
+ * incomplete — first invoice not paid within 23 hours
12
+ * incomplete_expired — first invoice failed, no retry coming
13
+ * trialing — inside a trial window (treat as active)
14
+ * active — paying, current
15
+ * past_due — auto-renewal failed; grace period running
16
+ * canceled — terminal; ended at period boundary or hard
17
+ * unpaid — past_due → unpaid after retries exhausted
18
+ * paused — operator-paused (collection_method=pause_collection)
19
+ *
20
+ * Transition rules below derive STRICTLY from the Stripe state diagram
21
+ * (https://docs.stripe.com/billing/subscriptions/overview#subscription-statuses)
22
+ * — the dispatcher in `webhooks.ts` calls `applyTransition()` which
23
+ * rejects any state pair Stripe never emits. This catches manual-edit
24
+ * bugs (someone POSTing a `force_state` admin endpoint) and tests for
25
+ * the consumer's state store at the same time.
26
+ *
27
+ * Persistence: products pick an adapter (FS, D1, Postgres, in-memory).
28
+ * The interface is intentionally minimal — load(), save(), and a
29
+ * compare-and-set `saveIfVersion()` that defends against duplicate
30
+ * webhook delivery racing the same store. Stripe re-delivers failed
31
+ * webhooks for 3 days; the in-flight one and the retry will both write
32
+ * the same key. `WebhookRouter`'s idempotency hook short-circuits at
33
+ * the event level, but a misconfigured deploy with two routers in
34
+ * different regions both processing the same event needs the second
35
+ * line of defense here.
36
+ *
37
+ * `requireActiveSubscription()` (middleware) calls `gateAccess(state)`
38
+ * to map state → access-decision. `past_due` is intentionally allowed
39
+ * with a warning flag — the dunning period is when products MOST need
40
+ * customers to keep using the product so they remember why they pay,
41
+ * but the UI should render the "card declined" banner.
42
+ */
43
+ type SubscriptionState = 'incomplete' | 'incomplete_expired' | 'trialing' | 'active' | 'past_due' | 'canceled' | 'unpaid' | 'paused';
44
+ /** All eight states, exported so tests + consumers can enumerate. */
45
+ declare const SUBSCRIPTION_STATES: readonly SubscriptionState[];
46
+ /** Tristate access decision. `warn` means the route runs, but the UI
47
+ * should render a billing banner. */
48
+ type AccessDecision = {
49
+ allowed: true;
50
+ warn?: 'past_due' | 'trial_ending';
51
+ } | {
52
+ allowed: false;
53
+ reason: 'no_subscription' | 'subscription_inactive' | 'subscription_past_due' | 'trial_expired';
54
+ };
55
+ interface SubscriptionRecord {
56
+ /** Tenant key the product uses to look up "is this workspace paying?" —
57
+ * typically a workspaceId, but products that bill per-user or
58
+ * per-organization can swap in their own scope. */
59
+ workspaceId: string;
60
+ /** Stripe customer id (`cus_...`). */
61
+ customerId: string;
62
+ /** Stripe subscription id (`sub_...`). */
63
+ subscriptionId: string;
64
+ /** Last-known subscription state — updated by webhook handlers. */
65
+ state: SubscriptionState;
66
+ /** Stripe price id active on the subscription. Null for canceled. */
67
+ priceId: string | null;
68
+ /** Current billing period end (unix seconds). Used by middleware to
69
+ * emit `trial_ending` warning in the last 72h of a trial. */
70
+ currentPeriodEnd: number | null;
71
+ /** Trial end (unix seconds), null for non-trial subs. */
72
+ trialEnd: number | null;
73
+ /** `cancel_at_period_end` flag — once true, state stays `active` until
74
+ * the period ends, then transitions to `canceled`. */
75
+ cancelAtPeriodEnd: boolean;
76
+ /** Monotonic write counter for optimistic concurrency. Incremented on
77
+ * every save; persistence adapters use it for CAS. */
78
+ version: number;
79
+ /** Last event id we processed for this subscription — defends against
80
+ * Stripe re-delivering the same event and us racing the dedupe store. */
81
+ lastEventId: string | null;
82
+ /** Wall-clock ms of last successful write. */
83
+ updatedAt: number;
84
+ }
85
+ /** Persistence adapter contract. Three operations — pick the storage
86
+ * layer that matches the product's existing infra. Adapters live below
87
+ * (in-memory + filesystem). D1 / Postgres are one-liners on top. */
88
+ interface SubscriptionStore {
89
+ load(workspaceId: string): Promise<SubscriptionRecord | null>;
90
+ save(record: SubscriptionRecord): Promise<void>;
91
+ /** Compare-and-set on `version`. Returns false if the stored record's
92
+ * version doesn't match `expectedVersion` (someone else wrote first).
93
+ * Implementations MUST return false rather than throw on contention —
94
+ * the caller branches on the bool. */
95
+ saveIfVersion(record: SubscriptionRecord, expectedVersion: number): Promise<boolean>;
96
+ }
97
+ /** Returns true if `to` is reachable from `from` in one Stripe transition.
98
+ * Self-edges are accepted (a webhook can re-emit the current state on
99
+ * any field change). */
100
+ declare function isValidTransition(from: SubscriptionState, to: SubscriptionState): boolean;
101
+ /** Apply a state transition to a record. Throws `BillingError` if Stripe
102
+ * would never emit this edge (defensive — a bad admin tool POSTing a
103
+ * raw state update gets refused). Returns the new record without writing. */
104
+ declare function applyTransition(current: SubscriptionRecord, next: Partial<SubscriptionRecord> & {
105
+ state: SubscriptionState;
106
+ }, options?: {
107
+ eventId?: string;
108
+ now?: () => number;
109
+ }): SubscriptionRecord;
110
+ /**
111
+ * Map a state to an access decision.
112
+ *
113
+ * Rule rationale:
114
+ * active, trialing → allow
115
+ * past_due → allow + warn (dunning grace)
116
+ * paused → deny (operator action; resume restores)
117
+ * canceled, unpaid → deny (terminal financial states)
118
+ * incomplete, incomplete_expired → deny (never paid; first invoice failed)
119
+ *
120
+ * Note `requireActiveSubscription` in `middleware.ts` is the consumer of
121
+ * this — gating is centralized here so the rule lives in one place. The
122
+ * mapping is one assertion in the test suite. Changing the rule for one
123
+ * product (e.g., legal-agent wants past_due to deny) is a per-call
124
+ * `overrides` option on the middleware, NOT a fork of this function.
125
+ */
126
+ declare function gateAccess(state: SubscriptionState): AccessDecision;
127
+ /**
128
+ * Process-local store. Useful for tests; product instances should pick
129
+ * `FileSystemSubscriptionStore` or wire D1 / Postgres. Implements proper
130
+ * CAS — concurrent saves with stale versions are rejected.
131
+ */
132
+ declare class InMemorySubscriptionStore implements SubscriptionStore {
133
+ private readonly records;
134
+ load(workspaceId: string): Promise<SubscriptionRecord | null>;
135
+ save(record: SubscriptionRecord): Promise<void>;
136
+ saveIfVersion(record: SubscriptionRecord, expectedVersion: number): Promise<boolean>;
137
+ }
138
+ /**
139
+ * File-per-workspace JSON store. One file per workspace under
140
+ * `<rootDir>/<workspaceId>.json`. Cheap, durable, debuggable — adequate
141
+ * for self-hosted product agents. CAS is implemented via the version
142
+ * field plus a write that re-reads the file under a brief lock window
143
+ * (rename-temp-to-target pattern, atomic on POSIX).
144
+ *
145
+ * Why per-file and not one JSONL: subscriptions are
146
+ * accessed by workspaceId 99% of the time, scanning a JSONL on every
147
+ * request burns I/O. The file-per-workspace pattern keeps reads O(1).
148
+ *
149
+ * The store does NOT use `fs.watch` — webhooks are the only writer in
150
+ * production, and webhooks always go through `applyTransition()` →
151
+ * `saveIfVersion()`, so the CAS catches the race.
152
+ */
153
+ declare class FileSystemSubscriptionStore implements SubscriptionStore {
154
+ private readonly rootDir;
155
+ constructor(rootDir: string);
156
+ load(workspaceId: string): Promise<SubscriptionRecord | null>;
157
+ save(record: SubscriptionRecord): Promise<void>;
158
+ saveIfVersion(record: SubscriptionRecord, expectedVersion: number): Promise<boolean>;
159
+ /** Safe filename: workspaceId is restricted to a charset that maps 1:1
160
+ * to a posix filename. Anything outside is hex-encoded so we can never
161
+ * escape the rootDir via `../`. */
162
+ private fileName;
163
+ }
164
+ /** Convenience constructor for the initial record after a checkout
165
+ * succeeds. The webhook handler for `customer.subscription.created` calls
166
+ * this — exposed for tests + manual-fix scripts that need to backfill. */
167
+ declare function makeSubscriptionRecord(input: {
168
+ workspaceId: string;
169
+ customerId: string;
170
+ subscriptionId: string;
171
+ state: SubscriptionState;
172
+ priceId: string | null;
173
+ currentPeriodEnd: number | null;
174
+ trialEnd?: number | null;
175
+ cancelAtPeriodEnd?: boolean;
176
+ now?: () => number;
177
+ }): SubscriptionRecord;
178
+
179
+ /**
180
+ * Stripe billing error taxonomy.
181
+ *
182
+ * Layered on top of `IntegrationRuntimeError` (the cross-package error
183
+ * contract) so a billing failure surfaces through the same normalization
184
+ * pipeline the rest of the integration runtime uses: `status` maps to
185
+ * an HTTP status, `userAction` carries the recommended next step
186
+ * (connect / reconnect / contact_support), and `code` is stable across
187
+ * versions for product analytics.
188
+ *
189
+ * Why a billing-specific subclass and not bare `IntegrationRuntimeError`:
190
+ * three error classes carry a payload product agents NEED to branch on
191
+ * (subscription state, tenant id, plan id). Encoding them as discriminated
192
+ * subclasses keeps the call site `instanceof BillingError` lookup O(1)
193
+ * and avoids stuffing them into `metadata` where they decay to `unknown`.
194
+ *
195
+ * Code mapping (`BillingErrorCode` → `IntegrationErrorCode`):
196
+ * subscription_required → action_denied (403)
197
+ * subscription_inactive → action_denied (403)
198
+ * subscription_past_due → action_denied (403, w/ warning)
199
+ * trial_expired → action_denied (403)
200
+ * free_tier_exhausted → action_denied (403)
201
+ * tenant_not_configured → provider_error (500) — operator bug
202
+ * webhook_secret_missing→ provider_auth_failed (401) — config bug
203
+ * webhook_event_unknown → input_invalid (400)
204
+ * webhook_replay → input_invalid (400)
205
+ */
206
+
207
+ type BillingErrorCode = 'subscription_required' | 'subscription_inactive' | 'subscription_past_due' | 'trial_expired' | 'free_tier_exhausted' | 'tenant_not_configured' | 'webhook_secret_missing' | 'webhook_event_unknown' | 'webhook_replay';
208
+ interface BillingErrorContext {
209
+ workspaceId?: string;
210
+ productId?: string;
211
+ subscriptionId?: string;
212
+ subscriptionState?: SubscriptionState;
213
+ planId?: string;
214
+ eventId?: string;
215
+ }
216
+ declare class BillingError extends IntegrationRuntimeError {
217
+ readonly billingCode: BillingErrorCode;
218
+ readonly context: BillingErrorContext;
219
+ constructor(input: {
220
+ code: BillingErrorCode;
221
+ message: string;
222
+ context?: BillingErrorContext;
223
+ userAction?: IntegrationUserAction;
224
+ });
225
+ }
226
+ /** Distinct subclass: operator missed an environment variable. Surfaces
227
+ * with a different `userAction` (`contact_support`) so the consumer
228
+ * doesn't render a "connect Stripe" CTA to a customer for what is in
229
+ * fact a backend deploy bug. */
230
+ declare class ConfigError extends BillingError {
231
+ constructor(input: {
232
+ message: string;
233
+ context?: BillingErrorContext;
234
+ });
235
+ }
236
+
237
+ /**
238
+ * Stripe subscription webhook dispatcher.
239
+ *
240
+ * Receives `WebhookEnvelope` rows from `WebhookRouter`'s `deliver()`
241
+ * callback, decodes them into typed `StripeBillingEvent` values, and
242
+ * applies the corresponding state transition to the consumer's
243
+ * `SubscriptionStore`. Emits a typed event the consumer subscribes to.
244
+ *
245
+ * Layering:
246
+ *
247
+ * Stripe → HTTP → WebhookRouter (verify + idempotency dedup)
248
+ * ↓ deliver(envelope)
249
+ * StripeBillingDispatcher (this file)
250
+ * ↓
251
+ * ├─ SubscriptionStore.saveIfVersion(...)
252
+ * └─ emit(typed event) → consumer's subscriber
253
+ *
254
+ * Critical guarantees:
255
+ *
256
+ * 1. Idempotency at two layers — the router de-dupes at the event id;
257
+ * the dispatcher's `saveIfVersion` defends against the second
258
+ * router instance (multi-region) racing the same event. The
259
+ * consumer's subscriber sees an event AT MOST ONCE per `eventId`.
260
+ *
261
+ * 2. Order-independence — Stripe doesn't guarantee delivery order.
262
+ * We process events whose `event.created` timestamp is older than
263
+ * the stored `updatedAt` only when the resulting state would be a
264
+ * valid transition; otherwise we drop with `dropped:'out_of_order'`.
265
+ *
266
+ * 3. Explicit unknown handling — events we don't have a handler for
267
+ * are not dropped silently; we emit them as
268
+ * `stripe.event_unhandled` so the consumer can log + alert if
269
+ * they expected coverage that we don't ship.
270
+ *
271
+ * 4. Idempotency of the dispatcher itself — calling `dispatch()` with
272
+ * an event whose id equals `lastEventId` on the loaded record is a
273
+ * no-op that emits `stripe.event_replay` instead of advancing state.
274
+ *
275
+ * Events supported (8 critical + 2 lifecycle):
276
+ *
277
+ * customer.subscription.created
278
+ * customer.subscription.updated
279
+ * customer.subscription.deleted
280
+ * customer.subscription.trial_will_end
281
+ * customer.subscription.paused
282
+ * customer.subscription.resumed
283
+ * invoice.paid
284
+ * invoice.payment_failed
285
+ */
286
+
287
+ /** Strongly-typed events the consumer can subscribe to. Each carries
288
+ * enough context to drive downstream side effects without a second
289
+ * DB round-trip (audit log row, Slack ping, in-app notification). */
290
+ type StripeBillingEvent = {
291
+ kind: 'subscription.created';
292
+ eventId: string;
293
+ record: SubscriptionRecord;
294
+ } | {
295
+ kind: 'subscription.updated';
296
+ eventId: string;
297
+ previousState: SubscriptionState;
298
+ record: SubscriptionRecord;
299
+ } | {
300
+ kind: 'subscription.deleted';
301
+ eventId: string;
302
+ record: SubscriptionRecord;
303
+ } | {
304
+ kind: 'subscription.trial_will_end';
305
+ eventId: string;
306
+ record: SubscriptionRecord;
307
+ trialEndsAt: number;
308
+ } | {
309
+ kind: 'subscription.paused';
310
+ eventId: string;
311
+ record: SubscriptionRecord;
312
+ } | {
313
+ kind: 'subscription.resumed';
314
+ eventId: string;
315
+ record: SubscriptionRecord;
316
+ } | {
317
+ kind: 'invoice.paid';
318
+ eventId: string;
319
+ record: SubscriptionRecord | null;
320
+ invoiceId: string;
321
+ amountPaid: number;
322
+ } | {
323
+ kind: 'invoice.payment_failed';
324
+ eventId: string;
325
+ record: SubscriptionRecord | null;
326
+ invoiceId: string;
327
+ amountDue: number;
328
+ } | {
329
+ kind: 'event_unhandled';
330
+ eventId: string;
331
+ type: string;
332
+ } | {
333
+ kind: 'event_replay';
334
+ eventId: string;
335
+ type: string;
336
+ } | {
337
+ kind: 'event_dropped_out_of_order';
338
+ eventId: string;
339
+ type: string;
340
+ reason: string;
341
+ };
342
+ /** Listener — the product agent wires this to whatever side-effect bus
343
+ * it owns (audit log, in-process emitter, durable queue). Throws are
344
+ * caught by `dispatch()` and surfaced through `onError`. */
345
+ type StripeBillingListener = (event: StripeBillingEvent) => void | Promise<void>;
346
+ interface StripeBillingDispatcherOptions {
347
+ store: SubscriptionStore;
348
+ /** Maps a Stripe `customer.id` → the workspaceId the product uses to
349
+ * key its `SubscriptionStore`. We default to reading
350
+ * `subscription.metadata.workspaceId` (agents inject it at checkout
351
+ * time); supply this override for products that key by customer id
352
+ * directly or look up a join table. */
353
+ resolveWorkspaceId?(input: {
354
+ customerId: string;
355
+ subscriptionMetadata?: Record<string, string>;
356
+ invoiceMetadata?: Record<string, string>;
357
+ }): Promise<string | null> | string | null;
358
+ /** Single typed listener (most consumers want one — they route inside
359
+ * it themselves). Compose multiple via `combineListeners(a, b)`. */
360
+ listener?: StripeBillingListener;
361
+ /** Surface unexpected dispatcher errors (validation, store contention
362
+ * exhausted) without crashing the webhook handler. */
363
+ onError?(err: unknown, context: {
364
+ eventId: string;
365
+ type: string;
366
+ }): void;
367
+ /** Override `Date.now()` for tests. */
368
+ now?(): number;
369
+ /** Max retries on `saveIfVersion` contention. Default 3. */
370
+ maxCasRetries?: number;
371
+ }
372
+ /**
373
+ * Process a webhook envelope. Safe to call concurrently with itself —
374
+ * the in-store CAS serializes per-workspace updates.
375
+ */
376
+ declare class StripeBillingDispatcher {
377
+ private readonly store;
378
+ private readonly resolveWorkspaceId;
379
+ private readonly listener?;
380
+ private readonly onError;
381
+ private readonly now;
382
+ private readonly maxCasRetries;
383
+ constructor(opts: StripeBillingDispatcherOptions);
384
+ /** Drive one envelope through the pipeline. Idempotent w.r.t. the
385
+ * event id (replays are a no-op + emit `event_replay`). */
386
+ dispatch(envelope: WebhookEnvelope): Promise<void>;
387
+ private handle;
388
+ private handleSubCreated;
389
+ private handleSubUpdated;
390
+ private handleSubDeleted;
391
+ private handleTrialWillEnd;
392
+ private handleSubLifecycle;
393
+ private handleInvoicePaid;
394
+ private handleInvoicePaymentFailed;
395
+ /** Load, apply a transformation, CAS-write. The transformation may
396
+ * return 'replay' / 'out_of_order' for the dispatcher to emit
397
+ * diagnostic events instead. Retries on contention up to
398
+ * `maxCasRetries`; if exhausted, emits via `onError`. */
399
+ private advance;
400
+ private cas;
401
+ private emit;
402
+ private emitNoWorkspace;
403
+ }
404
+ /** Compose multiple listeners — fan out + collect errors. */
405
+ declare function combineListeners(...listeners: StripeBillingListener[]): StripeBillingListener;
406
+
407
+ /**
408
+ * Per-tenant Stripe configuration routing.
409
+ *
410
+ * Five product agents (legal, tax, gtm, creative, agent-builder) each
411
+ * own a SEPARATE Stripe account. Reasons we pay the multi-account tax
412
+ * rather than billing everyone through a single Tangle Stripe account:
413
+ *
414
+ * 1. Each product is a different LLC/legal entity for tax + dispute
415
+ * handling. Customer chargebacks land on the product's account.
416
+ * 2. Stripe Tax + Atlas are per-account; we can't share a single
417
+ * Tax-collection setup across five SaaS products.
418
+ * 3. Each product has its own pricing experiments; sharing one account
419
+ * would force a shared products/prices namespace and surface
420
+ * leak risk in the Stripe dashboard.
421
+ *
422
+ * The routing table maps `productId` (a Tangle-internal stable
423
+ * identifier) to:
424
+ * - Stripe Secret Key (`sk_live_…` or `rk_live_…`)
425
+ * - Webhook signing secret (`whsec_…`) — used by the WebhookRouter's
426
+ * `resolveSecret` callback.
427
+ * - Optional success/cancel URL defaults the product wants used
428
+ * unless the caller overrides per-checkout.
429
+ *
430
+ * Env-var convention is `STRIPE_SK_<PRODUCT_UPPER>` and
431
+ * `STRIPE_WHSEC_<PRODUCT_UPPER>` — the resolver below honors that by
432
+ * default. Consumers that store keys in a vault (Doppler, AWS Secrets
433
+ * Manager) inject their own `TenantConfigResolver` instead.
434
+ *
435
+ * Critical invariant: this module NEVER caches resolved keys across
436
+ * `getStripeClient()` calls without the consumer opting in. Stripe
437
+ * encourages key rotation (Atlas docs); a cached `sk_…` outlives the
438
+ * rotation. The default `EnvTenantConfigResolver` re-reads env every
439
+ * call. Consumers that want a memoized cache wrap with
440
+ * `memoizeResolver(resolver, ttlMs)`.
441
+ */
442
+ /** Stable product identifiers — kept in sync with the product registry.
443
+ * Adding a product is a one-line addition; we centralize so a typo at
444
+ * a call site (`'legal-agent'` vs `'legal'`) is a type error. */
445
+ type ProductId = 'legal' | 'tax' | 'gtm' | 'creative' | 'agent-builder';
446
+ declare const PRODUCT_IDS: readonly ProductId[];
447
+ interface TenantStripeConfig {
448
+ productId: ProductId;
449
+ /** Stripe API secret key. Treat as opaque — do NOT log. */
450
+ secretKey: string;
451
+ /** Webhook signing secret (`whsec_...`). */
452
+ webhookSecret: string;
453
+ /** Optional default URLs the checkout/portal generators fall back to. */
454
+ successUrl?: string;
455
+ cancelUrl?: string;
456
+ /** Free-form metadata threaded through to the product (e.g., the
457
+ * Connect account id if you later migrate to Connect). */
458
+ metadata?: Record<string, string>;
459
+ }
460
+ /** Stateless resolver — called per `getStripeClient()` / `resolveSecret()`.
461
+ * Implementations: read from env (default), read from a vault, read
462
+ * from a workspace-scoped DB row (per-tenant Connect). */
463
+ interface TenantConfigResolver {
464
+ resolve(productId: ProductId): Promise<TenantStripeConfig | null> | TenantStripeConfig | null;
465
+ }
466
+ /**
467
+ * Reads `STRIPE_SK_<PRODUCT>` + `STRIPE_WHSEC_<PRODUCT>` from
468
+ * `process.env`. Product id is upper-snake-cased (`agent-builder` →
469
+ * `AGENT_BUILDER`).
470
+ *
471
+ * Optional defaults:
472
+ * STRIPE_SUCCESS_URL_<PRODUCT>
473
+ * STRIPE_CANCEL_URL_<PRODUCT>
474
+ */
475
+ declare class EnvTenantConfigResolver implements TenantConfigResolver {
476
+ private readonly env;
477
+ constructor(env?: NodeJS.ProcessEnv);
478
+ resolve(productId: ProductId): TenantStripeConfig | null;
479
+ }
480
+ /**
481
+ * Static resolver — pass a hardcoded map, useful for tests and for
482
+ * deployments that pull from a vault at boot.
483
+ */
484
+ declare class StaticTenantConfigResolver implements TenantConfigResolver {
485
+ private readonly table;
486
+ constructor(table: Partial<Record<ProductId, TenantStripeConfig>>);
487
+ resolve(productId: ProductId): TenantStripeConfig | null;
488
+ }
489
+ /**
490
+ * Memoize a resolver with a TTL. Used in production to avoid pounding
491
+ * a remote vault on every webhook. Default 60s — short enough that a
492
+ * key rotation lands within the next minute.
493
+ */
494
+ declare function memoizeResolver(inner: TenantConfigResolver, ttlMs?: number): TenantConfigResolver;
495
+ /**
496
+ * Thin Stripe HTTP client handle. We do NOT depend on the `stripe`
497
+ * npm package — same rationale as `stripe-pack`: keep the install
498
+ * footprint zero, use `fetch` directly. The handle carries the
499
+ * resolved secret + a Stripe-spec base URL so call sites can issue
500
+ * scoped requests without re-resolving for every operation in a
501
+ * batch.
502
+ *
503
+ * Idempotency-Key forwarding: every mutation MUST include an
504
+ * `idempotency-key` header. Stripe enforces a 24h replay window
505
+ * keyed off it. The `mutate()` helper accepts the key explicitly to
506
+ * make it impossible to forget.
507
+ */
508
+ interface StripeClient {
509
+ productId: ProductId;
510
+ config: TenantStripeConfig;
511
+ /** GET request — returns parsed JSON or throws on non-2xx. */
512
+ get<T = unknown>(path: string, query?: Record<string, string>): Promise<T>;
513
+ /** Form-urlencoded POST/DELETE with idempotency. */
514
+ mutate<T = unknown>(method: 'POST' | 'DELETE', path: string, body: Record<string, string | number | boolean | undefined>, idempotencyKey: string): Promise<T>;
515
+ }
516
+ /**
517
+ * Look up the Stripe client for a product. Throws `ConfigError` if the
518
+ * resolver returns null — the product agent fails its startup health
519
+ * check and the deploy is held back. NEVER silently falls back to a
520
+ * shared key.
521
+ */
522
+ declare function getStripeClient(productId: ProductId, resolver: TenantConfigResolver): Promise<StripeClient>;
523
+ /** Build a client from an already-resolved config — for callers that
524
+ * manage resolution themselves (e.g., long-lived workers that
525
+ * resolved at startup). */
526
+ declare function buildStripeClient(config: TenantStripeConfig): StripeClient;
527
+ /**
528
+ * `WebhookRouter.resolveSecret` adapter. The router calls this with
529
+ * the provider id and headers; we extract the product id from a
530
+ * header the gateway routes by (`x-tangle-product`) and look it up.
531
+ *
532
+ * Why a header and not the URL path: the router is provider-keyed,
533
+ * not product-keyed, by design. Products inject the header in their
534
+ * gateway layer (Hono middleware in our case). The header is
535
+ * authenticated as part of the gateway's edge auth — Stripe's own
536
+ * signature still has to verify against the secret we return here,
537
+ * so a forged header alone can't bypass anything.
538
+ */
539
+ declare function makeStripeSecretResolver(resolver: TenantConfigResolver): (providerId: string, headers: {
540
+ [name: string]: string | string[] | undefined;
541
+ }) => Promise<string | null>;
542
+
543
+ /**
544
+ * Pricing plan scaffold + checkout URL generator.
545
+ *
546
+ * Per task constraint, this module does NOT bake in pricing. The
547
+ * consumer (product agent) supplies the `PricingPlan[]` table at boot.
548
+ * We standardize the SHAPE (id, name, monthly/yearly USD, feature
549
+ * bullets, stripe price ids), the LOOKUP (`findPlan`, `requirePlan`),
550
+ * and the CHECKOUT URL flow (`createCheckoutUrl`).
551
+ *
552
+ * The shape is intentionally USD-only with month/year recurrence.
553
+ * Stripe supports more — multi-currency, week/quarter, usage-based,
554
+ * tiered — but adding columns we don't use creates pressure to fill
555
+ * them with defaults that mislead. When a product needs more, extend
556
+ * the shape; do not work around it in the consumer.
557
+ *
558
+ * `createCheckoutUrl` writes the workspaceId into
559
+ * `subscription_data.metadata.workspaceId` so the dispatcher's default
560
+ * `resolveWorkspaceId` finds it on the first `customer.subscription.created`
561
+ * webhook. THIS IS LOAD-BEARING: drop it and you have to write a
562
+ * customer → workspace join table by hand.
563
+ */
564
+
565
+ interface PricingPlanFeature {
566
+ /** Short label rendered in pricing table rows. */
567
+ label: string;
568
+ /** Optional richer description for the marketing page. */
569
+ description?: string;
570
+ /** Whether the feature is included in this plan. Many products show
571
+ * the same feature row across plans with a check/cross. */
572
+ included: boolean;
573
+ }
574
+ interface PricingPlan {
575
+ /** Stable internal id — used by middleware to gate features, NOT the
576
+ * Stripe price id. */
577
+ id: string;
578
+ /** Display name in the pricing table. */
579
+ name: string;
580
+ /** Monthly price in whole USD. `null` for plans that are yearly-only
581
+ * or contact-sales tiers. */
582
+ monthlyUsd: number | null;
583
+ /** Yearly price in whole USD. `null` for plans that don't offer
584
+ * annual billing. */
585
+ yearlyUsd: number | null;
586
+ /** Marketing feature bullets. */
587
+ features: PricingPlanFeature[];
588
+ /** Stripe `price_…` ids per cadence. At least one must be set if the
589
+ * matching `*Usd` field is non-null. */
590
+ stripePriceIds: {
591
+ monthly?: string;
592
+ yearly?: string;
593
+ };
594
+ /** Optional trial-day grant. The dispatcher writes `trialEnd` based
595
+ * on Stripe's response; this field is only the request-time intent. */
596
+ trialDays?: number;
597
+ /** Optional metadata threaded into Stripe Subscription metadata — the
598
+ * product can use these for analytics or grant-feature lookup. */
599
+ metadata?: Record<string, string>;
600
+ }
601
+ type BillingCadence = 'monthly' | 'yearly';
602
+ /** Find a plan by id. Returns null when not found. */
603
+ declare function findPlan(plans: readonly PricingPlan[], id: string): PricingPlan | null;
604
+ /** Look up a plan or throw — for code paths where missing is a bug
605
+ * (e.g., resolving a stored subscription's plan id back to a name). */
606
+ declare function requirePlan(plans: readonly PricingPlan[], id: string): PricingPlan;
607
+ interface CreateCheckoutUrlInput {
608
+ /** Tenant key — written to subscription metadata for webhook routing. */
609
+ workspaceId: string;
610
+ /** Plan from the consumer's pricing table. */
611
+ plan: PricingPlan;
612
+ /** Which Stripe price id to charge against. */
613
+ billing: BillingCadence;
614
+ /** Optional existing Stripe customer id — pre-fills the checkout. */
615
+ customerId?: string;
616
+ /** Customer email — used by Stripe to pre-fill or to create a new
617
+ * customer if `customerId` is absent. */
618
+ customerEmail?: string;
619
+ /** Success/cancel URLs. Overrides the per-tenant defaults from
620
+ * `TenantStripeConfig.successUrl`/`cancelUrl`. */
621
+ successUrl?: string;
622
+ cancelUrl?: string;
623
+ /** Idempotency key — pass a deterministic key (e.g.,
624
+ * `${workspaceId}:${plan.id}:${billing}`) so the same user clicking
625
+ * twice gets the same checkout session. */
626
+ idempotencyKey: string;
627
+ /** Trial override — if set, beats `plan.trialDays`. */
628
+ trialDays?: number;
629
+ /** Optional extra metadata mixed into Stripe metadata. */
630
+ metadata?: Record<string, string>;
631
+ }
632
+ interface CheckoutUrl {
633
+ sessionId: string;
634
+ url: string;
635
+ }
636
+ /**
637
+ * Create a Stripe checkout session and return its hosted URL. Uses the
638
+ * per-tenant `StripeClient` from `getStripeClient(productId)`.
639
+ *
640
+ * The workspaceId is written into TWO metadata maps:
641
+ * - `metadata` (on the session itself, surfaces on `checkout.session.*`)
642
+ * - `subscription_data[metadata]` (carries through to the Subscription
643
+ * row Stripe creates, which is what `customer.subscription.*`
644
+ * webhooks carry)
645
+ *
646
+ * Without the second, the dispatcher can't route the first
647
+ * `subscription.created` event to a workspace. We've shipped that bug
648
+ * before — written here once to make it impossible to forget.
649
+ */
650
+ declare function createCheckoutUrl(client: StripeClient, input: CreateCheckoutUrlInput): Promise<CheckoutUrl>;
651
+ /**
652
+ * Create a Stripe customer-billing-portal session and return its URL.
653
+ * The product calls this when a user clicks "manage billing" — the
654
+ * portal handles cancel / change plan / update card without us
655
+ * implementing those flows.
656
+ */
657
+ declare function createBillingPortalUrl(client: StripeClient, input: {
658
+ customerId: string;
659
+ returnUrl: string;
660
+ idempotencyKey: string;
661
+ }): Promise<{
662
+ sessionId: string;
663
+ url: string;
664
+ }>;
665
+
666
+ /**
667
+ * Drop-in middleware for product agents.
668
+ *
669
+ * Three primitives consumers wire into their HTTP layer (Hono, Express,
670
+ * raw Workers `fetch` handler — middleware here is framework-neutral,
671
+ * returns a `BillingGate` value the consumer chooses how to respond to).
672
+ *
673
+ * requireActiveSubscription({ workspaceId, store })
674
+ * → 'allow' | { allowed: false, error: BillingError }
675
+ *
676
+ * withTrialAccess({ workspaceId, days, trialStore })
677
+ * → allow while trial < days expired since workspace creation
678
+ *
679
+ * getRemainingFreeTier({ workspaceId, freeTierStore })
680
+ * → { remaining: number, total: number }
681
+ *
682
+ * Frameworks: we don't import Hono / Express. The middleware shape is a
683
+ * pure async function returning a decision. The product wires it into
684
+ * its framework with a 3-line adapter (see `examples/hono.ts`).
685
+ *
686
+ * Past-due policy: by default `requireActiveSubscription` allows
687
+ * `past_due` (the dunning grace window — see `gateAccess` in
688
+ * `subscription-state.ts`). Pass `denyPastDue: true` to override
689
+ * per-route (e.g., legal-agent's "file new petition" gate where
690
+ * irreversible actions justify a stricter rule).
691
+ */
692
+
693
+ interface RequireActiveSubscriptionInput {
694
+ workspaceId: string;
695
+ store: SubscriptionStore;
696
+ /** Strict mode: reject `past_due`. Default false (allow with warn). */
697
+ denyPastDue?: boolean;
698
+ }
699
+ type SubscriptionGateResult = {
700
+ allowed: true;
701
+ record: SubscriptionRecord;
702
+ warn?: 'past_due' | 'trial_ending';
703
+ } | {
704
+ allowed: false;
705
+ error: BillingError;
706
+ };
707
+ /**
708
+ * Gate decision for a route that requires an active subscription.
709
+ *
710
+ * Returns `{ allowed: true }` on `active` / `trialing` and on
711
+ * `past_due` (unless `denyPastDue`). Returns `{ allowed: false, error }`
712
+ * with a typed `BillingError` for any other state — the consumer maps
713
+ * the error's `status` to the HTTP response.
714
+ */
715
+ declare function requireActiveSubscription(input: RequireActiveSubscriptionInput): Promise<SubscriptionGateResult>;
716
+ /** Workspace creation timestamp store — required by `withTrialAccess`. */
717
+ interface TrialStore {
718
+ /** Returns workspace creation timestamp (ms epoch), or null if the
719
+ * workspace doesn't exist yet. */
720
+ getCreatedAt(workspaceId: string): Promise<number | null> | number | null;
721
+ }
722
+ interface WithTrialAccessInput {
723
+ workspaceId: string;
724
+ /** Trial length in days from workspace creation. */
725
+ days: number;
726
+ trialStore: TrialStore;
727
+ /** Optional `now` override for tests. */
728
+ now?: () => number;
729
+ }
730
+ interface TrialAccessResult {
731
+ /** Whether the workspace is still inside its free-trial window. */
732
+ inTrial: boolean;
733
+ /** Days remaining (rounded down). Zero when `inTrial` is false. */
734
+ daysRemaining: number;
735
+ /** Trial end timestamp (ms epoch), null when no workspace found. */
736
+ trialEndsAt: number | null;
737
+ }
738
+ /**
739
+ * Free-trial gate independent of Stripe state. Use BEFORE a workspace
740
+ * has a Stripe subscription (the product's onboarding period). Compose
741
+ * with `requireActiveSubscription`: trial OR active sub passes the gate.
742
+ *
743
+ * Composition pattern:
744
+ *
745
+ * const trial = await withTrialAccess(...)
746
+ * if (trial.inTrial) return next()
747
+ * const sub = await requireActiveSubscription(...)
748
+ * if (sub.allowed) return next()
749
+ * return respond(sub.error)
750
+ */
751
+ declare function withTrialAccess(input: WithTrialAccessInput): Promise<TrialAccessResult>;
752
+ /** Free-tier counter store — abstract over the consumer's metering
753
+ * pipeline. The interface is read-only; products own counter increment
754
+ * on usage (e.g., increment on every API call in their own metrics
755
+ * layer). */
756
+ interface FreeTierStore {
757
+ /** Returns `{ used, total }` for the workspace. Implementations
758
+ * return `{ used: 0, total: <default> }` for unknown workspaces if
759
+ * the product wants implicit free-tier grant. */
760
+ getUsage(workspaceId: string): Promise<{
761
+ used: number;
762
+ total: number;
763
+ }> | {
764
+ used: number;
765
+ total: number;
766
+ };
767
+ }
768
+ interface GetRemainingFreeTierInput {
769
+ workspaceId: string;
770
+ freeTierStore: FreeTierStore;
771
+ }
772
+ interface FreeTierResult {
773
+ /** Units (whatever the product counts: API calls, tokens, generations) still allowed. */
774
+ remaining: number;
775
+ /** Total quota. */
776
+ total: number;
777
+ /** Whether the quota is exhausted. */
778
+ exhausted: boolean;
779
+ }
780
+ /**
781
+ * Return how much free-tier quota the workspace has left. Pure projection
782
+ * over the store; consumers use the result to decide whether to grant the
783
+ * route or return `BillingError(code: 'free_tier_exhausted')`.
784
+ *
785
+ * Why this isn't a gate function itself: free-tier "exhausted" is rarely
786
+ * a hard deny — most products throttle, queue, or upsell instead. The
787
+ * decision is product-specific; we provide the read and the typed error
788
+ * but stop short of opining on the response shape.
789
+ */
790
+ declare function getRemainingFreeTier(input: GetRemainingFreeTierInput): Promise<FreeTierResult>;
791
+ interface ComposedGateInput {
792
+ workspaceId: string;
793
+ store: SubscriptionStore;
794
+ trialStore?: TrialStore;
795
+ trialDays?: number;
796
+ denyPastDue?: boolean;
797
+ now?: () => number;
798
+ }
799
+ /**
800
+ * Compose `withTrialAccess` || `requireActiveSubscription`. Most product
801
+ * routes want this exact combo — passes if EITHER the workspace is
802
+ * inside its free trial OR has an active subscription. Returns the
803
+ * subscription error from `requireActiveSubscription` when both fail
804
+ * (the more actionable of the two — the customer can convert it into
805
+ * a checkout).
806
+ */
807
+ declare function gateSubscriptionOrTrial(input: ComposedGateInput): Promise<SubscriptionGateResult & {
808
+ viaTrial?: boolean;
809
+ daysRemaining?: number;
810
+ }>;
811
+
812
+ export { type AccessDecision, type BillingCadence, BillingError, type BillingErrorCode, type BillingErrorContext, type CheckoutUrl, type ComposedGateInput, ConfigError, type CreateCheckoutUrlInput, EnvTenantConfigResolver, FileSystemSubscriptionStore, type FreeTierResult, type FreeTierStore, type GetRemainingFreeTierInput, InMemorySubscriptionStore, PRODUCT_IDS, type PricingPlan, type PricingPlanFeature, type ProductId, type RequireActiveSubscriptionInput, SUBSCRIPTION_STATES, StaticTenantConfigResolver, StripeBillingDispatcher, type StripeBillingDispatcherOptions, type StripeBillingEvent, type StripeBillingListener, type StripeClient, type SubscriptionGateResult, type SubscriptionRecord, type SubscriptionState, type SubscriptionStore, type TenantConfigResolver, type TenantStripeConfig, type TrialAccessResult, type TrialStore, type WithTrialAccessInput, applyTransition, buildStripeClient, combineListeners, createBillingPortalUrl, createCheckoutUrl, findPlan, gateAccess, gateSubscriptionOrTrial, getRemainingFreeTier, getStripeClient, isValidTransition, makeStripeSecretResolver, makeSubscriptionRecord, memoizeResolver, requireActiveSubscription, requirePlan, withTrialAccess };