npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 TypeORM

Un 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 <= 0 se trata como deshabilitado, no como "todo gratis". El seed deja shipping.freeThreshold en 0, 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. useCheckout de @quadcore-lib/storefront-core ya 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 409 por 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:

  1. La implementación escribe en la misma base y conexión que orders-server.
  2. Honra el EntityManager que recibe como último parámetro en resolve/decrementStock/restoreStock (y validate/redeem/restore en el redeemer).
class MiResolver implements ProductResolver {
  readonly transactional = true;
  decrementStock(id: string, qty: number, em?: EntityManager) {
    return (em ?? this.repo.manager).createQueryBuilder()/* ... */;
  }
}

⚠️ Declarar transactional: true sin honrar el em es peor que dejarlo en false: 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 attempts de JobOptions: ese campo significa cosas distintas según el driver (el InMemoryQueueDriver ni siquiera reintenta), así que la política vive en orders-server para 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 UPDATE ató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, restoreReservations igual 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/addressId nuevos), que sí está cubierta por dist/src/migrations/ (ver sección Migraciones más abajo y el README de core-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-server
  1. validate(code, subtotal) — si valid: false400 con el reason del cupón (vencido, agotado, compra mínima no alcanzada, etc.), y no redime.
  2. redeem(code) — atómico (UPDATE ... WHERE usedCount < maxUses); si dos checkouts agotan el cupón a la vez, el segundo recibe 409.
  3. El discount de 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.

userId guarda el sub de Auth0 del cliente logueado ("auth0|abc123", columna varchar, no un uuid) — mismo criterio que addresses-server/notifications-server/audit-server.actorId, para que sea comparable/dereferenciable contra ellos sin conversión. null en checkout de invitado.

⚠️ addressId no se valida: create() lo persiste tal cual viene en el DTO, sin chequear que exista en addresses-server ni que pertenezca al usuario de la orden (igual que userId, tampoco validado — POST /orders es público). Si tu proyecto dereferencia order.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 consumaaddressesService.findById no alcanza, hay que confirmar address.userId === order.userId (o equivalente) antes de exponerla. OrderItemEntity (order_items): snapshot name/sku/unitPrice/quantity/lineTotal (tomados del catálogo al comprar) + productId.

Depende de @quadcore-lib/products-server y @quadcore-lib/coupons-server para los resolvers por defecto. Si usás un productResolver/couponRedeemer propio, 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".