From d09693c050d7162f6601e9cd085a1fedfdb2d6e6 Mon Sep 17 00:00:00 2001 From: Rafael Batista Date: Wed, 1 Apr 2026 13:20:23 -0300 Subject: [PATCH] =?UTF-8?q?iniciando=20a=20cria=C3=A7=C3=A3o=20do=20upgrad?= =?UTF-8?q?e=20de=20plano=20pelo=20sistema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../subscriptions/reconcile_subscription.rb | 11 + .../app/configurations_controller.rb | 53 +++++ app/models/subscription.rb | 217 ++++++++++++++++++ .../mercado_pago/webhook_processor.rb | 13 +- .../app/configurations/_subscription.html.erb | 35 +++ config/routes/app.rb | 1 + 6 files changed, 327 insertions(+), 3 deletions(-) diff --git a/app/commands/cmd/subscriptions/reconcile_subscription.rb b/app/commands/cmd/subscriptions/reconcile_subscription.rb index 8368ef2..e398e45 100644 --- a/app/commands/cmd/subscriptions/reconcile_subscription.rb +++ b/app/commands/cmd/subscriptions/reconcile_subscription.rb @@ -85,6 +85,13 @@ def apply_gateway_status(status, approved_at: nil) when "approved", "authorized" activate_subscription(approved_at || Time.current) when "cancelled", "canceled", "paused", "rejected" + handled = @subscription.mark_pending_upgrade_as!( + payment_id: @gateway_identifier, + status: "payment_cancelled", + reason: normalized + ) + return if handled + @subscription.cancel! unless @subscription.cancelled? when "pending", "in_process" @subscription.update!(status: :pending) unless @subscription.pending? || @subscription.active? @@ -92,6 +99,10 @@ def apply_gateway_status(status, approved_at: nil) end def activate_subscription(started_at) + if @subscription.apply_pending_upgrade_if_payment_confirmed!(payment_id: @gateway_identifier) + return + end + return if @subscription.active? @subscription.activate_for!(started_at: started_at) diff --git a/app/controllers/app/configurations_controller.rb b/app/controllers/app/configurations_controller.rb index 88a93f2..1cfb277 100644 --- a/app/controllers/app/configurations_controller.rb +++ b/app/controllers/app/configurations_controller.rb @@ -43,6 +43,59 @@ def promote_manager end end + def upgrade_plan + company = current_user.company + subscription = company&.current_subscription + return redirect_to app_configurations_path, alert: "Nenhuma assinatura ativa encontrada para upgrade." if subscription.blank? + + target_plan = Plan.find_by(id: params[:plan_id], status: "active") + return redirect_to app_configurations_path, alert: "Plano selecionado não é válido para upgrade." if target_plan.blank? + + unless subscription.can_upgrade_to_plan?(target_plan) + return redirect_to app_configurations_path, alert: "Upgrade não permitido para o plano selecionado." + end + + if subscription.has_pending_upgrade_request? + return redirect_to app_configurations_path, alert: "Já existe uma solicitação de upgrade aguardando confirmação de pagamento." + end + + payment_method = company.payment_method.to_s + unless %w[pix boleto].include?(payment_method) + return redirect_to app_configurations_path, alert: "Upgrade com pró-rata imediato disponível apenas para PIX e boleto no momento." + end + + proration = subscription.proration_for_upgrade(target_plan) + if proration[:proration_amount].to_d <= 0 + return redirect_to app_configurations_path, alert: "Não há diferença de valor para gerar cobrança de upgrade." + end + + result = + if payment_method == "pix" + Cmd::MercadoPago::CreatePixPayment.new(company, amount_override: proration[:proration_amount]).call + else + Cmd::MercadoPago::CreateBoletoPayment.new(company, amount_override: proration[:proration_amount]).call + end + + unless result.success? + return redirect_to app_configurations_path, alert: "Não foi possível gerar a cobrança do upgrade: #{result.errors}" + end + + payment_id = result.mailer_params[:external_id].to_s + subscription.register_pending_upgrade!(target_plan: target_plan, proration: proration, payment_id: payment_id) + + if payment_method == "pix" + Subscriptions::PixMailer.with(result.mailer_params).pix_email.deliver_later + else + Subscriptions::BoletoMailer.with(result.mailer_params).ticket_email.deliver_later + end + + notice = "Solicitação de upgrade registrada para #{target_plan.name}." + notice += " A ativação do novo plano ocorrerá somente após a confirmação do pagamento pró-rata (#{helpers.number_to_currency(proration[:proration_amount], unit: 'R$')})." + redirect_to app_configurations_path, notice: notice + rescue ActiveRecord::RecordInvalid => e + redirect_to app_configurations_path, alert: e.record.errors.full_messages.to_sentence + end + private def profile_params diff --git a/app/models/subscription.rb b/app/models/subscription.rb index f354632..6b0aa04 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -40,6 +40,16 @@ class Subscription < ApplicationRecord ) } + PLAN_TIER_BASIC = "basic".freeze + PLAN_TIER_PROFESSIONAL = "professional".freeze + PLAN_TIER_ENTERPRISE = "enterprise".freeze + + UPGRADE_RULES = { + PLAN_TIER_BASIC => [PLAN_TIER_PROFESSIONAL, PLAN_TIER_ENTERPRISE].freeze, + PLAN_TIER_PROFESSIONAL => [PLAN_TIER_ENTERPRISE].freeze, + PLAN_TIER_ENTERPRISE => [].freeze + }.freeze + after_commit :sync_company_access, on: :update, if: -> { previous_changes.key?('status') } def allows_access? @@ -78,9 +88,216 @@ def expire! update!(status: :expired, expired_date: Time.current) end + + def can_upgrade_to_plan?(target_plan) + return false if target_plan.blank? || plan.blank? + + current_tier = self.class.plan_tier_from_name(plan.name) + target_tier = self.class.plan_tier_from_name(target_plan.name) + return false if current_tier.blank? || target_tier.blank? + + UPGRADE_RULES.fetch(current_tier, []).include?(target_tier) + end + + def upgradeable_plans + return [] if plan.blank? + + current_tier = self.class.plan_tier_from_name(plan.name) + allowed_tiers = UPGRADE_RULES.fetch(current_tier, []) + return [] if allowed_tiers.blank? + + Plan.where(status: "active").select do |candidate| + allowed_tiers.include?(self.class.plan_tier_from_name(candidate.name)) + end + end + + def upgrade_to!(target_plan) + unless can_upgrade_to_plan?(target_plan) + errors.add(:base, "Upgrade não permitido para o plano selecionado.") + raise ActiveRecord::RecordInvalid, self + end + + proration = calculate_proration_for_upgrade(target_plan) + + transaction do + merged_payload = (raw_payload || {}).deep_dup + merged_payload["plan_upgrade"] = { + "from_plan_name" => plan&.name, + "to_plan_name" => target_plan.name, + "from_amount" => proration[:from_amount].to_s("F"), + "to_amount" => target_plan.transaction_amount.to_d.to_s("F"), + "difference_amount" => proration[:difference_amount].to_s("F"), + "proration_amount" => proration[:proration_amount].to_s("F"), + "proration_ratio" => proration[:proration_ratio].to_s("F"), + "cycle_days_total" => proration[:cycle_days_total], + "cycle_days_remaining" => proration[:cycle_days_remaining], + "billing_mode" => "immediate_prorata", + "effective_on" => Date.current.to_s, + "computed_at" => Time.current.iso8601 + } + + update!( + preapproval_plan_id: target_plan.external_id, + reason: target_plan.reason, + transaction_amount: target_plan.transaction_amount, + raw_payload: merged_payload + ) + company.update!(plan: target_plan) + end + + proration + end + + def register_pending_upgrade!(target_plan:, proration:, payment_id:) + payload = safe_raw_payload_hash + payload["plan_upgrade"] = { + "status" => "pending_payment", + "requested_at" => Time.current.iso8601, + "payment_id" => payment_id.to_s, + "target_plan_id" => target_plan.id, + "target_plan_external_id" => target_plan.external_id, + "target_plan_name" => target_plan.name, + "target_plan_reason" => target_plan.reason, + "target_transaction_amount" => target_plan.transaction_amount.to_d.to_s("F"), + "from_plan_name" => plan&.name, + "from_amount" => proration[:from_amount].to_s("F"), + "to_amount" => proration[:to_amount].to_s("F"), + "difference_amount" => proration[:difference_amount].to_s("F"), + "proration_amount" => proration[:proration_amount].to_s("F"), + "proration_ratio" => proration[:proration_ratio].to_s("F"), + "cycle_days_total" => proration[:cycle_days_total], + "cycle_days_remaining" => proration[:cycle_days_remaining], + "billing_mode" => "immediate_prorata" + } + + update!(raw_payload: payload) + end + + def apply_pending_upgrade_if_payment_confirmed!(payment_id:) + payload = safe_raw_payload_hash + upgrade = payload["plan_upgrade"] + return false if upgrade.blank? + return false unless upgrade["status"] == "pending_payment" + return false unless upgrade["payment_id"].to_s == payment_id.to_s + + target_plan = Plan.find_by(external_id: upgrade["target_plan_external_id"].to_s) + return false if target_plan.blank? + + transaction do + update!( + preapproval_plan_id: target_plan.external_id, + reason: target_plan.reason, + transaction_amount: target_plan.transaction_amount + ) + company.update!(plan: target_plan) + + payload_after = safe_raw_payload_hash + payload_after["plan_upgrade"] = upgrade.merge( + "status" => "applied", + "applied_at" => Time.current.iso8601 + ) + update!(raw_payload: payload_after) + end + + true + end + + def proration_for_upgrade(target_plan, reference_date: Date.current) + calculate_proration_for_upgrade(target_plan, reference_date: reference_date) + end + + def pending_upgrade_for_payment?(payment_id:) + upgrade = safe_raw_payload_hash["plan_upgrade"] + return false if upgrade.blank? + + upgrade["status"] == "pending_payment" && upgrade["payment_id"].to_s == payment_id.to_s + end + + def has_pending_upgrade_request? + upgrade = safe_raw_payload_hash["plan_upgrade"] + upgrade.present? && upgrade["status"] == "pending_payment" + end + + def pending_upgrade_data + upgrade = safe_raw_payload_hash["plan_upgrade"] + return nil if upgrade.blank? + return nil unless upgrade["status"] == "pending_payment" + + upgrade + end + + def mark_pending_upgrade_as!(payment_id:, status:, reason: nil) + payload = safe_raw_payload_hash + upgrade = payload["plan_upgrade"] + return false if upgrade.blank? + return false unless upgrade["status"] == "pending_payment" + return false unless upgrade["payment_id"].to_s == payment_id.to_s + + payload["plan_upgrade"] = upgrade.merge( + "status" => status.to_s, + "updated_at" => Time.current.iso8601, + "failure_reason" => reason.presence + ).compact + + update!(raw_payload: payload) + true + end private + def self.plan_tier_from_name(name) + normalized = I18n.transliterate(name.to_s).downcase + return PLAN_TIER_BASIC if normalized.include?("basico") || normalized.include?("basic") + return PLAN_TIER_PROFESSIONAL if normalized.include?("profissional") || normalized.include?("professional") + return PLAN_TIER_ENTERPRISE if normalized.include?("enterprise") + + nil + end + + def calculate_proration_for_upgrade(target_plan, reference_date: Date.current) + from_amount = (transaction_amount.presence || plan&.transaction_amount).to_d + to_amount = target_plan.transaction_amount.to_d + difference_amount = (to_amount - from_amount) + difference_amount = 0.to_d if difference_amount.negative? + + cycle_days_total = cycle_days_total(reference_date) + cycle_days_remaining = cycle_days_remaining(reference_date) + proration_ratio = cycle_days_remaining.to_d / cycle_days_total.to_d + proration_amount = (difference_amount * proration_ratio).round(2) + + { + from_amount: from_amount, + to_amount: to_amount, + difference_amount: difference_amount, + proration_amount: proration_amount, + proration_ratio: proration_ratio.round(6), + cycle_days_total: cycle_days_total, + cycle_days_remaining: cycle_days_remaining + } + end + + def cycle_days_total(reference_date = Date.current) + return 30 unless start_date.present? && end_date.present? + + total = (end_date.to_date - start_date.to_date).to_i + return 30 if total <= 0 + + total + end + + def cycle_days_remaining(reference_date = Date.current) + return cycle_days_total(reference_date) unless end_date.present? + + remaining = (end_date.to_date - reference_date.to_date).to_i + return 0 if remaining.negative? + + remaining + end + + def safe_raw_payload_hash + raw_payload.is_a?(Hash) ? raw_payload.deep_dup : {} + end + def auditable_created_action "subscription.created" end diff --git a/app/services/mercado_pago/webhook_processor.rb b/app/services/mercado_pago/webhook_processor.rb index 48c5233..d3ebe44 100644 --- a/app/services/mercado_pago/webhook_processor.rb +++ b/app/services/mercado_pago/webhook_processor.rb @@ -58,11 +58,18 @@ def process_payment case payment["status"] when "approved" - subscription.activate! + applied = subscription.apply_pending_upgrade_if_payment_confirmed!(payment_id: external_payment_id) + subscription.activate! unless applied || subscription.active? when "pending" - subscription.update!(status: :pending) + handled = subscription.mark_pending_upgrade_as!(payment_id: external_payment_id, status: "pending_payment") + subscription.update!(status: :pending) unless handled || subscription.active? when "cancelled" - subscription.cancel! + handled = subscription.mark_pending_upgrade_as!( + payment_id: external_payment_id, + status: "payment_cancelled", + reason: payment["status_detail"] + ) + subscription.cancel! unless handled || subscription.active? end Result.new(true, "Pagamento #{payment_id} processado com status #{payment['status']}") diff --git a/app/views/app/configurations/_subscription.html.erb b/app/views/app/configurations/_subscription.html.erb index b44df6a..c0be80f 100644 --- a/app/views/app/configurations/_subscription.html.erb +++ b/app/views/app/configurations/_subscription.html.erb @@ -47,6 +47,41 @@
Tipo de suporte:
<%= subscription.plan.support_level %>
+ + <% upgrade_options = subscription.upgradeable_plans %> + <% if upgrade_options.any? %> +
+
Upgrade de plano
+

+ Você pode fazer upgrade do plano atual para opções superiores. + O sistema gera a cobrança proporcional (pró-rata) no ciclo atual e o novo plano só é ativado após a confirmação do pagamento. +

+ + <% if subscription.pending_upgrade_data.present? %> +
+ Existe um upgrade pendente aguardando confirmação de pagamento. +
+ <% else %> + <%= form_with url: upgrade_plan_app_configurations_path, method: :patch, local: true, class: "row g-2 align-items-end" do %> +
+ + +
+
+ +
+ <% end %> + <% end %> + <% end %> <% else %>
Nenhuma assinatura ativa encontrada para sua empresa. diff --git a/config/routes/app.rb b/config/routes/app.rb index 897224e..4f5e06f 100644 --- a/config/routes/app.rb +++ b/config/routes/app.rb @@ -42,6 +42,7 @@ collection do patch :update_profile patch :update_company + patch :upgrade_plan post :promote_manager end end