alegra-api-client 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gary Dormoi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,185 @@
1
+ # alegra-api-client
2
+
3
+ [![CI](https://github.com/garydormoi/alegra-api-client/actions/workflows/ci.yml/badge.svg)](https://github.com/garydormoi/alegra-api-client/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
4
+
5
+ Cliente ligero y **tipado** (TypeScript) para la [API de Alegra](https://developer.alegra.com/).
6
+ Cubre lo esencial de la capa de integración: **autenticación**, **paginación** y
7
+ **manejo de rate limits** con reintentos, sobre `contactos`, `ítems` y `facturas`.
8
+
9
+ > Proyecto **no oficial** y sin afiliación con Alegra. Alegra® es marca de sus
10
+ > respectivos dueños. Documentación en español porque hay poca disponible.
11
+
12
+ ## Características
13
+
14
+ - Autenticación HTTP Basic (`correo:token`), portable (Node, Deno, edge).
15
+ - Paginación por `start`/`limit` (máx. 30 por página) con generador asíncrono
16
+ para no cargar todo en memoria.
17
+ - Manejo de **rate limit (HTTP 429)** respetando `Retry-After`, con reintentos
18
+ y retroceso exponencial con jitter.
19
+ - Reintentos también ante errores de red y `5xx`; los `4xx` no se reintentan.
20
+ - Throttle preventivo opcional entre solicitudes.
21
+ - Timeout por solicitud con `AbortController`.
22
+ - Cero dependencias en tiempo de ejecución (usa `fetch` nativo de Node ≥ 18).
23
+
24
+ ## Instalación
25
+
26
+ ```bash
27
+ npm install alegra-api-client
28
+ ```
29
+
30
+ Requiere Node.js 18 o superior (por `fetch` nativo).
31
+
32
+ ## Autenticación
33
+
34
+ La API de Alegra usa autenticación **HTTP Basic** con tu **correo** y un **token**
35
+ de API (no tu contraseña). El token se genera en Alegra, en la sección de
36
+ integraciones / API de tu cuenta.
37
+
38
+ Guarda las credenciales en variables de entorno, nunca en el código:
39
+
40
+ ```bash
41
+ cp .env.example .env
42
+ # edita .env con tus valores reales
43
+ ```
44
+
45
+ ## Uso rápido
46
+
47
+ ```ts
48
+ import { Alegra } from "alegra-api-client";
49
+
50
+ const alegra = new Alegra({
51
+ email: process.env.ALEGRA_EMAIL!,
52
+ token: process.env.ALEGRA_TOKEN!,
53
+ });
54
+
55
+ // Una página (máx. 30)
56
+ const clientes = await alegra.contactos.listar({ type: "client", limit: 30 });
57
+
58
+ // Descargar todo (paginando internamente)
59
+ const todos = await alegra.contactos.listarTodos({ type: "client" });
60
+
61
+ // Un recurso por id
62
+ const factura = await alegra.facturas.obtener("123");
63
+ ```
64
+
65
+ ## Paginación
66
+
67
+ La API devuelve como máximo 30 registros por página. Usa el generador para
68
+ procesar página por página sin agotar memoria:
69
+
70
+ ```ts
71
+ for await (const pagina of alegra.facturas.paginar({
72
+ date_afterOrNow: "2026-01-01",
73
+ date_beforeOrNow: "2026-01-31",
74
+ })) {
75
+ for (const factura of pagina) {
76
+ // procesar
77
+ }
78
+ }
79
+ ```
80
+
81
+ O junta todo de una vez con `listarTodas` / `listarTodos` / `collectAll`.
82
+
83
+ ## Manejo de rate limits y reintentos
84
+
85
+ La API de Alegra permite **150 solicitudes por minuto por usuario** (≈ 2.5/seg).
86
+ Al excederlo responde **HTTP 429** e informa el estado en cabeceras
87
+ `X-Rate-Limit-Limit`, `X-Rate-Limit-Remaining` y `X-Rate-Limit-Reset` (segundos
88
+ que faltan para reiniciar la ventana). Alegra **no** envía `Retry-After`.
89
+
90
+ El cliente reintenta automáticamente ante `429` y `5xx`. Ante `429` espera lo que
91
+ indique `X-Rate-Limit-Reset` (o `Retry-After` si estuviera presente) y, si no hay
92
+ ninguno, aplica retroceso exponencial con jitter.
93
+
94
+ ```ts
95
+ const alegra = new Alegra({
96
+ email,
97
+ token,
98
+ minRequestIntervalMs: 400, // ≈150/min: espaciado preventivo para no llegar al límite
99
+ maxRetries: 3, // reintentos ante 429 / 5xx
100
+ retryBaseMs: 500, // backoff base
101
+ timeoutMs: 30000, // timeout por solicitud
102
+ });
103
+ ```
104
+
105
+ Si se agotan los reintentos ante `429`, se lanza `AlegraRateLimitError` (con
106
+ `retryAfterSeconds` cuando la respuesta lo permite calcular).
107
+
108
+ ## Manejo de errores
109
+
110
+ ```ts
111
+ import { AlegraApiError, AlegraRateLimitError } from "alegra-api-client";
112
+
113
+ try {
114
+ await alegra.facturas.obtener("no-existe");
115
+ } catch (err) {
116
+ if (err instanceof AlegraRateLimitError) {
117
+ // esperar y reintentar más tarde
118
+ } else if (err instanceof AlegraApiError) {
119
+ console.error(err.status, err.endpoint, err.body);
120
+ }
121
+ }
122
+ ```
123
+
124
+ ## Recursos disponibles
125
+
126
+ | Recurso | Métodos |
127
+ | ----------- | --------------------------------------------------- |
128
+ | `contactos` | `listar`, `obtener`, `paginar`, `listarTodos` |
129
+ | `items` | `listar`, `obtener`, `paginar`, `listarTodos` |
130
+ | `facturas` | `listar`, `obtener`, `paginar`, `listarTodas` |
131
+
132
+ Para endpoints no cubiertos por los recursos, usa el cliente directo:
133
+
134
+ ```ts
135
+ const data = await alegra.client.request<MiTipo>("otro-endpoint", {
136
+ query: { limit: 30 },
137
+ });
138
+ ```
139
+
140
+ ## Referencia rápida de la API de Alegra
141
+
142
+ Resumen de los hechos de la API en que se basa este cliente (fuente:
143
+ documentación oficial de Alegra para desarrolladores).
144
+
145
+ | Tema | Detalle |
146
+ | --- | --- |
147
+ | URL base | `https://api.alegra.com/api/v1` |
148
+ | Autenticación | HTTP **Basic**: `Authorization: Basic base64(correo:token)` |
149
+ | Token | Alegra → Configuración → *API - Integraciones con otros sistemas* |
150
+ | Error de autenticación | HTTP **401** |
151
+ | Límite de uso | **150 solicitudes/minuto por usuario** |
152
+ | Límite excedido | HTTP **429** + cabeceras `X-Rate-Limit-Limit` / `X-Rate-Limit-Remaining` / `X-Rate-Limit-Reset` |
153
+ | Paginación | Parámetros `start` (offset) y `limit`; **máximo 30** por página |
154
+
155
+ Referencias:
156
+ [Autenticación](https://developer.alegra.com/reference/autenticaci%C3%B3n) ·
157
+ [Límite de request](https://developer.alegra.com/reference/l%C3%ADmite-de-request) ·
158
+ [Documentación general](https://developer.alegra.com/docs)
159
+
160
+ > Este resumen existe porque la documentación en español de la API de Alegra es
161
+ > escasa. Si algo cambia en la API oficial, esta tabla debe actualizarse.
162
+
163
+ ## Desarrollo
164
+
165
+ ```bash
166
+ npm install
167
+ npm run build # compila a dist/
168
+ npm test # pruebas con vitest (fetch simulado, datos ficticios)
169
+ npm run lint:types # verificación de tipos sin emitir
170
+ ```
171
+
172
+ Los ejemplos en `examples/` usan datos **ficticios** y credenciales por variables
173
+ de entorno.
174
+
175
+ ## Contribuir
176
+
177
+ Los issues y PRs son bienvenidos. Al reportar un problema, incluye el endpoint,
178
+ los parámetros usados (sin credenciales) y el comportamiento esperado.
179
+
180
+ ## Licencia
181
+
182
+ [MIT](./LICENSE)
183
+
184
+ ## Changelog
185
+ - 0.1.0 - primera version publica
@@ -0,0 +1,86 @@
1
+ /** Opciones de construcción del cliente. */
2
+ export interface AlegraClientOptions {
3
+ /** Correo de la cuenta de Alegra (usuario en la autenticación Basic). */
4
+ email: string;
5
+ /** Token de la API de Alegra. */
6
+ token: string;
7
+ /** URL base de la API. Por defecto: https://api.alegra.com/api/v1 */
8
+ baseUrl?: string;
9
+ /**
10
+ * Milisegundos mínimos entre solicitudes (throttle preventivo del lado cliente).
11
+ * Útil para no acercarse al límite de la API. Por defecto: 0 (sin throttle).
12
+ */
13
+ minRequestIntervalMs?: number;
14
+ /** Número máximo de reintentos ante 429 y errores 5xx. Por defecto: 3. */
15
+ maxRetries?: number;
16
+ /** Backoff base (ms) para el retroceso exponencial. Por defecto: 500. */
17
+ retryBaseMs?: number;
18
+ /** Tiempo máximo por solicitud (ms) antes de abortar. Por defecto: 30000. */
19
+ timeoutMs?: number;
20
+ /** Implementación de fetch a usar (inyectable para pruebas). Por defecto: globalThis.fetch. */
21
+ fetchImpl?: typeof fetch;
22
+ /** Cadena User-Agent enviada en cada solicitud. */
23
+ userAgent?: string;
24
+ }
25
+ /** Opciones por solicitud individual. */
26
+ export interface RequestOptions {
27
+ method?: string;
28
+ /** Parámetros de query. Los `undefined` se omiten. */
29
+ query?: Record<string, string | number | boolean | undefined>;
30
+ /** Cuerpo a serializar como JSON (para POST/PUT). */
31
+ body?: unknown;
32
+ /** Señal externa de cancelación. */
33
+ signal?: AbortSignal;
34
+ }
35
+ /**
36
+ * Cliente HTTP para la API de Alegra.
37
+ *
38
+ * Responsabilidades:
39
+ * - Autenticación HTTP Basic (`email:token`).
40
+ * - Serialización de query y cuerpo JSON.
41
+ * - Manejo de rate limit (HTTP 429) respetando `Retry-After`.
42
+ * - Reintentos con retroceso exponencial ante 429 y 5xx.
43
+ * - Throttle preventivo opcional entre solicitudes.
44
+ * - Timeout por solicitud.
45
+ *
46
+ * @example
47
+ * const client = new AlegraClient({ email: "demo@ejemplo.com", token: "TU_TOKEN" });
48
+ * const contactos = await client.request<Contacto[]>("contacts", { query: { limit: 30 } });
49
+ */
50
+ export declare class AlegraClient {
51
+ private readonly baseUrl;
52
+ private readonly authHeader;
53
+ private readonly minRequestIntervalMs;
54
+ private readonly maxRetries;
55
+ private readonly retryBaseMs;
56
+ private readonly timeoutMs;
57
+ private readonly fetchImpl;
58
+ private readonly userAgent;
59
+ private lastRequestAt;
60
+ constructor(options: AlegraClientOptions);
61
+ /** Construye la URL completa a partir del endpoint relativo y la query. */
62
+ private buildUrl;
63
+ /** Lee `Retry-After` (segundos o fecha HTTP) y lo convierte a milisegundos. */
64
+ private parseRetryAfter;
65
+ /**
66
+ * Lee `X-Rate-Limit-Reset` (segundos que faltan para reiniciar la ventana) y lo
67
+ * convierte a milisegundos. Alegra devuelve este header en cada respuesta y NO
68
+ * envía `Retry-After`, por lo que es la señal de espera fiable ante un 429.
69
+ * Ref.: https://developer.alegra.com/reference/límite-de-request
70
+ */
71
+ private parseResetSeconds;
72
+ /**
73
+ * Realiza una solicitud a la API y devuelve el JSON tipado.
74
+ *
75
+ * @typeParam T - Forma esperada de la respuesta.
76
+ * @throws {AlegraRateLimitError} si se agotan los reintentos ante 429.
77
+ * @throws {AlegraApiError} ante cualquier otra respuesta no 2xx.
78
+ */
79
+ request<T>(endpoint: string, options?: RequestOptions): Promise<T>;
80
+ /** Espera lo necesario para respetar `minRequestIntervalMs`. */
81
+ private throttle;
82
+ /** Backoff exponencial con jitter. */
83
+ private backoffMs;
84
+ /** Intenta parsear JSON; si no es JSON, devuelve el texto. */
85
+ private parseBody;
86
+ }
package/dist/client.js ADDED
@@ -0,0 +1,195 @@
1
+ import { AlegraApiError, AlegraRateLimitError } from "./errors.js";
2
+ const DEFAULT_BASE_URL = "https://api.alegra.com/api/v1";
3
+ /** Codifica `usuario:token` en base64, de forma portable (Node, Deno, edge). */
4
+ function toBase64(input) {
5
+ if (typeof btoa === "function")
6
+ return btoa(input);
7
+ // eslint-disable-next-line no-undef
8
+ return Buffer.from(input, "utf-8").toString("base64");
9
+ }
10
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
11
+ /**
12
+ * Cliente HTTP para la API de Alegra.
13
+ *
14
+ * Responsabilidades:
15
+ * - Autenticación HTTP Basic (`email:token`).
16
+ * - Serialización de query y cuerpo JSON.
17
+ * - Manejo de rate limit (HTTP 429) respetando `Retry-After`.
18
+ * - Reintentos con retroceso exponencial ante 429 y 5xx.
19
+ * - Throttle preventivo opcional entre solicitudes.
20
+ * - Timeout por solicitud.
21
+ *
22
+ * @example
23
+ * const client = new AlegraClient({ email: "demo@ejemplo.com", token: "TU_TOKEN" });
24
+ * const contactos = await client.request<Contacto[]>("contacts", { query: { limit: 30 } });
25
+ */
26
+ export class AlegraClient {
27
+ baseUrl;
28
+ authHeader;
29
+ minRequestIntervalMs;
30
+ maxRetries;
31
+ retryBaseMs;
32
+ timeoutMs;
33
+ fetchImpl;
34
+ userAgent;
35
+ lastRequestAt = 0;
36
+ constructor(options) {
37
+ if (!options.email || !options.token) {
38
+ throw new Error("AlegraClient requiere `email` y `token`.");
39
+ }
40
+ this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
41
+ this.authHeader = `Basic ${toBase64(`${options.email}:${options.token}`)}`;
42
+ this.minRequestIntervalMs = options.minRequestIntervalMs ?? 0;
43
+ this.maxRetries = options.maxRetries ?? 3;
44
+ this.retryBaseMs = options.retryBaseMs ?? 500;
45
+ this.timeoutMs = options.timeoutMs ?? 30_000;
46
+ const injected = options.fetchImpl ?? globalThis.fetch;
47
+ if (typeof injected !== "function") {
48
+ throw new Error("No se encontró `fetch`. Usa Node >=18, o pasa `fetchImpl` en las opciones.");
49
+ }
50
+ this.fetchImpl = injected;
51
+ this.userAgent = options.userAgent ?? "alegra-api-client";
52
+ }
53
+ /** Construye la URL completa a partir del endpoint relativo y la query. */
54
+ buildUrl(endpoint, query) {
55
+ const path = endpoint.replace(/^\/+/, "");
56
+ const url = new URL(`${this.baseUrl}/${path}`);
57
+ if (query) {
58
+ for (const [key, value] of Object.entries(query)) {
59
+ if (value !== undefined)
60
+ url.searchParams.set(key, String(value));
61
+ }
62
+ }
63
+ return url.toString();
64
+ }
65
+ /** Lee `Retry-After` (segundos o fecha HTTP) y lo convierte a milisegundos. */
66
+ parseRetryAfter(header) {
67
+ if (!header)
68
+ return undefined;
69
+ const asSeconds = Number(header);
70
+ if (!Number.isNaN(asSeconds))
71
+ return Math.max(0, asSeconds * 1000);
72
+ const asDate = Date.parse(header);
73
+ if (!Number.isNaN(asDate))
74
+ return Math.max(0, asDate - Date.now());
75
+ return undefined;
76
+ }
77
+ /**
78
+ * Lee `X-Rate-Limit-Reset` (segundos que faltan para reiniciar la ventana) y lo
79
+ * convierte a milisegundos. Alegra devuelve este header en cada respuesta y NO
80
+ * envía `Retry-After`, por lo que es la señal de espera fiable ante un 429.
81
+ * Ref.: https://developer.alegra.com/reference/límite-de-request
82
+ */
83
+ parseResetSeconds(header) {
84
+ if (!header)
85
+ return undefined;
86
+ const asSeconds = Number(header);
87
+ if (Number.isNaN(asSeconds))
88
+ return undefined;
89
+ return Math.max(0, asSeconds * 1000);
90
+ }
91
+ /**
92
+ * Realiza una solicitud a la API y devuelve el JSON tipado.
93
+ *
94
+ * @typeParam T - Forma esperada de la respuesta.
95
+ * @throws {AlegraRateLimitError} si se agotan los reintentos ante 429.
96
+ * @throws {AlegraApiError} ante cualquier otra respuesta no 2xx.
97
+ */
98
+ async request(endpoint, options = {}) {
99
+ const url = this.buildUrl(endpoint, options.query);
100
+ const method = options.method ?? "GET";
101
+ let attempt = 0;
102
+ // Reintentos: 429 y 5xx. 4xx (salvo 429) no se reintentan.
103
+ for (;;) {
104
+ await this.throttle();
105
+ const controller = new AbortController();
106
+ const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
107
+ if (options.signal) {
108
+ options.signal.addEventListener("abort", () => controller.abort(), { once: true });
109
+ }
110
+ let response;
111
+ try {
112
+ response = await this.fetchImpl(url, {
113
+ method,
114
+ headers: {
115
+ Accept: "application/json",
116
+ Authorization: this.authHeader,
117
+ "User-Agent": this.userAgent,
118
+ ...(options.body !== undefined ? { "Content-Type": "application/json" } : {}),
119
+ },
120
+ body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
121
+ signal: controller.signal,
122
+ });
123
+ }
124
+ catch (err) {
125
+ clearTimeout(timeout);
126
+ // Errores de red / abort: reintentar con backoff si quedan intentos.
127
+ if (attempt < this.maxRetries) {
128
+ await sleep(this.backoffMs(attempt));
129
+ attempt += 1;
130
+ continue;
131
+ }
132
+ throw new AlegraApiError(`Fallo de red al llamar a Alegra (${endpoint}): ${err.message}`, { status: 0, body: null, endpoint });
133
+ }
134
+ finally {
135
+ clearTimeout(timeout);
136
+ }
137
+ if (response.ok) {
138
+ return (await this.parseBody(response));
139
+ }
140
+ const body = await this.parseBody(response);
141
+ if (response.status === 429) {
142
+ // Alegra: 150 req/min por usuario. No envía Retry-After; usa X-Rate-Limit-Reset
143
+ // (segundos que faltan para reiniciar la ventana de 1 minuto).
144
+ const retryAfterMs = this.parseRetryAfter(response.headers.get("Retry-After"));
145
+ const resetMs = this.parseResetSeconds(response.headers.get("X-Rate-Limit-Reset"));
146
+ const waitMs = retryAfterMs ?? resetMs;
147
+ if (attempt < this.maxRetries) {
148
+ await sleep(waitMs ?? this.backoffMs(attempt));
149
+ attempt += 1;
150
+ continue;
151
+ }
152
+ throw new AlegraRateLimitError(`Rate limit de Alegra (429) tras ${this.maxRetries} reintentos en ${endpoint}.`, {
153
+ status: 429,
154
+ body,
155
+ endpoint,
156
+ retryAfterSeconds: waitMs !== undefined ? waitMs / 1000 : undefined,
157
+ });
158
+ }
159
+ if (response.status >= 500 && attempt < this.maxRetries) {
160
+ await sleep(this.backoffMs(attempt));
161
+ attempt += 1;
162
+ continue;
163
+ }
164
+ throw new AlegraApiError(`Alegra respondió ${response.status} en ${endpoint}.`, { status: response.status, body, endpoint });
165
+ }
166
+ }
167
+ /** Espera lo necesario para respetar `minRequestIntervalMs`. */
168
+ async throttle() {
169
+ if (this.minRequestIntervalMs <= 0)
170
+ return;
171
+ const elapsed = Date.now() - this.lastRequestAt;
172
+ const wait = this.minRequestIntervalMs - elapsed;
173
+ if (wait > 0)
174
+ await sleep(wait);
175
+ this.lastRequestAt = Date.now();
176
+ }
177
+ /** Backoff exponencial con jitter. */
178
+ backoffMs(attempt) {
179
+ const base = this.retryBaseMs * 2 ** attempt;
180
+ const jitter = Math.random() * this.retryBaseMs;
181
+ return base + jitter;
182
+ }
183
+ /** Intenta parsear JSON; si no es JSON, devuelve el texto. */
184
+ async parseBody(response) {
185
+ const text = await response.text();
186
+ if (!text)
187
+ return null;
188
+ try {
189
+ return JSON.parse(text);
190
+ }
191
+ catch {
192
+ return text;
193
+ }
194
+ }
195
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Errores del cliente de Alegra.
3
+ *
4
+ * Todas las respuestas no exitosas de la API se envuelven en {@link AlegraApiError}.
5
+ * Cuando el servidor responde 429 (demasiadas solicitudes) se usa la subclase
6
+ * {@link AlegraRateLimitError}, que expone el tiempo de espera sugerido.
7
+ */
8
+ /** Error genérico de la API de Alegra (respuesta HTTP no 2xx). */
9
+ export declare class AlegraApiError extends Error {
10
+ /** Código de estado HTTP devuelto por la API. */
11
+ readonly status: number;
12
+ /** Cuerpo de la respuesta, ya parseado si era JSON; si no, el texto crudo. */
13
+ readonly body: unknown;
14
+ /** Endpoint (ruta relativa) que originó el error. */
15
+ readonly endpoint: string;
16
+ constructor(message: string, params: {
17
+ status: number;
18
+ body: unknown;
19
+ endpoint: string;
20
+ });
21
+ }
22
+ /** Error específico de rate limit (HTTP 429). */
23
+ export declare class AlegraRateLimitError extends AlegraApiError {
24
+ /** Segundos sugeridos de espera antes de reintentar (cabecera Retry-After), si vino. */
25
+ readonly retryAfterSeconds?: number;
26
+ constructor(message: string, params: {
27
+ status: number;
28
+ body: unknown;
29
+ endpoint: string;
30
+ retryAfterSeconds?: number;
31
+ });
32
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Errores del cliente de Alegra.
3
+ *
4
+ * Todas las respuestas no exitosas de la API se envuelven en {@link AlegraApiError}.
5
+ * Cuando el servidor responde 429 (demasiadas solicitudes) se usa la subclase
6
+ * {@link AlegraRateLimitError}, que expone el tiempo de espera sugerido.
7
+ */
8
+ /** Error genérico de la API de Alegra (respuesta HTTP no 2xx). */
9
+ export class AlegraApiError extends Error {
10
+ /** Código de estado HTTP devuelto por la API. */
11
+ status;
12
+ /** Cuerpo de la respuesta, ya parseado si era JSON; si no, el texto crudo. */
13
+ body;
14
+ /** Endpoint (ruta relativa) que originó el error. */
15
+ endpoint;
16
+ constructor(message, params) {
17
+ super(message);
18
+ this.name = "AlegraApiError";
19
+ this.status = params.status;
20
+ this.body = params.body;
21
+ this.endpoint = params.endpoint;
22
+ }
23
+ }
24
+ /** Error específico de rate limit (HTTP 429). */
25
+ export class AlegraRateLimitError extends AlegraApiError {
26
+ /** Segundos sugeridos de espera antes de reintentar (cabecera Retry-After), si vino. */
27
+ retryAfterSeconds;
28
+ constructor(message, params) {
29
+ super(message, params);
30
+ this.name = "AlegraRateLimitError";
31
+ this.retryAfterSeconds = params.retryAfterSeconds;
32
+ }
33
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * alegra-api-client
3
+ *
4
+ * Cliente ligero y tipado para la API de Alegra.
5
+ * Punto de entrada: exporta el cliente HTTP, los recursos, los helpers de
6
+ * paginación, los tipos y los errores.
7
+ */
8
+ export { AlegraClient } from "./client.js";
9
+ export type { AlegraClientOptions, RequestOptions } from "./client.js";
10
+ export { paginate, collectAll, MAX_LIMIT } from "./pagination.js";
11
+ export type { PaginateOptions } from "./pagination.js";
12
+ export { Contactos } from "./resources/contacts.js";
13
+ export { Items } from "./resources/items.js";
14
+ export { Facturas } from "./resources/invoices.js";
15
+ export { AlegraApiError, AlegraRateLimitError } from "./errors.js";
16
+ export type * from "./types.js";
17
+ import { AlegraClient, type AlegraClientOptions } from "./client.js";
18
+ import { Contactos } from "./resources/contacts.js";
19
+ import { Items } from "./resources/items.js";
20
+ import { Facturas } from "./resources/invoices.js";
21
+ /**
22
+ * Fachada de conveniencia: crea el cliente y expone los recursos ya cableados.
23
+ *
24
+ * @example
25
+ * const alegra = new Alegra({ email: "demo@ejemplo.com", token: "TU_TOKEN" });
26
+ * const clientes = await alegra.contactos.listarTodos({ type: "client" });
27
+ * const facturas = await alegra.facturas.listar({ limit: 30 });
28
+ */
29
+ export declare class Alegra {
30
+ /** Cliente HTTP subyacente (por si necesitas endpoints no cubiertos por los recursos). */
31
+ readonly client: AlegraClient;
32
+ readonly contactos: Contactos;
33
+ readonly items: Items;
34
+ readonly facturas: Facturas;
35
+ constructor(options: AlegraClientOptions);
36
+ }
package/dist/index.js ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * alegra-api-client
3
+ *
4
+ * Cliente ligero y tipado para la API de Alegra.
5
+ * Punto de entrada: exporta el cliente HTTP, los recursos, los helpers de
6
+ * paginación, los tipos y los errores.
7
+ */
8
+ export { AlegraClient } from "./client.js";
9
+ export { paginate, collectAll, MAX_LIMIT } from "./pagination.js";
10
+ export { Contactos } from "./resources/contacts.js";
11
+ export { Items } from "./resources/items.js";
12
+ export { Facturas } from "./resources/invoices.js";
13
+ export { AlegraApiError, AlegraRateLimitError } from "./errors.js";
14
+ import { AlegraClient } from "./client.js";
15
+ import { Contactos } from "./resources/contacts.js";
16
+ import { Items } from "./resources/items.js";
17
+ import { Facturas } from "./resources/invoices.js";
18
+ /**
19
+ * Fachada de conveniencia: crea el cliente y expone los recursos ya cableados.
20
+ *
21
+ * @example
22
+ * const alegra = new Alegra({ email: "demo@ejemplo.com", token: "TU_TOKEN" });
23
+ * const clientes = await alegra.contactos.listarTodos({ type: "client" });
24
+ * const facturas = await alegra.facturas.listar({ limit: 30 });
25
+ */
26
+ export class Alegra {
27
+ /** Cliente HTTP subyacente (por si necesitas endpoints no cubiertos por los recursos). */
28
+ client;
29
+ contactos;
30
+ items;
31
+ facturas;
32
+ constructor(options) {
33
+ this.client = new AlegraClient(options);
34
+ this.contactos = new Contactos(this.client);
35
+ this.items = new Items(this.client);
36
+ this.facturas = new Facturas(this.client);
37
+ }
38
+ }
@@ -0,0 +1,37 @@
1
+ import type { AlegraClient, RequestOptions } from "./client.js";
2
+ /** Tamaño de página máximo admitido por la API de Alegra. */
3
+ export declare const MAX_LIMIT = 30;
4
+ /** Opciones para recorrer páginas de un recurso listable. */
5
+ export interface PaginateOptions {
6
+ /** Tamaño de página (1..30). Por defecto 30. */
7
+ limit?: number;
8
+ /** Offset inicial. Por defecto 0. */
9
+ start?: number;
10
+ /** Query adicional a repetir en cada página (filtros, orden, etc.). */
11
+ query?: RequestOptions["query"];
12
+ /**
13
+ * Tope de páginas a recorrer (salvaguarda ante bucles). Por defecto sin tope.
14
+ * Útil para no agotar cuota si un filtro trae demasiados resultados.
15
+ */
16
+ maxPages?: number;
17
+ }
18
+ /**
19
+ * Itera página por página un endpoint de lista de Alegra (paginación por `start`/`limit`).
20
+ *
21
+ * La API devuelve arreglos; la paginación termina cuando una página trae menos
22
+ * elementos que `limit`. Se usa un generador asíncrono para no cargar todo en memoria.
23
+ *
24
+ * @typeParam T - Tipo de cada elemento.
25
+ * @example
26
+ * for await (const pagina of paginate<Factura>(client, "invoices", { limit: 30 })) {
27
+ * procesar(pagina);
28
+ * }
29
+ */
30
+ export declare function paginate<T>(client: AlegraClient, endpoint: string, options?: PaginateOptions): AsyncGenerator<T[], void, unknown>;
31
+ /**
32
+ * Recorre todas las páginas y devuelve el arreglo completo.
33
+ *
34
+ * Cómodo para conjuntos moderados. Para volúmenes grandes prefiere {@link paginate}
35
+ * y procesa página por página.
36
+ */
37
+ export declare function collectAll<T>(client: AlegraClient, endpoint: string, options?: PaginateOptions): Promise<T[]>;
@@ -0,0 +1,47 @@
1
+ /** Tamaño de página máximo admitido por la API de Alegra. */
2
+ export const MAX_LIMIT = 30;
3
+ /**
4
+ * Itera página por página un endpoint de lista de Alegra (paginación por `start`/`limit`).
5
+ *
6
+ * La API devuelve arreglos; la paginación termina cuando una página trae menos
7
+ * elementos que `limit`. Se usa un generador asíncrono para no cargar todo en memoria.
8
+ *
9
+ * @typeParam T - Tipo de cada elemento.
10
+ * @example
11
+ * for await (const pagina of paginate<Factura>(client, "invoices", { limit: 30 })) {
12
+ * procesar(pagina);
13
+ * }
14
+ */
15
+ export async function* paginate(client, endpoint, options = {}) {
16
+ const limit = Math.min(Math.max(options.limit ?? MAX_LIMIT, 1), MAX_LIMIT);
17
+ let start = options.start ?? 0;
18
+ let pages = 0;
19
+ for (;;) {
20
+ if (options.maxPages !== undefined && pages >= options.maxPages)
21
+ return;
22
+ const page = await client.request(endpoint, {
23
+ query: { ...options.query, start, limit },
24
+ });
25
+ const items = Array.isArray(page) ? page : [];
26
+ if (items.length === 0)
27
+ return;
28
+ yield items;
29
+ pages += 1;
30
+ if (items.length < limit)
31
+ return;
32
+ start += limit;
33
+ }
34
+ }
35
+ /**
36
+ * Recorre todas las páginas y devuelve el arreglo completo.
37
+ *
38
+ * Cómodo para conjuntos moderados. Para volúmenes grandes prefiere {@link paginate}
39
+ * y procesa página por página.
40
+ */
41
+ export async function collectAll(client, endpoint, options = {}) {
42
+ const out = [];
43
+ for await (const page of paginate(client, endpoint, options)) {
44
+ out.push(...page);
45
+ }
46
+ return out;
47
+ }
@@ -0,0 +1,22 @@
1
+ import type { AlegraClient } from "../client.js";
2
+ import type { Contacto, ParametrosContactos } from "../types.js";
3
+ /**
4
+ * Recurso de contactos (clientes y proveedores).
5
+ *
6
+ * @example
7
+ * const alegra = new AlegraClient({ email, token });
8
+ * const contactos = new Contactos(alegra);
9
+ * const primeros = await contactos.listar({ type: "client", limit: 30 });
10
+ */
11
+ export declare class Contactos {
12
+ private readonly client;
13
+ constructor(client: AlegraClient);
14
+ /** Lista una sola página de contactos según los parámetros dados. */
15
+ listar(params?: ParametrosContactos): Promise<Contacto[]>;
16
+ /** Obtiene un contacto por su id. */
17
+ obtener(id: string): Promise<Contacto>;
18
+ /** Recorre todos los contactos página por página (generador asíncrono). */
19
+ paginar(params?: ParametrosContactos): AsyncGenerator<Contacto[], void, unknown>;
20
+ /** Descarga todos los contactos que cumplan el filtro. */
21
+ listarTodos(params?: ParametrosContactos): Promise<Contacto[]>;
22
+ }
@@ -0,0 +1,38 @@
1
+ import { collectAll, paginate } from "../pagination.js";
2
+ /**
3
+ * Recurso de contactos (clientes y proveedores).
4
+ *
5
+ * @example
6
+ * const alegra = new AlegraClient({ email, token });
7
+ * const contactos = new Contactos(alegra);
8
+ * const primeros = await contactos.listar({ type: "client", limit: 30 });
9
+ */
10
+ export class Contactos {
11
+ client;
12
+ constructor(client) {
13
+ this.client = client;
14
+ }
15
+ /** Lista una sola página de contactos según los parámetros dados. */
16
+ listar(params = {}) {
17
+ return this.client.request("contacts", { query: { ...params } });
18
+ }
19
+ /** Obtiene un contacto por su id. */
20
+ obtener(id) {
21
+ return this.client.request(`contacts/${encodeURIComponent(id)}`);
22
+ }
23
+ /** Recorre todos los contactos página por página (generador asíncrono). */
24
+ paginar(params = {}) {
25
+ const { start, limit, ...query } = params;
26
+ const opts = { start, limit, query: query };
27
+ return paginate(this.client, "contacts", opts);
28
+ }
29
+ /** Descarga todos los contactos que cumplan el filtro. */
30
+ listarTodos(params = {}) {
31
+ const { start, limit, ...query } = params;
32
+ return collectAll(this.client, "contacts", {
33
+ start,
34
+ limit,
35
+ query: query,
36
+ });
37
+ }
38
+ }
@@ -0,0 +1,28 @@
1
+ import type { AlegraClient } from "../client.js";
2
+ import type { Factura, ParametrosFacturas } from "../types.js";
3
+ /**
4
+ * Recurso de facturas de venta.
5
+ *
6
+ * @example
7
+ * const facturas = new Facturas(alegra);
8
+ * for await (const pagina of facturas.paginar({ date_afterOrNow: "2026-01-01" })) {
9
+ * // procesar cada página sin cargar todo en memoria
10
+ * }
11
+ */
12
+ export declare class Facturas {
13
+ private readonly client;
14
+ constructor(client: AlegraClient);
15
+ /** Lista una sola página de facturas. */
16
+ listar(params?: ParametrosFacturas): Promise<Factura[]>;
17
+ /** Obtiene una factura por su id. */
18
+ obtener(id: string): Promise<Factura>;
19
+ /** Recorre todas las facturas página por página. */
20
+ paginar(params?: ParametrosFacturas): AsyncGenerator<Factura[], void, unknown>;
21
+ /**
22
+ * Descarga todas las facturas que cumplan el filtro.
23
+ *
24
+ * Nota: para rangos de fechas muy amplios conviene usar {@link paginar} y procesar
25
+ * por página, o acotar con `date_afterOrNow` / `date_beforeOrNow`.
26
+ */
27
+ listarTodas(params?: ParametrosFacturas): Promise<Factura[]>;
28
+ }
@@ -0,0 +1,47 @@
1
+ import { collectAll, paginate } from "../pagination.js";
2
+ /**
3
+ * Recurso de facturas de venta.
4
+ *
5
+ * @example
6
+ * const facturas = new Facturas(alegra);
7
+ * for await (const pagina of facturas.paginar({ date_afterOrNow: "2026-01-01" })) {
8
+ * // procesar cada página sin cargar todo en memoria
9
+ * }
10
+ */
11
+ export class Facturas {
12
+ client;
13
+ constructor(client) {
14
+ this.client = client;
15
+ }
16
+ /** Lista una sola página de facturas. */
17
+ listar(params = {}) {
18
+ return this.client.request("invoices", { query: { ...params } });
19
+ }
20
+ /** Obtiene una factura por su id. */
21
+ obtener(id) {
22
+ return this.client.request(`invoices/${encodeURIComponent(id)}`);
23
+ }
24
+ /** Recorre todas las facturas página por página. */
25
+ paginar(params = {}) {
26
+ const { start, limit, ...query } = params;
27
+ return paginate(this.client, "invoices", {
28
+ start,
29
+ limit,
30
+ query: query,
31
+ });
32
+ }
33
+ /**
34
+ * Descarga todas las facturas que cumplan el filtro.
35
+ *
36
+ * Nota: para rangos de fechas muy amplios conviene usar {@link paginar} y procesar
37
+ * por página, o acotar con `date_afterOrNow` / `date_beforeOrNow`.
38
+ */
39
+ listarTodas(params = {}) {
40
+ const { start, limit, ...query } = params;
41
+ return collectAll(this.client, "invoices", {
42
+ start,
43
+ limit,
44
+ query: query,
45
+ });
46
+ }
47
+ }
@@ -0,0 +1,21 @@
1
+ import type { AlegraClient } from "../client.js";
2
+ import type { Item, ParametrosItems } from "../types.js";
3
+ /**
4
+ * Recurso de ítems (productos y servicios).
5
+ *
6
+ * @example
7
+ * const items = new Items(alegra);
8
+ * const todos = await items.listarTodos({ status: "active" });
9
+ */
10
+ export declare class Items {
11
+ private readonly client;
12
+ constructor(client: AlegraClient);
13
+ /** Lista una sola página de ítems. */
14
+ listar(params?: ParametrosItems): Promise<Item[]>;
15
+ /** Obtiene un ítem por su id. */
16
+ obtener(id: string): Promise<Item>;
17
+ /** Recorre todos los ítems página por página. */
18
+ paginar(params?: ParametrosItems): AsyncGenerator<Item[], void, unknown>;
19
+ /** Descarga todos los ítems que cumplan el filtro. */
20
+ listarTodos(params?: ParametrosItems): Promise<Item[]>;
21
+ }
@@ -0,0 +1,40 @@
1
+ import { collectAll, paginate } from "../pagination.js";
2
+ /**
3
+ * Recurso de ítems (productos y servicios).
4
+ *
5
+ * @example
6
+ * const items = new Items(alegra);
7
+ * const todos = await items.listarTodos({ status: "active" });
8
+ */
9
+ export class Items {
10
+ client;
11
+ constructor(client) {
12
+ this.client = client;
13
+ }
14
+ /** Lista una sola página de ítems. */
15
+ listar(params = {}) {
16
+ return this.client.request("items", { query: { ...params } });
17
+ }
18
+ /** Obtiene un ítem por su id. */
19
+ obtener(id) {
20
+ return this.client.request(`items/${encodeURIComponent(id)}`);
21
+ }
22
+ /** Recorre todos los ítems página por página. */
23
+ paginar(params = {}) {
24
+ const { start, limit, ...query } = params;
25
+ return paginate(this.client, "items", {
26
+ start,
27
+ limit,
28
+ query: query,
29
+ });
30
+ }
31
+ /** Descarga todos los ítems que cumplan el filtro. */
32
+ listarTodos(params = {}) {
33
+ const { start, limit, ...query } = params;
34
+ return collectAll(this.client, "items", {
35
+ start,
36
+ limit,
37
+ query: query,
38
+ });
39
+ }
40
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Tipos de los objetos de la API de Alegra usados por este cliente.
3
+ *
4
+ * Los tipos reflejan los campos que la API devuelve con más frecuencia. No pretenden
5
+ * ser exhaustivos: la API puede incluir campos adicionales según el plan y la
6
+ * configuración de cada cuenta, por eso las interfaces admiten propiedades extra.
7
+ */
8
+ /** Estado activo/inactivo común a varios recursos. */
9
+ export type EstadoRecurso = "active" | "inactive";
10
+ /** Moneda tal como la expone Alegra. */
11
+ export interface Moneda {
12
+ code: string;
13
+ symbol?: string;
14
+ }
15
+ /** Precio de un ítem dentro de una lista de precios. */
16
+ export interface PrecioItem {
17
+ idPriceList?: string;
18
+ name?: string;
19
+ type?: string;
20
+ price: number;
21
+ currency?: Moneda;
22
+ main?: boolean;
23
+ edited?: boolean;
24
+ }
25
+ /** Contacto (cliente o proveedor). */
26
+ export interface Contacto {
27
+ id: string;
28
+ name: string;
29
+ identification?: string | null;
30
+ email?: string | null;
31
+ phonePrimary?: string | null;
32
+ phoneSecondary?: string | null;
33
+ mobile?: string | null;
34
+ status?: EstadoRecurso;
35
+ type?: string[];
36
+ observations?: string | null;
37
+ address?: Record<string, unknown> | null;
38
+ created_at?: string;
39
+ updated_at?: string;
40
+ /** Campos adicionales devueltos por la API. */
41
+ [extra: string]: unknown;
42
+ }
43
+ /** Ítem / producto o servicio. */
44
+ export interface Item {
45
+ id: string;
46
+ name: string;
47
+ description?: string | null;
48
+ reference?: string | null;
49
+ status?: EstadoRecurso;
50
+ price?: PrecioItem[];
51
+ type?: string;
52
+ [extra: string]: unknown;
53
+ }
54
+ /** Numeración/plantilla de una factura. */
55
+ export interface NumeracionFactura {
56
+ id?: string;
57
+ prefix?: string;
58
+ number?: string;
59
+ fullNumber?: string;
60
+ documentType?: string;
61
+ isElectronic?: boolean;
62
+ }
63
+ /** Referencia mínima al cliente dentro de una factura. */
64
+ export interface ClienteFactura {
65
+ id: string;
66
+ name?: string;
67
+ identification?: string;
68
+ }
69
+ /** Factura de venta. */
70
+ export interface Factura {
71
+ id: string;
72
+ date: string;
73
+ dueDate?: string;
74
+ status?: string;
75
+ subtotal?: number;
76
+ discount?: number;
77
+ tax?: number;
78
+ total: number;
79
+ totalPaid?: number;
80
+ balance?: number;
81
+ numberTemplate?: NumeracionFactura;
82
+ client?: ClienteFactura;
83
+ [extra: string]: unknown;
84
+ }
85
+ /** Parámetros de listado con paginación (comunes a los recursos). */
86
+ export interface ParametrosListado {
87
+ /** Desplazamiento (offset). Por defecto 0. */
88
+ start?: number;
89
+ /** Tamaño de página. Máximo permitido por Alegra: 30. */
90
+ limit?: number;
91
+ /** Campo por el cual ordenar. */
92
+ order_field?: string;
93
+ /** Dirección del orden. */
94
+ order_direction?: "ASC" | "DESC";
95
+ }
96
+ /** Parámetros específicos para listar contactos. */
97
+ export interface ParametrosContactos extends ParametrosListado {
98
+ type?: "client" | "provider";
99
+ identification?: string;
100
+ query?: string;
101
+ }
102
+ /** Parámetros específicos para listar facturas. */
103
+ export interface ParametrosFacturas extends ParametrosListado {
104
+ /** Fecha exacta de creación (YYYY-MM-DD). */
105
+ date?: string;
106
+ /** Creadas en o después de (YYYY-MM-DD). */
107
+ date_afterOrNow?: string;
108
+ /** Creadas en o antes de (YYYY-MM-DD). */
109
+ date_beforeOrNow?: string;
110
+ /** Estado: open, closed, draft, void (admite lista separada por comas). */
111
+ status?: string;
112
+ client_id?: string;
113
+ }
114
+ /** Parámetros específicos para listar ítems. */
115
+ export interface ParametrosItems extends ParametrosListado {
116
+ name?: string;
117
+ reference?: string;
118
+ query?: string;
119
+ status?: EstadoRecurso;
120
+ }
package/dist/types.js ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Tipos de los objetos de la API de Alegra usados por este cliente.
3
+ *
4
+ * Los tipos reflejan los campos que la API devuelve con más frecuencia. No pretenden
5
+ * ser exhaustivos: la API puede incluir campos adicionales según el plan y la
6
+ * configuración de cada cuenta, por eso las interfaces admiten propiedades extra.
7
+ */
8
+ export {};
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "alegra-api-client",
3
+ "version": "0.1.0",
4
+ "description": "Cliente ligero y tipado para la API de Alegra (contactos, ítems y facturas): autenticación, paginación y manejo de rate limits.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "scripts": {
23
+ "build": "tsc -p tsconfig.json",
24
+ "test": "vitest run",
25
+ "test:watch": "vitest",
26
+ "lint:types": "tsc --noEmit",
27
+ "prepublishOnly": "npm run build"
28
+ },
29
+ "keywords": [
30
+ "alegra",
31
+ "alegra-api",
32
+ "facturacion",
33
+ "contabilidad",
34
+ "api-client",
35
+ "typescript"
36
+ ],
37
+ "license": "MIT",
38
+ "devDependencies": {
39
+ "@types/node": "^20.14.0",
40
+ "typescript": "^5.5.0",
41
+ "vitest": "^2.0.0"
42
+ },
43
+ "author": "Gary Dormoi",
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "git+https://github.com/garydormoi/alegra-api-client.git"
47
+ },
48
+ "homepage": "https://github.com/garydormoi/alegra-api-client#readme",
49
+ "bugs": {
50
+ "url": "https://github.com/garydormoi/alegra-api-client/issues"
51
+ },
52
+ "publishConfig": {
53
+ "access": "public"
54
+ }
55
+ }