Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions app/commands/cmd/subscriptions/reconcile_subscription.rb
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,24 @@ 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?
end
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)
Expand Down
53 changes: 53 additions & 0 deletions app/controllers/app/configurations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
217 changes: 217 additions & 0 deletions app/models/subscription.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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
Expand Down
13 changes: 10 additions & 3 deletions app/services/mercado_pago/webhook_processor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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']}")
Expand Down
35 changes: 35 additions & 0 deletions app/views/app/configurations/_subscription.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,41 @@
<div class="col-md-4 text-secondary"><strong>Tipo de suporte:</strong></div>
<div class="col-md-8"><%= subscription.plan.support_level %></div>
</div>

<% upgrade_options = subscription.upgradeable_plans %>
<% if upgrade_options.any? %>
<hr class="my-4">
<h6 class="mb-3">Upgrade de plano</h6>
<p class="text-muted mb-3">
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.
</p>

<% if subscription.pending_upgrade_data.present? %>
<div class="alert alert-info mb-0">
Existe um upgrade pendente aguardando confirmação de pagamento.
</div>
<% else %>
<%= form_with url: upgrade_plan_app_configurations_path, method: :patch, local: true, class: "row g-2 align-items-end" do %>
<div class="col-12 col-md-8">
<label class="form-label fw-semibold">Novo plano</label>
<select name="plan_id" class="form-select" required>
<option value="">Selecione</option>
<% upgrade_options.each do |plan| %>
<option value="<%= plan.id %>">
<%= plan.name %> - <%= number_to_currency(plan.transaction_amount, unit: "R$") %>
</option>
<% end %>
</select>
</div>
<div class="col-12 col-md-4">
<button type="submit" class="btn btn-primary w-100">
Confirmar upgrade
</button>
</div>
<% end %>
<% end %>
<% end %>
<% else %>
<div class="alert alert-warning mb-0">
Nenhuma assinatura ativa encontrada para sua empresa.
Expand Down
1 change: 1 addition & 0 deletions config/routes/app.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
collection do
patch :update_profile
patch :update_company
patch :upgrade_plan
post :promote_manager
end
end
Expand Down
Loading