fullstackgtm 0.45.0 → 0.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +115 -1
- package/INSTALL_FOR_AGENTS.md +13 -11
- package/README.md +27 -18
- package/dist/backfill.d.ts +86 -0
- package/dist/backfill.js +251 -0
- package/dist/bin.js +4 -1
- package/dist/cli/auth.d.ts +2 -0
- package/dist/cli/auth.js +119 -12
- package/dist/cli/backfill.d.ts +1 -0
- package/dist/cli/backfill.js +125 -0
- package/dist/cli/backfillRuns.d.ts +1 -0
- package/dist/cli/backfillRuns.js +187 -0
- package/dist/cli/call.js +23 -9
- package/dist/cli/enrich.js +28 -10
- package/dist/cli/fix.js +9 -7
- package/dist/cli/help.js +60 -23
- package/dist/cli/plans.js +13 -11
- package/dist/cli/shared.d.ts +4 -3
- package/dist/cli/shared.js +31 -20
- package/dist/cli/ui.d.ts +21 -0
- package/dist/cli/ui.js +53 -1
- package/dist/cli.js +37 -41
- package/dist/connector.d.ts +8 -0
- package/dist/connector.js +104 -21
- package/dist/connectors/hubspot.d.ts +8 -1
- package/dist/connectors/hubspot.js +406 -13
- package/dist/connectors/hubspotAuth.js +5 -1
- package/dist/connectors/linkedin.js +14 -14
- package/dist/connectors/salesforce.d.ts +8 -1
- package/dist/connectors/salesforce.js +163 -2
- package/dist/connectors/stripe.d.ts +37 -0
- package/dist/connectors/stripe.js +103 -31
- package/dist/enrich.d.ts +7 -0
- package/dist/enrich.js +7 -0
- package/dist/health.d.ts +11 -69
- package/dist/health.js +4 -134
- package/dist/healthScore.d.ts +71 -0
- package/dist/healthScore.js +143 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/llm.d.ts +29 -0
- package/dist/llm.js +206 -0
- package/dist/market.d.ts +39 -1
- package/dist/market.js +116 -44
- package/dist/marketClassify.d.ts +9 -1
- package/dist/marketClassify.js +10 -1
- package/dist/marketTaxonomy.d.ts +6 -1
- package/dist/marketTaxonomy.js +4 -2
- package/dist/mcp-bin.js +29 -0
- package/dist/mcp.js +117 -4
- package/dist/progress.d.ts +96 -0
- package/dist/progress.js +142 -0
- package/dist/runReport.d.ts +24 -0
- package/dist/runReport.js +139 -4
- package/dist/types.d.ts +33 -1
- package/docs/api.md +4 -2
- package/docs/architecture.md +2 -0
- package/docs/linkedin-connector-spec.md +1 -1
- package/llms.txt +3 -3
- package/package.json +10 -3
- package/skills/fullstackgtm/SKILL.md +1 -0
- package/src/backfill.ts +340 -0
- package/src/bin.ts +5 -1
- package/src/cli/auth.ts +135 -15
- package/src/cli/backfill.ts +156 -0
- package/src/cli/backfillRuns.ts +198 -0
- package/src/cli/call.ts +26 -10
- package/src/cli/enrich.ts +33 -10
- package/src/cli/fix.ts +11 -10
- package/src/cli/help.ts +61 -23
- package/src/cli/plans.ts +15 -14
- package/src/cli/shared.ts +44 -27
- package/src/cli/ui.ts +72 -1
- package/src/cli.ts +38 -41
- package/src/connector.ts +110 -16
- package/src/connectors/hubspot.ts +423 -14
- package/src/connectors/hubspotAuth.ts +5 -1
- package/src/connectors/linkedin.ts +14 -14
- package/src/connectors/salesforce.ts +168 -3
- package/src/connectors/stripe.ts +154 -34
- package/src/enrich.ts +13 -0
- package/src/health.ts +14 -213
- package/src/healthScore.ts +223 -0
- package/src/index.ts +28 -1
- package/src/llm.ts +239 -0
- package/src/market.ts +157 -44
- package/src/marketClassify.ts +18 -1
- package/src/marketTaxonomy.ts +10 -2
- package/src/mcp-bin.ts +32 -0
- package/src/mcp.ts +140 -6
- package/src/progress.ts +197 -0
- package/src/runReport.ts +159 -4
- package/src/types.ts +35 -2
- package/docs/dx-punch-list.md +0 -87
- package/docs/roadmap-to-1.0.md +0 -211
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { SALESFORCE_DEFAULT_FIELD_MAPPINGS, mappedField, mappedFields, readMappedValue, } from "../mappings.js";
|
|
2
|
+
import { SNAPSHOT_PULL_STAGES } from "../progress.js";
|
|
2
3
|
const DEFAULT_API_VERSION = "v59.0";
|
|
3
4
|
const SOBJECT_TYPES = {
|
|
4
5
|
account: "Account",
|
|
@@ -16,6 +17,14 @@ const MAPPING_OBJECT_TYPES = {
|
|
|
16
17
|
contact: "contacts",
|
|
17
18
|
deal: "deals",
|
|
18
19
|
};
|
|
20
|
+
// SnapshotProgress object types → the shared SNAPSHOT_PULL_STAGES names, so
|
|
21
|
+
// the CLI checklist and the app StageTimeline read the same stage labels.
|
|
22
|
+
const PULL_STAGE_BY_TYPE = {
|
|
23
|
+
user: "owners",
|
|
24
|
+
account: "accounts",
|
|
25
|
+
contact: "contacts",
|
|
26
|
+
deal: "deals",
|
|
27
|
+
};
|
|
19
28
|
/**
|
|
20
29
|
* Reference connector for Salesforce.
|
|
21
30
|
*
|
|
@@ -144,7 +153,22 @@ export function createSalesforceConnector(options) {
|
|
|
144
153
|
return mappedFields(mappings, objectType, SALESFORCE_DEFAULT_FIELD_MAPPINGS[objectType]).join(", ");
|
|
145
154
|
}
|
|
146
155
|
async function assembleSnapshot(whereClause) {
|
|
147
|
-
|
|
156
|
+
// Per-page snapshot-pull progress: the legacy onProgress callback plus the
|
|
157
|
+
// shared emitter (stage on the first page of each object type, items per
|
|
158
|
+
// page). Both are presentation-only — `query` already swallows errors.
|
|
159
|
+
let lastPullStage;
|
|
160
|
+
const progressFor = (objectType) => (fetched) => {
|
|
161
|
+
options.onProgress?.({ objectType, fetched });
|
|
162
|
+
const emitter = options.progress;
|
|
163
|
+
if (!emitter)
|
|
164
|
+
return;
|
|
165
|
+
const stage = PULL_STAGE_BY_TYPE[objectType];
|
|
166
|
+
if (stage !== lastPullStage) {
|
|
167
|
+
lastPullStage = stage;
|
|
168
|
+
emitter.stage(stage, SNAPSHOT_PULL_STAGES.indexOf(stage), SNAPSHOT_PULL_STAGES.length);
|
|
169
|
+
}
|
|
170
|
+
emitter.items(fetched);
|
|
171
|
+
};
|
|
148
172
|
const sfUsers = await query(`SELECT ${selectFields("owners")} FROM User${whereClause}`, progressFor("user"));
|
|
149
173
|
const users = sfUsers.map((user) => {
|
|
150
174
|
const id = String(readMapped(user, "owners", "id", "Id"));
|
|
@@ -235,6 +259,8 @@ export function createSalesforceConnector(options) {
|
|
|
235
259
|
raw: opportunity,
|
|
236
260
|
};
|
|
237
261
|
});
|
|
262
|
+
// Deliver any throttled trailing items heartbeat before the pull returns.
|
|
263
|
+
options.progress?.flush();
|
|
238
264
|
return {
|
|
239
265
|
generatedAt: new Date().toISOString(),
|
|
240
266
|
provider: "salesforce",
|
|
@@ -350,6 +376,139 @@ export function createSalesforceConnector(options) {
|
|
|
350
376
|
detail: `Deleted ${sobjectType}/${operation.objectId}.`,
|
|
351
377
|
};
|
|
352
378
|
}
|
|
379
|
+
function normalizeSalesforceValue(value) {
|
|
380
|
+
if (value === undefined || value === null || value === "")
|
|
381
|
+
return null;
|
|
382
|
+
return String(value).split("T")[0];
|
|
383
|
+
}
|
|
384
|
+
function fieldMappingFor(operation) {
|
|
385
|
+
const sobjectType = SOBJECT_TYPES[operation.objectType];
|
|
386
|
+
const mappingType = MAPPING_OBJECT_TYPES[operation.objectType];
|
|
387
|
+
if (!sobjectType || !mappingType || !operation.field)
|
|
388
|
+
return null;
|
|
389
|
+
const defaults = SALESFORCE_DEFAULT_FIELD_MAPPINGS[mappingType] ?? {};
|
|
390
|
+
return {
|
|
391
|
+
sobjectType,
|
|
392
|
+
field: mappedField(mappings, mappingType, operation.field, defaults[operation.field] ?? operation.field),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
function compositeErrorDetail(result) {
|
|
396
|
+
const errors = Array.isArray(result?.errors) ? result.errors : [];
|
|
397
|
+
const message = errors
|
|
398
|
+
.map((error) => [error?.statusCode, error?.message].filter(Boolean).join(": "))
|
|
399
|
+
.filter(Boolean)
|
|
400
|
+
.join("; ");
|
|
401
|
+
return message ? `Salesforce rejected this record: ${message.slice(0, 300)}.` : "Salesforce rejected this record.";
|
|
402
|
+
}
|
|
403
|
+
async function applySetFieldBatch(operations) {
|
|
404
|
+
const out = new Map();
|
|
405
|
+
const mapped = operations.map((operation) => ({ operation, mapping: fieldMappingFor(operation) }));
|
|
406
|
+
for (const item of mapped) {
|
|
407
|
+
if (!item.mapping) {
|
|
408
|
+
out.set(item.operation.id, {
|
|
409
|
+
operationId: item.operation.id,
|
|
410
|
+
status: "skipped",
|
|
411
|
+
detail: "Field writes are only supported for accounts, contacts, and deals with an explicit field.",
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const clean = mapped.filter((item) => Boolean(item.mapping));
|
|
416
|
+
if (clean.length === 0)
|
|
417
|
+
return operations.map((operation) => out.get(operation.id));
|
|
418
|
+
const sobjectType = clean[0].mapping.sobjectType;
|
|
419
|
+
const fields = [...new Set(clean.map((item) => item.mapping.field))];
|
|
420
|
+
const liveById = new Map();
|
|
421
|
+
const ids = [...new Set(clean.map((item) => item.operation.objectId))];
|
|
422
|
+
for (let i = 0; i < ids.length; i += 200) {
|
|
423
|
+
const idList = ids.slice(i, i + 200).map((id) => `'${id.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`).join(",");
|
|
424
|
+
const rows = await query(`SELECT Id, ${fields.join(", ")} FROM ${sobjectType} WHERE Id IN (${idList})`);
|
|
425
|
+
for (const row of rows)
|
|
426
|
+
liveById.set(String(row.Id), row);
|
|
427
|
+
}
|
|
428
|
+
const toWrite = [];
|
|
429
|
+
for (const { operation, mapping } of clean) {
|
|
430
|
+
const row = liveById.get(operation.objectId);
|
|
431
|
+
const current = row?.[mapping.field] ?? null;
|
|
432
|
+
const expected = normalizeSalesforceValue(operation.beforeValue);
|
|
433
|
+
const found = normalizeSalesforceValue(current);
|
|
434
|
+
if (!row || expected !== found) {
|
|
435
|
+
out.set(operation.id, {
|
|
436
|
+
operationId: operation.id,
|
|
437
|
+
status: "conflict",
|
|
438
|
+
detail: `Value drifted since the plan was proposed: expected ${expected ?? "∅"}, found ${found ?? "∅"}. Re-run the audit.`,
|
|
439
|
+
providerData: { currentValue: current ?? null },
|
|
440
|
+
});
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
toWrite.push({ operation, field: mapping.field });
|
|
444
|
+
}
|
|
445
|
+
for (let i = 0; i < toWrite.length; i += 200) {
|
|
446
|
+
const chunk = toWrite.slice(i, i + 200);
|
|
447
|
+
const records = chunk.map(({ operation, field }) => ({
|
|
448
|
+
attributes: { type: sobjectType },
|
|
449
|
+
Id: operation.objectId,
|
|
450
|
+
[field]: operation.operation === "clear_field" ? null : String(operation.afterValue ?? ""),
|
|
451
|
+
}));
|
|
452
|
+
let response;
|
|
453
|
+
try {
|
|
454
|
+
response = (await request(`/services/data/${apiVersion}/composite/sobjects`, {
|
|
455
|
+
method: "PATCH",
|
|
456
|
+
body: JSON.stringify({ allOrNone: false, records }),
|
|
457
|
+
}));
|
|
458
|
+
}
|
|
459
|
+
catch (error) {
|
|
460
|
+
for (const { operation } of chunk)
|
|
461
|
+
out.set(operation.id, { operationId: operation.id, status: "failed", detail: error instanceof Error ? error.message : String(error) });
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
for (let j = 0; j < chunk.length; j++) {
|
|
465
|
+
const { operation, field } = chunk[j];
|
|
466
|
+
const result = Array.isArray(response) ? response[j] : undefined;
|
|
467
|
+
out.set(operation.id, result?.success
|
|
468
|
+
? { operationId: operation.id, status: "applied", detail: `Set ${field} on ${sobjectType}/${operation.objectId}.`, providerData: { sobjectType, field } }
|
|
469
|
+
: { operationId: operation.id, status: "failed", detail: compositeErrorDetail(result) });
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return operations.map((operation) => out.get(operation.id) ?? { operationId: operation.id, status: "failed", detail: "Batch update did not process this operation." });
|
|
473
|
+
}
|
|
474
|
+
async function applyArchiveBatch(operations) {
|
|
475
|
+
const sobjectType = SOBJECT_TYPES[operations[0]?.objectType];
|
|
476
|
+
if (!sobjectType)
|
|
477
|
+
return operations.map((operation) => ({ operationId: operation.id, status: "skipped", detail: "archive_record is supported for accounts, contacts, and deals." }));
|
|
478
|
+
const out = new Map();
|
|
479
|
+
for (let i = 0; i < operations.length; i += 200) {
|
|
480
|
+
const chunk = operations.slice(i, i + 200);
|
|
481
|
+
const ids = chunk.map((operation) => encodeURIComponent(operation.objectId)).join(",");
|
|
482
|
+
let response;
|
|
483
|
+
try {
|
|
484
|
+
response = (await request(`/services/data/${apiVersion}/composite/sobjects?ids=${ids}&allOrNone=false`, { method: "DELETE" }));
|
|
485
|
+
}
|
|
486
|
+
catch (error) {
|
|
487
|
+
for (const operation of chunk)
|
|
488
|
+
out.set(operation.id, { operationId: operation.id, status: "failed", detail: error instanceof Error ? error.message : String(error) });
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
for (let j = 0; j < chunk.length; j++) {
|
|
492
|
+
const operation = chunk[j];
|
|
493
|
+
const result = Array.isArray(response) ? response[j] : undefined;
|
|
494
|
+
out.set(operation.id, result?.success
|
|
495
|
+
? { operationId: operation.id, status: "applied", detail: `Deleted ${sobjectType}/${operation.objectId}.` }
|
|
496
|
+
: { operationId: operation.id, status: "failed", detail: compositeErrorDetail(result) });
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return operations.map((operation) => out.get(operation.id) ?? { operationId: operation.id, status: "failed", detail: "Batch delete did not process this operation." });
|
|
500
|
+
}
|
|
501
|
+
async function applyBatch(operations) {
|
|
502
|
+
if (operations.length === 0)
|
|
503
|
+
return [];
|
|
504
|
+
if (operations.every((operation) => (operation.operation === "set_field" || operation.operation === "clear_field") && operation.objectType === operations[0].objectType)) {
|
|
505
|
+
return applySetFieldBatch(operations);
|
|
506
|
+
}
|
|
507
|
+
if (operations.every((operation) => operation.operation === "archive_record" && operation.objectType === operations[0].objectType)) {
|
|
508
|
+
return applyArchiveBatch(operations);
|
|
509
|
+
}
|
|
510
|
+
return operations.map((operation) => ({ operationId: operation.id, status: "skipped", detail: "This mixed operation batch is not supported." }));
|
|
511
|
+
}
|
|
353
512
|
function escapeXml(value) {
|
|
354
513
|
return value
|
|
355
514
|
.replace(/&/g, "&")
|
|
@@ -451,7 +610,7 @@ export function createSalesforceConnector(options) {
|
|
|
451
610
|
return { operationId: operation.id, status: "skipped", detail: "create_record needs a CreateRecordPayload afterValue." };
|
|
452
611
|
}
|
|
453
612
|
if (operation.objectType !== "contact" && operation.objectType !== "account") {
|
|
454
|
-
return { operationId: operation.id, status: "skipped", detail: "create_record supports contacts and accounts." };
|
|
613
|
+
return { operationId: operation.id, status: "skipped", detail: "create_record supports contacts and accounts on Salesforce (deal/Opportunity creation is HubSpot-only for now)." };
|
|
455
614
|
}
|
|
456
615
|
const matchValue = String(payload.matchValue ?? "").trim();
|
|
457
616
|
if (!matchValue) {
|
|
@@ -712,6 +871,8 @@ export function createSalesforceConnector(options) {
|
|
|
712
871
|
fetchChanges,
|
|
713
872
|
applyOperation,
|
|
714
873
|
applyCreateContactsBatch,
|
|
874
|
+
applyBatch,
|
|
875
|
+
applyBatchLimit: 200,
|
|
715
876
|
readField,
|
|
716
877
|
};
|
|
717
878
|
}
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import type { GtmConnector } from "../types.ts";
|
|
2
|
+
import { type ProgressEmitter } from "../progress.ts";
|
|
2
3
|
export type StripeConnectorOptions = {
|
|
3
4
|
/** Stripe secret key (sk_...) or restricted key. */
|
|
4
5
|
getApiKey: () => string | Promise<string>;
|
|
5
6
|
apiBaseUrl?: string;
|
|
6
7
|
/** Injectable fetch for testing. */
|
|
7
8
|
fetchImpl?: typeof fetch;
|
|
9
|
+
/**
|
|
10
|
+
* Shared progress vocabulary (src/progress.ts): the snapshot pull emits a
|
|
11
|
+
* `stage` per collection (customers, subscriptions) and an `items`
|
|
12
|
+
* heartbeat per page. Presentation-only — errors are swallowed.
|
|
13
|
+
*/
|
|
14
|
+
progress?: ProgressEmitter;
|
|
8
15
|
};
|
|
9
16
|
/**
|
|
10
17
|
* Read-only billing connector for Stripe — the first non-CRM connector,
|
|
@@ -25,3 +32,33 @@ export type StripeConnectorOptions = {
|
|
|
25
32
|
* collections are always empty.
|
|
26
33
|
*/
|
|
27
34
|
export declare function createStripeConnector(options: StripeConnectorOptions): GtmConnector;
|
|
35
|
+
/**
|
|
36
|
+
* One PAID Stripe invoice, normalized for the backfill plan builder
|
|
37
|
+
* (`backfill stripe`). Amounts are major units (Stripe returns cents);
|
|
38
|
+
* `paidAt` is the payment date (YYYY-MM-DD) from status_transitions.paid_at.
|
|
39
|
+
*/
|
|
40
|
+
export type StripePaidInvoice = {
|
|
41
|
+
id: string;
|
|
42
|
+
number?: string;
|
|
43
|
+
customerId?: string;
|
|
44
|
+
customerName?: string;
|
|
45
|
+
customerEmail?: string;
|
|
46
|
+
/** Lowercased domain of the customer's billing email — the CRM match key. */
|
|
47
|
+
customerDomain?: string;
|
|
48
|
+
/** Major currency units (amount_paid / 100). */
|
|
49
|
+
amountPaid: number;
|
|
50
|
+
currency?: string;
|
|
51
|
+
/** YYYY-MM-DD from status_transitions.paid_at (unix seconds). */
|
|
52
|
+
paidAt?: string;
|
|
53
|
+
description?: string;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Read-only pull of PAID invoices (GET /v1/invoices?status=paid with the
|
|
57
|
+
* customer expanded), the revenue source of truth the backfill plan builder
|
|
58
|
+
* turns into closed-won deal proposals. Invoices with a zero amount_paid or
|
|
59
|
+
* no id are skipped — a $0 invoice is not a won deal.
|
|
60
|
+
*/
|
|
61
|
+
export declare function fetchStripePaidInvoices(options: StripeConnectorOptions, opts?: {
|
|
62
|
+
sinceIso?: string;
|
|
63
|
+
onPage?: (fetched: number) => void;
|
|
64
|
+
}): Promise<StripePaidInvoice[]>;
|
|
@@ -1,4 +1,42 @@
|
|
|
1
|
+
import { STRIPE_SNAPSHOT_STAGES } from "../progress.js";
|
|
1
2
|
const DEFAULT_API_BASE_URL = "https://api.stripe.com";
|
|
3
|
+
// Module-scope request/list (parameterized by options) so the connector
|
|
4
|
+
// factory and fetchStripePaidInvoices share one implementation.
|
|
5
|
+
async function stripeRequest(options, path) {
|
|
6
|
+
const baseUrl = (options.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/$/, "");
|
|
7
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
8
|
+
const apiKey = await options.getApiKey();
|
|
9
|
+
const response = await fetchImpl(`${baseUrl}${path}`, {
|
|
10
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
11
|
+
});
|
|
12
|
+
if (!response.ok) {
|
|
13
|
+
// Status line only — the body can echo request details bound to a live
|
|
14
|
+
// billing key, and these errors land in scheduled-run records.
|
|
15
|
+
await response.text().catch(() => undefined);
|
|
16
|
+
throw new Error(`Stripe API error ${response.status}. Check the restricted key and request.`);
|
|
17
|
+
}
|
|
18
|
+
return response.json();
|
|
19
|
+
}
|
|
20
|
+
/** Stripe list pagination: follow `has_more` with `starting_after=<last id>`. */
|
|
21
|
+
async function stripeList(options, path, onPage) {
|
|
22
|
+
const results = [];
|
|
23
|
+
let startingAfter;
|
|
24
|
+
do {
|
|
25
|
+
const separator = path.includes("?") ? "&" : "?";
|
|
26
|
+
const data = await stripeRequest(options, `${path}${startingAfter ? `${separator}starting_after=${encodeURIComponent(startingAfter)}` : ""}`);
|
|
27
|
+
const page = data.data ?? [];
|
|
28
|
+
results.push(...page);
|
|
29
|
+
try {
|
|
30
|
+
onPage?.(results.length);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// progress is presentation-only; never let it fail a pull
|
|
34
|
+
}
|
|
35
|
+
startingAfter =
|
|
36
|
+
data.has_more === true && page.length > 0 ? String(page.at(-1).id) : undefined;
|
|
37
|
+
} while (startingAfter);
|
|
38
|
+
return results;
|
|
39
|
+
}
|
|
2
40
|
/**
|
|
3
41
|
* Read-only billing connector for Stripe — the first non-CRM connector,
|
|
4
42
|
* proving the GtmConnector contract generalizes to billing systems.
|
|
@@ -18,40 +56,16 @@ const DEFAULT_API_BASE_URL = "https://api.stripe.com";
|
|
|
18
56
|
* collections are always empty.
|
|
19
57
|
*/
|
|
20
58
|
export function createStripeConnector(options) {
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const response = await fetchImpl(`${baseUrl}${path}`, {
|
|
26
|
-
headers: { Authorization: `Bearer ${apiKey}` },
|
|
27
|
-
});
|
|
28
|
-
if (!response.ok) {
|
|
29
|
-
// Status line only — the body can echo request details bound to a live
|
|
30
|
-
// billing key, and these errors land in scheduled-run records.
|
|
31
|
-
await response.text().catch(() => undefined);
|
|
32
|
-
throw new Error(`Stripe API error ${response.status}. Check the restricted key and request.`);
|
|
33
|
-
}
|
|
34
|
-
return response.json();
|
|
35
|
-
}
|
|
36
|
-
/** Stripe list pagination: follow `has_more` with `starting_after=<last id>`. */
|
|
37
|
-
async function list(path) {
|
|
38
|
-
const results = [];
|
|
39
|
-
let startingAfter;
|
|
40
|
-
do {
|
|
41
|
-
const separator = path.includes("?") ? "&" : "?";
|
|
42
|
-
const data = await request(`${path}${startingAfter ? `${separator}starting_after=${encodeURIComponent(startingAfter)}` : ""}`);
|
|
43
|
-
const page = data.data ?? [];
|
|
44
|
-
results.push(...page);
|
|
45
|
-
startingAfter =
|
|
46
|
-
data.has_more === true && page.length > 0 ? String(page.at(-1).id) : undefined;
|
|
47
|
-
} while (startingAfter);
|
|
48
|
-
return results;
|
|
49
|
-
}
|
|
59
|
+
const list = (path, onPage) => stripeList(options, path, onPage);
|
|
60
|
+
// Shared progress vocabulary: stage per collection, items per page.
|
|
61
|
+
const pullStage = (stage) => options.progress?.stage(stage, STRIPE_SNAPSHOT_STAGES.indexOf(stage), STRIPE_SNAPSHOT_STAGES.length);
|
|
62
|
+
const pullItems = (fetched) => options.progress?.items(fetched);
|
|
50
63
|
async function assembleSnapshot(createdGte) {
|
|
51
64
|
// Stripe filters list endpoints on the integer `created` field via
|
|
52
65
|
// created[gte]=<unix seconds>; omitted on a full snapshot.
|
|
53
66
|
const createdFilter = createdGte === undefined ? "" : `&created[gte]=${createdGte}`;
|
|
54
|
-
|
|
67
|
+
pullStage("customers");
|
|
68
|
+
const customers = await list(`/v1/customers?limit=100${createdFilter}`, pullItems);
|
|
55
69
|
const accounts = [];
|
|
56
70
|
const contacts = [];
|
|
57
71
|
for (const customer of customers) {
|
|
@@ -86,7 +100,8 @@ export function createStripeConnector(options) {
|
|
|
86
100
|
});
|
|
87
101
|
}
|
|
88
102
|
}
|
|
89
|
-
|
|
103
|
+
pullStage("subscriptions");
|
|
104
|
+
const subscriptions = await list(`/v1/subscriptions?status=all&limit=100${createdFilter}`, pullItems);
|
|
90
105
|
const deals = subscriptions
|
|
91
106
|
.filter((subscription) => subscription.id)
|
|
92
107
|
.map((subscription) => {
|
|
@@ -125,6 +140,8 @@ export function createStripeConnector(options) {
|
|
|
125
140
|
raw: subscription,
|
|
126
141
|
};
|
|
127
142
|
});
|
|
143
|
+
// Deliver any throttled trailing items heartbeat before the pull returns.
|
|
144
|
+
options.progress?.flush();
|
|
128
145
|
return {
|
|
129
146
|
generatedAt: new Date().toISOString(),
|
|
130
147
|
provider: "stripe",
|
|
@@ -165,6 +182,61 @@ export function createStripeConnector(options) {
|
|
|
165
182
|
applyOperation,
|
|
166
183
|
};
|
|
167
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* Read-only pull of PAID invoices (GET /v1/invoices?status=paid with the
|
|
187
|
+
* customer expanded), the revenue source of truth the backfill plan builder
|
|
188
|
+
* turns into closed-won deal proposals. Invoices with a zero amount_paid or
|
|
189
|
+
* no id are skipped — a $0 invoice is not a won deal.
|
|
190
|
+
*/
|
|
191
|
+
export async function fetchStripePaidInvoices(options, opts = {}) {
|
|
192
|
+
let createdFilter = "";
|
|
193
|
+
if (opts.sinceIso !== undefined) {
|
|
194
|
+
const sinceMs = Date.parse(opts.sinceIso);
|
|
195
|
+
if (!Number.isFinite(sinceMs))
|
|
196
|
+
throw new Error(`Invalid since timestamp: ${opts.sinceIso}`);
|
|
197
|
+
createdFilter = `&created[gte]=${Math.floor(sinceMs / 1000)}`;
|
|
198
|
+
}
|
|
199
|
+
const invoices = await stripeList(options, `/v1/invoices?status=paid&limit=100&expand[]=data.customer${createdFilter}`, opts.onPage);
|
|
200
|
+
const paid = [];
|
|
201
|
+
for (const invoice of invoices) {
|
|
202
|
+
if (!invoice?.id)
|
|
203
|
+
continue;
|
|
204
|
+
const amountPaidCents = numberOrUndefined(invoice.amount_paid);
|
|
205
|
+
if (amountPaidCents === undefined || amountPaidCents <= 0)
|
|
206
|
+
continue;
|
|
207
|
+
// customer is the expanded object when expand[]=data.customer was honored,
|
|
208
|
+
// else the plain id string — handle both.
|
|
209
|
+
const customer = invoice.customer;
|
|
210
|
+
const customerId = typeof customer === "string" ? customer : stringOrUndefined(customer?.id);
|
|
211
|
+
// Invoices also carry flat customer_name/customer_email copies — use them
|
|
212
|
+
// when the customer object wasn't expanded (or lacks the field).
|
|
213
|
+
const customerName = (typeof customer === "object" && customer !== null
|
|
214
|
+
? stringOrUndefined(customer.name)
|
|
215
|
+
: undefined) ?? stringOrUndefined(invoice.customer_name);
|
|
216
|
+
const customerEmail = typeof customer === "object" && customer !== null
|
|
217
|
+
? stringOrUndefined(customer.email) ?? stringOrUndefined(invoice.customer_email)
|
|
218
|
+
: stringOrUndefined(invoice.customer_email);
|
|
219
|
+
const customerDomain = customerEmail?.includes("@")
|
|
220
|
+
? customerEmail.split("@").at(-1).toLowerCase()
|
|
221
|
+
: undefined;
|
|
222
|
+
const paidAtSeconds = numberOrUndefined(invoice.status_transitions?.paid_at);
|
|
223
|
+
paid.push({
|
|
224
|
+
id: String(invoice.id),
|
|
225
|
+
number: stringOrUndefined(invoice.number),
|
|
226
|
+
customerId,
|
|
227
|
+
customerName,
|
|
228
|
+
customerEmail,
|
|
229
|
+
customerDomain,
|
|
230
|
+
amountPaid: amountPaidCents / 100,
|
|
231
|
+
currency: stringOrUndefined(invoice.currency)?.toUpperCase(),
|
|
232
|
+
paidAt: paidAtSeconds !== undefined
|
|
233
|
+
? new Date(paidAtSeconds * 1000).toISOString().split("T")[0]
|
|
234
|
+
: undefined,
|
|
235
|
+
description: stringOrUndefined(invoice.description),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
return paid;
|
|
239
|
+
}
|
|
168
240
|
function stringOrUndefined(value) {
|
|
169
241
|
if (value === undefined || value === null || value === "")
|
|
170
242
|
return undefined;
|
package/dist/enrich.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AcquireBudget } from "./acquireMeter.ts";
|
|
2
|
+
import type { ProgressEmitter } from "./progress.ts";
|
|
2
3
|
import type { AssignmentContext, AssignmentPolicy } from "./assign.ts";
|
|
3
4
|
import type { CanonicalGtmSnapshot, PatchPlan } from "./types.ts";
|
|
4
5
|
/**
|
|
@@ -250,6 +251,12 @@ export type BuildAcquirePlanOptions = {
|
|
|
250
251
|
/** Meter ceiling: max create ops to emit. null/undefined = unlimited. */
|
|
251
252
|
maxRecords?: number | null;
|
|
252
253
|
now?: () => Date;
|
|
254
|
+
/**
|
|
255
|
+
* Shared progress vocabulary (src/progress.ts): `items` over the candidate
|
|
256
|
+
* rows as they are routed, plus a `meter` reading of creates vs the meter
|
|
257
|
+
* ceiling. Presentation-only — never changes what the plan proposes.
|
|
258
|
+
*/
|
|
259
|
+
progress?: ProgressEmitter;
|
|
253
260
|
};
|
|
254
261
|
/**
|
|
255
262
|
* Lift a prospect/source payload into the routing attributes a territory rule
|
package/dist/enrich.js
CHANGED
|
@@ -816,7 +816,10 @@ export function buildAcquirePlan(options) {
|
|
|
816
816
|
}
|
|
817
817
|
}
|
|
818
818
|
}
|
|
819
|
+
let routed = 0;
|
|
819
820
|
for (const record of records) {
|
|
821
|
+
// Progress heartbeat over the candidate rows (throttled by the emitter).
|
|
822
|
+
options.progress?.items((routed += 1), records.length);
|
|
820
823
|
const createMap = acquire.create[record.objectType];
|
|
821
824
|
const match = config.match[record.objectType];
|
|
822
825
|
// No create mapping or no match config for this type — can neither create
|
|
@@ -923,6 +926,10 @@ export function buildAcquirePlan(options) {
|
|
|
923
926
|
counts.created += 1;
|
|
924
927
|
estCostUsd += costPerRecord;
|
|
925
928
|
}
|
|
929
|
+
options.progress?.flush();
|
|
930
|
+
// Meter reading: creates proposed this run against the meter's ceiling.
|
|
931
|
+
if (cap !== null)
|
|
932
|
+
options.progress?.meter(counts.created, cap, "creates");
|
|
926
933
|
const plan = {
|
|
927
934
|
id: `patch_plan_acq_${fnv1a(`${source}:${runLabel}:${nowIso}`)}`,
|
|
928
935
|
title: `Acquire leads — ${source}`,
|
package/dist/health.d.ts
CHANGED
|
@@ -1,74 +1,16 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
at: string;
|
|
5
|
-
/** The saved plan this entry summarizes (correlates to plans/ and snapshots/). */
|
|
6
|
-
planId: string;
|
|
7
|
-
/** Deterministic 0–100 hygiene score (higher is healthier). */
|
|
8
|
-
score: number;
|
|
9
|
-
/** Total finding count across all rules. */
|
|
10
|
-
findings: number;
|
|
11
|
-
/** Severity-weighted finding total (drives the score). */
|
|
12
|
-
weightedFindings: number;
|
|
13
|
-
/** Record counts at audit time — the denominator that normalizes the score. */
|
|
14
|
-
records: {
|
|
15
|
-
accounts: number;
|
|
16
|
-
contacts: number;
|
|
17
|
-
deals: number;
|
|
18
|
-
total: number;
|
|
19
|
-
};
|
|
20
|
-
/** Finding count per rule id. */
|
|
21
|
-
byRule: Record<string, number>;
|
|
22
|
-
/** Finding count per severity. */
|
|
23
|
-
severityCounts: Record<AuditFindingSeverity, number>;
|
|
24
|
-
/**
|
|
25
|
-
* Per-object-type breakdown — so "is my contact data clean but my pipeline
|
|
26
|
-
* messy?" is answerable without re-auditing. Each type carries its own
|
|
27
|
-
* record-normalized score (same curve as the overall score, scoped to that
|
|
28
|
-
* type's records + findings).
|
|
29
|
-
*/
|
|
30
|
-
byObjectType: Record<"account" | "contact" | "deal", {
|
|
31
|
-
records: number;
|
|
32
|
-
findings: number;
|
|
33
|
-
weightedFindings: number;
|
|
34
|
-
score: number;
|
|
35
|
-
}>;
|
|
36
|
-
};
|
|
37
|
-
export type HealthRuleDelta = {
|
|
38
|
-
ruleId: string;
|
|
39
|
-
current: number;
|
|
40
|
-
previous: number;
|
|
41
|
-
/** current − previous: positive = more findings (worse), negative = fewer (better). */
|
|
42
|
-
delta: number;
|
|
43
|
-
};
|
|
44
|
-
export type HealthRollup = {
|
|
45
|
-
profile: string;
|
|
46
|
-
auditCount: number;
|
|
47
|
-
first: string;
|
|
48
|
-
latest: string;
|
|
49
|
-
current: HealthEntry;
|
|
50
|
-
previous: HealthEntry | null;
|
|
51
|
-
/** current.score − previous.score: positive = improving. null on the first audit. */
|
|
52
|
-
scoreDelta: number | null;
|
|
53
|
-
ruleDeltas: HealthRuleDelta[];
|
|
54
|
-
history: Array<{
|
|
55
|
-
at: string;
|
|
56
|
-
score: number;
|
|
57
|
-
findings: number;
|
|
58
|
-
}>;
|
|
59
|
-
};
|
|
1
|
+
import type { CanonicalGtmSnapshot } from "./types.ts";
|
|
2
|
+
import type { HealthEntry } from "./healthScore.ts";
|
|
3
|
+
export { computeHealth, summarizeHealth, healthToMarkdown, type HealthEntry, type HealthRuleDelta, type HealthRollup, } from "./healthScore.ts";
|
|
60
4
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
5
|
+
* The engagement workspace: a per-client health timeline that accrues from the
|
|
6
|
+
* verb people already run (`audit --save`). State lives in the profile dir
|
|
7
|
+
* (`$FSGTM_HOME[/profiles/<name>]`) alongside credentials and plans, so a
|
|
8
|
+
* consultant working `--profile <client>` builds a continuous record of one
|
|
9
|
+
* org's CRM health over time — not just episodic one-off audits.
|
|
10
|
+
*
|
|
11
|
+
* The score is DETERMINISTIC (no LLM): same plan + same snapshot → same score,
|
|
12
|
+
* so it is stable in CI and comparable run-over-run.
|
|
66
13
|
*/
|
|
67
|
-
export declare function computeHealth(plan: PatchPlan, snapshot: CanonicalGtmSnapshot, at: string): HealthEntry;
|
|
68
|
-
/** Roll a timeline up into the current state, the change since last time, and per-rule deltas. */
|
|
69
|
-
export declare function summarizeHealth(entries: HealthEntry[], profile: string): HealthRollup | null;
|
|
70
|
-
/** Human-readable rollup: headline score + trend, then per-rule change since last audit. */
|
|
71
|
-
export declare function healthToMarkdown(rollup: HealthRollup): string;
|
|
72
14
|
/** `$FSGTM_HOME[/profiles/<name>]/health.jsonl` — append-only, one entry per audit-save. */
|
|
73
15
|
export declare function healthFilePath(): string;
|
|
74
16
|
/** `$FSGTM_HOME[/profiles/<name>]/snapshots/` — canonical snapshots correlated by plan id. */
|