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>
173 lines
6.4 KiB
Ruby
173 lines
6.4 KiB
Ruby
# == Schema Information
|
|
#
|
|
# Table name: captain_units
|
|
#
|
|
# id :bigint not null, primary key
|
|
# concierge_config :jsonb not null
|
|
# currency :string default("BRL"), not null
|
|
# extra_person_fee :decimal(10, 2) default(0.0), not null
|
|
# inter_account_number :string
|
|
# inter_cert_content :text
|
|
# inter_cert_path :string
|
|
# inter_client_secret :string
|
|
# inter_key_content :text
|
|
# inter_key_path :string
|
|
# inter_pix_key :string
|
|
# last_synced_at :datetime
|
|
# leader_whatsapp :string
|
|
# name :string not null
|
|
# payment_receipt_review_enabled :boolean default(FALSE), not null
|
|
# plug_play_token :string
|
|
# proactive_pix_polling_enabled :boolean default(FALSE), not null
|
|
# reservation_source_tag :string
|
|
# reservations_sync_enabled :boolean
|
|
# status :string
|
|
# suite_category_images :jsonb not null
|
|
# visible_suite_categories :jsonb not null
|
|
# webhook_configured_at :datetime
|
|
# webhook_url :string
|
|
# created_at :datetime not null
|
|
# updated_at :datetime not null
|
|
# account_id :bigint not null
|
|
# captain_brand_id :bigint not null
|
|
# concierge_inbox_id :bigint
|
|
# inbox_id :bigint
|
|
# inter_client_id :string
|
|
# plug_play_id :string
|
|
# supabase_marca_id :uuid
|
|
# supabase_tenant_id :bigint default(1)
|
|
# supabase_unit_id :uuid
|
|
#
|
|
# Indexes
|
|
#
|
|
# index_captain_units_on_account_id (account_id)
|
|
# index_captain_units_on_captain_brand_id (captain_brand_id)
|
|
# index_captain_units_on_concierge_inbox_id (concierge_inbox_id)
|
|
# index_captain_units_on_inbox_id (inbox_id)
|
|
# index_captain_units_on_supabase_unit_id (supabase_unit_id) UNIQUE WHERE (supabase_unit_id IS NOT NULL)
|
|
#
|
|
# Foreign Keys
|
|
#
|
|
# fk_rails_... (account_id => accounts.id)
|
|
# fk_rails_... (captain_brand_id => captain_brands.id)
|
|
# fk_rails_... (concierge_inbox_id => inboxes.id)
|
|
# fk_rails_... (inbox_id => inboxes.id)
|
|
#
|
|
class Captain::Unit < ApplicationRecord
|
|
self.table_name = 'captain_units'
|
|
|
|
belongs_to :account
|
|
belongs_to :brand, class_name: 'Captain::Brand', foreign_key: 'captain_brand_id', inverse_of: :units
|
|
belongs_to :concierge_inbox, class_name: 'Inbox', optional: true
|
|
has_many :unit_inboxes, class_name: 'Captain::UnitInbox', foreign_key: :captain_unit_id,
|
|
inverse_of: :captain_unit, dependent: :destroy
|
|
has_many :inboxes, through: :unit_inboxes
|
|
has_many :pix_charges, class_name: 'Captain::PixCharge', dependent: :restrict_with_error
|
|
has_many :gallery_items, class_name: 'Captain::GalleryItem', foreign_key: :captain_unit_id, inverse_of: :captain_unit,
|
|
dependent: :destroy
|
|
has_many :pricing_categories, class_name: 'Captain::PricingCategory', foreign_key: :captain_unit_id,
|
|
inverse_of: :captain_unit, dependent: :destroy
|
|
|
|
encrypts :inter_client_secret
|
|
encrypts :inter_account_number
|
|
encrypts :inter_cert_content
|
|
encrypts :inter_key_content
|
|
|
|
enum status: { active: 'active', inactive: 'inactive' }, _default: 'active'
|
|
enum pix_mode: { inter_dynamic: 'inter_dynamic', manual_static: 'manual_static' }, _default: 'inter_dynamic', _prefix: true
|
|
|
|
MANUAL_PIX_KEY_TYPES = %w[cpf cnpj email phone random].freeze
|
|
|
|
validates :name, presence: true
|
|
validates :manual_pix_key_type, inclusion: { in: MANUAL_PIX_KEY_TYPES }, allow_nil: true
|
|
validate :proactive_pix_polling_requires_inter_credentials
|
|
validate :manual_static_requires_manual_pix_fields
|
|
|
|
after_commit :enqueue_supabase_provisioning, on: :create
|
|
|
|
def concierge_persona_name
|
|
concierge_config_hash['persona_name'].presence || 'Sofia'
|
|
end
|
|
|
|
def concierge_knowledge
|
|
concierge_config_hash['knowledge'].to_s
|
|
end
|
|
|
|
def concierge_variables
|
|
concierge_config_hash['variables'].to_h
|
|
end
|
|
|
|
def concierge_configured?
|
|
concierge_inbox_id.present?
|
|
end
|
|
|
|
def inter_credentials_present?
|
|
inter_client_id.present? &&
|
|
inter_client_secret.present? &&
|
|
inter_pix_key.present? &&
|
|
(inter_cert_content.present? || resolved_inter_cert_path.present?) &&
|
|
(inter_key_content.present? || resolved_inter_key_path.present?)
|
|
end
|
|
|
|
def manual_pix_configured?
|
|
pix_mode_manual_static? &&
|
|
manual_pix_key.present? &&
|
|
manual_pix_owner_name.present? &&
|
|
manual_pix_bank_name.present?
|
|
end
|
|
|
|
def resolved_inter_cert_path
|
|
resolve_certificate_path(inter_cert_path)
|
|
end
|
|
|
|
def resolved_inter_key_path
|
|
resolve_certificate_path(inter_key_path)
|
|
end
|
|
|
|
private
|
|
|
|
def concierge_config_hash
|
|
(concierge_config || {}).with_indifferent_access
|
|
end
|
|
|
|
def proactive_pix_polling_requires_inter_credentials
|
|
return unless proactive_pix_polling_enabled?
|
|
return if inter_credentials_present?
|
|
|
|
errors.add(
|
|
:proactive_pix_polling_enabled,
|
|
'só pode ser habilitado quando a integração Inter estiver completa (client id/secret, chave pix, cert e key)'
|
|
)
|
|
end
|
|
|
|
def manual_static_requires_manual_pix_fields
|
|
return unless pix_mode_manual_static?
|
|
|
|
%i[manual_pix_key manual_pix_owner_name manual_pix_bank_name].each do |field|
|
|
errors.add(field, 'é obrigatório quando pix_mode = manual_static') if public_send(field).blank?
|
|
end
|
|
end
|
|
|
|
# Resolve o path do certificado — suporta caminho absoluto, relativo ao Rails.root
|
|
# ou nome de arquivo simples dentro de storage/certs/.
|
|
def resolve_certificate_path(path)
|
|
return nil if path.blank?
|
|
return path if File.exist?(path)
|
|
|
|
rails_root_path = Rails.root.join(path).to_s
|
|
return rails_root_path if File.exist?(rails_root_path)
|
|
|
|
filename = File.basename(path)
|
|
fallback_path = Rails.root.join('storage/certs', filename).to_s
|
|
return fallback_path if File.exist?(fallback_path)
|
|
|
|
path # Retorna original se nenhum caminho for encontrado
|
|
end
|
|
|
|
def enqueue_supabase_provisioning
|
|
Captain::Reserva::ProvisionUnitInSupabaseJob.perform_later(id)
|
|
rescue StandardError => e
|
|
Rails.logger.warn("[Captain::Unit##{id}] enqueue ProvisionUnitInSupabaseJob falhou: #{e.class} - #{e.message}")
|
|
end
|
|
end
|