@ssddo/ecf-sdk 0.1.0 → 0.2.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/README.md CHANGED
@@ -144,36 +144,103 @@ const result = await client.sendEcf(ecf, {
144
144
 
145
145
  ## Arquitectura Backend / Frontend
146
146
 
147
- En la mayoría de aplicaciones, el backend maneja la lógica de negocio (validación, almacenamiento, conversión) y envía el ECF. El frontend obtiene un token de solo lectura para consultar el estado directamente:
147
+ ```mermaid
148
+ sequenceDiagram
149
+ participant C as Cliente (Browser/App)
150
+ participant BE as Backend
151
+ participant ECF as ECF API
152
+
153
+ C->>BE: POST /invoice (datos de factura)
154
+ Note over BE: Valida, guarda y convierte a formato ECF
155
+
156
+ BE->>ECF: POST /ecf/{tipo} (enviar ECF)
157
+ ECF-->>BE: { messageId }
158
+ BE-->>C: { messageId }
159
+
160
+ Note over C: No espera — puede continuar
161
+
162
+ alt Token en cache
163
+ C->>C: Usar token existente
164
+ else Sin token o expirado
165
+ C->>BE: GET /ecf-token
166
+ BE->>ECF: POST /apikey (solo lectura, scoped a RNC)
167
+ ECF-->>BE: { apiKey }
168
+ BE-->>C: { apiKey }
169
+ C->>C: Almacenar token en cache
170
+ end
171
+
172
+ loop Polling hasta completar
173
+ C->>ECF: GET /ecf/{rnc}/{encf} (token solo lectura)
174
+ ECF-->>C: { progress, codSec, ... }
175
+ end
176
+ ```
177
+
178
+ ### Flujo detallado
179
+
180
+ 1. El **cliente** (browser/app) envía los datos de la factura al **backend** (`POST /invoice`, `/order`, `/sale`)
181
+ 2. El **backend** valida, guarda y convierte la factura interna al formato ECF
182
+ 3. El **backend** envía el ECF a la API de ECF SSD (`POST /ecf/{tipo}`) y recibe un `messageId`
183
+ 4. El **backend** retorna el `messageId` al cliente — **el cliente no espera**, puede continuar
184
+ 5. Cuando el cliente necesita consultar el estado del ECF, usa `EcfFrontendClient` que internamente:
185
+ - Verifica si hay un **token de solo lectura** en cache
186
+ - Si **no existe o expiró**: llama a `getToken()` (que el consumidor provee — típicamente un `fetch('/ecf-token')` a su backend), luego llama a `cacheToken(token)` para almacenarlo
187
+ - Si la API retorna **401**: automáticamente llama a `getToken()` de nuevo, actualiza el cache, y reintenta
188
+ 6. El cliente hace **polling** directamente contra la API de ECF SSD (`GET /ecf/{rnc}/{encf}`) hasta que `progress` sea `Finished`
189
+
190
+ ### Ejemplo: Backend
148
191
 
149
192
  ```typescript
150
- // Backend: tu endpoint de facturas
193
+ import { EcfClient } from '@ssddo/ecf-sdk';
194
+
151
195
  const ecfClient = new EcfClient({
152
196
  apiKey: process.env.ECF_BACKEND_TOKEN,
153
197
  environment: 'prod',
154
198
  });
155
199
 
200
+ // Endpoint de facturación
156
201
  app.post('/api/v1/invoices', async (req, res) => {
157
- // 1. Validar y guardar tu factura interna
158
202
  const invoice = await validateAndSave(req.body);
159
- // 2. Convertir a formato ECF
160
203
  const ecf = convertToEcf(invoice);
161
- // 3. Enviar a ECF SSD
162
204
  const { data } = await ecfClient.raw.POST('/ecf/31', { body: ecf });
163
205
  await updateInvoice(invoice.id, { messageId: data.messageId });
164
206
  res.json({ id: invoice.id, messageId: data.messageId });
165
207
  });
166
208
 
167
- // Endpoint separado: generar token de solo lectura para el frontend
209
+ // Generar token de solo lectura para el cliente
168
210
  app.get('/api/v1/ecf-token', async (req, res) => {
169
211
  const { data } = await ecfClient.createApiKey({ rnc: tenant.rnc });
170
- res.json({ token: data.token });
212
+ res.json({ apiKey: data.token });
171
213
  });
172
214
  ```
173
215
 
174
- El frontend almacena el token de forma segura, lo renueva ante `401 Unauthorized` o expiración, y consulta ECF SSD directamente. Consulta el [README principal](../README.md#arquitectura-backend--frontend) para el diagrama completo y ejemplo con React.
216
+ ### Ejemplo: Frontend (con `EcfFrontendClient`)
217
+
218
+ ```typescript
219
+ import { createFrontendClient } from '@ssddo/ecf-sdk';
175
220
 
176
- > **`sendEcf`** envuelve envío + polling en una sola llamada. Ideal para scripts o backends simples sin frontend.
221
+ // 1. Enviar la factura al backend
222
+ const invoiceRes = await fetch('/api/v1/invoices', {
223
+ method: 'POST',
224
+ body: JSON.stringify(invoiceData),
225
+ });
226
+ const { messageId, rnc, encf } = await invoiceRes.json();
227
+ // El cliente no espera — puede continuar con otras operaciones
228
+
229
+ // 2. Crear cliente de solo lectura (getToken se llama automáticamente)
230
+ const frontend = createFrontendClient({
231
+ getToken: async () => {
232
+ const res = await fetch('/api/v1/ecf-token');
233
+ const { apiKey } = await res.json();
234
+ return apiKey;
235
+ },
236
+ environment: 'prod',
237
+ // cacheToken y getCachedToken usan localStorage por defecto
238
+ });
239
+
240
+ // 3. Consultar el estado del ECF
241
+ const { data } = await frontend.queryEcf(rnc, encf);
242
+ const { data: ecfs } = await frontend.searchEcfs(rnc);
243
+ ```
177
244
 
178
245
  ## Acceso directo al cliente
179
246
 
package/dist/index.cjs CHANGED
@@ -412,10 +412,32 @@ var EcfFrontendClient = class {
412
412
  raw;
413
413
  constructor(config) {
414
414
  const baseUrl = config.baseUrl ?? ENVIRONMENT_URLS[config.environment ?? "test"];
415
+ const getToken = config.getToken;
416
+ const cacheToken = config.cacheToken ?? (async (token) => {
417
+ localStorage.setItem("ecf-token", token);
418
+ });
419
+ const getCachedToken = config.getCachedToken ?? (async () => {
420
+ return localStorage.getItem("ecf-token");
421
+ });
415
422
  const authMiddleware = {
416
423
  async onRequest({ request }) {
417
- request.headers.set("Authorization", `Bearer ${config.apiKey}`);
424
+ let token = await getCachedToken();
425
+ if (!token) {
426
+ token = await getToken();
427
+ await cacheToken(token);
428
+ }
429
+ request.headers.set("Authorization", `Bearer ${token}`);
418
430
  return request;
431
+ },
432
+ async onResponse({ request, response }) {
433
+ if (response.status === 401) {
434
+ const token = await getToken();
435
+ await cacheToken(token);
436
+ const newRequest = request.clone();
437
+ newRequest.headers.set("Authorization", `Bearer ${token}`);
438
+ return fetch(newRequest);
439
+ }
440
+ return response;
419
441
  }
420
442
  };
421
443
  this.raw = (0, import_openapi_fetch.default)({ baseUrl });
package/dist/index.d.cts CHANGED
@@ -6851,6 +6851,18 @@ interface EcfClientConfig {
6851
6851
  /** Target environment. Defaults to 'test'. */
6852
6852
  environment?: Environment;
6853
6853
  }
6854
+ interface EcfFrontendClientConfig {
6855
+ /** Function that fetches a fresh token (e.g. calls your backend's GET /ecf-token). */
6856
+ getToken: () => Promise<string>;
6857
+ /** Function to cache the token. Defaults to localStorage.setItem('ecf-token', token). */
6858
+ cacheToken?: (token: string) => Promise<void>;
6859
+ /** Function to retrieve a cached token. Defaults to localStorage.getItem('ecf-token'). */
6860
+ getCachedToken?: () => Promise<string | null>;
6861
+ /** Base URL override. Takes precedence over `environment`. */
6862
+ baseUrl?: string;
6863
+ /** Target environment. Defaults to 'test'. */
6864
+ environment?: Environment;
6865
+ }
6854
6866
  interface PollingOptions {
6855
6867
  /** Initial delay between polls in ms. Default: 1000 */
6856
6868
  initialDelay?: number;
@@ -8860,11 +8872,15 @@ declare class EcfClient {
8860
8872
  * A restricted, read-only client that only exposes GET endpoints.
8861
8873
  * Suitable for use in frontend / browser code where write operations
8862
8874
  * should not be available.
8875
+ *
8876
+ * Token lifecycle is handled automatically:
8877
+ * - On each request, checks `getCachedToken()`. If null, calls `getToken()` then `cacheToken(token)`.
8878
+ * - On 401 responses, calls `getToken()` again, updates the cache, and retries the request.
8863
8879
  */
8864
8880
  declare class EcfFrontendClient {
8865
8881
  /** The underlying openapi-fetch client for direct endpoint access. */
8866
8882
  private readonly raw;
8867
- constructor(config: EcfClientConfig);
8883
+ constructor(config: EcfFrontendClientConfig);
8868
8884
  /** Query ECFs by RNC and eNCF. */
8869
8885
  queryEcf(rnc: string, encf: string): Promise<openapi_fetch.FetchResponse<{
8870
8886
  parameters: {
@@ -9296,7 +9312,7 @@ declare class EcfFrontendClient {
9296
9312
  * Factory that creates a restricted read-only client suitable for frontend use.
9297
9313
  * Only GET endpoints are exposed.
9298
9314
  */
9299
- declare function createFrontendClient(config: EcfClientConfig): EcfFrontendClient;
9315
+ declare function createFrontendClient(config: EcfFrontendClientConfig): EcfFrontendClient;
9300
9316
 
9301
9317
  declare class PollingTimeoutError extends Error {
9302
9318
  constructor(message?: string);
@@ -9314,4 +9330,4 @@ declare class PollingMaxRetriesError extends Error {
9314
9330
  */
9315
9331
  declare function pollUntilComplete<T>(fn: () => Promise<T>, isComplete: (result: T) => boolean, options?: PollingOptions): Promise<T>;
9316
9332
 
9317
- export { EcfClient, type EcfClientConfig, EcfError, EcfFrontendClient, type Environment, PollingMaxRetriesError, type PollingOptions, PollingTimeoutError, type components, createFrontendClient, type operations, type paths, pollUntilComplete };
9333
+ export { EcfClient, type EcfClientConfig, EcfError, EcfFrontendClient, type EcfFrontendClientConfig, type Environment, PollingMaxRetriesError, type PollingOptions, PollingTimeoutError, type components, createFrontendClient, type operations, type paths, pollUntilComplete };
package/dist/index.d.ts CHANGED
@@ -6851,6 +6851,18 @@ interface EcfClientConfig {
6851
6851
  /** Target environment. Defaults to 'test'. */
6852
6852
  environment?: Environment;
6853
6853
  }
6854
+ interface EcfFrontendClientConfig {
6855
+ /** Function that fetches a fresh token (e.g. calls your backend's GET /ecf-token). */
6856
+ getToken: () => Promise<string>;
6857
+ /** Function to cache the token. Defaults to localStorage.setItem('ecf-token', token). */
6858
+ cacheToken?: (token: string) => Promise<void>;
6859
+ /** Function to retrieve a cached token. Defaults to localStorage.getItem('ecf-token'). */
6860
+ getCachedToken?: () => Promise<string | null>;
6861
+ /** Base URL override. Takes precedence over `environment`. */
6862
+ baseUrl?: string;
6863
+ /** Target environment. Defaults to 'test'. */
6864
+ environment?: Environment;
6865
+ }
6854
6866
  interface PollingOptions {
6855
6867
  /** Initial delay between polls in ms. Default: 1000 */
6856
6868
  initialDelay?: number;
@@ -8860,11 +8872,15 @@ declare class EcfClient {
8860
8872
  * A restricted, read-only client that only exposes GET endpoints.
8861
8873
  * Suitable for use in frontend / browser code where write operations
8862
8874
  * should not be available.
8875
+ *
8876
+ * Token lifecycle is handled automatically:
8877
+ * - On each request, checks `getCachedToken()`. If null, calls `getToken()` then `cacheToken(token)`.
8878
+ * - On 401 responses, calls `getToken()` again, updates the cache, and retries the request.
8863
8879
  */
8864
8880
  declare class EcfFrontendClient {
8865
8881
  /** The underlying openapi-fetch client for direct endpoint access. */
8866
8882
  private readonly raw;
8867
- constructor(config: EcfClientConfig);
8883
+ constructor(config: EcfFrontendClientConfig);
8868
8884
  /** Query ECFs by RNC and eNCF. */
8869
8885
  queryEcf(rnc: string, encf: string): Promise<openapi_fetch.FetchResponse<{
8870
8886
  parameters: {
@@ -9296,7 +9312,7 @@ declare class EcfFrontendClient {
9296
9312
  * Factory that creates a restricted read-only client suitable for frontend use.
9297
9313
  * Only GET endpoints are exposed.
9298
9314
  */
9299
- declare function createFrontendClient(config: EcfClientConfig): EcfFrontendClient;
9315
+ declare function createFrontendClient(config: EcfFrontendClientConfig): EcfFrontendClient;
9300
9316
 
9301
9317
  declare class PollingTimeoutError extends Error {
9302
9318
  constructor(message?: string);
@@ -9314,4 +9330,4 @@ declare class PollingMaxRetriesError extends Error {
9314
9330
  */
9315
9331
  declare function pollUntilComplete<T>(fn: () => Promise<T>, isComplete: (result: T) => boolean, options?: PollingOptions): Promise<T>;
9316
9332
 
9317
- export { EcfClient, type EcfClientConfig, EcfError, EcfFrontendClient, type Environment, PollingMaxRetriesError, type PollingOptions, PollingTimeoutError, type components, createFrontendClient, type operations, type paths, pollUntilComplete };
9333
+ export { EcfClient, type EcfClientConfig, EcfError, EcfFrontendClient, type EcfFrontendClientConfig, type Environment, PollingMaxRetriesError, type PollingOptions, PollingTimeoutError, type components, createFrontendClient, type operations, type paths, pollUntilComplete };
package/dist/index.js CHANGED
@@ -370,10 +370,32 @@ var EcfFrontendClient = class {
370
370
  raw;
371
371
  constructor(config) {
372
372
  const baseUrl = config.baseUrl ?? ENVIRONMENT_URLS[config.environment ?? "test"];
373
+ const getToken = config.getToken;
374
+ const cacheToken = config.cacheToken ?? (async (token) => {
375
+ localStorage.setItem("ecf-token", token);
376
+ });
377
+ const getCachedToken = config.getCachedToken ?? (async () => {
378
+ return localStorage.getItem("ecf-token");
379
+ });
373
380
  const authMiddleware = {
374
381
  async onRequest({ request }) {
375
- request.headers.set("Authorization", `Bearer ${config.apiKey}`);
382
+ let token = await getCachedToken();
383
+ if (!token) {
384
+ token = await getToken();
385
+ await cacheToken(token);
386
+ }
387
+ request.headers.set("Authorization", `Bearer ${token}`);
376
388
  return request;
389
+ },
390
+ async onResponse({ request, response }) {
391
+ if (response.status === 401) {
392
+ const token = await getToken();
393
+ await cacheToken(token);
394
+ const newRequest = request.clone();
395
+ newRequest.headers.set("Authorization", `Bearer ${token}`);
396
+ return fetch(newRequest);
397
+ }
398
+ return response;
377
399
  }
378
400
  };
379
401
  this.raw = createClient({ baseUrl });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ssddo/ecf-sdk",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "SDK de TypeScript para la API de ECF DGII (comprobantes fiscales electrónicos de República Dominicana)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -21,6 +21,11 @@
21
21
  "files": [
22
22
  "dist"
23
23
  ],
24
+ "scripts": {
25
+ "generate": "openapi-typescript ../ecf_dgii/src/Apis/ECF_DGII.EcfApi/wwwroot/openapi/v1.json -o src/generated/v1.d.ts",
26
+ "build": "tsup",
27
+ "prepublishOnly": "npm run build"
28
+ },
24
29
  "dependencies": {
25
30
  "openapi-fetch": "^0.13.0"
26
31
  },
@@ -56,9 +61,5 @@
56
61
  "openapi",
57
62
  "ssd"
58
63
  ],
59
- "license": "MIT",
60
- "scripts": {
61
- "generate": "openapi-typescript ../ecf_dgii/src/Apis/ECF_DGII.EcfApi/wwwroot/openapi/v1.json -o src/generated/v1.d.ts",
62
- "build": "tsup"
63
- }
64
- }
64
+ "license": "MIT"
65
+ }