Adiciona caminho paralelo de PIX manual estático pra unidades sem integração Inter (Padova, Express AL). Mudança 100% aditiva — todas as outras unidades continuam no fluxo Inter inalterado (default pix_mode=inter_dynamic aplicado pela migration). Backend (sem SOUL/SKILL ainda — Frente 7 vem depois): - Migration concurrent: pix_mode + 4 campos manual_pix_* em captain_units; provider + manual_proof_payload + manual_review_reason em captain_pix_charges - Captain::Unit: enum pix_mode (prefix), validação condicional manual_* - Captain::PixCharge: status estendido (awaiting_proof, pending_review), scope manual/inter, nota interna ramificada por modo - GeneratePixTool MCP: branch manual_static (chave fixa, mensagem direta sem QR/Inter), preserva fluxo Inter intacto - 4 tools MCP novas: verificar_comprovante_pix (vision gpt-5.3-codex), criar_nota_interna (genérica), confirmar_reserva_pix_manual (wrapper do ConfirmationService), marcar_reserva_pendente - ConfirmationService: source_label cobre 'manual_pix_proof' Próximos passos manuais (não inclusos neste commit): 1. Rodar migration em prod (entrypoint não roda no boot) 2. Seed Padova/Express com pix_mode=manual_static + chaves Stone 3. Deploy nova imagem via docker service update 4. Editar SOUL/SKILL Padova/Express na VPS Hermes + kill+boot Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
153 lines
4.5 KiB
Ruby
153 lines
4.5 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',
|
|
awaiting_proof: 'awaiting_proof',
|
|
pending_review: 'pending_review'
|
|
}
|
|
|
|
validates :txid, presence: true, uniqueness: true
|
|
validates :unit_id, presence: true
|
|
|
|
scope :manual, -> { where(provider: 'manual') }
|
|
scope :inter, -> { where(provider: 'inter') }
|
|
|
|
def manual?
|
|
provider.to_s == 'manual'
|
|
end
|
|
|
|
def inter?
|
|
provider.to_s == 'inter'
|
|
end
|
|
|
|
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?
|
|
|
|
content = manual? ? manual_pix_note_content : inter_pix_note_content
|
|
|
|
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
|
|
|
|
if manual?
|
|
deposit = reservation&.metadata.to_h['deposit_amount']
|
|
return deposit.to_f if deposit.present?
|
|
end
|
|
|
|
reservation&.total_amount
|
|
end
|
|
|
|
private
|
|
|
|
def manual_pix_note_content
|
|
[
|
|
'💸 *PIX MANUAL enviado ao cliente* — aguardando comprovante',
|
|
"Valor: R$ #{format('%.2f', original_value.to_f)}",
|
|
"Chave: #{unit&.manual_pix_key} (#{unit&.manual_pix_bank_name})",
|
|
"Beneficiário esperado: #{unit&.manual_pix_owner_name}",
|
|
"Reserva ##{reservation_id}"
|
|
].join("\n")
|
|
end
|
|
|
|
def inter_pix_note_content
|
|
expires_fmt = expires_at&.strftime('%d/%m/%Y %H:%M') || '—'
|
|
[
|
|
'💸 *PIX enviado ao cliente* — aguardando pagamento',
|
|
"Valor: R$ #{format('%.2f', original_value.to_f)}",
|
|
"Txid: #{txid}",
|
|
"Expira em: #{expires_fmt}",
|
|
"Reserva ##{reservation_id}"
|
|
].join("\n")
|
|
end
|
|
end
|