DOC-002: Domain Model
Estado
Approved
Contexto
Este documento define el modelo de dominio principal de MercoraHub.
Su objetivo es establecer:
- Entidades de negocio.
- Agregados.
- Value Objects.
- Relaciones.
- Ownership.
- Reglas de negocio principales.
Este documento será la base para:
- PostgreSQL Schema
- Prisma Schema
- NestJS Modules
- APIs
- Domain Events
- Bounded Contexts
Referencias: ADR-013 (multi-tenant), ADR-040 (pagos de pedidos).
Principios
MercoraHub adopta:
- Domain Driven Design (DDD)
- Arquitectura Hexagonal
- Modular Monolith
- Multi-Tenancy
Todos los modelos deberán pertenecer a un tenant.
Jerarquía operativa MVP: Tenant → Location. Brand se introduce en Fase 2 (ADR-013).
Entidades Raíz
Los agregados principales son:
Tenant
Location
Menu
Order
Customer
User
Subscription
Aggregate: Tenant
Representa una organización cliente del SaaS (contrato, facturación, aislamiento).
Responsabilidades
- Aislamiento de datos.
- Configuración global.
- Plan contratado.
- Branding.
- Cuenta Stripe Connect (MVP: una por tenant).
Atributos
id
name
slug
status
timezone
currency
language
country
planId
stripeConnectAccountId
stripeConnectStatus
createdAt
updatedAt
Relaciones
Tenant
├── Locations
├── Users
├── Customers
├── Orders
├── Subscription
└── Settings
Aggregate: Location
Unidad operativa donde ocurre la operación comercial.
Puede ser (ejemplos):
- Restaurante / food truck / dark kitchen (
BusinessVertical=FOOD) - Tienda retail / showroom (
RETAIL) - Otro negocio que vende vía catálogo (
OTHER)
Amendment 2026-08-09 (FEAT-071 / ADR-047): Location lleva businessVertical (FOOD | RETAIL | OTHER). Defaults de módulos food vía feature flags Tenant (ADR-036). No confundir vertical de discovery con categorías de menú/catálogo.
Atributos
id
tenantId
brandId (nullable, Fase 2)
name
slug
description
businessVertical (FOOD | RETAIL | OTHER)
logo
phone
email
status
street
city
state
postalCode
country
timezone
createdAt
updatedAt
Relaciones
Location
├── Menus
├── Orders
├── Tables
├── QrCodes
├── BusinessHours
├── TemporaryClosure
└── Settings
Aggregate: Brand (Fase 2)
Agrupación opcional de locations bajo una marca. No implementar en MVP. Reservar brandId nullable en Location.
Entity: BusinessHours
Horario operativo de una location.
id
locationId
dayOfWeek
openTime
closeTime
isClosed
Entity: TemporaryClosure
Cierre manual temporal de una location para bloquear pedidos sin alterar el horario semanal ni la visibilidad del menú.
id
tenantId
locationId
startsAt
endsOn (nullable — fin de día en TZ location; null = hasta reopen manual)
createdAt
updatedAt
Reglas:
- Máximo un cierre activo por location.
endsOnopcional; si se omite, el cierre permanece hasta Reopen manual.- Si
endsOnestá presente, debe ser hoy o futuro (TZ location) y ≤ hoy + 30 días. - Cuando está activo,
acceptingOrdersesfalseindependientemente deBusinessHours.
Entity: Table
Mesa física para pedidos dine-in y QR por mesa.
id
tenantId
locationId
label
zone
status (AVAILABLE | OCCUPIED | INACTIVE)
Entity: QrCode
Token de acceso público al menú/pedido.
id
tenantId
locationId
tableId (nullable — null = QR general de location)
token
isActive
createdAt
Estrategia QR MVP:
- QR general por location (tableId null).
- QR por mesa opcional (
?table={token}).
Aggregate: Menu
Representa la oferta comercial publicada por location.
Entidad Raíz
Menu
Entidades Hijas
Category
Product
Category
id
menuId
name
description
displayOrder
Product
id
categoryId
name
description
price
currency
imageUrl
status
isAvailable
Aggregate: Order
Es el núcleo del negocio.
Atributos
id
orderNumber
tenantId
locationId
customerId
tableId (nullable)
status
orderType
source
paymentStatus
subtotal
taxAmount
tipAmount
deliveryFee
serviceFee
discountAmount
total
currency
deliveryAddressId (nullable)
notes
createdAt
updatedAt
Order Types
PICKUP
DELIVERY
DINE_IN
Order Source
QR
WEB
ADMIN
Order Status
PENDING
ACCEPTED
PREPARING
READY
COMPLETED
CANCELLED
COMPLETED = retirado (pickup), entregado (delivery) o servido (dine-in).
Payment Status
UNPAID
PAID
REFUNDED
PARTIALLY_REFUNDED
Entidades Hijas
OrderItem
OrderTaxLine
OrderItem
id
orderId
productId
productName (snapshot)
quantity
unitPrice
totalPrice
selectedModifiers (snapshot JSONB — FEAT-040)
notes
OrderTaxLine
Snapshot inmutable de impuesto calculado (Stripe Tax).
id
orderId
jurisdiction
taxType
rate
amount
providerReference
CustomerInvoiceProfile
Perfil fiscal USA 0..1 del Customer global, distinto de CustomerAddress (entrega), OrderTaxLine (Stripe Tax) y de la factura SaaS de ADR-021.
customerId (unique)
legalName
taxId
line1 / line2?
city / state / postalCode
countryCode (US)
OrderInvoiceSnapshot
Snapshot fiscal inmutable 0..1 por Order. Se copia desde CustomerInvoiceProfile al procesar la confirmación o recibo; no se actualiza si el perfil cambia después.
orderId (unique)
legalName / taxId
line1 / line2?
city / state / postalCode / countryCode
capturedAt
Aggregate: Customer
Representa al comprador final.
Atributos
id
name
email
phone
status
createdAt
tenantId no aplica al agregado Customer en MVP: la cuenta es global de plataforma (una persona pedidos en cualquier Location). El vínculo comercial con un tenant ocurre en Order (tenantId + locationId), no en el registro del customer.
Relaciones
Customer
├── Orders
└── Addresses
└── InvoiceProfile (0..1)
Aggregate: User
Representa usuarios internos del tenant.
Atributos
id
tenantId
email
firstName
lastName
status
lastLoginAt
createdAt
Relaciones
User
├── Roles
├── Permissions
├── LocationAssignments
├── TenantNotifications
├── TenantNotificationPreferences
└── TenantNotificationEmailDeliveries
Entity: TenantNotification
Bandeja in-app 1:N del Tenant User (FEAT-076 / S-103). Siempre tenantId (ADR-013). ≠ CustomerNotification · CourierNotification · PlatformNotification.
id
tenantId
userId
locationId?
type # SCREAMING_SNAKE sin :v1 (ORDER_CREATED, STOCK_LOW, …)
title # EN persistido
body?
referenceId
payload? # href, entityType, entityId; sin secretos
readAt? # null = unread
createdAt
Unique (tenantId, userId, type, referenceId). List/mutaciones self: userId + tenantId del JWT.
Entity: TenantNotificationPreference
Preferencia por usuario y tipo: Inbox y Email independientes. Sin fila → defaults (ruidosos off; resto on).
id
tenantId
userId
eventType
inboxEnabled
emailEnabled
createdAt
updatedAt
Unique (userId, eventType).
Entity: TenantNotificationEmailDelivery
Ledger de envío por usuario (varios staff por el mismo hecho). No reutilizar notification_email_deliveries (unique global channel+event+resource).
id
userId
eventType
resourceId
createdAt
Unique (userId, eventType, resourceId).
Entity: UserLocationAssignment
Scope operativo por location (RBAC).
userId
locationId
roleId
Aggregate: Plan (platform SaaS catalog)
Catálogo de planes que un tenant contrata con MercoraHub (ADR-021). Distinto de Subscription (contrato del tenant) y de Payment de pedidos (ADR-040).
id
code # unique, immutable after create (A-Z0-9_)
name
monthlyEnabled # flag — not inferred from Stripe Price ID
yearlyEnabled
monthlyPrice
yearlyPrice
stripePriceIdMonthly # pasted by Alex
stripePriceIdYearly
status # DRAFT | PUBLISHED | ARCHIVED (UI Delete = ARCHIVE)
featured # at most one Published “Most popular”
sortOrder
selfServe
isSystem # interno: Trial/Enterprise seed fuera de GET /public/plans; Alex los administra igual
active # synced: status === PUBLISHED
Related: plan_feature_bullets (marketing copy) vs plan_entitlements (max_locations, max_users, ai_assistant, AI quotas). Publish checklist uses flags + Stripe IDs + bullets + entitlements. Mutations emit audit PLATFORM_PLAN_* and MUST NOT emit PlanChanged:v1.
Aggregate: Subscription
Contrato SaaS del tenant. Ver ADR-021.
id
tenantId
planId
status
renewalDate
stripeCustomerId
stripeSubscriptionId
createdAt
Estados
TRIAL
ACTIVE
PAST_DUE
CANCELLED
SUSPENDED
Aggregate: Payment
Pagos de pedidos del consumidor. Ver ADR-040.
No confundir con facturación SaaS (Stripe Billing → Subscription).
Atributos
id
tenantId
orderId
type (ORDER)
provider
providerReference
amount
tipAmount
currency
status
createdAt
Estados
PENDING
AUTHORIZED
PAID
FAILED
REFUNDED
Aggregate: Notification
id
tenantId
channel
recipient
status
sentAt
Canales
EMAIL
SMS
PUSH
Aggregate: FeatureFlag
Definido en ADR-036.
Aggregate: AuditLog
Definido en ADR-037. Escritura inmutable (AuditService.record). Lectura:
| Superficie | Permiso | Alcance |
|---|---|---|
| Tenant S-104 | audit.read (catálogo permissions) | Solo filas con tenantId = JWT. Sin tenantId en el DTO. |
| Platform S-048 | platform.audit.read | Cross-tenant + filas tenantId null (platformOnly). |
No hay entidad TenantAuditLog. El visor tenant lee audit_logs existentes. Mutar el registro está prohibido.
Aggregate: AIUsage
Definido en ADR-030.
Value Objects
Money
amount
currency
Address
street
city
state
postalCode
country
PhoneNumber
countryCode
number
value
Relaciones Globales
Tenant
│
├── Locations
│ ├── Menus
│ │ ├── Categories
│ │ │ └── Products
│ ├── Tables
│ ├── QrCodes
│ └── Orders
│ ├── OrderItems
│ └── OrderTaxLines
│
├── Users
│ └── LocationAssignments
│
├── Customers
│ └── Orders
│
├── Subscription
│
├── Payments (order)
│
├── Notifications
│
├── FeatureFlags
│
└── AuditLogs
Reglas de Negocio Principales
Tenant Isolation
Todos los registros de negocio contienen tenantId.
Order Ownership
Tenant → Location → Order
Menu Ownership
Location → Menu → Category → Product
Customer Ownership
Un Customer es una cuenta global de la plataforma MercoraHub (modelo tipo Uber Eats): la misma persona puede crear pedidos en Locations de distintos tenants. Los pedidos (Order) siguen aislamiento multi-tenant vía tenantId + locationId. Las direcciones guardadas pertenecen al customer global, no a un tenant.
Tax Snapshot
Los impuestos se calculan al crear/checkout del pedido y se persisten en OrderTaxLine. No se recalculan retroactivamente.
Pagos duales
- Suscripción SaaS → ADR-021 (Stripe Billing).
- Pago de pedido → ADR-040 (Stripe Connect + Checkout payment).
Exclusiones MVP
No forman parte del modelo inicial:
Inventory
Loyalty
Coupons
Gift Cards
Marketplace completo (ranking / comisiones agregador)
POS
Brand (multi-marca)
KDS UI (realtime sí — ADR-023)
Brand se modelará en Fase 2. Product Modifiers (extras/toppings) entregados en MVP — FEAT-040, amendment 2026-07-08. Ver ADR-003.
Amendment 2026-07-18 — Hybrid Delivery (FEAT-044 / ADR-046): Drivers deja de ser exclusión absoluta. El bounded context Delivery introduce despacho híbrido (Tenant fleet · MercoraHub network · external last-mile) bajo feature flags en Release 5. No es marketplace completo.
Amendment 2026-07-18 — Delivery & Couriers (FEAT-044)
| Entidad | Descripción |
|---|---|
Delivery | Agregado last-mile 1:1 con Order DELIVERY; lifecycle propio |
DeliveryAssignment | Offer/assign a courier o provider job |
CourierProfile | TENANT_FLEET | FOODHUB_NETWORK |
CourierAvailability | Online/offline + capacity |
CourierLocation | Última posición GPS (+ historial corto) |
LocationDeliverySettings | Radio/zonas, fee, SLA, vías, fallback |
DeliveryProviderJob | Referencia externa quote/create/status |
CourierEarningLedgerEntry | Tarifa red MercoraHub; payout manual |
Estados Delivery: UNASSIGNED · OFFERED · ASSIGNED · ARRIVED_AT_PICKUP · PICKED_UP · IN_TRANSIT · COMPLETED · FAILED · CANCELLED.
Regla: Order.status=COMPLETED solo tras Delivery.COMPLETED (vía use case Ordering). FAILED/CANCELLED no completan el Order.
Ref: ADR-046 · specs/015-hybrid-dispatch/ · brief FEAT-044.
Amendment 2026-07-08 — Product Modifiers (FEAT-040)
Entidades (bounded context menus, Location-scoped):
| Entidad | Descripción |
|---|---|
ModifierGroup | Plantilla reutilizable por Location — nombre, minSelect, maxSelect, isRequired |
ModifierOption | Opción dentro del grupo — name, priceDelta, isAvailable |
ProductModifierGroup | Join producto↔grupo con displayOrder en ficha |
Reglas: grupos y opciones llevan tenantId; producto y grupo deben pertenecer a la misma Location; snapshot en pedido (order_items.selected_modifiers) — slice 002-06.
Ref: specs/002-menu-management/data-model.md, spike 008, OpenSpec modifier-catalog-api.
Amendment 2026-07-20 — Post-Order Ratings (FEAT-045 / SPEC-016)
Bounded context ratings (módulo API dedicado). Una encuesta create-only por orderId tras Order.status=COMPLETED.
| Entidad | Descripción |
|---|---|
LocationReview | Extendida: orderId unique (post-order), tagCodes, hiddenAt/hiddenBy |
OrderItemRating | 1:1 por orderItemId — estrellas + chips del producto |
OrderRatingMeta | Cabecera 1:1 por orderId — comment privado (Tenant/Platform) |
CourierRating | Opcional; 1 por (orderId, courierId) si Delivery con courier FH/Tenant |
Reglas: Customer owner; items[] cubre todos los order_items; courier omitido en PICKUP/DINE_IN; comentario no público; Location.avgRating / reviewCount excluyen hiddenAt set.
Ref: brief FEAT-045 · specs/016-post-order-ratings/ · OpenSpec post-order-ratings-api.
Amendment 2026-07-22 — Post-Order Tips & Gratuity (FEAT-049 / SPEC-018)
Bounded context tips (módulo API dedicado). Tip post-pedido separado del Order.tipAmount de checkout (nunca se muta): el Customer dueño de un Order COMPLETED puede propinar al courier y/o a la Location dentro de una ventana de 7 días.
| Entidad | Descripción |
|---|---|
OrderTip | Create-once por (orderId, beneficiary) — beneficiary COURIER | LOCATION; estado PENDING_PAYMENT → SUCCEEDED | FAILED; courierId/deliveryId solo si COURIER |
Payment (existente): type admite ORDER_TIP (además de ORDER) cuando el Payment corresponde a un tip post-pedido; discriminado en metadata Stripe type=order_tip.
CourierEarningLedgerEntry (existente, extendida): nuevo campo entryKind (FARE | TIP, default FARE en backfill); unique compuesto (deliveryId, courierId, entryKind) — permite una fila FARE (fee red) y una fila TIP (propina) por delivery/courier sin colisionar.
Reglas: ventana elegibilidad = Order.status=COMPLETED y Date.now() - Order.updatedAt <= 7 días (no existe completedAt dedicado aún); monto ≥ 1 y ≤ min(100, 2× subtotal); currency = Order.currency; tip Location siempre elegible (todo orderType) si dentro de ventana; tip Courier solo DELIVERY con assignment FOODHUB_NETWORK/TENANT_FLEET aceptado.
Ref: brief FEAT-049 · specs/018-tips-gratuity/ · OpenSpec post-order-tips-api (SLICE-018-01).
Amendment 2026-07-23 — Survey Engagement Rewards (FEAT-051 / SPEC-019)
Bounded context survey-rewards (módulo API dedicado). Loyalty-lite: el Tenant configura una regla; al cruzar umbral de encuestas enviadas (FEAT-045) se emite un cupón one-shot survey-scoped canjeable en checkout (Order.discountAmount). No abre Loyalty/Coupons genéricos (siguen en exclusiones MVP).
| Entidad | Descripción |
|---|---|
SurveyRewardRule | N por Tenant (CRUD): name, umbral N, ventana días, PERCENT|FIXED, valor, TTL cupón, locationIds[] opcional, active, vigencia; matching location → all → ventana corta → updatedAt |
CustomerCoupon | Cupón one-shot: status ISSUED|REDEEMED|EXPIRED; máx. un ISSUED por (tenantId, customerId) (índice parcial); snapshot reward |
Order (existente): discountAmount (default 0) — seteado al canjear cupón en create/checkout; no confundir con tip.
Reglas: tips no cuentan; emisión cuando count >= N y count % N === 0 sin ISSUED activo; redención mismo Tenant + customer; cálculo descuento server-side sobre subtotal.
Ref: brief FEAT-051 · specs/019-survey-engagement-rewards/ · OpenSpec survey-rewards-api (SLICE-019-01).
Amendment 2026-07-27 — Marketing Promotions (FEAT-058 / SPEC-025, SLICE-025-01)
Bounded context marketing-promotions (módulo API dedicado). Motor de promociones de marketing configuradas por el Tenant, distinto de SurveyRewardRule/CustomerCoupon (FEAT-051, cupón one-shot ligado a encuestas) y de LocationPromotion (badge visual marketplace, FEAT-031, sin descuento).
| Entidad | Descripción |
|---|---|
MarketingPromotion | N por Tenant (CRUD): name, code? único por Tenant, PERCENT|FIXED, rewardValue, startsAt/endsAt requeridos, locationIds[] (vacío = todas), minOrderAmount?, maxRedemptions?, maxPerCustomer?, autoApply, requireFavoriteLocation, active |
MarketingPromotionRedemption | 1 fila por Order que canjeó la promo: promotionId, customerId, orderId (unique), locationId, discountAmount snapshot, redeemedAt |
Order (existente): marketingPromotionId? FK nullable → MarketingPromotion. Invariante: no setear marketingPromotionId y CustomerCoupon redeem en el mismo pedido (exclusión mutua, resuelta en checkout — SLICE-025-02).
Glosario (no confundir):
MarketingPromotion— descuento de marketing reutilizable, configurado por el Tenant, aplicado en checkout.CustomerCoupon(FEAT-051) — cupón one-shot emitido automáticamente al cruzar un umbral de encuestas.LocationPromotion(FEAT-031) — badge visual de descubrimiento en el marketplace, sin lógica de descuento.
Reglas (SLICE-025-01, solo dominio + CRUD): código único por (tenantId, code) cuando presente, almacenado en mayúsculas; PERCENT 1–100, FIXED > 0; endsAt >= startsAt; delete solo si redemptionsCount = 0 (409 si no); defaults create: active=false, rewardType=PERCENT, rewardValue=10, autoApply=true, requireFavoriteLocation=false. Elegibilidad Customer/Location y aplicación en checkout se implementan en SLICE-025-02.
Ref: brief FEAT-058 · specs/025-consumer-promotions/ · OpenSpec marketing-promotions-domain-api (SLICE-025-01).
Amendment 2026-07-29 — Referral / Invite Friends (FEAT-059 / SPEC-026, SLICE-026-01)
Bounded context referral (módulo API dedicado). Programa de referidos por Tenant (opt-in flag referral.invite_friends). Reward reutiliza CustomerCoupon con source=REFERRAL (emisión en SLICE-026-02). Distinto de MarketingPromotion (descuento reutilizable) y de cupón survey (source=SURVEY).
| Entidad | Descripción |
|---|---|
ReferralProgram | 0..1 por Tenant: rewards referrer/referee (PERCENT|FIXED), rewardExpiresInDays, límites opcionales, active (sync con Enable / flag) |
ReferralInvite | 1 por (tenantId, referrerCustomerId): code + linkToken (write en SLICE-026-02) |
ReferralAttribution | Vínculo referee → referrer·Tenant (PENDING|CONVERTED|REJECTED); unique refereeCustomerId (write en SLICE-026-02) |
ReferralRewardQueue | Cola diferida si ya hay cupón ISSUED (PENDING_ISSUE|ISSUED|CANCELLED) |
CustomerCoupon (ext.) | source SURVEY|REFERRAL (default SURVEY); referralAttributionId? |
FeatureFlag / override | Catálogo ADR-036 + tenant_feature_flags; seed referral.invite_friends (off) |
Glosario (no confundir):
ReferralProgram— regla de invite friends del Tenant (growth).CustomerCouponsource=REFERRAL— cupón one-shot emitido al convertir un referido.CustomerCouponsource=SURVEY— cupón one-shot de encuesta (FEAT-051).MarketingPromotion— descuento de marketing reutilizable (FEAT-058).
Reglas (SLICE-026-01): GET/PUT /api/v1/tenant/referral-program; Enable sync active + tenant flag en una TX; defaults FIXED 5/5, expiry 30, enabled false; permisos referral.read / referral.write.
Ref: brief FEAT-059 · specs/026-referral-invite-friends/ · OpenSpec referral-domain-program-api (SLICE-026-01).
Resultado Esperado
Este Domain Model servirá como fuente oficial para:
- Bounded Contexts.
- Prisma Schema.
- PostgreSQL Model.
- APIs.
- Eventos de Dominio.
- Casos de Uso.
- OpenSpec.