fraim 2.0.351 → 2.0.353
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/src/api/payment/dashboard-link.js +42 -68
- package/dist/src/api/payment/webhook.js +63 -389
- package/dist/src/api/personas/me.js +4 -7
- package/dist/src/cli/commands/sync.js +3 -2
- package/dist/src/cli/mcp/fraim-mcp-latest-launcher.js +86 -9
- package/dist/src/cli/setup/user-level-sync.js +51 -7
- package/dist/src/cli/utils/managed-agent-paths.js +25 -9
- package/dist/src/core/handoff-contracts.js +8 -2
- package/dist/src/core/proposal-set-contract.js +105 -21
- package/dist/src/core/utils/server-startup.js +2 -2
- package/dist/src/fraim/current-license.js +51 -0
- package/dist/src/fraim/db-service.js +253 -30
- package/dist/src/fraim/license-presentation.js +41 -0
- package/dist/src/local-mcp-server/stdio-server.js +188 -73
- package/dist/src/local-mcp-server/usage-collector.js +20 -11
- package/dist/src/middleware/auth.js +44 -51
- package/dist/src/routes/analytics.js +3 -11
- package/dist/src/routes/auth-routes.js +4 -13
- package/dist/src/services/email-service.js +46 -32
- package/dist/src/services/mcp-service.js +17 -1
- package/dist/src/services/persona-entitlement-service.js +89 -31
- package/dist/src/services/subscription-payment.js +171 -0
- package/dist/src/services/subscription-provisioning.js +68 -0
- package/dist/src/services/subscription-reconciliation.js +147 -0
- package/package.json +1 -1
|
@@ -2,21 +2,15 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.createDashboardAccessUrl = void 0;
|
|
4
4
|
exports.handleStripeWebhook = handleStripeWebhook;
|
|
5
|
-
exports.resolveSubscriptionNextBillingDate = resolveSubscriptionNextBillingDate;
|
|
6
5
|
exports.isInitialSubscriptionInvoice = isInitialSubscriptionInvoice;
|
|
7
6
|
exports.getInvoiceAmountPaidCents = getInvoiceAmountPaidCents;
|
|
8
|
-
exports.extractSubscriptionPeriodEndDate = extractSubscriptionPeriodEndDate;
|
|
9
|
-
exports.extractInvoicePeriodEndDate = extractInvoicePeriodEndDate;
|
|
10
|
-
exports.resolveSubscriptionNextBillingDateWithInvoiceFallback = resolveSubscriptionNextBillingDateWithInvoiceFallback;
|
|
11
|
-
exports.buildCurrentPeriodEndUpdate = buildCurrentPeriodEndUpdate;
|
|
12
|
-
exports.resolveInvoicePaidNextBillingDate = resolveInvoicePaidNextBillingDate;
|
|
13
7
|
const stripe_1 = require("../../config/stripe");
|
|
14
|
-
const redact_secrets_1 = require("../../core/utils/redact-secrets");
|
|
15
8
|
const feature_flags_1 = require("../../config/feature-flags");
|
|
16
9
|
const email_service_1 = require("../../services/email-service");
|
|
17
10
|
const dashboard_access_1 = require("../../services/dashboard-access");
|
|
18
11
|
Object.defineProperty(exports, "createDashboardAccessUrl", { enumerable: true, get: function () { return dashboard_access_1.createDashboardAccessUrl; } });
|
|
19
12
|
const persona_entitlement_service_1 = require("../../services/persona-entitlement-service");
|
|
13
|
+
const subscription_provisioning_1 = require("../../services/subscription-provisioning");
|
|
20
14
|
async function handleStripeWebhook(req, res, paymentRepo, dbService) {
|
|
21
15
|
const sig = req.headers['stripe-signature'];
|
|
22
16
|
if (!sig) {
|
|
@@ -61,8 +55,11 @@ async function handleStripeWebhook(req, res, paymentRepo, dbService) {
|
|
|
61
55
|
console.log(`ℹ️ Webhook ${event.id} already processed (race condition), skipping`);
|
|
62
56
|
return res.json({ received: true, skipped: true });
|
|
63
57
|
}
|
|
58
|
+
if (error.message === 'Webhook event is processing') {
|
|
59
|
+
return res.status(503).json({ error: 'Webhook event is still being processed; retry later' });
|
|
60
|
+
}
|
|
64
61
|
console.error('❌ Error recording webhook event:', error);
|
|
65
|
-
|
|
62
|
+
return res.status(500).json({ error: 'Could not claim webhook event for processing' });
|
|
66
63
|
}
|
|
67
64
|
}
|
|
68
65
|
try {
|
|
@@ -132,417 +129,94 @@ async function handleStripeWebhook(req, res, paymentRepo, dbService) {
|
|
|
132
129
|
}
|
|
133
130
|
}
|
|
134
131
|
// ========== Subscription Lifecycle Handlers ==========
|
|
135
|
-
async function resolveSubscriptionNextBillingDate(subscription, subscriptionRetriever = (subscriptionId) => stripe_1.stripe.subscriptions.retrieve(subscriptionId)) {
|
|
136
|
-
const directPeriodEnd = extractSubscriptionPeriodEndDate(subscription);
|
|
137
|
-
if (directPeriodEnd) {
|
|
138
|
-
return directPeriodEnd;
|
|
139
|
-
}
|
|
140
|
-
if (!subscription.id) {
|
|
141
|
-
return null;
|
|
142
|
-
}
|
|
143
|
-
try {
|
|
144
|
-
const fetchedSubscription = await subscriptionRetriever(subscription.id);
|
|
145
|
-
const fetchedPeriodEnd = extractSubscriptionPeriodEndDate(fetchedSubscription);
|
|
146
|
-
if (fetchedPeriodEnd) {
|
|
147
|
-
return fetchedPeriodEnd;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
catch (error) {
|
|
151
|
-
console.warn(`⚠️ Failed to retrieve subscription ${subscription.id} for period end: ${error?.message || error}`);
|
|
152
|
-
}
|
|
153
|
-
return null;
|
|
154
|
-
}
|
|
155
132
|
function isInitialSubscriptionInvoice(invoice) {
|
|
156
133
|
return invoice.billing_reason === 'subscription_create';
|
|
157
134
|
}
|
|
158
135
|
function getInvoiceAmountPaidCents(invoice) {
|
|
159
136
|
return typeof invoice.amount_paid === 'number' ? Math.max(0, invoice.amount_paid) : null;
|
|
160
137
|
}
|
|
161
|
-
function extractSubscriptionPeriodEndDate(subscription) {
|
|
162
|
-
const rootPeriodEnd = subscription.current_period_end;
|
|
163
|
-
if (typeof rootPeriodEnd === 'number' && rootPeriodEnd > 0) {
|
|
164
|
-
return new Date(rootPeriodEnd * 1000);
|
|
165
|
-
}
|
|
166
|
-
const itemEnds = (subscription.items?.data || [])
|
|
167
|
-
.map((item) => item?.current_period_end)
|
|
168
|
-
.filter((value) => typeof value === 'number' && value > 0);
|
|
169
|
-
if (itemEnds.length > 0) {
|
|
170
|
-
// Use the earliest upcoming period boundary across items.
|
|
171
|
-
return new Date(Math.min(...itemEnds) * 1000);
|
|
172
|
-
}
|
|
173
|
-
return null;
|
|
174
|
-
}
|
|
175
|
-
function extractInvoicePeriodEndDate(invoice) {
|
|
176
|
-
const rootPeriodEnd = invoice.period_end;
|
|
177
|
-
if (typeof rootPeriodEnd === 'number' && rootPeriodEnd > 0) {
|
|
178
|
-
return new Date(rootPeriodEnd * 1000);
|
|
179
|
-
}
|
|
180
|
-
const linePeriodEnds = (invoice.lines?.data || [])
|
|
181
|
-
.map((line) => line?.period?.end)
|
|
182
|
-
.filter((value) => typeof value === 'number' && value > 0);
|
|
183
|
-
if (linePeriodEnds.length > 0) {
|
|
184
|
-
// Use the furthest period end across recurring/proration lines.
|
|
185
|
-
return new Date(Math.max(...linePeriodEnds) * 1000);
|
|
186
|
-
}
|
|
187
|
-
return null;
|
|
188
|
-
}
|
|
189
|
-
async function resolveSubscriptionNextBillingDateWithInvoiceFallback(subscription, subscriptionRetriever = (subscriptionId) => stripe_1.stripe.subscriptions.retrieve(subscriptionId), invoiceRetriever = (invoiceId) => stripe_1.stripe.invoices.retrieve(invoiceId)) {
|
|
190
|
-
const subscriptionDate = await resolveSubscriptionNextBillingDate(subscription, subscriptionRetriever);
|
|
191
|
-
if (subscriptionDate)
|
|
192
|
-
return subscriptionDate;
|
|
193
|
-
const latestInvoiceField = subscription.latest_invoice;
|
|
194
|
-
if (latestInvoiceField && typeof latestInvoiceField === 'object') {
|
|
195
|
-
const invoiceDate = extractInvoicePeriodEndDate(latestInvoiceField);
|
|
196
|
-
if (invoiceDate)
|
|
197
|
-
return invoiceDate;
|
|
198
|
-
}
|
|
199
|
-
if (typeof latestInvoiceField === 'string' && latestInvoiceField) {
|
|
200
|
-
try {
|
|
201
|
-
const latestInvoice = await invoiceRetriever(latestInvoiceField);
|
|
202
|
-
const invoiceDate = extractInvoicePeriodEndDate(latestInvoice);
|
|
203
|
-
if (invoiceDate)
|
|
204
|
-
return invoiceDate;
|
|
205
|
-
}
|
|
206
|
-
catch (error) {
|
|
207
|
-
console.warn(`⚠️ Failed to retrieve latest invoice ${latestInvoiceField} for subscription ${subscription.id}: ${error?.message || error}`);
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
return null;
|
|
211
|
-
}
|
|
212
|
-
function buildCurrentPeriodEndUpdate(nextBillingDate) {
|
|
213
|
-
return nextBillingDate ? { currentPeriodEnd: nextBillingDate } : {};
|
|
214
|
-
}
|
|
215
|
-
async function resolveInvoicePaidNextBillingDate(invoice, subscription, subscriptionDateResolver = resolveSubscriptionNextBillingDate) {
|
|
216
|
-
const subscriptionDate = subscription ? await subscriptionDateResolver(subscription) : null;
|
|
217
|
-
return subscriptionDate || extractInvoicePeriodEndDate(invoice);
|
|
218
|
-
}
|
|
219
138
|
function extractFounderDiscountFlagFromSubscription(subscription) {
|
|
220
139
|
return subscription.metadata?.founderDiscount === 'true';
|
|
221
140
|
}
|
|
141
|
+
async function reconcileSubscription(subscription, dbService) {
|
|
142
|
+
const customerId = (0, subscription_provisioning_1.subscriptionCustomerId)(subscription);
|
|
143
|
+
const customer = await stripe_1.stripe.customers.retrieve(customerId);
|
|
144
|
+
const email = !('deleted' in customer) && customer.id === customerId ? customer.email?.trim().toLowerCase() : null;
|
|
145
|
+
if (!email)
|
|
146
|
+
throw new Error('Could not verify subscription customer identity');
|
|
147
|
+
return (0, subscription_provisioning_1.provisionSubscriptionApiKey)(dbService, email, subscription, (0, subscription_provisioning_1.subscriptionPeriodEnd)(subscription));
|
|
148
|
+
}
|
|
222
149
|
async function handleSubscriptionCreatedOrUpdated(subscription, dbService) {
|
|
223
|
-
|
|
224
|
-
if (subscription.
|
|
225
|
-
|
|
226
|
-
const customerId = typeof subscription.customer === 'string'
|
|
227
|
-
? subscription.customer
|
|
228
|
-
: subscription.customer.id;
|
|
229
|
-
const customer = await stripe_1.stripe.customers.retrieve(customerId);
|
|
230
|
-
const email = !('deleted' in customer) ? customer.email : null;
|
|
231
|
-
if (email && subscription.metadata.personaKey && (subscription.metadata.hireMode === 'job' || subscription.metadata.hireMode === 'fulltime')) {
|
|
232
|
-
const nextBillingDate = await resolveSubscriptionNextBillingDateWithInvoiceFallback(subscription);
|
|
233
|
-
await (0, persona_entitlement_service_1.syncPersonaEntitlementPurchase)(dbService, {
|
|
234
|
-
userId: email,
|
|
235
|
-
stripeCustomerId: customerId,
|
|
236
|
-
stripeSubscriptionId: subscription.id,
|
|
237
|
-
stripeCheckoutSessionId: null,
|
|
238
|
-
personaKey: subscription.metadata.personaKey,
|
|
239
|
-
hireMode: subscription.metadata.hireMode,
|
|
240
|
-
purchaseSource: 'stripe-webhook',
|
|
241
|
-
status: subscription.status === 'active' ? 'active' : 'suspended',
|
|
242
|
-
expiresAt: nextBillingDate,
|
|
243
|
-
metadata: subscription.metadata
|
|
244
|
-
});
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
console.log(`ℹ️ Skipping API-key provisioning for persona hire subscription: ${subscription.id}`);
|
|
248
|
-
return;
|
|
150
|
+
const current = await stripe_1.stripe.subscriptions.retrieve(subscription.id);
|
|
151
|
+
if (current.id !== subscription.id || (0, subscription_provisioning_1.subscriptionCustomerId)(current) !== (0, subscription_provisioning_1.subscriptionCustomerId)(subscription)) {
|
|
152
|
+
throw new Error('Subscription event customer does not match current subscription');
|
|
249
153
|
}
|
|
250
|
-
|
|
251
|
-
? subscription.customer
|
|
252
|
-
: subscription.customer.id;
|
|
253
|
-
// Get or create API key for this customer
|
|
254
|
-
let apiKey = await dbService.getApiKeyByStripeCustomerId(customerId);
|
|
255
|
-
if (!apiKey) {
|
|
256
|
-
// Check if subscription has customer email in metadata
|
|
257
|
-
const customer = await stripe_1.stripe.customers.retrieve(customerId);
|
|
258
|
-
const email = customer.email;
|
|
259
|
-
if (!email) {
|
|
260
|
-
console.error(`❌ No email found for customer ${customerId}`);
|
|
261
|
-
return;
|
|
262
|
-
}
|
|
263
|
-
// Check if user already has an API key (trial user upgrading)
|
|
264
|
-
apiKey = await dbService.getApiKeyByUserId(email);
|
|
265
|
-
const nextBillingDate = await resolveSubscriptionNextBillingDateWithInvoiceFallback(subscription);
|
|
266
|
-
if (apiKey) {
|
|
267
|
-
// Upgrade trial key to paid subscription
|
|
268
|
-
await dbService.updateApiKey(apiKey.key, {
|
|
269
|
-
tier: 'paid-subscription',
|
|
270
|
-
status: subscription.status === 'active' ? 'active' : 'suspended',
|
|
271
|
-
stripeCustomerId: customerId,
|
|
272
|
-
stripeSubscriptionId: subscription.id,
|
|
273
|
-
...buildCurrentPeriodEndUpdate(nextBillingDate),
|
|
274
|
-
expiresAt: null, // Subscriptions don't have fixed expiration
|
|
275
|
-
cancelAt: subscription.cancel_at ? new Date(subscription.cancel_at * 1000) : null
|
|
276
|
-
});
|
|
277
|
-
console.log(`✅ Upgraded trial key to paid subscription: ${(0, redact_secrets_1.redactApiKey)(apiKey.key)}`);
|
|
278
|
-
}
|
|
279
|
-
else {
|
|
280
|
-
// Create new API key for subscription (shouldn't happen in normal flow)
|
|
281
|
-
const newKey = dbService.generateApiKey(email, 'default');
|
|
282
|
-
await dbService.createApiKey({
|
|
283
|
-
key: newKey,
|
|
284
|
-
userId: email,
|
|
285
|
-
orgId: 'default',
|
|
286
|
-
tier: 'paid-subscription',
|
|
287
|
-
status: subscription.status === 'active' ? 'active' : 'suspended',
|
|
288
|
-
expiresAt: null,
|
|
289
|
-
stripeCustomerId: customerId,
|
|
290
|
-
stripeSubscriptionId: subscription.id,
|
|
291
|
-
currentPeriodEnd: nextBillingDate,
|
|
292
|
-
cancelAt: subscription.cancel_at ? new Date(subscription.cancel_at * 1000) : null,
|
|
293
|
-
suspendedAt: null,
|
|
294
|
-
suspensionReason: null,
|
|
295
|
-
lastUsedAt: null,
|
|
296
|
-
apiCallCount: 0,
|
|
297
|
-
personaSystemActive: (0, feature_flags_1.isPersonaEntitlementsEnabled)() || undefined
|
|
298
|
-
});
|
|
299
|
-
console.log(`✅ Created new API key for subscription: ${(0, redact_secrets_1.redactApiKey)(newKey)}`);
|
|
300
|
-
apiKey = { key: newKey, userId: email };
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
else {
|
|
304
|
-
// Update existing subscription key
|
|
305
|
-
const nextBillingDate = await resolveSubscriptionNextBillingDateWithInvoiceFallback(subscription);
|
|
306
|
-
await dbService.updateApiKey(apiKey.key, {
|
|
307
|
-
tier: 'paid-subscription',
|
|
308
|
-
status: subscription.status === 'active' ? 'active' : 'suspended',
|
|
309
|
-
stripeCustomerId: customerId,
|
|
310
|
-
stripeSubscriptionId: subscription.id,
|
|
311
|
-
...buildCurrentPeriodEndUpdate(nextBillingDate),
|
|
312
|
-
expiresAt: null,
|
|
313
|
-
cancelAt: subscription.cancel_at ? new Date(subscription.cancel_at * 1000) : null,
|
|
314
|
-
suspendedAt: subscription.status !== 'active' ? new Date() : null,
|
|
315
|
-
suspensionReason: subscription.status !== 'active' ? subscription.status : null
|
|
316
|
-
});
|
|
317
|
-
}
|
|
318
|
-
// Intentionally no activation email here.
|
|
319
|
-
// Billing-confirmed messaging is sent from invoice.paid for Stripe source-of-truth amounts.
|
|
154
|
+
await reconcileSubscription(current, dbService);
|
|
320
155
|
}
|
|
321
156
|
async function handleSubscriptionDeleted(subscription, dbService) {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
personaKey: subscription.metadata.personaKey,
|
|
335
|
-
hireMode: 'fulltime',
|
|
336
|
-
purchaseSource: 'stripe-webhook',
|
|
337
|
-
status: 'expired',
|
|
338
|
-
expiresAt: new Date(),
|
|
339
|
-
metadata: subscription.metadata
|
|
340
|
-
});
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
console.log(`❌ Subscription cancelled: ${subscription.id}`);
|
|
344
|
-
if (subscription.metadata?.plan === 'persona-hire') {
|
|
345
|
-
console.log(`ℹ️ Skipping persona hire cancellation handling for subscription: ${subscription.id}`);
|
|
346
|
-
return;
|
|
347
|
-
}
|
|
348
|
-
const apiKey = await dbService.getApiKeyByStripeSubscriptionId(subscription.id);
|
|
349
|
-
if (!apiKey) {
|
|
350
|
-
console.warn(`⚠️ No API key found for subscription: ${subscription.id}`);
|
|
351
|
-
return;
|
|
157
|
+
const key = await reconcileSubscription({ ...subscription, status: 'canceled' }, dbService);
|
|
158
|
+
await new email_service_1.EmailService().sendSubscriptionCancelled(key.userId, key.stripeSubscriptionEndedAt || new Date());
|
|
159
|
+
}
|
|
160
|
+
async function subscriptionForInvoice(invoice) {
|
|
161
|
+
const field = invoice.subscription || invoice.parent?.subscription_details?.subscription;
|
|
162
|
+
const id = typeof field === 'string' ? field : field?.id;
|
|
163
|
+
if (!id)
|
|
164
|
+
return null;
|
|
165
|
+
const customerId = typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
|
|
166
|
+
const subscription = await stripe_1.stripe.subscriptions.retrieve(id);
|
|
167
|
+
if (!customerId || subscription.id !== id || (0, subscription_provisioning_1.subscriptionCustomerId)(subscription) !== customerId) {
|
|
168
|
+
throw new Error('Invoice subscription customer does not match');
|
|
352
169
|
}
|
|
353
|
-
|
|
354
|
-
await dbService.updateApiKey(apiKey.key, {
|
|
355
|
-
status: 'expired',
|
|
356
|
-
expiresAt: new Date(), // Expired now
|
|
357
|
-
cancelAt: null
|
|
358
|
-
});
|
|
359
|
-
// Send cancellation email
|
|
360
|
-
const emailService = new email_service_1.EmailService();
|
|
361
|
-
await emailService.sendSubscriptionCancelled(apiKey.userId, new Date());
|
|
362
|
-
console.log(`✅ API key expired due to subscription cancellation: ${(0, redact_secrets_1.redactApiKey)(apiKey.key)}`);
|
|
170
|
+
return subscription;
|
|
363
171
|
}
|
|
364
172
|
async function handleInvoicePaid(invoice, dbService) {
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
if (!subscriptionField) {
|
|
368
|
-
console.log(`ℹ️ Invoice is not for a subscription, skipping`);
|
|
369
|
-
return;
|
|
370
|
-
}
|
|
371
|
-
const subscriptionId = typeof subscriptionField === 'string'
|
|
372
|
-
? subscriptionField
|
|
373
|
-
: subscriptionField.id;
|
|
374
|
-
const customerId = typeof invoice.customer === 'string'
|
|
375
|
-
? invoice.customer
|
|
376
|
-
: invoice.customer?.id;
|
|
377
|
-
let apiKey = await dbService.getApiKeyByStripeSubscriptionId(subscriptionId);
|
|
378
|
-
if (!apiKey && customerId) {
|
|
379
|
-
apiKey = await dbService.getApiKeyByStripeCustomerId(customerId);
|
|
380
|
-
if (apiKey) {
|
|
381
|
-
await dbService.updateApiKey(apiKey.key, { stripeSubscriptionId: subscriptionId });
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
if (!apiKey && (0, feature_flags_1.isPersonaEntitlementsEnabled)() && invoice.customer_email) {
|
|
385
|
-
try {
|
|
386
|
-
const subscription = await stripe_1.stripe.subscriptions.retrieve(subscriptionId);
|
|
387
|
-
if (subscription.metadata?.plan === 'persona-hire') {
|
|
388
|
-
apiKey = { userId: invoice.customer_email };
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
catch (error) {
|
|
392
|
-
console.warn(`âš ï¸ Failed persona entitlement prefetch for invoice ${invoice.id}: ${error?.message || error}`);
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
if (!apiKey) {
|
|
396
|
-
console.warn(`⚠️ No API key found for subscription: ${subscriptionId}`);
|
|
173
|
+
const subscription = await subscriptionForInvoice(invoice);
|
|
174
|
+
if (!subscription)
|
|
397
175
|
return;
|
|
398
|
-
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
subscriptionForInvoice = await stripe_1.stripe.subscriptions.retrieve(subscriptionId);
|
|
404
|
-
}
|
|
405
|
-
catch (error) {
|
|
406
|
-
console.warn(`⚠️ Failed to retrieve subscription ${subscriptionId} during invoice.paid: ${error?.message || error}`);
|
|
407
|
-
}
|
|
408
|
-
if (subscriptionForInvoice?.metadata?.plan === 'persona-hire') {
|
|
409
|
-
if ((0, feature_flags_1.isPersonaEntitlementsEnabled)() && subscriptionForInvoice.metadata.personaKey && subscriptionForInvoice.metadata.hireMode === 'fulltime') {
|
|
410
|
-
const email = apiKey?.userId || invoice.customer_email;
|
|
411
|
-
if (email) {
|
|
412
|
-
await (0, persona_entitlement_service_1.syncPersonaEntitlementPurchase)(dbService, {
|
|
413
|
-
userId: email,
|
|
414
|
-
stripeCustomerId: customerId || null,
|
|
415
|
-
stripeSubscriptionId: subscriptionId,
|
|
416
|
-
stripeCheckoutSessionId: null,
|
|
417
|
-
personaKey: subscriptionForInvoice.metadata.personaKey,
|
|
418
|
-
hireMode: 'fulltime',
|
|
419
|
-
purchaseSource: 'stripe-webhook',
|
|
420
|
-
status: 'active',
|
|
421
|
-
expiresAt: await resolveInvoicePaidNextBillingDate(invoice, subscriptionForInvoice),
|
|
422
|
-
metadata: {
|
|
423
|
-
...subscriptionForInvoice.metadata,
|
|
424
|
-
latestInvoiceId: invoice.id
|
|
425
|
-
}
|
|
426
|
-
});
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
console.log(`ℹ️ Skipping API-key activation emails for persona hire subscription: ${subscriptionId}`);
|
|
430
|
-
return;
|
|
431
|
-
}
|
|
432
|
-
const nextBillingDate = await resolveInvoicePaidNextBillingDate(invoice, subscriptionForInvoice);
|
|
433
|
-
const currentPeriodPatch = buildCurrentPeriodEndUpdate(nextBillingDate);
|
|
434
|
-
// Restore key if it was suspended due to payment failure
|
|
435
|
-
if (apiKey.status === 'suspended') {
|
|
436
|
-
await dbService.updateApiKey(apiKey.key, {
|
|
437
|
-
status: 'active',
|
|
438
|
-
...currentPeriodPatch,
|
|
439
|
-
suspendedAt: null,
|
|
440
|
-
suspensionReason: null
|
|
441
|
-
});
|
|
442
|
-
console.log(`✅ API key restored after payment: ${(0, redact_secrets_1.redactApiKey)(apiKey.key)}`);
|
|
443
|
-
// Send restoration email
|
|
444
|
-
const emailService = new email_service_1.EmailService();
|
|
445
|
-
const restorationBillingDate = nextBillingDate || new Date();
|
|
446
|
-
await emailService.sendPaymentRestored(apiKey.userId, restorationBillingDate);
|
|
176
|
+
const prior = await dbService.getApiKeyByStripeSubscriptionId(subscription.id);
|
|
177
|
+
const key = await reconcileSubscription(subscription, dbService);
|
|
178
|
+
// Notifications trigger reconciliation; only verified collection authorizes a receipt.
|
|
179
|
+
if (key.stripeSubscriptionEndedAt || key.paymentStatus !== 'paid'
|
|
180
|
+
|| key.paidInvoiceId !== invoice.id || !key.paidThrough || key.status !== 'active')
|
|
447
181
|
return;
|
|
182
|
+
const emailService = new email_service_1.EmailService();
|
|
183
|
+
if (prior?.status === 'suspended') {
|
|
184
|
+
await emailService.sendPaymentRestored(key.userId, key.paidThrough);
|
|
448
185
|
}
|
|
449
|
-
if (
|
|
450
|
-
|
|
451
|
-
const
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
const
|
|
455
|
-
|
|
456
|
-
(
|
|
457
|
-
const founderDiscount = extractFounderDiscountFlagFromSubscription(subscription);
|
|
458
|
-
if (activationBillingDate) {
|
|
459
|
-
await dbService.updateApiKey(apiKey.key, buildCurrentPeriodEndUpdate(activationBillingDate));
|
|
460
|
-
}
|
|
461
|
-
if (amountPaidCents === null || !activationBillingDate) {
|
|
462
|
-
console.warn(`⚠️ Skipping subscription activation email due to missing Stripe invoice/subscription data for ${subscriptionId}`);
|
|
186
|
+
else if (isInitialSubscriptionInvoice(invoice)) {
|
|
187
|
+
const recurring = subscription.items.data[0]?.price?.recurring;
|
|
188
|
+
const amount = getInvoiceAmountPaidCents(invoice);
|
|
189
|
+
if (amount === null)
|
|
190
|
+
return;
|
|
191
|
+
const cadence = recurring?.interval === 'year' ? 'annual' : recurring?.interval === 'month' ? 'monthly' : null;
|
|
192
|
+
if (!cadence || (recurring?.interval_count || 1) !== 1) {
|
|
193
|
+
await emailService.sendRenewalReceipt(key.userId, amount, key.paidThrough, invoice.hosted_invoice_url || undefined);
|
|
463
194
|
return;
|
|
464
195
|
}
|
|
465
|
-
const
|
|
466
|
-
|
|
467
|
-
|
|
196
|
+
const plan = subscription.items.data[0]?.price?.nickname
|
|
197
|
+
|| (subscription.metadata.plan === 'persona-hire' ? subscription.metadata.personaKey : 'FRAIM');
|
|
198
|
+
await emailService.sendSubscriptionActivated(key.userId, plan, amount, cadence, key.paidThrough, await (0, dashboard_access_1.createDashboardAccessUrl)(dbService, key.userId, key.key), extractFounderDiscountFlagFromSubscription(subscription));
|
|
468
199
|
}
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
console.log(`✅ Subscription renewed: ${(0, redact_secrets_1.redactApiKey)(apiKey.key)}`);
|
|
472
|
-
if (nextBillingDate) {
|
|
473
|
-
await dbService.updateApiKey(apiKey.key, buildCurrentPeriodEndUpdate(nextBillingDate));
|
|
474
|
-
}
|
|
475
|
-
// Send renewal receipt
|
|
476
|
-
const emailService = new email_service_1.EmailService();
|
|
477
|
-
const amount = (invoice.amount_paid || 0) / 100;
|
|
478
|
-
const renewalBillingDate = nextBillingDate || new Date();
|
|
479
|
-
await emailService.sendRenewalReceipt(apiKey.userId, amount, renewalBillingDate, invoice.hosted_invoice_url || undefined);
|
|
200
|
+
else {
|
|
201
|
+
await emailService.sendRenewalReceipt(key.userId, invoice.amount_paid || 0, key.paidThrough, invoice.hosted_invoice_url || undefined);
|
|
480
202
|
}
|
|
481
203
|
}
|
|
482
204
|
async function handleInvoicePaymentFailed(invoice, dbService) {
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
if (!subscriptionField) {
|
|
486
|
-
console.log(`ℹ️ Invoice is not for a subscription, skipping`);
|
|
205
|
+
const subscription = await subscriptionForInvoice(invoice);
|
|
206
|
+
if (!subscription)
|
|
487
207
|
return;
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
? subscriptionField
|
|
491
|
-
: subscriptionField.id;
|
|
492
|
-
try {
|
|
493
|
-
const subscription = await stripe_1.stripe.subscriptions.retrieve(subscriptionId);
|
|
494
|
-
if (subscription.metadata?.plan === 'persona-hire') {
|
|
495
|
-
if ((0, feature_flags_1.isPersonaEntitlementsEnabled)()) {
|
|
496
|
-
const customerId = typeof subscription.customer === 'string'
|
|
497
|
-
? subscription.customer
|
|
498
|
-
: subscription.customer.id;
|
|
499
|
-
const customer = await stripe_1.stripe.customers.retrieve(customerId);
|
|
500
|
-
const email = !('deleted' in customer) ? customer.email : null;
|
|
501
|
-
if (email && subscription.metadata.personaKey && subscription.metadata.hireMode === 'fulltime') {
|
|
502
|
-
await (0, persona_entitlement_service_1.syncPersonaEntitlementPurchase)(dbService, {
|
|
503
|
-
userId: email,
|
|
504
|
-
stripeCustomerId: customerId,
|
|
505
|
-
stripeSubscriptionId: subscriptionId,
|
|
506
|
-
stripeCheckoutSessionId: null,
|
|
507
|
-
personaKey: subscription.metadata.personaKey,
|
|
508
|
-
hireMode: 'fulltime',
|
|
509
|
-
purchaseSource: 'stripe-webhook',
|
|
510
|
-
status: 'suspended',
|
|
511
|
-
metadata: {
|
|
512
|
-
...subscription.metadata,
|
|
513
|
-
latestFailedInvoiceId: invoice.id
|
|
514
|
-
}
|
|
515
|
-
});
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
console.log(`ℹ️ Skipping persona hire payment-failure handling for subscription: ${subscriptionId}`);
|
|
519
|
-
return;
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
catch (error) {
|
|
523
|
-
console.warn(`⚠️ Failed to retrieve subscription ${subscriptionId} during invoice.payment_failed: ${error?.message || error}`);
|
|
524
|
-
}
|
|
525
|
-
const apiKey = await dbService.getApiKeyByStripeSubscriptionId(subscriptionId);
|
|
526
|
-
if (!apiKey) {
|
|
527
|
-
console.warn(`⚠️ No API key found for subscription: ${subscriptionId}`);
|
|
208
|
+
const key = await reconcileSubscription(subscription, dbService);
|
|
209
|
+
if (key.stripeSubscriptionEndedAt || key.paymentStatus !== 'unpaid')
|
|
528
210
|
return;
|
|
529
|
-
|
|
530
|
-
// Suspend API key
|
|
531
|
-
await dbService.updateApiKey(apiKey.key, {
|
|
532
|
-
status: 'suspended',
|
|
533
|
-
suspendedAt: new Date(),
|
|
534
|
-
suspensionReason: 'payment_failed'
|
|
535
|
-
});
|
|
536
|
-
console.log(`✅ API key suspended due to payment failure: ${(0, redact_secrets_1.redactApiKey)(apiKey.key)}`);
|
|
537
|
-
// Send payment failure email with 7-day grace period
|
|
538
|
-
const emailService = new email_service_1.EmailService();
|
|
539
|
-
const gracePeriodEndsAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
|
|
540
|
-
const billingPortalUrl = process.env.STRIPE_BILLING_PORTAL_URL || 'https://fraimworks.ai/billing';
|
|
541
|
-
await emailService.sendPaymentFailed(apiKey.userId, billingPortalUrl, gracePeriodEndsAt);
|
|
211
|
+
await new email_service_1.EmailService().sendPaymentFailed(key.userId, process.env.STRIPE_BILLING_PORTAL_URL || 'https://fraimworks.ai/billing', key.expiresAt);
|
|
542
212
|
}
|
|
543
213
|
// ========== Legacy Payment Handlers ==========
|
|
544
214
|
async function handleCheckoutSessionCompleted(session, paymentRepo, dbService) {
|
|
545
|
-
if (dbService &&
|
|
215
|
+
if (dbService && session.subscription) {
|
|
216
|
+
const id = typeof session.subscription === 'string' ? session.subscription : session.subscription.id;
|
|
217
|
+
await handleSubscriptionCreatedOrUpdated(await stripe_1.stripe.subscriptions.retrieve(id), dbService);
|
|
218
|
+
}
|
|
219
|
+
else if (dbService && (0, feature_flags_1.isPersonaEntitlementsEnabled)() && session.metadata?.plan === 'persona-hire') {
|
|
546
220
|
await (0, persona_entitlement_service_1.syncPersonaEntitlementFromCheckoutSession)(dbService, session, 'stripe-checkout');
|
|
547
221
|
}
|
|
548
222
|
console.log(`✅ Checkout session completed: ${session.id}`);
|
|
@@ -10,16 +10,13 @@ async function getMyPersonas(req, res, dbService) {
|
|
|
10
10
|
res.status(401).json({ error: 'Authentication required' });
|
|
11
11
|
return;
|
|
12
12
|
}
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
// verbatim, and matches the execution gate (maybeBuildPersonaLockResponse),
|
|
17
|
-
// which also treats "flag off" as "allowed". No DB lookup needed.
|
|
18
|
-
if (!(0, feature_flags_1.isPersonaEntitlementsEnabled)()) {
|
|
13
|
+
// General licenses keep their rollout behavior. A purchased persona-only
|
|
14
|
+
// license always reports its purchased scope, even while the flag is off.
|
|
15
|
+
if (!(0, feature_flags_1.isPersonaEntitlementsEnabled)() && apiKeyData.accessScope !== 'persona') {
|
|
19
16
|
res.json((0, persona_entitlement_service_1.buildUngatedPersonaWorkspaceState)(apiKeyData.userId, apiKeyData.key));
|
|
20
17
|
return;
|
|
21
18
|
}
|
|
22
|
-
const state = await (0, persona_entitlement_service_1.getWorkspacePersonaState)(dbService, apiKeyData.userId, apiKeyData.key);
|
|
19
|
+
const state = await (0, persona_entitlement_service_1.getWorkspacePersonaState)(dbService, apiKeyData.userId, apiKeyData.key, (0, feature_flags_1.isPersonaEntitlementsEnabled)());
|
|
23
20
|
res.json(state);
|
|
24
21
|
}
|
|
25
22
|
catch (error) {
|
|
@@ -272,10 +272,11 @@ const runSync = async (options) => {
|
|
|
272
272
|
// fix reaches an installed machine up to a window late and nothing the user can run shortens
|
|
273
273
|
// that. Writing the record here makes `npx fraim@latest sync` the deliberate way to pull a
|
|
274
274
|
// release through immediately: it resolves latest, runs as latest, and records latest, which
|
|
275
|
-
// the next MCP server spawn picks up at once.
|
|
275
|
+
// the next MCP server spawn picks up at once. Automatic catalog sync does not select a
|
|
276
|
+
// launcher version: an older running server must not overwrite a background update.
|
|
276
277
|
const { ensureFraimMcpLatestLauncher, recordFraimMcpLauncherVersion } = await Promise.resolve().then(() => __importStar(require('../mcp/fraim-mcp-latest-launcher')));
|
|
277
278
|
ensureFraimMcpLatestLauncher();
|
|
278
|
-
if (recordFraimMcpLauncherVersion((0, version_utils_1.getFraimVersion)())) {
|
|
279
|
+
if (recordFraimMcpLauncherVersion((0, version_utils_1.getFraimVersion)(), options.automatic ? 'automatic-sync' : 'explicit-sync')) {
|
|
279
280
|
console.log(chalk_1.default.green(` MCP launcher will use fraim@${(0, version_utils_1.getFraimVersion)()} on its next launch`));
|
|
280
281
|
}
|
|
281
282
|
// 1f. Ensure user-level directories and runtime dependencies (Issue #1508:
|