fraim-framework 2.0.177 → 2.0.180

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.
Files changed (77) hide show
  1. package/dist/src/ai-hub/desktop-main.js +2 -2
  2. package/dist/src/ai-hub/server.js +50 -1
  3. package/dist/src/api/admin/payments.js +33 -0
  4. package/dist/src/api/admin/sales-leads.js +21 -0
  5. package/dist/src/api/payment/create-session.js +338 -0
  6. package/dist/src/api/payment/dashboard-link.js +149 -0
  7. package/dist/src/api/payment/session-details.js +31 -0
  8. package/dist/src/api/payment/webhook.js +587 -0
  9. package/dist/src/api/personas/me.js +29 -0
  10. package/dist/src/api/pricing/get-config.js +25 -0
  11. package/dist/src/api/sales/contact.js +44 -0
  12. package/dist/src/cli/commands/add-provider.js +74 -61
  13. package/dist/src/cli/commands/add-surface.js +128 -0
  14. package/dist/src/cli/commands/login.js +5 -69
  15. package/dist/src/cli/commands/setup.js +27 -347
  16. package/dist/src/cli/distribution/marketplace-bundles.js +580 -0
  17. package/dist/src/cli/fraim.js +2 -0
  18. package/dist/src/cli/mcp/ide-formats.js +5 -3
  19. package/dist/src/cli/mcp/mcp-server-registry.js +10 -3
  20. package/dist/src/cli/providers/local-provider-registry.js +2 -3
  21. package/dist/src/cli/setup/auto-mcp-setup.js +9 -32
  22. package/dist/src/cli/setup/ide-detector.js +34 -14
  23. package/dist/src/config/persona-capability-bundles.js +17 -13
  24. package/dist/src/db/payment-repository.js +61 -0
  25. package/dist/src/first-run/session-service.js +2 -2
  26. package/dist/src/fraim/config-loader.js +11 -0
  27. package/dist/src/fraim/db-service.js +2387 -0
  28. package/dist/src/fraim/issues.js +152 -0
  29. package/dist/src/fraim/template-processor.js +184 -0
  30. package/dist/src/fraim/utils/request-utils.js +23 -0
  31. package/dist/src/local-mcp-server/stdio-server.js +28 -4
  32. package/dist/src/local-mcp-server/usage-collector.js +24 -0
  33. package/dist/src/middleware/auth.js +266 -0
  34. package/dist/src/middleware/cors-config.js +111 -0
  35. package/dist/src/middleware/logger.js +116 -0
  36. package/dist/src/middleware/rate-limit.js +110 -0
  37. package/dist/src/middleware/reject-query-api-key.js +45 -0
  38. package/dist/src/middleware/security-headers.js +41 -0
  39. package/dist/src/middleware/telemetry.js +134 -0
  40. package/dist/src/models/payment.js +2 -0
  41. package/dist/src/routes/analytics.js +1447 -0
  42. package/dist/src/routes/app-routes.js +32 -0
  43. package/dist/src/routes/auth-routes.js +505 -0
  44. package/dist/src/routes/oauth-routes.js +325 -0
  45. package/dist/src/routes/payment-routes.js +186 -0
  46. package/dist/src/routes/persona-catalog-routes.js +84 -0
  47. package/dist/src/services/admin-service.js +229 -0
  48. package/dist/src/services/audit-log-persistence.js +60 -0
  49. package/dist/src/services/audit-log.js +69 -0
  50. package/dist/src/services/cookie-service.js +129 -0
  51. package/dist/src/services/dashboard-access.js +27 -0
  52. package/dist/src/services/demo-seed-service.js +139 -0
  53. package/dist/src/services/email-code.js +23 -0
  54. package/dist/src/services/email-service-clean.js +782 -0
  55. package/dist/src/services/email-service.js +951 -0
  56. package/dist/src/services/installer-service.js +131 -0
  57. package/dist/src/services/mcp-oauth-store.js +33 -0
  58. package/dist/src/services/mcp-service.js +823 -0
  59. package/dist/src/services/oauth-helpers.js +127 -0
  60. package/dist/src/services/org-service.js +89 -0
  61. package/dist/src/services/persona-entitlement-service.js +288 -0
  62. package/dist/src/services/provider-service.js +215 -0
  63. package/dist/src/services/registry-service.js +628 -0
  64. package/dist/src/services/session-service.js +86 -0
  65. package/dist/src/services/trial-reminder-service.js +120 -0
  66. package/dist/src/services/usage-analytics-service.js +419 -0
  67. package/dist/src/services/workspace-identity.js +21 -0
  68. package/dist/src/types/analytics.js +2 -0
  69. package/dist/src/utils/payment-calculator.js +52 -0
  70. package/extensions/office-word/favicon.ico +0 -0
  71. package/extensions/office-word/icon-64.png +0 -0
  72. package/extensions/office-word/manifest.xml +33 -0
  73. package/extensions/office-word/taskpane.html +242 -0
  74. package/package.json +14 -3
  75. package/public/ai-hub/index.html +14 -2
  76. package/public/ai-hub/script.js +340 -66
  77. package/public/ai-hub/styles.css +83 -0
@@ -8,7 +8,6 @@ const electron_1 = require("electron");
8
8
  const path_1 = __importDefault(require("path"));
9
9
  const fs_1 = __importDefault(require("fs"));
10
10
  const server_1 = require("./server");
11
- const db_service_1 = require("../fraim/db-service");
12
11
  const cert_store_1 = require("./cert-store");
13
12
  const office_sideload_1 = require("./office-sideload");
14
13
  // ---------------------------------------------------------------------------
@@ -24,7 +23,8 @@ let isQuitting = false; // distinguishes window-close (→ tray) from app-quit
24
23
  // ---------------------------------------------------------------------------
25
24
  function tryCreateDbService() {
26
25
  try {
27
- return new db_service_1.FraimDbService();
26
+ const loaded = require('../fraim/db-service');
27
+ return new loaded.FraimDbService();
28
28
  }
29
29
  catch {
30
30
  return undefined;
@@ -110,6 +110,16 @@ function loadPersonaCapabilityModule() {
110
110
  function getProtectedPersonaForHubJob(jobName) {
111
111
  return loadPersonaCapabilityModule()?.getProtectedPersonaForJob(jobName) ?? null;
112
112
  }
113
+ const FRAIM_INTERNAL_JOB_IDS = new Set([
114
+ 'contribute-to-fraim',
115
+ 'create-registry-asset',
116
+ 'extract-ashley-learnings',
117
+ 'file-fraim-issue',
118
+ 'praise-fraim',
119
+ 'run-on-remote-hub',
120
+ 'setup-remote-hub',
121
+ 'update-registry-override',
122
+ ]);
113
123
  function listHubPersonaBundles() {
114
124
  return loadPersonaCapabilityModule()?.listPersonaCapabilityBundles() ?? [];
115
125
  }
@@ -147,6 +157,9 @@ function createDefaultDbService() {
147
157
  return undefined;
148
158
  }
149
159
  }
160
+ function supportsManagerSeatAssignments(dbService) {
161
+ return typeof dbService.countHubManagerAssignments === 'function';
162
+ }
150
163
  class AiHubRunRegistry {
151
164
  constructor() {
152
165
  this.runs = new Map();
@@ -1246,6 +1259,23 @@ class AiHubServer {
1246
1259
  this.app.get('/api/health', (_req, res) => {
1247
1260
  res.json({ status: 'ok', service: 'fraim-ai-hub' });
1248
1261
  });
1262
+ // Issue #659: Inbound auth middleware for remote-exposed hubs.
1263
+ // Activated only when FRAIM_HUB_AUTH_TOKEN env var is set. Gates all
1264
+ // /api/ai-hub/* routes. /health and /api/health are registered above
1265
+ // this block and are never gated.
1266
+ if (process.env.FRAIM_HUB_AUTH_TOKEN) {
1267
+ const expectedToken = process.env.FRAIM_HUB_AUTH_TOKEN;
1268
+ this.app.use('/api/ai-hub', (req, res, next) => {
1269
+ const token = req.headers['x-hub-auth'];
1270
+ if (typeof token !== 'string' ||
1271
+ token.length !== expectedToken.length ||
1272
+ !(0, crypto_1.timingSafeEqual)(Buffer.from(token), Buffer.from(expectedToken))) {
1273
+ res.status(401).json({ error: 'Unauthorized' });
1274
+ return;
1275
+ }
1276
+ next();
1277
+ });
1278
+ }
1249
1279
  this.registerRoutes();
1250
1280
  }
1251
1281
  getApp() {
@@ -1363,7 +1393,9 @@ class AiHubServer {
1363
1393
  // Issue #566 (R7): jobs already carry `personalized` from catalog discovery
1364
1394
  // (true for the fraim/personalized-employee layer). The Hub renders a plain
1365
1395
  // "Personalized" marking from that flag — no author/attribution is tracked.
1366
- const jobs = rawJobs.map((job) => ({
1396
+ const jobs = rawJobs
1397
+ .filter((job) => !FRAIM_INTERNAL_JOB_IDS.has(job.id))
1398
+ .map((job) => ({
1367
1399
  ...job,
1368
1400
  requiredPersonaKey: getProtectedPersonaForHubJob(job.id),
1369
1401
  }));
@@ -1853,6 +1885,23 @@ class AiHubServer {
1853
1885
  if (!state) {
1854
1886
  return { personas: fallbackPersonas, subscriptionActive: false, workspaceId: null, userKey: null };
1855
1887
  }
1888
+ // Legacy bypass: persona gating is not active for this workspace, so all personas are accessible.
1889
+ // Mirrors the evaluatePersonaAccess legacy bypass in persona-entitlement-service.ts.
1890
+ if (!state.subscriptionActive) {
1891
+ const legacySeatCount = supportsManagerSeatAssignments(this.dbService) ? 0 : 1;
1892
+ const allPersonas = allBundles.map((bundle) => ({
1893
+ key: bundle.personaKey,
1894
+ displayName: bundle.catalogMetadata.displayName,
1895
+ role: bundle.catalogMetadata.role,
1896
+ avatarUrl: buildHubPersonaAvatarUrl(bundle.personaKey),
1897
+ pricingLabel: '',
1898
+ status: 'hired',
1899
+ hireUrl: buildHubPersonaHireUrl(bundle.personaKey, bundle.defaultHireMode),
1900
+ seatCount: legacySeatCount,
1901
+ seatsInUse: 0,
1902
+ }));
1903
+ return { personas: allPersonas, subscriptionActive: false, workspaceId: state.workspaceId, userKey: state.userId ?? null };
1904
+ }
1856
1905
  const hiredKeys = new Set(state.entitlements
1857
1906
  .filter((e) => e.status === 'active')
1858
1907
  .map((e) => e.personaKey));
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.listPayments = listPayments;
4
+ async function listPayments(req, res, paymentRepo) {
5
+ try {
6
+ const { plan, status, startDate, endDate, limit = '50', offset = '0', } = req.query;
7
+ // Parse and validate parameters
8
+ const filters = {
9
+ limit: Math.min(parseInt(limit) || 50, 100),
10
+ offset: parseInt(offset) || 0,
11
+ };
12
+ if (plan)
13
+ filters.plan = plan;
14
+ if (status)
15
+ filters.status = status;
16
+ if (startDate)
17
+ filters.startDate = new Date(startDate);
18
+ if (endDate)
19
+ filters.endDate = new Date(endDate);
20
+ // Query payments
21
+ const { payments, total } = await paymentRepo.listPayments(filters);
22
+ res.json({
23
+ payments,
24
+ total,
25
+ limit: filters.limit,
26
+ offset: filters.offset,
27
+ });
28
+ }
29
+ catch (error) {
30
+ console.error('❌ Error listing payments:', error);
31
+ res.status(500).json({ error: 'Failed to list payments', details: error.message });
32
+ }
33
+ }
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.listSalesLeads = listSalesLeads;
4
+ async function listSalesLeads(req, res, dbService) {
5
+ try {
6
+ const { limit = '50', } = req.query;
7
+ // Parse and validate parameters
8
+ const limitNum = Math.min(parseInt(limit) || 50, 100);
9
+ // Query sales inquiries using existing collection
10
+ const inquiries = await dbService.getSalesInquiries(limitNum);
11
+ res.json({
12
+ leads: inquiries,
13
+ total: inquiries.length,
14
+ limit: limitNum,
15
+ });
16
+ }
17
+ catch (error) {
18
+ console.error('❌ Error listing sales inquiries:', error);
19
+ res.status(500).json({ error: 'Failed to list sales inquiries', details: error.message });
20
+ }
21
+ }
@@ -0,0 +1,338 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createCheckoutSession = createCheckoutSession;
4
+ const stripe_1 = require("../../config/stripe");
5
+ const payment_calculator_1 = require("../../utils/payment-calculator");
6
+ const persona_hiring_1 = require("../../config/persona-hiring");
7
+ async function createCheckoutSession(req, res, paymentRepo, dbService) {
8
+ try {
9
+ const { plan } = req.body;
10
+ // Validation
11
+ if (!plan || !['self-served', 'managed', 'persona-hire', 'bootcamp'].includes(plan)) {
12
+ return res.status(400).json({ error: 'Invalid plan. Must be "self-served", "managed", "persona-hire", or "bootcamp"' });
13
+ }
14
+ if (plan === 'persona-hire') {
15
+ return await createPersonaHireSession(req.body, res, paymentRepo);
16
+ }
17
+ if (plan === 'bootcamp') {
18
+ return await createBootcampSession(req.body, res, paymentRepo);
19
+ }
20
+ const { numberOfUsers, email, company, founderDiscount, billingCycle } = req.body;
21
+ if (!numberOfUsers || numberOfUsers < 1 || numberOfUsers > 1000) {
22
+ return res.status(400).json({ error: 'Invalid numberOfUsers. Must be between 1 and 1000' });
23
+ }
24
+ if (!email || !email.includes('@')) {
25
+ return res.status(400).json({ error: 'Valid email is required' });
26
+ }
27
+ if (!billingCycle || !['monthly', 'annual'].includes(billingCycle)) {
28
+ return res.status(400).json({ error: 'Invalid billingCycle. Must be "monthly" or "annual"' });
29
+ }
30
+ // Auto-lookup identity-based partner discount (email-level takes priority over domain-level)
31
+ const partnerEntry = await dbService.lookupPartnerDiscount(email);
32
+ // Validate founder discount if requested and no partner discount overrides it
33
+ if (founderDiscount && !partnerEntry) {
34
+ const validation = (0, payment_calculator_1.validateFounderEmail)(email);
35
+ if (!validation.valid) {
36
+ return res.status(400).json({ error: validation.reason });
37
+ }
38
+ }
39
+ // Calculate payment amount (founder discount affects displayed amount)
40
+ const applyFounderForCalc = founderDiscount && !partnerEntry;
41
+ const legacyPlan = plan;
42
+ const { amount, discountAmount } = (0, payment_calculator_1.calculatePaymentAmount)(legacyPlan, numberOfUsers, billingCycle, Boolean(applyFounderForCalc));
43
+ // Determine which coupon to apply: partner discount takes priority over founder
44
+ const appliedCouponId = partnerEntry
45
+ ? partnerEntry.couponId
46
+ : (founderDiscount ? 'FOUNDER90' : null);
47
+ // Create or retrieve Stripe customer
48
+ const customers = await stripe_1.stripe.customers.list({ email, limit: 1 });
49
+ let customer;
50
+ if (customers.data.length > 0) {
51
+ customer = customers.data[0];
52
+ }
53
+ else {
54
+ customer = await stripe_1.stripe.customers.create({
55
+ email,
56
+ metadata: {
57
+ company: company || '',
58
+ plan,
59
+ numberOfUsers: numberOfUsers.toString(),
60
+ },
61
+ });
62
+ }
63
+ const recurringInterval = billingCycle === 'monthly' ? 'month' : 'year';
64
+ // Build Stripe checkout session config (subscription mode, always collect card)
65
+ const sessionConfig = {
66
+ customer: customer.id,
67
+ payment_method_types: ['card'],
68
+ line_items: [
69
+ {
70
+ price_data: {
71
+ currency: stripe_1.STRIPE_CONFIG.currency,
72
+ product_data: {
73
+ name: `FRAIM ${plan === 'self-served' ? 'Self-Served' : 'Managed'} Plan`,
74
+ description: `${numberOfUsers} user${numberOfUsers > 1 ? 's' : ''} - ${billingCycle === 'monthly' ? 'Monthly' : 'Annual'} billing`,
75
+ },
76
+ unit_amount: Math.round(amount / numberOfUsers),
77
+ recurring: {
78
+ interval: recurringInterval,
79
+ },
80
+ },
81
+ quantity: numberOfUsers,
82
+ },
83
+ ],
84
+ mode: 'subscription',
85
+ payment_method_collection: 'always',
86
+ success_url: `${stripe_1.STRIPE_CONFIG.successUrl}?session_id={CHECKOUT_SESSION_ID}`,
87
+ cancel_url: stripe_1.STRIPE_CONFIG.cancelUrl,
88
+ subscription_data: {
89
+ metadata: {
90
+ plan,
91
+ numberOfUsers: numberOfUsers.toString(),
92
+ founderDiscount: founderDiscount ? 'true' : 'false',
93
+ partnerDiscountMatch: partnerEntry ? partnerEntry.match : '',
94
+ appliedCouponId: appliedCouponId || '',
95
+ billingCycle,
96
+ company: company || '',
97
+ },
98
+ },
99
+ metadata: {
100
+ plan,
101
+ numberOfUsers: numberOfUsers.toString(),
102
+ founderDiscount: founderDiscount ? 'true' : 'false',
103
+ partnerDiscountMatch: partnerEntry ? partnerEntry.match : '',
104
+ appliedCouponId: appliedCouponId || '',
105
+ discountAmount: discountAmount.toString(),
106
+ billingCycle,
107
+ company: company || '',
108
+ },
109
+ };
110
+ // Apply coupon if one was selected
111
+ if (appliedCouponId) {
112
+ let coupon;
113
+ if (appliedCouponId === 'FOUNDER90') {
114
+ try {
115
+ coupon = await stripe_1.stripe.coupons.retrieve('FOUNDER90');
116
+ }
117
+ catch {
118
+ coupon = await stripe_1.stripe.coupons.create({
119
+ id: 'FOUNDER90',
120
+ percent_off: 90,
121
+ duration: 'forever',
122
+ name: 'Founder Discount (90% Off)',
123
+ });
124
+ }
125
+ }
126
+ else if (appliedCouponId === 'PARTNER100') {
127
+ try {
128
+ coupon = await stripe_1.stripe.coupons.retrieve('PARTNER100');
129
+ }
130
+ catch {
131
+ coupon = await stripe_1.stripe.coupons.create({
132
+ id: 'PARTNER100',
133
+ percent_off: 100,
134
+ duration: 'forever',
135
+ name: 'Partner Discount (100% Off)',
136
+ });
137
+ }
138
+ }
139
+ else {
140
+ coupon = await stripe_1.stripe.coupons.retrieve(appliedCouponId);
141
+ }
142
+ sessionConfig.discounts = [{ coupon: coupon.id }];
143
+ }
144
+ const session = await stripe_1.stripe.checkout.sessions.create(sessionConfig);
145
+ // Increment partner discount use count
146
+ if (partnerEntry) {
147
+ await dbService.incrementPartnerDiscountUseCount(partnerEntry._id);
148
+ }
149
+ // Create payment record in database
150
+ await paymentRepo.createPayment({
151
+ email,
152
+ company,
153
+ plan: plan,
154
+ numberOfUsers,
155
+ billingCycle: billingCycle,
156
+ amount,
157
+ currency: stripe_1.STRIPE_CONFIG.currency,
158
+ founderDiscount: founderDiscount && !partnerEntry ? true : false,
159
+ appliedCouponId: appliedCouponId || undefined,
160
+ discountAmount: appliedCouponId ? discountAmount : undefined,
161
+ status: 'pending',
162
+ stripeCustomerId: customer.id,
163
+ stripePaymentIntentId: '', // Will be updated by webhook
164
+ stripeCheckoutSessionId: session.id,
165
+ timestamp: new Date(),
166
+ });
167
+ res.json({
168
+ sessionId: session.id,
169
+ sessionUrl: session.url,
170
+ });
171
+ }
172
+ catch (error) {
173
+ console.error('❌ Error creating checkout session:', error);
174
+ res.status(500).json({ error: 'Failed to create checkout session', details: error.message });
175
+ }
176
+ }
177
+ async function createPersonaHireSession(body, res, paymentRepo) {
178
+ const { email, company, personaKey, hireMode, founderDiscount } = body;
179
+ if (!email || !email.includes('@')) {
180
+ return res.status(400).json({ error: 'Valid email is required' });
181
+ }
182
+ if (!personaKey || !(0, persona_hiring_1.isPersonaHireKey)(personaKey)) {
183
+ return res.status(400).json({ error: 'Invalid personaKey for persona-hire checkout' });
184
+ }
185
+ if (!hireMode || !['job', 'fulltime'].includes(hireMode)) {
186
+ return res.status(400).json({ error: 'Invalid hireMode. Must be "job" or "fulltime"' });
187
+ }
188
+ if (founderDiscount) {
189
+ const validation = (0, payment_calculator_1.validateFounderEmail)(email);
190
+ if (!validation.valid) {
191
+ return res.status(400).json({ error: validation.reason });
192
+ }
193
+ }
194
+ const typedPersonaKey = personaKey;
195
+ const typedHireMode = hireMode;
196
+ const persona = persona_hiring_1.PERSONA_HIRE_CATALOG[typedPersonaKey];
197
+ const baseAmount = (0, persona_hiring_1.getPersonaHireAmountCents)(typedPersonaKey, typedHireMode);
198
+ const amount = founderDiscount ? Math.round(baseAmount * 0.10) : baseAmount;
199
+ const metadata = {
200
+ plan: 'persona-hire',
201
+ personaKey: typedPersonaKey,
202
+ personaName: persona.displayName,
203
+ personaRole: persona.role,
204
+ hireMode: typedHireMode,
205
+ billingCycle: typedHireMode === 'fulltime' ? 'monthly' : '',
206
+ company: company || '',
207
+ };
208
+ const hubBaseUrl = process.env.FRAIM_HUB_BASE_URL;
209
+ const successUrl = hubBaseUrl
210
+ ? `${hubBaseUrl}/ai-hub/payment-success?session_id={CHECKOUT_SESSION_ID}&email=${encodeURIComponent(email)}`
211
+ : `${stripe_1.STRIPE_CONFIG.successUrl}?session_id={CHECKOUT_SESSION_ID}`;
212
+ const sessionConfig = {
213
+ customer_email: email,
214
+ payment_method_types: ['card'],
215
+ success_url: successUrl,
216
+ cancel_url: stripe_1.STRIPE_CONFIG.cancelUrl,
217
+ metadata,
218
+ };
219
+ if (typedHireMode === 'fulltime') {
220
+ sessionConfig.mode = 'subscription';
221
+ sessionConfig.payment_method_collection = 'always';
222
+ sessionConfig.line_items = [
223
+ {
224
+ price_data: {
225
+ currency: stripe_1.STRIPE_CONFIG.currency,
226
+ product_data: {
227
+ name: `${persona.displayName} Full-Time Hire`,
228
+ description: `${persona.role} - billed monthly`,
229
+ },
230
+ unit_amount: amount,
231
+ recurring: {
232
+ interval: 'month',
233
+ },
234
+ },
235
+ quantity: 1,
236
+ },
237
+ ];
238
+ sessionConfig.subscription_data = { metadata };
239
+ }
240
+ else {
241
+ sessionConfig.mode = 'payment';
242
+ sessionConfig.line_items = [
243
+ {
244
+ price_data: {
245
+ currency: stripe_1.STRIPE_CONFIG.currency,
246
+ product_data: {
247
+ name: `${persona.displayName} Per-Job Hire`,
248
+ description: `${persona.role} - one scoped job`,
249
+ },
250
+ unit_amount: amount,
251
+ },
252
+ quantity: 1,
253
+ },
254
+ ];
255
+ }
256
+ const session = await stripe_1.stripe.checkout.sessions.create(sessionConfig);
257
+ await paymentRepo.createPayment({
258
+ email,
259
+ company,
260
+ plan: 'persona-hire',
261
+ numberOfUsers: 1,
262
+ billingCycle: 'monthly',
263
+ amount,
264
+ currency: stripe_1.STRIPE_CONFIG.currency,
265
+ founderDiscount: founderDiscount ?? false,
266
+ status: 'pending',
267
+ stripeCustomerId: typeof session.customer === 'string' ? session.customer : (session.customer?.id ?? ''),
268
+ stripePaymentIntentId: '',
269
+ stripeCheckoutSessionId: session.id,
270
+ timestamp: new Date(),
271
+ metadata,
272
+ });
273
+ return res.json({
274
+ sessionId: session.id,
275
+ sessionUrl: session.url,
276
+ });
277
+ }
278
+ const BOOTCAMP_PRICE_CENTS = 199500; // $1,995
279
+ async function createBootcampSession(body, res, paymentRepo) {
280
+ const { email, company, founderDiscount } = body;
281
+ if (!email || !email.includes('@')) {
282
+ return res.status(400).json({ error: 'Valid email is required' });
283
+ }
284
+ if (founderDiscount) {
285
+ const validation = (0, payment_calculator_1.validateFounderEmail)(email);
286
+ if (!validation.valid) {
287
+ return res.status(400).json({ error: validation.reason });
288
+ }
289
+ }
290
+ const amount = founderDiscount ? Math.round(BOOTCAMP_PRICE_CENTS * 0.10) : BOOTCAMP_PRICE_CENTS;
291
+ const customers = await stripe_1.stripe.customers.list({ email, limit: 1 });
292
+ const customer = customers.data[0] || await stripe_1.stripe.customers.create({
293
+ email,
294
+ metadata: { company: company || '', plan: 'bootcamp' },
295
+ });
296
+ const metadata = {
297
+ plan: 'bootcamp',
298
+ company: company || '',
299
+ };
300
+ const session = await stripe_1.stripe.checkout.sessions.create({
301
+ customer: customer.id,
302
+ payment_method_types: ['card'],
303
+ mode: 'payment',
304
+ success_url: `${stripe_1.STRIPE_CONFIG.successUrl}?session_id={CHECKOUT_SESSION_ID}`,
305
+ cancel_url: stripe_1.STRIPE_CONFIG.cancelUrl,
306
+ metadata,
307
+ line_items: [
308
+ {
309
+ price_data: {
310
+ currency: stripe_1.STRIPE_CONFIG.currency,
311
+ product_data: {
312
+ name: 'FRAIM Manager Bootcamp',
313
+ description: '4-week cohort · 1 seat · live sessions + capstone',
314
+ },
315
+ unit_amount: amount,
316
+ },
317
+ quantity: 1,
318
+ },
319
+ ],
320
+ });
321
+ await paymentRepo.createPayment({
322
+ email,
323
+ company,
324
+ plan: 'bootcamp',
325
+ numberOfUsers: 1,
326
+ billingCycle: 'monthly',
327
+ amount,
328
+ currency: stripe_1.STRIPE_CONFIG.currency,
329
+ founderDiscount: founderDiscount ?? false,
330
+ status: 'pending',
331
+ stripeCustomerId: customer.id,
332
+ stripePaymentIntentId: '',
333
+ stripeCheckoutSessionId: session.id,
334
+ timestamp: new Date(),
335
+ metadata,
336
+ });
337
+ return res.json({ sessionId: session.id, sessionUrl: session.url });
338
+ }
@@ -0,0 +1,149 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveDashboardLinkForCheckoutSession = resolveDashboardLinkForCheckoutSession;
4
+ exports.getDashboardLinkFromCheckoutSession = getDashboardLinkFromCheckoutSession;
5
+ const stripe_1 = require("../../config/stripe");
6
+ const feature_flags_1 = require("../../config/feature-flags");
7
+ const dashboard_access_1 = require("../../services/dashboard-access");
8
+ const persona_entitlement_service_1 = require("../../services/persona-entitlement-service");
9
+ function isCheckoutSessionReadyForDashboard(session) {
10
+ const status = session.status;
11
+ const paymentStatus = session.payment_status;
12
+ const isComplete = status === 'complete';
13
+ const isPaidOrNoPaymentRequired = paymentStatus === 'paid' || paymentStatus === 'no_payment_required';
14
+ return isComplete && isPaidOrNoPaymentRequired;
15
+ }
16
+ function getSessionCustomerId(session) {
17
+ if (!session.customer)
18
+ return null;
19
+ return typeof session.customer === 'string' ? session.customer : session.customer.id;
20
+ }
21
+ function getSessionSubscriptionId(session) {
22
+ if (!session.subscription)
23
+ return null;
24
+ return typeof session.subscription === 'string' ? session.subscription : session.subscription.id;
25
+ }
26
+ async function resolveSessionEmail(session, customerId, customerRetriever) {
27
+ const customerDetailsEmail = session.customer_details?.email;
28
+ if (customerDetailsEmail)
29
+ return customerDetailsEmail.toLowerCase();
30
+ if (!customerId)
31
+ return null;
32
+ try {
33
+ const customer = await customerRetriever(customerId);
34
+ if (!('deleted' in customer) && customer.email) {
35
+ return customer.email.toLowerCase();
36
+ }
37
+ }
38
+ catch {
39
+ // Fallback: return null and let caller decide.
40
+ }
41
+ return null;
42
+ }
43
+ function extractSubscriptionPeriodEnd(subscription) {
44
+ if (!subscription)
45
+ return null;
46
+ const rootPeriodEnd = subscription.current_period_end;
47
+ if (typeof rootPeriodEnd === 'number' && rootPeriodEnd > 0) {
48
+ return new Date(rootPeriodEnd * 1000);
49
+ }
50
+ const itemEnds = (subscription.items?.data || [])
51
+ .map((item) => item?.current_period_end)
52
+ .filter((value) => typeof value === 'number' && value > 0);
53
+ if (itemEnds.length > 0) {
54
+ return new Date(Math.min(...itemEnds) * 1000);
55
+ }
56
+ return null;
57
+ }
58
+ async function resolveDashboardLinkForCheckoutSession(sessionId, dbService, deps = {}) {
59
+ if (!sessionId || !sessionId.startsWith('cs_')) {
60
+ return { ok: false, status: 400, error: 'Invalid session_id' };
61
+ }
62
+ const sessionRetriever = deps.sessionRetriever || ((id) => stripe_1.stripe.checkout.sessions.retrieve(id));
63
+ const customerRetriever = deps.customerRetriever || ((id) => stripe_1.stripe.customers.retrieve(id));
64
+ const subscriptionRetriever = deps.subscriptionRetriever || ((id) => stripe_1.stripe.subscriptions.retrieve(id));
65
+ let session;
66
+ try {
67
+ session = await sessionRetriever(sessionId);
68
+ }
69
+ catch (error) {
70
+ return { ok: false, status: 500, error: `Failed to load checkout session: ${error?.message || error}` };
71
+ }
72
+ if (!isCheckoutSessionReadyForDashboard(session)) {
73
+ return { ok: false, status: 409, error: 'Checkout session is not complete yet' };
74
+ }
75
+ const customerId = getSessionCustomerId(session);
76
+ const subscriptionId = getSessionSubscriptionId(session);
77
+ const email = await resolveSessionEmail(session, customerId, customerRetriever);
78
+ if ((0, feature_flags_1.isPersonaEntitlementsEnabled)() && session.metadata?.plan === 'persona-hire') {
79
+ await (0, persona_entitlement_service_1.syncPersonaEntitlementFromCheckoutSession)(dbService, {
80
+ ...session,
81
+ customer_details: session.customer_details || (email ? { email } : undefined)
82
+ }, 'dashboard-link');
83
+ }
84
+ let subscription = null;
85
+ if (subscriptionId) {
86
+ try {
87
+ subscription = await subscriptionRetriever(subscriptionId);
88
+ }
89
+ catch (error) {
90
+ console.warn(`⚠️ Could not retrieve subscription ${subscriptionId} for dashboard link: ${error?.message || error}`);
91
+ }
92
+ }
93
+ const nextBillingDate = extractSubscriptionPeriodEnd(subscription);
94
+ let apiKey = customerId ? await dbService.getApiKeyByStripeCustomerId(customerId) : null;
95
+ if (!apiKey && email) {
96
+ apiKey = await dbService.getApiKeyByUserId(email);
97
+ }
98
+ if (apiKey) {
99
+ const updatePatch = {
100
+ tier: 'paid-subscription',
101
+ status: 'active',
102
+ expiresAt: null,
103
+ };
104
+ if (customerId)
105
+ updatePatch.stripeCustomerId = customerId;
106
+ if (subscriptionId)
107
+ updatePatch.stripeSubscriptionId = subscriptionId;
108
+ if (nextBillingDate)
109
+ updatePatch.currentPeriodEnd = nextBillingDate;
110
+ await dbService.updateApiKey(apiKey.key, updatePatch);
111
+ }
112
+ else {
113
+ if (!email) {
114
+ return { ok: false, status: 404, error: 'Could not resolve customer email for session' };
115
+ }
116
+ const newKey = dbService.generateApiKey(email, 'default');
117
+ await dbService.createApiKey({
118
+ key: newKey,
119
+ userId: email,
120
+ orgId: 'default',
121
+ tier: 'paid-subscription',
122
+ status: 'active',
123
+ expiresAt: null,
124
+ stripeCustomerId: customerId,
125
+ stripeSubscriptionId: subscriptionId,
126
+ currentPeriodEnd: nextBillingDate,
127
+ cancelAt: null,
128
+ suspendedAt: null,
129
+ suspensionReason: null,
130
+ lastUsedAt: null,
131
+ apiCallCount: 0,
132
+ personaSystemActive: (0, feature_flags_1.isPersonaEntitlementsEnabled)() || undefined
133
+ });
134
+ apiKey = { key: newKey, userId: email };
135
+ }
136
+ if (!apiKey) {
137
+ return { ok: false, status: 500, error: 'Could not resolve API key for checkout session' };
138
+ }
139
+ const dashboardUrl = await (0, dashboard_access_1.createDashboardAccessUrl)(dbService, apiKey.userId, apiKey.key);
140
+ return { ok: true, dashboardUrl };
141
+ }
142
+ async function getDashboardLinkFromCheckoutSession(req, res, dbService) {
143
+ const sessionId = req.query.session_id;
144
+ const resolved = await resolveDashboardLinkForCheckoutSession(sessionId, dbService);
145
+ if (!resolved.ok) {
146
+ return res.status(resolved.status).json({ error: resolved.error });
147
+ }
148
+ return res.json({ dashboardUrl: resolved.dashboardUrl });
149
+ }
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getSessionDetails = getSessionDetails;
4
+ const stripe_1 = require("../../config/stripe");
5
+ async function getSessionDetails(req, res) {
6
+ const sessionId = req.query.session_id;
7
+ if (!sessionId || !sessionId.startsWith('cs_')) {
8
+ return res.status(400).json({ error: 'Invalid session_id' });
9
+ }
10
+ try {
11
+ const session = await stripe_1.stripe.checkout.sessions.retrieve(sessionId);
12
+ const meta = session.metadata || {};
13
+ const amountTotal = session.amount_total ?? 0; // cents
14
+ res.json({
15
+ plan: meta.plan || null,
16
+ personaKey: meta.personaKey || null,
17
+ personaName: meta.personaName || null,
18
+ personaRole: meta.personaRole || null,
19
+ hireMode: meta.hireMode || null,
20
+ numberOfUsers: meta.numberOfUsers ? parseInt(meta.numberOfUsers) : null,
21
+ billingCycle: meta.billingCycle || null,
22
+ amountTotal, // cents
23
+ currency: session.currency || 'usd',
24
+ appliedCouponId: meta.appliedCouponId || null,
25
+ });
26
+ }
27
+ catch (error) {
28
+ console.error('❌ Error fetching session details:', error.message);
29
+ res.status(500).json({ error: 'Failed to fetch session details' });
30
+ }
31
+ }