thumbgate 1.28.0 → 1.28.2

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 (38) hide show
  1. package/.claude-plugin/plugin.json +2 -1
  2. package/.well-known/mcp/server-card.json +1 -1
  3. package/README.md +96 -122
  4. package/adapters/claude/.mcp.json +2 -2
  5. package/adapters/mcp/server-stdio.js +54 -34
  6. package/adapters/opencode/opencode.json +1 -1
  7. package/bin/cli.js +100 -77
  8. package/config/gates/default.json +2 -2
  9. package/config/github-about.json +2 -5
  10. package/config/mcp-allowlists.json +5 -0
  11. package/config/post-deploy-marketing-pages.json +1 -1
  12. package/hooks/hooks.json +38 -0
  13. package/package.json +19 -8
  14. package/public/chatgpt-app.html +2 -2
  15. package/public/codex-enterprise.html +1 -1
  16. package/public/codex-plugin.html +1 -1
  17. package/public/diagnostic.html +23 -1
  18. package/public/guide.html +1 -1
  19. package/public/index.html +172 -201
  20. package/public/numbers.html +2 -2
  21. package/public/partner-intake.html +198 -0
  22. package/public/pricing.html +51 -14
  23. package/scripts/auto-wire-hooks.js +58 -2
  24. package/scripts/billing.js +815 -137
  25. package/scripts/checkout-attribution-reference.js +85 -0
  26. package/scripts/claude-feedback-sync.js +23 -6
  27. package/scripts/cli-feedback.js +20 -10
  28. package/scripts/feedback-history-distiller.js +385 -0
  29. package/scripts/feedback-loop.js +290 -1
  30. package/scripts/feedback-quality.js +25 -26
  31. package/scripts/feedback-sanitizer.js +151 -3
  32. package/scripts/gates-engine.js +128 -49
  33. package/scripts/hosted-config.js +9 -2
  34. package/scripts/mailer/resend-mailer.js +11 -5
  35. package/scripts/secret-scanner.js +363 -68
  36. package/scripts/self-protection.js +21 -36
  37. package/src/api/server.js +121 -12
  38. package/public/assets/brand/github-social-preview.png +0 -0
@@ -51,7 +51,11 @@ const {
51
51
  } = require('./analytics-window');
52
52
  const { ensureParentDir } = require('./fs-utils');
53
53
  const mailer = require('./mailer');
54
- const { recordCheckoutFunnelEvent } = require('./plausible-server-events');
54
+ const { plausibleEvent, recordCheckoutFunnelEvent } = require('./plausible-server-events');
55
+ const { parseCheckoutReference } = require('./checkout-attribution-reference');
56
+ const { redactSecrets } = require('./secret-redaction');
57
+
58
+ const diagnosticEmailInFlight = new Map();
55
59
 
56
60
  function loadWorkflowSprintIntakeModule() {
57
61
  const modulePath = path.resolve(__dirname, 'workflow-sprint-intake.js');
@@ -91,12 +95,17 @@ const CONFIG = {
91
95
  get TRIAL_EMAIL_LEDGER_PATH() {
92
96
  return process.env._TEST_TRIAL_EMAIL_LEDGER_PATH || process.env.THUMBGATE_TRIAL_EMAIL_LEDGER_PATH || path.join(getFeedbackPaths().FEEDBACK_DIR, 'trial-emails.jsonl');
93
97
  },
98
+ get ORDER_EMAIL_LEDGER_PATH() {
99
+ return process.env._TEST_ORDER_EMAIL_LEDGER_PATH || process.env.THUMBGATE_ORDER_EMAIL_LEDGER_PATH || path.join(getFeedbackPaths().FEEDBACK_DIR, 'order-emails.jsonl');
100
+ },
94
101
  RESEND_API_KEY: process.env.RESEND_API_KEY || process.env.THUMBGATE_RESEND_API_KEY || '',
95
102
  TRIAL_EMAIL_FROM: process.env.THUMBGATE_TRIAL_EMAIL_FROM || process.env.RESEND_FROM_EMAIL || process.env.RESEND_FROM || 'onboarding@resend.dev',
96
103
  TRIAL_EMAIL_REPLY_TO: process.env.THUMBGATE_TRIAL_EMAIL_REPLY_TO || 'igor.ganapolsky@gmail.com',
97
104
  CREDIT_PACKS: {}
98
105
  };
99
106
 
107
+ const DEFAULT_DIAGNOSTIC_PAYMENT_LINK_ID = 'plink_1TsO6lGGBpd520QYsFToXuRC';
108
+
100
109
  function resolveLegacyBillingPath(fileName) {
101
110
  return resolveFallbackArtifactPath(fileName, {
102
111
  feedbackDir: getFeedbackPaths().FEEDBACK_DIR,
@@ -486,8 +495,8 @@ async function verifyActiveProductForPlan(stripe, planId) {
486
495
  // session.create fires with inline product_data. Safe path.
487
496
  return;
488
497
  }
489
- const active = matching.find((p) => p.active === true);
490
- if (!active) {
498
+ const hasActive = matching.some((p) => p.active === true);
499
+ if (!hasActive) {
491
500
  const archived = matching[0];
492
501
  throw new Error(
493
502
  `Refusing to create checkout session: Stripe product named "${expectedName}" ` +
@@ -682,7 +691,8 @@ function buildTrialActivationEmail({ customerEmail, apiKey, sessionId, planId, a
682
691
 
683
692
  function sendResendEmail(message) {
684
693
  return new Promise((resolve, reject) => {
685
- const body = JSON.stringify(message);
694
+ const { idempotencyKey, ...payload } = message;
695
+ const body = JSON.stringify(payload);
686
696
  const req = https.request({
687
697
  hostname: 'api.resend.com',
688
698
  path: '/emails',
@@ -691,6 +701,7 @@ function sendResendEmail(message) {
691
701
  Authorization: `Bearer ${CONFIG.RESEND_API_KEY}`,
692
702
  'Content-Type': 'application/json',
693
703
  'Content-Length': Buffer.byteLength(body),
704
+ ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
694
705
  },
695
706
  timeout: 10000,
696
707
  }, (res) => {
@@ -730,6 +741,7 @@ async function sendTrialActivationEmail(params = {}, options = {}) {
730
741
  : null;
731
742
  const transport = options.transport || sendResendEmail;
732
743
  const planId = normalizeText(params.planId);
744
+ const idempotencyKey = `trial-${crypto.createHash('sha256').update(`${sessionId}:${customerEmail}`).digest('hex')}`;
733
745
 
734
746
  if (!customerEmail) {
735
747
  return { status: 'skipped', reason: 'missing_customer_email' };
@@ -780,6 +792,7 @@ async function sendTrialActivationEmail(params = {}, options = {}) {
780
792
  customerId: params.customerId,
781
793
  customerName: params.customerName,
782
794
  trialEndAt: params.trialEndAt,
795
+ idempotencyKey,
783
796
  });
784
797
  if (!response || response.sent !== true) {
785
798
  const rawReason = normalizeText(response && response.reason) || 'provider_error';
@@ -818,6 +831,7 @@ async function sendTrialActivationEmail(params = {}, options = {}) {
818
831
  planId,
819
832
  appOrigin: params.appOrigin,
820
833
  });
834
+ message.idempotencyKey = idempotencyKey;
821
835
  const response = await transport(message, params);
822
836
  providerId = response && response.body ? response.body.id : response && response.id ? response.id : null;
823
837
  }
@@ -868,6 +882,281 @@ function trialEmailToWebhookEmailResult(trialEmail = {}) {
868
882
  };
869
883
  }
870
884
 
885
+ function isDiagnosticCheckoutSession(session = {}) {
886
+ const configuredIds = String(
887
+ process.env.THUMBGATE_DIAGNOSTIC_PAYMENT_LINK_IDS
888
+ || process.env.THUMBGATE_DIAGNOSTIC_PAYMENT_LINK_ID
889
+ || DEFAULT_DIAGNOSTIC_PAYMENT_LINK_ID
890
+ )
891
+ .split(',')
892
+ .map((value) => value.trim())
893
+ .filter((value) => /^plink_[A-Za-z0-9]+$/.test(value));
894
+ const paymentLinkId = typeof session.payment_link === 'string'
895
+ ? session.payment_link
896
+ : normalizeText(session.payment_link && session.payment_link.id);
897
+ // Metadata, client_reference_id, and amount are not authorization: public
898
+ // checkout input can set metadata, while other SKUs can share a price. The
899
+ // Stripe-created Payment Link identity is the diagnostic SKU boundary.
900
+ return session.mode === 'payment'
901
+ && configuredIds.includes(paymentLinkId);
902
+ }
903
+
904
+ /**
905
+ * Decide whether a completed Stripe session is allowed to mint an API key.
906
+ *
907
+ * A paid session is not automatically a software entitlement. ThumbGate also
908
+ * sells one-time services through Stripe Payment Links, and those orders must
909
+ * be recorded without receiving Pro access. Subscription mode is Stripe-owned
910
+ * state. Credit packs are accepted only when both the pack id and credit count
911
+ * match the server-side catalog written by createCheckoutSession().
912
+ */
913
+ function resolveCheckoutProvisioningGrant(session = {}) {
914
+ if (session.mode === 'subscription') {
915
+ return { allowed: true, kind: 'subscription', credits: null };
916
+ }
917
+ if (session.mode !== 'payment') {
918
+ return { allowed: false, kind: 'unknown', credits: null };
919
+ }
920
+
921
+ const packId = normalizeText(session.metadata && session.metadata.packId);
922
+ const configuredPack = packId && CONFIG.CREDIT_PACKS[packId];
923
+ const credits = normalizeInteger(session.metadata && session.metadata.credits);
924
+ if (configuredPack && credits === configuredPack.credits) {
925
+ return { allowed: true, kind: 'credit_pack', credits };
926
+ }
927
+ return { allowed: false, kind: 'service_order', credits: null };
928
+ }
929
+
930
+ function resolveStripeCustomerLedgerId(session = {}, customerEmail = '') {
931
+ const stripeCustomerId = normalizeText(session.customer);
932
+ if (stripeCustomerId) return stripeCustomerId;
933
+ const normalizedEmail = normalizeEmail(customerEmail);
934
+ const seed = normalizedEmail || normalizeText(session.id) || crypto.randomBytes(16).toString('hex');
935
+ return `guest_${crypto.createHash('sha256').update(seed).digest('hex').slice(0, 24)}`;
936
+ }
937
+
938
+ function resolveOperatorAlertEmail(env = process.env) {
939
+ return normalizeEmail(
940
+ env.THUMBGATE_OPERATOR_ALERT_EMAIL
941
+ || env.THUMBGATE_PRO_ACTIVATION_ALERT_EMAIL
942
+ || env.THUMBGATE_SUPPORT_EMAIL
943
+ || 'igor.ganapolsky@gmail.com'
944
+ );
945
+ }
946
+
947
+ function findOrderEmailRecord({ sessionId, kind } = {}) {
948
+ const normalizedSessionId = normalizeText(sessionId);
949
+ const normalizedKind = normalizeText(kind);
950
+ if (!normalizedSessionId || !normalizedKind) return null;
951
+ return loadJsonlRecords(CONFIG.ORDER_EMAIL_LEDGER_PATH).find((row) => (
952
+ row
953
+ && row.sessionId === normalizedSessionId
954
+ && row.kind === normalizedKind
955
+ && row.status === 'sent'
956
+ )) || null;
957
+ }
958
+
959
+ function appendOrderEmailRecord(payload = {}) {
960
+ return appendJsonlRecord(CONFIG.ORDER_EMAIL_LEDGER_PATH, {
961
+ timestamp: new Date().toISOString(),
962
+ ...payload,
963
+ });
964
+ }
965
+
966
+ function getOrderOperationStatePaths(sessionId, operation) {
967
+ const key = crypto.createHash('sha256').update(String(sessionId || '')).digest('hex');
968
+ const safeOperation = String(operation || 'checkout').replace(/[^a-z0-9_-]/gi, '-').toLowerCase();
969
+ const stateDir = path.join(path.dirname(CONFIG.ORDER_EMAIL_LEDGER_PATH), `${safeOperation}-order-state`);
970
+ return {
971
+ stateDir,
972
+ lockPath: path.join(stateDir, `${key}.lock`),
973
+ receiptPath: path.join(stateDir, `${key}.json`),
974
+ };
975
+ }
976
+
977
+ function readOrderOperationReceipt(sessionId, operation) {
978
+ const { receiptPath } = getOrderOperationStatePaths(sessionId, operation);
979
+ try {
980
+ return JSON.parse(fs.readFileSync(receiptPath, 'utf8'));
981
+ } catch {
982
+ return null;
983
+ }
984
+ }
985
+
986
+ function claimOrderOperation(sessionId, operation, now = Date.now()) {
987
+ const previous = readOrderOperationReceipt(sessionId, operation);
988
+ if (previous) return { claimed: false, completed: true, receipt: previous };
989
+ const paths = getOrderOperationStatePaths(sessionId, operation);
990
+ fs.mkdirSync(paths.stateDir, { recursive: true });
991
+ try {
992
+ fs.mkdirSync(paths.lockPath);
993
+ fs.writeFileSync(path.join(paths.lockPath, 'owner.json'), JSON.stringify({ acquiredAt: now }));
994
+ return { claimed: true, completed: false, ...paths };
995
+ } catch (error) {
996
+ if (!error || error.code !== 'EEXIST') throw error;
997
+ try {
998
+ const ageMs = now - fs.statSync(paths.lockPath).mtimeMs;
999
+ if (ageMs > 5 * 60 * 1000) {
1000
+ fs.rmSync(paths.lockPath, { recursive: true, force: true });
1001
+ return claimOrderOperation(sessionId, operation, now);
1002
+ }
1003
+ } catch {
1004
+ return claimOrderOperation(sessionId, operation, now);
1005
+ }
1006
+ return { claimed: false, completed: false, ...paths };
1007
+ }
1008
+ }
1009
+
1010
+ function releaseOrderOperation(claim) {
1011
+ if (claim && claim.claimed && claim.lockPath) {
1012
+ fs.rmSync(claim.lockPath, { recursive: true, force: true });
1013
+ }
1014
+ }
1015
+
1016
+ function completeOrderOperation(claim, payload = {}) {
1017
+ if (!claim || !claim.claimed) return;
1018
+ const tmpPath = `${claim.receiptPath}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
1019
+ fs.writeFileSync(tmpPath, JSON.stringify({
1020
+ completedAt: new Date().toISOString(),
1021
+ ...payload,
1022
+ }));
1023
+ fs.renameSync(tmpPath, claim.receiptPath);
1024
+ releaseOrderOperation(claim);
1025
+ claim.claimed = false;
1026
+ }
1027
+
1028
+ async function sendDiagnosticOrderEmails({
1029
+ session = {},
1030
+ customerEmail,
1031
+ customerName,
1032
+ attribution = {},
1033
+ env = process.env,
1034
+ } = {}) {
1035
+ const injectedMailer = module.exports && module.exports._mailer;
1036
+ const sendEmail = injectedMailer && typeof injectedMailer.sendEmail === 'function'
1037
+ ? injectedMailer.sendEmail
1038
+ : null;
1039
+ const sessionId = normalizeText(session.id);
1040
+ const operatorEmail = resolveOperatorAlertEmail(env);
1041
+ const normalizedCustomerEmail = normalizeEmail(customerEmail);
1042
+ const safeCustomerName = redactSecrets(normalizeText(customerName) || '');
1043
+ const amount = Number(session.amount_total);
1044
+ const amountLabel = Number.isFinite(amount) ? `$${(amount / 100).toFixed(2)}` : 'unknown';
1045
+
1046
+ async function sendOnce({ kind, to, subject, text }) {
1047
+ if (!to) return { sent: false, reason: 'missing_recipient', kind };
1048
+ const previous = findOrderEmailRecord({ sessionId, kind });
1049
+ if (previous) return { sent: true, status: 'already_sent', providerId: previous.providerId || null };
1050
+ if (!sendEmail) return { sent: false, reason: 'missing_mailer' };
1051
+ const operationKey = `${sessionId}:${kind}`;
1052
+ if (diagnosticEmailInFlight.has(operationKey)) {
1053
+ return diagnosticEmailInFlight.get(operationKey);
1054
+ }
1055
+ const operation = (async () => {
1056
+ try {
1057
+ const idempotencyKey = `diagnostic-${crypto.createHash('sha256').update(operationKey).digest('hex')}`;
1058
+ const response = await sendEmail({ to, subject, text, idempotencyKey });
1059
+ const sent = response && response.sent === true;
1060
+ appendOrderEmailRecord({
1061
+ sessionId,
1062
+ kind,
1063
+ status: sent ? 'sent' : 'skipped',
1064
+ reason: sent ? null : (response && response.reason) || 'provider_error',
1065
+ providerId: response && (response.id || response.providerId) || null,
1066
+ idempotencyKeyHash: crypto.createHash('sha256').update(idempotencyKey).digest('hex').slice(0, 16),
1067
+ });
1068
+ return response || { sent: false, reason: 'provider_error' };
1069
+ } catch (error) {
1070
+ appendOrderEmailRecord({
1071
+ sessionId,
1072
+ kind,
1073
+ status: 'failed',
1074
+ reason: 'exception',
1075
+ error: error && error.message ? error.message : String(error),
1076
+ });
1077
+ return {
1078
+ sent: false,
1079
+ reason: 'exception',
1080
+ error: error && error.message ? error.message : String(error),
1081
+ };
1082
+ } finally {
1083
+ diagnosticEmailInFlight.delete(operationKey);
1084
+ }
1085
+ })();
1086
+ diagnosticEmailInFlight.set(operationKey, operation);
1087
+ return operation;
1088
+ }
1089
+
1090
+ const buyerText = [
1091
+ safeCustomerName ? `Hi ${safeCustomerName},` : 'Hi there,',
1092
+ '',
1093
+ `We received your ${amountLabel} ThumbGate Workflow Hardening Diagnostic order.`,
1094
+ 'The diagnostic covers one AI-agent workflow and is delivered within two business days.',
1095
+ 'Reply with the repository or product URL plus the repeated failure trace if you did not submit them before checkout.',
1096
+ '',
1097
+ `Order reference: ${sessionId || 'unknown'}`,
1098
+ 'Questions: reply to this email.',
1099
+ ].join('\n');
1100
+ const operatorText = [
1101
+ 'Paid ThumbGate diagnostic received.',
1102
+ '',
1103
+ `Amount: ${amountLabel} ${(session.currency || 'usd').toUpperCase()}`,
1104
+ `Stripe session: ${sessionId || 'unknown'}`,
1105
+ `Customer: ${safeCustomerName || 'not provided'}`,
1106
+ `Email: ${normalizedCustomerEmail || 'not provided'}`,
1107
+ `Source: ${redactSecrets(normalizeText(attribution.source) || 'unknown')}`,
1108
+ `Campaign: ${redactSecrets(normalizeText(attribution.campaign) || 'unknown')}`,
1109
+ `Client reference: ${redactSecrets(normalizeText(session.client_reference_id) || 'not provided')}`,
1110
+ '',
1111
+ 'Start the two-business-day fulfillment clock and match the buyer to any captured workflow intake.',
1112
+ ].join('\n');
1113
+
1114
+ const [buyer, operator] = await Promise.all([
1115
+ sendOnce({
1116
+ kind: 'diagnostic_buyer_receipt',
1117
+ to: normalizedCustomerEmail,
1118
+ subject: 'ThumbGate diagnostic order received',
1119
+ text: buyerText,
1120
+ }),
1121
+ sendOnce({
1122
+ kind: 'diagnostic_operator_alert',
1123
+ to: operatorEmail,
1124
+ subject: `Paid ThumbGate diagnostic: ${amountLabel}`,
1125
+ text: operatorText,
1126
+ }),
1127
+ ]);
1128
+
1129
+ return { buyer, operator };
1130
+ }
1131
+
1132
+ function isRetryableDiagnosticDelivery(result = {}) {
1133
+ if (result && result.sent === true) return false;
1134
+ const reason = normalizeText(result && result.reason);
1135
+ if (reason === 'api_error') {
1136
+ const status = Number(result && result.status);
1137
+ if (!Number.isFinite(status)) return true;
1138
+ if (status !== 400 && status !== 422) return true;
1139
+ const bodyText = typeof result.body === 'string'
1140
+ ? result.body
1141
+ : JSON.stringify(result.body || {});
1142
+ const normalizedBody = bodyText.toLowerCase();
1143
+ const recipientOnlyFailure = (
1144
+ normalizedBody.includes('invalid_to_address')
1145
+ || normalizedBody.includes('invalid recipient')
1146
+ || normalizedBody.includes('recipient address is invalid')
1147
+ ) && !(
1148
+ normalizedBody.includes('invalid_from_address')
1149
+ || normalizedBody.includes('sender')
1150
+ || normalizedBody.includes('domain')
1151
+ );
1152
+ return !recipientOnlyFailure;
1153
+ }
1154
+ if (reason === 'missing_recipient') {
1155
+ return result.kind === 'diagnostic_operator_alert';
1156
+ }
1157
+ return ['no_api_key', 'no_fetch', 'exception', 'provider_error', 'missing_mailer'].includes(reason);
1158
+ }
1159
+
871
1160
  function normalizeCurrency(value) {
872
1161
  const text = normalizeText(value);
873
1162
  return text ? text.toUpperCase() : null;
@@ -2518,16 +2807,72 @@ function loadKeyStore() {
2518
2807
  const primary = CONFIG.API_KEYS_PATH;
2519
2808
  const legacy = resolveLegacyBillingPath('api-keys.json');
2520
2809
  const target = (IS_TEST || fs.existsSync(primary)) ? primary : legacy;
2521
- if (!fs.existsSync(target)) return { keys: {} };
2810
+ if (!fs.existsSync(target)) return { keys: {}, canceledSubscriptions: {}, customerRevocations: {} };
2522
2811
  const parsed = JSON.parse(fs.readFileSync(target, 'utf-8'));
2523
- return (parsed && typeof parsed.keys === 'object') ? parsed : { keys: {} };
2524
- } catch { return { keys: {} }; }
2812
+ if (!parsed || typeof parsed.keys !== 'object') {
2813
+ return { keys: {}, canceledSubscriptions: {}, customerRevocations: {} };
2814
+ }
2815
+ parsed.canceledSubscriptions = parsed.canceledSubscriptions && typeof parsed.canceledSubscriptions === 'object'
2816
+ ? parsed.canceledSubscriptions
2817
+ : {};
2818
+ parsed.customerRevocations = parsed.customerRevocations && typeof parsed.customerRevocations === 'object'
2819
+ ? parsed.customerRevocations
2820
+ : {};
2821
+ return parsed;
2822
+ } catch { return { keys: {}, canceledSubscriptions: {}, customerRevocations: {} }; }
2525
2823
  }
2526
2824
 
2527
2825
  function saveKeyStore(store) {
2528
2826
  const target = CONFIG.API_KEYS_PATH;
2529
2827
  ensureParentDir(target);
2530
- fs.writeFileSync(target, JSON.stringify(store, null, 2), 'utf-8');
2828
+ const tmpPath = `${target}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
2829
+ fs.writeFileSync(tmpPath, JSON.stringify(store, null, 2), 'utf-8');
2830
+ fs.renameSync(tmpPath, target);
2831
+ }
2832
+
2833
+ function sleepSync(milliseconds) {
2834
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
2835
+ }
2836
+
2837
+ function acquireDirectoryLock(lockPath, options = {}) {
2838
+ const timeoutMs = normalizeInteger(options.timeoutMs) || 10000;
2839
+ const staleMs = normalizeInteger(options.staleMs) || 30000;
2840
+ const startedAt = Date.now();
2841
+ ensureParentDir(lockPath);
2842
+ while (true) {
2843
+ try {
2844
+ fs.mkdirSync(lockPath);
2845
+ fs.writeFileSync(path.join(lockPath, 'owner.json'), JSON.stringify({
2846
+ acquiredAt: Date.now(),
2847
+ pid: process.pid,
2848
+ }));
2849
+ return;
2850
+ } catch (error) {
2851
+ if (!error || error.code !== 'EEXIST') throw error;
2852
+ try {
2853
+ if (Date.now() - fs.statSync(lockPath).mtimeMs > staleMs) {
2854
+ fs.rmSync(lockPath, { recursive: true, force: true });
2855
+ continue;
2856
+ }
2857
+ } catch {
2858
+ continue;
2859
+ }
2860
+ if (Date.now() - startedAt >= timeoutMs) {
2861
+ throw new Error(`Timed out waiting for billing state lock: ${path.basename(lockPath)}`);
2862
+ }
2863
+ sleepSync(20);
2864
+ }
2865
+ }
2866
+ }
2867
+
2868
+ function withKeyStoreLock(mutator) {
2869
+ const lockPath = `${CONFIG.API_KEYS_PATH}.lock`;
2870
+ acquireDirectoryLock(lockPath);
2871
+ try {
2872
+ return mutator();
2873
+ } finally {
2874
+ fs.rmSync(lockPath, { recursive: true, force: true });
2875
+ }
2531
2876
  }
2532
2877
 
2533
2878
  // ---------------------------------------------------------------------------
@@ -2564,6 +2909,7 @@ async function createCheckoutSession({ successUrl, cancelUrl, customerEmail, ins
2564
2909
  customer_email: localCustomerEmail,
2565
2910
  customer_details: localCustomerEmail ? { email: localCustomerEmail } : null,
2566
2911
  metadata: { ...checkoutMetadata, packId: pack ? pack.id : null, credits: pack ? pack.credits : null },
2912
+ mode: pack ? 'payment' : 'subscription',
2567
2913
  payment_status: 'paid',
2568
2914
  status: 'complete'
2569
2915
  };
@@ -2687,6 +3033,7 @@ function buildCheckoutSessionPayload({ successUrl, cancelUrl, customerEmail, che
2687
3033
  payment_method_collection: 'always',
2688
3034
  ...(shouldStartTrial ? { subscription_data: { trial_period_days: trialPeriodDays } } : {}),
2689
3035
  }),
3036
+ ...(pack ? { customer_creation: 'always' } : {}),
2690
3037
  };
2691
3038
 
2692
3039
  const normalizedCustomerEmail = normalizeText(customerEmail);
@@ -2701,25 +3048,43 @@ async function getCheckoutSessionStatus(sessionId) {
2701
3048
  const store = loadLocalCheckoutSessions();
2702
3049
  const session = store.sessions[sessionId];
2703
3050
  if (!session) return { found: false };
2704
- const provisioned = provisionApiKey(session.customer, {
2705
- installId: session.metadata?.installId,
2706
- credits: session.metadata?.credits,
2707
- source: 'local_checkout_lookup'
2708
- });
3051
+ const diagnosticOrder = isDiagnosticCheckoutSession(session);
3052
+ const provisioningGrant = resolveCheckoutProvisioningGrant(session);
3053
+ const provisioned = provisioningGrant.allowed
3054
+ ? provisionApiKey(session.customer, {
3055
+ installId: session.metadata?.installId,
3056
+ credits: provisioningGrant.credits,
3057
+ orderId: session.id,
3058
+ subscriptionId: session.subscription,
3059
+ orderCreatedAt: session.created,
3060
+ enforceCancellationHistory: provisioningGrant.kind === 'subscription',
3061
+ entitlementKind: provisioningGrant.kind,
3062
+ source: 'local_checkout_lookup'
3063
+ })
3064
+ : null;
2709
3065
  const customerEmail = session.customer_details?.email || session.customer_email || '';
2710
3066
  const customerName = session.customer_details?.name || null;
2711
3067
  const trialEndAt = computeTrialEndAt(session);
2712
- const trialEmail = await sendTrialActivationEmail({
2713
- sessionId,
2714
- customerId: session.customer,
2715
- customerEmail,
2716
- customerName,
2717
- trialEndAt,
2718
- apiKey: provisioned.key,
2719
- planId: session.metadata?.planId || session.metadata?.packId || null,
2720
- appOrigin: process.env.THUMBGATE_PUBLIC_APP_ORIGIN,
2721
- source: 'local_checkout_lookup',
2722
- });
3068
+ const trialEmail = !provisioned || !provisioned.key
3069
+ ? {
3070
+ status: 'skipped',
3071
+ reason: diagnosticOrder
3072
+ ? 'diagnostic_order'
3073
+ : provisioned && provisioned.entitlementActive === false
3074
+ ? 'entitlement_inactive'
3075
+ : 'non_entitlement_order',
3076
+ }
3077
+ : await sendTrialActivationEmail({
3078
+ sessionId,
3079
+ customerId: session.customer,
3080
+ customerEmail,
3081
+ customerName,
3082
+ trialEndAt,
3083
+ apiKey: provisioned.key,
3084
+ planId: session.metadata?.planId || session.metadata?.packId || null,
3085
+ appOrigin: process.env.THUMBGATE_PUBLIC_APP_ORIGIN,
3086
+ source: 'local_checkout_lookup',
3087
+ });
2723
3088
  return {
2724
3089
  found: true,
2725
3090
  localMode: true,
@@ -2737,10 +3102,11 @@ async function getCheckoutSessionStatus(sessionId) {
2737
3102
  ctaId: session.metadata?.ctaId || null,
2738
3103
  ctaPlacement: session.metadata?.ctaPlacement || null,
2739
3104
  planId: session.metadata?.planId || session.metadata?.packId || null,
3105
+ offerKind: diagnosticOrder ? 'workflow_hardening_diagnostic' : null,
2740
3106
  landingPath: session.metadata?.landingPath || null,
2741
3107
  referrerHost: session.metadata?.referrerHost || null,
2742
- apiKey: provisioned.key,
2743
- remainingCredits: provisioned.remainingCredits,
3108
+ apiKey: provisioned ? provisioned.key : null,
3109
+ remainingCredits: provisioned ? provisioned.remainingCredits : null,
2744
3110
  trialEmail,
2745
3111
  };
2746
3112
  }
@@ -2748,28 +3114,53 @@ async function getCheckoutSessionStatus(sessionId) {
2748
3114
  try {
2749
3115
  const stripe = getStripeClient();
2750
3116
  const session = await stripe.checkout.sessions.retrieve(sessionId);
2751
- const isPaid = session.payment_status === 'paid' || session.payment_status === 'no_payment_required';
3117
+ const diagnosticOrder = isDiagnosticCheckoutSession(session);
3118
+ const provisioningGrant = resolveCheckoutProvisioningGrant(session);
3119
+ const isPaid = session.payment_status === 'paid'
3120
+ || (!diagnosticOrder
3121
+ && session.mode === 'subscription'
3122
+ && session.payment_status === 'no_payment_required');
2752
3123
  const traceId = session.metadata?.traceId || null;
2753
3124
 
2754
3125
  if (!isPaid) return { found: true, localMode: false, sessionId, paid: false, paymentStatus: session.payment_status, status: session.status };
2755
3126
 
2756
3127
  const installId = session.metadata?.installId || null;
2757
- const credits = session.metadata?.credits ? parseInt(session.metadata.credits, 10) : null;
2758
- const provisioned = provisionApiKey(session.customer, { installId, credits, source: 'stripe_checkout_session_lookup' });
2759
3128
  const customerEmail = session.customer_details?.email || session.customer_email || '';
3129
+ const customerId = resolveStripeCustomerLedgerId(session, customerEmail);
3130
+ const provisioned = provisioningGrant.allowed
3131
+ ? provisionApiKey(customerId, {
3132
+ installId,
3133
+ credits: provisioningGrant.credits,
3134
+ orderId: session.id,
3135
+ subscriptionId: session.subscription,
3136
+ orderCreatedAt: session.created,
3137
+ enforceCancellationHistory: provisioningGrant.kind === 'subscription',
3138
+ entitlementKind: provisioningGrant.kind,
3139
+ source: 'stripe_checkout_session_lookup',
3140
+ })
3141
+ : null;
2760
3142
  const customerName = session.customer_details?.name || null;
2761
3143
  const trialEndAt = computeTrialEndAt(session);
2762
- const trialEmail = await sendTrialActivationEmail({
2763
- sessionId,
2764
- customerId: session.customer,
2765
- customerEmail,
2766
- customerName,
2767
- trialEndAt,
2768
- apiKey: provisioned.key,
2769
- planId: session.metadata?.planId || session.metadata?.packId || null,
2770
- appOrigin: process.env.THUMBGATE_PUBLIC_APP_ORIGIN,
2771
- source: 'stripe_checkout_session_lookup',
2772
- });
3144
+ const trialEmail = !provisioned || !provisioned.key
3145
+ ? {
3146
+ status: 'skipped',
3147
+ reason: diagnosticOrder
3148
+ ? 'diagnostic_order'
3149
+ : provisioned && provisioned.entitlementActive === false
3150
+ ? 'entitlement_inactive'
3151
+ : 'non_entitlement_order',
3152
+ }
3153
+ : await sendTrialActivationEmail({
3154
+ sessionId,
3155
+ customerId,
3156
+ customerEmail,
3157
+ customerName,
3158
+ trialEndAt,
3159
+ apiKey: provisioned.key,
3160
+ planId: session.metadata?.planId || session.metadata?.packId || null,
3161
+ appOrigin: process.env.THUMBGATE_PUBLIC_APP_ORIGIN,
3162
+ source: 'stripe_checkout_session_lookup',
3163
+ });
2773
3164
 
2774
3165
  return {
2775
3166
  found: true,
@@ -2777,7 +3168,7 @@ async function getCheckoutSessionStatus(sessionId) {
2777
3168
  sessionId,
2778
3169
  paid: true,
2779
3170
  paymentStatus: session.payment_status,
2780
- customerId: session.customer,
3171
+ customerId,
2781
3172
  customerEmail,
2782
3173
  installId,
2783
3174
  traceId,
@@ -2787,10 +3178,11 @@ async function getCheckoutSessionStatus(sessionId) {
2787
3178
  ctaId: session.metadata?.ctaId || null,
2788
3179
  ctaPlacement: session.metadata?.ctaPlacement || null,
2789
3180
  planId: session.metadata?.planId || session.metadata?.packId || null,
3181
+ offerKind: diagnosticOrder ? 'workflow_hardening_diagnostic' : null,
2790
3182
  landingPath: session.metadata?.landingPath || null,
2791
3183
  referrerHost: session.metadata?.referrerHost || null,
2792
- apiKey: provisioned.key,
2793
- remainingCredits: provisioned.remainingCredits,
3184
+ apiKey: provisioned ? provisioned.key : null,
3185
+ remainingCredits: provisioned ? provisioned.remainingCredits : null,
2794
3186
  trialEmail,
2795
3187
  };
2796
3188
  } catch {
@@ -2800,58 +3192,150 @@ async function getCheckoutSessionStatus(sessionId) {
2800
3192
 
2801
3193
  function provisionApiKey(customerId, opts = {}) {
2802
3194
  if (!customerId || typeof customerId !== 'string') throw new Error('customerId is required');
2803
- const store = loadKeyStore();
2804
- const existing = Object.entries(store.keys).find(([, m]) => m.customerId === customerId && m.active);
3195
+ return withKeyStoreLock(() => {
3196
+ const store = loadKeyStore();
3197
+ const orderId = normalizeText(opts.orderId);
3198
+ const subscriptionId = normalizeText(opts.subscriptionId);
3199
+ const orderCreatedAt = normalizeInteger(opts.orderCreatedAt);
3200
+ const enforceCancellationHistory = opts.enforceCancellationHistory === true;
3201
+
3202
+ if (orderId) {
3203
+ const fulfilled = Object.entries(store.keys)
3204
+ .filter(([, metadata]) => (
3205
+ Array.isArray(metadata.fulfilledOrderIds)
3206
+ && metadata.fulfilledOrderIds.includes(orderId)
3207
+ ))
3208
+ .sort((left, right) => Number(Boolean(right[1].active)) - Number(Boolean(left[1].active)))[0];
3209
+ if (fulfilled) {
3210
+ const [key, metadata] = fulfilled;
3211
+ return {
3212
+ key: metadata.active ? key : null,
3213
+ customerId,
3214
+ createdAt: metadata.createdAt,
3215
+ installId: metadata.installId || null,
3216
+ reused: true,
3217
+ orderAlreadyFulfilled: true,
3218
+ entitlementActive: Boolean(metadata.active),
3219
+ remainingCredits: metadata.remainingCredits,
3220
+ };
3221
+ }
3222
+ }
2805
3223
 
2806
- const creditsToAdd = normalizeInteger(opts.credits);
3224
+ const customerKeys = Object.entries(store.keys)
3225
+ .filter(([, metadata]) => metadata.customerId === customerId);
3226
+ const activeCustomerKey = customerKeys.find(([, metadata]) => metadata.active);
3227
+ const latestDisabledKey = customerKeys
3228
+ .filter(([, metadata]) => !metadata.active && metadata.disabledAt)
3229
+ .sort((left, right) => Date.parse(right[1].disabledAt) - Date.parse(left[1].disabledAt))[0];
3230
+ const canceledSubscription = subscriptionId && store.canceledSubscriptions[subscriptionId];
3231
+ const customerRevocation = store.customerRevocations[customerId];
3232
+ const revocationCreatedAt = normalizeInteger(customerRevocation && customerRevocation.eventCreatedAt);
3233
+ const isNewSubscriptionAfterRevocation = Boolean(
3234
+ customerRevocation
3235
+ && subscriptionId
3236
+ && subscriptionId !== customerRevocation.subscriptionId
3237
+ && orderCreatedAt !== null
3238
+ && revocationCreatedAt !== null
3239
+ && orderCreatedAt > revocationCreatedAt
3240
+ );
3241
+ const legacyDisabledAt = latestDisabledKey
3242
+ ? Math.floor(Date.parse(latestDisabledKey[1].disabledAt) / 1000)
3243
+ : null;
3244
+ const blockedByLegacyCancellation = enforceCancellationHistory
3245
+ && !activeCustomerKey
3246
+ && latestDisabledKey
3247
+ && (orderCreatedAt === null || !Number.isFinite(legacyDisabledAt) || legacyDisabledAt >= orderCreatedAt);
3248
+ const blockedByCustomerRevocation = customerRevocation && !isNewSubscriptionAfterRevocation
3249
+ && (
3250
+ !subscriptionId
3251
+ || subscriptionId === customerRevocation.subscriptionId
3252
+ || orderCreatedAt === null
3253
+ || revocationCreatedAt === null
3254
+ || revocationCreatedAt >= orderCreatedAt
3255
+ );
2807
3256
 
2808
- if (existing) {
2809
- const key = existing[0];
2810
- const meta = existing[1];
2811
- if (opts.installId && !meta.installId) { meta.installId = opts.installId; }
2812
- if (creditsToAdd !== null) {
2813
- meta.remainingCredits = (meta.remainingCredits || 0) + creditsToAdd;
3257
+ if (canceledSubscription || blockedByCustomerRevocation || blockedByLegacyCancellation) {
3258
+ return {
3259
+ key: null,
3260
+ customerId,
3261
+ reused: true,
3262
+ entitlementActive: false,
3263
+ remainingCredits: null,
3264
+ reason: canceledSubscription ? 'subscription_cancelled' : 'customer_entitlement_revoked',
3265
+ };
3266
+ }
3267
+ if (isNewSubscriptionAfterRevocation) {
3268
+ delete store.customerRevocations[customerId];
2814
3269
  }
2815
- saveKeyStore(store);
2816
- return { key, customerId, createdAt: meta.createdAt, installId: meta.installId || null, reused: true, remainingCredits: meta.remainingCredits };
2817
- }
2818
3270
 
2819
- const key = `tg_${crypto.randomBytes(16).toString('hex')}`;
2820
- const createdAt = new Date().toISOString();
2821
- store.keys[key] = {
2822
- customerId,
2823
- active: true,
2824
- usageCount: 0,
2825
- createdAt,
2826
- installId: opts.installId || null,
2827
- source: opts.source || 'provision',
2828
- remainingCredits: creditsToAdd // null means unlimited (standard subscription)
2829
- };
2830
- saveKeyStore(store);
2831
- return { key, customerId, createdAt, installId: opts.installId || null, remainingCredits: creditsToAdd };
3271
+ const creditsToAdd = normalizeInteger(opts.credits);
3272
+ if (activeCustomerKey) {
3273
+ const key = activeCustomerKey[0];
3274
+ const meta = activeCustomerKey[1];
3275
+ if (opts.installId && !meta.installId) meta.installId = opts.installId;
3276
+ if (opts.entitlementKind === 'subscription') {
3277
+ meta.remainingCredits = null;
3278
+ meta.entitlementKind = 'subscription';
3279
+ if (subscriptionId) meta.subscriptionId = subscriptionId;
3280
+ } else if (creditsToAdd !== null && meta.remainingCredits !== null) {
3281
+ meta.remainingCredits = (meta.remainingCredits || 0) + creditsToAdd;
3282
+ }
3283
+ if (orderId) {
3284
+ meta.fulfilledOrderIds = Array.isArray(meta.fulfilledOrderIds)
3285
+ ? [...new Set([...meta.fulfilledOrderIds, orderId])]
3286
+ : [orderId];
3287
+ }
3288
+ saveKeyStore(store);
3289
+ return { key, customerId, createdAt: meta.createdAt, installId: meta.installId || null, reused: true, remainingCredits: meta.remainingCredits };
3290
+ }
3291
+
3292
+ const key = `tg_${crypto.randomBytes(16).toString('hex')}`;
3293
+ const createdAt = new Date().toISOString();
3294
+ store.keys[key] = {
3295
+ customerId,
3296
+ active: true,
3297
+ usageCount: 0,
3298
+ createdAt,
3299
+ installId: opts.installId || null,
3300
+ source: opts.source || 'provision',
3301
+ subscriptionId: subscriptionId || null,
3302
+ entitlementKind: opts.entitlementKind || null,
3303
+ remainingCredits: creditsToAdd, // null means unlimited (standard subscription)
3304
+ fulfilledOrderIds: orderId ? [orderId] : [],
3305
+ };
3306
+ saveKeyStore(store);
3307
+ return { key, customerId, createdAt, installId: opts.installId || null, remainingCredits: creditsToAdd };
3308
+ });
2832
3309
  }
2833
3310
 
2834
3311
  function rotateApiKey(oldKey) {
2835
3312
  if (!oldKey) return { rotated: false, reason: 'missing_old_key' };
2836
- const store = loadKeyStore();
2837
- const meta = store.keys[oldKey];
2838
- if (!meta || !meta.active) return { rotated: false, reason: 'key_not_active' };
2839
-
2840
- meta.active = false;
2841
- meta.disabledAt = new Date().toISOString();
2842
- const newKey = `tg_${crypto.randomBytes(16).toString('hex')}`;
2843
- store.keys[newKey] = {
2844
- customerId: meta.customerId,
2845
- active: true,
2846
- usageCount: 0,
2847
- createdAt: new Date().toISOString(),
2848
- installId: meta.installId,
2849
- source: 'rotation',
2850
- replacedKey: oldKey,
2851
- remainingCredits: meta.remainingCredits
2852
- };
2853
- saveKeyStore(store);
2854
- return { rotated: true, key: newKey, oldKey };
3313
+ return withKeyStoreLock(() => {
3314
+ const store = loadKeyStore();
3315
+ const meta = store.keys[oldKey];
3316
+ if (!meta || !meta.active) return { rotated: false, reason: 'key_not_active' };
3317
+
3318
+ meta.active = false;
3319
+ meta.disabledAt = new Date().toISOString();
3320
+ const fulfilledOrderIds = Array.isArray(meta.fulfilledOrderIds) ? [...meta.fulfilledOrderIds] : [];
3321
+ meta.fulfilledOrderIds = [];
3322
+ const newKey = `tg_${crypto.randomBytes(16).toString('hex')}`;
3323
+ store.keys[newKey] = {
3324
+ customerId: meta.customerId,
3325
+ active: true,
3326
+ usageCount: 0,
3327
+ createdAt: new Date().toISOString(),
3328
+ installId: meta.installId,
3329
+ source: 'rotation',
3330
+ replacedKey: oldKey,
3331
+ subscriptionId: meta.subscriptionId || null,
3332
+ entitlementKind: meta.entitlementKind || null,
3333
+ remainingCredits: meta.remainingCredits,
3334
+ fulfilledOrderIds,
3335
+ };
3336
+ saveKeyStore(store);
3337
+ return { rotated: true, key: newKey, oldKey };
3338
+ });
2855
3339
  }
2856
3340
 
2857
3341
  function validateApiKey(key) {
@@ -2876,32 +3360,47 @@ function validateApiKey(key) {
2876
3360
  }
2877
3361
 
2878
3362
  function recordUsage(key) {
2879
- const store = loadKeyStore();
2880
- const meta = store.keys[key];
2881
- if (meta && meta.active) {
2882
- const oldVal = meta.usageCount || 0;
2883
- meta.usageCount = oldVal + 1;
3363
+ return withKeyStoreLock(() => {
3364
+ const store = loadKeyStore();
3365
+ const meta = store.keys[key];
3366
+ if (meta && meta.active) {
3367
+ const oldVal = meta.usageCount || 0;
3368
+ meta.usageCount = oldVal + 1;
3369
+
3370
+ if (meta.remainingCredits !== undefined && meta.remainingCredits !== null) {
3371
+ meta.remainingCredits = Math.max(0, meta.remainingCredits - 1);
3372
+ }
2884
3373
 
2885
- // Decrement credits if applicable
2886
- if (meta.remainingCredits !== undefined && meta.remainingCredits !== null) {
2887
- meta.remainingCredits = Math.max(0, meta.remainingCredits - 1);
3374
+ if (oldVal === 0) appendFunnelEvent({ stage: 'activation', event: 'api_key_first_usage', installId: meta.installId, evidence: key, metadata: { customerId: meta.customerId } });
3375
+ saveKeyStore(store);
3376
+ return { recorded: true, usageCount: meta.usageCount, remainingCredits: meta.remainingCredits };
2888
3377
  }
2889
-
2890
- if (oldVal === 0) appendFunnelEvent({ stage: 'activation', event: 'api_key_first_usage', installId: meta.installId, evidence: key, metadata: { customerId: meta.customerId } });
2891
- saveKeyStore(store);
2892
- return { recorded: true, usageCount: meta.usageCount, remainingCredits: meta.remainingCredits };
2893
- }
2894
- return { recorded: false };
3378
+ return { recorded: false };
3379
+ });
2895
3380
  }
2896
3381
 
2897
- function disableCustomerKeys(customerId) {
2898
- const store = loadKeyStore();
2899
- let disabledCount = 0;
2900
- for (const [key, meta] of Object.entries(store.keys)) {
2901
- if (meta.customerId === customerId && meta.active) { meta.active = false; meta.disabledAt = new Date().toISOString(); disabledCount++; }
2902
- }
2903
- if (disabledCount > 0) saveKeyStore(store);
2904
- return { disabledCount };
3382
+ function disableCustomerKeys(customerId, options = {}) {
3383
+ if (!customerId || typeof customerId !== 'string') return { disabledCount: 0 };
3384
+ return withKeyStoreLock(() => {
3385
+ const store = loadKeyStore();
3386
+ let disabledCount = 0;
3387
+ const disabledAt = new Date().toISOString();
3388
+ for (const meta of Object.values(store.keys)) {
3389
+ if (meta.customerId === customerId && meta.active) {
3390
+ meta.active = false;
3391
+ meta.disabledAt = disabledAt;
3392
+ disabledCount++;
3393
+ }
3394
+ }
3395
+ const subscriptionId = normalizeText(options.subscriptionId);
3396
+ if (subscriptionId) {
3397
+ const eventCreatedAt = normalizeInteger(options.eventCreatedAt) || Math.floor(Date.now() / 1000);
3398
+ store.canceledSubscriptions[subscriptionId] = { customerId, eventCreatedAt, disabledAt };
3399
+ store.customerRevocations[customerId] = { subscriptionId, eventCreatedAt, disabledAt };
3400
+ }
3401
+ if (disabledCount > 0 || subscriptionId) saveKeyStore(store);
3402
+ return { disabledCount };
3403
+ });
2905
3404
  }
2906
3405
 
2907
3406
  function verifyWebhookSignature(rawBody, signature) {
@@ -2957,34 +3456,93 @@ async function handleWebhook(rawBody, signature) {
2957
3456
  }
2958
3457
 
2959
3458
  switch (event.type) {
3459
+ case 'checkout.session.async_payment_succeeded':
2960
3460
  case 'checkout.session.completed': {
2961
3461
  const session = event.data.object;
2962
- const customerId = session.customer;
3462
+ const diagnosticOrder = isDiagnosticCheckoutSession(session);
3463
+ const provisioningGrant = resolveCheckoutProvisioningGrant(session);
3464
+ const paymentConfirmed = session.payment_status === 'paid'
3465
+ || (!diagnosticOrder
3466
+ && session.mode === 'subscription'
3467
+ && session.payment_status === 'no_payment_required');
3468
+ if (!paymentConfirmed) {
3469
+ return {
3470
+ handled: true,
3471
+ action: 'payment_pending',
3472
+ sessionId: session.id || null,
3473
+ paymentStatus: session.payment_status || 'unknown',
3474
+ };
3475
+ }
3476
+ const customerEmail = session.customer_details?.email || session.customer_email || '';
3477
+ const customerId = resolveStripeCustomerLedgerId(session, customerEmail);
2963
3478
  const installId = session.metadata?.installId;
2964
3479
  const traceId = session.metadata?.traceId || null;
2965
- const credits = session.metadata?.credits ? parseInt(session.metadata.credits, 10) : null;
2966
3480
  const packId = session.metadata?.packId || null;
2967
- const customerEmail = session.customer_details?.email || session.customer_email || '';
2968
3481
  const customerName = session.customer_details?.name || null;
2969
3482
  const trialEndAt = computeTrialEndAt(session);
2970
3483
 
2971
3484
  const attribution = extractAttribution(session.metadata);
2972
- const result = provisionApiKey(customerId, {
2973
- installId,
2974
- credits,
2975
- source: 'stripe_webhook_checkout_completed'
2976
- });
2977
- const trialEmail = await sendTrialActivationEmail({
2978
- sessionId: session.id,
2979
- customerId,
2980
- customerEmail,
2981
- customerName,
2982
- trialEndAt,
2983
- apiKey: result.key,
2984
- planId: session.metadata?.planId || packId || null,
2985
- appOrigin: process.env.THUMBGATE_PUBLIC_APP_ORIGIN,
2986
- source: 'stripe_webhook_checkout_completed',
2987
- });
3485
+ // External Stripe Payment Links (the diagnostic/sprint checkouts) carry no
3486
+ // metadata but preserve client_reference_id. When metadata yields no source,
3487
+ // recover it so marketplace-attributed paid checkouts (e.g. utm_source=aiventyx)
3488
+ // are credited/reported instead of silently landing as source=unknown.
3489
+ if (!attribution.source) {
3490
+ const ref = parseCheckoutReference(session.client_reference_id);
3491
+ if (ref?.source) {
3492
+ attribution.source = ref.source;
3493
+ if (!attribution.acquisitionId) attribution.acquisitionId = ref.acquisitionId;
3494
+ }
3495
+ }
3496
+ let diagnosticClaim = null;
3497
+ if (diagnosticOrder) {
3498
+ diagnosticClaim = claimOrderOperation(session.id, 'diagnostic');
3499
+ if (diagnosticClaim.completed) {
3500
+ return {
3501
+ handled: true,
3502
+ action: 'diagnostic_order_already_processed',
3503
+ result: null,
3504
+ };
3505
+ }
3506
+ if (!diagnosticClaim.claimed) {
3507
+ return {
3508
+ handled: false,
3509
+ retryable: true,
3510
+ reason: 'diagnostic_fulfillment_in_progress',
3511
+ };
3512
+ }
3513
+ }
3514
+ const result = provisioningGrant.allowed
3515
+ ? provisionApiKey(customerId, {
3516
+ installId,
3517
+ credits: provisioningGrant.credits,
3518
+ orderId: session.id,
3519
+ subscriptionId: session.subscription,
3520
+ orderCreatedAt: session.created,
3521
+ enforceCancellationHistory: provisioningGrant.kind === 'subscription',
3522
+ entitlementKind: provisioningGrant.kind,
3523
+ source: 'stripe_webhook_checkout_completed'
3524
+ })
3525
+ : null;
3526
+ const trialEmail = !result || !result.key
3527
+ ? {
3528
+ status: 'skipped',
3529
+ reason: diagnosticOrder
3530
+ ? 'diagnostic_order'
3531
+ : result && result.entitlementActive === false
3532
+ ? 'entitlement_inactive'
3533
+ : 'non_entitlement_order',
3534
+ }
3535
+ : await sendTrialActivationEmail({
3536
+ sessionId: session.id,
3537
+ customerId,
3538
+ customerEmail,
3539
+ customerName,
3540
+ trialEndAt,
3541
+ apiKey: result.key,
3542
+ planId: session.metadata?.planId || packId || null,
3543
+ appOrigin: process.env.THUMBGATE_PUBLIC_APP_ORIGIN,
3544
+ source: 'stripe_webhook_checkout_completed',
3545
+ });
2988
3546
  const funnelRecord = {
2989
3547
  stage: 'paid',
2990
3548
  event: 'stripe_checkout_completed',
@@ -2994,6 +3552,9 @@ async function handleWebhook(rawBody, signature) {
2994
3552
  sessionId: session.id,
2995
3553
  traceId,
2996
3554
  packId,
3555
+ offerKind: diagnosticOrder
3556
+ ? 'workflow_hardening_diagnostic'
3557
+ : provisioningGrant.allowed ? provisioningGrant.kind : 'service_order',
2997
3558
  ...extractJourneyFields(session.metadata),
2998
3559
  ...attribution,
2999
3560
  },
@@ -3009,11 +3570,9 @@ async function handleWebhook(rawBody, signature) {
3009
3570
  });
3010
3571
  }
3011
3572
  // Write checkout_paid_confirmed event with amount/currency for funnel analytics
3012
- appendFunnelEvent({
3573
+ const paidConfirmationRecord = {
3013
3574
  stage: 'paid',
3014
3575
  event: 'checkout_paid_confirmed',
3015
- installId,
3016
- traceId,
3017
3576
  evidence: session.id,
3018
3577
  metadata: {
3019
3578
  source: 'stripe_webhook_checkout_completed',
@@ -3022,7 +3581,14 @@ async function handleWebhook(rawBody, signature) {
3022
3581
  customerId,
3023
3582
  ...funnelRecord.metadata,
3024
3583
  },
3025
- });
3584
+ };
3585
+ if (!hasFunnelEventMatch(loadFunnelLedger(), paidConfirmationRecord)) {
3586
+ appendFunnelEvent({
3587
+ ...paidConfirmationRecord,
3588
+ installId,
3589
+ traceId,
3590
+ });
3591
+ }
3026
3592
  const revenueRecord = {
3027
3593
  provider: 'stripe',
3028
3594
  event: 'stripe_checkout_completed',
@@ -3035,10 +3601,25 @@ async function handleWebhook(rawBody, signature) {
3035
3601
  mode: session.mode || null,
3036
3602
  paymentStatus: session.payment_status || null,
3037
3603
  packId,
3604
+ offerKind: diagnosticOrder
3605
+ ? 'workflow_hardening_diagnostic'
3606
+ : provisioningGrant.allowed ? provisioningGrant.kind : 'service_order',
3038
3607
  },
3039
3608
  };
3040
- if (!hasRevenueEventMatch(loadRevenueLedger(), revenueRecord)) {
3041
- appendRevenueEvent({
3609
+ const revenueClaim = claimOrderOperation(session.id, 'revenue');
3610
+ if (!revenueClaim.claimed && !revenueClaim.completed) {
3611
+ if (diagnosticOrder) releaseOrderOperation(diagnosticClaim);
3612
+ return {
3613
+ handled: false,
3614
+ retryable: true,
3615
+ reason: 'payment_record_in_progress',
3616
+ };
3617
+ }
3618
+ const isNewRevenueRecord = revenueClaim.claimed
3619
+ && !hasRevenueEventMatch(loadRevenueLedger(), revenueRecord);
3620
+ let revenueWriteResult = { written: true, reason: 'already_recorded' };
3621
+ if (isNewRevenueRecord) {
3622
+ revenueWriteResult = appendRevenueEvent({
3042
3623
  ...revenueRecord,
3043
3624
  installId,
3044
3625
  traceId,
@@ -3050,10 +3631,25 @@ async function handleWebhook(rawBody, signature) {
3050
3631
  attribution,
3051
3632
  });
3052
3633
  }
3634
+ if (revenueWriteResult.written !== true) {
3635
+ releaseOrderOperation(revenueClaim);
3636
+ if (diagnosticOrder) releaseOrderOperation(diagnosticClaim);
3637
+ return {
3638
+ handled: false,
3639
+ retryable: true,
3640
+ reason: 'payment_record_retry_required',
3641
+ };
3642
+ }
3643
+ if (revenueClaim.claimed) {
3644
+ completeOrderOperation(revenueClaim, {
3645
+ sessionId: session.id,
3646
+ action: isNewRevenueRecord ? 'revenue_recorded' : 'revenue_already_recorded',
3647
+ });
3648
+ }
3053
3649
  // Fire Plausible purchase event so the funnel poller can measure
3054
3650
  // end-to-end conversion: visitor → CTA → checkout → email → Stripe → purchase.
3055
3651
  // Fire-and-forget (never blocks the webhook response).
3056
- void recordCheckoutFunnelEvent('purchase', {
3652
+ const purchaseEventOptions = {
3057
3653
  page: '/success',
3058
3654
  props: {
3059
3655
  sessionId: session.id,
@@ -3064,7 +3660,69 @@ async function handleWebhook(rawBody, signature) {
3064
3660
  currency: session.currency || '',
3065
3661
  ...attribution,
3066
3662
  },
3067
- });
3663
+ };
3664
+ if (isNewRevenueRecord) {
3665
+ if (diagnosticOrder) {
3666
+ void plausibleEvent('Workflow Diagnostic Purchase Completed', purchaseEventOptions);
3667
+ } else {
3668
+ void recordCheckoutFunnelEvent('purchase', purchaseEventOptions);
3669
+ }
3670
+ }
3671
+
3672
+ if (diagnosticOrder) {
3673
+ const diagnosticEmails = await sendDiagnosticOrderEmails({
3674
+ session,
3675
+ customerEmail,
3676
+ customerName,
3677
+ attribution,
3678
+ });
3679
+ const deliveryComplete = diagnosticEmails.buyer.sent && diagnosticEmails.operator.sent;
3680
+ const deliveryRetryable = [diagnosticEmails.buyer, diagnosticEmails.operator]
3681
+ .some(isRetryableDiagnosticDelivery);
3682
+ if (!deliveryComplete && deliveryRetryable) {
3683
+ releaseOrderOperation(diagnosticClaim);
3684
+ return {
3685
+ handled: false,
3686
+ retryable: true,
3687
+ paymentRecorded: true,
3688
+ reason: 'diagnostic_fulfillment_retry_required',
3689
+ delivery: {
3690
+ buyer: diagnosticEmails.buyer.reason || diagnosticEmails.buyer.status || 'not_sent',
3691
+ operator: diagnosticEmails.operator.reason || diagnosticEmails.operator.status || 'not_sent',
3692
+ },
3693
+ };
3694
+ }
3695
+ completeOrderOperation(diagnosticClaim, {
3696
+ sessionId: session.id,
3697
+ action: 'diagnostic_order_recorded',
3698
+ fulfillment: deliveryComplete ? 'complete' : 'attention_required',
3699
+ });
3700
+ return {
3701
+ handled: true,
3702
+ action: 'diagnostic_order_recorded',
3703
+ result: null,
3704
+ diagnosticEmails,
3705
+ fulfillment: deliveryComplete ? 'complete' : 'attention_required',
3706
+ };
3707
+ }
3708
+
3709
+ if (!provisioningGrant.allowed) {
3710
+ return {
3711
+ handled: true,
3712
+ action: 'paid_service_order_recorded',
3713
+ result: null,
3714
+ trialEmail,
3715
+ };
3716
+ }
3717
+
3718
+ if (result && result.entitlementActive === false) {
3719
+ return {
3720
+ handled: true,
3721
+ action: 'checkout_entitlement_inactive',
3722
+ result,
3723
+ trialEmail,
3724
+ };
3725
+ }
3068
3726
 
3069
3727
  return {
3070
3728
  handled: true,
@@ -3074,9 +3732,25 @@ async function handleWebhook(rawBody, signature) {
3074
3732
  email: trialEmailToWebhookEmailResult(trialEmail),
3075
3733
  };
3076
3734
  }
3735
+ case 'checkout.session.async_payment_failed': {
3736
+ const session = event.data.object;
3737
+ return {
3738
+ handled: true,
3739
+ action: 'payment_failed',
3740
+ sessionId: session.id || null,
3741
+ paymentStatus: session.payment_status || 'unpaid',
3742
+ };
3743
+ }
3077
3744
  case 'customer.subscription.deleted': {
3078
3745
  const sub = event.data.object;
3079
- return { handled: true, action: 'disabled_customer_keys', result: disableCustomerKeys(sub.customer) };
3746
+ return {
3747
+ handled: true,
3748
+ action: 'disabled_customer_keys',
3749
+ result: disableCustomerKeys(sub.customer, {
3750
+ subscriptionId: sub.id,
3751
+ eventCreatedAt: event.created || sub.canceled_at || sub.ended_at,
3752
+ }),
3753
+ };
3080
3754
  }
3081
3755
  default: return { handled: false, reason: `unhandled_event_type:${event.type}` };
3082
3756
  }
@@ -3217,6 +3891,9 @@ module.exports = {
3217
3891
  _buildCheckoutSessionPayload: buildCheckoutSessionPayload,
3218
3892
  _buildTrialActivationEmail: buildTrialActivationEmail,
3219
3893
  _sendTrialActivationEmail: sendTrialActivationEmail,
3894
+ _isDiagnosticCheckoutSession: isDiagnosticCheckoutSession,
3895
+ _resolveCheckoutProvisioningGrant: resolveCheckoutProvisioningGrant,
3896
+ _sendDiagnosticOrderEmails: sendDiagnosticOrderEmails,
3220
3897
  _resolveSubscriptionCheckoutSelection: resolveSubscriptionCheckoutSelection,
3221
3898
  _verifyActiveProductForPlan: verifyActiveProductForPlan,
3222
3899
  _API_KEYS_PATH: () => CONFIG.API_KEYS_PATH,
@@ -3224,6 +3901,7 @@ module.exports = {
3224
3901
  _REVENUE_LEDGER_PATH: () => CONFIG.REVENUE_LEDGER_PATH,
3225
3902
  _LOCAL_CHECKOUT_SESSIONS_PATH: () => CONFIG.LOCAL_CHECKOUT_SESSIONS_PATH,
3226
3903
  _TRIAL_EMAIL_LEDGER_PATH: () => CONFIG.TRIAL_EMAIL_LEDGER_PATH,
3904
+ _ORDER_EMAIL_LEDGER_PATH: () => CONFIG.ORDER_EMAIL_LEDGER_PATH,
3227
3905
  _LOCAL_MODE: () => LOCAL_MODE(),
3228
3906
  _withTimeout: withTimeout,
3229
3907
  // Default to the real Resend-backed mailer so production webhooks send the