@pimia/sdk 0.2.0 → 0.4.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/dist/client.d.ts CHANGED
@@ -13,8 +13,42 @@
13
13
  * - **429**: respeta `Retry-After` y reintenta con espera acotada;
14
14
  * - **errores tipados**: MissingScopeError trae el scope exacto que falta.
15
15
  */
16
+ import type { components } from './api.js';
16
17
  import { OAuth, type OAuthConfig } from './oauth.js';
17
18
  import { type TokenStore } from './tokens.js';
19
+ type Schemas = components['schemas'];
20
+ /** Cliente tal y como lo devuelve la API. */
21
+ export type CustomerResource = Schemas['CustomerResource'];
22
+ /** Factura tal y como la devuelve la API. */
23
+ export type InvoiceResource = Schemas['InvoiceResource'];
24
+ /** Presupuesto tal y como lo devuelve la API. */
25
+ export type EstimateResource = Schemas['EstimateResource'];
26
+ /** Cuerpo de alta/edición de cliente. Incluye `customFields`. */
27
+ export type CustomerRequest = Schemas['CustomerRequest'];
28
+ /** Cuerpo de alta/edición de factura. Incluye `customFields`. */
29
+ export type InvoicesRequest = Schemas['InvoicesRequest'];
30
+ /** Cuerpo de alta/edición de presupuesto. Incluye `customFields`. */
31
+ export type EstimatesRequest = Schemas['EstimatesRequest'];
32
+ /**
33
+ * El sobre `{ data: … }` de Laravel para las escrituras que el spec **no
34
+ * tipa**.
35
+ *
36
+ * Hay 17 operaciones cuyo `200` sale del generador como objeto vacío, y entre
37
+ * ellas están `POST /invoices`, `PUT /invoices/{id}`, `PUT /customers/{id}` y
38
+ * `POST /estimates/{id}/convert-to-invoice`. Usar ahí el tipo generado sería
39
+ * peor que no tipar: `Record<string, never>` afirma que la respuesta **no
40
+ * tiene propiedades**, y el `data` real desaparecería del autocompletado.
41
+ *
42
+ * Así que el sobre se declara aquí y el recurso de dentro sí sale del spec.
43
+ * Está verificado contra los controladores del core, no supuesto: los cuatro
44
+ * devuelven `new XResource($modelo)` con el envoltorio `data` de Laravel
45
+ * activo. La causa del hueco es del generador —un `@return JsonResponse`
46
+ * heredado que le gana a la inferencia—, no del contrato; cuando se arregle
47
+ * en el core, estos tipos pasarán a salir de `Ok<…>` como los demás.
48
+ */
49
+ export interface ResourceEnvelope<T> {
50
+ data: T;
51
+ }
18
52
  export interface PimiaClientOptions extends OAuthConfig {
19
53
  tokens: TokenStore;
20
54
  /** Segundos de margen para refrescar antes de que caduque (default 60). */
@@ -94,21 +128,361 @@ export declare class PimiaClient {
94
128
  /** Cabeceras `X-RateLimit-*` de la última respuesta. */
95
129
  get rateLimit(): RateLimit;
96
130
  get invoices(): {
97
- list: (query?: RequestOptions["query"]) => Promise<unknown>;
98
- get: (id: number | string) => Promise<unknown>;
99
- create: (body: unknown, options?: WriteOptions) => Promise<unknown>;
100
- update: (id: number | string, body: unknown, options?: WriteOptions) => Promise<unknown>;
131
+ list: (query?: RequestOptions["query"]) => Promise<{
132
+ data: components["schemas"]["InvoiceResource"][];
133
+ meta: {
134
+ invoice_total_count: number;
135
+ };
136
+ }>;
137
+ get: (id: number | string) => Promise<{
138
+ data: components["schemas"]["InvoiceResource"] & Record<string, never>;
139
+ }>;
140
+ /**
141
+ * Devuelve `{ data: InvoiceResource }`. El tipo NO sale del spec: el
142
+ * `200` de `invoices.store` está vacío ahí (ver {@link ResourceEnvelope}).
143
+ */
144
+ create: (body: InvoicesRequest, options?: WriteOptions) => Promise<ResourceEnvelope<{
145
+ id: string;
146
+ invoice_date: string;
147
+ due_date: string;
148
+ invoice_number: string;
149
+ reference_number: string;
150
+ status: string;
151
+ paid_status: string;
152
+ tax_per_item: string;
153
+ tax_included: string;
154
+ discount_per_item: string;
155
+ notes: string;
156
+ discount_type: string;
157
+ discount: string;
158
+ discount_val: string;
159
+ sub_total: string;
160
+ total: string;
161
+ effective_total: string;
162
+ tax: string;
163
+ due_amount: string;
164
+ effective_due_amount: string;
165
+ sent: string;
166
+ viewed: string;
167
+ unique_hash: string;
168
+ template_name: string;
169
+ invoice_series_id: string;
170
+ customer_id: string;
171
+ external_ref: string | null;
172
+ payment_method_id: string;
173
+ recurring_invoice_id: string;
174
+ sequence_number: string;
175
+ exchange_rate: string;
176
+ base_discount_val: string;
177
+ base_sub_total: string;
178
+ base_total: string;
179
+ creator_id: string;
180
+ base_tax: string;
181
+ base_due_amount: string;
182
+ effective_base_total: string;
183
+ effective_base_due_amount: string;
184
+ credited_total: string;
185
+ credited_base_total: string;
186
+ currency_id: string;
187
+ formatted_created_at: string;
188
+ invoice_pdf_url: string;
189
+ formatted_invoice_date: string;
190
+ formatted_due_date: string;
191
+ allow_edit: string;
192
+ payment_module_enabled: string;
193
+ sales_tax_type: string;
194
+ sales_tax_address_type: string;
195
+ overdue: string;
196
+ effective_paid_status: string;
197
+ effective_overdue: string;
198
+ aeat_status: string;
199
+ qr_data: string;
200
+ hash: string;
201
+ aeat_csv: string;
202
+ is_credit_note: string | boolean;
203
+ rectified_invoice_id: string;
204
+ rectified_invoice_number?: string | null;
205
+ rectified_invoice?: {
206
+ id: string;
207
+ invoice_number: string;
208
+ tax_per_item: string;
209
+ tax_included: string;
210
+ sub_total: string;
211
+ discount_val: string;
212
+ tax: string;
213
+ total: string;
214
+ items: components["schemas"]["InvoiceItemResource"][];
215
+ taxes: components["schemas"]["TaxResource"][];
216
+ };
217
+ credit_notes_count: number;
218
+ items?: components["schemas"]["InvoiceItemResource"][];
219
+ payments?: components["schemas"]["PaymentResource"][];
220
+ customer?: components["schemas"]["CustomerResource"];
221
+ invoice_series?: components["schemas"]["InvoiceSeriesResource"];
222
+ payment_method?: components["schemas"]["PaymentMethodResource"];
223
+ creator?: components["schemas"]["UserResource"];
224
+ taxes: components["schemas"]["TaxResource"][];
225
+ fields?: components["schemas"]["CustomFieldValueResource"][];
226
+ company?: components["schemas"]["CompanyResource"];
227
+ currency?: components["schemas"]["CurrencyResource"];
228
+ }>>;
229
+ /** Mismo caso que `create`: el `200` de `invoices.update` no está tipado en el spec. */
230
+ update: (id: number | string, body: InvoicesRequest, options?: WriteOptions) => Promise<ResourceEnvelope<{
231
+ id: string;
232
+ invoice_date: string;
233
+ due_date: string;
234
+ invoice_number: string;
235
+ reference_number: string;
236
+ status: string;
237
+ paid_status: string;
238
+ tax_per_item: string;
239
+ tax_included: string;
240
+ discount_per_item: string;
241
+ notes: string;
242
+ discount_type: string;
243
+ discount: string;
244
+ discount_val: string;
245
+ sub_total: string;
246
+ total: string;
247
+ effective_total: string;
248
+ tax: string;
249
+ due_amount: string;
250
+ effective_due_amount: string;
251
+ sent: string;
252
+ viewed: string;
253
+ unique_hash: string;
254
+ template_name: string;
255
+ invoice_series_id: string;
256
+ customer_id: string;
257
+ external_ref: string | null;
258
+ payment_method_id: string;
259
+ recurring_invoice_id: string;
260
+ sequence_number: string;
261
+ exchange_rate: string;
262
+ base_discount_val: string;
263
+ base_sub_total: string;
264
+ base_total: string;
265
+ creator_id: string;
266
+ base_tax: string;
267
+ base_due_amount: string;
268
+ effective_base_total: string;
269
+ effective_base_due_amount: string;
270
+ credited_total: string;
271
+ credited_base_total: string;
272
+ currency_id: string;
273
+ formatted_created_at: string;
274
+ invoice_pdf_url: string;
275
+ formatted_invoice_date: string;
276
+ formatted_due_date: string;
277
+ allow_edit: string;
278
+ payment_module_enabled: string;
279
+ sales_tax_type: string;
280
+ sales_tax_address_type: string;
281
+ overdue: string;
282
+ effective_paid_status: string;
283
+ effective_overdue: string;
284
+ aeat_status: string;
285
+ qr_data: string;
286
+ hash: string;
287
+ aeat_csv: string;
288
+ is_credit_note: string | boolean;
289
+ rectified_invoice_id: string;
290
+ rectified_invoice_number?: string | null;
291
+ rectified_invoice?: {
292
+ id: string;
293
+ invoice_number: string;
294
+ tax_per_item: string;
295
+ tax_included: string;
296
+ sub_total: string;
297
+ discount_val: string;
298
+ tax: string;
299
+ total: string;
300
+ items: components["schemas"]["InvoiceItemResource"][];
301
+ taxes: components["schemas"]["TaxResource"][];
302
+ };
303
+ credit_notes_count: number;
304
+ items?: components["schemas"]["InvoiceItemResource"][];
305
+ payments?: components["schemas"]["PaymentResource"][];
306
+ customer?: components["schemas"]["CustomerResource"];
307
+ invoice_series?: components["schemas"]["InvoiceSeriesResource"];
308
+ payment_method?: components["schemas"]["PaymentMethodResource"];
309
+ creator?: components["schemas"]["UserResource"];
310
+ taxes: components["schemas"]["TaxResource"][];
311
+ fields?: components["schemas"]["CustomFieldValueResource"][];
312
+ company?: components["schemas"]["CompanyResource"];
313
+ currency?: components["schemas"]["CurrencyResource"];
314
+ }>>;
101
315
  };
102
316
  get customers(): {
103
- list: (query?: RequestOptions["query"]) => Promise<unknown>;
104
- get: (id: number | string) => Promise<unknown>;
105
- create: (body: unknown, options?: WriteOptions) => Promise<unknown>;
106
- update: (id: number | string, body: unknown, options?: WriteOptions) => Promise<unknown>;
317
+ list: (query?: RequestOptions["query"]) => Promise<{
318
+ data: components["schemas"]["CustomerResource"][];
319
+ meta: {
320
+ customer_total_count: number;
321
+ };
322
+ }>;
323
+ get: (id: number | string) => Promise<{
324
+ data: components["schemas"]["CustomerResource"];
325
+ }>;
326
+ create: (body: CustomerRequest, options?: WriteOptions) => Promise<{
327
+ data: components["schemas"]["CustomerResource"];
328
+ }>;
329
+ /** El `200` de `customers.update` no está tipado en el spec. */
330
+ update: (id: number | string, body: CustomerRequest, options?: WriteOptions) => Promise<ResourceEnvelope<{
331
+ id: string;
332
+ name: string;
333
+ email: string;
334
+ phone: string;
335
+ contact_name: string;
336
+ company_name: string;
337
+ website: string;
338
+ enable_portal: string;
339
+ password_added: boolean;
340
+ currency_id: string;
341
+ payment_method_id: string;
342
+ company_id: string;
343
+ facebook_id: string;
344
+ google_id: string;
345
+ github_id: string;
346
+ created_at: string;
347
+ formatted_created_at: string;
348
+ updated_at: string;
349
+ avatar: string;
350
+ due_amount: string;
351
+ base_due_amount: string;
352
+ prefix: string;
353
+ tax_id: string;
354
+ notes: string;
355
+ external_ref: string | null;
356
+ iban: string;
357
+ bic: string;
358
+ sepa_mandate_id: string;
359
+ sepa_mandate_date: string;
360
+ billing?: components["schemas"]["AddressResource"];
361
+ shipping?: components["schemas"]["AddressResource"];
362
+ fields?: components["schemas"]["CustomFieldValueResource"][];
363
+ company?: components["schemas"]["CompanyResource"];
364
+ currency?: components["schemas"]["CurrencyResource"];
365
+ payment_method?: components["schemas"]["PaymentMethodResource"];
366
+ }>>;
107
367
  };
108
368
  get estimates(): {
109
- list: (query?: RequestOptions["query"]) => Promise<unknown>;
110
- get: (id: number | string) => Promise<unknown>;
111
- create: (body: unknown, options?: WriteOptions) => Promise<unknown>;
369
+ list: (query?: RequestOptions["query"]) => Promise<{
370
+ data: components["schemas"]["EstimateResource"][];
371
+ meta: {
372
+ estimate_total_count: number;
373
+ };
374
+ }>;
375
+ get: (id: number | string) => Promise<{
376
+ data: components["schemas"]["EstimateResource"];
377
+ }>;
378
+ create: (body: EstimatesRequest, options?: WriteOptions) => Promise<{
379
+ data: components["schemas"]["EstimateResource"];
380
+ }>;
381
+ /**
382
+ * Convierte un presupuesto aceptado en factura.
383
+ *
384
+ * El helper existe porque es el cierre natural del bucle
385
+ * `estimate.accepted` → facturar, y sin él hay que ir por ruta cruda y
386
+ * adivinar la forma de la respuesta.
387
+ *
388
+ * Dos cosas que conviene saber y que el spec no dice:
389
+ *
390
+ * - **la factura nace BORRADOR y sin numerar**: `data.invoice_number` es
391
+ * `null` hasta que la publiques cambiando su estado. No es un fallo;
392
+ * - el id de la factura nueva está en `data.id`. El `r?.data?.id ?? r?.id`
393
+ * defensivo que se ve por ahí sobra: la segunda rama nunca ocurre.
394
+ *
395
+ * Manda `idempotencyKey` —una clave estable por presupuesto, del estilo
396
+ * `estimate:{id}:invoice`— y el reintento tras un timeout no te creará
397
+ * una segunda factura.
398
+ *
399
+ * Exige `estimates:write` **e** `invoices:write`.
400
+ */
401
+ convertToInvoice: (id: number | string, options?: WriteOptions) => Promise<ResourceEnvelope<{
402
+ id: string;
403
+ invoice_date: string;
404
+ due_date: string;
405
+ invoice_number: string;
406
+ reference_number: string;
407
+ status: string;
408
+ paid_status: string;
409
+ tax_per_item: string;
410
+ tax_included: string;
411
+ discount_per_item: string;
412
+ notes: string;
413
+ discount_type: string;
414
+ discount: string;
415
+ discount_val: string;
416
+ sub_total: string;
417
+ total: string;
418
+ effective_total: string;
419
+ tax: string;
420
+ due_amount: string;
421
+ effective_due_amount: string;
422
+ sent: string;
423
+ viewed: string;
424
+ unique_hash: string;
425
+ template_name: string;
426
+ invoice_series_id: string;
427
+ customer_id: string;
428
+ external_ref: string | null;
429
+ payment_method_id: string;
430
+ recurring_invoice_id: string;
431
+ sequence_number: string;
432
+ exchange_rate: string;
433
+ base_discount_val: string;
434
+ base_sub_total: string;
435
+ base_total: string;
436
+ creator_id: string;
437
+ base_tax: string;
438
+ base_due_amount: string;
439
+ effective_base_total: string;
440
+ effective_base_due_amount: string;
441
+ credited_total: string;
442
+ credited_base_total: string;
443
+ currency_id: string;
444
+ formatted_created_at: string;
445
+ invoice_pdf_url: string;
446
+ formatted_invoice_date: string;
447
+ formatted_due_date: string;
448
+ allow_edit: string;
449
+ payment_module_enabled: string;
450
+ sales_tax_type: string;
451
+ sales_tax_address_type: string;
452
+ overdue: string;
453
+ effective_paid_status: string;
454
+ effective_overdue: string;
455
+ aeat_status: string;
456
+ qr_data: string;
457
+ hash: string;
458
+ aeat_csv: string;
459
+ is_credit_note: string | boolean;
460
+ rectified_invoice_id: string;
461
+ rectified_invoice_number?: string | null;
462
+ rectified_invoice?: {
463
+ id: string;
464
+ invoice_number: string;
465
+ tax_per_item: string;
466
+ tax_included: string;
467
+ sub_total: string;
468
+ discount_val: string;
469
+ tax: string;
470
+ total: string;
471
+ items: components["schemas"]["InvoiceItemResource"][];
472
+ taxes: components["schemas"]["TaxResource"][];
473
+ };
474
+ credit_notes_count: number;
475
+ items?: components["schemas"]["InvoiceItemResource"][];
476
+ payments?: components["schemas"]["PaymentResource"][];
477
+ customer?: components["schemas"]["CustomerResource"];
478
+ invoice_series?: components["schemas"]["InvoiceSeriesResource"];
479
+ payment_method?: components["schemas"]["PaymentMethodResource"];
480
+ creator?: components["schemas"]["UserResource"];
481
+ taxes: components["schemas"]["TaxResource"][];
482
+ fields?: components["schemas"]["CustomFieldValueResource"][];
483
+ company?: components["schemas"]["CompanyResource"];
484
+ currency?: components["schemas"]["CurrencyResource"];
485
+ }>>;
112
486
  };
113
487
  get<T = unknown>(path: string, query?: RequestOptions['query']): Promise<T>;
114
488
  post<T = unknown>(path: string, body?: unknown, options?: WriteOptions): Promise<T>;
@@ -149,3 +523,4 @@ export declare class PimiaClient {
149
523
  private captureRateLimit;
150
524
  private retryDelay;
151
525
  }
526
+ export {};
package/dist/client.js CHANGED
@@ -46,7 +46,12 @@ export class PimiaClient {
46
46
  return {
47
47
  list: (query) => this.get('/invoices', query),
48
48
  get: (id) => this.get(`/invoices/${id}`),
49
+ /**
50
+ * Devuelve `{ data: InvoiceResource }`. El tipo NO sale del spec: el
51
+ * `200` de `invoices.store` está vacío ahí (ver {@link ResourceEnvelope}).
52
+ */
49
53
  create: (body, options) => this.post('/invoices', body, options),
54
+ /** Mismo caso que `create`: el `200` de `invoices.update` no está tipado en el spec. */
50
55
  update: (id, body, options) => this.put(`/invoices/${id}`, body, options),
51
56
  };
52
57
  }
@@ -55,6 +60,7 @@ export class PimiaClient {
55
60
  list: (query) => this.get('/customers', query),
56
61
  get: (id) => this.get(`/customers/${id}`),
57
62
  create: (body, options) => this.post('/customers', body, options),
63
+ /** El `200` de `customers.update` no está tipado en el spec. */
58
64
  update: (id, body, options) => this.put(`/customers/${id}`, body, options),
59
65
  };
60
66
  }
@@ -63,6 +69,27 @@ export class PimiaClient {
63
69
  list: (query) => this.get('/estimates', query),
64
70
  get: (id) => this.get(`/estimates/${id}`),
65
71
  create: (body, options) => this.post('/estimates', body, options),
72
+ /**
73
+ * Convierte un presupuesto aceptado en factura.
74
+ *
75
+ * El helper existe porque es el cierre natural del bucle
76
+ * `estimate.accepted` → facturar, y sin él hay que ir por ruta cruda y
77
+ * adivinar la forma de la respuesta.
78
+ *
79
+ * Dos cosas que conviene saber y que el spec no dice:
80
+ *
81
+ * - **la factura nace BORRADOR y sin numerar**: `data.invoice_number` es
82
+ * `null` hasta que la publiques cambiando su estado. No es un fallo;
83
+ * - el id de la factura nueva está en `data.id`. El `r?.data?.id ?? r?.id`
84
+ * defensivo que se ve por ahí sobra: la segunda rama nunca ocurre.
85
+ *
86
+ * Manda `idempotencyKey` —una clave estable por presupuesto, del estilo
87
+ * `estimate:{id}:invoice`— y el reintento tras un timeout no te creará
88
+ * una segunda factura.
89
+ *
90
+ * Exige `estimates:write` **e** `invoices:write`.
91
+ */
92
+ convertToInvoice: (id, options) => this.post(`/estimates/${id}/convert-to-invoice`, {}, options),
66
93
  };
67
94
  }
68
95
  get(path, query) {
package/dist/errors.d.ts CHANGED
@@ -38,6 +38,49 @@ export declare class NotFoundError extends PimiaApiError {
38
38
  export declare class ValidationError extends PimiaApiError {
39
39
  get errors(): Record<string, string[]>;
40
40
  }
41
+ /**
42
+ * 422 `external_ref_already_used`: la referencia externa que intentaste colgar
43
+ * ya la lleva otro recurso del mismo tipo dentro de tu namespace (company +
44
+ * client OAuth).
45
+ *
46
+ * **Lo normal es que no sea un error tuyo, sino tu reintento**: «crea el cliente
47
+ * del deal 42» ejecutado dos veces porque el proceso se cayó entre el POST y el
48
+ * guardado de tu mapeo. Por eso el error trae {@link existingId}, el recurso que
49
+ * ya lleva esa referencia — que es lo que convierte el choque en un
50
+ * find-or-create sin mantener ningún mapeo local:
51
+ *
52
+ * ```ts
53
+ * async function clienteDelDeal(dealId: string, name: string): Promise<number> {
54
+ * try {
55
+ * const { id } = await crearCliente({ name, external_ref: `deal_${dealId}` })
56
+ * return id
57
+ * } catch (error) {
58
+ * // Ya existía: el propio error dice cuál es.
59
+ * if (error instanceof DuplicateExternalRefError) return error.existingId
60
+ * throw error
61
+ * }
62
+ * }
63
+ * ```
64
+ *
65
+ * Hereda de {@link ValidationError} a propósito: el cuerpo trae también el
66
+ * `errors` de siempre, así que el código que ya trataba los 422 por ese camino
67
+ * sigue funcionando sin ramas nuevas.
68
+ */
69
+ export declare class DuplicateExternalRefError extends ValidationError {
70
+ /** Id del recurso que YA lleva esa referencia. Tu find-or-create acaba aquí. */
71
+ readonly existingId: number;
72
+ /** La referencia que chocó, tal y como la mandaste. */
73
+ readonly externalRef: string;
74
+ /** Tipo del recurso en el core (`customer`, `estimate`, `invoice`). */
75
+ readonly entityType: string;
76
+ constructor(
77
+ /** Id del recurso que YA lleva esa referencia. Tu find-or-create acaba aquí. */
78
+ existingId: number,
79
+ /** La referencia que chocó, tal y como la mandaste. */
80
+ externalRef: string,
81
+ /** Tipo del recurso en el core (`customer`, `estimate`, `invoice`). */
82
+ entityType: string, status: number, message: string, body: unknown, requestId?: string);
83
+ }
41
84
  /** 429: pasado el rate limit. `retryAfter` en segundos si la API lo dijo. */
42
85
  export declare class RateLimitError extends PimiaApiError {
43
86
  readonly retryAfter: number | undefined;
package/dist/errors.js CHANGED
@@ -30,8 +30,13 @@ export class PimiaApiError extends PimiaError {
30
30
  return new MissingScopeError(scope, status, message, body, requestId);
31
31
  return new ForbiddenError(status, message, body, requestId);
32
32
  }
33
- if (status === 422)
33
+ if (status === 422) {
34
+ const duplicate = duplicateExternalRefFrom(body);
35
+ if (duplicate) {
36
+ return new DuplicateExternalRefError(duplicate.existingId, duplicate.externalRef, duplicate.entityType, status, message, body, requestId);
37
+ }
34
38
  return new ValidationError(status, message, body, requestId);
39
+ }
35
40
  if (status === 404)
36
41
  return new NotFoundError(status, message, body, requestId);
37
42
  return new PimiaApiError(status, message, body, requestId);
@@ -67,6 +72,51 @@ export class ValidationError extends PimiaApiError {
67
72
  return body?.errors ?? {};
68
73
  }
69
74
  }
75
+ /**
76
+ * 422 `external_ref_already_used`: la referencia externa que intentaste colgar
77
+ * ya la lleva otro recurso del mismo tipo dentro de tu namespace (company +
78
+ * client OAuth).
79
+ *
80
+ * **Lo normal es que no sea un error tuyo, sino tu reintento**: «crea el cliente
81
+ * del deal 42» ejecutado dos veces porque el proceso se cayó entre el POST y el
82
+ * guardado de tu mapeo. Por eso el error trae {@link existingId}, el recurso que
83
+ * ya lleva esa referencia — que es lo que convierte el choque en un
84
+ * find-or-create sin mantener ningún mapeo local:
85
+ *
86
+ * ```ts
87
+ * async function clienteDelDeal(dealId: string, name: string): Promise<number> {
88
+ * try {
89
+ * const { id } = await crearCliente({ name, external_ref: `deal_${dealId}` })
90
+ * return id
91
+ * } catch (error) {
92
+ * // Ya existía: el propio error dice cuál es.
93
+ * if (error instanceof DuplicateExternalRefError) return error.existingId
94
+ * throw error
95
+ * }
96
+ * }
97
+ * ```
98
+ *
99
+ * Hereda de {@link ValidationError} a propósito: el cuerpo trae también el
100
+ * `errors` de siempre, así que el código que ya trataba los 422 por ese camino
101
+ * sigue funcionando sin ramas nuevas.
102
+ */
103
+ export class DuplicateExternalRefError extends ValidationError {
104
+ existingId;
105
+ externalRef;
106
+ entityType;
107
+ constructor(
108
+ /** Id del recurso que YA lleva esa referencia. Tu find-or-create acaba aquí. */
109
+ existingId,
110
+ /** La referencia que chocó, tal y como la mandaste. */
111
+ externalRef,
112
+ /** Tipo del recurso en el core (`customer`, `estimate`, `invoice`). */
113
+ entityType, status, message, body, requestId) {
114
+ super(status, message, body, requestId);
115
+ this.existingId = existingId;
116
+ this.externalRef = externalRef;
117
+ this.entityType = entityType;
118
+ }
119
+ }
70
120
  /** 429: pasado el rate limit. `retryAfter` en segundos si la API lo dijo. */
71
121
  export class RateLimitError extends PimiaApiError {
72
122
  retryAfter;
@@ -103,6 +153,36 @@ function messageFrom(body) {
103
153
  }
104
154
  return undefined;
105
155
  }
156
+ /**
157
+ * Reconoce el 422 de referencia duplicada por su campo `error`, no por la prosa
158
+ * del mensaje —que está en castellano y puede cambiar—. Sin `existing_id` usable
159
+ * no se promueve el error: sin ese id no hay find-or-create que hacer, y un
160
+ * {@link ValidationError} normal describe mejor lo que pasó.
161
+ */
162
+ function duplicateExternalRefFrom(body) {
163
+ if (!body || typeof body !== 'object')
164
+ return undefined;
165
+ const record = body;
166
+ if (record.error !== 'external_ref_already_used')
167
+ return undefined;
168
+ // El core lo manda como entero; se normaliza igualmente porque en este
169
+ // contrato hay enteros que llegan como cadena según el driver. Ojo con
170
+ // `Number(null)`, que es 0 y no NaN: sin descartar antes los no-numéricos, un
171
+ // `existing_id: null` se colaría como el id 0.
172
+ const raw = record.existing_id;
173
+ const existingId = typeof raw === 'number'
174
+ ? raw
175
+ : typeof raw === 'string' && raw.trim() !== ''
176
+ ? Number(raw)
177
+ : Number.NaN;
178
+ if (!Number.isInteger(existingId))
179
+ return undefined;
180
+ return {
181
+ existingId,
182
+ externalRef: typeof record.external_ref === 'string' ? record.external_ref : '',
183
+ entityType: typeof record.entity_type === 'string' ? record.entity_type : '',
184
+ };
185
+ }
106
186
  /** «Token lacks the invoices:write scope» → `invoices:write`. */
107
187
  function scopeFrom(message) {
108
188
  return /Token lacks the (\S+) scope/.exec(message)?.[1];
package/dist/index.d.ts CHANGED
@@ -6,12 +6,14 @@
6
6
  * salen los tipos de `./api`.
7
7
  */
8
8
  export { PimiaClient } from './client.js';
9
- export type { PimiaClientOptions, RateLimit, RequestOptions, ResponseMeta, ResponseWithMeta, WriteOptions, } from './client.js';
9
+ export type { CustomerRequest, CustomerResource, EstimateResource, EstimatesRequest, InvoiceResource, InvoicesRequest, PimiaClientOptions, RateLimit, RequestOptions, ResourceEnvelope, ResponseMeta, ResponseWithMeta, WriteOptions, } from './client.js';
10
10
  export { OAuth, createPkceChallenge, createState } from './oauth.js';
11
11
  export type { AuthorizationServerMetadata, AuthorizeUrlOptions, OAuthConfig, PkceChallenge, } from './oauth.js';
12
12
  export { MemoryTokenStore, isExpired, tokenSetFromResponse } from './tokens.js';
13
13
  export type { TokenSet, TokenStore } from './tokens.js';
14
- export { ForbiddenError, MissingScopeError, NotAuthenticatedError, NotFoundError, OAuthError, PimiaApiError, PimiaError, RateLimitError, UnauthorizedError, ValidationError, } from './errors.js';
14
+ export { DuplicateExternalRefError, ForbiddenError, MissingScopeError, NotAuthenticatedError, NotFoundError, OAuthError, PimiaApiError, PimiaError, RateLimitError, UnauthorizedError, ValidationError, } from './errors.js';
15
+ export { WEBHOOK_DEFAULT_TOLERANCE_SECONDS, WEBHOOK_EVENTS, WEBHOOK_HEADERS, WEBHOOK_SIGNATURE_VERSION, WebhookVerificationError, isWebhookEvent, signWebhook, verifyWebhook, } from './webhooks.js';
16
+ export type { ApprovalDecidedPayload, AppRevokedPayload, CustomerPayload, EstimateAcceptedPayload, ExternalRef, InvoiceCreatedPayload, InvoicePaidPayload, InvoiceReceivedPayload, IsoDateTime, KnownWebhook, PimiaWebhook, SignWebhookOptions, UnknownWebhook, VerifyWebhookOptions, WebhookBodyInput, WebhookEvent, WebhookHeadersInput, WebhookPayloads, WebhookVerificationReason, } from './webhooks.js';
15
17
  /** Scopes granulares del catálogo de Pimia (paso 4). Pide siempre lo mínimo. */
16
18
  export declare const SCOPES: {
17
19
  readonly invoicesRead: "invoices:read";
package/dist/index.js CHANGED
@@ -8,7 +8,8 @@
8
8
  export { PimiaClient } from './client.js';
9
9
  export { OAuth, createPkceChallenge, createState } from './oauth.js';
10
10
  export { MemoryTokenStore, isExpired, tokenSetFromResponse } from './tokens.js';
11
- export { ForbiddenError, MissingScopeError, NotAuthenticatedError, NotFoundError, OAuthError, PimiaApiError, PimiaError, RateLimitError, UnauthorizedError, ValidationError, } from './errors.js';
11
+ export { DuplicateExternalRefError, ForbiddenError, MissingScopeError, NotAuthenticatedError, NotFoundError, OAuthError, PimiaApiError, PimiaError, RateLimitError, UnauthorizedError, ValidationError, } from './errors.js';
12
+ export { WEBHOOK_DEFAULT_TOLERANCE_SECONDS, WEBHOOK_EVENTS, WEBHOOK_HEADERS, WEBHOOK_SIGNATURE_VERSION, WebhookVerificationError, isWebhookEvent, signWebhook, verifyWebhook, } from './webhooks.js';
12
13
  /** Scopes granulares del catálogo de Pimia (paso 4). Pide siempre lo mínimo. */
13
14
  export const SCOPES = {
14
15
  invoicesRead: 'invoices:read',