Skip to content

Instantly share code, notes, and snippets.

@Chucheen
Last active April 3, 2026 03:23
Show Gist options
  • Select an option

  • Save Chucheen/6c1ba251e1441ff680479d9108c53dd6 to your computer and use it in GitHub Desktop.

Select an option

Save Chucheen/6c1ba251e1441ff680479d9108c53dd6 to your computer and use it in GitHub Desktop.
MRI Payments - Cambios técnicos (dispersiones, remaining amount negativo, sobrepago)

MRI Payments - Cambios técnicos

1. Corrección del monto de dispersión de billing items

Problema

BillingItemAmountCalculator usaba billing_item.paid_amount (que es amount - remaining_amount) para calcular cuánto dispersar. Este valor incluye todos los pagos sin importar su origen. Para rentas MRI, la sincronización nocturna crea pagos tipo manual_deposit que ya representan dinero en Gran Ciudad — no deben generar dispersión.

Ejemplo real: Un billing item de $36,000 fue pagado $35,883 por sync MRI y $117 por STP. Se dispersaron $36,000 en lugar de $117.

Antes

# billing_item_amount_calculator.rb
def payout_amount
  @billing_item.paid_amount - payouts_processed_amount
end

Ahora

# billing_item_amount_calculator.rb
def payout_amount
  non_manual_paid_amount - payouts_processed_amount
end

def non_manual_paid_amount
  @billing_item.withdrawal_transactions
                .where(deposit_transaction_id: Wallet::Transaction.where(transaction_type: :deposit).select(:id))
                .sum(:amount).to_f
end

Solo se cuentan withdrawals cuyo deposit_transaction sea de tipo :deposit (STP real), excluyendo :manual_deposit (sync MRI).


2. Dispersión de sobrepago al centro de costos de Gran Ciudad

Problema

Cuando un inquilino sobrepaga (queda dinero después de cubrir todos los billing items), se crea un ErpCreditBalanceTransaction pero el dinero no se dispersaba al centro de costos de Gran Ciudad Rentas.

Cambios

Nuevo job: Mri::SendOverpaymentPayoutJob

class Mri::SendOverpaymentPayoutJob < ApplicationJob
  include StpDepositLoggable
  LOG_TAG = '[Mri::SendOverpaymentPayoutJob]'

  def perform(erp_credit_balance_transaction_id, from_transaction_id:)
    erp_transaction = ErpCreditBalanceTransaction.find(erp_credit_balance_transaction_id)
    cost_center = erp_transaction.cost_center
    return unless cost_center&.bank_account.present?

    company = erp_transaction.mica_rent.broker&.company
    source_clabe = base_account_clabe(company)

    RentalMonths::Payout.new(
      erp_transaction, erp_transaction.amount, cost_center.bank_account,
      source_clabe: source_clabe, from_transaction_id: from_transaction_id,
    ).process!
  rescue StandardError => e
    Rollbar.error(e)
  end
end

OverpaymentProcessor — se agregó send_payout!

Antes solo encolaba CreditBalanceSubmitterJob. Ahora también encola SendOverpaymentPayoutJob con 20 segundos de espera:

# Antes (overpayment_processor.rb)
Mri::CreditBalanceSubmitterJob.set(wait: 60.seconds).perform_later(withdrawal.id, erp_transaction.id)
# fin del método

# Ahora
Mri::CreditBalanceSubmitterJob.set(wait: 60.seconds).perform_later(withdrawal.id, erp_transaction.id)
send_payout!(erp_transaction)  # nuevo

def send_payout!(erp_transaction)
  return unless erp_transaction.cost_center&.bank_account.present?
  Mri::SendOverpaymentPayoutJob.set(wait: 20.seconds).perform_later(
    erp_transaction.id, from_transaction_id: @deposit_transaction&.id
  )
end

ErpCreditBalanceTransaction — nuevas asociaciones y métodos

# Antes
class ErpCreditBalanceTransaction < ApplicationRecord
  belongs_to :mica_rent
  has_many :http_generic_logs, as: :loggable
  # ...
end

# Ahora
class ErpCreditBalanceTransaction < ApplicationRecord
  audited

  belongs_to :mica_rent
  has_many :http_generic_logs, as: :loggable
  has_many :withdrawal_transactions, class_name: 'Wallet::Transaction', as: :transactionable
  has_many :payouts, class_name: 'PayoutOrder', as: :payable

  def cost_center
    CostCenter.find_by(name: CostCenter::GRAN_CIUDAD_RENTS, company_id: mica_rent.broker&.company_id)
  end

  def currency
    'MXN'
  end

  def payout_statement
    cost_center&.payout_statement
  end
end

Se agregaron: audited (necesario porque PayoutOrder tiene rewrite_audits_for :payable), withdrawal_transactions, payouts, cost_center, currency, payout_statement.


3. Prevención de remaining amount negativo

Problema

La sincronización de MRI sobre-acreditaba billing items, causando remaining_amount negativo. Cuando llegaba un pago STP, PayableProcessor#amount retornaba min(deposit_amount, remaining_amount) = un número negativo, y validate! lanzaba Wallets::Errors::NegativeAmount.

Cambios — Clamp a 0 en todos los puntos de persistencia

RemainingAmountResolver (rental month)

Antes calculaba billing_items.sum(:amount) - discount - withdrawals de forma global. Si un billing item era sobre-pagado, los withdrawals extra reducían el total del rental month, "robando" de otros billing items pendientes. Ahora suma los withdrawals por cada billing item y los capea al monto del billing item — el sobre-pago en uno no afecta a otros.

# Antes
def remaining_amount
  @remaining_amount ||= (amount_to_add - amount_to_subtract).round(2)
end

def amount_to_add
  @rental_month.billing_items&.not_transferred&.sum(:amount)
end

def amount_to_subtract
  @rental_month.discount_applied.to_f + withdrawals_amount
end

def withdrawals_amount
  @rental_month.billing_items_withdrawals&.merge(RentalMonth::BillingItem.not_transferred)&.sum(:amount)
end

# Ahora
def remaining_amount
  @remaining_amount ||= (amount_to_add - capped_withdrawals_total).round(2)
end

def amount_to_add
  @amount_to_add ||= @rental_month.billing_items&.not_transferred&.sum(:amount).to_f
end

def capped_withdrawals_total
  @capped_withdrawals_total ||= @rental_month.billing_items.not_transferred
                                              .includes(:withdrawal_transactions).to_a
                                              .sum do |billing_item|
    item_withdrawals = billing_item.withdrawal_transactions.sum(&:amount).to_f
    [item_withdrawals, billing_item.amount.to_f].min
  end
end

Ejemplo: billing item A (amount: 500, withdrawals: 700) y billing item B (amount: 300, sin withdrawals).

  • Antes: remaining = (500 + 300) - 700 = 100 (los 200 extra de A "robaban" de B)
  • Ahora: remaining = (500 + 300) - (min(700,500) + min(0,300)) = 800 - 500 = 300 (correcto)

BillingItems::Payer (billing item)

# Antes
remaining_amount = @billing_item.remaining_amount - transaction_amount

# Ahora
remaining_amount = [@billing_item.remaining_amount - transaction_amount, 0.0].max

ProcessMriLedgerBatchJob (recálculo batch MRI)

# Antes
new_remaining = billing_item.amount - paid
billing_item.update!(remaining_amount: new_remaining, status: new_remaining <= 0 ? 'paid' : 'pending')

# Ahora
new_remaining = [billing_item.amount - paid, 0.0].max
billing_item.update!(remaining_amount: new_remaining, status: new_remaining.zero? ? 'paid' : 'pending')

ApplicationFeeReimbursement (reembolso de fee)

# Antes
billing_item.update!(
  remaining_amount: billing_item.remaining_amount - amount,
  status: (billing_item.remaining_amount - amount).zero? ? :paid : :pending
)

# Ahora
new_remaining = [billing_item.remaining_amount - amount, 0.0].max
billing_item.update!(
  remaining_amount: new_remaining,
  status: new_remaining.zero? ? :paid : :pending
)

BillingItems::Updater

# Antes
def remaining_amount
  return @amount unless withdrawal_transactions?
  @amount - total_amount_withdrawal_transactions
end

# Ahora
def remaining_amount
  return @amount unless withdrawal_transactions?
  [@amount - total_amount_withdrawal_transactions, 0.0].max
end

Filtro defensivo en last_pending_rental_month

PayUserPendingRentalMonths ya filtraba por remaining_amount > 0, pero last_pending_rental_month no. Se alinearon:

# Antes
def last_pending_rental_month
  rental_months.pending.reorder(pay_date: :asc).first
end

# Ahora
def last_pending_rental_month
  rental_months.pending.where('remaining_amount > 0').reorder(pay_date: :asc).first
end

4. Sobrepago cuando no hay meses pendientes (rentas MRI)

Problema

Cuando un inquilino de una renta MRI hacía un pago STP pero no había meses pendientes (ya todos pagados, o aún no cargados desde MRI), el dinero se quedaba en el wallet sin generar un sobrepago. Además, el TargetResolver no encontraba la renta MRI porque el scope with_pending_rental_months requería al menos un rental month con status pending.

Cambios

MicaRent.with_pending_rental_months — incluir rentas MRI

# Antes
scope :with_pending_rental_months, lambda {
  joins(:rental_months).where(rental_months: { status: 'pending' }).distinct
}

# Ahora
scope :with_pending_rental_months, lambda {
  left_joins(:rental_months, broker: :company)
    .where(
      'rental_months.status = ? OR companies.rent_settings @> ?',
      'pending',
      { external_erp_system: 'mri' }.to_json
    )
    .distinct
}

Usa left_joins para no excluir rentas sin rental months ni sin broker/company. Las rentas MRI se incluyen siempre, aún sin meses pendientes.

Payments::PaidProcessor — procesar sobrepago MRI cuando no hay payable

# Antes
return if payable.nil?
::Wallets::Withdrawals::PayableProcessor.new(@reference_order).withdrawal!

# Ahora
if payable.present?
  ::Wallets::Withdrawals::PayableProcessor.new(@reference_order).withdrawal!
elsif @mica_rent&.mri_rent?
  process_mri_overpayment!
end

def process_mri_overpayment!
  deposit_transaction = deposit_processor.transaction
  unless deposit_transaction
    log("deposit_processor.transaction is nil for MRI rent #{@mica_rent.id}, skipping overpayment")
    return
  end

  log("No pending rental month for MRI rent #{@mica_rent.id}. Creating overpayment for deposit amount: #{deposit_transaction.amount}")
  ::Mri::Ledger::OverpaymentProcessor.new(
    wallet, deposit_transaction, @mica_rent
  ).process!(overpayment_amount: deposit_transaction.amount)
end

Acepta mica_rent: como parámetro opcional (default nil). Los otros callers (Stripe, BanWire) no se afectan.

Wallets::Rechargers::Stp — pasar contexto de mica_rent

# Antes
Payments::PaidProcessor.new(reference_order).mark_as_paid!

# Ahora
Payments::PaidProcessor.new(reference_order, mica_rent: @mica_rent).mark_as_paid!

Flujo completo

  1. Pago STP llega → TargetResolver encuentra la renta MRI (gracias al scope actualizado)
  2. Stp recharger → last_pending_rental_month retorna nil → reference_order sin payable
  3. PaidProcessor → deposita en wallet → payable.nil? + mri_rent?process_mri_overpayment!
  4. OverpaymentProcessor crea withdrawal del wallet + ErpCreditBalanceTransaction + encola payout
  5. Resultado: wallet balance vuelve a 0, el monto queda como saldo a favor

5. Pandora — Panel de dispersiones de saldo a favor

ErpCreditBalanceTransaction (Pandora)

# Antes
class ErpCreditBalanceTransaction < ApplicationRecord
  belongs_to :mica_rent
  enum transaction_type: { credit: 'credit', debit: 'debit', overpayment: 'overpayment' }
end

# Ahora
class ErpCreditBalanceTransaction < ApplicationRecord
  audited
  belongs_to :mica_rent
  has_many :payouts, class_name: 'PayoutOrder', as: :payable
  enum transaction_type: { credit: 'credit', debit: 'debit', overpayment: 'overpayment' }
end

Show de mica_rent (_show.html.arb)

Se agregó un nuevo panel "Dispersiones de saldo a favor" dentro del bloque condicional de erp_credit_balance_transactions. Solo aparece si existen payouts asociados. Muestra: ID (con link), monto, destino (centro de costos con link), status y fecha de creación.

Traducción (mica_rents.es.yml)

erp_credit_balance_transactions:
  payouts_panel_title: 'Dispersiones de saldo a favor'  # nuevo

6. Specs nuevos/modificados

  • spec/lib/rental_months/payout_orders/billing_item_amount_calculator_spec.rb — Reescrito para usar transacciones reales en lugar de paid_amount. Agrega casos de pagos solo con manual_deposit y pagos mixtos.
  • spec/lib/mri/ledger/overpayment_processor_spec.rb — Agrega tests para encolamiento de SendOverpaymentPayoutJob.
  • spec/jobs/mri/send_overpayment_payout_job_spec.rb — Nuevo. Cubre: dispersión exitosa, sin centro de costos, sin cuenta bancaria, y manejo de errores.
  • spec/models/erp_credit_balance_transaction_spec.rb — Agrega tests para cost_center, currency, payout_statement, y nuevas asociaciones.
  • spec/lib/rental_months/remaining_amount_resolver_spec.rb — Nuevo. Cubre: withdrawals excediendo monto del billing item (capped), sobre-pago en un item no afecta a otros, pagos parciales, y sin withdrawals.
  • spec/lib/rental_months/billing_items/payer_spec.rb — Agrega caso para clamp cuando pago excede remaining.
  • spec/models/mica_rent/last_pending_rental_month_spec.rb — Nuevo. Cubre filtro de remaining negativo, cero y positivo.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment