Ir al contenido

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.

Catálogo de TCGs habilitados (Pokémon, One Piece, etc.).

CampoTipoNullableDefaultDescripción
_idStringnoSlug del TCG (pokemon, one-piece, etc.)
slugStringnoSlug (mismo valor que _id) — único
nameStringnoNombre display
descriptionStringno''Descripción corta
enabledBooleannotrueSi está habilitado
sourceStringno'tcgplayer' u otro
externalMixedno{}Metadata de la fuente externa

Sin índices explícitos (unique en slug).

Sets de cada TCG (e.g. “Scarlet & Violet 151”).

CampoTipoNullableDefaultDescripción
_idStringno{tcg}-{slug}
tcgStringnoSlug del TCG (índice)
slugStringnoSlug del set
nameStringnoNombre
codeStringCódigo corto
releaseDateDateFecha lanzamiento
cardsCountNumberno0Cuántas cartas tiene
pricesRefreshedAtDatenullÚ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.

CampoTipoNullableDefaultDescripción
_idStringno{tcg}-{externalProductId}
tcgStringnoSlug TCG
setIdStringnoRef al set
setSlug / setNameStringnoDenormalizado para queries
nameStringnoNombre completo (puede incluir variante)
baseNameStringnoNombre canónico para autocomplete
numberStringNú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"]
rarityStringRareza
imageUrlStringnoURL del catálogo (TCGplayer/CDN)
marketPricesSub-docPrecios 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.
listingsCountNumberno0Resumen 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)
minPriceNumbernullResumen: precio mínimo (CLP cents) entre las publicaciones activas
totalStockNumberno0Resumen: suma de quantity de las publicaciones activas
inStockBooleannofalseResumen: 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)
latestListedAtDatenullResumen: createdAt más reciente entre las activas (orden “recientes” del feed)
releaseDateAtDatenullDerivado 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 (24024); 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).

Reportes de sincronización con TCGplayer (auditoría de jobs).

Índices: {source:1}, {tcg:1}.

Publicaciones de los vendedores. Discriminated union por kind.

Campos comunes a todos los kinds:

CampoTipoNullableDescripción
_idStringnolisting-{nanoid}
sellerIdStringnoRef al user
kindenum card/accessory/bulk/sealed/decknoTipo de producto
priceNumbernoPrecio en CLP cents (mín 1)
currencyenum CLPnoSolo CLP por ahora
quantityNumbernoStock disponible (mín 1)
photos[String]noHasta N fotos (Cloudinary URLs)
customPhotoStringFoto custom legacy
descriptionStringHasta 500 chars
statusenumnoactive / paused / sold / hidden_by_admin / hidden_by_user_suspension
locationSub-docno{region, commune}
autoPriceBooleannoSi la publicación tiene Precios dinámicos activo (se repricea sola a diario). Default false. Solo aplica a cartas de catálogo TCGplayer
autoPricedAtDateÚltima vez que el job de precios dinámicos le escribió el precio (null si nunca lo hizo)
soldCount, lastSoldAtNumber / Dateno / 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, cardAttributesvariosDenormalizados 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}
autoPriceSkippedSub-doc{reason, at} — por qué el job la saltó: reasonbelow_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 tener photos (0-2) opcionales: fotos propias del vendedor de su carta real (frente/dorso). customPhoto es el legacy de 1 sola foto.
  • accessory: accessoryType, accessoryCondition, accessoryName, tcg.
  • bulk: bulkComposition, bulkCondition, bulkLanguages ([]), cardsPerLot, bulkTitle.
  • sealed: sealedTitle, sealedLanguage.
  • deck: deckTitle, deckCardCount, tcg. Reutiliza description (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 condition ni language — 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.

CampoTipoDescripción
_idStringuserId
sellerIdStringlegacy, sin uso — el carrito ahora acepta múltiples vendedores (ver checkouts)
itemsarray{listingId, quantity}

Las órdenes generadas en checkout.

CampoTipoDescripción
_idStringorder-{nanoid} — interno, en URLs
displayIdString8 chars + dash — visible para el usuario
buyerId, sellerIdStringrefs
itemsarraysnapshot 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)
totalPriceNumbersuma de subtotales en CLP cents
buyerAddressSub-docsnapshot 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
statusenumawaiting_payment/pending/accepted/shipped/completed/canceled
deliveryMethodenumshipping/in_person/null
paymentId, paymentTimeoutAt, mpInitPointrefs a payments
checkoutIdStringsí; 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, warningEmailSentAttracking
acceptedAt/shippedAt/completedAt/canceledAtDatetimestamps

Í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).

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).

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.

CampoTipoDescripción
_idStringcheckout-{nanoid} — es el external_reference que viaja a MP
buyerIdStringref
orderIds[String]las N sub-órdenes (una por vendedor)
totalPriceNumbersuma de subtotales en CLP cents (= monto cobrado en MP)
mpPreferenceId, mpPaymentId, mpFee, netAmount, mpStatusdatos MP
statusenumawaiting_payment/paid/expired/failed/refunded
paymentTimeoutAt, webhookReceivedAtDatetracking

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.

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}.

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).

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}.

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}.

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).

Solicitudes de retiro de wallet a cuenta bancaria.

Estados: pending/processing/completed/canceled. Índices: {userId:1, createdAt:-1}, {status:1, createdAt:-1}.

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.

Cuentas de usuario.

CampoTipoDescripción
_idStringuser-{nanoid}
emailStringunique, lowercase
usernameStringunique
name, avatarUrlperfil
authProvidersarraygoogle por ahora
region, commune, street, houseNumber, phone, rutdirección + RUT (privados; rut normalizado "12345678-5", solo se comparte con el vendedor al comprarle)
contactPreferences, notificationPreferencesSub-doctoggles
autoPricingSub-docConfig 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
reviewStatsSub-docrating promedio + count
isAdminBooleanflag admin
statusenumactive/warned/suspended/banned
suspendedUntil, warningsCount, lastWarningAt, warningAcknowledgedAtmoderación
walletBalanceNumberbalance actual (denormalizado del ledger)
isSystemBooleanla cuenta system de la plataforma

Índices: {email:1} (unique), {username:1} (unique), {status:1, suspendedUntil:1} (partial para auto-unsuspend).

Banners visuales para el home.

Campos: imageUrl, imageScale/Offset, CTA primary/secondary, dark variant, status (draft/published/archived), order. Índices: {status:1, order:1}.

Audit log de acciones de admin.

Índices: {adminId:1, createdAt:-1}.

Contador diario de requests al OCR de OCR.space (para vigilar el cupo del free tier). Definida en src/scanner/persistence/ScannerUsageSchema.ts.

CampoTipoDefaultDescripción
_idStringFecha YYYY-MM-DD en UTC (la cuota de OCR.space resetea 00:00 UTC)
countNumber0Requests consumidos ese día. $inc atómico (findOneAndUpdate + upsert) por cada scan
alertedAt80BooleanfalseSi 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.

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.

CampoTipoDefaultNotas
keyString${sellerId}:${clientRef}. Índice único
sellerIdStringVendedor dueño del registro
listingIdStringPublicación creada (o fusionada) para ese clientRef
createdAtDatenowÍ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ía ListingService.create por item; al alcanzarlo, los items restantes vuelven con error daily_cap.

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).