Skip to content

feat: [TESIS-138] connect Shopify per company, publish the stock and receive its orders - #98

Merged
TomasMartin2004 merged 15 commits into
masterfrom
TESIS-138-shopify-integration
Oct 3, 2026
Merged

TomasMartin2004 merged 15 commits into
masterfrom
TESIS-138-shopify-integration

Conversation

@LauAubert

@LauAubert LauAubert commented Oct 2, 2026 •

Copy link
Copy Markdown
Member

Ticket de Jira

https://proyectofinalfrlp.atlassian.net/browse/TESIS-138


Descripción

Conecta Shopify de punta a punta contra una tienda de prueba real, que es la mitad e-commerce del criterio de aceptación del proyecto (E4b V2 §2.6: una plataforma de e-commerce funcionando end-to-end en sandbox).

  • Conexión: cada empresa conecta su propia app de Shopify (client credentials). El equipo de OneStock carga las credenciales desde el backoffice.
  • Vínculo: la empresa vincula sus productos con las variantes de la tienda, por id o por SKU.
  • Stock saliente: el stock del OMS se publica en Shopify.
  • Ventas entrantes: las ventas de la tienda entran solas por webhook, firmado y con ciudad y provincia, y descuentan stock.

Todo es plantilla (Service): no hay código específico de Shopify en app/. Las decisiones están en ADR-018 (conexión, autenticación, plantillas de operación, stock) y ADR-019 (firma, mapeo de la orden, suscripción del webhook).

Depende de / se mergea junto con el PR de front TESIS-139 (proyecto-web#59): GET /integrations cambia de forma y la API de la empresa deja de cargar credenciales. Mergear este primero.

Probado contra la tienda onestock-norte el 01/10 con una compra real: la venta entró como orden pagada con cliente, dirección, Berisso / Buenos Aires, y NOR-002 pasó de 20 a 19 en el OMS y en Shopify.


Cambios realizados

Conexión y autenticación

  • Agrega company_integrations.settings (jsonb, sin cifrar) para la configuración de la cuenta (dominio, ubicación de stock, nombre de la cuenta). credentials queda sólo para secretos
  • Agrega services.auth_strategy / auth_config: oauth_client_credentials pide el token con las credenciales de la empresa, lo cachea, lo renueva bajo row lock y reintenta una vez ante un 401 (Integrations::EnsureAccessToken, RequestClientCredentialsToken, AuthHeaders). bearer sigue siendo el default
  • Agrega credential_fields / setting_fields declarados por la plantilla y Integrations::ApplyDeclaredFields, que valida contra ellos
  • Agrega Integrations::TestConnection («probar conexión»): completa la ubicación de stock y guarda el nombre de la cuenta

Plantillas de operación y transporte

  • Agrega services.parent_service_id + operation: una plantilla hija es otra llamada al mismo proveedor con la cuenta de su madre. No se lista ni se conecta sola
  • Agrega request_format: graphql, body_template y error_path (errores que llegan con HTTP 200, Integrations::DetectResponseErrors)
  • El request_mapper acepta settings.<clave> y valores {{variable}}

Productos y stock

  • Agrega product_mappings.external_refs (el inventory_item_id de Shopify)
  • POST /products/:id/mappings le pregunta al canal antes de vincular, por id de variante o, sin id, por SKU (Catalog::LinkExternalProduct). Responde 422 si no existe o es ambiguo, 502 si el canal no responde, y warnings si el SKU difiere
  • Catalog::OutboundSync publica sólo en Service#stock_template, con las external_refs y una clave de idempotencia. Al vincular publica sólo en el canal recién vinculado

Backoffice (Avo)

  • Agrega las acciones Configure connection (un campo por dato declarado, nunca precarga secretos), Test connection y Register webhook en Company integrations
  • Una integración nueva nace inactiva

API de la empresa (sólo lectura)

  • GET /api/v1/integrations lista sólo plantillas conectables con configured, is_active, integration_id y account_name. Ya no devuelve uri ni http_method
  • Quita PUT /integrations/:service_id, DELETE y POST .../test, junto con CompanyIntegrationPolicy: las credenciales sólo se cargan desde el backoffice (ADR-018)

Ventas entrantes (ADR-019)

  • Agrega services.webhook_config y Webhooks::VerifySignature. El gateway verifica el HMAC sobre el body crudo antes de persistir: sin firma válida responde 401 y no guarda nada. Falla cerrado si falta el secreto. Las plantillas sin firma (Mercado Libre, couriers) siguen igual
  • La plantilla Shopify mapea orders/create (cliente, dirección de envío, ítems por variante) y declara la firma con el client_secret de la app de cada empresa
  • Orders::TranslateWebhookPayload / ProcessWebhookOrder guardan ciudad y provincia. La provincia se compara sin mayúsculas ni tildes contra Order::PROVINCES, y si no matchea la venta entra sin provincia
  • Agrega Integrations::RegisterWebhook con dos hijas (webhook_lookup, webhook_subscription): registrar dos veces no duplica la suscripción. Se registra al guardar Configure connection, con la acción Register webhook o con bin/rails "integrations:shopify:webhook[norte]"
  • Nombra las rutas del gateway (api_webhooks_integration, api_webhooks_courier) y agrega config.x.public_webhook_base_url. En desarrollo, config.hosts acepta el host del túnel

Otros

  • Seeds: plantillas de Shopify (madre + 5 hijas). Mercado Libre y Tiendanube de ejemplo quedan inactivas porque sus tokens son inventados
  • bin/jobs vuelve a ser ejecutable. architecture.md documenta las dos variables que necesitan los workers en macOS
  • Rake de desarrollo integrations:shopify:{connect,test,link,webhook}
  • Fixture real anonimizado spec/fixtures/shopify/orders_create.json y spec de punta a punta del gateway

Evidencia visual

N/A (las acciones nuevas del backoffice se ven en el Caso 1).


Cómo probar

Preparación

Precondición: base con las migraciones y las seeds de esta rama.

bin/rails db:migrate && bin/rails db:seed

En .env (no se suben al repo). Las credenciales de la app de Norte se las piden a Lautaro:

SHOPIFY_NORTE_SHOP_DOMAIN=onestock-norte.myshopify.com
SHOPIFY_NORTE_CLIENT_ID=<client id de la app de Norte>
SHOPIFY_NORTE_CLIENT_SECRET=<client secret de la app de Norte>

Los workers en macOS necesitan dos variables (ver architecture.md §8.3). Sin ellas se caen en cada fork y ningún job corre:

OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES PGGSSENCMODE=disable bin/jobs

Caso 1: conectar Norte con su tienda desde el backoffice

  1. Entrar a /admin con el admin de las seeds (admin@backoffice.com) → Company integrations → la de Norte con Shopify (crearla si no está).
  2. Actions → Configure connection con el dominio, el Client ID y el Client secret.
    → Avisa «Connection saved. Connected to onestock-norte.» y los Settings muestran location_id y account_name.
  3. Activarla con Edit → Is active.
  • Alternativa por consola, que lee el .env: bin/rails "integrations:shopify:connect[norte]" y bin/rails "integrations:shopify:test[norte]".

Caso 2: la empresa ve el estado, pero no puede cargar credenciales

Precondición: JWT de admin@norte.com (POST /api/v1/auth/login, contraseña de las seeds).

  1. GET /api/v1/integrations → Shopify con configured: true, is_active: true, account_name: "onestock-norte". Mercado Libre y Tiendanube aparecen con is_active: false. Ninguna trae uri, http_method ni credenciales.
  2. PUT /api/v1/integrations/<service_id> → 404.

Caso 3: vincular un producto por SKU y publicar su stock

Precondición: workers corriendo; la tienda tiene una variante con SKU NOR-001.

  1. POST /api/v1/products/<id de NOR-001>/mappings con { "product_mapping": { "company_integration_id": <id de la integración Shopify> } } (sin external_product_id → busca por SKU).
    → 201 con el vínculo: external_product_id es el id de la variante y warnings viene vacío. El inventory_item_id queda guardado en external_refs, que la API no expone.
    → En el admin de Shopify, el stock de NOR-001 pasa a ser el total del OMS.
  2. Con un external_product_id que no existe en la tienda → 422.
  • Alternativa por consola: bin/rails "integrations:shopify:link[norte,NOR-001]".

Caso 4: una venta de la tienda entra sola (webhook real)

Precondición: casos 1 y 3; NOR-00X vinculados; workers corriendo.

  1. Túnel a la API local: ngrok http 3000. Si el puerto 3000 está ocupado, usar otro y el mismo en el server.
  2. Levantar la API con la URL del túnel:
    PUBLIC_WEBHOOK_BASE_URL=https://<tu-subdominio>.ngrok-free.dev bin/rails server
  3. Registrar el webhook (si no se registró solo al hacer Configure connection):
    PUBLIC_WEBHOOK_BASE_URL=https://<tu-subdominio>.ngrok-free.dev bin/rails "integrations:shopify:webhook[norte]"
    → ok: true — Webhook registered: https://…/api/webhooks/integrations/<id>. Correrlo de nuevo dice already registered.
  4. Comprar en la tienda onestock-norte un producto vinculado. La contraseña de la tienda se la piden a Lautaro. Usar una dirección de envío en Argentina y pagar con Bogus Gateway, número de tarjeta 1.
    → En el log de Rails: POST /api/webhooks/integrations/<id> → 202.
    → WebhookLog en processed y una Order nueva con external_order_id = id de Shopify, status: paid, nombre, dirección, CP, ciudad y provincia, y el total.
    → El stock del producto baja en el OMS y el admin de Shopify muestra el mismo número.
  5. Reenviar el webhook desde Shopify, o repetir el POST con el mismo body y firma → 202, sin otra orden ni otro descuento.

Caso 5: un webhook sin firma no entra

curl -i -X POST https://<tu-subdominio>.ngrok-free.dev/api/webhooks/integrations/<id> \
  -H 'Content-Type: application/json' -d '{"id":1}'

→ 401, sin WebhookLog nuevo. El log de Rails dice rejected event for integration <id>: X-Shopify-Hmac-SHA256 header is missing.

Caso 6: probar la ingesta sin comprar (webhook firmado a mano)

Precondición: NOR-002 vinculado a la variante 67579271348457 (la del fixture).

bin/rails runner '
c = Company.find_by!(slug: "norte")
Current.set(company_id: c.id) do
  i = c.company_integrations.joins(:service).find_by!(services: { service_name: "Shopify" })
  body = Rails.root.join("spec/fixtures/shopify/orders_create.json").read.sub("18922194698473", "#{Time.now.to_i}")
  hmac = Base64.strict_encode64(OpenSSL::HMAC.digest("SHA256", i.credentials["client_secret"], body))
  res = Net::HTTP.post(URI("http://localhost:3000/api/webhooks/integrations/#{i.id}"), body,
                       "Content-Type" => "application/json", "X-Shopify-Hmac-SHA256" => hmac)
  puts res.code
end'

→ 202, una orden nueva a nombre de «Juana Pérez» en Berisso / Buenos Aires, y NOR-002 baja 1 en el OMS y en Shopify.

Tests

  • bundle exec rspec (1667 ejemplos), bundle exec rubocop y bundle exec brakeman -q en verde.
  • spec/requests/api/webhooks/shopify_orders_spec.rb recorre el payload real por el gateway y el worker.

Impacto y consideraciones

¿Introduce breaking changes?
Sí:

  • GET /api/v1/integrations cambia de forma: deja uri y http_method, y suma integration_id y account_name.
  • Se quitan PUT /integrations/:service_id, DELETE /integrations/:service_id y POST /integrations/:service_id/test.
  • El front se adapta en TESIS-139: mergear juntos, este primero.

¿Requiere nuevas variables de entorno?
Sí:

  • PUBLIC_WEBHOOK_BASE_URL: URL pública HTTPS de la API, sin barra final. En producción es el dominio del servidor (y tiene que estar en RAILS_ALLOWED_HOSTS). En desarrollo, la del túnel. Sin ella no se puede registrar el webhook.
  • Sólo para desarrollo (rake tasks): SHOPIFY_<SLUG>_SHOP_DOMAIN, SHOPIFY_<SLUG>_CLIENT_ID, SHOPIFY_<SLUG>_CLIENT_SECRET.

¿Afecta la arquitectura o genera un nuevo patrón?
Sí:

  • ADR-018 (nuevo): settings vs. credenciales, estrategias de autenticación, campos declarados, plantillas de operación y GraphQL. Es el tercer caso sincrónico de architecture.md §7.4: vincular consulta al canal dentro del request.
  • ADR-019 (nuevo): firma declarada por plantilla, mapeo de orders/create y suscripción del webhook.
  • ADR-011, ADR-017 y ADR-018 llevan notas de actualización.

Migraciones: 20260926143012_add_connection_config_to_integrations, 20260926171540_add_external_refs_to_product_mappings, 20261001211235_add_webhook_config_to_services. Después de migrar hay que correr db:seed: reaplica las plantillas sobre una base ya sembrada.

Pendiente (documentado en ADR-019):

  • Nada impide todavía que dos empresas conecten la misma tienda.
  • El gateway no mira is_active.
  • Las plantillas que no firman (Mercado Libre, couriers) siguen aceptando cualquier evento.

🤖 Generated with Claude Code

LauAubert and others added 14 commits October 1, 2026 21:10
Adds what the engine needs to talk to a real provider without code per
provider, starting with Shopify:

- company_integrations.settings for the non-secret account config, which
  the template interpolates in its URI (https://:shop_domain/...)
- services.auth_strategy/auth_config: oauth_client_credentials asks for a
  token with the client id and secret of the company, caches it and renews
  it under a row lock; bearer keeps the previous behavior
- GraphQL request format and errors that arrive with HTTP 200
- operation templates: a child runs with the integration of its parent,
  is not listed and cannot be connected on its own
- POST /api/v1/integrations/:service_id/test and dev rake tasks to connect
  a seeded company with its Shopify dev store

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nt or SKU

- product_mappings.external_refs keeps the extra channel ids a link needs
  (Shopify publishes stock by inventory item, not by variant)
- linking asks the channel first with operation templates: product_lookup
  confirms an external id, product_search finds the only variant with the
  SKU of the product; the stock is pushed right after linking
- the outbound sync only pushes to a template that publishes stock
  (Service#stock_template) and sends the mapping refs plus an idempotency key
- request_mapper values can read settings.<key> and build {{variable}} strings
- Shopify templates: inventorySetQuantities on the parent, lookup and SKU
  search as children

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Linking NOR-001 with Shopify republished its stock on every channel of the
product, so a failing channel made the new link look broken. The outbound
sync and its job take an optional integration, and the link uses it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… form

What the frontend needs to connect a provider without Postman or Avo:

- GET /integrations lists what the connection form asks for
  (credential_fields, setting_fields), the stored settings, which secrets
  are loaded (keys only) and whether the connection can be tested; uri and
  http_method no longer travel
- PUT validates against the declared fields and answers 422 with
  { error, fields } and a code per field; a blank secret keeps the stored
  one, a changed credential drops the cached token, and is_active is kept
  when omitted
- DELETE /integrations/:service_id disconnects: stops the integration and
  forgets its secrets, keeping settings and linked products
- seeds declare the access_token field for the bearer templates

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Settings apart from credentials, credentials per tenant (each company
connects its own Shopify app), auth strategies with a cached token renewed
under a row lock, declared fields as the source of the connection form,
operation templates, GraphQL transport, stock published only by templates
that know how, and the synchronous lookup when linking a product. ADR-015
notes that field errors travel next to error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… crash

bin/jobs was committed without the executable bit, so it answered
Permission denied. On macOS the Solid Queue workers abort right after the
fork unless OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES is set; the guide now
says so next to the Windows note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ns as inactive

Their tokens are made up, so the integrations screen showed them as
connected and every stock change tried to publish to them and failed.
The rows stay because the demo orders, mappings and events use them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The OneStock team loads each company's account in Avo with two actions:
"Configure connection" asks for every field the template declares and
never prefills a secret, and "Test connection" checks the account.

The company API becomes read-only: GET /integrations reports status and
the connected account, and the create, disconnect and test endpoints are
removed so credentials cannot be loaded bypassing the backoffice. A new
integration starts inactive, and an operation template cannot be
connected on its own. ADR-018 records the decision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The integrations screen shows it to say which account the company is
connected to, but only the declared settings were stored, so it was
always empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The public gateway accepted any body for any integration id, so anyone who
knew the URL could inject sales that deduct stock. A template now declares
how its provider signs (services.webhook_config: algorithm, header and the
integration credential that holds the secret) and Webhooks::VerifySignature
checks the HMAC over the raw body before anything is persisted. A wrong or
missing signature answers 401 and stores nothing; a template that asks for a
signature without a secret to check it rejects the event instead of skipping
the check. Templates without a signature behave as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The courier needs them to quote the shipment and the ingestion dropped them.
The province is matched against Order::PROVINCES ignoring case and accents,
since each channel spells it its own way, and a sale with an unknown one is
registered without province instead of failing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- the Shopify template maps orders/create (customer, shipping address, city,
  province and line items by variant) and declares the HMAC signature Shopify
  computes with the client secret of each company app
- Integrations::RegisterWebhook subscribes the gateway of the integration
  with two operation templates: webhook_lookup, so registering twice does not
  duplicate the subscription, and webhook_subscription
- the address is built on PUBLIC_WEBHOOK_BASE_URL, the public HTTPS URL of
  the API (a tunnel in development, accepted by config.hosts)
- the backoffice registers it after a successful "Configure connection" and
  through a new "Register webhook" action; dev task integrations:shopify:webhook

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signature declared by the template and checked over the raw body, the
orders/create mapping, city and province, and the webhook subscription. The
architecture guide adds PGGSSENCMODE=disable for the Solid Queue workers on
macOS, which otherwise segfault when they open their PostgreSQL connection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fixture is the first sale received from the dev store through the
tunnel, anonymized (customer, address, IP, user agent and tokens). The spec
signs it like Shopify does and runs it through the gateway and the ingestion
worker: order with customer, city and province, stock deducted once even
when the delivery is retried, and rejected when signed with another secret.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@LauAubert
LauAubert marked this pull request as ready for review October 2, 2026 12:30
@LauAubert
LauAubert requested a review from a team as a code owner October 2, 2026 12:30
@LauAubert
LauAubert requested review from Sanntinat and TomasMartin2004 and removed request for Sanntinat October 2, 2026 12:30

@TomasMartin2004 TomasMartin2004 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aprobado. Es el PR que cierra el criterio de aceptación de la E4b que veníamos arrastrando: una plataforma de e-commerce funcionando punta a punta. Lo verifiqué en local, no de lectura.

Check Resultado
Suite completa 1.667 ejemplos · 0 fallas
RuboCop 304 archivos · 0 infracciones
Brakeman 0 advertencias
CI 4/4 en verde (los 16 PRs rojos del repo lo están por el bump de Brakeman 8.1.0, no por esto)

Los dos controles de seguridad, probados rompiéndolos

No alcanza con que los specs existan: rompí cada regla y verifiqué que el test la agarra.

Rotura Falla
VerifySignature devuelve en vez de rechazar cuando falta el secreto rejects the event instead of skipping the verification
Muevo la verificación después del WebhookLog.create! persists nothing when the signature does not match

El segundo es el que más me importaba: que la firma se verifique antes de persistir es lo que evita que cualquiera que conozca la URL llene la auditoría de ventas falsas, y el spec lo sostiene.

También seguí el camino del secreto: credentials va cifrado (encrypts), settings no, y ApplyDeclaredFields rechaza toda clave que la plantilla no declare en su scope. Un secreto no puede terminar en settings por error de tipeo, está cerrado por construcción.

Lo que me gustó

  • El unscoped del gateway con el Current.set(company_id: nil) alrededor del insert. El comentario explica exactamente por qué: si algún día alguien setea Current antes de esa acción, assign_current_company pisaría el company_id derivado de la integración y el log caería en el tenant equivocado. Eso es blindar contra un cambio futuro, no contra el bug de hoy.
  • El doble chequeo bajo with_lock en EnsureAccessToken, con el caso force resuelto: el que esperó el lock compara contra el token que él usó, no contra cualquiera. Es el detalle que normalmente se olvida y produce dos renovaciones.
  • La provincia que entra sin provincia en vez de fallar. Mismo criterio que el status, y dicho en el comentario. La venta es el dato que no se puede perder.
  • Cero código de Shopify en app/. Todo plantilla. Es la prueba de que el RNF-05 (plug & play) se sostiene, que es justo lo que el motor prometía desde TESIS-31.

🟡 La cobertura queda al borde del piso

La rama baja la cobertura de rama de 99,81 % a 95,26 %, con el piso en 95. Son 0,26 puntos de margen: el próximo PR que agregue una rama sin cubrir deja la CI en rojo para todos.

Las 14 líneas sin cubrir son todas caminos de error, y tres me parecen las que más valen:

app/poros/integrations/request_client_credentials_token.rb  [28, 61, 66]
  • 28 — el rescue de error de red del pedido de token.
  • 61 — la respuesta sin access_token.
  • 66 — el body ilegible.

Son los tres modos de falla de la autenticación contra un proveedor real, y es donde más caro sale enterarse en producción. El camino feliz y el error HTTP sí están cubiertos. Las otras once (sync_stock_to_channels_job 26-27, las acciones de Avo, register_webhook 71, upsert_integration 36) las dejaría pasar.

⚪ Una observación que no es de este PR, pero que este PR vuelve real

El gateway ingesta webhooks de integraciones desactivadas. Lo comprobé con una sonda: POST /api/webhooks/integrations/:id sobre una integración con is_active: false responde 202, persiste el log y —si la plantilla es de e-commerce— encola el procesamiento, que crea la orden y descuenta stock.

Verifiqué que es preexistente: el gateway de master tampoco mira is_active. Pero hasta ahora no había ningún canal real conectado, y este PR además introduce el toggle como algo con significado («una integración nueva nace inactiva»). El escenario incómodo: la empresa desactiva Shopify, Shopify sigue mandando (la suscripción vive del lado de ellos), el OMS sigue descontando stock, y el OutboundSync —que sí filtra por is_active— ya no publica de vuelta. Las dos puntas se desalinean en silencio.

No bloquea el merge. Si te parece, levanto la card.

⚪ Nit de contrato

POST /products/:id/mappings devuelve el recurso con warnings adentro. ADR-015 dice que un recurso solo viaja pelado; warnings es metadato viajando dentro del recurso. Funciona y no rompe nada, pero es la primera grieta en esa convención.

Sobre el orden de merge

El cuerpo avisa que este va primero y que GET /integrations cambia de forma. Dejo la review de proyecto-web#59 en un rato; los mergeo juntos, este primero.

@TomasMartin2004
TomasMartin2004 merged commit 4b65af8 into master Oct 3, 2026
4 checks passed
@TomasMartin2004
TomasMartin2004 deleted the TESIS-138-shopify-integration branch October 3, 2026 14:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants