diff -ru extractos-original/admin.py extractos/admin.py
--- extractos-original/admin.py	2026-09-26 22:33:37.231011300 -0300
+++ extractos/admin.py	2026-09-26 22:39:03.827441400 -0300
@@ -66,6 +66,14 @@
     if uid == g.usuario["id"] and accion in ("bloquear", "quitar_admin", "eliminar"):
         flash("No podés hacer eso con tu propia cuenta.", "error")
         return redirect(url_for("admin.panel"))
+    # sin cargo y administradores no pagan: si tenía un débito vigente se cancela antes, o seguiría cobrándole
+    # (y la página de suscripción ya no le muestra cómo cancelarlo)
+    if accion in ("exentar", "hacer_admin") and u["mp_preapproval_id"] and u["mp_estado"] in ("pending", "authorized", "paused"):
+        try:
+            pagos.cancelar_suscripcion(u)
+        except pagos.ErrorMP as e:
+            flash(f"No se pudo cancelar su débito de Mercado Pago ({e}); no se hizo el cambio.", "error")
+            return redirect(url_for("admin.panel"))
     if accion == "bloquear":
         db.actualizar_usuario(uid, bloqueado=1)
         db.evento(f"Bloqueado por el administrador {g.usuario['email']}", uid)
diff -ru extractos-original/app.py extractos/app.py
--- extractos-original/app.py	2026-09-26 22:33:37.248633700 -0300
+++ extractos/app.py	2026-09-26 22:45:54.787734100 -0300
@@ -3,6 +3,7 @@
 import re
 import os
 import tempfile
+import threading
 import time
 import uuid
 from collections import Counter
@@ -46,44 +47,66 @@
 
 LOTES = {}  # id -> {"creado": ts, "usuario": id, "archivos": [...]}
 VIDA_LOTE = 6 * 3600
+# gunicorn corre con 4 hilos: LOTES y los movimientos de cada lote (que clasificar() modifica) se tocan
+# siempre con este candado tomado, así dos pedidos simultáneos no se pisan.
+_CANDADO = threading.RLock()
 
 
 def _limpiar():
     ahora = time.time()
-    for k in [k for k, v in LOTES.items() if ahora - v["creado"] > VIDA_LOTE]:
-        LOTES.pop(k, None)
+    with _CANDADO:
+        for k in [k for k, v in LOTES.items() if ahora - v["creado"] > VIDA_LOTE]:
+            LOTES.pop(k, None)
+
+
+def _en_dolares(m):
+    return m.get("moneda") == "USD"
 
 
 def _armar_respuesta(lote_id):
+    with _CANDADO:
+        return _armar_respuesta_sin_candado(lote_id)
+
+
+def _armar_respuesta_sin_candado(lote_id):
     lote = LOTES[lote_id]
-    todos = []
+    todos = []       # solo pesos: los totales no mezclan monedas
+    dolares = []
     archivos = []
     for a in lote["archivos"]:
         if a.get("error"):
             archivos.append({"nombre": a["nombre"], "error": a["error"]})
             continue
         clasificador.clasificar(a["movimientos"], a["banco_clave"], db.reglas_de(lote["usuario"], a["banco_clave"]))
-        todos.extend(a["movimientos"])
-        archivos.append({"nombre": a["nombre"], "banco": a["banco"], "avisos": a["avisos"],
+        en_pesos = [m for m in a["movimientos"] if not _en_dolares(m)]
+        usd = [m for m in a["movimientos"] if _en_dolares(m)]
+        todos.extend(en_pesos)
+        dolares.extend(usd)
+        avisos = list(a["avisos"])
+        if usd:
+            t = clasificador.totalizar(usd)
+            avisos.append(f"Este resumen tiene {len(usd)} movimientos en dólares (U$S {pesos(t['total_leido']['debitos'])} "
+                          f"de débitos y U$S {pesos(t['total_leido']['creditos'])} de créditos). No se suman a los "
+                          "totales, que son en pesos; figuran aparte en el Excel.")
+        archivos.append({"nombre": a["nombre"], "banco": a["banco"], "avisos": avisos,
                          "resumen_banco": a["resumen_banco"], "cantidad": len(a["movimientos"]),
-                         "totales": clasificador.totalizar(a["movimientos"])})
+                         "totales": clasificador.totalizar(en_pesos)})
     # mismo movimiento (día, mes, concepto e importes) en dos archivos = períodos superpuestos
     duplicados = []
-    firmas = {}
+    firmas = []  # por posición y no por nombre: dos archivos pueden llamarse igual
     for a in lote["archivos"]:
         if a.get("error"):
             continue
         cont = Counter()
         for m in a["movimientos"]:
             partes = re.findall(r"\d+", m["fecha"])[:2]
-            cont[(m["banco_clave"], tuple(int(p) for p in partes), clasificador.normalizar(m["concepto"]),
-                  m["debito"], m["credito"])] += 1
-        firmas[a["nombre"]] = cont
-    nombres = list(firmas)
-    for i, x in enumerate(nombres):
-        for y in nombres[i + 1:]:
-            comunes = sum((firmas[x] & firmas[y]).values())
-            menor = min(sum(firmas[x].values()), sum(firmas[y].values()))
+            cont[(m["banco_clave"], m.get("moneda", "ARS"), tuple(int(p) for p in partes),
+                  clasificador.normalizar(m["concepto"]), m["debito"], m["credito"])] += 1
+        firmas.append((a["nombre"], cont))
+    for i, (x, fx) in enumerate(firmas):
+        for y, fy in firmas[i + 1:]:
+            comunes = sum((fx & fy).values())
+            menor = min(sum(fx.values()), sum(fy.values()))
             if menor and comunes >= max(3, 0.2 * menor):
                 duplicados.append(f"{comunes} movimientos de '{x}' también están en '{y}'. Parece el mismo período: "
                                   "quitá uno de los dos archivos para no contarlos dos veces.")
@@ -98,15 +121,18 @@
             s["debitos"] += m["debito"]
             s["creditos"] += m["credito"]
     movs = [{k: m[k] for k in ("archivo_nombre", "banco", "banco_clave", "pagina", "fecha", "concepto", "detalle",
-                               "debito", "credito", "categoria", "clave")} for m in todos]
-    return {"id": lote_id, "archivos": archivos, "duplicados": duplicados,"totales": clasificador.totalizar(todos),
+                               "debito", "credito", "categoria", "clave")} | {"moneda": m.get("moneda", "ARS")}
+            for m in todos + dolares]
+    return {"id": lote_id, "archivos": archivos, "duplicados": duplicados, "totales": clasificador.totalizar(todos),
+            "totales_usd": clasificador.totalizar(dolares) if dolares else None,
             "sin_clasificar": sorted(sin_clasificar.values(), key=lambda s: -(s["debitos"] + s["creditos"])),
             "movimientos": movs, "categorias": clasificador.CATEGORIAS,
             "orden_categorias": list(clasificador.CATEGORIAS)}
 
 
 def _lote_propio(lote_id):
-    lote = LOTES.get(lote_id)
+    with _CANDADO:
+        lote = LOTES.get(lote_id)
     return lote if lote and lote["usuario"] == g.usuario["id"] else None
 
 
@@ -155,7 +181,8 @@
             os.remove(ruta)
         lote["archivos"].append(item)
     lote_id = uuid.uuid4().hex
-    LOTES[lote_id] = lote
+    with _CANDADO:
+        LOTES[lote_id] = lote
     return jsonify(_armar_respuesta(lote_id))
 
 
@@ -166,11 +193,14 @@
     d = request.get_json(force=True)
     if d.get("banco_clave") not in LECTORES:
         return jsonify({"error": "Banco inválido."}), 400
-    if d.get("categoria") not in clasificador.CATEGORIAS or not str(d.get("clave", "")).strip():
+    clave = str(d.get("clave", ""))
+    if d.get("categoria") not in clasificador.CATEGORIAS or not clave.strip() or len(clave) > 2000:
         return jsonify({"error": "Datos inválidos."}), 400
-    db.guardar_regla(g.usuario["id"], d["banco_clave"], str(d["clave"])[:300], d["categoria"])
-    if _lote_propio(d.get("lote")):
-        return jsonify(_armar_respuesta(d["lote"]))
+    # la clave se guarda entera: recortada no coincidiría nunca con la del movimiento
+    db.guardar_regla(g.usuario["id"], d["banco_clave"], clave, d["categoria"])
+    with _CANDADO:
+        if _lote_propio(d.get("lote")):
+            return jsonify(_armar_respuesta(d["lote"]))
     return jsonify({"ok": True})
 
 
@@ -181,11 +211,12 @@
 @app.get("/api/excel/<lote_id>")
 @requiere_suscripcion
 def excel(lote_id):
-    if not _lote_propio(lote_id):
-        return "El análisis expiró; volvé a subir los archivos.", 404
+    with _CANDADO:
+        if not _lote_propio(lote_id):
+            return "El análisis expiró; volvé a subir los archivos.", 404
+        data = _armar_respuesta(lote_id)
     from openpyxl import Workbook
     from openpyxl.styles import Font
-    data = _armar_respuesta(lote_id)
     t = data["totales"]
     wb = Workbook()
     ws = wb.active
@@ -205,6 +236,14 @@
         ("Sin clasificar (NO incluidos en ningún total)", t["sin_clasificar"]["debitos"], t["sin_clasificar"]["creditos"]),
         ("Total leído de los PDF", t["total_leido"]["debitos"], t["total_leido"]["creditos"]),
     ]
+    u = data["totales_usd"]
+    if u:  # los movimientos en dólares van aparte: nunca se suman a los pesos
+        filas += [
+            (),
+            ("EN DÓLARES (U$S) — no incluidos en los totales en pesos", "Débitos", "Créditos"),
+            ("Total leído en dólares", u["total_leido"]["debitos"], u["total_leido"]["creditos"]),
+            ("  Sin clasificar", u["sin_clasificar"]["debitos"], u["sin_clasificar"]["creditos"]),
+        ]
     for f in filas:
         ws.append(list(f))
     for c in ws[1] + ws[8]:
@@ -215,11 +254,11 @@
         for c in ws[col]:
             c.number_format = "#,##0.00"
     wd = wb.create_sheet("Movimientos")
-    wd.append(["Archivo", "Banco", "Página", "Fecha", "Concepto", "Detalle", "Débito", "Crédito", "Categoría"])
+    wd.append(["Archivo", "Banco", "Página", "Fecha", "Concepto", "Detalle", "Débito", "Crédito", "Categoría", "Moneda"])
     for m in data["movimientos"]:
         wd.append([_texto(m["archivo_nombre"]), m["banco"], m["pagina"], m["fecha"], _texto(m["concepto"]), _texto(m["detalle"]),
                    m["debito"] or None, m["credito"] or None,
-                   data["categorias"].get(m["categoria"], "SIN CLASIFICAR")])
+                   data["categorias"].get(m["categoria"], "SIN CLASIFICAR"), m["moneda"]])
     for c in wd[1]:
         c.font = Font(bold=True)
     for col in "GH":
diff -ru extractos-original/cuentas.py extractos/cuentas.py
--- extractos-original/cuentas.py	2026-09-26 22:33:37.246072700 -0300
+++ extractos/cuentas.py	2026-09-26 22:45:54.790733600 -0300
@@ -3,6 +3,7 @@
 import hmac
 import re
 import secrets
+import sqlite3
 import time
 from datetime import datetime, timedelta
 from functools import wraps
@@ -76,6 +77,15 @@
     return hmac.new(config.SECRET_KEY.encode(), f"{uid}:{codigo}".encode(), hashlib.sha256).hexdigest()
 
 
+def reservar_envio(uid):
+    """Marca el envío ANTES de mandar: si ya se mandó un código hace menos de SEGUNDOS_REENVIO, devuelve False.
+    Es un solo UPDATE condicional, así dos pedidos simultáneos no mandan dos correos."""
+    limite = (datetime.now() - timedelta(seconds=SEGUNDOS_REENVIO)).isoformat(timespec="seconds")
+    with db.conectar() as con:
+        return con.execute("UPDATE usuarios SET codigo_enviado = ? WHERE id = ? AND (codigo_enviado IS NULL OR codigo_enviado < ?)",
+                           (db.ahora(), uid, limite)).rowcount == 1
+
+
 def enviar_codigo(u):
     """Genera un código de 6 dígitos, lo manda por correo y guarda su hash. Devuelve True si se envió."""
     codigo = f"{secrets.randbelow(1_000_000):06d}"
@@ -119,7 +129,14 @@
         elif u["codigo_intentos"] >= INTENTOS_CODIGO:
             flash("Demasiados intentos. Pedí un código nuevo.", "error")
         elif hmac.compare_digest(_hash_codigo(u["id"], codigo), u["codigo_hash"]):
-            db.actualizar_usuario(u["id"], email_verificado=1, codigo_hash=None, codigo_vence=None, codigo_intentos=0)
+            # solo si nadie reemplazó el registro (contraseña y código) desde que se leyó el usuario
+            with db.conectar() as con:
+                activada = con.execute("""UPDATE usuarios SET email_verificado = 1, codigo_hash = NULL, codigo_vence = NULL,
+                                          codigo_intentos = 0 WHERE id = ? AND pass_hash = ? AND codigo_hash = ?""",
+                                       (u["id"], u["pass_hash"], u["codigo_hash"])).rowcount
+            if not activada:
+                flash("El registro cambió mientras verificabas. Ingresá de nuevo.", "error")
+                return redirect(url_for("cuentas.ingresar"))
             db.evento("Email verificado", u["id"])
             flash("¡Email verificado! Tu cuenta está activa.", "ok")
             return redirect(url_for("cuentas.suscripcion"))
@@ -138,8 +155,7 @@
         return redirect(url_for("cuentas.ingresar"))
     if u["email_verificado"]:
         return redirect("/")
-    ultimo = u["codigo_enviado"]
-    if ultimo and datetime.now() - datetime.fromisoformat(ultimo) < timedelta(seconds=SEGUNDOS_REENVIO):
+    if not reservar_envio(u["id"]):
         flash("Esperá un minuto antes de pedir otro código.", "aviso")
     elif enviar_codigo(u):
         flash(f"Te mandamos un código nuevo a {u['email']}.", "ok")
@@ -175,7 +191,7 @@
     @requiere_ingreso
     @wraps(f)
     def envoltura(*a, **k):
-        if not g.usuario["es_admin"]:
+        if not g.usuario["es_admin"] or g.usuario["bloqueado"]:
             abort(403)
         return f(*a, **k)
     return envoltura
@@ -234,16 +250,31 @@
             flash(error, "error")
             return render_template("registro.html", email=email, nombre=nombre)
         if existente:
-            # registro anterior nunca verificado: se reemplaza (así nadie "ocupa" el email de otro)
+            # registro anterior nunca verificado: se reemplaza (así nadie "ocupa" el email de otro).
+            # La condición va en el UPDATE: si el dueño verificó entre la lectura y acá, no se toca nada.
             uid = existente["id"]
-            db.actualizar_usuario(uid, nombre=nombre, pass_hash=generate_password_hash(clave), codigo_enviado=None)
-        else:
             with db.conectar() as con:
-                uid = con.execute("INSERT INTO usuarios (email, nombre, pass_hash, email_verificado, creado) VALUES (?, ?, ?, 0, ?)",
-                                  (email, nombre, generate_password_hash(clave), db.ahora())).lastrowid
+                cambiados = con.execute("UPDATE usuarios SET nombre = ?, pass_hash = ?, codigo_hash = NULL "
+                                        "WHERE id = ? AND email_verificado = 0",
+                                        (nombre, generate_password_hash(clave), uid)).rowcount
+            if not cambiados:
+                flash("Ya existe una cuenta con ese email.", "error")
+                return render_template("registro.html", email=email, nombre=nombre)
+        else:
+            try:
+                with db.conectar() as con:
+                    uid = con.execute("INSERT INTO usuarios (email, nombre, pass_hash, email_verificado, creado) VALUES (?, ?, ?, 0, ?)",
+                                      (email, nombre, generate_password_hash(clave), db.ahora())).lastrowid
+            except sqlite3.IntegrityError:  # dos registros simultáneos con el mismo email
+                flash("Ya existe una cuenta con ese email.", "error")
+                return render_template("registro.html", email=email, nombre=nombre)
         db.evento("Usuario registrado desde la web (falta verificar el email)", uid)
-        iniciar_sesion(db.usuario(uid))
-        if enviar_codigo(db.usuario(uid)):
+        u = db.usuario(uid)
+        iniciar_sesion(u)
+        # registrarse de nuevo no saltea la espera entre códigos (si no, sirve para inundar una casilla ajena)
+        if not reservar_envio(uid):
+            flash(f"Ya te mandamos un código a {email}. Si no llegó, pedí otro en un minuto.", "aviso")
+        elif enviar_codigo(u):
             flash(f"Te mandamos un código de 6 dígitos a {email}.", "ok")
         else:
             flash("No se pudo enviar el código por correo. Tocá \"Enviar otro código\" en unos minutos.", "error")
@@ -354,5 +385,7 @@
     try:
         pagos.procesar_aviso(tipo, data_id)
     except Exception as e:
+        # sin 200 Mercado Pago reintenta el aviso; con 200 el pago se perdía del historial para siempre
         db.evento(f"Error procesando aviso de Mercado Pago ({tipo} {data_id}): {e}")
+        return "error", 500
     return "ok", 200
diff -ru extractos-original/db.py extractos/db.py
--- extractos-original/db.py	2026-09-26 22:33:37.231011300 -0300
+++ extractos/db.py	2026-09-26 22:39:04.178620700 -0300
@@ -143,11 +143,11 @@
 
 def tiene_acceso(u):
     """Admin siempre; el resto si no está bloqueado y es sin cargo o la suscripción no venció."""
-    if u is None:
+    if u is None or u["bloqueado"]:  # el bloqueo vale también para un administrador
         return False
     if u["es_admin"]:
         return True
-    if not u["email_verificado"] or u["bloqueado"]:
+    if not u["email_verificado"]:
         return False
     if u["exento"]:
         return True
diff -ru extractos-original/docker-compose.yml extractos/docker-compose.yml
--- extractos-original/docker-compose.yml	2026-09-26 22:33:37.248633700 -0300
+++ extractos/docker-compose.yml	2026-09-26 22:38:42.370362200 -0300
@@ -4,6 +4,9 @@
     build: .
     restart: unless-stopped
     env_file: .env
+    environment:
+      # los vencimientos son fechas de Argentina: en UTC cortaban el acceso a las 21 h del último día
+      TZ: America/Argentina/Buenos_Aires
     volumes:
       - ./data:/data
     ports:
diff -ru extractos-original/equipos.py extractos/equipos.py
--- extractos-original/equipos.py	2026-09-26 22:33:37.252495000 -0300
+++ extractos/equipos.py	2026-09-26 22:45:54.788734200 -0300
@@ -6,6 +6,7 @@
 """
 import hashlib
 import secrets
+import threading
 from datetime import datetime, timedelta
 
 from flask import Blueprint, flash, g, jsonify, redirect, render_template, request, url_for
@@ -16,6 +17,10 @@
 bp = Blueprint("equipos", __name__)
 COOKIE = "equipo"
 LIBERACIONES_POR_MES = 3
+# Contar puestos y registrar un equipo (o contar liberaciones y liberar) tiene que ser una sola operación:
+# con 4 hilos, dos equipos nuevos a la vez veían el mismo puesto libre y entraban los dos.
+# Alcanza con un candado del proceso porque la app corre con un solo worker (gunicorn -w 1).
+_CANDADO = threading.Lock()
 # páginas que se pueden ver desde un equipo no habilitado (para liberar equipos, pagar o salir)
 LIBRES = {"equipos.equipos", "equipos.liberar", "equipos.confirmar", "cuentas.suscripcion", "cuentas.pagar", "cuentas.volver",
           "cuentas.cancelar", "cuentas.cambiar_puestos", "cuentas.cuenta", "cuentas.salir", "cuentas.ingresar",
@@ -61,7 +66,7 @@
 
 def ajustar_a_puestos(uid, puestos):
     """Si el usuario pasa a tener menos puestos que equipos registrados, se liberan los usados hace más tiempo."""
-    with db.conectar() as con:
+    with _CANDADO, db.conectar() as con:
         sobrantes = con.execute("SELECT id, nombre FROM equipos WHERE usuario_id = ? ORDER BY ultimo_uso DESC LIMIT -1 OFFSET ?",
                                 (uid, max(1, puestos))).fetchall()
         for e in sobrantes:
@@ -83,7 +88,7 @@
         token = secrets.token_urlsafe(32)
         g.token_nuevo = token
     h = _hash(token)
-    with db.conectar() as con:
+    with _CANDADO, db.conectar() as con:
         e = con.execute("SELECT id FROM equipos WHERE usuario_id = ? AND token_hash = ?", (u["id"], h)).fetchone()
         if e:
             con.execute("UPDATE equipos SET ultimo_uso = ?, ip = ? WHERE id = ?",
@@ -92,7 +97,9 @@
         if u["es_admin"]:
             return  # el administrador no ocupa puestos
         usados = con.execute("SELECT COUNT(*) FROM equipos WHERE usuario_id = ?", (u["id"],)).fetchone()[0]
-        if usados < max(1, u["puestos"] or 1):
+        # los puestos se leen de nuevo acá adentro: g.usuario puede ser de antes de un cambio de puestos
+        puestos = con.execute("SELECT puestos FROM usuarios WHERE id = ?", (u["id"],)).fetchone()[0]
+        if usados < max(1, puestos or 1):
             con.execute("INSERT INTO equipos (usuario_id, token_hash, nombre, creado, ultimo_uso, ip) VALUES (?, ?, ?, ?, ?, ?)",
                         (u["id"], h, _nombre_equipo(request.headers.get("User-Agent")), db.ahora(), db.ahora(),
                          request.headers.get("X-Real-IP", request.remote_addr)))
@@ -199,11 +206,14 @@
     if p is None or p["equipo"] is None:
         return render_template("confirmar_liberacion.html", invalido=True), 410
     if request.method == "POST":
-        with db.conectar() as con:
-            con.execute("DELETE FROM liberaciones_pendientes WHERE token_hash = ?", (_hash(token),))
-        if liberaciones_restantes(p["usuario_id"]) <= 0:
-            return render_template("confirmar_liberacion.html", invalido=True,
-                                   motivo=f"Ya se liberaron {LIBERACIONES_POR_MES} equipos en los últimos 30 días."), 409
-        liberar_equipo(p["usuario_id"], p["equipo_id"])
+        with _CANDADO:
+            with db.conectar() as con:
+                usado = con.execute("DELETE FROM liberaciones_pendientes WHERE token_hash = ?", (_hash(token),)).rowcount
+            if not usado:  # otro pedido ya usó este enlace
+                return render_template("confirmar_liberacion.html", invalido=True), 410
+            if liberaciones_restantes(p["usuario_id"]) <= 0:
+                return render_template("confirmar_liberacion.html", invalido=True,
+                                       motivo=f"Ya se liberaron {LIBERACIONES_POR_MES} equipos en los últimos 30 días."), 409
+            liberar_equipo(p["usuario_id"], p["equipo_id"])
         return render_template("confirmar_liberacion.html", hecho=True, equipo=p["equipo"])
     return render_template("confirmar_liberacion.html", equipo=p["equipo"], email=p["email"], token=token)
diff -ru extractos-original/pagos.py extractos/pagos.py
--- extractos-original/pagos.py	2026-09-26 22:33:37.231011300 -0300
+++ extractos/pagos.py	2026-09-26 22:46:06.837863300 -0300
@@ -108,11 +108,19 @@
         raise ErrorMP("El administrador todavía no configuró el precio mensual.")
     monto = round(precio() * puestos, 2)
     # si ya tenía una suscripción pendiente o activa, se cancela para no cobrar dos veces
+    # Si no se puede cancelar, NO se crea la nueva: quedarían dos débitos y la vieja sin seguimiento.
     if u["mp_preapproval_id"] and u["mp_estado"] in ("pending", "authorized", "paused"):
         try:
             _api("PUT", f"/preapproval/{u['mp_preapproval_id']}", {"status": "cancelled"})
-        except ErrorMP:
-            pass
+        except ErrorMP as e:
+            try:
+                ya_cancelada = _api("GET", f"/preapproval/{u['mp_preapproval_id']}").get("status") == "cancelled"
+            except ErrorMP:
+                ya_cancelada = False
+            if not ya_cancelada:
+                db.evento(f"No se pudo cancelar la suscripción anterior ({u['mp_preapproval_id']}): {e}", u["id"])
+                raise ErrorMP("No se pudo cancelar tu suscripción anterior, así que no se creó otra para no "
+                              "cobrarte dos veces. Probá de nuevo en unos minutos.") from None
     r = _api("POST", "/preapproval", {
         "reason": db.get_config("nombre_servicio", "Suscripción mensual") + (f" – {puestos} puestos" if puestos > 1 else ""),
         "external_reference": f"usuario-{u['id']}",
@@ -154,28 +162,41 @@
     """Consulta la suscripción en Mercado Pago y recalcula hasta cuándo tiene acceso el usuario."""
     if not u["mp_preapproval_id"] or not configurado():
         return
-    p = _api("GET", f"/preapproval/{u['mp_preapproval_id']}")
+    pre_id = u["mp_preapproval_id"]
+    p = _api("GET", f"/preapproval/{pre_id}")
+    # la consulta a Mercado Pago tarda: lo que se compara es el usuario de ahora, no el de antes de la consulta
+    u = db.usuario(u["id"])
+    if u is None or u["mp_preapproval_id"] != pre_id:
+        return  # mientras tanto se creó otra suscripción (o se borró el usuario): esta respuesta ya no vale
     estado = p.get("status")
     resumen = p.get("summarized") or {}
     ultimo_cobro = _fecha(resumen.get("last_charged_date"))
-    campos = {"mp_estado": estado, "mp_proximo_cobro": (p.get("next_payment_date") or "")[:10] or None}
+    recien_autorizada = bool(estado == "authorized" and u["mp_estado"] != "authorized" and u["mp_puestos"]
+                             and u["mp_puestos"] != u["puestos"] and not u["exento"])
+    with db.conectar() as con:  # estado y puestos en la misma transacción, atados a esta suscripción
+        vigente = con.execute("UPDATE usuarios SET mp_estado = ?, mp_proximo_cobro = ?, puestos = CASE WHEN ? THEN ? ELSE puestos END "
+                              "WHERE id = ? AND mp_preapproval_id = ?",
+                              (estado, (p.get("next_payment_date") or "")[:10] or None,
+                               int(recien_autorizada), u["mp_puestos"], u["id"], pre_id)).rowcount
+    if not vigente:
+        return  # se reemplazó la suscripción mientras tanto
     if ultimo_cobro:
         # un cobro cubre un mes desde la fecha en que se hizo, más unos días de tolerancia por reintentos
         nuevo = (_sumar_mes(ultimo_cobro) + timedelta(days=config.DIAS_GRACIA)).isoformat()
-        if not u["vence"] or nuevo > u["vence"]:  # nunca acorta un vencimiento (p. ej. meses regalados por el admin)
-            campos["vence"] = nuevo
+        # nunca acorta un vencimiento (p. ej. meses regalados por el admin); la condición va en el UPDATE
+        with db.conectar() as con:
+            alargado = con.execute("UPDATE usuarios SET vence = ? WHERE id = ? AND mp_preapproval_id = ? "
+                                   "AND (vence IS NULL OR vence < ?)", (nuevo, u["id"], pre_id, nuevo)).rowcount
+        if alargado:
             db.evento(f"Pago registrado ({resumen.get('last_charged_amount')}); acceso hasta {nuevo}", u["id"])
     if estado != u["mp_estado"]:
         db.evento(f"Estado de la suscripción en Mercado Pago: {estado}", u["id"])
-    # con la suscripción confirmada, los puestos habilitados pasan a ser los contratados
-    if estado == "authorized" and u["mp_puestos"] and u["mp_puestos"] != u["puestos"] and not u["exento"]:
+    # Recién confirmada la suscripción, los puestos habilitados pasan a ser los contratados. Solo en ese
+    # momento: si no, cada sincronización deshacía los puestos que después fija a mano el administrador.
+    if recien_autorizada:
         import equipos
-        campos["puestos"] = u["mp_puestos"]
         db.evento(f"Puestos habilitados: {u['mp_puestos']}", u["id"])
-        db.actualizar_usuario(u["id"], **campos)
         equipos.ajustar_a_puestos(u["id"], u["mp_puestos"])
-        return
-    db.actualizar_usuario(u["id"], **campos)
 
 
 def registrar_pago_autorizado(pago_id):
diff -ru extractos-original/parsers/galicia.py extractos/parsers/galicia.py
--- extractos-original/parsers/galicia.py	2026-09-26 22:33:37.256911300 -0300
+++ extractos/parsers/galicia.py	2026-09-26 22:39:04.406724800 -0300
@@ -92,6 +92,12 @@
         if abs(calc - resumen["saldo_final"]) > 0.005:
             avisos.append(f"La lectura no cierra: saldo inicial {resumen['saldo_inicial']:,.2f} + créditos {cred:,.2f} "
                           f"- débitos {deb:,.2f} = {calc:,.2f}, pero el banco informa saldo final {resumen['saldo_final']:,.2f}")
+        # el saldo solo no alcanza: un débito y un crédito iguales que falten se compensan y el saldo cierra igual
+        if abs(cred - resumen["creditos"]) > 0.005:
+            avisos.append(f"Los créditos leídos suman {cred:,.2f}, pero el banco informa {resumen['creditos']:,.2f}.")
+        if min(abs(deb - resumen["debitos"]), abs(deb - resumen["debitos"] - resumen["iva"])) > 0.005:
+            avisos.append(f"Los débitos leídos suman {deb:,.2f}, pero el banco informa {resumen['debitos']:,.2f} "
+                          f"(más {resumen['iva']:,.2f} de IVA).")
     else:
         avisos.append("No se encontró el cuadro de totales del banco para controlar la lectura.")
     return {"banco": BANCO, "movimientos": movs, "resumen_banco": resumen, "avisos": avisos}
diff -ru extractos-original/parsers/macro.py extractos/parsers/macro.py
--- extractos-original/parsers/macro.py	2026-09-26 22:33:37.256911300 -0300
+++ extractos/parsers/macro.py	2026-09-26 22:39:04.462382200 -0300
@@ -13,7 +13,7 @@
 import pdfplumber
 
 BANCO = "Banco Macro"
-AMT = re.compile(r"^\$?\s*-?\s*\d{1,3}(?:[.,]\d{3})*[.,]\d{2}-?$")
+AMT = re.compile(r"^\$?\s*-?\s*(?:\d{1,3}(?:[.,]\d{3})+|\d+)[.,]\d{2}-?$")
 FECHA = re.compile(r"^\d{2}/\d{2}/(\d{2}|\d{4})$")
 TOL = 0.005
 
@@ -182,6 +182,7 @@
                 continue
             if FECHA.match(ws[0]["text"]) and ws[0]["x0"] < cols["ref"] - 150:
                 mv = _mov(path, pn, ws[0]["text"], cur_cta["cuenta"])
+                mv["moneda"] = "USD" if cur_cta["usd"] else "ARS"
                 desc, ref = [], []
                 for w in ws[1:]:
                     t = w["text"]
diff -ru extractos-original/parsers/nacion.py extractos/parsers/nacion.py
--- extractos-original/parsers/nacion.py	2026-09-26 22:33:37.256911300 -0300
+++ extractos/parsers/nacion.py	2026-09-26 22:39:04.514917400 -0300
@@ -10,7 +10,7 @@
 import pdfplumber
 
 BANCO = "Banco Nación"
-AMT = re.compile(r"^-?\d{1,3}(?:\.\d{3})*,\d{2}-?$")
+AMT = re.compile(r"^-?(?:\d{1,3}(?:\.\d{3})+|\d+),\d{2}-?$")
 FECHA = re.compile(r"^\d{2}/\d{2}/\d{2}(?:\d{2})?$")
 PERIODO = re.compile(r"PERIODO:\s*(\d{2}/\d{2}/\d{4})\s*AL\s*(\d{2}/\d{2}/\d{4})")
 CUIT_BNA = "30-50001091-2"
@@ -190,11 +190,13 @@
     varias = len(cuentas) > 1
     for s in segmentos:
         etiqueta = s["cuenta"]
-        if "DOLAR" in _sin_tildes((s.get("tipo") or "").upper()) or "U$S" in (s.get("tipo") or "").upper():
+        usd = "DOLAR" in _sin_tildes((s.get("tipo") or "").upper()) or "U$S" in (s.get("tipo") or "").upper()
+        if usd:
             etiqueta += " (USD)"
         s["etiqueta"] = etiqueta
         for m in s["movs"]:
             m["cuenta"] = etiqueta if varias else ""
+            m["moneda"] = "USD" if usd else "ARS"
 
     # controles de la lectura
     resumen = {}
diff -ru extractos-original/parsers/patagonia.py extractos/parsers/patagonia.py
--- extractos-original/parsers/patagonia.py	2026-09-26 22:33:37.252495000 -0300
+++ extractos/parsers/patagonia.py	2026-09-26 22:45:54.785733600 -0300
@@ -13,7 +13,7 @@
 
 BANCO = "Banco Patagonia"
 
-AMT = re.compile(r"^-?\d{1,3}(?:\.\d{3})*,\d{2}-?$")
+AMT = re.compile(r"^-?(?:\d{1,3}(?:\.\d{3})+|\d+),\d{2}-?$")
 FECHA_OF = re.compile(r"^\d{1,2}/\d{2}/\d{2}$")
 FECHA_WEB = re.compile(r"^\d{2}/\d{2}/\d{4}$")
 MARCAS = {"(", "%", "&"}  # caracteres de control que algunos extractos imprimen al margen
@@ -138,6 +138,7 @@
                 cols, mov = None, None
                 continue
             mov = {"archivo": path, "pagina": pn, "fecha": fecha, "cuenta": cur_cta["id"],
+                   "moneda": "USD" if " USD " in cur_cta["id"] else "ARS",
                    "lineas": [concepto] + ([" ".join(ref)] if ref else []),
                    "debito": 0.0, "credito": 0.0, "saldo": saldo, "_top": top}
             for c, v in montos:
@@ -210,6 +211,7 @@
             if FECHA_WEB.match(ws[0]["text"]):
                 desc, ref = [], []
                 m = {"archivo": path, "pagina": pn, "fecha": ws[0]["text"], "cuenta": "",
+                     "moneda": "USD" if _moneda(cuenta or "") == "USD" else "ARS",
                      "debito": 0.0, "credito": 0.0, "saldo": None, "_top": top, "_ref": ref}
                 for w in ws[1:]:
                     t = w["text"]
diff -ru extractos-original/templates/index.html extractos/templates/index.html
--- extractos-original/templates/index.html	2026-09-26 22:33:37.236816500 -0300
+++ extractos/templates/index.html	2026-09-26 22:45:54.786738200 -0300
@@ -134,9 +134,9 @@
     const ms = datos.movimientos.filter(m=>m.categoria===g);
     if (!ms.length) return '';
     const porClave = {};
-    ms.forEach(m=>{ const p = porClave[m.banco_clave+'\u0000'+m.clave] ||= {banco_clave:m.banco_clave,banco:m.banco,clave:m.clave,n:0,d:0,c:0}; p.n++; p.d+=m.debito; p.c+=m.credito; });
+    ms.forEach(m=>{ const p = porClave[m.banco_clave+'\u0000'+m.clave+'\u0000'+m.moneda] ||= {banco_clave:m.banco_clave,banco:m.banco,clave:m.clave,moneda:m.moneda,n:0,d:0,c:0}; p.n++; p.d+=m.debito; p.c+=m.credito; });
     const filas = Object.values(porClave).sort((a,b)=>(b.d+b.c)-(a.d+a.c))
-      .map(p=>`<tr><td>${esc(p.banco)}</td><td>${esc(p.clave)}</td><td class="num">${p.n}</td><td class="num">${fmt(p.d)}</td><td class="num">${fmt(p.c)}</td>
+      .map(p=>`<tr><td>${esc(p.banco)}</td><td>${esc(p.clave)}${p.moneda==='USD'?' <b>(U$S)</b>':''}</td><td class="num">${p.n}</td><td class="num">${fmt(p.d)}</td><td class="num">${fmt(p.c)}</td>
         <td>${selector(p.banco_clave, p.banco, p.clave, g==='sin_clasificar'?'':g)}</td></tr>`).join('');
     const nombre = datos.categorias[g] || 'Sin clasificar';
     return `<details data-grupo="${g}"${abiertos.includes(g)?' open':''}><summary>${esc(nombre)} — ${ms.length} movimientos</summary><div class="scroll"><table>
