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
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
const { runAudit: auditStripeLiveEvidence } = require('./external-customer-audit');
|
|
8
|
+
const { auditPayPalLiveEvidence } = require('./provider-live-evidence');
|
|
9
|
+
const { digestBuyerEmail } = require('./provider-revenue-evidence');
|
|
10
|
+
const { OFFER_CATALOG } = require('./revenue-offer-system');
|
|
11
|
+
const {
|
|
12
|
+
VERIFIED_PAYMENT_DIGEST_PATTERN,
|
|
13
|
+
VERIFIED_PAYMENT_SOURCE_PATTERN,
|
|
14
|
+
advanceSalesLead,
|
|
15
|
+
getSalesPipelinePath,
|
|
16
|
+
loadSalesLeads,
|
|
17
|
+
} = require('./sales-pipeline');
|
|
18
|
+
|
|
19
|
+
function normalizeRequiredText(value, label, maxLength = 1000) {
|
|
20
|
+
const text = String(value || '').trim().slice(0, maxLength);
|
|
21
|
+
if (!text) throw new Error(`${label} is required.`);
|
|
22
|
+
return text;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseArgs(argv = []) {
|
|
26
|
+
const options = {};
|
|
27
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
28
|
+
const arg = argv[index];
|
|
29
|
+
if (!arg.startsWith('--')) continue;
|
|
30
|
+
const eqIndex = arg.indexOf('=', 2);
|
|
31
|
+
const rawKey = eqIndex === -1 ? arg.slice(2) : arg.slice(2, eqIndex);
|
|
32
|
+
const key = rawKey.replaceAll(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
33
|
+
if (eqIndex !== -1) {
|
|
34
|
+
options[key] = arg.slice(eqIndex + 1);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const nextArg = argv[index + 1];
|
|
38
|
+
if (nextArg && !nextArg.startsWith('--')) {
|
|
39
|
+
options[key] = nextArg;
|
|
40
|
+
index += 1;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
options[key] = true;
|
|
44
|
+
}
|
|
45
|
+
return options;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const SUPPORTED_PAYMENT_PROVIDERS = new Set(['paypal', 'stripe']);
|
|
49
|
+
|
|
50
|
+
function offer(id, leadOffers = [id]) {
|
|
51
|
+
return Object.freeze([id, Object.freeze(leadOffers)]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const OFFERS = Object.freeze({
|
|
55
|
+
[OFFER_CATALOG.workflow_hardening_diagnostic.priceCents]: offer(
|
|
56
|
+
'workflow_hardening_diagnostic', ['workflow_hardening_diagnostic', 'workflow_diagnostic']),
|
|
57
|
+
[OFFER_CATALOG.workflow_hardening_sprint.priceCents]: offer('workflow_hardening_sprint'),
|
|
58
|
+
[OFFER_CATALOG.workflow_hardening_sprint.priceCents
|
|
59
|
+
- OFFER_CATALOG.workflow_hardening_diagnostic.priceCents]: offer(
|
|
60
|
+
'workflow_hardening_sprint_diagnostic_credit_balance', ['workflow_hardening_sprint']),
|
|
61
|
+
[OFFER_CATALOG.pro.priceCents]: offer('pro_monthly', ['pro', 'pro_self_serve', 'pro_monthly']),
|
|
62
|
+
[OFFER_CATALOG.pro.annualPriceCents]: offer('pro_annual', ['pro', 'pro_self_serve', 'pro_annual']),
|
|
63
|
+
4900: offer('team_monthly_legacy', ['team', 'team_monthly_legacy']),
|
|
64
|
+
[OFFER_CATALOG.workflow_reliability_operations.priceCents]: offer('workflow_reliability_operations'),
|
|
65
|
+
[OFFER_CATALOG.enterprise_governance_pilot.priceCents]: offer('enterprise_governance_pilot'),
|
|
66
|
+
[OFFER_CATALOG.enterprise_reliability_operations.priceCents]: offer('enterprise_reliability_operations'),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
function offerIdsFor(payment = {}) {
|
|
70
|
+
const attribution = payment.productAttribution || {};
|
|
71
|
+
const values = [
|
|
72
|
+
...(Array.isArray(payment.offerIds) ? payment.offerIds : []),
|
|
73
|
+
payment.offerId,
|
|
74
|
+
...(Array.isArray(attribution.offerIds) ? attribution.offerIds : []),
|
|
75
|
+
attribution.offerId,
|
|
76
|
+
];
|
|
77
|
+
return [...new Set(values
|
|
78
|
+
.map((value) => String(value || '').trim().slice(0, 120))
|
|
79
|
+
.filter(Boolean))].sort((a, b) => a.localeCompare(b));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function matchPaymentOffer(payment = {}, lead = {}) {
|
|
83
|
+
const leadOffer = String(lead.offer || '').trim();
|
|
84
|
+
const identity = OFFERS[payment.grossCents];
|
|
85
|
+
if (!identity) {
|
|
86
|
+
throw new Error(`${payment.provider} payment ${payment.id} amount has no exact ThumbGate offer.`);
|
|
87
|
+
}
|
|
88
|
+
const [offerId, leadOffers] = identity;
|
|
89
|
+
const offerIds = Array.isArray(payment.offerIds) ? payment.offerIds : [];
|
|
90
|
+
if (payment.provider === 'stripe' && offerIds.length !== 1) {
|
|
91
|
+
throw new Error(`Stripe ${payment.id} requires one catalog offer ID.`);
|
|
92
|
+
}
|
|
93
|
+
if (payment.provider === 'paypal' && offerIds.length > 1) {
|
|
94
|
+
throw new Error(`PayPal ${payment.id} has multiple offer IDs.`);
|
|
95
|
+
}
|
|
96
|
+
if (offerIds.length === 1 && offerIds[0] !== offerId) {
|
|
97
|
+
throw new Error(`${payment.provider} payment ${payment.id} offer disagrees with amount.`);
|
|
98
|
+
}
|
|
99
|
+
if (!leadOffers.includes(leadOffer)) {
|
|
100
|
+
throw new Error(`${payment.provider} payment ${payment.id} is for ${offerId}, not ${leadOffer || '(missing)'}.`);
|
|
101
|
+
}
|
|
102
|
+
const buyerDigest = digestBuyerEmail(lead.contact?.email);
|
|
103
|
+
if (!buyerDigest) {
|
|
104
|
+
throw new Error(`Sales lead ${lead.leadId || '(missing)'} needs buyer email.`);
|
|
105
|
+
}
|
|
106
|
+
if (payment.buyerEmailDigest !== buyerDigest) {
|
|
107
|
+
throw new Error(`${payment.provider} payment ${payment.id} buyer does not match lead ${lead.leadId || '(missing)'}.`);
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
paymentOfferId: offerId,
|
|
111
|
+
buyerEmailDigest: buyerDigest,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function validateVerifiedPayment(payment = {}, expectedId, expectedProvider = 'paypal', { auditedAt } = {}) {
|
|
116
|
+
const paymentId = String(payment.id || '').trim();
|
|
117
|
+
const provider = String(payment.provider || '').trim().toLowerCase();
|
|
118
|
+
const evidenceSource = String(payment.evidenceSource || '').trim();
|
|
119
|
+
const evidenceDigest = String(payment.evidenceDigest || '').trim().toLowerCase();
|
|
120
|
+
const status = String(payment.status || '').trim().toLowerCase();
|
|
121
|
+
const grossCents = payment.grossCents;
|
|
122
|
+
const refundedCents = payment.refundedCents;
|
|
123
|
+
const netCents = payment.netCents;
|
|
124
|
+
const currency = String(payment.currency || '').trim().toLowerCase();
|
|
125
|
+
const createdAt = new Date(String(payment.createdAt || ''));
|
|
126
|
+
const auditTime = new Date(String(auditedAt || ''));
|
|
127
|
+
const productAttribution = payment.productAttribution || {};
|
|
128
|
+
const invoiceId = String(payment.invoiceId || '').trim().slice(0, 127) || null;
|
|
129
|
+
const offerIds = offerIdsFor(payment);
|
|
130
|
+
const buyerEmailDigest = String(payment.buyerEmailDigest || '').trim().toLowerCase();
|
|
131
|
+
|
|
132
|
+
if (paymentId !== expectedId || provider !== expectedProvider ||
|
|
133
|
+
!SUPPORTED_PAYMENT_PROVIDERS.has(provider) || payment.evidenceVerified !== true
|
|
134
|
+
|| !VERIFIED_PAYMENT_SOURCE_PATTERN.test(evidenceSource)
|
|
135
|
+
|| !VERIFIED_PAYMENT_DIGEST_PATTERN.test(evidenceDigest)
|
|
136
|
+
|| !Number.isSafeInteger(grossCents) || grossCents <= 0
|
|
137
|
+
|| !Number.isSafeInteger(refundedCents) || refundedCents < 0 || refundedCents > grossCents
|
|
138
|
+
|| !Number.isSafeInteger(netCents) || netCents < 0 || netCents !== grossCents - refundedCents
|
|
139
|
+
|| currency !== 'usd'
|
|
140
|
+
|| !/^sha256:[a-f0-9]{64}$/.test(buyerEmailDigest)
|
|
141
|
+
|| (netCents === 0 ? status !== 'refunded' : !['completed', 'partially_refunded'].includes(status))
|
|
142
|
+
|| Number.isNaN(createdAt.getTime())
|
|
143
|
+
|| Number.isNaN(auditTime.getTime()) || createdAt.getTime() > auditTime.getTime() + 5 * 60 * 1000
|
|
144
|
+
|| payment.customerClassification !== 'external' || payment.ownerTest !== false
|
|
145
|
+
|| productAttribution.verified !== true
|
|
146
|
+
|| String(productAttribution.product || '').trim().toLowerCase() !== 'thumbgate') {
|
|
147
|
+
throw new Error(`${expectedProvider} payment is malformed or lacks verified evidence.`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
provider,
|
|
152
|
+
id: paymentId,
|
|
153
|
+
status,
|
|
154
|
+
grossCents,
|
|
155
|
+
refundedCents,
|
|
156
|
+
netCents,
|
|
157
|
+
currency,
|
|
158
|
+
createdAt: createdAt.toISOString(),
|
|
159
|
+
evidenceSource,
|
|
160
|
+
evidenceDigest,
|
|
161
|
+
invoiceId,
|
|
162
|
+
offerIds,
|
|
163
|
+
buyerEmailDigest,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function findPaymentUse(leads, payment) {
|
|
168
|
+
for (const lead of leads) {
|
|
169
|
+
for (const event of [...(lead.history || [])].reverse()) {
|
|
170
|
+
const evidence = event.evidence || {};
|
|
171
|
+
if (['provider_payment', 'provider_refund'].includes(evidence.kind)
|
|
172
|
+
&& evidence.provider === payment.provider
|
|
173
|
+
&& evidence.reference === payment.id
|
|
174
|
+
&& evidence.verified === true) {
|
|
175
|
+
return { leadId: lead.leadId, event };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function withPaymentReconciliationLock(statePath, operation, options = {}) {
|
|
183
|
+
const lockPath = `${statePath}.reconcile.lock`;
|
|
184
|
+
const retryDelayMs = options.retryDelayMs || 10;
|
|
185
|
+
const maxAttempts = options.maxAttempts || 400;
|
|
186
|
+
const staleAfterMs = options.staleAfterMs || 30_000;
|
|
187
|
+
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
|
188
|
+
let acquired = false;
|
|
189
|
+
for (let attempt = 0; attempt < maxAttempts && !acquired; attempt += 1) {
|
|
190
|
+
try {
|
|
191
|
+
fs.mkdirSync(lockPath);
|
|
192
|
+
acquired = true;
|
|
193
|
+
} catch (error) {
|
|
194
|
+
if (!error || error.code !== 'EEXIST') throw error;
|
|
195
|
+
try {
|
|
196
|
+
if (Date.now() - fs.statSync(lockPath).mtimeMs > staleAfterMs) {
|
|
197
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
} catch (statError) {
|
|
201
|
+
if (statError?.code !== 'ENOENT') throw statError;
|
|
202
|
+
}
|
|
203
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (!acquired) throw new Error('Sales payment reconciliation is busy; retry after the active reconciliation finishes.');
|
|
207
|
+
try {
|
|
208
|
+
return await operation();
|
|
209
|
+
} finally {
|
|
210
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function reconcileProviderPayment(payload = {}, options = {}) {
|
|
215
|
+
const leadId = normalizeRequiredText(payload.leadId || payload.lead, 'leadId', 160);
|
|
216
|
+
const paymentId = normalizeRequiredText(payload.paymentId || payload.payment, 'paymentId', 1000);
|
|
217
|
+
const provider = String(payload.provider || 'paypal').trim().toLowerCase();
|
|
218
|
+
if (!SUPPORTED_PAYMENT_PROVIDERS.has(provider)) {
|
|
219
|
+
throw new Error(`provider must be one of: ${[...SUPPORTED_PAYMENT_PROVIDERS].join(', ')}.`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const stateOptions = {
|
|
223
|
+
statePath: options.statePath || payload.statePath || null,
|
|
224
|
+
feedbackDir: options.feedbackDir || payload.feedbackDir || null,
|
|
225
|
+
};
|
|
226
|
+
const preflightLead = loadSalesLeads(stateOptions).find((entry) => entry.leadId === leadId);
|
|
227
|
+
if (!preflightLead) throw new Error(`Unknown sales lead: ${leadId}`);
|
|
228
|
+
|
|
229
|
+
const auditOptions = {
|
|
230
|
+
env: options.env || process.env,
|
|
231
|
+
now: options.now || payload.now,
|
|
232
|
+
timeZone: options.timeZone || payload.timeZone,
|
|
233
|
+
};
|
|
234
|
+
const audit = provider === 'paypal'
|
|
235
|
+
? await (options.auditPayPalLiveEvidence || auditPayPalLiveEvidence)({
|
|
236
|
+
...auditOptions,
|
|
237
|
+
fetchImpl: options.fetchImpl,
|
|
238
|
+
})
|
|
239
|
+
: await (options.auditStripeLiveEvidence || auditStripeLiveEvidence)({
|
|
240
|
+
...auditOptions,
|
|
241
|
+
stripeClient: options.stripeClient,
|
|
242
|
+
stripeFactory: options.stripeFactory,
|
|
243
|
+
secretKey: options.stripeSecretKey,
|
|
244
|
+
secretPaths: options.secretPaths,
|
|
245
|
+
});
|
|
246
|
+
const auditedAt = new Date(String(audit?.generatedAt || options.now || payload.now || new Date().toISOString()));
|
|
247
|
+
const reconciliationAt = Number.isNaN(auditedAt.getTime())
|
|
248
|
+
? new Date().toISOString()
|
|
249
|
+
: auditedAt.toISOString();
|
|
250
|
+
const stripeEvidence = audit?.productAttribution?.thumbgate || {};
|
|
251
|
+
const candidates = provider === 'stripe'
|
|
252
|
+
? (Array.isArray(stripeEvidence.individualPaymentStates)
|
|
253
|
+
? stripeEvidence.individualPaymentStates
|
|
254
|
+
: (Array.isArray(audit?.individualPaymentStates) ? audit.individualPaymentStates : []))
|
|
255
|
+
: (Array.isArray(audit?.individualPaymentStates)
|
|
256
|
+
? audit.individualPaymentStates
|
|
257
|
+
: (Array.isArray(audit?.individualPayments) ? audit.individualPayments : []));
|
|
258
|
+
const candidate = candidates.find((payment) => String(payment?.id || '').trim() === paymentId);
|
|
259
|
+
if (!candidate) {
|
|
260
|
+
const gap = String(audit?.gap || '').trim();
|
|
261
|
+
throw new Error(gap
|
|
262
|
+
? `Live ${provider} audit did not verify payment ${paymentId}: ${gap}`
|
|
263
|
+
: `Live ${provider} audit did not verify payment ${paymentId}.`);
|
|
264
|
+
}
|
|
265
|
+
const payment = validateVerifiedPayment(candidate, paymentId, provider, {
|
|
266
|
+
auditedAt: reconciliationAt,
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
return withPaymentReconciliationLock(getSalesPipelinePath(stateOptions), () => {
|
|
270
|
+
const leads = loadSalesLeads(stateOptions);
|
|
271
|
+
const lead = leads.find((entry) => entry.leadId === leadId);
|
|
272
|
+
if (!lead) throw new Error(`Unknown sales lead: ${leadId}`);
|
|
273
|
+
const offerMatch = matchPaymentOffer(payment, lead);
|
|
274
|
+
const existingUse = findPaymentUse(leads, payment);
|
|
275
|
+
if (existingUse && existingUse.leadId !== leadId) {
|
|
276
|
+
throw new Error(`${provider} payment ${paymentId} is already attributed to another sales lead.`);
|
|
277
|
+
}
|
|
278
|
+
if (payment.netCents === 0) {
|
|
279
|
+
if (!existingUse || existingUse.leadId !== leadId || !['paid', 'lost'].includes(lead.stage)) {
|
|
280
|
+
throw new Error(`Fully refunded ${provider} payment ${paymentId} cannot create a new paid pipeline record.`);
|
|
281
|
+
}
|
|
282
|
+
if (lead.stage === 'lost' && existingUse.event.evidence.kind === 'provider_refund') {
|
|
283
|
+
return {
|
|
284
|
+
provider: payment.provider,
|
|
285
|
+
paymentId: payment.id,
|
|
286
|
+
leadId,
|
|
287
|
+
stage: lead.stage,
|
|
288
|
+
amountCents: lead.revenue.amountCents,
|
|
289
|
+
currency: lead.revenue.currency,
|
|
290
|
+
offerId: offerMatch.paymentOfferId,
|
|
291
|
+
evidenceDigest: existingUse.event.evidence.digest,
|
|
292
|
+
reverifiedEvidenceDigest: payment.evidenceDigest,
|
|
293
|
+
unchanged: true,
|
|
294
|
+
statePath: getSalesPipelinePath(stateOptions),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
if (lead.stage !== 'paid') {
|
|
298
|
+
throw new Error(`Sales lead ${leadId} cannot apply a full refund from stage ${lead.stage}.`);
|
|
299
|
+
}
|
|
300
|
+
const refunded = advanceSalesLead({
|
|
301
|
+
leadId,
|
|
302
|
+
stage: 'lost',
|
|
303
|
+
amountCents: 0,
|
|
304
|
+
currency: 'usd',
|
|
305
|
+
timestamp: reconciliationAt,
|
|
306
|
+
evidenceKind: 'provider_refund',
|
|
307
|
+
evidenceProvider: payment.provider,
|
|
308
|
+
evidenceSource: payment.evidenceSource,
|
|
309
|
+
evidenceRef: payment.id,
|
|
310
|
+
evidenceVerified: true,
|
|
311
|
+
evidenceDigest: payment.evidenceDigest,
|
|
312
|
+
evidenceInvoiceId: payment.invoiceId,
|
|
313
|
+
evidenceOfferId: offerMatch.paymentOfferId,
|
|
314
|
+
evidenceBuyerDigest: offerMatch.buyerEmailDigest,
|
|
315
|
+
actor: 'provider-reconciliation',
|
|
316
|
+
note: `Paid status retired after authenticated live ${provider} evidence verified a full refund.`,
|
|
317
|
+
}, stateOptions);
|
|
318
|
+
return {
|
|
319
|
+
provider: payment.provider,
|
|
320
|
+
paymentId: payment.id,
|
|
321
|
+
leadId,
|
|
322
|
+
stage: refunded.lead.stage,
|
|
323
|
+
amountCents: refunded.lead.revenue.amountCents,
|
|
324
|
+
currency: refunded.lead.revenue.currency,
|
|
325
|
+
offerId: offerMatch.paymentOfferId,
|
|
326
|
+
evidenceDigest: payment.evidenceDigest,
|
|
327
|
+
unchanged: refunded.unchanged,
|
|
328
|
+
statePath: getSalesPipelinePath(stateOptions),
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
const existingBindingMatches = existingUse?.event?.evidence?.offerId === offerMatch.paymentOfferId
|
|
332
|
+
&& existingUse?.event?.evidence?.buyerDigest === offerMatch.buyerEmailDigest;
|
|
333
|
+
if (existingUse && existingUse.leadId === leadId && lead.stage === 'paid'
|
|
334
|
+
&& lead.revenue.amountCents === payment.netCents && existingBindingMatches) {
|
|
335
|
+
return {
|
|
336
|
+
provider: payment.provider,
|
|
337
|
+
paymentId: payment.id,
|
|
338
|
+
leadId,
|
|
339
|
+
stage: lead.stage,
|
|
340
|
+
amountCents: lead.revenue.amountCents,
|
|
341
|
+
currency: lead.revenue.currency,
|
|
342
|
+
offerId: offerMatch.paymentOfferId,
|
|
343
|
+
evidenceDigest: existingUse.event.evidence.digest,
|
|
344
|
+
reverifiedEvidenceDigest: payment.evidenceDigest,
|
|
345
|
+
unchanged: true,
|
|
346
|
+
statePath: getSalesPipelinePath(stateOptions),
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
if (existingUse && existingUse.leadId === leadId && lead.stage !== 'paid') {
|
|
350
|
+
throw new Error(`Sales lead ${leadId} cannot restore paid revenue from stage ${lead.stage}.`);
|
|
351
|
+
}
|
|
352
|
+
if (lead.stage === 'paid') {
|
|
353
|
+
if (!existingUse) throw new Error(`Sales lead ${leadId} is already paid with different evidence.`);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const upgradesLegacyBinding = Boolean(existingUse
|
|
357
|
+
&& lead.revenue.amountCents === payment.netCents
|
|
358
|
+
&& !existingBindingMatches);
|
|
359
|
+
|
|
360
|
+
const result = advanceSalesLead({
|
|
361
|
+
leadId,
|
|
362
|
+
stage: 'paid',
|
|
363
|
+
amountCents: payment.netCents,
|
|
364
|
+
currency: 'usd',
|
|
365
|
+
timestamp: existingUse ? reconciliationAt : payment.createdAt,
|
|
366
|
+
evidenceKind: 'provider_payment',
|
|
367
|
+
evidenceProvider: payment.provider,
|
|
368
|
+
evidenceSource: payment.evidenceSource,
|
|
369
|
+
evidenceRef: payment.id,
|
|
370
|
+
evidenceVerified: true,
|
|
371
|
+
evidenceDigest: payment.evidenceDigest,
|
|
372
|
+
evidenceInvoiceId: payment.invoiceId,
|
|
373
|
+
evidenceOfferId: offerMatch.paymentOfferId,
|
|
374
|
+
evidenceBuyerDigest: offerMatch.buyerEmailDigest,
|
|
375
|
+
actor: 'provider-reconciliation',
|
|
376
|
+
note: existingUse
|
|
377
|
+
? (upgradesLegacyBinding
|
|
378
|
+
? `Live ${provider} buyer and offer binding added.`
|
|
379
|
+
: `Paid amount updated from authenticated live ${provider} refund-adjusted evidence.`)
|
|
380
|
+
: `Paid status reconciled from authenticated live ${provider} evidence.`,
|
|
381
|
+
force: payload.force === true,
|
|
382
|
+
}, stateOptions);
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
provider: payment.provider,
|
|
386
|
+
paymentId: payment.id,
|
|
387
|
+
leadId,
|
|
388
|
+
stage: result.lead.stage,
|
|
389
|
+
amountCents: result.lead.revenue.amountCents,
|
|
390
|
+
currency: result.lead.revenue.currency,
|
|
391
|
+
offerId: offerMatch.paymentOfferId,
|
|
392
|
+
evidenceDigest: payment.evidenceDigest,
|
|
393
|
+
unchanged: result.unchanged,
|
|
394
|
+
statePath: getSalesPipelinePath(stateOptions),
|
|
395
|
+
};
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function runCli(argv = process.argv.slice(2), options = {}) {
|
|
400
|
+
const args = parseArgs(argv);
|
|
401
|
+
return reconcileProviderPayment({
|
|
402
|
+
leadId: args.lead || args.leadId,
|
|
403
|
+
paymentId: args.payment || args.paymentId,
|
|
404
|
+
provider: args.provider,
|
|
405
|
+
force: args.force === true,
|
|
406
|
+
now: args.now,
|
|
407
|
+
timeZone: args.timeZone,
|
|
408
|
+
}, {
|
|
409
|
+
...options,
|
|
410
|
+
statePath: args.state || options.statePath,
|
|
411
|
+
feedbackDir: args.feedbackDir || options.feedbackDir,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function isCliInvocation(argv = process.argv) {
|
|
416
|
+
const invokedPath = argv[1];
|
|
417
|
+
if (!invokedPath) return false;
|
|
418
|
+
try {
|
|
419
|
+
return fs.realpathSync(path.resolve(invokedPath)) === fs.realpathSync(__filename);
|
|
420
|
+
} catch {
|
|
421
|
+
return path.resolve(invokedPath) === path.resolve(__filename);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (isCliInvocation()) {
|
|
426
|
+
runCli()
|
|
427
|
+
.then((result) => console.log(JSON.stringify(result, null, 2)))
|
|
428
|
+
.catch((error) => {
|
|
429
|
+
console.error(error?.message || error);
|
|
430
|
+
process.exitCode = 1;
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
module.exports = {
|
|
435
|
+
findPaymentUse,
|
|
436
|
+
isCliInvocation,
|
|
437
|
+
parseArgs,
|
|
438
|
+
reconcileProviderPayment,
|
|
439
|
+
runCli,
|
|
440
|
+
validateVerifiedPayment,
|
|
441
|
+
withPaymentReconciliationLock,
|
|
442
|
+
};
|
|
@@ -0,0 +1,249 @@
|
|
|
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
|
+
|
|
9
|
+
const SCHEMA_VERSION = 1;
|
|
10
|
+
const MAX_SNAPSHOT_AGE_MS = 24 * 60 * 60 * 1000;
|
|
11
|
+
const MAX_FUTURE_SKEW_MS = 5 * 60 * 1000;
|
|
12
|
+
const PROVIDERS = Object.freeze(['paypal', 'merchantOfRecord', 'githubMarketplace']);
|
|
13
|
+
const SOURCE_KINDS = Object.freeze({
|
|
14
|
+
paypal: new Set(['provider_api_export', 'provider_api_live']),
|
|
15
|
+
merchantOfRecord: new Set(['provider_api_export', 'provider_api_live']),
|
|
16
|
+
githubMarketplace: new Set(['provider_api_export']),
|
|
17
|
+
});
|
|
18
|
+
const COUNTED_STATUSES = new Set(['paid', 'settled', 'completed', 'partially_refunded', 'refunded']);
|
|
19
|
+
const ALLOWED_STATUSES = new Set([...COUNTED_STATUSES, 'cancelled', 'canceled', 'failed']);
|
|
20
|
+
|
|
21
|
+
function normalizeBuyerEmail(value) {
|
|
22
|
+
const email = String(value || '').trim().toLowerCase();
|
|
23
|
+
return email && email.length <= 320 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? email : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function digestBuyerEmail(value) {
|
|
27
|
+
const email = normalizeBuyerEmail(value);
|
|
28
|
+
return email ? `sha256:${crypto.createHash('sha256').update(`thumbgate-buyer-email-v1:${email}`).digest('hex')}` : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function localDateRange(startLocalDate, endLocalDate) {
|
|
32
|
+
const dates = [];
|
|
33
|
+
const end = Date.parse(`${endLocalDate}T00:00:00.000Z`);
|
|
34
|
+
for (let cursor = Date.parse(`${startLocalDate}T00:00:00.000Z`); cursor <= end; cursor += 86400000) {
|
|
35
|
+
dates.push(new Date(cursor).toISOString().slice(0, 10));
|
|
36
|
+
}
|
|
37
|
+
return dates;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function canonicalProvider(value) {
|
|
41
|
+
const normalized = String(value || '').trim().toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
42
|
+
if (normalized === 'paypal') return 'paypal';
|
|
43
|
+
if (normalized === 'merchantofrecord' || normalized === 'mor') return 'merchantOfRecord';
|
|
44
|
+
if (normalized === 'githubmarketplace' || normalized === 'github') return 'githubMarketplace';
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function integer(value) {
|
|
49
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseTimestamp(value) {
|
|
53
|
+
const parsed = new Date(String(value || ''));
|
|
54
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function fail(provider, gap, extra = {}) {
|
|
58
|
+
return {
|
|
59
|
+
provider,
|
|
60
|
+
audited: false,
|
|
61
|
+
status: 'audit_incomplete',
|
|
62
|
+
evidenceVerified: false,
|
|
63
|
+
evidenceSource: null,
|
|
64
|
+
evidenceDigest: extra.evidenceDigest || null,
|
|
65
|
+
revenue: null,
|
|
66
|
+
gap,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function auditProviderSnapshot(snapshot, {
|
|
71
|
+
expectedProvider,
|
|
72
|
+
now,
|
|
73
|
+
timeZone = 'UTC',
|
|
74
|
+
evidenceDigest = null,
|
|
75
|
+
evidencePath = null,
|
|
76
|
+
} = {}) {
|
|
77
|
+
const provider = canonicalProvider(expectedProvider || snapshot?.provider);
|
|
78
|
+
if (!provider || !PROVIDERS.includes(provider)) return fail(provider || expectedProvider || null, 'Unsupported revenue provider.');
|
|
79
|
+
if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) return fail(provider, 'Evidence must be one JSON object.');
|
|
80
|
+
if (snapshot.schemaVersion !== SCHEMA_VERSION) return fail(provider, `schemaVersion must equal ${SCHEMA_VERSION}.`);
|
|
81
|
+
if (canonicalProvider(snapshot.provider) !== provider) return fail(provider, 'Snapshot provider does not match the requested provider.');
|
|
82
|
+
|
|
83
|
+
const nowDate = parseTimestamp(now || new Date().toISOString());
|
|
84
|
+
const generatedAt = parseTimestamp(snapshot.generatedAt);
|
|
85
|
+
if (!nowDate || !generatedAt) return fail(provider, 'now and generatedAt must be valid ISO timestamps.', { evidenceDigest });
|
|
86
|
+
if (generatedAt.getTime() > nowDate.getTime() + MAX_FUTURE_SKEW_MS) return fail(provider, 'Snapshot generatedAt is implausibly in the future.', { evidenceDigest });
|
|
87
|
+
if (nowDate.getTime() - generatedAt.getTime() > MAX_SNAPSHOT_AGE_MS) return fail(provider, 'Snapshot is stale; provider evidence must be refreshed within 24 hours.', { evidenceDigest });
|
|
88
|
+
|
|
89
|
+
const sourceKind = String(snapshot.source?.kind || '').trim();
|
|
90
|
+
const sourceReference = String(snapshot.source?.reference || '').trim();
|
|
91
|
+
if (!SOURCE_KINDS[provider].has(sourceKind) || !sourceReference) {
|
|
92
|
+
return fail(provider, 'A supported provider-origin source.kind and non-empty source.reference are required.', { evidenceDigest });
|
|
93
|
+
}
|
|
94
|
+
if (String(snapshot.currency || '').trim().toLowerCase() !== 'usd') return fail(provider, 'Only an explicit USD snapshot can enter the USD target control.', { evidenceDigest });
|
|
95
|
+
|
|
96
|
+
let window;
|
|
97
|
+
try {
|
|
98
|
+
window = resolveAnalyticsWindow({ window: '30d', now: nowDate.toISOString(), timeZone });
|
|
99
|
+
} catch (error) {
|
|
100
|
+
return fail(provider, `Invalid audit window: ${error.message}`, { evidenceDigest });
|
|
101
|
+
}
|
|
102
|
+
const scope = snapshot.scope || {};
|
|
103
|
+
const scopeDatePattern = /^\d{4}-\d{2}-\d{2}$/;
|
|
104
|
+
if (!scopeDatePattern.test(String(scope.startLocalDate || '')) ||
|
|
105
|
+
!scopeDatePattern.test(String(scope.endLocalDate || '')) ||
|
|
106
|
+
scope.completeness !== 'all_transactions' || scope.timeZone !== window.timeZone ||
|
|
107
|
+
String(scope.startLocalDate || '') > window.startLocalDate ||
|
|
108
|
+
String(scope.endLocalDate || '') !== window.endLocalDate) {
|
|
109
|
+
return fail(provider, 'Snapshot scope must attest all transactions and cover the exact trailing 30-day local-date window.', { evidenceDigest });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!Array.isArray(snapshot.transactions) || !Array.isArray(snapshot.subscriptions)) {
|
|
113
|
+
return fail(provider, 'transactions and subscriptions must both be arrays, including for audited zero.', { evidenceDigest });
|
|
114
|
+
}
|
|
115
|
+
const subscriptionsComplete = scope.subscriptionsCompleteness !== 'not_audited';
|
|
116
|
+
if (!subscriptionsComplete && snapshot.subscriptions.length > 0) {
|
|
117
|
+
return fail(provider, 'A snapshot cannot include subscriptions while declaring subscription state not audited.', { evidenceDigest });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const dates = localDateRange(window.startLocalDate, window.endLocalDate);
|
|
121
|
+
const dailyGrossRevenueCents = Object.fromEntries(dates.map((date) => [date, 0]));
|
|
122
|
+
const dailyNetRevenueCents = Object.fromEntries(dates.map((date) => [date, 0]));
|
|
123
|
+
const transactionIds = new Set();
|
|
124
|
+
const payingCustomers = new Set();
|
|
125
|
+
let transactionCount = 0;
|
|
126
|
+
|
|
127
|
+
for (const [index, transaction] of snapshot.transactions.entries()) {
|
|
128
|
+
const id = String(transaction?.id || '').trim();
|
|
129
|
+
const status = String(transaction?.status || '').trim().toLowerCase();
|
|
130
|
+
const createdAt = parseTimestamp(transaction?.createdAt);
|
|
131
|
+
const grossCents = integer(transaction?.grossCents);
|
|
132
|
+
const refundedCents = integer(transaction?.refundedCents);
|
|
133
|
+
const customerId = String(transaction?.customerId || '').trim();
|
|
134
|
+
const attributed = transaction?.productAttribution?.verified === true &&
|
|
135
|
+
String(transaction?.productAttribution?.product || '').trim().toLowerCase() === 'thumbgate';
|
|
136
|
+
const external = transaction?.customerClassification === 'external' && transaction?.ownerTest === false;
|
|
137
|
+
if (!id || transactionIds.has(id)) return fail(provider, `Transaction ${index} has a missing or duplicate provider ID.`, { evidenceDigest });
|
|
138
|
+
transactionIds.add(id);
|
|
139
|
+
if (!ALLOWED_STATUSES.has(status)) return fail(provider, `Transaction ${id} has an unsupported status.`, { evidenceDigest });
|
|
140
|
+
if (!createdAt || createdAt.getTime() > nowDate.getTime() + MAX_FUTURE_SKEW_MS) return fail(provider, `Transaction ${id} has an invalid or future createdAt.`, { evidenceDigest });
|
|
141
|
+
if (grossCents === null || refundedCents === null || refundedCents > grossCents) return fail(provider, `Transaction ${id} has invalid gross/refund cents.`, { evidenceDigest });
|
|
142
|
+
if (status === 'refunded' && refundedCents !== grossCents) return fail(provider, `Transaction ${id} is marked refunded but is not fully refunded.`, { evidenceDigest });
|
|
143
|
+
if (status === 'partially_refunded' && (refundedCents === 0 || refundedCents === grossCents)) return fail(provider, `Transaction ${id} has an inconsistent partial-refund status.`, { evidenceDigest });
|
|
144
|
+
if (!customerId || !attributed || !external) return fail(provider, `Transaction ${id} lacks verified ThumbGate attribution or external-customer evidence.`, { evidenceDigest });
|
|
145
|
+
if (!COUNTED_STATUSES.has(status)) continue;
|
|
146
|
+
const localDate = formatLocalDate(createdAt, window.timeZone);
|
|
147
|
+
if (!(localDate in dailyGrossRevenueCents)) continue;
|
|
148
|
+
const netCents = grossCents - refundedCents;
|
|
149
|
+
dailyGrossRevenueCents[localDate] += grossCents;
|
|
150
|
+
dailyNetRevenueCents[localDate] += netCents;
|
|
151
|
+
transactionCount += 1;
|
|
152
|
+
if (netCents > 0) payingCustomers.add(customerId);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const subscriptionIds = new Set();
|
|
156
|
+
const activeSubscriptionCustomers = new Set();
|
|
157
|
+
let activeSubscriptionCount = 0;
|
|
158
|
+
let mrrCents = 0;
|
|
159
|
+
for (const [index, subscription] of snapshot.subscriptions.entries()) {
|
|
160
|
+
const id = String(subscription?.id || '').trim();
|
|
161
|
+
const status = String(subscription?.status || '').trim().toLowerCase();
|
|
162
|
+
const customerId = String(subscription?.customerId || '').trim();
|
|
163
|
+
const monthlyCents = integer(subscription?.mrrCents);
|
|
164
|
+
const attributed = subscription?.productAttribution?.verified === true &&
|
|
165
|
+
String(subscription?.productAttribution?.product || '').trim().toLowerCase() === 'thumbgate';
|
|
166
|
+
const external = subscription?.customerClassification === 'external' && subscription?.ownerTest === false;
|
|
167
|
+
if (!id || subscriptionIds.has(id)) return fail(provider, `Subscription ${index} has a missing or duplicate provider ID.`, { evidenceDigest });
|
|
168
|
+
subscriptionIds.add(id);
|
|
169
|
+
if (!['active', 'trialing', 'cancelled', 'canceled', 'past_due'].includes(status)) return fail(provider, `Subscription ${id} has an unsupported status.`, { evidenceDigest });
|
|
170
|
+
if (monthlyCents === null || !customerId || !attributed || !external) return fail(provider, `Subscription ${id} lacks valid MRR, attribution, or external-customer evidence.`, { evidenceDigest });
|
|
171
|
+
if (status !== 'active') continue;
|
|
172
|
+
activeSubscriptionCount += 1;
|
|
173
|
+
mrrCents += monthlyCents;
|
|
174
|
+
activeSubscriptionCustomers.add(customerId);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const todayGrossRevenueCents = dailyGrossRevenueCents[window.endLocalDate];
|
|
178
|
+
const todayNetRevenueCents = dailyNetRevenueCents[window.endLocalDate];
|
|
179
|
+
const trailing30DayGrossRevenueCents = Object.values(dailyGrossRevenueCents).reduce((sum, value) => sum + value, 0);
|
|
180
|
+
const trailing30DayNetRevenueCents = Object.values(dailyNetRevenueCents).reduce((sum, value) => sum + value, 0);
|
|
181
|
+
for (const customerId of activeSubscriptionCustomers) payingCustomers.add(customerId);
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
provider,
|
|
185
|
+
audited: true,
|
|
186
|
+
status: 'provider_snapshot_audited',
|
|
187
|
+
evidenceVerified: true,
|
|
188
|
+
evidenceSource: `${sourceKind}:${sourceReference}`,
|
|
189
|
+
evidenceDigest,
|
|
190
|
+
evidencePath,
|
|
191
|
+
gap: null,
|
|
192
|
+
revenue: {
|
|
193
|
+
verified: true,
|
|
194
|
+
currency: 'usd',
|
|
195
|
+
basis: 'provider-origin all-transactions snapshot; refunds assigned to original transaction cohort',
|
|
196
|
+
timeZone: window.timeZone,
|
|
197
|
+
todayLocalDate: window.endLocalDate,
|
|
198
|
+
trailing30DayStartLocalDate: window.startLocalDate,
|
|
199
|
+
todayGrossRevenueCents,
|
|
200
|
+
todayNetRevenueCents,
|
|
201
|
+
trailing30DayGrossRevenueCents,
|
|
202
|
+
trailing30DayNetRevenueCents,
|
|
203
|
+
dailyGrossRevenueCents,
|
|
204
|
+
dailyNetRevenueCents,
|
|
205
|
+
externalMrrCents: subscriptionsComplete ? mrrCents : null,
|
|
206
|
+
activeExternalSubscriptions: subscriptionsComplete ? activeSubscriptionCount : null,
|
|
207
|
+
externalPayingCustomerIdentities: payingCustomers.size,
|
|
208
|
+
countedTransactionCount: transactionCount,
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function auditProviderSnapshotFile(filePath, options = {}) {
|
|
214
|
+
const provider = canonicalProvider(options.expectedProvider);
|
|
215
|
+
if (!filePath) return fail(provider, `${provider} evidence path was not provided.`);
|
|
216
|
+
let raw;
|
|
217
|
+
try {
|
|
218
|
+
raw = fs.readFileSync(filePath);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
return fail(provider, `Could not read provider evidence: ${error.message}`);
|
|
221
|
+
}
|
|
222
|
+
const evidenceDigest = `sha256:${crypto.createHash('sha256').update(raw).digest('hex')}`;
|
|
223
|
+
let snapshot;
|
|
224
|
+
try {
|
|
225
|
+
snapshot = JSON.parse(raw.toString('utf8'));
|
|
226
|
+
} catch (error) {
|
|
227
|
+
return fail(provider, `Provider evidence is not valid JSON: ${error.message}`, { evidenceDigest });
|
|
228
|
+
}
|
|
229
|
+
return auditProviderSnapshot(snapshot, { ...options, evidenceDigest, evidencePath: filePath });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function auditProviderEvidenceFiles({ paths = {}, now, timeZone } = {}) {
|
|
233
|
+
return Object.fromEntries(PROVIDERS.map((provider) => [
|
|
234
|
+
provider,
|
|
235
|
+
auditProviderSnapshotFile(paths[provider], { expectedProvider: provider, now, timeZone }),
|
|
236
|
+
]));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
module.exports = {
|
|
240
|
+
MAX_SNAPSHOT_AGE_MS,
|
|
241
|
+
PROVIDERS,
|
|
242
|
+
SCHEMA_VERSION,
|
|
243
|
+
auditProviderEvidenceFiles,
|
|
244
|
+
auditProviderSnapshot,
|
|
245
|
+
auditProviderSnapshotFile,
|
|
246
|
+
canonicalProvider,
|
|
247
|
+
digestBuyerEmail,
|
|
248
|
+
normalizeBuyerEmail,
|
|
249
|
+
};
|