/** * Facturo RD - Electronic Billing & Fiscal Emission API Client for Node.js / TypeScript. * Zero external dependencies. Uses native fetch (Node.js 18+, Bun, Deno, Cloudflare Workers, Next.js). */ export type DGIIEnv = 'testecf' | 'certecf' | 'ecf'; export type ECFType = | 31 // Factura de Crédito Fiscal Electrónica | 32 // Factura de Consumo Electrónica | 33 // Nota de Débito Electrónica | 34 // Nota de Crédito Electrónica | 41 // Compras Electrónico | 43 // Gastos Menores Electrónico | 44 // Regímenes Especiales Electrónica | 45 // Gubernamental Electrónico | 46 // Comprobante para Exportaciones Electrónico | 47 // Comprobante para Pagos al Exterior Electrónico | number; export interface SequenceRange { from_encf: string; to_encf: string; } export interface CancellationDetail { ecf_type: ECFType; sequence_ranges: SequenceRange[]; line_number?: number; } export interface CancelRangesRequest { details: CancellationDetail[]; company_id?: string; dgii_env?: DGIIEnv | string; } export interface CancelRangesResponse { rnc: string; code: string; name: string; total_cancelled: number; messages?: string[]; signed_xml?: string; signed_xml_path?: string; } export interface ECFEmissionRequest { company_id?: string; dgii_env?: DGIIEnv | string; allow_certification_reset?: boolean; ecf: Record; } export interface ECFEmissionResponse { document_id?: string; encf?: string; track_id?: string; internal_status: string; dgii_status: string; message?: string; signed_xml_path?: string; signed_xml?: string; qr_url?: string; security_code?: string; emission_exact?: string; } export interface InvoiceTrackingStatusResponse { track_id: string; invoice_number?: string; dgii_status: string; internal_status: string; last_status: string; last_updated_at: string; } export interface EmissionResult { document_id?: string; document_type_code?: string; encf?: string; track_id?: string; internal_status?: string; dgii_status?: string; message?: string; signed_xml_path?: string; signed_xml?: string; qr_url?: string; signature_code?: string; signature_datetime?: string; created_at?: string; total_amount?: number; } export interface FacturoClientOptions { /** * Base URL of Facturo API. Defaults to "https://ecf.facturord.app/". */ baseUrl?: string; /** * Integration API Key authorized with required scopes (e.g. documents.emit, documents.view). */ apiKey: string; /** * Optional default company UUID used when not passed explicitly in methods. */ defaultCompanyId?: string; /** * Request timeout in milliseconds. Defaults to 30,000 ms (30s). */ timeoutMs?: number; /** * Optional custom fetch implementation (useful for testing or specific runtime polyfills). */ customFetch?: typeof fetch; } export interface RequestOptions { companyId?: string; signal?: AbortSignal; } export interface GetSignedXmlOptions extends RequestOptions { dgiiEnv?: DGIIEnv | string; download?: boolean; } export class FacturoAPIError extends Error { public readonly statusCode: number; public readonly payload: any; constructor(message: string, statusCode: number, payload?: any) { super(message); this.name = 'FacturoAPIError'; this.statusCode = statusCode; this.payload = payload; // Restore prototype chain in transpiled environments Object.setPrototypeOf(this, new.target.prototype); } } export class FacturoClient { private readonly baseUrl: string; private readonly apiKey: string; private readonly defaultCompanyId?: string; private readonly timeoutMs: number; private readonly fetchImpl: typeof fetch; constructor(options: FacturoClientOptions) { if (!options.apiKey?.trim()) { throw new Error('FacturoClient: apiKey is required.'); } this.baseUrl = (options.baseUrl || 'https://ecf.facturord.app/').replace(/\/+$/, ''); this.apiKey = options.apiKey.trim(); this.defaultCompanyId = options.defaultCompanyId?.trim(); this.timeoutMs = options.timeoutMs ?? 30_000; this.fetchImpl = options.customFetch ?? fetch; if (typeof this.fetchImpl !== 'function') { throw new Error( 'FacturoClient: No global fetch implementation found. Ensure you are running Node.js 18+ or provide customFetch in options.' ); } } private resolveCompanyId(companyId?: string): string { const target = companyId || this.defaultCompanyId; if (!target) { throw new FacturoAPIError( 'company_id must be provided in FacturoClientOptions or as a method parameter.', 400 ); } return target.trim(); } private buildUrl(path: string, queryParams?: Record): string { const cleanPath = path.replace(/^\/+/, ''); const apiPath = cleanPath.startsWith('api/v1/') ? cleanPath : `api/v1/${cleanPath}`; const url = new URL(`${this.baseUrl}/${apiPath}`); if (queryParams) { for (const [key, value] of Object.entries(queryParams)) { if (value !== undefined && value !== null) { url.searchParams.set(key, value); } } } return url.toString(); } private async request( method: string, path: string, body?: any, queryParams?: Record, options?: RequestOptions, isRawText = false ): Promise { const url = this.buildUrl(path, queryParams); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs); // Merge abort signals if external signal is provided if (options?.signal) { options.signal.addEventListener('abort', () => controller.abort()); } try { const headers: Record = { 'X-API-Key': this.apiKey, 'User-Agent': 'FacturoNodeClient/1.0 (Node/TS)', Accept: isRawText ? 'application/xml, text/plain, */*' : 'application/json', }; let requestBody: string | undefined; if (body !== undefined && body !== null) { headers['Content-Type'] = 'application/json'; requestBody = JSON.stringify(body); } const response = await this.fetchImpl(url, { method, headers, body: requestBody, signal: controller.signal, }); if (!response.ok) { let errorPayload: any; let errorMessage = `Facturo API HTTP ${response.status} ${response.statusText}`; try { const text = await response.text(); try { errorPayload = JSON.parse(text); if (errorPayload && typeof errorPayload === 'object') { errorMessage = errorPayload.error || errorPayload.message || text; } } catch { errorPayload = text; if (text) { errorMessage = text; } } } catch { // Ignore read failures on error } throw new FacturoAPIError( `Facturo API error (${response.status}): ${errorMessage}`, response.status, errorPayload ); } if (isRawText) { return (await response.text()) as unknown as T; } const contentType = response.headers.get('content-type') || ''; if (contentType.includes('application/json')) { return (await response.json()) as T; } return (await response.text()) as unknown as T; } catch (err: any) { if (err.name === 'AbortError') { throw new FacturoAPIError(`Facturo API timeout after ${this.timeoutMs}ms connecting to ${url}`, 408); } if (err instanceof FacturoAPIError) { throw err; } throw new FacturoAPIError(`Facturo API network failure: ${err.message}`, 500, err); } finally { clearTimeout(timeoutId); } } // ----------------------------------------------------------------------------------------------- // Sequence Ranges Cancellation (DGII ANECF v1.0) // ----------------------------------------------------------------------------------------------- /** * Submit sequence cancellation (ANECF) to DGII to cancel unused or unaccepted sequence ranges. */ public async cancelRanges( details: CancellationDetail[], options?: RequestOptions & { dgiiEnv?: DGIIEnv | string } ): Promise { const companyId = this.resolveCompanyId(options?.companyId); const payload: CancelRangesRequest = { details, company_id: companyId, dgii_env: options?.dgiiEnv, }; return this.request( 'POST', `companies/${companyId}/emission/cancel-ranges`, payload, undefined, options ); } /** * Helper to void a single e-NCF sequence number. */ public async cancelSingleSequence( ecfType: ECFType, encf: string, options?: RequestOptions & { dgiiEnv?: DGIIEnv | string } ): Promise { const cleanEncf = encf.trim(); const details: CancellationDetail[] = [ { ecf_type: ecfType, sequence_ranges: [{ from_encf: cleanEncf, to_encf: cleanEncf }], }, ]; return this.cancelRanges(details, options); } /** * Helper to void a continuous range of e-NCF sequences. */ public async cancelSequenceRange( ecfType: ECFType, fromEncf: string, toEncf: string, options?: RequestOptions & { dgiiEnv?: DGIIEnv | string } ): Promise { const details: CancellationDetail[] = [ { ecf_type: ecfType, sequence_ranges: [{ from_encf: fromEncf.trim(), to_encf: toEncf.trim() }], }, ]; return this.cancelRanges(details, options); } // ----------------------------------------------------------------------------------------------- // Electronic Emission & Tracking // ----------------------------------------------------------------------------------------------- /** * Submit a complete e-CF document structure for fiscal signing and DGII emission. */ public async emitRichECF( ecfPayload: ECFEmissionRequest | Record, options?: RequestOptions ): Promise { const companyId = this.resolveCompanyId(options?.companyId); return this.request( 'POST', `companies/${companyId}/emission/ecf`, ecfPayload, undefined, options ); } /** * Query DGII reception and validation status for an emitted track ID. */ public async getTrackingStatus( trackId: string, options?: RequestOptions ): Promise { const companyId = this.resolveCompanyId(options?.companyId); return this.request( 'GET', `companies/${companyId}/emission/invoices/${encodeURIComponent(trackId.trim())}/status`, undefined, undefined, options ); } /** * Download or retrieve the digitally signed XML string for an emitted document. */ public async getSignedXml( documentIdOrEncf: string, options?: GetSignedXmlOptions ): Promise { const companyId = this.resolveCompanyId(options?.companyId); const queryParams: Record = {}; if (options?.download) queryParams.download = 'true'; if (options?.dgiiEnv) queryParams.dgii_env = options.dgiiEnv.toLowerCase(); return this.request( 'GET', `companies/${companyId}/emission/documents/${encodeURIComponent(documentIdOrEncf.trim())}/signed-xml`, undefined, queryParams, options, true ); } /** * Retry emission of a previously failed or pending document. */ public async retryEmission( documentId: string, options?: RequestOptions ): Promise { const companyId = this.resolveCompanyId(options?.companyId); return this.request( 'POST', `companies/${companyId}/emission/documents/${encodeURIComponent(documentId.trim())}/retry`, undefined, undefined, options ); } }