From 2c30858d67a0c230731c0a1e1a08367a20fa0131 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Sun, 16 Aug 2026 20:21:04 -0700 Subject: [PATCH 1/2] Add implementation plan for double-entry accounting milestone 6 --- .../implementation_plan_milestone_6.md | 2486 +++++++++++++++++ 1 file changed, 2486 insertions(+) create mode 100644 documentation/double_entry_accounting/implementation_plan_milestone_6.md 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. From d940bf7d6aba41dcdcbcedfd75c70b88c087096a Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Sun, 16 Aug 2026 21:26:03 -0700 Subject: [PATCH 2/2] Implement Milestone 6: Security Deposits double-entry accounting and workflows - Add SecurityDeposit and SecurityDepositTransaction models with immutability and lifecycle guards - Add double-entry posting and balance queries for security_deposits_held liability account - Implement LiabilityTimeline validator enforcing non-negative liability across full timeline - Implement ReceiveService, RefundService, ApplyService, VoidService, and CorrectService - Add concurrency protection and lock ordering (Expense -> SecurityDeposit -> Charge) - Add tenancy dashboard UI, transaction views, correction workflow, and property financial integration - Add RBS type signatures, Rubocop cleanups, unit/request/feature specs with >97% line and >90% branch coverage --- app/controllers/charges_controller.rb | 6 +- app/controllers/properties_controller.rb | 1 + ...ecurity_deposit_transactions_controller.rb | 75 ++ .../security_deposits_controller.rb | 128 +++ app/models/charge.rb | 12 + app/models/party.rb | 1 + app/models/property.rb | 1 + app/models/security_deposit.rb | 99 +++ app/models/security_deposit_transaction.rb | 175 +++++ app/models/tenancy.rb | 4 +- .../security_deposit_balance_query.rb | 42 + app/queries/properties/active_years_query.rb | 1 + .../properties/financial_items_query.rb | 3 +- app/services/charges/correct_service.rb | 5 + app/services/charges/void_service.rb | 8 + app/services/expenses/correct_service.rb | 7 + .../apply_service.rb | 200 +++++ .../correct_service.rb | 383 +++++++++ .../receive_service.rb | 163 ++++ .../refund_service.rb | 163 ++++ .../void_service.rb | 100 +++ .../security_deposits/create_service.rb | 86 ++ .../security_deposits/liability_timeline.rb | 66 ++ .../security_deposits/update_service.rb | 92 +++ app/views/properties/_financials.html.erb | 18 + app/views/properties/show.html.erb | 12 +- .../correction.html.erb | 61 ++ .../show.html.erb | 150 ++++ app/views/security_deposits/edit.html.erb | 48 ++ app/views/security_deposits/new.html.erb | 47 ++ app/views/security_deposits/show.html.erb | 281 +++++++ app/views/tenancies/show.html.erb | 39 + config/routes.rb | 13 + db/cable_schema.rb | 43 +- db/cache_schema.rb | 43 +- ...eate_security_deposits_and_transactions.rb | 39 + db/queue_schema.rb | 43 +- db/schema.rb | 49 +- db/seeds.rb | 15 + sig/app/controllers/properties_controller.rbs | 1 + ...curity_deposit_transactions_controller.rbs | 16 + .../security_deposits_controller.rbs | 21 + sig/app/models/charge.rbs | 2 + sig/app/models/security_deposit.rbs | 17 + .../models/security_deposit_transaction.rbs | 22 + .../security_deposit_balance_query.rbs | 13 + .../apply_service.rbs | 39 + .../correct_service.rbs | 47 ++ .../receive_service.rbs | 41 + .../refund_service.rbs | 41 + .../void_service.rbs | 11 + .../security_deposits/create_service.rbs | 15 + .../security_deposits/liability_timeline.rbs | 13 + .../security_deposits/update_service.rbs | 15 + sig/rbs_rails/app/models/charge.rbs | 8 + sig/rbs_rails/app/models/party.rbs | 8 + sig/rbs_rails/app/models/property.rbs | 8 + sig/rbs_rails/app/models/security_deposit.rbs | 361 +++++++++ .../models/security_deposit_transaction.rbs | 742 ++++++++++++++++++ sig/rbs_rails/app/models/tenancy.rbs | 16 + sig/rbs_rails/path_helpers.rbs | 20 + spec/factories.rb | 51 ++ spec/features/milestone_6_acceptance_spec.rb | 104 +++ spec/models/security_deposit_spec.rb | 126 +++ .../security_deposit_transaction_spec.rb | 224 ++++++ .../security_deposit_balance_query_spec.rb | 68 ++ .../properties/active_years_query_spec.rb | 15 + spec/requests/charges_spec.rb | 10 + spec/requests/properties_spec.rb | 20 + .../security_deposit_transactions_spec.rb | 197 +++++ spec/requests/security_deposits_spec.rb | 210 +++++ spec/services/charges/correct_service_spec.rb | 28 + spec/services/charges/void_service_spec.rb | 20 +- .../services/expenses/correct_service_spec.rb | 20 + .../accounting_posting_invariants_spec.rb | 241 ++++++ .../apply_service_spec.rb | 215 +++++ .../correct_service_spec.rb | 541 +++++++++++++ .../receive_service_spec.rb | 107 +++ .../refund_service_spec.rb | 107 +++ .../void_service_spec.rb | 171 ++++ .../security_deposits/create_service_spec.rb | 104 +++ .../liability_timeline_spec.rb | 48 ++ .../security_deposits/update_service_spec.rb | 76 ++ 83 files changed, 6887 insertions(+), 15 deletions(-) create mode 100644 app/controllers/security_deposit_transactions_controller.rb create mode 100644 app/controllers/security_deposits_controller.rb create mode 100644 app/models/security_deposit.rb create mode 100644 app/models/security_deposit_transaction.rb create mode 100644 app/queries/accounting/security_deposit_balance_query.rb create mode 100644 app/services/security_deposit_transactions/apply_service.rb create mode 100644 app/services/security_deposit_transactions/correct_service.rb create mode 100644 app/services/security_deposit_transactions/receive_service.rb create mode 100644 app/services/security_deposit_transactions/refund_service.rb create mode 100644 app/services/security_deposit_transactions/void_service.rb create mode 100644 app/services/security_deposits/create_service.rb create mode 100644 app/services/security_deposits/liability_timeline.rb create mode 100644 app/services/security_deposits/update_service.rb create mode 100644 app/views/security_deposit_transactions/correction.html.erb create mode 100644 app/views/security_deposit_transactions/show.html.erb create mode 100644 app/views/security_deposits/edit.html.erb create mode 100644 app/views/security_deposits/new.html.erb create mode 100644 app/views/security_deposits/show.html.erb create mode 100644 db/migrate/20260816000010_create_security_deposits_and_transactions.rb create mode 100644 sig/app/controllers/security_deposit_transactions_controller.rbs create mode 100644 sig/app/controllers/security_deposits_controller.rbs create mode 100644 sig/app/models/security_deposit.rbs create mode 100644 sig/app/models/security_deposit_transaction.rbs create mode 100644 sig/app/queries/accounting/security_deposit_balance_query.rbs create mode 100644 sig/app/services/security_deposit_transactions/apply_service.rbs create mode 100644 sig/app/services/security_deposit_transactions/correct_service.rbs create mode 100644 sig/app/services/security_deposit_transactions/receive_service.rbs create mode 100644 sig/app/services/security_deposit_transactions/refund_service.rbs create mode 100644 sig/app/services/security_deposit_transactions/void_service.rbs create mode 100644 sig/app/services/security_deposits/create_service.rbs create mode 100644 sig/app/services/security_deposits/liability_timeline.rbs create mode 100644 sig/app/services/security_deposits/update_service.rbs create mode 100644 sig/rbs_rails/app/models/security_deposit.rbs create mode 100644 sig/rbs_rails/app/models/security_deposit_transaction.rbs create mode 100644 spec/features/milestone_6_acceptance_spec.rb create mode 100644 spec/models/security_deposit_spec.rb create mode 100644 spec/models/security_deposit_transaction_spec.rb create mode 100644 spec/queries/accounting/security_deposit_balance_query_spec.rb create mode 100644 spec/requests/security_deposit_transactions_spec.rb create mode 100644 spec/requests/security_deposits_spec.rb create mode 100644 spec/services/security_deposit_transactions/accounting_posting_invariants_spec.rb create mode 100644 spec/services/security_deposit_transactions/apply_service_spec.rb create mode 100644 spec/services/security_deposit_transactions/correct_service_spec.rb create mode 100644 spec/services/security_deposit_transactions/receive_service_spec.rb create mode 100644 spec/services/security_deposit_transactions/refund_service_spec.rb create mode 100644 spec/services/security_deposit_transactions/void_service_spec.rb create mode 100644 spec/services/security_deposits/create_service_spec.rb create mode 100644 spec/services/security_deposits/liability_timeline_spec.rb create mode 100644 spec/services/security_deposits/update_service_spec.rb 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| %> +
    + + <%= 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 %> +
    + +
    + + <%= 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? %> +
    + + <%= 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? %> +
    + + <%= 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 %> + +
    + + <%= f.text_field :external_reference, value: @transaction.external_reference, class: "input input-bordered w-full", id: "security_deposit_transaction_external_reference" %> +
    + +
    + + <%= 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") %> +
    + + + + + + + + + + <% entry.postings.each do |p| %> + + + + + + <% end %> + +
    AccountDebitCredit
    <%= p.account.name %><%= p.debit? ? number_to_currency(p.debit_amount / 100.0) : "" %><%= p.credit? ? number_to_currency(p.credit_amount / 100.0) : "" %>
    +
    + <% 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 %> + +
    + + <%= 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 %> +
    + +
    + + <%= 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 %> + +
    + + <%= 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 %> +
    + +
    + + <%= 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| %> +
    + + <%= 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 %> +
    + +
    + + <%= 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 %> +
    + +
    + + <%= date_field_tag :occurred_on, Date.current, class: "input input-bordered input-sm w-full", id: "receive_occurred_on", required: true %> +
    + +
    + + <%= text_field_tag :external_reference, nil, placeholder: "Check #, wire ref", class: "input input-bordered input-sm w-full", id: "receive_external_reference" %> +
    + +
    + + <%= 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| %> +
    + + <%= 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 %> +
    + +
    + + <%= 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 %> +
    + +
    + + <%= date_field_tag :occurred_on, Date.current, class: "input input-bordered input-sm w-full", id: "refund_occurred_on", required: true %> +
    + +
    + + <%= text_field_tag :external_reference, nil, placeholder: "Check #, wire ref", class: "input input-bordered input-sm w-full", id: "refund_external_reference" %> +
    + +
    + + <%= 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| %> +
    + + +
    + +
    + + <%= 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 %> +
    + +
    + + <%= date_field_tag :occurred_on, Date.current, class: "input input-bordered input-sm w-full", id: "apply_occurred_on", required: true %> +
    + +
    + + <%= 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

    + +
    + + + + + + + + + + + + + + <% if @transactions.any? %> + <% @transactions.each do |txn| %> + + + + + + + + + + <% end %> + <% else %> + + + + <% end %> + +
    DateKindParty / ChargeReference / MemoAmountStatusActions
    <%= 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" %> +
    + No deposit transactions recorded yet. +
    +
    +
    +
    +
    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/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