diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1b30ec5..382b53e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -39,5 +39,125 @@ jobs: fi - name: Publish to npm + id: publish_step if: env.exists == 'false' run: npm publish --provenance --access public + + - name: Notify Slack (success) + if: env.exists == 'false' && steps.publish_step.outcome == 'success' + env: + SLACK_DEPLOY_WEBHOOK_URL: ${{ secrets.SLACK_DEPLOY_WEBHOOK_URL }} + shell: bash + run: | + set -euo pipefail + if [ -z "${SLACK_DEPLOY_WEBHOOK_URL:-}" ]; then + echo "SLACK_DEPLOY_WEBHOOK_URL not set; skipping Slack notification" + exit 0 + fi + + node <<'JS' + const fs = require('node:fs'); + + const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8')); + const version = packageJson.version; + const packageName = packageJson.name; + let latestChanges = 'See CHANGELOG for details.'; + + if (fs.existsSync('CHANGELOG.md')) { + const lines = fs.readFileSync('CHANGELOG.md', 'utf8').split(/\r?\n/); + const bullets = []; + let inTargetSection = false; + for (const line of lines) { + if (line.startsWith(`## [${version}]`)) { + inTargetSection = true; + continue; + } + if (inTargetSection && line.startsWith('## [')) break; + if (inTargetSection && line.startsWith('- ')) { + bullets.push(line.slice(2).trim()); + } + } + if (bullets.length) { + latestChanges = bullets.slice(0, 5).map((bullet) => `• ${bullet}`).join('\n'); + } + } + + fs.writeFileSync('/tmp/slack_payload.json', JSON.stringify({ + text: `Facturapi Node SDK ${version} published to npm`, + blocks: [ + { + type: 'header', + text: { + type: 'plain_text', + text: `Node SDK ${version} published to npm`, + }, + }, + { + type: 'section', + fields: [ + { type: 'mrkdwn', text: `*Package:* \`${packageName}@${version}\`` }, + { type: 'mrkdwn', text: `*Branch:* \`${process.env.GITHUB_REF_NAME}\`` }, + { type: 'mrkdwn', text: `*Commit:* \`${process.env.GITHUB_SHA}\`` }, + { type: 'mrkdwn', text: `*Actor:* \`${process.env.GITHUB_ACTOR}\`` }, + ], + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: [ + '*Useful links*', + `• npm: `, + `• Workflow run: <${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}|Open run>`, + `• Changelog: <${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/blob/${process.env.GITHUB_SHA}/CHANGELOG.md|Read changes>`, + ].join('\n'), + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*Latest changes*\n${latestChanges}`, + }, + }, + ], + })); + JS + + curl -sS -X POST -H "Content-type: application/json" --data "@/tmp/slack_payload.json" "$SLACK_DEPLOY_WEBHOOK_URL" || true + + - name: Notify Slack (failure) + if: failure() && env.exists == 'false' && steps.publish_step.outcome == 'failure' + env: + SLACK_DEPLOY_WEBHOOK_URL: ${{ secrets.SLACK_DEPLOY_WEBHOOK_URL }} + shell: bash + run: | + set -euo pipefail + if [ -z "${SLACK_DEPLOY_WEBHOOK_URL:-}" ]; then + echo "SLACK_DEPLOY_WEBHOOK_URL not set; skipping Slack notification" + exit 0 + fi + + node <<'JS' + const fs = require('node:fs'); + const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8')); + + fs.writeFileSync('/tmp/slack_payload_failure.json', JSON.stringify({ + text: `Facturapi Node SDK ${packageJson.version} publish failed`, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: [ + `*Node SDK ${packageJson.version} publish failed*`, + `• Workflow run: <${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}|Open run>`, + `• Commit: \`${process.env.GITHUB_SHA}\``, + ].join('\n'), + }, + }, + ], + })); + JS + + curl -sS -X POST -H "Content-type: application/json" --data "@/tmp/slack_payload_failure.json" "$SLACK_DEPLOY_WEBHOOK_URL" || true diff --git a/CHANGELOG.md b/CHANGELOG.md index 068af7e..52628eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [4.18.0] 2026-06-06 +### Added +- Expose structured API error metadata through `FacturapiError`, including `status`, `code`, `path`, `location`, `errors`, `logId`, and response `headers`. + ## [4.17.0] 2026-04-27 ### Added - Add support for custom request headers through the `Facturapi` constructor options. diff --git a/package-lock.json b/package-lock.json index 97ef798..df9b27b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "facturapi", - "version": "4.17.0", + "version": "4.18.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "facturapi", - "version": "4.17.0", + "version": "4.18.0", "license": "MIT", "devDependencies": { "@eslint/js": "^10.0.1", @@ -5077,4 +5077,4 @@ } } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 5c916e3..1b9b2b9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "facturapi", - "version": "4.17.0", + "version": "4.18.0", "description": "SDK oficial de Facturapi para Node.js y navegadores. Integra facturación electrónica en México (CFDI) de forma simple y obtén una perspectiva fiscal completa de tu operación, con búsquedas indexadas, envío de documentos y trazabilidad.", "main": "dist/index.cjs.js", "module": "dist/index.es.js", @@ -95,4 +95,4 @@ "vite": "^8.0.3", "vitest": "^4.1.2" } -} \ No newline at end of file +} diff --git a/src/index.ts b/src/index.ts index 1250a9a..13075f8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,8 @@ import ComercioExteriorCatalogs from './tools/comercioExteriorCatalogs'; export * from './enums'; export * from './types'; +export { FacturapiError } from './wrapper'; +export type { FacturapiErrorDetail } from './wrapper'; const VALID_API_VERSIONS = ['v1', 'v2']; diff --git a/src/wrapper.ts b/src/wrapper.ts index 34b8a84..7a6ec75 100644 --- a/src/wrapper.ts +++ b/src/wrapper.ts @@ -24,6 +24,81 @@ type FormDataLike = { export type UniversalFormData = FormData | FormDataLike; +export interface FacturapiErrorDetail { + code?: string; + message?: string; + path?: string; + location?: string; + source?: string; + [key: string]: unknown; +} + +export interface FacturapiErrorOptions { + message: string; + status: number; + code?: string; + path?: string; + location?: string; + errors?: FacturapiErrorDetail[]; + logId?: string; + headers?: Record; +} + +export class FacturapiError extends Error { + status: number; + code?: string; + path?: string; + location?: string; + errors?: FacturapiErrorDetail[]; + logId?: string; + headers: Record; + + constructor(options: FacturapiErrorOptions) { + super(options.message); + this.name = 'FacturapiError'; + this.status = options.status; + this.code = options.code; + this.path = options.path; + this.location = options.location; + this.errors = options.errors; + this.logId = options.logId; + this.headers = options.headers || {}; + } +} + +const responseHeadersToObject = (headers: Headers): Record => { + const result: Record = {}; + if (typeof headers.forEach === 'function') { + headers.forEach((value, key) => { + result[key.toLowerCase()] = value; + }); + return result; + } + for (const key of ['retry-after', 'x-facturapi-log-id']) { + const value = headers.get(key); + if (value) { + result[key] = value; + } + } + return result; +}; + +const stringFrom = (value: unknown): string | undefined => + typeof value === 'string' ? value : undefined; + +const statusFrom = (value: unknown, fallback: number): number => { + if (typeof value === 'number') { + return value; + } + if (typeof value === 'string') { + const parsed = Number.parseInt(value, 10); + if (!Number.isNaN(parsed)) { + return parsed; + } + } + return fallback; +}; + const responseInterceptor = async (response: Response) => { if (!response.ok) { const contentType = response.headers.get('content-type') || ''; @@ -34,10 +109,11 @@ const responseInterceptor = async (response: Response) => { bodyText = null; } + let errorData: Record | null = null; let jsonMessage: string | null = null; if (contentType.includes('application/json') && bodyText) { try { - const errorData = JSON.parse(bodyText) as { message?: unknown }; + errorData = JSON.parse(bodyText) as Record; if (typeof errorData.message === 'string' && errorData.message.trim()) { jsonMessage = errorData.message; } @@ -46,7 +122,19 @@ const responseInterceptor = async (response: Response) => { } } - throw new Error(jsonMessage || bodyText || response.statusText); + const headers = responseHeadersToObject(response.headers); + throw new FacturapiError({ + message: jsonMessage || bodyText || response.statusText, + status: statusFrom(errorData?.status, response.status), + code: stringFrom(errorData?.code), + path: stringFrom(errorData?.path), + location: stringFrom(errorData?.location), + errors: Array.isArray(errorData?.errors) + ? (errorData.errors as FacturapiErrorDetail[]) + : undefined, + logId: headers['x-facturapi-log-id'], + headers, + }); } const contentType = response.headers.get('content-type') || ''; const contentDisposition = diff --git a/test-d/runtime-types.test-d.ts b/test-d/runtime-types.test-d.ts index a4e8f8b..13ad533 100644 --- a/test-d/runtime-types.test-d.ts +++ b/test-d/runtime-types.test-d.ts @@ -1,6 +1,7 @@ import { expectAssignable, expectType, expectError } from 'tsd'; import Facturapi, { BinaryDownload, + FacturapiError, NodeLikeReadableStream, TaxFactor, } from '../dist'; @@ -29,3 +30,11 @@ if ('pipe' in binary && typeof binary.pipe === 'function') { } expectAssignable(TaxFactor.EXENTO); + +declare const apiError: FacturapiError; +expectType(apiError.status); +expectType(apiError.code); +expectType(apiError.path); +expectType(apiError.location); +expectType(apiError.logId); +expectType>(apiError.headers); diff --git a/test/node/runtime-compat.node.test.ts b/test/node/runtime-compat.node.test.ts index 38b5cd4..0e5f97f 100644 --- a/test/node/runtime-compat.node.test.ts +++ b/test/node/runtime-compat.node.test.ts @@ -2,7 +2,7 @@ import crypto from 'node:crypto' import { Writable } from 'node:stream' import { afterEach, describe, expect, it, vi } from 'vitest' -import Facturapi from '../../src' +import Facturapi, { FacturapiError } from '../../src' const originalFetch = globalThis.fetch @@ -223,6 +223,65 @@ describe('runtime compatibility (node)', () => { ) }) + it('surfaces structured API errors and response headers in Node', async () => { + const client = createClient() + + globalThis.fetch = vi.fn(async () => { + return new Response( + JSON.stringify({ + message: 'Se excedió el límite de solicitudes.', + status: 429, + code: 'RATE_LIMIT_EXCEEDED', + path: 'date', + location: 'query', + errors: [ + { + code: 'required', + message: '"date" is required', + path: 'date', + location: 'query', + }, + ], + }), + { + status: 429, + headers: { + 'content-type': 'application/json', + 'retry-after': '3', + 'x-facturapi-log-id': 'log_123', + }, + }, + ) + }) as typeof fetch + + try { + await client.invoices.retrieve('inv_123') + throw new Error('Expected request to fail') + } catch (error) { + expect(error).toBeInstanceOf(FacturapiError) + expect((error as FacturapiError).message).toBe( + 'Se excedió el límite de solicitudes.', + ) + expect((error as FacturapiError).status).toBe(429) + expect((error as FacturapiError).code).toBe('RATE_LIMIT_EXCEEDED') + expect((error as FacturapiError).path).toBe('date') + expect((error as FacturapiError).location).toBe('query') + expect((error as FacturapiError).logId).toBe('log_123') + expect((error as FacturapiError).errors).toEqual([ + { + code: 'required', + message: '"date" is required', + path: 'date', + location: 'query', + }, + ]) + expect((error as FacturapiError).headers['retry-after']).toBe('3') + expect((error as FacturapiError).headers['x-facturapi-log-id']).toBe( + 'log_123', + ) + } + }) + it('falls back to raw text when non-OK JSON body is malformed', async () => { const client = createClient() diff --git a/test/web/runtime-compat.web.test.ts b/test/web/runtime-compat.web.test.ts index 289d9b3..d855cc9 100644 --- a/test/web/runtime-compat.web.test.ts +++ b/test/web/runtime-compat.web.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import Facturapi from '../../src' +import Facturapi, { FacturapiError } from '../../src' const originalFetch = globalThis.fetch const originalBuffer = (globalThis as any).Buffer @@ -127,6 +127,49 @@ describe('runtime compatibility (web simulation)', () => { ) }) + it('surfaces structured API errors and exposed headers in web-like runtime', async () => { + const client = createClient() + + globalThis.fetch = vi.fn(async () => { + return { + ok: false, + status: 429, + statusText: 'Too Many Requests', + headers: { + get(name: string) { + const values: Record = { + 'content-type': 'application/json', + 'retry-after': '3', + 'x-facturapi-log-id': 'log_123', + } + return values[name.toLowerCase()] || null + }, + }, + async text() { + return JSON.stringify({ + message: 'Se excedió el límite de solicitudes.', + status: 429, + code: 'RATE_LIMIT_EXCEEDED', + }) + }, + } as unknown as Response + }) as typeof fetch + + try { + await client.invoices.retrieve('inv_123') + throw new Error('Expected request to fail') + } catch (error) { + expect(error).toBeInstanceOf(FacturapiError) + expect((error as FacturapiError).status).toBe(429) + expect((error as FacturapiError).code).toBe('RATE_LIMIT_EXCEEDED') + expect((error as FacturapiError).logId).toBe('log_123') + expect((error as FacturapiError).headers['retry-after']).toBe('3') + expect((error as FacturapiError).headers['x-facturapi-log-id']).toBe( + 'log_123', + ) + } + }) + it('posts multiple receipts to invoice payload in web-like runtime', async () => { const client = createClient() const payload = {