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.
Files changed (95) hide show
  1. package/CHANGELOG.md +115 -1
  2. package/INSTALL_FOR_AGENTS.md +13 -11
  3. package/README.md +27 -18
  4. package/dist/backfill.d.ts +86 -0
  5. package/dist/backfill.js +251 -0
  6. package/dist/bin.js +4 -1
  7. package/dist/cli/auth.d.ts +2 -0
  8. package/dist/cli/auth.js +119 -12
  9. package/dist/cli/backfill.d.ts +1 -0
  10. package/dist/cli/backfill.js +125 -0
  11. package/dist/cli/backfillRuns.d.ts +1 -0
  12. package/dist/cli/backfillRuns.js +187 -0
  13. package/dist/cli/call.js +23 -9
  14. package/dist/cli/enrich.js +28 -10
  15. package/dist/cli/fix.js +9 -7
  16. package/dist/cli/help.js +60 -23
  17. package/dist/cli/plans.js +13 -11
  18. package/dist/cli/shared.d.ts +4 -3
  19. package/dist/cli/shared.js +31 -20
  20. package/dist/cli/ui.d.ts +21 -0
  21. package/dist/cli/ui.js +53 -1
  22. package/dist/cli.js +37 -41
  23. package/dist/connector.d.ts +8 -0
  24. package/dist/connector.js +104 -21
  25. package/dist/connectors/hubspot.d.ts +8 -1
  26. package/dist/connectors/hubspot.js +406 -13
  27. package/dist/connectors/hubspotAuth.js +5 -1
  28. package/dist/connectors/linkedin.js +14 -14
  29. package/dist/connectors/salesforce.d.ts +8 -1
  30. package/dist/connectors/salesforce.js +163 -2
  31. package/dist/connectors/stripe.d.ts +37 -0
  32. package/dist/connectors/stripe.js +103 -31
  33. package/dist/enrich.d.ts +7 -0
  34. package/dist/enrich.js +7 -0
  35. package/dist/health.d.ts +11 -69
  36. package/dist/health.js +4 -134
  37. package/dist/healthScore.d.ts +71 -0
  38. package/dist/healthScore.js +143 -0
  39. package/dist/index.d.ts +3 -1
  40. package/dist/index.js +3 -1
  41. package/dist/llm.d.ts +29 -0
  42. package/dist/llm.js +206 -0
  43. package/dist/market.d.ts +39 -1
  44. package/dist/market.js +116 -44
  45. package/dist/marketClassify.d.ts +9 -1
  46. package/dist/marketClassify.js +10 -1
  47. package/dist/marketTaxonomy.d.ts +6 -1
  48. package/dist/marketTaxonomy.js +4 -2
  49. package/dist/mcp-bin.js +29 -0
  50. package/dist/mcp.js +117 -4
  51. package/dist/progress.d.ts +96 -0
  52. package/dist/progress.js +142 -0
  53. package/dist/runReport.d.ts +24 -0
  54. package/dist/runReport.js +139 -4
  55. package/dist/types.d.ts +33 -1
  56. package/docs/api.md +4 -2
  57. package/docs/architecture.md +2 -0
  58. package/docs/linkedin-connector-spec.md +1 -1
  59. package/llms.txt +3 -3
  60. package/package.json +10 -3
  61. package/skills/fullstackgtm/SKILL.md +1 -0
  62. package/src/backfill.ts +340 -0
  63. package/src/bin.ts +5 -1
  64. package/src/cli/auth.ts +135 -15
  65. package/src/cli/backfill.ts +156 -0
  66. package/src/cli/backfillRuns.ts +198 -0
  67. package/src/cli/call.ts +26 -10
  68. package/src/cli/enrich.ts +33 -10
  69. package/src/cli/fix.ts +11 -10
  70. package/src/cli/help.ts +61 -23
  71. package/src/cli/plans.ts +15 -14
  72. package/src/cli/shared.ts +44 -27
  73. package/src/cli/ui.ts +72 -1
  74. package/src/cli.ts +38 -41
  75. package/src/connector.ts +110 -16
  76. package/src/connectors/hubspot.ts +423 -14
  77. package/src/connectors/hubspotAuth.ts +5 -1
  78. package/src/connectors/linkedin.ts +14 -14
  79. package/src/connectors/salesforce.ts +168 -3
  80. package/src/connectors/stripe.ts +154 -34
  81. package/src/enrich.ts +13 -0
  82. package/src/health.ts +14 -213
  83. package/src/healthScore.ts +223 -0
  84. package/src/index.ts +28 -1
  85. package/src/llm.ts +239 -0
  86. package/src/market.ts +157 -44
  87. package/src/marketClassify.ts +18 -1
  88. package/src/marketTaxonomy.ts +10 -2
  89. package/src/mcp-bin.ts +32 -0
  90. package/src/mcp.ts +140 -6
  91. package/src/progress.ts +197 -0
  92. package/src/runReport.ts +159 -4
  93. package/src/types.ts +35 -2
  94. package/docs/dx-punch-list.md +0 -87
  95. package/docs/roadmap-to-1.0.md +0 -211
@@ -19,6 +19,7 @@ import type {
19
19
  PatchOperationResult,
20
20
  SnapshotProgress,
21
21
  } from "../types.ts";
22
+ import { SNAPSHOT_PULL_STAGES, type ProgressEmitter } from "../progress.ts";
22
23
 
23
24
  const DEFAULT_API_VERSION = "v59.0";
24
25
 
@@ -38,6 +39,12 @@ export type SalesforceConnectorOptions = {
38
39
  fetchImpl?: typeof fetch;
39
40
  /** Per-page snapshot-pull progress (presentation only — errors are swallowed). */
40
41
  onProgress?: (progress: SnapshotProgress) => void;
42
+ /**
43
+ * Shared progress vocabulary (src/progress.ts): the snapshot pull emits a
44
+ * `stage` per object type and an `items` heartbeat per page. Additive
45
+ * alongside the legacy `onProgress` callback; both are presentation-only.
46
+ */
47
+ progress?: ProgressEmitter;
41
48
  };
42
49
 
43
50
  const SOBJECT_TYPES: Partial<Record<GtmObjectType, string>> = {
@@ -59,6 +66,15 @@ const MAPPING_OBJECT_TYPES: Partial<Record<GtmObjectType, Exclude<CrmObjectType,
59
66
  deal: "deals",
60
67
  };
61
68
 
69
+ // SnapshotProgress object types → the shared SNAPSHOT_PULL_STAGES names, so
70
+ // the CLI checklist and the app StageTimeline read the same stage labels.
71
+ const PULL_STAGE_BY_TYPE: Record<SnapshotProgress["objectType"], (typeof SNAPSHOT_PULL_STAGES)[number]> = {
72
+ user: "owners",
73
+ account: "accounts",
74
+ contact: "contacts",
75
+ deal: "deals",
76
+ };
77
+
62
78
  /**
63
79
  * Reference connector for Salesforce.
64
80
  *
@@ -70,7 +86,7 @@ const MAPPING_OBJECT_TYPES: Partial<Record<GtmObjectType, Exclude<CrmObjectType,
70
86
  */
71
87
  export function createSalesforceConnector(
72
88
  options: SalesforceConnectorOptions,
73
- ): Required<GtmConnector> {
89
+ ): GtmConnector {
74
90
  const apiVersion = options.apiVersion ?? DEFAULT_API_VERSION;
75
91
  const fetchImpl = options.fetchImpl ?? fetch;
76
92
  const mappings = options.fieldMappings;
@@ -202,8 +218,21 @@ export function createSalesforceConnector(
202
218
  }
203
219
 
204
220
  async function assembleSnapshot(whereClause: string): Promise<CanonicalGtmSnapshot> {
205
- const progressFor = (objectType: SnapshotProgress["objectType"]) => (fetched: number) =>
221
+ // Per-page snapshot-pull progress: the legacy onProgress callback plus the
222
+ // shared emitter (stage on the first page of each object type, items per
223
+ // page). Both are presentation-only — `query` already swallows errors.
224
+ let lastPullStage: string | undefined;
225
+ const progressFor = (objectType: SnapshotProgress["objectType"]) => (fetched: number) => {
206
226
  options.onProgress?.({ objectType, fetched });
227
+ const emitter = options.progress;
228
+ if (!emitter) return;
229
+ const stage = PULL_STAGE_BY_TYPE[objectType];
230
+ if (stage !== lastPullStage) {
231
+ lastPullStage = stage;
232
+ emitter.stage(stage, SNAPSHOT_PULL_STAGES.indexOf(stage), SNAPSHOT_PULL_STAGES.length);
233
+ }
234
+ emitter.items(fetched);
235
+ };
207
236
  const sfUsers = await query(
208
237
  `SELECT ${selectFields("owners")} FROM User${whereClause}`,
209
238
  progressFor("user"),
@@ -322,6 +351,9 @@ export function createSalesforceConnector(
322
351
  };
323
352
  });
324
353
 
354
+ // Deliver any throttled trailing items heartbeat before the pull returns.
355
+ options.progress?.flush();
356
+
325
357
  return {
326
358
  generatedAt: new Date().toISOString(),
327
359
  provider: "salesforce",
@@ -457,6 +489,137 @@ export function createSalesforceConnector(
457
489
  };
458
490
  }
459
491
 
492
+ function normalizeSalesforceValue(value: unknown): string | null {
493
+ if (value === undefined || value === null || value === "") return null;
494
+ return String(value).split("T")[0];
495
+ }
496
+
497
+ function fieldMappingFor(operation: PatchOperation): { sobjectType: string; field: string } | null {
498
+ const sobjectType = SOBJECT_TYPES[operation.objectType];
499
+ const mappingType = MAPPING_OBJECT_TYPES[operation.objectType];
500
+ if (!sobjectType || !mappingType || !operation.field) return null;
501
+ const defaults = SALESFORCE_DEFAULT_FIELD_MAPPINGS[mappingType] ?? {};
502
+ return {
503
+ sobjectType,
504
+ field: mappedField(mappings, mappingType, operation.field, defaults[operation.field] ?? operation.field),
505
+ };
506
+ }
507
+
508
+ function compositeErrorDetail(result: any): string {
509
+ const errors = Array.isArray(result?.errors) ? result.errors : [];
510
+ const message = errors
511
+ .map((error: any) => [error?.statusCode, error?.message].filter(Boolean).join(": "))
512
+ .filter(Boolean)
513
+ .join("; ");
514
+ return message ? `Salesforce rejected this record: ${message.slice(0, 300)}.` : "Salesforce rejected this record.";
515
+ }
516
+
517
+ async function applySetFieldBatch(operations: PatchOperation[]): Promise<PatchOperationResult[]> {
518
+ const out = new Map<string, PatchOperationResult>();
519
+ const mapped = operations.map((operation) => ({ operation, mapping: fieldMappingFor(operation) }));
520
+ for (const item of mapped) {
521
+ if (!item.mapping) {
522
+ out.set(item.operation.id, {
523
+ operationId: item.operation.id,
524
+ status: "skipped",
525
+ detail: "Field writes are only supported for accounts, contacts, and deals with an explicit field.",
526
+ });
527
+ }
528
+ }
529
+ const clean = mapped.filter((item): item is { operation: PatchOperation; mapping: { sobjectType: string; field: string } } => Boolean(item.mapping));
530
+ if (clean.length === 0) return operations.map((operation) => out.get(operation.id)!);
531
+ const sobjectType = clean[0].mapping.sobjectType;
532
+ const fields = [...new Set(clean.map((item) => item.mapping.field))];
533
+ const liveById = new Map<string, Record<string, unknown>>();
534
+ const ids = [...new Set(clean.map((item) => item.operation.objectId))];
535
+ for (let i = 0; i < ids.length; i += 200) {
536
+ const idList = ids.slice(i, i + 200).map((id) => `'${id.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`).join(",");
537
+ const rows = await query(`SELECT Id, ${fields.join(", ")} FROM ${sobjectType} WHERE Id IN (${idList})`);
538
+ for (const row of rows) liveById.set(String(row.Id), row);
539
+ }
540
+
541
+ const toWrite: Array<{ operation: PatchOperation; field: string }> = [];
542
+ for (const { operation, mapping } of clean) {
543
+ const row = liveById.get(operation.objectId);
544
+ const current = row?.[mapping.field] ?? null;
545
+ const expected = normalizeSalesforceValue(operation.beforeValue);
546
+ const found = normalizeSalesforceValue(current);
547
+ if (!row || expected !== found) {
548
+ out.set(operation.id, {
549
+ operationId: operation.id,
550
+ status: "conflict",
551
+ detail: `Value drifted since the plan was proposed: expected ${expected ?? "∅"}, found ${found ?? "∅"}. Re-run the audit.`,
552
+ providerData: { currentValue: current ?? null },
553
+ });
554
+ continue;
555
+ }
556
+ toWrite.push({ operation, field: mapping.field });
557
+ }
558
+
559
+ for (let i = 0; i < toWrite.length; i += 200) {
560
+ const chunk = toWrite.slice(i, i + 200);
561
+ const records = chunk.map(({ operation, field }) => ({
562
+ attributes: { type: sobjectType },
563
+ Id: operation.objectId,
564
+ [field]: operation.operation === "clear_field" ? null : String(operation.afterValue ?? ""),
565
+ }));
566
+ let response: any[];
567
+ try {
568
+ response = (await request(`/services/data/${apiVersion}/composite/sobjects`, {
569
+ method: "PATCH",
570
+ body: JSON.stringify({ allOrNone: false, records }),
571
+ })) as any[];
572
+ } catch (error) {
573
+ for (const { operation } of chunk) out.set(operation.id, { operationId: operation.id, status: "failed", detail: error instanceof Error ? error.message : String(error) });
574
+ continue;
575
+ }
576
+ for (let j = 0; j < chunk.length; j++) {
577
+ const { operation, field } = chunk[j];
578
+ const result = Array.isArray(response) ? response[j] : undefined;
579
+ out.set(operation.id, result?.success
580
+ ? { operationId: operation.id, status: "applied", detail: `Set ${field} on ${sobjectType}/${operation.objectId}.`, providerData: { sobjectType, field } }
581
+ : { operationId: operation.id, status: "failed", detail: compositeErrorDetail(result) });
582
+ }
583
+ }
584
+ return operations.map((operation) => out.get(operation.id) ?? { operationId: operation.id, status: "failed", detail: "Batch update did not process this operation." });
585
+ }
586
+
587
+ async function applyArchiveBatch(operations: PatchOperation[]): Promise<PatchOperationResult[]> {
588
+ const sobjectType = SOBJECT_TYPES[operations[0]?.objectType];
589
+ if (!sobjectType) return operations.map((operation) => ({ operationId: operation.id, status: "skipped", detail: "archive_record is supported for accounts, contacts, and deals." }));
590
+ const out = new Map<string, PatchOperationResult>();
591
+ for (let i = 0; i < operations.length; i += 200) {
592
+ const chunk = operations.slice(i, i + 200);
593
+ const ids = chunk.map((operation) => encodeURIComponent(operation.objectId)).join(",");
594
+ let response: any[];
595
+ try {
596
+ response = (await request(`/services/data/${apiVersion}/composite/sobjects?ids=${ids}&allOrNone=false`, { method: "DELETE" })) as any[];
597
+ } catch (error) {
598
+ for (const operation of chunk) out.set(operation.id, { operationId: operation.id, status: "failed", detail: error instanceof Error ? error.message : String(error) });
599
+ continue;
600
+ }
601
+ for (let j = 0; j < chunk.length; j++) {
602
+ const operation = chunk[j];
603
+ const result = Array.isArray(response) ? response[j] : undefined;
604
+ out.set(operation.id, result?.success
605
+ ? { operationId: operation.id, status: "applied", detail: `Deleted ${sobjectType}/${operation.objectId}.` }
606
+ : { operationId: operation.id, status: "failed", detail: compositeErrorDetail(result) });
607
+ }
608
+ }
609
+ return operations.map((operation) => out.get(operation.id) ?? { operationId: operation.id, status: "failed", detail: "Batch delete did not process this operation." });
610
+ }
611
+
612
+ async function applyBatch(operations: PatchOperation[]): Promise<PatchOperationResult[]> {
613
+ if (operations.length === 0) return [];
614
+ if (operations.every((operation) => (operation.operation === "set_field" || operation.operation === "clear_field") && operation.objectType === operations[0].objectType)) {
615
+ return applySetFieldBatch(operations);
616
+ }
617
+ if (operations.every((operation) => operation.operation === "archive_record" && operation.objectType === operations[0].objectType)) {
618
+ return applyArchiveBatch(operations);
619
+ }
620
+ return operations.map((operation) => ({ operationId: operation.id, status: "skipped", detail: "This mixed operation batch is not supported." }));
621
+ }
622
+
460
623
  function escapeXml(value: string): string {
461
624
  return value
462
625
  .replace(/&/g, "&amp;")
@@ -566,7 +729,7 @@ export function createSalesforceConnector(
566
729
  return { operationId: operation.id, status: "skipped", detail: "create_record needs a CreateRecordPayload afterValue." };
567
730
  }
568
731
  if (operation.objectType !== "contact" && operation.objectType !== "account") {
569
- return { operationId: operation.id, status: "skipped", detail: "create_record supports contacts and accounts." };
732
+ return { operationId: operation.id, status: "skipped", detail: "create_record supports contacts and accounts on Salesforce (deal/Opportunity creation is HubSpot-only for now)." };
570
733
  }
571
734
  const matchValue = String(payload.matchValue ?? "").trim();
572
735
  if (!matchValue) {
@@ -837,6 +1000,8 @@ export function createSalesforceConnector(
837
1000
  fetchChanges,
838
1001
  applyOperation,
839
1002
  applyCreateContactsBatch,
1003
+ applyBatch,
1004
+ applyBatchLimit: 200,
840
1005
  readField,
841
1006
  };
842
1007
  }
@@ -7,6 +7,7 @@ import type {
7
7
  PatchOperation,
8
8
  PatchOperationResult,
9
9
  } from "../types.ts";
10
+ import { STRIPE_SNAPSHOT_STAGES, type ProgressEmitter } from "../progress.ts";
10
11
 
11
12
  const DEFAULT_API_BASE_URL = "https://api.stripe.com";
12
13
 
@@ -16,8 +17,59 @@ export type StripeConnectorOptions = {
16
17
  apiBaseUrl?: string;
17
18
  /** Injectable fetch for testing. */
18
19
  fetchImpl?: typeof fetch;
20
+ /**
21
+ * Shared progress vocabulary (src/progress.ts): the snapshot pull emits a
22
+ * `stage` per collection (customers, subscriptions) and an `items`
23
+ * heartbeat per page. Presentation-only — errors are swallowed.
24
+ */
25
+ progress?: ProgressEmitter;
19
26
  };
20
27
 
28
+ // Module-scope request/list (parameterized by options) so the connector
29
+ // factory and fetchStripePaidInvoices share one implementation.
30
+ async function stripeRequest(options: StripeConnectorOptions, path: string): Promise<any> {
31
+ const baseUrl = (options.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/$/, "");
32
+ const fetchImpl = options.fetchImpl ?? fetch;
33
+ const apiKey = await options.getApiKey();
34
+ const response = await fetchImpl(`${baseUrl}${path}`, {
35
+ headers: { Authorization: `Bearer ${apiKey}` },
36
+ });
37
+ if (!response.ok) {
38
+ // Status line only — the body can echo request details bound to a live
39
+ // billing key, and these errors land in scheduled-run records.
40
+ await response.text().catch(() => undefined);
41
+ throw new Error(`Stripe API error ${response.status}. Check the restricted key and request.`);
42
+ }
43
+ return response.json();
44
+ }
45
+
46
+ /** Stripe list pagination: follow `has_more` with `starting_after=<last id>`. */
47
+ async function stripeList(
48
+ options: StripeConnectorOptions,
49
+ path: string,
50
+ onPage?: (fetched: number) => void,
51
+ ): Promise<any[]> {
52
+ const results: any[] = [];
53
+ let startingAfter: string | undefined;
54
+ do {
55
+ const separator = path.includes("?") ? "&" : "?";
56
+ const data = await stripeRequest(
57
+ options,
58
+ `${path}${startingAfter ? `${separator}starting_after=${encodeURIComponent(startingAfter)}` : ""}`,
59
+ );
60
+ const page: any[] = data.data ?? [];
61
+ results.push(...page);
62
+ try {
63
+ onPage?.(results.length);
64
+ } catch {
65
+ // progress is presentation-only; never let it fail a pull
66
+ }
67
+ startingAfter =
68
+ data.has_more === true && page.length > 0 ? String(page.at(-1).id) : undefined;
69
+ } while (startingAfter);
70
+ return results;
71
+ }
72
+
21
73
  /**
22
74
  * Read-only billing connector for Stripe — the first non-CRM connector,
23
75
  * proving the GtmConnector contract generalizes to billing systems.
@@ -37,46 +89,24 @@ export type StripeConnectorOptions = {
37
89
  * collections are always empty.
38
90
  */
39
91
  export function createStripeConnector(options: StripeConnectorOptions): GtmConnector {
40
- const baseUrl = (options.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/$/, "");
41
- const fetchImpl = options.fetchImpl ?? fetch;
42
-
43
- async function request(path: string): Promise<any> {
44
- const apiKey = await options.getApiKey();
45
- const response = await fetchImpl(`${baseUrl}${path}`, {
46
- headers: { Authorization: `Bearer ${apiKey}` },
47
- });
48
- if (!response.ok) {
49
- // Status line only the body can echo request details bound to a live
50
- // billing key, and these errors land in scheduled-run records.
51
- await response.text().catch(() => undefined);
52
- throw new Error(`Stripe API error ${response.status}. Check the restricted key and request.`);
53
- }
54
- return response.json();
55
- }
56
-
57
- /** Stripe list pagination: follow `has_more` with `starting_after=<last id>`. */
58
- async function list(path: string): Promise<any[]> {
59
- const results: any[] = [];
60
- let startingAfter: string | undefined;
61
- do {
62
- const separator = path.includes("?") ? "&" : "?";
63
- const data = await request(
64
- `${path}${startingAfter ? `${separator}starting_after=${encodeURIComponent(startingAfter)}` : ""}`,
65
- );
66
- const page: any[] = data.data ?? [];
67
- results.push(...page);
68
- startingAfter =
69
- data.has_more === true && page.length > 0 ? String(page.at(-1).id) : undefined;
70
- } while (startingAfter);
71
- return results;
72
- }
92
+ const list = (path: string, onPage?: (fetched: number) => void) =>
93
+ stripeList(options, path, onPage);
94
+ // Shared progress vocabulary: stage per collection, items per page.
95
+ const pullStage = (stage: (typeof STRIPE_SNAPSHOT_STAGES)[number]) =>
96
+ options.progress?.stage(
97
+ stage,
98
+ STRIPE_SNAPSHOT_STAGES.indexOf(stage),
99
+ STRIPE_SNAPSHOT_STAGES.length,
100
+ );
101
+ const pullItems = (fetched: number) => options.progress?.items(fetched);
73
102
 
74
103
  async function assembleSnapshot(createdGte?: number): Promise<CanonicalGtmSnapshot> {
75
104
  // Stripe filters list endpoints on the integer `created` field via
76
105
  // created[gte]=<unix seconds>; omitted on a full snapshot.
77
106
  const createdFilter =
78
107
  createdGte === undefined ? "" : `&created[gte]=${createdGte}`;
79
- const customers = await list(`/v1/customers?limit=100${createdFilter}`);
108
+ pullStage("customers");
109
+ const customers = await list(`/v1/customers?limit=100${createdFilter}`, pullItems);
80
110
  const accounts: CanonicalAccount[] = [];
81
111
  const contacts: CanonicalContact[] = [];
82
112
  for (const customer of customers) {
@@ -111,8 +141,10 @@ export function createStripeConnector(options: StripeConnectorOptions): GtmConne
111
141
  }
112
142
  }
113
143
 
144
+ pullStage("subscriptions");
114
145
  const subscriptions = await list(
115
146
  `/v1/subscriptions?status=all&limit=100${createdFilter}`,
147
+ pullItems,
116
148
  );
117
149
  const deals: CanonicalDeal[] = subscriptions
118
150
  .filter((subscription) => subscription.id)
@@ -161,6 +193,9 @@ export function createStripeConnector(options: StripeConnectorOptions): GtmConne
161
193
  };
162
194
  });
163
195
 
196
+ // Deliver any throttled trailing items heartbeat before the pull returns.
197
+ options.progress?.flush();
198
+
164
199
  return {
165
200
  generatedAt: new Date().toISOString(),
166
201
  provider: "stripe",
@@ -205,6 +240,91 @@ export function createStripeConnector(options: StripeConnectorOptions): GtmConne
205
240
  };
206
241
  }
207
242
 
243
+ /**
244
+ * One PAID Stripe invoice, normalized for the backfill plan builder
245
+ * (`backfill stripe`). Amounts are major units (Stripe returns cents);
246
+ * `paidAt` is the payment date (YYYY-MM-DD) from status_transitions.paid_at.
247
+ */
248
+ export type StripePaidInvoice = {
249
+ id: string;
250
+ number?: string;
251
+ customerId?: string;
252
+ customerName?: string;
253
+ customerEmail?: string;
254
+ /** Lowercased domain of the customer's billing email — the CRM match key. */
255
+ customerDomain?: string;
256
+ /** Major currency units (amount_paid / 100). */
257
+ amountPaid: number;
258
+ currency?: string;
259
+ /** YYYY-MM-DD from status_transitions.paid_at (unix seconds). */
260
+ paidAt?: string;
261
+ description?: string;
262
+ };
263
+
264
+ /**
265
+ * Read-only pull of PAID invoices (GET /v1/invoices?status=paid with the
266
+ * customer expanded), the revenue source of truth the backfill plan builder
267
+ * turns into closed-won deal proposals. Invoices with a zero amount_paid or
268
+ * no id are skipped — a $0 invoice is not a won deal.
269
+ */
270
+ export async function fetchStripePaidInvoices(
271
+ options: StripeConnectorOptions,
272
+ opts: { sinceIso?: string; onPage?: (fetched: number) => void } = {},
273
+ ): Promise<StripePaidInvoice[]> {
274
+ let createdFilter = "";
275
+ if (opts.sinceIso !== undefined) {
276
+ const sinceMs = Date.parse(opts.sinceIso);
277
+ if (!Number.isFinite(sinceMs)) throw new Error(`Invalid since timestamp: ${opts.sinceIso}`);
278
+ createdFilter = `&created[gte]=${Math.floor(sinceMs / 1000)}`;
279
+ }
280
+ const invoices = await stripeList(
281
+ options,
282
+ `/v1/invoices?status=paid&limit=100&expand[]=data.customer${createdFilter}`,
283
+ opts.onPage,
284
+ );
285
+ const paid: StripePaidInvoice[] = [];
286
+ for (const invoice of invoices) {
287
+ if (!invoice?.id) continue;
288
+ const amountPaidCents = numberOrUndefined(invoice.amount_paid);
289
+ if (amountPaidCents === undefined || amountPaidCents <= 0) continue;
290
+ // customer is the expanded object when expand[]=data.customer was honored,
291
+ // else the plain id string — handle both.
292
+ const customer = invoice.customer;
293
+ const customerId =
294
+ typeof customer === "string" ? customer : stringOrUndefined(customer?.id);
295
+ // Invoices also carry flat customer_name/customer_email copies — use them
296
+ // when the customer object wasn't expanded (or lacks the field).
297
+ const customerName =
298
+ (typeof customer === "object" && customer !== null
299
+ ? stringOrUndefined(customer.name)
300
+ : undefined) ?? stringOrUndefined(invoice.customer_name);
301
+ const customerEmail =
302
+ typeof customer === "object" && customer !== null
303
+ ? stringOrUndefined(customer.email) ?? stringOrUndefined(invoice.customer_email)
304
+ : stringOrUndefined(invoice.customer_email);
305
+ const customerDomain = customerEmail?.includes("@")
306
+ ? customerEmail.split("@").at(-1)!.toLowerCase()
307
+ : undefined;
308
+ const paidAtSeconds = numberOrUndefined(invoice.status_transitions?.paid_at);
309
+ paid.push({
310
+ id: String(invoice.id),
311
+ number: stringOrUndefined(invoice.number),
312
+ customerId,
313
+ customerName,
314
+ customerEmail,
315
+ customerDomain,
316
+ amountPaid: amountPaidCents / 100,
317
+ currency: stringOrUndefined(invoice.currency)?.toUpperCase(),
318
+ paidAt:
319
+ paidAtSeconds !== undefined
320
+ ? new Date(paidAtSeconds * 1000).toISOString().split("T")[0]
321
+ : undefined,
322
+ description: stringOrUndefined(invoice.description),
323
+ });
324
+ }
325
+ return paid;
326
+ }
327
+
208
328
  function stringOrUndefined(value: unknown): string | undefined {
209
329
  if (value === undefined || value === null || value === "") return undefined;
210
330
  return String(value);
package/src/enrich.ts CHANGED
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { credentialsDir, ensureSecureHomeDir, writeSecureFile } from "./credentials.ts";
4
4
  import { HUBSPOT_DEFAULT_FIELD_MAPPINGS } from "./mappings.ts";
5
5
  import type { AcquireBudget } from "./acquireMeter.ts";
6
+ import type { ProgressEmitter } from "./progress.ts";
6
7
  import { parseAssignmentPolicy, resolveAssignment } from "./assign.ts";
7
8
  import type { AssignmentContext, AssignmentPolicy } from "./assign.ts";
8
9
  import type {
@@ -1028,6 +1029,12 @@ export type BuildAcquirePlanOptions = {
1028
1029
  /** Meter ceiling: max create ops to emit. null/undefined = unlimited. */
1029
1030
  maxRecords?: number | null;
1030
1031
  now?: () => Date;
1032
+ /**
1033
+ * Shared progress vocabulary (src/progress.ts): `items` over the candidate
1034
+ * rows as they are routed, plus a `meter` reading of creates vs the meter
1035
+ * ceiling. Presentation-only — never changes what the plan proposes.
1036
+ */
1037
+ progress?: ProgressEmitter;
1031
1038
  };
1032
1039
 
1033
1040
  /** First non-empty value among several candidate paths in a source payload. */
@@ -1120,7 +1127,10 @@ export function buildAcquirePlan(options: BuildAcquirePlanOptions): AcquirePlanR
1120
1127
  }
1121
1128
  }
1122
1129
 
1130
+ let routed = 0;
1123
1131
  for (const record of records) {
1132
+ // Progress heartbeat over the candidate rows (throttled by the emitter).
1133
+ options.progress?.items((routed += 1), records.length);
1124
1134
  const createMap = acquire.create[record.objectType];
1125
1135
  const match = config.match[record.objectType];
1126
1136
  // No create mapping or no match config for this type — can neither create
@@ -1235,6 +1245,9 @@ export function buildAcquirePlan(options: BuildAcquirePlanOptions): AcquirePlanR
1235
1245
  counts.created += 1;
1236
1246
  estCostUsd += costPerRecord;
1237
1247
  }
1248
+ options.progress?.flush();
1249
+ // Meter reading: creates proposed this run against the meter's ceiling.
1250
+ if (cap !== null) options.progress?.meter(counts.created, cap, "creates");
1238
1251
 
1239
1252
  const plan: PatchPlan = {
1240
1253
  id: `patch_plan_acq_${fnv1a(`${source}:${runLabel}:${nowIso}`)}`,