fullstackgtm 0.44.0 → 0.46.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 +293 -1
- package/INSTALL_FOR_AGENTS.md +13 -11
- package/README.md +45 -24
- package/dist/audit.d.ts +3 -1
- package/dist/audit.js +29 -2
- package/dist/backfill.d.ts +86 -0
- package/dist/backfill.js +251 -0
- package/dist/bin.js +4 -1
- package/dist/cli/audit.d.ts +15 -0
- package/dist/cli/audit.js +392 -0
- package/dist/cli/auth.d.ts +82 -0
- package/dist/cli/auth.js +607 -0
- 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.d.ts +1 -0
- package/dist/cli/call.js +387 -0
- package/dist/cli/capabilities.d.ts +30 -0
- package/dist/cli/capabilities.js +197 -0
- package/dist/cli/draft.d.ts +7 -0
- package/dist/cli/draft.js +87 -0
- package/dist/cli/enrich.d.ts +8 -0
- package/dist/cli/enrich.js +806 -0
- package/dist/cli/fix.d.ts +33 -0
- package/dist/cli/fix.js +346 -0
- package/dist/cli/help.d.ts +25 -0
- package/dist/cli/help.js +646 -0
- package/dist/cli/icp.d.ts +7 -0
- package/dist/cli/icp.js +229 -0
- package/dist/cli/init.d.ts +7 -0
- package/dist/cli/init.js +58 -0
- package/dist/cli/market.d.ts +1 -0
- package/dist/cli/market.js +391 -0
- package/dist/cli/plans.d.ts +5 -0
- package/dist/cli/plans.js +456 -0
- package/dist/cli/schedule.d.ts +8 -0
- package/dist/cli/schedule.js +479 -0
- package/dist/cli/shared.d.ts +71 -0
- package/dist/cli/shared.js +342 -0
- package/dist/cli/signals.d.ts +7 -0
- package/dist/cli/signals.js +412 -0
- package/dist/cli/suggest.d.ts +49 -0
- package/dist/cli/suggest.js +135 -0
- package/dist/cli/tam.d.ts +9 -0
- package/dist/cli/tam.js +387 -0
- package/dist/cli/ui.d.ts +142 -0
- package/dist/cli/ui.js +427 -0
- package/dist/cli.d.ts +1 -57
- package/dist/cli.js +123 -5157
- package/dist/connector.d.ts +23 -0
- package/dist/connector.js +47 -0
- package/dist/connectors/hubspot.d.ts +10 -1
- package/dist/connectors/hubspot.js +244 -10
- package/dist/connectors/hubspotAuth.js +5 -1
- package/dist/connectors/linkedin.js +14 -14
- package/dist/connectors/salesforce.d.ts +10 -1
- package/dist/connectors/salesforce.js +39 -6
- package/dist/connectors/signalSources.js +7 -59
- 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/icp.d.ts +15 -11
- package/dist/icp.js +19 -36
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/judge.d.ts +2 -0
- package/dist/judge.js +6 -0
- 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 +11 -1
- package/dist/marketClassify.js +17 -2
- package/dist/marketTaxonomy.d.ts +6 -1
- package/dist/marketTaxonomy.js +4 -2
- package/dist/mcp-bin.js +31 -2
- package/dist/mcp.js +372 -166
- 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/schedule.d.ts +80 -2
- package/dist/schedule.js +254 -1
- package/dist/spoolFiles.d.ts +44 -0
- package/dist/spoolFiles.js +114 -0
- package/dist/types.d.ts +29 -1
- package/docs/api.md +75 -8
- package/docs/architecture.md +2 -0
- package/docs/linkedin-connector-spec.md +1 -1
- package/docs/signal-spool-format.md +13 -0
- package/llms.txt +18 -9
- package/package.json +10 -3
- package/skills/fullstackgtm/SKILL.md +2 -1
- package/src/audit.ts +27 -1
- package/src/backfill.ts +340 -0
- package/src/bin.ts +5 -1
- package/src/cli/audit.ts +447 -0
- package/src/cli/auth.ts +669 -0
- package/src/cli/backfill.ts +156 -0
- package/src/cli/backfillRuns.ts +198 -0
- package/src/cli/call.ts +414 -0
- package/src/cli/capabilities.ts +215 -0
- package/src/cli/draft.ts +101 -0
- package/src/cli/enrich.ts +908 -0
- package/src/cli/fix.ts +373 -0
- package/src/cli/help.ts +702 -0
- package/src/cli/icp.ts +265 -0
- package/src/cli/init.ts +65 -0
- package/src/cli/market.ts +423 -0
- package/src/cli/plans.ts +524 -0
- package/src/cli/schedule.ts +526 -0
- package/src/cli/shared.ts +392 -0
- package/src/cli/signals.ts +434 -0
- package/src/cli/suggest.ts +151 -0
- package/src/cli/tam.ts +435 -0
- package/src/cli/ui.ts +497 -0
- package/src/cli.ts +122 -5855
- package/src/connector.ts +66 -0
- package/src/connectors/hubspot.ts +273 -9
- package/src/connectors/hubspotAuth.ts +5 -1
- package/src/connectors/linkedin.ts +14 -14
- package/src/connectors/salesforce.ts +51 -3
- package/src/connectors/signalSources.ts +7 -56
- 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/icp.ts +19 -35
- package/src/index.ts +28 -1
- package/src/judge.ts +7 -0
- package/src/llm.ts +239 -0
- package/src/market.ts +157 -44
- package/src/marketClassify.ts +26 -2
- package/src/marketTaxonomy.ts +10 -2
- package/src/mcp-bin.ts +34 -2
- package/src/mcp.ts +270 -63
- package/src/progress.ts +197 -0
- package/src/runReport.ts +159 -4
- package/src/schedule.ts +310 -3
- package/src/spoolFiles.ts +116 -0
- package/src/types.ts +32 -2
- package/docs/dx-punch-list.md +0 -87
- package/docs/roadmap-to-1.0.md +0 -211
package/llms.txt
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> Plan/apply for your GTM stack: canonical CRM/GTM data model, deterministic
|
|
4
4
|
> hygiene audits, reviewable dry-run patch plans, and approval-gated
|
|
5
|
-
> write-back to HubSpot
|
|
5
|
+
> write-back to HubSpot and Salesforce; Stripe is read-only. Think
|
|
6
6
|
> `terraform plan` for your CRM. Apache-2.0.
|
|
7
7
|
|
|
8
8
|
CLI quick check: `fullstackgtm doctor --json`. Zero-credential demo:
|
|
@@ -15,7 +15,7 @@ at/above `--fail-on`.
|
|
|
15
15
|
- [README](https://github.com/fullstackgtm/core/blob/main/README.md): install, five-minute loop, auth ladder, MCP setup, programmatic use
|
|
16
16
|
- [INSTALL_FOR_AGENTS](https://github.com/fullstackgtm/core/blob/main/INSTALL_FOR_AGENTS.md): deterministic install-and-verify steps with expected outputs
|
|
17
17
|
- [Agent skill](https://github.com/fullstackgtm/core/blob/main/skills/fullstackgtm/SKILL.md): compact operating guide, installable via `npx skills add fullstackgtm/core`
|
|
18
|
-
- [Recipes](https://github.com/fullstackgtm/core/blob/main/
|
|
18
|
+
- [Recipes](https://github.com/fullstackgtm/core/blob/main/docs/recipes.md): five composable GTM plays over the primitives (cold-start lead-fill, the trigger→judge→draft outbound loop, scheduled-continuous, ABM-from-companies, hygiene-gated outbound) — the CLI ships primitives, your agent is the orchestrator, the package never sends
|
|
19
19
|
- [Architecture](https://github.com/fullstackgtm/core/blob/main/docs/architecture.md): module map + snapshot → audit → plan → apply data flow; "where do I add a rule/connector/operation"
|
|
20
20
|
- [Contributing](https://github.com/fullstackgtm/core/blob/main/CONTRIBUTING.md): dev setup, the open-core mirror model, the release ritual
|
|
21
21
|
- [API reference](https://github.com/fullstackgtm/core/blob/main/docs/api.md): semver-covered surfaces — canonical model, rule interface, plan/apply contract, connector contract, config, CLI, MCP tools
|
|
@@ -202,7 +202,7 @@ outbox is the send-side mirror of the spool; the always-on sender is hosted/opt-
|
|
|
202
202
|
--contact <id> --result replied|...` credits the contact reached so weights
|
|
203
203
|
re-learn. The contact↔account join (`contact.accountId` + `account.domain`) is
|
|
204
204
|
surfaced at every hop, so the loop is contact-coherent end to end — see the
|
|
205
|
-
[recipes](https://github.com/fullstackgtm/core/blob/main/
|
|
205
|
+
[recipes](https://github.com/fullstackgtm/core/blob/main/docs/recipes.md).
|
|
206
206
|
|
|
207
207
|
## Key invariants (schedule)
|
|
208
208
|
|
|
@@ -211,17 +211,26 @@ owns cron logic. `add "<command>" --cron "<expr>"` validates the command
|
|
|
211
211
|
against the read/plan-side allowlist (audit, snapshot, enrich append|refresh,
|
|
212
212
|
enrich acquire --save, market capture|refresh, signals fetch, icp judge|eval,
|
|
213
213
|
draft, suggest, report, doctor) and the 5-field cron
|
|
214
|
-
expression, but touches no
|
|
215
|
-
into
|
|
216
|
-
|
|
217
|
-
|
|
214
|
+
expression, but touches no system timer; `install` materializes enabled
|
|
215
|
+
entries into the platform timer (`--timer` defaults launchd on macOS,
|
|
216
|
+
crontab elsewhere). crontab: a sentinel-delimited managed block
|
|
217
|
+
(`# >>> fullstackgtm <profile> >>>`) replaced wholesale on re-install, lines
|
|
218
|
+
outside it untouched. launchd: one LaunchAgent plist per entry
|
|
219
|
+
(`com.fullstackgtm.<profile>.<id>` in `~/Library/LaunchAgents`), the fleet
|
|
220
|
+
replaced wholesale, foreign plists untouched — chosen because macOS gates
|
|
221
|
+
`crontab` writes behind Full Disk Access (unpromptable TCC), while
|
|
222
|
+
LaunchAgents need no permission and coalesce firings missed during sleep
|
|
223
|
+
into one run on wake. `uninstall` removes only what install created.
|
|
224
|
+
Scheduling never auto-approves: `apply` is
|
|
218
225
|
schedulable ONLY as `apply --plan-id <id>` and every firing re-checks the
|
|
219
226
|
plan is approved — unapproved plans record a `plan_not_approved` no-op run,
|
|
220
227
|
no flag relaxes this. `schedule run <id>` is the single provider entry point
|
|
221
|
-
(in-process dispatch, never shell
|
|
228
|
+
(in-process dispatch, never shell; a second cron trigger in the same minute
|
|
229
|
+
records a `duplicate_firing` no-op — launchd double-triggers the Vixie
|
|
230
|
+
dom+dow OR corner); run records (exit code, output tail,
|
|
222
231
|
artifacts: plan ids / enrich run labels, trigger cron|manual) land under
|
|
223
232
|
`<home>/schedule/runs/<id>/`. `status` shows next firing and surfaces missed
|
|
224
|
-
firings (visibility only —
|
|
233
|
+
firings (visibility only — cron has no catch-up). Providers beyond
|
|
225
234
|
`local` are not yet implemented (future: codegen scaffolds, same contract).
|
|
226
235
|
|
|
227
236
|
## Key invariants (engagement workspace / health)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullstackgtm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.46.0",
|
|
4
4
|
"description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Full Stack GTM LLC <ryan@fullstackgtm.com> (https://fullstackgtm.com)",
|
|
@@ -25,7 +25,14 @@
|
|
|
25
25
|
"files": [
|
|
26
26
|
"dist",
|
|
27
27
|
"src",
|
|
28
|
-
"docs",
|
|
28
|
+
"docs/api.md",
|
|
29
|
+
"docs/architecture.md",
|
|
30
|
+
"docs/crm-health-lifecycle.md",
|
|
31
|
+
"docs/linkedin-connector-spec.md",
|
|
32
|
+
"docs/outbox-format.md",
|
|
33
|
+
"docs/recipes.md",
|
|
34
|
+
"docs/signal-spool-format.md",
|
|
35
|
+
"docs/tam.md",
|
|
29
36
|
"README.md",
|
|
30
37
|
"CHANGELOG.md",
|
|
31
38
|
"INSTALL_FOR_AGENTS.md",
|
|
@@ -41,7 +48,7 @@
|
|
|
41
48
|
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
42
49
|
"pretest": "test -d tests || { echo 'No tests/ in this directory. In the published mirror, tests live here and `npm test` works. In the monorepo, run package tests from the repo ROOT: `node --experimental-strip-types --test tests/fullstackgtm*.test.ts` (the tests live at the monorepo root tests/).' >&2; exit 1; }",
|
|
43
50
|
"test": "node --experimental-strip-types --test tests/*.test.ts",
|
|
44
|
-
"prepublishOnly": "npm run build"
|
|
51
|
+
"prepublishOnly": "npm run build && npm pack --dry-run && node dist/bin.js --help && node dist/mcp-bin.js --help"
|
|
45
52
|
},
|
|
46
53
|
"devDependencies": {
|
|
47
54
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
@@ -60,6 +60,7 @@ credentials AND stored plans per client org.
|
|
|
60
60
|
| `bulk-update <object> --where … --set\|--archive\|--create-task` | Filtered dry-run plan; filter re-verified per record at apply time |
|
|
61
61
|
| `reassign --from <owner> --to <owner>` | Ownership handoff plans per object type; `--assign-unowned --to <id>` backfills every ownerless record |
|
|
62
62
|
| `fix --rule <id>` | audit one rule → suggest → approve at the confidence bar → apply only with `--yes` |
|
|
63
|
+
| `backfill stripe\|runs [--since <iso>] [--pipeline <id\|label>]` | Paid Stripe invoices → proposed closed-won deals (amount = invoice total, close date = paid date, company matched by billing-email domain then name); deduped by a `stripe_invoice_id` deal property re-resolved at apply, so re-running never double-creates; a customer the CRM doesn't know gets a proposed account create in the same plan (freemail domains never used; `--skip-unmatched` = report-only); `--save` → approve → `apply`. `backfill runs` replays LOCAL run history + health timeline to the paired hosted app (idempotent; `--dry-run` to preview) |
|
|
63
64
|
| `call parse\|score\|link\|plan` | Transcripts → evidence-quoted insights, rubric scorecards, deal linking, governed next-step writes |
|
|
64
65
|
| `enrich append\|refresh\|ingest\|status` | Governed enrichment (Apollo pull / Clay ingest), fill-blanks-only plans |
|
|
65
66
|
| `enrich acquire [--source explorium\|pipe0\|linkedin]` | Net-new ICP-targeted lead gen: resolve-first deduped `create_record` plans, capped by a per-profile windowed meter (records + spend); owner-stamped via `acquire.assign`/`--assign-owner` (never born ownerless); links each lead to a domain-stamped, signal-watchable account; `acquire.create.company` acquires accounts (ABM); `--source linkedin --list <id>` reads a HeyReach lead list (Phase 1 discovery, read-only — never sends); never auto-writes |
|
|
@@ -79,7 +80,7 @@ Add `--json` for machine-readable output on any command.
|
|
|
79
80
|
## MCP server (alternative surface, same gates)
|
|
80
81
|
|
|
81
82
|
```bash
|
|
82
|
-
npx -y
|
|
83
|
+
npx -y fullstackgtm-mcp
|
|
83
84
|
```
|
|
84
85
|
|
|
85
86
|
Tools over stdio: `fullstackgtm_audit` (read-only), `fullstackgtm_rules`,
|
package/src/audit.ts
CHANGED
|
@@ -18,6 +18,19 @@ const DEFAULT_POLICY: Omit<GtmPolicy, "today"> = {
|
|
|
18
18
|
requireAccountForDeal: true,
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Wall clock for the plan's stdout-bound `createdAt`, honoring the
|
|
23
|
+
* SOURCE_DATE_EPOCH reproducible-build convention (seconds since the Unix
|
|
24
|
+
* epoch). When set, `audit --demo --json` is byte-deterministic across
|
|
25
|
+
* re-runs — golden tests and CI diffs can pin the payload. Unset (normal
|
|
26
|
+
* use) it is the real clock, exactly as before.
|
|
27
|
+
*/
|
|
28
|
+
function nowIso(): string {
|
|
29
|
+
const epoch = process.env.SOURCE_DATE_EPOCH;
|
|
30
|
+
if (epoch && /^\d+$/.test(epoch)) return new Date(Number(epoch) * 1000).toISOString();
|
|
31
|
+
return new Date().toISOString();
|
|
32
|
+
}
|
|
33
|
+
|
|
21
34
|
export function defaultPolicy(today = new Date().toISOString().slice(0, 10)): GtmPolicy {
|
|
22
35
|
return {
|
|
23
36
|
...DEFAULT_POLICY,
|
|
@@ -28,20 +41,33 @@ export function defaultPolicy(today = new Date().toISOString().slice(0, 10)): Gt
|
|
|
28
41
|
/**
|
|
29
42
|
* Run every rule over the snapshot and collect the results into a single
|
|
30
43
|
* dry-run patch plan. Pass custom rules to extend or replace the built-ins.
|
|
44
|
+
* `onRule` reports per-rule progress (presentation only — a throwing callback
|
|
45
|
+
* never fails the audit).
|
|
31
46
|
*/
|
|
32
47
|
export function auditSnapshot(
|
|
33
48
|
snapshot: CanonicalGtmSnapshot,
|
|
34
49
|
policy: GtmPolicy = defaultPolicy(),
|
|
35
50
|
rules: GtmAuditRule[] = builtinAuditRules,
|
|
51
|
+
onRule?: (ruleId: string, phase: "start" | "done", findings?: number) => void,
|
|
36
52
|
): PatchPlan {
|
|
37
53
|
const context = { snapshot, policy, index: buildSnapshotIndex(snapshot) };
|
|
38
54
|
const findings: AuditFinding[] = [];
|
|
39
55
|
const operations: PatchOperation[] = [];
|
|
40
56
|
|
|
41
57
|
for (const rule of rules) {
|
|
58
|
+
try {
|
|
59
|
+
onRule?.(rule.id, "start");
|
|
60
|
+
} catch {
|
|
61
|
+
// progress is presentation-only
|
|
62
|
+
}
|
|
42
63
|
const result = rule.evaluate(context);
|
|
43
64
|
findings.push(...result.findings);
|
|
44
65
|
operations.push(...result.operations);
|
|
66
|
+
try {
|
|
67
|
+
onRule?.(rule.id, "done", result.findings.length);
|
|
68
|
+
} catch {
|
|
69
|
+
// progress is presentation-only
|
|
70
|
+
}
|
|
45
71
|
}
|
|
46
72
|
|
|
47
73
|
|
|
@@ -52,7 +78,7 @@ export function auditSnapshot(
|
|
|
52
78
|
return {
|
|
53
79
|
id: `patch_plan_${stableHash(`${snapshot.provider}:${snapshot.generatedAt}:${findings.length}:${operations.length}`)}`,
|
|
54
80
|
title: "GTM hygiene audit patch plan",
|
|
55
|
-
createdAt:
|
|
81
|
+
createdAt: nowIso(),
|
|
56
82
|
status: operations.length > 0 ? "needs_approval" : "draft",
|
|
57
83
|
dryRun: true,
|
|
58
84
|
summary: `${findings.length} findings and ${operations.length} proposed dry-run operations.`,
|
package/src/backfill.ts
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backfill: Stripe paid invoices → closed-won CRM deals, through the normal
|
|
3
|
+
* governed plan/apply flow.
|
|
4
|
+
*
|
|
5
|
+
* Product assumption this encodes: billing (Stripe) is the revenue source
|
|
6
|
+
* of truth; the CRM should retroactively carry ONE closed-won deal per paid
|
|
7
|
+
* invoice (amount = invoice total, close date = paid date, associated to the
|
|
8
|
+
* customer's company, deduped by invoice id). This module only BUILDS the
|
|
9
|
+
* dry-run plan — nothing here touches a CRM. Writes happen exclusively via
|
|
10
|
+
* `apply` on operations approved through `plans approve`, where the HubSpot
|
|
11
|
+
* connector re-resolves each invoice id (resolve-first) and creates only on
|
|
12
|
+
* a confirmed miss.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type {
|
|
16
|
+
CanonicalAccount,
|
|
17
|
+
CanonicalGtmSnapshot,
|
|
18
|
+
CreateRecordPayload,
|
|
19
|
+
GtmEvidence,
|
|
20
|
+
PatchOperation,
|
|
21
|
+
PatchPlan,
|
|
22
|
+
} from "./types.ts";
|
|
23
|
+
import type { StripePaidInvoice } from "./connectors/stripe.ts";
|
|
24
|
+
import { normalizeDomain } from "./merge.ts";
|
|
25
|
+
|
|
26
|
+
// Mirrors stableHash in rules.ts (FNV-1a); duplicated to keep backfill.ts
|
|
27
|
+
// importable without pulling the audit engine (the enrich.ts precedent).
|
|
28
|
+
function fnv1a(value: string): string {
|
|
29
|
+
let hash = 0x811c9dc5;
|
|
30
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
31
|
+
hash ^= value.charCodeAt(i);
|
|
32
|
+
hash = Math.imul(hash, 0x01000193);
|
|
33
|
+
}
|
|
34
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const DEFAULT_BACKFILL_MATCH_PROPERTY = "stripe_invoice_id";
|
|
38
|
+
|
|
39
|
+
// Freemail domains never become a company domain: stamping "gmail.com" on an
|
|
40
|
+
// account (or resolving a company BY gmail.com) would collapse unrelated
|
|
41
|
+
// customers onto one record. Mirrors FREE_MAIL in connectors/signalSources.ts
|
|
42
|
+
// (duplicated to keep backfill.ts importable without connector code, the
|
|
43
|
+
// fnv1a precedent above).
|
|
44
|
+
const FREE_MAIL = new Set([
|
|
45
|
+
"gmail.com",
|
|
46
|
+
"yahoo.com",
|
|
47
|
+
"hotmail.com",
|
|
48
|
+
"outlook.com",
|
|
49
|
+
"icloud.com",
|
|
50
|
+
"aol.com",
|
|
51
|
+
"proton.me",
|
|
52
|
+
"protonmail.com",
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
export type StripeBackfillOptions = {
|
|
56
|
+
/** Pipeline id or case-insensitive label; absent = the portal's default pipeline. */
|
|
57
|
+
pipeline?: string;
|
|
58
|
+
/** Deal dedupe property stamped with the invoice id (default "stripe_invoice_id"). */
|
|
59
|
+
matchProperty?: string;
|
|
60
|
+
/** Source label recorded on payloads/evidence (default "stripe:invoices"). */
|
|
61
|
+
source?: string;
|
|
62
|
+
/**
|
|
63
|
+
* When a customer matches no CRM account, propose creating the account as
|
|
64
|
+
* part of this plan (default true). The account op is explicit and
|
|
65
|
+
* approval-gated like everything else; apply is resolve-first, so an
|
|
66
|
+
* account that appeared in the meantime is reused, not duplicated. False =
|
|
67
|
+
* the conservative original behavior: unmatched customers are reported
|
|
68
|
+
* only and their invoices produce no operations.
|
|
69
|
+
*/
|
|
70
|
+
createMissingAccounts?: boolean;
|
|
71
|
+
/** Injectable clock for deterministic tests. */
|
|
72
|
+
now?: () => Date;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** One invoice whose customer could not be matched to any CRM account. */
|
|
76
|
+
export type StripeBackfillUnmatched = {
|
|
77
|
+
invoiceId: string;
|
|
78
|
+
invoiceNumber?: string;
|
|
79
|
+
customerName?: string;
|
|
80
|
+
customerDomain?: string;
|
|
81
|
+
amountPaid: number;
|
|
82
|
+
currency?: string;
|
|
83
|
+
paidAt?: string;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** One account this plan proposes to create for an unmatched Stripe customer. */
|
|
87
|
+
export type StripeBackfillProposedAccount = {
|
|
88
|
+
name: string;
|
|
89
|
+
domain?: string;
|
|
90
|
+
/** Paid invoices from this customer that the plan attaches to the account. */
|
|
91
|
+
invoiceCount: number;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export type StripeBackfillCounts = {
|
|
95
|
+
invoices: number;
|
|
96
|
+
/** create_record deal ops emitted. */
|
|
97
|
+
planned: number;
|
|
98
|
+
/** create_record account ops emitted for unmatched customers. */
|
|
99
|
+
accountsProposed: number;
|
|
100
|
+
/** Customers with no CRM match AND no usable name/domain — reported only. */
|
|
101
|
+
unmatched: number;
|
|
102
|
+
/** Plan-time prefilter: a deal with the same name already exists. */
|
|
103
|
+
alreadyInCrm: number;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export type StripeBackfillResult = {
|
|
107
|
+
plan: PatchPlan;
|
|
108
|
+
counts: StripeBackfillCounts;
|
|
109
|
+
unmatched: StripeBackfillUnmatched[];
|
|
110
|
+
proposedAccounts: StripeBackfillProposedAccount[];
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/** `Invoice <number || id> — <customer name>` (the plan-time dedupe name). */
|
|
114
|
+
function backfillDealName(invoice: StripePaidInvoice, companyName: string): string {
|
|
115
|
+
return `Invoice ${invoice.number ?? invoice.id} — ${invoice.customerName ?? companyName}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Match each paid invoice's customer to a snapshot account (normalized domain
|
|
120
|
+
* first — the accurate key — then exact case-insensitive name) and emit one
|
|
121
|
+
* create_record deal op per invoice that has no deal yet.
|
|
122
|
+
*
|
|
123
|
+
* Unmatched customers (default): the plan ALSO proposes creating the account —
|
|
124
|
+
* one explicit, approval-gated account create_record op per new customer,
|
|
125
|
+
* with the customer's invoices attached as deals. Freemail billing domains
|
|
126
|
+
* (gmail/outlook/…) are never used as a company domain. A customer with no
|
|
127
|
+
* usable name or domain stays report-only. `createMissingAccounts: false`
|
|
128
|
+
* restores the original conservative behavior (unmatched = reported, no ops).
|
|
129
|
+
*
|
|
130
|
+
* The by-deal-name prefilter here is best-effort plan hygiene; the
|
|
131
|
+
* AUTHORITATIVE duplicate gate is the connector's apply-time resolve-first
|
|
132
|
+
* search on the invoice-id property (and resolve-by-domain/name for accounts).
|
|
133
|
+
*/
|
|
134
|
+
export function buildStripeBackfillPlan(
|
|
135
|
+
invoices: StripePaidInvoice[],
|
|
136
|
+
snapshot: CanonicalGtmSnapshot,
|
|
137
|
+
opts: StripeBackfillOptions = {},
|
|
138
|
+
): StripeBackfillResult {
|
|
139
|
+
const nowIso = (opts.now ?? (() => new Date()))().toISOString();
|
|
140
|
+
const matchProperty = opts.matchProperty ?? DEFAULT_BACKFILL_MATCH_PROPERTY;
|
|
141
|
+
const source = opts.source ?? "stripe:invoices";
|
|
142
|
+
|
|
143
|
+
// Snapshot lookups. First occurrence wins on a duplicate key — the
|
|
144
|
+
// connector's own resolve/associate step handles ambiguity at apply time.
|
|
145
|
+
const accountsByDomain = new Map<string, CanonicalAccount>();
|
|
146
|
+
const accountsByName = new Map<string, CanonicalAccount>();
|
|
147
|
+
for (const account of snapshot.accounts ?? []) {
|
|
148
|
+
const domain = normalizeDomain(account.domain);
|
|
149
|
+
if (domain && !accountsByDomain.has(domain)) accountsByDomain.set(domain, account);
|
|
150
|
+
const name = account.name?.trim().toLowerCase();
|
|
151
|
+
if (name && !accountsByName.has(name)) accountsByName.set(name, account);
|
|
152
|
+
}
|
|
153
|
+
const existingDealNames = new Set(
|
|
154
|
+
(snapshot.deals ?? [])
|
|
155
|
+
.map((deal) => deal.name?.trim().toLowerCase())
|
|
156
|
+
.filter((name): name is string => Boolean(name)),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const createMissingAccounts = opts.createMissingAccounts ?? true;
|
|
160
|
+
const operations: PatchOperation[] = [];
|
|
161
|
+
const evidence: GtmEvidence[] = [];
|
|
162
|
+
const unmatched: StripeBackfillUnmatched[] = [];
|
|
163
|
+
const proposedByKey = new Map<string, StripeBackfillProposedAccount>();
|
|
164
|
+
const counts: StripeBackfillCounts = {
|
|
165
|
+
invoices: invoices.length,
|
|
166
|
+
planned: 0,
|
|
167
|
+
accountsProposed: 0,
|
|
168
|
+
unmatched: 0,
|
|
169
|
+
alreadyInCrm: 0,
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
for (const invoice of invoices) {
|
|
173
|
+
// Match the customer to a CRM account: domain first, then exact name.
|
|
174
|
+
const domain = normalizeDomain(invoice.customerDomain);
|
|
175
|
+
let account = domain ? accountsByDomain.get(domain) : undefined;
|
|
176
|
+
if (!account && invoice.customerName) {
|
|
177
|
+
account = accountsByName.get(invoice.customerName.trim().toLowerCase());
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// The company the deal will land on: the matched account, or (default) a
|
|
181
|
+
// new account this plan proposes for the unmatched customer.
|
|
182
|
+
let companyName: string | undefined = account?.name;
|
|
183
|
+
let companyDomain = normalizeDomain(account?.domain);
|
|
184
|
+
let accountIsNew = false;
|
|
185
|
+
if (!account && createMissingAccounts) {
|
|
186
|
+
const usableDomain = domain && !FREE_MAIL.has(domain) ? domain : undefined;
|
|
187
|
+
const name = invoice.customerName?.trim() || usableDomain;
|
|
188
|
+
if (name) {
|
|
189
|
+
companyName = name;
|
|
190
|
+
companyDomain = usableDomain;
|
|
191
|
+
accountIsNew = true;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (!companyName) {
|
|
195
|
+
counts.unmatched += 1;
|
|
196
|
+
unmatched.push({
|
|
197
|
+
invoiceId: invoice.id,
|
|
198
|
+
invoiceNumber: invoice.number,
|
|
199
|
+
customerName: invoice.customerName,
|
|
200
|
+
customerDomain: invoice.customerDomain,
|
|
201
|
+
amountPaid: invoice.amountPaid,
|
|
202
|
+
currency: invoice.currency,
|
|
203
|
+
paidAt: invoice.paidAt,
|
|
204
|
+
});
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Plan-time dupe prefilter by deal name (best-effort; apply-time
|
|
209
|
+
// resolve-first on the invoice-id property is the authoritative gate).
|
|
210
|
+
const dealName = backfillDealName(invoice, companyName);
|
|
211
|
+
if (existingDealNames.has(dealName.trim().toLowerCase())) {
|
|
212
|
+
counts.alreadyInCrm += 1;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const invoiceLabel = invoice.number ?? invoice.id;
|
|
217
|
+
const amountLabel = `${invoice.amountPaid}${invoice.currency ? ` ${invoice.currency}` : ""}`;
|
|
218
|
+
const recordEvidence: GtmEvidence = {
|
|
219
|
+
id: `ev_bkf_${fnv1a(`${source}:${invoice.id}`)}`,
|
|
220
|
+
sourceSystem: "web",
|
|
221
|
+
sourceObjectType: "invoice",
|
|
222
|
+
sourceObjectId: invoice.id,
|
|
223
|
+
title: `Stripe paid invoice ${invoiceLabel}`,
|
|
224
|
+
text:
|
|
225
|
+
`Stripe invoice ${invoiceLabel} (${invoice.id}) for ${invoice.customerName ?? companyName}: ` +
|
|
226
|
+
`${amountLabel} paid${invoice.paidAt ? ` on ${invoice.paidAt}` : ""}.`,
|
|
227
|
+
capturedAt: nowIso,
|
|
228
|
+
metadata: {
|
|
229
|
+
source,
|
|
230
|
+
invoiceId: invoice.id,
|
|
231
|
+
amountPaid: invoice.amountPaid,
|
|
232
|
+
currency: invoice.currency ?? null,
|
|
233
|
+
paidAt: invoice.paidAt ?? null,
|
|
234
|
+
customerId: invoice.customerId ?? null,
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
evidence.push(recordEvidence);
|
|
238
|
+
|
|
239
|
+
// One explicit account op per NEW customer (not per invoice): the reviewer
|
|
240
|
+
// sees exactly which companies this plan will create. Apply resolves by
|
|
241
|
+
// domain-then-name first, so a concurrently-created account is reused.
|
|
242
|
+
if (accountIsNew) {
|
|
243
|
+
const accountKey = (companyDomain ?? companyName).toLowerCase();
|
|
244
|
+
const existing = proposedByKey.get(accountKey);
|
|
245
|
+
if (existing) {
|
|
246
|
+
existing.invoiceCount += 1;
|
|
247
|
+
} else {
|
|
248
|
+
proposedByKey.set(accountKey, {
|
|
249
|
+
name: companyName,
|
|
250
|
+
...(companyDomain ? { domain: companyDomain } : {}),
|
|
251
|
+
invoiceCount: 1,
|
|
252
|
+
});
|
|
253
|
+
const accountPayload: CreateRecordPayload = {
|
|
254
|
+
properties: {
|
|
255
|
+
name: companyName,
|
|
256
|
+
...(companyDomain ? { domain: companyDomain } : {}),
|
|
257
|
+
},
|
|
258
|
+
matchKey: "name",
|
|
259
|
+
matchValue: companyName,
|
|
260
|
+
source,
|
|
261
|
+
};
|
|
262
|
+
operations.push({
|
|
263
|
+
id: `op_bkf_${fnv1a(`${source}:account:${accountKey}`)}`,
|
|
264
|
+
objectType: "account",
|
|
265
|
+
objectId: `create:stripe:customer:${invoice.customerId ?? accountKey}`,
|
|
266
|
+
operation: "create_record",
|
|
267
|
+
beforeValue: null,
|
|
268
|
+
afterValue: accountPayload,
|
|
269
|
+
reason:
|
|
270
|
+
`Stripe customer "${companyName}"${companyDomain ? ` (${companyDomain})` : ""} has paid ` +
|
|
271
|
+
`invoice(s) but no CRM account — create it so the backfilled deals have a home. ` +
|
|
272
|
+
`Resolve-first by ${companyDomain ? "domain, then " : ""}name at apply.`,
|
|
273
|
+
sourceRuleOrPolicy: `backfill:${source}`,
|
|
274
|
+
riskLevel: "medium",
|
|
275
|
+
approvalRequired: true,
|
|
276
|
+
rollback: "Archive the created company (it was net-new).",
|
|
277
|
+
evidenceIds: [recordEvidence.id],
|
|
278
|
+
});
|
|
279
|
+
counts.accountsProposed += 1;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Prefer the matched ACCOUNT's own name/domain for the association so the
|
|
284
|
+
// connector's resolve-first company step lands on the matched record; for
|
|
285
|
+
// a new customer these are the proposed account's name/domain.
|
|
286
|
+
const payload: CreateRecordPayload = {
|
|
287
|
+
properties: {
|
|
288
|
+
dealname: dealName,
|
|
289
|
+
amount: String(invoice.amountPaid),
|
|
290
|
+
...(invoice.paidAt ? { closedate: invoice.paidAt } : {}),
|
|
291
|
+
...(invoice.description ? { description: invoice.description } : {}),
|
|
292
|
+
},
|
|
293
|
+
matchKey: matchProperty,
|
|
294
|
+
matchValue: invoice.id,
|
|
295
|
+
source,
|
|
296
|
+
dealStage: "closed_won",
|
|
297
|
+
...(opts.pipeline ? { dealPipeline: opts.pipeline } : {}),
|
|
298
|
+
associateCompanyName: companyName,
|
|
299
|
+
...(companyDomain ? { associateCompanyDomain: companyDomain } : {}),
|
|
300
|
+
};
|
|
301
|
+
operations.push({
|
|
302
|
+
id: `op_bkf_${fnv1a(`${source}:deal:${invoice.id}`)}`,
|
|
303
|
+
objectType: "deal",
|
|
304
|
+
objectId: `create:stripe:invoice:${invoice.id}`,
|
|
305
|
+
operation: "create_record",
|
|
306
|
+
beforeValue: null,
|
|
307
|
+
afterValue: payload,
|
|
308
|
+
reason:
|
|
309
|
+
`Paid Stripe invoice ${invoiceLabel} (${amountLabel}, paid ${invoice.paidAt ?? "date unknown"}) ` +
|
|
310
|
+
`has no CRM deal — backfill as closed-won on ${accountIsNew ? "NEW account" : "account"} ` +
|
|
311
|
+
`"${companyName}"${accountIsNew ? " (created by this plan)" : ""} ` +
|
|
312
|
+
`(deduped by ${matchProperty}=${invoice.id}).`,
|
|
313
|
+
sourceRuleOrPolicy: `backfill:${source}`,
|
|
314
|
+
riskLevel: "medium",
|
|
315
|
+
approvalRequired: true,
|
|
316
|
+
rollback: "Archive the created deal (it was net-new).",
|
|
317
|
+
evidenceIds: [recordEvidence.id],
|
|
318
|
+
});
|
|
319
|
+
counts.planned += 1;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const plan: PatchPlan = {
|
|
323
|
+
id: `patch_plan_backfill_${fnv1a(`${source}:${nowIso}:${invoices.map((invoice) => invoice.id).join(",")}`)}`,
|
|
324
|
+
title: `Backfill closed-won deals — ${source}`,
|
|
325
|
+
createdAt: nowIso,
|
|
326
|
+
status: operations.length > 0 ? "needs_approval" : "draft",
|
|
327
|
+
dryRun: true,
|
|
328
|
+
summary:
|
|
329
|
+
`${counts.planned} closed-won deal(s) proposed from ${counts.invoices} paid Stripe invoice(s)` +
|
|
330
|
+
`${counts.accountsProposed > 0 ? `, plus ${counts.accountsProposed} new account(s) proposed for customers the CRM doesn't know` : ""} ` +
|
|
331
|
+
`(${counts.alreadyInCrm} already have a matching deal, ${counts.unmatched} ` +
|
|
332
|
+
`${createMissingAccounts ? "customer(s) with no usable name/domain" : "unmatched customer(s)"} reported only` +
|
|
333
|
+
`${createMissingAccounts ? "" : " — account creation disabled"}). ` +
|
|
334
|
+
`Deduped by ${matchProperty} = invoice id; apply re-checks resolve-first before creating.`,
|
|
335
|
+
findings: [],
|
|
336
|
+
evidence,
|
|
337
|
+
operations,
|
|
338
|
+
};
|
|
339
|
+
return { plan, counts, unmatched, proposedAccounts: [...proposedByKey.values()] };
|
|
340
|
+
}
|
package/src/bin.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { runCli } from "./cli.ts";
|
|
3
|
-
import { flushRunReport } from "./runReport.ts";
|
|
3
|
+
import { beginRunReport, flushRunReport } from "./runReport.ts";
|
|
4
4
|
|
|
5
5
|
const args = process.argv.slice(2);
|
|
6
6
|
const startedAt = Date.now();
|
|
7
7
|
|
|
8
|
+
// Arm live progress heartbeats (paired CLIs stream long runs to the hosted
|
|
9
|
+
// app under the same clientRunId the final flush below will upsert).
|
|
10
|
+
beginRunReport(args, startedAt);
|
|
11
|
+
|
|
8
12
|
runCli(args)
|
|
9
13
|
.then(async () => {
|
|
10
14
|
// exitCode 2 (audit findings / create-gate) or 1 (no value) = "partial":
|