Domain model

Core entities, Location (not Restaurant), and business rules (DOC-002).

Source: docs/domain/DOC-002-domain-model.md

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.
  • endsOn opcional; si se omite, el cierre permanece hasta Reopen manual.
  • Si endsOn está presente, debe ser hoy o futuro (TZ location) y ≤ hoy + 30 días.
  • Cuando está activo, acceptingOrders es false independientemente de BusinessHours.

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:

SuperficiePermisoAlcance
Tenant S-104audit.read (catálogo permissions)Solo filas con tenantId = JWT. Sin tenantId en el DTO.
Platform S-048platform.audit.readCross-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

Email

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)

EntidadDescripción
DeliveryAgregado last-mile 1:1 con Order DELIVERY; lifecycle propio
DeliveryAssignmentOffer/assign a courier o provider job
CourierProfileTENANT_FLEET | FOODHUB_NETWORK
CourierAvailabilityOnline/offline + capacity
CourierLocationÚltima posición GPS (+ historial corto)
LocationDeliverySettingsRadio/zonas, fee, SLA, vías, fallback
DeliveryProviderJobReferencia externa quote/create/status
CourierEarningLedgerEntryTarifa 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):

EntidadDescripción
ModifierGroupPlantilla reutilizable por Location — nombre, minSelect, maxSelect, isRequired
ModifierOptionOpción dentro del grupo — name, priceDelta, isAvailable
ProductModifierGroupJoin 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.

EntidadDescripción
LocationReviewExtendida: orderId unique (post-order), tagCodes, hiddenAt/hiddenBy
OrderItemRating1:1 por orderItemId — estrellas + chips del producto
OrderRatingMetaCabecera 1:1 por orderIdcomment privado (Tenant/Platform)
CourierRatingOpcional; 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.

EntidadDescripción
OrderTipCreate-once por (orderId, beneficiary)beneficiary COURIER | LOCATION; estado PENDING_PAYMENTSUCCEEDED | 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).

EntidadDescripción
SurveyRewardRuleN 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
CustomerCouponCupó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).

EntidadDescripción
MarketingPromotionN por Tenant (CRUD): name, code? único por Tenant, PERCENT|FIXED, rewardValue, startsAt/endsAt requeridos, locationIds[] (vacío = todas), minOrderAmount?, maxRedemptions?, maxPerCustomer?, autoApply, requireFavoriteLocation, active
MarketingPromotionRedemption1 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).

EntidadDescripción
ReferralProgram0..1 por Tenant: rewards referrer/referee (PERCENT|FIXED), rewardExpiresInDays, límites opcionales, active (sync con Enable / flag)
ReferralInvite1 por (tenantId, referrerCustomerId): code + linkToken (write en SLICE-026-02)
ReferralAttributionVínculo referee → referrer·Tenant (PENDING|CONVERTED|REJECTED); unique refereeCustomerId (write en SLICE-026-02)
ReferralRewardQueueCola diferida si ya hay cupón ISSUED (PENDING_ISSUE|ISSUED|CANCELLED)
CustomerCoupon (ext.)source SURVEY|REFERRAL (default SURVEY); referralAttributionId?
FeatureFlag / overrideCatálogo ADR-036 + tenant_feature_flags; seed referral.invite_friends (off)

Glosario (no confundir):

  • ReferralProgram — regla de invite friends del Tenant (growth).
  • CustomerCoupon source=REFERRAL — cupón one-shot emitido al convertir un referido.
  • CustomerCoupon source=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.