@classytic/crm 0.1.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 +36 -0
- package/LICENSE +21 -0
- package/README.md +322 -0
- package/dist/index.d.ts +1420 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1943 -0
- package/dist/index.js.map +1 -0
- package/package.json +57 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@classytic/crm` are documented here.
|
|
4
|
+
|
|
5
|
+
## [0.1.0] — 2026-04-18
|
|
6
|
+
|
|
7
|
+
Initial release.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Eight domain entities.** `Contact`, `Account`, `Lead`, `Opportunity`, `Pipeline` (+ nested `Stage`), `Activity`, `Note`, `LossReason`.
|
|
12
|
+
- **Three state machines.** Lead (`new` → `contacted` → `qualified` → `converted`, + `disqualified` / `nurturing` branches), Opportunity (`open` → `won` / `lost` / `abandoned` with in-pipeline Stage movement), Activity (`planned` → `in_progress` → `completed` / `cancelled`). All transitions CAS-guarded with explicit `*_TRANSITIONS` maps and `canTransition*` guards.
|
|
13
|
+
- **Six services.**
|
|
14
|
+
- `ContactService` — CRUD + tag filtering + email lookup.
|
|
15
|
+
- `AccountService` — CRUD + domain lookup.
|
|
16
|
+
- `PipelineService` — create, archive, stage helpers (`getStage`, `getFirstStage`, `getNextStage`).
|
|
17
|
+
- `OpportunityService` — create at first stage (or specified), `moveToStage` with advance/regress event disambiguation, `win` / `lose` (with `lostReasonId`) / `abandon`.
|
|
18
|
+
- `LeadService` — CRUD + full lifecycle (`markContacted`, `qualify`, `disqualify`, `nurture`) + `convert()` — the crown-jewel verb that atomically creates Contact + Account + Opportunity and transitions the lead to `converted`.
|
|
19
|
+
- `ActivityService` — polymorphic activities (`plan`, `start`, `complete`, `cancel`) + notes (`addNote`, `listNotesBySubject`).
|
|
20
|
+
- **Pure calculators.**
|
|
21
|
+
- `defaultLeadScorer` + `composeScorers(...rules)` — host injects whatever rubric (firmographics, in-app signals, ML prediction, enrichment) via a `(lead: Lead) => number` function.
|
|
22
|
+
- `forecast(opportunities, options?)` — committed + weighted pipeline by bucket (default ISO month), with custom bucket function support. Currency-mismatch detection.
|
|
23
|
+
- `pipelineConversion(opportunities, stageIds)` — where do deals die? returns per-stage current count + won/lost counts + conversion rate.
|
|
24
|
+
- **Nine repository ports** — one per entity (plus `EventTransport`, `IdGenerator`, `Clock`, optional `OutreachPort`). Full in-memory reference implementations shipped.
|
|
25
|
+
- **Typed error hierarchy** rooted at `CrmError`: `NotFoundError`, `InvalidLeadTransitionError`, `InvalidOpportunityTransitionError`, `InvalidActivityTransitionError`, `StageNotInPipelineError`, `LeadAlreadyConvertedError`, `DuplicateLeadError`, `EmptyPipelineError`.
|
|
26
|
+
- **Nineteen lifecycle events** (`CRM_EVENTS`): covering lead/account/contact/opportunity/pipeline/activity/note verbs, all following `crm:<resource>.<verb>` per PACKAGE_RULES rule 12.
|
|
27
|
+
- **Optional `OutreachPort`** — one-method (`send`) + `NullOutreachPort` + `InMemoryOutreachPort` (recording). Host subscribes to `crm:activity.*` events and drives the port from their automation layer (keeps pacing / retry / bounce handling out of the core).
|
|
28
|
+
- **Testing.** `vitest.config.ts` projects split `unit` + `integration` per [testing-infrastructure.md](../../testing-infrastructure.md). 48 tests (7 files) — all green in <1.5s.
|
|
29
|
+
|
|
30
|
+
### Design choices
|
|
31
|
+
|
|
32
|
+
- Pure-TS port-based pattern — consistent with `@classytic/manufacturing`, `@classytic/assets`, `@classytic/fleet`, `@classytic/carrier-*`.
|
|
33
|
+
- Separate Lead + Opportunity models (erpnext style) rather than unified (odoo style) — cleaner semantics for pre-qualification vs in-pipeline data and enables distinct analytics.
|
|
34
|
+
- Polymorphic Activity / Note via `subjectKind` + `subjectId` so hosts can extend without schema changes.
|
|
35
|
+
- `LeadService.convert()` is atomic in intent and sequenced for auditability — it creates Account, then Contact, then Opportunity, then transitions the lead. If a downstream repo fails, the ones that succeeded persist (hosts using Mongoose sessions get atomicity at their layer).
|
|
36
|
+
- Campaigns, appointment booking, competitors, contracts, team routing, RBAC, custom-field validation, email-thread parsing deferred to v0.2+ to keep the surface focused.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Classytic
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
# @classytic/crm
|
|
2
|
+
|
|
3
|
+
Framework-agnostic CRM primitive for Node.js + Bun — the base to build
|
|
4
|
+
the next Pipedrive, HubSpot, Salesforce, or Buffer-style outreach
|
|
5
|
+
automation on top of.
|
|
6
|
+
|
|
7
|
+
- **Pure TypeScript.** No Mongo, no HTTP, no framework dep.
|
|
8
|
+
- **Event-driven.** 19 lifecycle events out of the box — any
|
|
9
|
+
subscribing automation layer plugs in without modifying the core.
|
|
10
|
+
- **Port-based storage.** Plug in Mongoose / Prisma / your existing
|
|
11
|
+
data store per entity (8 repo interfaces, 1 optional outreach
|
|
12
|
+
bridge). In-memory reference implementations shipped for tests +
|
|
13
|
+
standalone use.
|
|
14
|
+
- **Atomic state transitions.** Every lead / opportunity / activity
|
|
15
|
+
status change goes through a CAS against the expected status.
|
|
16
|
+
- **Pluggable lead scoring.** `defaultLeadScorer` is replaceable with
|
|
17
|
+
`composeScorers(...rules)` or any `(lead: Lead) => number`.
|
|
18
|
+
- **Forecast + pipeline-conversion analytics.** Pure functions over
|
|
19
|
+
opportunity lists — safe to run in any context.
|
|
20
|
+
- **Polymorphic activities + notes.** Attach to Lead, Opportunity,
|
|
21
|
+
Contact, or Account. Automation subscribes to `crm:activity.*` and
|
|
22
|
+
does its thing — email, SMS, calendar booking, Slack ping, LLM
|
|
23
|
+
summary.
|
|
24
|
+
|
|
25
|
+
## Why this matters for the Node / Bun community
|
|
26
|
+
|
|
27
|
+
Most CRMs ship as monolithic apps. This package is the **headless
|
|
28
|
+
engine** — it owns the entities, state machines, events, and
|
|
29
|
+
analytics, and gets out of your way on everything else. Want a
|
|
30
|
+
Buffer-style scheduled-outreach app? Subscribe to `crm:activity.planned`
|
|
31
|
+
with `type: 'email'`, queue the send. Want an AI-written follow-up
|
|
32
|
+
layer? Subscribe to `crm:opportunity.stage_advanced`, call an LLM,
|
|
33
|
+
`activities.plan(...)` the generated task. None of that lives in this
|
|
34
|
+
package; all of it is unblocked by it.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npm install @classytic/crm
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`@classytic/primitives` is the only peer (for `Money`).
|
|
43
|
+
|
|
44
|
+
## Full lead-to-customer flow
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import {
|
|
48
|
+
AccountService,
|
|
49
|
+
ActivityService,
|
|
50
|
+
ContactService,
|
|
51
|
+
LeadService,
|
|
52
|
+
OpportunityService,
|
|
53
|
+
PipelineService,
|
|
54
|
+
InMemoryAccountRepository,
|
|
55
|
+
InMemoryActivityRepository,
|
|
56
|
+
InMemoryContactRepository,
|
|
57
|
+
InMemoryLeadRepository,
|
|
58
|
+
InMemoryNoteRepository,
|
|
59
|
+
InMemoryOpportunityRepository,
|
|
60
|
+
InMemoryPipelineRepository,
|
|
61
|
+
InMemoryEventTransport,
|
|
62
|
+
} from '@classytic/crm';
|
|
63
|
+
|
|
64
|
+
const events = new InMemoryEventTransport();
|
|
65
|
+
|
|
66
|
+
const accounts = new AccountService({ repo: new InMemoryAccountRepository(), events });
|
|
67
|
+
const contacts = new ContactService({ repo: new InMemoryContactRepository(), events });
|
|
68
|
+
const pipelines = new PipelineService({ repo: new InMemoryPipelineRepository(), events });
|
|
69
|
+
const opportunities = new OpportunityService({
|
|
70
|
+
repo: new InMemoryOpportunityRepository(),
|
|
71
|
+
pipelines,
|
|
72
|
+
events,
|
|
73
|
+
});
|
|
74
|
+
const leads = new LeadService({
|
|
75
|
+
repo: new InMemoryLeadRepository(),
|
|
76
|
+
contacts,
|
|
77
|
+
accounts,
|
|
78
|
+
opportunities,
|
|
79
|
+
events,
|
|
80
|
+
});
|
|
81
|
+
const activities = new ActivityService({
|
|
82
|
+
repo: new InMemoryActivityRepository(),
|
|
83
|
+
notes: new InMemoryNoteRepository(),
|
|
84
|
+
events,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Pipeline setup.
|
|
88
|
+
const pipeline = await pipelines.create({
|
|
89
|
+
name: 'New Business',
|
|
90
|
+
stages: [
|
|
91
|
+
{ name: 'Prospecting', sequence: 1, defaultProbability: 0.1 },
|
|
92
|
+
{ name: 'Discovery', sequence: 2, defaultProbability: 0.3 },
|
|
93
|
+
{ name: 'Proposal', sequence: 3, defaultProbability: 0.6 },
|
|
94
|
+
{ name: 'Negotiation', sequence: 4, defaultProbability: 0.8 },
|
|
95
|
+
],
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Inbound lead.
|
|
99
|
+
const lead = await leads.create({
|
|
100
|
+
email: 'alex@acme.co',
|
|
101
|
+
firstName: 'Alex',
|
|
102
|
+
lastName: 'Rivera',
|
|
103
|
+
companyName: 'Acme',
|
|
104
|
+
jobTitle: 'Director',
|
|
105
|
+
source: 'web-form',
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Qualify + convert — spawns Contact + Account + Opportunity atomically.
|
|
109
|
+
await leads.markContacted(lead.id);
|
|
110
|
+
await leads.qualify(lead.id);
|
|
111
|
+
const { opportunityId } = await leads.convert(lead.id, {
|
|
112
|
+
pipelineId: pipeline.id,
|
|
113
|
+
amount: { amount: 120_000, currency: 'USD' },
|
|
114
|
+
expectedCloseAt: new Date('2026-06-30'),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Move through pipeline.
|
|
118
|
+
await opportunities.moveToStage(opportunityId, { stageId: pipeline.stages[1]!.id });
|
|
119
|
+
await opportunities.moveToStage(opportunityId, { stageId: pipeline.stages[2]!.id });
|
|
120
|
+
await opportunities.moveToStage(opportunityId, { stageId: pipeline.stages[3]!.id });
|
|
121
|
+
|
|
122
|
+
// Close as won.
|
|
123
|
+
await opportunities.win(opportunityId, { amount: { amount: 125_000, currency: 'USD' } });
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## State machines
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
Lead: new → contacted → qualified → converted
|
|
130
|
+
↘ ↘ ↘
|
|
131
|
+
disqualified / nurturing ←──────┐
|
|
132
|
+
│
|
|
133
|
+
(nurturing → contacted/qualified)
|
|
134
|
+
|
|
135
|
+
Opportunity: open → won | lost | abandoned (terminal)
|
|
136
|
+
(stage movement happens while open)
|
|
137
|
+
|
|
138
|
+
Activity: planned → in_progress → completed
|
|
139
|
+
↘ ↘
|
|
140
|
+
cancelled (from any non-terminal)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Each transition is CAS-guarded and appends to the record's
|
|
144
|
+
`statusHistory`. Invalid transitions throw typed errors.
|
|
145
|
+
|
|
146
|
+
## Lead scoring
|
|
147
|
+
|
|
148
|
+
`LeadService` runs the scorer on `create` and on-demand via
|
|
149
|
+
`recomputeScore(id)`. Replace the default with your own:
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
import { LeadService, composeScorers } from '@classytic/crm';
|
|
153
|
+
|
|
154
|
+
const scorer = composeScorers(
|
|
155
|
+
(l) => (l.email ? 20 : 0),
|
|
156
|
+
(l) => (l.source === 'referral' ? 30 : 0),
|
|
157
|
+
(l) => (l.tags?.includes('enterprise') ? 50 : 0),
|
|
158
|
+
(l) => (/ceo|cto|founder/i.test(l.jobTitle ?? '') ? 40 : 0),
|
|
159
|
+
// Plug in ML / enrichment predictions:
|
|
160
|
+
(l) => myEnricher.scoreFor(l),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
new LeadService({ repo, contacts, accounts, opportunities, scorer });
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Forecast + pipeline conversion
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
import { forecast, pipelineConversion } from '@classytic/crm';
|
|
170
|
+
|
|
171
|
+
const open = await opportunities.list({ status: 'open' });
|
|
172
|
+
const rows = forecast(open); // default: ISO-month buckets
|
|
173
|
+
// [{ bucket: '2026-06', committed: 120_000, weighted: 96_000, count: 1, currency: 'USD' }]
|
|
174
|
+
|
|
175
|
+
// Custom buckets (quarters, fiscal years, per-team, …):
|
|
176
|
+
forecast(open, { bucket: (o) => quarterOf(o.expectedCloseAt!) });
|
|
177
|
+
|
|
178
|
+
// Funnel analysis: where do deals die?
|
|
179
|
+
const all = await opportunities.list();
|
|
180
|
+
const conv = pipelineConversion(all, pipeline.stages.map((s) => s.id));
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Activities + notes (polymorphic)
|
|
184
|
+
|
|
185
|
+
Attach to any entity via `subjectKind` + `subjectId`:
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
await activities.plan({
|
|
189
|
+
type: 'email',
|
|
190
|
+
subjectKind: 'lead',
|
|
191
|
+
subjectId: lead.id,
|
|
192
|
+
subject: 'Follow-up on demo',
|
|
193
|
+
scheduledAt: new Date('2026-04-20T15:00:00Z'),
|
|
194
|
+
ownerId: 'ae_1',
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// Quick note on an opportunity:
|
|
198
|
+
await activities.addNote({
|
|
199
|
+
subjectKind: 'opportunity',
|
|
200
|
+
subjectId: opp.id,
|
|
201
|
+
body: 'Champion confirmed — legal review starts Monday.',
|
|
202
|
+
authorId: 'ae_1',
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Pull everything for an entity:
|
|
206
|
+
const allActivities = await activities.listBySubject('opportunity', opp.id);
|
|
207
|
+
const allNotes = await activities.listNotesBySubject('opportunity', opp.id);
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## Events catalog
|
|
211
|
+
|
|
212
|
+
Subscribe via `EventTransport` from `@classytic/primitives/events`
|
|
213
|
+
(the same contract mirrored from Arc) — Arc's bus, Redis, in-memory,
|
|
214
|
+
Kafka, or any transport implementing that shape works.
|
|
215
|
+
|
|
216
|
+
| Event | When |
|
|
217
|
+
|---|---|
|
|
218
|
+
| `crm:lead.created` | new lead |
|
|
219
|
+
| `crm:lead.status_changed` | any transition |
|
|
220
|
+
| `crm:lead.scored` | score recomputed |
|
|
221
|
+
| `crm:lead.converted` | spawns Contact + Account + Opportunity |
|
|
222
|
+
| `crm:lead.disqualified` | with reason |
|
|
223
|
+
| `crm:account.created` / `updated` | |
|
|
224
|
+
| `crm:contact.created` / `updated` | |
|
|
225
|
+
| `crm:opportunity.created` | |
|
|
226
|
+
| `crm:opportunity.stage_advanced` | forward motion |
|
|
227
|
+
| `crm:opportunity.stage_regressed` | backward motion (slip) |
|
|
228
|
+
| `crm:opportunity.won` / `lost` / `abandoned` | |
|
|
229
|
+
| `crm:pipeline.created` / `archived` | |
|
|
230
|
+
| `crm:activity.planned` / `completed` / `cancelled` | |
|
|
231
|
+
| `crm:note.added` | |
|
|
232
|
+
|
|
233
|
+
This event surface is the extension plane — automation tools
|
|
234
|
+
subscribe with globs (`crm:*`, `crm:lead.*`) and do their thing
|
|
235
|
+
without touching the core package.
|
|
236
|
+
|
|
237
|
+
## Optional outreach bridge
|
|
238
|
+
|
|
239
|
+
`OutreachPort` is a one-method interface for sending email / SMS /
|
|
240
|
+
social. The package doesn't call it automatically — wire it in your
|
|
241
|
+
automation layer:
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
import { CRM_EVENTS } from '@classytic/crm';
|
|
245
|
+
|
|
246
|
+
events.subscribe(CRM_EVENTS.ACTIVITY_PLANNED, async (event) => {
|
|
247
|
+
const { activityId } = event.payload as { activityId: string };
|
|
248
|
+
const activity = await activities.findById(activityId);
|
|
249
|
+
if (activity?.type !== 'email' || !activity.metadata?.toAddress) return;
|
|
250
|
+
await outreach.send({
|
|
251
|
+
channel: 'email',
|
|
252
|
+
toAddress: activity.metadata.toAddress,
|
|
253
|
+
subject: activity.subject,
|
|
254
|
+
body: activity.body!,
|
|
255
|
+
activityId: activity.id,
|
|
256
|
+
});
|
|
257
|
+
await activities.complete(activity.id);
|
|
258
|
+
});
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
`InMemoryOutreachPort` records sends for tests; `NullOutreachPort` is
|
|
262
|
+
a no-op; real integrations wrap SendGrid, Postmark, Twilio, or an
|
|
263
|
+
in-house provider.
|
|
264
|
+
|
|
265
|
+
## What v0.1 does NOT include
|
|
266
|
+
|
|
267
|
+
Kept off v0.1 to ship a focused surface — each is small to bolt on
|
|
268
|
+
via the event system:
|
|
269
|
+
|
|
270
|
+
- Campaigns / sequences (build via activity templates + cron).
|
|
271
|
+
- Territory / team routing (ownership fields are there — use them).
|
|
272
|
+
- Appointment booking (host integrates calendar).
|
|
273
|
+
- Email thread parsing (host integrates inbox).
|
|
274
|
+
- Custom-field schema validation (host owns the `metadata` map).
|
|
275
|
+
- Role-based access control (host owns auth).
|
|
276
|
+
- Audit log (host subscribes to events + persists).
|
|
277
|
+
- Competitor tracking (free-form metadata for now).
|
|
278
|
+
- Contract management (out of scope — use an invoicing package).
|
|
279
|
+
|
|
280
|
+
## Architecture
|
|
281
|
+
|
|
282
|
+
```
|
|
283
|
+
src/
|
|
284
|
+
├── index.ts ← main barrel
|
|
285
|
+
├── domain/
|
|
286
|
+
│ ├── entities/ ← Account, Contact, Lead, Opportunity, Pipeline,
|
|
287
|
+
│ │ Activity, Note, LossReason
|
|
288
|
+
│ ├── enums/ ← lead-status, opportunity-status, activity (+ subject-kinds)
|
|
289
|
+
│ ├── ports/ ← 8 repo ports + OutreachPort + Clock + Ids
|
|
290
|
+
│ └── errors.ts
|
|
291
|
+
├── adapters/in-memory/ ← reference repos + null/recording outreach + events + ids
|
|
292
|
+
├── services/
|
|
293
|
+
│ ├── account.service.ts
|
|
294
|
+
│ ├── activity.service.ts ← activities + notes
|
|
295
|
+
│ ├── contact.service.ts
|
|
296
|
+
│ ├── lead.service.ts ← includes convert() — crown-jewel verb
|
|
297
|
+
│ ├── opportunity.service.ts ← stage movement + win/lose/abandon
|
|
298
|
+
│ ├── pipeline.service.ts
|
|
299
|
+
│ ├── scoring.ts ← pure lead scorer + composeScorers
|
|
300
|
+
│ └── forecast.ts ← pure forecast() + pipelineConversion()
|
|
301
|
+
└── events/
|
|
302
|
+
└── event-constants.ts ← CRM_EVENTS
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
## Tests
|
|
306
|
+
|
|
307
|
+
48 tests across unit + integration:
|
|
308
|
+
|
|
309
|
+
```bash
|
|
310
|
+
npm test # unit + integration (default)
|
|
311
|
+
npm run test:unit
|
|
312
|
+
npm run test:integration
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
The integration scenario in [tests/integration/lead-to-customer.test.ts](tests/integration/lead-to-customer.test.ts)
|
|
316
|
+
is the MOVEMENTS.md §7 gate: **lead → opportunity → customer end-to-end** —
|
|
317
|
+
walks the full happy path, the lost-path, and the nurture-then-requalify
|
|
318
|
+
path, asserting cross-entity links, event order, and forecast roll-up.
|
|
319
|
+
|
|
320
|
+
## License
|
|
321
|
+
|
|
322
|
+
MIT
|