Ir al contenido

Decisiones técnicas (ADRs)

Estas son las decisiones técnicas significativas del proyecto, cada una con: qué se decidió, por qué y cuándo. Sirven para que una persona nueva entienda el “por qué” del código sin tener que reconstruirlo desde cero.

Las decisiones detalladas viven en tcgcards-api/docs/superpowers/specs/ y tcgcards-api/docs/superpowers/plans/. Acá hago el resumen ejecutivo.

Qué: tcgcards-web (Next.js) y tcgcards-api (Express) son repos separados con sus propios deploys.

Por qué:

  • El frontend cambia más rápido (UI iterations) → deploy automático a Vercel.
  • El backend cambia menos pero deploys son más cuidadosos → manual con gcloud run deploy.
  • Permite escalar independientemente.
  • Permite que en el futuro otros clientes (móvil) usen el mismo backend.

Trade-off: dos repos para mantener, y los secrets compartidos (INTERNAL_JWT_SECRET etc.) hay que rotar en ambos.

Qué: la BD primaria es Mongo, no SQL.

Por qué:

  • Modelo de listings es naturalmente discriminated union (kind: card/accessory/bulk/sealed) con campos distintos por tipo. Schema-on-read encaja bien.
  • Atlas tiene tier free generoso + backups built-in + Search incluido.
  • Mongoose ya provee schema validation a nivel de aplicación (Zod en routes + Mongoose en persistencia).

Trade-off: menos garantías de integridad referencial (hay que cuidar en código). Las transacciones existen pero son más caras que en Postgres.

Qué: el backend corre en Cloud Run (serverless containers de GCP).

Por qué:

  • Free tier alcanza para empezar.
  • Auto-scaling sin configuración.
  • Deploys atómicos con rollback trivial (cambiar tráfico entre revisiones).
  • Builds en GCP (no necesitas Docker local).

Trade-off: cold starts en la primera request después de inactividad (~2-3 seg). Para una app productiva con tráfico mínimo se nota; con tráfico real desaparece.

Backups en 2 capas (Atlas diario + mongodump horario a GCS)

Sección titulada «Backups en 2 capas (Atlas diario + mongodump horario a GCS)»

Qué: (1) snapshots diarios nativos de Atlas (~8 días de retención, restore 1-clic en la UI; el tier Flex actual no tiene PITR), y (2) un mongodump horario propio (Cloud Run Job mongo-backup) subido a GCS gs://tcgcards-mongo-backups/ (30 días).

Por qué:

  • La capa horaria da mejor RPO (~1 h vs ~1 día) y vive fuera de Atlas (regla 3-2-1): sobrevive a un problema de la cuenta/cluster de Atlas.
  • La capa de Atlas da el restore más simple (botón en la UI) para todo el cluster.
  • El usuario de la BD de la app es least-privilege (no puede dropDatabase) → un script con bug no puede tirar abajo una base entera.

Verificado: restore drill real exitoso el 2026-05-29 (0 documentos fallidos). Runbook a prueba de tontos en runbooks.

Mercado Pago (no Stripe, no Webpay, no Flow)

Sección titulada «Mercado Pago (no Stripe, no Webpay, no Flow)»

Qué: procesador único de pagos.

Por qué:

  • Stripe no opera en Chile como acquirer todavía (2026).
  • Webpay y Flow tienen integración más compleja y comisiones similares.
  • MP soporta tarjetas, transferencias, MercadoCrédito, y cuotas — la mayor cobertura.
  • Webhooks confiables.

Trade-off: estamos atados a las particularidades de MP (cambio de TEST tokens a APP_USR- en 2026, etc.).

Qué: Los vendedores cobran a un wallet interno (user.walletBalance + tabla wallet_entries) y retiran a su cuenta bancaria con un job manual del admin.

Por qué:

  • Evita complejidad de “split payments” en MP (que requiere KYC de cada vendedor con MP).
  • Nos da control sobre cuándo liberamos los fondos (post-confirmación de recepción del comprador).
  • El owner (admin) puede ver el flujo completo y reconciliar manualmente.

Trade-off: necesitamos hacer transferencias manuales a los bancos de los vendedores. Funciona bien hasta cierta escala; después automatizar via API bancaria.

Panel de finanzas: “un pote, sobres virtuales” (derivado, sin tocar el flujo de pagos) — 2026-06-02

Sección titulada «Panel de finanzas: “un pote, sobres virtuales” (derivado, sin tocar el flujo de pagos) — 2026-06-02»

Qué: Un panel de admin (/admin/finanzas) muestra cuánto del pozo único (la cuenta de MP donde caen todos los pagos) corresponde a cada concepto: ganancia de tcgcards, IVA por pagar al SII, fondo por pagar a usuarios, comisión MP, y en tránsito (órdenes pagadas sin completar). Incluye un chequeo de salud (esperado vs. saldo real de MP), desglose por mes (para el F29) y movimientos.

Cómo: todo se deriva leyendo órdenes/payments/ledger existentes — la comisión (7%) e IVA (19% sobre la comisión) por orden se reproducen con la misma fórmula floor que OrderService.completeInternal. No se modifica nada del flujo de pagos (checkout / PaymentService / completeInternal / RefundService / WithdrawalService). La única escritura nueva es registrar pagos de IVA, sobre una cuenta de sistema separada user-system-iva (movimiento iva_payment); los retiros de ganancia reusan owner_draw.

Por qué derivar y no separar el IVA en el ledger: evita tocar la ruta crítica de dinero, y además el mp_fee se debita al pagar mientras la comisión se acredita al completar, así que el saldo crudo del system wallet no sería una “ganancia” limpia de todos modos. Derivar desde órdenes completadas (comisión − mpFee) da el número correcto. Exacto mientras la tasa sea constante; versionar solo si cambia (YAGNI).

Reversibilidad: una orden o se reembolsa (antes de completarse → sin reparto) o se completa (y reparte → sin reembolso posterior); son mutuamente excluyentes, así que los sobres (derivados de órdenes completadas) se ajustan solos. El reembolso recupera el fee de MP (mp_fee_reversal). El fee de MP lo absorbe tcgcards (sale de la comisión), no el vendedor.

Detalle: spec tcgcards-api/docs/superpowers/specs/2026-06-02-panel-finanzas-admin-design.md.

Reembolsos: ventana de 30 días a la tarjeta, luego al wallet — 2026-06-03

Sección titulada «Reembolsos: ventana de 30 días a la tarjeta, luego al wallet — 2026-06-03»

Qué: Cuando reembolsamos una orden individual, intentamos devolver a la tarjeta (vía API de MP, reembolso total PaymentRefund.totalPOST /v1/payments/{id}/refunds con body vacío) solo si el pago tiene menos de 30 días. Pasado ese plazo, o si MP rechaza, el reembolso va al wallet del comprador (retirable). El umbral es la env var REFUND_MP_WINDOW_DAYS (default 30), leída en RefundService. (Las órdenes grupales de carrito multi-vendedor reembolsan siempre al wallet, por construcción — no usan esta ventana.)

Por qué 30 y no el límite de MP: MP permite reembolsos hasta 180 días desde la aprobación del pago (doc oficial Checkout Pro → Reembolsos → “Plazo de reembolso”). El 30 es una elección nuestra, conservadora, no un límite técnico:

  • Cubre de sobra el ciclo normal de una orden (SLA máximo ~14 días para completar + margen de disputa), así que casi todo reembolso real cae dentro de la ventana y vuelve a la tarjeta.
  • Dentro de ese plazo la plata sigue retenida en nuestra cuenta de MP (al vendedor solo se le acredita al completar la orden), así que el “saldo suficiente en la cuenta” que MP exige para reembolsar está garantizado — no arriesgamos un rechazo por saldo.
  • Más allá de los 30 días preferimos el wallet (que el usuario puede retirar) antes que arriesgar un rechazo de MP. Subir el umbral hasta 180 es solo cambiar la env var, sin tocar código.

Ojo — no confundir dos “30 días” de MP: el “30 días” que aparece en la doc de MP es el plazo de expiración de un pago sin confirmar (operación de cancelación, PUT /v1/payments/{id} con status:canceled), que no usamos. Nosotros siempre reembolsamos pagos ya aprobados (reembolso, no cancelación), cuyo techo real es 180 días.

No-doble-reembolso: RefundService exige payment.status === 'confirmed' y al terminar lo marca 'refunded', así que una segunda llamada choca con ConflictError antes de tocar MP. El cron RefundReconcileService es read-only a propósito (solo reporta inconsistencias para revisión manual) para no arriesgar un doble reembolso si MP ya devolvió. Una orden o se reembolsa (antes de completarse) o se completa (y reparte), nunca ambas. Antes de actuar a mano sobre una inconsistencia, el admin debe consultar la lista de reembolsos de MP (GET /v1/payments/{id}/refunds) para ver si ya se reembolsó.

Carrito multi-vendedor con pago combinado (Opción B: top-up al wallet) — 2026-06-03

Sección titulada «Carrito multi-vendedor con pago combinado (Opción B: top-up al wallet) — 2026-06-03»

Qué: El carrito acepta items de varios vendedores y se pagan todos juntos en un solo pago de MP, pero la plata de cada vendedor se liquida en su wallet por el proceso per-vendedor de siempre (sin tocar la ruta de dinero existente). Se crea una orden por vendedor + un agregado checkout; cada orden avanza/se cancela/se completa por separado. Mínimo $1.500 por vendedor.

Cómo (Opción B): el pago combinado se modela como un top-up al wallet del comprador del que se fondea cada orden. En la confirmación (confirmCheckoutAndAdvanceOrders): topup +total al comprador, −subtotal por orden (suman −total → net-0 del comprador, con guard que aborta si no cuadra), mp_fee al sistema. El vendedor se acredita solo al completar cada orden (igual que una orden individual). 1 vendedor en el carrito = flujo individual de siempre (reembolso a tarjeta); 2+ = este flujo combinado.

Por qué Opción B y no split payments / N pagos: un solo pago = una sola fricción de checkout para el comprador y un solo mp_fee. Modelarlo como top-up reusa el wallet existente sin tocar PaymentService/completeInternal/WithdrawalService. Evita el KYC por-vendedor que exigiría el split nativo de MP.

Reembolsos: una sub-orden grupal cancelada siempre reembolsa al wallet (no a tarjeta) — el comprador pagó vía un top-up combinado, así que la parte de esa sub-orden vuelve a ese mismo wallet. El mínimo de $1.500/vendedor garantiza que todo reembolso de sub-orden supera el mínimo de retiro ($1.000), así que nunca queda plata atrapada. Invariante clave: una orden tiene paymentId (individual→MP) XOR checkoutId (grupal→wallet), disjuntos por construcción → imposible reembolsar por MP y wallet a la vez.

Detalle: spec/plan tcgcards-api/docs/superpowers/specs/…carrito-multi-vendedor… y …/plans/….

Cancelación con claim atómico (CAS) para no duplicar reembolsos — 2026-06-03

Sección titulada «Cancelación con claim atómico (CAS) para no duplicar reembolsos — 2026-06-03»

Qué: La transición de una orden a canceled se reclama atómicamente con OrderRepository.claimCancellation (CAS {pending|accepted}→canceled), espejo de claimCompletion. Solo el ganador del claim restaura stock, postea el mensaje de sistema y dispara el reembolso.

Por qué: Antes la cancelación hacía un updateById(status='canceled') incondicional tras un check-then-act sin lock. Bajo cancelación concurrente (doble-click, dos pestañas, o cancel manual coincidiendo con el cron SLA de auto-cancelación) el mismo pedido podía restaurar stock 2 veces y reembolsar 2 veces (doble MP en individual, doble crédito wallet en grupal). El feature de carrito + el fix de SLA añadieron un segundo actor (el cron) que puede correr en paralelo con el cancel manual, aumentando la exposición. Detectado y corregido en la auditoría de pagos pre-deploy (test de concurrencia rojo→verde).

El comprador no puede arrepentirse tras “Aceptada” (anti-estafa) + accept/ship con CAS — 2026-06-26

Sección titulada «El comprador no puede arrepentirse tras “Aceptada” (anti-estafa) + accept/ship con CAS — 2026-06-26»

Qué: El comprador solo cancela mientras la orden está pending. Una vez que el vendedor acepta (accepted), solo el vendedor puede cancelar (p.ej. error de stock), lo que igual reembolsa al comprador. shipped/completed siguen sin cancelación manual. Además, accept/ship ahora usan CAS (claimAcceptance {pending→accepted}, claimShipment {accepted→shipped}), completando la cobertura atómica del ciclo junto a claimCancellation/claimCompletion.

Por qué: Antes el comprador también podía cancelar (con reembolso + restauración de stock) en accepted. Eso abría una ventana de estafa: el vendedor acepta y empieza a preparar/despachar, y el comprador cancela y recupera su plata (recibiendo igual el producto). Cerrar la cancelación del comprador en accepted lo evita; el vendedor mantiene el escape por stock y, si nunca despacha, el SLA igual auto-cancela y reembolsa al comprador a los 7 días (processAccepted, 168h). El CAS en accept/ship evita que un accept/ship tardío sobreescriba una cancelación que ganó la carrera (la orden reembolsada “reviviría”). UI: el comprador en accepted ya no ve botón de cancelar; al aceptar, el vendedor confirma con un diálogo (“ten el stock antes de aceptar”). No toca la ruta de dinero (reembolso/escrow), solo la guarda de permiso y la atomicidad de las transiciones.

Qué: versión más reciente de Next.js, no Pages Router.

Por qué:

  • Server Components reducen JS shipped al browser.
  • Server Actions eliminan boilerplate de API routes para mutations.
  • Streaming/Suspense para UX mejor.

Trade-off: Next.js 16 tiene breaking changes vs versiones previas. Hay que leer AGENTS.md antes de tocar código.

Qué: Tailwind v4 (config en CSS, no JS) + shadcn como sistema de componentes.

Por qué:

  • shadcn da accesibilidad (Radix) sin lockin de librería pesada (Material UI, Chakra).
  • Tailwind v4 es más rápido y simple.
  • Permite el design system “Onyx + Champagne” custom con tokens shadcn.

Qué: login solo con Google por ahora.

Por qué:

  • Sin password reset, magic link emails, ni soporte recovery — todo problema delegado a Google.
  • La mayoría del público objetivo tiene Gmail.

Trade-off: usuarios sin Gmail no pueden registrarse (raro en Chile).

Qué: Todos los colores en componentes vienen de tokens shadcn. Solo los templates de email tienen colores literales.

Por qué:

  • Dark/light theme funciona desde el primer commit sin reescribir.
  • Cambio de paleta = cambio en globals.css, no en cada componente.
  • Los emails son una excepción porque los clientes de email no soportan CSS variables consistentemente.

Qué: Ningún server component asume “pokemon” como TCG por defecto. Server components fetchean getTcgs() y propagan availableTcgs a clientes.

Por qué:

  • Si en el futuro deshabilitamos Pokémon o agregamos un TCG nuevo, no rompemos nada.
  • Los placeholders no muestran iconografía de un TCG específico.

Qué: Backend guarda y maneja precios como centavos enteros (e.g., $15.000 CLP = 1500000). Frontend convierte para display con formatPriceCents().

Por qué:

  • Aritmética de floats da errores de redondeo. Con integers nunca pasa.
  • MP también usa cents en su API.

Qué: Si TS dice condition: string | null, el Mongoose schema usa default: null, NO required: true.

Por qué:

  • Si están desalineados, inserts fallan con errores raros. Bug real ocurrió 2026-05-19 con condition/language/variant en listings de tipos accessory/bulk.

Switch exhaustivo con default: const _exhaustive: never

Sección titulada «Switch exhaustivo con default: const _exhaustive: never»

Qué: Toda switch sobre kind (card/accessory/bulk/sealed) termina con default: const _exhaustive: never.

Por qué:

  • Si alguien agrega un nuevo kind (packs, etc.) y no actualiza todos los switches, falla compilación.
  • Bug real 2026-05-19: bulk listings caían silently al else en CartService y OrderService porque no había exhaustividad.

Detalle del incidente y checklist al agregar nuevo kind: ver memoria feedback_new_listing_kind_checklist.md.

Qué: Archivos como src/lib/env.ts, src/lib/jwt.ts, src/lib/api/client.ts empiezan con import 'server-only'.

Por qué:

  • Si alguien importa por error en un client component, el build de Next.js falla.
  • Mejor fallar en CI que filtrar INTERNAL_JWT_SECRET al browser.

Qué: En lugar de leer la sesión en Header con auth() server-side, se usa SessionProvider global + useSession() en client.

Por qué:

  • Leer la sesión en cada server component rompía el UI en páginas mixtas (header parpadeaba entre logged/anonymous).
  • SessionProvider cachea client-side y mantiene estado consistente.

Qué: Endpoints que consultan stock (/cards/[id]/listings, etc.) usan cache: 'no-store'. Solo sets/rarities cachean 5 min.

Por qué:

  • Mostrar stock viejo lleva a “agregar al carrito → no hay stock” en checkout. Mala UX y posible double-sell.
  • Sets/rarities cambian rara vez, cache es seguro ahí.

Escáner: OCR.space server-side (en vez de Tesseract.js + pHash en el cliente) — 2026-05-28

Sección titulada «Escáner: OCR.space server-side (en vez de Tesseract.js + pHash en el cliente) — 2026-05-28»

Qué: El escáner identifica cartas mandando la foto a OCR.space (Engine 3) desde el backend, extrayendo el número de coleccionista con una regex por TCG y resolviendo contra cards. Reemplazó al approach anterior, que corría todo en el cliente: Tesseract.js (OCR en el browser) + perceptual hashing (pHash) contra hashes precalculados del catálogo.

Cómo funciona (pipeline):

  1. Cliente (mobile/tablet): captura el frame de la cámara, lo recorta al guía visual (aspect ~0.72), lo comprime a JPEG ≤1 MB y lo manda como data URL a POST /api/v1/scan/identify-image.
  2. Backend: trackea el uso del día (scanner_usage, $inc atómico) → llama a OCR.space → recibe el texto impreso.
  3. identifierExtractor: regex por TCG extrae el identificador (NNN/NNN para Pokémon/Magic/Lorcana/Riftbound; prefijo + número para Gundam/One Piece/Yu-Gi-Oh/Flesh & Blood).
  4. IdentifyService (árbol de decisión):
    • Con número → query exacta { tcg, number } (cap 20 candidatos). 1 match = confianza high; varios = narrow por baseName presente en el texto OCR, prefiriendo impresiones “base” (sin paréntesis, ej. para que la regular gane a las variantes World Championship), top 5.
    • Sin número o 0 matches → $text search sobre name (confianza medium), top 5.

Por qué migramos:

  • Tesseract.js en el browser era pesado (bundle + CPU del teléfono) y poco preciso.
  • pHash requería mantener hashes precalculados de las ~205k cartas y compararlos en memoria del Cloud Run → forzaba 512 Mi de RAM.
  • OCR.space server-side delega el OCR, permitió bajar el Cloud Run a 256 Mi (medido ~326→128 MiB en uso), y el match por número de coleccionista es mucho más exacto que por imagen.

Trade-off / límites:

  • Dependemos del free tier de OCR.space: 25k/mes, 500/día por IP, imágenes ≤1 MB. Por eso el body Zod tope a 1.5 M chars y el cliente comprime agresivo.
  • Al agotarse el cupo, los scans fallan con 503 SCANNER_QUOTA_EXCEEDED hasta el reset (00:00 UTC). Mitigación: email de alerta al 80% (inline, no es cron) + página /admin/scanner-usage. Si el volumen crece → plan PRO.
  • El escáner está detrás de feature flag (NEXT_PUBLIC_SCANNER_ENABLED) y solo en dispositivos táctiles (mobile/tablet).

Detalle: planes tcgcards-api/docs/superpowers/plans/2026-05-28-card-scanner-ocr.md y 2026-05-28-scanner-ocrspace-migration.md.

Filtros por TCG: registro declarativo + facetas vía Atlas Search — 2026-05-29

Sección titulada «Filtros por TCG: registro declarativo + facetas vía Atlas Search — 2026-05-29»

Qué: El catálogo (/search, detalle de set, mis-publicaciones) tiene filtros por atributo específicos de cada TCG (ej. Pokémon por tipo de energía y stage; One Piece por color y tipo de carta). Los filtros disponibles, su label y su path en attributes.raw.* se definen en un registro declarativo (src/services/tcgFilters.ts), no hardcodeados por endpoint.

Por qué un registro y no columnas fijas: cada TCG tiene atributos distintos y el catálogo guarda los atributos crudos en attributes.raw. Un registro central permite agregar/quitar un filtro tocando un solo archivo, y que el $match (filtrado), las facetas (conteos) y el front (chips) deriven de la misma fuente.

Decisiones clave:

  • Modelo B (auto-exclusión) en las facetas: el conteo de cada filtro ignora su propia selección pero respeta los otros, para que el usuario pueda sumar valores sin que el conteo caiga a 0.
  • Facetas consistentes con la búsqueda: cuando hay texto (q), las facetas se calculan con Atlas Search (mismo índice card-search que los resultados), no con regex. Antes diferían — la regex hacía substring-match (más cartas que las que devolvía $search), y al hacer click en una faceta salían 0 resultados. Fallback a regex solo si Atlas no está disponible (tests/CI).
  • Índices: ~19 índices parciales por campo filtrable (ver base de datos); partialFilterExpression para no reventar el límite de 64 índices/colección.
  • Cache: las facetas se memoizan en proceso (TTL corto, sin Redis), porque son caras y cambian poco.

Detalle: planes 2026-05-30-per-tcg-filters-api.md (API) y tcgcards-web/.../2026-05-30-per-tcg-filters-web.md (web).

Cloudflare Email Routing (inbound) + Resend (outbound)

Sección titulada «Cloudflare Email Routing (inbound) + Resend (outbound)»

Qué: Para recibir emails (soporte@, disputas@) usamos Email Routing de Cloudflare. Para enviar transaccionales usamos Resend SMTP. Para envío manual desde Gmail, configurado “Send As” con SMTP de Resend.

Por qué:

  • Email Routing es gratis y robusto.
  • Resend es simple, tiene buen tier free, deliverability decente.
  • Combinar ambos cubre todos los casos sin pagar una suite tipo SendGrid.

Qué: Todo texto user-facing usa “tú” en forma neutral (quieres/puedes/tienes/inicia/usa).

Por qué:

  • Audiencia chilena; el tuteo es universal en LATAM para registro casual.
  • Aplica a web, emails, toasts, admin.

Qué: Sitemap XML por sub-tipo (cards, sets, users, static), structured data JSON-LD, metadata por página, OG tags.

Por qué:

  • Marketplace nuevo necesita indexación rápida en Google.
  • Sets y cards generan URL canónica indexable.
  • JSON-LD ayuda a Google a entender Product/Offer.

Detalle: spec tcgcards-api/docs/superpowers/specs/2026-05-10-plan-5h-seo-design.md.

Qué: Cuando entran a /search sin query, no se muestra “browse all” — se muestra placeholder.

Por qué:

  • El sort releaseDate agregado es lento sin índice apropiado.
  • “Explora por TCG” lleva a /sets?tcg=... que sí es rápido.

Qué: Background cream #f5f4ef, foreground onyx #18181c, accent champagne #c9a05d.

Por qué:

  • Diferencia visual de los marketplaces típicos (azules/verdes).
  • Asocia con cartas premium (champagne = oro suave).
  • Funciona en dark mode con inversión de tonos.

Spec del design system: tcgcards-api/docs/superpowers/specs/2026-05-08-plan-5-5-design-system-premium-design.md.

Sección Novedades: changelog público de hitos no técnicos — 2026-06-28

Sección titulada «Sección Novedades: changelog público de hitos no técnicos — 2026-06-28»

Qué: página pública /novedades con los hitos de la plataforma en una línea de tiempo tipo roadmap (zig-zag en escritorio, columna en móvil; más reciente arriba). Solo cosas no técnicas de cara al usuario; los bugs no entran, salvo una corrección pedida por usuarios que se marque explícitamente.

Por qué: dar visibilidad de lo que se va sumando sin robarle protagonismo a las cartas (acceso desde footer + menús, no en la nav principal).

Modelo de contenido: archivo estático en código (src/lib/data/novedades.ts), página force-static → una novedad nueva aparece al desplegar a prod (sin CMS, sin runtime, sin limpiar caché). Pill “Nuevo” 14 días (cliente). Detalle en web. Política de trabajo: al cerrar un feature nuevo se le pregunta al usuario si va a Novedades.

Spec: tcgcards-web/docs/superpowers/specs/2026-06-28-novedades-design.md.

Precio de referencia TCGplayer: refresco vía Cloud Run Job + Scheduler — 2026-06-15

Sección titulada «Precio de referencia TCGplayer: refresco vía Cloud Run Job + Scheduler — 2026-06-15»

Qué: los precios de TCGplayer (cards.marketPrices, USD) se refrescan a diario con un Cloud Run Job (refresh-prices) disparado por Cloud Scheduler (refresh-prices-daily, 4 AM Chile), desacoplado del sync de catálogo. Frescura ≤24h. Se exponen al vendedor como referencia (USD) en publicar/editar y mis publicaciones.

Por qué desacoplar del sync: antes el precio viajaba pegado al sync de catálogo, que re-sincroniza con cooldowns largos (3/30/120 días) → los precios quedaban estancados (semanas). Separarlo permite refrescar precios a diario sin re-bajar todo el catálogo.

Por qué Cloud Run Job y no GitHub Actions: se evaluó correr el refresco como workflow de GH Actions (como el sync). Pero el repo es privado → GH Actions cobra minutos: a este volumen serían ~$5-13/mes. Medimos que el API usa solo ~22% del free tier de Cloud Run → una pasada diaria (~90 min) cabe gratis ($0). Por eso se movió a Cloud Run Job + Scheduler. El workflow refresh-prices.yml quedó obsoleto (no reactivar; sería doble refresco).

Detalles técnicos clave:

  • pageSize máximo = 50mp-search-api.tcgplayer.com rechaza size>50 con HTTP 400.
  • Escritura quirúrgica — solo $set de marketPrices vía bulkWrite (sin upsert → no inserta, sin deletes, skip-null), filtro source=tcgplayerMitos nunca se toca. Ver [Cuidado al escribir en la BD].
  • Solo USD — no se convierte a CLP (decisión de producto); se muestra “Referencia TCGplayer: USD $X” con nota “se actualiza una vez al día”. Cartas de Mitos no muestran nada (no aplica).
  • Campo nuevo sets.pricesRefreshedAt (oldest-first). Sets nuevos del sync entran solos (null → primeros).

Specs: tcgcards-api/docs/superpowers/specs/2026-06-14-precios-referencia-tcgplayer-refresco-design.md (backend) y 2026-06-15-precio-referencia-tcgplayer-ui-design.md (UI). Runbook operativo: ver crons.

Precios dinámicos: auto-precio derivado de la referencia de TCGplayer — 2026-06-30

Sección titulada «Precios dinámicos: auto-precio derivado de la referencia de TCGplayer — 2026-06-30»

Qué: un vendedor puede activar Precios dinámicos (internamente el código se llama autoPrice/autoPricing) para que sus cartas de TCGplayer se repricen solas cada día. Es opt-in, por carta o en todas a la vez. El precio se deriva de la referencia de TCGplayer (USD) por el tipo de cambio que el vendedor define: precio_CLP = round100(marketPrices.market[USD] × usdRate_del_vendedor), con redondeo half-up al múltiplo de $100 más cercano y un mínimo de publicación de $50.

Por qué redondeo en aritmética entera (no float, no doble Math.round): el redondeo se hace sobre centavos-de-peso enteros. Primero Math.round(pesos * 100) lleva el monto a centavos y mata el ruido IEEE-754 de la multiplicación; después floor((c + 5000) / 10000) * 100 es el half-up exacto al múltiplo de $100. Hacerlo con doble redondeo en float metía un sesgo de +$100 en la banda X49,5: por ejemplo 1,21 × 950 = 1.149,5 debe dar $1.100, no $1.200. La aritmética entera lo resuelve sin casos borde.

Por qué gate de frescura 26h (a nivel SET y CARTA, cinturón y tirantes): solo se repricea si el set (sets.pricesRefreshedAt) y la carta (cards.marketPrices.pricesUpdatedAt) tienen la referencia fresca (≤26h). Si cualquiera de los dos está rancio, esa carta se salta (no_fresh_price) en vez de aplicar un precio viejo. La doble verificación evita repreciar con datos que el refresco diario aún no actualizó.

Por qué no toca el flujo de dinero: las órdenes congelan el precio al crearse, así que repreciar una publicación no afecta compras en curso. La actualización es escritura quirúrgica con guard CAS {status:active, autoPrice:true} (solo reprecia publicaciones activas que siguen con el auto-precio encendido), sin tocar PaymentService/completeInternal ni el resto de la ruta de pago. Ver [Cuidado al escribir en la BD].

Límites y responsabilidad: solo aplica a cartas de catálogo de TCGplayer (card.source == "tcgplayer"). Mitos y Leyendas (source api.myl.cl), sellado, accesorios y cartas custom nunca se repricean (no tienen referencia en USD). El vendedor sigue siendo responsable de sus precios (disclaimer en la ayuda) y siempre puede no aceptar una venta a un precio que no le acomode.

  • Image fallback (catalog backup) — diferido 2026-05-18. Backup de TCGplayer → Cloudflare R2 (~$0.60/mes). Valorar más adelante.
  • Filtrar cartas individuales futuras — diferido 2026-05-18. Hoy filtramos sets futuros pero no cartas individuales. Revisitar si crecen vendedores reales o aparecen disputas.
  • Búsqueda global multi-TCG (estilo tcgmatch.cl) — diferido 2026-05-08. Grid mezclado + checkboxes multi-select TCG en sidebar. ~3-4h. Revisitar tras uso real de la solución actual.
  • M10 + PITR / IP Access List acotada — diferido 2026-05-29. Hoy el tier Flex (snapshots diarios sin PITR) + el mongodump horario a GCS bastan pre-lanzamiento. Subir a M10 (PITR + PrivateLink, y acotar la IP — requiere VPC connector/Cloud NAT por el egress dinámico de Cloud Run) cuando haya volumen real de transacciones.