@pimia/sdk 0.5.0 → 0.7.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.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';
@@ -35,6 +35,16 @@ export declare const SCOPES: {
35
35
  readonly agendaRead: "agenda:read";
36
36
  readonly agendaWrite: "agenda:write";
37
37
  readonly reportsRead: "reports:read";
38
+ /** Leer la configuración de la empresa (impuestos, preferencias, series). */
39
+ readonly settingsRead: "settings:read";
40
+ /** Ver la tienda de módulos y qué tiene contratado el tenant. */
41
+ readonly storeRead: "store:read";
42
+ /** Leer la gestión de personal: empleados, ausencias, fichajes, calendarios. */
43
+ readonly hrRead: "hr:read";
44
+ /** Gestionar el personal: altas, ausencias, correcciones de fichaje, horarios. */
45
+ readonly hrWrite: "hr:write";
46
+ /** Gestionar los avisos (webhooks) que recibe tu app. */
47
+ readonly webhooksWrite: "webhooks:write";
38
48
  /**
39
49
  * Proponer cambios que el dueño del tenant aprueba antes de aplicarse.
40
50
  * `approvalsSubmit` es un alias del mismo permiso, aceptado por el
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';
@@ -31,6 +31,16 @@ export const SCOPES = {
31
31
  agendaRead: 'agenda:read',
32
32
  agendaWrite: 'agenda:write',
33
33
  reportsRead: 'reports:read',
34
+ /** Leer la configuración de la empresa (impuestos, preferencias, series). */
35
+ settingsRead: 'settings:read',
36
+ /** Ver la tienda de módulos y qué tiene contratado el tenant. */
37
+ storeRead: 'store:read',
38
+ /** Leer la gestión de personal: empleados, ausencias, fichajes, calendarios. */
39
+ hrRead: 'hr:read',
40
+ /** Gestionar el personal: altas, ausencias, correcciones de fichaje, horarios. */
41
+ hrWrite: 'hr:write',
42
+ /** Gestionar los avisos (webhooks) que recibe tu app. */
43
+ webhooksWrite: 'webhooks:write',
34
44
  /**
35
45
  * Proponer cambios que el dueño del tenant aprueba antes de aplicarse.
36
46
  * `approvalsSubmit` es un alias del mismo permiso, aceptado por el
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pimia/sdk",
3
- "version": "0.5.0",
3
+ "version": "0.7.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
  },