@planescrm/sdk 0.3.0 → 0.55.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/README.md CHANGED
@@ -1,14 +1,19 @@
1
- # @planes/sdk
1
+ # @planescrm/sdk
2
2
 
3
3
  TypeScript SDK for the [Planes CRM](https://planescrm.com) platform — a typed API
4
4
  client, an ergonomic webhook router, OAuth helpers, and an action-handler server
5
5
  for building agents.
6
6
 
7
- > **Not yet published.** `@planes/sdk` is currently a private workspace package —
8
- > it is **not** on npm. Consume it from within this monorepo as a workspace
9
- > dependency (`"@planes/sdk": "workspace:*"`), or request access from the Planes
10
- > team. The `npm install @planes/sdk` examples below describe the intended
11
- > published surface once publishing is enabled.
7
+ ```bash
8
+ npm install @planescrm/sdk
9
+ ```
10
+
11
+ **Self-contained types (0.52.1).** The built package imports only Node builtins
12
+ and its own dependencies — never the private `@blueprint/shared`. Types the API
13
+ shares are mirrored under `src/types/*-types.ts` and checked against shared at
14
+ compile time by `*.parity.ts` files, which are not reachable from `index.ts`.
15
+ `build` runs `scripts/check-shipped-imports.mjs` after `tsup` and fails if
16
+ `dist/` or a shipped source file imports anything else.
12
17
 
13
18
  ## Client
14
19
 
@@ -16,7 +21,7 @@ for building agents.
16
21
  exactly **one** authentication mode.
17
22
 
18
23
  ```ts
19
- import { PlanesClient } from '@planes/sdk';
24
+ import { PlanesClient } from '@planescrm/sdk';
20
25
 
21
26
  // 1. A pre-obtained OAuth access token (not auto-refreshed).
22
27
  const client = new PlanesClient({ accessToken: 'tok_...' });
@@ -36,7 +41,413 @@ const opp = await client.opportunities.get('opp_123');
36
41
  ```
37
42
 
38
43
  Resources: `opportunities`, `customers`, `properties`, `calendarEvents`,
39
- `contracts`, `jobs`, `estimates`, `invoices`, `inventory`.
44
+ `contracts`, `jobs`, `estimates`, `invoices`, `inventory`, `contacts`,
45
+ `activities`, `communications`, `gmail`, `documents`, `tags`, `tasks`,
46
+ `customFields`, `recordViews`, `modules`, `plan`, `me`, `roles`, `contactSequences`,
47
+ `enrollments`, `workflows`, `billingSchedules`.
48
+
49
+ ### Breaking changes (0.43.0)
50
+
51
+ Twenty methods called routes the API doesn't serve. Every old call failed with
52
+ a 404 or 405, so no working integration depended on them. Each one now follows
53
+ the real route, and a method the API doesn't have is gone, so calling it is a
54
+ compile error instead of a runtime 404.
55
+
56
+ | Resource | Before (never worked) | Now |
57
+ |---|---|---|
58
+ | `properties` | `list(params)`, `create(body)`, `update(id, body)` (PATCH), `delete(id)` on `/properties` | `list(customerId, params)`, `create(customerId, body)`, `update(customerId, id, body)` (PUT), `delete(customerId, id)` on `/customers/{customerId}/properties`. `get(id)` is unchanged (`GET /properties/{id}`). |
59
+ | `jobs` | `list`, `get`, `create`, `update` (PATCH), `delete` on `/jobs` | `list(opportunityId, params)`, `get(opportunityId, id)`, `create(opportunityId, body)`, `update(opportunityId, id, body)` (PUT), `delete(opportunityId, id)` on `/opportunities/{opportunityId}/jobs`. `listForOpportunity()` and `getTradeCoverage()` are unchanged. |
60
+ | `estimates` | `list`, `get`, `create`, `update` (PATCH), `delete` on `/estimates` | `list(jobId, params)`, `get(jobId, id)`, `create(jobId, body)`, `update(jobId, id, body)` (PUT), `delete(jobId, id)` on `/jobs/{jobId}/estimates`. `listForOpportunity()` is unchanged. |
61
+ | `inventory` | CRUD on `/inventory/items`, `update` sent PATCH | Same methods on `/admin/inventory/items`; `update` sends PUT. |
62
+ | `opportunities` | `update(id, body)` sent PATCH | `update(id, body)` sends PUT; the body is typed `UpdateOpportunityDto`. |
63
+ | `tasks` | `delete(id)` | Removed (there is no delete route). Use `cancel(id)`; `reopen(id)` brings a task back. |
64
+ | `tags` | `get(id)` | Removed (there is no single-tag read). Use `list()` or `getEntities(id)`. |
65
+
66
+ New request types, mirroring the API's validation schemas: `CreatePropertyDto`,
67
+ `UpdatePropertyDto`, `PropertyListParams`, `CreateJobDto`, `UpdateJobDto`,
68
+ `CreateEstimateDto`, `UpdateEstimateDto`, `CreateInventoryItemDto`,
69
+ `UpdateInventoryItemDto`, `InventoryItemListParams`, `UpdateOpportunityDto`.
70
+
71
+ ```ts
72
+ const { data: properties } = await client.properties.list('cust_123');
73
+ const job = await client.jobs.create('opp_123', { tradeId: 'trade_123' });
74
+ await client.estimates.update('job_123', 'est_123', { taxRate: 0.0825 });
75
+ await client.tasks.cancel('task_123');
76
+ ```
77
+
78
+ ### Invoices
79
+
80
+ `client.invoices.list({ status, customerId, page, limit })` returns typed
81
+ `InvoiceListItemDto` rows. Each row carries `customerName` — the company name
82
+ for a company customer, "First Last" for a person — so a list view needs no
83
+ follow-up `customers.get()` per row:
84
+
85
+ ```ts
86
+ const { data } = await client.invoices.list({ status: 'sent' });
87
+ data.forEach((inv) => console.log(inv.invoiceNumber, inv.customerName, inv.balance));
88
+ ```
89
+
90
+ ### Consent state (0.33.0)
91
+
92
+ Every contact channel the API returns (`contacts.get`, `contacts.list({ include: ['channels'] })`,
93
+ channel create/update) and `GET /customers/:id` / `PUT /customers/:id` carry a `consent`
94
+ object (`ConsentStateDto`): the record's own `marketing_opt_out` / `dnc` flags,
95
+ `effective_opted_out` (`marketing_email` / `sms` / `call`) — the by-address verdict every send
96
+ honours, which can be true while the record's own flag is false because another record with the
97
+ same address holds the opt-out — and `last_change` (`{ outcome, source, at, scope }` or `null`).
98
+
99
+ ```ts
100
+ const { data } = await client.contacts.list({ customer_id: customerId, include: ['channels'] });
101
+ for (const ch of data.flatMap((c) => c.channels ?? [])) {
102
+ if (ch.consent.effective_opted_out.marketing_email && !ch.consent.marketing_opt_out) {
103
+ console.log(ch.value, 'is opted out via another record', ch.consent.last_change?.source);
104
+ }
105
+ }
106
+ `client.invoices.downloadPdf(id)` (0.36.0) returns the invoice PDF as an
107
+ `ArrayBuffer`. A sent invoice returns the copy stored when it was sent, and a
108
+ draft renders on demand with a DRAFT watermark:
109
+
110
+ ```ts
111
+ import { writeFile } from 'node:fs/promises';
112
+ await writeFile('INV-0001.pdf', Buffer.from(await client.invoices.downloadPdf(invoiceId)));
113
+ ### Overdue invoices (0.35.0)
114
+
115
+ `client.invoices.listOverdue({ minDays, customerId, page, limit })` returns
116
+ sent / partially paid invoices past their due date, most overdue first, with
117
+ `daysOverdue` and `lastOffsetReached`; `client.invoices.overdueSummary()`
118
+ returns `{ count, total }`. A daily sweep emits the `invoice.overdue` webhook
119
+ event (typed `InvoiceOverdueEvent`) once per invoice at each of the company's
120
+ offsets — days after the due date, default `[3, 7, 14]`, read and set (admin)
121
+ with `client.invoiceSettings.getOverdueOffsets()` / `setOverdueOffsets([...])`:
122
+
123
+ ```ts
124
+ const { data } = await client.invoices.listOverdue({ minDays: 7 });
125
+ const { count, total } = await client.invoices.overdueSummary();
126
+ await client.invoiceSettings.setOverdueOffsets([5, 10, 30]); // 1–90 days, up to 5
127
+ ```
128
+
129
+ Invoice emails attach that stored copy (as `INV-0001.pdf`) unless an admin turns
130
+ it off: `client.invoiceSettings.setAttachPdf(false)` / `getAttachPdf()` (0.39.0;
131
+ the write needs Update Tenant).
132
+
133
+ Payment reminders (0.41.0, admin only): `client.invoiceReminders.list()` returns
134
+ the rules and the overdue offsets (a rule at an offset the sweep never emits is
135
+ `willNeverFire`); `update(rules)` replaces the whole set (unknown placeholders are
136
+ a 422 `REMINDER_TEMPLATE_INVALID`); `preview({ subject, body, invoiceId? })`
137
+ renders exactly what would send, without sending:
138
+
139
+ ```ts
140
+ await client.invoiceReminders.update([
141
+ { offsetDays: 7, enabled: true, subject: 'Invoice {{invoiceNumber}} is past due', body: 'Hi {{customerName}}, {{amountDue}} was due {{dueDate}}.' },
142
+ ]);
143
+ const { subject, body } = await client.invoiceReminders.preview({ subject: '…', body: '…', invoiceId });
144
+ ```
145
+
146
+ ### Billing schedules (0.32.0)
147
+ ### Billing schedules (0.32.0; line versions 0.34.0; adjustments 0.38.0; autopay summary 0.46.0)
148
+
149
+ `client.billingSchedules` manages recurring invoices (`/admin/billing-schedules`,
150
+ invoicing module). Fields are snake_case, money is a 2-decimal string, and
151
+ dates are `YYYY-MM-DD`:
152
+
153
+ ```ts
154
+ const schedule = await client.billingSchedules.create({
155
+ customer_id: 'cus_123',
156
+ name: 'Monthly maintenance',
157
+ cadence: 'monthly',
158
+ start_date: '2026-11-01', // a past date also needs backfill: 'skip' | 'draft'
159
+ lines: [{ description: 'Lawn care', quantity: '1', unit_price: '120.00' }],
160
+ }); // timezone defaults to the tenant's
161
+
162
+ await client.billingSchedules.preview(schedule.id, { count: 12 }); // next 12 periods
163
+ // A price change from a date: periods starting on or after it bill the new lines.
164
+ await client.billingSchedules.update(schedule.id, {
165
+ lines: [{ description: 'Lawn care', quantity: '1', unit_price: '135.00' }],
166
+ effective_from: '2027-01-01',
167
+ });
168
+ await client.billingSchedules.listVersions(schedule.id); // line version history
169
+ // A one-off credit (or charge) on the next invoice only — no automatic proration.
170
+ const credit = await client.billingSchedules.addAdjustment(schedule.id, {
171
+ description: 'Credit: 10 days not serviced',
172
+ amount: '-40.00',
173
+ });
174
+ await client.billingSchedules.listAdjustments(schedule.id); // pending + consumed
175
+ await client.billingSchedules.cancelAdjustment(schedule.id, credit.id); // only while pending
176
+ await client.billingSchedules.periods(schedule.id, { status: 'failed' }); // the period ledger
177
+ await client.billingSchedules.needsReview(); // tenant-wide queue
178
+ await client.billingSchedules.autopaySummary(); // { data: { autopaySchedules, customers, savedCards } } before a Stripe disconnect
179
+ await client.billingSchedules.generateNow(schedule.id); // bill the current due period now
180
+ await client.billingSchedules.skipPeriod(schedule.id, '2026-12-01', { reason: 'Away' });
181
+ await client.billingSchedules.regeneratePeriod(schedule.id, '2026-11-01'); // after a void
182
+ await client.billingSchedules.pause(schedule.id); // also resume(id), end(id)
183
+ ```
184
+
185
+ Also `list({ customer_id, status, page, limit })`, `get(id)` (with
186
+ `upcoming_periods`), `update(id, patch)` (`lines` replaces every line) and
187
+ `delete(id)` (only a schedule that never invoiced).
188
+
189
+ ### Completing calendar events (0.44.0)
190
+
191
+ `client.calendarEvents.complete(id, { completion_notes? })` marks an event
192
+ completed and runs its "on complete" transition, best-effort. The event is
193
+ always completed; `workflow_trigger` says whether the transition ran:
194
+
195
+ ```ts
196
+ const res = await client.calendarEvents.complete('evt_123');
197
+ if (res.workflow_trigger.status === 'failed') {
198
+ // e.g. reason 'not_valid_from_current_stage' or 'wrong_pipeline'
199
+ console.warn(res.workflow_trigger.message);
200
+ }
201
+ ```
202
+
203
+ `status` is `fired`, `skipped` (no transition set) or `failed` (with a
204
+ `reason` and `message`; the tenant's admins also get a notification).
205
+ Since 0.55.0 a `fired` trigger carries `to_stage: { id, name }`, the stage the
206
+ transition moved the record to; `tasks.complete(id)` returns the same
207
+ `workflow_trigger`:
208
+
209
+ ```ts
210
+ const { workflow_trigger } = await client.tasks.complete('task_123');
211
+ if (workflow_trigger.status === 'fired') console.log(`Moved to ${workflow_trigger.to_stage?.name}`);
212
+ ```
213
+
214
+ Actions on that transition run afterwards, so a chained transition may move the
215
+ record again later; `to_stage` doesn't report that later move. Creating
216
+ or updating an event with a `completion_workflow_transition_id` from a pipeline
217
+ other than its linked record's is rejected with a 422
218
+ `COMPLETION_TRANSITION_WRONG_PIPELINE`.
219
+
220
+ ### Updating recurring calendar events (0.54.0)
221
+
222
+ `client.calendarEvents.update(id, patch, { editMode })` chooses which
223
+ occurrences of a recurring event change: `this` (only this occurrence, the
224
+ default), `this_and_future` (splits the series here) or `all` (the whole
225
+ series). It is sent as the `recurrence_edit_mode` query parameter and ignored
226
+ for one-time events. `this` on a series master is a 400 `INVALID_EDIT_MODE`.
227
+
228
+ ```ts
229
+ await client.calendarEvents.update('evt_123', { starts_at: '2026-11-02T15:00:00' }, { editMode: 'this_and_future' });
230
+ ```
231
+
232
+ A blank `recurrence_rule` in the patch means "doesn't repeat": it is ignored
233
+ rather than stored, so it never changes an existing series.
234
+
235
+ ### Recently viewed
236
+
237
+ `client.recordViews.touch({ entity_type, entity_id })` records that the calling
238
+ user opened a customer / contact / opportunity / job (idempotent; 204 even for
239
+ an id that no longer exists). `client.recordViews.recent({ limit })` returns
240
+ that user's newest views. The four list calls accept `sort_by: 'recent'` —
241
+ the caller's most recently opened records first, never-viewed last
242
+ (`sort_order` is ignored for it):
243
+
244
+ ```ts
245
+ await client.recordViews.touch({ entity_type: 'customer', entity_id: 'cus_123' });
246
+ const { data } = await client.customers.list({ sort_by: 'recent' });
247
+ const jobs = await client.jobs.listForOpportunity('opp_123', { sort_by: 'recent' });
248
+ ```
249
+
250
+ ### Module status
251
+
252
+ `client.modules.list()` returns `{ moduleKey, isActive }` for every registered
253
+ module in the caller's tenant (any role; read-only). A module that was never
254
+ activated reads `isActive: false`:
255
+
256
+ ```ts
257
+ const modules = await client.modules.list();
258
+ const contactsOn = modules.some((m) => m.moduleKey === 'contacts' && m.isActive);
259
+ ```
260
+
261
+ ### Plan
262
+
263
+ `client.plan.get()` returns the caller tenant's plan (any role; read-only):
264
+ `planKey`, `label`, `billingStatus`, `planSource`, `trialEndsAt`,
265
+ `billingInterval`, `priceLockedUntil`, and the plan's ceiling — the `modules`
266
+ and `features` it may enable, `includedFullSeats`, `crewSeatsAllowed`,
267
+ `monthlyAiCredits` and `storageGb`. A ceiling is not activation: check
268
+ `client.modules.list()` for what is switched on. Plans change in Settings →
269
+ Billing, not through the API.
270
+
271
+ ```ts
272
+ const plan = await client.plan.get();
273
+ const canInvoice = plan.modules.includes('invoicing');
274
+ ```
275
+
276
+ ### Caller abilities
277
+
278
+ `client.me.abilities()` returns the caller's effective ability — the exact
279
+ rules the server enforces — as `{ role, actingRole?, version, rules }`.
280
+ `rules` are CASL packed rules; for an API key, `actingRole` is the acting
281
+ agent's live role and the rules are the key role ∩ that role. `version`
282
+ matches the `X-Planes-Abilities-Version` header on every authenticated
283
+ response, so a changed header means refetch:
284
+
285
+ ```ts
286
+ import { createMongoAbility } from '@casl/ability';
287
+ import { unpackRules } from '@casl/ability/extra';
288
+
289
+ const { role, rules } = await client.me.abilities();
290
+ const ability = createMongoAbility(unpackRules(rules));
291
+ ability.can('delete', 'Customer');
292
+ ```
293
+
294
+ ### Roles (admin only)
295
+
296
+ `client.roles` manages Settings → Roles (0.19.0). Every call needs an admin
297
+ caller — any other role gets a 403 `FORBIDDEN_BY_ROLE`. `list()` returns
298
+ `{ data: [{ key, label, tunable, userCount, changeCount }] }`; `get(key)` one
299
+ role's matrix (`{ key, label, tunable, summary, version, rows: [{ rowId,
300
+ cells: [{ action, subject, value, default, tier, locked }] }] }`).
301
+ `updateGrants(key, body)` sends sparse cell changes (`effect: 'allow' | 'deny' |
302
+ 'default'`); granting a money action (approve, mark paid, correct, payments)
303
+ needs `confirm: true` and a `reason`, and passing the `version` you read as
304
+ `baseVersion` turns a concurrent edit into a 409. `reset(key)` returns a role
305
+ to the Planes defaults; `history({ role, cursor, limit })` pages the change log:
306
+
307
+ ```ts
308
+ const sales = await client.roles.get('sales');
309
+ await client.roles.updateGrants('sales', {
310
+ changes: [{ action: 'delete', subject: 'Customer', effect: 'allow' }],
311
+ baseVersion: sales.version,
312
+ });
313
+ const { data, cursor } = await client.roles.history({ role: 'sales', limit: 20 });
314
+ ```
315
+
316
+ ### Contact sequences
317
+
318
+ `client.contactSequences.list()` returns the tenant's published contact
319
+ sequences as `{ data: [{ id, name, description }], meta: { total } }`
320
+ (name-sorted, max 100), readable by any role that can enroll a contact. Each
321
+ `id` is the sequence's lineage root — the `workflow_id` that
322
+ `POST /contacts/:id/enrollments` accepts:
323
+
324
+ ```ts
325
+ const { data: sequences } = await client.contactSequences.list();
326
+ ```
327
+
328
+ ### Sequence enrollments
329
+
330
+ `client.enrollments` enrolls a contact in a sequence (the `id` from
331
+ `contactSequences.list()`), lists a contact's enrollments, and exits one:
332
+
333
+ ```ts
334
+ const enrollment = await client.enrollments.enroll(contactId, sequences[0].id);
335
+ // 409 if already active in that sequence; 422 if it has no published version
336
+
337
+ const active = await client.enrollments.list(contactId, { status: 'active' });
338
+ // bare array, newest first — each row carries sequenceName + currentStageName
339
+
340
+ await client.enrollments.exit(enrollment.id); // reason 'manual'; idempotent
341
+ ```
342
+
343
+ ### Workflow version stages
344
+
345
+ `client.workflows.getStages(workflowId)` returns the ordered stages of one
346
+ workflow version — `{ id, name, entityType, version, status, stages: [{ id,
347
+ name, color, icon, sortOrder, isInitial, isTerminal }] }`. Pass a record's
348
+ instance `workflowId` to draw its progress against the version it is pinned
349
+ to, even an archived one after a newer version was published. Readable by any
350
+ role that can read the workflow's record type (403 otherwise; 404 for an
351
+ unknown or draft id):
352
+
353
+ ```ts
354
+ const { stages } = await client.workflows.getStages(instance.workflowId);
355
+ ```
356
+
357
+ A stage's `color` is a hex code or `null`. The API only accepts `#RGB` or
358
+ `#RRGGBB` when a stage is written, and stores it as uppercase `#RRGGBB`.
359
+ A stage saved before that rule may still hold some other string, so check
360
+ the value before using it as a CSS colour.
361
+
362
+ ### Workflow transition summaries
363
+
364
+ `client.workflows.listTransitions({ entityTypes, workflowId })` returns
365
+ `{ transitions: [{ id, name, from_stage_name, to_stage_name, workflow_name,
366
+ entity_type, workflow_id, workflow_version, lineage_workflow_id,
367
+ automatic_only }] }`. With no `workflowId` it lists every published
368
+ workflow's transitions; with one it lists that version's, published or
369
+ archived, so a record pinned to an older version gets its own steps (an
370
+ unknown, draft or other-tenant id returns an empty list). `lineage_workflow_id`
371
+ is the same for every version of one pipeline. Both filters together are an
372
+ AND. Only pipelines whose record type the caller can read are listed (the
373
+ same rule as `getStages`): a `workflowId` of a type the key's role can't read
374
+ returns an empty list, not 403, and an unreadable type in `entityTypes` is
375
+ dropped:
376
+
377
+ ```ts
378
+ const { transitions } = await client.workflows.listTransitions({ workflowId: instance.workflowId });
379
+ const manual = transitions.filter((t) => !t.automatic_only);
380
+ ```
381
+
382
+ ### Workflow event rules (0.51.0)
383
+
384
+ `client.workflows.eventRules(workflowId)` manages a workflow's "advance
385
+ automatically when…" rules on the admin workflow API (manage Workflow + the
386
+ `workflow_builder` feature): `list()`, `create(input)`, `update(ruleId, input)`
387
+ and `delete(ruleId)`. A rule fires `transitionRef` (stage NAMES, resolved
388
+ against the version each record is pinned to) when a related contract, invoice
389
+ or calendar event emits `eventType` and its `changes` match `when`. Any
390
+ version's id works; rules are keyed by the workflow's lineage root, so they
391
+ survive a publish. `list()` is a bare array, newest first; `delete()` is a soft
392
+ delete returning `{ id }`.
393
+
394
+ Since 0.53.0 a rule carries exactly `id`, `workflowId`, `eventType`, `when`,
395
+ `transitionRef`, `enabled`, `createdAt` and `updatedAt` (no `tenantId`,
396
+ `createdBy` or `deletedAt`), and `update`/`delete` only reach a rule through an
397
+ id of its own pipeline (any version); another pipeline's id is a 404
398
+ `EVENT_RULE_NOT_FOUND`. A path id that isn't a uuid is a 400.
399
+
400
+ ```ts
401
+ const rules = client.workflows.eventRules(workflowId);
402
+
403
+ // Advance when the contract is sent.
404
+ await rules.create({
405
+ eventType: 'contract.status_changed',
406
+ when: { status: { new: 'sent' } },
407
+ transitionRef: { from_stage_name: 'Estimate', to_stage_name: 'Contract Sent' },
408
+ });
409
+
410
+ // Advance when an event is booked. Creation types carry no `changes`, so the
411
+ // only matcher is `when: {}` (see WORKFLOW_EVENT_RULE_MATCH_ALL_EVENT_TYPES).
412
+ const booked = await rules.create({
413
+ eventType: 'calendar_event.created',
414
+ when: {},
415
+ transitionRef: { from_stage_name: 'New', to_stage_name: 'Booked' },
416
+ });
417
+
418
+ await rules.update(booked.id, { enabled: false }); // only the fields sent change
419
+ await rules.delete(booked.id);
420
+ ```
421
+
422
+ Event types: `WORKFLOW_EVENT_RULE_EVENT_TYPES` (`contract.status_changed`,
423
+ `invoice.status_changed`, `calendar_event.status_changed`,
424
+ `calendar_event.created`). A body the server rejects is a 400
425
+ `VALIDATION_FAILED`; an unknown workflow is 404 `WORKFLOW_NOT_FOUND`, an
426
+ unknown or deleted rule 404 `EVENT_RULE_NOT_FOUND`.
427
+
428
+ ### Job trade coverage
429
+
430
+ `client.jobs.getTradeCoverage()` returns every active trade with whether a job
431
+ can be created under it — `{ trades: [{ tradeId, name, hasWorkflow }] }`,
432
+ ordered by name. `hasWorkflow` is true when a published job workflow resolves
433
+ for the trade (its own or the generic one); creating a job under a trade with
434
+ `hasWorkflow: false` returns 422 `WORKFLOW_NOT_CONFIGURED`. Readable by any
435
+ role that can create jobs (403 otherwise):
436
+
437
+ ```ts
438
+ const { trades } = await client.jobs.getTradeCoverage();
439
+ const creatable = trades.filter((t) => t.hasWorkflow);
440
+ ```
441
+
442
+ ### Estimates across an opportunity
443
+
444
+ `client.estimates.listForOpportunity(opportunityId, { status, page, limit })`
445
+ returns every estimate across all of the opportunity's jobs in one request
446
+ (newest first, each row carrying `jobId`; `limit` max 100):
447
+
448
+ ```ts
449
+ const { data } = await client.estimates.listForOpportunity('opp_123', { limit: 100 });
450
+ ```
40
451
 
41
452
  ### Errors
42
453
 
@@ -44,7 +455,7 @@ Every failure throws a subclass of `PlanesError`, each carrying `status` and
44
455
  `code`:
45
456
 
46
457
  ```ts
47
- import { PlanesError, RateLimitError, ValidationError } from '@planes/sdk';
458
+ import { PlanesError, RateLimitError, ValidationError } from '@planescrm/sdk';
48
459
 
49
460
  try {
50
461
  await client.opportunities.get('missing');
@@ -93,7 +504,7 @@ Use the OAuth helpers to drive the login redirect and exchange the returned code
93
504
  for tokens.
94
505
 
95
506
  ```ts
96
- import { buildAuthorizeUrl, exchangeCode } from '@planes/sdk';
507
+ import { buildAuthorizeUrl, exchangeCode } from '@planescrm/sdk';
97
508
 
98
509
  const baseUrl = 'https://api.planescrm.com/api/v1';
99
510
 
@@ -115,7 +526,7 @@ const tokens = await exchangeCode({
115
526
  });
116
527
 
117
528
  // 3. Seed an OAuth-managed client so refreshes happen automatically.
118
- import { PlanesClient, MemoryTokenStorage } from '@planes/sdk';
529
+ import { PlanesClient, MemoryTokenStorage } from '@planescrm/sdk';
119
530
 
120
531
  const client = new PlanesClient({
121
532
  clientId: process.env.PLANES_CLIENT_ID!,
@@ -135,7 +546,7 @@ gives a chainable builder; call `registerAction(type, handler)` per action and
135
546
  `listen()` to start.
136
547
 
137
548
  ```ts
138
- import { createAgentApp } from '@planes/sdk';
549
+ import { createAgentApp } from '@planescrm/sdk';
139
550
 
140
551
  createAgentApp()
141
552
  .registerAction('enrich-opportunity', async (ctx) => {