@pimia/sdk 0.3.0 → 0.5.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/api.d.ts CHANGED
@@ -233,6 +233,21 @@ export interface paths {
233
233
  /**
234
234
  * Handle the incoming request
235
235
  * @description **No disponible para integradores.** Exige `settings:write`, que el Authorization Server de Pimia no emite: la acción la realiza el dueño desde su panel y un token de partner recibe `403`. Aparece en el contrato para que el hueco sea explícito, no para que se llame.
236
+ *
237
+ * Asistente de arranque: fija el tipo de cambio de cada moneda en uso y
238
+ * RECALCULA con él los importes base de todas las facturas, presupuestos y
239
+ * pagos de esa moneda — documentos ya emitidos incluidos. Corre una sola vez
240
+ * por empresa (mientras `bulk_exchange_rate_configured` sea 'NO').
241
+ *
242
+ * Reescribir en masa los importes de documentos emitidos es configuración de
243
+ * empresa, no una operación de uso diario: va con el mismo gate de owner que
244
+ * el resto de lo que escribe en Settings. Hasta 2026-08-10 no tenía ninguno
245
+ * —el FormRequest autoriza a todo el mundo— y para un token de API la única
246
+ * barrera era que `settings:write` no esté en el catálogo OAuth.
247
+ *
248
+ * OJO al frontend: el modal lo abre `LayoutBasic` en cuanto entra CUALQUIER
249
+ * usuario con la marca a 'NO'. Sin la condición de owner que se añadió allí,
250
+ * este gate deja a un empleado atrapado en un modal que no puede completar.
236
251
  */
237
252
  post: operations["general.bulkExchangeRate"];
238
253
  delete?: never;
@@ -384,7 +399,14 @@ export interface paths {
384
399
  };
385
400
  get?: never;
386
401
  put?: never;
387
- /** Handle the incoming request */
402
+ /**
403
+ * Convertir un presupuesto en factura borrador
404
+ * @description Acepta `external_ref` para etiquetar la factura resultante **en el mismo
405
+ * paso**. Etiquetarla después, con `PUT /invoices/{id}`, llega tarde:
406
+ * `invoice.created` ya ha salido con la referencia nula, y entre las dos
407
+ * llamadas hay una ventana en la que la factura existe y no la encuentras
408
+ * por tu referencia.
409
+ */
388
410
  post: operations["estimate.convertEstimate"];
389
411
  delete?: never;
390
412
  options?: never;
@@ -881,6 +903,34 @@ export interface paths {
881
903
  /**
882
904
  * Toggle lock state for a quarter
883
905
  * @description **No disponible para integradores.** Exige `reports:write`, que el Authorization Server de Pimia no emite: la acción la realiza el dueño desde su panel y un token de partner recibe `403`. Aparece en el contrato para que el hueco sea explícito, no para que se llame.
906
+ *
907
+ * Cerrar un trimestre corta el alta y la edición de facturas emitidas y
908
+ * recibidas con fecha en ese periodo ({@see FiscalQuarter::isLocked}); esta
909
+ * misma acción lo REABRE. Es cierre contable, así que va con el gate de
910
+ * owner que ya gobierna la configuración sensible del tenant.
911
+ *
912
+ * Hasta 2026-08-10 no tenía ninguno: el `index` y el `toggle` colgaban de la
913
+ * ability de LECTURA de informes que gatea la pantalla (VIEW_FINANCIAL_REPORT
914
+ * en el router del panel), de modo que cualquiera que pudiera ver el informe
915
+ * contable podía reabrir un ejercicio cerrado. Para un token de API la única
916
+ * barrera era que `reports:write` no esté en el catálogo OAuth — una ausencia
917
+ * que protegía por accidente y que este gate deja de necesitar.
918
+ *
919
+ * El `index` sigue sin gate a propósito: leer qué trimestres están cerrados
920
+ * es lo que pinta el panel y no cambia nada.
921
+ *
922
+ * `locked` (opcional) es el ESTADO DESEADO, y es lo que deberían mandar los
923
+ * clientes: con él la operación es idempotente y dice lo que quiere en vez
924
+ * de depender de lo que había. Sin él se conserva el alternado histórico.
925
+ *
926
+ * El alternado ciego tiene una carrera que no es teórica: el cliente decide
927
+ * qué va a pasar leyendo un estado que ya puede haber cambiado. El panel
928
+ * pinta «Cerrar T1» porque lo leyó abierto, otro usuario lo cierra, y el
929
+ * clic acaba REABRIENDO el trimestre que su botón prometía cerrar. Por la
930
+ * vía del MCP era peor: `toggle_fiscal_quarter_lock` declaraba este mismo
931
+ * parámetro y el servidor lo ignoraba, así que un agente que pidiera cerrar
932
+ * un trimestre ya cerrado lo abría — en una operación que el propio MCP
933
+ * anuncia como PELIGROSA.
884
934
  */
885
935
  post: operations["fiscalQuarter.toggle"];
886
936
  delete?: never;
@@ -2976,6 +3026,22 @@ export interface components {
2976
3026
  created_at: string;
2977
3027
  updated_at: string;
2978
3028
  };
3029
+ /**
3030
+ * ConvertEstimateRequest
3031
+ * @description Cuerpo —opcional— de la conversión de un presupuesto en factura.
3032
+ */
3033
+ ConvertEstimateRequest: {
3034
+ /**
3035
+ * @description Referencia externa: TU identificador para la factura que sale de
3036
+ * esta conversión (el id del pedido o de la venta cerrada en tu
3037
+ * sistema). Sin él la factura nace sin etiquetar y los webhooks
3038
+ * `invoice.created` e `invoice.paid` llegan con `external_ref`
3039
+ * nulo. El alcance es tu client OAuth, igual que en el alta
3040
+ * directa de facturas. Duplicada dentro de tu namespace → 422 con
3041
+ * `existing_id`, y la conversión entera se deshace.
3042
+ */
3043
+ external_ref?: string | null;
3044
+ };
2979
3045
  /** CountryResource */
2980
3046
  CountryResource: {
2981
3047
  id: number;
@@ -3081,6 +3147,17 @@ export interface components {
3081
3147
  prefix?: string | null;
3082
3148
  tax_id?: string | null;
3083
3149
  notes?: string | null;
3150
+ /**
3151
+ * @description Referencia externa: TU identificador para este cliente (el id
3152
+ * del deal, del contacto o del pedido en tu sistema). El alcance
3153
+ * es tu client OAuth, así que dos integradores pueden usar la
3154
+ * misma cadena sin pisarse y ninguno ve la del otro. Se consulta
3155
+ * con `GET /customers?external_ref=…`, vuelve en el recurso y
3156
+ * viaja en los webhooks `customer.*`. Si ya la lleva otro cliente
3157
+ * tuyo, la respuesta es un 422 con `existing_id` — que es el
3158
+ * find-or-create sin mantener mapeo local. `null` la desvincula.
3159
+ */
3160
+ external_ref?: string | null;
3084
3161
  currency_id?: string | null;
3085
3162
  payment_method_id?: number | null;
3086
3163
  billing?: {
@@ -3139,6 +3216,13 @@ export interface components {
3139
3216
  prefix: string;
3140
3217
  tax_id: string;
3141
3218
  notes: string;
3219
+ /**
3220
+ * @description Referencia externa DE QUIEN PREGUNTA: la resuelve el client OAuth
3221
+ * del token, así que dos integradores ven cada uno la suya y el
3222
+ * panel (sin client) ve la del namespace de la company. Siempre
3223
+ * presente aunque sea null, para que el tipo del SDK no alterne.
3224
+ */
3225
+ external_ref: string | null;
3142
3226
  iban: string;
3143
3227
  bic: string;
3144
3228
  sepa_mandate_id: string;
@@ -3280,6 +3364,12 @@ export interface components {
3280
3364
  template_name: string;
3281
3365
  customer_id: string;
3282
3366
  lead_id: string;
3367
+ /**
3368
+ * @description Referencia externa del client OAuth del token (null si no hay).
3369
+ * Es el asidero que `lead_id` no podía ser: aquel es un entero de
3370
+ * Pimia y no admite el cuid o el uuid de un CRM de fuera.
3371
+ */
3372
+ external_ref: string | null;
3283
3373
  exchange_rate: string;
3284
3374
  base_discount_val: string;
3285
3375
  base_sub_total: string;
@@ -3313,6 +3403,17 @@ export interface components {
3313
3403
  expiry_date?: string | null;
3314
3404
  customer_id?: number | null;
3315
3405
  lead_id?: number | null;
3406
+ /**
3407
+ * @description Referencia externa: TU identificador para este presupuesto (el
3408
+ * id de la oportunidad en tu CRM). Es el asidero que `lead_id` no
3409
+ * podía ser: aquel es un entero de Pimia y no admite un cuid ni un
3410
+ * uuid. El alcance es tu client OAuth. Se consulta con
3411
+ * `GET /estimates?external_ref=…` y viaja en el payload de
3412
+ * `estimate.accepted`, así que recuperas tu oportunidad en el
3413
+ * evento sin mantener ningún mapeo. Duplicada dentro de tu
3414
+ * namespace → 422 con `existing_id`. `null` la desvincula.
3415
+ */
3416
+ external_ref?: string | null;
3316
3417
  /**
3317
3418
  * @description Opcional en el alta: si no llega, lo genera el servidor con el mismo
3318
3419
  * SerialNumberFormatter que alimenta a GET /next-number, que es de donde
@@ -3533,6 +3634,11 @@ export interface components {
3533
3634
  template_name: string;
3534
3635
  invoice_series_id: string;
3535
3636
  customer_id: string;
3637
+ /**
3638
+ * @description Referencia externa del client OAuth del token (null si no hay):
3639
+ * cada integrador ve la suya y solo la suya.
3640
+ */
3641
+ external_ref: string | null;
3536
3642
  payment_method_id: string;
3537
3643
  recurring_invoice_id: string;
3538
3644
  sequence_number: string;
@@ -3684,6 +3790,15 @@ export interface components {
3684
3790
  due_date?: string | null;
3685
3791
  customer_id: number;
3686
3792
  invoice_number?: string | null;
3793
+ /**
3794
+ * @description Referencia externa: TU identificador para esta factura (el id
3795
+ * del pedido o de la suscripción en tu sistema). El alcance es tu
3796
+ * client OAuth. Se consulta con `GET /invoices?external_ref=…` y
3797
+ * viaja en los payloads de `invoice.created` e `invoice.paid`.
3798
+ * Duplicada dentro de tu namespace → 422 con `existing_id`.
3799
+ * `null` la desvincula.
3800
+ */
3801
+ external_ref?: string | null;
3687
3802
  exchange_rate?: string | null;
3688
3803
  /**
3689
3804
  * @description Descuento global: opcional en el contrato y rellenado a 0 en
@@ -4720,31 +4835,31 @@ export interface components {
4720
4835
  };
4721
4836
  };
4722
4837
  responses: {
4723
- /** @description Validation error */
4724
- ValidationException: {
4838
+ /** @description Authorization error */
4839
+ AuthorizationException: {
4725
4840
  headers: {
4726
4841
  [name: string]: unknown;
4727
4842
  };
4728
4843
  content: {
4729
4844
  "application/json": {
4730
- /** @description Errors overview. */
4845
+ /** @description Error overview. */
4731
4846
  message: string;
4732
- /** @description A detailed description of each field that failed validation. */
4733
- errors: {
4734
- [key: string]: string[];
4735
- };
4736
4847
  };
4737
4848
  };
4738
4849
  };
4739
- /** @description Authorization error */
4740
- AuthorizationException: {
4850
+ /** @description Validation error */
4851
+ ValidationException: {
4741
4852
  headers: {
4742
4853
  [name: string]: unknown;
4743
4854
  };
4744
4855
  content: {
4745
4856
  "application/json": {
4746
- /** @description Error overview. */
4857
+ /** @description Errors overview. */
4747
4858
  message: string;
4859
+ /** @description A detailed description of each field that failed validation. */
4860
+ errors: {
4861
+ [key: string]: string[];
4862
+ };
4748
4863
  };
4749
4864
  };
4750
4865
  };
@@ -5436,6 +5551,7 @@ export interface operations {
5436
5551
  "application/json": Record<string, never>;
5437
5552
  };
5438
5553
  };
5554
+ 403: components["responses"]["AuthorizationException"];
5439
5555
  422: components["responses"]["ValidationException"];
5440
5556
  };
5441
5557
  };
@@ -5759,7 +5875,11 @@ export interface operations {
5759
5875
  };
5760
5876
  cookie?: never;
5761
5877
  };
5762
- requestBody?: never;
5878
+ requestBody?: {
5879
+ content: {
5880
+ "application/json": components["schemas"]["ConvertEstimateRequest"];
5881
+ };
5882
+ };
5763
5883
  responses: {
5764
5884
  200: {
5765
5885
  headers: {
@@ -5771,6 +5891,7 @@ export interface operations {
5771
5891
  };
5772
5892
  403: components["responses"]["AuthorizationException"];
5773
5893
  404: components["responses"]["ModelNotFoundException"];
5894
+ 422: components["responses"]["ValidationException"];
5774
5895
  };
5775
5896
  };
5776
5897
  "general.countries": {
@@ -6074,7 +6195,10 @@ export interface operations {
6074
6195
  };
6075
6196
  "customers.index": {
6076
6197
  parameters: {
6077
- query?: never;
6198
+ query?: {
6199
+ /** @description Devuelve solo el recurso que lleve esta referencia externa. El alcance es el client OAuth del token: cada integrador consulta las suyas y nunca ve las de otro. Combinado con la escritura de `external_ref`, es el find-or-create sin mantener ningún mapeo local. */
6200
+ external_ref?: string;
6201
+ };
6078
6202
  header?: never;
6079
6203
  path?: never;
6080
6204
  cookie?: never;
@@ -6632,7 +6756,10 @@ export interface operations {
6632
6756
  };
6633
6757
  "estimates.index": {
6634
6758
  parameters: {
6635
- query?: never;
6759
+ query?: {
6760
+ /** @description Devuelve solo el recurso que lleve esta referencia externa. El alcance es el client OAuth del token: cada integrador consulta las suyas y nunca ve las de otro. Combinado con la escritura de `external_ref`, es el find-or-create sin mantener ningún mapeo local. */
6761
+ external_ref?: string;
6762
+ };
6636
6763
  header?: never;
6637
6764
  path?: never;
6638
6765
  cookie?: never;
@@ -7094,6 +7221,7 @@ export interface operations {
7094
7221
  "application/json": {
7095
7222
  year: number;
7096
7223
  quarter: number;
7224
+ locked?: boolean | null;
7097
7225
  };
7098
7226
  };
7099
7227
  };
@@ -7115,6 +7243,7 @@ export interface operations {
7115
7243
  };
7116
7244
  };
7117
7245
  };
7246
+ 403: components["responses"]["AuthorizationException"];
7118
7247
  422: components["responses"]["ValidationException"];
7119
7248
  };
7120
7249
  };
@@ -8013,6 +8142,8 @@ export interface operations {
8013
8142
  parameters: {
8014
8143
  query?: {
8015
8144
  limit?: string;
8145
+ /** @description Devuelve solo el recurso que lleve esta referencia externa. El alcance es el client OAuth del token: cada integrador consulta las suyas y nunca ve las de otro. Combinado con la escritura de `external_ref`, es el find-or-create sin mantener ningún mapeo local. */
8146
+ external_ref?: string;
8016
8147
  };
8017
8148
  header?: never;
8018
8149
  path?: never;
package/dist/client.d.ts CHANGED
@@ -112,6 +112,19 @@ export interface ResponseWithMeta<T> {
112
112
  }
113
113
  /** Lo que se puede afinar en una escritura (`post`/`put`/`patch`). */
114
114
  export type WriteOptions = Pick<RequestOptions, 'headers' | 'query' | 'signal' | 'idempotencyKey'>;
115
+ /**
116
+ * Lo que se puede afinar en una lectura (`get`/`delete` y los atajos de
117
+ * recurso).
118
+ *
119
+ * Sin `idempotencyKey`, que no significa nada en una lectura, y sin `query`,
120
+ * que en `get()` ya es un parámetro propio.
121
+ *
122
+ * Existe sobre todo por `signal`: hasta la 0.4 los atajos de lectura no
123
+ * aceptaban opciones, así que ponerle un timeout a un GET obligaba a bajar a
124
+ * `request()` — o a quedarse sin él, que es lo que pasa de verdad. Un cliente
125
+ * que sondea y se cuelga en una lectura deja de sondear sin dar un solo error.
126
+ */
127
+ export type ReadOptions = Pick<RequestOptions, 'headers' | 'signal'>;
115
128
  export declare class PimiaClient {
116
129
  readonly oauth: OAuth;
117
130
  private readonly baseUrl;
@@ -128,13 +141,13 @@ export declare class PimiaClient {
128
141
  /** Cabeceras `X-RateLimit-*` de la última respuesta. */
129
142
  get rateLimit(): RateLimit;
130
143
  get invoices(): {
131
- list: (query?: RequestOptions["query"]) => Promise<{
144
+ list: (query?: RequestOptions["query"], options?: ReadOptions) => Promise<{
132
145
  data: components["schemas"]["InvoiceResource"][];
133
146
  meta: {
134
147
  invoice_total_count: number;
135
148
  };
136
149
  }>;
137
- get: (id: number | string) => Promise<{
150
+ get: (id: number | string, options?: ReadOptions) => Promise<{
138
151
  data: components["schemas"]["InvoiceResource"] & Record<string, never>;
139
152
  }>;
140
153
  /**
@@ -168,6 +181,7 @@ export declare class PimiaClient {
168
181
  template_name: string;
169
182
  invoice_series_id: string;
170
183
  customer_id: string;
184
+ external_ref: string | null;
171
185
  payment_method_id: string;
172
186
  recurring_invoice_id: string;
173
187
  sequence_number: string;
@@ -253,6 +267,7 @@ export declare class PimiaClient {
253
267
  template_name: string;
254
268
  invoice_series_id: string;
255
269
  customer_id: string;
270
+ external_ref: string | null;
256
271
  payment_method_id: string;
257
272
  recurring_invoice_id: string;
258
273
  sequence_number: string;
@@ -312,13 +327,13 @@ export declare class PimiaClient {
312
327
  }>>;
313
328
  };
314
329
  get customers(): {
315
- list: (query?: RequestOptions["query"]) => Promise<{
330
+ list: (query?: RequestOptions["query"], options?: ReadOptions) => Promise<{
316
331
  data: components["schemas"]["CustomerResource"][];
317
332
  meta: {
318
333
  customer_total_count: number;
319
334
  };
320
335
  }>;
321
- get: (id: number | string) => Promise<{
336
+ get: (id: number | string, options?: ReadOptions) => Promise<{
322
337
  data: components["schemas"]["CustomerResource"];
323
338
  }>;
324
339
  create: (body: CustomerRequest, options?: WriteOptions) => Promise<{
@@ -350,6 +365,7 @@ export declare class PimiaClient {
350
365
  prefix: string;
351
366
  tax_id: string;
352
367
  notes: string;
368
+ external_ref: string | null;
353
369
  iban: string;
354
370
  bic: string;
355
371
  sepa_mandate_id: string;
@@ -363,13 +379,13 @@ export declare class PimiaClient {
363
379
  }>>;
364
380
  };
365
381
  get estimates(): {
366
- list: (query?: RequestOptions["query"]) => Promise<{
382
+ list: (query?: RequestOptions["query"], options?: ReadOptions) => Promise<{
367
383
  data: components["schemas"]["EstimateResource"][];
368
384
  meta: {
369
385
  estimate_total_count: number;
370
386
  };
371
387
  }>;
372
- get: (id: number | string) => Promise<{
388
+ get: (id: number | string, options?: ReadOptions) => Promise<{
373
389
  data: components["schemas"]["EstimateResource"];
374
390
  }>;
375
391
  create: (body: EstimatesRequest, options?: WriteOptions) => Promise<{
@@ -393,9 +409,24 @@ export declare class PimiaClient {
393
409
  * `estimate:{id}:invoice`— y el reintento tras un timeout no te creará
394
410
  * una segunda factura.
395
411
  *
412
+ * Y manda `externalRef` si la venta nació en tu sistema: es lo que hace
413
+ * que `invoice.created` e `invoice.paid` te lleguen con tu referencia en
414
+ * vez de con `null`. **Tiene que ir aquí, en la conversión**; etiquetar
415
+ * después con `PUT /invoices/{id}` llega tarde por dos motivos: para
416
+ * entonces `invoice.created` ya salió con la referencia nula, y entre las
417
+ * dos llamadas hay una ventana en la que la factura existe y no la
418
+ * encuentras por tu referencia.
419
+ *
420
+ * Va como opción y no como segundo parámetro para no romperle la llamada
421
+ * a quien ya hace `convertToInvoice(id, { idempotencyKey })`: el cuerpo lo
422
+ * monta el atajo, y `external_ref` es además el único campo que el
423
+ * endpoint acepta.
424
+ *
396
425
  * Exige `estimates:write` **e** `invoices:write`.
397
426
  */
398
- convertToInvoice: (id: number | string, options?: WriteOptions) => Promise<ResourceEnvelope<{
427
+ convertToInvoice: (id: number | string, options?: WriteOptions & {
428
+ externalRef?: string | null;
429
+ }) => Promise<ResourceEnvelope<{
399
430
  id: string;
400
431
  invoice_date: string;
401
432
  due_date: string;
@@ -422,6 +453,7 @@ export declare class PimiaClient {
422
453
  template_name: string;
423
454
  invoice_series_id: string;
424
455
  customer_id: string;
456
+ external_ref: string | null;
425
457
  payment_method_id: string;
426
458
  recurring_invoice_id: string;
427
459
  sequence_number: string;
@@ -480,11 +512,11 @@ export declare class PimiaClient {
480
512
  currency?: components["schemas"]["CurrencyResource"];
481
513
  }>>;
482
514
  };
483
- get<T = unknown>(path: string, query?: RequestOptions['query']): Promise<T>;
515
+ get<T = unknown>(path: string, query?: RequestOptions['query'], options?: ReadOptions): Promise<T>;
484
516
  post<T = unknown>(path: string, body?: unknown, options?: WriteOptions): Promise<T>;
485
517
  put<T = unknown>(path: string, body?: unknown, options?: WriteOptions): Promise<T>;
486
518
  patch<T = unknown>(path: string, body?: unknown, options?: WriteOptions): Promise<T>;
487
- delete<T = unknown>(path: string): Promise<T>;
519
+ delete<T = unknown>(path: string, options?: ReadOptions): Promise<T>;
488
520
  /**
489
521
  * Petición cruda contra `/api/v1`. `path` puede llevar el prefijo o no:
490
522
  * `/invoices` y `/api/v1/invoices` son lo mismo.
package/dist/client.js CHANGED
@@ -44,8 +44,8 @@ export class PimiaClient {
44
44
  }
45
45
  get invoices() {
46
46
  return {
47
- list: (query) => this.get('/invoices', query),
48
- get: (id) => this.get(`/invoices/${id}`),
47
+ list: (query, options) => this.get('/invoices', query, options),
48
+ get: (id, options) => this.get(`/invoices/${id}`, undefined, options),
49
49
  /**
50
50
  * Devuelve `{ data: InvoiceResource }`. El tipo NO sale del spec: el
51
51
  * `200` de `invoices.store` está vacío ahí (ver {@link ResourceEnvelope}).
@@ -57,8 +57,8 @@ export class PimiaClient {
57
57
  }
58
58
  get customers() {
59
59
  return {
60
- list: (query) => this.get('/customers', query),
61
- get: (id) => this.get(`/customers/${id}`),
60
+ list: (query, options) => this.get('/customers', query, options),
61
+ get: (id, options) => this.get(`/customers/${id}`, undefined, options),
62
62
  create: (body, options) => this.post('/customers', body, options),
63
63
  /** El `200` de `customers.update` no está tipado en el spec. */
64
64
  update: (id, body, options) => this.put(`/customers/${id}`, body, options),
@@ -66,8 +66,8 @@ export class PimiaClient {
66
66
  }
67
67
  get estimates() {
68
68
  return {
69
- list: (query) => this.get('/estimates', query),
70
- get: (id) => this.get(`/estimates/${id}`),
69
+ list: (query, options) => this.get('/estimates', query, options),
70
+ get: (id, options) => this.get(`/estimates/${id}`, undefined, options),
71
71
  create: (body, options) => this.post('/estimates', body, options),
72
72
  /**
73
73
  * Convierte un presupuesto aceptado en factura.
@@ -87,13 +87,33 @@ export class PimiaClient {
87
87
  * `estimate:{id}:invoice`— y el reintento tras un timeout no te creará
88
88
  * una segunda factura.
89
89
  *
90
+ * Y manda `externalRef` si la venta nació en tu sistema: es lo que hace
91
+ * que `invoice.created` e `invoice.paid` te lleguen con tu referencia en
92
+ * vez de con `null`. **Tiene que ir aquí, en la conversión**; etiquetar
93
+ * después con `PUT /invoices/{id}` llega tarde por dos motivos: para
94
+ * entonces `invoice.created` ya salió con la referencia nula, y entre las
95
+ * dos llamadas hay una ventana en la que la factura existe y no la
96
+ * encuentras por tu referencia.
97
+ *
98
+ * Va como opción y no como segundo parámetro para no romperle la llamada
99
+ * a quien ya hace `convertToInvoice(id, { idempotencyKey })`: el cuerpo lo
100
+ * monta el atajo, y `external_ref` es además el único campo que el
101
+ * endpoint acepta.
102
+ *
90
103
  * Exige `estimates:write` **e** `invoices:write`.
91
104
  */
92
- convertToInvoice: (id, options) => this.post(`/estimates/${id}/convert-to-invoice`, {}, options),
105
+ convertToInvoice: (id, options) => {
106
+ const { externalRef, ...resto } = options ?? {};
107
+ return this.post(`/estimates/${id}/convert-to-invoice`,
108
+ // Cuerpo vacío si no se pide, y no `external_ref: null`: mandar el
109
+ // null explícito DESVINCULA la referencia, que no es lo mismo que no
110
+ // tocarla.
111
+ externalRef === undefined ? {} : { external_ref: externalRef }, resto);
112
+ },
93
113
  };
94
114
  }
95
- get(path, query) {
96
- return this.request(path, { method: 'GET', query });
115
+ get(path, query, options) {
116
+ return this.request(path, { ...options, method: 'GET', query });
97
117
  }
98
118
  post(path, body, options) {
99
119
  return this.request(path, { ...options, method: 'POST', body });
@@ -104,8 +124,8 @@ export class PimiaClient {
104
124
  patch(path, body, options) {
105
125
  return this.request(path, { ...options, method: 'PATCH', body });
106
126
  }
107
- delete(path) {
108
- return this.request(path, { method: 'DELETE' });
127
+ delete(path, options) {
128
+ return this.request(path, { ...options, method: 'DELETE' });
109
129
  }
110
130
  /**
111
131
  * Petición cruda contra `/api/v1`. `path` puede llevar el prefijo o no:
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,14 +6,14 @@
6
6
  * salen los tipos de `./api`.
7
7
  */
8
8
  export { PimiaClient } from './client.js';
9
- export type { CustomerRequest, CustomerResource, EstimateResource, EstimatesRequest, InvoiceResource, InvoicesRequest, PimiaClientOptions, RateLimit, RequestOptions, ResourceEnvelope, ResponseMeta, ResponseWithMeta, WriteOptions, } from './client.js';
9
+ export type { CustomerRequest, CustomerResource, EstimateResource, EstimatesRequest, InvoiceResource, InvoicesRequest, PimiaClientOptions, RateLimit, ReadOptions, 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
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, InvoiceCreatedPayload, InvoicePaidPayload, InvoiceReceivedPayload, IsoDateTime, KnownWebhook, PimiaWebhook, SignWebhookOptions, UnknownWebhook, VerifyWebhookOptions, WebhookBodyInput, WebhookEvent, WebhookHeadersInput, WebhookPayloads, WebhookVerificationReason, } 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';
17
17
  /** Scopes granulares del catálogo de Pimia (paso 4). Pide siempre lo mínimo. */
18
18
  export declare const SCOPES: {
19
19
  readonly invoicesRead: "invoices:read";
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@
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
12
  export { WEBHOOK_DEFAULT_TOLERANCE_SECONDS, WEBHOOK_EVENTS, WEBHOOK_HEADERS, WEBHOOK_SIGNATURE_VERSION, WebhookVerificationError, isWebhookEvent, signWebhook, verifyWebhook, } from './webhooks.js';
13
13
  /** Scopes granulares del catálogo de Pimia (paso 4). Pide siempre lo mínimo. */
14
14
  export const SCOPES = {
@@ -67,6 +67,26 @@ export declare function isWebhookEvent(value: string): value is WebhookEvent;
67
67
  export type IsoDateTime = string;
68
68
  /** Une un enum abierto: autocompleta los valores conocidos sin cerrar el tipo. */
69
69
  type OpenEnum<T extends string> = T | (string & {});
70
+ /**
71
+ * Referencia externa **de quien recibe esta entrega**: la que TU app puso sobre
72
+ * el recurso del evento, nunca la de otro integrador.
73
+ *
74
+ * No se calcula una vez y se reparte. El core la resuelve endpoint por endpoint
75
+ * contra el `client_id` del destinatario, porque un mismo evento se entrega a
76
+ * todos los endpoints suscritos de la company y esos pueden pertenecer a
77
+ * integradores distintos: con un valor común en el payload, la referencia del
78
+ * integrador A habría llegado al endpoint de B. Una referencia escrita sin
79
+ * client OAuth (panel, token personal) no sale nunca por este canal.
80
+ *
81
+ * Es `string | null` y **no** opcional a propósito: la clave viaja siempre, con
82
+ * `null` cuando no hay referencia. Un payload cuya forma cambia según el dato es
83
+ * un payload que el receptor no puede tipar.
84
+ *
85
+ * Solo la llevan los cinco eventos de recurso (`customer.*`, `invoice.created`,
86
+ * `invoice.paid`, `estimate.accepted`). `approval.decided`, `invoice.received` y
87
+ * `app.revoked` no van sobre un recurso etiquetable y no la incluyen.
88
+ */
89
+ export type ExternalRef = string | null;
70
90
  /**
71
91
  * Una decisión de aprobación delegada se resolvió.
72
92
  *
@@ -129,6 +149,8 @@ export interface CustomerPayload {
129
149
  company_id: number;
130
150
  created_at: IsoDateTime | null;
131
151
  updated_at: IsoDateTime | null;
152
+ /** Tu referencia para este cliente. Ver {@link ExternalRef}. */
153
+ external_ref: ExternalRef;
132
154
  }
133
155
  /** Alta de factura. Importes en céntimos. */
134
156
  export interface InvoiceCreatedPayload {
@@ -147,6 +169,14 @@ export interface InvoiceCreatedPayload {
147
169
  due_amount: number;
148
170
  currency_id: number | null;
149
171
  created_at: IsoDateTime | null;
172
+ /**
173
+ * Tu referencia para esta factura. Ver {@link ExternalRef}.
174
+ *
175
+ * Llega poblada también cuando la factura nace de
176
+ * `POST /estimates/{id}/convert-to-invoice` con `external_ref` en el cuerpo:
177
+ * sellar en la conversión existe justo para que este evento no salga nulo.
178
+ */
179
+ external_ref: ExternalRef;
150
180
  }
151
181
  /**
152
182
  * El CLIENTE FINAL aceptó el presupuesto. Es una transición, no un estado: solo
@@ -166,6 +196,14 @@ export interface EstimateAcceptedPayload {
166
196
  total: number;
167
197
  currency_id: number | null;
168
198
  accepted_at: IsoDateTime | null;
199
+ /**
200
+ * Tu referencia para este presupuesto. Ver {@link ExternalRef}.
201
+ *
202
+ * Es el asidero que `lead_id` no podía ser: aquel es un entero de Pimia y no
203
+ * admite el cuid ni el uuid de un CRM de fuera, así que recuperas tu
204
+ * oportunidad desde el propio evento sin mantener ninguna tabla de mapeo.
205
+ */
206
+ external_ref: ExternalRef;
169
207
  }
170
208
  /**
171
209
  * La factura quedó cobrada del todo. `PARTIALLY_PAID` no emite: es una
@@ -185,6 +223,8 @@ export interface InvoicePaidPayload {
185
223
  due_amount: number;
186
224
  currency_id: number | null;
187
225
  paid_at: IsoDateTime | null;
226
+ /** Tu referencia para esta factura. Ver {@link ExternalRef}. */
227
+ external_ref: ExternalRef;
188
228
  }
189
229
  /** Payload de cada evento del catálogo, por nombre. */
190
230
  export interface WebhookPayloads {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pimia/sdk",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Cliente TypeScript de la API de Pimia para apps de partner: OAuth con PKCE, rotación de refresh persistida, reintentos de rate limit y tipos generados del OpenAPI.",
5
5
  "license": "MIT",
6
6
  "author": "Pimia (https://pimia.es)",