@tangle-network/agent-integrations 0.27.0 → 0.28.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.
- package/dist/bin/tangle-catalog-runtime.js +2 -1
- package/dist/bin/tangle-catalog-runtime.js.map +1 -1
- package/dist/catalog.d.ts +1 -0
- package/dist/catalog.js +2 -1
- package/dist/chunk-H4XYLS7T.js +75 -0
- package/dist/chunk-H4XYLS7T.js.map +1 -0
- package/dist/{chunk-ICSBYCE2.js → chunk-UWRYFPJW.js} +15 -83
- package/dist/chunk-UWRYFPJW.js.map +1 -0
- package/dist/errors-Bg3_rxnQ.d.ts +32 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +6 -4
- package/dist/registry.d.ts +2 -32
- package/dist/registry.js +2 -1
- package/dist/router-BncoovUh.d.ts +149 -0
- package/dist/runtime.d.ts +1 -0
- package/dist/runtime.js +2 -1
- package/dist/specs.d.ts +1 -0
- package/dist/stripe/index.d.ts +812 -0
- package/dist/stripe/index.js +866 -0
- package/dist/stripe/index.js.map +1 -0
- package/dist/tangle-catalog-runtime.d.ts +1 -0
- package/dist/tangle-catalog-runtime.js +2 -1
- package/dist/webhooks/index.d.ts +3 -148
- package/package.json +6 -1
- package/dist/chunk-ICSBYCE2.js.map +0 -1
|
@@ -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
|