feat(captain): pix_mode manual_static pra Padova e Express

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) <noreply@anthropic.com>
This commit is contained in:
Rodribm10 2026-05-06 16:01:01 -03:00
parent c7d5bbff99
commit e94cadbdf6
10 changed files with 706 additions and 11 deletions

View File

@ -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

View File

@ -36,11 +36,29 @@ class Captain::PixCharge < ApplicationRecord
belongs_to :reservation, class_name: 'Captain::Reservation' belongs_to :reservation, class_name: 'Captain::Reservation'
belongs_to :unit, class_name: 'Captain::Unit' 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 :txid, presence: true, uniqueness: true
validates :unit_id, presence: 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 :post_internal_pix_sent_note
after_create_commit :enqueue_retention_recalc after_create_commit :enqueue_retention_recalc
after_update_commit :enqueue_retention_recalc_on_status_change after_update_commit :enqueue_retention_recalc_on_status_change
@ -64,16 +82,7 @@ class Captain::PixCharge < ApplicationRecord
conversation = reservation&.conversation conversation = reservation&.conversation
return if conversation.blank? return if conversation.blank?
value = original_value.to_f content = manual? ? manual_pix_note_content : inter_pix_note_content
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")
Messages::MessageBuilder.new( Messages::MessageBuilder.new(
nil, nil,
@ -110,6 +119,34 @@ class Captain::PixCharge < ApplicationRecord
return val.to_f if val.present? return val.to_f if val.present?
end end
if manual?
deposit = reservation&.metadata.to_h['deposit_amount']
return deposit.to_f if deposit.present?
end
reservation&.total_amount reservation&.total_amount
end 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 end

View File

@ -74,9 +74,14 @@ class Captain::Unit < ApplicationRecord
encrypts :inter_key_content encrypts :inter_key_content
enum status: { active: 'active', inactive: 'inactive' }, _default: 'active' 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 :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 :proactive_pix_polling_requires_inter_credentials
validate :manual_static_requires_manual_pix_fields
after_commit :enqueue_supabase_provisioning, on: :create after_commit :enqueue_supabase_provisioning, on: :create
@ -104,6 +109,13 @@ class Captain::Unit < ApplicationRecord
(inter_key_content.present? || resolved_inter_key_path.present?) (inter_key_content.present? || resolved_inter_key_path.present?)
end 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 def resolved_inter_cert_path
resolve_certificate_path(inter_cert_path) resolve_certificate_path(inter_cert_path)
end end
@ -128,6 +140,14 @@ class Captain::Unit < ApplicationRecord
) )
end 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 # Resolve o path do certificado — suporta caminho absoluto, relativo ao Rails.root
# ou nome de arquivo simples dentro de storage/certs/. # ou nome de arquivo simples dentro de storage/certs/.
def resolve_certificate_path(path) def resolve_certificate_path(path)

View File

@ -19,6 +19,11 @@ class Captain::Mcp::ToolRegistry
Captain::Mcp::Tools::RescheduleReservationTool, Captain::Mcp::Tools::RescheduleReservationTool,
Captain::Mcp::Tools::ReactToMessageTool, Captain::Mcp::Tools::ReactToMessageTool,
Captain::Mcp::Tools::CheckSuiteAvailabilityTool, 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 # Construtor (admin scope) — usadas pelo profile Hermes "construtor" pra criar novos agentes
Captain::Mcp::Tools::ListAssistantsTool, Captain::Mcp::Tools::ListAssistantsTool,
Captain::Mcp::Tools::GetAssistantPricingTool, Captain::Mcp::Tools::GetAssistantPricingTool,

View File

@ -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

View File

@ -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

View File

@ -80,6 +80,12 @@ class Captain::Mcp::Tools::GeneratePixTool < Captain::Mcp::Tools::BaseTool
unit = resolve_unit(conversation, context) unit = resolve_unit(conversation, context)
return error_response('Unidade do Captain não vinculada à inbox dessa conversa.') if unit.blank? 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 # 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 # invés de retornar erro pro LLM (que ele ia transformar em "vou
# verificar" e travar). Cliente recebe link da página oficial pra # 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) conversation.update_labels(merged)
end 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 # Fallback "leve" pra cenários onde Pix nem foi tentado (categoria não
# existe na unit, período inválido, sem credencial Inter cadastrada). # existe na unit, período inválido, sem credencial Inter cadastrada).
# Sem reservation/pricing/valores — só monta link com o que tem do # Sem reservation/pricing/valores — só monta link com o que tem do

View File

@ -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

View File

@ -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 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. 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

View File

@ -66,6 +66,7 @@ class Captain::Payments::ConfirmationService
when 'payment_callback' then 'callback de pagamento' when 'payment_callback' then 'callback de pagamento'
when 'inter_cob_query_polling' then 'consulta periódica no Inter' when 'inter_cob_query_polling' then 'consulta periódica no Inter'
when 'inter_cob_query' then 'consulta manual no Inter' when 'inter_cob_query' then 'consulta manual no Inter'
when 'manual_pix_proof' then 'comprovante PIX manual validado'
else else
'integração de pagamento' 'integração de pagamento'
end end