Skip to content
Merged
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
6 changes: 5 additions & 1 deletion app/controllers/charges_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ def create
unless Charges::CreateFeeService::ALLOWED_KINDS.include?(kind)
@charge = @tenancy.charges.new(charge_params)
@charge.errors.add(:charge_kind, "must be late_fee or other")
return render :new, status: :unprocessable_content
respond_to do |format|
format.html { render :new, status: :unprocessable_content }
format.json { render json: @charge.errors, status: :unprocessable_content }
end
return
end

result = Charges::CreateFeeService.call(
Expand Down
1 change: 1 addition & 0 deletions app/controllers/properties_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ def show
tenancies: %i[parties receipts charges]
).find(params.expect(:id))
@financial_items = @property.financial_items(@year)
@security_deposits_held_cents = Accounting::SecurityDepositBalanceQuery.call(property: @property)
end

def schedule_e
Expand Down
75 changes: 75 additions & 0 deletions app/controllers/security_deposit_transactions_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
class SecurityDepositTransactionsController < ApplicationController
before_action :set_transaction

def show
@journal_entries = @transaction.journal_entries.includes(postings: :account).order(:occurred_on, :id)
end

def correction
if @transaction.voided? || @transaction.superseded?
redirect_to security_deposit_transaction_path(@transaction), alert: "This transaction cannot be corrected."
return
end

@parties = authenticated_user.parties.order(:display_name)
@active_charges = load_active_charges
end

def correct
result = SecurityDepositTransactions::CorrectService.call(
transaction: @transaction,
amount: transaction_params[:amount],
occurred_on: transaction_params[:occurred_on],
party_id: transaction_params[:party_id],
charge_id: transaction_params[:charge_id],
external_reference: transaction_params[:external_reference],
memo: transaction_params[:memo]
)

if result.success?
replacement = result.value!.data[:replacement]
redirect_to security_deposit_transaction_path(replacement), notice: "Deposit transaction corrected successfully."
else
@parties = authenticated_user.parties.order(:display_name)
@active_charges = load_active_charges
flash.now[:alert] = result.failure.error
render :correction, status: :unprocessable_entity
end
end

def void
result = SecurityDepositTransactions::VoidService.call(
transaction: @transaction,
reason: params[:reason]
)

if result.success?
redirect_to tenancy_security_deposit_path(@transaction.tenancy), notice: "Deposit transaction voided successfully."
else
redirect_to security_deposit_transaction_path(@transaction), alert: result.failure.error
end
end

private

def load_active_charges
tenancy = @transaction.tenancy
if tenancy
tenancy.charges.posted.active.order(charge_date: :desc)
else
Charge.none
end
end

def set_transaction
@transaction = SecurityDepositTransaction.joins(security_deposit: { tenancy: { rentable_unit: :property } })
.where(properties: { user_id: authenticated_user.id })
.find(params[:id])
end

def transaction_params
params.require(:security_deposit_transaction).permit(
:amount, :occurred_on, :party_id, :charge_id, :external_reference, :memo
)
end
end
128 changes: 128 additions & 0 deletions app/controllers/security_deposits_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
class SecurityDepositsController < ApplicationController
before_action :set_tenancy
before_action :set_security_deposit, only: %i[show edit update receive refund apply]

def show
@transactions = @security_deposit.transactions.includes(:party, :charge, :superseded_by).order(occurred_on: :desc, id: :desc)
@parties = authenticated_user.parties.order(:display_name)
@active_charges = @tenancy.charges.posted.active.order(charge_date: :desc)
end

def new
if @tenancy.security_deposit.present?
redirect_to tenancy_security_deposit_path(@tenancy)
return
end

@security_deposit = @tenancy.build_security_deposit(due_on: @tenancy.commencement_date)
end

def create
result = SecurityDeposits::CreateService.call(
tenancy: @tenancy,
required_amount: security_deposit_params[:required_amount],
due_on: security_deposit_params[:due_on]
)

if result.success?
redirect_to tenancy_security_deposit_path(@tenancy), notice: "Security deposit requirement recorded."
else
@security_deposit = @tenancy.build_security_deposit(security_deposit_params)
flash.now[:alert] = result.failure.error
render :new, status: :unprocessable_entity
end
end

def edit
if @security_deposit.transactions.exists?
redirect_to tenancy_security_deposit_path(@tenancy), alert: "Deposit requirement cannot be edited after transactions exist."
end
end

def update
result = SecurityDeposits::UpdateService.call(
security_deposit: @security_deposit,
required_amount: security_deposit_params[:required_amount],
due_on: security_deposit_params[:due_on]
)

if result.success?
redirect_to tenancy_security_deposit_path(@tenancy), notice: "Security deposit requirement updated."
else
flash.now[:alert] = result.failure.error
render :edit, status: :unprocessable_entity
end
end

def receive
party = authenticated_user.parties.find_by(id: params[:party_id])
result = SecurityDepositTransactions::ReceiveService.call(
security_deposit: @security_deposit,
party: party,
amount: params[:amount],
occurred_on: params[:occurred_on],
external_reference: params[:external_reference],
memo: params[:memo]
)

if result.success?
redirect_to tenancy_security_deposit_path(@tenancy), notice: "Security deposit payment received."
else
redirect_to tenancy_security_deposit_path(@tenancy), alert: result.failure.error
end
end

def refund
party = authenticated_user.parties.find_by(id: params[:party_id])
result = SecurityDepositTransactions::RefundService.call(
security_deposit: @security_deposit,
party: party,
amount: params[:amount],
occurred_on: params[:occurred_on],
external_reference: params[:external_reference],
memo: params[:memo]
)

if result.success?
redirect_to tenancy_security_deposit_path(@tenancy), notice: "Security deposit refund recorded."
else
redirect_to tenancy_security_deposit_path(@tenancy), alert: result.failure.error
end
end

def apply
charge = @tenancy.charges.find_by(id: params[:charge_id])
result = SecurityDepositTransactions::ApplyService.call(
security_deposit: @security_deposit,
charge: charge,
amount: params[:amount],
occurred_on: params[:occurred_on],
memo: params[:memo]
)

if result.success?
redirect_to tenancy_security_deposit_path(@tenancy), notice: "Security deposit applied to charge."
else
redirect_to tenancy_security_deposit_path(@tenancy), alert: result.failure.error
end
end

private

def set_tenancy
@tenancy = authenticated_user.tenancies.find(params[:tenancy_id])
end

def set_security_deposit
deposit = @tenancy.security_deposit
unless deposit
redirect_to new_tenancy_security_deposit_path(@tenancy)
return
end
@security_deposit = deposit
end

def security_deposit_params
params.require(:security_deposit).permit(:required_amount, :due_on)
end
end
12 changes: 12 additions & 0 deletions app/models/charge.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ class Charge < ApplicationRecord

has_one :superseded_charge, class_name: "Charge", foreign_key: :superseded_by_id
has_many :journal_entries, as: :source, dependent: :restrict_with_error
has_many :security_deposit_applications,
-> { where(transaction_kind: "applied") },
class_name: "SecurityDepositTransaction",
dependent: :restrict_with_error

enum :charge_kind, CHARGE_KINDS.index_by(&:itself), prefix: false, validate: true

Expand Down Expand Up @@ -92,6 +96,14 @@ def accounting_user
tenancy&.accounting_user
end

def deposit_applied_cents
security_deposit_applications.active.sum(&:amount_cents)
end

def remaining_deposit_application_cents
[ amount_cents - deposit_applied_cents, 0 ].max
end

private

def validate_service_period_range
Expand Down
1 change: 1 addition & 0 deletions app/models/party.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ class Party < ApplicationRecord
has_many :tenancies, through: :tenancy_parties
has_many :accounting_postings, class_name: "Posting", dependent: :restrict_with_error
has_many :receipts_as_payer, class_name: "Receipt", foreign_key: :payer_party_id, dependent: :restrict_with_error
has_many :security_deposit_transactions, dependent: :restrict_with_error
has_many :payment_ingestions, dependent: :nullify

PARTY_TYPES = %w[
Expand Down
1 change: 1 addition & 0 deletions app/models/property.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ class Property < ApplicationRecord
has_many :expenses, dependent: :restrict_with_error
has_many :charges, through: :tenancies
has_many :receipts, through: :tenancies
has_many :security_deposit_transactions, through: :tenancies
has_many :accounting_postings, class_name: "Posting", dependent: :restrict_with_error

ASSET_TYPES = %w[
Expand Down
99 changes: 99 additions & 0 deletions app/models/security_deposit.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
class SecurityDeposit < ApplicationRecord
belongs_to :tenancy

has_many :transactions,
class_name: "SecurityDepositTransaction",
dependent: :restrict_with_error

has_many :journal_entries, as: :source, dependent: :restrict_with_error
has_many :postings, through: :journal_entries

validates :required_amount_cents, numericality: { only_integer: true, greater_than: 0 }
validates :due_on, presence: true
validates :tenancy_id, uniqueness: true

validate :validate_requirement_immutability, on: :update

def required_amount
required_amount_cents ? (required_amount_cents / 100.0) : 0.0
end

def required_amount=(val)
if val.nil? || (val.is_a?(String) && val.blank?)
write_attribute(:required_amount_cents, nil)
return
end

str = val.is_a?(Numeric) ? val.to_s : val.to_s.strip
if str.match?(/\A\d+(\.\d{1,2})?\z/)
self.required_amount_cents = (BigDecimal(str) * 100).round
else
self.required_amount_cents = -1
end
end

def accounting_user
tenancy&.accounting_user
end

def property
tenancy&.property
end

def rentable_unit
tenancy&.rentable_unit
end

def held_cents(as_of: Date.current)
Accounting::SecurityDepositBalanceQuery.call(tenancy: tenancy, as_of: as_of)
end

def held_amount(as_of: Date.current)
held_cents(as_of: as_of) / 100.0
end

def remaining_required_cents(as_of: Date.current)
[ required_amount_cents - held_cents(as_of: as_of), 0 ].max
end

def remaining_required_amount(as_of: Date.current)
remaining_required_cents(as_of: as_of) / 100.0
end

def fully_funded?(as_of: Date.current)
held_cents(as_of: as_of) >= required_amount_cents
end

def overfunded?(as_of: Date.current)
held_cents(as_of: as_of) > required_amount_cents
end

def funding_status(as_of: Date.current)
held = held_cents(as_of: as_of)
if held == 0
"not_funded"
elsif held < required_amount_cents
"partially_funded"
elsif held == required_amount_cents
"funded"
else
"overfunded"
end
end

private

def validate_requirement_immutability
if transactions.exists?
if required_amount_cents_changed?
errors.add(:required_amount_cents, "cannot be changed after deposit transactions exist")
end
if due_on_changed?
errors.add(:due_on, "cannot be changed after deposit transactions exist")
end
if tenancy_id_changed?
errors.add(:tenancy_id, "cannot be changed after deposit transactions exist")
end
end
end
end
Loading