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:
parent
c7d5bbff99
commit
e94cadbdf6
19
db/migrate/20260506183716_add_manual_pix_to_captain_units.rb
Normal file
19
db/migrate/20260506183716_add_manual_pix_to_captain_units.rb
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user