Three UX bugs from staging testing: 1. Duplicate Pix link in WhatsApp — the tool's formatted_message embedded the full link + instructions, so the LLM copied it into its own response on top of the dedicated link message sent by dispatch_direct_link_message. The tool now returns a short summary with no URL; dispatch is the single source of the link. 2. "Reserva confirmada!" sent before payment — the scenario prompt used the word "confirmação" loosely, which the LLM read as the reservation being closed. Now the prompt forces "pré-reserva / aguardando pagamento" until the Pix is actually paid, and the dispatched link message explains that the reservation is only secured after payment clears. 3. Memory extraction wrote "Reservou Hidromassagem para pernoite em 22/04/2026" when the customer only received a Pix link and replied "obrigado". Tightened the extraction prompt so padrao_comportamental of a reservation requires a literal payment confirmation — Pix generated alone no longer qualifies.
839 lines
29 KiB
Ruby
839 lines
29 KiB
Ruby
class Captain::Tools::GeneratePixTool < Captain::Tools::BaseTool
|
|
CPF_WITH_LABEL_REGEX = /cpf[^\d]*(\d{3}\.?\d{3}\.?\d{3}-?\d{2}|\d{11})/i
|
|
CPF_FALLBACK_REGEX = /\b\d{11}\b/
|
|
NAME_WITH_LABEL_REGEX = /nome\s*[:\-]\s*([^\n\r,;]+)/i
|
|
EMAIL_REGEX = /\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b/
|
|
SUITE_REGEX = /su[ií]te\s+([^\n\r,.!?]+)/i
|
|
DDMMYYYY_REGEX = %r{\b(\d{1,2}/\d{1,2}(?:/\d{2,4})?)\b}
|
|
CURRENCY_REGEX = /r\$\s*([\d.,]+)/i
|
|
TOTAL_AMOUNT_REGEX = /valor\s+total[^\n\r]{0,80}/i
|
|
DEPOSIT_AMOUNT_REGEX = /(sinal|entrada)[^\n\r]{0,80}/i
|
|
NIGHTS_REGEX = /(\d+)\s*(?:noite|noites|di[aá]ria|di[aá]rias)/i
|
|
ONE_NIGHT_REGEX = /\b(uma|um)\s+pernoite\b/i
|
|
|
|
def name
|
|
'generate_pix'
|
|
end
|
|
|
|
def description
|
|
'Gera uma cobrança Pix para a reserva da conversa. Se ainda não existir rascunho, tenta criar automaticamente. ' \
|
|
'Retorna um objeto com formatted_message (link de pagamento) e raw_payload (código copia e cola).'
|
|
end
|
|
|
|
def tool_parameters_schema
|
|
{
|
|
type: 'object',
|
|
properties: {
|
|
amount: {
|
|
type: 'number',
|
|
description: 'Opcional. Valor exato a cobrar. Se informado, substitui o valor padrão da reserva.'
|
|
},
|
|
suite: {
|
|
type: 'string',
|
|
description: 'Opcional. Nome da suíte.'
|
|
},
|
|
check_in: {
|
|
type: 'string',
|
|
description: 'Opcional. Data de check-in (YYYY-MM-DD ou DD/MM/YYYY).'
|
|
},
|
|
check_out: {
|
|
type: 'string',
|
|
description: 'Opcional. Data de check-out (YYYY-MM-DD ou DD/MM/YYYY).'
|
|
},
|
|
nights: {
|
|
type: 'integer',
|
|
description: 'Opcional. Quantidade de noites para inferir check-out.'
|
|
},
|
|
total_amount: {
|
|
type: 'number',
|
|
description: 'Opcional. Valor total da reserva.'
|
|
}
|
|
}
|
|
}
|
|
end
|
|
|
|
MAX_PIX_AMOUNT = 50_000.0
|
|
MIN_PIX_AMOUNT = 1.0
|
|
|
|
def execute(*args, **params)
|
|
actual_params = resolve_params(args, params)
|
|
@conversation ||= resolve_conversation(args, params)
|
|
return error_response('Erro técnico ao gerar o Pix. Não foi possível identificar a conversa atual.') if @conversation.blank?
|
|
|
|
input_amount = actual_params[:amount].to_s.gsub(/[^\d,.]/, '').tr(',', '.')
|
|
override_amount = input_amount.to_f if input_amount.present? && input_amount.to_f.positive?
|
|
|
|
if override_amount
|
|
return error_response("Valor mínimo para Pix é R$ #{format('%.2f', MIN_PIX_AMOUNT)}") if override_amount < MIN_PIX_AMOUNT
|
|
return error_response("Valor máximo para Pix é R$ #{format('%.2f', MAX_PIX_AMOUNT)}") if override_amount > MAX_PIX_AMOUNT
|
|
end
|
|
|
|
contact = @conversation.contact
|
|
hydrate_contact_identity_from_conversation!(contact, @conversation)
|
|
|
|
missing = []
|
|
missing << 'name' if valid_contact_name?(contact.name).blank?
|
|
missing << 'cpf' if contact_cpf(contact).blank?
|
|
return missing_identity_response(missing) if missing.any?
|
|
|
|
# Verifica se já existe reserva pendente de pagamento
|
|
pending = Captain::Reservation.where(conversation_id: @conversation.id, status: 'pending_payment').last
|
|
return handle_pending_reservation(pending, override_amount) if pending
|
|
|
|
reservation = find_recent_draft_reservation
|
|
unless reservation
|
|
reservation = ensure_draft_reservation!(actual_params, override_amount)
|
|
return reservation if reservation.is_a?(Hash)
|
|
end
|
|
|
|
charge_amount = override_amount || default_charge_amount(reservation)
|
|
merge_reservation_amount_metadata!(reservation, deposit_amount: charge_amount)
|
|
Rails.logger.info "[GeneratePixTool] Reserva ID #{reservation.id} | Valor: #{charge_amount}"
|
|
generate_new_pix(reservation, amount: charge_amount)
|
|
end
|
|
|
|
private
|
|
|
|
def resolve_params(args, params)
|
|
merged = params.to_h
|
|
|
|
args.each do |arg|
|
|
next unless arg.is_a?(Hash)
|
|
next if tool_context_hash?(arg)
|
|
|
|
merged = arg.merge(merged)
|
|
end
|
|
|
|
merged.with_indifferent_access
|
|
end
|
|
|
|
def hydrate_contact_identity_from_conversation!(contact, conversation)
|
|
return if contact.blank? || conversation.blank?
|
|
|
|
extracted = extract_identity_from_recent_messages(conversation)
|
|
return if extracted.blank?
|
|
|
|
updates = {}
|
|
current_custom_attributes = contact.custom_attributes.to_h
|
|
|
|
updates[:custom_attributes] = current_custom_attributes.merge('cpf' => extracted[:cpf]) if contact_cpf(contact).blank? && extracted[:cpf].present?
|
|
|
|
updates[:name] = extracted[:name] if valid_contact_name?(contact.name).blank? && extracted[:name].present?
|
|
|
|
updates[:email] = extracted[:email] if contact.email.blank? && extracted[:email].present?
|
|
|
|
return if updates.blank?
|
|
|
|
contact.update!(updates)
|
|
Rails.logger.info("[GeneratePixTool] Contato #{contact.id} hidratado com dados da conversa")
|
|
rescue StandardError => e
|
|
Rails.logger.warn("[GeneratePixTool] Falha ao hidratar contato #{contact&.id}: #{e.class} - #{e.message}")
|
|
end
|
|
|
|
def extract_identity_from_recent_messages(conversation)
|
|
recent_messages = conversation.messages
|
|
.where(message_type: :incoming, sender_type: 'Contact')
|
|
.reorder(created_at: :desc)
|
|
.limit(20)
|
|
|
|
cpf = nil
|
|
name = nil
|
|
email = nil
|
|
|
|
recent_messages.each do |message|
|
|
content = normalize_text(message.content)
|
|
next if content.blank?
|
|
|
|
cpf ||= extract_cpf_from_text(content)
|
|
name ||= extract_name_from_text(content)
|
|
email ||= extract_email_from_text(content)
|
|
break if cpf.present? && name.present? && email.present?
|
|
end
|
|
|
|
# Fallback: quando o cliente responde só com o nome cru logo após a IA
|
|
# perguntar "qual seu nome completo?", a regex com prefixo `nome:` não
|
|
# pega. Procuramos pelo pattern pergunta->resposta nas mensagens.
|
|
name ||= extract_name_from_qa_pattern(conversation)
|
|
|
|
{
|
|
cpf: cpf,
|
|
name: name,
|
|
email: email
|
|
}.compact
|
|
end
|
|
|
|
# Lê as mensagens na ordem em que aconteceram, acha a última vez em que o
|
|
# agente perguntou pelo nome completo, e pega a próxima mensagem do cliente
|
|
# como candidata a nome. Aceita só se o texto parecer de fato um nome
|
|
# (2+ palavras alfabéticas de 3+ letras, sem dígitos).
|
|
def extract_name_from_qa_pattern(conversation)
|
|
all_msgs = conversation.messages
|
|
.where(private: false)
|
|
.reorder(created_at: :asc)
|
|
.to_a
|
|
|
|
last_ask = nil
|
|
all_msgs.each do |m|
|
|
next unless m.message_type.to_s == 'outgoing'
|
|
|
|
content = normalize_text(m.content)
|
|
next if content.blank?
|
|
next unless content.match?(/nome\s+completo|confirmar.*nome|seu\s+nome/i)
|
|
|
|
last_ask = m
|
|
end
|
|
return if last_ask.nil?
|
|
|
|
answer = all_msgs.find do |m|
|
|
m.message_type.to_s == 'incoming' &&
|
|
m.sender_type.to_s == 'Contact' &&
|
|
m.created_at > last_ask.created_at
|
|
end
|
|
return if answer.nil?
|
|
|
|
raw = normalize_text(answer.content)
|
|
extract_name_run_from_text(raw)
|
|
end
|
|
|
|
# Extrai um "run" alfabético de 2-6 palavras (3+ letras cada) de dentro
|
|
# de um texto que pode conter CPF, vírgulas, etc.
|
|
# Ex: "Rodrigo Borba Machado, 00251938131" → "Rodrigo Borba Machado"
|
|
# Ex: "Ta ok" → nil (primeira palavra tem 2 letras)
|
|
# Ex: "00251938131" → nil (só dígitos)
|
|
def extract_name_run_from_text(text)
|
|
normalized = normalize_text(text).gsub(/[,;:]/, ' ').squish
|
|
return if normalized.blank?
|
|
|
|
tokens = normalized.split(/\s+/)
|
|
alpha_tokens = tokens.take_while { |w| w.match?(/\A[\p{L}'\-]+\z/u) && w.length >= 3 }
|
|
return if alpha_tokens.length < 2
|
|
return if alpha_tokens.length > 6
|
|
|
|
alpha_tokens.join(' ').titleize
|
|
end
|
|
|
|
def extract_email_from_text(text)
|
|
text = normalize_text(text)
|
|
candidate = text[EMAIL_REGEX]
|
|
candidate.to_s.strip.downcase.presence
|
|
end
|
|
|
|
def extract_cpf_from_text(text)
|
|
text = normalize_text(text)
|
|
candidate = text[CPF_WITH_LABEL_REGEX, 1]
|
|
candidate ||= text[CPF_FALLBACK_REGEX]
|
|
|
|
digits_only = candidate.to_s.gsub(/\D/, '')
|
|
return if digits_only.length != 11
|
|
|
|
digits_only
|
|
end
|
|
|
|
def extract_name_from_text(text)
|
|
text = normalize_text(text)
|
|
candidate = text[NAME_WITH_LABEL_REGEX, 1].to_s.squish
|
|
return if candidate.blank? || candidate.length < 3
|
|
|
|
candidate.titleize
|
|
end
|
|
|
|
def contact_cpf(contact)
|
|
attrs = contact.custom_attributes.to_h.with_indifferent_access
|
|
digits_only = attrs[:cpf].to_s.gsub(/\D/, '')
|
|
return if digits_only.length != 11
|
|
|
|
digits_only
|
|
end
|
|
|
|
def valid_contact_name?(name)
|
|
candidate = name.to_s.squish
|
|
return if candidate.blank?
|
|
return unless candidate.match?(/\p{L}/)
|
|
|
|
candidate
|
|
end
|
|
|
|
def missing_cpf_response
|
|
missing_identity_response(['cpf'])
|
|
end
|
|
|
|
def missing_name_response
|
|
missing_identity_response(['name'])
|
|
end
|
|
|
|
# Quando CPF e nome faltam juntos, pede os dois em UMA única mensagem
|
|
# pra evitar aquele vai-e-vem de "preciso do CPF" / "preciso do nome".
|
|
def missing_identity_response(missing_fields)
|
|
has_name = missing_fields.include?('name')
|
|
has_cpf = missing_fields.include?('cpf')
|
|
|
|
msg = if has_name && has_cpf
|
|
'Perfeito! Pra fechar a reserva e gerar o Pix, me manda numa única mensagem: ' \
|
|
'seu nome completo e seu CPF (11 dígitos). Exemplo: "João da Silva, 12345678900".'
|
|
elsif has_cpf
|
|
'Para gerar o Pix e seguir com sua reserva, preciso do seu CPF com 11 dígitos. ' \
|
|
'Pode me enviar agora? Se preferir, pode mandar só os números.'
|
|
else
|
|
'Perfeito. Para gerar o Pix, preciso confirmar seu nome completo. Pode me informar?'
|
|
end
|
|
|
|
normalize_payload(
|
|
{
|
|
formatted_message: msg,
|
|
success: true,
|
|
requires_input: true,
|
|
missing_fields: missing_fields,
|
|
missing_field: missing_fields.first
|
|
}
|
|
)
|
|
end
|
|
|
|
def missing_reservation_details_response(missing_fields)
|
|
labels = []
|
|
labels << 'a suíte desejada' if missing_fields.include?('suite')
|
|
labels << 'a data de check-in' if missing_fields.include?('check_in')
|
|
labels << 'a duração da estadia (quantidade de noites)' if missing_fields.include?('check_out')
|
|
labels << 'o valor total da reserva' if missing_fields.include?('amount')
|
|
|
|
normalize_payload(
|
|
{
|
|
formatted_message: "Para gerar o Pix certinho, preciso confirmar #{human_sentence(labels)}. Pode me passar?",
|
|
success: true,
|
|
requires_input: true,
|
|
missing_fields: missing_fields
|
|
}
|
|
)
|
|
end
|
|
|
|
def resolve_conversation(args, params)
|
|
state = extract_state(args, params)
|
|
return nil if state.blank?
|
|
|
|
conversation_state = state[:conversation] || state['conversation'] || {}
|
|
conversation_id = conversation_state[:id] || conversation_state['id']
|
|
display_id = conversation_state[:display_id] || conversation_state['display_id']
|
|
account_id = state[:account_id] || state['account_id']
|
|
|
|
conversation = Conversation.find_by(id: conversation_id) if conversation_id.present?
|
|
return conversation if conversation.present?
|
|
return nil if display_id.blank?
|
|
|
|
scope = Conversation.where(display_id: display_id)
|
|
scope = scope.where(account_id: account_id) if account_id.present?
|
|
scope.first
|
|
end
|
|
|
|
def extract_state(args, params)
|
|
context_sources = [
|
|
*args,
|
|
params[:tool_context],
|
|
params['tool_context'],
|
|
params[:context_wrapper],
|
|
params['context_wrapper'],
|
|
params[:context],
|
|
params['context']
|
|
].compact
|
|
|
|
context_sources.each do |source|
|
|
state = extract_state_from_source(source)
|
|
return state if state.present?
|
|
end
|
|
|
|
{}
|
|
end
|
|
|
|
def extract_state_from_source(source)
|
|
return source.state if source.respond_to?(:state)
|
|
|
|
if source.respond_to?(:context)
|
|
context = source.context
|
|
return context[:state] || context['state'] if context.is_a?(Hash)
|
|
end
|
|
|
|
return nil unless source.is_a?(Hash)
|
|
|
|
source[:state] || source['state'] || source.dig(:context, :state) || source.dig('context', 'state')
|
|
end
|
|
|
|
def tool_context_hash?(hash)
|
|
hash.key?(:state) ||
|
|
hash.key?('state') ||
|
|
hash.key?(:context) ||
|
|
hash.key?('context') ||
|
|
hash.key?(:conversation) ||
|
|
hash.key?('conversation')
|
|
end
|
|
|
|
def handle_pending_reservation(pending, override_amount)
|
|
charge_amount = default_charge_amount(pending)
|
|
|
|
if override_amount
|
|
Rails.logger.info "[GeneratePixTool] Valor alterado para #{override_amount}. Forçando novo Pix."
|
|
merge_reservation_amount_metadata!(pending, deposit_amount: override_amount)
|
|
Captain::PixCharge.where(reservation_id: pending.id).update_all(status: 'expired')
|
|
return generate_new_pix(
|
|
pending,
|
|
amount: override_amount,
|
|
prefix: "Atualizei o valor para R$ #{format('%.2f', override_amount)}. Novo Pix abaixo:"
|
|
)
|
|
end
|
|
|
|
charge = current_pix_charge_for(pending)
|
|
if charge&.pix_copia_e_cola.present?
|
|
if charge.expired? || charge.expired_by_time?
|
|
charge.update!(status: 'expired') unless charge.expired?
|
|
return generate_new_pix(pending, amount: charge_amount, prefix: 'Pix expirado. Gerando um novo agora.')
|
|
end
|
|
|
|
return build_pix_response(charge, pending, amount: charge_amount, prefix: 'Pix ainda válido. Segue abaixo para pagamento:')
|
|
end
|
|
|
|
generate_new_pix(pending, amount: charge_amount, prefix: 'Nenhuma cobrança ativa encontrada. Gerando um novo Pix.')
|
|
end
|
|
|
|
def error_response(msg)
|
|
normalize_payload({ formatted_message: msg, success: false })
|
|
end
|
|
|
|
def tool_feedback_response(msg)
|
|
normalize_payload({ formatted_message: msg, success: true })
|
|
end
|
|
|
|
def dispatch_direct_link_message(link, label)
|
|
return if @conversation.blank? || link.to_s.strip.empty?
|
|
|
|
content = "#{label}\n#{link}".strip
|
|
Messages::MessageBuilder.new(@assistant, @conversation, {
|
|
content: content,
|
|
message_type: 'outgoing'
|
|
}).perform
|
|
rescue StandardError => e
|
|
Rails.logger.warn("[GeneratePixTool] failed to dispatch direct link: #{e.class} - #{e.message}")
|
|
end
|
|
|
|
def current_pix_charge_for(reservation)
|
|
return nil unless reservation
|
|
|
|
Captain::PixCharge.where(reservation_id: reservation.id).order(created_at: :desc).first
|
|
end
|
|
|
|
def generate_new_pix(reservation, amount: nil, prefix: nil)
|
|
charge_amount = amount || default_charge_amount(reservation)
|
|
service = Captain::Inter::CobService.new(reservation, amount: charge_amount)
|
|
charge = service.call
|
|
|
|
reservation.update!(status: 'pending_payment')
|
|
mark_conversation_as_awaiting_payment(reservation)
|
|
Rails.logger.info "[GeneratePixTool] Reserva #{reservation.id} → pending_payment"
|
|
|
|
response = build_pix_response(charge, reservation, amount: charge_amount, prefix: prefix)
|
|
|
|
# Envia o link como mensagem direta pro cliente. Isso garante que o URL chegue
|
|
# no WhatsApp mesmo que a LLM parafraseie com placeholder tipo "[Link do Pix]".
|
|
# A mensagem do link é a ÚNICA com URL — a resposta da LLM não deve conter link.
|
|
dispatch_direct_link_message(
|
|
response[:payment_link],
|
|
"Pix do sinal (R$ #{format('%.2f', charge_amount)}) — sua reserva só fica garantida depois que o pagamento cair:"
|
|
)
|
|
|
|
response
|
|
rescue StandardError => e
|
|
safe_error_message = normalize_text(e.message)
|
|
Rails.logger.error("[GeneratePixTool] Falha ao gerar Pix: #{e.class} - #{safe_error_message}")
|
|
mapped_error = map_pix_error_message(e)
|
|
return mapped_error if mapped_error.is_a?(Hash)
|
|
|
|
# Erro real na geração do Pix deve voltar success: false pra LLM
|
|
# cair no fluxo de fallback (generate_reservation_link) em vez de
|
|
# alucinar que o Pix foi gerado.
|
|
error_response(mapped_error)
|
|
end
|
|
|
|
def map_pix_error_message(error)
|
|
msg = normalize_text(error.message)
|
|
msg_downcase = msg.downcase
|
|
return missing_cpf_response if msg.match?(/cpf/i)
|
|
|
|
if msg_downcase.include?('login/senha inválido') || msg_downcase.include?('login/senha invalido')
|
|
return 'Não foi possível gerar o Pix — login/senha inválidos na integração Inter. Peça para o gestor revisar Client ID/Secret/certificados.'
|
|
end
|
|
if msg_downcase.include?('unit not configured for pix')
|
|
return 'Não foi possível gerar o Pix — unidade não configurada para Pix. Revise chave Pix e dados Inter.'
|
|
end
|
|
if msg_downcase.include?('certificate file not found') || msg_downcase.include?('key file not found')
|
|
return 'Não foi possível gerar o Pix — certificado ausente. Revise os arquivos .crt/.key da integração Inter.'
|
|
end
|
|
|
|
'Erro técnico ao gerar o Pix. Por favor, tente novamente em alguns instantes.'
|
|
end
|
|
|
|
def find_recent_draft_reservation
|
|
Captain::Reservation.where(conversation_id: @conversation.id, status: 'draft')
|
|
.where('updated_at > ?', 2.hours.ago)
|
|
.order(created_at: :desc)
|
|
.first
|
|
end
|
|
|
|
def ensure_draft_reservation!(actual_params, override_amount)
|
|
reservation_data = build_reservation_data(actual_params, override_amount)
|
|
|
|
missing_fields = []
|
|
missing_fields << 'suite' if reservation_data[:suite_identifier].blank?
|
|
missing_fields << 'check_in' if reservation_data[:check_in_at].blank?
|
|
missing_fields << 'check_out' if reservation_data[:check_in_at].present? && reservation_data[:check_out_at].blank?
|
|
missing_fields << 'amount' if reservation_data[:total_amount].to_f <= 0
|
|
return missing_reservation_details_response(missing_fields) if missing_fields.present?
|
|
if @conversation.contact_inbox.blank?
|
|
return error_response('Não consegui gerar o Pix porque este contato não está vinculado a esta caixa de entrada.')
|
|
end
|
|
|
|
reservation = Captain::Reservation.create!(
|
|
account: @conversation.account,
|
|
inbox: @conversation.inbox,
|
|
contact: @conversation.contact,
|
|
contact_inbox: @conversation.contact_inbox,
|
|
conversation: @conversation,
|
|
suite_identifier: reservation_data[:suite_identifier],
|
|
check_in_at: reservation_data[:check_in_at],
|
|
check_out_at: reservation_data[:check_out_at],
|
|
status: :draft,
|
|
payment_status: 'pending',
|
|
total_amount: reservation_data[:total_amount],
|
|
metadata: reservation_data[:metadata]
|
|
)
|
|
|
|
Rails.logger.info("[GeneratePixTool] Reserva draft criada automaticamente ##{reservation.id} para conversa #{@conversation.id}")
|
|
reservation
|
|
rescue ActiveRecord::RecordInvalid => e
|
|
Rails.logger.warn("[GeneratePixTool] Falha ao criar reserva draft: #{e.record.errors.full_messages.join(', ')}")
|
|
error_response('Não consegui preparar a reserva para gerar o Pix. Pode confirmar os dados da reserva para eu tentar novamente?')
|
|
rescue StandardError => e
|
|
Rails.logger.error("[GeneratePixTool] Erro ao criar reserva draft: #{e.class} - #{e.message}")
|
|
error_response('Erro técnico ao preparar a reserva para o Pix. Por favor, tente novamente em instantes.')
|
|
end
|
|
|
|
def build_reservation_data(actual_params, override_amount)
|
|
inferred = infer_reservation_data_from_conversation(@conversation)
|
|
suite_identifier = normalize_suite_name(actual_params[:suite] || actual_params[:suite_identifier]) || inferred[:suite_identifier]
|
|
check_in_at = parse_datetime_in_account_zone(actual_params[:check_in]) || inferred[:check_in_at]
|
|
check_out_at = parse_datetime_in_account_zone(actual_params[:check_out]) || inferred[:check_out_at]
|
|
|
|
nights = actual_params[:nights].to_i
|
|
inferred_nights = inferred[:nights].to_i
|
|
nights = inferred_nights if nights <= 0 && inferred_nights.positive?
|
|
nights = 1 if nights <= 0
|
|
check_out_at ||= infer_check_out_at(check_in_at, nights)
|
|
|
|
parsed_total = parse_amount(actual_params[:total_amount])
|
|
total_amount = parsed_total || inferred[:total_amount]
|
|
deposit_amount = override_amount || inferred[:deposit_amount]
|
|
total_amount ||= (deposit_amount * 2.0).round(2) if deposit_amount.present?
|
|
|
|
metadata = {
|
|
'full_amount' => total_amount&.to_f,
|
|
'deposit_amount' => deposit_amount&.to_f,
|
|
'created_by' => 'generate_pix_tool',
|
|
'auto_intent' => true
|
|
}.compact
|
|
|
|
{
|
|
suite_identifier: suite_identifier,
|
|
check_in_at: check_in_at,
|
|
check_out_at: check_out_at,
|
|
total_amount: total_amount&.to_f,
|
|
metadata: metadata
|
|
}
|
|
end
|
|
|
|
def infer_reservation_data_from_conversation(conversation)
|
|
return {} if conversation.blank?
|
|
|
|
suite_identifier = nil
|
|
check_in_date = nil
|
|
nights = nil
|
|
total_amount = nil
|
|
deposit_amount = nil
|
|
|
|
recent_messages_for_reservation_scan(conversation).each do |message|
|
|
content = normalize_text(message.content)
|
|
next if content.blank?
|
|
|
|
suite_identifier ||= extract_suite_from_text(content)
|
|
check_in_date ||= extract_check_in_date_from_text(content)
|
|
nights ||= extract_nights_from_text(content)
|
|
total_amount ||= extract_total_amount_from_text(content)
|
|
deposit_amount ||= extract_deposit_amount_from_text(content)
|
|
break if suite_identifier && check_in_date && nights && total_amount && deposit_amount
|
|
end
|
|
|
|
check_in_at = normalize_check_in_datetime(check_in_date)
|
|
check_out_at = infer_check_out_at(check_in_at, nights || 1)
|
|
|
|
{
|
|
suite_identifier: suite_identifier,
|
|
check_in_at: check_in_at,
|
|
check_out_at: check_out_at,
|
|
nights: nights,
|
|
total_amount: total_amount,
|
|
deposit_amount: deposit_amount
|
|
}.compact
|
|
end
|
|
|
|
def recent_messages_for_reservation_scan(conversation)
|
|
conversation.messages
|
|
.where(message_type: %i[incoming outgoing], private: false)
|
|
.reorder(created_at: :desc)
|
|
.limit(30)
|
|
end
|
|
|
|
def extract_suite_from_text(text)
|
|
text = normalize_text(text)
|
|
raw_suite = text[SUITE_REGEX, 1].to_s.squish
|
|
return if raw_suite.blank?
|
|
|
|
normalize_suite_name(raw_suite)
|
|
end
|
|
|
|
def normalize_suite_name(raw_suite)
|
|
cleaned = normalize_text(raw_suite)
|
|
.gsub(/\b(amanh[ãa]|hoje|no dia)\b/i, '')
|
|
.gsub(/\s+/, ' ')
|
|
.strip
|
|
return if cleaned.blank? || cleaned.length < 3
|
|
|
|
cleaned
|
|
end
|
|
|
|
def extract_check_in_date_from_text(text)
|
|
text = normalize_text(text)
|
|
date_match = text[DDMMYYYY_REGEX, 1]
|
|
return parse_date_value(date_match) if date_match.present?
|
|
|
|
current_date = account_current_date
|
|
return current_date + 2.days if text.match?(/depois\s+de\s+amanh[ãa]/i)
|
|
return current_date + 1.day if text.match?(/\bamanh[ãa]\b/i)
|
|
return current_date if text.match?(/\bhoje\b/i)
|
|
|
|
nil
|
|
end
|
|
|
|
def extract_nights_from_text(text)
|
|
text = normalize_text(text)
|
|
return 1 if text.match?(ONE_NIGHT_REGEX)
|
|
return 1 if text.match?(/\bpernoite\b/i)
|
|
|
|
matches = text.scan(NIGHTS_REGEX)
|
|
nights = matches.flatten.compact.map(&:to_i).find(&:positive?)
|
|
nights if nights&.positive?
|
|
end
|
|
|
|
def extract_total_amount_from_text(text)
|
|
text = normalize_text(text)
|
|
segment = text[TOTAL_AMOUNT_REGEX]
|
|
segment ||= text if text.match?(/valor\s+total/i)
|
|
parse_amount_from_text(segment)
|
|
end
|
|
|
|
def extract_deposit_amount_from_text(text)
|
|
text = normalize_text(text)
|
|
segment = text[DEPOSIT_AMOUNT_REGEX]
|
|
segment ||= text if text.match?(/sinal|entrada/i)
|
|
parse_amount_from_text(segment)
|
|
end
|
|
|
|
def parse_amount_from_text(text)
|
|
text = normalize_text(text)
|
|
return if text.blank?
|
|
|
|
raw = text[CURRENCY_REGEX, 1]
|
|
parse_amount(raw)
|
|
end
|
|
|
|
def parse_amount(value)
|
|
clean = normalize_text(value).gsub(/[^\d.,]/, '')
|
|
return if clean.blank?
|
|
|
|
normalized = if clean.include?(',')
|
|
clean.delete('.').tr(',', '.')
|
|
else
|
|
clean
|
|
end
|
|
amount = normalized.to_f
|
|
amount if amount.positive?
|
|
end
|
|
|
|
def parse_datetime_in_account_zone(raw_value)
|
|
return if raw_value.blank?
|
|
return normalize_check_in_datetime(raw_value) if raw_value.is_a?(Date) || raw_value.is_a?(Time)
|
|
|
|
normalized_raw = normalize_text(raw_value)
|
|
parsed_date = parse_date_value(normalized_raw)
|
|
return normalize_check_in_datetime(parsed_date) if parsed_date.present?
|
|
|
|
with_account_time_zone { Time.zone.parse(normalized_raw) }
|
|
rescue ArgumentError, TypeError
|
|
nil
|
|
end
|
|
|
|
def parse_date_value(raw_value)
|
|
string_value = normalize_text(raw_value).strip
|
|
return if string_value.blank?
|
|
|
|
Date.strptime(string_value, '%d/%m/%Y')
|
|
rescue ArgumentError
|
|
begin
|
|
Date.strptime(string_value, '%d/%m/%y')
|
|
rescue ArgumentError
|
|
begin
|
|
Date.iso8601(string_value)
|
|
rescue ArgumentError
|
|
parse_date_without_year(string_value)
|
|
end
|
|
end
|
|
end
|
|
|
|
# DD/MM sem ano — assume ano corrente; se a data já passou, empurra para o próximo ano
|
|
def parse_date_without_year(string_value)
|
|
match = string_value.match(%r{\A(\d{1,2})/(\d{1,2})\z})
|
|
return nil unless match
|
|
|
|
day = match[1].to_i
|
|
month = match[2].to_i
|
|
candidate = Date.new(account_current_date.year, month, day)
|
|
candidate < account_current_date ? candidate.next_year : candidate
|
|
rescue ArgumentError
|
|
nil
|
|
end
|
|
|
|
def normalize_check_in_datetime(value)
|
|
return if value.blank?
|
|
|
|
return value.in_time_zone(account_timezone) if value.is_a?(Time)
|
|
|
|
date = value.to_date
|
|
with_account_time_zone { Time.zone.local(date.year, date.month, date.day, 19, 0, 0) }
|
|
end
|
|
|
|
def infer_check_out_at(check_in_at, nights)
|
|
return if check_in_at.blank?
|
|
|
|
total_nights = nights.to_i.positive? ? nights.to_i : 1
|
|
check_out_date = check_in_at.to_date + total_nights.days
|
|
with_account_time_zone { Time.zone.local(check_out_date.year, check_out_date.month, check_out_date.day, 12, 0, 0) }
|
|
end
|
|
|
|
def account_current_date
|
|
with_account_time_zone { Time.zone.today }
|
|
end
|
|
|
|
def with_account_time_zone(&)
|
|
Time.use_zone(account_timezone, &)
|
|
end
|
|
|
|
def account_timezone
|
|
account = @conversation&.account
|
|
configured_timezone = if account.respond_to?(:timezone)
|
|
account.timezone
|
|
elsif account.respond_to?(:time_zone)
|
|
account.time_zone
|
|
else
|
|
account&.settings&.dig('timezone')
|
|
end
|
|
configured_timezone.presence || Time.zone.name
|
|
end
|
|
|
|
def human_sentence(labels)
|
|
return labels.first.to_s if labels.length <= 1
|
|
|
|
"#{labels[0..-2].join(', ')} e #{labels.last}"
|
|
end
|
|
|
|
def normalize_text(value)
|
|
value.to_s
|
|
.dup
|
|
.force_encoding(Encoding::UTF_8)
|
|
.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: '')
|
|
rescue StandardError
|
|
value.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: '')
|
|
end
|
|
|
|
def normalize_payload(value)
|
|
case value
|
|
when String
|
|
normalize_text(value)
|
|
when Array
|
|
value.map { |item| normalize_payload(item) }
|
|
when Hash
|
|
normalized = value.each_with_object({}) do |(key, item), acc|
|
|
normalized_key = key.is_a?(String) ? normalize_text(key) : key
|
|
acc[normalized_key] = normalize_payload(item)
|
|
end
|
|
value.is_a?(ActiveSupport::HashWithIndifferentAccess) ? normalized.with_indifferent_access : normalized
|
|
else
|
|
value
|
|
end
|
|
end
|
|
|
|
def build_pix_response(charge, reservation, amount:, prefix: nil)
|
|
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)
|
|
|
|
final_code = charge.pix_copia_e_cola.to_s.strip
|
|
if final_code.start_with?('/spi/')
|
|
header = '00020101021226930014BR.GOV.BCB.PIX2571spi-qrcode.bancointer.com.br'
|
|
final_code = "#{header}#{final_code}"
|
|
end
|
|
|
|
final_code = normalize_text(final_code)
|
|
|
|
# formatted_message vai pra LLM como tool output — deve ser CURTO, sem URL
|
|
# e sem código Pix, porque dispatch_direct_link_message já envia o link em
|
|
# mensagem separada. Se incluirmos link aqui, a LLM parafraseia/cola de
|
|
# novo e o cliente recebe 2 mensagens com a mesma URL.
|
|
# prefix opcional pra casos como "Pix ainda válido" / "Gerando novo Pix".
|
|
summary = prefix.presence || 'Pix do sinal gerado e enviado em mensagem separada.'
|
|
|
|
normalize_payload(
|
|
{
|
|
formatted_message: summary,
|
|
raw_payload: final_code,
|
|
payment_link: link,
|
|
amount: amount.to_f,
|
|
reservation_id: reservation.id,
|
|
success: true
|
|
}
|
|
)
|
|
end
|
|
|
|
def default_charge_amount(reservation)
|
|
metadata = reservation.metadata.to_h
|
|
configured = metadata['deposit_amount']
|
|
return configured.to_f if configured.present? && configured.to_f.positive?
|
|
|
|
(reservation.total_amount.to_f / 2.0).round(2)
|
|
end
|
|
|
|
def merge_reservation_amount_metadata!(reservation, deposit_amount:)
|
|
metadata = reservation.metadata.to_h
|
|
merged = metadata.merge(
|
|
'full_amount' => metadata['full_amount'].presence || reservation.total_amount.to_f,
|
|
'deposit_amount' => deposit_amount.to_f
|
|
)
|
|
reservation.update!(metadata: merged)
|
|
end
|
|
|
|
def mark_conversation_as_awaiting_payment(reservation)
|
|
conversation = reservation.conversation || @conversation
|
|
return if conversation.blank?
|
|
|
|
current = conversation.label_list
|
|
merged = (current + ['aguardando_pagamento']).uniq
|
|
merged -= %w[pagamento_confirmado reserva_feita]
|
|
conversation.update_labels(merged)
|
|
end
|
|
end
|