Modelo de datos (Mongo)
Base de datos en MongoDB Atlas, database api-cards. Driver: Mongoose 8. Cada colección está definida en src/**/persistence/*Schema.ts del repo tcgcards-api.
Colecciones por dominio
Sección titulada «Colecciones por dominio»Catálogo
Sección titulada «Catálogo»Catálogo de TCGs habilitados (Pokémon, One Piece, etc.).
| Campo | Tipo | Nullable | Default | Descripción |
|---|---|---|---|---|
_id | String | no | — | Slug del TCG (pokemon, one-piece, etc.) |
slug | String | no | — | Slug (mismo valor que _id) — único |
name | String | no | — | Nombre display |
description | String | no | '' | Descripción corta |
enabled | Boolean | no | true | Si está habilitado |
source | String | no | — | 'tcgplayer' u otro |
external | Mixed | no | {} | Metadata de la fuente externa |
Sin índices explícitos (unique en slug).
Sets de cada TCG (e.g. “Scarlet & Violet 151”).
| Campo | Tipo | Nullable | Default | Descripción |
|---|---|---|---|---|
_id | String | no | — | {tcg}-{slug} |
tcg | String | no | — | Slug del TCG (índice) |
slug | String | no | — | Slug del set |
name | String | no | — | Nombre |
code | String | sí | — | Código corto |
releaseDate | Date | sí | — | Fecha lanzamiento |
cardsCount | Number | no | 0 | Cuántas cartas tiene |
pricesRefreshedAt | Date | sí | null | Última vez que el job refresh-prices actualizó los precios de las cartas del set (oldest-first). Independiente de lastSyncedAt. |
Índices: {tcg:1, slug:1} (unique), {tcg:1, releaseDate:-1}, {source:1, pricesRefreshedAt:1} (para el refresco de precios oldest-first).
Cartas individuales sincronizadas desde TCGplayer.
| Campo | Tipo | Nullable | Default | Descripción |
|---|---|---|---|---|
_id | String | no | — | {tcg}-{externalProductId} |
tcg | String | no | — | Slug TCG |
setId | String | no | — | Ref al set |
setSlug / setName | String | no | — | Denormalizado para queries |
name | String | no | — | Nombre completo (puede incluir variante) |
baseName | String | no | — | Nombre canónico para autocomplete |
number | String | sí | — | Número en el set (ej. 97/101, OP09-095) |
numberSearch | [String] | no | [] | Derivado de number: tokens normalizados (minúscula, número completo + segmento con/sin ceros) para buscar por número con prefijo anclado. Lo calcula numberSearchTokens() dentro de CardRepository.upsert() → el sync lo puebla solo. Ej: 024/078 → ["024/078","024","24"] |
rarity | String | sí | — | Rareza |
imageUrl | String | no | — | URL del catálogo (TCGplayer/CDN) |
marketPrices | Sub-doc | sí | — | Precios USD de TCGplayer (market/median/low/listingsCount/pricesUpdatedAt). Refrescados a diario por el job refresh-prices (ver crons), no por el sync. Se exponen al vendedor como referencia. |
listingsCount | Number | no | 0 | Resumen de publicaciones (jul 2026, proyecto perf-escala): cantidad de publicaciones activas de la carta. Mantenido por CardStockRecalcService (recálculo derivado tras cada transición del listing, diferido 1500ms) + reconciliación nocturna. El upsert del sync JAMÁS lo pisa (va en $setOnInsert) |
minPrice | Number | sí | null | Resumen: precio mínimo (CLP cents) entre las publicaciones activas |
totalStock | Number | no | 0 | Resumen: suma de quantity de las publicaciones activas |
inStock | Boolean | no | false | Resumen: totalStock > 0. Alimenta feed/facetas/búsqueda/sitemap. La ficha de carta sigue calculando stock EN VIVO (invariante: lo que toca compra lee datos vivos) |
latestListedAt | Date | sí | null | Resumen: createdAt más reciente entre las activas (orden “recientes” del feed) |
releaseDateAt | Date | sí | null | Derivado de attributes.raw.releaseDate (string→Date UTC) en CardRepository.upsert() (como numberSearch) — eliminó el $dateFromString por doc del sort del catálogo |
Índices: {tcg:1, setSlug:1}, text index {tcg:1, name:'text'}, {tcg:1, rarity:1}, {tcg:1, baseName:1}, {tcg:1, 'attributes.raw.releaseDate':-1}, {tcg:1, numberSearch:1} (multikey; soporta la búsqueda por número con prefijo anclado). Perf-escala (jul 2026): instock_latest_idx {inStock:1,latestListedAt:-1}, tcg_instock_latest_idx, tcg_instock_minprice_idx, tcg_instock_release_idx {tcg:1,inStock:-1,releaseDateAt:-1,baseName:1} — OJO: en deploys se crean A MANO (autoIndex no corre al boot; ver runbook perf-escala-prod-rollout.md del repo API).
Búsqueda por número (jun 2026): la búsqueda de cartas matchea nombre O número. Una query con dígitos (ej. 97, 97/101, OP09-095) se resuelve por prefijo anclado sobre numberSearch (índice arriba), tolerando ceros (24↔024); combinada con un set queda única. Cubre catálogo, autocomplete, mis publicaciones, perfiles públicos y el feed de publicaciones (NO el escáner, que ya identifica por número con su propio path). Backfill inicial: scripts/backfill-number-search.ts.
Para los filtros por TCG hay ~19 índices parciales {tcg:1, 'attributes.raw.<campo>':1} con partialFilterExpression: { 'attributes.raw.<campo>': { $exists: true } } — uno por campo filtrable del registro tcgFilters.ts. El partialFilterExpression mantiene cada índice chico (solo cartas que tienen ese atributo) y ayuda a respetar el límite de 64 índices por colección de MongoDB. Las búsquedas por texto (q) y las facetas cuando hay búsqueda usan el índice de Atlas Search card-search (autocomplete sobre baseName).
sync_reports
Sección titulada «sync_reports»Reportes de sincronización con TCGplayer (auditoría de jobs).
Índices: {source:1}, {tcg:1}.
Listings y carrito
Sección titulada «Listings y carrito»listings
Sección titulada «listings»Publicaciones de los vendedores. Discriminated union por kind.
Campos comunes a todos los kinds:
| Campo | Tipo | Nullable | Descripción |
|---|---|---|---|
_id | String | no | listing-{nanoid} |
sellerId | String | no | Ref al user |
kind | enum card/accessory/bulk/sealed/deck | no | Tipo de producto |
price | Number | no | Precio en CLP cents (mín 1) |
currency | enum CLP | no | Solo CLP por ahora |
quantity | Number | no | Stock disponible (mín 1) |
photos | [String] | no | Hasta N fotos (Cloudinary URLs) |
customPhoto | String | sí | Foto custom legacy |
description | String | sí | Hasta 500 chars |
status | enum | no | active / paused / sold / hidden_by_admin / hidden_by_user_suspension |
location | Sub-doc | no | {region, commune} |
autoPrice | Boolean | no | Si la publicación tiene Precios dinámicos activo (se repricea sola a diario). Default false. Solo aplica a cartas de catálogo TCGplayer |
autoPricedAt | Date | sí | Última vez que el job de precios dinámicos le escribió el precio (null si nunca lo hizo) |
soldCount, lastSoldAt | Number / Date | no / sí | Contador de ventas (jul 2026): unidades vendidas NETAS + última venta, mantenidos en el MISMO update atómico que el stock (decrementStock +N / restoreStock −N con clamp a 0). El filtro “Vendidos” de Mis publicaciones = soldCount>0 O status:'sold' (cubre ventas manuales), ordenado por lastSoldAt. Privado del vendedor (excluido de payloads públicos). Backfill/reconciliación: scripts/backfill-sold-count.ts = familia C de reconcile-denorm |
tcg, cardSource, setId, setName, cardBaseName, cardNumberSearch, cardAttributes | varios | sí | Denormalizados de la carta (jul 2026, perf-escala): copiados de cards al CREAR la publicación (datos estáticos: una carta no cambia de juego/set/nombre). Permiten que “Mis publicaciones” filtre/facetee/busque SIN $lookup (O(página)). update() jamás los pisa; backfill/reconciliación: scripts/backfill-listing-tcg.ts (cubre legacy sin kind). Índices: seller_status_tcg_idx {sellerId,status,tcg}, status_cardId_idx {status,cardId} |
autoPriceSkipped | Sub-doc | sí | {reason, at} — por qué el job la saltó: reason ∈ below_min / no_fresh_price. Alimenta el badge “Fuera de precios dinámicos”. null si no se saltó |
Campos por kind:
card:cardId,condition,language,variant,hasLeagueStamp,customTitle(legacy). Puede tenerphotos(0-2) opcionales: fotos propias del vendedor de su carta real (frente/dorso).customPhotoes el legacy de 1 sola foto.accessory:accessoryType,accessoryCondition,accessoryName,tcg.bulk:bulkComposition,bulkCondition,bulkLanguages([]),cardsPerLot,bulkTitle.sealed:sealedTitle,sealedLanguage.deck:deckTitle,deckCardCount,tcg. Reutilizadescription(obligatoria, min 10),photos(1-5),price,quantity,location.deckTitle: string | null— Nombre libre del mazo (5-80 chars).deckCardCount: number | null— Cantidad total de cartas (1-500).- No incluye
conditionnilanguage— un mazo puede mezclar cartas de distintos idiomas y condiciones.
Precios dinámicos (jun 2026): opt-in por vendedor. Cuando autoPrice está en true, el job diario de precios dinámicos repricea la publicación a precio_CLP = round100(card.marketPrices.market[USD] × usdRate) (redondeo half-up al múltiplo de $100 más cercano, en aritmética entera; mínimo de publicación $50). Solo aplica a cartas de catálogo TCGplayer (card.source == "tcgplayer"): nunca a Mitos (source: api.myl.cl), sellado, accesorios ni custom. Exige frescura del precio (≤26h tanto en sets.pricesRefreshedAt como en cards.marketPrices.pricesUpdatedAt); si no está fresco, salta la carta y deja autoPriceSkipped con reason: "no_fresh_price" (o "below_min" cuando el precio calculado cae bajo $50). La escritura es quirúrgica con guard CAS {status: active, autoPrice: true} y no toca el flujo de pago — las órdenes congelan el precio al crearse. El interruptor maestro del vendedor vive en users.autoPricing (ver abajo). En el código se llama autoPrice/autoPricing; la marca visible es “Precios dinámicos”.
Índices: {autoPrice:1, status:1} (autoprice_active_idx, soporta el $match de candidatos del job de precios dinámicos), {cardId:1, status:1}, {sellerId:1, status:1}, {status:1, createdAt:-1}, {cardId:1, condition:1, language:1}, {sellerId:1, createdAt:-1} (seller_createdAt_idx).
Un cart por usuario. _id = userId.
| Campo | Tipo | Descripción |
|---|---|---|
_id | String | userId |
sellerId | String | legacy, sin uso — el carrito ahora acepta múltiples vendedores (ver checkouts) |
items | array | {listingId, quantity} |
Órdenes y pagos
Sección titulada «Órdenes y pagos»Las órdenes generadas en checkout.
| Campo | Tipo | Descripción |
|---|---|---|
_id | String | order-{nanoid} — interno, en URLs |
displayId | String | 8 chars + dash — visible para el usuario |
buyerId, sellerId | String | refs |
items | array | snapshot de items al momento del checkout. Jul 2026: además de condición/idioma/variante, el snapshot guarda hasLeagueStamp (cards — el sello distingue publicaciones idénticas; el vendedor lo necesita para preparar el pedido) y deckCardCount (decks). Sin default: kinds ajenos y órdenes legacy no llevan el campo (UI/correos lo omiten). Se muestra como badge/chip Liga en detalle de orden, tarjetas de ventas/compras y TODOS los correos con productos (renderizador único renderOrderItems) |
totalPrice | Number | suma de subtotales en CLP cents |
buyerAddress | Sub-doc | snapshot del comprador al checkout: name, phone, email, rut + street/houseNumber/commune/region/deliveryPreference. Solo lo ven comprador/vendedor de la orden; el vendedor ve rut/correo/dirección únicamente en envío a domicilio |
status | enum | awaiting_payment/pending/accepted/shipped/completed/canceled |
deliveryMethod | enum | shipping/in_person/null |
paymentId, paymentTimeoutAt, mpInitPoint | — | refs a payments |
checkoutId | String | sí; set en sub-órdenes de carrito multi-vendedor (pago combinado). Invariante: una orden tiene paymentId XOR checkoutId — individual (MP) vs grupal (wallet), nunca ambos |
openDisputeId, warningEmailSentAt | — | tracking |
acceptedAt/shippedAt/completedAt/canceledAt | Date | timestamps |
Índices: {buyerId:1, createdAt:-1}, {sellerId:1, createdAt:-1}, {status:1, updatedAt:-1}, {displayId:1} (unique sparse), {createdAt:-1, _id:-1}, {status:1, paymentTimeoutAt:1} (← agregado 2026-05-20 para cron de cleanup).
payments
Sección titulada «payments»Tabla de pagos contra MP. Un payment por order individual (las sub-órdenes de carrito multi-vendedor no tienen payment propio — su pago es el checkout agregado).
Índices: {userId:1, createdAt:-1}, {mpPaymentId:1} (unique partial).
checkouts
Sección titulada «checkouts»Agregado de un carrito multi-vendedor pagado en un solo pago combinado (MP). Une las N sub-órdenes (una por vendedor) a un único pago.
| Campo | Tipo | Descripción |
|---|---|---|
_id | String | checkout-{nanoid} — es el external_reference que viaja a MP |
buyerId | String | ref |
orderIds | [String] | las N sub-órdenes (una por vendedor) |
totalPrice | Number | suma de subtotales en CLP cents (= monto cobrado en MP) |
mpPreferenceId, mpPaymentId, mpFee, netAmount, mpStatus | — | datos MP |
status | enum | awaiting_payment/paid/expired/failed/refunded |
paymentTimeoutAt, webhookReceivedAt | Date | tracking |
Al confirmarse el pago (webhook con external_reference que empieza con checkout-), confirmCheckoutAndAdvanceOrders aplica el fan-out de wallet (topup +total al comprador, −subtotal por orden, mp_fee al sistema; net-0 del comprador con guard) y avanza cada sub-orden a pending. Cada sub-orden luego se acepta/envía/completa/cancela por separado; el reembolso de una sub-orden cancelada va al wallet.
conversations + messages
Sección titulada «conversations + messages»Chat entre comprador y vendedor por cada orden.
conversations índices: {orderId:1} (unique), {buyerId:1, lastMessageAt:-1}, {sellerId:1, lastMessageAt:-1}.
messages índices: {conversationId:1, createdAt:1}.
Disputes, reviews, reports
Sección titulada «Disputes, reviews, reports»disputes
Sección titulada «disputes»Una dispute por orden (máximo 1).
Campos clave: reason (not_received/damaged/incorrect/counterfeit/no_response/other), description (min 30 chars), photos (max 4), status (open/resolved), resolution (canceled_in_favor_of_buyer/closed_in_favor_of_seller/dismissed), response (sub-doc).
Índices: {status:1, createdAt:-1}, {orderId:1} (unique).
reviews
Sección titulada «reviews»Reseñas de buyer → seller. Una por orden completada.
Campos: rating (1-5), comment (max 500).
Índices: {orderId:1} (unique), {sellerId:1, createdAt:-1}, {buyerId:1, createdAt:-1}.
reports
Sección titulada «reports»Reportes de usuarios (a listings o a otros users).
Índices: {reporterId:1, targetType:1, targetId:1} (unique partial), {status:1, createdAt:-1}, {targetType:1, targetId:1, status:1}, {reporterId:1, createdAt:-1}.
Wallet, withdrawals, cuentas bancarias
Sección titulada «Wallet, withdrawals, cuentas bancarias»wallet_entries
Sección titulada «wallet_entries»Ledger inmutable de movimientos.
Índices: {userId:1, createdAt:-1}, {metadata.orderId:1} (sparse), {metadata.withdrawalId:1} (sparse), {type:1, createdAt:-1}.
Tipos de movimiento: topup, purchase, sale, commission, mp_fee, refund, reversas (*_reversal), manual_credit/manual_debit, owner_draw, withdrawal_*, y iva_payment. Hay dos cuentas de sistema en users (isSystem:true): user-system-tcgcards (comisión/ganancia + mp_fee + owner_draw) y user-system-iva (registra pagos de IVA al SII vía iva_payment). Sus saldos + las órdenes completadas alimentan el panel de finanzas (ver decisiones).
withdrawals
Sección titulada «withdrawals»Solicitudes de retiro de wallet a cuenta bancaria.
Estados: pending/processing/completed/canceled.
Índices: {userId:1, createdAt:-1}, {status:1, createdAt:-1}.
bank_accounts
Sección titulada «bank_accounts»Cuentas bancarias guardadas, encriptadas con AES-GCM.
Campos: userId (unique), ciphertext, iv, authTag, keyVersion, last4, bankName.
La encriptación usa la env var BANK_ACCOUNT_ENCRYPTION_KEY (32 bytes base64). Ver runbooks para rotación.
Usuarios y moderación
Sección titulada «Usuarios y moderación»Cuentas de usuario.
| Campo | Tipo | Descripción |
|---|---|---|
_id | String | user-{nanoid} |
email | String | unique, lowercase |
username | String | unique |
name, avatarUrl | — | perfil |
authProviders | array | google por ahora |
region, commune, street, houseNumber, phone, rut | — | dirección + RUT (privados; rut normalizado "12345678-5", solo se comparte con el vendedor al comprarle) |
contactPreferences, notificationPreferences | Sub-doc | toggles |
autoPricing | Sub-doc | Config maestra de Precios dinámicos del vendedor: {enabled, usdRate}. enabled (Boolean, default false) = si tiene precios dinámicos activos; usdRate (Number entero 1..100000 o null) = a cuánto toma el dólar para repreciar. Validador de entero en el schema + runValidators en el update |
reviewStats | Sub-doc | rating promedio + count |
isAdmin | Boolean | flag admin |
status | enum | active/warned/suspended/banned |
suspendedUntil, warningsCount, lastWarningAt, warningAcknowledgedAt | — | moderación |
walletBalance | Number | balance actual (denormalizado del ledger) |
isSystem | Boolean | la cuenta system de la plataforma |
Índices: {email:1} (unique), {username:1} (unique), {status:1, suspendedUntil:1} (partial para auto-unsuspend).
homepageBanners
Sección titulada «homepageBanners»Banners visuales para el home.
Campos: imageUrl, imageScale/Offset, CTA primary/secondary, dark variant, status (draft/published/archived), order.
Índices: {status:1, order:1}.
adminActions
Sección titulada «adminActions»Audit log de acciones de admin.
Índices: {adminId:1, createdAt:-1}.
Scanner
Sección titulada «Scanner»scanner_usage
Sección titulada «scanner_usage»Contador diario de requests al OCR de OCR.space (para vigilar el cupo del free tier). Definida en src/scanner/persistence/ScannerUsageSchema.ts.
| Campo | Tipo | Default | Descripción |
|---|---|---|---|
_id | String | — | Fecha YYYY-MM-DD en UTC (la cuota de OCR.space resetea 00:00 UTC) |
count | Number | 0 | Requests consumidos ese día. $inc atómico (findOneAndUpdate + upsert) por cada scan |
alertedAt80 | Boolean | false | Si ya se envió el email de alerta al 80% ese día (evita re-alertar) |
Sin índices explícitos: la key _id por fecha basta para el $inc atómico y el query del día. El agregado mensual y de últimos 30 días se hace por prefijo del _id.
batchidempotencies
Sección titulada «batchidempotencies»Idempotencia del Publicador Pro (publicación masiva). Mapea ${sellerId}:${clientRef} → listingId creado, para que reintentar un item tras un corte de red no duplique la publicación.
| Campo | Tipo | Default | Notas |
|---|---|---|---|
key | String | — | ${sellerId}:${clientRef}. Índice único |
sellerId | String | — | Vendedor dueño del registro |
listingId | String | — | Publicación creada (o fusionada) para ese clientRef |
createdAt | Date | now | Índice TTL expireAfterSeconds: 48h — el registro se borra solo pasada la ventana de reintentos |
El tope diario de publicaciones por vendedor es 2000/24h (
LISTING_DAILY_CAP). El Publicador Pro lo respeta víaListingService.createpor item; al alcanzarlo, los items restantes vuelven con errordaily_cap.
Resumen
Sección titulada «Resumen»23 colecciones en total. Las más voluminosas a futuro: cards (catálogo), listings, messages. Las más críticas para integridad: orders, payments, wallet_entries (ledger).