@quadcore-lib/orders-server
v0.2.0
Published
Órdenes y carrito para e-commerce. Checkout **público**, gestión **admin**. **El precio es autoritativo del servidor**: el cliente solo envía `productId` + `quantity`; el precio, nombre y SKU se resuelven desde el catálogo (no se confía en el request).
Readme
@quadcore-lib/orders-server
Órdenes y carrito para e-commerce. Checkout público, gestión admin. El precio es autoritativo del servidor: el cliente solo envía productId + quantity; el precio, nombre y SKU se resuelven desde el catálogo (no se confía en el request).
Resolución de precio (server-side)
create() resuelve cada producto vía un ProductResolver. Por defecto usa TypeOrmProductResolver, que consulta la tabla products (de @quadcore-lib/products-server). Podés inyectar el tuyo:
QuadcoreOrdersModule.forRoot({ productResolver: miResolver }); // o sin opciones => default TypeORMUn ProductResolver propio debe implementar resolve, decrementStock y restoreStock (ver sección Stock), y puede declarar transactional: true para compartir la transacción de la orden (ver "Garantía transaccional").
Stock (atómico, sin overselling)
create() descuenta el stock de cada línea con un UPDATE ... WHERE stock >= quantity (vía ProductResolver.decrementStock): si dos checkouts concurrentes compiten por el mismo producto, el segundo que llega ve stock ya actualizado y su UPDATE afecta 0 filas → 409 (ConflictException), sin overselling posible (TOCTOU cerrado).
Costo de envío (server-side)
El importe del envío no viaja en el request. Lo resuelve un ShippingResolver, por el mismo motivo que el precio del producto sale del catálogo y el descuento sale del cupón: si el cliente lo fija, no lo paga.
Antes CreateOrderDto aceptaba shippingCost con validación @Min(0). Eso impedía negativos, pero no lo obvio: mandar shippingCost: 0. El DTO ahora acepta solo shippingMethodId?, para elegir entre métodos si tu resolver ofrece varios.
QuadcoreOrdersModule.forRoot({
shippingResolver: createSiteConfigShippingResolver(settingsService),
});El resolver recibe { subtotal, shippingAddress, shippingMethodId, currency } y devuelve el importe. OrdersService lo clampa con Math.max(0, ...): el envío puede sumar, nunca abaratar.
Resolver sobre siteConfig
createSiteConfigShippingResolver(reader, options?) lee las keys estándar shipping.cost y shipping.freeThreshold (las que siembra seedSiteConfig de @quadcore-lib/settings-server). Si el subtotal alcanza el umbral, envío gratis.
Toma cualquier objeto con getPublicMap(): Promise<Record<string, unknown>> — interfaz estructural a propósito, así orders-server no depende de settings-server. SettingsService la cumple tal cual.
Un umbral
<= 0se trata como deshabilitado, no como "todo gratis". El seed dejashipping.freeThresholden0, así que interpretarlo literalmente regalaría el envío en cualquier instalación recién sembrada.
Si tu resolver necesita inyección de dependencias, pasalo por clase:
QuadcoreOrdersModule.forRoot({ shippingResolverClass: MiShippingResolver });Sin resolver configurado
El envío queda en 0 y se loguea un warning al arrancar:
Sin ShippingResolver configurado: todas las órdenes se crean con shippingCost 0.No hay default silencioso que adivine un importe. Lo importante es que, con o sin resolver, el cliente nunca puede fijar el envío.
Idempotencia del checkout
POST /orders acepta el header opcional Idempotency-Key:
POST /api/orders
Idempotency-Key: 7c3b1f2a-...Con la misma clave, un reintento devuelve la orden ya creada en vez de crear otra: no se vuelve a descontar stock ni a redimir el cupón. Sin el header, el comportamiento es el de siempre (cada request crea su orden), así que el checkout de invitado no se rompe.
Cubre los tres casos típicos de duplicado: doble click en "Confirmar", timeout de red donde el request llegó pero la respuesta se perdió, y retry automático de un proxy o service worker.
La unicidad la garantiza un índice único parcial en la DB (WHERE "idempotencyKey" IS NOT NULL), no un check previo en el servicio. Un check previo tendría el mismo TOCTOU que ya cierra el descuento de stock: dos requests concurrentes con la misma clave pasarían ambas la verificación sin encontrar nada. El servicio hace igual una búsqueda inicial, pero solo como atajo barato para el caso común; la carrera la resuelve el índice, y quien la pierde recibe la orden ganadora.
Reglas de la clave:
- Debe ser la misma entre reintentos del mismo checkout y distinta entre checkouts — si no, no sirve para nada o directamente bloquea compras nuevas.
useCheckoutde@quadcore-lib/storefront-coreya lo maneja. - Máximo 255 caracteres; más largo →
400. - Vacía o solo espacios cuenta como ausente.
⚠️ No se compara el payload. Si reusás una clave con un carrito distinto, recibís la orden original sin aviso — no hay
409por payload distinto como en Stripe. Generá una clave nueva por intento de compra y no vas a chocar con esto.
Garantía transaccional
El descuento de stock, la redención del cupón y el guardado de la orden corren en una sola transacción de DB cuando ambos puertos declaran transactional: true. Los defaults (TypeOrmProductResolver y CouponsServiceRedeemer) lo hacen, porque escriben en la misma conexión que las órdenes. En ese modo, si el proceso muere a mitad del create la DB revierte todo: no queda stock descontado para una orden que no existe.
transactional: true significa dos cosas, y hay que cumplir las dos:
- La implementación escribe en la misma base y conexión que
orders-server. - Honra el
EntityManagerque recibe como último parámetro enresolve/decrementStock/restoreStock(yvalidate/redeem/restoreen el redeemer).
class MiResolver implements ProductResolver {
readonly transactional = true;
decrementStock(id: string, qty: number, em?: EntityManager) {
return (em ?? this.repo.manager).createQueryBuilder()/* ... */;
}
}⚠️ Declarar
transactional: truesin honrar elemes peor que dejarlo enfalse: el servicio se saltea la compensación confiando en el rollback, y ese rollback no alcanza escrituras hechas fuera de la transacción.
Dentro de la transacción, los descuentos se aplican ordenados por productId. Los locks de fila se sostienen hasta el commit, así que dos checkouts con los mismos productos tomados en distinto orden podrían quedar en deadlock; el orden fijo lo evita. El orden de los ítems guardados en la orden no cambia: sigue siendo el del pedido.
Sin transacción común (puertos propios)
Si alguno de los dos puertos no es transaccional —porque tu catálogo o tus cupones viven en otra base o detrás de un servicio remoto— no hay transacción posible que los abarque, así que se usa el camino compensado para todo (mezclar los dos sería peor: la escritura del puerto no transaccional quedaría fuera del rollback y además nos saltearíamos su compensación).
En ese modo, si un producto de la orden falla (sin stock, producto inactivo, error al guardar), se compensa devolviendo el stock ya descontado de los items previos (ProductResolver.restoreStock) antes de relanzar el error original — cada restauración loguea su propio fallo sin abortar el resto ni tapar el error real.
⚠️ Esa compensación es best-effort: son UPDATEs atómicos independientes. Si el proceso muere entre el descuento y la compensación, ese stock/uso puede quedar sin devolver.
Reintento durable de la compensación
Una compensación que falla (DB caída, deadlock, timeout) por defecto solo queda en el log y ese stock no vuelve nunca. Pasando una cola, se reintenta:
QuadcoreOrdersModule.forRoot({
compensationQueue: jobsService, // JobsService de @quadcore-lib/jobs-server
compensationRetry: { maxAttempts: 5, baseDelayMs: 1000, maxDelayMs: 60000 },
});El backoff es exponencial (baseDelayMs × 2^(intento-1), topeado en maxDelayMs). Al agotar los intentos se loguea a nivel error como compensación perdida, que requiere corrección manual.
compensationQueue acepta cualquier objeto con enqueue y process — interfaz estructural, así orders-server no depende de jobs-server. JobsService la cumple tal cual.
El contador de intentos es propio, no el
attemptsdeJobOptions: ese campo significa cosas distintas según el driver (elInMemoryQueueDriverni siquiera reintenta), así que la política vive enorders-serverpara comportarse igual con cualquier backend de colas.
Sin cola configurada, el comportamiento es exactamente el anterior: se loguea y se sigue. Nada se rompe para quien no la use.
Esto solo aplica al camino sin transacción común: con puertos transaccionales el rollback ya deshace todo y no hay compensación que reintentar.
Cancelar una orden (POST /orders/:id/cancel o PATCH /orders/:id/status con status: cancelled) devuelve el stock de todos sus items. Con puertos transaccionales, el cambio de estado y la devolución commitean juntos. Sin ellos, la restauración corre después de guardar el nuevo status, así que si el guardado falla no queda nada restaurado a medias. setStatus/cancel son idempotentes: repetir el mismo status (doble click, retry secuencial) no vuelve a ejecutar la restauración.
⚠️ La idempotencia es check-then-act (lee el status, decide, guarda), no un
UPDATEatómico como el del stock — dos cancelaciones concurrentes de verdad (dos requests en vuelo al mismo tiempo, no un retry secuencial) todavía pueden duplicar la restauración. Bajo riesgo en un endpoint admin, pero si tu integración dispara cancelaciones automáticas concurrentes desde varios workers, tenelo en cuenta.
⚠️ Deploy sobre datos existentes: este mecanismo de stock es nuevo. Órdenes creadas antes de tenerlo activo nunca descontaron stock al crearse — si se cancelan después del deploy,
restoreReservationsigual les va a sumar stock que nunca se restó, inflándolo. Si vas a activar esto sobre una base con órdenes viejas, corré una migración de datos propia o excluí las órdenes pre-deploy del flujo de cancelación con restauración — esto es independiente de la migración de schema (couponCode/addressIdnuevos), que sí está cubierta pordist/src/migrations/(ver sección Migraciones más abajo y el README decore-server).
Cupones (opcional, server-side)
create() acepta couponCode?: string. Si viene, se resuelve vía un CouponRedeemer (default: CouponsServiceRedeemer, que delega en CouponsService de @quadcore-lib/coupons-server):
QuadcoreOrdersModule.forRoot({ couponRedeemer: miRedeemer }); // o sin opciones => default coupons-servervalidate(code, subtotal)— sivalid: false→400con elreasondel cupón (vencido, agotado, compra mínima no alcanzada, etc.), y no redime.redeem(code)— atómico (UPDATE ... WHERE usedCount < maxUses); si dos checkouts agotan el cupón a la vez, el segundo recibe409.- El
discountde la orden sale siempre del cupón validado — el cliente nunca lo fija directamente.
Igual que con el stock: dentro de la transacción, un fallo posterior revierte la redención junto con todo lo demás. Sin transacción común, se compensa devolviendo el uso del cupón (CouponRedeemer.restore) antes de relanzar el error. Cancelar la orden también devuelve el uso del cupón si tenía uno (OrderEntity.couponCode).
Un CouponRedeemer propio debe implementar validate, redeem y restore, y puede declarar transactional: true si comparte conexión con las órdenes y honra el EntityManager recibido (ver "Garantía transaccional").
Endpoints
| Método | Ruta | Acceso | Descripción |
|---|---|---|---|
| POST | /orders | público | Crear orden. Header opcional Idempotency-Key. Body: customerEmail, items[] ({ productId, quantity }), shippingMethodId?, currency?, shippingAddress?, addressId? (uuid), notes?, couponCode?. No acepta unitPrice, discount ni shippingCost — los tres se resuelven server-side. |
| GET | /orders | admin | Listado paginado. Query: page, pageSize, status, customerEmail. |
| GET | /orders/:id | admin | Una orden (con items). |
| PATCH | /orders/:id/status | admin | Cambiar estado. |
| POST | /orders/:id/cancel | admin | Cancelar. |
Cálculo de totales (autoritativo)
unitPrice = precio del catálogo; lineTotal = unitPrice × quantity; subtotal = Σ lineTotal; total = max(0, subtotal + shippingCost − discount). Los tres componentes que no son el subtotal se resuelven server-side: shippingCost vía ShippingResolver (0 si no hay resolver configurado) y discount vía cupón (0 si no hay couponCode), con clamp a ≥ 0. Producto inexistente o inactivo → 400.
Entidades
OrderEntity (orders): orderNumber, datos de cliente, status, subtotal/shippingCost/discount/total, currency, shippingAddress, addressId? (referencia suelta al id de @quadcore-lib/addresses-server, sin FK — el snapshot shippingAddress sigue siendo la fuente de verdad histórica, editar/borrar la dirección después nunca toca órdenes pasadas), couponCode?, userId?, items, soft-delete.
userIdguarda el sub de Auth0 del cliente logueado ("auth0|abc123", columnavarchar, no un uuid) — mismo criterio queaddresses-server/notifications-server/audit-server.actorId, para que sea comparable/dereferenciable contra ellos sin conversión.nullen checkout de invitado.
⚠️
addressIdno se valida:create()lo persiste tal cual viene en el DTO, sin chequear que exista enaddresses-serverni que pertenezca al usuario de la orden (igual queuserId, tampoco validado —POST /orderses público). Si tu proyecto dereferenciaorder.addressId(ej. para mostrar la dirección en un panel admin o generar una etiqueta de envío), el chequeo de ownership es responsabilidad de quien lo consuma —addressesService.findByIdno alcanza, hay que confirmaraddress.userId === order.userId(o equivalente) antes de exponerla.OrderItemEntity(order_items): snapshotname/sku/unitPrice/quantity/lineTotal(tomados del catálogo al comprar) +productId.
Depende de
@quadcore-lib/products-servery@quadcore-lib/coupons-serverpara los resolvers por defecto. Si usás unproductResolver/couponRedeemerpropio, ese acoplamiento no aplica.
Máquina de estados
PATCH /orders/:id/status y cancel validan la transición contra ORDER_TRANSITIONS (transición ilegal → 400):
pending → paid, cancelled
paid → shipped, cancelled
shipped → delivered
delivered, cancelled → (terminales)Ej.: cancelled → paid o cancelar una orden delivered se rechazan.
La transición a cancelled (por cualquiera de las dos rutas) devuelve el stock reservado — ver sección Stock.
Notificaciones automáticas al cambiar de estado (opcional)
QuadcoreOrdersModule.forRoot({ eventEmitter }) emite ORDER_STATUS_CHANGED (de @quadcore-lib/core-server) cada vez que setStatus()/cancel() cambia el estado — eventEmitter es cualquier objeto con emit/on (new EventEmitter2() de @nestjs/event-emitter, o el EventEmitter nativo de Node). Sin la opción, no se emite nada. Ver @quadcore-lib/notifications-server (autoNotify) para consumir esto sin escribir el listener a mano — hay que pasarle la MISMA instancia de eventEmitter a los tres módulos.
Migraciones
Este paquete trae sus migraciones de TypeORM en dist/src/migrations/*.js (se compilan junto al resto). Ver la guía completa (setup del DataSource, cómo combinarlas con las de otros paquetes, synchronize en dev vs. prod) en el README de @quadcore-lib/core-server, sección "Migraciones de DB".
