@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 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