From d14851d6a37ad713b217dc2bce1c0a2e11fae6ca Mon Sep 17 00:00:00 2001 From: LauAubert Date: Fri, 2 Oct 2026 01:49:55 -0300 Subject: [PATCH 1/2] fix: [TESIS-999016] keep the stock of a sale that arrives already cancelled The order ingestion accepts any known status, cancelled included, and took the units of every line regardless. A sale whose first notification already comes cancelled (a rejected payment in Mercado Libre) left its units deducted for good: a cancelled order cannot be modified nor cancelled again, so nothing ever gave them back, and the stock callbacks published the lower quantity to every channel. The sale is still recorded, so it leaves a trace, but its lines take no units and therefore record no warehouse, like the lines older than TESIS-126. Paid and pending sales deduct as before. Co-Authored-By: Claude Opus 5.5 --- app/poros/orders/process_webhook_order.rb | 20 ++++++- .../orders/process_webhook_order_spec.rb | 55 +++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/app/poros/orders/process_webhook_order.rb b/app/poros/orders/process_webhook_order.rb index 7fa31cf0..3e74ef22 100644 --- a/app/poros/orders/process_webhook_order.rb +++ b/app/poros/orders/process_webhook_order.rb @@ -21,6 +21,7 @@ class UnmappedProductError < StandardError; end MISSING_ITEMS = 'the payload does not carry any order item' UNREADABLE_ITEMS = 'the template could not read %d of the order items in the payload' ORDERS_UNIQUE_INDEX = 'index_orders_on_company_id_and_external_order_id' + CANCELLED = 'cancelled' def initialize(webhook_log:) super() @@ -82,11 +83,26 @@ def create_order(items) # transacción, así que el orden no abre ninguna ventana. def register_item(order, item, mapping) quantity = quantity_of(item) - stock = Catalog::DeductStock.new(product: mapping.product, quantity: quantity).call - OrderItem.create!(order: order, product: mapping.product, warehouse_id: stock.warehouse_id, + OrderItem.create!(order: order, product: mapping.product, + warehouse_id: take_units(order, mapping.product, quantity), quantity: quantity, unit_price: unit_price_of(item, mapping)) end + # Una venta que llega ya cancelada (en Mercado Libre la primera notificación + # puede traer un pago rechazado) se registra —queda el rastro de que existió— + # pero no se lleva stock: no va a salir. Antes se descontaba igual, y como una + # orden cancelada no se edita ni se vuelve a cancelar, esas unidades no + # volvían nunca y los canales publicaban de menos. + # + # Sin descuento no hay depósito que registrar: la línea queda sin él, igual + # que las anteriores a TESIS-126, y como la orden ya está cancelada nada va a + # intentar devolverle unidades. + def take_units(order, product, quantity) + return if order.status == CANCELLED + + Catalog::DeductStock.new(product: product, quantity: quantity).call.warehouse_id + end + # Resuelve el producto interno de cada ítem antes de escribir nada: un ítem # sin mapear corta el procesamiento acá, no a mitad de la orden. # diff --git a/spec/poros/orders/process_webhook_order_spec.rb b/spec/poros/orders/process_webhook_order_spec.rb index 4576afa7..b2b7681f 100644 --- a/spec/poros/orders/process_webhook_order_spec.rb +++ b/spec/poros/orders/process_webhook_order_spec.rb @@ -379,6 +379,61 @@ def unique_violation end end + # La primera notificación de una venta puede traerla ya cancelada (en ML, un + # pago rechazado). Antes descontaba igual, y como una orden cancelada no se + # edita ni se vuelve a cancelar, esas unidades no volvían nunca. + context 'when the sale arrives already cancelled' do + subject(:process) { described_class.new(webhook_log: cancelled_log) } + + def cancelled_log + @cancelled_log ||= create_log(order_payload(items: [line('MLA-1', 2, 100)], + status: 'cancelado')) + end + + def create_service(mapper: order_mapper) + Service.create!(service_name: "Mercado Libre #{SecureRandom.hex(4)}", type: 'ecommerce', + http_method: 'GET', uri: 'https://api.ml.test/orders', + response_mapper: mapper, + response_value_mapper: { 'pagado' => 'paid', 'cancelado' => 'cancelled' }) + end + + before { publish('SKU-1', 'MLA-1', stock: 20) } + + it 'records the sale as cancelled, so it leaves a trace', :aggregate_failures do + order = process.call + + expect(order.status).to eq('cancelled') + expect(order.order_items.sum(:quantity)).to eq(2) + end + + it 'takes no units from the stock' do + process.call + + expect(stock_of('SKU-1')).to eq(20) + end + + it 'marks the log as processed' do + process.call + + expect(cancelled_log.reload.status).to eq('processed') + end + + # Ya cancelada, nada va a intentar devolverle unidades. + it 'leaves its lines without a warehouse, since nothing was taken from any' do + expect(process.call.order_items.pluck(:warehouse_id)).to eq([nil]) + end + end + + context 'when the sale arrives paid' do + before { publish('SKU-1', 'MLA-1', stock: 20) } + + it 'still takes its units from the stock' do + described_class.new(webhook_log: create_log(order_payload(items: [line('MLA-1', 2, 100)]))).call + + expect(stock_of('SKU-1')).to eq(18) + end + end + context 'when the channel reports a status the OMS does not know' do before { publish('SKU-1', 'MLA-1') } From ac6b7aa915228c57a8da954408993df605d4fe45 Mon Sep 17 00:00:00 2001 From: LauAubert Date: Fri, 2 Oct 2026 02:12:29 -0300 Subject: [PATCH 2/2] docs: [TESIS-999016] record the cancelled arrival in ADR-010 Co-Authored-By: Claude Opus 5.5 --- docs/adr/ADR-010-ingesta-de-ordenes-de-webhooks.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/adr/ADR-010-ingesta-de-ordenes-de-webhooks.md b/docs/adr/ADR-010-ingesta-de-ordenes-de-webhooks.md index 55796040..59910c7b 100644 --- a/docs/adr/ADR-010-ingesta-de-ordenes-de-webhooks.md +++ b/docs/adr/ADR-010-ingesta-de-ordenes-de-webhooks.md @@ -89,6 +89,7 @@ Una venta que entra mal es peor que una venta que no entra: queda escrita, con m | `unit_price` en el payload **y** `external_price` en el mapping (los dos) | Corta | Un ítem en 0 es indistinguible de una bonificación legítima: el error queda enterrado en un registro financiero y ya no se puede detectar | | Un id externo sin `ProductMapping` | Corta (`UnmappedProductError`) | Es el caso que la DLQ resuelve sola: se crea el mapeo y se reintenta | | `status` ausente o desconocido para el OMS | **No corta**: entra como `pending` | El estado es informativo y se corrige después; la venta es el dato que no se puede perder | +| `status` que llega `cancelled` | **No corta**: se registra cancelada, **sin descontar stock** y con las líneas sin depósito | La venta no va a salir. Descontar dejaba las unidades fuera para siempre, porque una orden cancelada no se edita ni se vuelve a cancelar (TESIS-999016) | | Datos del comprador (documento, dirección, CP) | **No corta** | La plantilla los mapea si el canal los manda; su ausencia no mueve ni stock ni dinero | El precio tiene un respaldo antes de cortar: el `external_price` del `ProductMapping`, o sea el precio publicado en ese canal. Es un precio real de la venta y no uno inventado, y cubre el caso común de un canal que no manda el precio en el webhook. Un `0` que **sí** viene en el payload se respeta: ahí el canal está afirmando que el ítem fue bonificado.