Caso real · disputas y contracargosReal case · disputes & chargebacks
Catorce pasos que nadie
quería hacer todos los días.
Fourteen steps nobody
wanted to do every single day.
Cada disputa que un usuario abre contra un comercio dispara una cadena de trabajo manual: leer la alerta, identificar de qué comercio es, cargarla en la planilla de seguimiento, avisarle al emisor, archivar el correo, y cerrar el caso cuando se resuelve. Multiplicado por varios emisores, cada uno con su planilla y su formato. Esto es cómo lo saqué del medio, y, más importante, por qué está construido así y no de la forma obvia.
Every dispute a user opens against a merchant kicks off a chain of manual work: read the alert, identify the merchant, log it in the tracking sheet, notify the issuer, archive the email, close the case when it resolves. Multiplied by several issuers, each with its own sheet and its own format. This is how I took it off the table and, more importantly, why it's built this way and not the obvious way.
Sobre el código de esta página. Los fragmentos son ilustrativos y reescritos: muestran los patrones de diseño que usé, no el código fuente de mi empleador, que no es mío para publicar. Los nombres de emisores, comercios e identificadores están inventados. Lo que sí es exacto es la arquitectura y el razonamiento detrás de cada decisión.
About the code on this page. The snippets are illustrative and rewritten: they show the design patterns I used, not my employer's source, which isn't mine to publish. Issuer names, merchants and identifiers are made up. What is exact is the architecture and the reasoning behind each decision.
El problema, en concreto
The problem, concretely
El trabajo no era difícil. Era disperso. Para cerrar un solo día había que tener abiertos al mismo tiempo: un canal de Slack donde caen las alertas, la base de datos para saber a qué comercio corresponde cada una, tres planillas de terceros (una por emisor, cada una con columnas distintas) y la casilla de correo donde llegan los avisos formales.
The work wasn't hard. It was scattered. Closing a single day meant having open at the same time: a Slack channel where the alerts land, the database to know which merchant each one belongs to, three third-party spreadsheets (one per issuer, each with different columns) and the mailbox where the formal notices arrive.
Ninguno de esos pasos requiere criterio. Todos requieren atención, que es lo que se agota. Y el modo de falla no era hacerlo mal: era saltearse uno. Un caso que no se carga en la planilla del emisor es un caso que no se disputa, y un caso que no se disputa es plata.
None of those steps needs judgement. All of them need attention, which is the thing that runs out. And the failure mode wasn't doing it badly: it was skipping one. A case that doesn't make it into the issuer's sheet is a case that doesn't get disputed, and a case that doesn't get disputed is money.
Lo que costaba: sumando el turno de la mañana y el de la tarde, alrededor de ocho horas por día. Una jornada completa de una persona, todos los días, dedicada a mover datos de un sistema a otro. Hoy se dispara sola con el programador del sistema, y esas ocho horas son una corrida que se revisa en minutos.
What it cost: adding up the morning and the afternoon shift, around eight hours a day. A full person-day, every day, spent moving data from one system into another. Today it fires on the OS scheduler, and those eight hours are one run you review in minutes.
La arquitectura
The architecture
Slack ──────────┐
(alertas / alerts)│
▼
┌─────────────┐ ┌──────────────┐
│ orquestador │◄──────►│ PostgreSQL │
│ 14 pasos │ │ comercios, │
│ 14 steps │ │ transacciones │
└──────┬──────┘ └──────────────┘
│
┌────────────┼────────────┐
▼ ▼ ▼
Google Sheets Gmail Slack
planillas de avisos a marcar como
cada emisor emisores procesado
issuer sheets issuer mark as
notices processed
Cuatro sistemas externos, ninguno bajo mi control, cada uno con su propia manera de fallar. Esa frase es la que explica casi todas las decisiones que siguen.
Four external systems, none of them under my control, each with its own way of failing. That sentence explains nearly every decision that follows.
Los 14 pasos
The 14 steps
- Leer las alertas del canal · Clasificarlas contra la base · Comparar con lo ya cargado · Escribir los casos nuevos · Cerrar los resueltos · Marcar lo procesado en Slack · Informar a cada emisor
- Read the channel alerts · Classify them against the DB · Diff against what's logged · Append the new cases · Close the resolved ones · Mark processed items in Slack · Notify each issuer
- Actualizar planillas de terceros · Detectar comercios sin integrar · Cruzar legajos recibidos · Revisar documentación pendiente · Archivar los avisos del correo · Notificar los KYC cerrados · Espejar todo en el tracking
- Update third-party sheets · Flag merchants without integration · Reconcile received case files · Review pending documentation · Archive the inbox notices · Notify closed KYC cases · Mirror everything into tracking
Las cuatro decisiones que importan
The four decisions that matter
1. --dry-run por defecto, no como opción
1. --dry-run by default, not as an option
La forma obvia es escribir la rutina y agregarle un modo de prueba después. Lo hice al revés:
el modo de prueba es el default y escribir de verdad requiere pedirlo. La
razón es que esta rutina le manda correos a terceros y escribe en planillas que no son mías.
Una corrida equivocada no se deshace con un ctrl+z: ya salió el mail.
The obvious way is to write the routine and add a test mode afterwards. I did it backwards:
test mode is the default and writing for real has to be asked for. The
reason is that this routine emails third parties and writes into spreadsheets that aren't
mine. A wrong run doesn't undo with ctrl+z: the email is already out.
class Run:
"""Contexto de una corrida. Si dry_run es True, ningún
efecto externo ocurre, pero todo se calcula igual,
así el log muestra exactamente qué habría pasado."""
def __init__(self, dry_run=True, steps=None, days=7):
self.dry_run = dry_run # default seguro / safe default
self.steps = steps # None = todos / all
self.days = days
self.log = []
def commit(self, what, fn):
"""Único punto por donde pasa cualquier escritura externa."""
self.log.append(what)
if self.dry_run:
print(f" [dry-run] {what}")
return None
return fn()
El detalle que lo hace útil: en dry-run todo se calcula igual.
No se saltea la consulta ni el armado del mensaje: sólo no se ejecuta el efecto. Así el log
de una corrida de prueba es una lista exacta de lo que va a pasar, no una aproximación.
The detail that makes it useful: in dry-run, everything is still
computed. The query runs, the message gets built: only the effect is skipped. That
way the log of a test run is an exact list of what will happen, not an approximation.
2. Un flag por paso, porque los pasos fallan de a uno
2. One flag per step, because steps fail one at a time
Una rutina de 14 pasos contra cuatro sistemas externos no falla entera: falla en el paso 9 porque la API de Sheets devolvió un 429. Si el único modo de correrla es de principio a fin, arreglar eso significa repetir los ocho pasos que ya salieron bien, que es justo lo que manda correos duplicados.
A 14-step routine against four external systems doesn't fail as a whole: it fails at step 9 because the Sheets API returned a 429. If the only way to run it is start to finish, fixing that means repeating the eight steps that already succeeded, which is exactly what sends duplicate emails.
# Reanudar desde donde se rompió, sin repetir lo hecho # Resume from where it broke, without repeating what's done $ python rutina.py --steps 9-14 --commit # Un solo paso, en seco, para ver qué haría # One step, dry, to see what it would do $ python rutina.py --steps 7 --days 3 # Todo, de verdad: lo que corre el programador # Everything, for real: what the scheduler runs $ python rutina.py --commit
La ventana de días también es un parámetro por la misma razón: si algo estuvo caído dos
días, se recupera con --days 3 en vez de con una corrida especial escrita a mano.
The day window is a parameter for the same reason: if something was down for two days, you
recover with --days 3 instead of a hand-written one-off run.
3. Idempotencia antes que reintentos
3. Idempotency before retries
La consecuencia directa de las dos decisiones anteriores: si la rutina se puede reanudar y volver a correr, tiene que poder correrse dos veces sin duplicar nada. Por eso el paso 3 es «comparar con lo ya cargado» y no «cargar». Cada escritura se decide contra el estado actual del destino, no contra lo que la rutina cree recordar.
The direct consequence of the previous two: if the routine can resume and re-run, it has to survive running twice without duplicating anything. That's why step 3 is “diff against what's logged” and not “log it”. Every write is decided against the current state of the destination, not against what the routine thinks it remembers.
def casos_nuevos(alertas, planilla):
"""Lo que hay que escribir = lo que vino menos lo que ya está.
La fuente de verdad es el destino, no un archivo de estado
local: si alguien cargó una fila a mano, se respeta."""
ya_cargados = {fila["case_id"] for fila in planilla}
return [a for a in alertas if a.case_id not in ya_cargados]
Cuesta más que llevar un archivo de estado local, y vale la pena por un motivo que no es técnico: hay gente trabajando sobre las mismas planillas. Si alguien cargó una fila a mano el sábado, la rutina la tiene que respetar el lunes, no pisarla.
It costs more than keeping a local state file, and it's worth it for a reason that isn't technical: there are people working on those same sheets. If someone added a row by hand on Saturday, the routine has to respect it on Monday, not overwrite it.
4. Un panel web, no la terminal
4. A web panel, not the terminal
Esta es la decisión que más se nota en el uso y la que menos tiene que ver con programar. Una rutina que sólo yo puedo correr no resolvió el problema: lo mudó a mi máquina. Si estoy de vacaciones, vuelve el trabajo manual, y encima ahora nadie se acuerda de cómo se hacía.
This is the decision you feel most in daily use and the one least related to programming. A routine only I can run didn't solve the problem: it moved it onto my laptop. If I'm on holiday the manual work comes back, and now nobody remembers how it was done.
El panel expone lo mismo que los flags (elegir pasos, elegir ventana, correr en seco o de verdad) con botones y el log en pantalla. Dos cosas se ganaron con eso: alguien de Operaciones puede correrla sin pedirme nada, y el log en pantalla convirtió la rutina en algo auditable en vez de una caja negra en la que hay que confiar.
The panel exposes exactly what the flags do (pick steps, pick the window, dry or real) with buttons and the log on screen. Two things came out of that: someone in Operations can run it without asking me, and the on-screen log turned the routine into something auditable instead of a black box you have to trust.
Por qué el programador del sistema y no un cron. Es una laptop, no un
servidor: se cierra, se suspende, se apaga. cron asume que la máquina estaba
encendida a la hora exacta y, si no lo estaba, esa corrida simplemente no ocurrió y nadie se
entera. El programador de macOS, en cambio, ejecuta lo que quedó pendiente cuando la
máquina vuelve. Con una rutina idempotente y una ventana de días como parámetro, una
corrida tarde no molesta. Una corrida que nunca pasó, sí.
Why the OS scheduler and not cron. It's a laptop, not a server: it gets
closed, suspended, shut down. cron assumes the machine was awake at the exact
time, and if it wasn't, that run simply never happened and nobody finds out. The macOS
scheduler, by contrast, runs what was missed once the machine comes back.
With an idempotent routine and a day window as a parameter, a late run is harmless. A run
that never happened is not.
Qué aprendí construyéndolo
What building it taught me
Lo construí con Claude Code, y el aprendizaje más útil no fue sobre Python. Fue que el cuello de botella nunca estuvo en escribir el código: estuvo en saber qué tenía que hacer exactamente cada paso. Los 14 pasos existen porque hice el trabajo a mano durante meses. Esa parte no la puede hacer una IA, y es la que decide si la automatización sirve o hay que rehacerla.
I built it with Claude Code, and the most useful lesson wasn't about Python. It was that the bottleneck was never writing code: it was knowing exactly what each step had to do. The 14 steps exist because I did the work by hand for months. That part an AI can't do, and it's what decides whether the automation is useful or has to be rebuilt.
Lo segundo: escribir para cuatro APIs que no controlo me cambió la forma de leer un error de
integración en el otro sentido. Después de manejar los 429 de Sheets, la
paginación de Slack y los tokens que vencen, los tickets de comercios que me escriben porque
«la API falla» se leen distinto: ahora estuve de los dos lados del mismo problema.
The second: writing against four APIs I don't control changed how I read an integration error
from the other direction. After handling Sheets' 429s, Slack's pagination and
tokens that expire, tickets from merchants writing in because “the API is failing” read
differently: I've now been on both sides of the same problem.
El recorrido de diagnóstico que hay detrás de esto está en la otra página.
The diagnostic method behind all this is on the other page.
Cómo investigo un problemaHow I debug a problem sebastianlategano@gmail.com