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.
116 lines
3.6 KiB
Ruby
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
|