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.
@@ -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
- // Continue processing even if recording fails
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
- console.log(`✅ Subscription ${subscription.status}: ${subscription.id}`);
224
- if (subscription.metadata?.plan === 'persona-hire') {
225
- if ((0, feature_flags_1.isPersonaEntitlementsEnabled)()) {
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
- const customerId = typeof subscription.customer === 'string'
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
- if (subscription.metadata?.plan === 'persona-hire' && (0, feature_flags_1.isPersonaEntitlementsEnabled)()) {
323
- const customerId = typeof subscription.customer === 'string'
324
- ? subscription.customer
325
- : subscription.customer.id;
326
- const customer = await stripe_1.stripe.customers.retrieve(customerId);
327
- const email = !('deleted' in customer) ? customer.email : null;
328
- if (email && subscription.metadata.personaKey && subscription.metadata.hireMode === 'fulltime') {
329
- await (0, persona_entitlement_service_1.syncPersonaEntitlementPurchase)(dbService, {
330
- userId: email,
331
- stripeCustomerId: customerId,
332
- stripeSubscriptionId: subscription.id,
333
- stripeCheckoutSessionId: null,
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
- // Mark key as expired
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
- console.log(`✅ Invoice paid: ${invoice.id}`);
366
- const subscriptionField = invoice.subscription;
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 isInitialSubscriptionCharge = isInitialSubscriptionInvoice(invoice);
400
- const amountPaidCents = getInvoiceAmountPaidCents(invoice);
401
- let subscriptionForInvoice = null;
402
- try {
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 (isInitialSubscriptionCharge) {
450
- // First paid subscription charge: send activation using Stripe invoice + subscription data
451
- const emailService = new email_service_1.EmailService();
452
- const subscription = subscriptionForInvoice || await stripe_1.stripe.subscriptions.retrieve(subscriptionId);
453
- const activationBillingDate = nextBillingDate || await resolveSubscriptionNextBillingDate(subscription);
454
- const billingCycle = subscription.items.data[0]?.price?.recurring?.interval === 'year' ? 'annual' : 'monthly';
455
- const plan = subscription.items.data[0]?.price?.nickname ||
456
- (subscription.metadata?.plan === 'managed' ? 'FRAIM Managed' : 'FRAIM Self-Serve');
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 dashboardUrl = await (0, dashboard_access_1.createDashboardAccessUrl)(dbService, apiKey.userId, apiKey.key);
466
- await emailService.sendSubscriptionActivated(apiKey.userId, plan, amountPaidCents, billingCycle, activationBillingDate, dashboardUrl, founderDiscount);
467
- return;
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
- // Regular renewal
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
- console.log(`❌ Invoice payment failed: ${invoice.id}`);
484
- const subscriptionField = invoice.subscription;
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
- const subscriptionId = typeof subscriptionField === 'string'
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 && (0, feature_flags_1.isPersonaEntitlementsEnabled)() && session.metadata?.plan === 'persona-hire') {
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
- // Feature OFF => the persona system isn't gating anything, so report the
14
- // ungated state (every persona accessible) rather than a 404. This makes the
15
- // authenticated persona view a single source of truth the Hub renders
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: