thumbgate 1.28.4 → 1.29.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.
- package/.claude/commands/dashboard.md +11 -1
- package/.claude/commands/thumbgate-dashboard.md +23 -8
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/llms.txt +18 -10
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +66 -3
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/forge/forge.yaml +3 -3
- package/adapters/mcp/server-stdio.js +88 -2
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +8 -8
- package/bin/postinstall.js +4 -13
- package/commands/dashboard.md +11 -1
- package/commands/thumbgate-dashboard.md +23 -8
- package/config/agent-outcome-monitor-thresholds.json +63 -0
- package/config/evals/agent-outcomes-baseline.json +17 -0
- package/config/evals/agent-outcomes-golden.json +412 -0
- package/config/evals/prompt-eval-baseline.json +23 -0
- package/config/github-about.json +5 -4
- package/config/post-deploy-marketing-pages.json +6 -6
- package/config/schemas/task-outcome-receipt.schema.json +296 -0
- package/docs/integrations/grafana/README.md +109 -0
- package/docs/integrations/grafana/thumbgate-revenue-evidence-dashboard.json +1930 -0
- package/openapi/openapi.yaml +475 -5
- package/package.json +75 -22
- package/public/agent-manager.html +10 -11
- package/public/agents-cost-savings.html +2 -2
- package/public/assets/brand/thumbgate-logo-transparent.svg +6 -11
- package/public/assets/brand/thumbgate-mark-inline-v3.svg +11 -10
- package/public/assets/brand/thumbgate-mark.svg +10 -11
- package/public/blog/inside-your-boundary.html +114 -0
- package/public/blog/process-over-outcome-gates.html +119 -0
- package/public/blog.html +296 -402
- package/public/brand/thumbgate-mark.svg +5 -9
- package/public/codex-enterprise.html +2 -2
- package/public/compare.html +12 -3
- package/public/diagnostic.html +79 -29
- package/public/guide.html +4 -4
- package/public/index.html +1090 -2098
- package/public/install.html +3 -3
- package/public/js/buyer-intent.js +33 -18
- package/public/numbers.html +2 -2
- package/public/pricing.html +268 -408
- package/public/pro.html +4 -4
- package/scripts/agent-outcome-eval.js +130 -0
- package/scripts/agent-outcome-monitor.js +261 -0
- package/scripts/agent-reasoning-traces.js +8 -9
- package/scripts/async-job-runner.js +107 -13
- package/scripts/billing.js +456 -126
- package/scripts/buyer-paths.js +102 -0
- package/scripts/cli-feedback.js +2 -2
- package/scripts/commercial-offer.js +18 -10
- package/scripts/durability/step.js +121 -12
- package/scripts/external-customer-audit.js +881 -0
- package/scripts/feedback-loop.js +26 -0
- package/scripts/gates-engine.js +554 -19
- package/scripts/grafana-revenue-evidence.js +856 -0
- package/scripts/human-escalation.js +265 -0
- package/scripts/hybrid-feedback-context.js +93 -50
- package/scripts/jsonl-window.js +89 -0
- package/scripts/judge-reward-function.js +30 -18
- package/scripts/lesson-embedding-index.js +3 -7
- package/scripts/meta-agent-loop.js +20 -2
- package/scripts/observability-env.js +139 -0
- package/scripts/observability-setup.js +55 -0
- package/scripts/plausible-domain-config.js +4 -0
- package/scripts/prompt-eval.js +81 -4
- package/scripts/provider-live-evidence.js +1290 -0
- package/scripts/provider-payment-reconciler.js +442 -0
- package/scripts/provider-revenue-evidence.js +249 -0
- package/scripts/rate-limiter.js +1 -5
- package/scripts/revenue-action-eligibility.js +414 -0
- package/scripts/revenue-evidence-remediation.js +694 -0
- package/scripts/revenue-offer-system.js +709 -0
- package/scripts/sales-pipeline.js +1117 -0
- package/scripts/schedule-manager.js +249 -0
- package/scripts/seo-gsd.js +8 -4
- package/scripts/stripe-credentials.js +37 -0
- package/scripts/stripe-revenue-catalog-audit.js +363 -0
- package/scripts/stripe-revenue-catalog.js +164 -0
- package/scripts/task-outcomes.js +425 -0
- package/scripts/telemetry-analytics.js +23 -3
- package/scripts/tool-contract-validator.js +287 -59
- package/scripts/tool-registry.js +143 -0
- package/scripts/vector-store.js +83 -7
- package/scripts/workflow-intake-queue.js +483 -0
- package/src/api/server.js +647 -118
package/scripts/billing.js
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* billing.js — Stripe billing integration using official Stripe SDK.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
2
|
'use strict';
|
|
7
3
|
|
|
8
4
|
const STRIPE_TIMEOUT_MS = 5000;
|
|
9
5
|
const STRIPE_RECONCILIATION_SUMMARY_TIMEOUT_MS = 3500;
|
|
10
6
|
function withTimeout(promise, ms = STRIPE_TIMEOUT_MS) {
|
|
11
|
-
return Promise
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const timeout = setTimeout(() => reject(new Error(`Stripe API timeout after ${ms}ms`)), ms);
|
|
9
|
+
Promise.resolve(promise).then(
|
|
10
|
+
(value) => { clearTimeout(timeout); resolve(value); },
|
|
11
|
+
(error) => { clearTimeout(timeout); reject(error); },
|
|
12
|
+
);
|
|
13
|
+
});
|
|
15
14
|
}
|
|
16
15
|
|
|
17
16
|
const fs = require('fs');
|
|
@@ -63,14 +62,14 @@ function loadWorkflowSprintIntakeModule() {
|
|
|
63
62
|
return require(modulePath);
|
|
64
63
|
}
|
|
65
64
|
|
|
66
|
-
// ---------------------------------------------------------------------------
|
|
67
|
-
// Config
|
|
68
|
-
// ---------------------------------------------------------------------------
|
|
69
|
-
|
|
70
65
|
const CONFIG = {
|
|
71
66
|
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY || '',
|
|
72
67
|
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET || '',
|
|
73
68
|
GITHUB_MARKETPLACE_WEBHOOK_SECRET: process.env.GITHUB_MARKETPLACE_WEBHOOK_SECRET || '',
|
|
69
|
+
PAYPAL_CLIENT_ID: process.env.THUMBGATE_PAYPAL_CLIENT_ID || process.env.PAYPAL_CLIENT_ID || '',
|
|
70
|
+
PAYPAL_CLIENT_SECRET: process.env.THUMBGATE_PAYPAL_CLIENT_SECRET || process.env.PAYPAL_CLIENT_SECRET || '',
|
|
71
|
+
PAYPAL_WEBHOOK_ID: process.env.THUMBGATE_PAYPAL_WEBHOOK_ID || '',
|
|
72
|
+
PAYPAL_API_BASE_URL: process.env.THUMBGATE_PAYPAL_API_BASE_URL || 'https://api-m.paypal.com',
|
|
74
73
|
GITHUB_MARKETPLACE_PLAN_PRICES_JSON: process.env.THUMBGATE_GITHUB_MARKETPLACE_PLAN_PRICES_JSON || '',
|
|
75
74
|
STRIPE_PRICE_ID: process.env.STRIPE_PRICE_ID || PRO_MONTHLY_PRICE_ID,
|
|
76
75
|
STRIPE_PRICE_ID_PRO_MONTHLY: process.env.STRIPE_PRICE_ID_PRO_MONTHLY || PRO_MONTHLY_PRICE_ID,
|
|
@@ -86,6 +85,16 @@ const CONFIG = {
|
|
|
86
85
|
get REVENUE_LEDGER_PATH() {
|
|
87
86
|
return process.env._TEST_REVENUE_LEDGER_PATH || process.env.THUMBGATE_REVENUE_LEDGER_PATH || path.join(getFeedbackPaths().FEEDBACK_DIR, 'revenue-events.jsonl');
|
|
88
87
|
},
|
|
88
|
+
get GITHUB_MARKETPLACE_WEBHOOK_LEDGER_PATH() {
|
|
89
|
+
return process.env._TEST_GITHUB_MARKETPLACE_WEBHOOK_LEDGER_PATH ||
|
|
90
|
+
process.env.THUMBGATE_GITHUB_MARKETPLACE_WEBHOOK_LEDGER_PATH ||
|
|
91
|
+
path.join(getFeedbackPaths().FEEDBACK_DIR, 'github-marketplace-webhook-deliveries.jsonl');
|
|
92
|
+
},
|
|
93
|
+
get PAYPAL_WEBHOOK_LEDGER_PATH() {
|
|
94
|
+
return process.env._TEST_PAYPAL_WEBHOOK_LEDGER_PATH ||
|
|
95
|
+
process.env.THUMBGATE_PAYPAL_WEBHOOK_LEDGER_PATH ||
|
|
96
|
+
path.join(getFeedbackPaths().FEEDBACK_DIR, 'paypal-webhook-deliveries.jsonl');
|
|
97
|
+
},
|
|
89
98
|
get LOCAL_CHECKOUT_SESSIONS_PATH() {
|
|
90
99
|
return process.env._TEST_LOCAL_CHECKOUT_SESSIONS_PATH || path.join(getFeedbackPaths().FEEDBACK_DIR, 'local-checkout-sessions.json');
|
|
91
100
|
},
|
|
@@ -144,6 +153,8 @@ const IS_TEST = !!(
|
|
|
144
153
|
process.env._TEST_API_KEYS_PATH ||
|
|
145
154
|
process.env._TEST_FUNNEL_LEDGER_PATH ||
|
|
146
155
|
process.env._TEST_REVENUE_LEDGER_PATH ||
|
|
156
|
+
process.env._TEST_GITHUB_MARKETPLACE_WEBHOOK_LEDGER_PATH ||
|
|
157
|
+
process.env._TEST_PAYPAL_WEBHOOK_LEDGER_PATH ||
|
|
147
158
|
process.env._TEST_LOCAL_CHECKOUT_SESSIONS_PATH ||
|
|
148
159
|
process.env.NODE_ENV === 'test'
|
|
149
160
|
);
|
|
@@ -152,6 +163,10 @@ function allowUnsignedStripeWebhooks() {
|
|
|
152
163
|
return IS_TEST && process.env.THUMBGATE_ALLOW_UNSIGNED_STRIPE_WEBHOOKS === '1';
|
|
153
164
|
}
|
|
154
165
|
|
|
166
|
+
function allowUnsignedGithubWebhooks() {
|
|
167
|
+
return IS_TEST && process.env.THUMBGATE_ALLOW_UNSIGNED_GITHUB_WEBHOOKS === '1';
|
|
168
|
+
}
|
|
169
|
+
|
|
155
170
|
function shouldMergeLegacyBillingData() {
|
|
156
171
|
return process.env._TEST_INCLUDE_LEGACY_BILLING_DATA === '1'
|
|
157
172
|
|| process.env.THUMBGATE_INCLUDE_LEGACY_BILLING_DATA === '1';
|
|
@@ -170,10 +185,6 @@ function safeCompareHex(expectedHex, actualHex) {
|
|
|
170
185
|
}
|
|
171
186
|
}
|
|
172
187
|
|
|
173
|
-
// ---------------------------------------------------------------------------
|
|
174
|
-
// Internal helpers
|
|
175
|
-
// ---------------------------------------------------------------------------
|
|
176
|
-
|
|
177
188
|
function sanitizeMetadata(metadata) {
|
|
178
189
|
if (!metadata || typeof metadata !== 'object') return {};
|
|
179
190
|
try {
|
|
@@ -458,41 +469,17 @@ function buildCheckoutProductData({ name, description, appOrigin, planId }) {
|
|
|
458
469
|
};
|
|
459
470
|
}
|
|
460
471
|
|
|
461
|
-
/**
|
|
462
|
-
* Verify an ACTIVE Stripe product exists for the given plan name before
|
|
463
|
-
* we let buildSubscriptionPriceData create inline price_data under it.
|
|
464
|
-
*
|
|
465
|
-
* Stripe matches product_data by `name`. If only an archived product
|
|
466
|
-
* matches, new prices created via that path inherit active=false and the
|
|
467
|
-
* generated checkout URL renders "Something went wrong / The page you
|
|
468
|
-
* were looking for could not be found." for the buyer. Stripe Dashboard
|
|
469
|
-
* shows the session as `open` with no email captured — looks like the
|
|
470
|
-
* buyer abandoned, but they were never given a working page.
|
|
471
|
-
*
|
|
472
|
-
* Failing here surfaces the misconfiguration at the first checkout
|
|
473
|
-
* attempt instead of silently breaking every buyer for days.
|
|
474
|
-
*
|
|
475
|
-
* Verified incident: ThumbGate#2188 (May 2026) — 20 sessions abandoned in
|
|
476
|
-
* 7 days, all because the only product named "ThumbGate Pro" matching the
|
|
477
|
-
* inline product_data was archived (prod_UXxOHAfbDsPyRb), while an active
|
|
478
|
-
* product with the same name existed (prod_UW82THPxfNvwKT) that should
|
|
479
|
-
* have been used instead.
|
|
480
|
-
*/
|
|
481
472
|
async function verifyActiveProductForPlan(stripe, planId) {
|
|
482
473
|
const expectedName = planId === 'team' ? 'ThumbGate Team' : 'ThumbGate Pro';
|
|
483
474
|
let products;
|
|
484
475
|
try {
|
|
485
476
|
products = await stripe.products.list({ limit: 100 });
|
|
486
477
|
} catch (err) {
|
|
487
|
-
// Network/transient failures shouldn't block checkout creation.
|
|
488
|
-
// The original session.create call will surface real Stripe errors.
|
|
489
478
|
return;
|
|
490
479
|
}
|
|
491
480
|
const matching = (products && products.data ? products.data : [])
|
|
492
481
|
.filter((p) => p && p.name === expectedName);
|
|
493
482
|
if (matching.length === 0) {
|
|
494
|
-
// No product with this name exists; Stripe will create a new one when
|
|
495
|
-
// session.create fires with inline product_data. Safe path.
|
|
496
483
|
return;
|
|
497
484
|
}
|
|
498
485
|
const hasActive = matching.some((p) => p.active === true);
|
|
@@ -566,14 +553,6 @@ function appendTrialEmailRecord(payload) {
|
|
|
566
553
|
});
|
|
567
554
|
}
|
|
568
555
|
|
|
569
|
-
/**
|
|
570
|
-
* Resolve the trial expiry date for a Stripe checkout session.
|
|
571
|
-
*
|
|
572
|
-
* Prefers an explicit `subscription.trial_end` unix timestamp when the session
|
|
573
|
-
* embeds one (subscriptions with trial_period_days populate it). Falls back to
|
|
574
|
-
* the session's `expires_at`, and finally to now + 7 days. Always returns a
|
|
575
|
-
* Date; never throws.
|
|
576
|
-
*/
|
|
577
556
|
function computeTrialEndAt(session) {
|
|
578
557
|
const TRIAL_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
|
579
558
|
if (session && session.subscription && typeof session.subscription === 'object') {
|
|
@@ -796,10 +775,6 @@ async function sendTrialActivationEmail(params = {}, options = {}) {
|
|
|
796
775
|
});
|
|
797
776
|
if (!response || response.sent !== true) {
|
|
798
777
|
const rawReason = normalizeText(response && response.reason) || 'provider_error';
|
|
799
|
-
// Normalize the mailer module's `no_api_key` to billing.js's legacy
|
|
800
|
-
// `missing_resend_api_key` reason so downstream consumers (dashboards,
|
|
801
|
-
// tests, support tooling) see a stable vocabulary regardless of which
|
|
802
|
-
// transport produced the skip.
|
|
803
778
|
const reason = rawReason === 'no_api_key' ? 'missing_resend_api_key' : rawReason;
|
|
804
779
|
const isSkipped = reason === 'missing_resend_api_key';
|
|
805
780
|
const previousSkipped = isSkipped
|
|
@@ -894,22 +869,10 @@ function isDiagnosticCheckoutSession(session = {}) {
|
|
|
894
869
|
const paymentLinkId = typeof session.payment_link === 'string'
|
|
895
870
|
? session.payment_link
|
|
896
871
|
: 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
872
|
return session.mode === 'payment'
|
|
901
873
|
&& configuredIds.includes(paymentLinkId);
|
|
902
874
|
}
|
|
903
875
|
|
|
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
876
|
function resolveCheckoutProvisioningGrant(session = {}) {
|
|
914
877
|
if (session.mode === 'subscription') {
|
|
915
878
|
return { allowed: true, kind: 'subscription', credits: null };
|
|
@@ -1334,7 +1297,14 @@ function resolveRevenueEventKey(entry = {}) {
|
|
|
1334
1297
|
);
|
|
1335
1298
|
}
|
|
1336
1299
|
|
|
1337
|
-
|
|
1300
|
+
const QUALIFIED_WORKFLOW_SPRINT_STATUSES = new Set([
|
|
1301
|
+
'qualified',
|
|
1302
|
+
'named_pilot',
|
|
1303
|
+
'proof_backed_run',
|
|
1304
|
+
'paid_team',
|
|
1305
|
+
]);
|
|
1306
|
+
|
|
1307
|
+
function isCompleteWorkflowSprintIntake(entry = {}) {
|
|
1338
1308
|
return Boolean(
|
|
1339
1309
|
normalizeText(entry.contact && entry.contact.email) &&
|
|
1340
1310
|
normalizeText(entry.qualification && entry.qualification.workflow) &&
|
|
@@ -1344,6 +1314,13 @@ function isQualifiedWorkflowSprintLead(entry = {}) {
|
|
|
1344
1314
|
);
|
|
1345
1315
|
}
|
|
1346
1316
|
|
|
1317
|
+
function isQualifiedWorkflowSprintLead(entry = {}, workflowSprintIntake = null) {
|
|
1318
|
+
return isCompleteWorkflowSprintIntake(entry)
|
|
1319
|
+
&& typeof workflowSprintIntake?.isEvidenceBasedQualificationReview === 'function'
|
|
1320
|
+
&& workflowSprintIntake.isEvidenceBasedQualificationReview(entry.qualificationReview)
|
|
1321
|
+
&& QUALIFIED_WORKFLOW_SPRINT_STATUSES.has(normalizeText(entry.status));
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1347
1324
|
function isOperatorGeneratedAcquisitionEntry(entry = {}) {
|
|
1348
1325
|
const metadata = sanitizeMetadata(entry.metadata);
|
|
1349
1326
|
const attribution = extractAttribution({
|
|
@@ -1508,8 +1485,6 @@ function repairGithubMarketplaceRevenueLedger(options = {}) {
|
|
|
1508
1485
|
const resolvedAt = new Date().toISOString();
|
|
1509
1486
|
const repairs = [];
|
|
1510
1487
|
|
|
1511
|
-
// Pass 1: in-place repair of rows already in revenue-events.jsonl that have
|
|
1512
|
-
// unknown amounts but resolvable plan metadata.
|
|
1513
1488
|
const updatedRows = rows.map((entry) => {
|
|
1514
1489
|
const result = resolveGithubMarketplaceRevenueEntry(entry, {
|
|
1515
1490
|
annotate: true,
|
|
@@ -1532,12 +1507,6 @@ function repairGithubMarketplaceRevenueLedger(options = {}) {
|
|
|
1532
1507
|
return result.entry;
|
|
1533
1508
|
});
|
|
1534
1509
|
|
|
1535
|
-
// Pass 2: append rows for funnel-derived paid github_marketplace events that
|
|
1536
|
-
// never landed in the revenue ledger. The webhook handler at handleGithubWebhook
|
|
1537
|
-
// skips appendRevenueEvent when hasRevenueEventMatch is true, so duplicates from
|
|
1538
|
-
// a re-run are already prevented; the only way an order gets here is if the
|
|
1539
|
-
// revenue write was skipped at webhook time (e.g. funnel pre-existed, planPricing
|
|
1540
|
-
// had unknown amount at the time, or the row was created via a different path).
|
|
1541
1510
|
const funnelRecords = loadFunnelLedger().filter(
|
|
1542
1511
|
(e) =>
|
|
1543
1512
|
e &&
|
|
@@ -1554,10 +1523,6 @@ function repairGithubMarketplaceRevenueLedger(options = {}) {
|
|
|
1554
1523
|
annotate: true,
|
|
1555
1524
|
resolvedAt,
|
|
1556
1525
|
});
|
|
1557
|
-
// Skip funnel-derived rows that still have unknown amounts after resolving —
|
|
1558
|
-
// we don't want to permanently bake amountKnown:false rows when there's no
|
|
1559
|
-
// pricing data to attach. Better to leave them off-disk and keep the
|
|
1560
|
-
// read-time merge in loadResolvedRevenueEvents covering them.
|
|
1561
1526
|
if (!resolved.changed || !resolved.entry.amountKnown) continue;
|
|
1562
1527
|
|
|
1563
1528
|
const persisted = {
|
|
@@ -2404,11 +2369,14 @@ function getBusinessAnalytics(options = {}) {
|
|
|
2404
2369
|
const workflowSprintLeadByCreator = {};
|
|
2405
2370
|
const workflowSprintLeadByCommunity = {};
|
|
2406
2371
|
const workflowSprintLeadByRuntime = {};
|
|
2372
|
+
const completeWorkflowSprintIntakeBySource = {};
|
|
2373
|
+
const completeWorkflowSprintIntakeByCreator = {};
|
|
2407
2374
|
const qualifiedWorkflowSprintLeadBySource = {};
|
|
2408
2375
|
const qualifiedWorkflowSprintLeadByCreator = {};
|
|
2409
2376
|
let workflowSprintLeadLatest = null;
|
|
2410
2377
|
let workflowSprintLeadLatestAt = null;
|
|
2411
2378
|
let workflowSprintLeadContactable = 0;
|
|
2379
|
+
let completeWorkflowSprintIntakeCount = 0;
|
|
2412
2380
|
let qualifiedWorkflowSprintLeadCount = 0;
|
|
2413
2381
|
const newsletterBySource = {};
|
|
2414
2382
|
const newsletterByCampaign = {};
|
|
@@ -2435,7 +2403,15 @@ function getBusinessAnalytics(options = {}) {
|
|
|
2435
2403
|
if (entry.contact?.email) {
|
|
2436
2404
|
workflowSprintLeadContactable += 1;
|
|
2437
2405
|
}
|
|
2438
|
-
if (
|
|
2406
|
+
if (isCompleteWorkflowSprintIntake(entry)) {
|
|
2407
|
+
completeWorkflowSprintIntakeCount += 1;
|
|
2408
|
+
incrementCounter(
|
|
2409
|
+
completeWorkflowSprintIntakeBySource,
|
|
2410
|
+
resolveAttributionSource(attribution, 'workflow_sprint_intake')
|
|
2411
|
+
);
|
|
2412
|
+
incrementCounter(completeWorkflowSprintIntakeByCreator, attribution.creator);
|
|
2413
|
+
}
|
|
2414
|
+
if (isQualifiedWorkflowSprintLead(entry, workflowSprintIntake)) {
|
|
2439
2415
|
qualifiedWorkflowSprintLeadCount += 1;
|
|
2440
2416
|
incrementCounter(
|
|
2441
2417
|
qualifiedWorkflowSprintLeadBySource,
|
|
@@ -2614,6 +2590,11 @@ function getBusinessAnalytics(options = {}) {
|
|
|
2614
2590
|
latestLeadAt: workflowSprintLeadLatestAt,
|
|
2615
2591
|
latestLead: workflowSprintLeadLatest,
|
|
2616
2592
|
},
|
|
2593
|
+
completeWorkflowSprintIntakes: {
|
|
2594
|
+
total: completeWorkflowSprintIntakeCount,
|
|
2595
|
+
bySource: completeWorkflowSprintIntakeBySource,
|
|
2596
|
+
byCreator: completeWorkflowSprintIntakeByCreator,
|
|
2597
|
+
},
|
|
2617
2598
|
qualifiedWorkflowSprintLeads: {
|
|
2618
2599
|
total: qualifiedWorkflowSprintLeadCount,
|
|
2619
2600
|
bySource: qualifiedWorkflowSprintLeadBySource,
|
|
@@ -2778,30 +2759,49 @@ function getBillingSummary(options = {}) {
|
|
|
2778
2759
|
};
|
|
2779
2760
|
}
|
|
2780
2761
|
|
|
2781
|
-
|
|
2762
|
+
function buildBillingSummaryFailure(error) {
|
|
2763
|
+
const isTimeout = error && error.message && error.message.includes('Stripe API timeout');
|
|
2764
|
+
return {
|
|
2765
|
+
error: isTimeout ? 'stripe_timeout' : 'billing_summary_error',
|
|
2766
|
+
message: error && error.message ? error.message : 'Unknown error',
|
|
2767
|
+
revenue: { total: 0, mrr: 0, events: [] },
|
|
2768
|
+
usage: { totalUsage: 0, bySource: {}, activeBySource: {} },
|
|
2769
|
+
customers: [],
|
|
2770
|
+
};
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2773
|
+
async function getBillingSummariesLive(optionsByWindow = {}, {
|
|
2774
|
+
listStripeReconciledRevenueEventsFn = listStripeReconciledRevenueEvents,
|
|
2775
|
+
} = {}) {
|
|
2776
|
+
const entries = Object.entries(optionsByWindow || {});
|
|
2777
|
+
if (entries.length === 0) return {};
|
|
2778
|
+
|
|
2782
2779
|
try {
|
|
2783
|
-
const reconciliationTimeoutMs =
|
|
2780
|
+
const reconciliationTimeoutMs = entries
|
|
2781
|
+
.map(([, options]) => normalizeInteger(options && options.stripeReconciliationTimeoutMs))
|
|
2782
|
+
.find((value) => value !== null)
|
|
2784
2783
|
|| STRIPE_RECONCILIATION_SUMMARY_TIMEOUT_MS;
|
|
2785
2784
|
const extraRevenueEvents = await withTimeout(
|
|
2786
|
-
|
|
2785
|
+
listStripeReconciledRevenueEventsFn(),
|
|
2787
2786
|
reconciliationTimeoutMs
|
|
2788
2787
|
).catch(() => []);
|
|
2789
|
-
return
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2788
|
+
return Object.fromEntries(entries.map(([key, options]) => [
|
|
2789
|
+
key,
|
|
2790
|
+
getBillingSummary({
|
|
2791
|
+
...(options || {}),
|
|
2792
|
+
extraRevenueEvents,
|
|
2793
|
+
}),
|
|
2794
|
+
]));
|
|
2793
2795
|
} catch (err) {
|
|
2794
|
-
|
|
2795
|
-
return {
|
|
2796
|
-
error: isTimeout ? 'stripe_timeout' : 'billing_summary_error',
|
|
2797
|
-
message: err && err.message ? err.message : 'Unknown error',
|
|
2798
|
-
revenue: { total: 0, mrr: 0, events: [] },
|
|
2799
|
-
usage: { totalUsage: 0, bySource: {}, activeBySource: {} },
|
|
2800
|
-
customers: [],
|
|
2801
|
-
};
|
|
2796
|
+
return Object.fromEntries(entries.map(([key]) => [key, buildBillingSummaryFailure(err)]));
|
|
2802
2797
|
}
|
|
2803
2798
|
}
|
|
2804
2799
|
|
|
2800
|
+
async function getBillingSummaryLive(options = {}, runtime = {}) {
|
|
2801
|
+
const summaries = await getBillingSummariesLive({ summary: options }, runtime);
|
|
2802
|
+
return summaries.summary;
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
2805
|
function loadKeyStore() {
|
|
2806
2806
|
try {
|
|
2807
2807
|
const primary = CONFIG.API_KEYS_PATH;
|
|
@@ -2875,10 +2875,6 @@ function withKeyStoreLock(mutator) {
|
|
|
2875
2875
|
}
|
|
2876
2876
|
}
|
|
2877
2877
|
|
|
2878
|
-
// ---------------------------------------------------------------------------
|
|
2879
|
-
// Core Exports
|
|
2880
|
-
// ---------------------------------------------------------------------------
|
|
2881
|
-
|
|
2882
2878
|
async function createCheckoutSession({ successUrl, cancelUrl, customerEmail, installId, traceId, packId = null, metadata = {}, appOrigin } = {}) {
|
|
2883
2879
|
const resolvedTraceId = traceId || metadata.traceId || createTraceId('checkout');
|
|
2884
2880
|
const baseCheckoutMetadata = sanitizeMetadata({
|
|
@@ -2928,13 +2924,6 @@ async function createCheckoutSession({ successUrl, cancelUrl, customerEmail, ins
|
|
|
2928
2924
|
|
|
2929
2925
|
const stripe = getStripeClient();
|
|
2930
2926
|
|
|
2931
|
-
// Defensive guard against ThumbGate#2188:
|
|
2932
|
-
// When buildSubscriptionPriceData passes inline `product_data` to Stripe,
|
|
2933
|
-
// Stripe name-matches existing products. If the only existing product with
|
|
2934
|
-
// that name is ARCHIVED (active=false), the new price inherits active=false
|
|
2935
|
-
// and every Stripe checkout page renders "page not found" for the buyer.
|
|
2936
|
-
// That bug burnt 20+ silent abandoned sessions in May 2026. Fail fast
|
|
2937
|
-
// instead of letting the broken page ship.
|
|
2938
2927
|
if (!packId) {
|
|
2939
2928
|
await verifyActiveProductForPlan(stripe, checkoutSelection.planId);
|
|
2940
2929
|
}
|
|
@@ -3344,7 +3333,6 @@ function validateApiKey(key) {
|
|
|
3344
3333
|
const meta = store.keys[key];
|
|
3345
3334
|
if (!meta || !meta.active) return { valid: false };
|
|
3346
3335
|
|
|
3347
|
-
// Check if credits are exhausted
|
|
3348
3336
|
if (meta.remainingCredits !== undefined && meta.remainingCredits !== null && meta.remainingCredits <= 0) {
|
|
3349
3337
|
return { valid: false, reason: 'credits_exhausted' };
|
|
3350
3338
|
}
|
|
@@ -3407,7 +3395,6 @@ function verifyWebhookSignature(rawBody, signature) {
|
|
|
3407
3395
|
if (!CONFIG.STRIPE_WEBHOOK_SECRET) return allowUnsignedStripeWebhooks();
|
|
3408
3396
|
if (!signature || !rawBody) return false;
|
|
3409
3397
|
|
|
3410
|
-
// Stripe signature format: t=<timestamp>,v1=<hmac>,...
|
|
3411
3398
|
const parts = { v1: [] };
|
|
3412
3399
|
for (const part of signature.split(',')) {
|
|
3413
3400
|
const [k, v] = part.split('=');
|
|
@@ -3421,7 +3408,6 @@ function verifyWebhookSignature(rawBody, signature) {
|
|
|
3421
3408
|
|
|
3422
3409
|
if (!parts.t || !Array.isArray(parts.v1) || parts.v1.length === 0) return false;
|
|
3423
3410
|
|
|
3424
|
-
// Timestamp tolerance: +/- 5 minutes
|
|
3425
3411
|
const timestamp = parseInt(parts.t, 10);
|
|
3426
3412
|
const now = Math.floor(Date.now() / 1000);
|
|
3427
3413
|
if (isNaN(timestamp) || Math.abs(now - timestamp) > 300) return false;
|
|
@@ -3447,8 +3433,6 @@ async function handleWebhook(rawBody, signature) {
|
|
|
3447
3433
|
const stripe = getStripeClient();
|
|
3448
3434
|
event = stripe.webhooks.constructEvent(rawBody, signature, CONFIG.STRIPE_WEBHOOK_SECRET);
|
|
3449
3435
|
} else {
|
|
3450
|
-
// No webhook secret configured — signature was already checked by verifyWebhookSignature
|
|
3451
|
-
// (which is also lenient when no secret). Parse the raw body directly.
|
|
3452
3436
|
event = JSON.parse(rawBody.toString('utf-8'));
|
|
3453
3437
|
}
|
|
3454
3438
|
} catch (err) {
|
|
@@ -3482,10 +3466,6 @@ async function handleWebhook(rawBody, signature) {
|
|
|
3482
3466
|
const trialEndAt = computeTrialEndAt(session);
|
|
3483
3467
|
|
|
3484
3468
|
const attribution = extractAttribution(session.metadata);
|
|
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
3469
|
if (!attribution.source) {
|
|
3490
3470
|
const ref = parseCheckoutReference(session.client_reference_id);
|
|
3491
3471
|
if (ref?.source) {
|
|
@@ -3569,7 +3549,6 @@ async function handleWebhook(rawBody, signature) {
|
|
|
3569
3549
|
metadata: funnelRecord.metadata,
|
|
3570
3550
|
});
|
|
3571
3551
|
}
|
|
3572
|
-
// Write checkout_paid_confirmed event with amount/currency for funnel analytics
|
|
3573
3552
|
const paidConfirmationRecord = {
|
|
3574
3553
|
stage: 'paid',
|
|
3575
3554
|
event: 'checkout_paid_confirmed',
|
|
@@ -3646,9 +3625,6 @@ async function handleWebhook(rawBody, signature) {
|
|
|
3646
3625
|
action: isNewRevenueRecord ? 'revenue_recorded' : 'revenue_already_recorded',
|
|
3647
3626
|
});
|
|
3648
3627
|
}
|
|
3649
|
-
// Fire Plausible purchase event so the funnel poller can measure
|
|
3650
|
-
// end-to-end conversion: visitor → CTA → checkout → email → Stripe → purchase.
|
|
3651
|
-
// Fire-and-forget (never blocks the webhook response).
|
|
3652
3628
|
const purchaseEventOptions = {
|
|
3653
3629
|
page: '/success',
|
|
3654
3630
|
props: {
|
|
@@ -3757,7 +3733,7 @@ async function handleWebhook(rawBody, signature) {
|
|
|
3757
3733
|
}
|
|
3758
3734
|
|
|
3759
3735
|
function verifyGithubWebhookSignature(rawBody, signature) {
|
|
3760
|
-
if (!CONFIG.GITHUB_MARKETPLACE_WEBHOOK_SECRET) return
|
|
3736
|
+
if (!CONFIG.GITHUB_MARKETPLACE_WEBHOOK_SECRET) return allowUnsignedGithubWebhooks();
|
|
3761
3737
|
if (!signature || !rawBody) return false;
|
|
3762
3738
|
const expected = crypto.createHmac('sha256', CONFIG.GITHUB_MARKETPLACE_WEBHOOK_SECRET).update(rawBody).digest('hex');
|
|
3763
3739
|
const digest = Buffer.from(`sha256=${expected}`, 'utf8');
|
|
@@ -3765,7 +3741,359 @@ function verifyGithubWebhookSignature(rawBody, signature) {
|
|
|
3765
3741
|
return checksum.length === digest.length && crypto.timingSafeEqual(digest, checksum);
|
|
3766
3742
|
}
|
|
3767
3743
|
|
|
3768
|
-
|
|
3744
|
+
const PAYPAL_REVENUE_WEBHOOK_EVENT_TYPES = new Set([
|
|
3745
|
+
'PAYMENT.CAPTURE.COMPLETED',
|
|
3746
|
+
'PAYMENT.CAPTURE.REFUNDED',
|
|
3747
|
+
'PAYMENT.CAPTURE.REVERSED',
|
|
3748
|
+
]);
|
|
3749
|
+
const PAYPAL_WEBHOOK_REPLAY_WINDOW_MS = 4 * 24 * 60 * 60 * 1000;
|
|
3750
|
+
const PAYPAL_WEBHOOK_FUTURE_SKEW_MS = 5 * 60 * 1000;
|
|
3751
|
+
const PAYPAL_API_HOSTS = new Set(['api-m.paypal.com', 'api-m.sandbox.paypal.com']);
|
|
3752
|
+
const PAYPAL_WEBHOOK_MAX_BYTES = 1024 * 1024;
|
|
3753
|
+
|
|
3754
|
+
function normalizePayPalTransmissionHeaders(headers = {}) {
|
|
3755
|
+
const entries = Object.fromEntries(Object.entries(headers || {}).map(([key, value]) => [
|
|
3756
|
+
String(key).trim().toLowerCase(),
|
|
3757
|
+
Array.isArray(value) ? value[0] : value,
|
|
3758
|
+
]));
|
|
3759
|
+
const read = (name) => normalizeText(entries[name]);
|
|
3760
|
+
return {
|
|
3761
|
+
authAlgo: read('paypal-auth-algo'),
|
|
3762
|
+
certUrl: read('paypal-cert-url'),
|
|
3763
|
+
transmissionId: read('paypal-transmission-id'),
|
|
3764
|
+
transmissionSig: read('paypal-transmission-sig'),
|
|
3765
|
+
transmissionTime: read('paypal-transmission-time'),
|
|
3766
|
+
};
|
|
3767
|
+
}
|
|
3768
|
+
|
|
3769
|
+
function resolvePayPalWebhookVerifierConfig(overrides = {}) {
|
|
3770
|
+
const clientId = normalizeText(overrides.clientId || CONFIG.PAYPAL_CLIENT_ID);
|
|
3771
|
+
const clientSecret = normalizeText(overrides.clientSecret || CONFIG.PAYPAL_CLIENT_SECRET);
|
|
3772
|
+
const webhookId = normalizeText(overrides.webhookId || CONFIG.PAYPAL_WEBHOOK_ID);
|
|
3773
|
+
const apiBaseUrl = normalizeText(overrides.apiBaseUrl || CONFIG.PAYPAL_API_BASE_URL);
|
|
3774
|
+
if (!clientId || !clientSecret || !webhookId) {
|
|
3775
|
+
return {
|
|
3776
|
+
configured: false,
|
|
3777
|
+
reason: 'paypal_webhook_verification_not_configured',
|
|
3778
|
+
};
|
|
3779
|
+
}
|
|
3780
|
+
let parsedBaseUrl;
|
|
3781
|
+
try {
|
|
3782
|
+
parsedBaseUrl = new URL(apiBaseUrl);
|
|
3783
|
+
} catch {
|
|
3784
|
+
return { configured: false, reason: 'invalid_paypal_api_base_url' };
|
|
3785
|
+
}
|
|
3786
|
+
if (parsedBaseUrl.protocol !== 'https:' || !PAYPAL_API_HOSTS.has(parsedBaseUrl.hostname)) {
|
|
3787
|
+
return { configured: false, reason: 'untrusted_paypal_api_base_url' };
|
|
3788
|
+
}
|
|
3789
|
+
if (!/^[A-Za-z0-9]+$/.test(webhookId) || webhookId.length > 50) {
|
|
3790
|
+
return { configured: false, reason: 'invalid_paypal_webhook_id' };
|
|
3791
|
+
}
|
|
3792
|
+
return {
|
|
3793
|
+
configured: true,
|
|
3794
|
+
clientId,
|
|
3795
|
+
clientSecret,
|
|
3796
|
+
webhookId,
|
|
3797
|
+
apiBaseUrl: parsedBaseUrl.toString(),
|
|
3798
|
+
};
|
|
3799
|
+
}
|
|
3800
|
+
|
|
3801
|
+
async function readPayPalJsonResponse(response) {
|
|
3802
|
+
try {
|
|
3803
|
+
return await response.json();
|
|
3804
|
+
} catch {
|
|
3805
|
+
return null;
|
|
3806
|
+
}
|
|
3807
|
+
}
|
|
3808
|
+
|
|
3809
|
+
async function verifyPayPalWebhookSignature({
|
|
3810
|
+
rawBody,
|
|
3811
|
+
headers,
|
|
3812
|
+
fetchImpl = globalThis.fetch,
|
|
3813
|
+
now = new Date().toISOString(),
|
|
3814
|
+
...configOverrides
|
|
3815
|
+
} = {}) {
|
|
3816
|
+
const config = resolvePayPalWebhookVerifierConfig(configOverrides);
|
|
3817
|
+
if (!config.configured) return { verified: false, reason: config.reason };
|
|
3818
|
+
if (typeof fetchImpl !== 'function') return { verified: false, reason: 'paypal_verification_fetch_unavailable' };
|
|
3819
|
+
const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody || '');
|
|
3820
|
+
if (body.length === 0) return { verified: false, reason: 'missing_paypal_webhook_body' };
|
|
3821
|
+
if (body.length > PAYPAL_WEBHOOK_MAX_BYTES) return { verified: false, reason: 'paypal_webhook_body_too_large' };
|
|
3822
|
+
|
|
3823
|
+
let event;
|
|
3824
|
+
try {
|
|
3825
|
+
event = JSON.parse(body.toString('utf8'));
|
|
3826
|
+
} catch {
|
|
3827
|
+
return { verified: false, reason: 'invalid_paypal_webhook_json' };
|
|
3828
|
+
}
|
|
3829
|
+
const eventId = normalizeText(event && event.id);
|
|
3830
|
+
const eventType = normalizeText(event && event.event_type);
|
|
3831
|
+
const eventCreatedAt = normalizeText(event && event.create_time);
|
|
3832
|
+
const eventCreatedMs = new Date(eventCreatedAt || '').getTime();
|
|
3833
|
+
if (!eventId || eventId.length > 128 || !Number.isFinite(eventCreatedMs) ||
|
|
3834
|
+
!event.resource || typeof event.resource !== 'object' || Array.isArray(event.resource) ||
|
|
3835
|
+
!PAYPAL_REVENUE_WEBHOOK_EVENT_TYPES.has(eventType)) {
|
|
3836
|
+
return { verified: false, reason: 'invalid_or_unsupported_paypal_webhook_event' };
|
|
3837
|
+
}
|
|
3838
|
+
|
|
3839
|
+
const transmission = normalizePayPalTransmissionHeaders(headers);
|
|
3840
|
+
if (Object.values(transmission).some((value) => !value)) {
|
|
3841
|
+
return { verified: false, reason: 'missing_paypal_transmission_headers' };
|
|
3842
|
+
}
|
|
3843
|
+
let certUrl;
|
|
3844
|
+
try {
|
|
3845
|
+
certUrl = new URL(transmission.certUrl);
|
|
3846
|
+
} catch {
|
|
3847
|
+
return { verified: false, reason: 'invalid_paypal_cert_url' };
|
|
3848
|
+
}
|
|
3849
|
+
if (certUrl.protocol !== 'https:') return { verified: false, reason: 'invalid_paypal_cert_url' };
|
|
3850
|
+
const nowMs = new Date(now).getTime();
|
|
3851
|
+
const transmissionMs = new Date(transmission.transmissionTime).getTime();
|
|
3852
|
+
if (!Number.isFinite(nowMs) || !Number.isFinite(transmissionMs) ||
|
|
3853
|
+
transmissionMs > nowMs + PAYPAL_WEBHOOK_FUTURE_SKEW_MS ||
|
|
3854
|
+
nowMs - transmissionMs > PAYPAL_WEBHOOK_REPLAY_WINDOW_MS) {
|
|
3855
|
+
return { verified: false, reason: 'stale_or_future_paypal_transmission' };
|
|
3856
|
+
}
|
|
3857
|
+
|
|
3858
|
+
let tokenResponse;
|
|
3859
|
+
try {
|
|
3860
|
+
tokenResponse = await withTimeout(fetchImpl(new URL('/v1/oauth2/token', config.apiBaseUrl), {
|
|
3861
|
+
method: 'POST',
|
|
3862
|
+
headers: {
|
|
3863
|
+
Authorization: `Basic ${Buffer.from(`${config.clientId}:${config.clientSecret}`).toString('base64')}`,
|
|
3864
|
+
Accept: 'application/json',
|
|
3865
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
3866
|
+
},
|
|
3867
|
+
body: 'grant_type=client_credentials',
|
|
3868
|
+
}));
|
|
3869
|
+
} catch {
|
|
3870
|
+
return { verified: false, reason: 'paypal_oauth_request_failed' };
|
|
3871
|
+
}
|
|
3872
|
+
const tokenPayload = await readPayPalJsonResponse(tokenResponse);
|
|
3873
|
+
if (!tokenResponse.ok || !normalizeText(tokenPayload && tokenPayload.access_token)) {
|
|
3874
|
+
return { verified: false, reason: 'paypal_oauth_rejected' };
|
|
3875
|
+
}
|
|
3876
|
+
|
|
3877
|
+
let verificationResponse;
|
|
3878
|
+
try {
|
|
3879
|
+
verificationResponse = await withTimeout(fetchImpl(new URL('/v1/notifications/verify-webhook-signature', config.apiBaseUrl), {
|
|
3880
|
+
method: 'POST',
|
|
3881
|
+
headers: {
|
|
3882
|
+
Authorization: `Bearer ${tokenPayload.access_token}`,
|
|
3883
|
+
Accept: 'application/json',
|
|
3884
|
+
'Content-Type': 'application/json',
|
|
3885
|
+
},
|
|
3886
|
+
body: JSON.stringify({
|
|
3887
|
+
auth_algo: transmission.authAlgo,
|
|
3888
|
+
cert_url: transmission.certUrl,
|
|
3889
|
+
transmission_id: transmission.transmissionId,
|
|
3890
|
+
transmission_sig: transmission.transmissionSig,
|
|
3891
|
+
transmission_time: transmission.transmissionTime,
|
|
3892
|
+
webhook_id: config.webhookId,
|
|
3893
|
+
webhook_event: event,
|
|
3894
|
+
}),
|
|
3895
|
+
}));
|
|
3896
|
+
} catch {
|
|
3897
|
+
return { verified: false, reason: 'paypal_signature_verification_request_failed' };
|
|
3898
|
+
}
|
|
3899
|
+
const verificationPayload = await readPayPalJsonResponse(verificationResponse);
|
|
3900
|
+
const verificationStatus = (normalizeText(verificationPayload && verificationPayload.verification_status) || '').toUpperCase();
|
|
3901
|
+
if (!verificationResponse.ok || verificationStatus !== 'SUCCESS') {
|
|
3902
|
+
return { verified: false, reason: 'invalid_paypal_webhook_signature' };
|
|
3903
|
+
}
|
|
3904
|
+
const paypalDebugId = normalizeText(verificationResponse.headers && verificationResponse.headers.get
|
|
3905
|
+
? verificationResponse.headers.get('paypal-debug-id')
|
|
3906
|
+
: null);
|
|
3907
|
+
return {
|
|
3908
|
+
verified: true,
|
|
3909
|
+
reason: null,
|
|
3910
|
+
event,
|
|
3911
|
+
eventId,
|
|
3912
|
+
eventType,
|
|
3913
|
+
eventCreatedAt,
|
|
3914
|
+
transmission,
|
|
3915
|
+
verificationStatus,
|
|
3916
|
+
paypalDebugId: paypalDebugId || null,
|
|
3917
|
+
webhookId: config.webhookId,
|
|
3918
|
+
};
|
|
3919
|
+
}
|
|
3920
|
+
|
|
3921
|
+
function readPayPalWebhookLedgerStrict() {
|
|
3922
|
+
const ledgerPath = CONFIG.PAYPAL_WEBHOOK_LEDGER_PATH;
|
|
3923
|
+
try {
|
|
3924
|
+
if (!fs.existsSync(ledgerPath)) return { ok: true, rows: [] };
|
|
3925
|
+
const rows = [];
|
|
3926
|
+
for (const [index, line] of fs.readFileSync(ledgerPath, 'utf8').split('\n').entries()) {
|
|
3927
|
+
if (!line.trim()) continue;
|
|
3928
|
+
const row = JSON.parse(line);
|
|
3929
|
+
if (!row || typeof row !== 'object' || Array.isArray(row)) {
|
|
3930
|
+
return { ok: false, rows: [], reason: `invalid_row_${index}` };
|
|
3931
|
+
}
|
|
3932
|
+
rows.push(row);
|
|
3933
|
+
}
|
|
3934
|
+
return { ok: true, rows };
|
|
3935
|
+
} catch {
|
|
3936
|
+
return { ok: false, rows: [], reason: 'read_failed' };
|
|
3937
|
+
}
|
|
3938
|
+
}
|
|
3939
|
+
|
|
3940
|
+
function loadPayPalWebhookLedger() {
|
|
3941
|
+
const ledger = readPayPalWebhookLedgerStrict();
|
|
3942
|
+
return ledger.ok ? ledger.rows : [];
|
|
3943
|
+
}
|
|
3944
|
+
|
|
3945
|
+
async function recordPayPalWebhookDelivery({
|
|
3946
|
+
rawBody,
|
|
3947
|
+
headers,
|
|
3948
|
+
receivedAt = new Date().toISOString(),
|
|
3949
|
+
fetchImpl = globalThis.fetch,
|
|
3950
|
+
now = receivedAt,
|
|
3951
|
+
...configOverrides
|
|
3952
|
+
} = {}) {
|
|
3953
|
+
const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody || '');
|
|
3954
|
+
const verification = await verifyPayPalWebhookSignature({
|
|
3955
|
+
rawBody: body,
|
|
3956
|
+
headers,
|
|
3957
|
+
fetchImpl,
|
|
3958
|
+
now,
|
|
3959
|
+
...configOverrides,
|
|
3960
|
+
});
|
|
3961
|
+
if (!verification.verified) return { verified: false, recorded: false, reason: verification.reason };
|
|
3962
|
+
|
|
3963
|
+
const payloadSha256 = `sha256:${crypto.createHash('sha256').update(body).digest('hex')}`;
|
|
3964
|
+
const lockPath = `${CONFIG.PAYPAL_WEBHOOK_LEDGER_PATH}.lock`;
|
|
3965
|
+
let lockAcquired = false;
|
|
3966
|
+
try {
|
|
3967
|
+
acquireDirectoryLock(lockPath);
|
|
3968
|
+
lockAcquired = true;
|
|
3969
|
+
const ledger = readPayPalWebhookLedgerStrict();
|
|
3970
|
+
if (!ledger.ok) {
|
|
3971
|
+
return { verified: true, recorded: false, reason: 'paypal_webhook_ledger_unreadable' };
|
|
3972
|
+
}
|
|
3973
|
+
const eventId = normalizeText(verification.eventId);
|
|
3974
|
+
const transmissionId = normalizeText(verification.transmission.transmissionId);
|
|
3975
|
+
const existing = ledger.rows.find((entry) => (
|
|
3976
|
+
normalizeText(entry.eventId) === eventId ||
|
|
3977
|
+
normalizeText(entry.transmissionId) === transmissionId
|
|
3978
|
+
));
|
|
3979
|
+
if (existing) {
|
|
3980
|
+
if (normalizeText(existing.payloadSha256) !== payloadSha256 ||
|
|
3981
|
+
normalizeText(existing.eventId) !== eventId) {
|
|
3982
|
+
return { verified: false, recorded: false, reason: 'paypal_webhook_delivery_collision' };
|
|
3983
|
+
}
|
|
3984
|
+
return {
|
|
3985
|
+
verified: true, recorded: true, duplicate: true,
|
|
3986
|
+
eventId: verification.eventId, eventType: verification.eventType, payloadSha256,
|
|
3987
|
+
};
|
|
3988
|
+
}
|
|
3989
|
+
const write = appendJsonlRecord(CONFIG.PAYPAL_WEBHOOK_LEDGER_PATH, {
|
|
3990
|
+
schemaVersion: 1,
|
|
3991
|
+
provider: 'paypal',
|
|
3992
|
+
receivedAt,
|
|
3993
|
+
eventId: verification.eventId,
|
|
3994
|
+
eventType: verification.eventType,
|
|
3995
|
+
eventCreatedAt: verification.eventCreatedAt,
|
|
3996
|
+
webhookId: verification.webhookId,
|
|
3997
|
+
transmissionId: verification.transmission.transmissionId,
|
|
3998
|
+
transmissionTime: verification.transmission.transmissionTime,
|
|
3999
|
+
authAlgo: verification.transmission.authAlgo,
|
|
4000
|
+
certUrl: verification.transmission.certUrl,
|
|
4001
|
+
transmissionSig: verification.transmission.transmissionSig,
|
|
4002
|
+
verificationStatus: verification.verificationStatus,
|
|
4003
|
+
verificationSource: 'paypal_verify_webhook_signature_api',
|
|
4004
|
+
paypalDebugId: verification.paypalDebugId,
|
|
4005
|
+
payloadSha256,
|
|
4006
|
+
rawBodyBase64: body.toString('base64'),
|
|
4007
|
+
});
|
|
4008
|
+
if (!write.written) return { verified: true, recorded: false, reason: 'paypal_webhook_ledger_write_failed' };
|
|
4009
|
+
} catch {
|
|
4010
|
+
return { verified: true, recorded: false, reason: 'paypal_webhook_ledger_lock_failed' };
|
|
4011
|
+
} finally {
|
|
4012
|
+
if (lockAcquired) {
|
|
4013
|
+
try { fs.rmSync(lockPath, { recursive: true, force: true }); } catch { /* retry via provider */ }
|
|
4014
|
+
}
|
|
4015
|
+
}
|
|
4016
|
+
return {
|
|
4017
|
+
verified: true,
|
|
4018
|
+
recorded: true,
|
|
4019
|
+
duplicate: false,
|
|
4020
|
+
eventId: verification.eventId,
|
|
4021
|
+
eventType: verification.eventType,
|
|
4022
|
+
payloadSha256,
|
|
4023
|
+
};
|
|
4024
|
+
}
|
|
4025
|
+
|
|
4026
|
+
function loadGithubMarketplaceWebhookLedger() {
|
|
4027
|
+
return loadJsonlRecords(CONFIG.GITHUB_MARKETPLACE_WEBHOOK_LEDGER_PATH);
|
|
4028
|
+
}
|
|
4029
|
+
|
|
4030
|
+
function recordGithubMarketplaceWebhookDelivery({
|
|
4031
|
+
rawBody,
|
|
4032
|
+
signature,
|
|
4033
|
+
deliveryId,
|
|
4034
|
+
eventName,
|
|
4035
|
+
receivedAt = new Date().toISOString(),
|
|
4036
|
+
} = {}) {
|
|
4037
|
+
const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody || '');
|
|
4038
|
+
const normalizedDeliveryId = normalizeText(deliveryId);
|
|
4039
|
+
const normalizedEventName = normalizeText(eventName);
|
|
4040
|
+
if (!normalizedDeliveryId || normalizedEventName !== 'marketplace_purchase') {
|
|
4041
|
+
return { verified: false, recorded: false, reason: 'missing_or_invalid_github_delivery_headers' };
|
|
4042
|
+
}
|
|
4043
|
+
if (!verifyGithubWebhookSignature(body, signature)) {
|
|
4044
|
+
return { verified: false, recorded: false, reason: 'invalid_github_webhook_signature' };
|
|
4045
|
+
}
|
|
4046
|
+
|
|
4047
|
+
let event;
|
|
4048
|
+
try {
|
|
4049
|
+
event = JSON.parse(body.toString('utf8'));
|
|
4050
|
+
} catch {
|
|
4051
|
+
return { verified: false, recorded: false, reason: 'invalid_github_webhook_json' };
|
|
4052
|
+
}
|
|
4053
|
+
if (!event || !event.action || !event.marketplace_purchase) {
|
|
4054
|
+
return { verified: false, recorded: false, reason: 'invalid_github_marketplace_payload' };
|
|
4055
|
+
}
|
|
4056
|
+
|
|
4057
|
+
const payloadSha256 = `sha256:${crypto.createHash('sha256').update(body).digest('hex')}`;
|
|
4058
|
+
const existing = loadGithubMarketplaceWebhookLedger()
|
|
4059
|
+
.find((entry) => normalizeText(entry.deliveryId) === normalizedDeliveryId);
|
|
4060
|
+
if (existing) {
|
|
4061
|
+
if (normalizeText(existing.payloadSha256) !== payloadSha256) {
|
|
4062
|
+
return { verified: false, recorded: false, reason: 'github_delivery_id_collision' };
|
|
4063
|
+
}
|
|
4064
|
+
return {
|
|
4065
|
+
verified: true,
|
|
4066
|
+
recorded: true,
|
|
4067
|
+
duplicate: true,
|
|
4068
|
+
deliveryId: normalizedDeliveryId,
|
|
4069
|
+
eventName: normalizedEventName,
|
|
4070
|
+
payloadSha256,
|
|
4071
|
+
};
|
|
4072
|
+
}
|
|
4073
|
+
|
|
4074
|
+
const write = appendJsonlRecord(CONFIG.GITHUB_MARKETPLACE_WEBHOOK_LEDGER_PATH, {
|
|
4075
|
+
schemaVersion: 1,
|
|
4076
|
+
receivedAt,
|
|
4077
|
+
deliveryId: normalizedDeliveryId,
|
|
4078
|
+
eventName: normalizedEventName,
|
|
4079
|
+
signature: normalizeText(signature),
|
|
4080
|
+
payloadSha256,
|
|
4081
|
+
rawBodyBase64: body.toString('base64'),
|
|
4082
|
+
});
|
|
4083
|
+
if (!write.written) {
|
|
4084
|
+
return { verified: true, recorded: false, reason: 'github_webhook_ledger_write_failed' };
|
|
4085
|
+
}
|
|
4086
|
+
return {
|
|
4087
|
+
verified: true,
|
|
4088
|
+
recorded: true,
|
|
4089
|
+
duplicate: false,
|
|
4090
|
+
deliveryId: normalizedDeliveryId,
|
|
4091
|
+
eventName: normalizedEventName,
|
|
4092
|
+
payloadSha256,
|
|
4093
|
+
};
|
|
4094
|
+
}
|
|
4095
|
+
|
|
4096
|
+
function buildGithubMarketplaceRevenueMetadata(marketplacePurchase = {}, marketplaceOrderId, planPricing = {}, provenance = {}) {
|
|
3769
4097
|
const plan = marketplacePurchase && typeof marketplacePurchase.plan === 'object'
|
|
3770
4098
|
? marketplacePurchase.plan
|
|
3771
4099
|
: {};
|
|
@@ -3781,17 +4109,21 @@ function buildGithubMarketplaceRevenueMetadata(marketplacePurchase = {}, marketp
|
|
|
3781
4109
|
monthlyPriceInCents: normalizeInteger(plan.monthly_price_in_cents ?? plan.monthlyPriceInCents),
|
|
3782
4110
|
yearlyPriceInCents: normalizeInteger(plan.yearly_price_in_cents ?? plan.yearlyPriceInCents),
|
|
3783
4111
|
githubMarketplaceAmountSource: normalizeText(planPricing.pricingSource),
|
|
4112
|
+
githubWebhookSignatureVerified: provenance.verified === true,
|
|
4113
|
+
githubDeliveryId: normalizeText(provenance.deliveryId),
|
|
4114
|
+
githubEventName: normalizeText(provenance.eventName),
|
|
4115
|
+
githubPayloadSha256: normalizeText(provenance.payloadSha256),
|
|
3784
4116
|
};
|
|
3785
4117
|
}
|
|
3786
4118
|
|
|
3787
|
-
function handleGithubWebhook(event) {
|
|
4119
|
+
function handleGithubWebhook(event, provenance = {}) {
|
|
3788
4120
|
if (!event) return { handled: false, reason: 'missing_payload_data' };
|
|
3789
4121
|
const { action, marketplace_purchase: mp } = event;
|
|
3790
4122
|
if (!action || !mp || !mp.account?.id) return { handled: false, reason: 'missing_payload_data' };
|
|
3791
4123
|
const customerId = `github_${String(mp.account.type).toLowerCase()}_${mp.account.id}`;
|
|
3792
4124
|
const marketplaceOrderId = normalizeText(mp.id) || `github_marketplace_${String(mp.account.id)}_${String(mp.plan?.id || 'unknown')}`;
|
|
3793
4125
|
const planPricing = resolveGithubPlanPricing(mp.plan?.id, mp);
|
|
3794
|
-
const githubMetadata = buildGithubMarketplaceRevenueMetadata(mp, marketplaceOrderId, planPricing);
|
|
4126
|
+
const githubMetadata = buildGithubMarketplaceRevenueMetadata(mp, marketplaceOrderId, planPricing, provenance);
|
|
3795
4127
|
switch (action) {
|
|
3796
4128
|
case 'purchased': {
|
|
3797
4129
|
const result = provisionApiKey(customerId, { source: 'github_marketplace_purchased' });
|
|
@@ -3887,7 +4219,7 @@ function handleGithubWebhook(event) {
|
|
|
3887
4219
|
}
|
|
3888
4220
|
|
|
3889
4221
|
module.exports = {
|
|
3890
|
-
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,
|
|
4222
|
+
CONFIG, createCheckoutSession, getCheckoutSessionStatus, provisionApiKey, rotateApiKey, validateApiKey, recordUsage, disableCustomerKeys, handleWebhook, verifyWebhookSignature, verifyGithubWebhookSignature, verifyPayPalWebhookSignature, recordGithubMarketplaceWebhookDelivery, recordPayPalWebhookDelivery, handleGithubWebhook, loadKeyStore, appendFunnelEvent, appendRevenueEvent, loadFunnelLedger, loadRevenueLedger, loadGithubMarketplaceWebhookLedger, loadPayPalWebhookLedger, loadNewsletterSubscribers, loadResolvedRevenueEvents, getFunnelAnalytics, getBusinessAnalytics, getBillingSummary, getBillingSummaryLive, getBillingSummariesLive, listStripeReconciledRevenueEvents, repairGithubMarketplaceRevenueLedger,
|
|
3891
4223
|
_buildCheckoutSessionPayload: buildCheckoutSessionPayload,
|
|
3892
4224
|
_buildTrialActivationEmail: buildTrialActivationEmail,
|
|
3893
4225
|
_sendTrialActivationEmail: sendTrialActivationEmail,
|
|
@@ -3899,14 +4231,12 @@ module.exports = {
|
|
|
3899
4231
|
_API_KEYS_PATH: () => CONFIG.API_KEYS_PATH,
|
|
3900
4232
|
_FUNNEL_LEDGER_PATH: () => CONFIG.FUNNEL_LEDGER_PATH,
|
|
3901
4233
|
_REVENUE_LEDGER_PATH: () => CONFIG.REVENUE_LEDGER_PATH,
|
|
4234
|
+
_GITHUB_MARKETPLACE_WEBHOOK_LEDGER_PATH: () => CONFIG.GITHUB_MARKETPLACE_WEBHOOK_LEDGER_PATH,
|
|
4235
|
+
_PAYPAL_WEBHOOK_LEDGER_PATH: () => CONFIG.PAYPAL_WEBHOOK_LEDGER_PATH,
|
|
3902
4236
|
_LOCAL_CHECKOUT_SESSIONS_PATH: () => CONFIG.LOCAL_CHECKOUT_SESSIONS_PATH,
|
|
3903
4237
|
_TRIAL_EMAIL_LEDGER_PATH: () => CONFIG.TRIAL_EMAIL_LEDGER_PATH,
|
|
3904
4238
|
_ORDER_EMAIL_LEDGER_PATH: () => CONFIG.ORDER_EMAIL_LEDGER_PATH,
|
|
3905
4239
|
_LOCAL_MODE: () => LOCAL_MODE(),
|
|
3906
4240
|
_withTimeout: withTimeout,
|
|
3907
|
-
// Default to the real Resend-backed mailer so production webhooks send the
|
|
3908
|
-
// marketing-grade trial-welcome template. Tests overwrite this with a stub
|
|
3909
|
-
// (freshBilling() re-requires the module so the default is restored between
|
|
3910
|
-
// tests — see tests/billing-webhook-email.test.js).
|
|
3911
4241
|
_mailer: mailer,
|
|
3912
4242
|
};
|