diff --git a/app/controllers/charges_controller.rb b/app/controllers/charges_controller.rb
index 12f47f48..ef3fef24 100644
--- a/app/controllers/charges_controller.rb
+++ b/app/controllers/charges_controller.rb
@@ -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(
diff --git a/app/controllers/properties_controller.rb b/app/controllers/properties_controller.rb
index a557e86a..de432343 100644
--- a/app/controllers/properties_controller.rb
+++ b/app/controllers/properties_controller.rb
@@ -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
diff --git a/app/controllers/security_deposit_transactions_controller.rb b/app/controllers/security_deposit_transactions_controller.rb
new file mode 100644
index 00000000..c7fde17d
--- /dev/null
+++ b/app/controllers/security_deposit_transactions_controller.rb
@@ -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
diff --git a/app/controllers/security_deposits_controller.rb b/app/controllers/security_deposits_controller.rb
new file mode 100644
index 00000000..bfb6d54f
--- /dev/null
+++ b/app/controllers/security_deposits_controller.rb
@@ -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
diff --git a/app/models/charge.rb b/app/models/charge.rb
index 2902a672..cb870263 100644
--- a/app/models/charge.rb
+++ b/app/models/charge.rb
@@ -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
@@ -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
diff --git a/app/models/party.rb b/app/models/party.rb
index 58f4cedf..93dbe4d5 100644
--- a/app/models/party.rb
+++ b/app/models/party.rb
@@ -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[
diff --git a/app/models/property.rb b/app/models/property.rb
index 9e321645..14dfebce 100644
--- a/app/models/property.rb
+++ b/app/models/property.rb
@@ -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[
diff --git a/app/models/security_deposit.rb b/app/models/security_deposit.rb
new file mode 100644
index 00000000..2fafb2a1
--- /dev/null
+++ b/app/models/security_deposit.rb
@@ -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
diff --git a/app/models/security_deposit_transaction.rb b/app/models/security_deposit_transaction.rb
new file mode 100644
index 00000000..9a6b4d92
--- /dev/null
+++ b/app/models/security_deposit_transaction.rb
@@ -0,0 +1,175 @@
+class SecurityDepositTransaction < ApplicationRecord
+ KINDS = %w[received refunded applied].freeze
+
+ enum :transaction_kind, {
+ received: "received",
+ refunded: "refunded",
+ applied: "applied"
+ }, validate: true
+
+ belongs_to :security_deposit
+ belongs_to :party, optional: true
+ belongs_to :charge, optional: true
+ belongs_to :superseded_by, class_name: "SecurityDepositTransaction", optional: true
+ has_one :superseded_transaction, class_name: "SecurityDepositTransaction", foreign_key: :superseded_by_id, dependent: :nullify
+
+ has_many :journal_entries, as: :source, dependent: :restrict_with_error
+ has_many :postings, through: :journal_entries
+
+ validates :amount_cents, numericality: { only_integer: true, greater_than: 0 }
+ validates :occurred_on, presence: true
+ validate :validate_occurred_on_not_in_future
+ validate :validate_kind_requirements
+ validate :validate_ownership_and_tenancy
+ validate :prevent_direct_lifecycle_assignment
+ validate :validate_immutability_when_posted, on: :update
+
+ before_destroy :prevent_destroy_if_posted
+
+ scope :active, -> { where(voided_at: nil) }
+ scope :posted, -> { where.not(posted_at: nil) }
+ scope :voided, -> { where.not(voided_at: nil) }
+ scope :superseded, -> { where.not(superseded_by_id: nil) }
+
+ def amount
+ amount_cents ? (amount_cents / 100.0) : 0.0
+ end
+
+ def amount=(val)
+ if val.nil? || (val.is_a?(String) && val.blank?)
+ write_attribute(: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.amount_cents = (BigDecimal(str) * 100).round
+ else
+ self.amount_cents = -1
+ end
+ end
+
+ def posted?
+ posted_at.present?
+ end
+
+ def voided?
+ voided_at.present?
+ end
+
+ def superseded?
+ superseded_by_id.present?
+ end
+
+ def active?
+ posted? && !voided?
+ end
+
+ def tenancy
+ security_deposit&.tenancy
+ end
+
+ def property
+ tenancy&.property
+ end
+
+ def rentable_unit
+ tenancy&.rentable_unit
+ end
+
+ def accounting_user
+ security_deposit&.accounting_user
+ end
+
+ private
+
+ def validate_occurred_on_not_in_future
+ return unless occurred_on
+
+ if occurred_on > Date.current
+ errors.add(:occurred_on, "cannot be in the future")
+ end
+ end
+
+ def validate_kind_requirements
+ case transaction_kind
+ when "received"
+ errors.add(:party, "is required for received deposit") if party_id.blank?
+ errors.add(:charge, "must be blank for received deposit") if charge_id.present?
+ when "refunded"
+ errors.add(:party, "is required for deposit refund") if party_id.blank?
+ errors.add(:charge, "must be blank for deposit refund") if charge_id.present?
+ when "applied"
+ errors.add(:charge, "is required for deposit application") if charge_id.blank?
+ errors.add(:party, "must be blank for deposit application") if party_id.present?
+ end
+ end
+
+ def validate_ownership_and_tenancy
+ return unless security_deposit
+
+ p = party
+ if p && p.user_id != security_deposit.accounting_user&.id
+ errors.add(:party, "must belong to your account")
+ end
+
+ c = charge
+ if c
+ if c.tenancy_id != security_deposit.tenancy_id
+ errors.add(:charge, "must belong to the same tenancy as the security deposit")
+ end
+ if c.tenancy&.accounting_user != security_deposit.accounting_user
+ errors.add(:charge, "must belong to your account")
+ end
+ if applied? && occurred_on.present? && c.charge_date.present? && occurred_on < c.charge_date
+ errors.add(:occurred_on, "cannot precede the charge being settled (#{c.charge_date})")
+ end
+ end
+ end
+
+ def prevent_direct_lifecycle_assignment
+ if new_record?
+ if posted_at.present?
+ errors.add(:posted_at, "cannot be modified directly; posting is managed by the accounting service")
+ end
+ if voided_at.present?
+ errors.add(:voided_at, "cannot be modified directly; use SecurityDepositTransactions::VoidService or SecurityDepositTransactions::CorrectService")
+ end
+ if superseded_by_id.present?
+ errors.add(:superseded_by_id, "cannot be modified directly; use SecurityDepositTransactions::CorrectService")
+ end
+ else
+ if will_save_change_to_voided_at?
+ errors.add(:voided_at, "cannot be modified directly; use SecurityDepositTransactions::VoidService or SecurityDepositTransactions::CorrectService")
+ end
+ if will_save_change_to_superseded_by_id?
+ errors.add(:superseded_by_id, "cannot be modified directly; use SecurityDepositTransactions::CorrectService")
+ end
+ if will_save_change_to_posted_at?
+ errors.add(:posted_at, "cannot be modified directly; posting is managed by the accounting service")
+ end
+ end
+ end
+
+ def validate_immutability_when_posted
+ return unless posted_at_was.present?
+
+ immutable_fields = %w[
+ security_deposit_id transaction_kind amount_cents occurred_on
+ party_id charge_id external_reference memo
+ ]
+
+ immutable_fields.each do |field|
+ if will_save_change_to_attribute?(field)
+ errors.add(field.to_sym, "cannot be changed after transaction is posted")
+ end
+ end
+ end
+
+ def prevent_destroy_if_posted
+ if posted?
+ errors.add(:base, "Posted transactions cannot be deleted")
+ throw :abort
+ end
+ end
+end
diff --git a/app/models/tenancy.rb b/app/models/tenancy.rb
index b5cc3539..541a9b4d 100644
--- a/app/models/tenancy.rb
+++ b/app/models/tenancy.rb
@@ -8,6 +8,8 @@ class Tenancy < ApplicationRecord
has_many :charges, dependent: :restrict_with_error
has_many :receipts, dependent: :restrict_with_error
has_many :accounting_postings, class_name: "Posting", dependent: :restrict_with_error
+ has_one :security_deposit, dependent: :restrict_with_error
+ has_many :security_deposit_transactions, through: :security_deposit, source: :transactions
has_many :payment_ingestions, dependent: :nullify
AGREEMENT_TYPES = %w[
@@ -91,7 +93,7 @@ def most_recent_rent_term
end
def financial_history?
- charges.exists? || receipts.exists? || accounting_postings.exists?
+ charges.exists? || receipts.exists? || accounting_postings.exists? || security_deposit_transactions.exists?
end
def balance_cents(as_of: Date.current)
diff --git a/app/queries/accounting/security_deposit_balance_query.rb b/app/queries/accounting/security_deposit_balance_query.rb
new file mode 100644
index 00000000..07e24e9d
--- /dev/null
+++ b/app/queries/accounting/security_deposit_balance_query.rb
@@ -0,0 +1,42 @@
+module Accounting
+ class SecurityDepositBalanceQuery
+ def self.call(tenancy: nil, property: nil, user: nil, as_of: nil)
+ new(tenancy: tenancy, property: property, user: user).balance_cents_as_of(as_of || Date.current)
+ end
+
+ def initialize(tenancy: nil, property: nil, user: nil)
+ @tenancy = tenancy
+ @property = property || tenancy&.property
+ @user = user || tenancy&.accounting_user || @property&.user
+ end
+
+ def balance_cents_as_of(as_of = Date.current)
+ return 0 unless user
+
+ account = user.accounts.find_by(key: "security_deposits_held")
+ return 0 unless account
+
+ scope = account.postings
+ .joins(:journal_entry)
+ .where("journal_entries.occurred_on <= ?", as_of)
+
+ if tenancy
+ scope = scope.where(tenancy_id: tenancy.id)
+ elsif property
+ scope = scope.where(property_id: property.id)
+ end
+
+ # Liability postings have negative amounts for credits (deposit held).
+ # Inverting sign gives positive amount for held deposit.
+ -scope.sum(:amount_cents)
+ end
+
+ def balance_as_of(as_of = Date.current)
+ BigDecimal(balance_cents_as_of(as_of)) / 100
+ end
+
+ private
+
+ attr_reader :tenancy, :property, :user
+ end
+end
diff --git a/app/queries/properties/active_years_query.rb b/app/queries/properties/active_years_query.rb
index 52b8a289..184daa74 100644
--- a/app/queries/properties/active_years_query.rb
+++ b/app/queries/properties/active_years_query.rb
@@ -10,6 +10,7 @@ def call(additional_years: [])
years.merge(years_for(:charges, :charge_date))
years.merge(years_for(:receipts, :received_on))
years.merge(years_for(:expenses, :paid_on))
+ years.merge(years_for(:security_deposit_transactions, :occurred_on))
additional_years.each do |year|
next unless year.respond_to?(:to_i)
diff --git a/app/queries/properties/financial_items_query.rb b/app/queries/properties/financial_items_query.rb
index 2736c396..0fb4ccb1 100644
--- a/app/queries/properties/financial_items_query.rb
+++ b/app/queries/properties/financial_items_query.rb
@@ -11,7 +11,8 @@ def call(year:)
[
items_for(:charges, :charge_date, "Charge", start_date, end_date),
items_for(:receipts, :received_on, "Payment", start_date, end_date),
- items_for(:expenses, :paid_on, "Expense", start_date, end_date)
+ items_for(:expenses, :paid_on, "Expense", start_date, end_date),
+ items_for(:security_deposit_transactions, :occurred_on, "Security Deposit", start_date, end_date)
].flatten.sort_by { |item| item[:date] }
end
diff --git a/app/services/charges/correct_service.rb b/app/services/charges/correct_service.rb
index c8506642..8cb61967 100644
--- a/app/services/charges/correct_service.rb
+++ b/app/services/charges/correct_service.rb
@@ -162,6 +162,11 @@ def call
end
end
+ if charge.security_deposit_applications.active.exists?
+ failure_result = failure("Cannot correct a charge with active security deposit applications. Void or correct the deposit applications first.", :active_deposit_applications)
+ raise ActiveRecord::Rollback
+ end
+
if charge.voided?
failure_result = failure("Cannot correct a voided charge", :already_voided)
raise ActiveRecord::Rollback
diff --git a/app/services/charges/void_service.rb b/app/services/charges/void_service.rb
index 99a48c6f..6ec08e46 100644
--- a/app/services/charges/void_service.rb
+++ b/app/services/charges/void_service.rb
@@ -48,6 +48,14 @@ def call
raise ActiveRecord::Rollback
end
+ if charge.security_deposit_applications.active.exists?
+ failure_result = ServiceResult.failure(
+ error: "Cannot void a charge with active security deposit applications. Void or correct the deposit applications first.",
+ code: :active_deposit_applications
+ )
+ raise ActiveRecord::Rollback
+ end
+
description = reason.presence || "Void charge ##{charge.id}: #{charge.description || charge.charge_kind}"
effective_occurred_on = [ resolved_occurred_on, journal_entry.occurred_on ].max
diff --git a/app/services/expenses/correct_service.rb b/app/services/expenses/correct_service.rb
index 7171da61..b8f9a3cb 100644
--- a/app/services/expenses/correct_service.rb
+++ b/app/services/expenses/correct_service.rb
@@ -143,6 +143,13 @@ def call
end
active_reimbursements.each do |reimb|
+ if reimb.security_deposit_applications.active.exists?
+ return failure(
+ "Active reimbursement charge ##{reimb.id} has active security deposit applications. Void or correct the deposit applications first.",
+ :active_deposit_applications
+ )
+ end
+
reimb_tenancy = reimb.tenancy
if reimb_tenancy&.property&.id != target_prop.id
return failure(
diff --git a/app/services/security_deposit_transactions/apply_service.rb b/app/services/security_deposit_transactions/apply_service.rb
new file mode 100644
index 00000000..11995112
--- /dev/null
+++ b/app/services/security_deposit_transactions/apply_service.rb
@@ -0,0 +1,200 @@
+module SecurityDepositTransactions
+ class ApplyService
+ def self.call(security_deposit:, charge: nil, charge_id: nil, amount: nil, amount_cents: nil, occurred_on: nil, memo: nil)
+ new(
+ security_deposit: security_deposit,
+ charge: charge,
+ charge_id: charge_id,
+ amount: amount,
+ amount_cents: amount_cents,
+ occurred_on: occurred_on,
+ memo: memo
+ ).call
+ end
+
+ def initialize(security_deposit:, charge: nil, charge_id: nil, amount: nil, amount_cents: nil, occurred_on: nil, memo: nil)
+ @security_deposit = security_deposit
+ @charge = charge
+ @charge_id = charge_id
+ @raw_amount = amount
+ @raw_cents = amount_cents
+ @raw_occurred_on = occurred_on
+ @memo = memo
+ end
+
+ def call
+ unless security_deposit.is_a?(SecurityDeposit) && security_deposit.persisted? && !security_deposit.destroyed?
+ return ServiceResult.failure(error: "Security deposit must be a persisted record", code: :invalid_deposit)
+ end
+
+ res_charge = resolve_charge
+ unless res_charge.is_a?(Charge) && res_charge.persisted? && !res_charge.destroyed?
+ return ServiceResult.failure(error: "Charge must be a persisted record", code: :invalid_charge)
+ end
+
+ if res_charge.tenancy_id != security_deposit.tenancy_id
+ return ServiceResult.failure(error: "Charge must belong to the same tenancy as the security deposit", code: :tenancy_mismatch)
+ end
+
+ cents = resolve_cents
+ if cents.nil? || !cents.positive?
+ return ServiceResult.failure(error: "Amount must be greater than zero", code: :invalid_input)
+ end
+
+ if raw_occurred_on.blank?
+ return ServiceResult.failure(error: "Occurred on date is required", code: :invalid_input)
+ end
+
+ occ_date = resolve_occurred_on
+ unless occ_date
+ return ServiceResult.failure(error: "Invalid occurred on date", code: :invalid_input)
+ end
+
+ if occ_date > Date.current
+ return ServiceResult.failure(error: "Occurred on date cannot be in the future", code: :invalid_date)
+ end
+
+ created_txn = nil # : SecurityDepositTransaction?
+ journal_entry = nil # : JournalEntry?
+ failure_res = nil # : (Dry::Monads::Result::Success | Dry::Monads::Result::Failure)?
+
+ SecurityDeposit.transaction do
+ # Enforce lock order: SecurityDeposit -> Charge
+ security_deposit.lock!
+ res_charge.lock!
+
+ unless res_charge.posted? && res_charge.active?
+ failure_res = ServiceResult.failure(error: "Cannot apply deposit to an inactive or unposted charge", code: :invalid_charge_state)
+ raise ActiveRecord::Rollback
+ end
+
+ if occ_date < res_charge.charge_date
+ failure_res = ServiceResult.failure(
+ error: "Application date (#{occ_date}) cannot precede the charge date (#{res_charge.charge_date})",
+ code: :precedes_charge_date
+ )
+ raise ActiveRecord::Rollback
+ end
+
+ remaining_charge_cap = res_charge.remaining_deposit_application_cents
+ if cents > remaining_charge_cap
+ failure_res = ServiceResult.failure(
+ error: "Applied amount (#{format_money(cents)}) exceeds charge remaining capacity (#{format_money(remaining_charge_cap)})",
+ code: :exceeds_charge_capacity
+ )
+ raise ActiveRecord::Rollback
+ end
+
+ outstanding_balance = security_deposit.tenancy.balance_cents(as_of: occ_date)
+ if cents > outstanding_balance
+ failure_res = ServiceResult.failure(
+ error: "Applied amount (#{format_money(cents)}) exceeds tenancy outstanding balance (#{format_money(outstanding_balance)}) as of #{occ_date}",
+ code: :exceeds_tenancy_balance
+ )
+ raise ActiveRecord::Rollback
+ end
+
+ timeline_res = SecurityDeposits::LiabilityTimeline.validate(
+ security_deposit: security_deposit,
+ additions: [ { occurred_on: occ_date, delta_cents: -cents } ]
+ )
+ unless timeline_res.success?
+ failure_res = timeline_res
+ raise ActiveRecord::Rollback
+ end
+
+ txn = security_deposit.transactions.create!(
+ transaction_kind: "applied",
+ charge: res_charge,
+ amount_cents: cents,
+ occurred_on: occ_date,
+ memo: memo
+ )
+
+ post_res = post_transaction(txn)
+ unless post_res.success?
+ failure_res = post_res
+ raise ActiveRecord::Rollback
+ end
+
+ txn.update_columns(posted_at: Time.current)
+ created_txn = txn
+ journal_entry = post_res.value!.data[:journal_entry]
+ end
+
+ if (f = failure_res)
+ f
+ elsif (t = created_txn)
+ ServiceResult.success(transaction: t, journal_entry: journal_entry)
+ else
+ ServiceResult.failure(error: "Failed to apply security deposit", code: :application_failed)
+ end
+ end
+
+ private
+
+ attr_reader :security_deposit, :charge, :charge_id, :raw_amount, :raw_cents, :raw_occurred_on, :memo
+
+ def post_transaction(txn)
+ postings = [
+ Accounting::PostingSpec.new(
+ account_key: "security_deposits_held",
+ amount_cents: txn.amount_cents,
+ tenancy: txn.tenancy
+ ),
+ Accounting::PostingSpec.new(
+ account_key: "tenant_receivable",
+ amount_cents: -txn.amount_cents,
+ tenancy: txn.tenancy
+ )
+ ]
+
+ charge_desc = txn.charge&.description || txn.charge&.charge_kind&.titleize || "charge"
+ default_desc = "Security deposit applied to #{charge_desc}"
+
+ Accounting::PostEntryService.call(
+ source: txn,
+ event_type: "deposit_applied",
+ occurred_on: txn.occurred_on,
+ postings: postings,
+ description: txn.memo.presence || default_desc
+ )
+ end
+
+ def resolve_charge
+ return charge if charge.is_a?(Charge)
+ return Charge.find_by(id: charge_id) if charge_id.present?
+
+ nil
+ end
+
+ def resolve_cents
+ if raw_cents.present?
+ return raw_cents if raw_cents.is_a?(Integer)
+
+ nil
+ elsif raw_amount.present?
+ str = raw_amount.is_a?(Numeric) ? raw_amount.to_s : raw_amount.to_s.strip
+ return nil unless str.match?(/\A\d+(\.\d{1,2})?\z/)
+
+ (BigDecimal(str) * 100).round
+ else
+ nil
+ end
+ end
+
+ def resolve_occurred_on
+ return nil if raw_occurred_on.blank?
+ return raw_occurred_on if raw_occurred_on.is_a?(Date)
+ return raw_occurred_on.to_date if raw_occurred_on.respond_to?(:to_date)
+
+ Date.parse(raw_occurred_on.to_s)
+ rescue ArgumentError, Date::Error
+ nil
+ end
+
+ def format_money(cents)
+ sprintf("$%.2f", cents.to_f / 100)
+ end
+ end
+end
diff --git a/app/services/security_deposit_transactions/correct_service.rb b/app/services/security_deposit_transactions/correct_service.rb
new file mode 100644
index 00000000..a0432a64
--- /dev/null
+++ b/app/services/security_deposit_transactions/correct_service.rb
@@ -0,0 +1,383 @@
+module SecurityDepositTransactions
+ class CorrectService
+ def self.call(
+ transaction:,
+ amount: :not_set,
+ amount_cents: :not_set,
+ occurred_on: :not_set,
+ party: :not_set,
+ party_id: :not_set,
+ charge: :not_set,
+ charge_id: :not_set,
+ external_reference: :not_set,
+ memo: :not_set
+ )
+ new(
+ transaction: transaction,
+ amount: amount,
+ amount_cents: amount_cents,
+ occurred_on: occurred_on,
+ party: party,
+ party_id: party_id,
+ charge: charge,
+ charge_id: charge_id,
+ external_reference: external_reference,
+ memo: memo
+ ).call
+ end
+
+ def initialize(
+ transaction:,
+ amount: :not_set,
+ amount_cents: :not_set,
+ occurred_on: :not_set,
+ party: :not_set,
+ party_id: :not_set,
+ charge: :not_set,
+ charge_id: :not_set,
+ external_reference: :not_set,
+ memo: :not_set
+ )
+ @transaction = transaction
+ @raw_amount = amount
+ @raw_cents = amount_cents
+ @raw_occurred_on = occurred_on
+ @party = party
+ @party_id = party_id
+ @charge = charge
+ @charge_id = charge_id
+ @external_reference = external_reference
+ @memo = memo
+ end
+
+ def call
+ unless transaction.is_a?(SecurityDepositTransaction) && transaction.persisted? && !transaction.destroyed?
+ return ServiceResult.failure(error: "Transaction must be a persisted SecurityDepositTransaction record", code: :invalid_source)
+ end
+
+ deposit = transaction.security_deposit
+ unless deposit
+ return ServiceResult.failure(error: "Security deposit not found", code: :not_found)
+ end
+
+ cents = if raw_cents == :not_set && raw_amount == :not_set
+ transaction.amount_cents
+ else
+ resolve_cents
+ end
+
+ if cents.nil? || !cents.positive?
+ return ServiceResult.failure(error: "Amount must be greater than zero", code: :invalid_input)
+ end
+
+ occ_date = if raw_occurred_on == :not_set
+ transaction.occurred_on
+ else
+ resolve_occurred_on
+ end
+
+ unless occ_date
+ return ServiceResult.failure(error: "Occurred on date is invalid", code: :invalid_date)
+ end
+
+ if occ_date > Date.current
+ return ServiceResult.failure(error: "Occurred on date cannot be in the future", code: :invalid_date)
+ end
+
+ # Resolve party for received/refunded
+ res_party = nil # : Party?
+ if transaction.received? || transaction.refunded?
+ if party == :not_set && party_id == :not_set
+ res_party = transaction.party
+ elsif party.is_a?(Party)
+ res_party = party
+ elsif party_id != :not_set && party_id.present?
+ res_party = Party.find_by(id: party_id)
+ unless res_party
+ return ServiceResult.failure(error: "Party not found", code: :invalid_party)
+ end
+ else
+ return ServiceResult.failure(error: "Party is required", code: :invalid_party)
+ end
+
+ if res_party && res_party.user_id != deposit.accounting_user&.id
+ return ServiceResult.failure(error: "Party must belong to your account", code: :party_user_mismatch)
+ end
+ end
+
+ # Resolve charge for applied
+ res_charge = nil # : Charge?
+ if transaction.applied?
+ if charge == :not_set && charge_id == :not_set
+ res_charge = transaction.charge
+ elsif charge.is_a?(Charge)
+ res_charge = charge
+ elsif charge_id != :not_set && charge_id.present?
+ res_charge = Charge.find_by(id: charge_id)
+ unless res_charge
+ return ServiceResult.failure(error: "Charge not found", code: :invalid_charge)
+ end
+ else
+ return ServiceResult.failure(error: "Charge is required", code: :invalid_charge)
+ end
+ end
+
+ res_memo = if memo == :not_set
+ transaction.memo
+ elsif memo.present?
+ memo.to_s.strip
+ else
+ nil
+ end
+
+ res_ext = if external_reference == :not_set
+ transaction.external_reference
+ elsif external_reference.present?
+ external_reference.to_s.strip
+ else
+ nil
+ end
+
+ failure_result = nil # : (Dry::Monads::Result::Success | Dry::Monads::Result::Failure)?
+ success_result = nil # : (Dry::Monads::Result::Success | Dry::Monads::Result::Failure)?
+
+ SecurityDeposit.transaction do
+ # Enforce lock order: SecurityDeposit -> Charge(s) -> SecurityDepositTransaction
+ deposit.lock!
+
+ charges_to_lock = [ transaction.charge, res_charge ].compact.uniq.sort_by(&:id)
+ charges_to_lock.each do |c|
+ c.lock!
+ c.reload
+ end
+
+ transaction.lock!
+ transaction.reload
+
+ # 1. Check if already superseded (Idempotency)
+ if transaction.superseded?
+ replacement = transaction.superseded_by
+ reversal = transaction.journal_entries.find_by(event_type: "deposit_#{transaction.transaction_kind}")&.reversal
+
+ if replacement &&
+ replacement.amount_cents == cents &&
+ replacement.occurred_on == occ_date &&
+ replacement.party_id == res_party&.id &&
+ replacement.charge_id == res_charge&.id &&
+ replacement.memo.to_s.strip == res_memo.to_s.strip &&
+ replacement.external_reference.to_s.strip == res_ext.to_s.strip
+ success_result = ServiceResult.success(original: transaction, replacement: replacement, reversal: reversal)
+ else
+ failure_result = ServiceResult.failure(
+ error: "Cannot correct an already superseded transaction with different parameters",
+ code: :idempotency_conflict
+ )
+ end
+ raise ActiveRecord::Rollback
+ end
+
+ if transaction.voided?
+ failure_result = ServiceResult.failure(error: "Cannot correct a voided deposit transaction", code: :already_voided)
+ raise ActiveRecord::Rollback
+ end
+
+ # 2. Kind-specific validations for replacement after locks acquired
+ case transaction.transaction_kind
+ when "received", "refunded"
+ unless res_party
+ failure_result = ServiceResult.failure(error: "Party is required", code: :invalid_party)
+ raise ActiveRecord::Rollback
+ end
+ when "applied"
+ unless res_charge
+ failure_result = ServiceResult.failure(error: "Charge is required", code: :invalid_charge)
+ raise ActiveRecord::Rollback
+ end
+ if res_charge.tenancy_id != deposit.tenancy_id
+ failure_result = ServiceResult.failure(error: "Charge must belong to the same tenancy", code: :tenancy_mismatch)
+ raise ActiveRecord::Rollback
+ end
+ unless res_charge.posted? && res_charge.active?
+ failure_result = ServiceResult.failure(error: "Target charge is inactive or unposted", code: :invalid_charge_state)
+ raise ActiveRecord::Rollback
+ end
+
+ if occ_date < res_charge.charge_date
+ failure_result = ServiceResult.failure(
+ error: "Application date (#{occ_date}) cannot precede the charge date (#{res_charge.charge_date})",
+ code: :precedes_charge_date
+ )
+ raise ActiveRecord::Rollback
+ end
+
+ prior_apps_cents = res_charge.security_deposit_applications.active.where.not(id: transaction.id).sum(:amount_cents)
+ remaining_cap = res_charge.amount_cents.to_i - prior_apps_cents.to_i
+ if cents > remaining_cap
+ failure_result = ServiceResult.failure(
+ error: "Applied amount (#{format_money(cents)}) exceeds remaining capacity for this charge (#{format_money(remaining_cap)})",
+ code: :exceeds_charge_capacity
+ )
+ raise ActiveRecord::Rollback
+ end
+
+ current_ar = deposit.tenancy.balance_cents(as_of: occ_date)
+ effective_ar = current_ar + (occ_date >= transaction.occurred_on ? transaction.amount_cents : 0)
+ if cents > effective_ar
+ failure_result = ServiceResult.failure(
+ error: "Applied amount (#{format_money(cents)}) exceeds tenancy outstanding balance (#{format_money(effective_ar)}) as of #{occ_date}",
+ code: :exceeds_tenancy_balance
+ )
+ raise ActiveRecord::Rollback
+ end
+ end
+
+ # 3. Timeline validation
+ replacement_delta = transaction.received? ? cents : -cents
+ timeline_res = SecurityDeposits::LiabilityTimeline.validate(
+ security_deposit: deposit,
+ removing_ids: [ transaction.id ],
+ additions: [ { occurred_on: occ_date, delta_cents: replacement_delta } ]
+ )
+ unless timeline_res.success?
+ failure_result = timeline_res
+ raise ActiveRecord::Rollback
+ end
+
+ # 4. Reverse original entry
+ entry_event = "deposit_#{transaction.transaction_kind}"
+ journal_entry = transaction.journal_entries.find_by(event_type: entry_event)
+ unless journal_entry
+ failure_result = ServiceResult.failure(error: "Original journal entry not found", code: :not_found)
+ raise ActiveRecord::Rollback
+ end
+
+ rev_res = Accounting::ReverseEntryService.call(
+ journal_entry: journal_entry,
+ occurred_on: transaction.occurred_on,
+ description: "Correction of deposit transaction ##{transaction.id}"
+ )
+ unless rev_res.success?
+ failure_result = rev_res
+ raise ActiveRecord::Rollback
+ end
+ reversal = rev_res.value!.data[:journal_entry]
+
+ # 5. Create replacement transaction
+ replacement = SecurityDepositTransaction.new(
+ security_deposit: deposit,
+ transaction_kind: transaction.transaction_kind,
+ amount_cents: cents,
+ occurred_on: occ_date,
+ party: (transaction.applied? ? nil : res_party),
+ charge: (transaction.applied? ? res_charge : nil),
+ external_reference: res_ext,
+ memo: res_memo
+ )
+
+ unless replacement.save
+ failure_result = ServiceResult.failure(error: replacement.errors.full_messages.join(", "), code: :validation_error)
+ raise ActiveRecord::Rollback
+ end
+
+ post_res = post_replacement(replacement)
+ unless post_res.success?
+ failure_result = post_res
+ raise ActiveRecord::Rollback
+ end
+
+ replacement.update_columns(posted_at: Time.current)
+ transaction.update_columns(
+ voided_at: Time.current,
+ superseded_by_id: replacement.id
+ )
+
+ success_result = ServiceResult.success(original: transaction, replacement: replacement, reversal: reversal)
+ end
+
+ if (s = success_result)
+ s
+ elsif (f = failure_result)
+ f
+ else
+ ServiceResult.failure(error: "Failed to correct deposit transaction", code: :correction_failed)
+ end
+ end
+
+ private
+
+ attr_reader :transaction, :raw_amount, :raw_cents, :raw_occurred_on, :party, :party_id, :charge, :charge_id, :external_reference, :memo
+
+ def post_replacement(rep)
+ case rep.transaction_kind
+ when "received"
+ postings = [
+ Accounting::PostingSpec.new(account_key: "cash", amount_cents: rep.amount_cents, tenancy: rep.tenancy, party: rep.party),
+ Accounting::PostingSpec.new(account_key: "security_deposits_held", amount_cents: -rep.amount_cents, tenancy: rep.tenancy, party: rep.party)
+ ]
+ Accounting::PostEntryService.call(
+ source: rep,
+ event_type: "deposit_received",
+ occurred_on: rep.occurred_on,
+ postings: postings,
+ description: rep.memo.presence || "Security deposit received"
+ )
+ when "refunded"
+ postings = [
+ Accounting::PostingSpec.new(account_key: "security_deposits_held", amount_cents: rep.amount_cents, tenancy: rep.tenancy, party: rep.party),
+ Accounting::PostingSpec.new(account_key: "cash", amount_cents: -rep.amount_cents, tenancy: rep.tenancy, party: rep.party)
+ ]
+ Accounting::PostEntryService.call(
+ source: rep,
+ event_type: "deposit_refunded",
+ occurred_on: rep.occurred_on,
+ postings: postings,
+ description: rep.memo.presence || "Security deposit refund"
+ )
+ when "applied"
+ postings = [
+ Accounting::PostingSpec.new(account_key: "security_deposits_held", amount_cents: rep.amount_cents, tenancy: rep.tenancy),
+ Accounting::PostingSpec.new(account_key: "tenant_receivable", amount_cents: -rep.amount_cents, tenancy: rep.tenancy)
+ ]
+ charge_desc = rep.charge&.description || rep.charge&.charge_kind&.titleize || "charge"
+ Accounting::PostEntryService.call(
+ source: rep,
+ event_type: "deposit_applied",
+ occurred_on: rep.occurred_on,
+ postings: postings,
+ description: rep.memo.presence || "Security deposit applied to #{charge_desc}"
+ )
+ else
+ ServiceResult.failure(error: "Unknown transaction kind", code: :invalid_input)
+ end
+ end
+
+ def resolve_cents
+ if raw_cents != :not_set && raw_cents.present?
+ return raw_cents if raw_cents.is_a?(Integer)
+
+ nil
+ elsif raw_amount != :not_set && raw_amount.present?
+ str = raw_amount.is_a?(Numeric) ? raw_amount.to_s : raw_amount.to_s.strip
+ return nil unless str.match?(/\A\d+(\.\d{1,2})?\z/)
+
+ (BigDecimal(str) * 100).round
+ else
+ nil
+ end
+ end
+
+ def resolve_occurred_on
+ return raw_occurred_on if raw_occurred_on.is_a?(Date)
+ return raw_occurred_on.to_date if raw_occurred_on.respond_to?(:to_date)
+ return nil if raw_occurred_on.blank? || raw_occurred_on == :not_set
+
+ Date.parse(raw_occurred_on.to_s)
+ rescue ArgumentError, Date::Error
+ nil
+ end
+
+ def format_money(cents)
+ sprintf("$%.2f", cents.to_f / 100)
+ end
+ end
+end
diff --git a/app/services/security_deposit_transactions/receive_service.rb b/app/services/security_deposit_transactions/receive_service.rb
new file mode 100644
index 00000000..cf3f10d7
--- /dev/null
+++ b/app/services/security_deposit_transactions/receive_service.rb
@@ -0,0 +1,163 @@
+module SecurityDepositTransactions
+ class ReceiveService
+ def self.call(security_deposit:, party: nil, party_id: nil, amount: nil, amount_cents: nil, occurred_on: nil, external_reference: nil, memo: nil)
+ new(
+ security_deposit: security_deposit,
+ party: party,
+ party_id: party_id,
+ amount: amount,
+ amount_cents: amount_cents,
+ occurred_on: occurred_on,
+ external_reference: external_reference,
+ memo: memo
+ ).call
+ end
+
+ def initialize(security_deposit:, party: nil, party_id: nil, amount: nil, amount_cents: nil, occurred_on: nil, external_reference: nil, memo: nil)
+ @security_deposit = security_deposit
+ @party = party
+ @party_id = party_id
+ @raw_amount = amount
+ @raw_cents = amount_cents
+ @raw_occurred_on = occurred_on
+ @external_reference = external_reference
+ @memo = memo
+ end
+
+ def call
+ unless security_deposit.is_a?(SecurityDeposit) && security_deposit.persisted? && !security_deposit.destroyed?
+ return ServiceResult.failure(error: "Security deposit must be a persisted record", code: :invalid_deposit)
+ end
+
+ res_party = resolve_party
+ unless res_party.is_a?(Party) && res_party.persisted? && !res_party.destroyed?
+ return ServiceResult.failure(error: "Party must be a persisted record", code: :invalid_party)
+ end
+
+ if res_party.user_id != security_deposit.accounting_user&.id
+ return ServiceResult.failure(error: "Party must belong to your account", code: :party_user_mismatch)
+ end
+
+ cents = resolve_cents
+ if cents.nil? || !cents.positive?
+ return ServiceResult.failure(error: "Amount must be greater than zero", code: :invalid_input)
+ end
+
+ if raw_occurred_on.blank?
+ return ServiceResult.failure(error: "Occurred on date is required", code: :invalid_input)
+ end
+
+ occ_date = resolve_occurred_on
+ unless occ_date
+ return ServiceResult.failure(error: "Invalid occurred on date", code: :invalid_input)
+ end
+
+ if occ_date > Date.current
+ return ServiceResult.failure(error: "Occurred on date cannot be in the future", code: :invalid_date)
+ end
+
+ created_txn = nil # : SecurityDepositTransaction?
+ journal_entry = nil # : JournalEntry?
+ failure_res = nil # : (Dry::Monads::Result::Success | Dry::Monads::Result::Failure)?
+
+ security_deposit.with_lock do
+ timeline_res = SecurityDeposits::LiabilityTimeline.validate(
+ security_deposit: security_deposit,
+ additions: [ { occurred_on: occ_date, delta_cents: cents } ]
+ )
+ unless timeline_res.success?
+ failure_res = timeline_res
+ raise ActiveRecord::Rollback
+ end
+
+ txn = security_deposit.transactions.create!(
+ transaction_kind: "received",
+ amount_cents: cents,
+ occurred_on: occ_date,
+ party: res_party,
+ external_reference: external_reference,
+ memo: memo
+ )
+
+ post_res = post_transaction(txn)
+ unless post_res.success?
+ failure_res = post_res
+ raise ActiveRecord::Rollback
+ end
+
+ txn.update_columns(posted_at: Time.current)
+ created_txn = txn
+ journal_entry = post_res.value!.data[:journal_entry]
+ end
+
+ if (f = failure_res)
+ f
+ elsif (t = created_txn)
+ ServiceResult.success(transaction: t, journal_entry: journal_entry)
+ else
+ ServiceResult.failure(error: "Failed to record deposit receipt", code: :receipt_failed)
+ end
+ end
+
+ private
+
+ attr_reader :security_deposit, :party, :party_id, :raw_amount, :raw_cents, :raw_occurred_on, :external_reference, :memo
+
+ def post_transaction(txn)
+ postings = [
+ Accounting::PostingSpec.new(
+ account_key: "cash",
+ amount_cents: txn.amount_cents,
+ tenancy: txn.tenancy,
+ party: txn.party
+ ),
+ Accounting::PostingSpec.new(
+ account_key: "security_deposits_held",
+ amount_cents: -txn.amount_cents,
+ tenancy: txn.tenancy,
+ party: txn.party
+ )
+ ]
+
+ Accounting::PostEntryService.call(
+ source: txn,
+ event_type: "deposit_received",
+ occurred_on: txn.occurred_on,
+ postings: postings,
+ description: txn.memo.presence || "Security deposit received"
+ )
+ end
+
+ def resolve_party
+ return party if party.is_a?(Party)
+ return Party.find_by(id: party_id) if party_id.present?
+
+ nil
+ end
+
+ def resolve_cents
+ if raw_cents.present?
+ return raw_cents if raw_cents.is_a?(Integer)
+
+ nil
+ elsif raw_amount.present?
+ str = raw_amount.is_a?(Numeric) ? raw_amount.to_s : raw_amount.to_s.strip
+ return nil unless str.match?(/\A\d+(\.\d{1,2})?\z/)
+
+ (BigDecimal(str) * 100).round
+ else
+ nil
+ end
+ end
+
+ def resolve_occurred_on
+ return nil if raw_occurred_on.blank?
+ return raw_occurred_on if raw_occurred_on.is_a?(Date)
+ return raw_occurred_on.to_date if raw_occurred_on.respond_to?(:to_date)
+
+ Date.parse(raw_occurred_on.to_s)
+ rescue ArgumentError, Date::Error
+ nil
+ end
+ end
+end
diff --git a/app/services/security_deposit_transactions/refund_service.rb b/app/services/security_deposit_transactions/refund_service.rb
new file mode 100644
index 00000000..915336f1
--- /dev/null
+++ b/app/services/security_deposit_transactions/refund_service.rb
@@ -0,0 +1,163 @@
+module SecurityDepositTransactions
+ class RefundService
+ def self.call(security_deposit:, party: nil, party_id: nil, amount: nil, amount_cents: nil, occurred_on: nil, external_reference: nil, memo: nil)
+ new(
+ security_deposit: security_deposit,
+ party: party,
+ party_id: party_id,
+ amount: amount,
+ amount_cents: amount_cents,
+ occurred_on: occurred_on,
+ external_reference: external_reference,
+ memo: memo
+ ).call
+ end
+
+ def initialize(security_deposit:, party: nil, party_id: nil, amount: nil, amount_cents: nil, occurred_on: nil, external_reference: nil, memo: nil)
+ @security_deposit = security_deposit
+ @party = party
+ @party_id = party_id
+ @raw_amount = amount
+ @raw_cents = amount_cents
+ @raw_occurred_on = occurred_on
+ @external_reference = external_reference
+ @memo = memo
+ end
+
+ def call
+ unless security_deposit.is_a?(SecurityDeposit) && security_deposit.persisted? && !security_deposit.destroyed?
+ return ServiceResult.failure(error: "Security deposit must be a persisted record", code: :invalid_deposit)
+ end
+
+ res_party = resolve_party
+ unless res_party.is_a?(Party) && res_party.persisted? && !res_party.destroyed?
+ return ServiceResult.failure(error: "Party must be a persisted record", code: :invalid_party)
+ end
+
+ if res_party.user_id != security_deposit.accounting_user&.id
+ return ServiceResult.failure(error: "Party must belong to your account", code: :party_user_mismatch)
+ end
+
+ cents = resolve_cents
+ if cents.nil? || !cents.positive?
+ return ServiceResult.failure(error: "Amount must be greater than zero", code: :invalid_input)
+ end
+
+ if raw_occurred_on.blank?
+ return ServiceResult.failure(error: "Occurred on date is required", code: :invalid_input)
+ end
+
+ occ_date = resolve_occurred_on
+ unless occ_date
+ return ServiceResult.failure(error: "Invalid occurred on date", code: :invalid_input)
+ end
+
+ if occ_date > Date.current
+ return ServiceResult.failure(error: "Occurred on date cannot be in the future", code: :invalid_date)
+ end
+
+ created_txn = nil # : SecurityDepositTransaction?
+ journal_entry = nil # : JournalEntry?
+ failure_res = nil # : (Dry::Monads::Result::Success | Dry::Monads::Result::Failure)?
+
+ security_deposit.with_lock do
+ timeline_res = SecurityDeposits::LiabilityTimeline.validate(
+ security_deposit: security_deposit,
+ additions: [ { occurred_on: occ_date, delta_cents: -cents } ]
+ )
+ unless timeline_res.success?
+ failure_res = timeline_res
+ raise ActiveRecord::Rollback
+ end
+
+ txn = security_deposit.transactions.create!(
+ transaction_kind: "refunded",
+ party: res_party,
+ amount_cents: cents,
+ occurred_on: occ_date,
+ external_reference: external_reference,
+ memo: memo
+ )
+
+ post_res = post_transaction(txn)
+ unless post_res.success?
+ failure_res = post_res
+ raise ActiveRecord::Rollback
+ end
+
+ txn.update_columns(posted_at: Time.current)
+ created_txn = txn
+ journal_entry = post_res.value!.data[:journal_entry]
+ end
+
+ if (f = failure_res)
+ f
+ elsif (t = created_txn)
+ ServiceResult.success(transaction: t, journal_entry: journal_entry)
+ else
+ ServiceResult.failure(error: "Failed to record deposit refund", code: :refund_failed)
+ end
+ end
+
+ private
+
+ attr_reader :security_deposit, :party, :party_id, :raw_amount, :raw_cents, :raw_occurred_on, :external_reference, :memo
+
+ def post_transaction(txn)
+ postings = [
+ Accounting::PostingSpec.new(
+ account_key: "security_deposits_held",
+ amount_cents: txn.amount_cents,
+ tenancy: txn.tenancy,
+ party: txn.party
+ ),
+ Accounting::PostingSpec.new(
+ account_key: "cash",
+ amount_cents: -txn.amount_cents,
+ tenancy: txn.tenancy,
+ party: txn.party
+ )
+ ]
+
+ Accounting::PostEntryService.call(
+ source: txn,
+ event_type: "deposit_refunded",
+ occurred_on: txn.occurred_on,
+ postings: postings,
+ description: txn.memo.presence || "Security deposit refund"
+ )
+ end
+
+ def resolve_party
+ return party if party.is_a?(Party)
+ return Party.find_by(id: party_id) if party_id.present?
+
+ nil
+ end
+
+ def resolve_cents
+ if raw_cents.present?
+ return raw_cents if raw_cents.is_a?(Integer)
+
+ nil
+ elsif raw_amount.present?
+ str = raw_amount.is_a?(Numeric) ? raw_amount.to_s : raw_amount.to_s.strip
+ return nil unless str.match?(/\A\d+(\.\d{1,2})?\z/)
+
+ (BigDecimal(str) * 100).round
+ else
+ nil
+ end
+ end
+
+ def resolve_occurred_on
+ return nil if raw_occurred_on.blank?
+ return raw_occurred_on if raw_occurred_on.is_a?(Date)
+ return raw_occurred_on.to_date if raw_occurred_on.respond_to?(:to_date)
+
+ Date.parse(raw_occurred_on.to_s)
+ rescue ArgumentError, Date::Error
+ nil
+ end
+ end
+end
diff --git a/app/services/security_deposit_transactions/void_service.rb b/app/services/security_deposit_transactions/void_service.rb
new file mode 100644
index 00000000..4f19a68c
--- /dev/null
+++ b/app/services/security_deposit_transactions/void_service.rb
@@ -0,0 +1,100 @@
+module SecurityDepositTransactions
+ class VoidService
+ def self.call(transaction:, reason: nil)
+ new(transaction: transaction, reason: reason).call
+ end
+
+ def initialize(transaction:, reason: nil)
+ @transaction = transaction
+ @reason = reason
+ end
+
+ def call
+ unless transaction.is_a?(SecurityDepositTransaction) && transaction.persisted? && !transaction.destroyed?
+ return ServiceResult.failure(error: "Transaction must be a persisted SecurityDepositTransaction record", code: :invalid_source)
+ end
+
+ deposit = transaction.security_deposit
+ unless deposit
+ return ServiceResult.failure(error: "Security deposit not found", code: :not_found)
+ end
+
+ entry_event = "deposit_#{transaction.transaction_kind}"
+ journal_entry = transaction.journal_entries.find_by(event_type: entry_event)
+ unless journal_entry
+ return ServiceResult.failure(error: "Journal entry not found for deposit transaction", code: :not_found)
+ end
+
+ reversal_entry = nil # : JournalEntry?
+ failure_result = nil # : (Dry::Monads::Result::Success | Dry::Monads::Result::Failure)?
+ success_result = nil # : (Dry::Monads::Result::Success | Dry::Monads::Result::Failure)?
+
+ SecurityDeposit.transaction do
+ deposit.lock!
+ transaction.lock!
+
+ if transaction.superseded?
+ failure_result = ServiceResult.failure(
+ error: "Cannot void an already superseded deposit transaction",
+ code: :already_superseded
+ )
+ raise ActiveRecord::Rollback
+ end
+
+ description = reason.presence || "Void deposit transaction ##{transaction.id}: #{transaction.transaction_kind}"
+
+ if transaction.voided?
+ existing_reversal = journal_entry.reversal
+ if existing_reversal &&
+ existing_reversal.occurred_on == transaction.occurred_on &&
+ existing_reversal.description.to_s.strip == description.to_s.strip
+ success_result = ServiceResult.success(transaction: transaction, journal_entry: existing_reversal)
+ else
+ failure_result = ServiceResult.failure(
+ error: "Cannot void an already voided transaction with different parameters",
+ code: :idempotency_conflict
+ )
+ end
+ raise ActiveRecord::Rollback
+ end
+
+ timeline_res = SecurityDeposits::LiabilityTimeline.validate(
+ security_deposit: deposit,
+ removing_ids: [ transaction.id ]
+ )
+ unless timeline_res.success?
+ failure_result = timeline_res
+ raise ActiveRecord::Rollback
+ end
+
+ reverse_result = Accounting::ReverseEntryService.call(
+ journal_entry: journal_entry,
+ occurred_on: transaction.occurred_on,
+ description: description
+ )
+
+ unless reverse_result.success?
+ failure_result = reverse_result
+ raise ActiveRecord::Rollback
+ end
+
+ reversal_entry = reverse_result.value!.data[:journal_entry]
+ transaction.update_columns(voided_at: Time.current)
+ end
+
+ if (s = success_result)
+ s
+ elsif (f = failure_result)
+ f
+ elsif (r = reversal_entry)
+ ServiceResult.success(transaction: transaction, journal_entry: r)
+ else
+ ServiceResult.failure(error: "Failed to void deposit transaction", code: :void_failed)
+ end
+ end
+
+ private
+
+ attr_reader :transaction, :reason
+ end
+end
diff --git a/app/services/security_deposits/create_service.rb b/app/services/security_deposits/create_service.rb
new file mode 100644
index 00000000..9cd848ef
--- /dev/null
+++ b/app/services/security_deposits/create_service.rb
@@ -0,0 +1,86 @@
+module SecurityDeposits
+ class CreateService
+ def self.call(tenancy:, required_amount: nil, required_amount_cents: nil, due_on: nil)
+ new(
+ tenancy: tenancy,
+ required_amount: required_amount,
+ required_amount_cents: required_amount_cents,
+ due_on: due_on
+ ).call
+ end
+
+ def initialize(tenancy:, required_amount: nil, required_amount_cents: nil, due_on: nil)
+ @tenancy = tenancy
+ @raw_amount = required_amount
+ @raw_cents = required_amount_cents
+ @raw_due_on = due_on
+ end
+
+ def call
+ unless tenancy.is_a?(Tenancy) && tenancy.persisted? && !tenancy.destroyed?
+ return ServiceResult.failure(error: "Tenancy must be a persisted Tenancy record", code: :invalid_tenancy)
+ end
+
+ cents = resolve_cents
+ return ServiceResult.failure(error: "Required amount must be greater than zero", code: :invalid_input) unless cents&.positive?
+
+ due_date = resolve_due_on
+ return ServiceResult.failure(error: "Due date is required", code: :invalid_input) unless due_date
+
+ tenancy.with_lock do
+ existing = tenancy.security_deposit
+ if existing
+ if existing.required_amount_cents == cents && existing.due_on == due_date
+ return ServiceResult.success(security_deposit: existing)
+ else
+ return ServiceResult.failure(
+ error: "A security deposit requirement already exists for this tenancy with different terms",
+ code: :conflict
+ )
+ end
+ end
+
+ deposit = SecurityDeposit.new(
+ tenancy: tenancy,
+ required_amount_cents: cents,
+ due_on: due_date
+ )
+
+ if deposit.save
+ ServiceResult.success(security_deposit: deposit)
+ else
+ ServiceResult.failure(error: deposit.errors.full_messages.join(", "), code: :validation_error)
+ end
+ end
+ end
+
+ private
+
+ attr_reader :tenancy, :raw_amount, :raw_cents, :raw_due_on
+
+ def resolve_cents
+ if raw_cents.present?
+ return raw_cents if raw_cents.is_a?(Integer)
+
+ nil
+ elsif raw_amount.present?
+ str = raw_amount.is_a?(Numeric) ? raw_amount.to_s : raw_amount.to_s.strip
+ return nil unless str.match?(/\A\d+(\.\d{1,2})?\z/)
+
+ (BigDecimal(str) * 100).round
+ else
+ nil
+ end
+ end
+
+ def resolve_due_on
+ return raw_due_on if raw_due_on.is_a?(Date)
+ return raw_due_on.to_date if raw_due_on.respond_to?(:to_date)
+ return nil if raw_due_on.blank?
+
+ Date.parse(raw_due_on.to_s)
+ rescue ArgumentError, Date::Error
+ nil
+ end
+ end
+end
diff --git a/app/services/security_deposits/liability_timeline.rb b/app/services/security_deposits/liability_timeline.rb
new file mode 100644
index 00000000..28b1bd52
--- /dev/null
+++ b/app/services/security_deposits/liability_timeline.rb
@@ -0,0 +1,66 @@
+module SecurityDeposits
+ class LiabilityTimeline
+ def self.validate(security_deposit:, additions: [], removing_ids: [])
+ new(security_deposit: security_deposit, additions: additions, removing_ids: removing_ids).validate
+ end
+
+ def initialize(security_deposit:, additions: [], removing_ids: [])
+ @security_deposit = security_deposit
+ @additions = additions
+ @removing_ids = if removing_ids.is_a?(Array)
+ removing_ids.compact.map(&:to_i)
+ elsif removing_ids.present?
+ [ removing_ids.to_i ]
+ else
+ []
+ end
+ end
+
+ def validate
+ date_deltas = {} # : Hash[Date, Integer]
+
+ # 1. Load active posted transactions (excluding those marked for removal)
+ existing_txns = security_deposit.transactions.active.posted
+ existing_txns = existing_txns.where.not(id: removing_ids) if removing_ids.present?
+
+ existing_txns.each do |txn|
+ delta_int = txn.received? ? txn.amount_cents.to_i : -txn.amount_cents.to_i
+ curr_cents = (date_deltas[txn.occurred_on] || 0).to_i
+ date_deltas[txn.occurred_on] = (curr_cents + delta_int).to_i
+ end
+
+ # 2. Incorporate additions
+ additions.each do |add|
+ occ = add[:occurred_on]
+ occ = Date.parse(occ.to_s) unless occ.is_a?(Date)
+ curr_cents = (date_deltas[occ] || 0).to_i
+ date_deltas[occ] = (curr_cents + add[:delta_cents].to_i).to_i
+ end
+
+ # 3. Walk timeline chronologically
+ running_held_cents = 0
+ sorted_dates = date_deltas.keys.sort
+
+ sorted_dates.each do |date|
+ running_held_cents += date_deltas[date]
+ if running_held_cents < 0
+ return ServiceResult.failure(
+ error: "Transaction would result in negative security deposit liability (#{format_money(running_held_cents)}) as of #{date}",
+ code: :negative_deposit_liability,
+ data: { as_of: date, balance_cents: running_held_cents }
+ )
+ end
+ end
+
+ ServiceResult.success(final_balance_cents: running_held_cents)
+ end
+
+ private
+
+ attr_reader :security_deposit, :additions, :removing_ids
+
+ def format_money(cents)
+ sprintf("$%.2f", cents.to_f / 100)
+ end
+ end
+end
diff --git a/app/services/security_deposits/update_service.rb b/app/services/security_deposits/update_service.rb
new file mode 100644
index 00000000..62875dfb
--- /dev/null
+++ b/app/services/security_deposits/update_service.rb
@@ -0,0 +1,92 @@
+module SecurityDeposits
+ class UpdateService
+ def self.call(security_deposit:, required_amount: nil, required_amount_cents: nil, due_on: nil)
+ new(
+ security_deposit: security_deposit,
+ required_amount: required_amount,
+ required_amount_cents: required_amount_cents,
+ due_on: due_on
+ ).call
+ end
+
+ def initialize(security_deposit:, required_amount: nil, required_amount_cents: nil, due_on: nil)
+ @security_deposit = security_deposit
+ @raw_amount = required_amount
+ @raw_cents = required_amount_cents
+ @raw_due_on = due_on
+ end
+
+ def call
+ unless security_deposit.is_a?(SecurityDeposit) && security_deposit.persisted? && !security_deposit.destroyed?
+ return ServiceResult.failure(error: "Security deposit must be a persisted record", code: :invalid_source)
+ end
+
+ cents = if raw_cents.present? || raw_amount.present?
+ resolve_cents
+ else
+ security_deposit.required_amount_cents
+ end
+
+ if cents.nil? || !cents.positive?
+ return ServiceResult.failure(error: "Required amount must be greater than zero", code: :invalid_amount)
+ end
+
+ due_date = if raw_due_on.present?
+ resolve_due_on
+ else
+ security_deposit.due_on
+ end
+
+ unless due_date
+ return ServiceResult.failure(error: "Invalid due date", code: :invalid_due_on)
+ end
+
+ security_deposit.with_lock do
+ if security_deposit.transactions.exists?
+ return ServiceResult.failure(
+ error: "Security deposit requirement cannot be updated after transactions exist",
+ code: :immutable_requirement
+ )
+ end
+
+ security_deposit.required_amount_cents = cents
+ security_deposit.due_on = due_date
+
+ if security_deposit.save
+ ServiceResult.success(security_deposit: security_deposit)
+ else
+ ServiceResult.failure(error: security_deposit.errors.full_messages.join(", "), code: :validation_error)
+ end
+ end
+ end
+
+ private
+
+ attr_reader :security_deposit, :raw_amount, :raw_cents, :raw_due_on
+
+ def resolve_cents
+ if raw_cents.present?
+ return raw_cents if raw_cents.is_a?(Integer)
+
+ nil
+ elsif raw_amount.present?
+ str = raw_amount.is_a?(Numeric) ? raw_amount.to_s : raw_amount.to_s.strip
+ return nil unless str.match?(/\A\d+(\.\d{1,2})?\z/)
+
+ (BigDecimal(str) * 100).round
+ else
+ nil
+ end
+ end
+
+ def resolve_due_on
+ return raw_due_on if raw_due_on.is_a?(Date)
+ return raw_due_on.to_date if raw_due_on.respond_to?(:to_date)
+ return nil if raw_due_on.blank?
+
+ Date.parse(raw_due_on.to_s)
+ rescue ArgumentError, Date::Error
+ nil
+ end
+ end
+end
diff --git a/app/views/properties/_financials.html.erb b/app/views/properties/_financials.html.erb
index 9f8714b7..643322ac 100644
--- a/app/views/properties/_financials.html.erb
+++ b/app/views/properties/_financials.html.erb
@@ -56,6 +56,8 @@
<%= item[:type] %>
<% when 'Expense' %>
<%= item[:type] %>
+ <% when 'Security Deposit' %>
+ <%= item[:type] %>
<% end %>
@@ -93,6 +95,19 @@
<% elsif exp.reimbursed? %>
Reimbursable
<% end %>
+ <% elsif item[:type] == 'Security Deposit' %>
+ <% sdt = item[:object] %>
+ <%= sdt.transaction_kind.titleize %>
+ <% if sdt.party %>
+ - <%= sdt.party.display_name %>
+ <% elsif sdt.charge %>
+ - Charge #<%= sdt.charge_id %>
+ <% end %>
+ <% if sdt.superseded? %>
+ Corrected
+ <% elsif sdt.voided? %>
+ Voided
+ <% end %>
<% end %>
@@ -102,6 +117,9 @@
<%= number_to_currency(item[:amount]) %>
<% elsif item[:type] == 'Payment' || item[:type] == 'Receipt' %>
+<%= number_to_currency(item[:amount]) %>
+ <% elsif item[:type] == 'Security Deposit' %>
+ <% sdt = item[:object] %>
+ <%= sdt.received? ? '+' : '-' %><%= number_to_currency(item[:amount]) %>
<% else %>
<%= number_to_currency(item[:amount]) %>
<% end %>
diff --git a/app/views/properties/show.html.erb b/app/views/properties/show.html.erb
index edbf0d9e..0071f06e 100644
--- a/app/views/properties/show.html.erb
+++ b/app/views/properties/show.html.erb
@@ -79,7 +79,7 @@
-
+
Active Tenancies
@@ -105,6 +105,16 @@
Rentable units
+
+
+
+
Security Deposits Held
+
+ <%= number_to_currency((@security_deposits_held_cents || 0) / 100.0) %>
+
+
Current refundable liability
+
+
diff --git a/app/views/security_deposit_transactions/correction.html.erb b/app/views/security_deposit_transactions/correction.html.erb
new file mode 100644
index 00000000..f8ef9ac5
--- /dev/null
+++ b/app/views/security_deposit_transactions/correction.html.erb
@@ -0,0 +1,61 @@
+<% content_for :title, "Correct Deposit Transaction ##{@transaction.id}" %>
+
+<% content_for :breadcrumbs do %>
+
<%= link_to "Tenancies", tenancies_path %>
+
<%= link_to "Tenancy ##{@transaction.tenancy.id}", tenancy_path(@transaction.tenancy) %>
+
<%= link_to "Security Deposit", tenancy_security_deposit_path(@transaction.tenancy) %>
+
<%= link_to "Transaction ##{@transaction.id}", security_deposit_transaction_path(@transaction) %>
+
Correct
+<% end %>
+
+
+
+
+
Correct Deposit <%= @transaction.transaction_kind.titleize %>
+
+ This will reverse the original journal entry on its original date (<%= @transaction.occurred_on.strftime("%b %d, %Y") %>) and post a replacement transaction.
+
+
+ <%= form_with model: @transaction, url: correct_security_deposit_transaction_path(@transaction), method: :post do |f| %>
+
+ Amount ($)
+ <%= f.number_field :amount, value: sprintf("%.2f", @transaction.amount), step: "0.01", min: "0.01", class: "input input-bordered w-full font-mono", id: "security_deposit_transaction_amount", required: true %>
+
+
+
+ Date
+ <%= f.date_field :occurred_on, value: @transaction.occurred_on, class: "input input-bordered w-full", id: "security_deposit_transaction_occurred_on", required: true %>
+
+
+ <% if @transaction.received? || @transaction.refunded? %>
+
+
+ <%= @transaction.received? ? "Contributor (Payer)" : "Recipient" %>
+
+ <%= f.collection_select :party_id, @parties, :id, :display_name, { selected: @transaction.party_id }, class: "select select-bordered w-full", id: "security_deposit_transaction_party_id", required: true %>
+
+ <% elsif @transaction.applied? %>
+
+ Target Charge
+ <%= f.collection_select :charge_id, @active_charges, :id, :description, { selected: @transaction.charge_id }, class: "select select-bordered w-full", id: "security_deposit_transaction_charge_id", required: true %>
+
+ <% end %>
+
+
+ Reference (Optional)
+ <%= f.text_field :external_reference, value: @transaction.external_reference, class: "input input-bordered w-full", id: "security_deposit_transaction_external_reference" %>
+
+
+
+ Memo (Optional)
+ <%= f.text_field :memo, value: @transaction.memo, class: "input input-bordered w-full", id: "security_deposit_transaction_memo" %>
+
+
+
+ <%= link_to "Cancel", security_deposit_transaction_path(@transaction), class: "btn btn-ghost" %>
+ <%= f.submit "Save Correction", class: "btn btn-primary" %>
+
+ <% end %>
+
+
+
diff --git a/app/views/security_deposit_transactions/show.html.erb b/app/views/security_deposit_transactions/show.html.erb
new file mode 100644
index 00000000..c3b14c91
--- /dev/null
+++ b/app/views/security_deposit_transactions/show.html.erb
@@ -0,0 +1,150 @@
+<% content_for :title, "Deposit Transaction ##{@transaction.id}" %>
+
+<% content_for :breadcrumbs do %>
+
<%= link_to "Tenancies", tenancies_path %>
+
<%= link_to "Tenancy ##{@transaction.tenancy.id}", tenancy_path(@transaction.tenancy) %>
+
<%= link_to "Security Deposit", tenancy_security_deposit_path(@transaction.tenancy) %>
+
Transaction #<%= @transaction.id %>
+<% end %>
+
+
+
+
+
Deposit <%= @transaction.transaction_kind.titleize %>
+
<%= @transaction.tenancy.property.address %> — <%= @transaction.tenancy.rentable_unit.display_name %>
+
+
+ <%= link_to "Back to Deposit", tenancy_security_deposit_path(@transaction.tenancy), class: "btn btn-ghost" %>
+ <% if @transaction.active? %>
+ <%= link_to "Correct Transaction", correction_security_deposit_transaction_path(@transaction), class: "btn btn-outline btn-warning" %>
+ <%= button_to "Void Transaction", void_security_deposit_transaction_path(@transaction), method: :post, class: "btn btn-outline btn-error", form: { data: { turbo_confirm: "Are you sure you want to void this deposit transaction? This will reverse the accounting entry on its original date." } } %>
+ <% end %>
+
+
+
+
+ <% if @transaction.superseded? %>
+
+
+
This transaction was corrected
+
+ Superseded by
+ <%= link_to "Transaction ##{@transaction.superseded_by_id}", security_deposit_transaction_path(@transaction.superseded_by), class: "link font-semibold" %>
+ on <%= @transaction.voided_at.strftime("%B %d, %Y at %l:%M %p") %>.
+
+
+
+ <% elsif @transaction.voided? %>
+
+
+
This transaction was voided
+
Voided on <%= @transaction.voided_at.strftime("%B %d, %Y at %l:%M %p") %>.
+
+
+ <% elsif @transaction.superseded_transaction.present? %>
+
+
+
This is a replacement transaction
+
+ Replaced
+ <%= link_to "Transaction ##{@transaction.superseded_transaction.id}", security_deposit_transaction_path(@transaction.superseded_transaction), class: "link font-semibold" %>.
+
+
+
+ <% end %>
+
+
+
+
+
Transaction Details
+
+
+
+
Transaction Kind
+ <%= @transaction.transaction_kind.titleize %>
+
+
+
Amount
+ <%= number_to_currency(@transaction.amount) %>
+
+
+
Date
+ <%= @transaction.occurred_on.strftime("%B %d, %Y") %>
+
+ <% if @transaction.party %>
+
+
<%= @transaction.received? ? "Contributor" : "Recipient" %>
+ <%= link_to @transaction.party.display_name, @transaction.party, class: "link link-hover" %>
+
+ <% end %>
+ <% if @transaction.charge %>
+
+
Target Charge
+ <%= link_to "Charge ##{@transaction.charge_id}: #{@transaction.charge.description || @transaction.charge.charge_kind.titleize}", charge_path(@transaction.charge), class: "link link-hover" %>
+
+ <% end %>
+
+
Reference
+ <%= @transaction.external_reference || "—" %>
+
+
+
Memo
+ <%= @transaction.memo || "—" %>
+
+
+
Status
+
+ <% if @transaction.superseded? %>
+ Corrected
+ <% elsif @transaction.voided? %>
+ Voided
+ <% else %>
+ Active
+ <% end %>
+
+
+
+
+
+
+
+
+
+
Accounting Postings
+
+ <% if @journal_entries.any? %>
+
+ <% @journal_entries.each do |entry| %>
+
+
+ <%= entry.event_type.titleize %>
+ <%= entry.occurred_on.strftime("%b %d, %Y") %>
+
+
+
+
+ Account
+ Debit
+ Credit
+
+
+
+ <% entry.postings.each do |p| %>
+
+ <%= p.account.name %>
+ <%= p.debit? ? number_to_currency(p.debit_amount / 100.0) : "" %>
+ <%= p.credit? ? number_to_currency(p.credit_amount / 100.0) : "" %>
+
+ <% end %>
+
+
+
+ <% end %>
+
+ <% else %>
+
No journal entries recorded.
+ <% end %>
+
+
+
+
diff --git a/app/views/security_deposits/edit.html.erb b/app/views/security_deposits/edit.html.erb
new file mode 100644
index 00000000..1d206e8d
--- /dev/null
+++ b/app/views/security_deposits/edit.html.erb
@@ -0,0 +1,48 @@
+<% content_for :title, "Edit Security Deposit — Tenancy ##{@tenancy.id}" %>
+
+<% content_for :breadcrumbs do %>
+
<%= link_to "Tenancies", tenancies_path %>
+
<%= link_to "Tenancy ##{@tenancy.id}", tenancy_path(@tenancy) %>
+
<%= link_to "Security Deposit", tenancy_security_deposit_path(@tenancy) %>
+
Edit
+<% end %>
+
+
+
+
+
Edit Security Deposit Requirement
+
Modify the contractual deposit terms before transactions have been recorded.
+
+ <%= form_with model: @security_deposit, url: tenancy_security_deposit_path(@tenancy), method: :patch do |f| %>
+ <% if @security_deposit.errors.any? %>
+
+
+ <% @security_deposit.errors.full_messages.each do |msg| %>
+ <%= msg %>
+ <% end %>
+
+
+ <% end %>
+
+
+
+ Required Deposit Amount ($)
+
+ <%= f.number_field :required_amount, step: "0.01", min: "0.01", value: sprintf("%.2f", @security_deposit.required_amount), class: "input input-bordered w-full font-mono", id: "security_deposit_required_amount", required: true %>
+
+
+
+
+ Due Date
+
+ <%= f.date_field :due_on, value: @security_deposit.due_on, class: "input input-bordered w-full", id: "security_deposit_due_on", required: true %>
+
+
+
+ <%= link_to "Cancel", tenancy_security_deposit_path(@tenancy), class: "btn btn-ghost" %>
+ <%= f.submit "Update Requirement", class: "btn btn-primary" %>
+
+ <% end %>
+
+
+
diff --git a/app/views/security_deposits/new.html.erb b/app/views/security_deposits/new.html.erb
new file mode 100644
index 00000000..8f2239c3
--- /dev/null
+++ b/app/views/security_deposits/new.html.erb
@@ -0,0 +1,47 @@
+<% content_for :title, "Set Up Security Deposit — Tenancy ##{@tenancy.id}" %>
+
+<% content_for :breadcrumbs do %>
+
<%= link_to "Tenancies", tenancies_path %>
+
<%= link_to "Tenancy ##{@tenancy.id}", tenancy_path(@tenancy) %>
+
Security Deposit
+<% end %>
+
+
+
+
+
Set Up Security Deposit
+
Record the contractual security deposit requirement for this tenancy.
+
+ <%= form_with model: @security_deposit, url: tenancy_security_deposit_path(@tenancy), method: :post do |f| %>
+ <% if @security_deposit.errors.any? %>
+
+
+ <% @security_deposit.errors.full_messages.each do |msg| %>
+ <%= msg %>
+ <% end %>
+
+
+ <% end %>
+
+
+
+ Required Deposit Amount ($)
+
+ <%= f.number_field :required_amount, step: "0.01", min: "0.01", placeholder: "2000.00", class: "input input-bordered w-full font-mono", id: "security_deposit_required_amount", required: true %>
+
+
+
+
+ Due Date
+
+ <%= f.date_field :due_on, class: "input input-bordered w-full", id: "security_deposit_due_on", required: true %>
+
+
+
+ <%= link_to "Cancel", tenancy_path(@tenancy), class: "btn btn-ghost" %>
+ <%= f.submit "Save Deposit Requirement", class: "btn btn-primary" %>
+
+ <% end %>
+
+
+
diff --git a/app/views/security_deposits/show.html.erb b/app/views/security_deposits/show.html.erb
new file mode 100644
index 00000000..0463b8b0
--- /dev/null
+++ b/app/views/security_deposits/show.html.erb
@@ -0,0 +1,281 @@
+<% content_for :title, "Security Deposit — Tenancy ##{@tenancy.id}" %>
+
+<% content_for :breadcrumbs do %>
+
<%= link_to "Tenancies", tenancies_path %>
+
<%= link_to "Tenancy ##{@tenancy.id}", tenancy_path(@tenancy) %>
+
Security Deposit
+<% end %>
+
+
+
+
+
Security Deposit
+
<%= @tenancy.property.address %> — <%= @tenancy.rentable_unit.display_name %>
+
+
+ <%= link_to "Back to Tenancy", tenancy_path(@tenancy), class: "btn btn-ghost" %>
+ <% if @transactions.none? %>
+ <%= link_to "Edit Requirement", edit_tenancy_security_deposit_path(@tenancy), class: "btn btn-outline" %>
+ <% end %>
+
+
+
+
+
+
+
+
Required
+
<%= number_to_currency(@security_deposit.required_amount) %>
+
Due: <%= @security_deposit.due_on.strftime("%b %d, %Y") %>
+
+
+
+
+
+
Currently Held
+
<%= number_to_currency(@security_deposit.held_amount) %>
+
Ledger liability
+
+
+
+
+
+
Remaining Due
+
+ <%= number_to_currency(@security_deposit.remaining_required_amount) %>
+
+
To fulfill requirement
+
+
+
+
+
+
Funding Status
+
+ <% case @security_deposit.funding_status %>
+ <% when "funded" %>
+ Funded
+ <% when "partially_funded" %>
+ Partially Funded
+ <% when "overfunded" %>
+ Overfunded
+ <% else %>
+ Not Funded
+ <% end %>
+
+
Contractual state
+
+
+
+
+
+
+
+
+
+
Record Deposit Payment
+
Record money received toward this security deposit liability.
+
+ <%= form_with url: receive_tenancy_security_deposit_path(@tenancy), method: :post, local: true do |f| %>
+
+ Contributor (Payer)
+ <%= select_tag :party_id, options_from_collection_for_select(@parties, :id, :display_name), class: "select select-bordered select-sm w-full", id: "receive_party_id", required: true %>
+
+
+
+ Amount ($)
+ <%= number_field_tag :amount, sprintf("%.2f", @security_deposit.remaining_required_amount > 0 ? @security_deposit.remaining_required_amount : 100.0), step: "0.01", min: "0.01", class: "input input-bordered input-sm font-mono w-full", id: "receive_amount", required: true %>
+
+
+
+ Received On
+ <%= date_field_tag :occurred_on, Date.current, class: "input input-bordered input-sm w-full", id: "receive_occurred_on", required: true %>
+
+
+
+ Reference (Optional)
+ <%= text_field_tag :external_reference, nil, placeholder: "Check #, wire ref", class: "input input-bordered input-sm w-full", id: "receive_external_reference" %>
+
+
+
+ Memo (Optional)
+ <%= text_field_tag :memo, nil, placeholder: "Security deposit receipt", class: "input input-bordered input-sm w-full", id: "receive_memo" %>
+
+
+
+ <%= f.submit "Record Deposit", class: "btn btn-primary btn-sm w-full" %>
+
+ <% end %>
+
+
+
+
+
+
+
Refund Deposit
+
Return held deposit funds to a tenant or contributor.
+
+ <% if @security_deposit.held_cents > 0 %>
+ <%= form_with url: refund_tenancy_security_deposit_path(@tenancy), method: :post, local: true do |f| %>
+
+ Recipient
+ <%= select_tag :party_id, options_from_collection_for_select(@parties, :id, :display_name), class: "select select-bordered select-sm w-full", id: "refund_party_id", required: true %>
+
+
+
+ Amount ($) — Max: <%= number_to_currency(@security_deposit.held_amount) %>
+ <%= number_field_tag :amount, sprintf("%.2f", @security_deposit.held_amount), step: "0.01", min: "0.01", max: sprintf("%.2f", @security_deposit.held_amount), class: "input input-bordered input-sm font-mono w-full", id: "refund_amount", required: true %>
+
+
+
+ Refunded On
+ <%= date_field_tag :occurred_on, Date.current, class: "input input-bordered input-sm w-full", id: "refund_occurred_on", required: true %>
+
+
+
+ Reference (Optional)
+ <%= text_field_tag :external_reference, nil, placeholder: "Check #, wire ref", class: "input input-bordered input-sm w-full", id: "refund_external_reference" %>
+
+
+
+ Memo (Optional)
+ <%= text_field_tag :memo, nil, placeholder: "Deposit refund at move-out", class: "input input-bordered input-sm w-full", id: "refund_memo" %>
+
+
+
+ <%= f.submit "Record Refund", class: "btn btn-secondary btn-sm w-full" %>
+
+ <% end %>
+ <% else %>
+
+ No deposit funds are currently held to refund.
+
+ <% end %>
+
+
+
+
+
+
+
Apply Deposit to Charge
+
Apply held deposit toward an active charge (damages, rent, late fee).
+
+ <% if @security_deposit.held_cents > 0 && @tenancy.current_balance > 0 && @active_charges.any? %>
+ <%= form_with url: apply_tenancy_security_deposit_path(@tenancy), method: :post, local: true do |f| %>
+
+ Select Active Charge
+
+ <% @active_charges.each do |c| %>
+ <% cap = [c.remaining_deposit_application_cents, @security_deposit.held_cents, @tenancy.current_balance_cents].min / 100.0 %>
+
+ <%= c.charge_date.strftime("%b %d, %Y") %> — <%= c.description || c.charge_kind.titleize %> (<%= number_to_currency(c.amount) %>, Max: <%= number_to_currency(cap) %>)
+
+ <% end %>
+
+
+
+
+ Amount to Apply ($)
+ <%= number_field_tag :amount, sprintf("%.2f", [@security_deposit.held_amount, @tenancy.current_balance].min), step: "0.01", min: "0.01", class: "input input-bordered input-sm font-mono w-full", id: "apply_amount", required: true %>
+
+
+
+ Application Date
+ <%= date_field_tag :occurred_on, Date.current, class: "input input-bordered input-sm w-full", id: "apply_occurred_on", required: true %>
+
+
+
+ Memo (Optional)
+ <%= text_field_tag :memo, nil, placeholder: "Deposit deduction for repairs", class: "input input-bordered input-sm w-full", id: "apply_memo" %>
+
+
+
+ <%= f.submit "Apply Deposit", class: "btn btn-accent btn-sm w-full" %>
+
+ <% end %>
+ <% else %>
+
+ <% if @security_deposit.held_cents <= 0 %>
+ No deposit funds are currently held.
+ <% elsif @tenancy.current_balance <= 0 %>
+ Tenancy has no outstanding balance ($0.00).
+ <% else %>
+ No active posted charges to apply against.
+ <% end %>
+
+ <% end %>
+
+
+
+
+
+
+
+
Deposit Transaction History
+
+
+
+
+
+ Date
+ Kind
+ Party / Charge
+ Reference / Memo
+ Amount
+ Status
+ Actions
+
+
+
+ <% if @transactions.any? %>
+ <% @transactions.each do |txn| %>
+
+ <%= txn.occurred_on.strftime("%b %d, %Y") %>
+
+
+ <%= txn.transaction_kind.titleize %>
+
+
+
+ <% if txn.received? || txn.refunded? %>
+ <%= txn.party&.display_name %>
+ <% elsif txn.applied? %>
+ <%= link_to "Charge ##{txn.charge_id}: #{txn.charge&.description || txn.charge&.charge_kind&.titleize}", charge_path(txn.charge_id), class: "link link-hover" if txn.charge_id %>
+ <% end %>
+
+
+ <%= txn.external_reference.present? ? "[#{txn.external_reference}] " : "" %>
+ <%= txn.memo || "—" %>
+
+
+ <%= txn.received? ? '+' : '-' %><%= number_to_currency(txn.amount) %>
+
+
+ <% if txn.superseded? %>
+ Corrected
+ <% elsif txn.voided? %>
+ Voided
+ <% elsif txn.superseded_transaction.present? %>
+ Replacement
+ <% else %>
+ Active
+ <% end %>
+
+
+ <%= link_to "View", security_deposit_transaction_path(txn), class: "btn btn-xs btn-ghost" %>
+
+
+ <% end %>
+ <% else %>
+
+
+ No deposit transactions recorded yet.
+
+
+ <% end %>
+
+
+
+
+
+
diff --git a/app/views/tenancies/show.html.erb b/app/views/tenancies/show.html.erb
index d6251aec..a5d26dbd 100644
--- a/app/views/tenancies/show.html.erb
+++ b/app/views/tenancies/show.html.erb
@@ -315,6 +315,45 @@
+
+
+
+
+
+
Security Deposit
+ <% if @tenancy.security_deposit %>
+ <% case @tenancy.security_deposit.funding_status %>
+ <% when "funded" %>
+
Funded
+ <% when "partially_funded" %>
+
Partial
+ <% when "overfunded" %>
+
Overfunded
+ <% else %>
+
Not Funded
+ <% end %>
+ <% end %>
+
+
+ <% if @tenancy.security_deposit %>
+ <% dep = @tenancy.security_deposit %>
+
+ <%= number_to_currency(dep.held_amount) %>
+
+
+
Required: <%= number_to_currency(dep.required_amount) %>
+ <% if dep.remaining_required_amount > 0 %>
+
Remaining: <%= number_to_currency(dep.remaining_required_amount) %>
+ <% end %>
+
+
+ <%= link_to "Manage Security Deposit", tenancy_security_deposit_path(@tenancy), class: "btn btn-outline btn-secondary btn-sm w-full" %>
+ <% else %>
+
No security deposit requirement recorded for this tenancy.
+ <%= link_to "+ Set Up Security Deposit", new_tenancy_security_deposit_path(@tenancy), class: "btn btn-outline btn-sm w-full" %>
+ <% end %>
+
+
diff --git a/config/routes.rb b/config/routes.rb
index 24e31506..cedcff78 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -19,6 +19,19 @@
resources :charges, only: %i[new create]
resources :tenancy_parties, only: %i[new create edit update destroy]
resources :rent_terms, only: %i[new create]
+ resource :security_deposit, only: %i[new create show edit update] do
+ post :receive
+ post :refund
+ post :apply
+ end
+ end
+
+ resources :security_deposit_transactions, only: %i[show] do
+ member do
+ get :correction
+ post :correct
+ post :void
+ end
end
resources :expenses, only: %i[index show new create] do
diff --git a/db/cable_schema.rb b/db/cable_schema.rb
index b3e2162b..b50902aa 100644
--- a/db/cable_schema.rb
+++ b/db/cable_schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2026_08_16_000009) do
+ActiveRecord::Schema[8.1].define(version: 2026_08_16_000010) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@@ -242,6 +242,42 @@
t.index ["property_id"], name: "index_rentable_units_on_property_id"
end
+ create_table "security_deposit_transactions", force: :cascade do |t|
+ t.bigint "amount_cents", null: false
+ t.bigint "charge_id"
+ t.datetime "created_at", null: false
+ t.string "external_reference"
+ t.text "memo"
+ t.date "occurred_on", null: false
+ t.bigint "party_id"
+ t.datetime "posted_at"
+ t.bigint "security_deposit_id", null: false
+ t.bigint "superseded_by_id"
+ t.string "transaction_kind", null: false
+ t.datetime "updated_at", null: false
+ t.datetime "voided_at"
+ t.index ["charge_id"], name: "index_security_deposit_transactions_on_charge_id"
+ t.index ["occurred_on"], name: "index_security_deposit_transactions_on_occurred_on"
+ t.index ["party_id"], name: "index_security_deposit_transactions_on_party_id"
+ t.index ["security_deposit_id", "transaction_kind"], name: "index_sdt_on_deposit_and_kind"
+ t.index ["security_deposit_id"], name: "index_security_deposit_transactions_on_security_deposit_id"
+ t.index ["superseded_by_id"], name: "index_sdt_on_unique_superseded_by_id", unique: true, where: "(superseded_by_id IS NOT NULL)"
+ t.index ["superseded_by_id"], name: "index_security_deposit_transactions_on_superseded_by_id"
+ t.index ["voided_at"], name: "index_security_deposit_transactions_on_voided_at"
+ t.check_constraint "amount_cents > 0", name: "security_deposit_transactions_amount_cents_positive"
+ t.check_constraint "transaction_kind::text = ANY (ARRAY['received'::character varying, 'refunded'::character varying, 'applied'::character varying]::text[])", name: "security_deposit_transactions_kind_check"
+ end
+
+ create_table "security_deposits", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.date "due_on", null: false
+ t.bigint "required_amount_cents", null: false
+ t.bigint "tenancy_id", null: false
+ t.datetime "updated_at", null: false
+ t.index ["tenancy_id"], name: "index_security_deposits_on_tenancy_id", unique: true
+ t.check_constraint "required_amount_cents > 0", name: "security_deposits_required_amount_cents_positive"
+ end
+
create_table "sessions", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "ip_address"
@@ -325,6 +361,11 @@
add_foreign_key "receipts", "users"
add_foreign_key "rent_terms", "tenancies"
add_foreign_key "rentable_units", "properties"
+ add_foreign_key "security_deposit_transactions", "charges"
+ add_foreign_key "security_deposit_transactions", "parties"
+ add_foreign_key "security_deposit_transactions", "security_deposit_transactions", column: "superseded_by_id"
+ add_foreign_key "security_deposit_transactions", "security_deposits"
+ add_foreign_key "security_deposits", "tenancies"
add_foreign_key "sessions", "users"
add_foreign_key "tenancies", "rentable_units"
add_foreign_key "tenancy_parties", "parties"
diff --git a/db/cache_schema.rb b/db/cache_schema.rb
index db1bbe82..a0dcd688 100644
--- a/db/cache_schema.rb
+++ b/db/cache_schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2026_08_16_000009) do
+ActiveRecord::Schema[8.1].define(version: 2026_08_16_000010) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@@ -242,6 +242,42 @@
t.index ["property_id"], name: "index_rentable_units_on_property_id"
end
+ create_table "security_deposit_transactions", force: :cascade do |t|
+ t.bigint "amount_cents", null: false
+ t.bigint "charge_id"
+ t.datetime "created_at", null: false
+ t.string "external_reference"
+ t.text "memo"
+ t.date "occurred_on", null: false
+ t.bigint "party_id"
+ t.datetime "posted_at"
+ t.bigint "security_deposit_id", null: false
+ t.bigint "superseded_by_id"
+ t.string "transaction_kind", null: false
+ t.datetime "updated_at", null: false
+ t.datetime "voided_at"
+ t.index ["charge_id"], name: "index_security_deposit_transactions_on_charge_id"
+ t.index ["occurred_on"], name: "index_security_deposit_transactions_on_occurred_on"
+ t.index ["party_id"], name: "index_security_deposit_transactions_on_party_id"
+ t.index ["security_deposit_id", "transaction_kind"], name: "index_sdt_on_deposit_and_kind"
+ t.index ["security_deposit_id"], name: "index_security_deposit_transactions_on_security_deposit_id"
+ t.index ["superseded_by_id"], name: "index_sdt_on_unique_superseded_by_id", unique: true, where: "(superseded_by_id IS NOT NULL)"
+ t.index ["superseded_by_id"], name: "index_security_deposit_transactions_on_superseded_by_id"
+ t.index ["voided_at"], name: "index_security_deposit_transactions_on_voided_at"
+ t.check_constraint "amount_cents > 0", name: "security_deposit_transactions_amount_cents_positive"
+ t.check_constraint "transaction_kind::text = ANY (ARRAY['received'::character varying, 'refunded'::character varying, 'applied'::character varying]::text[])", name: "security_deposit_transactions_kind_check"
+ end
+
+ create_table "security_deposits", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.date "due_on", null: false
+ t.bigint "required_amount_cents", null: false
+ t.bigint "tenancy_id", null: false
+ t.datetime "updated_at", null: false
+ t.index ["tenancy_id"], name: "index_security_deposits_on_tenancy_id", unique: true
+ t.check_constraint "required_amount_cents > 0", name: "security_deposits_required_amount_cents_positive"
+ end
+
create_table "sessions", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "ip_address"
@@ -326,6 +362,11 @@
add_foreign_key "receipts", "users"
add_foreign_key "rent_terms", "tenancies"
add_foreign_key "rentable_units", "properties"
+ add_foreign_key "security_deposit_transactions", "charges"
+ add_foreign_key "security_deposit_transactions", "parties"
+ add_foreign_key "security_deposit_transactions", "security_deposit_transactions", column: "superseded_by_id"
+ add_foreign_key "security_deposit_transactions", "security_deposits"
+ add_foreign_key "security_deposits", "tenancies"
add_foreign_key "sessions", "users"
add_foreign_key "tenancies", "rentable_units"
add_foreign_key "tenancy_parties", "parties"
diff --git a/db/migrate/20260816000010_create_security_deposits_and_transactions.rb b/db/migrate/20260816000010_create_security_deposits_and_transactions.rb
new file mode 100644
index 00000000..1ca7a027
--- /dev/null
+++ b/db/migrate/20260816000010_create_security_deposits_and_transactions.rb
@@ -0,0 +1,39 @@
+class CreateSecurityDepositsAndTransactions < ActiveRecord::Migration[8.1]
+ def change
+ create_table :security_deposits do |t|
+ t.references :tenancy, null: false, foreign_key: true, index: { unique: true }
+ t.bigint :required_amount_cents, null: false
+ t.date :due_on, null: false
+
+ t.timestamps
+ end
+
+ add_check_constraint :security_deposits, "required_amount_cents > 0", name: "security_deposits_required_amount_cents_positive"
+
+ create_table :security_deposit_transactions do |t|
+ t.references :security_deposit, null: false, foreign_key: true
+ t.string :transaction_kind, null: false
+ t.bigint :amount_cents, null: false
+ t.date :occurred_on, null: false
+
+ t.references :party, foreign_key: true
+ t.references :charge, foreign_key: true
+ t.string :external_reference
+ t.text :memo
+
+ t.datetime :posted_at
+ t.datetime :voided_at
+ t.references :superseded_by, foreign_key: { to_table: :security_deposit_transactions }
+
+ t.timestamps
+ end
+
+ add_check_constraint :security_deposit_transactions, "amount_cents > 0", name: "security_deposit_transactions_amount_cents_positive"
+ add_check_constraint :security_deposit_transactions, "transaction_kind IN ('received', 'refunded', 'applied')", name: "security_deposit_transactions_kind_check"
+
+ add_index :security_deposit_transactions, [ :security_deposit_id, :transaction_kind ], name: "index_sdt_on_deposit_and_kind"
+ add_index :security_deposit_transactions, :occurred_on
+ add_index :security_deposit_transactions, :voided_at
+ add_index :security_deposit_transactions, :superseded_by_id, unique: true, where: "superseded_by_id IS NOT NULL", name: "index_sdt_on_unique_superseded_by_id"
+ end
+end
diff --git a/db/queue_schema.rb b/db/queue_schema.rb
index f31aa95e..8edaf30b 100644
--- a/db/queue_schema.rb
+++ b/db/queue_schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2026_08_16_000009) do
+ActiveRecord::Schema[8.1].define(version: 2026_08_16_000010) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@@ -242,6 +242,42 @@
t.index ["property_id"], name: "index_rentable_units_on_property_id"
end
+ create_table "security_deposit_transactions", force: :cascade do |t|
+ t.bigint "amount_cents", null: false
+ t.bigint "charge_id"
+ t.datetime "created_at", null: false
+ t.string "external_reference"
+ t.text "memo"
+ t.date "occurred_on", null: false
+ t.bigint "party_id"
+ t.datetime "posted_at"
+ t.bigint "security_deposit_id", null: false
+ t.bigint "superseded_by_id"
+ t.string "transaction_kind", null: false
+ t.datetime "updated_at", null: false
+ t.datetime "voided_at"
+ t.index ["charge_id"], name: "index_security_deposit_transactions_on_charge_id"
+ t.index ["occurred_on"], name: "index_security_deposit_transactions_on_occurred_on"
+ t.index ["party_id"], name: "index_security_deposit_transactions_on_party_id"
+ t.index ["security_deposit_id", "transaction_kind"], name: "index_sdt_on_deposit_and_kind"
+ t.index ["security_deposit_id"], name: "index_security_deposit_transactions_on_security_deposit_id"
+ t.index ["superseded_by_id"], name: "index_sdt_on_unique_superseded_by_id", unique: true, where: "(superseded_by_id IS NOT NULL)"
+ t.index ["superseded_by_id"], name: "index_security_deposit_transactions_on_superseded_by_id"
+ t.index ["voided_at"], name: "index_security_deposit_transactions_on_voided_at"
+ t.check_constraint "amount_cents > 0", name: "security_deposit_transactions_amount_cents_positive"
+ t.check_constraint "transaction_kind::text = ANY (ARRAY['received'::character varying, 'refunded'::character varying, 'applied'::character varying]::text[])", name: "security_deposit_transactions_kind_check"
+ end
+
+ create_table "security_deposits", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.date "due_on", null: false
+ t.bigint "required_amount_cents", null: false
+ t.bigint "tenancy_id", null: false
+ t.datetime "updated_at", null: false
+ t.index ["tenancy_id"], name: "index_security_deposits_on_tenancy_id", unique: true
+ t.check_constraint "required_amount_cents > 0", name: "security_deposits_required_amount_cents_positive"
+ end
+
create_table "sessions", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "ip_address"
@@ -436,6 +472,11 @@
add_foreign_key "receipts", "users"
add_foreign_key "rent_terms", "tenancies"
add_foreign_key "rentable_units", "properties"
+ add_foreign_key "security_deposit_transactions", "charges"
+ add_foreign_key "security_deposit_transactions", "parties"
+ add_foreign_key "security_deposit_transactions", "security_deposit_transactions", column: "superseded_by_id"
+ add_foreign_key "security_deposit_transactions", "security_deposits"
+ add_foreign_key "security_deposits", "tenancies"
add_foreign_key "sessions", "users"
add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
diff --git a/db/schema.rb b/db/schema.rb
index 5b151819..79ad9901 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2026_08_16_000009) do
+ActiveRecord::Schema[8.1].define(version: 2026_08_16_000010) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@@ -24,7 +24,7 @@
t.bigint "user_id", null: false
t.index ["user_id", "key"], name: "index_accounts_on_user_id_and_key", unique: true
t.index ["user_id"], name: "index_accounts_on_user_id"
- t.check_constraint "account_type::text = ANY (ARRAY['asset'::character varying, 'liability'::character varying, 'equity'::character varying, 'income'::character varying, 'expense'::character varying]::text[])", name: "check_accounts_account_type"
+ t.check_constraint "account_type::text = ANY (ARRAY['asset'::character varying::text, 'liability'::character varying::text, 'equity'::character varying::text, 'income'::character varying::text, 'expense'::character varying::text])", name: "check_accounts_account_type"
end
create_table "charges", force: :cascade do |t|
@@ -54,7 +54,7 @@
t.index ["tenancy_id"], name: "index_charges_on_tenancy_id"
t.index ["voided_at"], name: "index_charges_on_voided_at"
t.check_constraint "amount_cents > 0", name: "charges_amount_cents_positive"
- t.check_constraint "charge_kind::text = ANY (ARRAY['rent'::character varying, 'late_fee'::character varying, 'reimbursement'::character varying, 'other'::character varying]::text[])", name: "charges_charge_kind_valid"
+ t.check_constraint "charge_kind::text = ANY (ARRAY['rent'::character varying::text, 'late_fee'::character varying::text, 'reimbursement'::character varying::text, 'other'::character varying::text])", name: "charges_charge_kind_valid"
t.check_constraint "service_period_end IS NULL OR service_period_start IS NULL OR service_period_end >= service_period_start", name: "charges_service_period_valid"
end
@@ -81,7 +81,7 @@
t.index ["superseded_by_id"], name: "index_expenses_on_superseded_by_id_unique", unique: true, where: "(superseded_by_id IS NOT NULL)"
t.index ["voided_at"], name: "index_expenses_on_voided_at"
t.check_constraint "amount_cents > 0", name: "expenses_amount_cents_positive"
- t.check_constraint "expense_kind::text = ANY (ARRAY['advertising'::character varying, 'auto_and_travel'::character varying, 'cleaning_and_maintenance'::character varying, 'commissions'::character varying, 'insurance'::character varying, 'legal_and_professional'::character varying, 'management'::character varying, 'mortgage_interest'::character varying, 'other_interest'::character varying, 'repairs'::character varying, 'supplies'::character varying, 'taxes'::character varying, 'utilities'::character varying, 'other'::character varying]::text[])", name: "expenses_expense_kind_valid"
+ t.check_constraint "expense_kind::text = ANY (ARRAY['advertising'::character varying::text, 'auto_and_travel'::character varying::text, 'cleaning_and_maintenance'::character varying::text, 'commissions'::character varying::text, 'insurance'::character varying::text, 'legal_and_professional'::character varying::text, 'management'::character varying::text, 'mortgage_interest'::character varying::text, 'other_interest'::character varying::text, 'repairs'::character varying::text, 'supplies'::character varying::text, 'taxes'::character varying::text, 'utilities'::character varying::text, 'other'::character varying::text])", name: "expenses_expense_kind_valid"
end
create_table "journal_entries", force: :cascade do |t|
@@ -242,6 +242,42 @@
t.index ["property_id"], name: "index_rentable_units_on_property_id"
end
+ create_table "security_deposit_transactions", force: :cascade do |t|
+ t.bigint "amount_cents", null: false
+ t.bigint "charge_id"
+ t.datetime "created_at", null: false
+ t.string "external_reference"
+ t.text "memo"
+ t.date "occurred_on", null: false
+ t.bigint "party_id"
+ t.datetime "posted_at"
+ t.bigint "security_deposit_id", null: false
+ t.bigint "superseded_by_id"
+ t.string "transaction_kind", null: false
+ t.datetime "updated_at", null: false
+ t.datetime "voided_at"
+ t.index ["charge_id"], name: "index_security_deposit_transactions_on_charge_id"
+ t.index ["occurred_on"], name: "index_security_deposit_transactions_on_occurred_on"
+ t.index ["party_id"], name: "index_security_deposit_transactions_on_party_id"
+ t.index ["security_deposit_id", "transaction_kind"], name: "index_sdt_on_deposit_and_kind"
+ t.index ["security_deposit_id"], name: "index_security_deposit_transactions_on_security_deposit_id"
+ t.index ["superseded_by_id"], name: "index_sdt_on_unique_superseded_by_id", unique: true, where: "(superseded_by_id IS NOT NULL)"
+ t.index ["superseded_by_id"], name: "index_security_deposit_transactions_on_superseded_by_id"
+ t.index ["voided_at"], name: "index_security_deposit_transactions_on_voided_at"
+ t.check_constraint "amount_cents > 0", name: "security_deposit_transactions_amount_cents_positive"
+ t.check_constraint "transaction_kind::text = ANY (ARRAY['received'::character varying, 'refunded'::character varying, 'applied'::character varying]::text[])", name: "security_deposit_transactions_kind_check"
+ end
+
+ create_table "security_deposits", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.date "due_on", null: false
+ t.bigint "required_amount_cents", null: false
+ t.bigint "tenancy_id", null: false
+ t.datetime "updated_at", null: false
+ t.index ["tenancy_id"], name: "index_security_deposits_on_tenancy_id", unique: true
+ t.check_constraint "required_amount_cents > 0", name: "security_deposits_required_amount_cents_positive"
+ end
+
create_table "sessions", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "ip_address"
@@ -315,6 +351,11 @@
add_foreign_key "receipts", "users"
add_foreign_key "rent_terms", "tenancies"
add_foreign_key "rentable_units", "properties"
+ add_foreign_key "security_deposit_transactions", "charges"
+ add_foreign_key "security_deposit_transactions", "parties"
+ add_foreign_key "security_deposit_transactions", "security_deposit_transactions", column: "superseded_by_id"
+ add_foreign_key "security_deposit_transactions", "security_deposits"
+ add_foreign_key "security_deposits", "tenancies"
add_foreign_key "sessions", "users"
add_foreign_key "tenancies", "rentable_units"
add_foreign_key "tenancy_parties", "parties"
diff --git a/db/seeds.rb b/db/seeds.rb
index 98309d10..a796a680 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -59,4 +59,19 @@
Expenses::CreateService.call(property: property, amount: rand(200...300), expense_kind: "repairs", paid_on: Date.current - 1.year, description: "A/C tune-up")
Expenses::CreateService.call(property: property, amount: rand(150...250), expense_kind: "repairs", paid_on: Date.current - 1.month, description: "Unclog toilet")
Expenses::CreateService.call(property: property, amount: rand(250...350), expense_kind: "repairs", paid_on: Date.current, description: "A/C tune-up")
+
+ dep_res = SecurityDeposits::CreateService.call(
+ tenancy: tenancy,
+ required_amount: "2400.00",
+ due_on: Date.current - 1.year
+ )
+ if dep_res.success?
+ SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: dep_res.value!.data[:security_deposit],
+ party: party,
+ amount: "2400.00",
+ occurred_on: Date.current - 1.year,
+ memo: "Initial deposit receipt"
+ )
+ end
end
diff --git a/documentation/double_entry_accounting/implementation_plan_milestone_6.md b/documentation/double_entry_accounting/implementation_plan_milestone_6.md
new file mode 100644
index 00000000..7e0aaac1
--- /dev/null
+++ b/documentation/double_entry_accounting/implementation_plan_milestone_6.md
@@ -0,0 +1,2486 @@
+# Implementation Plan: Milestone 6 — Security Deposits
+
+## 1. Objective
+
+Implement:
+
+```text
+Tenancy
+└── SecurityDeposit
+ └── SecurityDepositTransaction
+ ├── received
+ ├── refunded
+ └── applied
+```
+
+with accounting:
+
+```text
+Deposit received
+ Dr Cash
+ Cr Security Deposits Held
+
+Deposit refunded
+ Dr Security Deposits Held
+ Cr Cash
+
+Deposit applied
+ Dr Security Deposits Held
+ Cr Tenant Receivable
+```
+
+The last operation settles an already-existing Charge. It does **not** recognize income again.
+
+At the end of the milestone:
+
+- deposit requirement is visible on the Tenancy;
+- deposit held is derived from accounting postings;
+- money can be received into the deposit liability;
+- held money can be refunded;
+- held money can be applied against an existing Charge;
+- liability can never go negative;
+- deposits never become ordinary `Receipt`s;
+- security deposits never contribute to ordinary rent-received reporting.
+
+---
+
+## 2. Milestone boundary
+
+Implement:
+
+- `SecurityDeposit`;
+- `SecurityDepositTransaction`;
+- deposit requirement;
+- deposit receipt;
+- deposit refund;
+- deposit application;
+- deposit transaction void/correction;
+- held-liability query;
+- tenancy UI;
+- interim property activity;
+- concurrency protection;
+- integration with Charge lifecycle.
+
+Do **not** implement:
+
+- `SourceDocument` / `ImportedTransaction`;
+- automatic deposit detection during ingestion;
+- trust/escrow bank accounts;
+- interest-bearing deposits;
+- jurisdiction-specific security-deposit law;
+- deposit deductions without an existing Charge;
+- deposit allocation across arbitrary accounting entries;
+- bank reconciliation;
+- final ledger/reporting rewrite.
+
+Milestone 7 will generalize ingestion so confirmation can choose either an ordinary Receipt or a security-deposit transaction.
+
+---
+
+# 3. Preserve the conceptual separation from `Receipt`
+
+Do not add:
+
+```text
+receipt_kind = security_deposit
+```
+
+and do not add:
+
+```text
+is_security_deposit
+```
+
+to `Receipt`.
+
+A Receipt currently posts against Tenant Receivable, which is exactly what a refundable deposit must **not** do. The current tenancy balance is already calculated solely from Tenant Receivable postings.
+
+The domain distinction should remain:
+
+```text
+Receipt
+ money credited to the tenancy running account
+
+SecurityDepositTransaction(received)
+ money held as a refundable liability
+```
+
+---
+
+# 4. Establish the baseline
+
+Before implementation:
+
+```bash
+git status --short
+
+bundle exec rspec
+bundle exec rbs validate
+bundle exec steep check
+bin/rubocop
+bin/brakeman --no-pager
+```
+
+Then inventory relevant code:
+
+```bash
+rg -n \
+ 'security_deposit|Receipt|tenant_receivable|security_deposits_held|financial_history|current_balance|BalanceQuery' \
+ app config db spec sig documentation
+```
+
+Also identify Charge lifecycle paths:
+
+```bash
+rg -n \
+ 'Charges::VoidService|Charges::CorrectService|superseded_by|voided_at' \
+ app spec
+```
+
+Deposit application will introduce a new dependency on Charge lifecycle.
+
+---
+
+# 5. Create `security_deposits`
+
+Use one deposit aggregate per Tenancy:
+
+```text
+security_deposits
+
+id
+tenancy_id NOT NULL
+required_amount_cents BIGINT NOT NULL
+due_on DATE NOT NULL
+created_at NOT NULL
+updated_at NOT NULL
+```
+
+Add:
+
+```text
+UNIQUE(tenancy_id)
+```
+
+The PRD models `SecurityDeposit` as the contractual requirement and explicitly says that the amount held is derived from its transactions rather than stored as a mutable balance.
+
+Absence of a `SecurityDeposit` means:
+
+```text
+no security-deposit requirement recorded
+```
+
+Do not create zero-dollar placeholder rows.
+
+---
+
+# 6. SecurityDeposit model
+
+Create:
+
+```ruby
+class SecurityDeposit < ApplicationRecord
+ belongs_to :tenancy
+
+ has_many :transactions,
+ class_name: "SecurityDepositTransaction",
+ dependent: :restrict_with_error
+
+ def accounting_user
+ tenancy&.accounting_user
+ end
+end
+```
+
+Validate:
+
+```text
+required_amount_cents > 0
+due_on present
+tenancy present
+one deposit per tenancy
+```
+
+---
+
+# 7. Requirement mutability
+
+`SecurityDeposit` itself is **not** a posted financial event.
+
+However, because the schema has no effective-dated requirement history, keep the MVP rule conservative:
+
+```text
+before any deposit transaction:
+ requirement and due date may be edited
+
+after any deposit transaction exists:
+ required_amount_cents and due_on are immutable
+```
+
+Otherwise changing a `$2,000` historical requirement to `$3,000` after years of activity rewrites what the aggregate appears to have required at the time.
+
+If future requirements need to change mid-tenancy, introduce effective-dated requirement terms later rather than silently mutating historical contractual state.
+
+---
+
+# 8. Create `security_deposit_transactions`
+
+Target:
+
+```text
+security_deposit_transactions
+
+id
+security_deposit_id NOT NULL
+transaction_kind NOT NULL
+amount_cents BIGINT NOT NULL
+occurred_on DATE NOT NULL
+
+party_id NULL
+charge_id NULL
+external_reference NULL
+memo NULL
+
+posted_at NULL
+voided_at NULL
+superseded_by_id NULL
+
+created_at NOT NULL
+updated_at NOT NULL
+```
+
+The PRD's core fields are deposit, kind, cents, accounting date, Party, optional Charge/reference, and posting lifecycle. Add `superseded_by_id` to support the project's established correction model rather than destructive editing.
+
+---
+
+# 9. Database constraints
+
+Require:
+
+```text
+amount_cents > 0
+```
+
+Restrict `transaction_kind` to:
+
+```text
+received
+refunded
+applied
+```
+
+Add indexes:
+
+```text
+security_deposit_id
+transaction_kind
+occurred_on
+party_id
+charge_id
+voided_at
+superseded_by_id
+```
+
+Add:
+
+```text
+UNIQUE(superseded_by_id)
+WHERE superseded_by_id IS NOT NULL
+```
+
+Posted transactions must not be hard-deleted. The PRD explicitly includes security-deposit transactions in the financial-record deletion policy.
+
+---
+
+# 10. Kind-specific semantics
+
+## `received`
+
+Require:
+
+```text
+party_id present
+charge_id nil
+```
+
+`party` means:
+
+```text
+who supplied the deposit money
+```
+
+It need not be a current tenant.
+
+Like ordinary Receipt payer identity, a parent, guarantor, employer, or organization may legitimately supply funds.
+
+## `refunded`
+
+Require:
+
+```text
+party_id present
+charge_id nil
+```
+
+Here `party` means:
+
+```text
+who received the refund
+```
+
+It need not necessarily be the original contributor.
+
+## `applied`
+
+Require:
+
+```text
+charge_id present
+party_id nil
+```
+
+The Tenancy/Charge identifies whose running account is settled. There is no meaningful payer Party for the application itself.
+
+---
+
+# 11. Ownership invariants
+
+Every referenced object must resolve to the same user:
+
+```text
+SecurityDeposit.tenancy.accounting_user
+party.user
+charge.tenancy.accounting_user
+```
+
+For application also require:
+
+```text
+charge.tenancy_id ==
+security_deposit.tenancy_id
+```
+
+Put these validations on the model as defense in depth, not only specialized services.
+
+---
+
+# 12. Transaction dates represent actual events
+
+Require:
+
+```text
+occurred_on <= Date.current
+```
+
+Do not use `SecurityDepositTransaction` to schedule future refunds or applications.
+
+A future planned event is not yet a financial event and should not affect ledger balances.
+
+---
+
+# 13. Posted transaction immutability
+
+Once `posted_at` exists, ordinary updates cannot change:
+
+```text
+security_deposit_id
+transaction_kind
+amount_cents
+occurred_on
+party_id
+charge_id
+external_reference
+memo
+posted_at
+voided_at
+superseded_by_id
+```
+
+Lifecycle fields may only be changed through controlled correction/void services.
+
+Follow the same pattern already established for Receipt, where posted financial fields and lifecycle fields are protected from ordinary model mutation.
+
+---
+
+# 14. Accounting posting service
+
+Create:
+
+```text
+SecurityDepositTransactions::PostService
+```
+
+Use:
+
+```text
+source: SecurityDepositTransaction
+```
+
+with event types already named by the PRD:
+
+```text
+deposit_received
+deposit_refunded
+deposit_applied
+```
+
+
+---
+
+# 15. Received posting
+
+For `$2,000`:
+
+```text
+Dr Cash +200000
+Cr Security Deposits Held -200000
+```
+
+Both postings carry:
+
+```text
+property
+rentable_unit
+tenancy
+party
+```
+
+The Party is the contributor.
+
+It must not touch:
+
+```text
+Tenant Receivable
+Rental Income
+```
+
+
+---
+
+# 16. Refunded posting
+
+For `$500`:
+
+```text
+Dr Security Deposits Held +50000
+Cr Cash -50000
+```
+
+Dimensions:
+
+```text
+property
+rentable_unit
+tenancy
+party = refund recipient
+```
+
+A refund is a **new real-world cash event**, not a reversal of the original deposit receipt.
+
+This distinction matters.
+
+---
+
+# 17. Applied posting
+
+For `$500` applied against an existing Charge:
+
+```text
+Dr Security Deposits Held +50000
+Cr Tenant Receivable -50000
+```
+
+Dimensions:
+
+```text
+property
+rentable_unit
+tenancy
+party = nil
+```
+
+No income account participates.
+
+The selected Charge already recognized whatever income/reimbursement classification generated the obligation.
+
+---
+
+# 18. Create an authoritative held-liability query
+
+Create:
+
+```text
+Accounting::SecurityDepositBalanceQuery
+```
+
+The result must come from the ledger, not by summing transaction rows.
+
+Conceptually:
+
+```text
+held_cents =
+ -SUM(
+ postings.amount_cents
+ where account = security_deposits_held
+ and tenancy = target tenancy
+ and journal_entry.occurred_on <= as_of
+ )
+```
+
+The sign inversion is because a liability normally carries a credit balance under Yanushi's signed-posting convention.
+
+Return:
+
+```text
+positive = money currently held
+zero = no liability
+```
+
+This follows the same architecture already used by tenancy balance, which reads postings rather than reconstructing state from Charges and Receipts.
+
+---
+
+# 19. SecurityDeposit helpers
+
+Expose:
+
+```ruby
+def held_cents(as_of: Date.current)
+def held_amount(as_of: Date.current)
+def remaining_required_cents(as_of: Date.current)
+```
+
+where:
+
+```text
+remaining_required =
+ max(required_amount_cents - held_cents, 0)
+```
+
+Do not store any of these values.
+
+---
+
+# 20. Do not cap receipts to the contractual requirement
+
+Permit:
+
+```text
+required $2,000
+held $2,050
+```
+
+Accounting should faithfully represent what was actually received as a refundable liability.
+
+The UI can display:
+
+```text
+Overfunded by $50
+```
+
+but must not silently turn excess deposit money into rent, income, or Tenant Receivable.
+
+---
+
+# 21. One aggregate lock controls liability mutations
+
+All operations that change held security-deposit liability must first acquire:
+
+```text
+SecurityDeposit row lock
+```
+
+That includes:
+
+```text
+receive
+refund
+apply
+void transaction
+correct transaction
+```
+
+The security-deposit aggregate row is the concurrency serialization boundary.
+
+This directly addresses the PRD's requirement that security-deposit application be protected against concurrent over-application.
+
+---
+
+# 22. Do not validate only the current total
+
+A stronger invariant is required than:
+
+```text
+current held >= 0
+```
+
+Backdated transactions can otherwise make an earlier liability period negative.
+
+Example:
+
+```text
+Jan 1 receive $1,000
+Jan 10 refund $1,000
+Jan 20 receive $500
+
+then insert:
+Jan 5 refund $500
+```
+
+Current held ends at `$0`, but from January 10 to January 19 the historical liability would be `-$500`.
+
+That must be rejected.
+
+---
+
+# 23. Add a deposit-liability timeline validator
+
+Create something like:
+
+```text
+SecurityDeposits::LiabilityTimeline
+```
+
+or:
+
+```text
+SecurityDeposits::ValidateBalanceService
+```
+
+Under the SecurityDeposit lock:
+
+1. load all active posted deposit transactions;
+2. apply the proposed create/void/correction change in memory;
+3. group liability deltas by `occurred_on`;
+4. walk dates chronologically;
+5. require cumulative held amount to remain `>= 0` at every date.
+
+Domain liability delta:
+
+```text
+received +amount
+refunded -amount
+applied -amount
+```
+
+Use this for **validation only**.
+
+Actual displayed/reportable held balance still comes from the ledger.
+
+---
+
+# 24. Received service
+
+Create:
+
+```text
+SecurityDepositTransactions::ReceiveService
+```
+
+Inputs:
+
+```text
+security_deposit
+party
+amount
+occurred_on
+external_reference optional
+memo optional
+```
+
+Inside one transaction:
+
+1. lock SecurityDeposit;
+2. validate Party ownership;
+3. parse amount/date;
+4. run liability timeline validation;
+5. create transaction;
+6. post it;
+7. set `posted_at`;
+8. commit.
+
+Do not route this through `Receipts::CreateService`.
+
+---
+
+# 25. Refund service
+
+Create:
+
+```text
+SecurityDepositTransactions::RefundService
+```
+
+Inputs:
+
+```text
+security_deposit
+party
+amount
+occurred_on
+external_reference optional
+memo optional
+```
+
+Inside the SecurityDeposit lock:
+
+```text
+proposed refund must preserve
+nonnegative held liability
+for every accounting date
+```
+
+Then post:
+
+```text
+Dr Deposit Liability
+Cr Cash
+```
+
+---
+
+# 26. Application semantics under a running account
+
+Yanushi deliberately does not allocate ordinary Receipts to individual Charges. The tenancy balance is simply the Tenant Receivable posting balance.
+
+Therefore do **not** invent a concept such as:
+
+```text
+charge.unpaid_balance
+```
+
+unless it is specifically deposit-application-derived.
+
+For a deposit application to Charge X, cap by three independently knowable values:
+
+```text
+1. deposit liability available
+2. remaining deposit amount ever applied to Charge X
+3. positive Tenant Receivable balance as of application date
+```
+
+---
+
+# 27. Charge-specific deposit application capacity
+
+Define:
+
+```text
+already_applied_to_charge =
+ SUM(active deposit-applied transactions for charge)
+
+remaining_charge_application =
+ charge.amount_cents - already_applied_to_charge
+```
+
+This does **not** claim to know whether ordinary Receipts paid that Charge.
+
+It merely prevents security-deposit applications themselves from exceeding the Charge that supplies their semantic reason.
+
+---
+
+# 28. Tenant-account application capacity
+
+Use:
+
+```text
+tenancy.balance_cents(as_of: occurred_on)
+```
+
+The current balance query already supports as-of dates from Tenant Receivable postings.
+
+Require:
+
+```text
+tenant_receivable_balance > 0
+```
+
+and cap application by that positive amount.
+
+Do not use current-day balance for a backdated application.
+
+---
+
+# 29. Final application maximum
+
+The maximum application is:
+
+```text
+min(
+ deposit held available at occurred_on,
+ selected Charge remaining deposit-application capacity,
+ positive tenancy receivable balance at occurred_on
+)
+```
+
+A requested amount greater than any of those fails.
+
+Do not silently truncate it.
+
+---
+
+# 30. Apply service
+
+Create:
+
+```text
+SecurityDepositTransactions::ApplyService
+```
+
+Inputs:
+
+```text
+security_deposit
+charge
+amount
+occurred_on
+memo optional
+```
+
+Require Charge to be:
+
+```text
+persisted
+posted
+active
+same tenancy
+```
+
+A voided or superseded Charge cannot receive a new deposit application.
+
+The current Charge model already distinguishes active/voided/superseded lifecycle states.
+
+---
+
+# 31. Lock order for application
+
+Application needs both aggregate and Charge stability.
+
+Use:
+
+```text
+SecurityDeposit
+ ↓
+Charge
+```
+
+as the lock order.
+
+Inside those locks:
+
+1. reload both;
+2. require Charge active;
+3. re-evaluate held liability;
+4. re-evaluate Tenant Receivable;
+5. re-evaluate prior applications to the Charge;
+6. create/post application.
+
+---
+
+# 32. Integrate deposit applications with Charge lifecycle
+
+Once a Charge has an active deposit application, its financial history cannot be independently voided or corrected without handling that application.
+
+Add:
+
+```ruby
+Charge
+ has_many :security_deposit_applications,
+ -> { ... transaction_kind applied ... },
+ dependent: :restrict_with_error
+```
+
+Then:
+
+```text
+Charges::VoidService
+Charges::CorrectService
+```
+
+must reject an active Charge that has active security-deposit applications.
+
+Tell the user:
+
+```text
+Reverse/correct the deposit application first.
+```
+
+Otherwise Yanushi could leave an application pointing to a voided Charge and create an unexplained Tenant Receivable credit.
+
+---
+
+# 33. Preserve global lock ordering
+
+Charge lifecycle already has special locking around reimbursement Expenses.
+
+Once deposit application exists, document a global order such as:
+
+```text
+Expense(s), stable ID order
+ ↓
+SecurityDeposit(s), stable ID order
+ ↓
+Charge
+```
+
+Then make all affected lifecycle services follow it.
+
+Avoid:
+
+```text
+Charge -> SecurityDeposit
+```
+
+anywhere.
+
+This prevents the new aggregate dependency from creating a lock-order cycle with the reimbursement lifecycle work completed in Milestone 5.
+
+---
+
+# 34. Integrate with Expense correction
+
+`Expenses::CorrectService` currently restates its reimbursement Charges.
+
+Before correcting an Expense, if any affected reimbursement Charge has an active security-deposit application:
+
+```text
+reject correction
+```
+
+with guidance to reverse/correct the deposit application first.
+
+Do not automatically guess what should happen to applied deposit money when the underlying reimbursement Charge is being restated.
+
+That policy should remain explicit.
+
+---
+
+# 35. Transaction void semantics
+
+For `SecurityDepositTransaction`, **void means the transaction was recorded in error**.
+
+That is different from:
+
+```text
+deposit refund
+```
+
+which has its own `refunded` transaction kind.
+
+Therefore a deposit-transaction void should always reverse on:
+
+```text
+original transaction.occurred_on
+```
+
+Example:
+
+```text
+Jan 1 erroneous $2,000 deposit receipt
+discovered Feb 1
+
+void:
+ reverse Jan 1
+```
+
+Do not date it February 1.
+
+If money actually leaves on February 1, record a `refunded` transaction instead.
+
+---
+
+# 36. Void service
+
+Create:
+
+```text
+SecurityDepositTransactions::VoidService
+```
+
+Inside one transaction:
+
+1. lock SecurityDeposit;
+2. lock transaction;
+3. reject superseded transaction;
+4. if already voided:
+ - identical retry => existing reversal;
+5. simulate removal through liability timeline validator;
+6. reject if removal would make historical held liability negative;
+7. reverse original JournalEntry on original `occurred_on`;
+8. set `voided_at`;
+9. commit.
+
+A received transaction with later refunds/applications may therefore be impossible to void until those later withdrawals are dealt with.
+
+That is correct.
+
+---
+
+# 37. Transaction correction
+
+Create:
+
+```text
+SecurityDepositTransactions::CorrectService
+```
+
+Correction:
+
+```text
+original transaction
+original JournalEntry
+reversal
+replacement transaction
+replacement JournalEntry
+```
+
+and:
+
+```text
+original.superseded_by = replacement
+```
+
+Do not edit a posted transaction in place.
+
+This follows the architecture-wide posted-history rule.
+
+---
+
+# 38. Keep correction within the same SecurityDeposit
+
+For Milestone 6, do **not** allow:
+
+```text
+SecurityDeposit A transaction
+ corrected into
+SecurityDeposit B
+```
+
+If the wrong tenancy/deposit was selected:
+
+```text
+void original
+create correct transaction on target deposit
+```
+
+This keeps correction concurrency local to one aggregate.
+
+---
+
+# 39. Same-kind correction
+
+For the first implementation, require the replacement to retain the same:
+
+```text
+transaction_kind
+```
+
+Allow correction of:
+
+```text
+amount
+occurred_on
+party
+charge for applied transaction
+external_reference
+memo
+```
+
+If the original kind itself was wrong, use:
+
+```text
+void + new correct transaction
+```
+
+This keeps capacity and liability validation understandable.
+
+---
+
+# 40. Correction atomicity
+
+Within the SecurityDeposit lock:
+
+1. lock original transaction;
+2. handle existing replacement idempotently;
+3. validate replacement inputs;
+4. simulate the replacement through the full liability timeline;
+5. reverse original at original date;
+6. create/post replacement;
+7. set original `voided_at`;
+8. link `superseded_by`;
+9. commit.
+
+Any failure:
+
+```text
+original remains active
+no reversal remains
+no replacement remains
+```
+
+---
+
+# 41. Application correction
+
+If correcting an `applied` transaction, re-run all application constraints for the replacement:
+
+```text
+target Charge active
+same tenancy
+held liability sufficient
+positive A/R sufficient
+charge-specific application capacity sufficient
+```
+
+When calculating capacity, exclude the original application being replaced.
+
+---
+
+# 42. Deposit query remains independent of required amount
+
+Do not define:
+
+```text
+held = required - refunded
+```
+
+The two concepts are independent:
+
+```text
+required amount
+ contractual expectation
+
+held amount
+ accounting liability
+```
+
+Examples:
+
+```text
+required $2,000
+held $1,000
+status partially funded
+```
+
+```text
+required $2,000
+held $2,000
+status funded
+```
+
+```text
+required $2,000
+held $500
+status $1,500 was refunded/applied
+```
+
+---
+
+# 43. Add Tenancy associations
+
+Add:
+
+```ruby
+has_one :security_deposit,
+ dependent: :restrict_with_error
+```
+
+and convenient transaction access if useful.
+
+Update `financial_history?` so deposit transactions count explicitly.
+
+Accounting postings already provide a safety net, but direct domain history should also be recognized. The current method presently considers Charges, Receipts, and accounting postings.
+
+---
+
+# 44. Party associations
+
+Add:
+
+```ruby
+has_many :security_deposit_transactions,
+ dependent: :restrict_with_error
+```
+
+A Party referenced as deposit contributor/refund recipient must not be deleted out from under posted financial history.
+
+---
+
+# 45. Security deposit setup service
+
+Create:
+
+```text
+SecurityDeposits::CreateService
+```
+
+Inputs:
+
+```text
+tenancy
+required_amount
+due_on
+```
+
+Acquire Tenancy lock before creating the unique aggregate to make duplicate concurrent creation deterministic.
+
+Return an existing equivalent aggregate idempotently if appropriate, otherwise fail on conflict.
+
+---
+
+# 46. Requirement update service
+
+Create:
+
+```text
+SecurityDeposits::UpdateService
+```
+
+Permit updating:
+
+```text
+required_amount
+due_on
+```
+
+only if:
+
+```text
+transactions.none?
+```
+
+Do the check under the SecurityDeposit lock.
+
+Do not expose generic `update` directly from the controller.
+
+---
+
+# 47. Routes
+
+Suggested shape:
+
+```ruby
+resources :tenancies do
+ resource :security_deposit, only: %i[new create show edit update] do
+ post :receive
+ post :refund
+ post :apply
+ end
+end
+
+resources :security_deposit_transactions, only: %i[show] do
+ member do
+ get :correction
+ post :correct
+ post :void
+ end
+end
+```
+
+A singular nested `security_deposit` matches the one-per-tenancy model.
+
+---
+
+# 48. Keep nested Tenancy authoritative
+
+For:
+
+```text
+/tenancies/:tenancy_id/security_deposit
+```
+
+resolve the Tenancy using:
+
+```ruby
+authenticated_user.tenancies.find(params[:tenancy_id])
+```
+
+Do not allow a body `tenancy_id` to override the route.
+
+Apply the same route-binding discipline established for Receipts and Expenses.
+
+---
+
+# 49. Tenancy page
+
+The current Tenancy page has separate Charges, Payments/Receipts, and account-balance sections. Security deposit deserves its own card rather than being added to Payments & Receipts.
+
+Show:
+
+```text
+Security Deposit
+
+Required $2,000
+Due Jan 1, 2027
+Currently Held $1,500
+Remaining $500
+```
+
+Status examples:
+
+```text
+Not funded
+Partially funded
+Funded
+Overfunded
+No amount held
+```
+
+---
+
+# 50. Deposit actions
+
+On the Security Deposit card:
+
+```text
+Record Deposit
+Refund Deposit
+Apply Deposit
+View History
+```
+
+Only enable:
+
+```text
+Refund
+Apply
+```
+
+when held liability is positive.
+
+Disable `Apply` when:
+
+```text
+tenancy balance <= 0
+```
+
+but keep server validation authoritative.
+
+---
+
+# 51. Record Deposit form
+
+Fields:
+
+```text
+Contributor
+Amount
+Received on
+External reference
+Memo
+```
+
+Contributor picker:
+
+```text
+all user-owned Parties
+```
+
+not only Tenancy participants.
+
+Do not reuse the ordinary Receipt form or route.
+
+---
+
+# 52. Refund form
+
+Fields:
+
+```text
+Recipient
+Amount
+Refunded on
+External reference
+Memo
+```
+
+Display:
+
+```text
+Currently held: $X
+Maximum refund: $X
+```
+
+Reject excess server-side.
+
+---
+
+# 53. Apply form
+
+Show:
+
+```text
+Currently held
+Current tenancy balance
+```
+
+Select from:
+
+```text
+active posted Charges
+for this Tenancy
+```
+
+For each option display something like:
+
+```text
+Rent — Aug 2026 — $2,000
+Damage reimbursement — $500
+Late fee — $50
+```
+
+After selecting a Charge, show the calculated maximum applicable amount.
+
+---
+
+# 54. Do not pretend the selected Charge has ordinary-payment settlement state
+
+Avoid UI copy such as:
+
+```text
+Charge unpaid balance
+```
+
+because ordinary Receipts are not allocated.
+
+Instead say:
+
+```text
+Maximum deposit application to this charge
+```
+
+which is based on:
+
+```text
+charge amount
+minus prior deposit applications
+```
+
+plus the tenancy-level A/R cap.
+
+---
+
+# 55. Deposit history
+
+Display every transaction:
+
+```text
+Date
+Kind
+Party / Charge
+Amount
+Reference
+Status
+```
+
+Examples:
+
+```text
+Jan 1 Received Alice +$2,000
+Aug 1 Applied Damage Charge -$500
+Aug 15 Refunded Alice -$500
+```
+
+Use liability-oriented presentation rather than debit/credit terminology.
+
+---
+
+# 56. Corrected and voided transaction presentation
+
+Use the lifecycle precedence already established elsewhere:
+
+```text
+superseded -> Corrected
+voided -> Voided
+posted -> Active
+```
+
+Show links:
+
+```text
+original -> replacement
+replacement -> original
+```
+
+Do not repeat the corrected-vs-voided UI bug previously encountered with Charges and Expenses.
+
+---
+
+# 57. Liability reporting
+
+At minimum add:
+
+```text
+Tenancy:
+ Required deposit
+ Deposit held
+```
+
+and:
+
+```text
+Property:
+ Security deposits held
+```
+
+Property deposit liability should come from the ledger:
+
+```text
+security_deposits_held postings
+filtered by property
+```
+
+not by summing `SecurityDepositTransaction` rows.
+
+The PRD explicitly requires security-deposit liability reporting in Milestone 6, while the larger ledger/reporting rewrite remains Milestone 8.
+
+---
+
+# 58. Interim property financial timeline
+
+The existing full ledger projection is not replaced until Milestone 8.
+
+For the current interim property activity query, add domain rows for:
+
+```text
+Security deposit received
+Security deposit refund
+Deposit applied
+```
+
+with lifecycle status.
+
+Do not rewrite the whole property ledger early.
+
+The PRD's eventual property activity vocabulary explicitly includes these deposit events.
+
+---
+
+# 59. Schedule E must remain unchanged by deposits
+
+Add explicit regression coverage proving:
+
+```text
+deposit received
+deposit refunded
+deposit applied
+```
+
+do not change:
+
+```text
+Schedule E rents received
+```
+
+A refundable deposit is not an ordinary rent receipt.
+
+This should happen naturally because deposits never become `Receipt`, but pin it with tests.
+
+---
+
+# 60. Tenant balance acceptance
+
+Receive:
+
+```text
+Security deposit $2,000
+```
+
+Expected:
+
+```text
+Cash +$2,000
+Deposit liability +$2,000
+
+Tenant Receivable unchanged
+Tenancy balance unchanged
+```
+
+This is a core PRD acceptance criterion.
+
+---
+
+# 61. Refund acceptance
+
+Given:
+
+```text
+held deposit $2,000
+```
+
+Refund:
+
+```text
+$750
+```
+
+Expected:
+
+```text
+Deposit held $1,250
+Tenant Receivable unchanged
+Cash decreased $750
+```
+
+A second refund over `$1,250` fails.
+
+---
+
+# 62. Application acceptance
+
+Given:
+
+```text
+Damage Charge $500
+Tenant balance $500
+Deposit held $2,000
+```
+
+Apply:
+
+```text
+$500
+```
+
+Expected:
+
+```text
+Tenant Receivable $0
+Deposit held $1,500
+Cash unchanged
+Income unchanged
+```
+
+This directly pins the PRD's application semantics.
+
+---
+
+# 63. Partial application
+
+Given:
+
+```text
+Charge $500
+Tenant balance $500
+Deposit held $300
+```
+
+Maximum application:
+
+```text
+$300
+```
+
+After application:
+
+```text
+Tenant balance $200
+Deposit held $0
+```
+
+---
+
+# 64. Account-credit protection
+
+Given:
+
+```text
+Tenant balance $100
+Deposit held $2,000
+Charge selected $500
+```
+
+Attempt:
+
+```text
+apply $500
+```
+
+must fail.
+
+Maximum application is:
+
+```text
+$100
+```
+
+Do not create a deposit-derived tenant credit unless explicitly supported later. The PRD requires the application not exceed the relevant tenant-account amount being settled.
+
+---
+
+# 65. Multiple applications to one Charge
+
+Given:
+
+```text
+Charge amount $500
+
+Application 1 $300
+```
+
+Maximum further deposit application to that Charge:
+
+```text
+$200
+```
+
+even if the tenancy has other outstanding Charges.
+
+---
+
+# 66. Running-account limitation test
+
+Explicitly document/test:
+
+```text
+Charge A $500
+Charge B $500
+Receipt $500
+Tenant balance $500
+```
+
+Yanushi does not know which Charge the ordinary Receipt settled.
+
+A deposit may still be applied against an active Charge subject to:
+
+```text
+selected-charge deposit cap
++
+overall Tenant Receivable cap
+```
+
+Do not introduce receipt allocation merely to make this distinction more precise.
+
+---
+
+# 67. Cross-tenancy isolation
+
+Security Deposit for:
+
+```text
+Unit A Tenancy
+```
+
+must never:
+
+- apply to Unit B Charge;
+- affect Unit B Tenant Receivable;
+- appear as Unit B deposit liability.
+
+This should be protected at both model and service layers.
+
+---
+
+# 68. Concurrent refund tests
+
+With:
+
+```text
+held = $2,000
+```
+
+two concurrent:
+
+```text
+refund $1,500
+```
+
+requests must result in:
+
+```text
+one success
+one failure
+held >= 0
+```
+
+Never:
+
+```text
+held = -$1,000
+```
+
+---
+
+# 69. Concurrent application tests
+
+With:
+
+```text
+held = $500
+A/R = $500
+```
+
+two concurrent:
+
+```text
+apply $500
+```
+
+requests must create only one valid application.
+
+The SecurityDeposit lock is the serialization boundary.
+
+---
+
+# 70. Concurrent refund vs application
+
+Given:
+
+```text
+held = $500
+```
+
+race:
+
+```text
+refund $500
+vs
+apply $500
+```
+
+Exactly one may consume the liability.
+
+The other must fail after reloading under the aggregate lock.
+
+---
+
+# 71. Backdated history tests
+
+Test:
+
+```text
+Jan 1 receive $1,000
+Jan 10 refund $1,000
+Jan 20 receive $500
+```
+
+Then attempt:
+
+```text
+Jan 5 refund $500
+```
+
+Reject because it would make the historical deposit liability negative on January 10 even though today's final held balance would be zero.
+
+This pins the timeline validator rather than merely current-total validation.
+
+---
+
+# 72. Void-received dependency
+
+Given:
+
+```text
+Jan 1 receive $2,000
+Feb 1 refund $1,000
+```
+
+Trying to void the January receipt must fail if removing it would make February liability negative.
+
+After the refund is voided:
+
+```text
+void received
+```
+
+may succeed.
+
+---
+
+# 73. Charge lifecycle integration tests
+
+Given active:
+
+```text
+SecurityDepositTransaction(applied)
+ -> Charge
+```
+
+verify:
+
+```text
+Charges::VoidService
+ rejected
+
+Charges::CorrectService
+ rejected
+```
+
+After voiding/correcting the deposit application:
+
+```text
+Charge lifecycle operation permitted
+```
+
+This prevents orphaned settlement semantics.
+
+---
+
+# 74. Expense/reimbursement integration test
+
+Given:
+
+```text
+Expense
+ -> reimbursement Charge
+ -> active deposit application
+```
+
+attempt:
+
+```text
+Correct Expense
+```
+
+must fail until the deposit application is reversed/corrected.
+
+This protects the Milestone 5 automatic reimbursement-restatement workflow.
+
+---
+
+# 75. Void transaction tests
+
+For every kind:
+
+```text
+received
+refunded
+applied
+```
+
+verify:
+
+- original remains;
+- original JournalEntry remains;
+- reversal exists;
+- reversal uses original `occurred_on`;
+- original receives `voided_at`;
+- transaction cannot be hard-deleted;
+- identical retry is idempotent;
+- conflicting retry fails if applicable.
+
+---
+
+# 76. Correction tests
+
+For each kind, test a straightforward correction.
+
+Example receive correction:
+
+```text
+original:
+Jan 1, $2,000 from Alice
+
+replacement:
+Jan 1, $2,100 from Alice
+```
+
+Expected net liability:
+
+```text
+$2,100
+```
+
+with full original/reversal/replacement audit chain.
+
+---
+
+# 77. Correction must preserve nonnegative history
+
+Example:
+
+```text
+Jan 1 received $2,000
+Feb 1 refunded $1,500
+```
+
+Attempt to correct January receipt to:
+
+```text
+$1,000
+```
+
+must fail because the resulting February liability would be negative.
+
+No reversal or replacement may remain after failure.
+
+---
+
+# 78. Correction retry semantics
+
+Identical repeated correction:
+
+```text
+same replacement returned
+one reversal
+one replacement
+```
+
+Different repeated correction:
+
+```text
+:idempotency_conflict
+```
+
+Follow the same strong lifecycle idempotency standard established in prior milestones.
+
+---
+
+# 79. Source-event posting identity
+
+Verify:
+
+```text
+source_type = SecurityDepositTransaction
+```
+
+and:
+
+```text
+received -> deposit_received
+refunded -> deposit_refunded
+applied -> deposit_applied
+```
+
+The ledger's unique source-event constraint should prevent the same transaction event from posting twice.
+
+---
+
+# 80. Money boundaries
+
+Use integer cents internally.
+
+Public HTTP forms accept:
+
+```text
+amount
+required_amount
+```
+
+in dollars.
+
+Do **not** permit:
+
+```text
+amount_cents
+required_amount_cents
+```
+
+through public strong parameters.
+
+The internal services may accept cents only when the argument is an actual `Integer`.
+
+Carry forward the boundary rule established during Milestone 5.
+
+---
+
+# 81. Seeds
+
+Seed security deposits through real services:
+
+```text
+SecurityDeposits::CreateService
+SecurityDepositTransactions::ReceiveService
+SecurityDepositTransactions::RefundService
+SecurityDepositTransactions::ApplyService
+```
+
+Do not manually construct JournalEntries.
+
+---
+
+# 82. Factories
+
+Add:
+
+```text
+:security_deposit
+:security_deposit_transaction
+```
+
+traits:
+
+```text
+:received
+:refunded
+:applied
+:voided
+:corrected
+```
+
+For financially-real test setup, prefer the domain services so the ledger is present.
+
+Use raw factories only for isolated validation tests.
+
+---
+
+# 83. RBS / Steep
+
+Add signatures for:
+
+```text
+SecurityDeposit
+SecurityDepositTransaction
+
+SecurityDeposits::CreateService
+SecurityDeposits::UpdateService
+SecurityDeposits::LiabilityTimeline
+
+SecurityDepositTransactions::PostService
+SecurityDepositTransactions::ReceiveService
+SecurityDepositTransactions::RefundService
+SecurityDepositTransactions::ApplyService
+SecurityDepositTransactions::VoidService
+SecurityDepositTransactions::CorrectService
+
+Accounting::SecurityDepositBalanceQuery
+```
+
+Update:
+
+```text
+Tenancy
+Party
+Charge
+Charges::VoidService
+Charges::CorrectService
+Expenses::CorrectService
+```
+
+Regenerate Rails signatures and keep broad Steep coverage.
+
+---
+
+# 84. Documentation
+
+Add:
+
+```text
+documentation/double_entry_accounting/implementation_plan_milestone_6.md
+```
+
+Document explicitly:
+
+- requirement versus held liability;
+- deposit versus Receipt;
+- liability posting rules;
+- refund versus reversal;
+- application semantics;
+- no duplicate income recognition;
+- application under running-account semantics;
+- historical nonnegative-liability invariant;
+- aggregate locking;
+- interaction with Charge correction;
+- transaction correction history.
+
+The project-wide architecture documentation already calls for explicit security-deposit semantics.
+
+---
+
+# 85. Stale-assumption searches
+
+Before finalizing:
+
+```bash
+rg -n \
+ 'security_deposit|security_deposits_held|deposit_received|deposit_refunded|deposit_applied' \
+ app config db spec sig
+```
+
+Then ensure ordinary Receipt code has not grown deposit branching:
+
+```bash
+rg -n \
+ 'security.deposit|deposit' \
+ app/models/receipt.rb \
+ app/services/receipts \
+ app/controllers/receipts_controller.rb
+```
+
+Expected:
+
+```text
+no security-deposit behavior in Receipt
+```
+
+---
+
+# 86. Verify service-owned persistence
+
+Search:
+
+```bash
+rg -n \
+ 'SecurityDepositTransaction\.(new|create|create!)|security_deposit_transactions\.(build|create|create!)' \
+ app
+```
+
+Every financially-real production transaction should flow through the specialized domain services.
+
+Likewise verify no direct JournalEntry/Posting creation escaped the accounting boundary.
+
+---
+
+# 87. Clean database verification
+
+Since the project still permits destructive schema development:
+
+```bash
+bin/rails db:drop db:create db:migrate
+RAILS_ENV=test bin/rails db:drop db:create db:migrate
+```
+
+Verify:
+
+```text
+security_deposits
+security_deposit_transactions
+```
+
+constraints and FKs.
+
+No Receipt schema change should be necessary.
+
+---
+
+# 88. Manual smoke test
+
+Using a clean database:
+
+1. Create Property/Unit/Tenancy.
+2. Set a `$2,000` security-deposit requirement.
+3. Confirm Tenant Receivable is unchanged.
+4. Record `$1,000` from Tenant A.
+5. Confirm held = `$1,000`.
+6. Record `$1,000` from Tenant B/third party.
+7. Confirm held = `$2,000`.
+8. Confirm tenancy balance is still unchanged.
+9. Create a `$500` damage/reimbursement Charge.
+10. Apply `$300` deposit.
+11. Confirm held = `$1,700`.
+12. Confirm tenancy balance decreases `$300`.
+13. Apply remaining `$200`.
+14. Confirm selected Charge cannot receive another deposit application.
+15. Refund `$500`.
+16. Confirm held = `$1,000`.
+17. Try an excessive refund.
+18. Confirm rejection.
+19. Try applying deposit to another Tenancy's Charge.
+20. Confirm rejection.
+21. Try voiding a Charge with an active application.
+22. Confirm rejection.
+23. Void the application.
+24. Confirm Charge lifecycle becomes available again.
+25. Correct a deposit receipt.
+26. Confirm original/reversal/replacement audit history.
+27. Open interim Schedule E.
+28. Confirm deposit money is absent from rents received.
+
+---
+
+# 89. Final quality gate
+
+Run:
+
+```bash
+bin/rubocop
+
+bundle exec rbs validate
+bundle exec steep check
+
+bundle exec rspec
+
+bin/brakeman --no-pager
+bin/bundler-audit
+bin/importmap audit
+```
+
+Include real threaded coverage for the aggregate-lock scenarios rather than mocked lock calls.
+
+---
+
+# 90. Suggested commit boundaries
+
+**Commit 1: Add SecurityDeposit domain**
+
+```text
+security_deposits
+security_deposit_transactions
+models
+constraints
+associations
+factories
+```
+
+**Commit 2: Add liability accounting**
+
+```text
+PostService
+SecurityDepositBalanceQuery
+received/refunded posting
+accounting specs
+```
+
+**Commit 3: Add receive/refund workflows**
+
+```text
+CreateService
+UpdateService
+ReceiveService
+RefundService
+tenancy UI
+routes/controllers
+```
+
+**Commit 4: Add deposit application**
+
+```text
+ApplyService
+Charge association
+application capacity
+A/R integration
+cross-tenancy validation
+```
+
+**Commit 5: Add lifecycle integration**
+
+```text
+deposit transaction void/correction
+Charge void/correct guards
+Expense correction guard
+lock-order documentation
+concurrency specs
+```
+
+**Commit 6: Reporting/UI compatibility**
+
+```text
+held-liability displays
+property deposit liability
+interim financial timeline
+Schedule E regression
+history views
+```
+
+**Commit 7: Typing/docs/cleanup**
+
+```text
+RBS
+Steep
+documentation
+seeds
+stale searches
+full quality gate
+```
+
+---
+
+# 91. Milestone 6 acceptance checklist
+
+### Domain
+
+- [ ] One `SecurityDeposit` per Tenancy.
+- [ ] Requirement uses integer cents.
+- [ ] Held amount is not stored.
+- [ ] Deposit transactions use integer cents.
+- [ ] Received/refunded/applied kinds exist.
+- [ ] Posted transactions are immutable.
+- [ ] Posted transactions cannot be deleted.
+- [ ] Deposit transactions never become Receipts.
+
+### Accounting
+
+- [ ] Received posts Dr Cash / Cr Deposit Liability.
+- [ ] Refund posts Dr Deposit Liability / Cr Cash.
+- [ ] Application posts Dr Deposit Liability / Cr Tenant Receivable.
+- [ ] No deposit event directly touches income.
+- [ ] Deposit receipt does not affect Tenant Receivable.
+- [ ] Deposit receipt does not affect ordinary rent-received reporting.
+- [ ] Held liability is derived from postings.
+
+### Liability integrity
+
+- [ ] Held liability can never be negative.
+- [ ] Historical held liability can never be negative.
+- [ ] Refunds serialize under the SecurityDeposit lock.
+- [ ] Applications serialize under the SecurityDeposit lock.
+- [ ] Backdated transactions cannot create historical negative periods.
+- [ ] Voiding/correcting received funds respects later withdrawals.
+
+### Applications
+
+- [ ] Application requires an active posted Charge.
+- [ ] Charge must belong to the same Tenancy.
+- [ ] Application cannot exceed held liability.
+- [ ] Application cannot exceed positive Tenant Receivable.
+- [ ] Deposit applications to one Charge cannot exceed that Charge amount.
+- [ ] Ordinary Receipt allocations remain unnecessary.
+- [ ] Application never recognizes income twice.
+
+### Lifecycle integration
+
+- [ ] Active deposit application blocks Charge void.
+- [ ] Active deposit application blocks Charge correction.
+- [ ] Active applied reimbursement blocks Expense correction indirectly.
+- [ ] Deposit application must be reversed/corrected first.
+- [ ] Global lock ordering is documented.
+- [ ] No lock-order cycle is introduced.
+
+### Corrections
+
+- [ ] Void means bookkeeping error.
+- [ ] Refund means actual cash returned.
+- [ ] Void reverses on original date.
+- [ ] Correction preserves original event.
+- [ ] Correction creates reversal.
+- [ ] Correction creates replacement.
+- [ ] Equivalent retry is idempotent.
+- [ ] Conflicting retry fails.
+
+### UI
+
+- [ ] Tenancy shows deposit required.
+- [ ] Tenancy shows deposit held.
+- [ ] Tenancy shows remaining requirement.
+- [ ] Deposit has its own card, separate from Receipts.
+- [ ] Record Deposit works.
+- [ ] Refund works.
+- [ ] Apply to Charge works.
+- [ ] Transaction history works.
+- [ ] Corrected/voided states are distinguishable.
+
+### Core PRD acceptance
+
+For:
+
+```text
+Receive deposit $2,000
+```
+
+- [ ] Cash increases `$2,000`.
+- [ ] Deposit liability increases `$2,000`.
+- [ ] Tenant Receivable unchanged.
+
+For:
+
+```text
+Refund $2,000
+```
+
+- [ ] Cash decreases `$2,000`.
+- [ ] Deposit liability returns to zero.
+
+For:
+
+```text
+Damage Charge $500
+Deposit applied $500
+```
+
+- [ ] Tenant Receivable decreases `$500`.
+- [ ] Deposit liability decreases `$500`.
+- [ ] No additional income is recognized.
+
+Those are the exact behavioral outcomes the PRD uses to define Milestone 6 completion.
+
+The most important implementation detail is the **SecurityDeposit aggregate lock plus historical liability validator**. Checking only today's held amount is not enough once backdated refunds, applications, voids, and corrections exist. And the most important cross-milestone integration is making active deposit applications block independent Charge correction: once deposit money has settled a Charge, those two immutable histories have to be unwound in the right order rather than allowed to drift apart.
diff --git a/sig/app/controllers/properties_controller.rbs b/sig/app/controllers/properties_controller.rbs
index 38f69cd9..7e86da2f 100644
--- a/sig/app/controllers/properties_controller.rbs
+++ b/sig/app/controllers/properties_controller.rbs
@@ -3,6 +3,7 @@ class PropertiesController < ApplicationController
@property: Property
@year: Integer
@financial_items: Array[untyped]
+ @security_deposits_held_cents: Integer
@rents_received: Float
@utility_reimbursements: Float
@total_income: Float
diff --git a/sig/app/controllers/security_deposit_transactions_controller.rbs b/sig/app/controllers/security_deposit_transactions_controller.rbs
new file mode 100644
index 00000000..5da6855d
--- /dev/null
+++ b/sig/app/controllers/security_deposit_transactions_controller.rbs
@@ -0,0 +1,16 @@
+class SecurityDepositTransactionsController < ApplicationController
+ @transaction: SecurityDepositTransaction
+ @journal_entries: JournalEntry::ActiveRecord_Relation
+ @parties: Party::ActiveRecord_Relation
+ @active_charges: Charge::ActiveRecord_Relation
+
+ def show: () -> void
+ def correction: () -> void
+ def correct: () -> void
+ def void: () -> void
+
+ private
+ def load_active_charges: () -> Charge::ActiveRecord_Relation
+ def set_transaction: () -> SecurityDepositTransaction
+ def transaction_params: () -> ActionController::Parameters
+end
diff --git a/sig/app/controllers/security_deposits_controller.rbs b/sig/app/controllers/security_deposits_controller.rbs
new file mode 100644
index 00000000..86584625
--- /dev/null
+++ b/sig/app/controllers/security_deposits_controller.rbs
@@ -0,0 +1,21 @@
+class SecurityDepositsController < ApplicationController
+ @tenancy: Tenancy
+ @security_deposit: SecurityDeposit
+ @transactions: untyped
+ @parties: untyped
+ @active_charges: untyped
+
+ def show: () -> void
+ def new: () -> void
+ def create: () -> void
+ def edit: () -> void
+ def update: () -> void
+ def receive: () -> void
+ def refund: () -> void
+ def apply: () -> void
+
+ private
+ def set_tenancy: () -> void
+ def set_security_deposit: () -> void
+ def security_deposit_params: () -> ActionController::Parameters
+end
diff --git a/sig/app/models/charge.rbs b/sig/app/models/charge.rbs
index 7049ef61..7d49e16c 100644
--- a/sig/app/models/charge.rbs
+++ b/sig/app/models/charge.rbs
@@ -10,6 +10,8 @@ class Charge < ApplicationRecord
def superseded?: () -> bool
def lifecycle_status: () -> Symbol
def accounting_user: () -> User?
+ def deposit_applied_cents: () -> Integer
+ def remaining_deposit_application_cents: () -> Integer
private
diff --git a/sig/app/models/security_deposit.rbs b/sig/app/models/security_deposit.rbs
new file mode 100644
index 00000000..6e38c7cb
--- /dev/null
+++ b/sig/app/models/security_deposit.rbs
@@ -0,0 +1,17 @@
+class SecurityDeposit < ApplicationRecord
+ def required_amount: () -> Float
+ def required_amount=: (untyped) -> void
+ def accounting_user: () -> User?
+ def property: () -> Property?
+ def rentable_unit: () -> RentableUnit?
+ def held_cents: (?as_of: Date) -> Integer
+ def held_amount: (?as_of: Date) -> Float
+ def remaining_required_cents: (?as_of: Date) -> Integer
+ def remaining_required_amount: (?as_of: Date) -> Float
+ def fully_funded?: (?as_of: Date) -> bool
+ def overfunded?: (?as_of: Date) -> bool
+ def funding_status: (?as_of: Date) -> String
+
+ private
+ def validate_requirement_immutability: () -> void
+end
diff --git a/sig/app/models/security_deposit_transaction.rbs b/sig/app/models/security_deposit_transaction.rbs
new file mode 100644
index 00000000..ea6488ae
--- /dev/null
+++ b/sig/app/models/security_deposit_transaction.rbs
@@ -0,0 +1,22 @@
+class SecurityDepositTransaction < ApplicationRecord
+ KINDS: Array[String]
+
+ def amount: () -> Float
+ def amount=: (untyped) -> void
+ def posted?: () -> bool
+ def voided?: () -> bool
+ def superseded?: () -> bool
+ def active?: () -> bool
+ def tenancy: () -> Tenancy?
+ def property: () -> Property?
+ def rentable_unit: () -> RentableUnit?
+ def accounting_user: () -> User?
+
+ private
+ def validate_occurred_on_not_in_future: () -> void
+ def validate_kind_requirements: () -> void
+ def validate_ownership_and_tenancy: () -> void
+ def prevent_direct_lifecycle_assignment: () -> void
+ def validate_immutability_when_posted: () -> void
+ def prevent_destroy_if_posted: () -> void
+end
diff --git a/sig/app/queries/accounting/security_deposit_balance_query.rbs b/sig/app/queries/accounting/security_deposit_balance_query.rbs
new file mode 100644
index 00000000..d1ce6fa2
--- /dev/null
+++ b/sig/app/queries/accounting/security_deposit_balance_query.rbs
@@ -0,0 +1,13 @@
+module Accounting
+ class SecurityDepositBalanceQuery
+ def self.call: (?tenancy: Tenancy?, ?property: Property?, ?user: User?, ?as_of: Date?) -> Integer
+ def initialize: (?tenancy: Tenancy?, ?property: Property?, ?user: User?) -> void
+ def balance_cents_as_of: (?Date as_of) -> Integer
+ def balance_as_of: (?Date as_of) -> BigDecimal
+
+ private
+ attr_reader tenancy: Tenancy?
+ attr_reader property: Property?
+ attr_reader user: User?
+ end
+end
diff --git a/sig/app/services/security_deposit_transactions/apply_service.rbs b/sig/app/services/security_deposit_transactions/apply_service.rbs
new file mode 100644
index 00000000..7abdfbe5
--- /dev/null
+++ b/sig/app/services/security_deposit_transactions/apply_service.rbs
@@ -0,0 +1,39 @@
+module SecurityDepositTransactions
+ class ApplyService
+ def self.call: (
+ security_deposit: SecurityDeposit,
+ ?charge: Charge?,
+ ?charge_id: Integer?,
+ ?amount: untyped,
+ ?amount_cents: Integer?,
+ ?occurred_on: untyped,
+ ?memo: String?
+ ) -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure)
+
+ def initialize: (
+ security_deposit: SecurityDeposit,
+ ?charge: Charge?,
+ ?charge_id: Integer?,
+ ?amount: untyped,
+ ?amount_cents: Integer?,
+ ?occurred_on: untyped,
+ ?memo: String?
+ ) -> void
+
+ def call: () -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure)
+
+ private
+ attr_reader security_deposit: SecurityDeposit
+ attr_reader charge: Charge?
+ attr_reader charge_id: Integer?
+ attr_reader raw_amount: untyped
+ attr_reader raw_cents: Integer?
+ attr_reader raw_occurred_on: untyped
+ attr_reader memo: String?
+ def post_transaction: (SecurityDepositTransaction) -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure)
+ def resolve_charge: () -> Charge?
+ def resolve_cents: () -> Integer?
+ def resolve_occurred_on: () -> Date?
+ def format_money: (untyped cents) -> String
+ end
+end
diff --git a/sig/app/services/security_deposit_transactions/correct_service.rbs b/sig/app/services/security_deposit_transactions/correct_service.rbs
new file mode 100644
index 00000000..65280652
--- /dev/null
+++ b/sig/app/services/security_deposit_transactions/correct_service.rbs
@@ -0,0 +1,47 @@
+module SecurityDepositTransactions
+ class CorrectService
+ def self.call: (
+ transaction: SecurityDepositTransaction,
+ ?amount: untyped,
+ ?amount_cents: (Integer | :not_set)?,
+ ?occurred_on: untyped,
+ ?party: untyped,
+ ?party_id: untyped,
+ ?charge: untyped,
+ ?charge_id: untyped,
+ ?external_reference: untyped,
+ ?memo: untyped
+ ) -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure)
+
+ def initialize: (
+ transaction: SecurityDepositTransaction,
+ ?amount: untyped,
+ ?amount_cents: (Integer | :not_set)?,
+ ?occurred_on: untyped,
+ ?party: untyped,
+ ?party_id: untyped,
+ ?charge: untyped,
+ ?charge_id: untyped,
+ ?external_reference: untyped,
+ ?memo: untyped
+ ) -> void
+
+ def call: () -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure)
+
+ private
+ attr_reader transaction: SecurityDepositTransaction
+ attr_reader raw_amount: untyped
+ attr_reader raw_cents: (Integer | :not_set)?
+ attr_reader raw_occurred_on: untyped
+ attr_reader party: untyped
+ attr_reader party_id: untyped
+ attr_reader charge: untyped
+ attr_reader charge_id: untyped
+ attr_reader external_reference: untyped
+ attr_reader memo: untyped
+ def post_replacement: (SecurityDepositTransaction) -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure)
+ def resolve_cents: () -> Integer?
+ def resolve_occurred_on: () -> Date?
+ def format_money: (untyped cents) -> String
+ end
+end
diff --git a/sig/app/services/security_deposit_transactions/receive_service.rbs b/sig/app/services/security_deposit_transactions/receive_service.rbs
new file mode 100644
index 00000000..640a4bdb
--- /dev/null
+++ b/sig/app/services/security_deposit_transactions/receive_service.rbs
@@ -0,0 +1,41 @@
+module SecurityDepositTransactions
+ class ReceiveService
+ def self.call: (
+ security_deposit: SecurityDeposit,
+ ?party: Party?,
+ ?party_id: Integer?,
+ ?amount: untyped,
+ ?amount_cents: Integer?,
+ ?occurred_on: untyped,
+ ?external_reference: String?,
+ ?memo: String?
+ ) -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure)
+
+ def initialize: (
+ security_deposit: SecurityDeposit,
+ ?party: Party?,
+ ?party_id: Integer?,
+ ?amount: untyped,
+ ?amount_cents: Integer?,
+ ?occurred_on: untyped,
+ ?external_reference: String?,
+ ?memo: String?
+ ) -> void
+
+ def call: () -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure)
+
+ private
+ attr_reader security_deposit: SecurityDeposit
+ attr_reader party: Party?
+ attr_reader party_id: Integer?
+ attr_reader raw_amount: untyped
+ attr_reader raw_cents: Integer?
+ attr_reader raw_occurred_on: untyped
+ attr_reader external_reference: String?
+ attr_reader memo: String?
+ def post_transaction: (SecurityDepositTransaction) -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure)
+ def resolve_party: () -> Party?
+ def resolve_cents: () -> Integer?
+ def resolve_occurred_on: () -> Date?
+ end
+end
diff --git a/sig/app/services/security_deposit_transactions/refund_service.rbs b/sig/app/services/security_deposit_transactions/refund_service.rbs
new file mode 100644
index 00000000..6568bc26
--- /dev/null
+++ b/sig/app/services/security_deposit_transactions/refund_service.rbs
@@ -0,0 +1,41 @@
+module SecurityDepositTransactions
+ class RefundService
+ def self.call: (
+ security_deposit: SecurityDeposit,
+ ?party: Party?,
+ ?party_id: Integer?,
+ ?amount: untyped,
+ ?amount_cents: Integer?,
+ ?occurred_on: untyped,
+ ?external_reference: String?,
+ ?memo: String?
+ ) -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure)
+
+ def initialize: (
+ security_deposit: SecurityDeposit,
+ ?party: Party?,
+ ?party_id: Integer?,
+ ?amount: untyped,
+ ?amount_cents: Integer?,
+ ?occurred_on: untyped,
+ ?external_reference: String?,
+ ?memo: String?
+ ) -> void
+
+ def call: () -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure)
+
+ private
+ attr_reader security_deposit: SecurityDeposit
+ attr_reader party: Party?
+ attr_reader party_id: Integer?
+ attr_reader raw_amount: untyped
+ attr_reader raw_cents: Integer?
+ attr_reader raw_occurred_on: untyped
+ attr_reader external_reference: String?
+ attr_reader memo: String?
+ def post_transaction: (SecurityDepositTransaction) -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure)
+ def resolve_party: () -> Party?
+ def resolve_cents: () -> Integer?
+ def resolve_occurred_on: () -> Date?
+ end
+end
diff --git a/sig/app/services/security_deposit_transactions/void_service.rbs b/sig/app/services/security_deposit_transactions/void_service.rbs
new file mode 100644
index 00000000..d89bc953
--- /dev/null
+++ b/sig/app/services/security_deposit_transactions/void_service.rbs
@@ -0,0 +1,11 @@
+module SecurityDepositTransactions
+ class VoidService
+ def self.call: (transaction: SecurityDepositTransaction, ?reason: String?) -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure)
+ def initialize: (transaction: SecurityDepositTransaction, ?reason: String?) -> void
+ def call: () -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure)
+
+ private
+ attr_reader transaction: SecurityDepositTransaction
+ attr_reader reason: String?
+ end
+end
diff --git a/sig/app/services/security_deposits/create_service.rbs b/sig/app/services/security_deposits/create_service.rbs
new file mode 100644
index 00000000..fd369a09
--- /dev/null
+++ b/sig/app/services/security_deposits/create_service.rbs
@@ -0,0 +1,15 @@
+module SecurityDeposits
+ class CreateService
+ def self.call: (tenancy: Tenancy, ?required_amount: untyped, ?required_amount_cents: Integer?, ?due_on: untyped) -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure)
+ def initialize: (tenancy: Tenancy, ?required_amount: untyped, ?required_amount_cents: Integer?, ?due_on: untyped) -> void
+ def call: () -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure)
+
+ private
+ attr_reader tenancy: Tenancy
+ attr_reader raw_amount: untyped
+ attr_reader raw_cents: Integer?
+ attr_reader raw_due_on: untyped
+ def resolve_cents: () -> Integer?
+ def resolve_due_on: () -> Date?
+ end
+end
diff --git a/sig/app/services/security_deposits/liability_timeline.rbs b/sig/app/services/security_deposits/liability_timeline.rbs
new file mode 100644
index 00000000..0b721d28
--- /dev/null
+++ b/sig/app/services/security_deposits/liability_timeline.rbs
@@ -0,0 +1,13 @@
+module SecurityDeposits
+ class LiabilityTimeline
+ def self.validate: (security_deposit: SecurityDeposit, ?additions: Array[Hash[Symbol, untyped]], ?removing_ids: Array[Integer] | Integer) -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure)
+ def initialize: (security_deposit: SecurityDeposit, ?additions: Array[Hash[Symbol, untyped]], ?removing_ids: Array[Integer] | Integer) -> void
+ def validate: () -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure)
+
+ private
+ attr_reader security_deposit: SecurityDeposit
+ attr_reader additions: Array[Hash[Symbol, untyped]]
+ attr_reader removing_ids: Array[Integer]
+ def format_money: (untyped cents) -> String
+ end
+end
diff --git a/sig/app/services/security_deposits/update_service.rbs b/sig/app/services/security_deposits/update_service.rbs
new file mode 100644
index 00000000..94b2eb9c
--- /dev/null
+++ b/sig/app/services/security_deposits/update_service.rbs
@@ -0,0 +1,15 @@
+module SecurityDeposits
+ class UpdateService
+ def self.call: (security_deposit: SecurityDeposit, ?required_amount: untyped, ?required_amount_cents: Integer?, ?due_on: untyped) -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure)
+ def initialize: (security_deposit: SecurityDeposit, ?required_amount: untyped, ?required_amount_cents: Integer?, ?due_on: untyped) -> void
+ def call: () -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure)
+
+ private
+ attr_reader security_deposit: SecurityDeposit
+ attr_reader raw_amount: untyped
+ attr_reader raw_cents: Integer?
+ attr_reader raw_due_on: untyped
+ def resolve_cents: () -> Integer?
+ def resolve_due_on: () -> Date?
+ end
+end
diff --git a/sig/rbs_rails/app/models/charge.rbs b/sig/rbs_rails/app/models/charge.rbs
index 192d2156..611f6c03 100644
--- a/sig/rbs_rails/app/models/charge.rbs
+++ b/sig/rbs_rails/app/models/charge.rbs
@@ -693,6 +693,10 @@ class ::Charge < ::ApplicationRecord
def journal_entries=: (::JournalEntry::ActiveRecord_Associations_CollectionProxy | ::Array[::JournalEntry]) -> (::JournalEntry::ActiveRecord_Associations_CollectionProxy | ::Array[::JournalEntry])
def journal_entry_ids: () -> ::Array[::Integer]
def journal_entry_ids=: (::Array[::Integer]) -> ::Array[::Integer]
+ def security_deposit_applications: () -> ::SecurityDepositTransaction::ActiveRecord_Associations_CollectionProxy
+ def security_deposit_applications=: (::SecurityDepositTransaction::ActiveRecord_Associations_CollectionProxy | ::Array[::SecurityDepositTransaction]) -> (::SecurityDepositTransaction::ActiveRecord_Associations_CollectionProxy | ::Array[::SecurityDepositTransaction])
+ def security_deposit_application_ids: () -> ::Array[::Integer]
+ def security_deposit_application_ids=: (::Array[::Integer]) -> ::Array[::Integer]
def superseded_charge: () -> ::Charge?
def superseded_charge=: (::Charge?) -> ::Charge?
@@ -809,6 +813,10 @@ class ::JournalEntry < ::ApplicationRecord
end
class ::JournalEntry::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy
end
+class ::SecurityDepositTransaction < ::ApplicationRecord
+end
+class ::SecurityDepositTransaction::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy
+end
class ::Charge < ::ApplicationRecord
end
class ::Tenancy < ::ApplicationRecord
diff --git a/sig/rbs_rails/app/models/party.rbs b/sig/rbs_rails/app/models/party.rbs
index dbf2500c..722c2438 100644
--- a/sig/rbs_rails/app/models/party.rbs
+++ b/sig/rbs_rails/app/models/party.rbs
@@ -429,6 +429,10 @@ class ::Party < ::ApplicationRecord
def receipts_as_payer=: (::Receipt::ActiveRecord_Associations_CollectionProxy | ::Array[::Receipt]) -> (::Receipt::ActiveRecord_Associations_CollectionProxy | ::Array[::Receipt])
def receipts_as_payer_ids: () -> ::Array[::Integer]
def receipts_as_payer_ids=: (::Array[::Integer]) -> ::Array[::Integer]
+ def security_deposit_transactions: () -> ::SecurityDepositTransaction::ActiveRecord_Associations_CollectionProxy
+ def security_deposit_transactions=: (::SecurityDepositTransaction::ActiveRecord_Associations_CollectionProxy | ::Array[::SecurityDepositTransaction]) -> (::SecurityDepositTransaction::ActiveRecord_Associations_CollectionProxy | ::Array[::SecurityDepositTransaction])
+ def security_deposit_transaction_ids: () -> ::Array[::Integer]
+ def security_deposit_transaction_ids=: (::Array[::Integer]) -> ::Array[::Integer]
def payment_ingestions: () -> ::PaymentIngestion::ActiveRecord_Associations_CollectionProxy
def payment_ingestions=: (::PaymentIngestion::ActiveRecord_Associations_CollectionProxy | ::Array[::PaymentIngestion]) -> (::PaymentIngestion::ActiveRecord_Associations_CollectionProxy | ::Array[::PaymentIngestion])
def payment_ingestion_ids: () -> ::Array[::Integer]
@@ -517,6 +521,10 @@ class ::Receipt < ::ApplicationRecord
end
class ::Receipt::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy
end
+class ::SecurityDepositTransaction < ::ApplicationRecord
+end
+class ::SecurityDepositTransaction::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy
+end
class ::PaymentIngestion < ::ApplicationRecord
end
class ::PaymentIngestion::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy
diff --git a/sig/rbs_rails/app/models/property.rbs b/sig/rbs_rails/app/models/property.rbs
index 4a12c642..a893a977 100644
--- a/sig/rbs_rails/app/models/property.rbs
+++ b/sig/rbs_rails/app/models/property.rbs
@@ -349,6 +349,10 @@ class ::Property < ::ApplicationRecord
def receipts=: (::Receipt::ActiveRecord_Associations_CollectionProxy | ::Array[::Receipt]) -> (::Receipt::ActiveRecord_Associations_CollectionProxy | ::Array[::Receipt])
def receipt_ids: () -> ::Array[::Integer]
def receipt_ids=: (::Array[::Integer]) -> ::Array[::Integer]
+ def security_deposit_transactions: () -> ::SecurityDepositTransaction::ActiveRecord_Associations_CollectionProxy
+ def security_deposit_transactions=: (::SecurityDepositTransaction::ActiveRecord_Associations_CollectionProxy | ::Array[::SecurityDepositTransaction]) -> (::SecurityDepositTransaction::ActiveRecord_Associations_CollectionProxy | ::Array[::SecurityDepositTransaction])
+ def security_deposit_transaction_ids: () -> ::Array[::Integer]
+ def security_deposit_transaction_ids=: (::Array[::Integer]) -> ::Array[::Integer]
def accounting_postings: () -> ::Posting::ActiveRecord_Associations_CollectionProxy
def accounting_postings=: (::Posting::ActiveRecord_Associations_CollectionProxy | ::Array[::Posting]) -> (::Posting::ActiveRecord_Associations_CollectionProxy | ::Array[::Posting])
def accounting_posting_ids: () -> ::Array[::Integer]
@@ -468,6 +472,10 @@ class ::Receipt < ::ApplicationRecord
end
class ::Receipt::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy
end
+class ::SecurityDepositTransaction < ::ApplicationRecord
+end
+class ::SecurityDepositTransaction::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy
+end
class ::Posting < ::ApplicationRecord
end
class ::Posting::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy
diff --git a/sig/rbs_rails/app/models/security_deposit.rbs b/sig/rbs_rails/app/models/security_deposit.rbs
new file mode 100644
index 00000000..17d21227
--- /dev/null
+++ b/sig/rbs_rails/app/models/security_deposit.rbs
@@ -0,0 +1,361 @@
+# resolve-type-names: false
+
+class ::SecurityDeposit < ::ApplicationRecord
+ extend ::ActiveRecord::Base::ClassMethods[::SecurityDeposit, ::SecurityDeposit::ActiveRecord_Relation, ::Integer]
+
+ module ::SecurityDeposit::GeneratedAttributeMethods
+ def id: () -> ::Integer
+
+ def id=: (::Integer) -> ::Integer
+
+ def id?: () -> bool
+
+ def id_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def id_change: () -> [ ::Integer?, ::Integer? ]
+
+ def id_will_change!: () -> void
+
+ def id_was: () -> ::Integer?
+
+ def id_previously_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def id_previous_change: () -> ::Array[::Integer?]?
+
+ def id_previously_was: () -> ::Integer?
+
+ def id_before_last_save: () -> ::Integer?
+
+ def id_change_to_be_saved: () -> ::Array[::Integer?]?
+
+ def id_in_database: () -> ::Integer?
+
+ def saved_change_to_id: () -> ::Array[::Integer?]?
+
+ def saved_change_to_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def will_save_change_to_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def restore_id!: () -> void
+
+ def clear_id_change: () -> void
+
+ def id_before_type_cast: () -> ::Integer
+
+ def id_for_database: () -> ::Integer
+
+ def tenancy_id: () -> ::Integer
+
+ def tenancy_id=: (::Integer) -> ::Integer
+
+ def tenancy_id?: () -> bool
+
+ def tenancy_id_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def tenancy_id_change: () -> [ ::Integer?, ::Integer? ]
+
+ def tenancy_id_will_change!: () -> void
+
+ def tenancy_id_was: () -> ::Integer?
+
+ def tenancy_id_previously_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def tenancy_id_previous_change: () -> ::Array[::Integer?]?
+
+ def tenancy_id_previously_was: () -> ::Integer?
+
+ def tenancy_id_before_last_save: () -> ::Integer?
+
+ def tenancy_id_change_to_be_saved: () -> ::Array[::Integer?]?
+
+ def tenancy_id_in_database: () -> ::Integer?
+
+ def saved_change_to_tenancy_id: () -> ::Array[::Integer?]?
+
+ def saved_change_to_tenancy_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def will_save_change_to_tenancy_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def restore_tenancy_id!: () -> void
+
+ def clear_tenancy_id_change: () -> void
+
+ def tenancy_id_before_type_cast: () -> ::Integer
+
+ def tenancy_id_for_database: () -> ::Integer
+
+ def required_amount_cents: () -> ::Integer
+
+ def required_amount_cents=: (::Integer) -> ::Integer
+
+ def required_amount_cents?: () -> bool
+
+ def required_amount_cents_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def required_amount_cents_change: () -> [ ::Integer?, ::Integer? ]
+
+ def required_amount_cents_will_change!: () -> void
+
+ def required_amount_cents_was: () -> ::Integer?
+
+ def required_amount_cents_previously_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def required_amount_cents_previous_change: () -> ::Array[::Integer?]?
+
+ def required_amount_cents_previously_was: () -> ::Integer?
+
+ def required_amount_cents_before_last_save: () -> ::Integer?
+
+ def required_amount_cents_change_to_be_saved: () -> ::Array[::Integer?]?
+
+ def required_amount_cents_in_database: () -> ::Integer?
+
+ def saved_change_to_required_amount_cents: () -> ::Array[::Integer?]?
+
+ def saved_change_to_required_amount_cents?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def will_save_change_to_required_amount_cents?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def restore_required_amount_cents!: () -> void
+
+ def clear_required_amount_cents_change: () -> void
+
+ def required_amount_cents_before_type_cast: () -> ::Integer
+
+ def required_amount_cents_for_database: () -> ::Integer
+
+ def due_on: () -> ::Date
+
+ def due_on=: (::Date) -> ::Date
+
+ def due_on?: () -> bool
+
+ def due_on_changed?: (?from: ::Date?, ?to: ::Date?) -> bool
+
+ def due_on_change: () -> [ ::Date?, ::Date? ]
+
+ def due_on_will_change!: () -> void
+
+ def due_on_was: () -> ::Date?
+
+ def due_on_previously_changed?: (?from: ::Date?, ?to: ::Date?) -> bool
+
+ def due_on_previous_change: () -> ::Array[::Date?]?
+
+ def due_on_previously_was: () -> ::Date?
+
+ def due_on_before_last_save: () -> ::Date?
+
+ def due_on_change_to_be_saved: () -> ::Array[::Date?]?
+
+ def due_on_in_database: () -> ::Date?
+
+ def saved_change_to_due_on: () -> ::Array[::Date?]?
+
+ def saved_change_to_due_on?: (?from: ::Date?, ?to: ::Date?) -> bool
+
+ def will_save_change_to_due_on?: (?from: ::Date?, ?to: ::Date?) -> bool
+
+ def restore_due_on!: () -> void
+
+ def clear_due_on_change: () -> void
+
+ def due_on_before_type_cast: () -> ::Date
+
+ def due_on_for_database: () -> ::Date
+
+ def created_at: () -> ::ActiveSupport::TimeWithZone
+
+ def created_at=: (::ActiveSupport::TimeWithZone) -> ::ActiveSupport::TimeWithZone
+
+ def created_at?: () -> bool
+
+ def created_at_changed?: (?from: ::ActiveSupport::TimeWithZone?, ?to: ::ActiveSupport::TimeWithZone?) -> bool
+
+ def created_at_change: () -> [ ::ActiveSupport::TimeWithZone?, ::ActiveSupport::TimeWithZone? ]
+
+ def created_at_will_change!: () -> void
+
+ def created_at_was: () -> ::ActiveSupport::TimeWithZone?
+
+ def created_at_previously_changed?: (?from: ::ActiveSupport::TimeWithZone?, ?to: ::ActiveSupport::TimeWithZone?) -> bool
+
+ def created_at_previous_change: () -> ::Array[::ActiveSupport::TimeWithZone?]?
+
+ def created_at_previously_was: () -> ::ActiveSupport::TimeWithZone?
+
+ def created_at_before_last_save: () -> ::ActiveSupport::TimeWithZone?
+
+ def created_at_change_to_be_saved: () -> ::Array[::ActiveSupport::TimeWithZone?]?
+
+ def created_at_in_database: () -> ::ActiveSupport::TimeWithZone?
+
+ def saved_change_to_created_at: () -> ::Array[::ActiveSupport::TimeWithZone?]?
+
+ def saved_change_to_created_at?: (?from: ::ActiveSupport::TimeWithZone?, ?to: ::ActiveSupport::TimeWithZone?) -> bool
+
+ def will_save_change_to_created_at?: (?from: ::ActiveSupport::TimeWithZone?, ?to: ::ActiveSupport::TimeWithZone?) -> bool
+
+ def restore_created_at!: () -> void
+
+ def clear_created_at_change: () -> void
+
+ def created_at_before_type_cast: () -> ::Time
+
+ def created_at_for_database: () -> ::Time
+
+ def updated_at: () -> ::ActiveSupport::TimeWithZone
+
+ def updated_at=: (::ActiveSupport::TimeWithZone) -> ::ActiveSupport::TimeWithZone
+
+ def updated_at?: () -> bool
+
+ def updated_at_changed?: (?from: ::ActiveSupport::TimeWithZone?, ?to: ::ActiveSupport::TimeWithZone?) -> bool
+
+ def updated_at_change: () -> [ ::ActiveSupport::TimeWithZone?, ::ActiveSupport::TimeWithZone? ]
+
+ def updated_at_will_change!: () -> void
+
+ def updated_at_was: () -> ::ActiveSupport::TimeWithZone?
+
+ def updated_at_previously_changed?: (?from: ::ActiveSupport::TimeWithZone?, ?to: ::ActiveSupport::TimeWithZone?) -> bool
+
+ def updated_at_previous_change: () -> ::Array[::ActiveSupport::TimeWithZone?]?
+
+ def updated_at_previously_was: () -> ::ActiveSupport::TimeWithZone?
+
+ def updated_at_before_last_save: () -> ::ActiveSupport::TimeWithZone?
+
+ def updated_at_change_to_be_saved: () -> ::Array[::ActiveSupport::TimeWithZone?]?
+
+ def updated_at_in_database: () -> ::ActiveSupport::TimeWithZone?
+
+ def saved_change_to_updated_at: () -> ::Array[::ActiveSupport::TimeWithZone?]?
+
+ def saved_change_to_updated_at?: (?from: ::ActiveSupport::TimeWithZone?, ?to: ::ActiveSupport::TimeWithZone?) -> bool
+
+ def will_save_change_to_updated_at?: (?from: ::ActiveSupport::TimeWithZone?, ?to: ::ActiveSupport::TimeWithZone?) -> bool
+
+ def restore_updated_at!: () -> void
+
+ def clear_updated_at_change: () -> void
+
+ def updated_at_before_type_cast: () -> ::Time
+
+ def updated_at_for_database: () -> ::Time
+ end
+ include ::SecurityDeposit::GeneratedAttributeMethods
+ module ::SecurityDeposit::GeneratedAliasAttributeMethods
+ include ::SecurityDeposit::GeneratedAttributeMethods
+
+ alias id_value id
+
+ alias id_value= id=
+
+ alias id_value? id?
+
+ alias id_value_changed? id_changed?
+
+ alias id_value_change id_change
+
+ alias id_value_will_change! id_will_change!
+
+ alias id_value_was id_was
+
+ alias id_value_previously_changed? id_previously_changed?
+
+ alias id_value_previous_change id_previous_change
+
+ alias id_value_previously_was id_previously_was
+
+ alias id_value_before_last_save id_before_last_save
+
+ alias id_value_change_to_be_saved id_change_to_be_saved
+
+ alias id_value_in_database id_in_database
+
+ alias saved_change_to_id_value saved_change_to_id
+
+ alias saved_change_to_id_value? saved_change_to_id?
+
+ alias will_save_change_to_id_value? will_save_change_to_id?
+
+ alias restore_id_value! restore_id!
+
+ alias clear_id_value_change clear_id_change
+
+ alias id_value_before_type_cast id_before_type_cast
+
+ alias id_value_for_database id_for_database
+ end
+ include ::SecurityDeposit::GeneratedAliasAttributeMethods
+ def transactions: () -> ::SecurityDepositTransaction::ActiveRecord_Associations_CollectionProxy
+ def transactions=: (::SecurityDepositTransaction::ActiveRecord_Associations_CollectionProxy | ::Array[::SecurityDepositTransaction]) -> (::SecurityDepositTransaction::ActiveRecord_Associations_CollectionProxy | ::Array[::SecurityDepositTransaction])
+ def transaction_ids: () -> ::Array[::Integer]
+ def transaction_ids=: (::Array[::Integer]) -> ::Array[::Integer]
+ def journal_entries: () -> ::JournalEntry::ActiveRecord_Associations_CollectionProxy
+ def journal_entries=: (::JournalEntry::ActiveRecord_Associations_CollectionProxy | ::Array[::JournalEntry]) -> (::JournalEntry::ActiveRecord_Associations_CollectionProxy | ::Array[::JournalEntry])
+ def journal_entry_ids: () -> ::Array[::Integer]
+ def journal_entry_ids=: (::Array[::Integer]) -> ::Array[::Integer]
+ def postings: () -> ::Posting::ActiveRecord_Associations_CollectionProxy
+ def postings=: (::Posting::ActiveRecord_Associations_CollectionProxy | ::Array[::Posting]) -> (::Posting::ActiveRecord_Associations_CollectionProxy | ::Array[::Posting])
+ def posting_ids: () -> ::Array[::Integer]
+ def posting_ids=: (::Array[::Integer]) -> ::Array[::Integer]
+
+ def tenancy: () -> ::Tenancy
+ def tenancy=: (::Tenancy?) -> ::Tenancy?
+ def reload_tenancy: () -> ::Tenancy?
+ def build_tenancy: (?untyped) -> ::Tenancy
+ def create_tenancy: (?untyped) -> ::Tenancy
+ def create_tenancy!: (?untyped) -> ::Tenancy
+
+ module ::SecurityDeposit::GeneratedAssociationMethods
+ end
+ include ::SecurityDeposit::GeneratedAssociationMethods
+
+ module ::SecurityDeposit::GeneratedRelationMethods
+ end
+
+ class ::SecurityDeposit::ActiveRecord_Relation < ::ActiveRecord::Relation
+ include ::Enumerable[::SecurityDeposit]
+ include ::SecurityDeposit::GeneratedRelationMethods
+ include ::ActiveRecord::Relation::Methods[::SecurityDeposit, ::Integer]
+ end
+
+ class ::SecurityDeposit::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy
+ include ::Enumerable[::SecurityDeposit]
+ include ::SecurityDeposit::GeneratedRelationMethods
+ include ::ActiveRecord::Relation::Methods[::SecurityDeposit, ::Integer]
+
+ def build: (?::ActiveRecord::Associations::CollectionProxy::_EachPair attributes) ?{ () -> untyped } -> ::SecurityDeposit
+ | (::Array[::ActiveRecord::Associations::CollectionProxy::_EachPair] attributes) ?{ () -> untyped } -> ::Array[::SecurityDeposit]
+ def create: (?::ActiveRecord::Associations::CollectionProxy::_EachPair attributes) ?{ () -> untyped } -> ::SecurityDeposit
+ | (::Array[::ActiveRecord::Associations::CollectionProxy::_EachPair] attributes) ?{ () -> untyped } -> ::Array[::SecurityDeposit]
+ def create!: (?::ActiveRecord::Associations::CollectionProxy::_EachPair attributes) ?{ () -> untyped } -> ::SecurityDeposit
+ | (::Array[::ActiveRecord::Associations::CollectionProxy::_EachPair] attributes) ?{ () -> untyped } -> ::Array[::SecurityDeposit]
+ def reload: () -> ::Array[::SecurityDeposit]
+
+ def replace: (::Array[::SecurityDeposit]) -> void
+ def delete: (*::SecurityDeposit | ::Integer) -> ::Array[::SecurityDeposit]
+ def destroy: (*::SecurityDeposit | ::Integer) -> ::Array[::SecurityDeposit]
+ def <<: (*::SecurityDeposit | ::Array[::SecurityDeposit]) -> self
+ def prepend: (*::SecurityDeposit | ::Array[::SecurityDeposit]) -> self
+ end
+end
+
+class ::ApplicationRecord < ::ActiveRecord::Base
+end
+class ::SecurityDepositTransaction < ::ApplicationRecord
+end
+class ::SecurityDepositTransaction::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy
+end
+class ::JournalEntry < ::ApplicationRecord
+end
+class ::JournalEntry::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy
+end
+class ::Posting < ::ApplicationRecord
+end
+class ::Posting::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy
+end
+class ::Tenancy < ::ApplicationRecord
+end
diff --git a/sig/rbs_rails/app/models/security_deposit_transaction.rbs b/sig/rbs_rails/app/models/security_deposit_transaction.rbs
new file mode 100644
index 00000000..0e755809
--- /dev/null
+++ b/sig/rbs_rails/app/models/security_deposit_transaction.rbs
@@ -0,0 +1,742 @@
+# resolve-type-names: false
+
+class ::SecurityDepositTransaction < ::ApplicationRecord
+ extend ::ActiveRecord::Base::ClassMethods[::SecurityDepositTransaction, ::SecurityDepositTransaction::ActiveRecord_Relation, ::Integer]
+
+ module ::SecurityDepositTransaction::GeneratedAttributeMethods
+ def id: () -> ::Integer
+
+ def id=: (::Integer) -> ::Integer
+
+ def id?: () -> bool
+
+ def id_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def id_change: () -> [ ::Integer?, ::Integer? ]
+
+ def id_will_change!: () -> void
+
+ def id_was: () -> ::Integer?
+
+ def id_previously_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def id_previous_change: () -> ::Array[::Integer?]?
+
+ def id_previously_was: () -> ::Integer?
+
+ def id_before_last_save: () -> ::Integer?
+
+ def id_change_to_be_saved: () -> ::Array[::Integer?]?
+
+ def id_in_database: () -> ::Integer?
+
+ def saved_change_to_id: () -> ::Array[::Integer?]?
+
+ def saved_change_to_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def will_save_change_to_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def restore_id!: () -> void
+
+ def clear_id_change: () -> void
+
+ def id_before_type_cast: () -> ::Integer
+
+ def id_for_database: () -> ::Integer
+
+ def security_deposit_id: () -> ::Integer
+
+ def security_deposit_id=: (::Integer) -> ::Integer
+
+ def security_deposit_id?: () -> bool
+
+ def security_deposit_id_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def security_deposit_id_change: () -> [ ::Integer?, ::Integer? ]
+
+ def security_deposit_id_will_change!: () -> void
+
+ def security_deposit_id_was: () -> ::Integer?
+
+ def security_deposit_id_previously_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def security_deposit_id_previous_change: () -> ::Array[::Integer?]?
+
+ def security_deposit_id_previously_was: () -> ::Integer?
+
+ def security_deposit_id_before_last_save: () -> ::Integer?
+
+ def security_deposit_id_change_to_be_saved: () -> ::Array[::Integer?]?
+
+ def security_deposit_id_in_database: () -> ::Integer?
+
+ def saved_change_to_security_deposit_id: () -> ::Array[::Integer?]?
+
+ def saved_change_to_security_deposit_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def will_save_change_to_security_deposit_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def restore_security_deposit_id!: () -> void
+
+ def clear_security_deposit_id_change: () -> void
+
+ def security_deposit_id_before_type_cast: () -> ::Integer
+
+ def security_deposit_id_for_database: () -> ::Integer
+
+ def transaction_kind: () -> ::String
+
+ def transaction_kind=: (::String | ::Symbol) -> ::String
+
+ def transaction_kind?: () -> bool
+
+ def transaction_kind_changed?: (?from: ::String?, ?to: ::String?) -> bool
+
+ def transaction_kind_change: () -> [ ::String?, ::String? ]
+
+ def transaction_kind_will_change!: () -> void
+
+ def transaction_kind_was: () -> ::String?
+
+ def transaction_kind_previously_changed?: (?from: ::String?, ?to: ::String?) -> bool
+
+ def transaction_kind_previous_change: () -> ::Array[::String?]?
+
+ def transaction_kind_previously_was: () -> ::String?
+
+ def transaction_kind_before_last_save: () -> ::String?
+
+ def transaction_kind_change_to_be_saved: () -> ::Array[::String?]?
+
+ def transaction_kind_in_database: () -> ::String?
+
+ def saved_change_to_transaction_kind: () -> ::Array[::String?]?
+
+ def saved_change_to_transaction_kind?: (?from: ::String?, ?to: ::String?) -> bool
+
+ def will_save_change_to_transaction_kind?: (?from: ::String?, ?to: ::String?) -> bool
+
+ def restore_transaction_kind!: () -> void
+
+ def clear_transaction_kind_change: () -> void
+
+ def transaction_kind_before_type_cast: () -> ::String
+
+ def transaction_kind_for_database: () -> ::String
+
+ def amount_cents: () -> ::Integer
+
+ def amount_cents=: (::Integer) -> ::Integer
+
+ def amount_cents?: () -> bool
+
+ def amount_cents_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def amount_cents_change: () -> [ ::Integer?, ::Integer? ]
+
+ def amount_cents_will_change!: () -> void
+
+ def amount_cents_was: () -> ::Integer?
+
+ def amount_cents_previously_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def amount_cents_previous_change: () -> ::Array[::Integer?]?
+
+ def amount_cents_previously_was: () -> ::Integer?
+
+ def amount_cents_before_last_save: () -> ::Integer?
+
+ def amount_cents_change_to_be_saved: () -> ::Array[::Integer?]?
+
+ def amount_cents_in_database: () -> ::Integer?
+
+ def saved_change_to_amount_cents: () -> ::Array[::Integer?]?
+
+ def saved_change_to_amount_cents?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def will_save_change_to_amount_cents?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def restore_amount_cents!: () -> void
+
+ def clear_amount_cents_change: () -> void
+
+ def amount_cents_before_type_cast: () -> ::Integer
+
+ def amount_cents_for_database: () -> ::Integer
+
+ def occurred_on: () -> ::Date
+
+ def occurred_on=: (::Date) -> ::Date
+
+ def occurred_on?: () -> bool
+
+ def occurred_on_changed?: (?from: ::Date?, ?to: ::Date?) -> bool
+
+ def occurred_on_change: () -> [ ::Date?, ::Date? ]
+
+ def occurred_on_will_change!: () -> void
+
+ def occurred_on_was: () -> ::Date?
+
+ def occurred_on_previously_changed?: (?from: ::Date?, ?to: ::Date?) -> bool
+
+ def occurred_on_previous_change: () -> ::Array[::Date?]?
+
+ def occurred_on_previously_was: () -> ::Date?
+
+ def occurred_on_before_last_save: () -> ::Date?
+
+ def occurred_on_change_to_be_saved: () -> ::Array[::Date?]?
+
+ def occurred_on_in_database: () -> ::Date?
+
+ def saved_change_to_occurred_on: () -> ::Array[::Date?]?
+
+ def saved_change_to_occurred_on?: (?from: ::Date?, ?to: ::Date?) -> bool
+
+ def will_save_change_to_occurred_on?: (?from: ::Date?, ?to: ::Date?) -> bool
+
+ def restore_occurred_on!: () -> void
+
+ def clear_occurred_on_change: () -> void
+
+ def occurred_on_before_type_cast: () -> ::Date
+
+ def occurred_on_for_database: () -> ::Date
+
+ def party_id: () -> ::Integer?
+
+ def party_id=: (::Integer?) -> ::Integer?
+
+ def party_id?: () -> bool
+
+ def party_id_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def party_id_change: () -> [ ::Integer?, ::Integer? ]
+
+ def party_id_will_change!: () -> void
+
+ def party_id_was: () -> ::Integer?
+
+ def party_id_previously_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def party_id_previous_change: () -> ::Array[::Integer?]?
+
+ def party_id_previously_was: () -> ::Integer?
+
+ def party_id_before_last_save: () -> ::Integer?
+
+ def party_id_change_to_be_saved: () -> ::Array[::Integer?]?
+
+ def party_id_in_database: () -> ::Integer?
+
+ def saved_change_to_party_id: () -> ::Array[::Integer?]?
+
+ def saved_change_to_party_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def will_save_change_to_party_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def restore_party_id!: () -> void
+
+ def clear_party_id_change: () -> void
+
+ def party_id_before_type_cast: () -> ::Integer?
+
+ def party_id_for_database: () -> ::Integer?
+
+ def charge_id: () -> ::Integer?
+
+ def charge_id=: (::Integer?) -> ::Integer?
+
+ def charge_id?: () -> bool
+
+ def charge_id_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def charge_id_change: () -> [ ::Integer?, ::Integer? ]
+
+ def charge_id_will_change!: () -> void
+
+ def charge_id_was: () -> ::Integer?
+
+ def charge_id_previously_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def charge_id_previous_change: () -> ::Array[::Integer?]?
+
+ def charge_id_previously_was: () -> ::Integer?
+
+ def charge_id_before_last_save: () -> ::Integer?
+
+ def charge_id_change_to_be_saved: () -> ::Array[::Integer?]?
+
+ def charge_id_in_database: () -> ::Integer?
+
+ def saved_change_to_charge_id: () -> ::Array[::Integer?]?
+
+ def saved_change_to_charge_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def will_save_change_to_charge_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def restore_charge_id!: () -> void
+
+ def clear_charge_id_change: () -> void
+
+ def charge_id_before_type_cast: () -> ::Integer?
+
+ def charge_id_for_database: () -> ::Integer?
+
+ def external_reference: () -> ::String?
+
+ def external_reference=: (::String?) -> ::String?
+
+ def external_reference?: () -> bool
+
+ def external_reference_changed?: (?from: ::String?, ?to: ::String?) -> bool
+
+ def external_reference_change: () -> [ ::String?, ::String? ]
+
+ def external_reference_will_change!: () -> void
+
+ def external_reference_was: () -> ::String?
+
+ def external_reference_previously_changed?: (?from: ::String?, ?to: ::String?) -> bool
+
+ def external_reference_previous_change: () -> ::Array[::String?]?
+
+ def external_reference_previously_was: () -> ::String?
+
+ def external_reference_before_last_save: () -> ::String?
+
+ def external_reference_change_to_be_saved: () -> ::Array[::String?]?
+
+ def external_reference_in_database: () -> ::String?
+
+ def saved_change_to_external_reference: () -> ::Array[::String?]?
+
+ def saved_change_to_external_reference?: (?from: ::String?, ?to: ::String?) -> bool
+
+ def will_save_change_to_external_reference?: (?from: ::String?, ?to: ::String?) -> bool
+
+ def restore_external_reference!: () -> void
+
+ def clear_external_reference_change: () -> void
+
+ def external_reference_before_type_cast: () -> ::String?
+
+ def external_reference_for_database: () -> ::String?
+
+ def memo: () -> ::String?
+
+ def memo=: (::String?) -> ::String?
+
+ def memo?: () -> bool
+
+ def memo_changed?: (?from: ::String?, ?to: ::String?) -> bool
+
+ def memo_change: () -> [ ::String?, ::String? ]
+
+ def memo_will_change!: () -> void
+
+ def memo_was: () -> ::String?
+
+ def memo_previously_changed?: (?from: ::String?, ?to: ::String?) -> bool
+
+ def memo_previous_change: () -> ::Array[::String?]?
+
+ def memo_previously_was: () -> ::String?
+
+ def memo_before_last_save: () -> ::String?
+
+ def memo_change_to_be_saved: () -> ::Array[::String?]?
+
+ def memo_in_database: () -> ::String?
+
+ def saved_change_to_memo: () -> ::Array[::String?]?
+
+ def saved_change_to_memo?: (?from: ::String?, ?to: ::String?) -> bool
+
+ def will_save_change_to_memo?: (?from: ::String?, ?to: ::String?) -> bool
+
+ def restore_memo!: () -> void
+
+ def clear_memo_change: () -> void
+
+ def memo_before_type_cast: () -> ::String?
+
+ def memo_for_database: () -> ::String?
+
+ def posted_at: () -> ::ActiveSupport::TimeWithZone?
+
+ def posted_at=: (::ActiveSupport::TimeWithZone?) -> ::ActiveSupport::TimeWithZone?
+
+ def posted_at?: () -> bool
+
+ def posted_at_changed?: (?from: ::ActiveSupport::TimeWithZone?, ?to: ::ActiveSupport::TimeWithZone?) -> bool
+
+ def posted_at_change: () -> [ ::ActiveSupport::TimeWithZone?, ::ActiveSupport::TimeWithZone? ]
+
+ def posted_at_will_change!: () -> void
+
+ def posted_at_was: () -> ::ActiveSupport::TimeWithZone?
+
+ def posted_at_previously_changed?: (?from: ::ActiveSupport::TimeWithZone?, ?to: ::ActiveSupport::TimeWithZone?) -> bool
+
+ def posted_at_previous_change: () -> ::Array[::ActiveSupport::TimeWithZone?]?
+
+ def posted_at_previously_was: () -> ::ActiveSupport::TimeWithZone?
+
+ def posted_at_before_last_save: () -> ::ActiveSupport::TimeWithZone?
+
+ def posted_at_change_to_be_saved: () -> ::Array[::ActiveSupport::TimeWithZone?]?
+
+ def posted_at_in_database: () -> ::ActiveSupport::TimeWithZone?
+
+ def saved_change_to_posted_at: () -> ::Array[::ActiveSupport::TimeWithZone?]?
+
+ def saved_change_to_posted_at?: (?from: ::ActiveSupport::TimeWithZone?, ?to: ::ActiveSupport::TimeWithZone?) -> bool
+
+ def will_save_change_to_posted_at?: (?from: ::ActiveSupport::TimeWithZone?, ?to: ::ActiveSupport::TimeWithZone?) -> bool
+
+ def restore_posted_at!: () -> void
+
+ def clear_posted_at_change: () -> void
+
+ def posted_at_before_type_cast: () -> ::Time?
+
+ def posted_at_for_database: () -> ::Time?
+
+ def voided_at: () -> ::ActiveSupport::TimeWithZone?
+
+ def voided_at=: (::ActiveSupport::TimeWithZone?) -> ::ActiveSupport::TimeWithZone?
+
+ def voided_at?: () -> bool
+
+ def voided_at_changed?: (?from: ::ActiveSupport::TimeWithZone?, ?to: ::ActiveSupport::TimeWithZone?) -> bool
+
+ def voided_at_change: () -> [ ::ActiveSupport::TimeWithZone?, ::ActiveSupport::TimeWithZone? ]
+
+ def voided_at_will_change!: () -> void
+
+ def voided_at_was: () -> ::ActiveSupport::TimeWithZone?
+
+ def voided_at_previously_changed?: (?from: ::ActiveSupport::TimeWithZone?, ?to: ::ActiveSupport::TimeWithZone?) -> bool
+
+ def voided_at_previous_change: () -> ::Array[::ActiveSupport::TimeWithZone?]?
+
+ def voided_at_previously_was: () -> ::ActiveSupport::TimeWithZone?
+
+ def voided_at_before_last_save: () -> ::ActiveSupport::TimeWithZone?
+
+ def voided_at_change_to_be_saved: () -> ::Array[::ActiveSupport::TimeWithZone?]?
+
+ def voided_at_in_database: () -> ::ActiveSupport::TimeWithZone?
+
+ def saved_change_to_voided_at: () -> ::Array[::ActiveSupport::TimeWithZone?]?
+
+ def saved_change_to_voided_at?: (?from: ::ActiveSupport::TimeWithZone?, ?to: ::ActiveSupport::TimeWithZone?) -> bool
+
+ def will_save_change_to_voided_at?: (?from: ::ActiveSupport::TimeWithZone?, ?to: ::ActiveSupport::TimeWithZone?) -> bool
+
+ def restore_voided_at!: () -> void
+
+ def clear_voided_at_change: () -> void
+
+ def voided_at_before_type_cast: () -> ::Time?
+
+ def voided_at_for_database: () -> ::Time?
+
+ def superseded_by_id: () -> ::Integer?
+
+ def superseded_by_id=: (::Integer?) -> ::Integer?
+
+ def superseded_by_id?: () -> bool
+
+ def superseded_by_id_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def superseded_by_id_change: () -> [ ::Integer?, ::Integer? ]
+
+ def superseded_by_id_will_change!: () -> void
+
+ def superseded_by_id_was: () -> ::Integer?
+
+ def superseded_by_id_previously_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def superseded_by_id_previous_change: () -> ::Array[::Integer?]?
+
+ def superseded_by_id_previously_was: () -> ::Integer?
+
+ def superseded_by_id_before_last_save: () -> ::Integer?
+
+ def superseded_by_id_change_to_be_saved: () -> ::Array[::Integer?]?
+
+ def superseded_by_id_in_database: () -> ::Integer?
+
+ def saved_change_to_superseded_by_id: () -> ::Array[::Integer?]?
+
+ def saved_change_to_superseded_by_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def will_save_change_to_superseded_by_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def restore_superseded_by_id!: () -> void
+
+ def clear_superseded_by_id_change: () -> void
+
+ def superseded_by_id_before_type_cast: () -> ::Integer?
+
+ def superseded_by_id_for_database: () -> ::Integer?
+
+ def created_at: () -> ::ActiveSupport::TimeWithZone
+
+ def created_at=: (::ActiveSupport::TimeWithZone) -> ::ActiveSupport::TimeWithZone
+
+ def created_at?: () -> bool
+
+ def created_at_changed?: (?from: ::ActiveSupport::TimeWithZone?, ?to: ::ActiveSupport::TimeWithZone?) -> bool
+
+ def created_at_change: () -> [ ::ActiveSupport::TimeWithZone?, ::ActiveSupport::TimeWithZone? ]
+
+ def created_at_will_change!: () -> void
+
+ def created_at_was: () -> ::ActiveSupport::TimeWithZone?
+
+ def created_at_previously_changed?: (?from: ::ActiveSupport::TimeWithZone?, ?to: ::ActiveSupport::TimeWithZone?) -> bool
+
+ def created_at_previous_change: () -> ::Array[::ActiveSupport::TimeWithZone?]?
+
+ def created_at_previously_was: () -> ::ActiveSupport::TimeWithZone?
+
+ def created_at_before_last_save: () -> ::ActiveSupport::TimeWithZone?
+
+ def created_at_change_to_be_saved: () -> ::Array[::ActiveSupport::TimeWithZone?]?
+
+ def created_at_in_database: () -> ::ActiveSupport::TimeWithZone?
+
+ def saved_change_to_created_at: () -> ::Array[::ActiveSupport::TimeWithZone?]?
+
+ def saved_change_to_created_at?: (?from: ::ActiveSupport::TimeWithZone?, ?to: ::ActiveSupport::TimeWithZone?) -> bool
+
+ def will_save_change_to_created_at?: (?from: ::ActiveSupport::TimeWithZone?, ?to: ::ActiveSupport::TimeWithZone?) -> bool
+
+ def restore_created_at!: () -> void
+
+ def clear_created_at_change: () -> void
+
+ def created_at_before_type_cast: () -> ::Time
+
+ def created_at_for_database: () -> ::Time
+
+ def updated_at: () -> ::ActiveSupport::TimeWithZone
+
+ def updated_at=: (::ActiveSupport::TimeWithZone) -> ::ActiveSupport::TimeWithZone
+
+ def updated_at?: () -> bool
+
+ def updated_at_changed?: (?from: ::ActiveSupport::TimeWithZone?, ?to: ::ActiveSupport::TimeWithZone?) -> bool
+
+ def updated_at_change: () -> [ ::ActiveSupport::TimeWithZone?, ::ActiveSupport::TimeWithZone? ]
+
+ def updated_at_will_change!: () -> void
+
+ def updated_at_was: () -> ::ActiveSupport::TimeWithZone?
+
+ def updated_at_previously_changed?: (?from: ::ActiveSupport::TimeWithZone?, ?to: ::ActiveSupport::TimeWithZone?) -> bool
+
+ def updated_at_previous_change: () -> ::Array[::ActiveSupport::TimeWithZone?]?
+
+ def updated_at_previously_was: () -> ::ActiveSupport::TimeWithZone?
+
+ def updated_at_before_last_save: () -> ::ActiveSupport::TimeWithZone?
+
+ def updated_at_change_to_be_saved: () -> ::Array[::ActiveSupport::TimeWithZone?]?
+
+ def updated_at_in_database: () -> ::ActiveSupport::TimeWithZone?
+
+ def saved_change_to_updated_at: () -> ::Array[::ActiveSupport::TimeWithZone?]?
+
+ def saved_change_to_updated_at?: (?from: ::ActiveSupport::TimeWithZone?, ?to: ::ActiveSupport::TimeWithZone?) -> bool
+
+ def will_save_change_to_updated_at?: (?from: ::ActiveSupport::TimeWithZone?, ?to: ::ActiveSupport::TimeWithZone?) -> bool
+
+ def restore_updated_at!: () -> void
+
+ def clear_updated_at_change: () -> void
+
+ def updated_at_before_type_cast: () -> ::Time
+
+ def updated_at_for_database: () -> ::Time
+ end
+ include ::SecurityDepositTransaction::GeneratedAttributeMethods
+ module ::SecurityDepositTransaction::GeneratedAliasAttributeMethods
+ include ::SecurityDepositTransaction::GeneratedAttributeMethods
+
+ alias id_value id
+
+ alias id_value= id=
+
+ alias id_value? id?
+
+ alias id_value_changed? id_changed?
+
+ alias id_value_change id_change
+
+ alias id_value_will_change! id_will_change!
+
+ alias id_value_was id_was
+
+ alias id_value_previously_changed? id_previously_changed?
+
+ alias id_value_previous_change id_previous_change
+
+ alias id_value_previously_was id_previously_was
+
+ alias id_value_before_last_save id_before_last_save
+
+ alias id_value_change_to_be_saved id_change_to_be_saved
+
+ alias id_value_in_database id_in_database
+
+ alias saved_change_to_id_value saved_change_to_id
+
+ alias saved_change_to_id_value? saved_change_to_id?
+
+ alias will_save_change_to_id_value? will_save_change_to_id?
+
+ alias restore_id_value! restore_id!
+
+ alias clear_id_value_change clear_id_change
+
+ alias id_value_before_type_cast id_before_type_cast
+
+ alias id_value_for_database id_for_database
+ end
+ include ::SecurityDepositTransaction::GeneratedAliasAttributeMethods
+ def journal_entries: () -> ::JournalEntry::ActiveRecord_Associations_CollectionProxy
+ def journal_entries=: (::JournalEntry::ActiveRecord_Associations_CollectionProxy | ::Array[::JournalEntry]) -> (::JournalEntry::ActiveRecord_Associations_CollectionProxy | ::Array[::JournalEntry])
+ def journal_entry_ids: () -> ::Array[::Integer]
+ def journal_entry_ids=: (::Array[::Integer]) -> ::Array[::Integer]
+ def postings: () -> ::Posting::ActiveRecord_Associations_CollectionProxy
+ def postings=: (::Posting::ActiveRecord_Associations_CollectionProxy | ::Array[::Posting]) -> (::Posting::ActiveRecord_Associations_CollectionProxy | ::Array[::Posting])
+ def posting_ids: () -> ::Array[::Integer]
+ def posting_ids=: (::Array[::Integer]) -> ::Array[::Integer]
+
+ def superseded_transaction: () -> ::SecurityDepositTransaction?
+ def superseded_transaction=: (::SecurityDepositTransaction?) -> ::SecurityDepositTransaction?
+ def build_superseded_transaction: (?untyped) -> ::SecurityDepositTransaction
+ def create_superseded_transaction: (?untyped) -> ::SecurityDepositTransaction
+ def create_superseded_transaction!: (?untyped) -> ::SecurityDepositTransaction
+ def reload_superseded_transaction: () -> ::SecurityDepositTransaction?
+ def security_deposit: () -> ::SecurityDeposit
+ def security_deposit=: (::SecurityDeposit?) -> ::SecurityDeposit?
+ def reload_security_deposit: () -> ::SecurityDeposit?
+ def build_security_deposit: (?untyped) -> ::SecurityDeposit
+ def create_security_deposit: (?untyped) -> ::SecurityDeposit
+ def create_security_deposit!: (?untyped) -> ::SecurityDeposit
+ def party: () -> ::Party?
+ def party=: (::Party?) -> ::Party?
+ def reload_party: () -> ::Party?
+ def build_party: (?untyped) -> ::Party
+ def create_party: (?untyped) -> ::Party
+ def create_party!: (?untyped) -> ::Party
+ def charge: () -> ::Charge?
+ def charge=: (::Charge?) -> ::Charge?
+ def reload_charge: () -> ::Charge?
+ def build_charge: (?untyped) -> ::Charge
+ def create_charge: (?untyped) -> ::Charge
+ def create_charge!: (?untyped) -> ::Charge
+ def superseded_by: () -> ::SecurityDepositTransaction?
+ def superseded_by=: (::SecurityDepositTransaction?) -> ::SecurityDepositTransaction?
+ def reload_superseded_by: () -> ::SecurityDepositTransaction?
+ def build_superseded_by: (?untyped) -> ::SecurityDepositTransaction
+ def create_superseded_by: (?untyped) -> ::SecurityDepositTransaction
+ def create_superseded_by!: (?untyped) -> ::SecurityDepositTransaction
+
+ module ::SecurityDepositTransaction::GeneratedAssociationMethods
+ end
+ include ::SecurityDepositTransaction::GeneratedAssociationMethods
+
+ def received!: () -> bool
+ def received?: () -> bool
+ def refunded!: () -> bool
+ def refunded?: () -> bool
+ def applied!: () -> bool
+ def applied?: () -> bool
+ def self.transaction_kinds: () -> ::ActiveSupport::HashWithIndifferentAccess[::String, ::String]
+ def self.received: () -> ::SecurityDepositTransaction::ActiveRecord_Relation
+ def self.not_received: () -> ::SecurityDepositTransaction::ActiveRecord_Relation
+ def self.refunded: () -> ::SecurityDepositTransaction::ActiveRecord_Relation
+ def self.not_refunded: () -> ::SecurityDepositTransaction::ActiveRecord_Relation
+ def self.applied: () -> ::SecurityDepositTransaction::ActiveRecord_Relation
+ def self.not_applied: () -> ::SecurityDepositTransaction::ActiveRecord_Relation
+ def self.active: () -> ::SecurityDepositTransaction::ActiveRecord_Relation
+ def self.posted: () -> ::SecurityDepositTransaction::ActiveRecord_Relation
+ def self.voided: () -> ::SecurityDepositTransaction::ActiveRecord_Relation
+ def self.superseded: () -> ::SecurityDepositTransaction::ActiveRecord_Relation
+
+ module ::SecurityDepositTransaction::GeneratedRelationMethods
+ def transaction_kinds: () -> ::ActiveSupport::HashWithIndifferentAccess[::String, ::String]
+
+ def received: () -> ::SecurityDepositTransaction::ActiveRecord_Relation
+
+ def not_received: () -> ::SecurityDepositTransaction::ActiveRecord_Relation
+
+ def refunded: () -> ::SecurityDepositTransaction::ActiveRecord_Relation
+
+ def not_refunded: () -> ::SecurityDepositTransaction::ActiveRecord_Relation
+
+ def applied: () -> ::SecurityDepositTransaction::ActiveRecord_Relation
+
+ def not_applied: () -> ::SecurityDepositTransaction::ActiveRecord_Relation
+
+ def active: () -> ::SecurityDepositTransaction::ActiveRecord_Relation
+
+ def posted: () -> ::SecurityDepositTransaction::ActiveRecord_Relation
+
+ def voided: () -> ::SecurityDepositTransaction::ActiveRecord_Relation
+
+ def superseded: () -> ::SecurityDepositTransaction::ActiveRecord_Relation
+ end
+
+ class ::SecurityDepositTransaction::ActiveRecord_Relation < ::ActiveRecord::Relation
+ include ::Enumerable[::SecurityDepositTransaction]
+ include ::SecurityDepositTransaction::GeneratedRelationMethods
+ include ::ActiveRecord::Relation::Methods[::SecurityDepositTransaction, ::Integer]
+ end
+
+ class ::SecurityDepositTransaction::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy
+ include ::Enumerable[::SecurityDepositTransaction]
+ include ::SecurityDepositTransaction::GeneratedRelationMethods
+ include ::ActiveRecord::Relation::Methods[::SecurityDepositTransaction, ::Integer]
+
+ def build: (?::ActiveRecord::Associations::CollectionProxy::_EachPair attributes) ?{ () -> untyped } -> ::SecurityDepositTransaction
+ | (::Array[::ActiveRecord::Associations::CollectionProxy::_EachPair] attributes) ?{ () -> untyped } -> ::Array[::SecurityDepositTransaction]
+ def create: (?::ActiveRecord::Associations::CollectionProxy::_EachPair attributes) ?{ () -> untyped } -> ::SecurityDepositTransaction
+ | (::Array[::ActiveRecord::Associations::CollectionProxy::_EachPair] attributes) ?{ () -> untyped } -> ::Array[::SecurityDepositTransaction]
+ def create!: (?::ActiveRecord::Associations::CollectionProxy::_EachPair attributes) ?{ () -> untyped } -> ::SecurityDepositTransaction
+ | (::Array[::ActiveRecord::Associations::CollectionProxy::_EachPair] attributes) ?{ () -> untyped } -> ::Array[::SecurityDepositTransaction]
+ def reload: () -> ::Array[::SecurityDepositTransaction]
+
+ def replace: (::Array[::SecurityDepositTransaction]) -> void
+ def delete: (*::SecurityDepositTransaction | ::Integer) -> ::Array[::SecurityDepositTransaction]
+ def destroy: (*::SecurityDepositTransaction | ::Integer) -> ::Array[::SecurityDepositTransaction]
+ def <<: (*::SecurityDepositTransaction | ::Array[::SecurityDepositTransaction]) -> self
+ def prepend: (*::SecurityDepositTransaction | ::Array[::SecurityDepositTransaction]) -> self
+ end
+end
+
+class ::ApplicationRecord < ::ActiveRecord::Base
+end
+class ::JournalEntry < ::ApplicationRecord
+end
+class ::JournalEntry::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy
+end
+class ::Posting < ::ApplicationRecord
+end
+class ::Posting::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy
+end
+class ::SecurityDepositTransaction < ::ApplicationRecord
+end
+class ::SecurityDeposit < ::ApplicationRecord
+end
+class ::Party < ::ApplicationRecord
+end
+class ::Charge < ::ApplicationRecord
+end
diff --git a/sig/rbs_rails/app/models/tenancy.rbs b/sig/rbs_rails/app/models/tenancy.rbs
index 3d276dc9..ad1451a3 100644
--- a/sig/rbs_rails/app/models/tenancy.rbs
+++ b/sig/rbs_rails/app/models/tenancy.rbs
@@ -393,6 +393,10 @@ class ::Tenancy < ::ApplicationRecord
def accounting_postings=: (::Posting::ActiveRecord_Associations_CollectionProxy | ::Array[::Posting]) -> (::Posting::ActiveRecord_Associations_CollectionProxy | ::Array[::Posting])
def accounting_posting_ids: () -> ::Array[::Integer]
def accounting_posting_ids=: (::Array[::Integer]) -> ::Array[::Integer]
+ def security_deposit_transactions: () -> ::SecurityDepositTransaction::ActiveRecord_Associations_CollectionProxy
+ def security_deposit_transactions=: (::SecurityDepositTransaction::ActiveRecord_Associations_CollectionProxy | ::Array[::SecurityDepositTransaction]) -> (::SecurityDepositTransaction::ActiveRecord_Associations_CollectionProxy | ::Array[::SecurityDepositTransaction])
+ def security_deposit_transaction_ids: () -> ::Array[::Integer]
+ def security_deposit_transaction_ids=: (::Array[::Integer]) -> ::Array[::Integer]
def payment_ingestions: () -> ::PaymentIngestion::ActiveRecord_Associations_CollectionProxy
def payment_ingestions=: (::PaymentIngestion::ActiveRecord_Associations_CollectionProxy | ::Array[::PaymentIngestion]) -> (::PaymentIngestion::ActiveRecord_Associations_CollectionProxy | ::Array[::PaymentIngestion])
def payment_ingestion_ids: () -> ::Array[::Integer]
@@ -404,6 +408,12 @@ class ::Tenancy < ::ApplicationRecord
def create_property: (?untyped) -> ::Property
def create_property!: (?untyped) -> ::Property
def reload_property: () -> ::Property?
+ def security_deposit: () -> ::SecurityDeposit?
+ def security_deposit=: (::SecurityDeposit?) -> ::SecurityDeposit?
+ def build_security_deposit: (?untyped) -> ::SecurityDeposit
+ def create_security_deposit: (?untyped) -> ::SecurityDeposit
+ def create_security_deposit!: (?untyped) -> ::SecurityDeposit
+ def reload_security_deposit: () -> ::SecurityDeposit?
def rentable_unit: () -> ::RentableUnit
def rentable_unit=: (::RentableUnit?) -> ::RentableUnit?
def reload_rentable_unit: () -> ::RentableUnit?
@@ -501,11 +511,17 @@ class ::Posting < ::ApplicationRecord
end
class ::Posting::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy
end
+class ::SecurityDepositTransaction < ::ApplicationRecord
+end
+class ::SecurityDepositTransaction::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy
+end
class ::PaymentIngestion < ::ApplicationRecord
end
class ::PaymentIngestion::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy
end
class ::Property < ::ApplicationRecord
end
+class ::SecurityDeposit < ::ApplicationRecord
+end
class ::RentableUnit < ::ApplicationRecord
end
diff --git a/sig/rbs_rails/path_helpers.rbs b/sig/rbs_rails/path_helpers.rbs
index f8c0e405..50930bfc 100644
--- a/sig/rbs_rails/path_helpers.rbs
+++ b/sig/rbs_rails/path_helpers.rbs
@@ -34,10 +34,20 @@ interface ::_RbsRailsPathHelpers
def tenancy_tenancy_party_path: (*untyped) -> ::String
def tenancy_rent_terms_path: (*untyped) -> ::String
def new_tenancy_rent_term_path: (*untyped) -> ::String
+ def receive_tenancy_security_deposit_path: (*untyped) -> ::String
+ def refund_tenancy_security_deposit_path: (*untyped) -> ::String
+ def apply_tenancy_security_deposit_path: (*untyped) -> ::String
+ def new_tenancy_security_deposit_path: (*untyped) -> ::String
+ def edit_tenancy_security_deposit_path: (*untyped) -> ::String
+ def tenancy_security_deposit_path: (*untyped) -> ::String
def tenancies_path: (*untyped) -> ::String
def new_tenancy_path: (*untyped) -> ::String
def edit_tenancy_path: (*untyped) -> ::String
def tenancy_path: (*untyped) -> ::String
+ def correction_security_deposit_transaction_path: (*untyped) -> ::String
+ def correct_security_deposit_transaction_path: (*untyped) -> ::String
+ def void_security_deposit_transaction_path: (*untyped) -> ::String
+ def security_deposit_transaction_path: (*untyped) -> ::String
def expense_reimbursements_path: (*untyped) -> ::String
def new_expense_reimbursement_path: (*untyped) -> ::String
def correction_expense_path: (*untyped) -> ::String
@@ -129,10 +139,20 @@ interface ::_RbsRailsPathHelpers
def tenancy_tenancy_party_url: (*untyped) -> ::String
def tenancy_rent_terms_url: (*untyped) -> ::String
def new_tenancy_rent_term_url: (*untyped) -> ::String
+ def receive_tenancy_security_deposit_url: (*untyped) -> ::String
+ def refund_tenancy_security_deposit_url: (*untyped) -> ::String
+ def apply_tenancy_security_deposit_url: (*untyped) -> ::String
+ def new_tenancy_security_deposit_url: (*untyped) -> ::String
+ def edit_tenancy_security_deposit_url: (*untyped) -> ::String
+ def tenancy_security_deposit_url: (*untyped) -> ::String
def tenancies_url: (*untyped) -> ::String
def new_tenancy_url: (*untyped) -> ::String
def edit_tenancy_url: (*untyped) -> ::String
def tenancy_url: (*untyped) -> ::String
+ def correction_security_deposit_transaction_url: (*untyped) -> ::String
+ def correct_security_deposit_transaction_url: (*untyped) -> ::String
+ def void_security_deposit_transaction_url: (*untyped) -> ::String
+ def security_deposit_transaction_url: (*untyped) -> ::String
def expense_reimbursements_url: (*untyped) -> ::String
def new_expense_reimbursement_url: (*untyped) -> ::String
def correction_expense_url: (*untyped) -> ::String
diff --git a/spec/factories.rb b/spec/factories.rb
index 94978466..bd3ad94a 100644
--- a/spec/factories.rb
+++ b/spec/factories.rb
@@ -213,4 +213,55 @@
posted_at { Time.current }
end
end
+
+ factory :security_deposit do
+ association :tenancy
+ required_amount_cents { 200_000 }
+ due_on { tenancy&.commencement_date || Date.current }
+ end
+
+ factory :security_deposit_transaction do
+ association :security_deposit
+ transaction_kind { "received" }
+ amount_cents { 200_000 }
+ occurred_on { Date.current }
+ party { association :party, user: security_deposit.accounting_user }
+
+ trait :received do
+ transaction_kind { "received" }
+ party { association :party, user: security_deposit.accounting_user }
+ charge { nil }
+ end
+
+ trait :refunded do
+ transaction_kind { "refunded" }
+ party { association :party, user: security_deposit.accounting_user }
+ charge { nil }
+ end
+
+ trait :applied do
+ transaction_kind { "applied" }
+ party { nil }
+ charge { association :charge, tenancy: security_deposit.tenancy }
+ end
+
+ trait :posted do
+ after(:create) do |txn|
+ txn.update_columns(posted_at: Time.current)
+ end
+ end
+
+ trait :voided do
+ after(:create) do |txn|
+ txn.update_columns(posted_at: 1.day.ago, voided_at: Time.current)
+ end
+ end
+
+ trait :superseded do
+ after(:create) do |txn|
+ rep = create(:security_deposit_transaction, :received, security_deposit: txn.security_deposit)
+ txn.update_columns(posted_at: 1.day.ago, voided_at: Time.current, superseded_by_id: rep.id)
+ end
+ end
+ end
end
diff --git a/spec/features/milestone_6_acceptance_spec.rb b/spec/features/milestone_6_acceptance_spec.rb
new file mode 100644
index 00000000..534e639c
--- /dev/null
+++ b/spec/features/milestone_6_acceptance_spec.rb
@@ -0,0 +1,104 @@
+require "rails_helper"
+
+RSpec.describe "Milestone 6 Acceptance: Double-Entry Security Deposits", type: :feature do
+ let(:user) { create(:user) }
+ let(:property) { create(:property, user: user) }
+ let(:unit) { create(:rentable_unit, property: property) }
+ let(:tenancy) { create(:tenancy, rentable_unit: unit) }
+ let(:party) { create(:party, user: user) }
+
+ before do
+ Accounting::ChartOfAccounts.ensure_for(user)
+ end
+
+ it "verifies full Milestone 6 end-to-end security deposit lifecycle and accounting invariants" do
+ # 1. Set up contractual deposit requirement
+ create_deposit_res = SecurityDeposits::CreateService.call(
+ tenancy: tenancy,
+ required_amount: "2000.00",
+ due_on: Date.new(2026, 1, 1)
+ )
+ expect(create_deposit_res).to be_success
+ deposit = create_deposit_res.value!.data[:security_deposit]
+
+ # Verify initial balances
+ expect(deposit.held_cents).to eq(0)
+ expect(tenancy.current_balance_cents).to eq(0)
+
+ # 2. Receive $2,000 refundable deposit
+ receive_res = SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: deposit,
+ party: party,
+ amount: "2000.00",
+ occurred_on: Date.new(2026, 1, 1),
+ memo: "Initial security deposit"
+ )
+ expect(receive_res).to be_success
+ expect(deposit.held_cents).to eq(200_000)
+ expect(tenancy.current_balance_cents).to eq(0) # Receivable unaffected!
+
+ # Schedule E summary should have 0 rents received
+ schedule_e = property.schedule_e_summary(year: 2026)
+ expect(schedule_e.rents_received).to eq(0)
+
+ # 3. Refund $500
+ refund_res = SecurityDepositTransactions::RefundService.call(
+ security_deposit: deposit,
+ party: party,
+ amount: "500.00",
+ occurred_on: Date.new(2026, 1, 15),
+ memo: "Partial refund"
+ )
+ expect(refund_res).to be_success
+ expect(deposit.held_cents).to eq(150_000)
+ expect(tenancy.current_balance_cents).to eq(0)
+
+ # 4. Create a $500 damage reimbursement charge
+ charge_res = Charges::CreateService.call(
+ tenancy: tenancy,
+ charge_kind: "other",
+ amount_cents: 50_000,
+ charge_date: Date.new(2026, 2, 1),
+ due_on: Date.new(2026, 2, 1),
+ description: "Drywall damage repair"
+ )
+ expect(charge_res).to be_success
+ charge = charge_res.value!.data[:charge]
+ expect(tenancy.current_balance_cents).to eq(50_000)
+
+ # 5. Apply $500 deposit toward the charge
+ apply_res = SecurityDepositTransactions::ApplyService.call(
+ security_deposit: deposit,
+ charge: charge,
+ amount: "500.00",
+ occurred_on: Date.new(2026, 2, 5),
+ memo: "Apply deposit for drywall repair"
+ )
+ expect(apply_res).to be_success
+ app_txn = apply_res.value!.data[:transaction]
+
+ # Verify balances after application
+ expect(deposit.held_cents).to eq(100_000)
+ expect(tenancy.current_balance_cents).to eq(0) # Receivable settled!
+
+ # 6. Verify Charge cannot be voided or corrected while deposit application is active
+ void_charge_res = Charges::VoidService.call(charge: charge)
+ expect(void_charge_res).to be_failure
+ expect(void_charge_res.failure.code).to eq(:active_deposit_applications)
+
+ correct_charge_res = Charges::CorrectService.call(charge: charge, amount_cents: 60_000)
+ expect(correct_charge_res).to be_failure
+ expect(correct_charge_res.failure.code).to eq(:active_deposit_applications)
+
+ # 7. Void deposit application -> restores receivable and held liability, unblocks charge lifecycle
+ void_app_res = SecurityDepositTransactions::VoidService.call(transaction: app_txn)
+ expect(void_app_res).to be_success
+ expect(deposit.held_cents).to eq(150_000)
+ expect(tenancy.current_balance_cents).to eq(50_000)
+
+ # Now charge can be voided
+ void_charge_res2 = Charges::VoidService.call(charge: charge)
+ expect(void_charge_res2).to be_success
+ expect(tenancy.current_balance_cents).to eq(0)
+ end
+end
diff --git a/spec/models/security_deposit_spec.rb b/spec/models/security_deposit_spec.rb
new file mode 100644
index 00000000..7425362a
--- /dev/null
+++ b/spec/models/security_deposit_spec.rb
@@ -0,0 +1,126 @@
+require "rails_helper"
+
+RSpec.describe SecurityDeposit, type: :model do
+ let(:user) { create(:user) }
+ let(:property) { create(:property, user: user) }
+ let(:unit) { create(:rentable_unit, property: property) }
+ let(:tenancy) { create(:tenancy, rentable_unit: unit) }
+ let(:security_deposit) { create(:security_deposit, tenancy: tenancy, required_amount_cents: 200_000) }
+
+ describe "validations" do
+ it "is valid with valid attributes" do
+ expect(security_deposit).to be_valid
+ end
+
+ it "requires positive required_amount_cents" do
+ deposit = build(:security_deposit, tenancy: tenancy, required_amount_cents: 0)
+ expect(deposit).not_to be_valid
+ expect(deposit.errors[:required_amount_cents]).to be_present
+
+ deposit.required_amount_cents = -500
+ expect(deposit).not_to be_valid
+ end
+
+ it "requires due_on" do
+ deposit = build(:security_deposit, tenancy: tenancy, due_on: nil)
+ expect(deposit).not_to be_valid
+ expect(deposit.errors[:due_on]).to be_present
+ end
+
+ it "enforces unique tenancy_id" do
+ security_deposit
+ duplicate = build(:security_deposit, tenancy: tenancy)
+ expect(duplicate).not_to be_valid
+ expect(duplicate.errors[:tenancy_id]).to be_present
+ end
+ end
+
+ describe "amount parsing helpers and delegation" do
+ it "converts required_amount_cents to required_amount decimal" do
+ deposit = build(:security_deposit, required_amount_cents: 150_550)
+ expect(deposit.required_amount).to eq(1505.50)
+
+ deposit_nil = build(:security_deposit, required_amount_cents: nil)
+ expect(deposit_nil.required_amount).to eq(0.0)
+ end
+
+ it "sets required_amount_cents from string/numeric amount" do
+ deposit = build(:security_deposit)
+ deposit.required_amount = "2500.50"
+ expect(deposit.required_amount_cents).to eq(250_050)
+
+ deposit.required_amount = 3000
+ expect(deposit.required_amount_cents).to eq(300_000)
+
+ deposit.required_amount = ""
+ expect(deposit.required_amount_cents).to be_nil
+
+ deposit.required_amount = "invalid"
+ expect(deposit.required_amount_cents).to eq(-1)
+ expect(deposit).not_to be_valid
+ end
+
+ it "delegates accounting_user, property, and rentable_unit" do
+ expect(security_deposit.accounting_user).to eq(user)
+ expect(security_deposit.property).to eq(property)
+ expect(security_deposit.rentable_unit).to eq(unit)
+
+ orphan_deposit = SecurityDeposit.new
+ expect(orphan_deposit.accounting_user).to be_nil
+ expect(orphan_deposit.property).to be_nil
+ expect(orphan_deposit.rentable_unit).to be_nil
+ end
+ end
+
+ describe "immutability after transactions exist" do
+ it "allows updating requirement before any transaction exists" do
+ expect(security_deposit.update(required_amount_cents: 250_000, due_on: Date.current + 1.month)).to be true
+ expect(security_deposit.reload.required_amount_cents).to eq(250_000)
+ end
+
+ it "prohibits updating requirement or tenancy after a transaction exists" do
+ create(:security_deposit_transaction, :received, security_deposit: security_deposit, amount_cents: 50_000)
+
+ expect(security_deposit.update(required_amount_cents: 300_000)).to be false
+ expect(security_deposit.errors[:required_amount_cents]).to include("cannot be changed after deposit transactions exist")
+
+ expect(security_deposit.update(due_on: Date.current + 10.days)).to be false
+ expect(security_deposit.errors[:due_on]).to include("cannot be changed after deposit transactions exist")
+
+ other_u = create(:rentable_unit, property: property)
+ other_t = create(:tenancy, rentable_unit: other_u)
+ expect(security_deposit.update(tenancy_id: other_t.id)).to be false
+ expect(security_deposit.errors[:tenancy_id]).to include("cannot be changed after deposit transactions exist")
+ end
+ end
+
+ describe "funding status helpers" do
+ it "reports funding statuses based on held_cents" do
+ deposit = create(:security_deposit, tenancy: tenancy, required_amount_cents: 200_000)
+ allow(deposit).to receive(:held_cents).and_return(0)
+ expect(deposit.funding_status).to eq("not_funded")
+ expect(deposit.held_amount).to eq(0.0)
+ expect(deposit.remaining_required_cents).to eq(200_000)
+ expect(deposit.fully_funded?).to be false
+ expect(deposit.overfunded?).to be false
+
+ allow(deposit).to receive(:held_cents).and_return(100_000)
+ expect(deposit.funding_status).to eq("partially_funded")
+ expect(deposit.held_amount).to eq(1000.0)
+ expect(deposit.remaining_required_cents).to eq(100_000)
+ expect(deposit.remaining_required_amount).to eq(1000.0)
+
+ allow(deposit).to receive(:held_cents).and_return(200_000)
+ expect(deposit.funding_status).to eq("funded")
+ expect(deposit.remaining_required_cents).to eq(0)
+ expect(deposit.fully_funded?).to be true
+ expect(deposit.overfunded?).to be false
+
+ allow(deposit).to receive(:held_cents).and_return(250_000)
+ expect(deposit.funding_status).to eq("overfunded")
+ expect(deposit.remaining_required_cents).to eq(0)
+ expect(deposit.fully_funded?).to be true
+ expect(deposit.overfunded?).to be true
+ end
+ end
+end
diff --git a/spec/models/security_deposit_transaction_spec.rb b/spec/models/security_deposit_transaction_spec.rb
new file mode 100644
index 00000000..11e887b4
--- /dev/null
+++ b/spec/models/security_deposit_transaction_spec.rb
@@ -0,0 +1,224 @@
+require "rails_helper"
+
+RSpec.describe SecurityDepositTransaction, type: :model do
+ let(:user) { create(:user) }
+ let(:property) { create(:property, user: user) }
+ let(:unit) { create(:rentable_unit, property: property) }
+ let(:tenancy) { create(:tenancy, rentable_unit: unit) }
+ let(:party) { create(:party, user: user) }
+ let(:other_user) { create(:user) }
+ let(:other_party) { create(:party, user: other_user) }
+ let(:security_deposit) { create(:security_deposit, tenancy: tenancy, required_amount_cents: 200_000) }
+ let(:charge) { create(:charge, tenancy: tenancy, amount_cents: 50_000) }
+
+ describe "validations" do
+ it "is valid for received transaction with party and no charge" do
+ txn = build(:security_deposit_transaction, :received, security_deposit: security_deposit, party: party)
+ expect(txn).to be_valid
+ end
+
+ it "is valid for refunded transaction with party and no charge" do
+ txn = build(:security_deposit_transaction, :refunded, security_deposit: security_deposit, party: party)
+ expect(txn).to be_valid
+ end
+
+ it "is valid for applied transaction with charge and no party" do
+ txn = build(:security_deposit_transaction, :applied, security_deposit: security_deposit, charge: charge)
+ expect(txn).to be_valid
+ end
+
+ it "rejects received transaction without party" do
+ txn = build(:security_deposit_transaction, transaction_kind: "received", security_deposit: security_deposit, party: nil)
+ expect(txn).not_to be_valid
+ expect(txn.errors[:party]).to be_present
+ end
+
+ it "rejects received transaction with charge" do
+ txn = build(:security_deposit_transaction, transaction_kind: "received", security_deposit: security_deposit, party: party, charge: charge)
+ expect(txn).not_to be_valid
+ expect(txn.errors[:charge]).to be_present
+ end
+
+ it "rejects refunded transaction without party" do
+ txn = build(:security_deposit_transaction, transaction_kind: "refunded", security_deposit: security_deposit, party: nil)
+ expect(txn).not_to be_valid
+ expect(txn.errors[:party]).to be_present
+ end
+
+ it "rejects refunded transaction with charge" do
+ txn = build(:security_deposit_transaction, transaction_kind: "refunded", security_deposit: security_deposit, party: party, charge: charge)
+ expect(txn).not_to be_valid
+ expect(txn.errors[:charge]).to be_present
+ end
+
+ it "rejects applied transaction without charge" do
+ txn = build(:security_deposit_transaction, transaction_kind: "applied", security_deposit: security_deposit, charge: nil)
+ expect(txn).not_to be_valid
+ expect(txn.errors[:charge]).to be_present
+ end
+
+ it "rejects applied transaction with party" do
+ txn = build(:security_deposit_transaction, transaction_kind: "applied", security_deposit: security_deposit, charge: charge, party: party)
+ expect(txn).not_to be_valid
+ expect(txn.errors[:party]).to be_present
+ end
+
+ it "rejects party from another user" do
+ txn = build(:security_deposit_transaction, :received, security_deposit: security_deposit, party: other_party)
+ expect(txn).not_to be_valid
+ expect(txn.errors[:party]).to include("must belong to your account")
+ end
+
+ it "rejects charge from a different tenancy" do
+ other_unit = create(:rentable_unit, property: property)
+ other_tenancy = create(:tenancy, rentable_unit: other_unit)
+ other_charge = create(:charge, tenancy: other_tenancy, amount_cents: 50_000)
+
+ txn = build(:security_deposit_transaction, :applied, security_deposit: security_deposit, charge: other_charge)
+ expect(txn).not_to be_valid
+ expect(txn.errors[:charge]).to include("must belong to the same tenancy as the security deposit")
+ end
+
+ it "rejects occurred_on in the future" do
+ txn = build(:security_deposit_transaction, :received, security_deposit: security_deposit, party: party, occurred_on: Date.tomorrow)
+ expect(txn).not_to be_valid
+ expect(txn.errors[:occurred_on]).to include("cannot be in the future")
+ end
+
+ it "rejects non-positive amount" do
+ txn = build(:security_deposit_transaction, :received, security_deposit: security_deposit, party: party, amount_cents: 0)
+ expect(txn).not_to be_valid
+ end
+ end
+
+ describe "amount parsing helpers" do
+ it "converts amount_cents to decimal and assigns from string/number" do
+ txn = build(:security_deposit_transaction, amount_cents: 125_050)
+ expect(txn.amount).to eq(1250.50)
+
+ txn.amount = "500.25"
+ expect(txn.amount_cents).to eq(50_025)
+
+ txn.amount = 750
+ expect(txn.amount_cents).to eq(75_000)
+
+ txn.amount = ""
+ expect(txn.amount_cents).to be_nil
+
+ txn.amount = "bad"
+ expect(txn.amount_cents).to eq(-1)
+ end
+ end
+
+ describe "immutability and deletion restrictions" do
+ let!(:posted_txn) do
+ create(:security_deposit_transaction, :received, :posted, security_deposit: security_deposit, party: party, amount_cents: 100_000)
+ end
+
+ it "allows destroying unposted transaction but not posted transaction" do
+ unposted = create(:security_deposit_transaction, :received, security_deposit: security_deposit, party: party, posted_at: nil)
+ expect { unposted.destroy }.to change(SecurityDepositTransaction, :count).by(-1)
+
+ expect { posted_txn.destroy }.not_to change(SecurityDepositTransaction, :count)
+ expect(posted_txn.errors[:base]).to include("Posted transactions cannot be deleted")
+ end
+
+ it "cannot modify financial attributes once posted" do
+ expect(posted_txn.update(amount_cents: 150_000)).to be false
+ expect(posted_txn.errors[:amount_cents]).to include("cannot be changed after transaction is posted")
+
+ expect(posted_txn.update(occurred_on: 2.days.ago.to_date)).to be false
+ expect(posted_txn.errors[:occurred_on]).to include("cannot be changed after transaction is posted")
+
+ expect(posted_txn.update(party: create(:party, user: user))).to be false
+ expect(posted_txn.errors[:party_id]).to include("cannot be changed after transaction is posted")
+
+ expect(posted_txn.update(memo: "New memo")).to be false
+ expect(posted_txn.errors[:memo]).to include("cannot be changed after transaction is posted")
+
+ expect(posted_txn.update(external_reference: "REF123")).to be false
+ expect(posted_txn.errors[:external_reference]).to include("cannot be changed after transaction is posted")
+ end
+
+ it "cannot directly set voided_at, superseded_by_id, or posted_at on initial creation" do
+ txn_posted = build(:security_deposit_transaction, :received, security_deposit: security_deposit, party: party, posted_at: Time.current)
+ expect(txn_posted).not_to be_valid
+ expect(txn_posted.errors[:posted_at]).to include("cannot be modified directly; posting is managed by the accounting service")
+
+ txn_voided = build(:security_deposit_transaction, :received, security_deposit: security_deposit, party: party, voided_at: Time.current)
+ expect(txn_voided).not_to be_valid
+ expect(txn_voided.errors[:voided_at]).to include("cannot be modified directly; use SecurityDepositTransactions::VoidService or SecurityDepositTransactions::CorrectService")
+
+ txn_superseded = build(:security_deposit_transaction, :received, security_deposit: security_deposit, party: party, superseded_by_id: 123)
+ expect(txn_superseded).not_to be_valid
+ expect(txn_superseded.errors[:superseded_by_id]).to include("cannot be modified directly; use SecurityDepositTransactions::CorrectService")
+ end
+
+ it "cannot directly set voided_at, superseded_by_id, or posted_at via ActiveRecord update on posted or unposted transactions" do
+ expect(posted_txn.update(voided_at: Time.current)).to be false
+ expect(posted_txn.errors[:voided_at]).to include("cannot be modified directly; use SecurityDepositTransactions::VoidService or SecurityDepositTransactions::CorrectService")
+
+ rep = create(:security_deposit_transaction, :received, security_deposit: security_deposit, party: party)
+ expect(posted_txn.update(superseded_by_id: rep.id)).to be false
+ expect(posted_txn.errors[:superseded_by_id]).to include("cannot be modified directly; use SecurityDepositTransactions::CorrectService")
+
+ expect(posted_txn.update(posted_at: 1.day.from_now)).to be false
+ expect(posted_txn.errors[:posted_at]).to include("cannot be modified directly; posting is managed by the accounting service")
+
+ unposted = create(:security_deposit_transaction, :received, security_deposit: security_deposit, party: party, posted_at: nil)
+ expect(unposted.update(posted_at: Time.current)).to be false
+ expect(unposted.errors[:posted_at]).to include("cannot be modified directly; posting is managed by the accounting service")
+ end
+
+ it "rejects applied transaction dated before the charge date" do
+ future_charge = create(:charge, tenancy: tenancy, amount_cents: 50_000, charge_date: Date.new(2026, 1, 10))
+ applied_txn = build(
+ :security_deposit_transaction,
+ :applied,
+ security_deposit: security_deposit,
+ charge: future_charge,
+ occurred_on: Date.new(2026, 1, 5)
+ )
+ expect(applied_txn).not_to be_valid
+ expect(applied_txn.errors[:occurred_on]).to include("cannot precede the charge being settled (2026-01-10)")
+
+ applied_txn.occurred_on = Date.new(2026, 1, 10)
+ expect(applied_txn).to be_valid
+
+ applied_txn.occurred_on = Date.new(2026, 1, 15)
+ expect(applied_txn).to be_valid
+ end
+ end
+
+ describe "lifecycle predicates and association delegates" do
+ it "reports posted?, voided?, superseded?, active? correctly and delegates associations" do
+ txn = create(:security_deposit_transaction, :received, security_deposit: security_deposit, party: party, posted_at: nil)
+ expect(txn).not_to be_posted
+ expect(txn).not_to be_active
+ expect(txn.tenancy).to eq(tenancy)
+ expect(txn.property).to eq(property)
+ expect(txn.rentable_unit).to eq(unit)
+ expect(txn.accounting_user).to eq(user)
+
+ txn.update_columns(posted_at: Time.current)
+ expect(txn).to be_posted
+ expect(txn).to be_active
+
+ txn.update_columns(voided_at: Time.current)
+ expect(txn).to be_voided
+ expect(txn).not_to be_active
+
+ replacement = create(:security_deposit_transaction, :received, security_deposit: security_deposit, party: party)
+ txn.update_columns(superseded_by_id: replacement.id)
+ expect(txn).to be_superseded
+ end
+
+ it "handles nil security_deposit on delegate methods" do
+ orphan = SecurityDepositTransaction.new
+ expect(orphan.tenancy).to be_nil
+ expect(orphan.property).to be_nil
+ expect(orphan.rentable_unit).to be_nil
+ expect(orphan.accounting_user).to be_nil
+ end
+ end
+end
diff --git a/spec/queries/accounting/security_deposit_balance_query_spec.rb b/spec/queries/accounting/security_deposit_balance_query_spec.rb
new file mode 100644
index 00000000..6bdc46da
--- /dev/null
+++ b/spec/queries/accounting/security_deposit_balance_query_spec.rb
@@ -0,0 +1,68 @@
+require "rails_helper"
+
+RSpec.describe Accounting::SecurityDepositBalanceQuery do
+ let(:user) { create(:user) }
+ let(:property) { create(:property, user: user) }
+ let(:unit) { create(:rentable_unit, property: property) }
+ let(:tenancy) { create(:tenancy, rentable_unit: unit) }
+ let(:party) { create(:party, user: user) }
+ let(:security_deposit) { create(:security_deposit, tenancy: tenancy, required_amount_cents: 200_000) }
+
+ before do
+ Accounting::ChartOfAccounts.ensure_for(user)
+ end
+
+ it "returns 0 when no deposit postings exist" do
+ expect(described_class.call(tenancy: tenancy)).to eq(0)
+ expect(described_class.call(property: property)).to eq(0)
+ expect(described_class.new(user: user).balance_as_of).to eq(0)
+ end
+
+ it "returns 0 when user or account is missing" do
+ expect(described_class.new.balance_cents_as_of).to eq(0)
+
+ user_no_acct = create(:user)
+ expect(described_class.new(user: user_no_acct).balance_cents_as_of).to eq(0)
+ end
+
+ it "calculates held liability from posted transactions" do
+ SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 100_000,
+ occurred_on: Date.new(2026, 1, 1)
+ )
+
+ expect(described_class.call(tenancy: tenancy)).to eq(100_000)
+ expect(described_class.call(property: property)).to eq(100_000)
+ expect(described_class.call(user: user)).to eq(100_000)
+
+ query = described_class.new(tenancy: tenancy)
+ expect(query.balance_as_of).to eq(1000.0)
+
+ # Add second received transaction
+ SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 50_000,
+ occurred_on: Date.new(2026, 2, 1)
+ )
+
+ expect(described_class.call(tenancy: tenancy)).to eq(150_000)
+ expect(described_class.call(property: property)).to eq(150_000)
+
+ # As of Jan 15, should only see txn1
+ expect(described_class.call(tenancy: tenancy, as_of: Date.new(2026, 1, 15))).to eq(100_000)
+
+ # Refund $30,000 on March 1
+ SecurityDepositTransactions::RefundService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 30_000,
+ occurred_on: Date.new(2026, 3, 1)
+ )
+
+ expect(described_class.call(tenancy: tenancy)).to eq(120_000)
+ expect(described_class.call(tenancy: tenancy, as_of: Date.new(2026, 2, 15))).to eq(150_000)
+ end
+end
diff --git a/spec/queries/properties/active_years_query_spec.rb b/spec/queries/properties/active_years_query_spec.rb
index 3015118b..788881f3 100644
--- a/spec/queries/properties/active_years_query_spec.rb
+++ b/spec/queries/properties/active_years_query_spec.rb
@@ -23,4 +23,19 @@ def unresponsive_obj.respond_to?(method, *)
expect(result).to include(Date.current.year, 2026, 2025, 2024, 2023, 2020)
expect(result).not_to include(0)
end
+
+ it "includes historical years that only have security deposit transactions" do
+ sd = create(:security_deposit, tenancy: tenancy, required_amount_cents: 100_000)
+ create(
+ :security_deposit_transaction,
+ :received,
+ :posted,
+ security_deposit: sd,
+ party: create(:party, user: user),
+ occurred_on: Date.new(2021, 6, 15)
+ )
+
+ result = described_class.new(property: property).call
+ expect(result).to include(2021)
+ end
end
diff --git a/spec/requests/charges_spec.rb b/spec/requests/charges_spec.rb
index d6f72670..12d801cc 100644
--- a/spec/requests/charges_spec.rb
+++ b/spec/requests/charges_spec.rb
@@ -139,6 +139,16 @@
expect(response).to have_http_status(:unprocessable_content)
expect(response.body).to include("must be late_fee or other")
+
+ post tenancy_charges_path(tenancy, format: :json), params: {
+ charge: {
+ charge_kind: "reimbursement",
+ amount: "100.00",
+ charge_date: Date.current,
+ due_on: Date.current
+ }
+ }
+ expect(response).to have_http_status(:unprocessable_content)
end
it "rejects creating charge on another user's tenancy" do
diff --git a/spec/requests/properties_spec.rb b/spec/requests/properties_spec.rb
index 279bfb88..32dee6d1 100644
--- a/spec/requests/properties_spec.rb
+++ b/spec/requests/properties_spec.rb
@@ -51,6 +51,26 @@
get property_url(property)
expect(response).to be_successful
end
+
+ it "displays property security deposits held balance" do
+ Accounting::ChartOfAccounts.ensure_for(user)
+ unit = create(:rentable_unit, property: property)
+ tenancy = create(:tenancy, rentable_unit: unit)
+ party = create(:party, user: user)
+ deposit = create(:security_deposit, tenancy: tenancy, required_amount_cents: 200_000)
+ SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: deposit,
+ party: party,
+ amount_cents: 150_000,
+ occurred_on: Date.current
+ )
+
+ get property_url(property)
+ expect(response).to be_successful
+ expect(response.body).to include("Security Deposits Held")
+ expect(response.body).to include("$1,500.00")
+ expect(response.body).to include("Current refundable liability")
+ end
end
describe "GET /properties/:id/edit" do
diff --git a/spec/requests/security_deposit_transactions_spec.rb b/spec/requests/security_deposit_transactions_spec.rb
new file mode 100644
index 00000000..f67e533e
--- /dev/null
+++ b/spec/requests/security_deposit_transactions_spec.rb
@@ -0,0 +1,197 @@
+require "rails_helper"
+
+RSpec.describe "SecurityDepositTransactions", type: :request do
+ let(:user) { create(:user) }
+ let(:property) { create(:property, user: user) }
+ let(:unit) { create(:rentable_unit, property: property) }
+ let(:tenancy) { create(:tenancy, rentable_unit: unit) }
+ let(:party) { create(:party, user: user) }
+ let(:security_deposit) { create(:security_deposit, tenancy: tenancy, required_amount_cents: 200_000) }
+
+ before do
+ post session_path, params: { email: user.email, password: "password" }
+ Accounting::ChartOfAccounts.ensure_for(user)
+ end
+
+ describe "GET /security_deposit_transactions/:id" do
+ it "renders the transaction show view" do
+ res = SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 100_000,
+ occurred_on: Date.current
+ )
+ txn = res.value!.data[:transaction]
+
+ get security_deposit_transaction_path(txn)
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("Deposit Received")
+ expect(response.body).to include("$1,000.00")
+ end
+ end
+
+ describe "GET /security_deposit_transactions/:id/correction" do
+ it "renders the correction form for active transaction" do
+ res = SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 100_000,
+ occurred_on: Date.current
+ )
+ txn = res.value!.data[:transaction]
+
+ get correction_security_deposit_transaction_path(txn)
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("Correct Deposit Received")
+ end
+
+ it "redirects with alert if transaction is already voided" do
+ res = SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 100_000,
+ occurred_on: Date.current
+ )
+ txn = res.value!.data[:transaction]
+ SecurityDepositTransactions::VoidService.call(transaction: txn)
+
+ get correction_security_deposit_transaction_path(txn)
+ expect(response).to redirect_to(security_deposit_transaction_path(txn))
+ expect(flash[:alert]).to be_present
+ end
+
+ it "redirects with alert if transaction is already superseded" do
+ res = SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 100_000,
+ occurred_on: Date.current
+ )
+ txn = res.value!.data[:transaction]
+ SecurityDepositTransactions::CorrectService.call(transaction: txn, amount_cents: 120_000)
+
+ get correction_security_deposit_transaction_path(txn)
+ expect(response).to redirect_to(security_deposit_transaction_path(txn))
+ expect(flash[:alert]).to be_present
+ end
+ end
+
+ describe "POST /security_deposit_transactions/:id/correct" do
+ it "corrects transaction and redirects to replacement show page" do
+ res = SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 100_000,
+ occurred_on: Date.current
+ )
+ txn = res.value!.data[:transaction]
+
+ post correct_security_deposit_transaction_path(txn), params: {
+ security_deposit_transaction: {
+ amount: "1500.00",
+ occurred_on: Date.current,
+ party_id: party.id
+ }
+ }
+
+ expect(txn.reload).to be_superseded
+ replacement = txn.superseded_by
+ expect(response).to redirect_to(security_deposit_transaction_path(replacement))
+ expect(replacement.amount_cents).to eq(150_000)
+ end
+
+ it "renders correction with unprocessable_entity on invalid input" do
+ res = SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 100_000,
+ occurred_on: Date.current
+ )
+ txn = res.value!.data[:transaction]
+
+ post correct_security_deposit_transaction_path(txn), params: {
+ security_deposit_transaction: {
+ amount: "-500",
+ occurred_on: Date.current,
+ party_id: party.id
+ }
+ }
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.body).to include("Correct Deposit Received")
+ end
+
+ it "renders correction with unprocessable_entity when submitting nonexistent charge_id" do
+ SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 100_000,
+ occurred_on: Date.current
+ )
+
+ charge = Charges::CreateService.call(
+ tenancy: tenancy,
+ charge_kind: "other",
+ amount_cents: 50_000,
+ charge_date: Date.current,
+ due_on: Date.current
+ ).value!.data[:charge]
+
+ app_txn = SecurityDepositTransactions::ApplyService.call(
+ security_deposit: security_deposit,
+ charge: charge,
+ amount_cents: 50_000,
+ occurred_on: Date.current
+ ).value!.data[:transaction]
+
+ post correct_security_deposit_transaction_path(app_txn), params: {
+ security_deposit_transaction: {
+ amount: "500.00",
+ occurred_on: Date.current,
+ charge_id: 999_999
+ }
+ }
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(flash[:alert]).to include("Charge not found")
+ end
+ end
+
+ describe "POST /security_deposit_transactions/:id/void" do
+ it "voids transaction and redirects to deposit dashboard" do
+ res = SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 100_000,
+ occurred_on: Date.current
+ )
+ txn = res.value!.data[:transaction]
+
+ post void_security_deposit_transaction_path(txn), params: { reason: "Mistake" }
+ expect(response).to redirect_to(tenancy_security_deposit_path(tenancy))
+ expect(txn.reload).to be_voided
+ end
+
+ it "redirects to transaction path with alert on void failure" do
+ res = SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 100_000,
+ occurred_on: Date.new(2026, 1, 1)
+ )
+ txn = res.value!.data[:transaction]
+
+ SecurityDepositTransactions::RefundService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 100_000,
+ occurred_on: Date.new(2026, 1, 2)
+ )
+
+ # Attempting to void the receipt fails due to timeline constraint
+ post void_security_deposit_transaction_path(txn)
+ expect(response).to redirect_to(security_deposit_transaction_path(txn))
+ expect(flash[:alert]).to be_present
+ end
+ end
+end
diff --git a/spec/requests/security_deposits_spec.rb b/spec/requests/security_deposits_spec.rb
new file mode 100644
index 00000000..90433ae6
--- /dev/null
+++ b/spec/requests/security_deposits_spec.rb
@@ -0,0 +1,210 @@
+require "rails_helper"
+
+RSpec.describe "SecurityDeposits", type: :request do
+ let(:user) { create(:user) }
+ let(:property) { create(:property, user: user) }
+ let(:unit) { create(:rentable_unit, property: property) }
+ let(:tenancy) { create(:tenancy, rentable_unit: unit) }
+ let(:party) { create(:party, user: user) }
+
+ before do
+ post session_path, params: { email: user.email, password: "password" }
+ Accounting::ChartOfAccounts.ensure_for(user)
+ end
+
+ describe "GET /tenancies/:tenancy_id/security_deposit/new" do
+ it "renders the new form" do
+ get new_tenancy_security_deposit_path(tenancy)
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("Set Up Security Deposit")
+ end
+
+ it "redirects to show if deposit requirement already exists" do
+ create(:security_deposit, tenancy: tenancy)
+ get new_tenancy_security_deposit_path(tenancy)
+ expect(response).to redirect_to(tenancy_security_deposit_path(tenancy))
+ end
+ end
+
+ describe "POST /tenancies/:tenancy_id/security_deposit" do
+ it "creates security deposit requirement" do
+ expect {
+ post tenancy_security_deposit_path(tenancy), params: {
+ security_deposit: { required_amount: "2000.00", due_on: Date.current }
+ }
+ }.to change(SecurityDeposit, :count).by(1)
+
+ expect(response).to redirect_to(tenancy_security_deposit_path(tenancy))
+ deposit = tenancy.reload.security_deposit
+ expect(deposit.required_amount_cents).to eq(200_000)
+ end
+
+ it "renders new with errors on invalid input" do
+ post tenancy_security_deposit_path(tenancy), params: {
+ security_deposit: { required_amount: "-100", due_on: Date.current }
+ }
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.body).to include("Set Up Security Deposit")
+ end
+ end
+
+ describe "GET /tenancies/:tenancy_id/security_deposit" do
+ it "renders the security deposit dashboard" do
+ create(:security_deposit, tenancy: tenancy, required_amount_cents: 200_000)
+ get tenancy_security_deposit_path(tenancy)
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("Security Deposit")
+ expect(response.body).to include("Record Deposit Payment")
+ end
+
+ it "redirects to new if no deposit exists yet" do
+ get tenancy_security_deposit_path(tenancy)
+ expect(response).to redirect_to(new_tenancy_security_deposit_path(tenancy))
+ end
+ end
+
+ describe "GET /tenancies/:tenancy_id/security_deposit/edit" do
+ it "renders the edit form before transactions exist" do
+ create(:security_deposit, tenancy: tenancy, required_amount_cents: 200_000)
+ get edit_tenancy_security_deposit_path(tenancy)
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("Edit Security Deposit Requirement")
+ end
+
+ it "redirects with alert if transactions already exist" do
+ deposit = create(:security_deposit, tenancy: tenancy, required_amount_cents: 200_000)
+ SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: deposit,
+ party: party,
+ amount_cents: 100_000,
+ occurred_on: Date.current
+ )
+ get edit_tenancy_security_deposit_path(tenancy)
+ expect(response).to redirect_to(tenancy_security_deposit_path(tenancy))
+ expect(flash[:alert]).to be_present
+ end
+ end
+
+ describe "PATCH /tenancies/:tenancy_id/security_deposit" do
+ it "updates the requirement" do
+ deposit = create(:security_deposit, tenancy: tenancy, required_amount_cents: 200_000)
+ patch tenancy_security_deposit_path(tenancy), params: {
+ security_deposit: { required_amount: "2500.00", due_on: Date.current }
+ }
+ expect(response).to redirect_to(tenancy_security_deposit_path(tenancy))
+ expect(deposit.reload.required_amount_cents).to eq(250_000)
+ end
+
+ it "renders edit with error on invalid input" do
+ create(:security_deposit, tenancy: tenancy, required_amount_cents: 200_000)
+ patch tenancy_security_deposit_path(tenancy), params: {
+ security_deposit: { required_amount: "-500", due_on: Date.current }
+ }
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.body).to include("Edit Security Deposit Requirement")
+ end
+ end
+
+ describe "POST /tenancies/:tenancy_id/security_deposit/receive" do
+ it "records deposit receipt" do
+ deposit = create(:security_deposit, tenancy: tenancy, required_amount_cents: 200_000)
+
+ expect {
+ post receive_tenancy_security_deposit_path(tenancy), params: {
+ party_id: party.id,
+ amount: "1000.00",
+ occurred_on: Date.current,
+ memo: "First half"
+ }
+ }.to change(SecurityDepositTransaction, :count).by(1)
+
+ expect(response).to redirect_to(tenancy_security_deposit_path(tenancy))
+ expect(deposit.held_cents).to eq(100_000)
+ end
+
+ it "redirects with alert on failure" do
+ create(:security_deposit, tenancy: tenancy, required_amount_cents: 200_000)
+ post receive_tenancy_security_deposit_path(tenancy), params: {
+ party_id: party.id,
+ amount: "-100",
+ occurred_on: Date.current
+ }
+ expect(response).to redirect_to(tenancy_security_deposit_path(tenancy))
+ expect(flash[:alert]).to be_present
+ end
+ end
+
+ describe "POST /tenancies/:tenancy_id/security_deposit/refund" do
+ it "records deposit refund" do
+ deposit = create(:security_deposit, tenancy: tenancy, required_amount_cents: 200_000)
+ SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: deposit,
+ party: party,
+ amount_cents: 100_000,
+ occurred_on: Date.current
+ )
+
+ post refund_tenancy_security_deposit_path(tenancy), params: {
+ party_id: party.id,
+ amount: "500.00",
+ occurred_on: Date.current
+ }
+
+ expect(response).to redirect_to(tenancy_security_deposit_path(tenancy))
+ expect(deposit.held_cents).to eq(50_000)
+ end
+
+ it "redirects with alert on failure" do
+ create(:security_deposit, tenancy: tenancy, required_amount_cents: 200_000)
+ post refund_tenancy_security_deposit_path(tenancy), params: {
+ party_id: party.id,
+ amount: "500.00",
+ occurred_on: Date.current
+ }
+ expect(response).to redirect_to(tenancy_security_deposit_path(tenancy))
+ expect(flash[:alert]).to be_present
+ end
+ end
+
+ describe "POST /tenancies/:tenancy_id/security_deposit/apply" do
+ it "applies deposit to charge" do
+ deposit = create(:security_deposit, tenancy: tenancy, required_amount_cents: 200_000)
+ charge = Charges::CreateService.call(
+ tenancy: tenancy,
+ charge_kind: "other",
+ amount_cents: 50_000,
+ charge_date: Date.current,
+ due_on: Date.current,
+ description: "Repairs"
+ ).value!.data[:charge]
+
+ SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: deposit,
+ party: party,
+ amount_cents: 100_000,
+ occurred_on: Date.current
+ )
+
+ post apply_tenancy_security_deposit_path(tenancy), params: {
+ charge_id: charge.id,
+ amount: "500.00",
+ occurred_on: Date.current
+ }
+
+ expect(response).to redirect_to(tenancy_security_deposit_path(tenancy))
+ expect(deposit.held_cents).to eq(50_000)
+ expect(tenancy.current_balance_cents).to eq(0)
+ end
+
+ it "redirects with alert on failure" do
+ create(:security_deposit, tenancy: tenancy, required_amount_cents: 200_000)
+ post apply_tenancy_security_deposit_path(tenancy), params: {
+ charge_id: 999_999,
+ amount: "500.00",
+ occurred_on: Date.current
+ }
+ expect(response).to redirect_to(tenancy_security_deposit_path(tenancy))
+ expect(flash[:alert]).to be_present
+ end
+ end
+end
diff --git a/spec/services/charges/correct_service_spec.rb b/spec/services/charges/correct_service_spec.rb
index d0b21571..b6f1811b 100644
--- a/spec/services/charges/correct_service_spec.rb
+++ b/spec/services/charges/correct_service_spec.rb
@@ -279,6 +279,34 @@
expect(described_class.call(charge: original, amount_cents: 6000)).to be_failure
end
+ it "rejects correcting when active deposit applications exist" do
+ party = create(:party, user: user)
+ deposit = create(:security_deposit, tenancy: tenancy, required_amount_cents: 200_000)
+ charge = Charges::CreateService.call(
+ tenancy: tenancy,
+ charge_kind: "late_fee",
+ amount_cents: 5000,
+ charge_date: Date.new(2026, 2, 1)
+ ).value!.data[:charge]
+
+ SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: deposit,
+ party: party,
+ amount_cents: 100_000,
+ occurred_on: Date.new(2026, 1, 1)
+ )
+ SecurityDepositTransactions::ApplyService.call(
+ security_deposit: deposit,
+ charge: charge,
+ amount_cents: 5000,
+ occurred_on: Date.new(2026, 2, 5)
+ )
+
+ result = described_class.call(charge: charge, amount_cents: 6000)
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:active_deposit_applications)
+ end
+
it "corrects a rent charge with rent_term and service periods" do
term = create(:rent_term, tenancy: tenancy, amount_cents: 120_000, effective_from: Date.new(2026, 1, 1), effective_until: Date.new(2026, 12, 31))
create_res = Charges::CreateService.call(
diff --git a/spec/services/charges/void_service_spec.rb b/spec/services/charges/void_service_spec.rb
index 7bb2f316..fd242b5d 100644
--- a/spec/services/charges/void_service_spec.rb
+++ b/spec/services/charges/void_service_spec.rb
@@ -128,13 +128,25 @@
expect(result.failure.code).to eq(:not_found)
end
- it "handles reverse service failure" do
- allow(Accounting::ReverseEntryService).to receive(:call).and_return(
- ServiceResult.failure(error: "Cannot reverse", code: :reverse_failed)
+ it "rejects voiding when active deposit applications exist" do
+ party = create(:party, user: user)
+ deposit = create(:security_deposit, tenancy: tenancy, required_amount_cents: 200_000)
+ SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: deposit,
+ party: party,
+ amount_cents: 100_000,
+ occurred_on: Date.new(2026, 5, 1)
)
+ SecurityDepositTransactions::ApplyService.call(
+ security_deposit: deposit,
+ charge: charge,
+ amount_cents: 5000,
+ occurred_on: Date.new(2026, 5, 11)
+ )
+
result = described_class.call(charge: charge)
expect(result).to be_failure
- expect(result.failure.code).to eq(:reverse_failed)
+ expect(result.failure.code).to eq(:active_deposit_applications)
end
end
end
diff --git a/spec/services/expenses/correct_service_spec.rb b/spec/services/expenses/correct_service_spec.rb
index 6820b5e6..bdf5ed14 100644
--- a/spec/services/expenses/correct_service_spec.rb
+++ b/spec/services/expenses/correct_service_spec.rb
@@ -183,6 +183,26 @@
res_unit = described_class.call(expense: original, rentable_unit: other_unit)
expect(res_unit).to be_failure
expect(res_unit.failure.code).to eq(:unit_mismatch)
+
+ # Active deposit application on reimbursement charge blocks expense correction
+ party = create(:party, user: user)
+ deposit = create(:security_deposit, tenancy: tenancy, required_amount_cents: 200_000)
+ SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: deposit,
+ party: party,
+ amount_cents: 100_000,
+ occurred_on: Date.new(2026, 1, 1)
+ )
+ reimb_charge = charge_res.value!.data[:charge]
+ SecurityDepositTransactions::ApplyService.call(
+ security_deposit: deposit,
+ charge: reimb_charge,
+ amount_cents: 5000,
+ occurred_on: Date.new(2026, 1, 5)
+ )
+ res_dep = described_class.call(expense: original, amount_cents: 35_000)
+ expect(res_dep).to be_failure
+ expect(res_dep.failure.code).to eq(:active_deposit_applications)
end
it "is idempotent on identical retries" do
diff --git a/spec/services/security_deposit_transactions/accounting_posting_invariants_spec.rb b/spec/services/security_deposit_transactions/accounting_posting_invariants_spec.rb
new file mode 100644
index 00000000..65da295b
--- /dev/null
+++ b/spec/services/security_deposit_transactions/accounting_posting_invariants_spec.rb
@@ -0,0 +1,241 @@
+require "rails_helper"
+
+RSpec.describe "Security Deposit Double-Entry Posting Invariants" do
+ let(:user) { create(:user) }
+ let(:property) { create(:property, user: user) }
+ let(:unit) { create(:rentable_unit, property: property) }
+ let(:tenancy) { create(:tenancy, rentable_unit: unit) }
+ let(:party) { create(:party, user: user) }
+ let(:security_deposit) { create(:security_deposit, tenancy: tenancy, required_amount_cents: 200_000) }
+
+ before do
+ Accounting::ChartOfAccounts.ensure_for(user)
+ end
+
+ describe "double-entry postings generated by orchestrators" do
+ it "posts balanced Dr Cash (+), Cr Security Deposits Held (-) on ReceiveService" do
+ result = SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 100_000,
+ occurred_on: Date.new(2026, 1, 1),
+ memo: "Custom received memo"
+ )
+
+ expect(result).to be_success
+ entry = result.value!.data[:journal_entry]
+ expect(entry.event_type).to eq("deposit_received")
+ expect(entry.occurred_on).to eq(Date.new(2026, 1, 1))
+ expect(entry.description).to eq("Custom received memo")
+
+ postings = entry.postings.includes(:account)
+ cash_posting = postings.find { |p| p.account.key == "cash" }
+ liability_posting = postings.find { |p| p.account.key == "security_deposits_held" }
+
+ expect(cash_posting.amount_cents).to eq(100_000)
+ expect(liability_posting.amount_cents).to eq(-100_000)
+ expect(cash_posting.party).to eq(party)
+ expect(liability_posting.party).to eq(party)
+ expect(liability_posting.property_id).to eq(property.id)
+ end
+
+ it "posts balanced Dr Security Deposits Held (+), Cr Cash (-) on RefundService" do
+ SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 100_000,
+ occurred_on: Date.new(2026, 1, 1)
+ )
+
+ result = SecurityDepositTransactions::RefundService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 40_000,
+ occurred_on: Date.new(2026, 1, 5),
+ memo: "Custom refund memo"
+ )
+
+ expect(result).to be_success
+ entry = result.value!.data[:journal_entry]
+ expect(entry.event_type).to eq("deposit_refunded")
+ expect(entry.description).to eq("Custom refund memo")
+
+ postings = entry.postings.includes(:account)
+ cash_posting = postings.find { |p| p.account.key == "cash" }
+ liability_posting = postings.find { |p| p.account.key == "security_deposits_held" }
+
+ expect(liability_posting.amount_cents).to eq(40_000)
+ expect(cash_posting.amount_cents).to eq(-40_000)
+ expect(cash_posting.party).to eq(party)
+ expect(liability_posting.party).to eq(party)
+ expect(liability_posting.property_id).to eq(property.id)
+ end
+
+ it "posts balanced Dr Security Deposits Held (+), Cr Tenant Receivable (-) on ApplyService" do
+ SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 100_000,
+ occurred_on: Date.new(2026, 1, 1)
+ )
+
+ charge = Charges::CreateService.call(
+ tenancy: tenancy,
+ charge_kind: "other",
+ amount_cents: 50_000,
+ charge_date: Date.new(2026, 1, 2),
+ due_on: Date.new(2026, 1, 2),
+ description: "Door repair"
+ ).value!.data[:charge]
+
+ result = SecurityDepositTransactions::ApplyService.call(
+ security_deposit: security_deposit,
+ charge: charge,
+ amount_cents: 50_000,
+ occurred_on: Date.new(2026, 1, 5),
+ memo: "Custom apply memo"
+ )
+
+ expect(result).to be_success
+ entry = result.value!.data[:journal_entry]
+ expect(entry.event_type).to eq("deposit_applied")
+ expect(entry.description).to eq("Custom apply memo")
+
+ postings = entry.postings.includes(:account)
+ ar_posting = postings.find { |p| p.account.key == "tenant_receivable" }
+ liability_posting = postings.find { |p| p.account.key == "security_deposits_held" }
+
+ expect(liability_posting.amount_cents).to eq(50_000)
+ expect(ar_posting.amount_cents).to eq(-50_000)
+ expect(ar_posting.party).to be_nil
+ expect(liability_posting.property_id).to eq(property.id)
+ end
+
+ it "reverses and replaces balanced entries on CorrectService" do
+ rec_res = SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 100_000,
+ occurred_on: Date.new(2026, 1, 1)
+ )
+ orig_txn = rec_res.value!.data[:transaction]
+
+ correct_res = SecurityDepositTransactions::CorrectService.call(
+ transaction: orig_txn,
+ amount_cents: 120_000,
+ occurred_on: Date.new(2026, 1, 1)
+ )
+
+ expect(correct_res).to be_success
+ replacement = correct_res.value!.data[:replacement]
+ reversal_entry = correct_res.value!.data[:reversal]
+
+ expect(reversal_entry.postings.sum(:amount_cents)).to eq(0)
+ expect(replacement.journal_entries.first.postings.sum(:amount_cents)).to eq(0)
+ expect(Accounting::SecurityDepositBalanceQuery.call(tenancy: tenancy)).to eq(120_000)
+ end
+ end
+
+ describe "invariants preventing invalid postings" do
+ it "rejects refund against zero held liability" do
+ result = SecurityDepositTransactions::RefundService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 50_000,
+ occurred_on: Date.current
+ )
+
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:negative_deposit_liability)
+ expect(Accounting::SecurityDepositBalanceQuery.call(tenancy: tenancy)).to eq(0)
+ expect(JournalEntry.where(source_type: "SecurityDepositTransaction").count).to eq(0)
+ end
+
+ it "rejects application against zero held liability" do
+ charge = Charges::CreateService.call(
+ tenancy: tenancy,
+ charge_kind: "other",
+ amount_cents: 50_000,
+ charge_date: Date.current,
+ due_on: Date.current
+ ).value!.data[:charge]
+
+ result = SecurityDepositTransactions::ApplyService.call(
+ security_deposit: security_deposit,
+ charge: charge,
+ amount_cents: 50_000,
+ occurred_on: Date.current
+ )
+
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:negative_deposit_liability)
+ expect(JournalEntry.where(source_type: "SecurityDepositTransaction").count).to eq(0)
+ end
+
+ it "rejects application exceeding tenancy A/R" do
+ SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 100_000,
+ occurred_on: Date.new(2026, 1, 1)
+ )
+
+ charge = Charges::CreateService.call(
+ tenancy: tenancy,
+ charge_kind: "other",
+ amount_cents: 50_000,
+ charge_date: Date.new(2026, 1, 2),
+ due_on: Date.new(2026, 1, 2)
+ ).value!.data[:charge]
+
+ # Pay $300 towards the $500 charge so remaining A/R is only $200
+ Receipts::CreateService.call(
+ tenancy: tenancy,
+ payer_party: party,
+ amount_cents: 30_000,
+ received_on: Date.new(2026, 1, 3),
+ payment_method: "check"
+ )
+
+ result = SecurityDepositTransactions::ApplyService.call(
+ security_deposit: security_deposit,
+ charge: charge,
+ amount_cents: 30_000,
+ occurred_on: Date.new(2026, 1, 5)
+ )
+
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:exceeds_tenancy_balance)
+ expect(charge.security_deposit_applications.count).to eq(0)
+ end
+
+ it "rejects application against inactive or unposted charge" do
+ SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 100_000,
+ occurred_on: Date.new(2026, 1, 1)
+ )
+
+ charge = Charges::CreateService.call(
+ tenancy: tenancy,
+ charge_kind: "other",
+ amount_cents: 50_000,
+ charge_date: Date.new(2026, 1, 2),
+ due_on: Date.new(2026, 1, 2)
+ ).value!.data[:charge]
+
+ Charges::VoidService.call(charge: charge, reason: "Mistake")
+
+ result = SecurityDepositTransactions::ApplyService.call(
+ security_deposit: security_deposit,
+ charge: charge.reload,
+ amount_cents: 50_000,
+ occurred_on: Date.new(2026, 1, 5)
+ )
+
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:invalid_charge_state)
+ end
+ end
+end
diff --git a/spec/services/security_deposit_transactions/apply_service_spec.rb b/spec/services/security_deposit_transactions/apply_service_spec.rb
new file mode 100644
index 00000000..f278e5b8
--- /dev/null
+++ b/spec/services/security_deposit_transactions/apply_service_spec.rb
@@ -0,0 +1,215 @@
+require "rails_helper"
+
+RSpec.describe SecurityDepositTransactions::ApplyService do
+ let(:user) { create(:user) }
+ let(:property) { create(:property, user: user) }
+ let(:unit) { create(:rentable_unit, property: property) }
+ let(:tenancy) { create(:tenancy, rentable_unit: unit) }
+ let(:party) { create(:party, user: user) }
+ let(:security_deposit) { create(:security_deposit, tenancy: tenancy, required_amount_cents: 200_000) }
+ let!(:charge) do
+ res = Charges::CreateService.call(
+ tenancy: tenancy,
+ charge_kind: "other",
+ amount_cents: 50_000,
+ charge_date: Date.new(2026, 1, 1),
+ due_on: Date.new(2026, 1, 1),
+ description: "Damage repair"
+ )
+ res.value!.data[:charge]
+ end
+
+ before do
+ Accounting::ChartOfAccounts.ensure_for(user)
+ SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount: "2000.00",
+ occurred_on: Date.new(2026, 1, 1)
+ )
+ end
+
+ it "applies deposit to charge, reducing liability and tenant receivable without double income" do
+ expect(tenancy.current_balance_cents).to eq(50_000)
+ expect(security_deposit.held_cents).to eq(200_000)
+
+ result = described_class.call(
+ security_deposit: security_deposit,
+ charge: charge,
+ amount: "500.00",
+ occurred_on: Date.new(2026, 1, 10),
+ memo: "Applied to damage"
+ )
+
+ expect(result).to be_success
+ txn = result.value!.data[:transaction]
+ expect(txn.amount_cents).to eq(50_000)
+ expect(txn).to be_applied
+ expect(security_deposit.held_cents).to eq(150_000)
+ expect(tenancy.current_balance_cents).to eq(0)
+ expect(charge.deposit_applied_cents).to eq(50_000)
+ expect(charge.remaining_deposit_application_cents).to eq(0)
+ end
+
+ it "accepts charge_id, numeric amount, integer cents, and string date" do
+ c2 = Charges::CreateService.call(
+ tenancy: tenancy,
+ charge_kind: "other",
+ amount_cents: 50_000,
+ charge_date: Date.new(2026, 1, 1),
+ due_on: Date.new(2026, 1, 1)
+ ).value!.data[:charge]
+
+ res = described_class.call(
+ security_deposit: security_deposit,
+ charge_id: c2.id,
+ amount: 250,
+ occurred_on: "2026-01-12"
+ )
+ expect(res).to be_success
+ expect(res.value!.data[:transaction].amount_cents).to eq(25_000)
+
+ c3 = Charges::CreateService.call(
+ tenancy: tenancy,
+ charge_kind: "other",
+ amount_cents: 50_000,
+ charge_date: Date.new(2026, 1, 1),
+ due_on: Date.new(2026, 1, 1)
+ ).value!.data[:charge]
+
+ res3 = described_class.call(
+ security_deposit: security_deposit,
+ charge: c3,
+ amount_cents: 15_000,
+ occurred_on: Date.current
+ )
+ expect(res3).to be_success
+ expect(res3.value!.data[:transaction].amount_cents).to eq(15_000)
+ end
+
+ it "rejects string amount_cents" do
+ res = described_class.call(
+ security_deposit: security_deposit,
+ charge: charge,
+ amount_cents: "15000",
+ occurred_on: Date.current
+ )
+ expect(res).to be_failure
+ expect(res.failure.code).to eq(:invalid_input)
+ end
+
+ it "rejects application exceeding charge remaining capacity" do
+ result = described_class.call(
+ security_deposit: security_deposit,
+ charge: charge,
+ amount: "600.00",
+ occurred_on: Date.new(2026, 1, 10)
+ )
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:exceeds_charge_capacity)
+ end
+
+ it "rejects application exceeding tenancy outstanding balance" do
+ # Record payment of $300 so tenancy balance is only $200
+ Receipts::CreateService.call(
+ tenancy: tenancy,
+ payer_party: party,
+ amount_cents: 30_000,
+ received_on: Date.new(2026, 1, 5),
+ payment_method: "check"
+ )
+ expect(tenancy.current_balance_cents).to eq(20_000)
+
+ result = described_class.call(
+ security_deposit: security_deposit,
+ charge: charge,
+ amount: "300.00",
+ occurred_on: Date.new(2026, 1, 10)
+ )
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:exceeds_tenancy_balance)
+ end
+
+ it "rejects application dated before the charge date" do
+ future_charge = Charges::CreateService.call(
+ tenancy: tenancy,
+ charge_kind: "other",
+ amount_cents: 50_000,
+ charge_date: Date.new(2026, 1, 10),
+ due_on: Date.new(2026, 1, 10),
+ description: "Future repair"
+ ).value!.data[:charge]
+
+ early_res = described_class.call(
+ security_deposit: security_deposit,
+ charge: future_charge,
+ amount: "200.00",
+ occurred_on: Date.new(2026, 1, 5)
+ )
+ expect(early_res).to be_failure
+ expect(early_res.failure.code).to eq(:precedes_charge_date)
+
+ on_date_res = described_class.call(
+ security_deposit: security_deposit,
+ charge: future_charge,
+ amount: "200.00",
+ occurred_on: Date.new(2026, 1, 10)
+ )
+ expect(on_date_res).to be_success
+
+ after_date_res = described_class.call(
+ security_deposit: security_deposit,
+ charge: future_charge,
+ amount: "200.00",
+ occurred_on: Date.new(2026, 1, 15)
+ )
+ expect(after_date_res).to be_success
+ end
+
+ it "rejects application to an inactive or cross-tenancy charge" do
+ other_unit = create(:rentable_unit, property: property)
+ other_tenancy = create(:tenancy, rentable_unit: other_unit)
+ other_charge = create(:charge, :posted, tenancy: other_tenancy, amount_cents: 50_000, charge_date: Date.current)
+
+ res1 = described_class.call(
+ security_deposit: security_deposit,
+ charge: other_charge,
+ amount: "100.00",
+ occurred_on: Date.current
+ )
+ expect(res1).to be_failure
+ expect(res1.failure.code).to eq(:tenancy_mismatch)
+
+ void_res = Charges::VoidService.call(charge: charge)
+ expect(void_res).to be_success
+
+ res2 = described_class.call(
+ security_deposit: security_deposit,
+ charge: charge,
+ amount: "100.00",
+ occurred_on: Date.current
+ )
+ expect(res2).to be_failure
+ expect(res2.failure.code).to eq(:invalid_charge_state)
+ end
+
+ it "rejects future date, missing date, or invalid inputs" do
+ expect(described_class.call(security_deposit: security_deposit, charge: charge, amount: "100", occurred_on: Date.tomorrow).failure.code).to eq(:invalid_date)
+ expect(described_class.call(security_deposit: security_deposit, charge: charge, amount: "100", occurred_on: nil).failure.code).to eq(:invalid_input)
+ expect(described_class.call(security_deposit: security_deposit, charge: charge, amount: "100", occurred_on: "").failure.code).to eq(:invalid_input)
+ expect(described_class.call(security_deposit: security_deposit, charge: charge, amount: "100", occurred_on: "bad").failure.code).to eq(:invalid_input)
+ expect(described_class.call(security_deposit: SecurityDeposit.new, charge: charge, amount: "100", occurred_on: Date.current).failure.code).to eq(:invalid_deposit)
+ expect(described_class.call(security_deposit: security_deposit, charge: Charge.new, amount: "100", occurred_on: Date.current).failure.code).to eq(:invalid_charge)
+ expect(described_class.call(security_deposit: security_deposit, charge: charge, amount: "-50", occurred_on: Date.current).failure.code).to eq(:invalid_input)
+ expect(described_class.call(security_deposit: security_deposit, charge: charge, amount: nil, amount_cents: nil, occurred_on: Date.current).failure.code).to eq(:invalid_input)
+ end
+
+ it "handles posting failure gracefully" do
+ allow(Accounting::PostEntryService).to receive(:call).and_return(
+ ServiceResult.failure(error: "Posting error", code: :post_failed)
+ )
+ res = described_class.call(security_deposit: security_deposit, charge: charge, amount: "100", occurred_on: Date.current)
+ expect(res).to be_failure
+ expect(res.failure.code).to eq(:post_failed)
+ end
+end
diff --git a/spec/services/security_deposit_transactions/correct_service_spec.rb b/spec/services/security_deposit_transactions/correct_service_spec.rb
new file mode 100644
index 00000000..01091cf1
--- /dev/null
+++ b/spec/services/security_deposit_transactions/correct_service_spec.rb
@@ -0,0 +1,541 @@
+require "rails_helper"
+
+RSpec.describe SecurityDepositTransactions::CorrectService do
+ let(:user) { create(:user) }
+ let(:property) { create(:property, user: user) }
+ let(:unit) { create(:rentable_unit, property: property) }
+ let(:tenancy) { create(:tenancy, rentable_unit: unit) }
+ let(:party) { create(:party, user: user) }
+ let(:party2) { create(:party, user: user) }
+ let(:security_deposit) { create(:security_deposit, tenancy: tenancy, required_amount_cents: 200_000) }
+
+ before do
+ Accounting::ChartOfAccounts.ensure_for(user)
+ end
+
+ describe "correcting a received transaction" do
+ it "reverses original at original date, creates replacement, links superseded_by, and updates balance" do
+ res = SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 100_000,
+ occurred_on: Date.new(2026, 1, 1)
+ )
+ orig_txn = res.value!.data[:transaction]
+
+ correct_res = described_class.call(
+ transaction: orig_txn,
+ amount_cents: 150_000,
+ occurred_on: Date.new(2026, 1, 1)
+ )
+
+ expect(correct_res).to be_success
+ replacement = correct_res.value!.data[:replacement]
+ expect(replacement.amount_cents).to eq(150_000)
+ expect(orig_txn.reload).to be_superseded
+ expect(orig_txn.superseded_by).to eq(replacement)
+ expect(security_deposit.held_cents).to eq(150_000)
+ end
+
+ it "allows clearing memo and external_reference" do
+ res = SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 100_000,
+ occurred_on: Date.new(2026, 1, 1),
+ external_reference: "CHK99",
+ memo: "Initial memo"
+ )
+ orig_txn = res.value!.data[:transaction]
+
+ correct_res = described_class.call(
+ transaction: orig_txn,
+ external_reference: "",
+ memo: ""
+ )
+
+ expect(correct_res).to be_success
+ rep = correct_res.value!.data[:replacement]
+ expect(rep.external_reference).to be_nil
+ expect(rep.memo).to be_nil
+ end
+
+ it "accepts integer amount_cents and rejects string amount_cents" do
+ res = SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 100_000,
+ occurred_on: Date.new(2026, 1, 1)
+ )
+ orig_txn = res.value!.data[:transaction]
+
+ valid_res = described_class.call(
+ transaction: orig_txn,
+ amount_cents: 120_000
+ )
+ expect(valid_res).to be_success
+
+ invalid_res = described_class.call(
+ transaction: valid_res.value!.data[:replacement],
+ amount_cents: "130000"
+ )
+ expect(invalid_res).to be_failure
+ expect(invalid_res.failure.code).to eq(:invalid_input)
+ end
+
+ it "rejects invalid party_id rather than falling back to old party" do
+ res = SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 100_000,
+ occurred_on: Date.new(2026, 1, 1)
+ )
+ orig_txn = res.value!.data[:transaction]
+
+ correct_res = described_class.call(
+ transaction: orig_txn,
+ party_id: 999_999
+ )
+
+ expect(correct_res).to be_failure
+ expect(correct_res.failure.code).to eq(:invalid_party)
+ expect(orig_txn.reload).not_to be_superseded
+ end
+
+ it "is idempotent on identical retry" do
+ res = SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 100_000,
+ occurred_on: Date.new(2026, 1, 1)
+ )
+ orig_txn = res.value!.data[:transaction]
+
+ c1 = described_class.call(transaction: orig_txn, amount_cents: 120_000)
+ expect(c1).to be_success
+
+ c2 = described_class.call(transaction: orig_txn, amount_cents: 120_000)
+ expect(c2).to be_success
+ expect(c2.value!.data[:replacement].id).to eq(c1.value!.data[:replacement].id)
+ end
+
+ it "returns idempotency_conflict when retried with different parameters" do
+ res = SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 100_000,
+ occurred_on: Date.new(2026, 1, 1)
+ )
+ orig_txn = res.value!.data[:transaction]
+
+ described_class.call(transaction: orig_txn, amount_cents: 120_000)
+ c2 = described_class.call(transaction: orig_txn, amount_cents: 130_000)
+ expect(c2).to be_failure
+ expect(c2.failure.code).to eq(:idempotency_conflict)
+ end
+
+ it "rejects correction of voided transaction" do
+ res = SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 100_000,
+ occurred_on: Date.new(2026, 1, 1)
+ )
+ orig_txn = res.value!.data[:transaction]
+ SecurityDepositTransactions::VoidService.call(transaction: orig_txn)
+
+ correct_res = described_class.call(transaction: orig_txn, amount_cents: 120_000)
+ expect(correct_res).to be_failure
+ expect(correct_res.failure.code).to eq(:already_voided)
+ end
+ end
+
+ describe "correcting a refunded transaction" do
+ it "replaces refund with updated amount and party" do
+ SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 200_000,
+ occurred_on: Date.new(2026, 1, 1)
+ )
+
+ ref_res = SecurityDepositTransactions::RefundService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 50_000,
+ occurred_on: Date.new(2026, 1, 5)
+ )
+ ref_txn = ref_res.value!.data[:transaction]
+
+ correct_res = described_class.call(
+ transaction: ref_txn,
+ amount_cents: 80_000,
+ party: party2,
+ memo: "Updated refund to roommate"
+ )
+
+ expect(correct_res).to be_success
+ expect(security_deposit.held_cents).to eq(120_000)
+ expect(correct_res.value!.data[:replacement].party).to eq(party2)
+ end
+ end
+
+ describe "correcting an applied transaction" do
+ it "replaces application with updated charge and amount" do
+ SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 200_000,
+ occurred_on: Date.new(2026, 1, 1)
+ )
+
+ charge1 = Charges::CreateService.call(
+ tenancy: tenancy,
+ charge_kind: "other",
+ amount_cents: 50_000,
+ charge_date: Date.new(2026, 1, 2),
+ due_on: Date.new(2026, 1, 2)
+ ).value!.data[:charge]
+
+ charge2 = Charges::CreateService.call(
+ tenancy: tenancy,
+ charge_kind: "other",
+ amount_cents: 70_000,
+ charge_date: Date.new(2026, 1, 2),
+ due_on: Date.new(2026, 1, 2)
+ ).value!.data[:charge]
+
+ app_res = SecurityDepositTransactions::ApplyService.call(
+ security_deposit: security_deposit,
+ charge: charge1,
+ amount_cents: 50_000,
+ occurred_on: Date.new(2026, 1, 5)
+ )
+ app_txn = app_res.value!.data[:transaction]
+
+ correct_res = described_class.call(
+ transaction: app_txn,
+ charge: charge2,
+ amount_cents: 60_000,
+ occurred_on: Date.new(2026, 1, 5)
+ )
+
+ expect(correct_res).to be_success
+ expect(charge1.deposit_applied_cents).to eq(0)
+ expect(charge2.deposit_applied_cents).to eq(60_000)
+ expect(security_deposit.held_cents).to eq(140_000)
+ end
+
+ it "rejects invalid charge_id rather than falling back to old charge" do
+ SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 200_000,
+ occurred_on: Date.new(2026, 1, 1)
+ )
+
+ charge = Charges::CreateService.call(
+ tenancy: tenancy,
+ charge_kind: "other",
+ amount_cents: 50_000,
+ charge_date: Date.new(2026, 1, 2),
+ due_on: Date.new(2026, 1, 2)
+ ).value!.data[:charge]
+
+ app_res = SecurityDepositTransactions::ApplyService.call(
+ security_deposit: security_deposit,
+ charge: charge,
+ amount_cents: 50_000,
+ occurred_on: Date.new(2026, 1, 5)
+ )
+ app_txn = app_res.value!.data[:transaction]
+
+ correct_res = described_class.call(
+ transaction: app_txn,
+ charge_id: 999_999
+ )
+
+ expect(correct_res).to be_failure
+ expect(correct_res.failure.code).to eq(:invalid_charge)
+ end
+
+ it "rejects correcting application date to precede target charge date" do
+ SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 200_000,
+ occurred_on: Date.new(2026, 1, 1)
+ )
+
+ future_charge = Charges::CreateService.call(
+ tenancy: tenancy,
+ charge_kind: "other",
+ amount_cents: 50_000,
+ charge_date: Date.new(2026, 1, 10),
+ due_on: Date.new(2026, 1, 10)
+ ).value!.data[:charge]
+
+ app_txn = SecurityDepositTransactions::ApplyService.call(
+ security_deposit: security_deposit,
+ charge: future_charge,
+ amount_cents: 50_000,
+ occurred_on: Date.new(2026, 1, 12)
+ ).value!.data[:transaction]
+
+ early_res = described_class.call(
+ transaction: app_txn,
+ occurred_on: Date.new(2026, 1, 8)
+ )
+ expect(early_res).to be_failure
+ expect(early_res.failure.code).to eq(:precedes_charge_date)
+
+ valid_res = described_class.call(
+ transaction: app_txn,
+ occurred_on: Date.new(2026, 1, 10)
+ )
+ expect(valid_res).to be_success
+ end
+
+ it "rejects application exceeding charge capacity or tenancy AR" do
+ SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 200_000,
+ occurred_on: Date.new(2026, 1, 1)
+ )
+
+ charge = Charges::CreateService.call(
+ tenancy: tenancy,
+ charge_kind: "other",
+ amount_cents: 50_000,
+ charge_date: Date.new(2026, 1, 2),
+ due_on: Date.new(2026, 1, 2)
+ ).value!.data[:charge]
+
+ app_res = SecurityDepositTransactions::ApplyService.call(
+ security_deposit: security_deposit,
+ charge: charge,
+ amount_cents: 30_000,
+ occurred_on: Date.new(2026, 1, 5)
+ )
+ app_txn = app_res.value!.data[:transaction]
+
+ # Exceeds charge amount ($500)
+ res_cap = described_class.call(transaction: app_txn, amount_cents: 60_000)
+ expect(res_cap).to be_failure
+ expect(res_cap.failure.code).to eq(:exceeds_charge_capacity)
+
+ # Target charge inactive or tenancy mismatch
+ other_unit = create(:rentable_unit, property: property)
+ other_tenancy = create(:tenancy, rentable_unit: other_unit)
+ other_charge = create(:charge, :posted, tenancy: other_tenancy, amount_cents: 50_000)
+
+ res_mis = described_class.call(transaction: app_txn, charge: other_charge)
+ expect(res_mis).to be_failure
+ expect(res_mis.failure.code).to eq(:tenancy_mismatch)
+
+ voided_charge = Charges::CreateService.call(
+ tenancy: tenancy,
+ charge_kind: "other",
+ amount_cents: 50_000,
+ charge_date: Date.new(2026, 1, 2),
+ due_on: Date.new(2026, 1, 2)
+ ).value!.data[:charge]
+ Charges::VoidService.call(charge: voided_charge)
+ res_inact = described_class.call(transaction: app_txn, charge: voided_charge.reload)
+ expect(res_inact).to be_failure
+ expect(res_inact.failure.code).to eq(:invalid_charge_state)
+ end
+
+ it "rejects correction resulting in negative deposit timeline" do
+ rec_res = SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 100_000,
+ occurred_on: Date.new(2026, 1, 1)
+ )
+ rec_txn = rec_res.value!.data[:transaction]
+
+ SecurityDepositTransactions::RefundService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 80_000,
+ occurred_on: Date.new(2026, 1, 5)
+ )
+
+ # Reducing receipt from $1,000 to $500 makes Jan 5 refund exceed held balance
+ res_timeline = described_class.call(transaction: rec_txn, amount_cents: 50_000)
+ expect(res_timeline).to be_failure
+ expect(res_timeline.failure.code).to eq(:negative_deposit_liability)
+ end
+ end
+
+ describe "concurrency serialization" do
+ it "safely serializes concurrent refunds" do
+ SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 200_000,
+ occurred_on: Date.new(2026, 1, 1)
+ )
+
+ res1 = nil
+ res2 = nil
+
+ t1 = Thread.new do
+ ActiveRecord::Base.connection_pool.with_connection do
+ res1 = SecurityDepositTransactions::RefundService.call(
+ security_deposit: SecurityDeposit.find(security_deposit.id),
+ party: Party.find(party.id),
+ amount_cents: 150_000,
+ occurred_on: Date.current
+ )
+ end
+ end
+
+ t2 = Thread.new do
+ ActiveRecord::Base.connection_pool.with_connection do
+ res2 = SecurityDepositTransactions::RefundService.call(
+ security_deposit: SecurityDeposit.find(security_deposit.id),
+ party: Party.find(party.id),
+ amount_cents: 150_000,
+ occurred_on: Date.current
+ )
+ end
+ end
+
+ [ t1, t2 ].each(&:join)
+
+ successes = [ res1, res2 ].count(&:success?)
+ failures = [ res1, res2 ].count(&:failure?)
+
+ expect(successes).to eq(1)
+ expect(failures).to eq(1)
+ expect(security_deposit.held_cents).to eq(50_000)
+ end
+
+ it "safely serializes concurrent refund vs application" do
+ SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 50_000,
+ occurred_on: Date.new(2026, 1, 1)
+ )
+
+ charge = Charges::CreateService.call(
+ tenancy: tenancy,
+ charge_kind: "other",
+ amount_cents: 50_000,
+ charge_date: Date.new(2026, 1, 1),
+ due_on: Date.new(2026, 1, 1),
+ description: "Repair"
+ ).value!.data[:charge]
+
+ refund_res = nil
+ apply_res = nil
+
+ t1 = Thread.new do
+ ActiveRecord::Base.connection_pool.with_connection do
+ refund_res = SecurityDepositTransactions::RefundService.call(
+ security_deposit: SecurityDeposit.find(security_deposit.id),
+ party: Party.find(party.id),
+ amount_cents: 50_000,
+ occurred_on: Date.current
+ )
+ end
+ end
+
+ t2 = Thread.new do
+ ActiveRecord::Base.connection_pool.with_connection do
+ apply_res = SecurityDepositTransactions::ApplyService.call(
+ security_deposit: SecurityDeposit.find(security_deposit.id),
+ charge: Charge.find(charge.id),
+ amount_cents: 50_000,
+ occurred_on: Date.current
+ )
+ end
+ end
+
+ [ t1, t2 ].each(&:join)
+
+ successes = [ refund_res, apply_res ].count(&:success?)
+ failures = [ refund_res, apply_res ].count(&:failure?)
+
+ expect(successes).to eq(1)
+ expect(failures).to eq(1)
+ expect(security_deposit.held_cents).to eq(0)
+ end
+
+ it "safely serializes concurrent application correction to Charge B vs Charge B void" do
+ SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 200_000,
+ occurred_on: Date.new(2026, 1, 1)
+ )
+
+ charge_a = Charges::CreateService.call(
+ tenancy: tenancy,
+ charge_kind: "other",
+ amount_cents: 50_000,
+ charge_date: Date.new(2026, 1, 2),
+ due_on: Date.new(2026, 1, 2)
+ ).value!.data[:charge]
+
+ charge_b = Charges::CreateService.call(
+ tenancy: tenancy,
+ charge_kind: "other",
+ amount_cents: 50_000,
+ charge_date: Date.new(2026, 1, 2),
+ due_on: Date.new(2026, 1, 2)
+ ).value!.data[:charge]
+
+ app_txn = SecurityDepositTransactions::ApplyService.call(
+ security_deposit: security_deposit,
+ charge: charge_a,
+ amount_cents: 50_000,
+ occurred_on: Date.new(2026, 1, 5)
+ ).value!.data[:transaction]
+
+ correct_res = nil
+ void_res = nil
+
+ t1 = Thread.new do
+ ActiveRecord::Base.connection_pool.with_connection do
+ correct_res = SecurityDepositTransactions::CorrectService.call(
+ transaction: SecurityDepositTransaction.find(app_txn.id),
+ charge: Charge.find(charge_b.id),
+ amount_cents: 50_000,
+ occurred_on: Date.new(2026, 1, 5)
+ )
+ end
+ end
+
+ t2 = Thread.new do
+ ActiveRecord::Base.connection_pool.with_connection do
+ void_res = Charges::VoidService.call(
+ charge: Charge.find(charge_b.id),
+ reason: "Voiding Charge B"
+ )
+ end
+ end
+
+ [ t1, t2 ].each(&:join)
+
+ if correct_res.success?
+ # Correction won: Charge B has active application, so voiding Charge B failed
+ expect(void_res).to be_failure
+ expect(void_res.failure.code).to eq(:active_deposit_applications)
+ expect(charge_b.reload).not_to be_voided
+ expect(charge_b.security_deposit_applications.active.count).to eq(1)
+ else
+ # Void won: Charge B was voided, so correction failed with invalid charge state
+ expect(void_res).to be_success
+ expect(charge_b.reload).to be_voided
+ expect(correct_res).to be_failure
+ expect(correct_res.failure.code).to eq(:invalid_charge_state)
+ expect(charge_b.security_deposit_applications.active.count).to eq(0)
+ end
+ end
+ end
+end
diff --git a/spec/services/security_deposit_transactions/receive_service_spec.rb b/spec/services/security_deposit_transactions/receive_service_spec.rb
new file mode 100644
index 00000000..2cf560b8
--- /dev/null
+++ b/spec/services/security_deposit_transactions/receive_service_spec.rb
@@ -0,0 +1,107 @@
+require "rails_helper"
+
+RSpec.describe SecurityDepositTransactions::ReceiveService do
+ let(:user) { create(:user) }
+ let(:property) { create(:property, user: user) }
+ let(:unit) { create(:rentable_unit, property: property) }
+ let(:tenancy) { create(:tenancy, rentable_unit: unit) }
+ let(:party) { create(:party, user: user) }
+ let(:security_deposit) { create(:security_deposit, tenancy: tenancy, required_amount_cents: 200_000) }
+
+ before do
+ Accounting::ChartOfAccounts.ensure_for(user)
+ end
+
+ it "records a deposit receipt, posts double-entry journal entry, and updates held liability" do
+ result = described_class.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount: "1500.00",
+ occurred_on: Date.current,
+ memo: "First installment",
+ external_reference: "CHK1001"
+ )
+
+ expect(result).to be_success
+ txn = result.value!.data[:transaction]
+ expect(txn.amount_cents).to eq(150_000)
+ expect(txn).to be_posted
+ expect(txn).to be_received
+ expect(txn.external_reference).to eq("CHK1001")
+ expect(security_deposit.held_cents).to eq(150_000)
+ expect(tenancy.current_balance_cents).to eq(0) # Receivable unchanged!
+ end
+
+ it "accepts numeric amount, integer cents, string date, and party_id" do
+ res = described_class.call(
+ security_deposit: security_deposit,
+ party_id: party.id,
+ amount: 1000,
+ occurred_on: "2026-01-01"
+ )
+ expect(res).to be_success
+ expect(res.value!.data[:transaction].amount_cents).to eq(100_000)
+
+ res2 = described_class.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 50_000,
+ occurred_on: Date.current
+ )
+ expect(res2).to be_success
+ expect(res2.value!.data[:transaction].amount_cents).to eq(50_000)
+ end
+
+ it "rejects string amount_cents" do
+ res = described_class.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: "50000",
+ occurred_on: Date.current
+ )
+ expect(res).to be_failure
+ expect(res.failure.code).to eq(:invalid_input)
+ end
+
+ it "rejects future occurred_on or invalid party" do
+ res1 = described_class.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount: "1000.00",
+ occurred_on: Date.tomorrow
+ )
+ expect(res1).to be_failure
+ expect(res1.failure.code).to eq(:invalid_date)
+
+ other_user = create(:user)
+ other_party = create(:party, user: other_user)
+ res2 = described_class.call(
+ security_deposit: security_deposit,
+ party: other_party,
+ amount: "1000.00",
+ occurred_on: Date.current
+ )
+ expect(res2).to be_failure
+ expect(res2.failure.code).to eq(:party_user_mismatch)
+ end
+
+ it "rejects invalid amount, unpersisted deposit, unpersisted party, or missing occurred_on" do
+ expect(described_class.call(security_deposit: security_deposit, party: party, amount: "-10", occurred_on: Date.current).failure.code).to eq(:invalid_input)
+ expect(described_class.call(security_deposit: security_deposit, party: party, amount: "invalid", occurred_on: Date.current).failure.code).to eq(:invalid_input)
+ expect(described_class.call(security_deposit: security_deposit, party: party, amount: nil, amount_cents: nil, occurred_on: Date.current).failure.code).to eq(:invalid_input)
+ expect(described_class.call(security_deposit: security_deposit, party: party, amount: "100", occurred_on: nil).failure.code).to eq(:invalid_input)
+ expect(described_class.call(security_deposit: security_deposit, party: party, amount: "100", occurred_on: "").failure.code).to eq(:invalid_input)
+ expect(described_class.call(security_deposit: security_deposit, party: party, amount: "100", occurred_on: "bad-date").failure.code).to eq(:invalid_input)
+ expect(described_class.call(security_deposit: SecurityDeposit.new, party: party, amount: "100", occurred_on: Date.current).failure.code).to eq(:invalid_deposit)
+ expect(described_class.call(security_deposit: security_deposit, party: Party.new, amount: "100", occurred_on: Date.current).failure.code).to eq(:invalid_party)
+ end
+
+ it "handles posting failure gracefully" do
+ allow(Accounting::PostEntryService).to receive(:call).and_return(
+ ServiceResult.failure(error: "Posting error", code: :post_failed)
+ )
+ res = described_class.call(security_deposit: security_deposit, party: party, amount: "100", occurred_on: Date.current)
+ expect(res).to be_failure
+ expect(res.failure.code).to eq(:post_failed)
+ end
+end
diff --git a/spec/services/security_deposit_transactions/refund_service_spec.rb b/spec/services/security_deposit_transactions/refund_service_spec.rb
new file mode 100644
index 00000000..53f80558
--- /dev/null
+++ b/spec/services/security_deposit_transactions/refund_service_spec.rb
@@ -0,0 +1,107 @@
+require "rails_helper"
+
+RSpec.describe SecurityDepositTransactions::RefundService do
+ let(:user) { create(:user) }
+ let(:property) { create(:property, user: user) }
+ let(:unit) { create(:rentable_unit, property: property) }
+ let(:tenancy) { create(:tenancy, rentable_unit: unit) }
+ let(:party) { create(:party, user: user) }
+ let(:security_deposit) { create(:security_deposit, tenancy: tenancy, required_amount_cents: 200_000) }
+
+ before do
+ Accounting::ChartOfAccounts.ensure_for(user)
+ SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount: "2000.00",
+ occurred_on: Date.new(2026, 1, 1)
+ )
+ end
+
+ it "refunds held deposit, reduces liability, and leaves tenancy receivable unchanged" do
+ result = described_class.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount: "750.00",
+ occurred_on: Date.new(2026, 1, 15),
+ external_reference: "CHK500",
+ memo: "Partial refund"
+ )
+
+ expect(result).to be_success
+ txn = result.value!.data[:transaction]
+ expect(txn.amount_cents).to eq(75_000)
+ expect(txn).to be_posted
+ expect(txn).to be_refunded
+ expect(txn.external_reference).to eq("CHK500")
+ expect(security_deposit.held_cents).to eq(125_000)
+ expect(tenancy.current_balance_cents).to eq(0)
+ end
+
+ it "accepts party_id, numeric amount, integer cents, and string date" do
+ res = described_class.call(
+ security_deposit: security_deposit,
+ party_id: party.id,
+ amount: 250,
+ occurred_on: "2026-01-20"
+ )
+ expect(res).to be_success
+ expect(res.value!.data[:transaction].amount_cents).to eq(25_000)
+
+ res2 = described_class.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 10_000,
+ occurred_on: Date.current
+ )
+ expect(res2).to be_success
+ expect(res2.value!.data[:transaction].amount_cents).to eq(10_000)
+ end
+
+ it "rejects string amount_cents" do
+ res = described_class.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: "10000",
+ occurred_on: Date.current
+ )
+ expect(res).to be_failure
+ expect(res.failure.code).to eq(:invalid_input)
+ end
+
+ it "rejects refund exceeding held liability" do
+ result = described_class.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount: "2500.00",
+ occurred_on: Date.new(2026, 1, 15)
+ )
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:negative_deposit_liability)
+ end
+
+ it "rejects future date, user mismatch, missing date, or invalid inputs" do
+ expect(described_class.call(security_deposit: security_deposit, party: party, amount: "100", occurred_on: Date.tomorrow).failure.code).to eq(:invalid_date)
+ expect(described_class.call(security_deposit: security_deposit, party: party, amount: "100", occurred_on: nil).failure.code).to eq(:invalid_input)
+ expect(described_class.call(security_deposit: security_deposit, party: party, amount: "100", occurred_on: "").failure.code).to eq(:invalid_input)
+ expect(described_class.call(security_deposit: security_deposit, party: party, amount: "100", occurred_on: "bad").failure.code).to eq(:invalid_input)
+
+ other_user = create(:user)
+ other_party = create(:party, user: other_user)
+ expect(described_class.call(security_deposit: security_deposit, party: other_party, amount: "100", occurred_on: Date.current).failure.code).to eq(:party_user_mismatch)
+
+ expect(described_class.call(security_deposit: SecurityDeposit.new, party: party, amount: "100", occurred_on: Date.current).failure.code).to eq(:invalid_deposit)
+ expect(described_class.call(security_deposit: security_deposit, party: Party.new, amount: "100", occurred_on: Date.current).failure.code).to eq(:invalid_party)
+ expect(described_class.call(security_deposit: security_deposit, party: party, amount: "-50", occurred_on: Date.current).failure.code).to eq(:invalid_input)
+ expect(described_class.call(security_deposit: security_deposit, party: party, amount: nil, amount_cents: nil, occurred_on: Date.current).failure.code).to eq(:invalid_input)
+ end
+
+ it "handles posting failure gracefully" do
+ allow(Accounting::PostEntryService).to receive(:call).and_return(
+ ServiceResult.failure(error: "Posting error", code: :post_failed)
+ )
+ res = described_class.call(security_deposit: security_deposit, party: party, amount: "100", occurred_on: Date.current)
+ expect(res).to be_failure
+ expect(res.failure.code).to eq(:post_failed)
+ end
+end
diff --git a/spec/services/security_deposit_transactions/void_service_spec.rb b/spec/services/security_deposit_transactions/void_service_spec.rb
new file mode 100644
index 00000000..019bd371
--- /dev/null
+++ b/spec/services/security_deposit_transactions/void_service_spec.rb
@@ -0,0 +1,171 @@
+require "rails_helper"
+
+RSpec.describe SecurityDepositTransactions::VoidService do
+ let(:user) { create(:user) }
+ let(:property) { create(:property, user: user) }
+ let(:unit) { create(:rentable_unit, property: property) }
+ let(:tenancy) { create(:tenancy, rentable_unit: unit) }
+ let(:party) { create(:party, user: user) }
+ let(:security_deposit) { create(:security_deposit, tenancy: tenancy, required_amount_cents: 200_000) }
+
+ before do
+ Accounting::ChartOfAccounts.ensure_for(user)
+ end
+
+ describe "voiding a received transaction" do
+ it "reverses journal entry on original occurred_on and marks transaction voided" do
+ res = SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 100_000,
+ occurred_on: Date.new(2026, 1, 1)
+ )
+ txn = res.value!.data[:transaction]
+ expect(security_deposit.held_cents).to eq(100_000)
+
+ void_res = described_class.call(transaction: txn, reason: "Entered in error")
+ expect(void_res).to be_success
+ expect(txn.reload).to be_voided
+ expect(security_deposit.held_cents).to eq(0)
+
+ reversal = void_res.value!.data[:journal_entry]
+ expect(reversal.occurred_on).to eq(Date.new(2026, 1, 1))
+ expect(reversal.description).to eq("Entered in error")
+ end
+
+ it "is idempotent on identical retry" do
+ res = SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 100_000,
+ occurred_on: Date.new(2026, 1, 1)
+ )
+ txn = res.value!.data[:transaction]
+
+ void1 = described_class.call(transaction: txn, reason: "Duplicate")
+ expect(void1).to be_success
+
+ void2 = described_class.call(transaction: txn, reason: "Duplicate")
+ expect(void2).to be_success
+ expect(void2.value!.data[:journal_entry].id).to eq(void1.value!.data[:journal_entry].id)
+ end
+
+ it "returns idempotency_conflict when retried with different reason" do
+ res = SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 100_000,
+ occurred_on: Date.new(2026, 1, 1)
+ )
+ txn = res.value!.data[:transaction]
+
+ described_class.call(transaction: txn, reason: "Reason A")
+ void2 = described_class.call(transaction: txn, reason: "Reason B")
+ expect(void2).to be_failure
+ expect(void2.failure.code).to eq(:idempotency_conflict)
+ end
+
+ it "rejects voiding if subsequent withdrawals would create negative liability" do
+ # Jan 1: Receive $1000
+ res1 = SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 100_000,
+ occurred_on: Date.new(2026, 1, 1)
+ )
+ txn1 = res1.value!.data[:transaction]
+
+ # Feb 1: Refund $600
+ SecurityDepositTransactions::RefundService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 60_000,
+ occurred_on: Date.new(2026, 2, 1)
+ )
+
+ # Attempt to void Jan 1 receipt
+ void_res = described_class.call(transaction: txn1)
+ expect(void_res).to be_failure
+ expect(void_res.failure.code).to eq(:negative_deposit_liability)
+ end
+ end
+
+ describe "voiding an applied transaction" do
+ it "restores tenant receivable and deposit held balance, and unblocks charge voiding" do
+ charge = Charges::CreateService.call(
+ tenancy: tenancy,
+ charge_kind: "other",
+ amount_cents: 50_000,
+ charge_date: Date.new(2026, 1, 1),
+ due_on: Date.new(2026, 1, 1),
+ description: "Repair"
+ ).value!.data[:charge]
+
+ SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 100_000,
+ occurred_on: Date.new(2026, 1, 1)
+ )
+
+ app_res = SecurityDepositTransactions::ApplyService.call(
+ security_deposit: security_deposit,
+ charge: charge,
+ amount_cents: 50_000,
+ occurred_on: Date.new(2026, 1, 10)
+ )
+ app_txn = app_res.value!.data[:transaction]
+
+ # Charge cannot be voided while deposit application is active
+ expect(Charges::VoidService.call(charge: charge)).to be_failure
+
+ # Void the application
+ void_res = described_class.call(transaction: app_txn)
+ expect(void_res).to be_success
+ expect(tenancy.current_balance_cents).to eq(50_000)
+ expect(security_deposit.held_cents).to eq(100_000)
+
+ # Charge can now be voided
+ expect(Charges::VoidService.call(charge: charge)).to be_success
+ end
+ end
+
+ describe "error conditions" do
+ it "rejects invalid source or missing journal entry" do
+ expect(described_class.call(transaction: SecurityDepositTransaction.new).failure.code).to eq(:invalid_source)
+
+ txn = create(:security_deposit_transaction, :received, security_deposit: security_deposit, party: party)
+ expect(described_class.call(transaction: txn).failure.code).to eq(:not_found)
+ end
+
+ it "rejects voiding an already superseded transaction" do
+ res = SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 100_000,
+ occurred_on: Date.new(2026, 1, 1)
+ )
+ txn = res.value!.data[:transaction]
+ SecurityDepositTransactions::CorrectService.call(transaction: txn, amount_cents: 120_000)
+
+ expect(described_class.call(transaction: txn).failure.code).to eq(:already_superseded)
+ end
+
+ it "handles ReverseEntryService failure" do
+ res = SecurityDepositTransactions::ReceiveService.call(
+ security_deposit: security_deposit,
+ party: party,
+ amount_cents: 100_000,
+ occurred_on: Date.new(2026, 1, 1)
+ )
+ txn = res.value!.data[:transaction]
+
+ allow(Accounting::ReverseEntryService).to receive(:call).and_return(
+ ServiceResult.failure(error: "Reverse failure", code: :reverse_error)
+ )
+ res_fail = described_class.call(transaction: txn)
+ expect(res_fail).to be_failure
+ expect(res_fail.failure.code).to eq(:reverse_error)
+ end
+ end
+end
diff --git a/spec/services/security_deposits/create_service_spec.rb b/spec/services/security_deposits/create_service_spec.rb
new file mode 100644
index 00000000..65dea8dc
--- /dev/null
+++ b/spec/services/security_deposits/create_service_spec.rb
@@ -0,0 +1,104 @@
+require "rails_helper"
+
+RSpec.describe SecurityDeposits::CreateService do
+ let(:user) { create(:user) }
+ let(:property) { create(:property, user: user) }
+ let(:unit) { create(:rentable_unit, property: property) }
+ let(:tenancy) { create(:tenancy, rentable_unit: unit) }
+
+ it "creates a security deposit requirement for a tenancy" do
+ result = described_class.call(
+ tenancy: tenancy,
+ required_amount: "2000.00",
+ due_on: Date.current
+ )
+
+ expect(result).to be_success
+ deposit = result.value!.data[:security_deposit]
+ expect(deposit.required_amount_cents).to eq(200_000)
+ expect(deposit.due_on).to eq(Date.current)
+ expect(tenancy.reload.security_deposit).to eq(deposit)
+ end
+
+ it "accepts numeric amount, integer amount_cents, and string due_on" do
+ t2 = create(:tenancy, rentable_unit: unit)
+ res = described_class.call(
+ tenancy: t2,
+ required_amount: 1500,
+ due_on: "2026-08-16"
+ )
+ expect(res).to be_success
+ expect(res.value!.data[:security_deposit].required_amount_cents).to eq(150_000)
+
+ u3 = create(:rentable_unit, property: property)
+ t3 = create(:tenancy, rentable_unit: u3)
+ res3 = described_class.call(
+ tenancy: t3,
+ required_amount_cents: 250_000,
+ due_on: Date.current
+ )
+ expect(res3).to be_success
+ expect(res3.value!.data[:security_deposit].required_amount_cents).to eq(250_000)
+ end
+
+ it "rejects string required_amount_cents" do
+ t2 = create(:tenancy, rentable_unit: unit)
+ res = described_class.call(
+ tenancy: t2,
+ required_amount_cents: "250000",
+ due_on: Date.current
+ )
+ expect(res).to be_failure
+ expect(res.failure.code).to eq(:invalid_input)
+ end
+
+ it "is idempotent when called with identical parameters" do
+ res1 = described_class.call(
+ tenancy: tenancy,
+ required_amount_cents: 200_000,
+ due_on: Date.current
+ )
+ expect(res1).to be_success
+
+ res2 = described_class.call(
+ tenancy: tenancy,
+ required_amount_cents: 200_000,
+ due_on: Date.current
+ )
+ expect(res2).to be_success
+ expect(res2.value!.data[:security_deposit].id).to eq(res1.value!.data[:security_deposit].id)
+ end
+
+ it "returns conflict when called with differing parameters on existing deposit" do
+ described_class.call(
+ tenancy: tenancy,
+ required_amount_cents: 200_000,
+ due_on: Date.current
+ )
+
+ res = described_class.call(
+ tenancy: tenancy,
+ required_amount_cents: 250_000,
+ due_on: Date.current
+ )
+ expect(res).to be_failure
+ expect(res.failure.code).to eq(:conflict)
+ end
+
+ it "rejects invalid amounts or missing/invalid due_on" do
+ expect(described_class.call(tenancy: Tenancy.new, required_amount: "100", due_on: Date.current).failure.code).to eq(:invalid_tenancy)
+ expect(described_class.call(tenancy: tenancy, required_amount: "0", due_on: Date.current).failure.code).to eq(:invalid_input)
+ expect(described_class.call(tenancy: tenancy, required_amount: "-100", due_on: Date.current).failure.code).to eq(:invalid_input)
+ expect(described_class.call(tenancy: tenancy, required_amount: "100.999", due_on: Date.current).failure.code).to eq(:invalid_input)
+ expect(described_class.call(tenancy: tenancy, required_amount_cents: "bad", due_on: Date.current).failure.code).to eq(:invalid_input)
+ expect(described_class.call(tenancy: tenancy, required_amount: "1000", due_on: nil).failure.code).to eq(:invalid_input)
+ expect(described_class.call(tenancy: tenancy, required_amount: "1000", due_on: "invalid-date").failure.code).to eq(:invalid_input)
+ end
+
+ it "handles validation failure on save" do
+ allow_any_instance_of(SecurityDeposit).to receive(:save).and_return(false)
+ res = described_class.call(tenancy: tenancy, required_amount_cents: 200_000, due_on: Date.current)
+ expect(res).to be_failure
+ expect(res.failure.code).to eq(:validation_error)
+ end
+end
diff --git a/spec/services/security_deposits/liability_timeline_spec.rb b/spec/services/security_deposits/liability_timeline_spec.rb
new file mode 100644
index 00000000..a1229d2c
--- /dev/null
+++ b/spec/services/security_deposits/liability_timeline_spec.rb
@@ -0,0 +1,48 @@
+require "rails_helper"
+
+RSpec.describe SecurityDeposits::LiabilityTimeline do
+ let(:user) { create(:user) }
+ let(:property) { create(:property, user: user) }
+ let(:unit) { create(:rentable_unit, property: property) }
+ let(:tenancy) { create(:tenancy, rentable_unit: unit) }
+ let(:party) { create(:party, user: user) }
+ let(:security_deposit) { create(:security_deposit, tenancy: tenancy, required_amount_cents: 200_000) }
+
+ before do
+ Accounting::ChartOfAccounts.ensure_for(user)
+ end
+
+ it "validates that cumulative liability is non-negative at all dates" do
+ # Jan 1: Receive $1,000
+ txn1 = create(:security_deposit_transaction, :received, :posted, security_deposit: security_deposit, party: party, amount_cents: 100_000, occurred_on: Date.new(2026, 1, 1))
+ # Jan 10: Refund $1,000
+ create(:security_deposit_transaction, :refunded, :posted, security_deposit: security_deposit, party: party, amount_cents: 100_000, occurred_on: Date.new(2026, 1, 10))
+ # Jan 20: Receive $500
+ create(:security_deposit_transaction, :received, :posted, security_deposit: security_deposit, party: party, amount_cents: 50_000, occurred_on: Date.new(2026, 1, 20))
+
+ # Proposal 1: Valid Jan 25 refund $500 with string date
+ res1 = described_class.validate(
+ security_deposit: security_deposit,
+ additions: [ { occurred_on: "2026-01-25", delta_cents: -50_000 } ]
+ )
+ expect(res1).to be_success
+ expect(res1.value!.data[:final_balance_cents]).to eq(0)
+
+ # Proposal 2: Invalid backdated Jan 5 refund $500 (would make Jan 10 balance -$500)
+ res2 = described_class.validate(
+ security_deposit: security_deposit,
+ additions: [ { occurred_on: Date.new(2026, 1, 5), delta_cents: -50_000 } ]
+ )
+ expect(res2).to be_failure
+ expect(res2.failure.code).to eq(:negative_deposit_liability)
+ expect(res2.failure.data[:as_of]).to eq(Date.new(2026, 1, 10))
+
+ # Proposal 3: Single scalar removing_ids
+ res3 = described_class.validate(
+ security_deposit: security_deposit,
+ removing_ids: txn1.id,
+ additions: [ { occurred_on: Date.new(2026, 1, 1), delta_cents: 150_000 } ]
+ )
+ expect(res3).to be_success
+ end
+end
diff --git a/spec/services/security_deposits/update_service_spec.rb b/spec/services/security_deposits/update_service_spec.rb
new file mode 100644
index 00000000..5a6065f8
--- /dev/null
+++ b/spec/services/security_deposits/update_service_spec.rb
@@ -0,0 +1,76 @@
+require "rails_helper"
+
+RSpec.describe SecurityDeposits::UpdateService do
+ let(:user) { create(:user) }
+ let(:property) { create(:property, user: user) }
+ let(:unit) { create(:rentable_unit, property: property) }
+ let(:tenancy) { create(:tenancy, rentable_unit: unit) }
+ let(:party) { create(:party, user: user) }
+ let(:security_deposit) { create(:security_deposit, tenancy: tenancy, required_amount_cents: 200_000, due_on: Date.current) }
+
+ it "updates requirement when no transactions exist" do
+ result = described_class.call(
+ security_deposit: security_deposit,
+ required_amount: "2500.00",
+ due_on: Date.current + 10.days
+ )
+
+ expect(result).to be_success
+ deposit = result.value!.data[:security_deposit]
+ expect(deposit.required_amount_cents).to eq(250_000)
+ expect(deposit.due_on).to eq(Date.current + 10.days)
+ end
+
+ it "accepts integer cents, numeric amount, and string due_on" do
+ res1 = described_class.call(
+ security_deposit: security_deposit,
+ required_amount_cents: 300_000,
+ due_on: "2026-09-15"
+ )
+ expect(res1).to be_success
+ expect(security_deposit.reload.required_amount_cents).to eq(300_000)
+ expect(security_deposit.due_on).to eq(Date.new(2026, 9, 15))
+
+ res2 = described_class.call(
+ security_deposit: security_deposit,
+ required_amount: 3500
+ )
+ expect(res2).to be_success
+ expect(security_deposit.reload.required_amount_cents).to eq(350_000)
+ end
+
+ it "rejects string required_amount_cents" do
+ res = described_class.call(
+ security_deposit: security_deposit,
+ required_amount_cents: "300000"
+ )
+ expect(res).to be_failure
+ expect(res.failure.code).to eq(:invalid_amount)
+ end
+
+ it "rejects update when transactions exist" do
+ create(:security_deposit_transaction, :received, security_deposit: security_deposit, party: party, amount_cents: 50_000)
+
+ result = described_class.call(
+ security_deposit: security_deposit,
+ required_amount: "3000.00"
+ )
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:immutable_requirement)
+ end
+
+ it "rejects invalid source, amount, or due_on" do
+ expect(described_class.call(security_deposit: SecurityDeposit.new, required_amount: "100").failure.code).to eq(:invalid_source)
+ expect(described_class.call(security_deposit: security_deposit, required_amount: "-50.00").failure.code).to eq(:invalid_amount)
+ expect(described_class.call(security_deposit: security_deposit, required_amount: "100.999").failure.code).to eq(:invalid_amount)
+ expect(described_class.call(security_deposit: security_deposit, required_amount_cents: "invalid").failure.code).to eq(:invalid_amount)
+ expect(described_class.call(security_deposit: security_deposit, due_on: "not-a-date").failure.code).to eq(:invalid_due_on)
+ end
+
+ it "handles validation failure on save" do
+ allow(security_deposit).to receive(:save).and_return(false)
+ res = described_class.call(security_deposit: security_deposit, required_amount: "2500.00")
+ expect(res).to be_failure
+ expect(res.failure.code).to eq(:validation_error)
+ end
+end