@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,866 @@
1
+ import {
2
+ IntegrationRuntimeError
3
+ } from "../chunk-H4XYLS7T.js";
4
+
5
+ // src/stripe/errors.ts
6
+ var BillingError = class extends IntegrationRuntimeError {
7
+ billingCode;
8
+ context;
9
+ constructor(input) {
10
+ super({
11
+ code: mapToIntegrationCode(input.code),
12
+ message: input.message,
13
+ status: statusForBillingCode(input.code),
14
+ userAction: input.userAction ?? defaultUserAction(input.code),
15
+ metadata: input.context
16
+ });
17
+ this.name = "BillingError";
18
+ this.billingCode = input.code;
19
+ this.context = input.context ?? {};
20
+ }
21
+ };
22
+ var ConfigError = class extends BillingError {
23
+ constructor(input) {
24
+ super({
25
+ code: "tenant_not_configured",
26
+ message: input.message,
27
+ context: input.context,
28
+ userAction: { type: "contact_support", label: "Contact support" }
29
+ });
30
+ this.name = "ConfigError";
31
+ }
32
+ };
33
+ function mapToIntegrationCode(code) {
34
+ switch (code) {
35
+ case "subscription_required":
36
+ case "subscription_inactive":
37
+ case "subscription_past_due":
38
+ case "trial_expired":
39
+ case "free_tier_exhausted":
40
+ return "action_denied";
41
+ case "tenant_not_configured":
42
+ return "provider_error";
43
+ case "webhook_secret_missing":
44
+ return "provider_auth_failed";
45
+ case "webhook_event_unknown":
46
+ case "webhook_replay":
47
+ return "input_invalid";
48
+ }
49
+ }
50
+ function statusForBillingCode(code) {
51
+ switch (code) {
52
+ case "subscription_required":
53
+ case "subscription_inactive":
54
+ case "subscription_past_due":
55
+ case "trial_expired":
56
+ case "free_tier_exhausted":
57
+ return 403;
58
+ case "tenant_not_configured":
59
+ return 500;
60
+ case "webhook_secret_missing":
61
+ return 401;
62
+ case "webhook_event_unknown":
63
+ case "webhook_replay":
64
+ return 400;
65
+ }
66
+ }
67
+ function defaultUserAction(code) {
68
+ switch (code) {
69
+ case "subscription_required":
70
+ return { type: "change_request", label: "Subscribe to continue" };
71
+ case "subscription_inactive":
72
+ case "subscription_past_due":
73
+ return { type: "change_request", label: "Update billing" };
74
+ case "trial_expired":
75
+ return { type: "change_request", label: "Choose a plan" };
76
+ case "free_tier_exhausted":
77
+ return { type: "change_request", label: "Upgrade for more usage" };
78
+ case "tenant_not_configured":
79
+ case "webhook_secret_missing":
80
+ return { type: "contact_support", label: "Contact support" };
81
+ case "webhook_event_unknown":
82
+ case "webhook_replay":
83
+ return void 0;
84
+ }
85
+ }
86
+
87
+ // src/stripe/subscription-state.ts
88
+ var SUBSCRIPTION_STATES = Object.freeze([
89
+ "incomplete",
90
+ "incomplete_expired",
91
+ "trialing",
92
+ "active",
93
+ "past_due",
94
+ "canceled",
95
+ "unpaid",
96
+ "paused"
97
+ ]);
98
+ var TRANSITIONS = Object.freeze({
99
+ incomplete: /* @__PURE__ */ new Set(["active", "trialing", "incomplete_expired", "canceled"]),
100
+ incomplete_expired: /* @__PURE__ */ new Set([]),
101
+ trialing: /* @__PURE__ */ new Set(["active", "past_due", "canceled", "paused", "unpaid"]),
102
+ active: /* @__PURE__ */ new Set(["past_due", "canceled", "paused", "unpaid", "trialing"]),
103
+ past_due: /* @__PURE__ */ new Set(["active", "canceled", "unpaid", "paused"]),
104
+ canceled: /* @__PURE__ */ new Set([]),
105
+ unpaid: /* @__PURE__ */ new Set(["active", "canceled", "past_due"]),
106
+ paused: /* @__PURE__ */ new Set(["active", "canceled", "past_due"])
107
+ });
108
+ function isValidTransition(from, to) {
109
+ if (from === to) return true;
110
+ return TRANSITIONS[from].has(to);
111
+ }
112
+ function applyTransition(current, next, options = {}) {
113
+ if (!isValidTransition(current.state, next.state)) {
114
+ throw new BillingError({
115
+ code: "webhook_event_unknown",
116
+ message: `Illegal subscription transition ${current.state} \u2192 ${next.state}`,
117
+ context: {
118
+ workspaceId: current.workspaceId,
119
+ subscriptionId: current.subscriptionId,
120
+ subscriptionState: current.state,
121
+ eventId: options.eventId
122
+ }
123
+ });
124
+ }
125
+ const now = (options.now ?? Date.now)();
126
+ return {
127
+ ...current,
128
+ ...next,
129
+ version: current.version + 1,
130
+ lastEventId: options.eventId ?? current.lastEventId,
131
+ updatedAt: now
132
+ };
133
+ }
134
+ function gateAccess(state) {
135
+ switch (state) {
136
+ case "active":
137
+ case "trialing":
138
+ return { allowed: true };
139
+ case "past_due":
140
+ return { allowed: true, warn: "past_due" };
141
+ case "paused":
142
+ return { allowed: false, reason: "subscription_past_due" };
143
+ case "canceled":
144
+ case "unpaid":
145
+ return { allowed: false, reason: "subscription_inactive" };
146
+ case "incomplete":
147
+ case "incomplete_expired":
148
+ return { allowed: false, reason: "subscription_inactive" };
149
+ }
150
+ }
151
+ var InMemorySubscriptionStore = class {
152
+ records = /* @__PURE__ */ new Map();
153
+ async load(workspaceId) {
154
+ const r = this.records.get(workspaceId);
155
+ return r ? { ...r } : null;
156
+ }
157
+ async save(record) {
158
+ this.records.set(record.workspaceId, { ...record });
159
+ }
160
+ async saveIfVersion(record, expectedVersion) {
161
+ const current = this.records.get(record.workspaceId);
162
+ if (current && current.version !== expectedVersion) return false;
163
+ if (!current && expectedVersion !== 0) return false;
164
+ this.records.set(record.workspaceId, { ...record });
165
+ return true;
166
+ }
167
+ };
168
+ var FileSystemSubscriptionStore = class {
169
+ constructor(rootDir) {
170
+ this.rootDir = rootDir;
171
+ }
172
+ rootDir;
173
+ async load(workspaceId) {
174
+ const fs = await import("fs/promises");
175
+ const path = await import("path");
176
+ const file = path.join(this.rootDir, this.fileName(workspaceId));
177
+ try {
178
+ const raw = await fs.readFile(file, "utf-8");
179
+ return JSON.parse(raw);
180
+ } catch (err) {
181
+ if (isNodeENOENT(err)) return null;
182
+ throw err;
183
+ }
184
+ }
185
+ async save(record) {
186
+ const fs = await import("fs/promises");
187
+ const path = await import("path");
188
+ await fs.mkdir(this.rootDir, { recursive: true });
189
+ const file = path.join(this.rootDir, this.fileName(record.workspaceId));
190
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
191
+ await fs.writeFile(tmp, JSON.stringify(record), "utf-8");
192
+ await fs.rename(tmp, file);
193
+ }
194
+ async saveIfVersion(record, expectedVersion) {
195
+ const existing = await this.load(record.workspaceId);
196
+ if (existing && existing.version !== expectedVersion) return false;
197
+ if (!existing && expectedVersion !== 0) return false;
198
+ await this.save(record);
199
+ return true;
200
+ }
201
+ /** Safe filename: workspaceId is restricted to a charset that maps 1:1
202
+ * to a posix filename. Anything outside is hex-encoded so we can never
203
+ * escape the rootDir via `../`. */
204
+ fileName(workspaceId) {
205
+ if (!/^[A-Za-z0-9_.-]+$/.test(workspaceId)) {
206
+ return `${Buffer.from(workspaceId, "utf-8").toString("hex")}.json`;
207
+ }
208
+ return `${workspaceId}.json`;
209
+ }
210
+ };
211
+ function isNodeENOENT(err) {
212
+ return !!err && typeof err === "object" && err.code === "ENOENT";
213
+ }
214
+ function makeSubscriptionRecord(input) {
215
+ const now = (input.now ?? Date.now)();
216
+ return {
217
+ workspaceId: input.workspaceId,
218
+ customerId: input.customerId,
219
+ subscriptionId: input.subscriptionId,
220
+ state: input.state,
221
+ priceId: input.priceId,
222
+ currentPeriodEnd: input.currentPeriodEnd,
223
+ trialEnd: input.trialEnd ?? null,
224
+ cancelAtPeriodEnd: input.cancelAtPeriodEnd ?? false,
225
+ version: 0,
226
+ lastEventId: null,
227
+ updatedAt: now
228
+ };
229
+ }
230
+
231
+ // src/stripe/webhooks.ts
232
+ var StripeBillingDispatcher = class {
233
+ store;
234
+ resolveWorkspaceId;
235
+ listener;
236
+ onError;
237
+ now;
238
+ maxCasRetries;
239
+ constructor(opts) {
240
+ this.store = opts.store;
241
+ this.resolveWorkspaceId = opts.resolveWorkspaceId ?? defaultResolveWorkspaceId;
242
+ this.listener = opts.listener;
243
+ this.onError = opts.onError ?? defaultOnError;
244
+ this.now = opts.now ?? Date.now;
245
+ this.maxCasRetries = opts.maxCasRetries ?? 3;
246
+ }
247
+ /** Drive one envelope through the pipeline. Idempotent w.r.t. the
248
+ * event id (replays are a no-op + emit `event_replay`). */
249
+ async dispatch(envelope) {
250
+ const evt = envelope.payload;
251
+ if (!evt || typeof evt !== "object" || typeof evt.id !== "string" || typeof evt.type !== "string") {
252
+ this.onError(new Error("Stripe envelope missing id or type"), {
253
+ eventId: "unknown",
254
+ type: "unknown"
255
+ });
256
+ return;
257
+ }
258
+ try {
259
+ await this.handle(evt);
260
+ } catch (err) {
261
+ this.onError(err, { eventId: evt.id, type: evt.type });
262
+ }
263
+ }
264
+ async handle(evt) {
265
+ switch (evt.type) {
266
+ case "customer.subscription.created":
267
+ return this.handleSubCreated(evt);
268
+ case "customer.subscription.updated":
269
+ return this.handleSubUpdated(evt);
270
+ case "customer.subscription.deleted":
271
+ return this.handleSubDeleted(evt);
272
+ case "customer.subscription.trial_will_end":
273
+ return this.handleTrialWillEnd(evt);
274
+ case "customer.subscription.paused":
275
+ return this.handleSubLifecycle(evt, "paused");
276
+ case "customer.subscription.resumed":
277
+ return this.handleSubLifecycle(evt, "active");
278
+ case "invoice.paid":
279
+ return this.handleInvoicePaid(evt);
280
+ case "invoice.payment_failed":
281
+ return this.handleInvoicePaymentFailed(evt);
282
+ default:
283
+ await this.emit({ kind: "event_unhandled", eventId: evt.id, type: evt.type });
284
+ return;
285
+ }
286
+ }
287
+ /* --------------------- subscription event handlers ------------------- */
288
+ async handleSubCreated(evt) {
289
+ const sub = evt.data.object;
290
+ const workspaceId = await this.resolveWorkspaceId({
291
+ customerId: sub.customer,
292
+ subscriptionMetadata: sub.metadata
293
+ });
294
+ if (!workspaceId) return this.emitNoWorkspace(evt);
295
+ const existing = await this.store.load(workspaceId);
296
+ if (existing && existing.lastEventId === evt.id) {
297
+ return this.emit({ kind: "event_replay", eventId: evt.id, type: evt.type });
298
+ }
299
+ if (existing && !canApplyFreshCreate(existing.state)) {
300
+ return this.emit({
301
+ kind: "event_dropped_out_of_order",
302
+ eventId: evt.id,
303
+ type: evt.type,
304
+ reason: `existing state ${existing.state} cannot accept a fresh 'created'`
305
+ });
306
+ }
307
+ const record = makeSubscriptionRecord({
308
+ workspaceId,
309
+ customerId: sub.customer,
310
+ subscriptionId: sub.id,
311
+ state: parseState(sub.status, evt.id),
312
+ priceId: extractPriceId(sub),
313
+ currentPeriodEnd: sub.current_period_end ?? null,
314
+ trialEnd: sub.trial_end ?? null,
315
+ cancelAtPeriodEnd: sub.cancel_at_period_end ?? false,
316
+ now: this.now
317
+ });
318
+ const stamped = { ...record, lastEventId: evt.id };
319
+ const expectedVersion = existing?.version ?? 0;
320
+ const written = await this.cas(stamped, expectedVersion);
321
+ if (!written) return;
322
+ await this.emit({ kind: "subscription.created", eventId: evt.id, record: stamped });
323
+ }
324
+ async handleSubUpdated(evt) {
325
+ const sub = evt.data.object;
326
+ const workspaceId = await this.resolveWorkspaceId({
327
+ customerId: sub.customer,
328
+ subscriptionMetadata: sub.metadata
329
+ });
330
+ if (!workspaceId) return this.emitNoWorkspace(evt);
331
+ const nextState = parseState(sub.status, evt.id);
332
+ await this.advance(evt, workspaceId, (current) => {
333
+ if (current.lastEventId === evt.id) return "replay";
334
+ if (!isValidTransition(current.state, nextState)) return "out_of_order";
335
+ const next = applyTransition(
336
+ current,
337
+ {
338
+ state: nextState,
339
+ priceId: extractPriceId(sub) ?? current.priceId,
340
+ currentPeriodEnd: sub.current_period_end ?? current.currentPeriodEnd,
341
+ trialEnd: sub.trial_end ?? current.trialEnd,
342
+ cancelAtPeriodEnd: sub.cancel_at_period_end ?? current.cancelAtPeriodEnd
343
+ },
344
+ { eventId: evt.id, now: this.now }
345
+ );
346
+ return {
347
+ next,
348
+ emit: { kind: "subscription.updated", eventId: evt.id, previousState: current.state, record: next }
349
+ };
350
+ });
351
+ }
352
+ async handleSubDeleted(evt) {
353
+ const sub = evt.data.object;
354
+ const workspaceId = await this.resolveWorkspaceId({
355
+ customerId: sub.customer,
356
+ subscriptionMetadata: sub.metadata
357
+ });
358
+ if (!workspaceId) return this.emitNoWorkspace(evt);
359
+ await this.advance(evt, workspaceId, (current) => {
360
+ if (current.lastEventId === evt.id) return "replay";
361
+ if (current.state === "canceled") return "replay";
362
+ const next = applyTransition(
363
+ current,
364
+ { state: "canceled", priceId: null, currentPeriodEnd: sub.current_period_end ?? current.currentPeriodEnd },
365
+ { eventId: evt.id, now: this.now }
366
+ );
367
+ return {
368
+ next,
369
+ emit: { kind: "subscription.deleted", eventId: evt.id, record: next }
370
+ };
371
+ });
372
+ }
373
+ async handleTrialWillEnd(evt) {
374
+ const sub = evt.data.object;
375
+ const workspaceId = await this.resolveWorkspaceId({
376
+ customerId: sub.customer,
377
+ subscriptionMetadata: sub.metadata
378
+ });
379
+ if (!workspaceId) return this.emitNoWorkspace(evt);
380
+ const current = await this.store.load(workspaceId);
381
+ if (!current) return this.emitNoWorkspace(evt);
382
+ if (current.lastEventId === evt.id) {
383
+ return this.emit({ kind: "event_replay", eventId: evt.id, type: evt.type });
384
+ }
385
+ const next = {
386
+ ...current,
387
+ lastEventId: evt.id,
388
+ trialEnd: sub.trial_end ?? current.trialEnd,
389
+ version: current.version + 1,
390
+ updatedAt: this.now()
391
+ };
392
+ const written = await this.cas(next, current.version);
393
+ if (!written) return;
394
+ await this.emit({
395
+ kind: "subscription.trial_will_end",
396
+ eventId: evt.id,
397
+ record: next,
398
+ trialEndsAt: sub.trial_end ?? next.trialEnd ?? 0
399
+ });
400
+ }
401
+ async handleSubLifecycle(evt, target) {
402
+ const sub = evt.data.object;
403
+ const workspaceId = await this.resolveWorkspaceId({
404
+ customerId: sub.customer,
405
+ subscriptionMetadata: sub.metadata
406
+ });
407
+ if (!workspaceId) return this.emitNoWorkspace(evt);
408
+ await this.advance(evt, workspaceId, (current) => {
409
+ if (current.lastEventId === evt.id) return "replay";
410
+ if (!isValidTransition(current.state, target)) return "out_of_order";
411
+ const next = applyTransition(current, { state: target }, { eventId: evt.id, now: this.now });
412
+ const kind = target === "paused" ? "subscription.paused" : "subscription.resumed";
413
+ return { next, emit: { kind, eventId: evt.id, record: next } };
414
+ });
415
+ }
416
+ /* ----------------------- invoice event handlers ---------------------- */
417
+ async handleInvoicePaid(evt) {
418
+ const inv = evt.data.object;
419
+ const workspaceId = await this.resolveWorkspaceId({
420
+ customerId: inv.customer ?? "",
421
+ invoiceMetadata: inv.metadata
422
+ });
423
+ let record = null;
424
+ if (workspaceId) record = await this.store.load(workspaceId);
425
+ await this.emit({
426
+ kind: "invoice.paid",
427
+ eventId: evt.id,
428
+ record,
429
+ invoiceId: inv.id,
430
+ amountPaid: inv.amount_paid ?? 0
431
+ });
432
+ }
433
+ async handleInvoicePaymentFailed(evt) {
434
+ const inv = evt.data.object;
435
+ const workspaceId = await this.resolveWorkspaceId({
436
+ customerId: inv.customer ?? "",
437
+ invoiceMetadata: inv.metadata
438
+ });
439
+ let record = null;
440
+ if (workspaceId) record = await this.store.load(workspaceId);
441
+ await this.emit({
442
+ kind: "invoice.payment_failed",
443
+ eventId: evt.id,
444
+ record,
445
+ invoiceId: inv.id,
446
+ amountDue: inv.amount_due ?? 0
447
+ });
448
+ }
449
+ /* ------------------------------- core -------------------------------- */
450
+ /** Load, apply a transformation, CAS-write. The transformation may
451
+ * return 'replay' / 'out_of_order' for the dispatcher to emit
452
+ * diagnostic events instead. Retries on contention up to
453
+ * `maxCasRetries`; if exhausted, emits via `onError`. */
454
+ async advance(evt, workspaceId, transform) {
455
+ for (let attempt = 0; attempt < this.maxCasRetries; attempt++) {
456
+ const current = await this.store.load(workspaceId);
457
+ if (!current) return this.emitNoWorkspace(evt);
458
+ const result = transform(current);
459
+ if (result === "replay") {
460
+ return this.emit({ kind: "event_replay", eventId: evt.id, type: evt.type });
461
+ }
462
+ if (result === "out_of_order") {
463
+ return this.emit({
464
+ kind: "event_dropped_out_of_order",
465
+ eventId: evt.id,
466
+ type: evt.type,
467
+ reason: `current=${current.state}`
468
+ });
469
+ }
470
+ const written = await this.store.saveIfVersion(result.next, current.version);
471
+ if (written) return this.emit(result.emit);
472
+ }
473
+ this.onError(new BillingError({
474
+ code: "webhook_event_unknown",
475
+ message: `CAS contention exhausted after ${this.maxCasRetries} attempts`,
476
+ context: { workspaceId, eventId: evt.id }
477
+ }), { eventId: evt.id, type: evt.type });
478
+ }
479
+ async cas(record, expectedVersion) {
480
+ for (let attempt = 0; attempt < this.maxCasRetries; attempt++) {
481
+ const ok = await this.store.saveIfVersion(record, expectedVersion + attempt);
482
+ if (ok) return true;
483
+ }
484
+ return false;
485
+ }
486
+ async emit(event) {
487
+ if (!this.listener) return;
488
+ try {
489
+ await this.listener(event);
490
+ } catch (err) {
491
+ this.onError(err, {
492
+ eventId: "eventId" in event ? event.eventId : "unknown",
493
+ type: event.kind
494
+ });
495
+ }
496
+ }
497
+ emitNoWorkspace(evt) {
498
+ return this.emit({
499
+ kind: "event_dropped_out_of_order",
500
+ eventId: evt.id,
501
+ type: evt.type,
502
+ reason: "workspaceId could not be resolved from event payload"
503
+ });
504
+ }
505
+ };
506
+ function combineListeners(...listeners) {
507
+ return async (event) => {
508
+ for (const l of listeners) await l(event);
509
+ };
510
+ }
511
+ function defaultResolveWorkspaceId(input) {
512
+ const sub = input.subscriptionMetadata?.workspaceId;
513
+ if (sub) return sub;
514
+ const inv = input.invoiceMetadata?.workspaceId;
515
+ return inv ?? null;
516
+ }
517
+ function defaultOnError(err, context) {
518
+ console.error("[StripeBillingDispatcher]", context, err);
519
+ }
520
+ function parseState(status, eventId) {
521
+ switch (status) {
522
+ case "incomplete":
523
+ case "incomplete_expired":
524
+ case "trialing":
525
+ case "active":
526
+ case "past_due":
527
+ case "canceled":
528
+ case "unpaid":
529
+ case "paused":
530
+ return status;
531
+ default:
532
+ throw new BillingError({
533
+ code: "webhook_event_unknown",
534
+ message: `Unknown Stripe subscription status '${status}'`,
535
+ context: { eventId }
536
+ });
537
+ }
538
+ }
539
+ function canApplyFreshCreate(state) {
540
+ return state === "incomplete" || state === "incomplete_expired";
541
+ }
542
+ function extractPriceId(sub) {
543
+ return sub.items?.data?.[0]?.price?.id ?? null;
544
+ }
545
+
546
+ // src/stripe/pricing.ts
547
+ function findPlan(plans, id) {
548
+ return plans.find((p) => p.id === id) ?? null;
549
+ }
550
+ function requirePlan(plans, id) {
551
+ const plan = findPlan(plans, id);
552
+ if (!plan) throw new Error(`pricing: unknown plan id '${id}'`);
553
+ return plan;
554
+ }
555
+ async function createCheckoutUrl(client, input) {
556
+ const priceId = input.plan.stripePriceIds[input.billing];
557
+ if (!priceId) {
558
+ throw new Error(`pricing: plan '${input.plan.id}' has no Stripe price for cadence '${input.billing}'`);
559
+ }
560
+ const successUrl = input.successUrl ?? client.config.successUrl;
561
+ const cancelUrl = input.cancelUrl ?? client.config.cancelUrl;
562
+ if (!successUrl || !cancelUrl) {
563
+ throw new Error("pricing: successUrl and cancelUrl required (per-call or in TenantStripeConfig)");
564
+ }
565
+ const trialDays = input.trialDays ?? input.plan.trialDays;
566
+ const body = {
567
+ mode: "subscription",
568
+ success_url: successUrl,
569
+ cancel_url: cancelUrl,
570
+ "line_items[0][price]": priceId,
571
+ "line_items[0][quantity]": 1,
572
+ "metadata[workspaceId]": input.workspaceId,
573
+ "metadata[planId]": input.plan.id,
574
+ "subscription_data[metadata][workspaceId]": input.workspaceId,
575
+ "subscription_data[metadata][planId]": input.plan.id
576
+ };
577
+ if (input.customerId) body.customer = input.customerId;
578
+ if (input.customerEmail && !input.customerId) body.customer_email = input.customerEmail;
579
+ if (trialDays && trialDays > 0) {
580
+ body["subscription_data[trial_period_days]"] = trialDays;
581
+ }
582
+ const extra = { ...input.plan.metadata ?? {}, ...input.metadata ?? {} };
583
+ for (const [k, v] of Object.entries(extra)) {
584
+ body[`metadata[${k}]`] = v;
585
+ body[`subscription_data[metadata][${k}]`] = v;
586
+ }
587
+ const created = await client.mutate(
588
+ "POST",
589
+ "/checkout/sessions",
590
+ body,
591
+ input.idempotencyKey
592
+ );
593
+ if (!created.url) {
594
+ throw new Error("pricing: Stripe checkout response missing url");
595
+ }
596
+ return { sessionId: created.id, url: created.url };
597
+ }
598
+ async function createBillingPortalUrl(client, input) {
599
+ const created = await client.mutate(
600
+ "POST",
601
+ "/billing_portal/sessions",
602
+ {
603
+ customer: input.customerId,
604
+ return_url: input.returnUrl
605
+ },
606
+ input.idempotencyKey
607
+ );
608
+ return { sessionId: created.id, url: created.url };
609
+ }
610
+
611
+ // src/stripe/tenant-config.ts
612
+ var PRODUCT_IDS = Object.freeze([
613
+ "legal",
614
+ "tax",
615
+ "gtm",
616
+ "creative",
617
+ "agent-builder"
618
+ ]);
619
+ var EnvTenantConfigResolver = class {
620
+ constructor(env = process.env) {
621
+ this.env = env;
622
+ }
623
+ env;
624
+ resolve(productId) {
625
+ const key = envKey(productId);
626
+ const sk = this.env[`STRIPE_SK_${key}`];
627
+ const wh = this.env[`STRIPE_WHSEC_${key}`];
628
+ if (!sk || !wh) return null;
629
+ return {
630
+ productId,
631
+ secretKey: sk,
632
+ webhookSecret: wh,
633
+ successUrl: this.env[`STRIPE_SUCCESS_URL_${key}`],
634
+ cancelUrl: this.env[`STRIPE_CANCEL_URL_${key}`]
635
+ };
636
+ }
637
+ };
638
+ var StaticTenantConfigResolver = class {
639
+ constructor(table) {
640
+ this.table = table;
641
+ }
642
+ table;
643
+ resolve(productId) {
644
+ return this.table[productId] ?? null;
645
+ }
646
+ };
647
+ function memoizeResolver(inner, ttlMs = 6e4) {
648
+ const cache = /* @__PURE__ */ new Map();
649
+ return {
650
+ async resolve(productId) {
651
+ const now = Date.now();
652
+ const hit = cache.get(productId);
653
+ if (hit && hit.expiresAt > now) return hit.config;
654
+ const config = await inner.resolve(productId);
655
+ cache.set(productId, { config, expiresAt: now + ttlMs });
656
+ return config;
657
+ }
658
+ };
659
+ }
660
+ var STRIPE_API = "https://api.stripe.com/v1";
661
+ async function getStripeClient(productId, resolver) {
662
+ const config = await resolver.resolve(productId);
663
+ if (!config) {
664
+ throw new ConfigError({
665
+ message: `Stripe not configured for product '${productId}'. Set STRIPE_SK_${envKey(productId)} and STRIPE_WHSEC_${envKey(productId)}.`,
666
+ context: { productId }
667
+ });
668
+ }
669
+ return buildClient(config);
670
+ }
671
+ function buildStripeClient(config) {
672
+ return buildClient(config);
673
+ }
674
+ function buildClient(config) {
675
+ const auth = `Bearer ${config.secretKey}`;
676
+ return {
677
+ productId: config.productId,
678
+ config,
679
+ async get(path, query) {
680
+ const qs = query ? `?${new URLSearchParams(query).toString()}` : "";
681
+ const res = await fetch(`${STRIPE_API}${path}${qs}`, {
682
+ headers: { authorization: auth },
683
+ signal: AbortSignal.timeout(1e4)
684
+ });
685
+ if (!res.ok) {
686
+ const text = await res.text().catch(() => "");
687
+ throw new Error(`stripe ${path} ${res.status}: ${text.slice(0, 200)}`);
688
+ }
689
+ return await res.json();
690
+ },
691
+ async mutate(method, path, body, idempotencyKey) {
692
+ const form = new URLSearchParams();
693
+ for (const [k, v] of Object.entries(body)) {
694
+ if (v === void 0) continue;
695
+ form.set(k, String(v));
696
+ }
697
+ const init = {
698
+ method,
699
+ headers: {
700
+ authorization: auth,
701
+ "idempotency-key": idempotencyKey,
702
+ ...method === "POST" ? { "content-type": "application/x-www-form-urlencoded" } : {}
703
+ },
704
+ signal: AbortSignal.timeout(15e3)
705
+ };
706
+ if (method === "POST") init.body = form;
707
+ const res = await fetch(`${STRIPE_API}${path}`, init);
708
+ if (!res.ok) {
709
+ const text = await res.text().catch(() => "");
710
+ throw new Error(`stripe ${path} ${res.status}: ${text.slice(0, 200)}`);
711
+ }
712
+ return await res.json();
713
+ }
714
+ };
715
+ }
716
+ function makeStripeSecretResolver(resolver) {
717
+ return async function resolveSecret(providerId, headers) {
718
+ if (providerId !== "stripe") return null;
719
+ const productHeader = headers["x-tangle-product"];
720
+ const productId = Array.isArray(productHeader) ? productHeader[0] : productHeader;
721
+ if (!productId || !isProductId(productId)) return null;
722
+ const config = await resolver.resolve(productId);
723
+ return config?.webhookSecret ?? null;
724
+ };
725
+ }
726
+ function isProductId(s) {
727
+ return PRODUCT_IDS.includes(s);
728
+ }
729
+ function envKey(productId) {
730
+ return productId.toUpperCase().replace(/-/g, "_");
731
+ }
732
+
733
+ // src/stripe/middleware.ts
734
+ async function requireActiveSubscription(input) {
735
+ const record = await input.store.load(input.workspaceId);
736
+ if (!record) {
737
+ return {
738
+ allowed: false,
739
+ error: new BillingError({
740
+ code: "subscription_required",
741
+ message: "This workspace has no Stripe subscription.",
742
+ context: { workspaceId: input.workspaceId }
743
+ })
744
+ };
745
+ }
746
+ const decision = gateAccess(record.state);
747
+ if (!decision.allowed) {
748
+ return {
749
+ allowed: false,
750
+ error: new BillingError({
751
+ code: decision.reason === "subscription_inactive" ? "subscription_inactive" : decision.reason === "subscription_past_due" ? "subscription_past_due" : "subscription_required",
752
+ message: `Subscription is ${record.state}.`,
753
+ context: {
754
+ workspaceId: input.workspaceId,
755
+ subscriptionId: record.subscriptionId,
756
+ subscriptionState: record.state
757
+ }
758
+ })
759
+ };
760
+ }
761
+ if (decision.warn === "past_due" && input.denyPastDue) {
762
+ return {
763
+ allowed: false,
764
+ error: new BillingError({
765
+ code: "subscription_past_due",
766
+ message: "Subscription is past due \u2014 this action requires a current payment method.",
767
+ context: {
768
+ workspaceId: input.workspaceId,
769
+ subscriptionId: record.subscriptionId,
770
+ subscriptionState: record.state
771
+ }
772
+ })
773
+ };
774
+ }
775
+ let warn = decision.warn;
776
+ if (!warn && record.state === "trialing" && record.trialEnd) {
777
+ const TRIAL_WARN_SECONDS = 72 * 60 * 60;
778
+ const nowSec = Math.floor(Date.now() / 1e3);
779
+ if (record.trialEnd - nowSec < TRIAL_WARN_SECONDS) {
780
+ warn = "trial_ending";
781
+ }
782
+ }
783
+ return { allowed: true, record, warn };
784
+ }
785
+ async function withTrialAccess(input) {
786
+ const createdAt = await input.trialStore.getCreatedAt(input.workspaceId);
787
+ if (createdAt === null) {
788
+ return { inTrial: false, daysRemaining: 0, trialEndsAt: null };
789
+ }
790
+ const now = (input.now ?? Date.now)();
791
+ const trialEndsAt = createdAt + input.days * 24 * 60 * 60 * 1e3;
792
+ const remainingMs = trialEndsAt - now;
793
+ if (remainingMs <= 0) {
794
+ return { inTrial: false, daysRemaining: 0, trialEndsAt };
795
+ }
796
+ const daysRemaining = Math.floor(remainingMs / (24 * 60 * 60 * 1e3));
797
+ return { inTrial: true, daysRemaining, trialEndsAt };
798
+ }
799
+ async function getRemainingFreeTier(input) {
800
+ const { used, total } = await input.freeTierStore.getUsage(input.workspaceId);
801
+ const remaining = Math.max(0, total - used);
802
+ return { remaining, total, exhausted: remaining === 0 };
803
+ }
804
+ async function gateSubscriptionOrTrial(input) {
805
+ if (input.trialStore && input.trialDays) {
806
+ const trial = await withTrialAccess({
807
+ workspaceId: input.workspaceId,
808
+ days: input.trialDays,
809
+ trialStore: input.trialStore,
810
+ now: input.now
811
+ });
812
+ if (trial.inTrial) {
813
+ const trialRecord = trialSyntheticRecord(input.workspaceId, trial.trialEndsAt ?? 0);
814
+ return { allowed: true, record: trialRecord, viaTrial: true, daysRemaining: trial.daysRemaining };
815
+ }
816
+ }
817
+ return requireActiveSubscription({
818
+ workspaceId: input.workspaceId,
819
+ store: input.store,
820
+ denyPastDue: input.denyPastDue
821
+ });
822
+ }
823
+ function trialSyntheticRecord(workspaceId, trialEndsAt) {
824
+ return {
825
+ workspaceId,
826
+ customerId: "",
827
+ subscriptionId: "",
828
+ state: "trialing",
829
+ priceId: null,
830
+ currentPeriodEnd: Math.floor(trialEndsAt / 1e3),
831
+ trialEnd: Math.floor(trialEndsAt / 1e3),
832
+ cancelAtPeriodEnd: false,
833
+ version: 0,
834
+ lastEventId: null,
835
+ updatedAt: Date.now()
836
+ };
837
+ }
838
+ export {
839
+ BillingError,
840
+ ConfigError,
841
+ EnvTenantConfigResolver,
842
+ FileSystemSubscriptionStore,
843
+ InMemorySubscriptionStore,
844
+ PRODUCT_IDS,
845
+ SUBSCRIPTION_STATES,
846
+ StaticTenantConfigResolver,
847
+ StripeBillingDispatcher,
848
+ applyTransition,
849
+ buildStripeClient,
850
+ combineListeners,
851
+ createBillingPortalUrl,
852
+ createCheckoutUrl,
853
+ findPlan,
854
+ gateAccess,
855
+ gateSubscriptionOrTrial,
856
+ getRemainingFreeTier,
857
+ getStripeClient,
858
+ isValidTransition,
859
+ makeStripeSecretResolver,
860
+ makeSubscriptionRecord,
861
+ memoizeResolver,
862
+ requireActiveSubscription,
863
+ requirePlan,
864
+ withTrialAccess
865
+ };
866
+ //# sourceMappingURL=index.js.map