iachat/enterprise/app/models/captain/pix_charge.rb
Rodribm10 f6488ce2de feat(retention): foundation for customer retention metrics
Lays the data + job foundation for tracking customer interactions,
recurrence, and Pix conversion on Contact. Design decisions negotiated
with Rodrigo (see docs to come):

Rules:
- Gap of 30h from last message defines separate interactions
- Qualified interaction = >=2 customer msgs + >=2 attendant msgs,
  both with textual content (>= 2 letters)
- One-shot consultation = >=1+1 but below the qualified threshold
  (tracked as secondary KPI)
- Excludes contacts labeled `equipe_interna`
- is_recurring = interactions_count >= 2
- pix_generated_count counts all PixCharges; reservations_paid_count
  only counts those with status = paid

Surface area:
- Migration adds denormalized stats to contacts + indexes for fast filtering
- Captain::ContactStats::InteractionCalculatorService computes the stats
  for a single contact (pure, no persistence)
- Captain::Retention::RecalculateContactStatsJob persists them for one
  contact (idempotent)
- Captain::Retention::RecalculateAllContactStatsJob runs daily at 3am BRT,
  enqueues per-contact jobs for everyone active in the last 120 days
- Event-driven refresh: CaptainListener#conversation_resolved enqueues
  recalc; Captain::PixCharge after_create/after_update enqueues recalc
  on status change

No UI yet — that's the next layer.
2026-04-22 09:50:23 -03:00

116 lines
3.6 KiB
Ruby

# == Schema Information
#
# Table name: captain_pix_charges
#
# id :bigint not null, primary key
# e2eid :string
# paid_at :datetime
# pix_copia_e_cola :text
# raw_webhook_payload :jsonb
# status :string
# txid :string
# created_at :datetime not null
# updated_at :datetime not null
# reservation_id :bigint not null
# unit_id :bigint not null
#
# Indexes
#
# idx_cp_charges_e2eid (e2eid)
# idx_cp_charges_txid (txid) UNIQUE
# index_captain_pix_charges_on_e2eid (e2eid)
# index_captain_pix_charges_on_reservation_id (reservation_id)
# index_captain_pix_charges_on_txid (txid)
# index_captain_pix_charges_on_unit_id (unit_id)
#
# Foreign Keys
#
# fk_rails_... (reservation_id => captain_reservations.id)
# fk_rails_... (unit_id => captain_units.id)
#
class Captain::PixCharge < ApplicationRecord
self.table_name = 'captain_pix_charges'
EXPIRATION_SECONDS = 3600 # 1 hora
belongs_to :reservation, class_name: 'Captain::Reservation'
belongs_to :unit, class_name: 'Captain::Unit'
enum status: { active: 'active', paid: 'paid', expired: 'expired', failed: 'failed' }
validates :txid, presence: true, uniqueness: true
validates :unit_id, presence: true
after_create_commit :post_internal_pix_sent_note
after_create_commit :enqueue_retention_recalc
after_update_commit :enqueue_retention_recalc_on_status_change
def expires_at
return nil unless created_at
created_at + EXPIRATION_SECONDS.seconds
end
def expired_by_time?(now = Time.current)
return false unless created_at
now > expires_at
end
# Cria uma nota interna (privada) na conversa avisando que o PIX foi gerado
# e enviado ao cliente. Nao significa que o cliente pagou — e so o marcador
# de "aguardando pagamento" com os detalhes da cobranca pra atendente.
def post_internal_pix_sent_note
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")
Messages::MessageBuilder.new(
nil,
conversation,
{ content: content, message_type: 'outgoing', private: true }
).perform
rescue StandardError => e
Rails.logger.warn("[Captain::PixCharge] failed to post sent note: #{e.class} - #{e.message}")
end
# Recalcula stats de retenção do contato sempre que um Pix novo aparece
# (incrementa pix_generated_count) ou muda de status pra paid/expired/failed
# (afeta reservations_paid_count).
def enqueue_retention_recalc
contact_id = reservation&.contact_id
return if contact_id.blank?
Captain::Retention::RecalculateContactStatsJob.perform_later(contact_id)
end
def enqueue_retention_recalc_on_status_change
return unless saved_change_to_status?
enqueue_retention_recalc
end
# Retorna o valor original da cobrança a partir do payload da Inter
def original_value
if raw_webhook_payload.present?
payload = raw_webhook_payload.is_a?(String) ? JSON.parse(raw_webhook_payload) : raw_webhook_payload
# Inter V2: { "valor": { "original": "140.00" } }
val = payload.dig('valor', 'original')
return val.to_f if val.present?
end
reservation&.total_amount
end
end