From e94cadbdf62447d73340d1dfb905e109bb0efa71 Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Wed, 6 May 2026 16:01:01 -0300 Subject: [PATCH] feat(captain): pix_mode manual_static pra Padova e Express MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adiciona caminho paralelo de PIX manual estático pra unidades sem integração Inter (Padova, Express AL). Mudança 100% aditiva — todas as outras unidades continuam no fluxo Inter inalterado (default pix_mode=inter_dynamic aplicado pela migration). Backend (sem SOUL/SKILL ainda — Frente 7 vem depois): - Migration concurrent: pix_mode + 4 campos manual_pix_* em captain_units; provider + manual_proof_payload + manual_review_reason em captain_pix_charges - Captain::Unit: enum pix_mode (prefix), validação condicional manual_* - Captain::PixCharge: status estendido (awaiting_proof, pending_review), scope manual/inter, nota interna ramificada por modo - GeneratePixTool MCP: branch manual_static (chave fixa, mensagem direta sem QR/Inter), preserva fluxo Inter intacto - 4 tools MCP novas: verificar_comprovante_pix (vision gpt-5.3-codex), criar_nota_interna (genérica), confirmar_reserva_pix_manual (wrapper do ConfirmationService), marcar_reserva_pendente - ConfirmationService: source_label cobre 'manual_pix_proof' Próximos passos manuais (não inclusos neste commit): 1. Rodar migration em prod (entrypoint não roda no boot) 2. Seed Padova/Express com pix_mode=manual_static + chaves Stone 3. Deploy nova imagem via docker service update 4. Editar SOUL/SKILL Padova/Express na VPS Hermes + kill+boot Co-Authored-By: Claude Opus 4.7 (1M context) --- ...6183716_add_manual_pix_to_captain_units.rb | 19 ++ enterprise/app/models/captain/pix_charge.rb | 59 +++- enterprise/app/models/captain/unit.rb | 20 ++ .../app/services/captain/mcp/tool_registry.rb | 5 + .../mcp/tools/confirm_pix_manual_tool.rb | 90 ++++++ .../mcp/tools/create_internal_note_tool.rb | 68 ++++ .../captain/mcp/tools/generate_pix_tool.rb | 81 +++++ .../tools/mark_reservation_pending_tool.rb | 83 +++++ .../mcp/tools/verify_pix_proof_tool.rb | 291 ++++++++++++++++++ .../captain/payments/confirmation_service.rb | 1 + 10 files changed, 706 insertions(+), 11 deletions(-) create mode 100644 db/migrate/20260506183716_add_manual_pix_to_captain_units.rb create mode 100644 enterprise/app/services/captain/mcp/tools/confirm_pix_manual_tool.rb create mode 100644 enterprise/app/services/captain/mcp/tools/create_internal_note_tool.rb create mode 100644 enterprise/app/services/captain/mcp/tools/mark_reservation_pending_tool.rb create mode 100644 enterprise/app/services/captain/mcp/tools/verify_pix_proof_tool.rb diff --git a/db/migrate/20260506183716_add_manual_pix_to_captain_units.rb b/db/migrate/20260506183716_add_manual_pix_to_captain_units.rb new file mode 100644 index 000000000..4ad7df57a --- /dev/null +++ b/db/migrate/20260506183716_add_manual_pix_to_captain_units.rb @@ -0,0 +1,19 @@ +class AddManualPixToCaptainUnits < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def change + add_column :captain_units, :pix_mode, :string, default: 'inter_dynamic', null: false + add_column :captain_units, :manual_pix_key, :string + add_column :captain_units, :manual_pix_key_type, :string + add_column :captain_units, :manual_pix_owner_name, :string + add_column :captain_units, :manual_pix_bank_name, :string + + add_index :captain_units, :pix_mode, algorithm: :concurrently + + add_column :captain_pix_charges, :provider, :string, default: 'inter', null: false + add_column :captain_pix_charges, :manual_proof_payload, :jsonb + add_column :captain_pix_charges, :manual_review_reason, :string + + add_index :captain_pix_charges, :provider, algorithm: :concurrently + end +end diff --git a/enterprise/app/models/captain/pix_charge.rb b/enterprise/app/models/captain/pix_charge.rb index 93ec283b8..32c5e2921 100644 --- a/enterprise/app/models/captain/pix_charge.rb +++ b/enterprise/app/models/captain/pix_charge.rb @@ -36,11 +36,29 @@ class Captain::PixCharge < ApplicationRecord belongs_to :reservation, class_name: 'Captain::Reservation' belongs_to :unit, class_name: 'Captain::Unit' - enum status: { active: 'active', paid: 'paid', expired: 'expired', failed: 'failed' } + enum status: { + active: 'active', + paid: 'paid', + expired: 'expired', + failed: 'failed', + awaiting_proof: 'awaiting_proof', + pending_review: 'pending_review' + } validates :txid, presence: true, uniqueness: true validates :unit_id, presence: true + scope :manual, -> { where(provider: 'manual') } + scope :inter, -> { where(provider: 'inter') } + + def manual? + provider.to_s == 'manual' + end + + def inter? + provider.to_s == 'inter' + end + after_create_commit :post_internal_pix_sent_note after_create_commit :enqueue_retention_recalc after_update_commit :enqueue_retention_recalc_on_status_change @@ -64,16 +82,7 @@ class Captain::PixCharge < ApplicationRecord conversation = reservation&.conversation return if conversation.blank? - value = original_value.to_f - expires_fmt = expires_at&.strftime('%d/%m/%Y %H:%M') || '—' - - content = [ - '💸 *PIX enviado ao cliente* — aguardando pagamento', - "Valor: R$ #{format('%.2f', value)}", - "Txid: #{txid}", - "Expira em: #{expires_fmt}", - "Reserva ##{reservation_id}" - ].join("\n") + content = manual? ? manual_pix_note_content : inter_pix_note_content Messages::MessageBuilder.new( nil, @@ -110,6 +119,34 @@ class Captain::PixCharge < ApplicationRecord return val.to_f if val.present? end + if manual? + deposit = reservation&.metadata.to_h['deposit_amount'] + return deposit.to_f if deposit.present? + end + reservation&.total_amount end + + private + + def manual_pix_note_content + [ + '💸 *PIX MANUAL enviado ao cliente* — aguardando comprovante', + "Valor: R$ #{format('%.2f', original_value.to_f)}", + "Chave: #{unit&.manual_pix_key} (#{unit&.manual_pix_bank_name})", + "Beneficiário esperado: #{unit&.manual_pix_owner_name}", + "Reserva ##{reservation_id}" + ].join("\n") + end + + def inter_pix_note_content + expires_fmt = expires_at&.strftime('%d/%m/%Y %H:%M') || '—' + [ + '💸 *PIX enviado ao cliente* — aguardando pagamento', + "Valor: R$ #{format('%.2f', original_value.to_f)}", + "Txid: #{txid}", + "Expira em: #{expires_fmt}", + "Reserva ##{reservation_id}" + ].join("\n") + end end diff --git a/enterprise/app/models/captain/unit.rb b/enterprise/app/models/captain/unit.rb index f4b0db731..badebac03 100644 --- a/enterprise/app/models/captain/unit.rb +++ b/enterprise/app/models/captain/unit.rb @@ -74,9 +74,14 @@ class Captain::Unit < ApplicationRecord encrypts :inter_key_content enum status: { active: 'active', inactive: 'inactive' }, _default: 'active' + enum pix_mode: { inter_dynamic: 'inter_dynamic', manual_static: 'manual_static' }, _default: 'inter_dynamic', _prefix: true + + MANUAL_PIX_KEY_TYPES = %w[cpf cnpj email phone random].freeze validates :name, presence: true + validates :manual_pix_key_type, inclusion: { in: MANUAL_PIX_KEY_TYPES }, allow_nil: true validate :proactive_pix_polling_requires_inter_credentials + validate :manual_static_requires_manual_pix_fields after_commit :enqueue_supabase_provisioning, on: :create @@ -104,6 +109,13 @@ class Captain::Unit < ApplicationRecord (inter_key_content.present? || resolved_inter_key_path.present?) end + def manual_pix_configured? + pix_mode_manual_static? && + manual_pix_key.present? && + manual_pix_owner_name.present? && + manual_pix_bank_name.present? + end + def resolved_inter_cert_path resolve_certificate_path(inter_cert_path) end @@ -128,6 +140,14 @@ class Captain::Unit < ApplicationRecord ) end + def manual_static_requires_manual_pix_fields + return unless pix_mode_manual_static? + + %i[manual_pix_key manual_pix_owner_name manual_pix_bank_name].each do |field| + errors.add(field, 'é obrigatório quando pix_mode = manual_static') if public_send(field).blank? + end + end + # Resolve o path do certificado — suporta caminho absoluto, relativo ao Rails.root # ou nome de arquivo simples dentro de storage/certs/. def resolve_certificate_path(path) diff --git a/enterprise/app/services/captain/mcp/tool_registry.rb b/enterprise/app/services/captain/mcp/tool_registry.rb index b791fb009..4a40c75e0 100644 --- a/enterprise/app/services/captain/mcp/tool_registry.rb +++ b/enterprise/app/services/captain/mcp/tool_registry.rb @@ -19,6 +19,11 @@ class Captain::Mcp::ToolRegistry Captain::Mcp::Tools::RescheduleReservationTool, Captain::Mcp::Tools::ReactToMessageTool, Captain::Mcp::Tools::CheckSuiteAvailabilityTool, + # PIX manual estático (Padova, Express AL) — fluxo paralelo ao Inter + Captain::Mcp::Tools::VerifyPixProofTool, + Captain::Mcp::Tools::CreateInternalNoteTool, + Captain::Mcp::Tools::ConfirmPixManualTool, + Captain::Mcp::Tools::MarkReservationPendingTool, # Construtor (admin scope) — usadas pelo profile Hermes "construtor" pra criar novos agentes Captain::Mcp::Tools::ListAssistantsTool, Captain::Mcp::Tools::GetAssistantPricingTool, diff --git a/enterprise/app/services/captain/mcp/tools/confirm_pix_manual_tool.rb b/enterprise/app/services/captain/mcp/tools/confirm_pix_manual_tool.rb new file mode 100644 index 000000000..74c2cd87c --- /dev/null +++ b/enterprise/app/services/captain/mcp/tools/confirm_pix_manual_tool.rb @@ -0,0 +1,90 @@ +# Tool MCP: confirma reserva via PIX manual (após validação de comprovante). +# +# Caso de uso: fluxo PIX manual (Padova, Express AL). Comprovante já foi +# validado pela tool verificar_comprovante_pix com verdict='ok'. Esta tool +# marca a charge como paga, persiste o payload extraído e dispara +# Captain::Payments::ConfirmationService — que cuida de marcar reserva +# paid+active, atualizar labels (pagamento_confirmado/reserva_feita), +# postar nota interna automática, disparar oferta de roleta e notificar +# Hermes proativamente. Mesmo trânsito da confirmação Inter. +# +# Pré-requisito: charge.provider='manual' E charge.manual_proof_payload +# com verdict='ok'. Tool é idempotente — chamada repetida em charge já +# paga retorna sucesso sem efeito colateral. +# rubocop:disable Metrics/MethodLength, Metrics/AbcSize +class Captain::Mcp::Tools::ConfirmPixManualTool < Captain::Mcp::Tools::BaseTool + class << self + def name + 'confirmar_reserva_pix_manual' + end + + def description + 'Confirma reserva PIX manual após comprovante validado (verdict=ok). Use SOMENTE depois de ' \ + 'verificar_comprovante_pix retornar ok. Marca PIX como pago e dispara o trânsito padrão de ' \ + 'confirmação (mensagem ao cliente, labels, roleta). NÃO use sem ter validado comprovante antes.' + end + + def input_schema + { + type: 'object', + properties: { + pix_charge_id: { + type: 'integer', + description: 'ID da Captain::PixCharge (provider=manual). Obrigatório.' + } + }, + required: ['pix_charge_id'] + } + end + end + + def call(args, _context:) + charge = Captain::PixCharge.find_by(id: args['pix_charge_id']) + return error_response('PixCharge não encontrada.') if charge.blank? + return error_response("PixCharge ##{charge.id} não é manual (provider=#{charge.provider}). Use o fluxo Inter normal.") unless charge.manual? + return text_response("PIX manual ##{charge.id} já estava confirmado (idempotente). Reserva ##{charge.reservation_id} ativa.") if charge.paid? + + payload = charge.manual_proof_payload || {} + return error_response("PixCharge ##{charge.id} não tem comprovante validado. Chame verificar_comprovante_pix antes.") if payload.blank? + + unless payload['verdict'] == 'ok' + return error_response("Comprovante não passou na validação (verdict=#{payload['verdict']}). Use marcar_reserva_pendente.") + end + + reservation = charge.reservation + return error_response('PixCharge sem reserva vinculada — não consigo confirmar.') if reservation.blank? + + mark_charge_paid!(charge, payload) + fire_confirmation!(reservation, payload) + + text_response( + "Reserva ##{reservation.id} confirmada via PIX manual. PIX ##{charge.id} marcado como pago. " \ + 'Mensagem de confirmação será enviada ao cliente automaticamente.' + ) + rescue StandardError => e + Rails.logger.error("[Captain::Mcp::ConfirmPixManualTool] error: #{e.class}: #{e.message}") + Rails.logger.error(e.backtrace.first(5).join("\n")) + error_response("Erro ao confirmar reserva manual: #{e.message}") + end + + private + + def mark_charge_paid!(charge, payload) + extracted = payload['extracted'].to_h + charge.update!( + status: 'paid', + paid_at: Time.current, + e2eid: extracted['id_transacao'].presence || charge.e2eid + ) + end + + def fire_confirmation!(reservation, payload) + Captain::Payments::ConfirmationService.new( + reservation: reservation, + source: 'manual_pix_proof', + payload: payload, + actor: nil + ).perform + end +end +# rubocop:enable Metrics/MethodLength, Metrics/AbcSize diff --git a/enterprise/app/services/captain/mcp/tools/create_internal_note_tool.rb b/enterprise/app/services/captain/mcp/tools/create_internal_note_tool.rb new file mode 100644 index 000000000..6a8072e26 --- /dev/null +++ b/enterprise/app/services/captain/mcp/tools/create_internal_note_tool.rb @@ -0,0 +1,68 @@ +# Tool MCP: cria nota interna (privada) numa conversa. +# +# Caso de uso primário: fluxo PIX manual — após verificar_comprovante_pix, +# Hermes registra análise pra humano via nota interna antes de +# confirmar/marcar pendente. Genérica e reaproveitável: qualquer fluxo +# Hermes pode publicar nota interna pra deixar trilha pro time humano. +# +# Visibilidade: a nota é private=true (só atendentes veem; cliente não). +class Captain::Mcp::Tools::CreateInternalNoteTool < Captain::Mcp::Tools::BaseTool + class << self + def name + 'criar_nota_interna' + end + + def description + 'Cria nota interna (privada) na conversa. Use pra registrar análise/contexto pro time humano ' \ + 'sem mandar mensagem visível pro cliente. Use sempre antes de handoffs importantes ou pra logar ' \ + 'verificações automáticas (ex: validação de comprovante PIX manual).' + end + + def input_schema + { + type: 'object', + properties: { + conversation_id: { + type: 'integer', + description: 'ID interno da conversa (cid do [ctx]). Obrigatório.' + }, + content: { + type: 'string', + description: 'Conteúdo da nota. Pode ter markdown simples (negrito, listas, quebras de linha).' + } + }, + required: %w[conversation_id content] + } + end + end + + def call(args, context:) + conversation = resolve_conversation(args, context) + return error_response('Conversa não encontrada. Passe conversation_id (cid do [ctx]).') if conversation.blank? + + content = args['content'].to_s.strip + return error_response('Conteúdo da nota vazio.') if content.blank? + + Messages::MessageBuilder.new( + nil, + conversation, + { content: content, message_type: 'outgoing', private: true } + ).perform + + text_response("Nota interna criada na conversa ##{conversation.display_id}.") + rescue StandardError => e + Rails.logger.error("[Captain::Mcp::CreateInternalNoteTool] error: #{e.class}: #{e.message}") + error_response("Erro ao criar nota interna: #{e.message}") + end + + private + + def resolve_conversation(args, context) + conv_id = args['conversation_id'].presence || + context[:conversation_internal_id] || + context[:conversation_id] + return nil if conv_id.blank? + + Conversation.find_by(id: conv_id) || Conversation.find_by(display_id: conv_id) + end +end diff --git a/enterprise/app/services/captain/mcp/tools/generate_pix_tool.rb b/enterprise/app/services/captain/mcp/tools/generate_pix_tool.rb index 65694142b..a1d8850d7 100644 --- a/enterprise/app/services/captain/mcp/tools/generate_pix_tool.rb +++ b/enterprise/app/services/captain/mcp/tools/generate_pix_tool.rb @@ -80,6 +80,12 @@ class Captain::Mcp::Tools::GeneratePixTool < Captain::Mcp::Tools::BaseTool unit = resolve_unit(conversation, context) return error_response('Unidade do Captain não vinculada à inbox dessa conversa.') if unit.blank? + # Modo PIX manual estático (Padova, Express AL): sem integração Inter, + # sem QR/copia-cola dinâmico, sem fallback de página de reserva. + # Hermes apresenta a chave PIX fixa da unidade e o cliente envia + # comprovante pra validação por vision. + return dispatch_manual_pix_flow!(conversation, unit, args) if unit.pix_mode_manual_static? + # Sem credencial Inter: vai DIRETO pro fallback de página de reserva ao # invés de retornar erro pro LLM (que ele ia transformar em "vou # verificar" e travar). Cliente recebe link da página oficial pra @@ -322,6 +328,81 @@ class Captain::Mcp::Tools::GeneratePixTool < Captain::Mcp::Tools::BaseTool conversation.update_labels(merged) end + # Fluxo PIX manual: unidade tem chave PIX estática (Padova, Express AL). + # Sem Inter, sem QR, sem fallback. Apresenta chave + nome do beneficiário + # pro cliente; aguarda comprovante (que será validado via vision pela + # tool verificar_comprovante_pix). + def dispatch_manual_pix_flow!(conversation, unit, args) + contact = conversation.contact + hydrate_contact_from_recent_messages!(contact, conversation) + missing = identity_missing_fields(contact) + return error_response("Faltam dados do cliente pra gerar Pix: #{missing.join(', ')}. Peça ao cliente antes de chamar esta tool.") if missing.any? + + pricing = Captain::Mcp::PricingTables.calculate( + unit_id: unit.id, + suite_category: args['suite_category'], + period: args['period'], + total_guests: (args['total_guests'] || 2).to_i + ) + if pricing[:error].present? + Rails.logger.warn("[Captain::Mcp::GeneratePixTool] manual pricing inválido: #{pricing[:error]}") + return error_response("Não consegui calcular o valor: #{pricing[:error]}. Confirma a categoria/permanência com o cliente.") + end + + total_amount = pricing[:amount] + deposit = (total_amount * DEFAULT_DEPOSIT_RATIO).round(2) + + reservation = build_or_update_reservation!(conversation, unit, args, pricing, total_amount, deposit) + + charge = Captain::PixCharge.create!( + reservation: reservation, + unit: unit, + provider: 'manual', + status: 'awaiting_proof', + txid: "manual_#{SecureRandom.uuid}" + ) + + reservation.update!(status: :pending_payment) + + dispatch_manual_pix_message(conversation, unit, deposit) + mark_awaiting_payment(conversation) + label_manual_pix(conversation) + + deposit_str = format('%.2f', deposit) + total_str = format('%.2f', total_amount) + breakdown = "#{pricing[:breakdown][:suite_category]} / #{pricing[:breakdown][:period]}" + text_response( + "Pix MANUAL enviado: chave #{unit.manual_pix_key} (#{unit.manual_pix_bank_name}) — " \ + "sinal R$ #{deposit_str} (50% de R$ #{total_str} — #{breakdown}). " \ + "Charge ##{charge.id}. Cliente vai mandar comprovante por imagem — quando chegar, " \ + "chame verificar_comprovante_pix(image_url, pix_charge_id=#{charge.id})." + ) + end + + def dispatch_manual_pix_message(conversation, unit, deposit) + body = [ + 'Pode fazer o Pix:', + '', + "🔑 Chave: #{unit.manual_pix_key}", + "🏦 Banco: #{unit.manual_pix_bank_name}", + "💰 Valor: R$ #{format('%.2f', deposit)}", + "👤 Nome que aparece: *#{unit.manual_pix_owner_name}*", + '', + 'Quando pagar, me manda o comprovante por aqui que eu confirmo.' + ].join("\n") + + Messages::MessageBuilder.new(nil, conversation, content: body, message_type: 'outgoing').perform + rescue StandardError => e + Rails.logger.warn("[Captain::Mcp::GeneratePixTool] failed to dispatch manual pix message: #{e.class} - #{e.message}") + end + + def label_manual_pix(conversation) + current = conversation.label_list + conversation.update_labels((current + ['pix_manual']).uniq) + rescue StandardError => e + Rails.logger.warn("[Captain::Mcp::GeneratePixTool] failed to label manual pix: #{e.class} - #{e.message}") + end + # Fallback "leve" pra cenários onde Pix nem foi tentado (categoria não # existe na unit, período inválido, sem credencial Inter cadastrada). # Sem reservation/pricing/valores — só monta link com o que tem do diff --git a/enterprise/app/services/captain/mcp/tools/mark_reservation_pending_tool.rb b/enterprise/app/services/captain/mcp/tools/mark_reservation_pending_tool.rb new file mode 100644 index 000000000..65118424b --- /dev/null +++ b/enterprise/app/services/captain/mcp/tools/mark_reservation_pending_tool.rb @@ -0,0 +1,83 @@ +# Tool MCP: marca reserva PIX manual como PENDENTE de revisão humana. +# +# Caso de uso: fluxo PIX manual (Padova, Express AL). Comprovante foi +# validado pela tool verificar_comprovante_pix com verdict='duvida' OU +# Hermes/atendente julgou necessário escalar mesmo com ok. NÃO confirma +# a reserva — humano precisa olhar o comprovante e decidir. +# +# Efeitos: +# - PixCharge.status='pending_review' + persiste motivo +# - Conversa ganha labels: revisao_humana_pix + comprovante_recebido +# - NÃO chama ConfirmationService (cliente NÃO recebe mensagem de +# confirmação automática até humano resolver) +class Captain::Mcp::Tools::MarkReservationPendingTool < Captain::Mcp::Tools::BaseTool + class << self + def name + 'marcar_reserva_pendente' + end + + def description + 'Marca PIX manual como PENDENTE de revisão humana. Use quando verificar_comprovante_pix ' \ + "retornar verdict='duvida' (valor não bate, data antiga, beneficiário diferente, suspeitas " \ + 'na imagem). NÃO confirma a reserva — humano precisa olhar antes. Cliente NÃO recebe ' \ + 'mensagem automática de confirmação.' + end + + def input_schema + { + type: 'object', + properties: { + pix_charge_id: { + type: 'integer', + description: 'ID da Captain::PixCharge (provider=manual). Obrigatório.' + }, + motivo: { + type: 'string', + description: 'Motivo curto (uma linha) pra deixar claro pro humano o que deu errado. ' \ + 'Ex: "valor R$ 10 a menos", "comprovante de ontem", "beneficiário não bate".' + } + }, + required: %w[pix_charge_id motivo] + } + end + end + + def call(args, _context:) + charge = Captain::PixCharge.find_by(id: args['pix_charge_id']) + return error_response('PixCharge não encontrada.') if charge.blank? + return error_response("PixCharge ##{charge.id} não é manual.") unless charge.manual? + + motivo = args['motivo'].to_s.strip + return error_response('Motivo é obrigatório.') if motivo.blank? + + mark_charge_pending!(charge, motivo) + label_pending_review(charge.reservation&.conversation) + + text_response( + "PIX manual ##{charge.id} marcado como pendente de revisão. Motivo: #{motivo}. " \ + "Reserva ##{charge.reservation_id} aguarda humano. Cliente NÃO foi notificado automaticamente." + ) + rescue StandardError => e + Rails.logger.error("[Captain::Mcp::MarkReservationPendingTool] error: #{e.class}: #{e.message}") + error_response("Erro ao marcar reserva pendente: #{e.message}") + end + + private + + def mark_charge_pending!(charge, motivo) + charge.update!( + status: 'pending_review', + manual_review_reason: motivo + ) + end + + def label_pending_review(conversation) + return if conversation.blank? + + current = conversation.label_list + merged = (current + %w[revisao_humana_pix comprovante_recebido]).uniq - %w[pagamento_confirmado reserva_feita] + conversation.update_labels(merged) + rescue StandardError => e + Rails.logger.warn("[Captain::Mcp::MarkReservationPendingTool] label failed: #{e.class} - #{e.message}") + end +end diff --git a/enterprise/app/services/captain/mcp/tools/verify_pix_proof_tool.rb b/enterprise/app/services/captain/mcp/tools/verify_pix_proof_tool.rb new file mode 100644 index 000000000..f7c40abe3 --- /dev/null +++ b/enterprise/app/services/captain/mcp/tools/verify_pix_proof_tool.rb @@ -0,0 +1,291 @@ +# Tool MCP: valida comprovante PIX (modo manual estático). +# +# Caso de uso: unidade opera em pix_mode='manual_static' (Padova, Express). +# Cliente recebeu chave PIX fixa, pagou, e enviou comprovante (imagem). +# Esta tool extrai dados via vision (gpt-5.3-codex multimodal), compara com +# o esperado (valor exato, data ≤24h, beneficiário/chave/banco fuzzy match +# com Captain::Unit.manual_pix_*) e retorna verdict pro Hermes. +# +# Verdicts: +# - ok → tudo bate, chamar confirmar_reserva_pix_manual +# - duvida → algo não bate, chamar marcar_reserva_pendente +# - nao_eh_comprovante → imagem não é comprovante PIX, pedir reenvio +# +# Hermes, ANTES de chamar esta tool, deve responder ao cliente: +# "⏳ Só um momento, vou verificar." +# Essa frase aciona handoff humano automaticamente (label triagem_humana), +# de modo que humano sempre acompanhe o resultado da validação. +# rubocop:disable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize, Layout/LineLength +class Captain::Mcp::Tools::VerifyPixProofTool < Captain::Mcp::Tools::BaseTool + PROOF_FRESHNESS_HOURS = 24 + VALUE_TOLERANCE = 0.0 # zero — valor exato + + class << self + def name + 'verificar_comprovante_pix' + end + + def description + 'Valida comprovante PIX (modo manual). Use SOMENTE quando cliente enviar IMAGEM ' \ + 'de comprovante numa conversa que tem PIX manual ativo (provider=manual). Extrai dados ' \ + 'via vision e compara com a cobrança esperada. Retorna ok / duvida / nao_eh_comprovante. ' \ + 'ANTES de chamar, RESPONDA ao cliente "⏳ Só um momento, vou verificar." pra acionar handoff humano.' + end + + def input_schema + { + type: 'object', + properties: { + image_url: { + type: 'string', + description: 'URL pública da imagem do comprovante (vinda do anexo da mensagem incoming).' + }, + conversation_id: { + type: 'integer', + description: 'ID interno da conversa (cid do [ctx]). Obrigatório.' + }, + pix_charge_id: { + type: 'integer', + description: 'Opcional. ID da Captain::PixCharge associada. Se vazio, usa a charge manual mais recente da conversa.' + } + }, + required: %w[image_url conversation_id] + } + end + end + + def call(args, context:) + conversation = resolve_conversation(args, context) + return error_response('Conversa não encontrada. Passe conversation_id (cid do [ctx]).') if conversation.blank? + + charge = resolve_charge(conversation, args['pix_charge_id']) + return error_response('Não há PIX manual aguardando comprovante nesta conversa. Confirme com o cliente o que foi enviado.') if charge.blank? + + unit = charge.unit + return error_response('PixCharge sem unidade vinculada — não consigo validar o beneficiário esperado.') if unit.blank? + + image_url = args['image_url'].to_s.strip + return error_response('image_url vazio — passe a URL da imagem do comprovante.') if image_url.blank? + + extracted = extract_proof_via_vision(image_url) + return text_response_for_verdict(charge, 'nao_eh_comprovante', extracted: extracted, mismatches: ['eh_comprovante_pix=false']) unless extracted['eh_comprovante_pix'] + + mismatches = compare_proof(extracted, charge, unit) + verdict = mismatches.empty? ? 'ok' : 'duvida' + + text_response_for_verdict(charge, verdict, extracted: extracted, mismatches: mismatches) + rescue StandardError => e + Rails.logger.error("[Captain::Mcp::VerifyPixProofTool] error: #{e.class}: #{e.message}") + Rails.logger.error(e.backtrace.first(5).join("\n")) + error_response("Erro ao validar comprovante: #{e.message}") + end + + private + + def resolve_conversation(args, context) + conv_id = args['conversation_id'].presence || + context[:conversation_internal_id] || + context[:conversation_id] + return nil if conv_id.blank? + + Conversation.find_by(id: conv_id) || Conversation.find_by(display_id: conv_id) + end + + def resolve_charge(conversation, charge_id) + if charge_id.present? + charge = Captain::PixCharge.find_by(id: charge_id) + return charge if charge&.manual? + end + + Captain::PixCharge.manual.joins(:reservation) + .where(captain_reservations: { conversation_id: conversation.id }) + .where(status: %w[awaiting_proof active]) + .order(created_at: :desc).first + end + + def extract_proof_via_vision(image_url) + parts = [ + { type: 'text', text: vision_prompt }, + { type: 'image_url', image_url: { url: image_url } } + ] + content = RubyLLM::Content::Raw.new(parts) + + raw = RubyLLM.chat(model: vision_model) + .with_temperature(0) + .with_params(response_format: { type: 'json_object' }) + .ask(content) + .content.to_s + + JSON.parse(raw) + rescue JSON::ParserError => e + Rails.logger.warn("[Captain::Mcp::VerifyPixProofTool] JSON parse falhou: #{e.message} — raw=#{raw&.first(200)}") + { 'eh_comprovante_pix' => false, 'parse_error' => true } + end + + def vision_model + InstallationConfig.find_by(name: 'CAPTAIN_VISION_MODEL')&.value.presence || + ENV.fetch('CAPTAIN_VISION_MODEL', 'gpt-5.3-codex') + end + + def vision_prompt + <<~PROMPT + Você analisa comprovantes de PIX. Receba a imagem e extraia os dados em JSON ESTRITO (sem markdown, sem texto fora do JSON). + + Schema obrigatório: + { + "eh_comprovante_pix": boolean, // true se a imagem é claramente um comprovante de PIX (transferência), false se é qualquer outra coisa (selfie, foto de chave, screenshot de chat, comprovante de outro tipo, etc). + "valor": number | null, // valor transferido em reais (ex: 90.00). Sem cifrão, sem espaços, com ponto decimal. + "data_hora_iso": string | null, // data e hora da transação em ISO 8601 com timezone Brasília (-03:00) ou UTC. Ex: "2026-05-06T14:30:00-03:00". Se a imagem só mostrar data sem hora, use 12:00:00. + "beneficiario_nome": string | null, // nome do destinatário (quem recebeu). Pode ser nome de empresa, CPF/CNPJ formatado, etc. Texto literal extraído. + "beneficiario_chave": string | null, // chave PIX do destinatário se aparecer (CPF, CNPJ, email, telefone, ou chave aleatória UUID). Pode estar em qualquer formato. Texto literal. + "banco_destino": string | null, // banco destinatário (ex: "Stone", "Itaú", "Inter"). Texto literal. + "id_transacao": string | null, // ID/E2E/autenticação da transação (qualquer identificador único da transação que aparecer). + "remetente_nome": string | null, // nome de quem PAGOU (origem). Pode ajudar humano a auditar. + "suspeitas": [string] // lista vazia ou avisos: "imagem_borrada", "edicao_aparente", "fonte_inconsistente", "screenshot_de_screenshot", "valor_ilegivel", etc. SÓ liste suspeitas REAIS, não invente. + } + + Regras: + - Retorne APENAS o JSON. Sem prefixo, sem sufixo, sem ```json. + - Se algum campo não estiver na imagem, use null (não invente). + - Para valor: sempre número (90.00, não "R$ 90,00"). + - Se a imagem não for claramente um comprovante PIX, eh_comprovante_pix=false e os outros campos podem ser null. + PROMPT + end + + def compare_proof(extracted, charge, unit) + mismatches = [] + + expected_value = charge.original_value.to_f + actual_value = extracted['valor'].to_f + if expected_value <= 0 + mismatches << 'valor_esperado_indisponivel' + elsif (actual_value - expected_value).abs > VALUE_TOLERANCE + mismatches << "valor_divergente (esperado=R$ #{format('%.2f', expected_value)}, comprovante=R$ #{format('%.2f', actual_value)})" + end + + if extracted['data_hora_iso'].blank? + mismatches << 'data_ausente_no_comprovante' + else + parsed = parse_proof_time(extracted['data_hora_iso']) + if parsed.nil? + mismatches << "data_invalida (#{extracted['data_hora_iso']})" + elsif parsed > 1.hour.from_now + mismatches << "data_no_futuro (#{extracted['data_hora_iso']})" + elsif parsed < PROOF_FRESHNESS_HOURS.hours.ago + mismatches << "data_antiga (#{extracted['data_hora_iso']}, > #{PROOF_FRESHNESS_HOURS}h)" + end + end + + expected_owner = unit.manual_pix_owner_name.to_s + actual_owner = extracted['beneficiario_nome'].to_s + mismatches << "beneficiario_divergente (esperado='#{expected_owner}', comprovante='#{actual_owner}')" unless name_matches?(expected_owner, actual_owner) + + expected_key = normalize_pix_key(unit.manual_pix_key) + actual_key = normalize_pix_key(extracted['beneficiario_chave']) + if expected_key.present? && actual_key.present? && !key_matches?(expected_key, actual_key) + mismatches << "chave_divergente (esperada=#{unit.manual_pix_key}, comprovante=#{extracted['beneficiario_chave']})" + end + + expected_bank = unit.manual_pix_bank_name.to_s.downcase + actual_bank = extracted['banco_destino'].to_s.downcase + if expected_bank.present? && actual_bank.present? && !bank_matches?(expected_bank, actual_bank) + mismatches << "banco_divergente (esperado='#{unit.manual_pix_bank_name}', comprovante='#{extracted['banco_destino']}')" + end + + suspeitas = Array(extracted['suspeitas']).reject(&:blank?) + mismatches << "suspeitas_vision: #{suspeitas.join(', ')}" if suspeitas.any? + + mismatches + end + + # Match flexível pra nome do beneficiário: case-insensitive, sem + # acentos, ignora pontuação e múltiplos espaços. Considera match se + # uma string contém a outra OU se compartilham >= 70% das palavras + # significativas (>2 chars). + def name_matches?(expected, actual) + return false if expected.blank? || actual.blank? + + e = normalize_text(expected) + a = normalize_text(actual) + return true if e == a + return true if a.include?(e) || e.include?(a) + + e_words = e.split.select { |w| w.length > 2 } + a_words = a.split.select { |w| w.length > 2 } + return false if e_words.empty? + + matched = e_words.count { |w| a_words.any? { |aw| aw.include?(w) || w.include?(aw) } } + (matched.to_f / e_words.size) >= 0.7 + end + + def key_matches?(expected, actual) + return true if expected == actual + + expected.include?(actual) || actual.include?(expected) + end + + def bank_matches?(expected, actual) + actual.include?(expected) || expected.include?(actual) + end + + def normalize_pix_key(key) + key.to_s.downcase.gsub(/[^\w@.+-]/, '') + end + + def normalize_text(text) + text.to_s.unicode_normalize(:nfd).gsub(/\p{Mn}/, '') + .downcase.gsub(/[^a-z0-9\s]/, ' ').squish + end + + def parse_proof_time(raw) + Time.iso8601(raw) + rescue ArgumentError, TypeError + begin + Time.zone.parse(raw) + rescue ArgumentError + nil + end + end + + def text_response_for_verdict(charge, verdict, extracted:, mismatches:) + payload = { + verdict: verdict, + charge_id: charge.id, + reservation_id: charge.reservation_id, + expected: { + valor: charge.original_value.to_f, + beneficiario: charge.unit.manual_pix_owner_name, + chave: charge.unit.manual_pix_key, + banco: charge.unit.manual_pix_bank_name + }, + extracted: extracted.slice('valor', 'data_hora_iso', 'beneficiario_nome', 'beneficiario_chave', 'banco_destino', 'id_transacao', 'remetente_nome'), + mismatches: mismatches + } + + persist_extraction!(charge, extracted, mismatches, verdict) + + text_response("VERIFICACAO_COMPROVANTE\n#{JSON.pretty_generate(payload)}\n\n" \ + "Próximo passo:\n" \ + "- ok → criar_nota_interna(...) + confirmar_reserva_pix_manual(pix_charge_id=#{charge.id})\n" \ + "- duvida → criar_nota_interna(...) + marcar_reserva_pendente(pix_charge_id=#{charge.id}, motivo=...)\n" \ + '- nao_eh_comprovante → peça novamente o comprovante real (sem handoff, sem nota interna).') + end + + def persist_extraction!(charge, extracted, mismatches, verdict) + payload = { + 'verdict' => verdict, + 'extracted' => extracted, + 'mismatches' => mismatches, + 'verified_at' => Time.current.iso8601 + } + # rubocop:disable Rails/SkipsModelValidations + # Skip de validação proposital — payload é JSON livre, não tem + # validação no model. Update direto evita disparar callbacks (ex: + # post_internal_pix_sent_note iria postar nota duplicada). + charge.update_columns(manual_proof_payload: payload) + # rubocop:enable Rails/SkipsModelValidations + rescue StandardError => e + Rails.logger.warn("[Captain::Mcp::VerifyPixProofTool] persist failed: #{e.class} - #{e.message}") + end +end +# rubocop:enable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize, Layout/LineLength diff --git a/enterprise/app/services/captain/payments/confirmation_service.rb b/enterprise/app/services/captain/payments/confirmation_service.rb index 14c0a322b..b1660360b 100644 --- a/enterprise/app/services/captain/payments/confirmation_service.rb +++ b/enterprise/app/services/captain/payments/confirmation_service.rb @@ -66,6 +66,7 @@ class Captain::Payments::ConfirmationService when 'payment_callback' then 'callback de pagamento' when 'inter_cob_query_polling' then 'consulta periódica no Inter' when 'inter_cob_query' then 'consulta manual no Inter' + when 'manual_pix_proof' then 'comprovante PIX manual validado' else 'integração de pagamento' end