@pimia/sdk 0.6.0 → 0.8.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.js CHANGED
@@ -1,5 +1,9 @@
1
1
  /**
2
2
  * This file was auto-generated by openapi-typescript.
3
3
  * Do not make direct changes to the file.
4
+ *
5
+ * Generado con `npm run generate:types` (scripts/generate-types.mjs), que
6
+ * añade UN transform al generador: `format: binary` sale como `Blob` y no
7
+ * como `string`. El porqué, en ese fichero.
4
8
  */
5
9
  export {};
package/dist/client.d.ts CHANGED
@@ -64,8 +64,43 @@ export interface RequestOptions {
64
64
  method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
65
65
  /** Query string. Los `undefined`/`null` se omiten; los arrays se repiten. */
66
66
  query?: Record<string, string | number | boolean | undefined | null | Array<string | number>>;
67
- /** Cuerpo JSON. */
67
+ /**
68
+ * El cuerpo de la petición.
69
+ *
70
+ * Por defecto se manda como **JSON**, que es lo que pide casi toda la API.
71
+ *
72
+ * Diez operaciones del contrato son `multipart/form-data` —el justificante
73
+ * de un gasto, el documento de una factura recibida, importar un extracto
74
+ * bancario, el membrete de una plantilla, el certificado de firma…—, y para
75
+ * ésas se pasa un {@link FormData}: el cliente lo manda **tal cual** y **no
76
+ * le pone `content-type`**, para que el runtime escriba el suyo con su
77
+ * `boundary`. {@link toFormData} lo arma con las conversiones que el
78
+ * servidor espera.
79
+ *
80
+ * También pasan sin tocar `Blob`, `URLSearchParams`, `ArrayBuffer` y las
81
+ * vistas de `ArrayBuffer`.
82
+ *
83
+ * ⛔ Un `ReadableStream` **no**, y es a propósito: este cliente reintenta
84
+ * ante un 401 (tras refrescar) y ante un 429, y un stream ya consumido no se
85
+ * puede volver a mandar — el reintento fallaría con un error que no se
86
+ * parece en nada a su causa. Los cinco de arriba se pueden releer.
87
+ */
68
88
  body?: unknown;
89
+ /**
90
+ * Cómo leer una respuesta **correcta**.
91
+ *
92
+ * `'json'` (el defecto) es lo de siempre. `'blob'` es para las dos
93
+ * operaciones que devuelven un fichero (`application/octet-stream`):
94
+ * descargar el membrete de una plantilla y el documento escaneado de una
95
+ * factura recibida.
96
+ *
97
+ * ⚠️ Sin esto, un PDF se lee con `response.text()` y **se corrompe en
98
+ * silencio**: el fichero «llega», pesa lo suyo y no se abre.
99
+ *
100
+ * Los errores se siguen leyendo como JSON aunque pidas `'blob'` — cuando la
101
+ * API falla contesta su sobre de error, no el fichero.
102
+ */
103
+ responseType?: 'json' | 'blob';
69
104
  headers?: Record<string, string>;
70
105
  signal?: AbortSignal;
71
106
  /**
@@ -164,12 +199,12 @@ export declare class PimiaClient {
164
199
  invoice_date: string;
165
200
  due_date: string | null;
166
201
  invoice_number: string | null;
167
- reference_number: string;
202
+ reference_number: string | null;
168
203
  status: string;
169
204
  paid_status: string;
170
- tax_per_item: string;
205
+ tax_per_item: string | null;
171
206
  tax_included: string;
172
- discount_per_item: string;
207
+ discount_per_item: string | null;
173
208
  notes: string | null;
174
209
  discount_type: string | null;
175
210
  discount: number;
@@ -178,7 +213,7 @@ export declare class PimiaClient {
178
213
  total: number;
179
214
  effective_total: string;
180
215
  tax: number;
181
- due_amount: string;
216
+ due_amount: number;
182
217
  effective_due_amount: string;
183
218
  sent: string;
184
219
  viewed: string;
@@ -186,19 +221,19 @@ export declare class PimiaClient {
186
221
  viewed_at: string;
187
222
  unique_hash: string | null;
188
223
  template_name: string | null;
189
- invoice_series_id: string;
224
+ invoice_series_id: number | null;
190
225
  customer_id: number | null;
191
226
  external_ref: string | null;
192
- payment_method_id: string;
193
- recurring_invoice_id: string;
227
+ payment_method_id: number | null;
228
+ recurring_invoice_id: number | null;
194
229
  sequence_number: number | null;
195
230
  exchange_rate: number | null;
196
- base_discount_val: string | null;
197
- base_sub_total: string | null;
198
- base_total: string | null;
231
+ base_discount_val: number;
232
+ base_sub_total: number;
233
+ base_total: number;
199
234
  creator_id: number | null;
200
- base_tax: string | null;
201
- base_due_amount: string | null;
235
+ base_tax: number;
236
+ base_due_amount: number;
202
237
  effective_base_total: string;
203
238
  effective_base_due_amount: string;
204
239
  credited_total: string;
@@ -223,7 +258,7 @@ export declare class PimiaClient {
223
258
  rectified_invoice?: {
224
259
  id: number;
225
260
  invoice_number: string | null;
226
- tax_per_item: string;
261
+ tax_per_item: string | null;
227
262
  tax_included: string;
228
263
  sub_total: number;
229
264
  discount_val: number;
@@ -237,8 +272,8 @@ export declare class PimiaClient {
237
272
  payments?: components["schemas"]["PaymentResource"][];
238
273
  email_logs?: components["schemas"]["EmailLogResource"][];
239
274
  customer?: components["schemas"]["CustomerResource"] | null;
240
- invoice_series?: components["schemas"]["InvoiceSeriesResource"];
241
- payment_method?: components["schemas"]["PaymentMethodResource"];
275
+ invoice_series?: components["schemas"]["InvoiceSeriesResource"] | null;
276
+ payment_method?: components["schemas"]["PaymentMethodResource"] | null;
242
277
  creator?: components["schemas"]["UserResource"] | null;
243
278
  taxes: components["schemas"]["TaxResource"][];
244
279
  fields?: components["schemas"]["CustomFieldValueResource"][];
@@ -251,12 +286,12 @@ export declare class PimiaClient {
251
286
  invoice_date: string;
252
287
  due_date: string | null;
253
288
  invoice_number: string | null;
254
- reference_number: string;
289
+ reference_number: string | null;
255
290
  status: string;
256
291
  paid_status: string;
257
- tax_per_item: string;
292
+ tax_per_item: string | null;
258
293
  tax_included: string;
259
- discount_per_item: string;
294
+ discount_per_item: string | null;
260
295
  notes: string | null;
261
296
  discount_type: string | null;
262
297
  discount: number;
@@ -265,7 +300,7 @@ export declare class PimiaClient {
265
300
  total: number;
266
301
  effective_total: string;
267
302
  tax: number;
268
- due_amount: string;
303
+ due_amount: number;
269
304
  effective_due_amount: string;
270
305
  sent: string;
271
306
  viewed: string;
@@ -273,19 +308,19 @@ export declare class PimiaClient {
273
308
  viewed_at: string;
274
309
  unique_hash: string | null;
275
310
  template_name: string | null;
276
- invoice_series_id: string;
311
+ invoice_series_id: number | null;
277
312
  customer_id: number | null;
278
313
  external_ref: string | null;
279
- payment_method_id: string;
280
- recurring_invoice_id: string;
314
+ payment_method_id: number | null;
315
+ recurring_invoice_id: number | null;
281
316
  sequence_number: number | null;
282
317
  exchange_rate: number | null;
283
- base_discount_val: string | null;
284
- base_sub_total: string | null;
285
- base_total: string | null;
318
+ base_discount_val: number;
319
+ base_sub_total: number;
320
+ base_total: number;
286
321
  creator_id: number | null;
287
- base_tax: string | null;
288
- base_due_amount: string | null;
322
+ base_tax: number;
323
+ base_due_amount: number;
289
324
  effective_base_total: string;
290
325
  effective_base_due_amount: string;
291
326
  credited_total: string;
@@ -310,7 +345,7 @@ export declare class PimiaClient {
310
345
  rectified_invoice?: {
311
346
  id: number;
312
347
  invoice_number: string | null;
313
- tax_per_item: string;
348
+ tax_per_item: string | null;
314
349
  tax_included: string;
315
350
  sub_total: number;
316
351
  discount_val: number;
@@ -324,8 +359,8 @@ export declare class PimiaClient {
324
359
  payments?: components["schemas"]["PaymentResource"][];
325
360
  email_logs?: components["schemas"]["EmailLogResource"][];
326
361
  customer?: components["schemas"]["CustomerResource"] | null;
327
- invoice_series?: components["schemas"]["InvoiceSeriesResource"];
328
- payment_method?: components["schemas"]["PaymentMethodResource"];
362
+ invoice_series?: components["schemas"]["InvoiceSeriesResource"] | null;
363
+ payment_method?: components["schemas"]["PaymentMethodResource"] | null;
329
364
  creator?: components["schemas"]["UserResource"] | null;
330
365
  taxes: components["schemas"]["TaxResource"][];
331
366
  fields?: components["schemas"]["CustomFieldValueResource"][];
@@ -362,17 +397,17 @@ export declare class PimiaClient {
362
397
  enable_portal: boolean;
363
398
  password_added: boolean;
364
399
  currency_id: number | null;
365
- payment_method_id: string;
400
+ payment_method_id: number | null;
366
401
  company_id: number | null;
367
402
  created_at: string | null;
368
403
  formatted_created_at: string;
369
404
  updated_at: string | null;
370
405
  avatar: string;
371
- due_amount: string;
372
- base_due_amount: string;
406
+ due_amount: number | null;
407
+ base_due_amount: number | null;
373
408
  prefix: string | null;
374
- tax_id: string;
375
- notes: string;
409
+ tax_id: string | null;
410
+ notes: string | null;
376
411
  external_ref: string | null;
377
412
  iban: string | null;
378
413
  bic: string | null;
@@ -383,7 +418,7 @@ export declare class PimiaClient {
383
418
  fields?: components["schemas"]["CustomFieldValueResource"][];
384
419
  company?: components["schemas"]["CompanyResource"] | null;
385
420
  currency?: components["schemas"]["CurrencyResource"] | null;
386
- payment_method?: components["schemas"]["PaymentMethodResource"];
421
+ payment_method?: components["schemas"]["PaymentMethodResource"] | null;
387
422
  }>>;
388
423
  };
389
424
  get estimates(): {
@@ -444,12 +479,12 @@ export declare class PimiaClient {
444
479
  invoice_date: string;
445
480
  due_date: string | null;
446
481
  invoice_number: string | null;
447
- reference_number: string;
482
+ reference_number: string | null;
448
483
  status: string;
449
484
  paid_status: string;
450
- tax_per_item: string;
485
+ tax_per_item: string | null;
451
486
  tax_included: string;
452
- discount_per_item: string;
487
+ discount_per_item: string | null;
453
488
  notes: string | null;
454
489
  discount_type: string | null;
455
490
  discount: number;
@@ -458,7 +493,7 @@ export declare class PimiaClient {
458
493
  total: number;
459
494
  effective_total: string;
460
495
  tax: number;
461
- due_amount: string;
496
+ due_amount: number;
462
497
  effective_due_amount: string;
463
498
  sent: string;
464
499
  viewed: string;
@@ -466,19 +501,19 @@ export declare class PimiaClient {
466
501
  viewed_at: string;
467
502
  unique_hash: string | null;
468
503
  template_name: string | null;
469
- invoice_series_id: string;
504
+ invoice_series_id: number | null;
470
505
  customer_id: number | null;
471
506
  external_ref: string | null;
472
- payment_method_id: string;
473
- recurring_invoice_id: string;
507
+ payment_method_id: number | null;
508
+ recurring_invoice_id: number | null;
474
509
  sequence_number: number | null;
475
510
  exchange_rate: number | null;
476
- base_discount_val: string | null;
477
- base_sub_total: string | null;
478
- base_total: string | null;
511
+ base_discount_val: number;
512
+ base_sub_total: number;
513
+ base_total: number;
479
514
  creator_id: number | null;
480
- base_tax: string | null;
481
- base_due_amount: string | null;
515
+ base_tax: number;
516
+ base_due_amount: number;
482
517
  effective_base_total: string;
483
518
  effective_base_due_amount: string;
484
519
  credited_total: string;
@@ -503,7 +538,7 @@ export declare class PimiaClient {
503
538
  rectified_invoice?: {
504
539
  id: number;
505
540
  invoice_number: string | null;
506
- tax_per_item: string;
541
+ tax_per_item: string | null;
507
542
  tax_included: string;
508
543
  sub_total: number;
509
544
  discount_val: number;
@@ -517,8 +552,8 @@ export declare class PimiaClient {
517
552
  payments?: components["schemas"]["PaymentResource"][];
518
553
  email_logs?: components["schemas"]["EmailLogResource"][];
519
554
  customer?: components["schemas"]["CustomerResource"] | null;
520
- invoice_series?: components["schemas"]["InvoiceSeriesResource"];
521
- payment_method?: components["schemas"]["PaymentMethodResource"];
555
+ invoice_series?: components["schemas"]["InvoiceSeriesResource"] | null;
556
+ payment_method?: components["schemas"]["PaymentMethodResource"] | null;
522
557
  creator?: components["schemas"]["UserResource"] | null;
523
558
  taxes: components["schemas"]["TaxResource"][];
524
559
  fields?: components["schemas"]["CustomFieldValueResource"][];
@@ -531,6 +566,25 @@ export declare class PimiaClient {
531
566
  put<T = unknown>(path: string, body?: unknown, options?: WriteOptions): Promise<T>;
532
567
  patch<T = unknown>(path: string, body?: unknown, options?: WriteOptions): Promise<T>;
533
568
  delete<T = unknown>(path: string, options?: ReadOptions): Promise<T>;
569
+ /**
570
+ * Descarga un fichero de la API y te lo da como `Blob`.
571
+ *
572
+ * Son dos operaciones: el membrete de una plantilla
573
+ * (`GET /invoice-templates/{id}/letterhead`) y el documento escaneado de una
574
+ * factura recibida (`GET /received-invoices/{id}/show/document`).
575
+ *
576
+ * Existe porque `get()` **corrompe un binario sin decirlo**: lee la
577
+ * respuesta con `response.text()`, y un PDF pasado por ahí llega entero de
578
+ * tamaño y no se abre. Ese es el peor final posible para una descarga, así
579
+ * que la forma correcta tiene nombre propio en vez de ser una bandera que
580
+ * hay que acordarse de poner.
581
+ *
582
+ * ```ts
583
+ * const pdf = await client.download(`/received-invoices/${id}/show/document`)
584
+ * const url = URL.createObjectURL(pdf)
585
+ * ```
586
+ */
587
+ download(path: string, query?: RequestOptions['query'], options?: ReadOptions): Promise<Blob>;
534
588
  /**
535
589
  * Petición cruda contra `/api/v1`. `path` puede llevar el prefijo o no:
536
590
  * `/invoices` y `/api/v1/invoices` son lo mismo.
@@ -565,4 +619,35 @@ export declare class PimiaClient {
565
619
  private captureRateLimit;
566
620
  private retryDelay;
567
621
  }
622
+ /**
623
+ * Arma el `FormData` de una operación multipart con las conversiones que el
624
+ * servidor de Pimia espera, que **no** son las que hace `FormData` sola.
625
+ *
626
+ * Tres reglas, y las tres salen del contrato, no de la costumbre:
627
+ *
628
+ * - **Los booleanos viajan como `1` y `0`.** Lo dice el propio spec en
629
+ * `ExpenseRequest.is_attachment_receipt_removed`: «en `multipart/form-data`
630
+ * viaja como `1` o `0`». Un `String(false)` daría `"false"`, que PHP lee
631
+ * como verdadero.
632
+ * - **Los objetos y arrays viajan como JSON en una cadena.** También del
633
+ * spec, en `ExpenseRequest.customFields`: «viaja como cadena JSON:
634
+ * `[{"id":3,"value":"REF-42"}]`».
635
+ * - **`null` y `undefined` se omiten**, en vez de mandar `"null"`. Un campo
636
+ * ausente es un campo ausente; la cadena `"null"` es un valor.
637
+ *
638
+ * Un `Blob` o un `File` se añaden tal cual. Con un `File` el runtime manda ya
639
+ * su nombre; con un `Blob` suelto se puede dar uno pasando `[blob, 'x.pdf']`,
640
+ * que es la forma que el tercer argumento de `append` admite.
641
+ *
642
+ * ```ts
643
+ * await client.post('/expenses', toFormData({
644
+ * expense_date: '2026-08-24',
645
+ * expense_category_id: 3,
646
+ * amount: 12100,
647
+ * attachment_receipt: ficheroPdf,
648
+ * customFields: [{ id: 3, value: 'REF-42' }],
649
+ * }))
650
+ * ```
651
+ */
652
+ export declare function toFormData(fields: Record<string, unknown | [Blob, string]>): FormData;
568
653
  export {};
package/dist/client.js CHANGED
@@ -127,6 +127,32 @@ export class PimiaClient {
127
127
  delete(path, options) {
128
128
  return this.request(path, { ...options, method: 'DELETE' });
129
129
  }
130
+ /**
131
+ * Descarga un fichero de la API y te lo da como `Blob`.
132
+ *
133
+ * Son dos operaciones: el membrete de una plantilla
134
+ * (`GET /invoice-templates/{id}/letterhead`) y el documento escaneado de una
135
+ * factura recibida (`GET /received-invoices/{id}/show/document`).
136
+ *
137
+ * Existe porque `get()` **corrompe un binario sin decirlo**: lee la
138
+ * respuesta con `response.text()`, y un PDF pasado por ahí llega entero de
139
+ * tamaño y no se abre. Ese es el peor final posible para una descarga, así
140
+ * que la forma correcta tiene nombre propio en vez de ser una bandera que
141
+ * hay que acordarse de poner.
142
+ *
143
+ * ```ts
144
+ * const pdf = await client.download(`/received-invoices/${id}/show/document`)
145
+ * const url = URL.createObjectURL(pdf)
146
+ * ```
147
+ */
148
+ download(path, query, options) {
149
+ return this.request(path, {
150
+ ...options,
151
+ method: 'GET',
152
+ query,
153
+ responseType: 'blob',
154
+ });
155
+ }
130
156
  /**
131
157
  * Petición cruda contra `/api/v1`. `path` puede llevar el prefijo o no:
132
158
  * `/invoices` y `/api/v1/invoices` son lo mismo.
@@ -152,6 +178,13 @@ export class PimiaClient {
152
178
  * ```
153
179
  */
154
180
  async requestWithMeta(path, options = {}) {
181
+ /* El cuerpo se clasifica UNA vez, fuera del bucle: lo que se manda no
182
+ cambia entre el intento y su reintento, y decidirlo dentro invitaría a
183
+ que algún día dejaran de coincidir. */
184
+ const cuerpoNativo = esCuerpoNativo(options.body);
185
+ if (cuerpoNativo) {
186
+ exigirSinContentType(options.body, { ...this.extraHeaders, ...options.headers });
187
+ }
155
188
  let tokens = await this.currentTokens();
156
189
  if (isExpired(tokens, this.skew)) {
157
190
  tokens = await this.refreshTokens(tokens);
@@ -162,8 +195,17 @@ export class PimiaClient {
162
195
  const response = await this.doFetch(this.urlFor(path, options.query), {
163
196
  method: options.method ?? 'GET',
164
197
  headers: {
165
- accept: 'application/json',
166
- ...(options.body === undefined ? {} : { 'content-type': 'application/json' }),
198
+ /* Una descarga no pide JSON: si se dejara `application/json` fijo, un
199
+ servidor que negocie el tipo tendría derecho a contestar 406 —o a
200
+ mandar un JSON de error donde se esperaba el fichero. */
201
+ accept: options.responseType === 'blob' ? '*/*' : 'application/json',
202
+ /* Un cuerpo nativo trae su propio tipo: el runtime le pone
203
+ `multipart/form-data` CON su `boundary`, o el de un `Blob`, o
204
+ `application/x-www-form-urlencoded`. Escribirlo aquí a mano se lo
205
+ quitaría, y sin `boundary` el servidor no puede parsear nada. */
206
+ ...(options.body === undefined || cuerpoNativo
207
+ ? {}
208
+ : { 'content-type': 'application/json' }),
167
209
  ...this.extraHeaders,
168
210
  ...options.headers,
169
211
  // Después de `options.headers` para que la opción con nombre mande
@@ -174,13 +216,23 @@ export class PimiaClient {
174
216
  : { 'idempotency-key': options.idempotencyKey }),
175
217
  authorization: `Bearer ${tokens.accessToken}`,
176
218
  },
177
- body: options.body === undefined ? undefined : JSON.stringify(options.body),
219
+ body: options.body === undefined
220
+ ? undefined
221
+ : cuerpoNativo
222
+ ? options.body
223
+ : JSON.stringify(options.body),
178
224
  signal: options.signal,
179
225
  });
180
226
  this.captureRateLimit(response);
181
227
  if (response.ok) {
182
228
  return {
183
- data: (await parseBody(response)),
229
+ /* Una descarga se devuelve como `Blob` SIN pasar por `parseBody`,
230
+ que hace `response.text()`: un PDF leído como texto se corrompe en
231
+ la primera secuencia que no sea UTF-8 válido, y lo hace en
232
+ silencio — el fichero «llega» y no se abre. */
233
+ data: (options.responseType === 'blob'
234
+ ? await response.blob()
235
+ : await parseBody(response)),
184
236
  meta: {
185
237
  status: response.status,
186
238
  // Presente solo cuando Pimia reproduce; su ausencia significa
@@ -287,6 +339,110 @@ export class PimiaClient {
287
339
  return Math.min(base, this.maxRetryDelayMs);
288
340
  }
289
341
  }
342
+ /**
343
+ * ¿Es un cuerpo que el runtime serializa por su cuenta?
344
+ *
345
+ * Los cinco de la lista tienen dos cosas en común, y las dos importan: `fetch`
346
+ * sabe convertirlos y **se pueden releer**. Lo segundo es lo que decide quién
347
+ * entra: este cliente reintenta ante un 401 (después de refrescar) y ante un
348
+ * 429, así que un cuerpo de un solo uso —un `ReadableStream`— reventaría en el
349
+ * reintento con un «body already used» que no se parece en nada a su causa.
350
+ *
351
+ * Los `typeof … !== 'undefined'` no son celo: este paquete corre en Node y en
352
+ * el navegador, y aunque Node 20 los trae todos, un runtime recortado que no
353
+ * tenga `FormData` debe fallar en el `instanceof`, no al evaluarlo.
354
+ */
355
+ function esCuerpoNativo(body) {
356
+ if (body === undefined || body === null)
357
+ return false;
358
+ return ((typeof FormData !== 'undefined' && body instanceof FormData) ||
359
+ (typeof Blob !== 'undefined' && body instanceof Blob) ||
360
+ (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) ||
361
+ body instanceof ArrayBuffer ||
362
+ ArrayBuffer.isView(body));
363
+ }
364
+ /**
365
+ * Un `FormData` con un `content-type` puesto a mano **no se manda**: se avisa.
366
+ *
367
+ * La cabecera de un multipart lleva el `boundary` que separa las partes, y lo
368
+ * genera el runtime al serializar. Escribir `content-type:
369
+ * multipart/form-data` a mano se lo quita, y entonces el servidor recibe un
370
+ * cuerpo que no puede parsear: contesta un 422 sobre un campo obligatorio que
371
+ * el cliente **sí mandó**, y el rastro no lleva a ninguna parte.
372
+ *
373
+ * Es un error de quien llama, no de la API, así que se lanza aquí y no se
374
+ * intenta arreglar por su cuenta: quitarle la cabecera en silencio dejaría en
375
+ * pie la creencia de que hacía falta.
376
+ */
377
+ function exigirSinContentType(body, headers) {
378
+ if (typeof FormData === 'undefined' || !(body instanceof FormData))
379
+ return;
380
+ const puesta = Object.keys(headers).find((k) => k.toLowerCase() === 'content-type');
381
+ if (puesta === undefined)
382
+ return;
383
+ throw new TypeError('No le pongas `content-type` a un cuerpo FormData: el runtime escribe el suyo ' +
384
+ 'con el `boundary` que separa las partes, y una cabecera a mano se lo quita ' +
385
+ `(el servidor respondería 422 sobre un campo que sí mandaste). Quita \`${puesta}\` ` +
386
+ 'de las cabeceras de esta petición.');
387
+ }
388
+ /**
389
+ * Arma el `FormData` de una operación multipart con las conversiones que el
390
+ * servidor de Pimia espera, que **no** son las que hace `FormData` sola.
391
+ *
392
+ * Tres reglas, y las tres salen del contrato, no de la costumbre:
393
+ *
394
+ * - **Los booleanos viajan como `1` y `0`.** Lo dice el propio spec en
395
+ * `ExpenseRequest.is_attachment_receipt_removed`: «en `multipart/form-data`
396
+ * viaja como `1` o `0`». Un `String(false)` daría `"false"`, que PHP lee
397
+ * como verdadero.
398
+ * - **Los objetos y arrays viajan como JSON en una cadena.** También del
399
+ * spec, en `ExpenseRequest.customFields`: «viaja como cadena JSON:
400
+ * `[{"id":3,"value":"REF-42"}]`».
401
+ * - **`null` y `undefined` se omiten**, en vez de mandar `"null"`. Un campo
402
+ * ausente es un campo ausente; la cadena `"null"` es un valor.
403
+ *
404
+ * Un `Blob` o un `File` se añaden tal cual. Con un `File` el runtime manda ya
405
+ * su nombre; con un `Blob` suelto se puede dar uno pasando `[blob, 'x.pdf']`,
406
+ * que es la forma que el tercer argumento de `append` admite.
407
+ *
408
+ * ```ts
409
+ * await client.post('/expenses', toFormData({
410
+ * expense_date: '2026-08-24',
411
+ * expense_category_id: 3,
412
+ * amount: 12100,
413
+ * attachment_receipt: ficheroPdf,
414
+ * customFields: [{ id: 3, value: 'REF-42' }],
415
+ * }))
416
+ * ```
417
+ */
418
+ export function toFormData(fields) {
419
+ const form = new FormData();
420
+ for (const [name, value] of Object.entries(fields)) {
421
+ if (value === undefined || value === null)
422
+ continue;
423
+ if (Array.isArray(value) && value.length === 2 && esBlob(value[0]) && typeof value[1] === 'string') {
424
+ form.append(name, value[0], value[1]);
425
+ continue;
426
+ }
427
+ if (esBlob(value)) {
428
+ form.append(name, value);
429
+ continue;
430
+ }
431
+ if (typeof value === 'boolean') {
432
+ form.append(name, value ? '1' : '0');
433
+ continue;
434
+ }
435
+ if (typeof value === 'object') {
436
+ form.append(name, JSON.stringify(value));
437
+ continue;
438
+ }
439
+ form.append(name, String(value));
440
+ }
441
+ return form;
442
+ }
443
+ function esBlob(value) {
444
+ return typeof Blob !== 'undefined' && value instanceof Blob;
445
+ }
290
446
  function retryAfterSeconds(response) {
291
447
  const header = response.headers.get('retry-after');
292
448
  if (header === null)
package/dist/index.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * por README.md; el contrato completo de endpoints está en el OpenAPI del que
6
6
  * salen los tipos de `./api`.
7
7
  */
8
- export { PimiaClient } from './client.js';
8
+ export { PimiaClient, toFormData } from './client.js';
9
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';
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * por README.md; el contrato completo de endpoints está en el OpenAPI del que
6
6
  * salen los tipos de `./api`.
7
7
  */
8
- export { PimiaClient } from './client.js';
8
+ export { PimiaClient, toFormData } from './client.js';
9
9
  export { OAuth, createPkceChallenge, createState } from './oauth.js';
10
10
  export { MemoryTokenStore, isExpired, tokenSetFromResponse } from './tokens.js';
11
11
  export { DuplicateExternalRefError, ForbiddenError, MissingScopeError, NotAuthenticatedError, NotFoundError, OAuthError, PimiaApiError, PimiaError, RateLimitError, UnauthorizedError, ValidationError, } from './errors.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pimia/sdk",
3
- "version": "0.6.0",
3
+ "version": "0.8.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)",
@@ -40,7 +40,7 @@
40
40
  "scripts": {
41
41
  "build": "tsc -p tsconfig.json",
42
42
  "typecheck": "tsc -p tsconfig.json --noEmit",
43
- "generate:types": "openapi-typescript ../spec/pimia-api-v1.json -o src/api.ts",
43
+ "generate:types": "node scripts/generate-types.mjs",
44
44
  "test": "node --test test/*.test.js",
45
45
  "prepublishOnly": "npm run build"
46
46
  },