diff --git a/README.md b/README.md index bdd38d5..af1226f 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,42 @@ await facturapi.invoices.sendByEmail(invoice.id, { }); ``` +### Educational services (IEDU complement) + +Schools issuing CFDIs to parents for tuition (colegiaturas) include the IEDU complement so that parents can claim the [Decreto de Deducción de Colegiaturas](https://www.sat.gob.mx/minisitio/DeduccionesPersonales/colegiaturas.html) on their annual ISR return. The SDK exports a typed `IeduComplementInput`, a `buildIeduComplement()` helper that returns the SAT-required XML, and an `IEDU_NAMESPACE` constant to register on the invoice. + +```ts +import Facturapi, { buildIeduComplement, IEDU_NAMESPACE } from 'facturapi'; + +const invoice = await facturapi.invoices.create({ + customer: 'YOUR_CUSTOMER_ID', + use: Facturapi.InvoiceUse.SERVICIOS_EDUCATIVOS, // D10 + payment_form: Facturapi.PaymentForm.TRANSFERENCIA_ELECTRONICA_DE_FONDOS, + items: [ + { + quantity: 1, + product: { + description: 'Colegiatura Mayo 2026 - Primaria', + product_key: '86121503', + unit_key: 'E48', + price: 5000, + taxes: [], + }, + complement: buildIeduComplement({ + nombreAlumno: 'JUAN PEREZ GARCIA', + CURP: 'PEGJ100515HDFRRN09', + nivelEducativo: 'Primaria', + autRVOE: 'ABC-123456', + rfcPago: 'PEGM800101AB1', // optional, only when payer ≠ receptor + }), + }, + ], + namespaces: [IEDU_NAMESPACE], +}); +``` + +`nivelEducativo` accepts: `Preescolar`, `Primaria`, `Secundaria`, `Profesional Técnico`, `Bachillerato o su equivalente` (university tuition is not deductible and therefore not part of IEDU). + ## Documentation Visit [docs.facturapi.io](https://docs.facturapi.io). diff --git a/src/index.ts b/src/index.ts index c57eef6..0615b1b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ import ComercioExteriorCatalogs from './tools/comercioExteriorCatalogs'; export * from './enums'; export * from './types'; +export { buildIeduComplement, IEDU_NAMESPACE } from './utils/iedu'; const VALID_API_VERSIONS = ['v1', 'v2']; diff --git a/src/types/complements.ts b/src/types/complements.ts index 0a27c15..e37e00c 100644 --- a/src/types/complements.ts +++ b/src/types/complements.ts @@ -76,3 +76,46 @@ export interface NominaComplementData { fecha_pago: string | Date; num_dias_pagados: number; } + +/** + * Education level for the IEDU (Instituciones Educativas) complement, + * as accepted by SAT. + * + * Reference: http://omawww.sat.gob.mx/informacion_fiscal/factura_electronica/Documents/Complementoscfdi/iedu.pdf + */ +export type IeduEducationLevel = + | 'Preescolar' + | 'Primaria' + | 'Secundaria' + | 'Profesional Técnico' + | 'Bachillerato o su equivalente'; + +/** + * Input for the IEDU (Instituciones Educativas) complement, applied per + * line item on tuition CFDIs from accredited K-12 schools. + * + * When provided on `items[].iedu`, the SDK serializes this object into + * the SAT-required XML fragment, sets `items[].complement` to that XML, + * and registers the iedu namespace on the invoice automatically. + * + * The IEDU complement is what enables parents to claim the colegiaturas + * deduction on their annual ISR return (Decreto 26-Dic-2013). Applies to + * Preescolar, Primaria, Secundaria, Profesional Técnico, and Bachillerato. + * University tuition is excluded. + */ +export interface IeduComplementInput { + /** Full name of the student. */ + nombreAlumno: string; + /** Student CURP (18-character SAT identifier). */ + CURP: string; + /** Education level — must match the SAT-accepted enumeration. */ + nivelEducativo: IeduEducationLevel; + /** RVOE authorization number issued by SEP for the school. */ + autRVOE: string; + /** + * RFC of the actual payer when different from the receptor of the CFDI + * (for example, when a grandparent pays for a child whose parent is the + * factura receptor). Optional. + */ + rfcPago?: string; +} diff --git a/src/utils/iedu.ts b/src/utils/iedu.ts new file mode 100644 index 0000000..89aa12b --- /dev/null +++ b/src/utils/iedu.ts @@ -0,0 +1,63 @@ +import type { XmlNamespace } from '../types/common'; +import type { IeduComplementInput } from '../types/complements'; + +/** + * IEDU XML namespace as accepted by FacturAPI when stamping CFDIs that include + * the Instituciones Educativas complement. Pass it in the invoice's top-level + * `namespaces` array alongside an item-level `complement` built with + * {@link buildIeduComplement}. + */ +export const IEDU_NAMESPACE: XmlNamespace = { + prefix: 'iedu', + uri: 'http://www.sat.gob.mx/iedu', + schema_location: 'http://www.sat.gob.mx/sitio_interet/cfd/iedu/iedu.xsd', +}; + +const IEDU_VERSION = '1.0'; + +function escapeXmlAttribute(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function assertNonEmpty( + value: string | undefined, + fieldName: string, +): asserts value is string { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error( + `IEDU complement: required field "${fieldName}" is missing or empty.`, + ); + } +} + +/** + * Serializes an {@link IeduComplementInput} into the SAT-required XML fragment + * that goes into the per-line-item `complement` field of an invoice. Pair it + * with {@link IEDU_NAMESPACE} on the invoice's top-level `namespaces` array. + * + * @throws Error if any required field is missing or empty. + */ +export function buildIeduComplement(input: IeduComplementInput): string { + assertNonEmpty(input.nombreAlumno, 'nombreAlumno'); + assertNonEmpty(input.CURP, 'CURP'); + assertNonEmpty(input.nivelEducativo, 'nivelEducativo'); + assertNonEmpty(input.autRVOE, 'autRVOE'); + + const attrs: string[] = [ + `version="${IEDU_VERSION}"`, + `nombreAlumno="${escapeXmlAttribute(input.nombreAlumno)}"`, + `CURP="${escapeXmlAttribute(input.CURP)}"`, + `nivelEducativo="${escapeXmlAttribute(input.nivelEducativo)}"`, + `autRVOE="${escapeXmlAttribute(input.autRVOE)}"`, + ]; + if (input.rfcPago !== undefined && input.rfcPago !== '') { + attrs.push(`rfcPago="${escapeXmlAttribute(input.rfcPago)}"`); + } + + return ``; +} diff --git a/test/invoice.spec.ts b/test/invoice.spec.ts index 47b911f..20ddf25 100644 --- a/test/invoice.spec.ts +++ b/test/invoice.spec.ts @@ -1,4 +1,8 @@ -import Facturapi from '..'; +import Facturapi, { + buildIeduComplement, + IEDU_NAMESPACE, + IeduComplementInput, +} from '..'; const facturapi = new Facturapi('YOUR_API_KEY_HERE'); @@ -55,3 +59,35 @@ const createInvoice = async () => { createInvoice() .then(() => console.log('invoice done')) .catch(console.error); + +// IEDU complement type-check example (consumed by tsd): +const ieduInput: IeduComplementInput = { + nombreAlumno: 'JUAN PEREZ GARCIA', + CURP: 'PEGJ100515HDFRRN09', + nivelEducativo: 'Primaria', + autRVOE: 'ABC-123456', + rfcPago: 'PEGM800101AB1', +}; + +const createTuitionInvoice = async () => { + await facturapi.invoices.create({ + customer: 'YOUR_CUSTOMER_ID', + use: Facturapi.InvoiceUse.SERVICIOS_EDUCATIVOS, + payment_form: Facturapi.PaymentForm.TRANSFERENCIA_ELECTRONICA_DE_FONDOS, + items: [ + { + quantity: 1, + product: { + description: 'Colegiatura Mayo 2026 - Primaria', + product_key: '86121503', + unit_key: 'E48', + price: 5000, + }, + complement: buildIeduComplement(ieduInput), + }, + ], + namespaces: [IEDU_NAMESPACE], + }); +}; + +createTuitionInvoice().catch(console.error); diff --git a/test/node/iedu.node.test.ts b/test/node/iedu.node.test.ts new file mode 100644 index 0000000..4b9fccd --- /dev/null +++ b/test/node/iedu.node.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest' + +import { buildIeduComplement, IEDU_NAMESPACE } from '../../src' + +describe('buildIeduComplement', () => { + it('serializes a typed iedu input into the SAT-shaped XML fragment', () => { + const xml = buildIeduComplement({ + nombreAlumno: 'JUAN PEREZ GARCIA', + CURP: 'PEGJ100515HDFRRN09', + nivelEducativo: 'Primaria', + autRVOE: 'ABC-123456', + }) + + expect(xml).toContain('$/) + }) + + it('includes rfcPago when provided', () => { + const xml = buildIeduComplement({ + nombreAlumno: 'A', + CURP: 'B', + nivelEducativo: 'Bachillerato o su equivalente', + autRVOE: 'C', + rfcPago: 'PEGM800101AB1', + }) + + expect(xml).toContain('rfcPago="PEGM800101AB1"') + }) + + it('omits rfcPago when explicitly empty string', () => { + const xml = buildIeduComplement({ + nombreAlumno: 'A', + CURP: 'B', + nivelEducativo: 'Primaria', + autRVOE: 'C', + rfcPago: '', + }) + + expect(xml).not.toContain('rfcPago') + }) + + it('escapes XML special characters in attribute values', () => { + const xml = buildIeduComplement({ + nombreAlumno: 'O\'Brien & Sons "Ltd"', + CURP: 'X', + nivelEducativo: 'Primaria', + autRVOE: 'Y', + }) + + expect(xml).toContain( + 'nombreAlumno="O'Brien & Sons "Ltd""', + ) + expect(xml).toContain('autRVOE="Y<Z>"') + }) + + it.each([ + [ + 'nombreAlumno', + { + nombreAlumno: '', + CURP: 'X', + nivelEducativo: 'Primaria' as const, + autRVOE: 'Y', + }, + ], + [ + 'CURP', + { + nombreAlumno: 'A', + CURP: ' ', + nivelEducativo: 'Primaria' as const, + autRVOE: 'Y', + }, + ], + [ + 'nivelEducativo', + { + nombreAlumno: 'A', + CURP: 'B', + nivelEducativo: '' as never, + autRVOE: 'Y', + }, + ], + [ + 'autRVOE', + { + nombreAlumno: 'A', + CURP: 'B', + nivelEducativo: 'Primaria' as const, + autRVOE: '', + }, + ], + ])( + 'throws when required field "%s" is missing or empty', + (fieldName, input) => { + expect(() => buildIeduComplement(input)).toThrow(new RegExp(fieldName)) + }, + ) +}) + +describe('IEDU_NAMESPACE', () => { + it('exposes the iedu prefix, uri and schema_location used by FacturAPI', () => { + expect(IEDU_NAMESPACE).toEqual({ + prefix: 'iedu', + uri: 'http://www.sat.gob.mx/iedu', + schema_location: 'http://www.sat.gob.mx/sitio_interet/cfd/iedu/iedu.xsd', + }) + }) +}) diff --git a/test/web/iedu.web.test.ts b/test/web/iedu.web.test.ts new file mode 100644 index 0000000..e75e9d3 --- /dev/null +++ b/test/web/iedu.web.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' + +import { buildIeduComplement, IEDU_NAMESPACE } from '../../src' + +describe('IEDU helpers (web simulation)', () => { + it('builds IEDU XML in browser-like runtimes', () => { + const xml = buildIeduComplement({ + nombreAlumno: 'JUAN PEREZ', + CURP: 'PEGJ100515HDFRRN09', + nivelEducativo: 'Primaria', + autRVOE: 'ABC-123', + }) + + expect(xml).toContain(' { + expect(IEDU_NAMESPACE.prefix).toBe('iedu') + expect(IEDU_NAMESPACE.uri).toBe('http://www.sat.gob.mx/iedu') + }) +})