Repository navigation
feat: [TESIS-138] connect Shopify per company, publish the stock and receive its orders - #98
Conversation
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>
TomasMartin2004
left a comment
There was a problem hiding this comment.
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
unscopeddel gateway con elCurrent.set(company_id: nil)alrededor del insert. El comentario explica exactamente por qué: si algún día alguien seteaCurrentantes de esa acción,assign_current_companypisaría elcompany_idderivado 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_lockenEnsureAccessToken, con el casoforceresuelto: 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
rescuede 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.
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).
Todo es plantilla (
Service): no hay código específico de Shopify enapp/. 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 /integrationscambia de forma y la API de la empresa deja de cargar credenciales. Mergear este primero.Probado contra la tienda
onestock-norteel 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
company_integrations.settings(jsonb, sin cifrar) para la configuración de la cuenta (dominio, ubicación de stock, nombre de la cuenta).credentialsqueda sólo para secretosservices.auth_strategy/auth_config:oauth_client_credentialspide 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).bearersigue siendo el defaultcredential_fields/setting_fieldsdeclarados por la plantilla yIntegrations::ApplyDeclaredFields, que valida contra ellosIntegrations::TestConnection(«probar conexión»): completa la ubicación de stock y guarda el nombre de la cuentaPlantillas de operación y transporte
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 solarequest_format: graphql,body_templateyerror_path(errores que llegan con HTTP 200,Integrations::DetectResponseErrors)request_mapperaceptasettings.<clave>y valores{{variable}}Productos y stock
product_mappings.external_refs(elinventory_item_idde Shopify)POST /products/:id/mappingsle 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, ywarningssi el SKU difiereCatalog::OutboundSyncpublica sólo enService#stock_template, con lasexternal_refsy una clave de idempotencia. Al vincular publica sólo en el canal recién vinculadoBackoffice (Avo)
API de la empresa (sólo lectura)
GET /api/v1/integrationslista sólo plantillas conectables conconfigured,is_active,integration_idyaccount_name. Ya no devuelveurinihttp_methodPUT /integrations/:service_id,DELETEyPOST .../test, junto conCompanyIntegrationPolicy: las credenciales sólo se cargan desde el backoffice (ADR-018)Ventas entrantes (ADR-019)
services.webhook_configyWebhooks::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 igualShopifymapeaorders/create(cliente, dirección de envío, ítems por variante) y declara la firma con elclient_secretde la app de cada empresaOrders::TranslateWebhookPayload/ProcessWebhookOrderguardan ciudad y provincia. La provincia se compara sin mayúsculas ni tildes contraOrder::PROVINCES, y si no matchea la venta entra sin provinciaIntegrations::RegisterWebhookcon 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 conbin/rails "integrations:shopify:webhook[norte]"api_webhooks_integration,api_webhooks_courier) y agregaconfig.x.public_webhook_base_url. En desarrollo,config.hostsacepta el host del túnelOtros
bin/jobsvuelve a ser ejecutable.architecture.mddocumenta las dos variables que necesitan los workers en macOSintegrations:shopify:{connect,test,link,webhook}spec/fixtures/shopify/orders_create.jsony spec de punta a punta del gatewayEvidencia 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:seedEn
.env(no se suben al repo). Las credenciales de la app de Norte se las piden a Lautaro:Los workers en macOS necesitan dos variables (ver
architecture.md§8.3). Sin ellas se caen en cada fork y ningún job corre:Caso 1: conectar Norte con su tienda desde el backoffice
/admincon el admin de las seeds (admin@backoffice.com) → Company integrations → la de Norte con Shopify (crearla si no está).→ Avisa «Connection saved. Connected to onestock-norte.» y los Settings muestran
location_idyaccount_name..env:bin/rails "integrations:shopify:connect[norte]"ybin/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).GET /api/v1/integrations→ Shopify conconfigured: true,is_active: true,account_name: "onestock-norte". Mercado Libre y Tiendanube aparecen conis_active: false. Ninguna traeuri,http_methodni credenciales.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.POST /api/v1/products/<id de NOR-001>/mappingscon{ "product_mapping": { "company_integration_id": <id de la integración Shopify> } }(sinexternal_product_id→ busca por SKU).→ 201 con el vínculo:
external_product_ides el id de la variante ywarningsviene vacío. Elinventory_item_idqueda guardado enexternal_refs, que la API no expone.→ En el admin de Shopify, el stock de NOR-001 pasa a ser el total del OMS.
external_product_idque no existe en la tienda → 422.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.
ngrok http 3000. Si el puerto 3000 está ocupado, usar otro y el mismo en el server.ok: true — Webhook registered: https://…/api/webhooks/integrations/<id>. Correrlo de nuevo dicealready registered.onestock-norteun 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 tarjeta1.→ En el log de Rails:
POST /api/webhooks/integrations/<id>→ 202.→
WebhookLogenprocessedy unaOrdernueva conexternal_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.
Caso 5: un webhook sin firma no entra
→ 401, sin
WebhookLognuevo. El log de Rails dicerejected 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).→
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 rubocopybundle exec brakeman -qen verde.spec/requests/api/webhooks/shopify_orders_spec.rbrecorre el payload real por el gateway y el worker.Impacto y consideraciones
¿Introduce breaking changes?
Sí:
GET /api/v1/integrationscambia de forma: dejauriyhttp_method, y sumaintegration_idyaccount_name.PUT /integrations/:service_id,DELETE /integrations/:service_idyPOST /integrations/:service_id/test.¿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 enRAILS_ALLOWED_HOSTS). En desarrollo, la del túnel. Sin ella no se puede registrar el webhook.SHOPIFY_<SLUG>_SHOP_DOMAIN,SHOPIFY_<SLUG>_CLIENT_ID,SHOPIFY_<SLUG>_CLIENT_SECRET.¿Afecta la arquitectura o genera un nuevo patrón?
Sí:
architecture.md§7.4: vincular consulta al canal dentro del request.orders/createy suscripción del webhook.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 correrdb:seed: reaplica las plantillas sobre una base ya sembrada.Pendiente (documentado en ADR-019):
is_active.🤖 Generated with Claude Code