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.
# billing_item_amount_calculator.rb
def payout_amount
@billing_item.paid_amount - payouts_processed_amount
end# 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
endSolo se cuentan withdrawals cuyo deposit_transaction sea de tipo :deposit (STP real), excluyendo :manual_deposit (sync MRI).
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.
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
endOverpaymentProcessor — 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
)
endErpCreditBalanceTransaction — 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
endSe agregaron: audited (necesario porque PayoutOrder tiene rewrite_audits_for :payable), withdrawal_transactions, payouts, cost_center, currency, payout_statement.
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.
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
endEjemplo: 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].maxProcessMriLedgerBatchJob (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
endPayUserPendingRentalMonths 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
endCuando 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.
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)
endAcepta 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!- Pago STP llega →
TargetResolverencuentra la renta MRI (gracias al scope actualizado) Stprecharger →last_pending_rental_monthretornanil→ reference_order sin payablePaidProcessor→ deposita en wallet →payable.nil?+mri_rent?→process_mri_overpayment!OverpaymentProcessorcrea withdrawal del wallet +ErpCreditBalanceTransaction+ encola payout- Resultado: wallet balance vuelve a 0, el monto queda como saldo a favor
# 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' }
endSe 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.
erp_credit_balance_transactions:
payouts_panel_title: 'Dispersiones de saldo a favor' # nuevospec/lib/rental_months/payout_orders/billing_item_amount_calculator_spec.rb— Reescrito para usar transacciones reales en lugar depaid_amount. Agrega casos de pagos solo con manual_deposit y pagos mixtos.spec/lib/mri/ledger/overpayment_processor_spec.rb— Agrega tests para encolamiento deSendOverpaymentPayoutJob.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 paracost_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.