diff --git a/.env.example b/.env.example index 3caa2172..e7d77c48 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,8 @@ PROYECTO_API_DATABASE_PASSWORD=admin # Generar cada una con: rails secret. En dev/test caen a secret_key_base. ENCRYPTION_KEY= ENCRYPTION_KEY_DERIVATION_SALT= + +# URL pública HTTPS de esta API, sin barra final: con ella se registran los +# webhooks en los proveedores. En desarrollo, la de un túnel, por ejemplo +# `ngrok http 3000` → https://xxxx.ngrok-free.app +PUBLIC_WEBHOOK_BASE_URL= diff --git a/app/avo/actions/configure_connection.rb b/app/avo/actions/configure_connection.rb new file mode 100644 index 00000000..b4b2c18c --- /dev/null +++ b/app/avo/actions/configure_connection.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +module Avo + module Actions + # Carga la cuenta de una empresa en un proveedor: un campo por cada dato que + # declara la plantilla (`credential_fields`, `setting_fields`). + # + # Las credenciales las carga el equipo de OneStock, no la empresa (ADR-018): + # la empresa se las pasa al administrador y él las carga acá. La API de la + # empresa sólo lee el estado de sus integraciones. + # + # - Los secretos nunca se muestran ni se precargan: vacío quiere decir «dejá + # el que está» (Integrations::ApplyDeclaredFields, que también valida). + # - La configuración sí se precarga, y vaciarla la borra. + # - Después de guardar prueba la conexión si la plantilla sabe hacerlo: en + # Shopify eso completa la ubicación donde se publica el stock. + # - Si la prueba pasa, registra el webhook del proveedor (las ventas). + class ConfigureConnection < Avo::BaseAction + CREDENTIAL_PREFIX = 'credentials__' + SETTING_PREFIX = 'settings__' + STORED_HELP = 'Already set: leave blank to keep it.' + + self.name = 'Configure connection' + self.message = 'Secrets are never shown. Leave a secret blank to keep the one already stored.' + self.confirm_button_label = 'Save' + self.visible = -> { view.show? && resource.record&.service&.declares_fields? } + + def fields + service = record&.service + return if service.nil? + + service.credential_fields.each { |spec| credential_field(spec) } + service.setting_fields.each { |spec| setting_field(spec) } + end + + def handle(query:, fields:, **) + integration = query.first + save(integration, fields) + report_test(integration) + reload + rescue ::Integrations::InvalidIntegrationError => e + error(invalid_fields_message(integration.service, e.fields)) + keep_modal_open + end + + private + + def credential_field(spec) + stored = record.credentials.is_a?(Hash) && record.credentials[spec['key']].present? + field :"#{CREDENTIAL_PREFIX}#{spec['key']}", as: :password, name: spec['label'], + help: (STORED_HELP if stored) + end + + def setting_field(spec) + field :"#{SETTING_PREFIX}#{spec['key']}", as: :text, name: spec['label'], + default: record.settings[spec['key']] + end + + def save(integration, fields) + credentials, settings = ::Integrations::ApplyDeclaredFields.new( + service: integration.service, integration: integration, + credentials: values_with_prefix(fields, CREDENTIAL_PREFIX), + settings: values_with_prefix(fields, SETTING_PREFIX) + ).call + integration.update!(credentials:, settings:) + end + + def values_with_prefix(fields, prefix) + fields.to_h + .select { |key, _value| key.start_with?(prefix) } + .transform_keys { |key| key.delete_prefix(prefix) } + end + + def report_test(integration) + return succeed('Connection saved.') unless integration.service.connection_testable? + + result = ::Integrations::TestConnection.new(company_integration: integration).call + if result[:ok] + succeed("Connection saved. #{result[:message]}.") + report_webhook(integration) + else + warn("Connection saved, but the test failed: #{result[:message]}") + end + end + + # Con la cuenta probada, se suscribe a los eventos del proveedor si la + # plantilla sabe hacerlo: así las ventas entran solas desde el primer + # momento. Si falla (la URL pública no está configurada, por ejemplo) se + # puede reintentar con «Register webhook». + def report_webhook(integration) + return unless ::Integrations::RegisterWebhook.declared_by?(integration.service) + + result = ::Integrations::RegisterWebhook.new(company_integration: integration).call + return succeed(result[:message]) if result[:ok] + + warn("Webhook not registered: #{result[:message]}") + end + + # «Client ID: required; Shop domain: invalid_format», con el nombre que ve + # el administrador en el formulario. + def invalid_fields_message(service, fields) + labels = (service.credential_fields.map { |spec| ['credentials', spec] } + + service.setting_fields.map { |spec| ['settings', spec] }) + .to_h { |scope, spec| ["#{scope}.#{spec['key']}", spec['label']] } + fields.map { |key, codes| "#{labels.fetch(key, key)}: #{codes.join(', ')}" }.join('; ') + end + end + end +end diff --git a/app/avo/actions/register_webhook.rb b/app/avo/actions/register_webhook.rb new file mode 100644 index 00000000..6c48d9f8 --- /dev/null +++ b/app/avo/actions/register_webhook.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Avo + module Actions + # «Registrar webhook» desde el backoffice: le pide al proveedor que avise a + # OneStock de los eventos de esta cuenta (Integrations::RegisterWebhook). + # «Configure connection» ya lo hace al guardar; esta acción es para cuando + # cambió la URL pública de la API o el registro falló. + class RegisterWebhook < Avo::BaseAction + self.name = 'Register webhook' + self.message = 'Asks the provider to notify OneStock of this account events.' + self.confirm_button_label = 'Register' + self.visible = lambda { + view.show? && ::Integrations::RegisterWebhook.declared_by?(resource.record&.service) + } + + def handle(query:, **) + result = ::Integrations::RegisterWebhook.new(company_integration: query.first).call + result[:ok] ? succeed(result[:message]) : error(result[:message]) + reload + end + end + end +end diff --git a/app/avo/actions/test_connection.rb b/app/avo/actions/test_connection.rb new file mode 100644 index 00000000..47374502 --- /dev/null +++ b/app/avo/actions/test_connection.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Avo + module Actions + # «Probar conexión» desde el backoffice: llama al proveedor con la cuenta de + # la empresa sin tocar datos de negocio (Integrations::TestConnection). Si el + # proveedor devuelve configuración que faltaba, la completa. + class TestConnection < Avo::BaseAction + self.name = 'Test connection' + self.message = 'Calls the provider with this account. No business data is changed.' + self.confirm_button_label = 'Test' + self.visible = -> { view.show? && resource.record&.service&.connection_testable? } + + def handle(query:, **) + result = ::Integrations::TestConnection.new(company_integration: query.first).call + result[:ok] ? succeed(result[:message]) : error(result[:message]) + reload + end + end + end +end diff --git a/app/avo/resources/company_integration.rb b/app/avo/resources/company_integration.rb index 93bd9110..c88aa8d7 100644 --- a/app/avo/resources/company_integration.rb +++ b/app/avo/resources/company_integration.rb @@ -8,15 +8,30 @@ class CompanyIntegration < Avo::BaseResource self.title = :display_name self.includes = %i[company service] + # Las credenciales las carga el equipo de OneStock desde acá (ADR-018): se + # crea la integración (empresa y plantilla) y los datos de la cuenta se + # cargan con la acción «Configure connection», que valida contra lo que + # declara la plantilla. def fields field :id, as: :id field :company, as: :belongs_to - field :service, as: :belongs_to - field :is_active, as: :boolean + # Una plantilla hija no se conecta sola (CompanyIntegration valida lo mismo). + field :service, as: :belongs_to, attach_scope: -> { query.connectable } + # Nace inactiva: la empresa la vería como conectada antes de tener cuenta. + field :is_active, as: :boolean, default: false, + help: 'Activate it once the connection is configured and tested.' credentials_field + field :settings, as: :code, language: 'json', only_on: :show, disabled: true, + format_using: -> { JSON.pretty_generate(value || {}) } field :product_mappings, as: :has_many, name: 'Product Mappings' end + def actions + action Avo::Actions::ConfigureConnection + action Avo::Actions::TestConnection + action Avo::Actions::RegisterWebhook + end + def filters filter Avo::Filters::CompanyFilter end @@ -25,11 +40,11 @@ def filters # Las credenciales son API keys y tokens de las cuentas de cada empresa, y # el backoffice las mostraba descifradas en el detalle y en el formulario - # (TESIS-129). Ahora dice qué claves hay configuradas, nunca sus valores, y - # no las edita: las carga cada empresa por la API - # (PUT /api/v1/integrations/:service_id). Editarlas acá además las rompía: - # el campo de código manda un String y `serialize :credentials` lo - # guardaba como String, no como Hash. + # (TESIS-129). Ahora dice qué claves hay configuradas, nunca sus valores. + # El formulario del recurso no las edita: se cargan con «Configure + # connection», un campo por clave. Editarlas como JSON acá las rompía: el + # campo de código manda un String y `serialize :credentials` lo guardaba + # como String, no como Hash. # # Un valor que no es Hash (una fila que quedó guardada como String) se # enmascara entero en vez de romper la página. diff --git a/app/controllers/api/v1/integrations_controller.rb b/app/controllers/api/v1/integrations_controller.rb index 5ef01b09..7f338868 100644 --- a/app/controllers/api/v1/integrations_controller.rb +++ b/app/controllers/api/v1/integrations_controller.rb @@ -5,10 +5,13 @@ module V1 class IntegrationsController < ApplicationController include Paginatable + # Sólo lectura. Las credenciales las carga el equipo de OneStock desde el + # backoffice (ADR-018): la empresa ve el estado de sus integraciones, no + # las configura. + # # El listado no pasa por Pundit: lo usa el widget de nodos del panel # aunque la empresa no tenga la feature `integrations`, y sólo muestra las - # plantillas globales con el estado de la propia empresa. El alta y la - # modificación sí: ver CompanyIntegrationPolicy. + # plantillas globales con el estado de la propia empresa. skip_after_action :verify_policy_scoped # Envuelto en `data` como el resto de las colecciones (ADR-015): era el @@ -24,7 +27,8 @@ class IntegrationsController < ApplicationController # nodos, no de a páginas. def index integrations = current_company.company_integrations.index_by(&:service_id) - services, meta = paginate(Service.order(:id), per_page: WHOLE_LIST_PER_PAGE) + services, meta = paginate(Service.connectable.order(:id), + per_page: WHOLE_LIST_PER_PAGE) render json: { data: IntegrationStatusSerializer.render_as_hash( @@ -33,28 +37,6 @@ def index meta: meta } end - - def update - authorize CompanyIntegration - integration = Integrations::UpsertIntegration.new( - company: current_company, - service_id: params[:service_id], - credentials: credentials_params, - is_active: params.fetch(:is_active, true) - ).call - render json: CompanyIntegrationSerializer.render(integration), status: :ok - end - - private - - def credentials_params - raw = params.require(:credentials) - unless raw.is_a?(ActionController::Parameters) - raise ActionController::ParameterMissing, :credentials - end - - raw.to_unsafe_h - end end end end diff --git a/app/controllers/api/v1/product_mappings_controller.rb b/app/controllers/api/v1/product_mappings_controller.rb index 63b3a22e..4ebb4175 100644 --- a/app/controllers/api/v1/product_mappings_controller.rb +++ b/app/controllers/api/v1/product_mappings_controller.rb @@ -12,6 +12,8 @@ class ProductMappingsController < ApplicationController rescue_from ActiveRecord::RecordNotSaved, with: :render_unprocessable rescue_from ActiveRecord::RecordNotUnique, with: :render_conflict + rescue_from Catalog::ExternalProductNotFoundError, with: :render_unprocessable + rescue_from Integrations::AdapterExecutionError, with: :render_channel_failure def index scope = policy_scope(ProductMapping) @@ -31,13 +33,17 @@ def index def create authorize ProductMapping - mapping = @product.product_mappings.create!( - company_integration: company_integration, + result = Catalog::LinkExternalProduct.new( + product: @product, company_integration: company_integration, external_product_id: mapping_params[:external_product_id], external_price: mapping_params[:external_price] - ) + ).call - render json: ProductMappingSerializer.render(mapping), status: :created + # `warnings` no bloquea el vínculo: avisa, por ejemplo, que el SKU del + # canal no coincide con el del producto. + render json: ProductMappingSerializer.render_as_hash(result.mapping) + .merge(warnings: result.warnings), + status: :created end def destroy @@ -89,6 +95,12 @@ def mapping_params def render_conflict(_exception) render json: { error: ALREADY_LINKED }, status: :conflict end + + # El canal no contestó o rechazó la consulta: el problema es de arriba + # (502), no del dato que mandó el usuario. + def render_channel_failure(exception) + render json: { error: exception.message }, status: :bad_gateway + end end end end diff --git a/app/controllers/concerns/api/webhooks/event_ingestion.rb b/app/controllers/concerns/api/webhooks/event_ingestion.rb index 1aec9ffe..2ecfbf3f 100644 --- a/app/controllers/concerns/api/webhooks/event_ingestion.rb +++ b/app/controllers/concerns/api/webhooks/event_ingestion.rb @@ -32,6 +32,11 @@ module EventIngestion # tiene que dejar de mandar eventos a esa integración. rescue_from ActiveRecord::InvalidForeignKey, with: :render_not_found + # Un evento que no prueba venir del proveedor no se persiste: guardarlo + # dejaría a cualquiera que conozca la URL llenando la auditoría de ventas + # falsas. 401 y una línea de log sin el payload ni la firma. + rescue_from ::Webhooks::InvalidSignatureError, with: :reject_unsigned_event + # El payload se lee del body crudo por dos razones: ParamsWrapper envolvería # el JSON bajo la clave del controller, y tocar `params` dispararía el parseo # del body en el middleware, que responde 400 ante un JSON malformado antes @@ -46,6 +51,8 @@ def create # si más adelante alguien setea Current antes de esta acción. integration = CompanyIntegration.unscoped .find(request.path_parameters[:company_integration_id]) + ::Webhooks::VerifySignature.new(company_integration: integration, + raw_body: request.raw_post, headers: request.headers).call # Current queda explícitamente en nil durante el insert: si algún día # llegara con valor, el assign_current_company de CompanyScoped pisaría @@ -71,6 +78,13 @@ def create # la conexión; qué worker procesa el evento lo decide cada endpoint. def enqueue_processing(_log, _integration) = nil + def reject_unsigned_event(error) + integration_id = request.path_parameters[:company_integration_id] + Rails.logger.warn("[webhooks] rejected event for integration #{integration_id}: " \ + "#{error.message}") + head :unauthorized + end + # Un body ilegible no se rechaza: se guarda tal cual para no perder el # evento (el reproceso es responsabilidad de la épica de resiliencia). def request_payload diff --git a/app/jobs/catalog/sync_stock_to_channels_job.rb b/app/jobs/catalog/sync_stock_to_channels_job.rb index 66e626ba..3bd1adf0 100644 --- a/app/jobs/catalog/sync_stock_to_channels_job.rb +++ b/app/jobs/catalog/sync_stock_to_channels_job.rb @@ -10,7 +10,9 @@ module Catalog class SyncStockToChannelsJob < ApplicationJob queue_as :low - def perform(product_id, company_id) + # `company_integration_id` acota el push a un canal (ver OutboundSync): sin + # él se publica en todos, que es lo que pide un cambio de stock. + def perform(product_id, company_id, company_integration_id = nil) with_tenant(company_id) do # El producto puede haberse borrado entre el encolado y la ejecución: # sin producto no hay nada que propagar, porque sus mappings se fueron @@ -18,7 +20,14 @@ def perform(product_id, company_id) product = Product.find_by(id: product_id) return if product.nil? - OutboundSync.new(product: product).call + # La integración pudo borrarse mientras el job esperaba: sin canal no hay + # nada que alinear. + if company_integration_id + integration = CompanyIntegration.find_by(id: company_integration_id) + return if integration.nil? + end + + OutboundSync.new(product: product, company_integration: integration).call end end end diff --git a/app/models/company_integration.rb b/app/models/company_integration.rb index ba10efe4..cafa5283 100644 --- a/app/models/company_integration.rb +++ b/app/models/company_integration.rb @@ -17,8 +17,20 @@ class CompanyIntegration < ApplicationRecord encrypts :credentials validates :service_id, uniqueness: { scope: :company_id } + validate :service_is_connectable def display_name "#{company&.name} \u2194 #{service&.service_name}" end + + private + + # Una plantilla hija (ej. 'Shopify - Conexión') corre con la integración de su + # madre. Conectarla sola dejaría una integración que ninguna parte del sistema + # usa: el backoffice la ofrecería en el alta si no se validara acá. + def service_is_connectable + return if service.nil? || service.connectable? + + errors.add(:service, 'is an operation of another template and cannot be connected on its own') + end end diff --git a/app/models/service.rb b/app/models/service.rb index 53572e80..da72aa95 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -9,6 +9,27 @@ class Service < ApplicationRecord MAPPER_FIELDS = %w[request_mapper response_mapper request_value_mapper response_value_mapper].freeze + # Cómo se autentica la plantilla (ver Integrations::AuthHeaders). `bearer` es + # el comportamiento de siempre: `access_token` como Bearer y el resto de las + # credenciales como headers literales. + BEARER = 'bearer' + OAUTH_CLIENT_CREDENTIALS = 'oauth_client_credentials' + AUTH_STRATEGIES = [BEARER, OAUTH_CLIENT_CREDENTIALS].freeze + + # Cómo viaja el body. GraphQL manda `{ query: body_template, variables }`, + # donde las variables son el resultado del request_mapper. + JSON_FORMAT = 'json' + GRAPHQL_FORMAT = 'graphql' + REQUEST_FORMATS = [JSON_FORMAT, GRAPHQL_FORMAT].freeze + + # Lo que define cómo se conecta una plantilla, además de sus mappers. Los + # seeds lo reaplican sobre las plantillas que ya existen. + CONNECTION_FIELDS = %w[auth_strategy auth_config credential_fields setting_fields + request_format body_template error_path operation webhook_config].freeze + + # La clave interna con la que el sync saliente manda la cantidad a publicar. + STOCK_KEY = 'available_quantity' + # Vocabulario de una plantilla de consulta de tracking (ver #answers_tracking?). TRACKING_STATUS_KEY = 'external_status' TRACKING_URI_PARAM = ':tracking_number' @@ -33,11 +54,33 @@ class Service < ApplicationRecord has_many :quoted_services, class_name: 'Service', foreign_key: :quote_service_id, inverse_of: :quote_service, dependent: :nullify + # Plantillas de operación: otra llamada al mismo proveedor con la misma + # cuenta (probar la conexión, buscar una variante, registrar un webhook). Una + # hija no es conectable: se ejecuta con la integración de su madre, igual que + # la plantilla de seguimiento de un courier (ADR-014). + belongs_to :parent_service, class_name: 'Service', optional: true, + inverse_of: :operation_services + has_many :operation_services, class_name: 'Service', foreign_key: :parent_service_id, + inverse_of: :parent_service, dependent: :destroy + + # Sólo las madres se conectan (ver #connectable?). + scope :connectable, -> { where(parent_service_id: nil) } + validates :service_name, presence: true, uniqueness: true validates :uri, presence: true validates :http_method, presence: true validates :type, presence: true, inclusion: { in: TYPES } + validates :auth_strategy, inclusion: { in: AUTH_STRATEGIES } + validates :request_format, inclusion: { in: REQUEST_FORMATS } + validates :operation, presence: true, uniqueness: { scope: :parent_service_id }, + if: :parent_service_id validate :mappers_are_valid_json + validate :field_specs_are_lists + # Una firma declarada a medias haría que el gateway rechace todos los eventos + # de la plantilla: mejor que no se pueda guardar. + validate do + Webhooks::VerifySignature.config_problems(webhook_config).each { errors.add(:webhook_config, it) } + end validate :tracking_service_answers_tracking validate :quote_service_quotes_shipping @@ -49,6 +92,57 @@ def ecommerce? = type == ECOMMERCE def courier? = type == COURIER + def graphql? = request_format == GRAPHQL_FORMAT + + def oauth_client_credentials? = auth_strategy == OAUTH_CLIENT_CREDENTIALS + + # Si el gateway tiene que verificar la firma de los webhooks de esta plantilla + # (`webhook_config`, ver Webhooks::VerifySignature). Sin `signature`, el + # proveedor no firma: lo de siempre. + def signs_webhooks? = webhook_config.is_a?(Hash) && webhook_config['signature'].present? + + # La plantilla hija que sabe hacer `operation` con la cuenta de esta + # integración, o nil si el proveedor no la declara. + def template_for(operation) + operation_services.find_by(operation: operation.to_s) + end + + # La plantilla que publica el stock de esta integración: la propia si mapea + # la cantidad (Tiendanube, 'Mercado Libre - Stock', Shopify), o su hija + # `stock`. Si no hay ninguna, el sync saliente no le manda nada: antes le + # mandaba el stock a cualquier plantilla con productos vinculados, aunque + # fuera la de órdenes y no supiera qué hacer con él. + def stock_template + return self if request_mapper.value?(STOCK_KEY) + + template_for(:stock) + end + + # Si la plantilla dice qué datos le pide a la empresa. Las que no declaran + # nada conservan el alta de siempre (ver Integrations::UpsertIntegration). + def declares_fields? + credential_fields.any? || setting_fields.any? + end + + # Si tiene una hija para `operation`. + def declares_operation?(operation) + operation_services.any? { |child| child.operation == operation.to_s } + end + + # Si «probar conexión» tiene algo que verificar: una hija que la prueba o, sin + # ella, obtener el token (Integrations::TestConnection). + def connection_testable? + declares_operation?(Integrations::TestConnection::OPERATION) || oauth_client_credentials? + end + + # Una hija se ejecuta con la cuenta de su madre: no se conecta sola. + def connectable? = parent_service_id.nil? + + # Las claves de configuración que la plantilla le pide a la empresa. + def setting_keys + setting_fields.filter_map { |field| field['key'] } + end + # Una plantilla de courier puede servir para cotizar o para despachar: son dos # endpoints distintos del mismo proveedor y, por convención del proyecto, dos # `Service` distintos (igual que 'Mercado Libre' y 'Mercado Libre - Stock'). @@ -141,6 +235,17 @@ def mappers_are_valid_json mapper_errors.each { |field, message| errors.add(field, message) } end + # Cada campo declarado es un objeto con al menos su `key`: es lo que el + # formulario de conexión usa para armar cada input. + def field_specs_are_lists + %i[credential_fields setting_fields].each do |attribute| + specs = public_send(attribute) + next if specs.is_a?(Array) && specs.all? { |spec| spec.is_a?(Hash) && spec['key'].present? } + + errors.add(attribute, 'debe ser una lista de campos con su key') + end + end + # Sólo un courier se consulta por tracking, y sólo con una plantilla que sepa # contestarlo: apuntar a la de cotización, o a sí misma, dejaría a la consulta # periódica llamando todos los ciclos a un endpoint que no responde estados. diff --git a/app/policies/company_integration_policy.rb b/app/policies/company_integration_policy.rb deleted file mode 100644 index a658233e..00000000 --- a/app/policies/company_integration_policy.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -class CompanyIntegrationPolicy < ApplicationPolicy - # El flag `integrations` de la empresa sólo lo aplicaba el front: una empresa - # sin la feature configuraba integraciones llamando al endpoint directo (QA de - # TESIS-82). Se compara con `== true` en Company#feature_enabled?, igual que - # el front, así los dos deciden lo mismo. - def update? - user.present? && user.company.feature_enabled?(:integrations) - end -end diff --git a/app/poros/catalog/external_product_not_found_error.rb b/app/poros/catalog/external_product_not_found_error.rb new file mode 100644 index 00000000..ad49da31 --- /dev/null +++ b/app/poros/catalog/external_product_not_found_error.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Catalog + # El canal no tiene la publicación que se quiso vincular (o no se la puede + # identificar sin ambigüedad). Es un dato inválido del usuario: 422. + class ExternalProductNotFoundError < StandardError; end +end diff --git a/app/poros/catalog/link_external_product.rb b/app/poros/catalog/link_external_product.rb new file mode 100644 index 00000000..08c6b27b --- /dev/null +++ b/app/poros/catalog/link_external_product.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +module Catalog + # Vincula un producto con su publicación en un canal de venta + # (ProductMapping) y deja el stock del canal alineado con el del OMS. + # + # Si el proveedor sabe buscar la publicación, se le pregunta antes de crear el + # vínculo, con plantillas hijas de la integración (nunca código por proveedor): + # - con un id externo, la hija `product_lookup` confirma que existe y trae los + # identificadores extra que necesita el canal para publicar el stock + # (en Shopify, el `inventory_item_id` de la variante); + # - sin id externo, la hija `product_search` la busca por el SKU del producto. + # Sólo se acepta un resultado único con el mismo SKU: vincular el producto + # equivocado le publicaría el stock a otra publicación. + # + # La consulta corre dentro del request, con el timeout corto de la cotización + # (ver docs/guidelines/architecture.md §7.4): el usuario está esperando saber + # si el vínculo es válido, y es una sola lectura sin efectos en el proveedor. + class LinkExternalProduct < ApplicationPoro + TIMEOUTS = { open: 4, read: 4 }.freeze + # Lo que la búsqueda devuelve para describir la publicación. El resto de lo + # que contesta son identificadores del canal y va a `external_refs`. + DESCRIPTIVE_KEYS = %w[external_product_id external_sku external_title ambiguous_match].freeze + + Result = Struct.new(:mapping, :warnings) + + def initialize(product:, company_integration:, external_product_id: nil, external_price: nil) + super() + @product = product + @integration = company_integration + @external_product_id = external_product_id.to_s.strip.presence + @external_price = external_price + end + + def call + found = @external_product_id ? look_up : search_by_sku + mapping = @product.product_mappings.create!( + company_integration: @integration, external_price: @external_price, + external_product_id: found.fetch('external_product_id', @external_product_id).to_s, + external_refs: found.except(*DESCRIPTIVE_KEYS) + ) + SyncStockToChannelsJob.perform_later(@product.id, @product.company_id, @integration.id) + Result.new(mapping, warnings(found)) + end + + private + + def service = @integration.service + + # Sin plantilla de búsqueda, el vínculo se crea con el id tal cual (el + # comportamiento de siempre). + def look_up + template = service.template_for(:product_lookup) + return {} if template.nil? + + found = run(template) + return found if found.present? + + not_found!("#{@external_product_id} does not exist in #{service.service_name}") + end + + def search_by_sku + template = service.template_for(:product_search) + not_found!('external_product_id is required') if template.nil? + + found = run(template) + unless same_sku?(found['external_sku']) + not_found!("no #{service.service_name} variant has the SKU #{@product.sku}") + end + not_found!("more than one variant has the SKU #{@product.sku}") if found['ambiguous_match'] + found + end + + # El id viaja en el payload y en la URI: cada plantilla usa el que le sirve + # (GraphQL lo manda como variable, una API REST lo lleva en el path). + def run(template) + ids = { external_id: @external_product_id }.compact + Integrations::HttpAdapter.new( + company_integration: @integration, service: template, timeouts: TIMEOUTS, + payload: ids.merge(sku: @product.sku), uri_params: ids + ).call + end + + def same_sku?(external_sku) + external_sku.to_s.strip.casecmp?(@product.sku.to_s.strip) + end + + # 1 producto = 1 SKU es regla del MVP: un SKU distinto no bloquea (el + # usuario puede estar corrigiéndolo) pero se avisa. + def warnings(found) + external_sku = found['external_sku'] + return [] if external_sku.blank? || same_sku?(external_sku) + + ["The #{service.service_name} SKU is #{external_sku}, the product SKU is #{@product.sku}"] + end + + def not_found!(message) + raise ExternalProductNotFoundError, message + end + end +end diff --git a/app/poros/catalog/outbound_sync.rb b/app/poros/catalog/outbound_sync.rb index ae5cf253..28db2e66 100644 --- a/app/poros/catalog/outbound_sync.rb +++ b/app/poros/catalog/outbound_sync.rb @@ -8,9 +8,12 @@ module Catalog # Corre siempre dentro de un job: las APIs externas son lentas y pueden estar # caídas, así que nunca debe colgarse del request del usuario. class OutboundSync < ApplicationPoro - def initialize(product:) + # `company_integration` acota la propagación a un solo canal: al vincular un + # producto hay que alinear ese canal, no volver a publicar en todos. + def initialize(product:, company_integration: nil) super() @product = product + @integration = company_integration end def call @@ -24,14 +27,25 @@ def call private - # Sólo los canales activos: una integración dada de baja puede tener - # credenciales revocadas y no debe recibir tráfico. + # Sólo los canales activos (una integración dada de baja puede tener + # credenciales revocadas y no debe recibir tráfico) y que saben publicar + # stock (Service#stock_template). def mappings - @mappings ||= @product.product_mappings - .joins(:company_integration) - .where(company_integrations: { is_active: true }) - .includes(company_integration: :service) - .to_a + @mappings ||= scoped_mappings.joins(:company_integration) + .where(company_integrations: { is_active: true }) + .includes(company_integration: :service) + .select { |mapping| stock_template(mapping) } + end + + def scoped_mappings + mappings = @product.product_mappings + @integration ? mappings.where(company_integration: @integration) : mappings + end + + def stock_template(mapping) + @stock_templates ||= {} + service = mapping.company_integration.service + @stock_templates.fetch(service.id) { @stock_templates[service.id] = service.stock_template } end # Devuelve nil si el envío salió bien y el error si falló, para que el @@ -39,7 +53,8 @@ def mappings def push_to(mapping) Integrations::HttpAdapter.new( company_integration: mapping.company_integration, - payload: { external_id: mapping.external_product_id, available_quantity: total_stock }, + service: stock_template(mapping), + payload: stock_payload(mapping), uri_params: { external_id: mapping.external_product_id } ).call nil @@ -47,6 +62,18 @@ def push_to(mapping) e end + # Los identificadores extra del vínculo (en Shopify, `inventory_item_id`) van + # en el payload para que el request_mapper los use. La clave de + # idempotencia es por intento: fijar una cantidad absoluta es idempotente + # por naturaleza, y hay canales que la exigen igual (Shopify, desde 2026-04). + def stock_payload(mapping) + mapping.external_refs.merge( + 'external_id' => mapping.external_product_id, + Service::STOCK_KEY => total_stock, + 'idempotency_key' => SecureRandom.uuid + ) + end + # El total se calcula al ejecutar, no al encolar: si el stock volvió a # cambiar mientras el job esperaba en la cola, se publica el valor vigente. def total_stock diff --git a/app/poros/integrations/apply_declared_fields.rb b/app/poros/integrations/apply_declared_fields.rb new file mode 100644 index 00000000..8536391f --- /dev/null +++ b/app/poros/integrations/apply_declared_fields.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +module Integrations + # Valida y combina los datos de la cuenta de una empresa contra lo que + # declara la plantilla (`credential_fields`, `setting_fields`). Es la única + # fuente de verdad del formulario: agregar un campo a la plantilla lo hace + # aparecer en el backoffice (Avo::Actions::ConfigureConnection) y validarse + # acá, sin tocar código. + # + # - Un secreto que llega vacío no se cambia: el formulario nunca los precarga + # (nadie los devuelve), así que «vacío» quiere decir «dejalo como está». + # - Un setting que llega vacío se borra: ése sí se precarga y vaciarlo es + # deliberado. + # - Si cambia una credencial, el token cacheado se descarta: se obtuvo con la + # anterior. + class ApplyDeclaredFields < ApplicationPoro + TOKEN_KEYS = %w[access_token token_expires_at].freeze + + def initialize(service:, integration:, credentials:, settings:) + super() + @service = service + @integration = integration + @credentials_in = normalize(credentials) + @settings_in = normalize(settings) + end + + # [credenciales, settings] combinados con lo que ya tenía la integración. + def call + errors = unknown_errors + credentials = merged_credentials + settings = merged_settings + errors.merge!(declared_errors('credentials', @service.credential_fields, credentials)) + errors.merge!(declared_errors('settings', @service.setting_fields, settings)) + raise InvalidIntegrationError, errors if errors.any? + + [credentials, settings] + end + + private + + def normalize(values) = (values || {}).to_h.transform_keys(&:to_s) + + def unknown_errors + unknown = unknown_keys('credentials', @credentials_in, @service.credential_fields) + + unknown_keys('settings', @settings_in, @service.setting_fields) + unknown.index_with { ['unknown'] } + end + + def unknown_keys(scope, values, specs) + declared = specs.pluck('key') + (values.keys - declared).map { |key| "#{scope}.#{key}" } + end + + def merged_credentials + stored = @integration.credentials || {} + provided = @credentials_in.select { |_key, value| value.to_s.strip.present? } + merged = stored.merge(provided) + changed = provided.any? { |key, value| stored[key] != value } + changed ? merged.except(*TOKEN_KEYS) : merged + end + + def merged_settings + merged = (@integration.settings || {}).merge(@settings_in) + merged.reject { |_key, value| value.to_s.strip.empty? } + end + + def declared_errors(scope, specs, values) + specs.each_with_object({}) do |spec, errors| + code = field_error(spec, values[spec['key']]) + errors["#{scope}.#{spec['key']}"] = [code] if code + end + end + + def field_error(spec, value) + return (spec['required'] ? 'required' : nil) if value.to_s.strip.empty? + return if spec['format'].blank? + + 'invalid_format' unless Regexp.new(spec['format']).match?(value.to_s) + end + end +end diff --git a/app/poros/integrations/auth_headers.rb b/app/poros/integrations/auth_headers.rb new file mode 100644 index 00000000..84f89931 --- /dev/null +++ b/app/poros/integrations/auth_headers.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Integrations + # Los headers de autenticación de un request, según cómo declara autenticarse + # la plantilla de la integración (`Service#auth_strategy`). + # + # Sale siempre de la plantilla conectada, no de la que se ejecuta: una + # plantilla hija (probar la conexión, buscar una variante) usa la cuenta y el + # token de su madre. + class AuthHeaders < ApplicationPoro + # Charset de nombre de header válido (RFC 9110 token). Net::HTTPHeader no + # valida la clave: un \r\n en el nombre parte la línea e inyecta headers. + HEADER_NAME = /\A[A-Za-z0-9!#$%&'*+\-.^_`|~]+\z/ + + DEFAULT_TOKEN_HEADER = 'Authorization' + DEFAULT_TOKEN_PREFIX = 'Bearer ' + + def initialize(company_integration:, force_refresh: false) + super() + @integration = company_integration + @force_refresh = force_refresh + end + + def call + service.oauth_client_credentials? ? oauth_headers : bearer_headers + end + + private + + def service = @integration.service + + def oauth_headers + config = service.auth_config + header = config.fetch('token_header', DEFAULT_TOKEN_HEADER) + prefix = config.fetch('token_prefix', DEFAULT_TOKEN_PREFIX) + token = EnsureAccessToken.new(company_integration: @integration, force: @force_refresh).call + + { header => "#{prefix}#{token}" } + end + + # Convención de siempre: access_token viaja como Bearer; cualquier otra + # clave del hash se envía como header literal (ej. X-Api-Key). + def bearer_headers + (@integration.credentials || {}).each_with_object({}) do |(key, value), result| + validate_header_name!(key) + + if key == 'access_token' + result['Authorization'] = "Bearer #{value}" + else + result[key] = value.to_s + end + end + end + + def validate_header_name!(key) + return if key.to_s.match?(HEADER_NAME) + + raise AdapterExecutionError, "#{service.service_name} has an invalid credential key" + end + end +end diff --git a/app/poros/integrations/build_external_payload.rb b/app/poros/integrations/build_external_payload.rb index 3981dcfb..84a504dd 100644 --- a/app/poros/integrations/build_external_payload.rb +++ b/app/poros/integrations/build_external_payload.rb @@ -5,23 +5,65 @@ module Integrations # del Service: request_mapper {"ruta.externa.anidada" => "clave_interna"} y # request_value_mapper {"valor_interno" => "valor_externo"}. Solo se envían # los campos mapeados (whitelist). + # + # El valor de una entrada del request_mapper puede ser, además de una clave + # del payload: + # - `settings.`: un dato de la cuenta de la empresa (en Shopify, la + # ubicación donde se publica el stock). El prefijo evita que una clave del + # payload y una de los settings choquen. + # - Un texto con `{{clave}}`: se arma con valores del payload o de los + # settings (Shopify identifica una variante como + # `gid://shopify/ProductVariant/{{external_id}}`, y GraphQL no concatena + # strings). No es un motor de plantillas: sólo reemplaza variables. + # + # Una entrada cuyo dato no está (o una plantilla a la que le falta alguna + # variable) no se envía, igual que una clave ausente del payload. class BuildExternalPayload < ApplicationPoro - def initialize(service:, payload:) + SETTINGS_PREFIX = 'settings.' + TEMPLATE_VARIABLE = /\{\{\s*([\w.]+)\s*\}\}/ + + def initialize(service:, payload:, settings: {}) super() @service = service @payload = payload.transform_keys(&:to_s) + @settings = (settings || {}).transform_keys(&:to_s) end def call - @service.request_mapper.each_with_object({}) do |(external_path, internal_key), result| - next unless @payload.key?(internal_key) + @service.request_mapper.each_with_object({}) do |(external_path, source), result| + found, value = resolve(source.to_s) + next unless found - set_nested(result, external_path, translate(@payload[internal_key])) + set_nested(result, external_path, translate(value)) end end private + # [encontrado, valor]: un valor puede ser legítimamente nil o false. + def resolve(source) + return render_template(source) if source.match?(TEMPLATE_VARIABLE) + + lookup(source) + end + + def lookup(source) + if source.start_with?(SETTINGS_PREFIX) + key = source.delete_prefix(SETTINGS_PREFIX) + [@settings.key?(key), @settings[key]] + else + [@payload.key?(source), @payload[source]] + end + end + + def render_template(source) + variables = source.scan(TEMPLATE_VARIABLE).flatten + values = variables.index_with { |variable| lookup(variable) } + return [false, nil] unless values.values.all? { |found, value| found && !value.nil? } + + [true, source.gsub(TEMPLATE_VARIABLE) { values[Regexp.last_match(1)].last.to_s }] + end + def translate(value) @service.request_value_mapper.fetch(value.to_s, value) end diff --git a/app/poros/integrations/detect_response_errors.rb b/app/poros/integrations/detect_response_errors.rb new file mode 100644 index 00000000..5db4f865 --- /dev/null +++ b/app/poros/integrations/detect_response_errors.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Integrations + # Algunas APIs contestan un error con HTTP 200 y lo explican en el body. El + # adaptador sólo miraba el status, así que ese fallo pasaba como un éxito. + # + # Dos fuentes, las dos declaradas por la plantilla y ninguna por proveedor: + # - GraphQL (`request_format: graphql`): un `errors` no vacío en la raíz. + # - `error_path`: la ruta donde el proveedor lista los errores de negocio + # (en Shopify, `data.inventorySetQuantities.userErrors`). + class DetectResponseErrors < ApplicationPoro + MAX_MESSAGE_LENGTH = 300 + + def initialize(service:, body:, payload: nil) + super() + @service = service + @body = body + @payload = payload + end + + def call + errors = graphql_errors + declared_errors + return if errors.empty? + + raise AdapterExecutionError.new( + "#{@service.service_name} returned errors: #{summary(errors)}", + payload: @payload, response_body: @body.to_json + ) + end + + private + + def graphql_errors + return [] unless @service.graphql? && @body.is_a?(Hash) + + Array.wrap(@body['errors']) + end + + def declared_errors + return [] if @service.error_path.blank? + + Array.wrap(ParseExternalResponse.dig_path(@body, @service.error_path)) + end + + def summary(errors) + errors.map { |error| error.is_a?(Hash) ? error['message'] || error.to_json : error.to_s } + .join('; ').truncate(MAX_MESSAGE_LENGTH) + end + end +end diff --git a/app/poros/integrations/ensure_access_token.rb b/app/poros/integrations/ensure_access_token.rb new file mode 100644 index 00000000..f955e101 --- /dev/null +++ b/app/poros/integrations/ensure_access_token.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +module Integrations + # Devuelve un token de acceso vigente para una integración cuya plantilla se + # autentica con OAuth client credentials, y lo pide de nuevo cuando vence. + # + # El client_id y el client_secret son de la empresa (cada una conecta su + # propia app del proveedor), así que viven cifrados en `credentials`, igual + # que el token que se obtiene con ellos y su vencimiento. + # + # La renovación corre con la fila de la integración bloqueada: dos workers que + # vean el token vencido a la vez harían dos pedidos, y el segundo pisaría al + # primero. Adentro del lock se vuelve a mirar, porque el que esperó puede + # encontrarse con que el otro ya lo renovó. + class EnsureAccessToken < ApplicationPoro + # Se renueva un poco antes del vencimiento para que un request no salga con + # un token que expira en el camino. + RENEWAL_MARGIN = 5.minutes + CLIENT_KEYS = %w[client_id client_secret].freeze + + # `force` descarta el token cacheado: lo usa el adaptador cuando el + # proveedor contesta 401 con un token que parecía vigente. + def initialize(company_integration:, force: false) + super() + @integration = company_integration + @force = force + end + + def call + used_token = @integration.credentials&.dig('access_token') + return used_token if !@force && fresh? + + @integration.with_lock do + # Otro worker lo renovó mientras éste esperaba el lock. + next current_token if fresh? && (!@force || current_token != used_token) + + renew + end + end + + private + + def service = @integration.service + + def credentials = @integration.credentials || {} + + def current_token = credentials['access_token'] + + def fresh? + expires_at = credentials['token_expires_at'] + current_token.present? && expires_at.present? && + Time.zone.parse(expires_at) > RENEWAL_MARGIN.from_now + end + + def renew + token = RequestClientCredentialsToken.new( + token_url: token_url, client: client_credentials, service_name: service.service_name + ).call + @integration.update!(credentials: credentials.merge( + 'access_token' => token[:access_token], + 'token_expires_at' => token[:expires_at].iso8601 + )) + token[:access_token] + end + + def token_url + InterpolateUri.new(template: service.auth_config.fetch('token_url'), + values: @integration.settings).call + end + + def client_credentials + CLIENT_KEYS.to_h do |key| + value = credentials[key] + if value.blank? + raise AdapterExecutionError, "#{service.service_name} is missing the credential #{key}" + end + + [key, value] + end + end + end +end diff --git a/app/poros/integrations/http_adapter.rb b/app/poros/integrations/http_adapter.rb index 9d2b2cc5..055c4735 100644 --- a/app/poros/integrations/http_adapter.rb +++ b/app/poros/integrations/http_adapter.rb @@ -10,10 +10,6 @@ class HttpAdapter < ApplicationPoro # Segundos para abrir la conexión y para esperar la respuesta. TIMEOUTS = { open: 10, read: 10 }.freeze - # Charset de nombre de header válido (RFC 9110 token). Net::HTTPHeader no - # valida la clave: un \r\n en el nombre parte la línea e inyecta headers. - HEADER_NAME = /\A[A-Za-z0-9!#$%&'*+\-.^_`|~]+\z/ - HTTP_METHODS = { 'GET' => Net::HTTP::Get, 'POST' => Net::HTTP::Post, @@ -56,11 +52,16 @@ def call # mappers. `call` aplica el response_value_mapper a todo lo que extrae, y hay # quien necesita el dato crudo: el seguimiento conserva el estado externo # textual además del traducido (ver Shipments::TranslateTrackingPayload). + # + # También es donde se detectan los errores que llegan con HTTP 200 + # (Integrations::DetectResponseErrors): así los ven los dos caminos. def fetch - response = execute(build_request) + response = execute_with_token_renewal raise_http_error(response) unless response.is_a?(Net::HTTPSuccess) - parse_json(response.body) + parse_json(response.body).tap do |body| + DetectResponseErrors.new(service: @service, body: body, payload: @payload).call + end rescue *NETWORK_ERRORS => e raise AdapterExecutionError.new( "#{@service.service_name} request failed: #{e.class}: #{e.message}", payload: @payload @@ -69,16 +70,37 @@ def fetch private - def build_request + # Un 401 con un token que el sistema creía vigente (lo revocaron, o el + # proveedor lo invalidó antes de tiempo) se reintenta una sola vez con un + # token nuevo. Sólo tiene sentido con las estrategias que obtienen el token + # solas: con `bearer` el token es el que cargó la empresa. + def execute_with_token_renewal + response = execute(build_request) + return response unless response.is_a?(Net::HTTPUnauthorized) && renewable_token? + + execute(build_request(force_refresh: true)) + end + + def renewable_token? = @integration.service.oauth_client_credentials? + + def build_request(force_refresh: false) uri = URI(interpolated_uri) request = request_class.new(uri) - headers.each { |key, value| request[key] = value } - unless BODYLESS_METHODS.include?(@service.http_method) - request.body = BuildExternalPayload.new(service: @service, payload: @payload).call.to_json - end + headers(force_refresh).each { |key, value| request[key] = value } + request.body = request_body unless BODYLESS_METHODS.include?(@service.http_method) [uri, request] end + # GraphQL manda el documento tal cual y los valores dinámicos siempre como + # variables, nunca interpolados en el documento. + def request_body + mapped = BuildExternalPayload.new(service: @service, payload: @payload, + settings: @integration.settings).call + return mapped.to_json unless @service.graphql? + + { query: @service.body_template, variables: mapped }.to_json + end + def request_class HTTP_METHODS.fetch(@service.http_method) do raise AdapterExecutionError.new( @@ -112,33 +134,17 @@ def parse_json(body) ) end - # Convención de credenciales: access_token viaja como Bearer; cualquier otra - # clave del hash se envía como header literal (ej. X-Api-Key). - def headers - base = { 'Content-Type' => 'application/json', 'Accept' => 'application/json' } - (@integration.credentials || {}).each_with_object(base) do |(key, value), result| - validate_header_name!(key) - - if key == 'access_token' - result['Authorization'] = "Bearer #{value}" - else - result[key] = value.to_s - end - end - end - - def validate_header_name!(key) - return if key.to_s.match?(HEADER_NAME) - - raise AdapterExecutionError.new( - "#{@service.service_name} has an invalid credential key", payload: @payload + def headers(force_refresh) + { 'Content-Type' => 'application/json', 'Accept' => 'application/json' }.merge( + AuthHeaders.new(company_integration: @integration, force_refresh: force_refresh).call ) end + # Los settings de la cuenta completan la URI (`https://:shop_domain/...`); + # los uri_params del caso de uso tienen prioridad sobre ellos. def interpolated_uri - @uri_params.reduce(@service.uri) do |uri, (key, value)| - uri.gsub(":#{key}", value.to_s) - end + values = (@integration.settings || {}).merge(@uri_params.transform_keys(&:to_s)) + InterpolateUri.new(template: @service.uri, values: values).call end end end diff --git a/app/poros/integrations/interpolate_uri.rb b/app/poros/integrations/interpolate_uri.rb new file mode 100644 index 00000000..f984f109 --- /dev/null +++ b/app/poros/integrations/interpolate_uri.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Integrations + # Reemplaza los `:placeholders` de una URI de plantilla con valores. La usan + # el adaptador (con los uri_params del caso de uso por encima de los settings + # de la integración) y el pedido de token, cuya URL también depende de la + # cuenta (`https://:shop_domain/admin/oauth/access_token`). + class InterpolateUri < ApplicationPoro + def initialize(template:, values:) + super() + @template = template.to_s + @values = values.to_h.transform_keys(&:to_s) + end + + # Las claves más largas primero: con `shop` y `shop_domain` cargadas, un + # reemplazo de `:shop` antes que `:shop_domain` rompería el segundo. + def call + @values.keys.sort_by { |key| -key.length }.reduce(@template) do |uri, key| + uri.gsub(":#{key}", @values[key].to_s) + end + end + end +end diff --git a/app/poros/integrations/invalid_integration_error.rb b/app/poros/integrations/invalid_integration_error.rb new file mode 100644 index 00000000..d448da17 --- /dev/null +++ b/app/poros/integrations/invalid_integration_error.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Integrations + # Los datos de una conexión no cumplen lo que declara la plantilla. `fields` + # dice qué campo falló y por qué (`required`, `invalid_format`, `unknown`), + # con la clave `credentials.` o `settings.`: el backoffice lo + # muestra con el nombre de cada campo (Avo::Actions::ConfigureConnection). + class InvalidIntegrationError < StandardError + MESSAGE = 'Invalid integration data' + + attr_reader :fields + + def initialize(fields) + super(MESSAGE) + @fields = fields + end + end +end diff --git a/app/poros/integrations/register_webhook.rb b/app/poros/integrations/register_webhook.rb new file mode 100644 index 00000000..00bef301 --- /dev/null +++ b/app/poros/integrations/register_webhook.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module Integrations + # Le pide al proveedor que avise a OneStock de cada evento de la cuenta (en + # Shopify, cada venta: `orders/create`). Es lo que hace que las ventas entren + # solas, sin que la empresa tenga que pegar una URL en ningún panel. + # + # Si la plantilla sabe suscribirse, se suscribe: la madre declara una hija + # `webhook_subscription` y, opcional, una `webhook_lookup` que busca si ya + # existe una suscripción a esta misma dirección. Con la búsqueda, registrar + # dos veces no duplica nada; sin ella, el proveedor decide qué hacer con el + # duplicado. Las dos contestan `webhook_subscription_id`. + # + # La dirección es la del gateway de esta integración sobre la URL pública de + # la API (config.x.public_webhook_base_url). Si esa URL cambia (un túnel + # nuevo en desarrollo), se vuelve a registrar y queda una suscripción más. + # + # Mismo contrato que TestConnection: nunca propaga el error del proveedor, lo + # devuelve como mensaje para el backoffice. + class RegisterWebhook < ApplicationPoro + LOOKUP = :webhook_lookup + SUBSCRIPTION = :webhook_subscription + SUBSCRIPTION_KEY = 'webhook_subscription_id' + URL_KEY = 'webhook_url' + + # Si la plantilla sabe suscribirse: el backoffice sólo ofrece registrar el + # webhook donde tiene sentido. + def self.declared_by?(service) + service.present? && service.declares_operation?(SUBSCRIPTION) + end + + def initialize(company_integration:, base_url: Rails.configuration.x.public_webhook_base_url) + super() + @integration = company_integration + @base_url = base_url + end + + def call + template = service.template_for(SUBSCRIPTION) + return failure("#{service.service_name} does not declare a subscription") unless template + return failure('PUBLIC_WEBHOOK_BASE_URL is not set') if @base_url.blank? + return success('already registered') if subscribed? + + subscribe(template) + rescue AdapterExecutionError => e + failure(e.message) + end + + def webhook_url + helpers = Rails.application.routes.url_helpers + path = if service.courier? + helpers.api_webhooks_courier_path(@integration.id) + else + helpers.api_webhooks_integration_path(@integration.id) + end + "#{@base_url}#{path}" + end + + private + + def service = @integration.service + + def subscribed? + lookup = service.template_for(LOOKUP) + lookup.present? && run(lookup)[SUBSCRIPTION_KEY].present? + end + + def subscribe(template) + return success('registered') if run(template)[SUBSCRIPTION_KEY].present? + + failure("#{service.service_name} did not confirm the subscription") + end + + def run(template) + HttpAdapter.new(company_integration: @integration, service: template, + payload: { URL_KEY => webhook_url }).call + end + + def success(what) = { ok: true, message: "Webhook #{what}: #{webhook_url}" } + + def failure(message) = { ok: false, message: message } + end +end diff --git a/app/poros/integrations/request_client_credentials_token.rb b/app/poros/integrations/request_client_credentials_token.rb new file mode 100644 index 00000000..9059ddc1 --- /dev/null +++ b/app/poros/integrations/request_client_credentials_token.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'net/http' + +module Integrations + # Pide un token al proveedor con el grant `client_credentials` (RFC 6749 + # §4.4): el body viaja form-urlencoded y la respuesta es un JSON con + # `access_token` y `expires_in`. Sólo pide: guardarlo y decidir cuándo + # renovarlo es de Integrations::EnsureAccessToken. + # + # Ningún mensaje de error incluye el body de la respuesta ni los datos del + # pedido: el pedido lleva el client_secret. + class RequestClientCredentialsToken < ApplicationPoro + def initialize(token_url:, client:, service_name:, timeouts: HttpAdapter::TIMEOUTS) + super() + @uri = URI(token_url) + @client = client + @service_name = service_name + @timeouts = timeouts + end + + def call + response = execute + raise_http_error(response) unless response.is_a?(Net::HTTPSuccess) + + parse(response.body) + rescue *HttpAdapter::NETWORK_ERRORS => e + raise AdapterExecutionError, "#{@service_name} token request failed: #{e.class}" + end + + private + + def execute + request = Net::HTTP::Post.new(@uri) + request['Accept'] = 'application/json' + request.set_form_data(@client.merge('grant_type' => 'client_credentials')) + + http = Net::HTTP.new(@uri.host, @uri.port) + http.use_ssl = @uri.scheme == 'https' + http.open_timeout = @timeouts[:open] + http.read_timeout = @timeouts[:read] + http.request(request) + end + + # El proveedor explica el rechazo en el body (Shopify, en el de una + # página HTML: `Oauth error app_not_installed`). Se conserva sólo ese código, + # que es lo que permite saber qué falta sin exponer nada más. + def raise_http_error(response) + reason = response.body.to_s[/Oauth error ([a-z_]+)/i, 1] + detail = reason ? " (#{reason})" : '' + raise AdapterExecutionError.new( + "#{@service_name} token request responded with HTTP #{response.code}#{detail}", + response_status: response.code.to_i + ) + end + + def parse(body) + data = JSON.parse(body.to_s) + token = data['access_token'] + if token.blank? + raise AdapterExecutionError, "#{@service_name} token response has no access_token" + end + + { access_token: token, expires_at: Integer(data.fetch('expires_in', 0)).seconds.from_now } + rescue JSON::ParserError, ArgumentError, TypeError + raise AdapterExecutionError, "#{@service_name} returned an unreadable token response" + end + end +end diff --git a/app/poros/integrations/test_connection.rb b/app/poros/integrations/test_connection.rb new file mode 100644 index 00000000..3e4b1896 --- /dev/null +++ b/app/poros/integrations/test_connection.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module Integrations + # «Probar conexión»: comprueba que la empresa puede hablar con el proveedor + # usando su cuenta, sin tocar datos de negocio. + # + # Si la plantilla declara una hija `connection_test`, la ejecuta. Lo que + # contesta sirve además para completar la configuración que la empresa no + # tiene por qué conocer: los settings declarados por la plantilla que siguen + # vacíos y vienen en la respuesta (en Shopify, `location_id`, la ubicación + # donde se publica el stock). Y guarda el nombre de la cuenta + # (`account_name`), que es lo que la empresa ve para saber a qué cuenta está + # conectada: ése se pisa siempre, porque la cuenta se puede renombrar. + # + # Nunca propaga el error del proveedor: lo devuelve como mensaje, que es lo + # que la pantalla le muestra al usuario. + class TestConnection < ApplicationPoro + OPERATION = :connection_test + ACCOUNT_NAME_KEY = 'account_name' + + def initialize(company_integration:) + super() + @integration = company_integration + end + + def call + template = service.template_for(OPERATION) + return without_test_template if template.nil? + + response = HttpAdapter.new(company_integration: @integration, service: template).call + complete_settings(response) + { ok: true, message: success_message(response) } + rescue AdapterExecutionError => e + { ok: false, message: e.message } + end + + private + + def service = @integration.service + + # Sin hija de prueba, lo único verificable es obtener el token. + def without_test_template + unless service.oauth_client_credentials? + return { ok: false, message: "#{service.service_name} does not declare a connection test" } + end + + EnsureAccessToken.new(company_integration: @integration).call + { ok: true, message: "#{service.service_name}: access token obtained" } + end + + def complete_settings(response) + settings = @integration.settings || {} + missing = service.setting_keys.select { |key| settings[key].blank? && response[key].present? } + learned = response.slice(*missing, ACCOUNT_NAME_KEY).compact_blank + return if learned <= settings + + @integration.update!(settings: settings.merge(learned)) + end + + def success_message(response) + name = response[ACCOUNT_NAME_KEY] + name.present? ? "Connected to #{name}" : "#{service.service_name}: connection verified" + end + end +end diff --git a/app/poros/integrations/upsert_integration.rb b/app/poros/integrations/upsert_integration.rb index 12bbd858..54da160c 100644 --- a/app/poros/integrations/upsert_integration.rb +++ b/app/poros/integrations/upsert_integration.rb @@ -2,21 +2,45 @@ module Integrations class UpsertIntegration < ApplicationPoro - def initialize(company:, service_id:, credentials:, is_active: true) + # `credentials`, `settings` e `is_active` son opcionales: sin ellos, lo que ya + # tenía la integración se conserva (editar la configuración no la reactiva, + # y activarla no obliga a reenviar las credenciales). Una integración nueva + # nace activa. + def initialize(company:, service_id:, credentials: nil, is_active: nil, settings: nil) super() @company = company @service_id = service_id @credentials = credentials @is_active = is_active + @settings = settings end + # Una plantilla hija no es conectable: se ejecuta con la integración de su + # madre, así que para el alta es como si no existiera (404). def call - service = Service.find(@service_id) + service = Service.connectable.find(@service_id) integration = @company.company_integrations.find_or_initialize_by(service: service) - integration.credentials = @credentials - integration.is_active = @is_active + assign_connection(integration, service) + integration.is_active = @is_active unless @is_active.nil? integration.save! integration end + + private + + # Una plantilla que declara sus campos valida y combina (ApplyDeclaredFields). + # Las que no declaran nada conservan el contrato de siempre: `credentials` + # reemplaza entero lo que había. + def assign_connection(integration, service) + if service.declares_fields? + integration.credentials, integration.settings = ApplyDeclaredFields.new( + service: service, integration: integration, + credentials: @credentials, settings: @settings + ).call + else + integration.credentials = @credentials unless @credentials.nil? + integration.settings = @settings unless @settings.nil? + end + end end end diff --git a/app/poros/orders/process_webhook_order.rb b/app/poros/orders/process_webhook_order.rb index 7fa31cf0..49a79b54 100644 --- a/app/poros/orders/process_webhook_order.rb +++ b/app/poros/orders/process_webhook_order.rb @@ -125,11 +125,28 @@ def external_product_ids def order_attributes translated[:order] .slice(:external_order_id, :customer_name, :customer_document, - :customer_address, :customer_zip_code) + :customer_address, :customer_zip_code, :customer_city) .merge(company_id: @log.company_id, company_integration: @log.company_integration, - status: status) + status: status, customer_province: province) end + # La provincia se valida contra Order::PROVINCES, y cada canal la escribe a + # su manera: Shopify manda «Santiago Del Estero», otro puede mandar + # «Cordoba» sin tilde. Se compara sin mayúsculas ni tildes para no perder + # lo que es la misma provincia; un alias de verdad («Capital Federal») lo + # traduce el response_value_mapper de la plantilla. + # + # Si igual no matchea, la venta entra sin provincia en vez de fallar: mismo + # criterio que el status, la venta es el dato que no se puede perder. + def province + value = comparable(translated[:order][:customer_province]) + return if value.empty? + + Order::PROVINCES.find { |name| comparable(name) == value } + end + + def comparable(text) = I18n.transliterate(text.to_s).downcase.squish + # El status externo llega ya traducido por el response_value_mapper de la # plantilla ('pagado' => 'paid'). Si no viene, o si el canal usa un estado # que el OMS no conoce, la venta entra como pendiente en lugar de fallar: el diff --git a/app/poros/orders/translate_webhook_payload.rb b/app/poros/orders/translate_webhook_payload.rb index a11e8e48..e780c88c 100644 --- a/app/poros/orders/translate_webhook_payload.rb +++ b/app/poros/orders/translate_webhook_payload.rb @@ -13,7 +13,8 @@ class TranslateWebhookPayload < ApplicationPoro # ignora: el mapper de un Service puede mapear campos que sirven para otros # flujos (ej. tracking_number en la respuesta de un courier). ORDER_KEYS = %w[external_order_id customer_name customer_document - customer_address customer_zip_code status].freeze + customer_address customer_zip_code customer_city + customer_province status].freeze ITEM_KEYS = %w[external_product_id quantity unit_price].freeze def initialize(service:, payload:) diff --git a/app/poros/webhooks/invalid_signature_error.rb b/app/poros/webhooks/invalid_signature_error.rb new file mode 100644 index 00000000..eccd159e --- /dev/null +++ b/app/poros/webhooks/invalid_signature_error.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Webhooks + # Un webhook que no prueba venir del proveedor (Webhooks::VerifySignature). El + # mensaje dice qué falló, nunca el secreto ni la firma esperada: el gateway lo + # loguea. + class InvalidSignatureError < StandardError; end +end diff --git a/app/poros/webhooks/verify_signature.rb b/app/poros/webhooks/verify_signature.rb new file mode 100644 index 00000000..18ac575a --- /dev/null +++ b/app/poros/webhooks/verify_signature.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +module Webhooks + # Comprueba que un webhook entrante lo mandó el proveedor y no cualquiera que + # conozca la URL. La plantilla declara cómo firma su proveedor + # (`services.webhook_config`): + # + # { "signature": "hmac_sha256_base64", + # "signature_header": "X-Shopify-Hmac-SHA256", + # "secret_key": "client_secret" } + # + # se lee como «el header trae el HMAC-SHA256 del body, en base64, calculado + # con la credencial `client_secret` de la integración». El secreto es de la + # integración y no de la plantilla porque cada empresa conecta su propia app + # (ADR-018): una venta firmada con la app de otra empresa no pasa. + # + # Se calcula sobre el body crudo, nunca sobre el JSON re-serializado: un + # espacio o una tilde escapada de otra forma cambian el HMAC. + # + # Una plantilla que no declara firma no se verifica (Mercado Libre no firma, + # los couriers de los seeds tampoco). Una que la declara y no tiene con qué + # verificarla rechaza el evento: nunca se saltea la verificación. + class VerifySignature < ApplicationPoro + ENCODINGS = { + 'hmac_sha256_base64' => ->(digest) { Base64.strict_encode64(digest) }, + 'hmac_sha256_hex' => ->(digest) { digest.unpack1('H*') } + }.freeze + + REQUIRED_KEYS = %w[signature_header secret_key].freeze + + # Lo que le falta a un `webhook_config` para poder verificarse. Vive acá y + # no en Service porque es este PORO el que sabe qué algoritmos verifica. + def self.config_problems(config) + return ['debe ser un objeto'] unless config.is_a?(Hash) + return [] if config['signature'].blank? + + problems = REQUIRED_KEYS.select { |key| config[key].blank? }.map { |key| "falta #{key}" } + return problems if ENCODINGS.key?(config['signature']) + + problems << "signature debe ser una de: #{ENCODINGS.keys.join(', ')}" + end + + def initialize(company_integration:, raw_body:, headers:) + super() + @integration = company_integration + @raw_body = raw_body.to_s + @headers = headers + end + + def call + return unless service.signs_webhooks? + + reject("#{config['signature_header']} header is missing") if received.blank? + reject("#{config['secret_key']} is not configured") if secret.blank? + return if ActiveSupport::SecurityUtils.secure_compare(expected, received) + + reject('signature does not match') + end + + private + + def reject(reason) = raise(InvalidSignatureError, reason) + + def service = @integration.service + + def config = service.webhook_config + + def received = @headers[config['signature_header']].to_s.strip + + def secret + credentials = @integration.credentials + credentials.is_a?(Hash) ? credentials[config['secret_key']] : nil + end + + def expected + digest = OpenSSL::HMAC.digest('SHA256', secret, @raw_body) + ENCODINGS.fetch(config['signature']).call(digest) + end + end +end diff --git a/app/serializers/integration_status_serializer.rb b/app/serializers/integration_status_serializer.rb index eca2c289..e27d4032 100644 --- a/app/serializers/integration_status_serializer.rb +++ b/app/serializers/integration_status_serializer.rb @@ -1,9 +1,16 @@ # frozen_string_literal: true +# Fila de GET /api/v1/integrations: una plantilla conectable con el estado de la +# integración de la empresa del token. Es lo que muestra la pantalla de +# integraciones, que es de sólo lectura: las credenciales las carga el equipo +# de OneStock desde el backoffice (ADR-018). +# +# Ni la URI ni el método de la plantilla viajan (son detalles del motor), ni +# nada de las credenciales. class IntegrationStatusSerializer < ApplicationSerializer identifier :id, name: :service_id - fields :service_name, :type, :uri, :http_method + fields :service_name, :type field :configured do |service, options| options[:integrations_by_service_id].key?(service.id) @@ -17,4 +24,11 @@ class IntegrationStatusSerializer < ApplicationSerializer field :integration_id do |service, options| options[:integrations_by_service_id][service.id]&.id end + + # El nombre de la cuenta del proveedor, si «probar conexión» lo obtuvo (en + # Shopify, el de la tienda). Dice a qué cuenta está conectada la empresa. + field :account_name do |service, options| + settings = options[:integrations_by_service_id][service.id]&.settings || {} + settings[Integrations::TestConnection::ACCOUNT_NAME_KEY] + end end diff --git a/bin/jobs b/bin/jobs old mode 100644 new mode 100755 diff --git a/config/application.rb b/config/application.rb index b5076ade..1c16d8f7 100644 --- a/config/application.rb +++ b/config/application.rb @@ -43,5 +43,13 @@ class Application < Rails::Application key: '_proyecto_api_session' config.middleware.insert_before Warden::Manager, ActionDispatch::Flash config.middleware.use Rack::MethodOverride + + # La URL pública HTTPS con la que los proveedores llegan a la API: la del + # servidor en producción, la de un túnel en desarrollo. Es con lo que se + # arma la dirección del webhook que se le registra a cada proveedor + # (Integrations::RegisterWebhook). No sale del host del request: el registro + # corre desde el backoffice o la consola, y lo que importa es cómo nos ve el + # proveedor, no cómo entró el administrador. + config.x.public_webhook_base_url = ENV['PUBLIC_WEBHOOK_BASE_URL'].presence&.chomp('/') end end diff --git a/config/environments/development.rb b/config/environments/development.rb index 1af618b1..170e761e 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -12,6 +12,10 @@ # Show full error reports. config.consider_all_requests_local = true + # Los webhooks de un proveedor real llegan por un túnel (ngrok, cloudflared) + # y la protección de hosts los rechazaría con 403. + config.hosts << URI(config.x.public_webhook_base_url).host if config.x.public_webhook_base_url + # Enable server timing. config.server_timing = true diff --git a/config/routes.rb b/config/routes.rb index c5e5dabd..19fced7c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -19,7 +19,9 @@ # del token. get 'me', to: 'me#show' - resources :integrations, only: %i[index update], param: :service_id + # Sólo lectura: las credenciales las carga el equipo de OneStock desde el + # backoffice (ADR-018). + resources :integrations, only: :index resources :warehouses, only: %i[index show create update destroy] resources :products, only: %i[index show create update destroy] do # Vocabulario de categorías: ruta de colección, no depende de un producto. @@ -74,8 +76,10 @@ # Ruta pública: la consumen las plataformas externas, no el frontend. namespace :webhooks do - post 'integrations/:company_integration_id', to: 'integrations#create' - post 'couriers/:company_integration_id', to: 'couriers#create' + # Con nombre: es la dirección que se le registra al proveedor + # (Integrations::RegisterWebhook). + post 'integrations/:company_integration_id', to: 'integrations#create', as: :integration + post 'couriers/:company_integration_id', to: 'couriers#create', as: :courier end end end diff --git a/db/migrate/20260926143012_add_connection_config_to_integrations.rb b/db/migrate/20260926143012_add_connection_config_to_integrations.rb new file mode 100644 index 00000000..e2dafc4f --- /dev/null +++ b/db/migrate/20260926143012_add_connection_config_to_integrations.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +class AddConnectionConfigToIntegrations < ActiveRecord::Migration[8.1] + # Lo que el motor necesita para hablar con un proveedor real, sin código por + # proveedor (TESIS-138, Shopify end-to-end): + # + # - `company_integrations.settings`: la configuración NO secreta de la cuenta + # de cada empresa (dominio de la tienda, ubicación de stock, CUIT...). Va + # aparte de `credentials` porque no hace falta cifrarla y porque la + # plantilla la interpola en la URI (`https://:shop_domain/...`). + # - `services.auth_strategy` / `auth_config`: cómo se autentica la plantilla. + # `bearer` es el comportamiento de siempre; las demás estrategias leen su + # configuración de `auth_config`. + # - `services.credential_fields` / `setting_fields`: qué datos pide la + # plantilla a la empresa. Es lo que arma el formulario de conexión. + # - `services.request_format` / `body_template` / `error_path`: el transporte. + # GraphQL manda `{ query: body_template, variables }` y puede contestar un + # error con HTTP 200, en la ruta que declara `error_path`. + # - `services.parent_service_id` / `operation`: plantillas de operación. Una + # hija (probar conexión, buscar variante...) no es conectable: se ejecuta + # con la integración de su madre. + def change + add_column :company_integrations, :settings, :jsonb, default: {}, null: false + + change_table :services, bulk: true do |t| + t.string :auth_strategy, default: 'bearer', null: false + t.jsonb :auth_config, default: {}, null: false + t.jsonb :credential_fields, default: [], null: false + t.jsonb :setting_fields, default: [], null: false + t.string :request_format, default: 'json', null: false + t.text :body_template + t.string :error_path + t.string :operation + end + + add_reference :services, :parent_service, + foreign_key: { to_table: :services, on_delete: :cascade } + add_index :services, %i[parent_service_id operation], unique: true, + where: 'parent_service_id IS NOT NULL' + + add_check_constraint :services, "auth_strategy IN ('bearer', 'oauth_client_credentials')", + name: 'services_auth_strategy_check' + add_check_constraint :services, "request_format IN ('json', 'graphql')", + name: 'services_request_format_check' + end +end diff --git a/db/migrate/20260926171540_add_external_refs_to_product_mappings.rb b/db/migrate/20260926171540_add_external_refs_to_product_mappings.rb new file mode 100644 index 00000000..aac009e9 --- /dev/null +++ b/db/migrate/20260926171540_add_external_refs_to_product_mappings.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class AddExternalRefsToProductMappings < ActiveRecord::Migration[8.1] + # Otros identificadores de la publicación en el canal, además del que llega en + # las ventas (`external_product_id`). Shopify vende por variante pero publica + # el stock por `inventory_item_id`: el vínculo necesita los dos. Es genérico a + # propósito: otro canal puede necesitar otros (Tiendanube, producto + variante). + # + # Lo completa la plantilla de búsqueda del proveedor al vincular, y el sync + # saliente lo suma al payload para que el request_mapper lo use. + def change + add_column :product_mappings, :external_refs, :jsonb, default: {}, null: false + end +end diff --git a/db/migrate/20261001211235_add_webhook_config_to_services.rb b/db/migrate/20261001211235_add_webhook_config_to_services.rb new file mode 100644 index 00000000..ac45bfb5 --- /dev/null +++ b/db/migrate/20261001211235_add_webhook_config_to_services.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddWebhookConfigToServices < ActiveRecord::Migration[8.1] + # Cómo prueba el proveedor que un webhook es suyo. La plantilla lo declara + # (algoritmo, header y de dónde sale el secreto) y el gateway lo verifica sobre + # el body crudo antes de persistir. Vacío es «el proveedor no firma»: el + # comportamiento de siempre para las plantillas que ya existían. + def change + add_column :services, :webhook_config, :jsonb, default: {}, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index a88bf772..95ee4433 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_09_25_120000) do +ActiveRecord::Schema[8.1].define(version: 2026_10_01_211235) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -43,6 +43,7 @@ t.text "credentials", default: "{}", null: false t.boolean "is_active", default: true, null: false t.bigint "service_id", null: false + t.jsonb "settings", default: {}, null: false t.datetime "updated_at", null: false t.index ["company_id", "service_id"], name: "index_company_integrations_on_company_id_and_service_id", unique: true t.index ["company_id"], name: "index_company_integrations_on_company_id" @@ -122,6 +123,7 @@ t.datetime "created_at", null: false t.decimal "external_price", precision: 10, scale: 2 t.string "external_product_id", null: false + t.jsonb "external_refs", default: {}, null: false t.bigint "product_id", null: false t.datetime "updated_at", null: false t.index ["company_integration_id", "external_product_id"], name: "index_product_mappings_on_integration_and_external_id", unique: true @@ -146,21 +148,35 @@ end create_table "services", force: :cascade do |t| + t.jsonb "auth_config", default: {}, null: false + t.string "auth_strategy", default: "bearer", null: false + t.text "body_template" t.datetime "created_at", null: false + t.jsonb "credential_fields", default: [], null: false + t.string "error_path" t.string "http_method", null: false + t.string "operation" + t.bigint "parent_service_id" t.bigint "quote_service_id" + t.string "request_format", default: "json", null: false t.jsonb "request_mapper", default: {}, null: false t.jsonb "request_value_mapper", default: {}, null: false t.jsonb "response_mapper", default: {}, null: false t.jsonb "response_value_mapper", default: {}, null: false t.string "service_name", null: false + t.jsonb "setting_fields", default: [], null: false t.bigint "tracking_service_id" t.string "type", null: false t.datetime "updated_at", null: false t.string "uri", null: false + t.jsonb "webhook_config", default: {}, null: false + t.index ["parent_service_id", "operation"], name: "index_services_on_parent_service_id_and_operation", unique: true, where: "(parent_service_id IS NOT NULL)" + t.index ["parent_service_id"], name: "index_services_on_parent_service_id" t.index ["quote_service_id"], name: "index_services_on_quote_service_id", unique: true t.index ["service_name"], name: "index_services_on_service_name", unique: true t.index ["tracking_service_id"], name: "index_services_on_tracking_service_id" + t.check_constraint "auth_strategy::text = ANY (ARRAY['bearer'::character varying, 'oauth_client_credentials'::character varying]::text[])", name: "services_auth_strategy_check" + t.check_constraint "request_format::text = ANY (ARRAY['json'::character varying, 'graphql'::character varying]::text[])", name: "services_request_format_check" t.check_constraint "type::text = ANY (ARRAY['ecommerce'::character varying, 'courier'::character varying]::text[])", name: "services_type_check" end @@ -278,6 +294,7 @@ add_foreign_key "product_mappings", "company_integrations", on_delete: :cascade add_foreign_key "product_mappings", "products", on_delete: :cascade add_foreign_key "products", "companies", on_delete: :cascade + add_foreign_key "services", "services", column: "parent_service_id", on_delete: :cascade add_foreign_key "services", "services", column: "quote_service_id", on_delete: :nullify add_foreign_key "services", "services", column: "tracking_service_id", on_delete: :nullify add_foreign_key "shipment_events", "shipments", on_delete: :cascade diff --git a/db/seeds.rb b/db/seeds.rb index 29820e21..0628d761 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -282,10 +282,199 @@ 'EN DISTRIBUCION' => 'in_transit', 'ENTREGADO' => 'delivered' } + }, + # Shopify (TESIS-138): la madre es la integración que la empresa conecta. + # Cada empresa carga el client_id y el client_secret de SU propia app del + # Dev Dashboard (opción B: la app y la tienda están en la organización de la + # empresa, que es lo que exige el grant client_credentials) y el dominio de + # su tienda. El token lo obtiene y renueva el sistema. + # + # La versión de la API vive en la URI: se actualiza desde el backoffice sin + # deploy. Shopify mantiene cada versión unos 12 meses. + # + # La madre publica el stock (la usa el sync saliente, TESIS-35): fija la + # cantidad `available` del inventory item en la ubicación de la cuenta. Es un + # valor absoluto, así que `changeFromQuantity: null` (sin compare-and-set: el + # OMS es la fuente de verdad). Desde 2026-04 Shopify exige la clave de + # idempotencia, que el sync genera por intento. + { + service_name: 'Shopify', + type: 'ecommerce', + uri: 'https://:shop_domain/admin/api/2026-07/graphql.json', + http_method: 'POST', + request_format: 'graphql', + body_template: <<~GRAPHQL.squish, + mutation SetStock($inventoryItemId: ID!, $locationId: ID!, $quantity: Int!, + $idempotencyKey: String!) { + inventorySetQuantities(input: { + name: "available", reason: "correction", + referenceDocumentUri: "logistics://onestock/stock-sync", + quantities: [{ inventoryItemId: $inventoryItemId, locationId: $locationId, + quantity: $quantity, changeFromQuantity: null }] + }) @idempotent(key: $idempotencyKey) { + userErrors { code field message } + } + } + GRAPHQL + error_path: 'data.inventorySetQuantities.userErrors', + auth_strategy: 'oauth_client_credentials', + auth_config: { + 'token_url' => 'https://:shop_domain/admin/oauth/access_token', + 'token_header' => 'X-Shopify-Access-Token', + 'token_prefix' => '' + }, + credential_fields: [ + { 'key' => 'client_id', 'label' => 'Client ID', 'required' => true }, + { 'key' => 'client_secret', 'label' => 'Client secret', 'required' => true } + ], + setting_fields: [ + { 'key' => 'shop_domain', 'label' => 'Dominio de la tienda', 'required' => true, + 'format' => '\A[a-z0-9][a-z0-9-]*\.myshopify\.com\z' }, + { 'key' => 'location_id', 'label' => 'Ubicación de stock', 'required' => false } + ], + request_mapper: { + 'inventoryItemId' => 'inventory_item_id', + 'locationId' => 'settings.location_id', + 'quantity' => 'available_quantity', + 'idempotencyKey' => 'idempotency_key' + }, + # Las ventas llegan por el webhook `orders/create`, que trae la orden + # completa: la ingesta (TESIS-43) la traduce con este mapper. El cliente y + # la dirección salen de la dirección de envío, que Shopify sólo manda si la + # app tiene acceso a los datos protegidos de cliente. + response_mapper: { + 'id' => 'external_order_id', + 'financial_status' => 'status', + 'shipping_address.name' => 'customer_name', + 'shipping_address.address1' => 'customer_address', + 'shipping_address.zip' => 'customer_zip_code', + 'shipping_address.city' => 'customer_city', + 'shipping_address.province' => 'customer_province', + 'line_items[].variant_id' => 'external_product_id', + 'line_items[].quantity' => 'quantity', + 'line_items[].price' => 'unit_price' + }, + request_value_mapper: {}, + # Estados de pago de Shopify que no se llaman igual en el OMS. `paid` y + # `pending` coinciden, y cualquier otro entra como pendiente. + response_value_mapper: { 'authorized' => 'pending', 'partially_paid' => 'pending', + 'voided' => 'cancelled' }, + # Shopify firma cada webhook con el client secret de la app que lo + # registró: como cada empresa conecta la suya, el secreto es el de su + # integración. + webhook_config: { 'signature' => 'hmac_sha256_base64', + 'signature_header' => 'X-Shopify-Hmac-SHA256', + 'secret_key' => 'client_secret' } + }, + # «Probar conexión» de Shopify: plantilla hija, se ejecuta con la cuenta de la + # madre. Trae el nombre de la tienda y la primera ubicación, que completa el + # setting `location_id` si la empresa no lo cargó. + { + service_name: 'Shopify - Conexión', + parent_service_name: 'Shopify', + operation: 'connection_test', + type: 'ecommerce', + uri: 'https://:shop_domain/admin/api/2026-07/graphql.json', + http_method: 'POST', + request_format: 'graphql', + body_template: '{ shop { name } locations(first: 5) { nodes { id name } } }', + request_mapper: {}, + response_mapper: { + 'data.shop.name' => 'account_name', + 'data.locations.nodes.0.id' => 'location_id' + }, + request_value_mapper: {}, + response_value_mapper: {} + }, + # Vincular por id de variante: confirma que existe y trae su inventory item, + # que es con lo que Shopify publica el stock. `node` en vez de una consulta + # por variante: es la forma estable de pedir cualquier objeto por su GID. + { + service_name: 'Shopify - Variante', + parent_service_name: 'Shopify', + operation: 'product_lookup', + type: 'ecommerce', + uri: 'https://:shop_domain/admin/api/2026-07/graphql.json', + http_method: 'POST', + request_format: 'graphql', + body_template: 'query Variant($id: ID!) { node(id: $id) { ... on ProductVariant { ' \ + 'legacyResourceId sku inventoryItem { id } product { title } } } }', + request_mapper: { 'id' => 'gid://shopify/ProductVariant/{{external_id}}' }, + response_mapper: { + 'data.node.legacyResourceId' => 'external_product_id', + 'data.node.sku' => 'external_sku', + 'data.node.product.title' => 'external_title', + 'data.node.inventoryItem.id' => 'inventory_item_id' + }, + request_value_mapper: {}, + response_value_mapper: {} + }, + # Vincular por SKU: busca la variante con el SKU del producto. Pide dos + # resultados para detectar un SKU repetido en la tienda (`ambiguous_match`). + { + service_name: 'Shopify - Buscar por SKU', + parent_service_name: 'Shopify', + operation: 'product_search', + type: 'ecommerce', + uri: 'https://:shop_domain/admin/api/2026-07/graphql.json', + http_method: 'POST', + request_format: 'graphql', + body_template: 'query BySku($query: String!) { productVariants(first: 2, query: $query) { ' \ + 'nodes { legacyResourceId sku inventoryItem { id } product { title } } } }', + request_mapper: { 'query' => 'sku:{{sku}}' }, + response_mapper: { + 'data.productVariants.nodes.0.legacyResourceId' => 'external_product_id', + 'data.productVariants.nodes.0.sku' => 'external_sku', + 'data.productVariants.nodes.0.product.title' => 'external_title', + 'data.productVariants.nodes.0.inventoryItem.id' => 'inventory_item_id', + 'data.productVariants.nodes.1.legacyResourceId' => 'ambiguous_match' + }, + request_value_mapper: {}, + response_value_mapper: {} + }, + # Registrar el webhook de ventas (Integrations::RegisterWebhook): primero se + # busca si la tienda ya avisa a esta dirección, para no duplicar la + # suscripción, y si no se crea. La dirección la arma el sistema. + { + service_name: 'Shopify - Buscar webhook', + parent_service_name: 'Shopify', + operation: 'webhook_lookup', + type: 'ecommerce', + uri: 'https://:shop_domain/admin/api/2026-07/graphql.json', + http_method: 'POST', + request_format: 'graphql', + body_template: 'query Webhook($uri: String!) { webhookSubscriptions(first: 1, uri: $uri, ' \ + 'topics: [ORDERS_CREATE]) { nodes { id } } }', + request_mapper: { 'uri' => 'webhook_url' }, + response_mapper: { 'data.webhookSubscriptions.nodes.0.id' => 'webhook_subscription_id' }, + request_value_mapper: {}, + response_value_mapper: {} + }, + { + service_name: 'Shopify - Webhook', + parent_service_name: 'Shopify', + operation: 'webhook_subscription', + type: 'ecommerce', + uri: 'https://:shop_domain/admin/api/2026-07/graphql.json', + http_method: 'POST', + request_format: 'graphql', + body_template: 'mutation Subscribe($uri: String!) { webhookSubscriptionCreate(' \ + 'topic: ORDERS_CREATE, webhookSubscription: { uri: $uri, format: JSON }) { ' \ + 'webhookSubscription { id } userErrors { field message } } }', + error_path: 'data.webhookSubscriptionCreate.userErrors', + request_mapper: { 'uri' => 'webhook_url' }, + response_mapper: { + 'data.webhookSubscriptionCreate.webhookSubscription.id' => 'webhook_subscription_id' + }, + request_value_mapper: {}, + response_value_mapper: {} } ] services.each do |attrs| + # El vínculo con la madre se resuelve después del loop: la madre recién existe + # cuando terminó de crearse. + attrs = attrs.except(:parent_service_name) service = Service.find_or_create_by!(service_name: attrs[:service_name]) do |s| s.assign_attributes(attrs) end @@ -297,7 +486,23 @@ # la plantilla (uri, http_method, type) por si se editó a mano desde el # backoffice, y sin tocar otras plantillas: cada vuelta sólo actualiza su # propio service_name. - service.update!(attrs.slice(*Service::MAPPER_FIELDS.map(&:to_sym))) + # La configuración de conexión (auth, transporte, campos declarados) se + # reaplica con el mismo criterio: si no, una base sembrada antes de TESIS-138 + # se quedaba con plantillas que no saben autenticarse. + service.update!(attrs.slice(*(Service::MAPPER_FIELDS + Service::CONNECTION_FIELDS).map(&:to_sym))) +end + +# Las plantillas de siempre se autentican con un token fijo (`bearer`): se +# declara ese campo para que el formulario de conexión del front sepa pedirlo. +Service.connectable.where(auth_strategy: 'bearer', credential_fields: []).find_each do |service| + service.update!(credential_fields: [{ 'key' => 'access_token', 'label' => 'Access token', + 'required' => true }]) +end + +# Plantillas de operación: cada hija apunta a su madre (TESIS-138). +services.select { |attrs| attrs[:parent_service_name] }.each do |attrs| + parent = Service.find_by!(service_name: attrs[:parent_service_name]) + Service.find_by!(service_name: attrs[:service_name]).update!(parent_service: parent) end # Correo Argentino no empuja el tracking: se le pregunta con su plantilla de @@ -322,7 +527,6 @@ if first_company && ml_service CompanyIntegration.find_or_create_by!(company: first_company, service: ml_service) do |ci| ci.credentials = { 'access_token' => 'DEMO-TOKEN-ML' } - ci.is_active = true end end @@ -407,7 +611,6 @@ company: norte_company, service: ml_stock_service ) do |ci| ci.credentials = { 'access_token' => 'DEMO-TOKEN-ML' } - ci.is_active = true end # Una base que corrió estas seeds antes de este cambio tiene el mapping @@ -442,7 +645,6 @@ company: norte_company, service: tn_service ) do |ci| ci.credentials = { 'access_token' => 'DEMO-TOKEN-TN' } - ci.is_active = true end ProductMapping.find_or_create_by!( @@ -461,6 +663,21 @@ end end +# Las conexiones de Mercado Libre y Tiendanube de Norte son de ejemplo: el token +# es inventado y nunca hablaron con el proveedor. Se conservan porque las órdenes, +# los vínculos y los eventos de ejemplo salen de ellas, pero inactivas: activas, +# la pantalla de integraciones las mostraba como conectadas y cada cambio de +# stock intentaba publicar en ellas y fallaba. Se recorre en vez de crearlas +# inactivas para corregir también una base que ya había corrido las seeds +# (find_or_create_by! no toca una fila que existe). +demo_channel_tokens = %w[DEMO-TOKEN-ML DEMO-TOKEN-TN] +CompanyIntegration.unscoped.where(is_active: true).find_each do |integration| + credentials = integration.credentials + next unless credentials.is_a?(Hash) && demo_channel_tokens.include?(credentials['access_token']) + + integration.update!(is_active: false) +end + if sur_company # Products de Comercial Sur taladro = Product.find_or_create_by!(sku: 'SUR-001', company: sur_company) do |p| diff --git a/docs/adr/ADR-011-push-tracking-de-couriers.md b/docs/adr/ADR-011-push-tracking-de-couriers.md index 7d6ace86..73068b01 100644 --- a/docs/adr/ADR-011-push-tracking-de-couriers.md +++ b/docs/adr/ADR-011-push-tracking-de-couriers.md @@ -122,6 +122,6 @@ El tenant sale de la integración (`CompanyIntegration#company_id`), nunca de un - ✅ `external_status` e `internal_status` quedan auditables por separado: una plantilla mal mapeada se corrige sin perder historial - ✅ Ningún evento se pierde: los fallos reales van a la DLQ y los eventos de paquetes ajenos se descartan sin generar reintentos infinitos - ✅ `EventIngestion` evita duplicar la mecánica de ingesta entre `integrations` y `couriers`, y sumar un tercer tipo de webhook entrante es repetir la misma receta, no tocar el gateway -- ⚠️ El endpoint es público y hoy no valida la firma del courier: cualquiera que conozca (o adivine) un `company_integration_id` puede escribir eventos de tracking falsos. Queda para una card de seguridad dedicada (verificación de firma/HMAC por proveedor) +- ⚠️ El endpoint es público y hoy no valida la firma del courier: cualquiera que conozca (o adivine) un `company_integration_id` puede escribir eventos de tracking falsos. Queda para una card de seguridad dedicada (verificación de firma/HMAC por proveedor). _(Actualización, [ADR-019](ADR-019-ventas-entrantes-de-shopify.md): el gateway ya verifica la firma de las plantillas que la declaran en `webhook_config`. Los couriers de los seeds no la declaran todavía.)_ - ⚠️ Sin `occurred_at` en el payload, `ProcessTrackingUpdate` cae a `Time.current`: si el evento llega tarde —cola atrasada, reintento del courier— la bitácora puede quedar con el timestamp del procesamiento en vez del real, desordenando el historial. Como ese timestamp sintético también es la clave de la barrera 1, el chequeo de duplicado deja de comparar por `occurred_at` exacto para este caso y compara sólo por `external_status` contra el último evento del envío: no distingue un reintento de un cambio de estado real que llegara sin fecha y coincidiera con el estado anterior, pero para una bitácora de auditoría el default seguro es no duplicar - ⚠️ La unicidad `(shipment_id, external_status, occurred_at)` descarta como duplicado un evento legítimo que repita exactamente el mismo estado y timestamp; se acepta el costo porque, en la práctica, el propio courier tampoco suele distinguir esos dos casos entre sí diff --git a/docs/adr/ADR-017-backoffice.md b/docs/adr/ADR-017-backoffice.md index 89d44ff3..a7a7a832 100644 --- a/docs/adr/ADR-017-backoffice.md +++ b/docs/adr/ADR-017-backoffice.md @@ -49,6 +49,7 @@ Las credenciales de `company_integrations` son API keys y tokens de las cuentas - El detalle muestra qué claves hay configuradas, con el valor enmascarado (`••••••`). - El formulario no las edita. Las carga cada empresa por la API (`PUT /api/v1/integrations/:service_id`), que tampoco las devuelve nunca. + - _(Actualización, [ADR-018](ADR-018-conexion-con-proveedores-reales.md): ahora las carga el equipo de OneStock desde acá, con la acción «Configure connection», un campo de password por clave que nunca se precarga. El formulario del recurso sigue sin editarlas, y la API de la empresa ya no tiene alta.)_ - Editarlas desde Avo, además, las rompía: el campo de código manda texto, y `serialize :credentials, coder: JSON` lo guardaba como String, con lo que `Integrations::HttpAdapter` fallaba al recorrerlas. Una fila que haya quedado así se muestra enmascarada entera. - ⚠️ En Avo, `only_on: :show` saca un campo del formulario pero lo sigue aceptando en un PATCH armado a mano. Para que no se pueda escribir hace falta `disabled: true`. Vale para cualquier recurso nuevo. @@ -90,6 +91,7 @@ Un store en la base (gema `activerecord-session_store`) o en la cache (`:cache_s - ✅ El admin podría cargar credenciales en nombre de una empresa - ❌ Obliga a parsear y validar JSON en el backoffice para un caso de uso que no existe: las carga la empresa +- _(Actualización, ADR-018: el caso de uso apareció, y se resolvió con una acción con un campo por clave en vez de JSON.)_ ### Sacar «Recordarme» diff --git a/docs/adr/ADR-018-conexion-con-proveedores-reales.md b/docs/adr/ADR-018-conexion-con-proveedores-reales.md new file mode 100644 index 00000000..012b3b14 --- /dev/null +++ b/docs/adr/ADR-018-conexion-con-proveedores-reales.md @@ -0,0 +1,176 @@ +# ADR-018: Conexión con proveedores reales + +**Fecha:** 2026-09-26 +**Estado:** Aceptado + +--- + +## Contexto + +El criterio de aceptación del proyecto (E4b V2 §2.6) pide que al menos una plataforma de e-commerce y un courier funcionen de punta a punta en un entorno sandbox o real. El motor de integraciones data-driven (TESIS-28 a TESIS-31) estaba construido, pero sólo se había probado contra WebMock, y no podía hablar con un proveedor real: + +- `HttpAdapter` sólo sabía mandar un `Bearer` fijo y JSON. Cualquier otra clave de `credentials` viajaba como header literal. No había OAuth, ni renovación de tokens, ni GraphQL. +- `CompanyIntegration` mezclaba en un solo campo cifrado los secretos y la configuración de la cuenta (el dominio de una tienda no es un secreto, y la plantilla necesita interpolarlo en la URI). +- Un proveedor real necesita varias llamadas con la misma cuenta (probar la conexión, buscar una publicación, publicar el stock). Hasta ahora eso obligaba a una integración por plantilla, con el token duplicado y los vínculos de productos repartidos (la consecuencia aceptada de ADR-010). +- El sync saliente (TESIS-35) publicaba el stock en **cualquier** plantilla con productos vinculados, aunque no supiera qué hacer con él. +- La API de la empresa aceptaba un `credentials` entero, sin validar nada, y el backoffice no permitía cargarlas (TESIS-129 lo dejó en sólo lectura para no mostrar secretos). + +El primer proveedor es **Shopify**, contra una tienda de prueba (*dev store*) del Partner Program. El equipo decidió integrarse sólo contra entornos de prueba, nunca contra producción. Este ADR registra las decisiones que la conexión real obligó a tomar. Las ventas entrantes (firma, mapeo de la orden y suscripción del webhook) están en [ADR-019](ADR-019-ventas-entrantes-de-shopify.md). + +## Decisión + +### Dos lugares por integración: `settings` y `credentials` + +`company_integrations.settings` (jsonb, **sin cifrar**) guarda la configuración no secreta de la cuenta de la empresa: el dominio de la tienda, la ubicación de stock, el CUIT. `credentials` (cifrado, como hasta ahora) queda sólo para secretos y para el token que se obtiene con ellos. + +La separación no es cosmética. La plantilla interpola los settings en su URI (`https://:shop_domain/admin/api/2026-07/graphql.json`) y en el `request_mapper` (`settings.location_id`), y el backoffice los muestra y los precarga en el formulario de conexión. Los secretos no se muestran en ningún lado: ni la API ni el backoffice los devuelven. + +### Cada empresa conecta su propia app del proveedor + +El sistema es multi-tenant, y la forma de autenticarse lo condiciona. Shopify ofrece dos caminos: + +- El grant **client credentials**, que sólo funciona si la app y la tienda pertenecen a la misma organización de Shopify. +- El **authorization code** de una app pública, que exige un flujo de instalación y la aprobación de Shopify para distribuirla. + +Se eligió que **cada empresa cree su propia app** en el Dev Dashboard de su organización y le pase al equipo de OneStock el `client_id`, el `client_secret` y el dominio de su tienda. Así, client credentials funciona para cualquier cliente, y no sólo para tiendas de la organización del equipo. Desde el 1/1/2026 Shopify tampoco permite crear apps desde el admin de la tienda: el Dev Dashboard es el camino oficial para la integración propia de una tienda. + +En consecuencia, **las credenciales de Shopify son del tenant** y viven cifradas en su integración. No hay credenciales de nivel app (Rails credentials) en uso: el mecanismo queda para un proveedor con una app única de OneStock (Mercado Libre, en pausa). + +### Las credenciales las carga el equipo de OneStock, desde el backoffice + +La empresa no carga ni ve sus credenciales. El administrador de la plataforma crea la integración en el backoffice (empresa y plantilla) y carga los datos de la cuenta con la acción **«Configure connection»** (`Avo::Actions::ConfigureConnection`), que muestra un campo por cada dato que declara la plantilla. **«Test connection»** (`Avo::Actions::TestConnection`) la prueba. + +La API de la empresa queda de **sólo lectura** (`GET /api/v1/integrations`): dice qué proveedores tiene conectados, si están activos y con qué cuenta (`account_name`). No hay alta, desconexión ni prueba: si quedaran los endpoints, un usuario podría cargar credenciales con Postman aunque el front no lo ofrezca. + +Motivos: + +- Conectar un proveedor es parte del alta de un cliente, que ya hace el equipo (la empresa, sus usuarios, las plantillas). La empresa no necesita ver una pantalla con campos técnicos (client secret, dominio `.myshopify.com`, ubicación de stock) que sólo usa una vez. +- Con dos empresas de demo, el equipo está de los dos lados: nadie le tiene que mandar un secreto a nadie. +- Menos superficie: una sola puerta para escribir secretos, detrás del login del backoffice (ADR-017). + +Una integración nueva **nace inactiva** en el backoffice: activa y sin cuenta, la empresa la vería como conectada. El administrador la activa después de configurarla y probarla. + +### La plantilla declara cómo se autentica + +`services.auth_strategy` + `services.auth_config`. El adaptador delega en la estrategia (`Integrations::AuthHeaders`) en lugar de iterar `credentials`: + +| Estrategia | Qué hace | +|---|---| +| `bearer` (default) | El comportamiento de siempre: `access_token` como `Authorization: Bearer`, el resto de las claves como headers literales | +| `oauth_client_credentials` | Pide el token con el `client_id`/`client_secret` de la integración (`Integrations::RequestClientCredentialsToken`), lo cachea hasta `expires_in` y lo manda en el header que declara `auth_config` (`X-Shopify-Access-Token`, sin prefijo) | + +La autenticación sale siempre de la plantilla **conectada**, no de la que se ejecuta: una plantilla hija usa la cuenta y el token de su madre. + +Sobre el token (`Integrations::EnsureAccessToken`): + +- Se renueva **5 minutos antes** de vencer, para que un request no salga con un token que expira en el camino. +- La renovación corre con **la fila de la integración bloqueada** (`with_lock`), y adentro se vuelve a mirar: el que esperó el lock puede encontrarse con que otro worker ya lo renovó. +- Ante un **401** con un token que parecía vigente, el adaptador pide uno nuevo **una sola vez** y reintenta. +- Si cambia una credencial, el token cacheado se descarta: se obtuvo con la anterior. +- Ningún error incluye el body del proveedor ni los datos del pedido, que llevan el secreto. Del rechazo de Shopify se conserva sólo el código (`app_not_installed`). + +Sólo se implementaron las dos estrategias que usa la demo. `oauth_refresh` (con refresh token de uso único) y `login` quedan para cuando un proveedor las pida. + +### La plantilla declara qué datos pide + +`services.credential_fields` y `services.setting_fields`: una lista de `{ key, label, required, format? }`. Es **la única fuente de verdad** del formulario de «Configure connection» y de su validación (`Integrations::ApplyDeclaredFields`). Agregar un campo a la plantilla lo hace aparecer en el formulario sin tocar código. + +Reglas: + +- Un campo requerido faltante o un valor que no cumple su `format` no se guardan, y el backoffice dice qué campo falló y por qué (`Shop domain: invalid_format`). +- Los secretos se escriben en campos de password y **nunca se precargan**, así que un secreto vacío quiere decir «dejá el que está». +- Un setting que llega vacío **se borra**: ése sí se precarga, y vaciarlo es deliberado. +- Si cambia una credencial, se descarta el token cacheado. +- Configurar no activa ni desactiva: el estado se cambia en el formulario de la integración. +- Después de guardar, la conexión se prueba sola si la plantilla sabe hacerlo. En Shopify eso completa la ubicación de stock. + +### Plantillas de operación + +`services.parent_service_id` + `services.operation`. Una plantilla **hija** es otra llamada al mismo proveedor con la misma cuenta: no es conectable (no aparece en el listado, y conectarla directamente da 404) y se ejecuta con la integración de su madre (`HttpAdapter.new(company_integration:, service: hija)`, que ya existía para el tracking de ADR-014). `Service#template_for(operation)` la resuelve. + +Las operaciones de Shopify son: + +| Hija | Operación | Para qué | +|---|---|---| +| `Shopify - Conexión` | `connection_test` | «Probar conexión». Además completa los settings declarados que la empresa no cargó y el proveedor sabe (la ubicación de stock) | +| `Shopify - Variante` | `product_lookup` | Confirmar que un id de variante existe al vincularlo, y traer su `inventory_item_id` | +| `Shopify - Buscar por SKU` | `product_search` | Vincular un producto buscando la variante con su mismo SKU | + +La madre es la que se conecta y la que publica el stock. + +`tracking_service` (ADR-014) y `quote_service` (TESIS-131) son el mismo concepto con otra forma, una FK por rol. No se migran: el patrón de operación es para lo nuevo. + +### Transporte: GraphQL y valores armados + +- `request_format: graphql` manda `{ query: body_template, variables }`, donde las variables son el resultado del `request_mapper`. El documento viaja tal cual: los valores dinámicos van siempre como variables, nunca interpolados en el documento. +- Algunas APIs contestan un error con **HTTP 200**. El adaptador lo detecta en `fetch`, así lo ven todos los caminos (`Integrations::DetectResponseErrors`), de dos fuentes: un `errors` no vacío en la raíz de una respuesta GraphQL, y la ruta que declara `services.error_path` (`data.inventorySetQuantities.userErrors`). +- El valor de una entrada del `request_mapper` puede leer un setting (`settings.location_id`) o armar un texto con variables (`gid://shopify/ProductVariant/{{external_id}}`, porque GraphQL no concatena strings). No es un motor de plantillas: sólo reemplaza variables, y una plantilla a la que le falta alguna no se envía a medias. + +### Publicar el stock sólo donde se sabe + +El sync saliente publica en `Service#stock_template`: la propia plantilla si mapea `available_quantity`, o su hija `stock`. Si ninguna sabe, el canal no recibe nada. Así se cierra el caso de la plantilla de órdenes de Mercado Libre, que recibía un stock que no sabía procesar. + +- **Referencias externas.** `product_mappings.external_refs` (jsonb) guarda los identificadores extra que necesita el canal. Shopify vende por variante (`external_product_id`, lo que trae la venta) pero publica el stock por inventory item. El sync las suma al payload. +- **Idempotencia.** Shopify exige una clave de idempotencia en `inventorySetQuantities` desde 2026-04. El sync genera una por intento: fijar una cantidad absoluta es idempotente por naturaleza, así que un reintento con otra clave no hace daño. La mutación usa `changeFromQuantity: null`, sin compare-and-set, porque el OMS es la fuente de verdad del stock. +- **Push inicial al vincular.** Vincular un producto publica su stock sólo en el canal recién vinculado, no en todos. + +### Vincular consulta al canal dentro del request + +`POST /api/v1/products/:product_id/mappings` le pregunta al canal si la publicación existe (`product_lookup`) o la busca por SKU (`product_search`) antes de crear el vínculo: + +- Si la publicación no existe, responde **422**. +- Si hay dos variantes con el mismo SKU, responde **422**: no elige. +- Si el SKU es distinto, crea el vínculo pero **avisa** (`warnings`), porque 1 producto = 1 SKU es regla del MVP. +- Si el canal no contesta, responde **502**. + +La consulta corre dentro del request, con el timeout de 4 s de la cotización. Es el tercer caso sincrónico que `docs/guidelines/architecture.md` §7.4 anticipaba. Se toma por el mismo argumento de producto (el usuario está esperando saber si el vínculo es válido) y porque es una sola lectura sin efectos en el proveedor. El push que sigue va a un job. + +## Alternativas consideradas + +### Que cada empresa cargue sus credenciales desde el front + +Se implementó primero: una pantalla de Integraciones con el formulario armado desde la plantilla, `PUT`/`DELETE`/`test` en la API de la empresa y errores 422 por campo. Se descartó antes de mergear, por los motivos de la decisión: conectar un proveedor es parte del alta del cliente, y el usuario de la empresa no tiene por qué manejar datos técnicos que usa una sola vez. Es el camino natural si algún día cada empresa se da de alta sola. + +### Todo en `credentials` + +Era lo que había. Se descartó porque obliga a descifrar para mostrar un dominio, impide que la plantilla lo interpole sin tratarlo como secreto y mezcla lo que el formulario puede precargar con lo que no debe volver nunca. + +### Una app única de OneStock con authorization code (modelo de app pública) + +Es el modelo «de libro» de un SaaS: la empresa toca «Conectar con Shopify», autoriza y el sistema guarda un token por tienda. Se descartó **por ahora** por costo, no por diseño: exige el flujo de instalación (redirect, callback, validación del `state`) y la aprobación de Shopify para instalar la app en tiendas de otras organizaciones. Queda como trabajo futuro. El mecanismo de credenciales de nivel app es el que usaría. + +### Una clase Ruby por proveedor + +Rompe el principio de TESIS-31: la diferencia entre proveedores tiene que vivir en datos (plantilla, estrategia, campos), no en código. Con este diseño, Shopify no tiene una sola línea propia en `app/`. + +### Una FK por rol para cada llamada auxiliar + +Como `tracking_service` (`lookup_service_id`, `connection_test_service_id`...). Una migración y una validación por rol. Shopify solo ya necesitaba tres. + +### Validar la publicación en un job al vincular + +Respeta la regla de «llamadas externas en un job», pero el usuario se entera de un id equivocado recién cuando el sync falla, y para mostrarlo haría falta un estado nuevo en el vínculo. Para una sola lectura, el costo no se justifica. + +## Consecuencias + +- ✅ Shopify funciona contra una tienda de prueba real sin código específico: conexión, prueba, vínculo por id o por SKU y publicación del stock +- ✅ Cualquier empresa puede conectar su propia tienda: el diseño es multi-tenant de punta a punta, y nada del proveedor queda atado a la organización del equipo +- ✅ Las integraciones que ya existían (Mercado Libre, Tiendanube, Andreani, Correo) siguen igual: `bearer` es el default y las plantillas sin campos declarados conservan su alta +- ✅ El formulario de conexión del backoffice se arma solo con lo que declara la plantilla +- ✅ Una sola puerta para escribir secretos, detrás del login del backoffice +- ✅ Ningún secreto sale de la API, y un error del proveedor no lo filtra +- ⚠️ La empresa tiene que crear su app en el Dev Dashboard de Shopify: es más fricción que un botón «Conectar» +- ⚠️ Con un cliente real, la empresa tendría que hacerle llegar su client secret al equipo por algún canal. Con las dos empresas de demo no pasa, porque el equipo está de los dos lados +- ⚠️ La empresa depende del equipo para conectar, cambiar o desconectar una cuenta +- ⚠️ Una sola cuenta por proveedor y por empresa (índice único `company_id + service_id`) +- ⚠️ Nada impide todavía conectar la misma tienda a dos empresas: cada venta entraría en las dos. Hace falta una regla de unicidad sobre el setting que identifica la cuenta. _(Actualización, ADR-019: las ventas entrantes ya están habilitadas y la regla sigue pendiente.)_ +- ⚠️ El token se renueva con la fila bloqueada durante el pedido HTTP (hasta 10 s) +- ⚠️ La versión de la API de Shopify vive en la URI de la plantilla: hay que actualizarla desde el backoffice antes de que venza (unos 12 meses) + +## Referencias + +- [ADR-010](ADR-010-ingesta-de-ordenes-de-webhooks.md): la integración por plantilla que las plantillas de operación resuelven +- [ADR-014](ADR-014-pull-tracking-de-couriers.md): una plantilla que se ejecuta con la cuenta de otra +- [ADR-017](ADR-017-backoffice.md): el backoffice, su login y cómo muestra las credenciales +- `docs/guidelines/architecture.md` §7.4: las llamadas sincrónicas a proveedores +- Shopify: [client credentials grant](https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/client-credentials-grant), [`inventorySetQuantities`](https://shopify.dev/docs/api/admin-graphql/latest/mutations/inventorySetQuantities) diff --git a/docs/adr/ADR-019-ventas-entrantes-de-shopify.md b/docs/adr/ADR-019-ventas-entrantes-de-shopify.md new file mode 100644 index 00000000..aeafdb39 --- /dev/null +++ b/docs/adr/ADR-019-ventas-entrantes-de-shopify.md @@ -0,0 +1,122 @@ +# ADR-019: Ventas entrantes de Shopify: firma, mapeo y suscripción + +**Fecha:** 2026-10-01 +**Estado:** Aceptado + +--- + +## Contexto + +ADR-018 conectó Shopify en un sentido: el OMS publica su stock en la tienda. El criterio de aceptación del proyecto (E4b V2 §2.6) pide que la plataforma funcione de punta a punta, y E4a lo detalla: las ventas del canal entran solas al OMS y descuentan stock (RF-19), sin duplicarse (RF-20). ADR-018 dejó las ventas entrantes para este ADR. + +El motor de ingesta (TESIS-36, TESIS-43, ADR-010) ya existía y estaba probado con payloads inventados. Para recibir ventas reales faltaban cuatro cosas: + +- **La firma.** El gateway público (`POST /api/webhooks/integrations/:id`) aceptaba cualquier body, y la integración se identifica por un id secuencial. Expuesto a internet, cualquiera podía inyectar ventas falsas que descuentan stock. ADR-011 ya había dejado la verificación de firma pendiente. +- **El mapeo.** La plantilla `Shopify` no traducía `orders/create` (`response_mapper` vacío). +- **La ciudad y la provincia.** La ingesta no las copiaba, y el courier las necesita para cotizar el envío. +- **La suscripción.** Nadie le decía a la tienda adónde avisar. + +## Decisión + +### La plantilla declara cómo firma su proveedor + +`services.webhook_config` (jsonb, vacío por defecto): + +```json +{ "signature": "hmac_sha256_base64", + "signature_header": "X-Shopify-Hmac-SHA256", + "secret_key": "client_secret" } +``` + +Se lee como «el header trae el HMAC-SHA256 del body, en base64, calculado con la credencial `client_secret` de la integración». Los algoritmos son `hmac_sha256_base64` y `hmac_sha256_hex`. Una plantilla sin `signature` no se verifica: así siguen Mercado Libre (que no firma) y los couriers de los seeds. + +`Webhooks::VerifySignature` corre en el gateway **antes de persistir**, en los dos endpoints (integraciones y couriers): + +- Calcula sobre el **body crudo** (`request.raw_post`), nunca sobre el JSON re-serializado: un espacio o una tilde escapada de otra forma cambian el HMAC. +- Compara con `ActiveSupport::SecurityUtils.secure_compare`. +- Una firma inválida o un header ausente responden **401** y no se persiste nada: guardar el evento dejaría a cualquiera llenando la auditoría. El log dice el motivo, sin el payload ni la firma. +- **Falla cerrado:** si la plantilla exige firma y la integración no tiene el secreto, el evento se rechaza. Nunca se saltea la verificación. + +El secreto es **de la integración**, no de la plantilla, porque cada empresa conecta su propia app de Shopify (ADR-018) y Shopify firma con el client secret de la app que registró el webhook. Una venta firmada con la app de otra empresa no pasa. + +Un `webhook_config` a medias (sin header o sin secreto, o con un algoritmo desconocido) no se puede guardar: el gateway rechazaría todos los eventos de la plantilla. + +### El mapeo de `orders/create` vive en la plantilla madre + +`orders/create` trae la orden completa, así que alcanza con el `response_mapper` de la madre `Shopify` (ADR-010: la ingesta aplica el mapper directo al payload): + +| Ruta en `orders/create` | Clave interna | +|---|---| +| `id` | `external_order_id` | +| `financial_status` | `status` | +| `shipping_address.name` / `address1` / `zip` | `customer_name` / `customer_address` / `customer_zip_code` | +| `shipping_address.city` / `province` | `customer_city` / `customer_province` | +| `line_items[].variant_id` / `quantity` / `price` | `external_product_id` / `quantity` / `unit_price` | + +`external_product_id` es el id numérico de la variante, que es lo que el vínculo guarda desde ADR-018: la venta se resuelve contra los vínculos existentes sin cambios. El `response_value_mapper` traduce los estados de pago que no se llaman igual (`authorized` y `partially_paid` → `pending`, `voided` → `cancelled`); cualquier otro entra como pendiente. + +Una venta descuenta stock, y eso dispara el sync saliente (TESIS-35): el total del OMS vuelve a publicarse en Shopify, que también había descontado el suyo. El valor final en la tienda es el del OMS. + +### Ciudad y provincia en la ingesta + +`customer_city` y `customer_province` se suman a las claves que la ingesta reconoce. La provincia se valida contra `Order::PROVINCES` (TESIS-128), y cada canal la escribe a su manera (Shopify manda «Santiago Del Estero»). Se compara sin mayúsculas ni tildes. Si igual no matchea, la venta entra **sin provincia** en vez de fallar: es el mismo criterio que el estado desconocido, la venta es el dato que no se puede perder. Un alias de verdad («Capital Federal») lo traduce el `response_value_mapper` de la plantilla. + +### La suscripción la registra el sistema + +Dos plantillas hijas (ADR-018) más: + +| Hija | Operación | Para qué | +|---|---|---| +| `Shopify - Buscar webhook` | `webhook_lookup` | Saber si la tienda ya avisa a esta dirección | +| `Shopify - Webhook` | `webhook_subscription` | Crear la suscripción a `ORDERS_CREATE` | + +`Integrations::RegisterWebhook` busca primero y crea sólo si no existe: registrar dos veces no duplica nada. Las dos contestan `webhook_subscription_id`. + +La dirección es la del gateway de la integración sobre la URL pública de la API: `PUBLIC_WEBHOOK_BASE_URL` (`config.x.public_webhook_base_url`). No sale del host del request, porque el registro corre desde el backoffice o la consola y lo que importa es cómo nos ve el proveedor. En producción es el dominio HTTPS del servidor. En desarrollo es un túnel (`ngrok http 3001`), y `config.hosts` lo acepta. + +Se registra: + +- al guardar **Configure connection** en el backoffice, si la prueba de conexión pasa; +- con la acción **Register webhook**, para cuando cambió la URL pública o el registro falló; +- con `bin/rails "integrations:shopify:webhook[norte]"` en desarrollo. + +### Los reintentos no duplican la venta + +Shopify reintenta una entrega que no recibió un 2xx (8 veces en 4 horas). La idempotencia de órdenes (TESIS-44, índice único `company_id + external_order_id`) ya lo cubre: el segundo evento se persiste y se marca `processed` sin crear otra orden ni descontar stock. No se agrega dedupe por `X-Shopify-Webhook-Id`. + +## Alternativas consideradas + +### Un token opaco en la URL en lugar del id secuencial + +Evita que se enumeren las integraciones, pero no prueba el origen: la URL la ve cualquiera que lea un log. Para Shopify la firma alcanza. El token sigue siendo útil para los proveedores que no firman y queda como trabajo futuro. + +### Suscribir el webhook en el `shopify.app.toml` + +Es una sola dirección para todas las tiendas que instalan la app, y habría que rutear por `X-Shopify-Shop-Domain`. Va en contra de que cada integración tenga su gateway, y con una app por empresa no simplifica nada. + +### Registrarlo a mano desde GraphiQL + +Funciona para la demo, pero es un paso más que alguien tiene que acordarse de hacer, y la dirección cambia con cada túnel. Queda como plan B documentado. + +### Fallar la venta con una provincia desconocida + +La venta quedaría en la DLQ por un dato que no la invalida. Se prefiere registrarla y que la provincia falte. + +## Consecuencias + +- ✅ Una venta en la tienda entra sola al OMS con cliente, dirección, ciudad y provincia, descuenta stock y el stock nuevo vuelve a Shopify +- ✅ Un evento sin la firma de la app de la empresa no se persiste ni se procesa +- ✅ Todo sigue siendo plantilla: ni el mapeo, ni la firma, ni la suscripción tienen código de Shopify +- ✅ Registrar el webhook es idempotente +- ⚠️ Las plantillas que no firman (Mercado Libre, couriers) siguen aceptando cualquier evento en su gateway +- ⚠️ Sigue sin haber regla de unicidad sobre la tienda (ADR-018): si dos empresas conectaran la misma, cada venta entraría en las dos +- ⚠️ El gateway no mira `is_active`: una integración desactivada con la suscripción viva sigue recibiendo ventas +- ⚠️ Si la app no tiene acceso a los datos protegidos de cliente, Shopify manda la orden sin dirección de envío. La venta falla por `customer_name` y queda visible en la DLQ +- ⚠️ Cada URL pública nueva deja una suscripción más en la tienda. Las viejas fallan hasta que Shopify las da de baja + +## Referencias + +- [ADR-010](ADR-010-ingesta-de-ordenes-de-webhooks.md): la ingesta de ventas por webhook +- [ADR-011](ADR-011-push-tracking-de-couriers.md): dejó la verificación de firma pendiente +- [ADR-018](ADR-018-conexion-con-proveedores-reales.md): conexión, plantillas de operación y publicación del stock +- Shopify: [verificar webhooks](https://shopify.dev/docs/apps/build/webhooks/subscribe/https#step-2-validate-the-origin-of-your-webhook), [`webhookSubscriptionCreate`](https://shopify.dev/docs/api/admin-graphql/latest/mutations/webhookSubscriptionCreate), [`orders/create`](https://shopify.dev/docs/api/webhooks/latest?reference=toml#list-of-topics-orders/create) diff --git a/docs/guidelines/architecture.md b/docs/guidelines/architecture.md index 5240f0ee..9f516033 100644 --- a/docs/guidelines/architecture.md +++ b/docs/guidelines/architecture.md @@ -448,6 +448,18 @@ estaba. > la respuesta del courier se pierde después de que él generó la etiqueta, el > envío queda `pending` y un reintento genera una segunda etiqueta. +> **Tercer caso: vincular un producto con su publicación (TESIS-138).** +> `POST /api/v1/products/:product_id/mappings` le pregunta al canal, dentro del +> request, si la publicación existe (plantilla hija `product_lookup`) o la busca +> por SKU (`product_search`), con el mismo timeout de 4 s de la cotización. Es el +> caso que la nota de arriba anticipaba, y se toma igual por el mismo argumento +> de producto: el usuario está esperando saber si el vínculo es válido, y un id +> equivocado le publicaría el stock a otra publicación. Lo que lo distingue de +> una sincronización de fondo es que es **una sola lectura, sin efectos en el +> proveedor**: el push de stock que sigue al vínculo sí va a un job +> (`Catalog::SyncStockToChannelsJob`). Si aparece un cuarto caso, la regla de +> «encolar y notificar» deja de ser opcional. + Lo que acota el riesgo de sostener un hilo de Puma: - **Timeout propio y más corto.** El adaptador acepta los timeouts por parámetro; @@ -511,6 +523,17 @@ worker.stop Alternativas: WSL, Docker, o dejar la verificación de workers al CI/deploy (Linux). +⚠️ **En macOS los workers se caen al arrancar** (`Abort trap: 6`, con un volcado de memoria que menciona `performForkChildInitialize`). Solid Queue arranca sus procesos con `fork`, y macOS aborta el proceso hijo si toca clases de Objective-C que el padre dejó a medio inicializar. Pasa igual con `bin/jobs` y con los workers dentro de Puma. Se evita con esta variable de entorno: + +```bash +OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES PGGSSENCMODE=disable bin/jobs +OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES PGGSSENCMODE=disable SOLID_QUEUE_IN_PUMA=1 bin/rails server # todo en un proceso, como en el deploy +``` + +`PGGSSENCMODE=disable` evita la segunda forma del mismo problema: el hijo muere con `Segmentation fault` en `pg/connection.rb` (`connect_start`) al abrir su conexión a PostgreSQL, porque libpq intenta negociar cifrado GSS con las librerías de Kerberos del sistema. El supervisor vuelve a forkear y el log se llena de volcados sin que ningún job corra. En desarrollo no se usa GSS, así que apagarlo no cambia nada más. + +Conviene exportar las dos en el perfil de la terminal (`~/.zshrc`). En Linux (el deploy) no hacen falta. + ### 8.4 Tareas programadas `config/recurring.yml` declara los cronjobs (formato de recurring tasks de Solid Queue): diff --git a/lib/tasks/integrations.rake b/lib/tasks/integrations.rake new file mode 100644 index 00000000..828018db --- /dev/null +++ b/lib/tasks/integrations.rake @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +# Tareas de desarrollo para conectar una empresa de los seeds con una tienda de +# prueba de Shopify sin pegar el client_secret en la consola ni en un request. +# +# Lee del entorno, o de `.env` si no están seteadas (Rails no carga `.env`): +# SHOPIFY_<SLUG>_SHOP_DOMAIN ej. onestock-norte.myshopify.com +# SHOPIFY_<SLUG>_CLIENT_ID +# SHOPIFY_<SLUG>_CLIENT_SECRET +# +# bin/rails "integrations:shopify:connect[norte]" +# bin/rails "integrations:shopify:test[norte]" +# bin/rails "integrations:shopify:webhook[norte]" (con PUBLIC_WEBHOOK_BASE_URL) +# bin/rails "integrations:shopify:link[norte,NOR-001]" (busca por SKU) +# bin/rails "integrations:shopify:link[norte,NOR-001,4567890]" (por id de variante) +module ShopifyDevTasks + module_function + + def connect(slug) + abort 'Solo para desarrollo' unless Rails.env.development? + + company = company(slug) + integration = Integrations::UpsertIntegration.new( + company: company, service_id: shopify.id, + credentials: { 'client_id' => setting(slug, 'CLIENT_ID'), + 'client_secret' => setting(slug, 'CLIENT_SECRET') }, + settings: { 'shop_domain' => setting(slug, 'SHOP_DOMAIN') } + ).call + + puts "#{company.name} conectada a #{integration.settings['shop_domain']} " \ + "(integración ##{integration.id}). Credenciales guardadas cifradas." + end + + def test(slug) + company = company(slug) + Current.set(company_id: company.id) do + integration = company.company_integrations.find_by!(service: shopify) + result = Integrations::TestConnection.new(company_integration: integration).call + + puts "ok: #{result[:ok]} — #{result[:message]}" + puts "settings: #{integration.reload.settings}" + end + end + + # Suscribe la tienda a las ventas: la dirección sale de + # PUBLIC_WEBHOOK_BASE_URL (en desarrollo, la de un túnel). + def webhook(slug) + company = company(slug) + Current.set(company_id: company.id) do + integration = company.company_integrations.find_by!(service: shopify) + result = Integrations::RegisterWebhook.new(company_integration: integration).call + + puts "ok: #{result[:ok]} — #{result[:message]}" + end + end + + # Vincula el producto (si no lo estaba) y publica su stock en el acto, sólo + # en Shopify y sin esperar al worker, para ver el resultado en la tienda. + def link(slug, sku, variant_id) + company = company(slug) + Current.set(company_id: company.id) do + product = Product.find_by!(sku: sku) + integration = company.company_integrations.find_by!(service: shopify) + mapping = product.product_mappings.find_by(company_integration: integration) || + link_product(product, integration, variant_id) + Catalog::OutboundSync.new(product: product, company_integration: integration).call + + puts "#{sku} vinculado a la variante #{mapping.external_product_id} " \ + "(#{mapping.external_refs}). Stock publicado en Shopify: #{product.total_stock}" + end + end + + def link_product(product, integration, variant_id) + result = Catalog::LinkExternalProduct.new(product: product, company_integration: integration, + external_product_id: variant_id).call + result.warnings.each { |warning| puts "Aviso: #{warning}" } + result.mapping + end + + def shopify = Service.find_by!(service_name: 'Shopify') + + def company(slug) + Company.find_by(slug: slug) || abort("No existe la empresa con slug #{slug}") + end + + def setting(slug, name) + key = "SHOPIFY_#{slug.upcase}_#{name}" + ENV.fetch(key) { dotenv[key] }.presence || abort("Falta #{key} en el entorno o en .env") + end + + def dotenv + path = Rails.root.join('.env') + return {} unless path.exist? + + path.each_line.with_object({}) do |line, values| + key, value = line.strip.split('=', 2) + values[key] = value if key.present? && value && !key.start_with?('#') + end + end +end + +namespace :integrations do + namespace :shopify do + desc 'Conecta la empresa [slug] con su tienda de prueba de Shopify (solo desarrollo)' + task :connect, [:slug] => :environment do |_task, args| + ShopifyDevTasks.connect(args.fetch(:slug, 'norte')) + end + + desc 'Vincula el producto [sku] de la empresa [slug] con Shopify y publica su stock' + task :link, %i[slug sku variant_id] => :environment do |_task, args| + ShopifyDevTasks.link(args.fetch(:slug, 'norte'), args.fetch(:sku), args[:variant_id]) + end + + desc 'Registra el webhook de ventas de Shopify de la empresa [slug]' + task :webhook, [:slug] => :environment do |_task, args| + ShopifyDevTasks.webhook(args.fetch(:slug, 'norte')) + end + + desc 'Prueba la conexión de Shopify de la empresa [slug]' + task :test, [:slug] => :environment do |_task, args| + ShopifyDevTasks.test(args.fetch(:slug, 'norte')) + end + end +end diff --git a/spec/fixtures/shopify/orders_create.json b/spec/fixtures/shopify/orders_create.json new file mode 100644 index 00000000..4cdba31c --- /dev/null +++ b/spec/fixtures/shopify/orders_create.json @@ -0,0 +1,385 @@ +{ + "id": 18922194698473, + "name": "#1001", + "note": null, + "tags": "", + "test": true, + "email": "cliente@example.com", + "phone": null, + "token": "00000000000000000000000000000000", + "app_id": 580111, + "number": 1, + "company": null, + "refunds": [], + "returns": [], + "user_id": null, + "currency": "ARS", + "customer": { + "id": 31257143804137, + "note": null, + "email": "cliente@example.com", + "phone": null, + "state": "disabled", + "currency": "ARS", + "last_name": "Pérez", + "created_at": "2026-10-01T23:37:25-03:00", + "first_name": "Juana", + "tax_exempt": false, + "updated_at": "2026-10-01T23:37:27-03:00", + "tax_exemptions": [], + "verified_email": true, + "default_address": { + "id": 43769391481065, + "zip": "1131", + "city": "Berisso", + "name": "Juana Pérez", + "phone": null, + "company": null, + "country": "Argentina", + "default": true, + "address1": "Calle 12 1234", + "address2": null, + "province": "Buenos Aires", + "last_name": "Pérez", + "first_name": "Juana", + "customer_id": 31257143804137, + "country_code": "AR", + "country_name": "Argentina", + "province_code": "B" + }, + "admin_graphql_api_id": "gid://shopify/Customer/31257143804137", + "multipass_identifier": null + }, + "closed_at": null, + "confirmed": true, + "device_id": null, + "po_number": null, + "reference": null, + "tax_lines": [], + "total_tax": "0.00", + "browser_ip": "203.0.113.10", + "cart_token": "fixture-cart-token", + "created_at": "2026-10-01T23:37:26-03:00", + "line_items": [ + { + "id": 50752648773865, + "sku": "NOR-002", + "name": "Notebook Lenovo ThinkPad", + "grams": 1500, + "price": "1450000.00", + "title": "Notebook Lenovo ThinkPad", + "duties": [], + "vendor": "OneStock Demo", + "taxable": true, + "quantity": 1, + "gift_card": false, + "price_set": { + "shop_money": { + "amount": "1450000.00", + "currency_code": "ARS" + }, + "presentment_money": { + "amount": "1450000.00", + "currency_code": "ARS" + } + }, + "tax_lines": [], + "product_id": 15394248065257, + "properties": [], + "variant_id": 67579271348457, + "variant_title": null, + "product_exists": true, + "total_discount": "0.00", + "current_quantity": 1, + "attributed_staffs": [], + "requires_shipping": true, + "fulfillment_status": null, + "total_discount_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "ARS" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "ARS" + } + }, + "fulfillment_service": "manual", + "admin_graphql_api_id": "gid://shopify/LineItem/50752648773865", + "discount_allocations": [], + "fulfillable_quantity": 1, + "sales_line_item_group_id": null, + "variant_inventory_management": "shopify" + } + ], + "source_url": null, + "tax_exempt": false, + "updated_at": "2026-10-01T23:37:29-03:00", + "location_id": null, + "source_name": "web", + "total_price": "1450000.00", + "cancelled_at": null, + "fulfillments": [], + "landing_site": "/password", + "order_number": 1001, + "processed_at": "2026-10-01T23:37:25-03:00", + "total_weight": 1500, + "cancel_reason": null, + "contact_email": "cliente@example.com", + "payment_terms": null, + "total_tax_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "ARS" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "ARS" + } + }, + "checkout_token": "00000000000000000000000000000000", + "client_details": { + "browser_ip": "203.0.113.10", + "user_agent": "Mozilla/5.0 (fixture)", + "session_hash": null, + "browser_width": null, + "browser_height": null, + "accept_language": null + }, + "discount_codes": [], + "referring_site": null, + "shipping_lines": [ + { + "id": 18364067873001, + "code": "Estándar", + "phone": null, + "price": "0.00", + "title": "Estándar", + "source": "shopify", + "price_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "ARS" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "ARS" + } + }, + "tax_lines": [], + "is_removed": false, + "discounted_price": "0.00", + "carrier_identifier": null, + "discount_allocations": [], + "discounted_price_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "ARS" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "ARS" + } + }, + "current_discounted_price_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "ARS" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "ARS" + } + }, + "requested_fulfillment_service_id": null + } + ], + "subtotal_price": "1450000.00", + "taxes_included": false, + "billing_address": { + "zip": "1131", + "city": "Berisso", + "name": "Juana Pérez", + "phone": null, + "company": null, + "country": "Argentina", + "address1": "Calle 12 1234", + "address2": null, + "latitude": -34.8696443, + "province": "Buenos Aires", + "last_name": "Pérez", + "longitude": -57.88185569999999, + "first_name": "Juana", + "country_code": "AR", + "province_code": "B" + }, + "customer_locale": "en-AR", + "duties_included": false, + "estimated_taxes": false, + "note_attributes": [], + "total_discounts": "0.00", + "total_price_set": { + "shop_money": { + "amount": "1450000.00", + "currency_code": "ARS" + }, + "presentment_money": { + "amount": "1450000.00", + "currency_code": "ARS" + } + }, + "financial_status": "paid", + "landing_site_ref": null, + "line_item_groups": [], + "order_status_url": "https://onestock-norte.myshopify.com/orders/fixture/authenticate?key=fixture", + "shipping_address": { + "zip": "1131", + "city": "Berisso", + "name": "Juana Pérez", + "phone": null, + "company": null, + "country": "Argentina", + "address1": "Calle 12 1234", + "address2": null, + "latitude": -34.8696443, + "province": "Buenos Aires", + "last_name": "Pérez", + "longitude": -57.88185569999999, + "first_name": "Juana", + "country_code": "AR", + "province_code": "B" + }, + "current_total_tax": "0.00", + "source_identifier": null, + "total_outstanding": "0.00", + "fulfillment_status": null, + "subtotal_price_set": { + "shop_money": { + "amount": "1450000.00", + "currency_code": "ARS" + }, + "presentment_money": { + "amount": "1450000.00", + "currency_code": "ARS" + } + }, + "total_tip_received": "0.00", + "confirmation_number": "IGXUR3QVH", + "current_total_price": "1450000.00", + "total_discounts_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "ARS" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "ARS" + } + }, + "admin_graphql_api_id": "gid://shopify/Order/18922194698473", + "presentment_currency": "ARS", + "current_total_tax_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "ARS" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "ARS" + } + }, + "discount_applications": [], + "payment_gateway_names": [ + "bogus" + ], + "current_subtotal_price": "1450000.00", + "total_line_items_price": "1450000.00", + "buyer_accepts_marketing": false, + "current_total_discounts": "0.00", + "current_total_price_set": { + "shop_money": { + "amount": "1450000.00", + "currency_code": "ARS" + }, + "presentment_money": { + "amount": "1450000.00", + "currency_code": "ARS" + } + }, + "current_total_duties_set": null, + "total_shipping_price_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "ARS" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "ARS" + } + }, + "merchant_of_record_app_id": null, + "original_total_duties_set": null, + "current_shipping_price_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "ARS" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "ARS" + } + }, + "current_subtotal_price_set": { + "shop_money": { + "amount": "1450000.00", + "currency_code": "ARS" + }, + "presentment_money": { + "amount": "1450000.00", + "currency_code": "ARS" + } + }, + "total_line_items_price_set": { + "shop_money": { + "amount": "1450000.00", + "currency_code": "ARS" + }, + "presentment_money": { + "amount": "1450000.00", + "currency_code": "ARS" + } + }, + "current_total_discounts_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "ARS" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "ARS" + } + }, + "merchant_business_entity_id": "37332320489", + "current_total_additional_fees_set": null, + "original_total_additional_fees_set": null, + "total_cash_rounding_refund_adjustment_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "ARS" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "ARS" + } + }, + "total_cash_rounding_payment_adjustment_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "ARS" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "ARS" + } + } +} diff --git a/spec/models/company_integration_spec.rb b/spec/models/company_integration_spec.rb index 009f2e79..f089b1bd 100644 --- a/spec/models/company_integration_spec.rb +++ b/spec/models/company_integration_spec.rb @@ -40,4 +40,13 @@ duplicate = described_class.new(company: company, service: service) expect { duplicate.save!(validate: false) }.to raise_error(ActiveRecord::RecordNotUnique) end + + # Una hija corre con la cuenta de su madre: el backoffice no la puede conectar sola. + it 'rejects an operation template of another service' do + child = Service.create!(service_name: "#{service.service_name} - Conexión", type: 'ecommerce', + uri: 'https://example.com/me', http_method: 'GET', + parent_service: service, operation: 'connection_test') + + expect(described_class.new(company: company, service: child)).not_to be_valid + end end diff --git a/spec/models/service_spec.rb b/spec/models/service_spec.rb index 10f427cb..de74df48 100644 --- a/spec/models/service_spec.rb +++ b/spec/models/service_spec.rb @@ -33,6 +33,36 @@ expect(described_class.new(type: 'ecommerce')).to be_an_instance_of(described_class) end + describe 'webhook_config' do + def signed(config) + service.webhook_config = config + service + end + + it 'is valid empty: the provider does not sign' do + expect(signed({})).to be_valid + end + + it 'is valid with the algorithm, the header and the secret' do + expect(signed('signature' => 'hmac_sha256_base64', 'signature_header' => 'X-Sig', + 'secret_key' => 'client_secret')).to be_valid + end + + it 'is invalid with an algorithm the gateway cannot verify' do + expect(signed('signature' => 'rsa', 'signature_header' => 'X-Sig', + 'secret_key' => 'client_secret')).not_to be_valid + end + + it 'is invalid without the header or the secret' do + expect(signed('signature' => 'hmac_sha256_hex')).not_to be_valid + end + + it 'tells whether the gateway has to verify the signature', :aggregate_failures do + expect(signed({})).not_to be_signs_webhooks + expect(signed('signature' => 'hmac_sha256_hex')).to be_signs_webhooks + end + end + describe '#ecommerce?' do it 'is true for an ecommerce service' do service.type = 'ecommerce' @@ -345,4 +375,86 @@ def other_dispatcher(**attrs) expect(service.request_mapper).to eq('a' => 'b') end end + + describe 'connection config (TESIS-138)' do + it 'authenticates with bearer and speaks JSON by default', :aggregate_failures do + expect(service.auth_strategy).to eq('bearer') + expect(service.request_format).to eq('json') + end + + it 'rejects an unknown auth strategy' do + service.auth_strategy = 'magic' + expect(service).not_to be_valid + end + + it 'rejects an unknown request format' do + service.request_format = 'soap' + expect(service).not_to be_valid + end + + it 'requires every declared field to have a key' do + service.setting_fields = [{ 'label' => 'Dominio' }] + expect(service).not_to be_valid + end + + it 'lists the setting keys it asks the company for' do + service.setting_fields = [{ 'key' => 'shop_domain' }, { 'key' => 'location_id' }] + expect(service.setting_keys).to eq(%w[shop_domain location_id]) + end + end + + describe 'operation templates' do + before { service.save! } + + def child(attributes = {}) + described_class.new({ service_name: 'ML - Conexión', type: 'ecommerce', + uri: 'https://api.ml.com/users/me', http_method: 'GET', + parent_service: service, operation: 'connection_test' } + .merge(attributes)) + end + + it 'finds the child that performs an operation' do + test_template = child.tap(&:save!) + expect(service.template_for(:connection_test)).to eq(test_template) + end + + it 'returns nil when the provider does not declare the operation' do + expect(service.template_for(:connection_test)).to be_nil + end + + it 'requires a child to say which operation it performs' do + expect(child(operation: nil)).not_to be_valid + end + + it 'allows a single child per operation' do + child.save! + expect(child(service_name: 'ML - Conexión 2')).not_to be_valid + end + + it 'keeps children out of the connectable services' do + child.save! + expect(described_class.connectable).to contain_exactly(service) + end + end + + describe '#stock_template' do + before { service.save! } + + it 'is the template itself when it maps the quantity' do + service.update!(request_mapper: { 'stock' => 'available_quantity' }) + expect(service.stock_template).to eq(service) + end + + it 'is its stock child otherwise' do + stock = described_class.create!(service_name: 'ML - Stock', type: 'ecommerce', + uri: 'https://api.ml.com/items/:external_id', + http_method: 'PUT', parent_service: service, + operation: 'stock') + expect(service.stock_template).to eq(stock) + end + + it 'is nil when the provider cannot publish stock' do + expect(service.stock_template).to be_nil + end + end end diff --git a/spec/policies/company_integration_policy_spec.rb b/spec/policies/company_integration_policy_spec.rb deleted file mode 100644 index b1281f2e..00000000 --- a/spec/policies/company_integration_policy_spec.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe CompanyIntegrationPolicy, type: :policy do - subject(:policy) { described_class.new(user, CompanyIntegration) } - - let(:company) do - Company.create!(name: 'Tenant A', tax_id: '30-11111111-1', features: { 'integrations' => true }) - end - let(:user) { User.create!(email: 'a@example.com', password: 'password123', company: company) } - - it 'lets a company with the integrations feature configure them' do - expect(policy.update?).to be(true) - end - - context 'when the company does not have the feature' do - before { company.update!(features: { 'integrations' => false }) } - - it 'denies configuring them' do - expect(policy.update?).to be(false) - end - end - - # Igual que el front (`features?.[feature] === true`): un "true" string es - # apagado en los dos lados. - context 'when the flag is not a real boolean' do - before { company.update!(features: { 'integrations' => 'true' }) } - - it 'denies configuring them' do - expect(policy.update?).to be(false) - end - end - - context 'without a user' do - let(:user) { nil } - - it 'denies configuring them' do - expect(policy.update?).to be(false) - end - end -end diff --git a/spec/poros/catalog/link_external_product_spec.rb b/spec/poros/catalog/link_external_product_spec.rb new file mode 100644 index 00000000..7cefe122 --- /dev/null +++ b/spec/poros/catalog/link_external_product_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Catalog::LinkExternalProduct, type: :poro do + let(:company) { Company.create!(name: 'Acme', tax_id: '20-12345678-9') } + let(:product) { Product.create!(company: company, sku: 'NOR-001', name: 'Celular') } + let(:shop) do + Service.create!(service_name: 'Shop', type: 'ecommerce', http_method: 'POST', + uri: 'https://shop.test/graphql') + end + let(:integration) do + CompanyIntegration.create!(company: company, service: shop, + credentials: { 'access_token' => 'TOKEN' }) + end + + def add_child(operation, response_mapper) + Service.create!(service_name: "Shop - #{operation}", type: 'ecommerce', http_method: 'POST', + uri: "https://shop.test/#{operation}", parent_service: shop, + operation: operation, request_mapper: { 'id' => 'external_id', 'q' => 'sku' }, + response_mapper: response_mapper) + end + + def stub_child(operation, body) + stub_request(:post, "https://shop.test/#{operation}") + .to_return(status: 200, headers: { 'Content-Type' => 'application/json' }, body: body.to_json) + end + + def link(external_product_id: nil) + described_class.new(product: product, company_integration: integration, + external_product_id: external_product_id).call + end + + context 'when the provider cannot look products up' do + it 'links the external id as it came' do + expect(link(external_product_id: 'MLA-1').mapping.external_product_id).to eq('MLA-1') + end + + it 'pushes the stock of the product only to the channel it was linked to' do + expect { link(external_product_id: 'MLA-1') } + .to have_enqueued_job(Catalog::SyncStockToChannelsJob) + .with(product.id, company.id, integration.id) + end + + it 'requires the external id, because it cannot search by SKU' do + expect { link }.to raise_error(Catalog::ExternalProductNotFoundError, + 'external_product_id is required') + end + end + + context 'when the provider looks a variant up by id' do + before do + add_child('product_lookup', { 'variant.sku' => 'external_sku', + 'variant.inventory' => 'inventory_item_id' }) + end + + it 'stores the channel identifiers it needs to publish the stock' do + stub_child('product_lookup', variant: { sku: 'NOR-001', inventory: 'gid://inv/9' }) + + expect(link(external_product_id: '111').mapping.external_refs) + .to eq('inventory_item_id' => 'gid://inv/9') + end + + it 'links it but warns when the SKU of the channel is another one' do + stub_child('product_lookup', variant: { sku: 'OTRO-9', inventory: 'gid://inv/9' }) + + expect(link(external_product_id: '111').warnings) + .to eq(['The Shop SKU is OTRO-9, the product SKU is NOR-001']) + end + + it 'refuses a variant that does not exist in the store', :aggregate_failures do + stub_child('product_lookup', variant: nil) + + expect { link(external_product_id: '999') } + .to raise_error(Catalog::ExternalProductNotFoundError, '999 does not exist in Shop') + expect(ProductMapping.count).to eq(0) + end + end + + context 'when the provider searches variants by SKU' do + before do + add_child('product_search', { 'nodes.0.id' => 'external_product_id', + 'nodes.0.sku' => 'external_sku', + 'nodes.0.inventory' => 'inventory_item_id', + 'nodes.1.id' => 'ambiguous_match' }) + end + + it 'links the only variant with the SKU of the product', :aggregate_failures do + stub_child('product_search', nodes: [{ id: '111', sku: 'nor-001', inventory: 'gid://inv/9' }]) + mapping = link.mapping + + expect(mapping.external_product_id).to eq('111') + expect(mapping.external_refs).to eq('inventory_item_id' => 'gid://inv/9') + end + + it 'refuses a result whose SKU is only similar' do + stub_child('product_search', nodes: [{ id: '111', sku: 'NOR-0010' }]) + + expect { link }.to raise_error(Catalog::ExternalProductNotFoundError, /has the SKU NOR-001/) + end + + it 'refuses to choose between two variants with the same SKU' do + stub_child('product_search', nodes: [{ id: '111', sku: 'NOR-001' }, { id: '222', sku: 'NOR-001' }]) + + expect { link }.to raise_error(Catalog::ExternalProductNotFoundError, /more than one/) + end + end +end diff --git a/spec/poros/catalog/outbound_sync_spec.rb b/spec/poros/catalog/outbound_sync_spec.rb index 9e9f01b1..22656e64 100644 --- a/spec/poros/catalog/outbound_sync_spec.rb +++ b/spec/poros/catalog/outbound_sync_spec.rb @@ -164,4 +164,63 @@ def stub_channel(host, external_id, status: 200) expect(a_request(:put, 'https://api.tn.test/items/TN-2')).to have_been_made.once end end + + # TESIS-138: sólo recibe stock una plantilla que sabe publicarlo. + context 'when the channel template does not publish stock' do + before do + integration = CompanyIntegration.create!( + company: company, credentials: { 'access_token' => 'TOKEN' }, + service: Service.create!(service_name: 'ML Orders', type: 'ecommerce', http_method: 'GET', + uri: 'https://api.ml.test/orders') + ) + ProductMapping.create!(product: product, company_integration: integration, + external_product_id: 'MLA-1') + end + + it 'does not send it anything' do + sync.call + expect(a_request(:any, //)).not_to have_been_made + end + end + + context 'when the channel publishes stock with a child template and external refs' do + before do + parent = Service.create!(service_name: 'Shop', type: 'ecommerce', http_method: 'POST', + uri: 'https://shop.test/orders') + Service.create!(service_name: 'Shop - Stock', type: 'ecommerce', http_method: 'POST', + uri: 'https://shop.test/inventory', parent_service: parent, + operation: 'stock', + request_mapper: { 'item' => 'inventory_item_id', 'qty' => 'available_quantity', + 'key' => 'idempotency_key' }) + integration = CompanyIntegration.create!(company: company, service: parent, + credentials: { 'access_token' => 'TOKEN' }) + ProductMapping.create!(product: product, company_integration: integration, + external_product_id: '111', external_refs: { 'inventory_item_id' => 'INV-9' }) + stub_request(:post, 'https://shop.test/inventory').to_return(status: 200, body: '{}') + end + + it 'sends the refs of the mapping and a fresh idempotency key to the stock template' do + sync.call + expect(a_request(:post, 'https://shop.test/inventory').with do |request| + body = JSON.parse(request.body) + body['item'] == 'INV-9' && body['qty'] == 10 && body['key'].to_s.match?(/\A\h{8}-/) + end).to have_been_made.once + end + end + + context 'when scoped to one integration' do + subject(:sync) { described_class.new(product: product, company_integration: shop.company_integration) } + + let(:shop) { publish_on('Shopify', 'shop.test', 'SH-1') } + + before do + publish_on('Mercado Libre', 'api.ml.test', 'MLA-1') + stub_channel('shop.test', 'SH-1') + end + + it 'publishes only on that channel' do + sync.call + expect(a_request(:put, /api\.ml\.test/)).not_to have_been_made + end + end end diff --git a/spec/poros/integrations/apply_declared_fields_spec.rb b/spec/poros/integrations/apply_declared_fields_spec.rb new file mode 100644 index 00000000..0ea92015 --- /dev/null +++ b/spec/poros/integrations/apply_declared_fields_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Integrations::ApplyDeclaredFields, type: :poro do + let(:service) do + Service.create!( + service_name: 'Shopify', type: 'ecommerce', http_method: 'POST', + uri: 'https://:shop_domain/admin/api/2026-07/graphql.json', + credential_fields: [{ 'key' => 'client_id', 'required' => true }, + { 'key' => 'client_secret', 'required' => true }], + setting_fields: [{ 'key' => 'shop_domain', 'required' => true, + 'format' => '\A[a-z0-9][a-z0-9-]*\.myshopify\.com\z' }, + { 'key' => 'location_id', 'required' => false }] + ) + end + let(:integration) { CompanyIntegration.new(service: service) } + let(:complete) do + { credentials: { 'client_id' => 'CID', 'client_secret' => 'shpss_S' }, + settings: { 'shop_domain' => 'demo.myshopify.com' } } + end + + def apply(credentials: {}, settings: {}) + described_class.new(service: service, integration: integration, + credentials: credentials, settings: settings).call + end + + def errors_for(**) + apply(**) + rescue Integrations::InvalidIntegrationError => e + e.fields + end + + it 'returns the declared credentials and settings when they are complete' do + expect(apply(**complete)) + .to eq([{ 'client_id' => 'CID', 'client_secret' => 'shpss_S' }, + { 'shop_domain' => 'demo.myshopify.com' }]) + end + + it 'reports each missing required field with its scope' do + expect(errors_for(credentials: { 'client_id' => 'CID' })) + .to eq('credentials.client_secret' => ['required'], 'settings.shop_domain' => ['required']) + end + + it 'reports a value that does not match the declared format' do + expect(errors_for(**complete, settings: { 'shop_domain' => 'demo.com' })) + .to eq('settings.shop_domain' => ['invalid_format']) + end + + it 'refuses a field the template does not declare' do + expect(errors_for(**complete, credentials: complete[:credentials].merge('pwd' => 'x'))) + .to eq('credentials.pwd' => ['unknown']) + end + + context 'when the integration already has its credentials' do + before do + integration.credentials = { 'client_id' => 'CID', 'client_secret' => 'shpss_S', + 'access_token' => 'TOKEN', 'token_expires_at' => 'later' } + integration.settings = { 'shop_domain' => 'demo.myshopify.com', 'location_id' => 'L1' } + end + + it 'keeps a secret that arrives blank, and the token obtained with it' do + credentials, = apply(credentials: { 'client_secret' => '' }) + expect(credentials).to include('client_secret' => 'shpss_S', 'access_token' => 'TOKEN') + end + + it 'drops the cached token when a credential changes' do + credentials, = apply(credentials: { 'client_secret' => 'shpss_NEW' }) + expect(credentials).to eq('client_id' => 'CID', 'client_secret' => 'shpss_NEW') + end + + it 'clears a setting that arrives blank' do + _, settings = apply(settings: { 'location_id' => '' }) + expect(settings).to eq('shop_domain' => 'demo.myshopify.com') + end + end +end diff --git a/spec/poros/integrations/build_external_payload_spec.rb b/spec/poros/integrations/build_external_payload_spec.rb index 040a05c2..b4d46218 100644 --- a/spec/poros/integrations/build_external_payload_spec.rb +++ b/spec/poros/integrations/build_external_payload_spec.rb @@ -65,4 +65,36 @@ expect(result).to eq('tags' => ['urgente']) end end + + # TESIS-138: datos de la cuenta y valores armados a partir de variables. + describe 'settings and templates' do + let(:graphql_service) do + Service.create!(service_name: 'Shopify', type: 'ecommerce', http_method: 'POST', + uri: 'https://demo.myshopify.com/graphql.json', + request_mapper: { 'locationId' => 'settings.location_id', + 'id' => 'gid://shopify/ProductVariant/{{external_id}}', + 'quantity' => 'available_quantity' }) + end + + def build(payload, settings = {}) + described_class.new(service: graphql_service, payload: payload, settings: settings).call + end + + it 'reads a settings.<key> value from the account settings, not from the payload' do + result = build({ location_id: 'from-payload' }, { 'location_id' => 'gid://shopify/Location/7' }) + expect(result['locationId']).to eq('gid://shopify/Location/7') + end + + it 'builds a value from a template with payload variables' do + expect(build({ external_id: 42 })['id']).to eq('gid://shopify/ProductVariant/42') + end + + it 'leaves out a template with a missing variable instead of sending it half-built' do + expect(build({ available_quantity: 3 })).to eq('quantity' => 3) + end + + it 'leaves out a settings value the account does not have' do + expect(build({ available_quantity: 3 })).not_to have_key('locationId') + end + end end diff --git a/spec/poros/integrations/ensure_access_token_spec.rb b/spec/poros/integrations/ensure_access_token_spec.rb new file mode 100644 index 00000000..3aa06709 --- /dev/null +++ b/spec/poros/integrations/ensure_access_token_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Integrations::EnsureAccessToken, type: :poro do + let(:service) do + Service.create!(service_name: 'Shopify', type: 'ecommerce', http_method: 'POST', + uri: 'https://:shop_domain/admin/api/2026-07/graphql.json', + auth_strategy: 'oauth_client_credentials', + auth_config: { 'token_url' => 'https://:shop_domain/admin/oauth/access_token' }) + end + let(:credentials) { { 'client_id' => 'CLIENT-ID', 'client_secret' => 'shpss_SECRET' } } + let(:integration) do + CompanyIntegration.create!(company: Company.create!(name: 'Acme', tax_id: '20-12345678-9'), + service: service, credentials: credentials, + settings: { 'shop_domain' => 'demo.myshopify.com' }) + end + let!(:token_request) do + stub_request(:post, token_url) + .with(body: { 'grant_type' => 'client_credentials', 'client_id' => 'CLIENT-ID', + 'client_secret' => 'shpss_SECRET' }) + .to_return(status: 200, headers: { 'Content-Type' => 'application/json' }, + body: { access_token: 'NEW-TOKEN', scope: 'read_orders', expires_in: 86_399 }.to_json) + end + + def token_url = 'https://demo.myshopify.com/admin/oauth/access_token' + + def ensure_token(force: false) + described_class.new(company_integration: integration, force: force).call + end + + def with_cached_token(token, expires_in:) + integration.update!(credentials: credentials.merge( + 'access_token' => token, 'token_expires_at' => expires_in.from_now.iso8601 + )) + end + + it 'asks the token endpoint of the account with the client credentials of the company' do + expect(ensure_token).to eq('NEW-TOKEN') + end + + it 'caches the token and its expiry in the encrypted credentials', :aggregate_failures do + ensure_token + stored = integration.reload.credentials + expect(stored['access_token']).to eq('NEW-TOKEN') + expect(Time.zone.parse(stored['token_expires_at'])).to be_within(1.minute).of(1.day.from_now) + end + + it 'reuses the cached token while it is fresh', :aggregate_failures do + with_cached_token('CACHED', expires_in: 1.hour) + + expect(ensure_token).to eq('CACHED') + expect(token_request).not_to have_been_requested + end + + it 'renews the token shortly before it expires, not after' do + with_cached_token('ALMOST-EXPIRED', expires_in: 2.minutes) + + expect(ensure_token).to eq('NEW-TOKEN') + end + + it 'renews a fresh token when forced (the provider rejected it)' do + with_cached_token('REVOKED', expires_in: 1.hour) + + expect(ensure_token(force: true)).to eq('NEW-TOKEN') + end + + # Dos workers ven el token vencido; el que espera el lock no pide otro si el + # primero ya lo renovó. + it 'does not ask again when another worker renewed it while waiting for the lock', + :aggregate_failures do + with_cached_token('EXPIRED', expires_in: -1.minute) + stale_copy = CompanyIntegration.find(integration.id) + with_cached_token('RENEWED-BY-OTHER', expires_in: 1.hour) + + expect(described_class.new(company_integration: stale_copy).call).to eq('RENEWED-BY-OTHER') + expect(token_request).not_to have_been_requested + end + + context 'when the company did not load its client secret' do + let(:credentials) { { 'client_id' => 'CLIENT-ID' } } + + it 'fails with a clear message before calling the provider', :aggregate_failures do + expect { ensure_token } + .to raise_error(Integrations::AdapterExecutionError, /missing the credential client_secret/) + expect(token_request).not_to have_been_requested + end + end + + context 'when the provider rejects the credentials' do + before do + stub_request(:post, token_url).to_return( + status: 400, body: '<html><title>400 - Oauth error app_not_installed' + ) + end + + it 'says why without exposing the request or the response body', :aggregate_failures do + expect { ensure_token }.to raise_error(Integrations::AdapterExecutionError) do |error| + expect(error.message).to eq('Shopify token request responded with HTTP 400 (app_not_installed)') + expect(error.response_body).to be_nil + end + end + end +end diff --git a/spec/poros/integrations/http_adapter_spec.rb b/spec/poros/integrations/http_adapter_spec.rb index beb30912..53637b2d 100644 --- a/spec/poros/integrations/http_adapter_spec.rb +++ b/spec/poros/integrations/http_adapter_spec.rb @@ -224,4 +224,111 @@ def fetch_raw .with(body: '') end end + + # Proveedor real (TESIS-138): la plantilla se autentica con OAuth client + # credentials, habla GraphQL y completa la URI con los settings de la cuenta. + describe 'OAuth client credentials over GraphQL' do + let(:shopify) do + Service.create!( + service_name: 'Shopify', type: 'ecommerce', http_method: 'POST', + uri: 'https://:shop_domain/admin/api/2026-07/graphql.json', + request_format: 'graphql', auth_strategy: 'oauth_client_credentials', + auth_config: { 'token_url' => 'https://:shop_domain/admin/oauth/access_token', + 'token_header' => 'X-Shopify-Access-Token', 'token_prefix' => '' }, + body_template: 'query Node($id: ID!) { node(id: $id) { id } }', + request_mapper: { 'id' => 'node_gid' }, + response_mapper: { 'data.node.id' => 'node_id' }, + error_path: 'data.node.userErrors' + ) + end + let(:shop_integration) do + CompanyIntegration.create!( + company: company, service: shopify, settings: { 'shop_domain' => 'demo.myshopify.com' }, + credentials: { 'client_id' => 'CLIENT-ID', 'client_secret' => 'shpss_SECRET', + 'access_token' => 'CACHED-TOKEN', + 'token_expires_at' => 1.hour.from_now.iso8601 } + ) + end + + def graphql_url = 'https://demo.myshopify.com/admin/api/2026-07/graphql.json' + + def token_url = 'https://demo.myshopify.com/admin/oauth/access_token' + + def run_graphql + described_class.new(company_integration: shop_integration, + payload: { node_gid: 'gid://shopify/Node/1' }).call + end + + def graphql_response(body) + { status: 200, headers: { 'Content-Type' => 'application/json' }, body: body.to_json } + end + + it 'sends the document with the mapped payload as variables to the URI of the account' do + stub_request(:post, graphql_url).to_return(graphql_response(data: { node: { id: 'N1' } })) + run_graphql + expect(WebMock).to have_requested(:post, graphql_url).with(body: { + query: shopify.body_template, variables: { id: 'gid://shopify/Node/1' } + }.to_json) + end + + it 'returns the mapped response' do + stub_request(:post, graphql_url).to_return(graphql_response(data: { node: { id: 'N1' } })) + expect(run_graphql).to eq('node_id' => 'N1') + end + + it 'authenticates with the token in the declared header and never sends the secrets' do + stub_request(:post, graphql_url).to_return(graphql_response(data: {})) + run_graphql + expect(WebMock).to(have_requested(:post, graphql_url).with { |request| token_only?(request) }) + end + + def token_only?(request) + request.headers['X-Shopify-Access-Token'] == 'CACHED-TOKEN' && + request.headers['Authorization'].nil? && + request.headers.keys.none? { |key| key.downcase.include?('client') } + end + + context 'when the provider rejects the cached token' do + before do + stub_request(:post, token_url) + .to_return(graphql_response(access_token: 'NEW-TOKEN', expires_in: 86_399)) + stub_request(:post, graphql_url) + .with(headers: { 'X-Shopify-Access-Token' => 'CACHED-TOKEN' }).to_return(status: 401) + stub_request(:post, graphql_url).with(headers: { 'X-Shopify-Access-Token' => 'NEW-TOKEN' }) + .to_return(graphql_response(data: { node: { id: 'N1' } })) + end + + it 'renews the token and retries with it' do + expect(run_graphql).to eq('node_id' => 'N1') + end + + it 'asks for a new token only once' do + run_graphql + expect(WebMock).to have_requested(:post, token_url).once + end + end + + it 'treats top-level GraphQL errors as a failure even with HTTP 200' do + stub_request(:post, graphql_url) + .to_return(graphql_response(errors: [{ message: 'Throttled' }])) + + expect { run_graphql } + .to raise_error(Integrations::AdapterExecutionError, /returned errors: Throttled/) + end + + it 'treats the errors listed in the declared error_path as a failure' do + stub_request(:post, graphql_url).to_return( + graphql_response(data: { node: { userErrors: [{ field: 'id', message: 'Not found' }] } }) + ) + + expect { run_graphql }.to raise_error(Integrations::AdapterExecutionError, /Not found/) + end + + it 'accepts an empty error list as a success' do + stub_request(:post, graphql_url) + .to_return(graphql_response(data: { node: { id: 'N1', userErrors: [] } })) + + expect(run_graphql).to eq('node_id' => 'N1') + end + end end diff --git a/spec/poros/integrations/register_webhook_spec.rb b/spec/poros/integrations/register_webhook_spec.rb new file mode 100644 index 00000000..f8d85bbd --- /dev/null +++ b/spec/poros/integrations/register_webhook_spec.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Integrations::RegisterWebhook, type: :poro do + let(:company) { Company.create!(name: 'Acme', tax_id: '20-12345678-9') } + let(:shopify) do + Service.create!(service_name: 'Shopify', type: 'ecommerce', http_method: 'POST', + uri: 'https://:shop_domain/admin/api/2026-07/graphql.json', + request_format: 'graphql') + end + let(:integration) do + CompanyIntegration.create!(company: company, service: shopify, + settings: { 'shop_domain' => 'demo.myshopify.com' }, + credentials: { 'access_token' => 'TOKEN' }) + end + + def base_url = 'https://api.onestock.test' + + def graphql_url = 'https://demo.myshopify.com/admin/api/2026-07/graphql.json' + + def webhook_url = "#{base_url}/api/webhooks/integrations/#{integration.id}" + + def child(operation, body_template, response_mapper, error_path: nil) + Service.create!(service_name: "Shopify - #{operation}", type: 'ecommerce', http_method: 'POST', + parent_service: shopify, operation: operation, + uri: 'https://:shop_domain/admin/api/2026-07/graphql.json', + request_format: 'graphql', body_template: body_template, + request_mapper: { 'uri' => 'webhook_url' }, response_mapper: response_mapper, + error_path: error_path) + end + + def declare_subscription + child('webhook_subscription', 'mutation Subscribe { webhookSubscriptionCreate }', + { 'data.webhookSubscriptionCreate.webhookSubscription.id' => 'webhook_subscription_id' }, + error_path: 'data.webhookSubscriptionCreate.userErrors') + end + + def declare_lookup + child('webhook_lookup', 'query Webhook { webhookSubscriptions }', + { 'data.webhookSubscriptions.nodes.0.id' => 'webhook_subscription_id' }) + end + + def graphql(data) + { status: 200, headers: { 'Content-Type' => 'application/json' }, body: { data: data }.to_json } + end + + # Con id, la suscripción quedó creada; sin él, el proveedor rechazó la + # dirección (Shopify lo contesta con HTTP 200 y userErrors). + def created(id) + graphql(webhookSubscriptionCreate: { webhookSubscription: id && { id: id }, + userErrors: id ? [] : [{ field: ['uri'], message: 'Address is invalid' }] }) + end + + def found(*ids) + graphql(webhookSubscriptions: { nodes: ids.map { |id| { id: id } } }) + end + + def stub_document(fragment, response) + stub_request(:post, graphql_url).with { |request| request.body.include?(fragment) } + .to_return(response) + end + + def register(url = base_url) + described_class.new(company_integration: integration, base_url: url).call + end + + context 'when the template declares how to subscribe' do + before { declare_subscription } + + let!(:create_request) do + stub_document('webhookSubscriptionCreate', created('gid://shopify/WebhookSubscription/1')) + end + + it 'reports the address it registered' do + expect(register).to eq(ok: true, message: "Webhook registered: #{webhook_url}") + end + + it 'sends the gateway of this integration as the address' do + register + expect(create_request.with { |r| JSON.parse(r.body)['variables'] == { 'uri' => webhook_url } }) + .to have_been_made + end + + it 'reports the error of the provider without raising' do + stub_document('webhookSubscriptionCreate', created(nil)) + expect(register).to include(ok: false, message: /Address is invalid/) + end + + it 'does not call the provider without the public URL of the API', :aggregate_failures do + expect(register(nil)).to eq(ok: false, message: 'PUBLIC_WEBHOOK_BASE_URL is not set') + expect(create_request).not_to have_been_made + end + end + + context 'when the template can also look the subscription up' do + before { declare_subscription && declare_lookup } + + let!(:create_request) do + stub_document('webhookSubscriptionCreate', created('gid://shopify/WebhookSubscription/2')) + end + + it 'does not subscribe twice to the same address', :aggregate_failures do + stub_document('webhookSubscriptions', found('gid://shopify/WebhookSubscription/1')) + expect(register).to eq(ok: true, message: "Webhook already registered: #{webhook_url}") + expect(create_request).not_to have_been_made + end + + it 'subscribes when the address is not registered yet', :aggregate_failures do + stub_document('webhookSubscriptions', found) + expect(register).to include(ok: true, message: /registered/) + expect(create_request).to have_been_made + end + end + + context 'when the template does not declare a subscription' do + it 'says so instead of calling the provider' do + expect(register).to eq(ok: false, message: 'Shopify does not declare a subscription') + end + end + + context 'when the integration is a courier' do + let(:shopify) do + Service.create!(service_name: 'Courier', type: 'courier', http_method: 'POST', + uri: 'https://:shop_domain/webhooks', request_format: 'json') + end + + it 'points the provider to the courier gateway' do + expect(described_class.new(company_integration: integration, base_url: base_url).webhook_url) + .to eq("#{base_url}/api/webhooks/couriers/#{integration.id}") + end + end +end diff --git a/spec/poros/integrations/test_connection_spec.rb b/spec/poros/integrations/test_connection_spec.rb new file mode 100644 index 00000000..057886a5 --- /dev/null +++ b/spec/poros/integrations/test_connection_spec.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Integrations::TestConnection, type: :poro do + let(:shopify) do + Service.create!( + service_name: 'Shopify', type: 'ecommerce', http_method: 'POST', + uri: 'https://:shop_domain/admin/api/2026-07/graphql.json', + request_format: 'graphql', auth_strategy: 'oauth_client_credentials', + auth_config: { 'token_url' => 'https://:shop_domain/admin/oauth/access_token', + 'token_header' => 'X-Shopify-Access-Token', 'token_prefix' => '' }, + setting_fields: [{ 'key' => 'shop_domain' }, { 'key' => 'location_id' }] + ) + end + let!(:connection_test) do + Service.create!( + service_name: 'Shopify - Conexión', type: 'ecommerce', http_method: 'POST', + parent_service: shopify, operation: 'connection_test', + uri: 'https://:shop_domain/admin/api/2026-07/graphql.json', request_format: 'graphql', + body_template: '{ shop { name } locations(first: 5) { nodes { id name } } }', + response_mapper: { 'data.shop.name' => 'account_name', + 'data.locations.nodes.0.id' => 'location_id' } + ) + end + let(:settings) { { 'shop_domain' => 'demo.myshopify.com' } } + let(:integration) do + CompanyIntegration.create!( + company: company, service: shopify, settings: settings, + credentials: { 'client_id' => 'CLIENT-ID', 'client_secret' => 'shpss_SECRET', + 'access_token' => 'CACHED-TOKEN', + 'token_expires_at' => 1.hour.from_now.iso8601 } + ) + end + + def graphql_url = 'https://demo.myshopify.com/admin/api/2026-07/graphql.json' + + def company + @company ||= Company.create!(name: 'Acme', tax_id: '20-12345678-9') + end + + def test_connection + described_class.new(company_integration: integration).call + end + + def shop_response + { status: 200, headers: { 'Content-Type' => 'application/json' }, + body: { data: { shop: { name: 'Demo Store' }, + locations: { nodes: [{ id: 'gid://shopify/Location/7', name: 'Main' }] } } } + .to_json } + end + + context 'when the provider answers' do + before { stub_request(:post, graphql_url).to_return(shop_response) } + + it 'reports the account it connected to' do + expect(test_connection).to eq(ok: true, message: 'Connected to Demo Store') + end + + it 'runs the child template with the account of the parent integration' do + test_connection + expect(WebMock).to have_requested(:post, graphql_url) + .with(headers: { 'X-Shopify-Access-Token' => 'CACHED-TOKEN' }, + body: hash_including('query' => connection_test.body_template)) + end + + it 'completes the declared settings the company did not load' do + test_connection + expect(integration.reload.settings) + .to include('shop_domain' => 'demo.myshopify.com', 'location_id' => 'gid://shopify/Location/7') + end + + # Es lo que la pantalla de integraciones muestra para decir a qué cuenta + # está conectada la empresa. + it 'remembers the name of the account' do + test_connection + expect(integration.reload.settings['account_name']).to eq('Demo Store') + end + + context 'when the company already chose a location' do + let(:settings) do + { 'shop_domain' => 'demo.myshopify.com', 'location_id' => 'gid://shopify/Location/9' } + end + + it 'keeps it' do + test_connection + expect(integration.reload.settings['location_id']).to eq('gid://shopify/Location/9') + end + end + + context 'when the account was renamed' do + let(:settings) { { 'shop_domain' => 'demo.myshopify.com', 'account_name' => 'Old Name' } } + + it 'updates the name' do + test_connection + expect(integration.reload.settings['account_name']).to eq('Demo Store') + end + end + end + + it 'returns the failure as a message instead of raising' do + stub_request(:post, graphql_url).to_return(status: 403) + + expect(test_connection).to eq(ok: false, message: 'Shopify - Conexión responded with HTTP 403') + end + + context 'when the template declares no connection test' do + let(:bearer_service) do + Service.create!(service_name: 'Tiendanube', type: 'ecommerce', http_method: 'PUT', + uri: 'https://api.tiendanube.com/v1/products') + end + let(:integration) do + CompanyIntegration.create!(company: company, service: bearer_service, + credentials: { 'access_token' => 'TOKEN' }) + end + + it 'says there is nothing to test' do + expect(test_connection) + .to eq(ok: false, message: 'Tiendanube does not declare a connection test') + end + end +end diff --git a/spec/poros/orders/process_webhook_order_spec.rb b/spec/poros/orders/process_webhook_order_spec.rb index 4576afa7..738d20d0 100644 --- a/spec/poros/orders/process_webhook_order_spec.rb +++ b/spec/poros/orders/process_webhook_order_spec.rb @@ -379,6 +379,51 @@ def unique_violation end end + # La ciudad y la provincia las necesita el courier para cotizar el envío + # (TESIS-128). La provincia se valida contra Order::PROVINCES. + describe 'the city and the province of the customer' do + let(:service_with_location) do + create_service(mapper: order_mapper.merge('shipping.address.city' => 'customer_city', + 'shipping.address.state' => 'customer_province')) + end + let(:integration) { CompanyIntegration.create!(company: company, service: service_with_location) } + + def payload_in(state) + order_payload(items: [line('MLA-1', 1, 100)]).tap do |body| + body['shipping']['address'].merge!('city' => 'La Plata', 'state' => state) + end + end + + before { publish('SKU-1', 'MLA-1') } + + it 'keeps the city as the channel wrote it' do + expect(described_class.new(webhook_log: create_log(payload_in('Buenos Aires'))).call + .customer_city).to eq('La Plata') + end + + it 'keeps a province the OMS knows' do + expect(described_class.new(webhook_log: create_log(payload_in('Buenos Aires'))).call + .customer_province).to eq('Buenos Aires') + end + + it 'recognises the province with other capitals and without accents' do + expect(described_class.new(webhook_log: create_log(payload_in('santiago Del ESTERO'))).call + .customer_province).to eq('Santiago del Estero') + end + + it 'matches accents the channel did not write' do + expect(described_class.new(webhook_log: create_log(payload_in('Cordoba'))).call + .customer_province).to eq('Córdoba') + end + + it 'registers the sale without province instead of failing on an unknown one', + :aggregate_failures do + order = described_class.new(webhook_log: create_log(payload_in('Montevideo'))).call + expect(order).to be_persisted + expect(order.customer_province).to be_nil + end + end + context 'when the channel reports a status the OMS does not know' do before { publish('SKU-1', 'MLA-1') } diff --git a/spec/poros/webhooks/verify_signature_spec.rb b/spec/poros/webhooks/verify_signature_spec.rb new file mode 100644 index 00000000..7db95a20 --- /dev/null +++ b/spec/poros/webhooks/verify_signature_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Webhooks::VerifySignature, type: :poro do + let(:signature) { 'hmac_sha256_base64' } + let(:service) do + Service.create!(service_name: 'Shopify', type: 'ecommerce', http_method: 'POST', + uri: 'https://shop.test/graphql.json', + webhook_config: { 'signature' => signature, + 'signature_header' => 'X-Shopify-Hmac-SHA256', + 'secret_key' => 'client_secret' }) + end + let(:credentials) { { 'client_id' => 'CLIENT-ID', 'client_secret' => 'shpss_SECRET' } } + let(:integration) do + CompanyIntegration.create!(company: Company.create!(name: 'Acme', tax_id: '20-12345678-9'), + service: service, credentials: credentials) + end + + # Con espacios y una tilde sin escapar: re-serializar este JSON cambiaría + # los bytes, y con ellos el HMAC. + def raw_body = '{"id": 1, "shipping_address": {"name": "Juana Pérez"}}' + + def hmac(body = raw_body, secret = 'shpss_SECRET') + OpenSSL::HMAC.digest('SHA256', secret, body) + end + + def verify(headers) + described_class.new(company_integration: integration, raw_body: raw_body, + headers: headers).call + end + + it 'accepts the event signed with the secret of the integration' do + expect { verify('X-Shopify-Hmac-SHA256' => Base64.strict_encode64(hmac)) }.not_to raise_error + end + + it 'reads the header case-insensitively, the way Rails exposes it' do + headers = ActionDispatch::Http::Headers.from_hash( + 'HTTP_X_SHOPIFY_HMAC_SHA256' => Base64.strict_encode64(hmac) + ) + expect { verify(headers) }.not_to raise_error + end + + it 'rejects a body altered after it was signed' do + signed = Base64.strict_encode64(hmac(raw_body.sub('1', '2'))) + expect { verify('X-Shopify-Hmac-SHA256' => signed) } + .to raise_error(Webhooks::InvalidSignatureError, 'signature does not match') + end + + it 'rejects an event signed with the app of another company' do + signed = Base64.strict_encode64(hmac(raw_body, 'shpss_OTHER_COMPANY')) + expect { verify('X-Shopify-Hmac-SHA256' => signed) } + .to raise_error(Webhooks::InvalidSignatureError) + end + + it 'rejects an event without the signature header' do + expect { verify({}) } + .to raise_error(Webhooks::InvalidSignatureError, /X-Shopify-Hmac-SHA256 header is missing/) + end + + context 'when the integration has no secret to verify with' do + let(:credentials) { { 'client_id' => 'CLIENT-ID' } } + + it 'rejects the event instead of skipping the verification' do + expect { verify('X-Shopify-Hmac-SHA256' => Base64.strict_encode64(hmac)) } + .to raise_error(Webhooks::InvalidSignatureError, /client_secret is not configured/) + end + end + + context 'when the provider signs in hex' do + let(:signature) { 'hmac_sha256_hex' } + + it 'accepts the hex digest' do + expect { verify('X-Shopify-Hmac-SHA256' => hmac.unpack1('H*')) }.not_to raise_error + end + + it 'rejects the base64 one' do + expect { verify('X-Shopify-Hmac-SHA256' => Base64.strict_encode64(hmac)) } + .to raise_error(Webhooks::InvalidSignatureError) + end + end + + context 'when the template does not declare a signature' do + let(:service) do + Service.create!(service_name: 'Mercado Libre', type: 'ecommerce', http_method: 'GET', + uri: 'https://api.ml.test') + end + + it 'accepts the event as before' do + expect { verify({}) }.not_to raise_error + end + end +end diff --git a/spec/requests/admin/company_integration_actions_spec.rb b/spec/requests/admin/company_integration_actions_spec.rb new file mode 100644 index 00000000..2229356e --- /dev/null +++ b/spec/requests/admin/company_integration_actions_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require 'rails_helper' + +# Las credenciales de una empresa las carga el equipo de OneStock desde el +# backoffice (ADR-018), con un campo por cada dato que declara la plantilla. +RSpec.describe 'Admin company integration actions (Avo)', type: :request do + let(:admin_user) { AdminUser.create!(email: 'admin@backoffice.com', password: 'admin123') } + let(:shopify) do + Service.create!( + service_name: 'Shopify', type: 'ecommerce', http_method: 'POST', + uri: 'https://:shop_domain/admin/api/2026-07/graphql.json', request_format: 'graphql', + auth_strategy: 'oauth_client_credentials', + auth_config: { 'token_url' => 'https://:shop_domain/admin/oauth/access_token', + 'token_header' => 'X-Shopify-Access-Token', 'token_prefix' => '' }, + credential_fields: [{ 'key' => 'client_id', 'label' => 'Client ID', 'required' => true }, + { 'key' => 'client_secret', 'label' => 'Client secret', + 'required' => true }], + setting_fields: [{ 'key' => 'shop_domain', 'label' => 'Shop domain', 'required' => true, + 'format' => '\A[a-z0-9-]+\.myshopify\.com\z' }, + { 'key' => 'location_id', 'label' => 'Location' }] + ) + end + let!(:integration) do + CompanyIntegration.create!( + company: Company.create!(name: 'Acme', tax_id: '20-11111111-1', slug: 'acme'), + service: shopify, is_active: false, + settings: { 'shop_domain' => 'demo.myshopify.com' }, + credentials: { 'client_id' => 'CID', 'client_secret' => 'shpss_STORED', + 'access_token' => 'CACHED', 'token_expires_at' => 1.hour.from_now.iso8601 } + ) + end + + before { sign_in admin_user } + + def record_path = "/admin/resources/company_integrations/#{integration.id}" + + def open_action(action) + get "#{record_path}/actions", params: { action_id: action } + end + + def run_action(action, fields = {}) + post "#{record_path}/actions", headers: { 'Accept' => 'text/vnd.turbo-stream.html' }, params: { + action_id: action, fields: fields.merge(avo_resource_ids: integration.id.to_s) + } + end + + describe 'Configure connection' do + def action = 'Avo::Actions::ConfigureConnection' + + it 'asks for each declared field without showing the stored secrets', :aggregate_failures do + open_action(action) + + expect(response).to have_http_status(:ok) + expect(response.body).to include('Client ID', 'Client secret', 'Shop domain') + expect(response.body).to include('demo.myshopify.com') + expect(response.body).not_to include('shpss_STORED', 'CACHED') + end + + context 'with valid data' do + before do + stub_request(:post, 'https://otra.myshopify.com/admin/oauth/access_token') + .to_return(status: 200, headers: { 'Content-Type' => 'application/json' }, + body: { access_token: 'NEW-TOKEN', expires_in: 86_399 }.to_json) + run_action(action, credentials__client_id: '', credentials__client_secret: 'shpss_NEW', + settings__shop_domain: 'otra.myshopify.com', settings__location_id: '') + end + + it 'stores the new secret and keeps the one left blank', :aggregate_failures do + credentials = integration.reload.credentials + expect(credentials['client_id']).to eq('CID') + expect(credentials['client_secret']).to eq('shpss_NEW') + end + + it 'stores the settings' do + expect(integration.reload.settings['shop_domain']).to eq('otra.myshopify.com') + end + + # Sin plantilla de prueba, probar la conexión es obtener el token: se pide + # con las credenciales nuevas, no con el que estaba cacheado. + it 'tests the connection with the new account' do + expect(integration.reload.credentials['access_token']).to eq('NEW-TOKEN') + end + + it 'does not activate the integration on its own' do + expect(integration.reload.is_active).to be(false) + end + end + + it 'rejects a field that does not match the declared format', :aggregate_failures do + run_action(action, credentials__client_secret: '', settings__shop_domain: 'no es un dominio') + + expect(response.body).to include('Shop domain: invalid_format') + expect(integration.reload.settings['shop_domain']).to eq('demo.myshopify.com') + end + end + + describe 'Test connection' do + before do + stub_request(:post, 'https://demo.myshopify.com/admin/oauth/access_token') + .to_return(status: 401) + integration.update!(credentials: integration.credentials.except('access_token')) + run_action('Avo::Actions::TestConnection') + end + + # El resultado se muestra como aviso al recargar el detalle. + it 'reports what the provider answered' do + expect(flash[:error][:body]).to include('HTTP 401') + end + + it 'keeps the stored account' do + expect(integration.reload.credentials['client_secret']).to eq('shpss_STORED') + end + end +end diff --git a/spec/requests/api/v1/integrations_spec.rb b/spec/requests/api/v1/integrations_spec.rb index 154688a7..e5e47708 100644 --- a/spec/requests/api/v1/integrations_spec.rb +++ b/spec/requests/api/v1/integrations_spec.rb @@ -18,6 +18,11 @@ def listed response.parsed_body['data'] end + def row_for(service) + get '/api/v1/integrations', headers: headers + listed.find { |r| r['service_id'] == service.id } + end + describe 'GET /api/v1/integrations' do it 'returns 401 without a token' do get '/api/v1/integrations' @@ -36,18 +41,34 @@ def listed context 'when the company has the service configured' do before do CompanyIntegration.create!(company: company, service: service, - credentials: { 'access_token' => 'TOKEN-A' }) - get '/api/v1/integrations', headers: headers + credentials: { 'access_token' => 'TOKEN-A' }, + settings: { 'account_name' => 'Tienda Norte' }) end it 'marks the service as configured and active', :aggregate_failures do - row = listed.find { |r| r['service_id'] == service.id } + row = row_for(service) expect(row['configured']).to be(true) expect(row['is_active']).to be(true) end - it 'never exposes the credentials' do - expect(response.body).not_to include('TOKEN-A', 'credentials') + it 'says which account is connected' do + expect(row_for(service)['account_name']).to eq('Tienda Norte') + end + + it 'never exposes the credentials', :aggregate_failures do + row = row_for(service) + expect(response.body).not_to include('TOKEN-A') + expect(row).not_to have_key('credentials') + end + + # La pantalla sólo muestra el estado: el formulario de conexión vive en + # el backoffice (ADR-018), así que ni los detalles del motor ni lo que + # pide la plantilla tienen por qué viajar. + it 'lists only the status, without the engine or the connection form', :aggregate_failures do + row = row_for(service) + expect(row.keys).to match_array( + %w[service_id service_name type configured is_active integration_id account_name] + ) end end @@ -55,145 +76,75 @@ def listed before do CompanyIntegration.create!(company: other_company, service: service, credentials: { 'access_token' => 'TOKEN-B' }) - get '/api/v1/integrations', headers: headers end let(:other_company) { Company.create!(name: 'Tenant B', tax_id: '30-22222222-2') } it 'shows the service as not configured for the current tenant', :aggregate_failures do - row = listed.find { |r| r['service_id'] == service.id } + row = row_for(service) expect(row['configured']).to be(false) expect(row['is_active']).to be(false) + expect(row['account_name']).to be_nil end end - end - - describe 'PUT /api/v1/integrations/:service_id' do - it 'returns 401 without a token' do - put "/api/v1/integrations/#{service.id}", params: payload, as: :json - expect(response).to have_http_status(:unauthorized) - end - it 'creates the integration for the company of the JWT' do - expect do - put "/api/v1/integrations/#{service.id}", params: payload, headers: headers, as: :json - end.to change(CompanyIntegration, :count).by(1) - end + # El widget de nodos del panel lo pide igual, y sólo lista las plantillas + # globales con el estado de la propia empresa. + it 'lists the services even without the integrations feature' do + company.update!(features: { 'integrations' => false }) + get '/api/v1/integrations', headers: headers - it 'associates the integration to the given service', :aggregate_failures do - put "/api/v1/integrations/#{service.id}", params: payload, headers: headers, as: :json - integration = CompanyIntegration.last - expect(integration.service_id).to eq(service.id) - expect(integration.company_id).to eq(company.id) + expect(response).to have_http_status(:ok) end + end - it 'stores the credentials encrypted at rest' do - put "/api/v1/integrations/#{service.id}", params: payload, headers: headers, as: :json - raw = ActiveRecord::Base.connection.select_value( - "SELECT credentials FROM company_integrations WHERE service_id = #{service.id}" - ) - expect(raw).not_to include('SECRET-TOKEN') + # Plantillas de operación (TESIS-138): una hija se ejecuta con la cuenta de + # su madre, así que no se lista por separado. + describe 'operation templates' do + before do + Service.create!(service_name: 'Mercado Libre - Conexión', type: 'ecommerce', + uri: 'https://api.mercadolibre.com/users/me', http_method: 'GET', + parent_service: service, operation: 'connection_test') end - # `credentials` tiene que ser un objeto. Un string o un array pasan el - # `require` —que sólo mira que no venga vacío— y reventarían recién adentro - # del cifrado, como 500. El guard los corta antes; nadie lo ejercitaba - # (TESIS-93). - context 'when credentials is not an object' do - it 'rejects a string instead of failing inside the encryption' do - put "/api/v1/integrations/#{service.id}", - params: { credentials: 'ACCESS-TOKEN' }, headers: headers, as: :json - - expect(response).to have_http_status(:bad_request) - end - - it 'rejects an array' do - put "/api/v1/integrations/#{service.id}", - params: { credentials: ['ACCESS-TOKEN'] }, headers: headers, as: :json - - expect(response).to have_http_status(:bad_request) - end + it 'does not list them as connectable services' do + get '/api/v1/integrations', headers: headers - it 'stores nothing' do - expect do - put "/api/v1/integrations/#{service.id}", - params: { credentials: 'ACCESS-TOKEN' }, headers: headers, as: :json - end.not_to change(CompanyIntegration, :count) - end + expect(listed.pluck('service_id')).to contain_exactly(service.id) end + end - it 'returns 404 for an unknown service' do - put '/api/v1/integrations/999999', params: payload, headers: headers, as: :json - expect(response).to have_http_status(:not_found) + # Las credenciales las carga el equipo de OneStock desde el backoffice + # (ADR-018). La empresa no tiene cómo cargarlas, cambiarlas ni probarlas: si + # quedara el endpoint, podría hacerlo con Postman aunque el front no lo ofrezca. + describe 'loading a connection' do + before do + CompanyIntegration.create!(company: company, service: service, + credentials: { 'access_token' => 'TOKEN-A' }) end - context 'when the integration already exists' do - before do - CompanyIntegration.create!(company: company, service: service, - credentials: { 'access_token' => 'OLD' }) - end - - it 'does not create a duplicate (upsert)' do - expect do - put "/api/v1/integrations/#{service.id}", params: payload, headers: headers, as: :json - end.not_to change(CompanyIntegration, :count) - end + it 'cannot be created or changed', :aggregate_failures do + put "/api/v1/integrations/#{service.id}", headers: headers, as: :json, + params: { credentials: { access_token: 'OTRO' } } - it 'overwrites the stored credentials' do - put "/api/v1/integrations/#{service.id}", params: payload, headers: headers, as: :json - integration = CompanyIntegration.find_by!(company: company, service: service) - expect(integration.credentials).to eq('access_token' => 'SECRET-TOKEN') - end + expect(response).to have_http_status(:not_found) + expect(CompanyIntegration.last.credentials).to eq('access_token' => 'TOKEN-A') end - # La QA de TESIS-82 encontró que el flag sólo lo aplicaba el front: una - # empresa sin la feature configuraba integraciones llamando al endpoint. - context 'when the company does not have the integrations feature' do - before { company.update!(features: { 'integrations' => false }) } - - it 'refuses to configure the integration', :aggregate_failures do - expect do - put "/api/v1/integrations/#{service.id}", params: payload, headers: headers, as: :json - end.not_to change(CompanyIntegration, :count) - expect(response).to have_http_status(:forbidden) - end - - # El widget de nodos del panel lo pide igual, y sólo lista las plantillas - # globales con el estado de la propia empresa. - it 'still lists the services' do - get '/api/v1/integrations', headers: headers - - expect(response).to have_http_status(:ok) - end + it 'cannot be disconnected' do + delete "/api/v1/integrations/#{service.id}", headers: headers + expect(response).to have_http_status(:not_found) end - context 'when another company already configured the same service' do - let!(:other_integration) do - CompanyIntegration.create!( - company: Company.create!(name: 'Tenant B', tax_id: '30-22222222-2'), - service: service, credentials: { 'access_token' => 'TOKEN-B' } - ) - end - - it 'creates a new row for the current company' do - expect do - put "/api/v1/integrations/#{service.id}", params: payload, headers: headers, as: :json - end.to change(CompanyIntegration, :count).by(1) - end - - it 'does not modify the integration of the other company' do - put "/api/v1/integrations/#{service.id}", params: payload, headers: headers, as: :json - expect(other_integration.reload.credentials).to eq('access_token' => 'TOKEN-B') - end + it 'cannot be tested' do + post "/api/v1/integrations/#{service.id}/test", headers: headers + expect(response).to have_http_status(:not_found) end end - def payload - { credentials: { access_token: 'SECRET-TOKEN' } } - end - def auth_headers(user) - post '/api/v1/auth/login', params: { email: user.email, password: 'password123' }, headers: { 'X-Tenant-Slug' => user.company.slug } + post '/api/v1/auth/login', params: { email: user.email, password: 'password123' }, + headers: { 'X-Tenant-Slug' => user.company.slug } { 'Authorization' => "Bearer #{response.parsed_body['token']}" } end end diff --git a/spec/requests/api/v1/product_mappings_spec.rb b/spec/requests/api/v1/product_mappings_spec.rb index 3baad6bd..b1715541 100644 --- a/spec/requests/api/v1/product_mappings_spec.rb +++ b/spec/requests/api/v1/product_mappings_spec.rb @@ -240,6 +240,43 @@ def post_mapping(product_id: product.id, **attrs) expect(response).to have_http_status(:unprocessable_content) end + # TESIS-138: la respuesta del alta avisa, sin bloquear, lo que conviene + # revisar (por ejemplo, un SKU distinto en el canal). + it 'returns the warnings of the link, empty when there are none' do + post mappings_url(product.id), params: valid_params, headers: headers, as: :json + + expect(response.parsed_body['warnings']).to eq([]) + end + + it 'pushes the stock of the product to the channel it was linked to' do + expect { post mappings_url(product.id), params: valid_params, headers: headers, as: :json } + .to have_enqueued_job(Catalog::SyncStockToChannelsJob) + end + + context 'when the channel can look the variant up' do + before do + Service.create!(service_name: 'Mercado Libre - Variante', type: 'ecommerce', + uri: 'https://api.meli.com/items/:external_id', http_method: 'GET', + parent_service: meli_integration.service, operation: 'product_lookup', + response_mapper: { 'id' => 'external_product_id' }) + end + + it 'refuses an external id the channel does not have', :aggregate_failures do + stub_request(:get, 'https://api.meli.com/items/MLA-123').to_return(status: 200, body: '{}') + post mappings_url(product.id), params: valid_params, headers: headers, as: :json + + expect(response).to have_http_status(:unprocessable_content) + expect(response.parsed_body['error']).to eq('MLA-123 does not exist in Mercado Libre') + end + + it 'answers 502 when the channel does not respond' do + stub_request(:get, 'https://api.meli.com/items/MLA-123').to_return(status: 503) + post mappings_url(product.id), params: valid_params, headers: headers, as: :json + + expect(response).to have_http_status(:bad_gateway) + end + end + it 'returns 422 when external_product_id is missing' do post_mapping(company_integration_id: meli_integration.id) diff --git a/spec/requests/api/webhooks/integrations_spec.rb b/spec/requests/api/webhooks/integrations_spec.rb index fd765945..68e15a8c 100644 --- a/spec/requests/api/webhooks/integrations_spec.rb +++ b/spec/requests/api/webhooks/integrations_spec.rb @@ -79,6 +79,57 @@ def post_webhook(id = integration.id, body = payload, headers = {}) expect(response.body).to be_empty end + # Shopify firma cada webhook con el client secret de la app de la empresa + # (ADR-019). Sin la firma correcta el evento no se persiste. + context 'when the template signs its webhooks' do + let(:service) do + Service.create!(service_name: 'Shopify', type: 'ecommerce', http_method: 'POST', + uri: 'https://shop.test/graphql.json', + webhook_config: { 'signature' => 'hmac_sha256_base64', + 'signature_header' => 'X-Shopify-Hmac-SHA256', + 'secret_key' => 'client_secret' }) + end + let(:integration) do + CompanyIntegration.create!(company: company, service: service, + credentials: { 'client_secret' => 'shpss_SECRET' }) + end + let(:body) { '{"id": 820982911946154508, "note": "Envío en 24 h"}' } + + def post_signed(signature) + post "/api/webhooks/integrations/#{integration.id}", + params: body, headers: { 'CONTENT_TYPE' => 'application/json', + 'X-Shopify-Hmac-SHA256' => signature } + end + + def signature_of(text) + Base64.strict_encode64(OpenSSL::HMAC.digest('SHA256', 'shpss_SECRET', text)) + end + + it 'accepts the event signed over the raw body', :aggregate_failures do + expect { post_signed(signature_of(body)) }.to change(WebhookLog.unscoped, :count).by(1) + expect(response).to have_http_status(:accepted) + end + + it 'answers 401 to a wrong signature' do + post_signed(signature_of("#{body} ")) + expect(response).to have_http_status(:unauthorized) + end + + it 'persists nothing when the signature does not match' do + expect { post_signed(signature_of('{}')) }.not_to change(WebhookLog.unscoped, :count) + end + + it 'enqueues nothing when the signature does not match' do + expect { post_signed(signature_of('{}')) }.not_to have_enqueued_job(Orders::ProcessWebhookEventJob) + end + + it 'answers 401 when the signature header is missing' do + post "/api/webhooks/integrations/#{integration.id}", + params: body, headers: { 'CONTENT_TYPE' => 'application/json' } + expect(response).to have_http_status(:unauthorized) + end + end + context 'when the body is not valid JSON' do def post_broken_body post "/api/webhooks/integrations/#{integration.id}", diff --git a/spec/requests/api/webhooks/shopify_orders_spec.rb b/spec/requests/api/webhooks/shopify_orders_spec.rb new file mode 100644 index 00000000..b3841aea --- /dev/null +++ b/spec/requests/api/webhooks/shopify_orders_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require 'rails_helper' + +# Una venta real de la tienda de prueba de Shopify (`orders/create`, capturada +# el 2026-10-01 y anonimizada) recorrida de punta a punta: gateway con firma, +# worker de ingesta y descuento de stock (ADR-019). +RSpec.describe 'Shopify orders/create', type: :request do + include ActiveJob::TestHelper + + let(:company) { Company.create!(name: 'Norte', tax_id: '20-12345678-9') } + let(:integration) do + CompanyIntegration.create!(company: company, service: shopify, is_active: true, + credentials: { 'client_id' => 'CLIENT-ID', + 'client_secret' => 'shpss_SECRET' }) + end + let(:product) { Product.create!(company: company, sku: 'NOR-002', name: 'Notebook') } + + # La plantilla tal como la siembra db/seeds.rb (sólo lo que usa la ingesta). + def shopify + @shopify ||= Service.create!( + service_name: 'Shopify', type: 'ecommerce', http_method: 'POST', + uri: 'https://:shop_domain/admin/api/2026-07/graphql.json', request_format: 'graphql', + response_mapper: { + 'id' => 'external_order_id', 'financial_status' => 'status', + 'shipping_address.name' => 'customer_name', + 'shipping_address.address1' => 'customer_address', + 'shipping_address.zip' => 'customer_zip_code', + 'shipping_address.city' => 'customer_city', + 'shipping_address.province' => 'customer_province', + 'line_items[].variant_id' => 'external_product_id', + 'line_items[].quantity' => 'quantity', 'line_items[].price' => 'unit_price' + }, + response_value_mapper: { 'authorized' => 'pending', 'voided' => 'cancelled' }, + webhook_config: { 'signature' => 'hmac_sha256_base64', + 'signature_header' => 'X-Shopify-Hmac-SHA256', + 'secret_key' => 'client_secret' } + ) + end + + def body = Rails.root.join('spec/fixtures/shopify/orders_create.json').read + + def deliver(signature = Base64.strict_encode64(OpenSSL::HMAC.digest('SHA256', 'shpss_SECRET', body))) + perform_enqueued_jobs(only: Orders::ProcessWebhookEventJob) do + post "/api/webhooks/integrations/#{integration.id}", + params: body, headers: { 'CONTENT_TYPE' => 'application/json', + 'X-Shopify-Topic' => 'orders/create', + 'X-Shopify-Hmac-SHA256' => signature } + end + end + + def order = Order.unscoped.find_by(external_order_id: '18922194698473') + + before do + warehouse = Warehouse.create!(company: company, name: 'Central', address: 'Calle 1', zip_code: '1900') + Stock.create!(product: product, warehouse: warehouse, quantity: 20) + ProductMapping.create!(product: product, company_integration: integration, + external_product_id: '67579271348457') + end + + it 'registers the sale with the customer and the address' do + deliver + expect(order).to have_attributes(status: 'paid', customer_name: 'Juana Pérez', + customer_address: 'Calle 12 1234', customer_zip_code: '1131') + end + + it 'keeps the city and the province for the courier' do + deliver + expect(order).to have_attributes(customer_city: 'Berisso', customer_province: 'Buenos Aires') + end + + it 'adds up the lines into the total' do + deliver + expect(order.total_amount).to eq(1_450_000) + end + + it 'resolves the line by its variant and deducts the stock' do + deliver + expect(Stock.unscoped.find_by(product: product).quantity).to eq(19) + end + + it 'does not register the sale twice when Shopify retries the delivery', :aggregate_failures do + 2.times { deliver } + expect(Order.unscoped.where(external_order_id: '18922194698473').count).to eq(1) + expect(Stock.unscoped.find_by(product: product).quantity).to eq(19) + end + + it 'rejects the delivery signed with another secret', :aggregate_failures do + deliver(Base64.strict_encode64(OpenSSL::HMAC.digest('SHA256', 'shpss_OTHER', body))) + expect(response).to have_http_status(:unauthorized) + expect(order).to be_nil + end +end