Emitir CFDI

Complemento de pago (REP)

Registrar cobros de facturas PPD y timbrar el CFDI de tipo Pago que exige el SAT.

Cuando emites una factura con metodoPago: "PPD", el SAT te obliga a emitir un Recibo Electrónico de Pago por cada cobro que recibas. Es un CFDI de tipo Pago: consume un timbre y solo se deshace cancelando.

Scopes: payments.create, más invoices.read, accounts.read y payments.read para armarlo. Para cancelarlo, invoices.cancel.

Las reglas antes de escribir código

Un REP solo procede si:

  1. Cada factura relacionada está timbrada (status: "stamped", con uuid).
  2. Cada factura es PPD. Una PUE jamás lleva complemento.
  3. Todas las facturas de un mismo pago son del mismo cliente.
  4. La moneda del pago coincide con la de la cuenta bancaria destino.
  5. Los saldos cuadran por documento, a 2 decimales: importeSaldoAnterior − importePagado = importeSaldoInsoluto.
  6. El monto del pago es la suma de todos los importePagado, a 2 decimales.
  7. numParcialidad va consecutivo desde 1, por factura.

El receptor, el UsoCFDI (CP01) y la serie/folio del REP los pone Facty. Tú no los mandas.

Paso 1 — encuentra las facturas por cobrar

curl -s "$FACTY_HOST/api/authenticated/$FACTY_ORG/clients/clx9aa…/ppd-invoices" \
  -H "X-API-Key: $FACTY_KEY" | jq

O con el filtro general:

curl -s "$FACTY_HOST/api/authenticated/$FACTY_ORG/invoices?status=stamped&clientId=clx9aa…" \
  -H "X-API-Key: $FACTY_KEY" | jq

Paso 2 — lee el saldo real. No lo calcules.

GET /api/authenticated/{org}/invoices/{invoiceId}/balance
{
  "balance": {
    "total": "11600.00",
    "totalPagado": "0.00",
    "saldoPendiente": "11600.00",
    "parcialidadCount": 0,
    "estadoPago": "outstanding"    // outstanding | partial | paid | cancelled
  },
  "complementos": [ /* pagos ya aplicados a esta factura */ ]
}

De aquí sale todo lo que va en el documento del pago:

Campo del documentoDe dónde
importeSaldoAnteriorbalance.saldoPendiente
numParcialidadbalance.parcialidadCount + 1
importeSaldoInsolutosaldoPendiente − importePagado

Si estadoPago ya es "paid", no hay nada que complementar.

Paso 3 — elige la cuenta destino

curl -s "$FACTY_HOST/api/authenticated/$FACTY_ORG/accounts" \
  -H "X-API-Key: $FACTY_KEY" | jq

Scope: accounts.read. Usa una cuenta no archivada y con la misma moneda que el pago. Filtros: ?kind=bank|cash, ?includeArchived=true.

Paso 4 — registra y timbra en una sola llamada

POST /api/authenticated/{org}/payments
{
  "idempotencyKey": "mi-erp:prod:pago:9931",
  "accountId": "clx3f8…",
  "fechaPago": "2026-08-27",
  "formaPago": "03",
  "moneda": "MXN",
  "monto": 5800,
  "numOperacion": "TRX-4471",
  "stamp": true,
  "documents": [
    {
      "invoiceId": "clx9aa…",
      "numParcialidad": 1,
      "importeSaldoAnterior": 11600,
      "importePagado": 5800,
      "importeSaldoInsoluto": 5800
    }
  ]
}
CampoRequeridoNotas
accountIdCuenta bancaria o de efectivo donde se recibió el dinero.
fechaPago"YYYY-MM-DD". Un ISO con offset se normaliza a UTC.
formaPagoClave de 2 dígitos de c_FormaPago. Aquí no puede ser 99.
montoSuma de los importePagado.
documentsAl menos uno.
idempotencyKey8–128 caracteres, derivada de tu registro de pago.
monedanoDefault "MXN". Si no es MXN, manda tipoCambio.
numOperacionnoReferencia bancaria. Opcional pero muy recomendable.
stampnoDefault true: registra y timbra. Con false solo registra.

Respuesta 201:

{
  "paymentId": "clxp1…",
  "paymentInvoiceId": "clxi7…",
  "uuid": "A1B2C3D4-E5F6-7890-ABCD-EF1234567890"
}

uuid es el folio fiscal del REP. Con stamp: false viene null.

Sobre taxes

Cada documento admite un arreglo taxes para desglosar los ImpuestosDR. Omítelo. Facty los deriva de los conceptos de la factura original y los escala proporcionalmente al importe pagado, que es lo que exige el SAT.

Mándalo solo si el usuario pide explícitamente un desglose distinto. Y cuidado: [] no significa "calcúlalo tú", significa "sin impuestos" (ObjetoImpDR 01), lo cual provoca un rechazo del PAC si la factura sí traía IVA.

{
  "taxes": [
    { "name": "IVA", "rate": 0.16, "base": 5000, "total": 800 }
  ]
}

rate: null expresa un traslado exento (sin tasa ni importe). isRetention: true marca una retención.

Timbrar después

Si registraste con stamp: false:

POST /api/authenticated/{org}/payments/{paymentId}/stamp
{ "idempotencyKey": "mi-erp:prod:pago:9931:stamp" }

Solo aplica a un pago en estado registered y no cancelado. Si ya tiene REP, 409.

Un pago que cubre varias facturas

Un solo pago puede aplicar a varias facturas del mismo cliente:

{
  "idempotencyKey": "mi-erp:prod:pago:9932",
  "accountId": "clx3f8…",
  "fechaPago": "2026-08-27",
  "formaPago": "03",
  "monto": 20000,
  "documents": [
    { "invoiceId": "clxA…", "numParcialidad": 2, "importeSaldoAnterior": 11600, "importePagado": 11600, "importeSaldoInsoluto": 0 },
    { "invoiceId": "clxB…", "numParcialidad": 1, "importeSaldoAnterior": 15000, "importePagado": 8400, "importeSaldoInsoluto": 6600 }
  ]
}

Listar pagos

GET /api/authenticated/{org}/payments?limit=25&cursor=…

Scope: payments.read

{
  "payments": [
    {
      "id": "clxp1…",
      "fechaPago": "2026-08-27T00:00:00.000Z",
      "formaPago": "03",
      "moneda": "MXN",
      "monto": 5800,
      "numOperacion": "TRX-4471",
      "status": "registered",
      "uuid": "A1B2C3D4-…",
      "documentsCount": 1,
      "createdAt": "2026-08-27T16:11:03.000Z"
    }
  ],
  "nextCursor": null
}

Descargar el REP

Es un CFDI como cualquier otro. Usa el paymentInvoiceId:

GET /api/authenticated/{org}/invoices/{paymentInvoiceId}/pdf
GET /api/authenticated/{org}/invoices/{paymentInvoiceId}/xml
GET /api/authenticated/{org}/invoices/{paymentInvoiceId}/cfdi

Cancelar un REP

POST /api/authenticated/{org}/payments/{paymentId}/cancel
{ "motivo": "02" }

Scope: invoices.cancel — no payments.create. Cancelar aquí es anular un CFDI ante el SAT y consume un timbre; debe requerir la misma autoridad que cancelar cualquier otro comprobante. El motivo 01 exige además folioSustitucion con el UUID del complemento que lo sustituye.

Errores frecuentes

code / HTTPCausa habitual
422Los saldos no cuadran, la factura es PUE, hay clientes distintos en un mismo pago, o la moneda no coincide con la cuenta.
409Ese pago ya tiene REP, o la llave de idempotencia está en vuelo.
402 INSUFFICIENT_TIMBRESSin timbres. Manda al usuario a comprar.
412Falta perfil fiscal o CSD.