Saltar al contenido
dte-sdk

Emitir una nota de crédito

Emite una nota de crédito electrónica tipo 61 que referencia la factura folio 1024, ya sea para anularla por completo (código 1) o para corregir sus montos (código 3).

Esta página se basa en Emitir una factura y reutiliza su módulo lib/dte.ts. La factura que se corrige es la que se emitió ahí:

CampoValor
Tipo y folio33, folio 1024
Emitida2026-10-28
Receptor77.654.321-7, Comercial Los Andes Ltda.
LíneasLicencia anual 1 × 120000; Curso de capacitación 2 × 45000 (exento)
Totalesneto 120000, exento 90000, IVA 22800, total 232800

Qué necesitas#

  • Un CAF para el tipo 61. Cada tipo de documento tiene su propio CAF: timbrar una nota de crédito con el CAF de facturas lanza CAF_MISMATCH. Agrega DTE_CAF_61_PATH a tus variables de entorno.
  • La factura original, tal como la guardó el flujo de facturas: folio, fecha de emisión, receptor y líneas.
  • El mismo certificado que usas para las facturas.

1. Construye la nota de crédito#

Una sola función cubre las dos variantes. La referencia a la factura es lo que la convierte en una corrección, y createDte() la exige en el tipo 61:

TypeScript
// lib/credit-note.ts
import { readFile } from 'node:fs/promises'
import { createDte, parseCaf, signDte, stampDte, type Caf, type Dte, type LineItem } from 'dte-sdk'
import { ISSUER, getCredentials } from './dte'
 
let creditNoteCaf: Promise<Caf> | undefined
 
function getCreditNoteCaf(): Promise<Caf> {
  creditNoteCaf ??= readFile(process.env.DTE_CAF_61_PATH!, 'latin1').then(parseCaf)
  return creditNoteCaf
}
 
export type StoredInvoice = {
  folio: number
  issuedAt: string
  receiver: Dte['receiver']
  items: LineItem[]
}
 
export async function buildCreditNote(input: {
  folio: number
  invoice: StoredInvoice
  code: 1 | 3
  reason: string
  items: LineItem[]
}) {
  const [caf, { certificate }] = await Promise.all([getCreditNoteCaf(), getCredentials()])
 
  const dte = createDte({
    type: 61,
    folio: input.folio,
    issuer: ISSUER,
    receiver: input.invoice.receiver, // same receiver as the invoice
    items: input.items,
    references: [
      {
        type: 33,
        folio: input.invoice.folio,
        date: input.invoice.issuedAt,
        code: input.code,
        reason: input.reason,
      },
    ],
  })
 
  return signDte(stampDte(dte, caf), certificate)
}

2a. Anula la factura (código 1)#

Usa el código 1 cuando la factura completa no debería existir: un pedido cancelado, un duplicado, un documento emitido al cliente equivocado. Copia exactamente las líneas de la factura, para que la nota de crédito cancele hasta el último peso:

TypeScript
import { buildCreditNote } from '@/lib/credit-note'
import { db } from '@/lib/db'
 
const invoice = await db.invoices.get(1024)
const folio = await db.folios.next(61) // e.g. 501
 
const creditNote = await buildCreditNote({
  folio,
  invoice,
  code: 1,
  reason: 'Anula factura',
  items: invoice.items,
})
 
creditNote.totals
// { net: 120000, exempt: 90000, vatRate: 19, vat: 22800, total: 232800 }
 
if (creditNote.totals.total !== invoice.totals.total) {
  throw new Error('A voiding credit note must match the invoice total')
}

2b. Corrige los montos (código 3)#

Usa el código 3 cuando la factura sigue siendo válida pero algunos montos estaban mal. La nota de crédito lista solo la diferencia, nunca el monto completo corregido.

Supongamos que el cliente obtuvo un descuento de 20000 en la licencia después de emitida la factura, y dejó uno de los dos cupos de capacitación:

TypeScript
import { buildCreditNote } from '@/lib/credit-note'
import { db } from '@/lib/db'
 
const invoice = await db.invoices.get(1024)
const folio = await db.folios.next(61) // e.g. 501
 
const creditNote = await buildCreditNote({
  folio,
  invoice,
  code: 3,
  reason: 'Corrige montos: descuento en licencia y un cupo menos de capacitación',
  items: [
    { name: 'Descuento Licencia anual', quantity: 1, unitPrice: 20000 },
    { name: 'Curso de capacitación', quantity: 1, unitPrice: 45000, exempt: true },
  ],
})
 
creditNote.totals
// net:    20000
// vat:    round(20000 × 0.19) = 3800
// exempt: 45000
// { net: 20000, exempt: 45000, vatRate: 19, vat: 3800, total: 68800 }

Después de esta nota, la factura queda en la práctica en 232800 − 68800 = 164000: neto 100000, IVA 19000 y exento 45000.

Los montos de una nota de crédito son positivos. Es el tipo de documento el que le indica al SII que debe restarlos; un unitPrice negativo lanza INVALID_AMOUNT.

El código 2 corrige solo texto, como una dirección mal escrita en la factura, y no corrige montos. Esta página cubre los códigos 1 y 3.

3. Guárdala, envíala y haz su seguimiento#

El mismo patrón que con la factura: guarda primero el XML firmado, luego envía, y después guarda el track ID.

TypeScript
import { DteError, sendToSii } from 'dte-sdk'
import { SII, getCredentials } from '@/lib/dte'
import { db } from '@/lib/db'
 
await db.creditNotes.insert({
  folio: creditNote.folio,
  invoiceFolio: 1024,
  code: 1, // or 3
  totals: creditNote.totals,
  xml: creditNote.xml,
  state: 'signed',
})
 
try {
  const { certificate } = await getCredentials()
  const { trackId } = await sendToSii(creditNote, { certificate, ...SII })
  await db.creditNotes.update(creditNote.folio, { trackId, state: 'sent' })
} catch (error) {
  if (!(error instanceof DteError && error.code === 'SII_UNAVAILABLE')) throw error
  // Stays 'signed': upload it from the job, as with invoices
}

El job de estado del ejemplo de la factura funciona igual: llama a getStatus() con el track ID y busca el documento con type === 61 y el folio de la nota de crédito.

Errores esperables#

CódigoCausa típica en este flujo
MISSING_FIELDFalta la referencia a la factura (al llamar a createDte() directamente)
CAF_MISMATCHTimbrar con el CAF del tipo 33 en vez del CAF del tipo 61
FOLIO_OUT_OF_RANGELa secuencia de folios del tipo 61 superó el rango del CAF
INVALID_AMOUNTUn monto negativo en una línea

Relacionado#