thumbgate 1.5.8 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,7 +16,13 @@ function withTimeout(promise, ms = STRIPE_TIMEOUT_MS) {
16
16
  const fs = require('fs');
17
17
  const path = require('path');
18
18
  const crypto = require('crypto');
19
- const { createTraceId } = require('./hosted-config');
19
+ const https = require('https');
20
+ const {
21
+ DEFAULT_PUBLIC_APP_ORIGIN,
22
+ createTraceId,
23
+ joinPublicUrl,
24
+ normalizeOrigin,
25
+ } = require('./hosted-config');
20
26
  const {
21
27
  getFeedbackPaths,
22
28
  getLegacyFeedbackDir,
@@ -44,6 +50,7 @@ const {
44
50
  serializeAnalyticsWindow,
45
51
  } = require('./analytics-window');
46
52
  const { ensureParentDir } = require('./fs-utils');
53
+ const mailer = require('./mailer');
47
54
 
48
55
  // ---------------------------------------------------------------------------
49
56
  // Config
@@ -74,6 +81,12 @@ const CONFIG = {
74
81
  get NEWSLETTER_SUBSCRIBERS_PATH() {
75
82
  return process.env._TEST_NEWSLETTER_SUBSCRIBERS_PATH || path.join(getFeedbackPaths().FEEDBACK_DIR, 'newsletter-subscribers.jsonl');
76
83
  },
84
+ get TRIAL_EMAIL_LEDGER_PATH() {
85
+ return process.env._TEST_TRIAL_EMAIL_LEDGER_PATH || process.env.THUMBGATE_TRIAL_EMAIL_LEDGER_PATH || path.join(getFeedbackPaths().FEEDBACK_DIR, 'trial-emails.jsonl');
86
+ },
87
+ RESEND_API_KEY: process.env.RESEND_API_KEY || process.env.THUMBGATE_RESEND_API_KEY || '',
88
+ TRIAL_EMAIL_FROM: process.env.THUMBGATE_TRIAL_EMAIL_FROM || process.env.RESEND_FROM_EMAIL || process.env.RESEND_FROM || 'onboarding@resend.dev',
89
+ TRIAL_EMAIL_REPLY_TO: process.env.THUMBGATE_TRIAL_EMAIL_REPLY_TO || 'igor.ganapolsky@gmail.com',
77
90
  CREDIT_PACKS: {}
78
91
  };
79
92
 
@@ -379,6 +392,407 @@ function normalizeText(value) {
379
392
  return text || null;
380
393
  }
381
394
 
395
+ function resolvePublicAppOrigin(appOrigin) {
396
+ return normalizeOrigin(appOrigin) || normalizeOrigin(process.env.THUMBGATE_PUBLIC_APP_ORIGIN) || DEFAULT_PUBLIC_APP_ORIGIN;
397
+ }
398
+
399
+ function resolveCheckoutBrandUrls(appOrigin) {
400
+ const origin = resolvePublicAppOrigin(appOrigin);
401
+ return {
402
+ icon: joinPublicUrl(origin, '/assets/brand/thumbgate-icon-512.png'),
403
+ logo: joinPublicUrl(origin, '/assets/brand/thumbgate-logo-1200x360.png'),
404
+ };
405
+ }
406
+
407
+ function buildCheckoutBrandingSettings(appOrigin) {
408
+ const brandUrls = resolveCheckoutBrandUrls(appOrigin);
409
+ return {
410
+ display_name: 'ThumbGate',
411
+ logo: {
412
+ type: 'url',
413
+ url: brandUrls.logo,
414
+ },
415
+ background_color: '#ffffff',
416
+ button_color: '#22d3ee',
417
+ border_style: 'rounded',
418
+ font_family: 'inter',
419
+ };
420
+ }
421
+
422
+ function buildCheckoutProductData({ name, description, appOrigin }) {
423
+ const brandUrls = resolveCheckoutBrandUrls(appOrigin);
424
+ return {
425
+ name,
426
+ description,
427
+ images: [brandUrls.icon],
428
+ };
429
+ }
430
+
431
+ function buildSubscriptionPriceData(checkoutSelection, appOrigin) {
432
+ const isTeam = checkoutSelection.planId === 'team';
433
+ const annual = checkoutSelection.billingCycle === 'annual';
434
+ const unitAmount = isTeam
435
+ ? TEAM_MONTHLY_PRICE_DOLLARS * 100
436
+ : (annual ? PRO_ANNUAL_PRICE_DOLLARS : PRO_MONTHLY_PRICE_DOLLARS) * 100;
437
+ return {
438
+ currency: 'usd',
439
+ unit_amount: unitAmount,
440
+ recurring: {
441
+ interval: annual ? 'year' : 'month',
442
+ },
443
+ product_data: buildCheckoutProductData({
444
+ name: isTeam ? 'ThumbGate Team' : 'ThumbGate Pro',
445
+ description: isTeam
446
+ ? 'Shared Pre-Action Gates, team governance, and workflow hardening for AI coding agents.'
447
+ : 'Local dashboard, DPO export, and Pre-Action Gates for AI coding agents.',
448
+ appOrigin,
449
+ }),
450
+ };
451
+ }
452
+
453
+ function normalizeEmail(value) {
454
+ const text = normalizeText(value);
455
+ if (!text || !text.includes('@')) return null;
456
+ return text.toLowerCase();
457
+ }
458
+
459
+ function escapeHtml(value) {
460
+ return String(value)
461
+ .replace(/&/g, '&')
462
+ .replace(/</g, '&lt;')
463
+ .replace(/>/g, '&gt;')
464
+ .replace(/"/g, '&quot;')
465
+ .replace(/'/g, '&#39;');
466
+ }
467
+
468
+ function findTrialEmailRecord({ sessionId, customerEmail, statuses = null } = {}) {
469
+ const normalizedEmail = normalizeEmail(customerEmail);
470
+ const rows = loadJsonlRecords(CONFIG.TRIAL_EMAIL_LEDGER_PATH);
471
+ return rows.find((row) => {
472
+ if (!row || typeof row !== 'object') return false;
473
+ if (statuses && !statuses.includes(row.status)) return false;
474
+ if (sessionId && row.sessionId === sessionId) return true;
475
+ return normalizedEmail && row.customerEmail === normalizedEmail;
476
+ }) || null;
477
+ }
478
+
479
+ function appendTrialEmailRecord(payload) {
480
+ return appendJsonlRecord(CONFIG.TRIAL_EMAIL_LEDGER_PATH, {
481
+ timestamp: new Date().toISOString(),
482
+ provider: payload.provider || 'resend',
483
+ ...payload,
484
+ });
485
+ }
486
+
487
+ /**
488
+ * Resolve the trial expiry date for a Stripe checkout session.
489
+ *
490
+ * Prefers an explicit `subscription.trial_end` unix timestamp when the session
491
+ * embeds one (subscriptions with trial_period_days populate it). Falls back to
492
+ * the session's `expires_at`, and finally to now + 7 days. Always returns a
493
+ * Date; never throws.
494
+ */
495
+ function computeTrialEndAt(session) {
496
+ const TRIAL_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
497
+ if (session && session.subscription && typeof session.subscription === 'object') {
498
+ const trialEndUnix = session.subscription.trial_end;
499
+ if (typeof trialEndUnix === 'number' && trialEndUnix > 0) {
500
+ return new Date(trialEndUnix * 1000);
501
+ }
502
+ }
503
+ if (session && typeof session.trial_end === 'number' && session.trial_end > 0) {
504
+ return new Date(session.trial_end * 1000);
505
+ }
506
+ return new Date(Date.now() + TRIAL_DAYS_MS);
507
+ }
508
+
509
+ function buildTrialActivationEmail({ customerEmail, apiKey, sessionId, planId, appOrigin } = {}) {
510
+ const email = normalizeEmail(customerEmail);
511
+ const origin = resolvePublicAppOrigin(appOrigin);
512
+ const dashboardUrl = joinPublicUrl(origin, '/dashboard');
513
+ const docsUrl = 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/VERIFICATION_EVIDENCE.md';
514
+ const command = `npx thumbgate pro --activate --key=${apiKey || ''}`;
515
+ const subject = 'Your 7-day ThumbGate Pro trial is live';
516
+ const preheader = 'Activate Pro in one command, open the dashboard, and start blocking repeated AI coding mistakes.';
517
+ const headline = 'Your 7-day ThumbGate Pro trial is live.';
518
+ const intro = 'ThumbGate turns thumbs up/down feedback into Pre-Action Gates that stop repeated AI coding mistakes before the next tool call. It keeps lessons local and turns repeated mistakes into Reliability Gateway blocks.';
519
+ const exampleFeedback = 'thumbs down: the answer skipped exact files and tests; next time include paths, commands, and verification evidence.';
520
+ const safeDashboardUrl = escapeHtml(dashboardUrl);
521
+ const safeDocsUrl = escapeHtml(docsUrl);
522
+ const safeCommand = escapeHtml(command);
523
+ const safeApiKey = escapeHtml(apiKey || '');
524
+ return {
525
+ from: CONFIG.TRIAL_EMAIL_FROM,
526
+ to: [email],
527
+ reply_to: CONFIG.TRIAL_EMAIL_REPLY_TO,
528
+ subject,
529
+ text: [
530
+ headline,
531
+ '',
532
+ intro,
533
+ '',
534
+ 'Next 3 minutes:',
535
+ '1. Activate Pro locally:',
536
+ command,
537
+ '',
538
+ `2. Open your dashboard: ${dashboardUrl}`,
539
+ '',
540
+ '3. Give one concrete thumbs up or thumbs down:',
541
+ exampleFeedback,
542
+ '',
543
+ 'Your trial key:',
544
+ apiKey,
545
+ '',
546
+ `Verification evidence: ${docsUrl}`,
547
+ 'Keep this key private. Questions? Reply to this email or write hello@thumbgate.app.',
548
+ sessionId ? `Stripe session: ${sessionId}` : null,
549
+ planId ? `Plan: ${planId}` : null,
550
+ ].filter(Boolean).join('\n'),
551
+ html: `<!doctype html>
552
+ <html>
553
+ <body style="margin:0;background:#f5f7fb;padding:28px 12px;font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;color:#17212b;">
554
+ <div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">${escapeHtml(preheader)}</div>
555
+ <table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse:collapse;">
556
+ <tr>
557
+ <td align="center">
558
+ <table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse:collapse;max-width:640px;background:#ffffff;border:1px solid #d8e2ea;border-radius:8px;overflow:hidden;">
559
+ <tr>
560
+ <td style="background:#071115;padding:22px 26px;color:#e7fbff;">
561
+ <div style="font-size:13px;font-weight:700;letter-spacing:0;text-transform:uppercase;color:#73d4e9;">ThumbGate Pro</div>
562
+ <h1 style="margin:12px 0 10px;font-size:28px;line-height:1.15;color:#ffffff;">${escapeHtml(headline)}</h1>
563
+ <p style="margin:0;font-size:15px;line-height:1.6;color:#c6d6de;">${escapeHtml(intro)}</p>
564
+ </td>
565
+ </tr>
566
+ <tr>
567
+ <td style="padding:26px;">
568
+ <p style="margin:0 0 18px;font-size:15px;line-height:1.6;color:#344451;">Run one command, open the dashboard, then give one concrete thumb signal. ThumbGate keeps the lesson local and turns repeated mistakes into Reliability Gateway blocks.</p>
569
+ <p style="margin:0 0 24px;">
570
+ <a href="${safeDashboardUrl}" style="display:inline-block;background:#45bfd8;color:#061015;text-decoration:none;font-weight:700;padding:12px 18px;border-radius:6px;">Open your dashboard</a>
571
+ </p>
572
+
573
+ <h2 style="margin:0 0 8px;font-size:17px;line-height:1.3;color:#17212b;">1. Activate Pro locally</h2>
574
+ <pre style="margin:0 0 22px;background:#081016;color:#d8f7e4;border:1px solid #23343d;border-radius:6px;padding:14px;font-size:13px;line-height:1.45;white-space:pre-wrap;word-break:break-word;"><code>${safeCommand}</code></pre>
575
+
576
+ <h2 style="margin:0 0 8px;font-size:17px;line-height:1.3;color:#17212b;">2. Save your trial key</h2>
577
+ <pre style="margin:0 0 22px;background:#eef6f7;color:#0b343c;border:1px solid #c7e2e7;border-radius:6px;padding:14px;font-size:13px;line-height:1.45;white-space:pre-wrap;word-break:break-word;"><code>${safeApiKey}</code></pre>
578
+
579
+ <h2 style="margin:0 0 8px;font-size:17px;line-height:1.3;color:#17212b;">3. Give one concrete thumbs up or thumbs down</h2>
580
+ <p style="margin:0 0 14px;font-size:14px;line-height:1.6;color:#344451;">Start with the failure you most want your agent to stop repeating.</p>
581
+ <pre style="margin:0 0 24px;background:#f1fff2;color:#22602b;border:1px solid #bae7c0;border-radius:6px;padding:14px;font-size:13px;line-height:1.45;white-space:pre-wrap;word-break:break-word;"><code>${escapeHtml(exampleFeedback)}</code></pre>
582
+
583
+ <table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse:collapse;margin:0 0 22px;">
584
+ <tr>
585
+ <td style="border:1px solid #d8e2ea;border-radius:8px;padding:14px;background:#fbfdff;">
586
+ <strong style="display:block;margin:0 0 6px;font-size:14px;color:#17212b;">Why this matters now</strong>
587
+ <span style="font-size:13px;line-height:1.55;color:#526273;">One correction should become a permanent pre-action block, not a note the next agent forgets.</span>
588
+ </td>
589
+ </tr>
590
+ </table>
591
+
592
+ <p style="margin:0;font-size:13px;line-height:1.6;color:#526273;">
593
+ Proof trail: <a href="${safeDocsUrl}" style="color:#087a91;">verification evidence</a>.
594
+ Keep this key private. Questions? Reply here or write <a href="mailto:hello@thumbgate.app" style="color:#087a91;">hello@thumbgate.app</a>.
595
+ </p>
596
+ ${sessionId ? `<p style="margin:12px 0 0;font-size:12px;line-height:1.5;color:#7a8790;">Stripe session: ${escapeHtml(sessionId)}</p>` : ''}
597
+ </td>
598
+ </tr>
599
+ </table>
600
+ </td>
601
+ </tr>
602
+ </table>
603
+ </body>
604
+ </html>`,
605
+ };
606
+ }
607
+
608
+ function sendResendEmail(message) {
609
+ return new Promise((resolve, reject) => {
610
+ const body = JSON.stringify(message);
611
+ const req = https.request({
612
+ hostname: 'api.resend.com',
613
+ path: '/emails',
614
+ method: 'POST',
615
+ headers: {
616
+ Authorization: `Bearer ${CONFIG.RESEND_API_KEY}`,
617
+ 'Content-Type': 'application/json',
618
+ 'Content-Length': Buffer.byteLength(body),
619
+ },
620
+ timeout: 10000,
621
+ }, (res) => {
622
+ let responseBody = '';
623
+ res.setEncoding('utf8');
624
+ res.on('data', (chunk) => { responseBody += chunk; });
625
+ res.on('end', () => {
626
+ let parsed = {};
627
+ try {
628
+ parsed = responseBody ? JSON.parse(responseBody) : {};
629
+ } catch {
630
+ parsed = { raw: responseBody };
631
+ }
632
+ if (res.statusCode >= 200 && res.statusCode < 300) {
633
+ resolve({ ok: true, statusCode: res.statusCode, body: parsed });
634
+ return;
635
+ }
636
+ const err = new Error(parsed.message || parsed.error || `Resend API returned HTTP ${res.statusCode}`);
637
+ err.statusCode = res.statusCode;
638
+ err.body = parsed;
639
+ reject(err);
640
+ });
641
+ });
642
+ req.on('timeout', () => req.destroy(new Error('Resend API timeout')));
643
+ req.on('error', reject);
644
+ req.end(body);
645
+ });
646
+ }
647
+
648
+ async function sendTrialActivationEmail(params = {}, options = {}) {
649
+ const customerEmail = normalizeEmail(params.customerEmail);
650
+ const sessionId = normalizeText(params.sessionId);
651
+ const apiKey = normalizeText(params.apiKey);
652
+ const injectedMailer = module.exports && module.exports._mailer;
653
+ const mailerTransport = !options.transport && injectedMailer && typeof injectedMailer.sendTrialWelcomeEmail === 'function'
654
+ ? injectedMailer
655
+ : null;
656
+ const transport = options.transport || sendResendEmail;
657
+ const planId = normalizeText(params.planId);
658
+
659
+ if (!customerEmail) {
660
+ return { status: 'skipped', reason: 'missing_customer_email' };
661
+ }
662
+ if (!apiKey) {
663
+ return { status: 'skipped', reason: 'missing_api_key', customerEmail };
664
+ }
665
+
666
+ const previousSent = findTrialEmailRecord({
667
+ sessionId,
668
+ customerEmail,
669
+ statuses: ['sent'],
670
+ });
671
+ if (previousSent) {
672
+ return {
673
+ status: 'already_sent',
674
+ customerEmail,
675
+ sessionId: previousSent.sessionId || sessionId,
676
+ providerId: previousSent.providerId || null,
677
+ };
678
+ }
679
+
680
+ if (!CONFIG.RESEND_API_KEY && !options.transport && !mailerTransport) {
681
+ const previousSkipped = findTrialEmailRecord({
682
+ sessionId,
683
+ customerEmail,
684
+ statuses: ['skipped'],
685
+ });
686
+ if (!previousSkipped) {
687
+ appendTrialEmailRecord({
688
+ status: 'skipped',
689
+ reason: 'missing_resend_api_key',
690
+ sessionId,
691
+ customerEmail,
692
+ planId,
693
+ source: params.source || 'checkout_session_status',
694
+ });
695
+ }
696
+ return { status: 'skipped', reason: 'missing_resend_api_key', customerEmail, sessionId };
697
+ }
698
+
699
+ try {
700
+ let providerId = null;
701
+ if (mailerTransport) {
702
+ const response = await mailerTransport.sendTrialWelcomeEmail({
703
+ to: customerEmail,
704
+ licenseKey: apiKey,
705
+ customerId: params.customerId,
706
+ customerName: params.customerName,
707
+ trialEndAt: params.trialEndAt,
708
+ });
709
+ if (!response || response.sent !== true) {
710
+ const rawReason = normalizeText(response && response.reason) || 'provider_error';
711
+ // Normalize the mailer module's `no_api_key` to billing.js's legacy
712
+ // `missing_resend_api_key` reason so downstream consumers (dashboards,
713
+ // tests, support tooling) see a stable vocabulary regardless of which
714
+ // transport produced the skip.
715
+ const reason = rawReason === 'no_api_key' ? 'missing_resend_api_key' : rawReason;
716
+ const isSkipped = reason === 'missing_resend_api_key';
717
+ const previousSkipped = isSkipped
718
+ ? findTrialEmailRecord({ sessionId, customerEmail, statuses: ['skipped'] })
719
+ : null;
720
+ if (!isSkipped || !previousSkipped) {
721
+ appendTrialEmailRecord({
722
+ status: isSkipped ? 'skipped' : 'failed',
723
+ reason,
724
+ sessionId,
725
+ customerEmail,
726
+ planId,
727
+ source: params.source || 'checkout_session_status',
728
+ });
729
+ }
730
+ return {
731
+ status: isSkipped ? 'skipped' : 'failed',
732
+ reason,
733
+ customerEmail,
734
+ sessionId,
735
+ };
736
+ }
737
+ providerId = response.id || response.providerId || null;
738
+ } else {
739
+ const message = buildTrialActivationEmail({
740
+ customerEmail,
741
+ apiKey,
742
+ sessionId,
743
+ planId,
744
+ appOrigin: params.appOrigin,
745
+ });
746
+ const response = await transport(message, params);
747
+ providerId = response && response.body ? response.body.id : response && response.id ? response.id : null;
748
+ }
749
+ appendTrialEmailRecord({
750
+ status: 'sent',
751
+ sessionId,
752
+ customerEmail,
753
+ planId,
754
+ providerId,
755
+ source: params.source || 'checkout_session_status',
756
+ });
757
+ return { status: 'sent', customerEmail, sessionId, providerId };
758
+ } catch (err) {
759
+ const reason = mailerTransport ? 'exception' : 'provider_error';
760
+ appendTrialEmailRecord({
761
+ status: 'failed',
762
+ reason,
763
+ error: err && err.message ? err.message : 'Email provider failed',
764
+ sessionId,
765
+ customerEmail,
766
+ planId,
767
+ source: params.source || 'checkout_session_status',
768
+ });
769
+ return {
770
+ status: 'failed',
771
+ reason,
772
+ error: err && err.message ? err.message : 'Email provider failed',
773
+ customerEmail,
774
+ sessionId,
775
+ };
776
+ }
777
+ }
778
+
779
+ function trialEmailToWebhookEmailResult(trialEmail = {}) {
780
+ if (trialEmail.status === 'sent' || trialEmail.status === 'already_sent') {
781
+ return {
782
+ sent: true,
783
+ id: trialEmail.providerId || null,
784
+ providerId: trialEmail.providerId || null,
785
+ };
786
+ }
787
+ return {
788
+ sent: false,
789
+ reason: trialEmail.reason === 'missing_customer_email'
790
+ ? 'no_recipient'
791
+ : trialEmail.reason || trialEmail.status || 'unknown',
792
+ error: trialEmail.error || undefined,
793
+ };
794
+ }
795
+
382
796
  function normalizeCurrency(value) {
383
797
  const text = normalizeText(value);
384
798
  return text ? text.toUpperCase() : null;
@@ -1972,7 +2386,7 @@ function saveKeyStore(store) {
1972
2386
  // Core Exports
1973
2387
  // ---------------------------------------------------------------------------
1974
2388
 
1975
- async function createCheckoutSession({ successUrl, cancelUrl, customerEmail, installId, traceId, packId = null, metadata = {} } = {}) {
2389
+ async function createCheckoutSession({ successUrl, cancelUrl, customerEmail, installId, traceId, packId = null, metadata = {}, appOrigin } = {}) {
1976
2390
  const resolvedTraceId = traceId || metadata.traceId || createTraceId('checkout');
1977
2391
  const baseCheckoutMetadata = sanitizeMetadata({
1978
2392
  ...metadata,
@@ -1995,9 +2409,12 @@ async function createCheckoutSession({ successUrl, cancelUrl, customerEmail, ins
1995
2409
  const localSessionId = `test_session_${crypto.randomBytes(8).toString('hex')}`;
1996
2410
  const store = loadLocalCheckoutSessions();
1997
2411
  const pack = packId ? CONFIG.CREDIT_PACKS[packId] : null;
2412
+ const localCustomerEmail = normalizeEmail(customerEmail);
1998
2413
  store.sessions[localSessionId] = {
1999
2414
  id: localSessionId,
2000
2415
  customer: `local_cus_${crypto.randomBytes(4).toString('hex')}`,
2416
+ customer_email: localCustomerEmail,
2417
+ customer_details: localCustomerEmail ? { email: localCustomerEmail } : null,
2001
2418
  metadata: { ...checkoutMetadata, packId: pack ? pack.id : null, credits: pack ? pack.credits : null },
2002
2419
  payment_status: 'paid',
2003
2420
  status: 'complete'
@@ -2022,8 +2439,19 @@ async function createCheckoutSession({ successUrl, cancelUrl, customerEmail, ins
2022
2439
  customerEmail,
2023
2440
  checkoutMetadata,
2024
2441
  packId,
2442
+ appOrigin,
2025
2443
  });
2026
- const session = await stripe.checkout.sessions.create(sessionPayload);
2444
+ let session;
2445
+ try {
2446
+ session = await stripe.checkout.sessions.create(sessionPayload);
2447
+ } catch (err) {
2448
+ if (!sessionPayload.branding_settings || !String(err && err.message).includes('branding_settings')) {
2449
+ throw err;
2450
+ }
2451
+ const fallbackPayload = { ...sessionPayload };
2452
+ delete fallbackPayload.branding_settings;
2453
+ session = await stripe.checkout.sessions.create(fallbackPayload);
2454
+ }
2027
2455
 
2028
2456
  appendFunnelEvent({
2029
2457
  stage: 'acquisition',
@@ -2036,7 +2464,7 @@ async function createCheckoutSession({ successUrl, cancelUrl, customerEmail, ins
2036
2464
  return { sessionId: session.id, url: session.url, localMode: false, traceId: resolvedTraceId, metadata: checkoutMetadata };
2037
2465
  }
2038
2466
 
2039
- function buildCheckoutSessionPayload({ successUrl, cancelUrl, customerEmail, checkoutMetadata, packId = null } = {}) {
2467
+ function buildCheckoutSessionPayload({ successUrl, cancelUrl, customerEmail, checkoutMetadata, packId = null, appOrigin } = {}) {
2040
2468
  const pack = packId ? CONFIG.CREDIT_PACKS[packId] : null;
2041
2469
  const checkoutSelection = pack ? null : resolveSubscriptionCheckoutSelection(checkoutMetadata);
2042
2470
  if (!pack && !checkoutSelection.priceId) {
@@ -2046,12 +2474,19 @@ function buildCheckoutSessionPayload({ successUrl, cancelUrl, customerEmail, che
2046
2474
  ? [{
2047
2475
  price_data: {
2048
2476
  currency: pack.currency.toLowerCase(),
2049
- product_data: { name: pack.name },
2477
+ product_data: buildCheckoutProductData({
2478
+ name: pack.name,
2479
+ description: 'ThumbGate usage credits for hosted agent governance.',
2480
+ appOrigin,
2481
+ }),
2050
2482
  unit_amount: pack.amountCents,
2051
2483
  },
2052
2484
  quantity: 1,
2053
2485
  }]
2054
- : [{ price: checkoutSelection.priceId, quantity: checkoutSelection.quantity }];
2486
+ : [{
2487
+ price_data: buildSubscriptionPriceData(checkoutSelection, appOrigin),
2488
+ quantity: checkoutSelection.quantity,
2489
+ }];
2055
2490
 
2056
2491
  const sessionPayload = {
2057
2492
  success_url: successUrl,
@@ -2059,6 +2494,7 @@ function buildCheckoutSessionPayload({ successUrl, cancelUrl, customerEmail, che
2059
2494
  payment_method_types: ['card', 'link'],
2060
2495
  mode: pack ? 'payment' : 'subscription',
2061
2496
  line_items: lineItems,
2497
+ branding_settings: buildCheckoutBrandingSettings(appOrigin),
2062
2498
  metadata: serializeStripeMetadata({
2063
2499
  ...checkoutMetadata,
2064
2500
  planId: pack ? checkoutMetadata.planId : checkoutSelection.planId,
@@ -2092,6 +2528,20 @@ async function getCheckoutSessionStatus(sessionId) {
2092
2528
  credits: session.metadata?.credits,
2093
2529
  source: 'local_checkout_lookup'
2094
2530
  });
2531
+ const customerEmail = session.customer_details?.email || session.customer_email || '';
2532
+ const customerName = session.customer_details?.name || null;
2533
+ const trialEndAt = computeTrialEndAt(session);
2534
+ const trialEmail = await sendTrialActivationEmail({
2535
+ sessionId,
2536
+ customerId: session.customer,
2537
+ customerEmail,
2538
+ customerName,
2539
+ trialEndAt,
2540
+ apiKey: provisioned.key,
2541
+ planId: session.metadata?.planId || session.metadata?.packId || null,
2542
+ appOrigin: process.env.THUMBGATE_PUBLIC_APP_ORIGIN,
2543
+ source: 'local_checkout_lookup',
2544
+ });
2095
2545
  return {
2096
2546
  found: true,
2097
2547
  localMode: true,
@@ -2100,6 +2550,7 @@ async function getCheckoutSessionStatus(sessionId) {
2100
2550
  paymentStatus: 'paid',
2101
2551
  status: 'complete',
2102
2552
  customerId: session.customer,
2553
+ customerEmail,
2103
2554
  installId: session.metadata?.installId,
2104
2555
  traceId: session.metadata?.traceId || null,
2105
2556
  acquisitionId: session.metadata?.acquisitionId || null,
@@ -2112,6 +2563,7 @@ async function getCheckoutSessionStatus(sessionId) {
2112
2563
  referrerHost: session.metadata?.referrerHost || null,
2113
2564
  apiKey: provisioned.key,
2114
2565
  remainingCredits: provisioned.remainingCredits,
2566
+ trialEmail,
2115
2567
  };
2116
2568
  }
2117
2569
 
@@ -2126,6 +2578,20 @@ async function getCheckoutSessionStatus(sessionId) {
2126
2578
  const installId = session.metadata?.installId || null;
2127
2579
  const credits = session.metadata?.credits ? parseInt(session.metadata.credits, 10) : null;
2128
2580
  const provisioned = provisionApiKey(session.customer, { installId, credits, source: 'stripe_checkout_session_lookup' });
2581
+ const customerEmail = session.customer_details?.email || session.customer_email || '';
2582
+ const customerName = session.customer_details?.name || null;
2583
+ const trialEndAt = computeTrialEndAt(session);
2584
+ const trialEmail = await sendTrialActivationEmail({
2585
+ sessionId,
2586
+ customerId: session.customer,
2587
+ customerEmail,
2588
+ customerName,
2589
+ trialEndAt,
2590
+ apiKey: provisioned.key,
2591
+ planId: session.metadata?.planId || session.metadata?.packId || null,
2592
+ appOrigin: process.env.THUMBGATE_PUBLIC_APP_ORIGIN,
2593
+ source: 'stripe_checkout_session_lookup',
2594
+ });
2129
2595
 
2130
2596
  return {
2131
2597
  found: true,
@@ -2134,7 +2600,7 @@ async function getCheckoutSessionStatus(sessionId) {
2134
2600
  paid: true,
2135
2601
  paymentStatus: session.payment_status,
2136
2602
  customerId: session.customer,
2137
- customerEmail: session.customer_details?.email || '',
2603
+ customerEmail,
2138
2604
  installId,
2139
2605
  traceId,
2140
2606
  acquisitionId: session.metadata?.acquisitionId || null,
@@ -2147,6 +2613,7 @@ async function getCheckoutSessionStatus(sessionId) {
2147
2613
  referrerHost: session.metadata?.referrerHost || null,
2148
2614
  apiKey: provisioned.key,
2149
2615
  remainingCredits: provisioned.remainingCredits,
2616
+ trialEmail,
2150
2617
  };
2151
2618
  } catch {
2152
2619
  return { found: false };
@@ -2312,6 +2779,9 @@ async function handleWebhook(rawBody, signature) {
2312
2779
  const traceId = session.metadata?.traceId || null;
2313
2780
  const credits = session.metadata?.credits ? parseInt(session.metadata.credits, 10) : null;
2314
2781
  const packId = session.metadata?.packId || null;
2782
+ const customerEmail = session.customer_details?.email || session.customer_email || '';
2783
+ const customerName = session.customer_details?.name || null;
2784
+ const trialEndAt = computeTrialEndAt(session);
2315
2785
 
2316
2786
  const attribution = extractAttribution(session.metadata);
2317
2787
  const result = provisionApiKey(customerId, {
@@ -2319,6 +2789,17 @@ async function handleWebhook(rawBody, signature) {
2319
2789
  credits,
2320
2790
  source: 'stripe_webhook_checkout_completed'
2321
2791
  });
2792
+ const trialEmail = await sendTrialActivationEmail({
2793
+ sessionId: session.id,
2794
+ customerId,
2795
+ customerEmail,
2796
+ customerName,
2797
+ trialEndAt,
2798
+ apiKey: result.key,
2799
+ planId: session.metadata?.planId || packId || null,
2800
+ appOrigin: process.env.THUMBGATE_PUBLIC_APP_ORIGIN,
2801
+ source: 'stripe_webhook_checkout_completed',
2802
+ });
2322
2803
  const funnelRecord = {
2323
2804
  stage: 'paid',
2324
2805
  event: 'stripe_checkout_completed',
@@ -2384,7 +2865,13 @@ async function handleWebhook(rawBody, signature) {
2384
2865
  attribution,
2385
2866
  });
2386
2867
  }
2387
- return { handled: true, action: 'provisioned_api_key', result };
2868
+ return {
2869
+ handled: true,
2870
+ action: 'provisioned_api_key',
2871
+ result,
2872
+ trialEmail,
2873
+ email: trialEmailToWebhookEmailResult(trialEmail),
2874
+ };
2388
2875
  }
2389
2876
  case 'customer.subscription.deleted': {
2390
2877
  const sub = event.data.object;
@@ -2527,11 +3014,19 @@ function handleGithubWebhook(event) {
2527
3014
  module.exports = {
2528
3015
  CONFIG, createCheckoutSession, getCheckoutSessionStatus, provisionApiKey, rotateApiKey, validateApiKey, recordUsage, disableCustomerKeys, handleWebhook, verifyWebhookSignature, verifyGithubWebhookSignature, handleGithubWebhook, loadKeyStore, appendFunnelEvent, appendRevenueEvent, loadFunnelLedger, loadRevenueLedger, loadNewsletterSubscribers, loadResolvedRevenueEvents, getFunnelAnalytics, getBusinessAnalytics, getBillingSummary, getBillingSummaryLive, listStripeReconciledRevenueEvents, repairGithubMarketplaceRevenueLedger,
2529
3016
  _buildCheckoutSessionPayload: buildCheckoutSessionPayload,
3017
+ _buildTrialActivationEmail: buildTrialActivationEmail,
3018
+ _sendTrialActivationEmail: sendTrialActivationEmail,
2530
3019
  _resolveSubscriptionCheckoutSelection: resolveSubscriptionCheckoutSelection,
2531
3020
  _API_KEYS_PATH: () => CONFIG.API_KEYS_PATH,
2532
3021
  _FUNNEL_LEDGER_PATH: () => CONFIG.FUNNEL_LEDGER_PATH,
2533
3022
  _REVENUE_LEDGER_PATH: () => CONFIG.REVENUE_LEDGER_PATH,
2534
3023
  _LOCAL_CHECKOUT_SESSIONS_PATH: () => CONFIG.LOCAL_CHECKOUT_SESSIONS_PATH,
3024
+ _TRIAL_EMAIL_LEDGER_PATH: () => CONFIG.TRIAL_EMAIL_LEDGER_PATH,
2535
3025
  _LOCAL_MODE: () => LOCAL_MODE(),
2536
3026
  _withTimeout: withTimeout,
3027
+ // Default to the real Resend-backed mailer so production webhooks send the
3028
+ // marketing-grade trial-welcome template. Tests overwrite this with a stub
3029
+ // (freshBilling() re-requires the module so the default is restored between
3030
+ // tests — see tests/billing-webhook-email.test.js).
3031
+ _mailer: mailer,
2537
3032
  };
@@ -90,7 +90,7 @@ const PACK_TEMPLATES = {
90
90
  namespaces: ['research', 'memoryLearning', 'rules'],
91
91
  maxItems: 12,
92
92
  maxChars: 10000,
93
- queryPrefix: 'research benchmark experiment reliability',
93
+ queryPrefix: 'research benchmark experiment holdout proof reward hacking reliability',
94
94
  },
95
95
  'gtm-research': {
96
96
  namespaces: ['research', 'memoryLearning'],