Modelo de datos
Documenta las entidades, los enums y la máquina de estados tal como están definidos en
packages/shared. Ese paquete es la fuente de verdad; este
documento lo describe en prosa. Identificadores en inglés.
Cambio clave de diseño: el Package es una entidad de primera clase (cada uno con su QR de Mercado Envíos, su dirección y su
confirmationCode). La confirmación de recepción se basa en el código del destinatario, no en la marca de "entregado".
1. Entidades
Definidas en entities.ts como esquemas zod con su
tipo TypeScript inferido.
User (base) — userSchema
| Campo | Tipo | Notas |
|---|---|---|
id |
Id (uuid) |
|
role |
Role |
'company' | 'courier' | 'admin' |
email |
string (email) | |
phone |
string | |
kycStatus |
KycStatus |
'pending' | 'verified' | 'rejected' |
reputationScore |
number | ≥ 0 |
createdAt |
ISO timestamp |
Company — companySchema
userId, taxId (CUIT), paymentMethods: string[].
Courier — courierSchema
userId, fullName, phone, baseAreaId: Id | null (zona base, referencia al catálogo
areas), destinationAreaIds: Id[] (zonas a las que va, referencias a areas),
coverageRadiusKm, availability: string[] (días de la semana, ver WEEKDAYS en inputs.ts),
vehicleType: VehicleType, walletBalance.
Zonas del courier = catálogo
areas(igual que el depósito de la empresa, decisión #1). En la DB: tablacouriers(1:1 con el usuario de auth) + tabla puentecourier_destination_areas(N:M courier↔area). El perfil se crea al registrarse vía el triggeron_auth_user_created, que ahora es role-aware: ramifica porraw_user_meta_data.roley creacouriers(rolcourier) ocompanies(rolcompany, default). El alta del repartidor sale de la app (apps/mobile,registerCourierInputSchema).
Area (catálogo de cobertura) — areaSchema
id, name, kind: AreaKind (partido | ciudad | comuna | barrio), parentId (CABA es
padre de sus comunas/barrios), lat/long (nullable, centroide a futuro), active.
Catálogo seedeado (zonas de Mercado Envíos + CABA por comunas y barrios), editable por admin
más adelante. Tabla areas en supabase/migrations.
Shipment (publicación / ruta) — shipmentSchema
id, companyId, originAreaId: Id (área de retiro), pricePerPackage,
pickupWindow: TimeWindow, deliveryWindow: TimeWindow, allowsSplit: boolean,
status: ShipmentStatus (derivado de sus Packages), createdAt.
Publicación = ruta. El destino NO está en el Shipment: cada Package tiene su
destinationAreaId. Hoy la empresa publica un destino + cantidad (createShipmentInputSchema); el server crea esa cantidad de paquetes conpricePerPackage = UNIFIED_PRICE_PER_PACKAGE(precio fijo unificado, luego por tier de zona; la empresa ya no carga el precio). El esquema soporta varios destinos por publicación (para reactivar multi-destino/multi-ruta sin migración).
Package (paquete individual) — packageSchema
| Campo | Tipo | Notas |
|---|---|---|
id |
Id |
|
shipmentId |
Id |
|
destinationAreaId |
Id | null |
área de destino del paquete |
mercadoEnviosTrackingId |
string | null | id del envío en ML (se carga al escanear) |
qrCodeData |
string | null | contenido del QR (opcional hasta escanear) |
labelFileUrl |
string | null | etiqueta/PDF |
scannedAtPickup |
ISO timestamp | null | momento del escaneo; null si no se retiró |
recipientName / recipientAddress / recipientPhone |
string | null | datos del destinatario (opcionales) |
dimensions |
Dimensions | null |
cm |
weight |
number | null | kg |
fragile |
boolean | |
confirmationCode |
string | SECRETO del destinatario. Nunca al repartidor. |
mercadoEnviosStatus |
MercadoEnviosStatus | null |
último estado leído de ML (conciliación) |
status |
PackageStatus |
ver máquina de estados |
assignmentId / courierId / proofOfDeliveryId |
Id | null |
Input mínimo: al publicar, por línea de destino sólo se carga área + cantidad (
createShipmentInputSchema). El QR (qrCodeData→mercadoEnviosTrackingIdvíaparseMercadoFlexTrackingId()) y el destinatario son opcionales (columnas nullable); se cargan después al escanear o desde la API de Mercado Envíos.
Assignment — assignmentSchema
id, shipmentId, courierId, packageIds: Id[], acceptedAt. Representa los paquetes
que un repartidor tomó de un Shipment (soporta fraccionamiento del lote).
ProofOfDelivery — proofOfDeliverySchema
packageId, deliveryType: DeliveryType ('recipient' | 'proxy'), photoUrl, lat,
long, timestamp, confirmationCodeEntered?, proxyDetails?, signature?.
Reglas de forma validadas con superRefine:
deliveryType = 'recipient'⇒confirmationCodeEnteredrequerido.deliveryType = 'proxy'⇒proxyDetailsrequerido (kind: portería / paquetería / vecino / otro).
Transaction — transactionSchema
packageId, amount, platformFee, courierPayout, appliedCommissionRate (snapshot
de la comisión usada), escrowStatus: EscrowStatus, releaseAt: ISO | null.
Dispute — disputeSchema
packageId, status: DisputeStatus, evidence: string[], resolution: string | null.
Rating — ratingSchema
fromUserId, toUserId, score (1–5), comment?.
PlatformConfig — platformConfigSchema
Parámetros editables desde el back-office:
commissionRate (default 0.05), confirmationWindowHours (default 48),
defaultCoverageRadiusKm, defaultDestinationRadiusKm.
Defaults en DEFAULT_PLATFORM_CONFIG.
2. Primitivas y geo
common.ts:Id(uuid),IsoTimestamp,moneySchema,rateSchema(0–1),TimeWindow,Dimensions.geo.ts:GeoPoint {lat, long},Zone {name, lat, long},DestinationZone(= Zone +radiusKm). El modelo de "zona" todavía no está cerrado (ver decisiones.md); el nombre + coordenadas permite migrar a barrios/CP/polígonos sin romper la API.
3. Enums
Definidos en enums.ts:
| Enum | Valores |
|---|---|
Role |
company, courier, admin |
KycStatus |
pending, verified, rejected |
VehicleType |
foot, bike, motorcycle, car, van |
AreaKind |
partido, ciudad, comuna, barrio |
PackageStatus |
AVAILABLE, ASSIGNED, PICKED_UP, DELIVERED_TO_RECIPIENT, DELIVERED_TO_PROXY, PENDING_CONFIRMATION, CONFIRMED, DISPUTED, CANCELLED |
ShipmentStatus |
PUBLISHED, PARTIALLY_ASSIGNED, FULLY_ASSIGNED, COMPLETED, CANCELLED |
DeliveryType |
recipient, proxy |
ProxyKind |
doorman, pickup_point, neighbor, other |
EscrowStatus |
held, released, refunded |
DisputeStatus |
open, under_review, resolved, rejected |
MercadoEnviosStatus |
handling, ready_to_ship, shipped, delivered, not_delivered |
4. Máquina de estados
Definida en state-machine.ts.
4.1 Package
AVAILABLE ─▶ ASSIGNED ─▶ PICKED_UP ─┬─▶ DELIVERED_TO_RECIPIENT ─▶ CONFIRMED
│ (código válido → inmediato)
└─▶ DELIVERED_TO_PROXY ─▶ PENDING_CONFIRMATION
(portería/3°) ├─▶ CONFIRMED
└─▶ DISPUTED
CANCELLED: desde estados previos a la entrega (AVAILABLE, ASSIGNED, PICKED_UP).
DISPUTED ─▶ CONFIRMED (liberar) | CANCELLED (reembolsar).
Transiciones exactas en PACKAGE_STATUS_TRANSITIONS:
| Desde | Hacia |
|---|---|
| AVAILABLE | ASSIGNED, CANCELLED |
| ASSIGNED | PICKED_UP, CANCELLED |
| PICKED_UP | DELIVERED_TO_RECIPIENT, DELIVERED_TO_PROXY, CANCELLED |
| DELIVERED_TO_RECIPIENT | CONFIRMED, DISPUTED |
| DELIVERED_TO_PROXY | PENDING_CONFIRMATION |
| PENDING_CONFIRMATION | CONFIRMED, DISPUTED |
| DISPUTED | CONFIRMED, CANCELLED |
| CONFIRMED | (terminal) |
| CANCELLED | (terminal) |
Helpers: canTransitionPackage(from, to), PRE_DELIVERY_PACKAGE_STATUSES,
PAYOUT_RELEASE_STATUS, isPayoutReleasable(status).
- DELIVERED_TO_RECIPIENT (con
confirmationCodeEnteredválido) → CONFIRMED de inmediato (señal fuerte). - DELIVERED_TO_PROXY (sin código) → PENDING_CONFIRMATION → CONFIRMED al vencer la ventana de confirmación sin reclamo, o si el destinatario/empresa confirma; → DISPUTED si el destinatario reclama.
4.2 REGLA DURA de pago
El pago se libera sólo al entrar en CONFIRMED. Ninguna variante de "entregado"
—DELIVERED_TO_RECIPIENT antes de validar el código, DELIVERED_TO_PROXY, ni el
delivered de Mercado Envíos— libera por sí sola. Está centralizada en
isPayoutReleasable(status) (única función que debe gobernar la liberación) y
PAYOUT_RELEASE_STATUS = 'CONFIRMED'.
4.3 Shipment (derivado)
ShipmentStatus se deriva de los estados de sus Packages vía deriveShipmentStatus():
| Resultado | Condición |
|---|---|
| PUBLISHED | sin paquetes, o todos AVAILABLE |
| PARTIALLY_ASSIGNED | alguno tomado pero queda al menos uno AVAILABLE |
| FULLY_ASSIGNED | ninguno AVAILABLE y no todos confirmados |
| COMPLETED | todos los no cancelados están CONFIRMED |
| CANCELLED | todos CANCELLED |
Transiciones de referencia en SHIPMENT_STATUS_TRANSITIONS /
canTransitionShipment(from, to).
5. Modelo económico
En payments.ts:
computePayout(price, commissionRate)→{ price, appliedCommissionRate, platformFee, courierPayout }(puro, redondeo a centavos):platformFee = price × rate;courierPayout = price − platformFee.- La comisión no es fija: se lee de
PlatformConfig.commissionRate(default 0.05) y cadaTransactionguardaappliedCommissionRate, así cambiarla no afecta pagos pasados. PaymentProvider(interfaz):holdInEscrow,releasePayout,refund. Implementación en pausa (mock) para el MVP.
6. Protección del confirmationCode
En dto.ts: CourierPackageView = Omit<Package, 'confirmationCode'> y toCourierPackageView(pkg). El repartidor nunca recibe el
código; sólo ingresa el que le dicta el destinatario, y el servidor lo valida contra el
confirmationCode real.