iachat/enterprise/app/services/captain/mcp/tools/generate_pix_tool.rb
Rodribm10 e94cadbdf6 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>
2026-05-06 16:01:01 -03:00

514 lines
22 KiB
Ruby

# Tool MCP: gera cobrança Pix Inter pra reserva de uma suíte.
#
# Caso de uso: cliente confirmou reserva (categoria + dia + duração).
# Hermes invoca esta tool com os dados estruturados; o CAPTAIN calcula o
# valor (consultando Captain::Mcp::PricingTables — fonte de verdade
# backend) e dispara a cobrança Pix via integração Inter já existente.
#
# **NUNCA aceitamos `amount` do LLM** — isso evita que ele invente
# desconto VIP, cortesia ou erro de cálculo. O LLM só fornece os dados
# de classificação; a tabela hardcoded no Captain decide o valor.
#
# Fluxo:
# 1. Resolve Conversation → Inbox → Captain::Unit
# 2. Lookup pricing (Captain::Mcp::PricingTables.calculate)
# 3. Cria/reusa Captain::Reservation (status=draft)
# 4. Captain::Inter::CobService gera Pix (txid + copia-e-cola)
# 5. Posta mensagem outgoing na conversa com link curto do pagamento
# 6. Marca conversa com label `aguardando_pagamento`
# 7. Retorna resumo curto pro LLM (sem URL pra evitar reposta colada)
# rubocop:disable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize, Layout/LineLength
class Captain::Mcp::Tools::GeneratePixTool < Captain::Mcp::Tools::BaseTool
DEFAULT_DEPOSIT_RATIO = 0.5 # sinal padrão = 50% do total
HOURS_BY_PERIOD = {
'3h' => 3,
'pernoite_promo' => 13, # 21h → 10h
'pernoite_integral' => 13,
'diaria' => 24
}.freeze
class << self
def name
'generate_pix'
end
def description
'Gera cobrança Pix pro sinal da reserva (50% do total). Use quando o cliente ' \
'confirmou: categoria de suíte, dia/horário, e tem nome+CPF cadastrados. ' \
'O Captain CALCULA o valor pela tabela oficial — você só passa os dados de ' \
'classificação, NUNCA o valor. A tool envia o link do Pix direto pro cliente; ' \
'você só confirma que foi gerado.'
end
def input_schema
{
type: 'object',
properties: {
conversation_id: {
type: 'integer',
description: 'ID interno da conversa (cid do [ctx]). Obrigatório.'
},
suite_category: {
type: 'string',
description: 'Categoria da suíte (ex: "suite_master", "apartamento", "mini_chale_45", "chale_2_suites", "suite_ouro", "chale_master_4_suites"). Aceita variações naturais.'
},
period: {
type: 'string',
enum: %w[3h pernoite_promo pernoite_integral diaria],
description: 'Tipo de permanência. "pernoite_promo" = Dom-Qui (mais barato). ' \
'"pernoite_integral" = Sex/Sáb/Feriado/Véspera (mais caro). "3h" = permanência curta. "diaria" = 24h.'
},
total_guests: {
type: 'integer',
description: 'Quantidade TOTAL de hóspedes (não só os extras). Default 2 (casal). A taxa de pessoa extra é calculada automaticamente conforme regra da categoria.',
default: 2
},
check_in_date: {
type: 'string',
description: 'Data de check-in (YYYY-MM-DD ou DD/MM/YYYY). Default: hoje no fuso da conta.'
}
},
required: %w[conversation_id suite_category period]
}
end
end
def call(args, context:)
conversation = resolve_conversation(args, context)
return error_response('Conversa não encontrada. Passe conversation_id (cid do [ctx]) em arguments.') if conversation.blank?
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
# finalizar manualmente — UX uniforme.
return dispatch_no_pricing_fallback!(conversation, unit, args, 'inter_credentials_missing') unless unit.inter_credentials_present?
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
)
# Erro estrutural (categoria não existe nessa unit, período inválido,
# dia indisponível). Antes retornava error_response e o LLM travava em
# "Um momento" sem mandar nada. Agora despacha link da página de
# reserva pra cliente concluir lá — UX consistente, marca pra triagem.
if pricing[:error].present?
Rails.logger.warn("[Captain::Mcp::GeneratePixTool] pricing inválido — usando fallback: #{pricing[:error]}")
return dispatch_no_pricing_fallback!(conversation, unit, args, "pricing: #{pricing[:error]}")
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)
begin
charge = Captain::Inter::CobService.new(reservation, amount: deposit).call
rescue StandardError => e
Rails.logger.error("[Captain::Mcp::GeneratePixTool] Inter falhou — usando fallback: #{e.class}: #{e.message}")
return dispatch_fallback_link!(conversation, unit, reservation, pricing, total_amount, deposit)
end
# Move da fase 'draft' pra 'pending_payment' — agora a reservation
# aparece nas abas/Kanban "Aguardando PIX" do painel.
reservation.update!(status: :pending_payment)
dispatch_link_message(conversation, charge, deposit)
mark_awaiting_payment(conversation)
text_response(
"Pix gerado: sinal R$ #{format('%.2f',
deposit)} (50% de R$ #{format('%.2f',
total_amount)}#{pricing[:breakdown][:suite_category]} / #{pricing[:breakdown][:period]}). Link enviado em mensagem separada na conversa #{conversation.display_id}."
)
rescue StandardError => e
Rails.logger.error("[Captain::Mcp::GeneratePixTool] error: #{e.class}: #{e.message}")
Rails.logger.error(e.backtrace.first(5).join("\n"))
error_response("Erro ao gerar Pix: #{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
# Resolve unit em 3 níveis (defesa em profundidade contra divergência
# entre Captain::Assistant.captain_unit_id e CaptainInbox.captain_unit_id):
# 1. Assistant.captain_unit (autoritativo — setado por hermes-provision
# e admin UI; não vaza entre agentes que compartilham inbox).
# 2. CaptainInbox legacy (fallback pré-engine column; só funciona se
# a inbox tem 1 agente único).
# 3. Captain::Unit.inbox_id legacy (fallback antigo, antes de CaptainInbox).
def resolve_unit(conversation, context = nil)
asst_id = context && (context[:assistant_id] || context['assistant_id'])
if asst_id
asst = Captain::Assistant.find_by(id: asst_id)
return asst.captain_unit if asst&.captain_unit_id.present?
end
captain_inbox = CaptainInbox.find_by(inbox_id: conversation.inbox_id)
return captain_inbox.captain_unit if captain_inbox&.captain_unit.present?
Captain::Unit.find_by(account_id: conversation.account_id, inbox_id: conversation.inbox_id)
end
def identity_missing_fields(contact)
missing = []
missing << 'nome completo' if contact&.name.to_s.squish.length < 3
cpf_digits = contact&.custom_attributes.to_h.with_indifferent_access[:cpf].to_s.gsub(/\D/, '')
missing << 'CPF' if cpf_digits.length != 11
missing
end
CPF_REGEX = /\b(\d{3}\.?\d{3}\.?\d{3}-?\d{2}|\d{11})\b/
NAME_RUN_REGEX = /\A([\p{L}'\-]{3,}(?:\s+[\p{L}'\-]{2,}){1,5})/u
# Cliente normalmente envia nome+CPF junto numa mensagem ("Rodrigo Borba 12345678901").
# Quando o contact ainda não tem CPF/nome cadastrados, varremos as últimas
# 10 mensagens incoming pra extrair e popular antes de chamar a Inter.
def hydrate_contact_from_recent_messages!(contact, conversation)
return if contact.blank?
needs_cpf = contact.custom_attributes.to_h.with_indifferent_access[:cpf].to_s.gsub(/\D/, '').length != 11
needs_name = contact.name.to_s.squish.length < 3
return unless needs_cpf || needs_name
extracted_cpf = nil
extracted_name = nil
conversation.messages
.where(message_type: :incoming, sender_type: 'Contact')
.reorder(created_at: :desc)
.limit(10).each do |msg|
text = msg.content.to_s
extracted_cpf ||= extract_cpf(text) if needs_cpf
extracted_name ||= extract_name(text) if needs_name
break if (!needs_cpf || extracted_cpf) && (!needs_name || extracted_name)
end
updates = {}
updates[:name] = extracted_name if needs_name && extracted_name.present?
updates[:custom_attributes] = contact.custom_attributes.to_h.merge('cpf' => extracted_cpf) if needs_cpf && extracted_cpf.present?
return if updates.empty?
contact.update!(updates)
Rails.logger.info("[Captain::Mcp::GeneratePixTool] hydrated contact #{contact.id} with #{updates.keys}")
rescue StandardError => e
Rails.logger.warn("[Captain::Mcp::GeneratePixTool] hydrate failed: #{e.class} - #{e.message}")
end
def extract_cpf(text)
digits = text.to_s.scan(CPF_REGEX).flatten.first.to_s.gsub(/\D/, '')
digits.length == 11 ? digits : nil
end
# Extrai 2-6 palavras alfabéticas seguidas no início do texto, ignorando
# números/pontuação ao redor.
def extract_name(text)
cleaned = text.to_s.gsub(/[\d.,;:\-()]+/, ' ').squish
match = cleaned.match(NAME_RUN_REGEX)
return nil if match.nil?
candidate = match[1].strip
return nil if candidate.length < 5
candidate.split.map(&:capitalize).join(' ')
end
# Reusa a draft mais recente da conversa (últimas 2h) ou cria nova.
# Atualiza campos com base nos novos args (categoria pode ter mudado).
def build_or_update_reservation!(conversation, unit, args, pricing, total_amount, deposit)
check_in_at = parse_check_in(args['check_in_date'], conversation.account)
period_hours = HOURS_BY_PERIOD[pricing[:breakdown][:period]] || 13
check_out_at = check_in_at + period_hours.hours
reservation = recent_draft_for(conversation) || Captain::Reservation.new(
account: conversation.account,
inbox: conversation.inbox,
contact: conversation.contact,
contact_inbox: conversation.contact_inbox,
conversation: conversation,
captain_unit_id: unit.id
)
reservation.assign_attributes(
suite_identifier: pricing[:breakdown][:suite_category],
check_in_at: check_in_at,
check_out_at: check_out_at,
status: :draft,
payment_status: 'pending',
total_amount: total_amount,
metadata: (reservation.metadata.to_h).merge(
'full_amount' => total_amount,
'deposit_amount' => deposit,
'created_by' => 'mcp_generate_pix_tool',
'pricing_breakdown' => pricing[:breakdown].stringify_keys
)
)
reservation.save!
reservation
end
def recent_draft_for(conversation)
Captain::Reservation
.where(conversation_id: conversation.id, status: 'draft')
.where('updated_at > ?', 2.hours.ago)
.order(updated_at: :desc).first
end
def parse_check_in(raw, account)
tz = account.respond_to?(:timezone) ? (account.timezone.presence || Time.zone.name) : Time.zone.name
Time.use_zone(tz) do
base = if raw.blank?
Time.zone.today
else
try_parse_date(raw) || Time.zone.today
end
Time.zone.local(base.year, base.month, base.day, 21, 0, 0) # entrada padrão 21h
end
end
def try_parse_date(raw)
Date.iso8601(raw)
rescue ArgumentError
begin
Date.strptime(raw, '%d/%m/%Y')
rescue ArgumentError
nil
end
end
def dispatch_link_message(conversation, charge, deposit)
base_url = InstallationConfig.find_by(name: 'FRONTEND_URL')&.value.presence ||
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
base_url = base_url.gsub('0.0.0.0', '127.0.0.1') if base_url.include?('0.0.0.0')
token = charge.to_sgid(expires_in: 2.hours, purpose: :pix_payment).to_s
link = Rails.application.routes.url_helpers.short_payment_link_url(token, host: base_url)
body = "💸 *Pix do sinal — R$ #{format('%.2f', deposit)}*\n\n" \
"Abra o link abaixo pra ver o QR Code e copiar o código Pix:\n#{link}\n\n" \
'Sua reserva fica confirmada automaticamente assim que o pagamento cair (alguns segundos).'
Messages::MessageBuilder.new(
nil,
conversation,
content: body,
message_type: 'outgoing'
).perform
rescue StandardError => e
Rails.logger.warn("[Captain::Mcp::GeneratePixTool] failed to dispatch link: #{e.class} - #{e.message}")
end
def mark_awaiting_payment(conversation)
current = conversation.label_list
merged = (current + ['aguardando_pagamento']).uniq - %w[pagamento_confirmado reserva_feita]
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
# contact + args. UX é igual ao fallback de Inter falhar: cliente recebe
# link pra página oficial e conversa fica marcada pra triagem.
def dispatch_no_pricing_fallback!(conversation, unit, args, reason_code)
base = ENV.fetch('RESERVA_1001_BASE_URL',
InstallationConfig.find_by(name: 'RESERVA_1001_BASE_URL')&.value.presence ||
'https://reservas.hoteis1001noites.com.br')
contact = conversation.contact
custom = contact&.custom_attributes.to_h.with_indifferent_access
params = {
marca: unit&.brand&.name,
unidade: unit&.name,
permanencia: humanize_period(args['period'].to_s),
categoria: humanize_category(args['suite_category'].to_s),
checkin: parse_check_in(args['check_in_date'], conversation.account)&.iso8601,
nome: contact&.name,
telefone: contact&.phone_number,
cpf: custom[:cpf],
email: contact&.email
}.compact.reject { |_, v| v.to_s.strip.empty? }
url = "#{base.chomp('/')}/?#{URI.encode_www_form(params)}"
body = 'Pra evitar qualquer atrito no fechamento, é só finalizar pela página oficial ' \
"(seus dados já estão pré-preenchidos):\n#{url}"
Messages::MessageBuilder.new(nil, conversation, content: body, message_type: 'outgoing').perform
current = conversation.label_list
conversation.update_labels((current + %w[aguardando_pagamento pix_falhou_fallback]).uniq)
text_response(
"Pix indisponível (motivo=#{reason_code}). Mandei link da página de reserva pro cliente. " \
'Marquei conversa com pix_falhou_fallback pra gerência ver. NÃO repita o link nem fale sobre o problema técnico — ' \
'só confirme com o cliente que o link foi enviado.'
)
end
# Quando a Inter API falha (auth, certificado, timeout, etc), em vez de
# devolver erro, mandamos o cliente pra página oficial de reserva
# (reserva-1001) com query string preenchida. Cliente conclui por lá.
# Marca a conversa com `pix_falhou_fallback` pra triagem da gerência.
def dispatch_fallback_link!(conversation, unit, reservation, pricing, total_amount, deposit)
base = ENV.fetch('RESERVA_1001_BASE_URL',
InstallationConfig.find_by(name: 'RESERVA_1001_BASE_URL')&.value.presence ||
'https://reservas.hoteis1001noites.com.br')
contact = conversation.contact
custom = contact&.custom_attributes.to_h.with_indifferent_access
params = {
marca: unit.brand&.name,
unidade: unit.name,
permanencia: humanize_period(pricing[:breakdown][:period]),
categoria: humanize_category(pricing[:breakdown][:suite_category]),
checkin: reservation.check_in_at&.iso8601,
nome: contact&.name,
telefone: contact&.phone_number,
cpf: custom[:cpf],
email: contact&.email
}.compact.reject { |_, v| v.to_s.strip.empty? }
url = "#{base.chomp('/')}/?#{URI.encode_www_form(params)}"
body = 'Tive um problema técnico pra gerar o Pix por aqui — mas tudo certo, é só finalizar pela página oficial ' \
"(seus dados já estão pré-preenchidos):\n#{url}"
Messages::MessageBuilder.new(nil, conversation, content: body, message_type: 'outgoing').perform
current = conversation.label_list
conversation.update_labels((current + %w[aguardando_pagamento pix_falhou_fallback]).uniq)
text_response(
"Inter API indisponível. Link da página de reserva enviado pro cliente (R$ #{format('%.2f',
total_amount)} total / R$ #{format('%.2f',
deposit)} sinal). Marquei conversa com pix_falhou_fallback."
)
end
PERIOD_LABELS = {
'3h' => '3hrs',
'pernoite_promo' => 'Pernoite',
'pernoite_integral' => 'Pernoite',
'diaria' => 'Diaria'
}.freeze
def humanize_period(period_key)
PERIOD_LABELS[period_key] || period_key.to_s.humanize
end
CATEGORY_LABELS = {
'apartamento' => 'Apartamento',
'suite_master' => 'Suite Master',
'suite_luxo' => 'Suite Luxo',
'suite_tematica' => 'Suite Tematica',
'mini_chale_45' => 'Mini Chale 45',
'chale_2_suites' => 'Chale 2 Suites',
'suite_ouro' => 'Suite Ouro',
'chale_master_4_suites' => 'Chale Master 4 Suites'
}.freeze
def humanize_category(cat_key)
CATEGORY_LABELS[cat_key] || cat_key.to_s.tr('_', ' ').split.map(&:capitalize).join(' ')
end
end
# rubocop:enable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize, Layout/LineLength