Cobre ambos os caminhos (generate_pix_tool e PublicReservationsController): toda reserva criada recebe um after_create_commit que posta uma mensagem privada na conversa com os detalhes (suite, check-in, valores, ID). Remove a criacao duplicada do PublicReservationsController.
194 lines
7.7 KiB
Ruby
194 lines
7.7 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# Endpoint público para criação de reservas via React app externo (Reserva 1001).
|
|
# Autenticado por token estático via header X-Reserva-Token.
|
|
class Public::Api::V1::Captain::PublicReservationsController < ActionController::API
|
|
before_action :authenticate_reserva_token!
|
|
|
|
def create # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
|
|
unit = Captain::Unit.find_by(id: params[:chatwoot_unit_id])
|
|
return render(json: { error: 'unit_not_found' }, status: :not_found) if unit.nil?
|
|
return render(json: { error: 'unit_has_no_inbox' }, status: :unprocessable_entity) if unit.inbox_id.blank?
|
|
return render(json: { error: 'unit_missing_inter_credentials' }, status: :unprocessable_entity) unless unit.inter_credentials_present?
|
|
|
|
customer = params[:customer] || {}
|
|
return render(json: { error: 'customer_required' }, status: :unprocessable_entity) if customer[:name].blank?
|
|
return render(json: { error: 'customer_phone_required' }, status: :unprocessable_entity) if customer[:phone].blank?
|
|
|
|
account = unit.account
|
|
inbox = Inbox.find(unit.inbox_id)
|
|
|
|
# WhatsApp inbox exige source_id com apenas digitos (padrao E.164 sem o +)
|
|
phone_digits = customer[:phone].to_s.gsub(/\D/, '')
|
|
return render(json: { error: 'customer_phone_invalid' }, status: :unprocessable_entity) if phone_digits.empty? || phone_digits.length > 15
|
|
|
|
normalized_phone = "+#{phone_digits}"
|
|
|
|
contact_inbox = ::ContactInboxWithContactBuilder.new(
|
|
source_id: phone_digits,
|
|
inbox: inbox,
|
|
contact_attributes: {
|
|
name: customer[:name],
|
|
phone_number: normalized_phone,
|
|
email: customer[:email].presence,
|
|
custom_attributes: {
|
|
cpf: customer[:cpf].presence
|
|
}.compact,
|
|
additional_attributes: {
|
|
origem: 'reserva-1001'
|
|
}
|
|
}
|
|
).perform
|
|
|
|
# Atualiza campos visiveis do contato (alem do ContactInboxBuilder, que so
|
|
# preenche no create e nao mexe em contato ja existente).
|
|
persist_customer_metadata!(contact_inbox.contact, customer, params)
|
|
|
|
conversation = ConversationBuilder.new(
|
|
params: ActionController::Parameters.new(
|
|
additional_attributes: {
|
|
source: 'reserva-1001',
|
|
reserva_category: params[:category],
|
|
reserva_stay_type: params[:stay_type],
|
|
reserva_checkin_at: params[:checkin_at]
|
|
}
|
|
),
|
|
contact_inbox: contact_inbox
|
|
).perform
|
|
|
|
# Nota: o model Captain::Reservation#post_internal_reservation_note ja cria
|
|
# a nota interna automaticamente via after_create_commit. Nao duplicamos aqui.
|
|
|
|
reservation = Captain::Reservation.create!(
|
|
account: account,
|
|
inbox: inbox,
|
|
contact: contact_inbox.contact,
|
|
contact_inbox: contact_inbox,
|
|
conversation: conversation,
|
|
unit: unit,
|
|
suite_identifier: "#{params[:category]} · #{params[:stay_type]}",
|
|
check_in_at: params[:checkin_at],
|
|
check_out_at: checkout_from(params[:checkin_at], params[:stay_type]),
|
|
status: :draft,
|
|
payment_status: 'pending',
|
|
total_amount: (params[:total_cents].to_i / 100.0),
|
|
metadata: {
|
|
origem: 'reserva-1001',
|
|
category: params[:category],
|
|
stay_type: params[:stay_type],
|
|
deposit_cents: params[:deposit_cents].to_i,
|
|
notes: params[:notes]
|
|
}
|
|
)
|
|
|
|
mark_conversation_as_awaiting_payment(conversation)
|
|
|
|
deposit_amount = (params[:deposit_cents].to_i / 100.0)
|
|
charge = Captain::Inter::CobService.new(reservation, amount: deposit_amount).call
|
|
reservation.update!(status: :pending_payment)
|
|
|
|
render json: {
|
|
reservation_id: reservation.id,
|
|
conversation_id: conversation.id,
|
|
pix: {
|
|
txid: charge.txid,
|
|
copia_e_cola: charge.pix_copia_e_cola,
|
|
qrcode_base64: nil,
|
|
expires_at: (Time.current + Captain::PixCharge::EXPIRATION_SECONDS.seconds).iso8601
|
|
}
|
|
}, status: :created
|
|
rescue ActiveRecord::RecordInvalid => e
|
|
Rails.logger.error("[PublicReservations] validation error: #{e.message}")
|
|
render json: { error: 'validation_failed', details: e.record.errors.full_messages }, status: :unprocessable_entity
|
|
rescue StandardError => e
|
|
Rails.logger.error("[PublicReservations] unexpected error: #{e.class} - #{e.message}")
|
|
render json: { error: 'internal_error', message: e.message }, status: :internal_server_error
|
|
end
|
|
|
|
def status
|
|
reservation = Captain::Reservation.find_by(id: params[:id])
|
|
return render(json: { error: 'not_found' }, status: :not_found) if reservation.nil?
|
|
|
|
render json: {
|
|
reservation_id: reservation.id,
|
|
status: reservation.payment_status
|
|
}
|
|
end
|
|
|
|
private
|
|
|
|
def authenticate_reserva_token!
|
|
expected = ENV.fetch('RESERVA_1001_API_TOKEN', nil)
|
|
provided = request.headers['X-Reserva-Token']
|
|
|
|
if expected.blank?
|
|
Rails.logger.error('[PublicReservations] RESERVA_1001_API_TOKEN not configured')
|
|
render json: { error: 'service_unavailable' }, status: :service_unavailable and return
|
|
end
|
|
|
|
return if provided.present? && ActiveSupport::SecurityUtils.secure_compare(provided, expected)
|
|
|
|
render json: { error: 'unauthorized' }, status: :unauthorized
|
|
end
|
|
|
|
# Persiste CPF, email, ultima suite/permanencia, data e total de reservas
|
|
# no contact.custom_attributes para aparecer no painel lateral do Chatwoot
|
|
# e pra facilitar reservas futuras (cliente recorrente).
|
|
def persist_customer_metadata!(contact, customer, payload) # rubocop:disable Metrics/AbcSize
|
|
return if contact.blank?
|
|
|
|
current_custom = contact.custom_attributes || {}
|
|
current_custom = current_custom.dup
|
|
|
|
current_custom['cpf'] = customer[:cpf] if customer[:cpf].present?
|
|
current_custom['ultima_suite'] = payload[:category] if payload[:category].present?
|
|
current_custom['ultima_permanencia'] = payload[:stay_type] if payload[:stay_type].present?
|
|
current_custom['ultima_reserva_em'] = Time.current.iso8601
|
|
current_custom['total_reservas'] = (current_custom['total_reservas'].to_i + 1)
|
|
|
|
updates = { custom_attributes: current_custom }
|
|
updates[:email] = customer[:email] if customer[:email].present? && contact.email.blank?
|
|
updates[:name] = customer[:name] if customer[:name].present? && contact.name.blank?
|
|
|
|
contact.update!(updates)
|
|
rescue StandardError => e
|
|
Rails.logger.error("[PublicReservations] persist_customer_metadata failed: #{e.message}")
|
|
end
|
|
|
|
# Espelha Captain::Tools::GeneratePixTool#mark_conversation_as_awaiting_payment
|
|
# (enterprise/app/services/captain/tools/generate_pix_tool.rb:713-721)
|
|
def mark_conversation_as_awaiting_payment(conversation)
|
|
current = conversation.label_list
|
|
merged = (current + ['aguardando_pagamento']).uniq
|
|
merged -= %w[pagamento_confirmado reserva_feita]
|
|
conversation.update_labels(merged)
|
|
rescue StandardError => e
|
|
Rails.logger.error("[PublicReservations] label update failed: #{e.message}")
|
|
# Não falha a request por causa disso
|
|
end
|
|
|
|
def build_initial_note(payload)
|
|
<<~NOTE.strip
|
|
Nova reserva via reserva.1001
|
|
Categoria: #{payload[:category]}
|
|
Permanencia: #{payload[:stay_type]}
|
|
Check-in: #{payload[:checkin_at]}
|
|
Total: R$ #{format('%.2f', payload[:total_cents].to_i / 100.0)}
|
|
Entrada (PIX 50%): R$ #{format('%.2f', payload[:deposit_cents].to_i / 100.0)}
|
|
Observacao: #{payload[:notes].presence || '-'}
|
|
NOTE
|
|
end
|
|
|
|
def checkout_from(checkin_iso, stay_type)
|
|
checkin = Time.zone.parse(checkin_iso.to_s)
|
|
hours = case stay_type.to_s.downcase
|
|
when '2hrs' then 2
|
|
when '3hrs' then 3
|
|
when 'pernoite' then 12
|
|
when 'diaria', 'diária' then 24
|
|
else 4 # default: 4hrs (inclui '4hrs' e qualquer outro valor)
|
|
end
|
|
checkin + hours.hours
|
|
end
|
|
end
|