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.
Files changed (87) hide show
  1. package/.claude/commands/dashboard.md +11 -1
  2. package/.claude/commands/thumbgate-dashboard.md +23 -8
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.well-known/llms.txt +18 -10
  5. package/.well-known/mcp/server-card.json +1 -1
  6. package/README.md +66 -3
  7. package/adapters/claude/.mcp.json +2 -2
  8. package/adapters/forge/forge.yaml +3 -3
  9. package/adapters/mcp/server-stdio.js +88 -2
  10. package/adapters/opencode/opencode.json +1 -1
  11. package/bin/cli.js +8 -8
  12. package/bin/postinstall.js +4 -13
  13. package/commands/dashboard.md +11 -1
  14. package/commands/thumbgate-dashboard.md +23 -8
  15. package/config/agent-outcome-monitor-thresholds.json +63 -0
  16. package/config/evals/agent-outcomes-baseline.json +17 -0
  17. package/config/evals/agent-outcomes-golden.json +412 -0
  18. package/config/evals/prompt-eval-baseline.json +23 -0
  19. package/config/github-about.json +5 -4
  20. package/config/post-deploy-marketing-pages.json +6 -6
  21. package/config/schemas/task-outcome-receipt.schema.json +296 -0
  22. package/docs/integrations/grafana/README.md +109 -0
  23. package/docs/integrations/grafana/thumbgate-revenue-evidence-dashboard.json +1930 -0
  24. package/openapi/openapi.yaml +475 -5
  25. package/package.json +75 -22
  26. package/public/agent-manager.html +10 -11
  27. package/public/agents-cost-savings.html +2 -2
  28. package/public/assets/brand/thumbgate-logo-transparent.svg +6 -11
  29. package/public/assets/brand/thumbgate-mark-inline-v3.svg +11 -10
  30. package/public/assets/brand/thumbgate-mark.svg +10 -11
  31. package/public/blog/inside-your-boundary.html +114 -0
  32. package/public/blog/process-over-outcome-gates.html +119 -0
  33. package/public/blog.html +296 -402
  34. package/public/brand/thumbgate-mark.svg +5 -9
  35. package/public/codex-enterprise.html +2 -2
  36. package/public/compare.html +12 -3
  37. package/public/diagnostic.html +79 -29
  38. package/public/guide.html +4 -4
  39. package/public/index.html +1090 -2098
  40. package/public/install.html +3 -3
  41. package/public/js/buyer-intent.js +33 -18
  42. package/public/numbers.html +2 -2
  43. package/public/pricing.html +268 -408
  44. package/public/pro.html +4 -4
  45. package/scripts/agent-outcome-eval.js +130 -0
  46. package/scripts/agent-outcome-monitor.js +261 -0
  47. package/scripts/agent-reasoning-traces.js +8 -9
  48. package/scripts/async-job-runner.js +107 -13
  49. package/scripts/billing.js +456 -126
  50. package/scripts/buyer-paths.js +102 -0
  51. package/scripts/cli-feedback.js +2 -2
  52. package/scripts/commercial-offer.js +18 -10
  53. package/scripts/durability/step.js +121 -12
  54. package/scripts/external-customer-audit.js +881 -0
  55. package/scripts/feedback-loop.js +26 -0
  56. package/scripts/gates-engine.js +554 -19
  57. package/scripts/grafana-revenue-evidence.js +856 -0
  58. package/scripts/human-escalation.js +265 -0
  59. package/scripts/hybrid-feedback-context.js +93 -50
  60. package/scripts/jsonl-window.js +89 -0
  61. package/scripts/judge-reward-function.js +30 -18
  62. package/scripts/lesson-embedding-index.js +3 -7
  63. package/scripts/meta-agent-loop.js +20 -2
  64. package/scripts/observability-env.js +139 -0
  65. package/scripts/observability-setup.js +55 -0
  66. package/scripts/plausible-domain-config.js +4 -0
  67. package/scripts/prompt-eval.js +81 -4
  68. package/scripts/provider-live-evidence.js +1290 -0
  69. package/scripts/provider-payment-reconciler.js +442 -0
  70. package/scripts/provider-revenue-evidence.js +249 -0
  71. package/scripts/rate-limiter.js +1 -5
  72. package/scripts/revenue-action-eligibility.js +414 -0
  73. package/scripts/revenue-evidence-remediation.js +694 -0
  74. package/scripts/revenue-offer-system.js +709 -0
  75. package/scripts/sales-pipeline.js +1117 -0
  76. package/scripts/schedule-manager.js +249 -0
  77. package/scripts/seo-gsd.js +8 -4
  78. package/scripts/stripe-credentials.js +37 -0
  79. package/scripts/stripe-revenue-catalog-audit.js +363 -0
  80. package/scripts/stripe-revenue-catalog.js +164 -0
  81. package/scripts/task-outcomes.js +425 -0
  82. package/scripts/telemetry-analytics.js +23 -3
  83. package/scripts/tool-contract-validator.js +287 -59
  84. package/scripts/tool-registry.js +143 -0
  85. package/scripts/vector-store.js +83 -7
  86. package/scripts/workflow-intake-queue.js +483 -0
  87. package/src/api/server.js +647 -118
@@ -0,0 +1,1290 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const crypto = require('node:crypto');
5
+ const fs = require('node:fs');
6
+
7
+ const { formatLocalDate, resolveAnalyticsWindow } = require('./analytics-window');
8
+ const { auditProviderSnapshot, digestBuyerEmail } = require('./provider-revenue-evidence');
9
+
10
+ const PAYPAL_API_BASE_URL = 'https://api-m.paypal.com';
11
+ const PAYPAL_REPORTING_MAX_LAG_MINUTES = 180;
12
+ const PAYPAL_PAGE_SIZE = 500;
13
+ const PAYPAL_MAX_PAGES = 20;
14
+ const PAYPAL_EVENT_PAGE_SIZE = 100;
15
+ const PAYPAL_MAX_EVENT_PAGES = 20;
16
+ const DEFAULT_REQUEST_TIMEOUT_MS = 10000;
17
+ const PAYPAL_PAYMENT_EVENT_CODES = new Set(['T0002', 'T0005', 'T0006', 'T0007']);
18
+ const PAYPAL_REFUND_EVENT_CODES = new Set(['T1106', 'T1107', 'T1120', 'T1201']);
19
+ const PAYPAL_REVENUE_WEBHOOK_EVENTS = new Set([
20
+ 'PAYMENT.CAPTURE.COMPLETED',
21
+ 'PAYMENT.CAPTURE.REFUNDED',
22
+ 'PAYMENT.CAPTURE.REVERSED',
23
+ ]);
24
+ const PAYPAL_API_HOSTS = new Set(['api-m.paypal.com', 'api-m.sandbox.paypal.com']);
25
+
26
+ function providerGap(provider, gap, diagnostics = null) {
27
+ return {
28
+ provider,
29
+ audited: false,
30
+ status: 'audit_incomplete',
31
+ evidenceVerified: false,
32
+ evidenceSource: null,
33
+ evidenceDigest: null,
34
+ revenue: null,
35
+ individualPayments: [],
36
+ individualPaymentStates: [],
37
+ gap,
38
+ diagnostics,
39
+ };
40
+ }
41
+
42
+ function collectVerifiedIndividualPayments(snapshot, { now, timeZone = 'UTC' } = {}) {
43
+ const nowDate = new Date(now || new Date().toISOString());
44
+ if (Number.isNaN(nowDate.getTime())) return { ok: false, gap: 'Individual-payment audit requires a valid current timestamp.' };
45
+ let window;
46
+ try {
47
+ window = resolveAnalyticsWindow({ window: '30d', now: nowDate.toISOString(), timeZone });
48
+ } catch (error) {
49
+ return { ok: false, gap: `Individual-payment audit window is invalid: ${error.message}` };
50
+ }
51
+ const transactions = Array.isArray(snapshot?.transactions) ? snapshot.transactions : [];
52
+ const currency = String(snapshot?.currency || '').trim().toLowerCase();
53
+ const sourceReference = String(snapshot?.source?.reference || '').trim();
54
+ if (snapshot?.source?.kind !== 'provider_api_live' || !sourceReference || currency !== 'usd') {
55
+ return { ok: false, gap: 'Individual payment requires live provider evidence in USD.' };
56
+ }
57
+ const evidenceSource = `provider_api_live:${sourceReference}`;
58
+ const evidenceDigest = `sha256:${crypto.createHash('sha256').update(JSON.stringify(snapshot)).digest('hex')}`;
59
+ const ids = new Set();
60
+ const payments = [];
61
+ const states = [];
62
+ for (const [index, transaction] of transactions.entries()) {
63
+ const id = String(transaction?.id || '').trim();
64
+ const createdAt = new Date(String(transaction?.createdAt || ''));
65
+ const grossCents = transaction?.grossCents;
66
+ const refundedCents = transaction?.refundedCents;
67
+ const customerId = String(transaction?.customerId || '').trim();
68
+ const buyerEmailDigest = String(transaction?.buyerEmailDigest || '').trim().toLowerCase();
69
+ const status = String(transaction?.status || '').trim().toLowerCase();
70
+ const attributed = transaction?.productAttribution?.verified === true &&
71
+ String(transaction?.productAttribution?.product || '').trim().toLowerCase() === 'thumbgate';
72
+ const external = transaction?.customerClassification === 'external' && transaction?.ownerTest === false;
73
+ if (!id || ids.has(id) || Number.isNaN(createdAt.getTime()) ||
74
+ createdAt.getTime() > nowDate.getTime() + 5 * 60 * 1000 ||
75
+ !Number.isSafeInteger(grossCents) || grossCents <= 0 ||
76
+ !Number.isSafeInteger(refundedCents) || refundedCents < 0 || refundedCents > grossCents ||
77
+ !customerId || !/^sha256:[a-f0-9]{64}$/.test(buyerEmailDigest) || !attributed || !external ||
78
+ !['completed', 'partially_refunded', 'refunded'].includes(status)) {
79
+ return { ok: false, gap: `PayPal individual-payment candidate ${index} is malformed or unverified.` };
80
+ }
81
+ ids.add(id);
82
+ const localDate = formatLocalDate(createdAt, window.timeZone);
83
+ if (localDate < window.startLocalDate || localDate > window.endLocalDate) continue;
84
+ const netCents = grossCents - refundedCents;
85
+ const paymentState = {
86
+ provider: 'paypal',
87
+ id,
88
+ createdAt: createdAt.toISOString(),
89
+ localDate,
90
+ timeZone: window.timeZone,
91
+ status,
92
+ grossCents,
93
+ refundedCents,
94
+ netCents,
95
+ currency,
96
+ customerId,
97
+ buyerEmailDigest,
98
+ customerClassification: 'external',
99
+ ownerTest: false,
100
+ productAttribution: { verified: true, product: 'thumbgate' },
101
+ evidenceVerified: true,
102
+ evidenceSource,
103
+ evidenceDigest,
104
+ };
105
+ const invoiceId = String(transaction?.invoiceId || '').trim().slice(0, 127);
106
+ if (invoiceId) paymentState.invoiceId = invoiceId;
107
+ states.push(paymentState);
108
+ if (netCents > 0) payments.push(paymentState);
109
+ }
110
+ return { ok: true, payments, states, evidenceSource, evidenceDigest };
111
+ }
112
+
113
+ function normalizeStrings(value) {
114
+ if (!Array.isArray(value)) return [];
115
+ return [...new Set(value.map((entry) => String(entry || '').trim()).filter(Boolean))];
116
+ }
117
+
118
+ function parseJsonObject(raw) {
119
+ if (!raw) return null;
120
+ try {
121
+ const parsed = JSON.parse(raw);
122
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
128
+ function resolvePayPalConfig(env = process.env) {
129
+ const clientId = env.THUMBGATE_PAYPAL_CLIENT_ID || env.PAYPAL_CLIENT_ID || '';
130
+ const clientSecret = env.THUMBGATE_PAYPAL_CLIENT_SECRET || env.PAYPAL_CLIENT_SECRET || '';
131
+ const rules = parseJsonObject(env.THUMBGATE_PAYPAL_EVIDENCE_RULES_JSON);
132
+ const apiBaseUrl = env.THUMBGATE_PAYPAL_API_BASE_URL || PAYPAL_API_BASE_URL;
133
+ const webhookId = String(env.THUMBGATE_PAYPAL_WEBHOOK_ID || '').trim();
134
+ const webhookUrl = String(env.THUMBGATE_PAYPAL_WEBHOOK_URL || '').trim();
135
+ const webhookLedgerPath = String(env.THUMBGATE_PAYPAL_WEBHOOK_LEDGER_PATH || '').trim();
136
+ if (!clientId || !clientSecret) {
137
+ return { configured: false, gap: 'PayPal direct audit is not configured: client ID and secret are both required.' };
138
+ }
139
+ if (!rules) {
140
+ return { configured: false, gap: 'PayPal direct audit is not configured: THUMBGATE_PAYPAL_EVIDENCE_RULES_JSON is required.' };
141
+ }
142
+ const attribution = {
143
+ customFieldValues: normalizeStrings(rules.customFieldValues),
144
+ customFieldPrefixes: normalizeStrings(rules.customFieldPrefixes),
145
+ invoiceIdPrefixes: normalizeStrings(rules.invoiceIdPrefixes),
146
+ subjects: normalizeStrings(rules.subjects),
147
+ };
148
+ if (!Object.values(attribution).some((entries) => entries.length > 0)) {
149
+ return { configured: false, gap: 'PayPal evidence rules require at least one exact ThumbGate attribution matcher.' };
150
+ }
151
+ if (rules.ownerIdentifiersReviewed !== true) {
152
+ return { configured: false, gap: 'PayPal evidence rules must explicitly attest ownerIdentifiersReviewed=true.' };
153
+ }
154
+ if (rules.subscriptionsEnabled !== false) {
155
+ return { configured: false, gap: 'PayPal direct audit currently requires subscriptionsEnabled=false; subscription-state reconciliation is not yet packaged as complete.' };
156
+ }
157
+ let parsedBaseUrl;
158
+ try {
159
+ parsedBaseUrl = new URL(apiBaseUrl);
160
+ } catch {
161
+ return { configured: false, gap: 'PayPal API base URL is invalid.' };
162
+ }
163
+ if (parsedBaseUrl.protocol !== 'https:' && env.NODE_ENV !== 'test') {
164
+ return { configured: false, gap: 'PayPal API base URL must use HTTPS.' };
165
+ }
166
+ if (!PAYPAL_API_HOSTS.has(parsedBaseUrl.hostname) && env.NODE_ENV !== 'test') {
167
+ return { configured: false, gap: 'PayPal API base URL must use the official live or sandbox host.' };
168
+ }
169
+ return {
170
+ configured: true,
171
+ clientId,
172
+ clientSecret,
173
+ apiBaseUrl: parsedBaseUrl.toString(),
174
+ webhookId,
175
+ webhookUrl,
176
+ webhookLedgerPath,
177
+ attribution,
178
+ ownerAccountIds: new Set(normalizeStrings(rules.ownerAccountIds).map((entry) => entry.toLowerCase())),
179
+ ownerEmails: new Set(normalizeStrings(rules.ownerEmails).map((entry) => entry.toLowerCase())),
180
+ };
181
+ }
182
+
183
+ function formatZonedParts(date, timeZone) {
184
+ const parts = new Intl.DateTimeFormat('en-CA', {
185
+ timeZone,
186
+ year: 'numeric',
187
+ month: '2-digit',
188
+ day: '2-digit',
189
+ hour: '2-digit',
190
+ minute: '2-digit',
191
+ second: '2-digit',
192
+ hourCycle: 'h23',
193
+ }).formatToParts(date);
194
+ return Object.fromEntries(parts.filter((part) => part.type !== 'literal').map((part) => [part.type, Number(part.value)]));
195
+ }
196
+
197
+ function localMidnightToUtc(localDate, timeZone) {
198
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(localDate || ''));
199
+ if (!match) throw new Error('Invalid local date.');
200
+ const target = {
201
+ year: Number(match[1]),
202
+ month: Number(match[2]),
203
+ day: Number(match[3]),
204
+ hour: 0,
205
+ minute: 0,
206
+ second: 0,
207
+ };
208
+ const targetUtc = Date.UTC(target.year, target.month - 1, target.day);
209
+ let guess = targetUtc;
210
+ for (let index = 0; index < 4; index += 1) {
211
+ const actual = formatZonedParts(new Date(guess), timeZone);
212
+ const actualUtc = Date.UTC(actual.year, actual.month - 1, actual.day, actual.hour, actual.minute, actual.second);
213
+ guess += targetUtc - actualUtc;
214
+ }
215
+ const verified = formatZonedParts(new Date(guess), timeZone);
216
+ if (verified.year !== target.year || verified.month !== target.month || verified.day !== target.day || verified.hour !== 0) {
217
+ throw new Error(`Could not resolve local midnight for ${localDate} in ${timeZone}.`);
218
+ }
219
+ return new Date(guess);
220
+ }
221
+
222
+ function shiftLocalDate(localDate, days) {
223
+ const [year, month, day] = String(localDate).split('-').map(Number);
224
+ return new Date(Date.UTC(year, month - 1, day + days)).toISOString().slice(0, 10);
225
+ }
226
+
227
+ function exactMoneyToCents(value) {
228
+ const text = String(value ?? '').trim();
229
+ if (!/^-?\d+(?:\.\d{1,2})?$/.test(text)) return null;
230
+ const negative = text.startsWith('-');
231
+ const unsigned = negative ? text.slice(1) : text;
232
+ const [whole, fraction = ''] = unsigned.split('.');
233
+ const cents = Number(whole) * 100 + Number(fraction.padEnd(2, '0'));
234
+ return Number.isSafeInteger(cents) ? (negative ? -cents : cents) : null;
235
+ }
236
+
237
+ function safeHeader(response, name) {
238
+ try {
239
+ return response.headers?.get?.(name) || null;
240
+ } catch {
241
+ return null;
242
+ }
243
+ }
244
+
245
+ async function fetchWithTimeout(fetchImpl, url, options = {}, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
246
+ const controller = new AbortController();
247
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
248
+ try {
249
+ return await fetchImpl(url, { ...options, signal: controller.signal });
250
+ } finally {
251
+ clearTimeout(timeout);
252
+ }
253
+ }
254
+
255
+ async function responseJson(response) {
256
+ try {
257
+ return await response.json();
258
+ } catch {
259
+ return null;
260
+ }
261
+ }
262
+
263
+ async function requestPayPalAccessToken(config, fetchImpl, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
264
+ const tokenUrl = new URL('/v1/oauth2/token', config.apiBaseUrl);
265
+ let response;
266
+ try {
267
+ response = await fetchWithTimeout(fetchImpl, tokenUrl, {
268
+ method: 'POST',
269
+ headers: {
270
+ Authorization: `Basic ${Buffer.from(`${config.clientId}:${config.clientSecret}`).toString('base64')}`,
271
+ 'Content-Type': 'application/x-www-form-urlencoded',
272
+ Accept: 'application/json',
273
+ },
274
+ body: 'grant_type=client_credentials',
275
+ }, timeoutMs);
276
+ } catch {
277
+ return { ok: false, gap: 'PayPal OAuth network request failed or timed out.', diagnostics: { configured: true } };
278
+ }
279
+ const payload = await responseJson(response);
280
+ const paypalDebugId = safeHeader(response, 'paypal-debug-id');
281
+ if (!response.ok || !payload?.access_token) {
282
+ return {
283
+ ok: false,
284
+ gap: `PayPal OAuth failed with HTTP ${response.status}.`,
285
+ diagnostics: { configured: true, paypalDebugId },
286
+ };
287
+ }
288
+ return { ok: true, accessToken: payload.access_token, paypalDebugId };
289
+ }
290
+
291
+ function paypalAttributionMatches(info, rules) {
292
+ const customValues = [info.custom_field, info.custom_id]
293
+ .map((value) => String(value || '').trim())
294
+ .filter(Boolean);
295
+ const invoice = String(info.invoice_id || '').trim();
296
+ const subject = String(info.transaction_subject || '').trim();
297
+ return customValues.some((custom) => rules.customFieldValues.includes(custom)) ||
298
+ customValues.some((custom) => rules.customFieldPrefixes.some((prefix) => custom.startsWith(prefix))) ||
299
+ rules.invoiceIdPrefixes.some((prefix) => invoice.startsWith(prefix)) ||
300
+ rules.subjects.includes(subject);
301
+ }
302
+
303
+ function hashedCustomerId(accountId, email) {
304
+ const identity = String(accountId || email || '').trim().toLowerCase();
305
+ if (!identity) return null;
306
+ return `paypal_${crypto.createHash('sha256').update(identity).digest('hex').slice(0, 24)}`;
307
+ }
308
+
309
+ function parsePayPalTransactions(details, config) {
310
+ const rows = [];
311
+ const rawKeys = new Set();
312
+ for (const [index, detail] of details.entries()) {
313
+ const info = detail?.transaction_info || {};
314
+ const eventCode = String(info.transaction_event_code || '').trim().toUpperCase();
315
+ const transactionId = String(info.transaction_id || '').trim();
316
+ const initiatedAt = String(info.transaction_initiation_date || '').trim();
317
+ const currency = String(info.transaction_amount?.currency_code || '').trim().toUpperCase();
318
+ const cents = exactMoneyToCents(info.transaction_amount?.value);
319
+ const rawKey = `${transactionId}|${eventCode}|${initiatedAt}|${cents}`;
320
+ if (!transactionId || !eventCode || !initiatedAt || cents === null || rawKeys.has(rawKey)) {
321
+ return { ok: false, gap: `PayPal transaction row ${index} is malformed or duplicated.` };
322
+ }
323
+ rawKeys.add(rawKey);
324
+ rows.push({ detail, info, eventCode, transactionId, initiatedAt, currency, cents });
325
+ }
326
+
327
+ const payments = new Map();
328
+ const paymentBaseIds = new Map();
329
+ const ownerPaymentBaseIds = new Set();
330
+ let ownerRowsExcluded = 0;
331
+ let unrelatedRowsExcluded = 0;
332
+ for (const row of rows) {
333
+ if (!PAYPAL_PAYMENT_EVENT_CODES.has(row.eventCode)) continue;
334
+ if (!paypalAttributionMatches(row.info, config.attribution)) {
335
+ unrelatedRowsExcluded += 1;
336
+ continue;
337
+ }
338
+ if (row.currency !== 'USD') return { ok: false, gap: `Attributed PayPal transaction ${row.transactionId} is not USD.` };
339
+ if (row.cents <= 0) return { ok: false, gap: `Attributed PayPal payment ${row.transactionId} has a non-positive amount.` };
340
+ const createdAt = new Date(row.initiatedAt);
341
+ if (Number.isNaN(createdAt.getTime())) return { ok: false, gap: `Attributed PayPal payment ${row.transactionId} has an invalid timestamp.` };
342
+ const providerStatus = String(row.info.transaction_status || '').trim().toUpperCase();
343
+ if (!['S', 'V', 'D'].includes(providerStatus)) {
344
+ return { ok: false, gap: `Attributed PayPal payment ${row.transactionId} is pending or has an unsupported status.` };
345
+ }
346
+ if (paymentBaseIds.has(row.transactionId) || ownerPaymentBaseIds.has(row.transactionId)) {
347
+ return { ok: false, gap: `Attributed PayPal payment ${row.transactionId} is duplicated across provider rows.` };
348
+ }
349
+ const payer = row.detail?.payer_info || {};
350
+ const accountId = String(payer.account_id || row.info.paypal_account_id || '').trim();
351
+ const email = String(payer.email_address || '').trim().toLowerCase();
352
+ const isOwner = config.ownerAccountIds.has(accountId.toLowerCase()) || config.ownerEmails.has(email);
353
+ if (isOwner) {
354
+ ownerRowsExcluded += 1;
355
+ ownerPaymentBaseIds.add(row.transactionId);
356
+ continue;
357
+ }
358
+ const customerId = hashedCustomerId(accountId, email);
359
+ if (!customerId) return { ok: false, gap: `Attributed PayPal payment ${row.transactionId} has no stable payer identity.` };
360
+ const buyerEmailDigest = digestBuyerEmail(email);
361
+ const id = `${row.transactionId}:${row.eventCode}:${row.initiatedAt}`;
362
+ const transaction = {
363
+ id,
364
+ providerTransactionId: row.transactionId,
365
+ status: providerStatus === 'V' ? 'refunded' : (providerStatus === 'D' ? 'failed' : 'completed'),
366
+ createdAt: createdAt.toISOString(),
367
+ grossCents: row.cents,
368
+ refundedCents: providerStatus === 'V' ? row.cents : 0,
369
+ customerId,
370
+ ...(buyerEmailDigest ? { buyerEmailDigest } : {}),
371
+ customerClassification: 'external',
372
+ ownerTest: false,
373
+ productAttribution: { verified: true, product: 'thumbgate' },
374
+ };
375
+ const invoiceId = String(row.info.invoice_id || '').trim().slice(0, 127);
376
+ if (invoiceId) transaction.invoiceId = invoiceId;
377
+ if (payments.has(id)) return { ok: false, gap: `PayPal payment ${id} is duplicated.` };
378
+ payments.set(id, transaction);
379
+ const matches = paymentBaseIds.get(row.transactionId) || [];
380
+ matches.push(transaction);
381
+ paymentBaseIds.set(row.transactionId, matches);
382
+ }
383
+
384
+ for (const row of rows) {
385
+ if (PAYPAL_PAYMENT_EVENT_CODES.has(row.eventCode)) continue;
386
+ const referenceId = String(row.info.paypal_reference_id || '').trim();
387
+ const referenced = referenceId ? (paymentBaseIds.get(referenceId) || []) : [];
388
+ const directlyAttributed = paypalAttributionMatches(row.info, config.attribution);
389
+ if (referenceId && ownerPaymentBaseIds.has(referenceId) && referenced.length === 0) {
390
+ ownerRowsExcluded += 1;
391
+ continue;
392
+ }
393
+ if (!PAYPAL_REFUND_EVENT_CODES.has(row.eventCode)) {
394
+ if (directlyAttributed || referenced.length > 0) {
395
+ return { ok: false, gap: `Attributed PayPal row ${row.transactionId} uses unsupported revenue event code ${row.eventCode}.` };
396
+ }
397
+ unrelatedRowsExcluded += 1;
398
+ continue;
399
+ }
400
+ if (!directlyAttributed && referenced.length === 0) {
401
+ unrelatedRowsExcluded += 1;
402
+ continue;
403
+ }
404
+ if (referenced.length !== 1) {
405
+ return { ok: false, gap: `PayPal refund ${row.transactionId} does not resolve to exactly one attributed payment.` };
406
+ }
407
+ if (row.currency !== 'USD' || row.cents >= 0) {
408
+ return { ok: false, gap: `PayPal refund ${row.transactionId} must be a negative USD movement.` };
409
+ }
410
+ const payment = referenced[0];
411
+ payment.refundedCents += Math.abs(row.cents);
412
+ if (payment.refundedCents > payment.grossCents) {
413
+ return { ok: false, gap: `PayPal refunds exceed gross for ${referenceId}.` };
414
+ }
415
+ payment.status = payment.refundedCents === payment.grossCents ? 'refunded' : 'partially_refunded';
416
+ }
417
+
418
+ return {
419
+ ok: true,
420
+ transactions: [...payments.values()],
421
+ diagnostics: { ownerRowsExcluded, unrelatedRowsExcluded, rawRowCount: rows.length },
422
+ };
423
+ }
424
+
425
+ async function collectPayPalCandidateSnapshot({
426
+ env = process.env,
427
+ fetchImpl = fetch,
428
+ now = new Date().toISOString(),
429
+ timeZone = 'UTC',
430
+ timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
431
+ } = {}) {
432
+ const config = resolvePayPalConfig(env);
433
+ if (!config.configured) return { ok: false, gap: config.gap, diagnostics: { configured: false } };
434
+ const window = resolveAnalyticsWindow({ window: '30d', now, timeZone });
435
+ const start = localMidnightToUtc(window.startLocalDate, window.timeZone);
436
+ const endExclusive = localMidnightToUtc(shiftLocalDate(window.endLocalDate, 1), window.timeZone);
437
+ const end = new Date(endExclusive.getTime() - 1000);
438
+
439
+ const token = await requestPayPalAccessToken(config, fetchImpl, timeoutMs);
440
+ if (!token.ok) return token;
441
+
442
+ const requestReferences = [];
443
+ const details = [];
444
+ let expectedTotalPages = 1;
445
+ let lastRefreshedAt = null;
446
+ for (let page = 1; page <= expectedTotalPages; page += 1) {
447
+ const url = new URL('/v1/reporting/transactions', config.apiBaseUrl);
448
+ url.searchParams.set('start_date', start.toISOString());
449
+ url.searchParams.set('end_date', end.toISOString());
450
+ url.searchParams.set('fields', 'all');
451
+ url.searchParams.set('balance_affecting_records_only', 'Y');
452
+ url.searchParams.set('page_size', String(PAYPAL_PAGE_SIZE));
453
+ url.searchParams.set('page', String(page));
454
+ let response;
455
+ try {
456
+ response = await fetchWithTimeout(fetchImpl, url, {
457
+ headers: {
458
+ Authorization: `Bearer ${token.accessToken}`,
459
+ Accept: 'application/json',
460
+ 'Content-Type': 'application/json',
461
+ 'PayPal-Enforce-ISO8601-Format': 'true',
462
+ },
463
+ }, timeoutMs);
464
+ } catch {
465
+ return { ok: false, gap: 'PayPal Transaction Search network request failed or timed out.', diagnostics: { configured: true, page } };
466
+ }
467
+ const payload = await responseJson(response);
468
+ const debugId = safeHeader(response, 'paypal-debug-id');
469
+ if (debugId) requestReferences.push(debugId);
470
+ if (!response.ok || !payload || !Array.isArray(payload.transaction_details)) {
471
+ return {
472
+ ok: false,
473
+ gap: `PayPal Transaction Search failed with HTTP ${response.status} or malformed JSON on page ${page}.`,
474
+ diagnostics: { configured: true, page, paypalDebugId: debugId },
475
+ };
476
+ }
477
+ const totalPages = Number(payload.total_pages ?? 1);
478
+ const totalItems = Number(payload.total_items ?? payload.transaction_details.length);
479
+ if (!Number.isSafeInteger(totalPages) || totalPages < 1 || totalPages > PAYPAL_MAX_PAGES ||
480
+ !Number.isSafeInteger(totalItems) || totalItems < 0 || totalItems > PAYPAL_PAGE_SIZE * PAYPAL_MAX_PAGES) {
481
+ return { ok: false, gap: 'PayPal result set is too large or has invalid pagination; shorten the query range.', diagnostics: { configured: true, page } };
482
+ }
483
+ if (page === 1) expectedTotalPages = totalPages;
484
+ if (totalPages !== expectedTotalPages) {
485
+ return { ok: false, gap: 'PayPal pagination changed during collection.', diagnostics: { configured: true, page } };
486
+ }
487
+ details.push(...payload.transaction_details);
488
+ lastRefreshedAt = payload.last_refreshed_datetime || lastRefreshedAt;
489
+ }
490
+
491
+ const parsed = parsePayPalTransactions(details, config);
492
+ if (!parsed.ok) return { ok: false, gap: parsed.gap, diagnostics: { configured: true, ...parsed.diagnostics } };
493
+ const sourceReference = requestReferences.length > 0
494
+ ? `paypal-debug-ids:${requestReferences.join(',')}`
495
+ : `transaction-search:${window.startLocalDate}:${window.endLocalDate}`;
496
+ return {
497
+ ok: true,
498
+ snapshot: {
499
+ schemaVersion: 1,
500
+ provider: 'paypal',
501
+ generatedAt: new Date(now).toISOString(),
502
+ source: {
503
+ kind: 'provider_api_live',
504
+ reference: sourceReference,
505
+ },
506
+ currency: 'usd',
507
+ scope: {
508
+ completeness: 'provider_reporting_lagged',
509
+ timeZone: window.timeZone,
510
+ startLocalDate: window.startLocalDate,
511
+ endLocalDate: window.endLocalDate,
512
+ maximumReportingLagMinutes: PAYPAL_REPORTING_MAX_LAG_MINUTES,
513
+ },
514
+ transactions: parsed.transactions,
515
+ subscriptions: [],
516
+ },
517
+ diagnostics: {
518
+ configured: true,
519
+ collected: true,
520
+ pageCount: expectedTotalPages,
521
+ rawRowCount: details.length,
522
+ candidateTransactionCount: parsed.transactions.length,
523
+ ownerRowsExcluded: parsed.diagnostics.ownerRowsExcluded,
524
+ unrelatedRowsExcluded: parsed.diagnostics.unrelatedRowsExcluded,
525
+ lastRefreshedAt,
526
+ maximumReportingLagMinutes: PAYPAL_REPORTING_MAX_LAG_MINUTES,
527
+ financialTransactionsComplete: false,
528
+ },
529
+ };
530
+ }
531
+
532
+ function loadPayPalWebhookLedgerCandidate(ledgerPath, expectedWebhookId) {
533
+ if (!ledgerPath) return { ok: false, gap: 'PayPal webhook ledger path is not configured.' };
534
+ let raw;
535
+ try {
536
+ raw = fs.readFileSync(ledgerPath, 'utf8');
537
+ } catch (error) {
538
+ return { ok: false, gap: `PayPal webhook ledger could not be read: ${error.message}` };
539
+ }
540
+ const events = new Map();
541
+ const transmissions = new Set();
542
+ const lines = raw.split('\n').map((line) => line.trim()).filter(Boolean);
543
+ for (const [index, line] of lines.entries()) {
544
+ let row;
545
+ try {
546
+ row = JSON.parse(line);
547
+ } catch {
548
+ return { ok: false, gap: `PayPal webhook ledger row ${index} is not valid JSON.` };
549
+ }
550
+ const body = decodeCanonicalBase64(row.rawBodyBase64);
551
+ const eventId = String(row.eventId || '').trim();
552
+ const transmissionId = String(row.transmissionId || '').trim();
553
+ if (row.schemaVersion !== 1 || row.provider !== 'paypal' || !eventId || !transmissionId ||
554
+ events.has(eventId) || transmissions.has(transmissionId) || !body ||
555
+ row.webhookId !== expectedWebhookId || row.verificationStatus !== 'SUCCESS' ||
556
+ row.verificationSource !== 'paypal_verify_webhook_signature_api') {
557
+ return { ok: false, gap: `PayPal webhook ledger row ${index} is malformed, duplicated, or belongs to another webhook.` };
558
+ }
559
+ const digest = `sha256:${crypto.createHash('sha256').update(body).digest('hex')}`;
560
+ if (row.payloadSha256 !== digest) {
561
+ return { ok: false, gap: `PayPal webhook ledger row ${index} failed its raw-payload digest check.` };
562
+ }
563
+ let event;
564
+ try {
565
+ event = JSON.parse(body.toString('utf8'));
566
+ } catch {
567
+ return { ok: false, gap: `PayPal webhook ledger row ${index} contains invalid payload JSON.` };
568
+ }
569
+ if (event.id !== eventId || event.event_type !== row.eventType ||
570
+ event.create_time !== row.eventCreatedAt || !PAYPAL_REVENUE_WEBHOOK_EVENTS.has(event.event_type) ||
571
+ !event.resource || typeof event.resource !== 'object' || Array.isArray(event.resource)) {
572
+ return { ok: false, gap: `PayPal webhook ledger row ${index} disagrees with its verified raw event.` };
573
+ }
574
+ transmissions.add(transmissionId);
575
+ events.set(eventId, event);
576
+ }
577
+ return {
578
+ ok: true,
579
+ events,
580
+ reference: `sha256:${crypto.createHash('sha256').update(raw).digest('hex')}`,
581
+ };
582
+ }
583
+
584
+ function paypalResourceIdFromLink(resource, resourceName) {
585
+ for (const link of Array.isArray(resource?.links) ? resource.links : []) {
586
+ try {
587
+ const match = new RegExp(`/v2/${resourceName}/([A-Za-z0-9_-]{1,128})/?$`).exec(new URL(link.href).pathname);
588
+ if (match) return match[1];
589
+ } catch { /* ignore malformed provider link */ }
590
+ }
591
+ return null;
592
+ }
593
+
594
+ function paypalCaptureIdFromEvent(event) {
595
+ const resource = event?.resource || {};
596
+ const direct = event?.event_type === 'PAYMENT.CAPTURE.COMPLETED' || event?.event_type === 'PAYMENT.CAPTURE.REVERSED'
597
+ ? resource.id
598
+ : resource?.supplementary_data?.related_ids?.capture_id;
599
+ const captureId = String(direct || paypalResourceIdFromLink(resource, 'payments/captures') || '').trim();
600
+ return /^[A-Za-z0-9_-]{1,128}$/.test(captureId) ? captureId : null;
601
+ }
602
+
603
+ function paypalOrderId(resource) {
604
+ const orderId = String(resource?.supplementary_data?.related_ids?.order_id ||
605
+ paypalResourceIdFromLink(resource, 'checkout/orders') || '').trim();
606
+ return /^[A-Za-z0-9_-]{1,128}$/.test(orderId) ? orderId : null;
607
+ }
608
+
609
+ function samePayPalEvent(left, right) {
610
+ const fingerprint = (event) => JSON.stringify({
611
+ id: event?.id,
612
+ eventType: event?.event_type,
613
+ createdAt: event?.create_time,
614
+ resourceId: event?.resource?.id,
615
+ resourceStatus: event?.resource?.status,
616
+ currency: event?.resource?.amount?.currency_code,
617
+ value: event?.resource?.amount?.value,
618
+ customId: event?.resource?.custom_id,
619
+ invoiceId: event?.resource?.invoice_id,
620
+ captureId: paypalCaptureIdFromEvent(event),
621
+ orderId: paypalOrderId(event?.resource),
622
+ });
623
+ return fingerprint(left) === fingerprint(right);
624
+ }
625
+
626
+ function safePayPalNextUrl(href, config, expectedQuery) {
627
+ let url;
628
+ try {
629
+ url = new URL(href, config.apiBaseUrl);
630
+ const base = new URL(config.apiBaseUrl);
631
+ if (url.origin !== base.origin || !/^\/v1\/notifications\/webhooks-events\/?$/.test(url.pathname) ||
632
+ ['start_time', 'end_time', 'page_size'].some((key) => url.searchParams.get(key) !== expectedQuery[key])) return null;
633
+ } catch {
634
+ return null;
635
+ }
636
+ return url;
637
+ }
638
+
639
+ async function paypalGetJson(config, accessToken, fetchImpl, url, timeoutMs, label) {
640
+ let response;
641
+ try {
642
+ response = await fetchWithTimeout(fetchImpl, url, {
643
+ headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
644
+ }, timeoutMs);
645
+ } catch {
646
+ return { ok: false, gap: `${label} network request failed or timed out.` };
647
+ }
648
+ const payload = await responseJson(response);
649
+ const paypalDebugId = safeHeader(response, 'paypal-debug-id');
650
+ if (!response.ok || !payload || typeof payload !== 'object' || Array.isArray(payload)) {
651
+ return { ok: false, gap: `${label} failed with HTTP ${response.status} or malformed JSON.`, paypalDebugId };
652
+ }
653
+ return { ok: true, payload, paypalDebugId };
654
+ }
655
+
656
+ function buildPayPalRecentTransaction(events, capture, order, config) {
657
+ const captureId = String(capture?.id || '').trim();
658
+ const orderId = paypalOrderId(capture);
659
+ const status = String(capture?.status || '').trim().toUpperCase();
660
+ const currency = String(capture?.amount?.currency_code || '').trim().toUpperCase();
661
+ const grossCents = exactMoneyToCents(capture?.amount?.value);
662
+ const createdAt = new Date(String(capture?.create_time || ''));
663
+ if (!captureId || !orderId || order?.id !== orderId || order?.status !== 'COMPLETED' ||
664
+ !['COMPLETED', 'PARTIALLY_REFUNDED', 'REFUNDED'].includes(status) || currency !== 'USD' ||
665
+ !Number.isSafeInteger(grossCents) || grossCents <= 0 || Number.isNaN(createdAt.getTime())) {
666
+ return { ok: false, gap: `PayPal capture ${captureId || '(missing)'} has incomplete or unsupported current financial state.` };
667
+ }
668
+ const matches = [];
669
+ for (const unit of Array.isArray(order.purchase_units) ? order.purchase_units : []) {
670
+ for (const orderCapture of Array.isArray(unit?.payments?.captures) ? unit.payments.captures : []) {
671
+ if (orderCapture?.id === captureId) matches.push({ unit, orderCapture });
672
+ }
673
+ }
674
+ if (matches.length !== 1) return { ok: false, gap: `PayPal order ${orderId} does not contain exactly one matching capture ${captureId}.` };
675
+ const { unit, orderCapture } = matches[0];
676
+ if (String(orderCapture.status || '').toUpperCase() !== status ||
677
+ String(orderCapture.amount?.currency_code || '').toUpperCase() !== currency ||
678
+ exactMoneyToCents(orderCapture.amount?.value) !== grossCents) {
679
+ return { ok: false, gap: `PayPal capture ${captureId} disagrees with order ${orderId}.` };
680
+ }
681
+ const captureCustomId = String(capture.custom_id || '').trim();
682
+ const unitCustomId = String(unit.custom_id || '').trim();
683
+ const captureInvoiceId = String(capture.invoice_id || '').trim();
684
+ const unitInvoiceId = String(unit.invoice_id || '').trim();
685
+ if ((captureCustomId && unitCustomId && captureCustomId !== unitCustomId) ||
686
+ (captureInvoiceId && unitInvoiceId && captureInvoiceId !== unitInvoiceId)) {
687
+ return { ok: false, gap: `PayPal capture ${captureId} attribution disagrees with order ${orderId}.` };
688
+ }
689
+ const attributionInfo = {
690
+ custom_id: captureCustomId || unitCustomId,
691
+ invoice_id: captureInvoiceId || unitInvoiceId,
692
+ transaction_subject: unit.description,
693
+ };
694
+ if (!paypalAttributionMatches(attributionInfo, config.attribution)) {
695
+ return { ok: true, transaction: null, excluded: 'unrelated' };
696
+ }
697
+ const payer = order?.payment_source?.paypal || order?.payer || {};
698
+ const accountId = String(payer.account_id || payer.payer_id || '').trim();
699
+ const email = String(payer.email_address || '').trim().toLowerCase();
700
+ if (config.ownerAccountIds.has(accountId.toLowerCase()) || config.ownerEmails.has(email)) {
701
+ return { ok: true, transaction: null, excluded: 'owner' };
702
+ }
703
+ if (events.some((event) => event.event_type === 'PAYMENT.CAPTURE.REVERSED')) {
704
+ return { ok: true, transaction: null, excluded: 'reversed' };
705
+ }
706
+ const customerId = hashedCustomerId(accountId, email);
707
+ if (!customerId) return { ok: false, gap: `PayPal order ${orderId} has no stable payer identity.` };
708
+ const buyerEmailDigest = digestBuyerEmail(email);
709
+ let refundedCents = exactMoneyToCents(capture?.seller_receivable_breakdown?.total_refunded_amount?.value);
710
+ const refundCurrency = String(capture?.seller_receivable_breakdown?.total_refunded_amount?.currency_code || '').toUpperCase();
711
+ if (refundedCents === null) refundedCents = 0;
712
+ if ((refundedCents > 0 && refundCurrency !== 'USD') || refundedCents < 0 || refundedCents > grossCents ||
713
+ (status === 'COMPLETED' && refundedCents !== 0) ||
714
+ (status === 'PARTIALLY_REFUNDED' && (refundedCents <= 0 || refundedCents >= grossCents)) ||
715
+ (status === 'REFUNDED' && refundedCents !== grossCents)) {
716
+ return { ok: false, gap: `PayPal capture ${captureId} has an inconsistent refund state.` };
717
+ }
718
+ if (!events.some((event) => paypalCaptureIdFromEvent(event) === captureId)) {
719
+ return { ok: false, gap: `PayPal capture ${captureId} has no matching recent provider event.` };
720
+ }
721
+ for (const event of events.filter((entry) => paypalCaptureIdFromEvent(entry) === captureId)) {
722
+ const eventCustomId = String(event?.resource?.custom_id || '').trim();
723
+ const eventInvoiceId = String(event?.resource?.invoice_id || '').trim();
724
+ if ((eventCustomId && attributionInfo.custom_id && eventCustomId !== attributionInfo.custom_id) ||
725
+ (eventInvoiceId && attributionInfo.invoice_id && eventInvoiceId !== attributionInfo.invoice_id)) {
726
+ return { ok: false, gap: `PayPal event attribution disagrees with current capture ${captureId}.` };
727
+ }
728
+ }
729
+ return {
730
+ ok: true,
731
+ transaction: {
732
+ id: `paypal-recent:${captureId}`,
733
+ providerTransactionId: captureId,
734
+ status: status.toLowerCase(),
735
+ createdAt: createdAt.toISOString(),
736
+ grossCents,
737
+ refundedCents,
738
+ customerId,
739
+ ...(buyerEmailDigest ? { buyerEmailDigest } : {}),
740
+ customerClassification: 'external',
741
+ ownerTest: false,
742
+ productAttribution: { verified: true, product: 'thumbgate' },
743
+ ...(String(attributionInfo.invoice_id || '').trim()
744
+ ? { invoiceId: String(attributionInfo.invoice_id).trim().slice(0, 127) }
745
+ : {}),
746
+ },
747
+ };
748
+ }
749
+
750
+ async function collectPayPalRecentPaymentSnapshot({
751
+ env = process.env,
752
+ fetchImpl = fetch,
753
+ now = new Date().toISOString(),
754
+ timeZone = 'UTC',
755
+ timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
756
+ } = {}) {
757
+ const config = resolvePayPalConfig(env);
758
+ if (!config.configured) return { ok: false, gap: config.gap, diagnostics: { configured: false } };
759
+ if (!config.webhookId || !config.webhookUrl || !config.webhookLedgerPath) {
760
+ return { ok: false, gap: 'PayPal recent-payment reconciliation requires webhook ID, URL, and ledger path.', diagnostics: { configured: true } };
761
+ }
762
+ let configuredWebhookUrl;
763
+ try {
764
+ configuredWebhookUrl = new URL(config.webhookUrl);
765
+ } catch {
766
+ return { ok: false, gap: 'PayPal recent-payment reconciliation requires a valid webhook URL.' };
767
+ }
768
+ if (!/^[A-Za-z0-9]{1,50}$/.test(config.webhookId) || configuredWebhookUrl.protocol !== 'https:' ||
769
+ configuredWebhookUrl.username || configuredWebhookUrl.password || configuredWebhookUrl.hash) {
770
+ return { ok: false, gap: 'PayPal recent-payment reconciliation requires a valid webhook ID and HTTPS callback URL.' };
771
+ }
772
+ const ledger = loadPayPalWebhookLedgerCandidate(config.webhookLedgerPath, config.webhookId);
773
+ if (!ledger.ok) return { ok: false, gap: ledger.gap, diagnostics: { configured: true } };
774
+ const token = await requestPayPalAccessToken(config, fetchImpl, timeoutMs);
775
+ if (!token.ok) return token;
776
+ const registration = await paypalGetJson(
777
+ config, token.accessToken, fetchImpl,
778
+ new URL(`/v1/notifications/webhooks/${encodeURIComponent(config.webhookId)}`, config.apiBaseUrl),
779
+ timeoutMs, 'PayPal webhook registration lookup'
780
+ );
781
+ if (!registration.ok) return { ok: false, gap: registration.gap, diagnostics: { configured: true, paypalDebugId: registration.paypalDebugId } };
782
+ const registeredEvents = new Set((Array.isArray(registration.payload.event_types) ? registration.payload.event_types : [])
783
+ .map((event) => String(event?.name || '').trim()));
784
+ if (registration.payload.id !== config.webhookId || registration.payload.url !== config.webhookUrl ||
785
+ [...PAYPAL_REVENUE_WEBHOOK_EVENTS].some((event) => !registeredEvents.has(event))) {
786
+ return { ok: false, gap: 'PayPal webhook registration does not match the configured ID, URL, and required revenue event set.' };
787
+ }
788
+ const nowDate = new Date(now);
789
+ if (Number.isNaN(nowDate.getTime())) return { ok: false, gap: 'PayPal recent-payment reconciliation requires a valid current timestamp.' };
790
+ const eventStart = new Date(nowDate.getTime() - (PAYPAL_REPORTING_MAX_LAG_MINUTES + 10) * 60000);
791
+ let nextUrl = new URL('/v1/notifications/webhooks-events', config.apiBaseUrl);
792
+ nextUrl.searchParams.set('start_time', eventStart.toISOString());
793
+ nextUrl.searchParams.set('end_time', nowDate.toISOString());
794
+ nextUrl.searchParams.set('page_size', String(PAYPAL_EVENT_PAGE_SIZE));
795
+ const expectedQuery = Object.fromEntries(['start_time', 'end_time', 'page_size'].map((key) => [key, nextUrl.searchParams.get(key)]));
796
+ const providerEvents = new Map();
797
+ const requestReferences = [token.paypalDebugId, registration.paypalDebugId].filter(Boolean);
798
+ const visited = new Set();
799
+ let pageCount = 0;
800
+ while (nextUrl) {
801
+ if (pageCount >= PAYPAL_MAX_EVENT_PAGES || visited.has(nextUrl.toString())) {
802
+ return { ok: false, gap: 'PayPal webhook event history pagination exceeded its safe bound or repeated a page.' };
803
+ }
804
+ visited.add(nextUrl.toString());
805
+ pageCount += 1;
806
+ const result = await paypalGetJson(config, token.accessToken, fetchImpl, nextUrl, timeoutMs, 'PayPal webhook event history');
807
+ if (!result.ok) return { ok: false, gap: result.gap, diagnostics: { configured: true, page: pageCount, paypalDebugId: result.paypalDebugId } };
808
+ if (!Array.isArray(result.payload.events) || result.payload.events.length > PAYPAL_EVENT_PAGE_SIZE) {
809
+ return { ok: false, gap: `PayPal webhook event history page ${pageCount} is malformed or exceeds its requested page size.` };
810
+ }
811
+ if (result.paypalDebugId) requestReferences.push(result.paypalDebugId);
812
+ for (const event of result.payload.events) {
813
+ const eventId = String(event?.id || '').trim();
814
+ const createdAt = new Date(String(event?.create_time || ''));
815
+ if (!eventId || providerEvents.has(eventId) || Number.isNaN(createdAt.getTime()) ||
816
+ createdAt < eventStart || createdAt > nowDate ||
817
+ !event.resource || typeof event.resource !== 'object' || Array.isArray(event.resource)) {
818
+ return { ok: false, gap: `PayPal webhook event history page ${pageCount} contains a malformed or duplicate event.` };
819
+ }
820
+ providerEvents.set(eventId, event);
821
+ }
822
+ const next = (Array.isArray(result.payload.links) ? result.payload.links : []).filter((link) => link?.rel === 'next');
823
+ if (next.length > 1) return { ok: false, gap: 'PayPal webhook event history contains ambiguous next-page links.' };
824
+ if (next.length === 0) nextUrl = null;
825
+ else {
826
+ nextUrl = safePayPalNextUrl(next[0].href, config, expectedQuery);
827
+ if (!nextUrl) return { ok: false, gap: 'PayPal webhook event history returned an unsafe next-page URL.' };
828
+ }
829
+ }
830
+ const revenueEvents = [...providerEvents.values()].filter((event) => PAYPAL_REVENUE_WEBHOOK_EVENTS.has(event.event_type));
831
+ const byCapture = new Map();
832
+ for (const event of revenueEvents) {
833
+ const captureId = paypalCaptureIdFromEvent(event);
834
+ if (!captureId) return { ok: false, gap: `PayPal revenue event ${event.id} has no valid capture reference.` };
835
+ const entries = byCapture.get(captureId) || [];
836
+ entries.push(event);
837
+ byCapture.set(captureId, entries);
838
+ }
839
+ const transactions = [];
840
+ let ownerRowsExcluded = 0;
841
+ let unrelatedRowsExcluded = 0;
842
+ let reversalRowsExcluded = 0;
843
+ for (const [captureId, events] of byCapture) {
844
+ const captureResult = await paypalGetJson(config, token.accessToken, fetchImpl,
845
+ new URL(`/v2/payments/captures/${encodeURIComponent(captureId)}`, config.apiBaseUrl), timeoutMs, 'PayPal capture lookup');
846
+ if (!captureResult.ok) return { ok: false, gap: captureResult.gap, diagnostics: { configured: true, paypalDebugId: captureResult.paypalDebugId } };
847
+ if (captureResult.paypalDebugId) requestReferences.push(captureResult.paypalDebugId);
848
+ const orderId = paypalOrderId(captureResult.payload);
849
+ if (!orderId) return { ok: false, gap: `PayPal capture ${captureId} has no valid related order.` };
850
+ const orderUrl = new URL(`/v2/checkout/orders/${encodeURIComponent(orderId)}`, config.apiBaseUrl);
851
+ orderUrl.searchParams.set('fields', 'payment_source');
852
+ const orderResult = await paypalGetJson(config, token.accessToken, fetchImpl,
853
+ orderUrl, timeoutMs, 'PayPal order lookup');
854
+ if (!orderResult.ok) return { ok: false, gap: orderResult.gap, diagnostics: { configured: true, paypalDebugId: orderResult.paypalDebugId } };
855
+ if (orderResult.paypalDebugId) requestReferences.push(orderResult.paypalDebugId);
856
+ const built = buildPayPalRecentTransaction(events, captureResult.payload, orderResult.payload, config);
857
+ if (!built.ok) return { ok: false, gap: built.gap, diagnostics: { configured: true } };
858
+ if (built.excluded === 'owner') ownerRowsExcluded += 1;
859
+ else if (built.excluded === 'unrelated') unrelatedRowsExcluded += 1;
860
+ else if (built.excluded === 'reversed') reversalRowsExcluded += 1;
861
+ else transactions.push(built.transaction);
862
+ }
863
+ let locallyMatchedEventCount = 0;
864
+ for (const event of revenueEvents) {
865
+ const eventId = event.id;
866
+ if (ledger.events.has(eventId) && samePayPalEvent(event, ledger.events.get(eventId))) locallyMatchedEventCount += 1;
867
+ else if (ledger.events.has(eventId)) return { ok: false, gap: `PayPal provider event ${eventId} disagrees with the locally verified delivery.` };
868
+ }
869
+ const window = resolveAnalyticsWindow({ window: '30d', now, timeZone });
870
+ const localOnlyEventCount = [...ledger.events.entries()].filter(([eventId, event]) => {
871
+ const createdAt = new Date(event.create_time);
872
+ return createdAt >= eventStart && createdAt <= nowDate && !providerEvents.has(eventId);
873
+ }).length;
874
+ const referenceMaterial = JSON.stringify({
875
+ eventIds: revenueEvents.map((event) => event.id).sort((a, b) => a.localeCompare(b)),
876
+ requestReferences: [...new Set(requestReferences)].sort((a, b) => a.localeCompare(b)),
877
+ ledgerReference: ledger.reference,
878
+ });
879
+ return {
880
+ ok: true,
881
+ snapshot: {
882
+ schemaVersion: 1,
883
+ provider: 'paypal',
884
+ generatedAt: nowDate.toISOString(),
885
+ source: { kind: 'provider_api_live', reference: `paypal-recent-reconciliation:sha256:${crypto.createHash('sha256').update(referenceMaterial).digest('hex')}` },
886
+ currency: 'usd',
887
+ scope: {
888
+ completeness: 'provider_reporting_lagged',
889
+ timeZone: window.timeZone,
890
+ startLocalDate: window.startLocalDate,
891
+ endLocalDate: window.endLocalDate,
892
+ maximumReportingLagMinutes: PAYPAL_REPORTING_MAX_LAG_MINUTES,
893
+ },
894
+ transactions,
895
+ subscriptions: [],
896
+ },
897
+ diagnostics: {
898
+ configured: true,
899
+ registeredWebhookVerified: true,
900
+ eventPageCount: pageCount,
901
+ providerEventCount: providerEvents.size,
902
+ revenueEventCount: revenueEvents.length,
903
+ candidateTransactionCount: transactions.length,
904
+ ownerRowsExcluded,
905
+ unrelatedRowsExcluded,
906
+ reversalRowsExcluded,
907
+ locallyMatchedEventCount,
908
+ missedLocalWebhookCount: revenueEvents.length - locallyMatchedEventCount,
909
+ localOnlyEventCount,
910
+ financialTransactionsComplete: false,
911
+ },
912
+ };
913
+ }
914
+
915
+ function mergePayPalIndividualSnapshots(reporting, recent) {
916
+ const transactions = [...reporting.transactions];
917
+ const byProviderId = new Map(transactions.map((transaction, index) => [transaction.providerTransactionId, { transaction, index }]));
918
+ for (const transaction of recent.transactions) {
919
+ const prior = byProviderId.get(transaction.providerTransactionId);
920
+ if (!prior) {
921
+ transactions.push(transaction);
922
+ continue;
923
+ }
924
+ if (prior.transaction.grossCents !== transaction.grossCents || prior.transaction.customerId !== transaction.customerId ||
925
+ prior.transaction.buyerEmailDigest !== transaction.buyerEmailDigest ||
926
+ String(prior.transaction.invoiceId || '') !== String(transaction.invoiceId || '') ||
927
+ Math.abs(new Date(prior.transaction.createdAt).getTime() - new Date(transaction.createdAt).getTime()) > 15 * 60 * 1000) {
928
+ return { ok: false, gap: `PayPal reporting and recent detail disagree for capture ${transaction.providerTransactionId}.` };
929
+ }
930
+ transactions[prior.index] = { ...transaction, id: prior.transaction.id };
931
+ }
932
+ const reference = `paypal-merged:sha256:${crypto.createHash('sha256').update(JSON.stringify([
933
+ reporting.source.reference, recent.source.reference,
934
+ ])).digest('hex')}`;
935
+ return { ok: true, snapshot: { ...reporting, source: { kind: 'provider_api_live', reference }, transactions } };
936
+ }
937
+
938
+ async function auditPayPalLiveEvidence(options = {}) {
939
+ const config = resolvePayPalConfig(options.env || process.env);
940
+ const recentSettings = config.configured ? [config.webhookId, config.webhookUrl, config.webhookLedgerPath] : [];
941
+ if (recentSettings.some(Boolean) && !recentSettings.every(Boolean)) {
942
+ return providerGap('paypal', 'PayPal recent-payment reconciliation is partially configured; webhook ID, URL, and ledger path are all required.', { configured: true });
943
+ }
944
+ const candidate = await collectPayPalCandidateSnapshot(options);
945
+ if (!candidate.ok) return providerGap('paypal', candidate.gap, candidate.diagnostics);
946
+ const recentConfigured = Boolean(config.configured && config.webhookId && config.webhookUrl && config.webhookLedgerPath);
947
+ let evidenceSnapshot = candidate.snapshot;
948
+ let recent = null;
949
+ if (recentConfigured) {
950
+ recent = await collectPayPalRecentPaymentSnapshot(options);
951
+ if (!recent.ok) return providerGap('paypal', recent.gap, { ...candidate.diagnostics, recentPaymentReconciliation: recent.diagnostics || null });
952
+ const merged = mergePayPalIndividualSnapshots(candidate.snapshot, recent.snapshot);
953
+ if (!merged.ok) return providerGap('paypal', merged.gap, { ...candidate.diagnostics, recentPaymentReconciliation: recent.diagnostics });
954
+ evidenceSnapshot = merged.snapshot;
955
+ }
956
+ const individual = collectVerifiedIndividualPayments(evidenceSnapshot, options);
957
+ if (!individual.ok) return providerGap('paypal', individual.gap, candidate.diagnostics);
958
+ const result = auditProviderSnapshot(candidate.snapshot, {
959
+ expectedProvider: 'paypal',
960
+ now: options.now,
961
+ timeZone: options.timeZone,
962
+ });
963
+ return {
964
+ ...result,
965
+ status: recent ? 'provider_api_and_recent_events_collected_but_incomplete' : 'provider_api_collected_but_incomplete',
966
+ evidenceSource: `provider_api_live:${evidenceSnapshot.source.reference}`,
967
+ gap: recent
968
+ ? 'Authenticated recent PayPal events and current capture/order details can prove individual payments, but they do not enumerate every balance-affecting movement; global revenue remains incomplete.'
969
+ : 'PayPal Transaction Search can lag by up to three hours; current-day all-transaction completeness requires authenticated recent event reconciliation before this slice can enter global revenue arithmetic.',
970
+ individualPayments: individual.payments,
971
+ individualPaymentStates: individual.states,
972
+ diagnostics: {
973
+ ...candidate.diagnostics,
974
+ recentPaymentReconciliation: recent?.diagnostics || null,
975
+ recentPaymentReconciliationConfigured: recentConfigured,
976
+ verifiedIndividualPaymentCount: individual.payments.length,
977
+ verifiedIndividualPaymentStateCount: individual.states.length,
978
+ individualPaymentEvidenceDigest: individual.evidenceDigest,
979
+ },
980
+ };
981
+ }
982
+
983
+ function decodeCanonicalBase64(value) {
984
+ const text = String(value || '').trim();
985
+ if (!text || !/^[A-Za-z0-9+/]+={0,2}$/.test(text)) return null;
986
+ const buffer = Buffer.from(text, 'base64');
987
+ return buffer.toString('base64') === text ? buffer : null;
988
+ }
989
+
990
+ function collectGithubMarketplaceLedgerCandidate({
991
+ ledgerPath,
992
+ secret,
993
+ now = new Date().toISOString(),
994
+ timeZone = 'UTC',
995
+ } = {}) {
996
+ if (!ledgerPath) return { ok: false, gap: 'GitHub Marketplace signed webhook ledger path is not configured.' };
997
+ if (!secret) return { ok: false, gap: 'GitHub Marketplace webhook secret is not configured; stored deliveries cannot be re-verified.' };
998
+ let raw;
999
+ try {
1000
+ raw = fs.readFileSync(ledgerPath, 'utf8');
1001
+ } catch (error) {
1002
+ return { ok: false, gap: `GitHub Marketplace webhook ledger could not be read: ${error.message}` };
1003
+ }
1004
+ const lines = raw.split('\n').map((line) => line.trim()).filter(Boolean);
1005
+ const deliveries = [];
1006
+ const deliveryIds = new Set();
1007
+ for (const [index, line] of lines.entries()) {
1008
+ let row;
1009
+ try {
1010
+ row = JSON.parse(line);
1011
+ } catch {
1012
+ return { ok: false, gap: `GitHub Marketplace webhook ledger row ${index} is not valid JSON.` };
1013
+ }
1014
+ const body = decodeCanonicalBase64(row.rawBodyBase64);
1015
+ const deliveryId = String(row.deliveryId || '').trim();
1016
+ const signature = String(row.signature || '').trim();
1017
+ if (row.schemaVersion !== 1 || row.eventName !== 'marketplace_purchase' || !deliveryId || deliveryIds.has(deliveryId) || !body) {
1018
+ return { ok: false, gap: `GitHub Marketplace webhook ledger row ${index} is malformed or duplicated.` };
1019
+ }
1020
+ deliveryIds.add(deliveryId);
1021
+ const expectedDigest = `sha256:${crypto.createHash('sha256').update(body).digest('hex')}`;
1022
+ const expectedSignature = `sha256=${crypto.createHmac('sha256', secret).update(body).digest('hex')}`;
1023
+ const signatureBytes = Buffer.from(signature);
1024
+ const expectedBytes = Buffer.from(expectedSignature);
1025
+ if (row.payloadSha256 !== expectedDigest || signatureBytes.length !== expectedBytes.length ||
1026
+ !crypto.timingSafeEqual(signatureBytes, expectedBytes)) {
1027
+ return { ok: false, gap: `GitHub Marketplace webhook ledger row ${index} failed digest or HMAC verification.` };
1028
+ }
1029
+ let event;
1030
+ try {
1031
+ event = JSON.parse(body.toString('utf8'));
1032
+ } catch {
1033
+ return { ok: false, gap: `GitHub Marketplace webhook ledger row ${index} contains invalid payload JSON.` };
1034
+ }
1035
+ if (!['purchased', 'changed', 'cancelled'].includes(event.action) || !event.marketplace_purchase?.account?.id) {
1036
+ return { ok: false, gap: `GitHub Marketplace webhook ledger row ${index} contains an unsupported Marketplace event.` };
1037
+ }
1038
+ deliveries.push({ row, event });
1039
+ }
1040
+ const window = resolveAnalyticsWindow({ window: '30d', now, timeZone });
1041
+ return {
1042
+ ok: true,
1043
+ snapshot: {
1044
+ schemaVersion: 1,
1045
+ provider: 'githubMarketplace',
1046
+ generatedAt: new Date(now).toISOString(),
1047
+ source: {
1048
+ kind: 'signed_webhook_ledger',
1049
+ reference: `sha256:${crypto.createHash('sha256').update(raw).digest('hex')}`,
1050
+ },
1051
+ currency: 'usd',
1052
+ scope: {
1053
+ completeness: 'subscription_events_only',
1054
+ timeZone: window.timeZone,
1055
+ startLocalDate: window.startLocalDate,
1056
+ endLocalDate: window.endLocalDate,
1057
+ },
1058
+ transactions: [],
1059
+ subscriptions: [],
1060
+ },
1061
+ diagnostics: {
1062
+ deliveryCount: deliveries.length,
1063
+ signaturesVerified: true,
1064
+ subscriptionEventsVerified: true,
1065
+ financialTransactionsComplete: false,
1066
+ officialFinancialSourceRequired: 'GitHub Marketplace Transactions CSV export',
1067
+ },
1068
+ };
1069
+ }
1070
+
1071
+ function auditGithubMarketplaceLedgerEvidence(options = {}) {
1072
+ const candidate = collectGithubMarketplaceLedgerCandidate(options);
1073
+ if (!candidate.ok) return providerGap('githubMarketplace', candidate.gap, candidate.diagnostics || null);
1074
+ return providerGap(
1075
+ 'githubMarketplace',
1076
+ 'Signed GitHub Marketplace webhooks verify subscription-event integrity, not charged transaction amounts, proration, refunds, or complete financial history. The official Transactions CSV export is still required for global revenue arithmetic.',
1077
+ candidate.diagnostics
1078
+ );
1079
+ }
1080
+
1081
+ function parseCsvRows(text) {
1082
+ const rows = [];
1083
+ let row = [];
1084
+ let field = '';
1085
+ let quoted = false;
1086
+ for (let index = 0; index < text.length; index += 1) {
1087
+ const character = text[index];
1088
+ if (quoted) {
1089
+ if (character === '"' && text[index + 1] === '"') {
1090
+ field += '"';
1091
+ index += 1;
1092
+ } else if (character === '"') {
1093
+ quoted = false;
1094
+ } else {
1095
+ field += character;
1096
+ }
1097
+ continue;
1098
+ }
1099
+ if (character === '"' && field.length === 0) {
1100
+ quoted = true;
1101
+ } else if (character === ',') {
1102
+ row.push(field);
1103
+ field = '';
1104
+ } else if (character === '\n') {
1105
+ row.push(field.endsWith('\r') ? field.slice(0, -1) : field);
1106
+ rows.push(row);
1107
+ row = [];
1108
+ field = '';
1109
+ } else {
1110
+ field += character;
1111
+ }
1112
+ }
1113
+ if (quoted) throw new Error('CSV contains an unterminated quoted field.');
1114
+ if (field.length > 0 || row.length > 0) {
1115
+ row.push(field.endsWith('\r') ? field.slice(0, -1) : field);
1116
+ rows.push(row);
1117
+ }
1118
+ return rows.filter((entry) => entry.some((cell) => cell !== ''));
1119
+ }
1120
+
1121
+ function collectGithubMarketplaceCsvSnapshot({
1122
+ csvPath,
1123
+ expectedAppName,
1124
+ ownerAccountIds = [],
1125
+ ownerIdentifiersReviewed = false,
1126
+ exportScope = null,
1127
+ now = new Date().toISOString(),
1128
+ timeZone = 'UTC',
1129
+ } = {}) {
1130
+ if (!csvPath) return { ok: false, gap: 'GitHub Marketplace Transactions CSV path is not configured.' };
1131
+ if (!expectedAppName) return { ok: false, gap: 'GitHub Marketplace expected app name is required for product attribution.' };
1132
+ if (ownerIdentifiersReviewed !== true) return { ok: false, gap: 'GitHub Marketplace owner identifiers must be explicitly reviewed.' };
1133
+ if (String(exportScope || '').trim().toLowerCase() !== 'all') return { ok: false, gap: 'GitHub Marketplace CSV must be exported with the entire-duration scope.' };
1134
+ let raw;
1135
+ let stat;
1136
+ try {
1137
+ raw = fs.readFileSync(csvPath);
1138
+ stat = fs.statSync(csvPath);
1139
+ } catch (error) {
1140
+ return { ok: false, gap: `GitHub Marketplace Transactions CSV could not be read: ${error.message}` };
1141
+ }
1142
+ let rows;
1143
+ try {
1144
+ rows = parseCsvRows(raw.toString('utf8'));
1145
+ } catch (error) {
1146
+ return { ok: false, gap: `GitHub Marketplace Transactions CSV is malformed: ${error.message}` };
1147
+ }
1148
+ if (rows.length < 1) return { ok: false, gap: 'GitHub Marketplace Transactions CSV has no header row.' };
1149
+ const headers = rows[0].map((header) => header.trim());
1150
+ const requiredHeaders = [
1151
+ 'date',
1152
+ 'app_name',
1153
+ 'user_login',
1154
+ 'user_id',
1155
+ 'user_type',
1156
+ 'country',
1157
+ 'amount_in_cents',
1158
+ 'renewal_frequency',
1159
+ 'marketplace_listing_plan_id',
1160
+ 'region',
1161
+ 'postal_code',
1162
+ ];
1163
+ if (new Set(headers).size !== headers.length || requiredHeaders.some((header) => !headers.includes(header))) {
1164
+ return { ok: false, gap: 'GitHub Marketplace Transactions CSV headers are missing, duplicated, or incompatible.' };
1165
+ }
1166
+ const window = resolveAnalyticsWindow({ window: '30d', now, timeZone });
1167
+ const ownerIds = new Set(normalizeStrings(ownerAccountIds).map((entry) => entry.toLowerCase()));
1168
+ const seenRows = new Set();
1169
+ const transactions = [];
1170
+ let ownerRowsExcluded = 0;
1171
+ let zeroAmountRows = 0;
1172
+ for (let index = 1; index < rows.length; index += 1) {
1173
+ if (rows[index].length !== headers.length) {
1174
+ return { ok: false, gap: `GitHub Marketplace Transactions CSV row ${index + 1} has the wrong column count.` };
1175
+ }
1176
+ const entry = Object.fromEntries(headers.map((header, column) => [header, rows[index][column].trim()]));
1177
+ if (entry.app_name !== expectedAppName) {
1178
+ return { ok: false, gap: `GitHub Marketplace CSV row ${index + 1} does not match the expected app name.` };
1179
+ }
1180
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(entry.date) || !entry.user_id || !entry.user_login ||
1181
+ !['User', 'Organization'].includes(entry.user_type) || !entry.marketplace_listing_plan_id ||
1182
+ !['Monthly', 'Yearly'].includes(entry.renewal_frequency)) {
1183
+ return { ok: false, gap: `GitHub Marketplace Transactions CSV row ${index + 1} has invalid identity, date, plan, or renewal fields.` };
1184
+ }
1185
+ const parsedDate = new Date(`${entry.date}T00:00:00.000Z`);
1186
+ if (Number.isNaN(parsedDate.getTime()) || parsedDate.toISOString().slice(0, 10) !== entry.date) {
1187
+ return { ok: false, gap: `GitHub Marketplace Transactions CSV row ${index + 1} has an impossible calendar date.` };
1188
+ }
1189
+ if (!/^\d+$/.test(entry.amount_in_cents)) {
1190
+ return { ok: false, gap: `GitHub Marketplace Transactions CSV row ${index + 1} has a negative or non-integer amount.` };
1191
+ }
1192
+ const amountCents = Number(entry.amount_in_cents);
1193
+ if (!Number.isSafeInteger(amountCents)) {
1194
+ return { ok: false, gap: `GitHub Marketplace Transactions CSV row ${index + 1} has an unsafe amount.` };
1195
+ }
1196
+ const canonical = JSON.stringify(entry);
1197
+ const rowDigest = crypto.createHash('sha256').update(canonical).digest('hex');
1198
+ if (seenRows.has(rowDigest)) {
1199
+ return { ok: false, gap: `GitHub Marketplace Transactions CSV row ${index + 1} duplicates another row without a provider transaction ID.` };
1200
+ }
1201
+ seenRows.add(rowDigest);
1202
+ if (ownerIds.has(entry.user_id.toLowerCase())) {
1203
+ ownerRowsExcluded += 1;
1204
+ continue;
1205
+ }
1206
+ if (amountCents === 0) {
1207
+ zeroAmountRows += 1;
1208
+ continue;
1209
+ }
1210
+ if (entry.date < window.startLocalDate || entry.date > window.endLocalDate) continue;
1211
+ const createdAt = new Date(localMidnightToUtc(entry.date, window.timeZone).getTime() + 12 * 60 * 60 * 1000);
1212
+ transactions.push({
1213
+ id: `github-csv-${rowDigest}`,
1214
+ status: 'completed',
1215
+ createdAt: createdAt.toISOString(),
1216
+ grossCents: amountCents,
1217
+ refundedCents: 0,
1218
+ customerId: `github_${entry.user_type.toLowerCase()}_${crypto.createHash('sha256').update(entry.user_id).digest('hex').slice(0, 24)}`,
1219
+ customerClassification: 'external',
1220
+ ownerTest: false,
1221
+ productAttribution: { verified: true, product: 'thumbgate' },
1222
+ });
1223
+ }
1224
+ const digest = `sha256:${crypto.createHash('sha256').update(raw).digest('hex')}`;
1225
+ return {
1226
+ ok: true,
1227
+ snapshot: {
1228
+ schemaVersion: 1,
1229
+ provider: 'githubMarketplace',
1230
+ generatedAt: stat.mtime.toISOString(),
1231
+ source: {
1232
+ kind: 'provider_api_export',
1233
+ reference: `github-marketplace-transactions-csv:${digest}`,
1234
+ },
1235
+ currency: 'usd',
1236
+ scope: {
1237
+ completeness: 'all_transactions',
1238
+ subscriptionsCompleteness: 'not_audited',
1239
+ timeZone: window.timeZone,
1240
+ startLocalDate: window.startLocalDate,
1241
+ endLocalDate: window.endLocalDate,
1242
+ },
1243
+ transactions,
1244
+ subscriptions: [],
1245
+ },
1246
+ diagnostics: {
1247
+ csvDigest: digest,
1248
+ sourceRowCount: rows.length - 1,
1249
+ candidateTransactionCount: transactions.length,
1250
+ ownerRowsExcluded,
1251
+ zeroAmountRows,
1252
+ subscriptionsComplete: false,
1253
+ mrrClaimed: false,
1254
+ },
1255
+ };
1256
+ }
1257
+
1258
+ function auditGithubMarketplaceCsvEvidence(options = {}) {
1259
+ const candidate = collectGithubMarketplaceCsvSnapshot(options);
1260
+ if (!candidate.ok) return providerGap('githubMarketplace', candidate.gap, candidate.diagnostics || null);
1261
+ const result = auditProviderSnapshot(candidate.snapshot, {
1262
+ expectedProvider: 'githubMarketplace',
1263
+ now: options.now,
1264
+ timeZone: options.timeZone,
1265
+ });
1266
+ return { ...result, diagnostics: candidate.diagnostics };
1267
+ }
1268
+
1269
+ module.exports = {
1270
+ PAYPAL_MAX_PAGES,
1271
+ PAYPAL_PAGE_SIZE,
1272
+ PAYPAL_REPORTING_MAX_LAG_MINUTES,
1273
+ auditGithubMarketplaceCsvEvidence,
1274
+ auditGithubMarketplaceLedgerEvidence,
1275
+ auditPayPalLiveEvidence,
1276
+ buildPayPalRecentTransaction,
1277
+ collectVerifiedIndividualPayments,
1278
+ collectGithubMarketplaceLedgerCandidate,
1279
+ collectGithubMarketplaceCsvSnapshot,
1280
+ collectPayPalCandidateSnapshot,
1281
+ collectPayPalRecentPaymentSnapshot,
1282
+ exactMoneyToCents,
1283
+ localMidnightToUtc,
1284
+ loadPayPalWebhookLedgerCandidate,
1285
+ mergePayPalIndividualSnapshots,
1286
+ parsePayPalTransactions,
1287
+ parseCsvRows,
1288
+ providerGap,
1289
+ resolvePayPalConfig,
1290
+ };