Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: <https://www.npmjs.com/package/${packageName}/v/${version}|View package>`,
`• 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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -95,4 +95,4 @@
"vite": "^8.0.3",
"vitest": "^4.1.2"
}
}
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];

Expand Down
92 changes: 90 additions & 2 deletions src/wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
}

export class FacturapiError extends Error {
status: number;
code?: string;
path?: string;
location?: string;
errors?: FacturapiErrorDetail[];
logId?: string;
headers: Record<string, string>;

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<string, string> => {
const result: Record<string, string> = {};
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') || '';
Expand All @@ -34,10 +109,11 @@ const responseInterceptor = async (response: Response) => {
bodyText = null;
}

let errorData: Record<string, unknown> | 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<string, unknown>;
if (typeof errorData.message === 'string' && errorData.message.trim()) {
jsonMessage = errorData.message;
}
Expand All @@ -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 =
Expand Down
9 changes: 9 additions & 0 deletions test-d/runtime-types.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { expectAssignable, expectType, expectError } from 'tsd';
import Facturapi, {
BinaryDownload,
FacturapiError,
NodeLikeReadableStream,
TaxFactor,
} from '../dist';
Expand Down Expand Up @@ -29,3 +30,11 @@ if ('pipe' in binary && typeof binary.pipe === 'function') {
}

expectAssignable<TaxFactor>(TaxFactor.EXENTO);

declare const apiError: FacturapiError;
expectType<number>(apiError.status);
expectType<string | undefined>(apiError.code);
expectType<string | undefined>(apiError.path);
expectType<string | undefined>(apiError.location);
expectType<string | undefined>(apiError.logId);
expectType<Record<string, string>>(apiError.headers);
61 changes: 60 additions & 1 deletion test/node/runtime-compat.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()

Expand Down
Loading
Loading