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