Cobrá transferencias sin pedirle nada al que paga
AcequiaPay acredita solo las transferencias bancarias en pesos, sin comisión de procesador y sin que tu cliente tenga que mandarte un comprobante. Se integra en cualquier aplicación Node/TypeScript.
El problema, y por qué no es trivial
Una transferencia bancaria no trae referencia. Llega plata a tu cuenta y no hay ningún campo que diga de quién es ni qué está pagando.
Y hay algo peor, que sólo se descubre midiendo una cuenta real: cuando la transferencia llega
desde otro banco, Mercado Pago tampoco expone al remitente. Rellena el bloque
payer con los datos de la cuenta que recibe. Ni el CUIT ni el email
sirven para identificar a nadie.
Sin referencia y sin remitente, lo único que queda es el importe. Por eso lo firmamos:
Esos dos dígitos son la referencia. Salen del id del cobro, y son lo único que vincula la transferencia con quien la hizo. Los $37 van al saldo de tu cliente igual — no es un costo, es plata suya.
Quién pone qué
AcequiaPay pone lo idéntico en toda integración, y lo que se aprendió contra una cuenta con plata real:
- la firma del importe y la elección de slot libre;
- qué operaciones son plata entrante — incluido
account_fund, que es el caso más común y el que una integración ingenua pierde entero; - cuándo el bloque
payermiente; - cómo se recorre la cuenta sin perder ni duplicar nada;
- los dos identificadores del comprobante: número de operación y COELSA.
Vos ponés dos cosas, y sólo dos: dónde se guarda y qué significa acreditar en tu negocio.
Esa frontera es deliberada. Un motor de cobros que además decide cómo se asienta la plata en tu contabilidad es un motor que no podés auditar: tu libro mayor tiene que seguir siendo tuyo.
Instalación
npm install @acequiapay/node
Sin dependencias: usa fetch y node:crypto. Node ≥ 20.
| Variable | Obligatoria | Qué es |
|---|---|---|
| MP_ACCESS_TOKEN | sí | Token de producción de tu cuenta de Mercado Pago |
| ACEQUIAPAY_ALIAS | para transferencias | Alias o CVU donde recibís |
| ACEQUIAPAY_TITULAR | para transferencias | Nombre que tu cliente confirma antes de transferir |
| ACEQUIAPAY_COLLECTOR_ID | muy recomendada | El collector_id de tu cuenta |
| ACEQUIAPAY_CRON_SECRET | sí | Protege el endpoint que acredita. Mínimo 24 caracteres |
Implementar el puerto
Una sola interfaz. El contrato completo está tipado en puerto.ts; esto es la forma:
import type { Almacen } from "@acequiapay/node";
const almacen: Almacen = {
// Hasta dónde escaneaste. Sólo avanza si el tick salió entero.
leerCursor, escribirCursor, marcarCorrida,
// Cobros abiertos que ese importe podría estar pagando.
cobrosPara(monto, observadoEn, riel) { … },
// Registra el pago y, si hay cobro, acredita. EN UNA TRANSACCIÓN.
registrarYAcreditar({ pago, cobroId, estado, motivo }) { … },
vencerCobros(ahora) { … },
importesTomados(riel) { … },
};
Las tres reglas que no se negocian
1 · registrarYAcreditar tiene que ser atómico. El motor registra y
acredita en la misma llamada justamente para que las dos cosas pasen o no pase ninguna. Si las
partís, dos ticks simultáneos acreditan la misma transferencia dos veces.
2 · El duplicado se detecta con un UNIQUE en la base, no con un
SELECT previo. Un filtro en código es una promesa; un UNIQUE es una garantía que
sobrevive a un bug, a un reproceso manual y a dos crons a la vez.
3 · La ventana se mide contra cuándo se acreditó el pago, no contra
now(). Si el cron corre con retraso, una transferencia que llegó a tiempo no debe
leerse como tardía.
Usarlo
Al crear el cobro
const montoATransferir = await montoFirmadoLibre(almacen, cobro.id, 50_000, "alias");
if (montoATransferir === null) {
// Rango agotado. Negate a crear el cobro y pedí otro monto: crearlo
// igual sería prometer una acreditación que va a terminar a mano.
}
Guardá ese número y mostráselo grande. Es la única referencia que va a tener la transferencia.
Cada minuto, desde un cron
const r = await correrTick(almacen, { accessToken, collectorId });
// → { acreditados, sinAsignar, ambiguos, retenidos, duplicados, … }
Cuando tu cliente dice "ya transferí"
const r = await verificarComprobante(almacen, entradaDelFormulario, cfg);
Acepta los dos identificadores en el mismo campo y decide solo cuál es: puros dígitos es un número de operación de Mercado Pago, con letras es un COELSA. Tu cliente no tiene que saber la diferencia — y como la mayoría transfiere desde su homebanking, sin el COELSA buena parte de ellos no tendría ningún dato que pegar.
Si más de una app cobra en la misma cuenta
Es la forma más cara de perder plata con AcequiaPay, y no se ve venir. El escáner de cada app ve
todos los movimientos de la cuenta, no sólo los suyos. Y el UNIQUE
que impide el doble crédito vive en la base de cada app: no las protege entre sí.
La solución es partir el espacio de firmas en tramos disjuntos. Cada app emite sólo dentro del suyo y descarta de entrada los de la otra:
const [bandaA, bandaB] = repartirBandas(2);
// bandaA = { desde: 0, hasta: 49 } → $50.000, $50.013, $50.049…
// bandaB = { desde: 50, hasta: 99 } → $50.050, $50.087, $50.099…
// La MISMA banda en los dos lados, siempre.
await montoFirmadoLibre(almacen, cobro.id, 50_000, "alias", banda);
await correrTick(almacen, { accessToken, collectorId, banda });
Un importe de $50.037 sólo puede ser de A. El escáner de B lo ve, mira el resto y lo descarta sin consultar sus cobros — ni lo registra, así que su tabla no se llena con tráfico ajeno.
Emitir con una banda y escanear con otra es el peor modo de falla posible: todo parece andar y no se acredita nada nunca. Y no cambies el reparto con cobros abiertos: los importes ya emitidos quedarían fuera de banda.
El costo es alcance: con dos bandas cada app distingue 50 cobros abiertos del mismo importe base en vez de 100. Si eso te ajusta, la salida correcta es una cuenta de Mercado Pago por app, no bandas más finas.
Los errores que vas a cometer
Están todos medidos contra una cuenta real. Ninguno es hipotético.
Escanear sólo money_transfer
Es lo intuitivo y pierde la mayoría: las transferencias desde otro banco caen en account_fund. En una noche de producción medida, 3 de 5 acreditaciones reales fueron account_fund.
Leer payer.id == collector_id como "me lo mandé yo"
En INTER_PSP eso significa "no sé quién pagó", no "es mío". Tratarlos igual hace que ningún cliente que use otro banco pueda pagar nunca.
Usar el CUIT del payer para desempatar
En INTER_PSP es el tuyo. Da positivo con cualquiera.
Confiar en el payload del webhook
Lo que llega es un puntero, nunca la prueba. AcequiaPay siempre re-consulta con tu credencial.
Acreditar ante la ambigüedad
Dos cobros del mismo importe son indistinguibles. Adivinar es cómo se le acredita al cliente equivocado — que desde el lado del que no cobró es indistinguible de un robo.
Avanzar el cursor con un tick a medias
Re-procesar es gratis; perder una transferencia no, porque nadie la reclama: el cliente ya pagó y cree que está.
Probar sin mover plata
npx tsx scripts/acequiapay-verificar.ts
Sólo lee — son todos GET — así que se puede correr contra producción. Te dice el
collector_id real de tu cuenta, con qué operation_type llega cada cosa
en tu cuenta, y cuántos movimientos aceptaría o descartaría el escáner.
Implementaciones de referencia
EmpleadoVirtual.ai es el ejemplo más instructivo porque no creó ninguna
tabla de cobros: su PaymentIntent ya era "un cobro esperado" y su
Payment ya era un libro de eventos con la guarda de idempotencia. El adaptador
entero traduce vocabulario. Si tu app ya tiene un dominio de pagos, es probable que te pase lo
mismo.
Club del Aroma es el caso contrario: no había dominio de pagos, así que se modeló desde cero. Sirve de plantilla si arrancás en blanco.