AcequiaPay

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:

Pidió cargar $50.000
Transfiere $50.037

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.

Por qué no alcanza con firmar por hash Con diez cobros abiertos del mismo monto base, un hash colisiona el 40 % de las veces, y una colisión manda los dos cobros a revisión manual. AcequiaPay verifica el importe contra los ya tomados y se corre al siguiente libre: el auto-crédito es inequívoco por construcción, no por probabilidad.

Quién pone qué

AcequiaPay pone lo idéntico en toda integración, y lo que se aprendió contra una cuenta con plata real:

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.

VariableObligatoriaQué es
MP_ACCESS_TOKENToken de producción de tu cuenta de Mercado Pago
ACEQUIAPAY_ALIASpara transferenciasAlias o CVU donde recibís
ACEQUIAPAY_TITULARpara transferenciasNombre que tu cliente confirma antes de transferir
ACEQUIAPAY_COLLECTOR_IDmuy recomendadaEl collector_id de tu cuenta
ACEQUIAPAY_CRON_SECRETProtege 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í.

El escenario Una transferencia de $50.037 coincide con un cobro abierto en las dos apps. Las dos acreditan, y ninguna base puede detectarlo. Nadie se entera hasta que cuadran caja.

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.

Corré esto antes de anunciarlo Es la única forma de saber cómo se comporta tu cuenta en particular. Las cuentas no son todas iguales y la documentación de Mercado Pago no dice esto.

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.