@pimia/sdk 0.1.0 → 0.3.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). */
@@ -34,12 +68,50 @@ export interface RequestOptions {
34
68
  body?: unknown;
35
69
  headers?: Record<string, string>;
36
70
  signal?: AbortSignal;
71
+ /**
72
+ * Clave de idempotencia para este `POST`. Manda una única por operación —un
73
+ * UUID nuevo— y reúsala SOLO en los reintentos de esa misma operación:
74
+ * Pimia ejecuta la escritura una vez y reproduce la respuesta original en
75
+ * los reintentos. La misma clave con otro cuerpo responde 422.
76
+ *
77
+ * Para saber si lo que recibiste es un eco y no una escritura nueva, usa
78
+ * {@link PimiaClient.requestWithMeta} y mira `meta.idempotentReplay`.
79
+ */
80
+ idempotencyKey?: string;
37
81
  }
38
82
  /** Cabeceras de rate limit que devuelve la API en cada respuesta. */
39
83
  export interface RateLimit {
40
84
  limit?: number;
41
85
  remaining?: number;
42
86
  }
87
+ /**
88
+ * Lo que la respuesta dice ADEMÁS del cuerpo.
89
+ *
90
+ * Va por petición y no como estado del cliente —al contrario que
91
+ * {@link PimiaClient.rateLimit}— a propósito: `idempotentReplay` solo
92
+ * significa algo referido a UNA llamada concreta, y justo se consulta cuando
93
+ * hay reintentos, que es cuando puede haber varias en vuelo. Un campo
94
+ * compartido en el cliente daría la respuesta de otra.
95
+ */
96
+ export interface ResponseMeta {
97
+ status: number;
98
+ /**
99
+ * `true` si Pimia reprodujo la respuesta de una petición anterior con la
100
+ * misma `Idempotency-Key` en vez de volver a escribir. Es la diferencia
101
+ * entre «he creado la factura» y «ya estaba creada»: sin esto, un partner
102
+ * no puede distinguirlas en sus propios registros.
103
+ */
104
+ idempotentReplay: boolean;
105
+ requestId?: string;
106
+ rateLimit: RateLimit;
107
+ }
108
+ /** Cuerpo y metadatos de una misma respuesta. */
109
+ export interface ResponseWithMeta<T> {
110
+ data: T;
111
+ meta: ResponseMeta;
112
+ }
113
+ /** Lo que se puede afinar en una escritura (`post`/`put`/`patch`). */
114
+ export type WriteOptions = Pick<RequestOptions, 'headers' | 'query' | 'signal' | 'idempotencyKey'>;
43
115
  export declare class PimiaClient {
44
116
  readonly oauth: OAuth;
45
117
  private readonly baseUrl;
@@ -56,32 +128,385 @@ export declare class PimiaClient {
56
128
  /** Cabeceras `X-RateLimit-*` de la última respuesta. */
57
129
  get rateLimit(): RateLimit;
58
130
  get invoices(): {
59
- list: (query?: RequestOptions["query"]) => Promise<unknown>;
60
- get: (id: number | string) => Promise<unknown>;
61
- create: (body: unknown) => Promise<unknown>;
62
- update: (id: number | string, body: unknown) => 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
+ payment_method_id: string;
172
+ recurring_invoice_id: string;
173
+ sequence_number: string;
174
+ exchange_rate: string;
175
+ base_discount_val: string;
176
+ base_sub_total: string;
177
+ base_total: string;
178
+ creator_id: string;
179
+ base_tax: string;
180
+ base_due_amount: string;
181
+ effective_base_total: string;
182
+ effective_base_due_amount: string;
183
+ credited_total: string;
184
+ credited_base_total: string;
185
+ currency_id: string;
186
+ formatted_created_at: string;
187
+ invoice_pdf_url: string;
188
+ formatted_invoice_date: string;
189
+ formatted_due_date: string;
190
+ allow_edit: string;
191
+ payment_module_enabled: string;
192
+ sales_tax_type: string;
193
+ sales_tax_address_type: string;
194
+ overdue: string;
195
+ effective_paid_status: string;
196
+ effective_overdue: string;
197
+ aeat_status: string;
198
+ qr_data: string;
199
+ hash: string;
200
+ aeat_csv: string;
201
+ is_credit_note: string | boolean;
202
+ rectified_invoice_id: string;
203
+ rectified_invoice_number?: string | null;
204
+ rectified_invoice?: {
205
+ id: string;
206
+ invoice_number: string;
207
+ tax_per_item: string;
208
+ tax_included: string;
209
+ sub_total: string;
210
+ discount_val: string;
211
+ tax: string;
212
+ total: string;
213
+ items: components["schemas"]["InvoiceItemResource"][];
214
+ taxes: components["schemas"]["TaxResource"][];
215
+ };
216
+ credit_notes_count: number;
217
+ items?: components["schemas"]["InvoiceItemResource"][];
218
+ payments?: components["schemas"]["PaymentResource"][];
219
+ customer?: components["schemas"]["CustomerResource"];
220
+ invoice_series?: components["schemas"]["InvoiceSeriesResource"];
221
+ payment_method?: components["schemas"]["PaymentMethodResource"];
222
+ creator?: components["schemas"]["UserResource"];
223
+ taxes: components["schemas"]["TaxResource"][];
224
+ fields?: components["schemas"]["CustomFieldValueResource"][];
225
+ company?: components["schemas"]["CompanyResource"];
226
+ currency?: components["schemas"]["CurrencyResource"];
227
+ }>>;
228
+ /** Mismo caso que `create`: el `200` de `invoices.update` no está tipado en el spec. */
229
+ update: (id: number | string, body: InvoicesRequest, options?: WriteOptions) => Promise<ResourceEnvelope<{
230
+ id: string;
231
+ invoice_date: string;
232
+ due_date: string;
233
+ invoice_number: string;
234
+ reference_number: string;
235
+ status: string;
236
+ paid_status: string;
237
+ tax_per_item: string;
238
+ tax_included: string;
239
+ discount_per_item: string;
240
+ notes: string;
241
+ discount_type: string;
242
+ discount: string;
243
+ discount_val: string;
244
+ sub_total: string;
245
+ total: string;
246
+ effective_total: string;
247
+ tax: string;
248
+ due_amount: string;
249
+ effective_due_amount: string;
250
+ sent: string;
251
+ viewed: string;
252
+ unique_hash: string;
253
+ template_name: string;
254
+ invoice_series_id: string;
255
+ customer_id: string;
256
+ payment_method_id: string;
257
+ recurring_invoice_id: string;
258
+ sequence_number: string;
259
+ exchange_rate: string;
260
+ base_discount_val: string;
261
+ base_sub_total: string;
262
+ base_total: string;
263
+ creator_id: string;
264
+ base_tax: string;
265
+ base_due_amount: string;
266
+ effective_base_total: string;
267
+ effective_base_due_amount: string;
268
+ credited_total: string;
269
+ credited_base_total: string;
270
+ currency_id: string;
271
+ formatted_created_at: string;
272
+ invoice_pdf_url: string;
273
+ formatted_invoice_date: string;
274
+ formatted_due_date: string;
275
+ allow_edit: string;
276
+ payment_module_enabled: string;
277
+ sales_tax_type: string;
278
+ sales_tax_address_type: string;
279
+ overdue: string;
280
+ effective_paid_status: string;
281
+ effective_overdue: string;
282
+ aeat_status: string;
283
+ qr_data: string;
284
+ hash: string;
285
+ aeat_csv: string;
286
+ is_credit_note: string | boolean;
287
+ rectified_invoice_id: string;
288
+ rectified_invoice_number?: string | null;
289
+ rectified_invoice?: {
290
+ id: string;
291
+ invoice_number: string;
292
+ tax_per_item: string;
293
+ tax_included: string;
294
+ sub_total: string;
295
+ discount_val: string;
296
+ tax: string;
297
+ total: string;
298
+ items: components["schemas"]["InvoiceItemResource"][];
299
+ taxes: components["schemas"]["TaxResource"][];
300
+ };
301
+ credit_notes_count: number;
302
+ items?: components["schemas"]["InvoiceItemResource"][];
303
+ payments?: components["schemas"]["PaymentResource"][];
304
+ customer?: components["schemas"]["CustomerResource"];
305
+ invoice_series?: components["schemas"]["InvoiceSeriesResource"];
306
+ payment_method?: components["schemas"]["PaymentMethodResource"];
307
+ creator?: components["schemas"]["UserResource"];
308
+ taxes: components["schemas"]["TaxResource"][];
309
+ fields?: components["schemas"]["CustomFieldValueResource"][];
310
+ company?: components["schemas"]["CompanyResource"];
311
+ currency?: components["schemas"]["CurrencyResource"];
312
+ }>>;
63
313
  };
64
314
  get customers(): {
65
- list: (query?: RequestOptions["query"]) => Promise<unknown>;
66
- get: (id: number | string) => Promise<unknown>;
67
- create: (body: unknown) => Promise<unknown>;
68
- update: (id: number | string, body: unknown) => Promise<unknown>;
315
+ list: (query?: RequestOptions["query"]) => Promise<{
316
+ data: components["schemas"]["CustomerResource"][];
317
+ meta: {
318
+ customer_total_count: number;
319
+ };
320
+ }>;
321
+ get: (id: number | string) => Promise<{
322
+ data: components["schemas"]["CustomerResource"];
323
+ }>;
324
+ create: (body: CustomerRequest, options?: WriteOptions) => Promise<{
325
+ data: components["schemas"]["CustomerResource"];
326
+ }>;
327
+ /** El `200` de `customers.update` no está tipado en el spec. */
328
+ update: (id: number | string, body: CustomerRequest, options?: WriteOptions) => Promise<ResourceEnvelope<{
329
+ id: string;
330
+ name: string;
331
+ email: string;
332
+ phone: string;
333
+ contact_name: string;
334
+ company_name: string;
335
+ website: string;
336
+ enable_portal: string;
337
+ password_added: boolean;
338
+ currency_id: string;
339
+ payment_method_id: string;
340
+ company_id: string;
341
+ facebook_id: string;
342
+ google_id: string;
343
+ github_id: string;
344
+ created_at: string;
345
+ formatted_created_at: string;
346
+ updated_at: string;
347
+ avatar: string;
348
+ due_amount: string;
349
+ base_due_amount: string;
350
+ prefix: string;
351
+ tax_id: string;
352
+ notes: string;
353
+ iban: string;
354
+ bic: string;
355
+ sepa_mandate_id: string;
356
+ sepa_mandate_date: string;
357
+ billing?: components["schemas"]["AddressResource"];
358
+ shipping?: components["schemas"]["AddressResource"];
359
+ fields?: components["schemas"]["CustomFieldValueResource"][];
360
+ company?: components["schemas"]["CompanyResource"];
361
+ currency?: components["schemas"]["CurrencyResource"];
362
+ payment_method?: components["schemas"]["PaymentMethodResource"];
363
+ }>>;
69
364
  };
70
365
  get estimates(): {
71
- list: (query?: RequestOptions["query"]) => Promise<unknown>;
72
- get: (id: number | string) => Promise<unknown>;
73
- create: (body: unknown) => Promise<unknown>;
366
+ list: (query?: RequestOptions["query"]) => Promise<{
367
+ data: components["schemas"]["EstimateResource"][];
368
+ meta: {
369
+ estimate_total_count: number;
370
+ };
371
+ }>;
372
+ get: (id: number | string) => Promise<{
373
+ data: components["schemas"]["EstimateResource"];
374
+ }>;
375
+ create: (body: EstimatesRequest, options?: WriteOptions) => Promise<{
376
+ data: components["schemas"]["EstimateResource"];
377
+ }>;
378
+ /**
379
+ * Convierte un presupuesto aceptado en factura.
380
+ *
381
+ * El helper existe porque es el cierre natural del bucle
382
+ * `estimate.accepted` → facturar, y sin él hay que ir por ruta cruda y
383
+ * adivinar la forma de la respuesta.
384
+ *
385
+ * Dos cosas que conviene saber y que el spec no dice:
386
+ *
387
+ * - **la factura nace BORRADOR y sin numerar**: `data.invoice_number` es
388
+ * `null` hasta que la publiques cambiando su estado. No es un fallo;
389
+ * - el id de la factura nueva está en `data.id`. El `r?.data?.id ?? r?.id`
390
+ * defensivo que se ve por ahí sobra: la segunda rama nunca ocurre.
391
+ *
392
+ * Manda `idempotencyKey` —una clave estable por presupuesto, del estilo
393
+ * `estimate:{id}:invoice`— y el reintento tras un timeout no te creará
394
+ * una segunda factura.
395
+ *
396
+ * Exige `estimates:write` **e** `invoices:write`.
397
+ */
398
+ convertToInvoice: (id: number | string, options?: WriteOptions) => Promise<ResourceEnvelope<{
399
+ id: string;
400
+ invoice_date: string;
401
+ due_date: string;
402
+ invoice_number: string;
403
+ reference_number: string;
404
+ status: string;
405
+ paid_status: string;
406
+ tax_per_item: string;
407
+ tax_included: string;
408
+ discount_per_item: string;
409
+ notes: string;
410
+ discount_type: string;
411
+ discount: string;
412
+ discount_val: string;
413
+ sub_total: string;
414
+ total: string;
415
+ effective_total: string;
416
+ tax: string;
417
+ due_amount: string;
418
+ effective_due_amount: string;
419
+ sent: string;
420
+ viewed: string;
421
+ unique_hash: string;
422
+ template_name: string;
423
+ invoice_series_id: string;
424
+ customer_id: string;
425
+ payment_method_id: string;
426
+ recurring_invoice_id: string;
427
+ sequence_number: string;
428
+ exchange_rate: string;
429
+ base_discount_val: string;
430
+ base_sub_total: string;
431
+ base_total: string;
432
+ creator_id: string;
433
+ base_tax: string;
434
+ base_due_amount: string;
435
+ effective_base_total: string;
436
+ effective_base_due_amount: string;
437
+ credited_total: string;
438
+ credited_base_total: string;
439
+ currency_id: string;
440
+ formatted_created_at: string;
441
+ invoice_pdf_url: string;
442
+ formatted_invoice_date: string;
443
+ formatted_due_date: string;
444
+ allow_edit: string;
445
+ payment_module_enabled: string;
446
+ sales_tax_type: string;
447
+ sales_tax_address_type: string;
448
+ overdue: string;
449
+ effective_paid_status: string;
450
+ effective_overdue: string;
451
+ aeat_status: string;
452
+ qr_data: string;
453
+ hash: string;
454
+ aeat_csv: string;
455
+ is_credit_note: string | boolean;
456
+ rectified_invoice_id: string;
457
+ rectified_invoice_number?: string | null;
458
+ rectified_invoice?: {
459
+ id: string;
460
+ invoice_number: string;
461
+ tax_per_item: string;
462
+ tax_included: string;
463
+ sub_total: string;
464
+ discount_val: string;
465
+ tax: string;
466
+ total: string;
467
+ items: components["schemas"]["InvoiceItemResource"][];
468
+ taxes: components["schemas"]["TaxResource"][];
469
+ };
470
+ credit_notes_count: number;
471
+ items?: components["schemas"]["InvoiceItemResource"][];
472
+ payments?: components["schemas"]["PaymentResource"][];
473
+ customer?: components["schemas"]["CustomerResource"];
474
+ invoice_series?: components["schemas"]["InvoiceSeriesResource"];
475
+ payment_method?: components["schemas"]["PaymentMethodResource"];
476
+ creator?: components["schemas"]["UserResource"];
477
+ taxes: components["schemas"]["TaxResource"][];
478
+ fields?: components["schemas"]["CustomFieldValueResource"][];
479
+ company?: components["schemas"]["CompanyResource"];
480
+ currency?: components["schemas"]["CurrencyResource"];
481
+ }>>;
74
482
  };
75
483
  get<T = unknown>(path: string, query?: RequestOptions['query']): Promise<T>;
76
- post<T = unknown>(path: string, body?: unknown): Promise<T>;
77
- put<T = unknown>(path: string, body?: unknown): Promise<T>;
78
- patch<T = unknown>(path: string, body?: unknown): Promise<T>;
484
+ post<T = unknown>(path: string, body?: unknown, options?: WriteOptions): Promise<T>;
485
+ put<T = unknown>(path: string, body?: unknown, options?: WriteOptions): Promise<T>;
486
+ patch<T = unknown>(path: string, body?: unknown, options?: WriteOptions): Promise<T>;
79
487
  delete<T = unknown>(path: string): Promise<T>;
80
488
  /**
81
489
  * Petición cruda contra `/api/v1`. `path` puede llevar el prefijo o no:
82
490
  * `/invoices` y `/api/v1/invoices` son lo mismo.
83
491
  */
84
492
  request<T = unknown>(path: string, options?: RequestOptions): Promise<T>;
493
+ /**
494
+ * Lo mismo que {@link request}, pero devuelve también los metadatos de la
495
+ * respuesta.
496
+ *
497
+ * Existe por la idempotencia: tras un reintento, el cuerpo es idéntico al de
498
+ * la primera llamada —ese es justo el contrato—, así que el cuerpo solo no
499
+ * dice si Pimia escribió o se limitó a repetirse. `meta.idempotentReplay` sí.
500
+ *
501
+ * ```ts
502
+ * const clave = crypto.randomUUID()
503
+ * const { data, meta } = await client.requestWithMeta('/estimates', {
504
+ * method: 'POST', body, idempotencyKey: clave,
505
+ * })
506
+ * if (meta.idempotentReplay) log('el presupuesto ya existía; no se duplicó')
507
+ * ```
508
+ */
509
+ requestWithMeta<T = unknown>(path: string, options?: RequestOptions): Promise<ResponseWithMeta<T>>;
85
510
  private currentTokens;
86
511
  /**
87
512
  * Refresca UNA sola vez aunque lo pidan N peticiones en paralelo, y persiste
@@ -94,3 +519,4 @@ export declare class PimiaClient {
94
519
  private captureRateLimit;
95
520
  private retryDelay;
96
521
  }
522
+ export {};
package/dist/client.js CHANGED
@@ -46,36 +46,63 @@ export class PimiaClient {
46
46
  return {
47
47
  list: (query) => this.get('/invoices', query),
48
48
  get: (id) => this.get(`/invoices/${id}`),
49
- create: (body) => this.post('/invoices', body),
50
- update: (id, body) => this.put(`/invoices/${id}`, body),
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
+ */
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. */
55
+ update: (id, body, options) => this.put(`/invoices/${id}`, body, options),
51
56
  };
52
57
  }
53
58
  get customers() {
54
59
  return {
55
60
  list: (query) => this.get('/customers', query),
56
61
  get: (id) => this.get(`/customers/${id}`),
57
- create: (body) => this.post('/customers', body),
58
- update: (id, body) => this.put(`/customers/${id}`, body),
62
+ create: (body, options) => this.post('/customers', body, options),
63
+ /** El `200` de `customers.update` no está tipado en el spec. */
64
+ update: (id, body, options) => this.put(`/customers/${id}`, body, options),
59
65
  };
60
66
  }
61
67
  get estimates() {
62
68
  return {
63
69
  list: (query) => this.get('/estimates', query),
64
70
  get: (id) => this.get(`/estimates/${id}`),
65
- create: (body) => this.post('/estimates', body),
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) {
69
96
  return this.request(path, { method: 'GET', query });
70
97
  }
71
- post(path, body) {
72
- return this.request(path, { method: 'POST', body });
98
+ post(path, body, options) {
99
+ return this.request(path, { ...options, method: 'POST', body });
73
100
  }
74
- put(path, body) {
75
- return this.request(path, { method: 'PUT', body });
101
+ put(path, body, options) {
102
+ return this.request(path, { ...options, method: 'PUT', body });
76
103
  }
77
- patch(path, body) {
78
- return this.request(path, { method: 'PATCH', body });
104
+ patch(path, body, options) {
105
+ return this.request(path, { ...options, method: 'PATCH', body });
79
106
  }
80
107
  delete(path) {
81
108
  return this.request(path, { method: 'DELETE' });
@@ -85,6 +112,26 @@ export class PimiaClient {
85
112
  * `/invoices` y `/api/v1/invoices` son lo mismo.
86
113
  */
87
114
  async request(path, options = {}) {
115
+ const { data } = await this.requestWithMeta(path, options);
116
+ return data;
117
+ }
118
+ /**
119
+ * Lo mismo que {@link request}, pero devuelve también los metadatos de la
120
+ * respuesta.
121
+ *
122
+ * Existe por la idempotencia: tras un reintento, el cuerpo es idéntico al de
123
+ * la primera llamada —ese es justo el contrato—, así que el cuerpo solo no
124
+ * dice si Pimia escribió o se limitó a repetirse. `meta.idempotentReplay` sí.
125
+ *
126
+ * ```ts
127
+ * const clave = crypto.randomUUID()
128
+ * const { data, meta } = await client.requestWithMeta('/estimates', {
129
+ * method: 'POST', body, idempotencyKey: clave,
130
+ * })
131
+ * if (meta.idempotentReplay) log('el presupuesto ya existía; no se duplicó')
132
+ * ```
133
+ */
134
+ async requestWithMeta(path, options = {}) {
88
135
  let tokens = await this.currentTokens();
89
136
  if (isExpired(tokens, this.skew)) {
90
137
  tokens = await this.refreshTokens(tokens);
@@ -99,6 +146,12 @@ export class PimiaClient {
99
146
  ...(options.body === undefined ? {} : { 'content-type': 'application/json' }),
100
147
  ...this.extraHeaders,
101
148
  ...options.headers,
149
+ // Después de `options.headers` para que la opción con nombre mande
150
+ // sobre una cabecera puesta a mano: si alguien usa las dos, la
151
+ // explícita del API es la que quiso de verdad.
152
+ ...(options.idempotencyKey === undefined
153
+ ? {}
154
+ : { 'idempotency-key': options.idempotencyKey }),
102
155
  authorization: `Bearer ${tokens.accessToken}`,
103
156
  },
104
157
  body: options.body === undefined ? undefined : JSON.stringify(options.body),
@@ -106,7 +159,17 @@ export class PimiaClient {
106
159
  });
107
160
  this.captureRateLimit(response);
108
161
  if (response.ok) {
109
- return (await parseBody(response));
162
+ return {
163
+ data: (await parseBody(response)),
164
+ meta: {
165
+ status: response.status,
166
+ // Presente solo cuando Pimia reproduce; su ausencia significa
167
+ // «esta escritura ocurrió de verdad».
168
+ idempotentReplay: response.headers.get('idempotency-replayed') === 'true',
169
+ requestId: response.headers.get('x-request-id') ?? undefined,
170
+ rateLimit: this.lastRateLimit,
171
+ },
172
+ };
110
173
  }
111
174
  const body = await parseBody(response);
112
175
  const requestId = response.headers.get('x-request-id') ?? undefined;
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 } 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
14
  export { 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, 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";
@@ -33,5 +35,12 @@ export declare const SCOPES: {
33
35
  readonly agendaRead: "agenda:read";
34
36
  readonly agendaWrite: "agenda:write";
35
37
  readonly reportsRead: "reports:read";
38
+ /**
39
+ * Proponer cambios que el dueño del tenant aprueba antes de aplicarse.
40
+ * `approvalsSubmit` es un alias del mismo permiso, aceptado por el
41
+ * Authorization Server.
42
+ */
43
+ readonly approvalsWrite: "approvals:write";
44
+ readonly approvalsSubmit: "approvals:submit";
36
45
  };
37
46
  export type Scope = (typeof SCOPES)[keyof typeof SCOPES];
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ export { PimiaClient } from './client.js';
9
9
  export { OAuth, createPkceChallenge, createState } from './oauth.js';
10
10
  export { MemoryTokenStore, isExpired, tokenSetFromResponse } from './tokens.js';
11
11
  export { 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',
@@ -30,4 +31,11 @@ export const SCOPES = {
30
31
  agendaRead: 'agenda:read',
31
32
  agendaWrite: 'agenda:write',
32
33
  reportsRead: 'reports:read',
34
+ /**
35
+ * Proponer cambios que el dueño del tenant aprueba antes de aplicarse.
36
+ * `approvalsSubmit` es un alias del mismo permiso, aceptado por el
37
+ * Authorization Server.
38
+ */
39
+ approvalsWrite: 'approvals:write',
40
+ approvalsSubmit: 'approvals:submit',
33
41
  };