Skip to content
Open
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
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];

Expand Down
43 changes: 43 additions & 0 deletions src/types/complements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
63 changes: 63 additions & 0 deletions src/utils/iedu.ts
Original file line number Diff line number Diff line change
@@ -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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}

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 `<iedu:instEducativas xmlns:iedu="${IEDU_NAMESPACE.uri}" ${attrs.join(' ')}/>`;
}
38 changes: 37 additions & 1 deletion test/invoice.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import Facturapi from '..';
import Facturapi, {
buildIeduComplement,
IEDU_NAMESPACE,
IeduComplementInput,
} from '..';

const facturapi = new Facturapi('YOUR_API_KEY_HERE');

Expand Down Expand Up @@ -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);
116 changes: 116 additions & 0 deletions test/node/iedu.node.test.ts
Original file line number Diff line number Diff line change
@@ -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('<iedu:instEducativas')
expect(xml).toContain('xmlns:iedu="http://www.sat.gob.mx/iedu"')
expect(xml).toContain('version="1.0"')
expect(xml).toContain('nombreAlumno="JUAN PEREZ GARCIA"')
expect(xml).toContain('CURP="PEGJ100515HDFRRN09"')
expect(xml).toContain('nivelEducativo="Primaria"')
expect(xml).toContain('autRVOE="ABC-123456"')
expect(xml).not.toContain('rfcPago')
expect(xml).toMatch(/\/>$/)
})

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<Z>',
})

expect(xml).toContain(
'nombreAlumno="O&apos;Brien &amp; Sons &quot;Ltd&quot;"',
)
expect(xml).toContain('autRVOE="Y&lt;Z&gt;"')
})

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',
})
})
})
22 changes: 22 additions & 0 deletions test/web/iedu.web.test.ts
Original file line number Diff line number Diff line change
@@ -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('<iedu:instEducativas')
expect(xml).toContain('CURP="PEGJ100515HDFRRN09"')
})

it('exports IEDU_NAMESPACE', () => {
expect(IEDU_NAMESPACE.prefix).toBe('iedu')
expect(IEDU_NAMESPACE.uri).toBe('http://www.sat.gob.mx/iedu')
})
})