From dc8e71cf8ab231bf10219cb463a4b0278dde512c Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Sun, 16 Aug 2026 11:10:02 -0700 Subject: [PATCH 1/2] Add implementation plan for double-entry accounting milestone 4 --- .../implementation_plan_milestone_4.md | 3095 +++++++++++++++++ 1 file changed, 3095 insertions(+) create mode 100644 documentation/double_entry_accounting/implementation_plan_milestone_4.md diff --git a/documentation/double_entry_accounting/implementation_plan_milestone_4.md b/documentation/double_entry_accounting/implementation_plan_milestone_4.md new file mode 100644 index 00000000..bade567b --- /dev/null +++ b/documentation/double_entry_accounting/implementation_plan_milestone_4.md @@ -0,0 +1,3095 @@ +# Implementation Plan: Milestone 4 — Receipts + +## Objective + +Replace the temporary `TenantPayment` domain with the permanent `Receipt` domain. + +At the end of this milestone: + +```text +Party (payer) + │ + ▼ +Receipt +├── Tenancy +├── amount_cents +├── received_on +├── payment_method +├── external_reference +└── memo + │ + ▼ +JournalEntry +├── Dr Cash +└── Cr Tenant Receivable +``` + +A Receipt must preserve: + +```text +who paid +which tenancy received the credit +how much was received +when it was received +how it was received +external transaction identity +correction/void history +``` + +Tenant balance remains: + +```text +SUM(Tenant Receivable postings for tenancy) +``` + +Milestone 4 must not change that definition. + +--- + +# 1. Milestone boundary + +Implement: + +- `Receipt` +- payer identity +- integer-cent receipt amounts +- ordinary receipt posting +- manual receipt/payment UI +- partial payments +- prepayments +- overpayments +- receipt voiding +- receipt correction/replacement +- immutable receipt history +- receipt PDF +- ingestion confirmation to Receipt +- ingestion duplicate checking against Receipt +- removal of the temporary `TenantPayment` ledger adapter +- deletion of `TenantPayment` + +Do not implement: + +- receipt-to-charge allocations +- security deposits +- deposit receipts +- returned-check/bank-return accounting +- tenant refunds +- cash-account reconciliation +- arbitrary account selection +- bank accounts +- expense posting +- property ledger migration to journal-entry projections +- final Schedule E accounting semantics +- `SourceDocument` / `ImportedTransaction` redesign + +Payment ingestion remains `PaymentIngestion` for now. + +--- + +# 2. Important semantic distinction: correction versus later cash movement + +Receipt correction in this milestone means: + +```text +"The original accounting record was wrong." +``` + +Examples: + +```text +entered $2,000 instead of $2,100 +wrong payer +wrong tenancy +wrong received date +duplicate receipt entered accidentally +``` + +Correction must **restate the bookkeeping history**. + +It does not represent: + +```text +payment bounced later +landlord refunded money later +bank reversed transfer later +``` + +Those are new economic events and must not be modeled as Receipt corrections. + +Do not use `Receipts::VoidService` to represent a later real-world cash outflow. + +--- + +# 3. Establish baseline + +Before modifying code: + +```bash +git status --short + +bundle exec rspec +bundle exec rbs validate +bundle exec steep check +bin/rubocop +bin/brakeman --no-pager +``` + +Then inventory the temporary payment implementation: + +```bash +rg -n \ + 'TenantPayment|tenant_payment|tenant_payments|TenantPayments::|payment_date|transaction_number' \ + app config db spec sig documentation +``` + +Pay particular attention to: + +```text +TenantPayments::CreateService +TenantPaymentsController +TenantPayments::ReceiptPdfService +PaymentIngestion +PaymentIngestions::ConfirmService +Properties::FinancialItemsQuery +Properties::ActiveYearsQuery +Properties::ScheduleESummaryQuery +Property +Tenancy +User +seeds +``` + +Do not delete `TenantPayment` until every one of these dependencies has been moved. + +--- + +# 4. Receipt ownership + +A Receipt belongs to: + +```text +User +Tenancy +payer Party +``` + +The canonical business ownership remains: + +```text +Receipt + -> Tenancy + -> RentableUnit + -> Property + -> User +``` + +However, include `user_id` directly on `receipts`. + +This is intentional denormalization so the database can enforce user-scoped external transaction uniqueness without relying on an application-level join. + +Enforce: + +```text +receipt.user_id +== +receipt.tenancy.accounting_user.id +== +receipt.payer_party.user_id +``` + +`user_id` is immutable. + +--- + +# 5. Create `receipts` + +Create: + +```text +receipts + +id +user_id NOT NULL +tenancy_id NOT NULL +payer_party_id NOT NULL + +amount_cents BIGINT NOT NULL +received_on DATE NOT NULL +payment_method NOT NULL +external_reference +memo + +posted_at +voided_at +superseded_by_id + +created_at NOT NULL +updated_at NOT NULL +``` + +Foreign keys: + +```text +user_id -> users +tenancy_id -> tenancies +payer_party_id -> parties +superseded_by_id -> receipts +``` + +--- + +# 6. Receipt database constraints + +Add: + +```text +amount_cents > 0 +``` + +Add indexes: + +```text +user_id +tenancy_id +payer_party_id +received_on +voided_at +superseded_by_id +``` + +Add an active external-reference uniqueness constraint: + +```text +UNIQUE ( + user_id, + payment_method, + external_reference +) +WHERE + external_reference IS NOT NULL + AND voided_at IS NULL +``` + +Normalize blank references to `NULL`. + +This permits: + +```text +void erroneous receipt +record corrected replacement using same external reference +``` + +while still preventing two active receipts from representing the same external transaction. + +Add: + +```text +UNIQUE(superseded_by_id) +WHERE superseded_by_id IS NOT NULL +``` + +so one replacement Receipt cannot accidentally serve as the replacement for multiple originals. + +--- + +# 7. Do not require external references + +Cash, check, or manually-recorded payments may lack a reliable external identifier. + +Therefore: + +```text +external_reference is optional +``` + +Do not use: + +```text +amount + date + payer +``` + +as a hard uniqueness key. + +Two legitimate receipts can have exactly the same: + +```text +payer +date +amount +method +``` + +--- + +# 8. Implement `Receipt` + +Create: + +```text +app/models/receipt.rb +``` + +Associations: + +```ruby +belongs_to :user +belongs_to :tenancy + +belongs_to :payer_party, + class_name: "Party" + +belongs_to :superseded_by, + class_name: "Receipt", + optional: true + +has_one :superseded_receipt, + class_name: "Receipt", + foreign_key: :superseded_by_id + +has_many :journal_entries, + as: :source, + dependent: :restrict_with_error +``` + +Implement: + +```ruby +def accounting_user + user +end +``` + +--- + +# 9. Receipt validation + +Validate: + +```text +user present +tenancy present +payer party present + +amount_cents > 0 +received_on present +payment_method present +``` + +Ownership validation: + +```text +tenancy.accounting_user == user +payer_party.user == user +``` + +Do **not** require: + +```text +payer_party participates in tenancy +``` + +A payer may legitimately be: + +```text +parent +employer +guarantor +organization +other third party +``` + +The Receipt identifies who sent money. + +The Tenancy identifies which account received the credit. + +--- + +# 10. Payment-method semantics + +Retain a flexible string field. + +Do not make Receipt dependent on a closed enum that cannot represent future payment methods. + +Normalize: + +```text +strip whitespace +downcase +``` + +Examples: + +```text +cash +check +ach +wire +zelle +venmo +p2p +other +``` + +The UI may present common choices. + +The persistence model should not require a schema change for a new method. + +--- + +# 11. External-reference semantics + +Rename the old concept: + +```text +transaction_number +``` + +to: + +```text +external_reference +``` + +because the identifier may come from: + +```text +Zelle +Venmo +check number +bank transfer +other external systems +``` + +Normalize: + +```text +strip surrounding whitespace +blank -> nil +``` + +Do not preserve the old restrictive alphanumeric/dash/underscore format unless there is a demonstrated external-system requirement. + +Use a reasonable maximum length, e.g.: + +```text +255 +``` + +--- + +# 12. Receipt amount API + +Persist only: + +```text +amount_cents +``` + +Provide presentation helpers: + +```ruby +receipt.amount +receipt.amount = "123.45" +``` + +if useful to existing views/forms. + +`amount` should return: + +```ruby +BigDecimal(amount_cents.to_s) / 100 +``` + +Parsing must reject values with fractional cents. + +For example: + +```text +100 valid +100.5 valid -> 10050 +100.50 valid -> 10050 +100.005 invalid +abc invalid +0 invalid +negative invalid +``` + +Do not silently round user input. + +--- + +# 13. Receipt lifecycle + +A Receipt begins: + +```text +persisted +posted_at = nil +``` + +only temporarily inside its creation transaction. + +Before transaction commit it must become: + +```text +posted_at = journal_entry.posted_at +``` + +There must be no normal committed state: + +```text +Receipt persisted +posted_at nil +JournalEntry absent +``` + +--- + +# 14. Posted Receipt immutability + +Once posted, the following become immutable: + +```text +user_id +tenancy_id +payer_party_id +amount_cents +received_on +payment_method +external_reference +memo +posted_at +``` + +Ordinary Active Record updates must reject changes. + +The only post-posting lifecycle fields are: + +```text +voided_at +superseded_by_id +``` + +and those may only be changed through Receipt lifecycle services. + +Direct code such as: + +```ruby +receipt.update!(voided_at: Time.current) +``` + +must fail. + +This should follow the same controlled-lifecycle pattern already established for `Charge`. + +--- + +# 15. Receipt scopes/helpers + +Implement: + +```ruby +scope :active, -> { where(voided_at: nil) } + +def posted? +def voided? +def superseded? +``` + +Avoid persisting redundant statuses such as: + +```text +active +corrected +void +``` + +Lifecycle is derived from existing fields. + +--- + +# 16. Add Receipt associations to domain models + +## Tenancy + +Replace: + +```ruby +has_many :tenant_payments +``` + +with: + +```ruby +has_many :receipts, + dependent: :restrict_with_error +``` + +Update: + +```ruby +financial_history? +``` + +to include: + +```text +charges +receipts +accounting_postings +``` + +Remove `tenant_payments`. + +## Party + +Add: + +```ruby +has_many :receipts_as_payer, + class_name: "Receipt", + foreign_key: :payer_party_id, + dependent: :restrict_with_error +``` + +A Party referenced by historical receipts must not be deleted. + +## Property + +Replace: + +```ruby +has_many :tenant_payments, through: :tenancies +``` + +with: + +```ruby +has_many :receipts, through: :tenancies +``` + +## User + +Replace: + +```ruby +has_many :tenant_payments, through: :tenancies +``` + +with: + +```ruby +has_many :receipts, through: :tenancies +``` + +--- + +# 17. Create `Receipts::PostService` + +Create: + +```text +app/services/receipts/post_service.rb +``` + +For a Receipt: + +```text +Dr Cash +amount_cents +Cr Tenant Receivable -amount_cents +``` + +Both PostingSpecs must contain: + +```text +tenancy: receipt.tenancy +party: receipt.payer_party +``` + +`PostingBuilder` will derive: + +```text +property +rentable_unit +tenancy +``` + +The explicit Party dimension preserves payer identity in the ledger. + +--- + +# 18. Receipt journal identity + +Use: + +```text +source: receipt +event_type: "receipt_posted" +occurred_on: receipt.received_on +``` + +Use a deterministic description such as: + +```text +Payment received - Zelle +Payment received - Cash +Payment received - Check +``` + +Do not include generated timestamps or mutable Party names in the idempotency-sensitive description. + +Payer identity already exists in the Posting dimension. + +--- + +# 19. Create `Receipts::CreateService` + +Suggested API: + +```ruby +Receipts::CreateService.call( + tenancy:, + payer_party:, + amount: nil, + amount_cents: nil, + received_on:, + payment_method:, + external_reference: nil, + memo: nil +) +``` + +The service owns: + +```text +amount parsing +date parsing +ownership validation +Receipt persistence +ledger posting +posted_at transition +``` + +--- + +# 20. Receipt creation transaction + +Inside one transaction: + +1. validate tenancy; +2. derive user from tenancy; +3. validate payer belongs to same user; +4. normalize/validate amount; +5. create Receipt; +6. call `Receipts::PostService`; +7. fail the transaction if accounting posting fails; +8. mark Receipt posted; +9. commit. + +Return: + +```text +Receipt +JournalEntry +``` + +through `ServiceResult`. + +If accounting fails: + +```text +Receipt count unchanged +JournalEntry count unchanged +Posting count unchanged +``` + +--- + +# 21. Do not expose accounting primitives to controllers + +Correct call graph: + +```text +ReceiptsController + │ + ▼ +Receipts::CreateService + │ + ▼ +Receipts::PostService + │ + ▼ +Accounting::PostEntryService +``` + +Never: + +```text +ReceiptsController + -> Accounting::PostEntryService +``` + +--- + +# 22. Running-account behavior remains unchanged + +A Receipt never requires a Charge association. + +Examples: + +```text +Rent charge +200000 A/R +Receipt -200000 A/R +Balance 0 +``` + +Prepayment: + +```text +Receipt -200000 +Balance credit -200000 + +Later rent +200000 +Balance 0 +``` + +Overpayment: + +```text +Rent +200000 +Receipt -250000 + +Balance -50000 +``` + +Do not introduce an unapplied-payment liability account in this milestone. + +--- + +# 23. No ReceiptAllocation table yet + +Do not create: + +```text +ReceiptAllocation +``` + +unless an implementation dependency proves it necessary. + +Current balance correctness must remain completely independent of allocation. + +The PRD explicitly keeps receipt-to-charge matching optional for MVP. + +--- + +# 24. Manual Receipt UI + +Replace: + +```text +TenantPaymentsController +tenant_payments views +tenant_payment routes +``` + +with: + +```text +ReceiptsController +receipts views +receipt routes +``` + +User-facing wording may continue saying: + +```text +Payment +Record Payment +Payment Details +``` + +where that is clearer. + +Internal domain vocabulary should be: + +```text +Receipt +``` + +--- + +# 25. Receipt routes + +Target: + +```ruby +resources :tenancies do + resources :receipts, only: %i[new create] +end + +resources :receipts, only: %i[index show new create] do + member do + get :correction + post :correct + post :void + end +end +``` + +Do not provide: + +```text +edit +update +destroy +``` + +for posted receipts. + +--- + +# 26. Manual Receipt form + +Fields: + +```text +Tenancy +Payer +Received on +Amount +Payment method +External reference +Memo +``` + +When nested beneath a tenancy: + +```text +tenancy is fixed +``` + +or clearly displayed and hidden from ordinary reassignment. + +The correction workflow is the place for fixing an incorrectly-selected tenancy. + +--- + +# 27. Payer selection + +The payer picker should include **all Parties belonging to the user**, not only tenancy participants. + +To make the common case convenient: + +If the tenancy has exactly one active: + +```text +TenancyParty(role: tenant) +``` + +on the relevant date, preselect that Party. + +If multiple tenant-role Parties are active: + +```text +do not guess +``` + +Require the user to choose. + +If the actual payer is an external Party: + +```text +parent +company +guarantor +etc. +``` + +allow selecting that Party. + +--- + +# 28. Joint-tenancy requirement + +Explicitly test: + +```text +Tenancy: + Alice + Bob + +Receipt 1: + payer = Alice + amount = $1,000 + +Receipt 2: + payer = Bob + amount = $1,000 +``` + +Both reduce: + +```text +the same Tenancy Receivable balance +``` + +while preserving different payer identities. + +This is a primary Milestone 4 acceptance criterion. + +--- + +# 29. Receipt detail page + +Show: + +```text +Amount +Received date +Payer +Payment method +External reference +Memo +Property +Unit +Tenancy +Posted status +Void/correction history +``` + +If corrected: + +```text +This payment was corrected. +Replacement: Receipt #... +``` + +If it supersedes another: + +```text +This payment replaces Receipt #... +``` + +If voided without replacement: + +```text +Voided +``` + +Historical Receipts remain directly viewable. + +--- + +# 30. Receipt PDF + +Replace: + +```text +TenantPayments::ReceiptPdfService +``` + +with: + +```text +Receipts::PdfService +``` + +or: + +```text +Receipts::ReceiptPdfService +``` + +PDF should include: + +```text +Payment Receipt +Received date +Amount +Payer +Method +External reference +Property +Unit +Tenancy reference +Receipt ID +``` + +A corrected/voided Receipt PDF should visibly indicate that it is no longer the active version. + +Do not silently print a voided receipt as if it were current. + +--- + +# 31. Implement `Receipts::VoidService` + +Void means: + +```text +remove an erroneously-recorded Receipt from accounting history +``` + +Suggested API: + +```ruby +Receipts::VoidService.call( + receipt:, + reason: nil +) +``` + +Inside one transaction: + +1. lock Receipt; +2. reject an already-superseded Receipt; +3. find its `receipt_posted` JournalEntry; +4. reverse that entry; +5. use: + ```text + reversal occurred_on = receipt.received_on + ``` +6. mark Receipt `voided_at`; +7. commit. + +--- + +# 32. Why void reversal uses the original received date + +This is deliberate bookkeeping restatement. + +Suppose a duplicate `$2,000` Receipt was accidentally entered January 10 and discovered February 1. + +If the correction reversal were dated February 1: + +```text +January as-of reports would continue showing the duplicate. +``` + +Instead: + +```text +original posting Jan 10 +reversal posting Jan 10 +``` + +net to zero in historical financial reporting. + +Audit chronology is still preserved through: + +```text +JournalEntry.posted_at +Receipt.voided_at +``` + +This distinguishes: + +```text +accounting correction +``` + +from: + +```text +actual later cash refund/reversal +``` + +--- + +# 33. Receipt void idempotency + +Calling void twice must not create two reversals. + +If already voided with no replacement: + +```text +return existing reversal successfully +``` + +if the request is semantically identical. + +Do not create multiple reversal entries. + +--- + +# 34. Implement `Receipts::CorrectService` + +Suggested API: + +```ruby +Receipts::CorrectService.call( + receipt:, + tenancy:, + payer_party:, + amount:, + received_on:, + payment_method:, + external_reference:, + memo: +) +``` + +The replacement may correct: + +```text +amount +payer +tenancy +received date +method +external reference +memo +``` + +--- + +# 35. Receipt correction transaction + +Within one transaction: + +1. lock original Receipt; +2. reject if it has already been voided without replacement; +3. if already superseded: + - compare requested replacement with existing replacement; + - identical => idempotent success; + - different => conflict; +4. reverse original journal entry at original `received_on`; +5. mark original `voided_at`; +6. create/post replacement Receipt; +7. set: + ```text + original.superseded_by_id = replacement.id + ``` +8. commit. + +If any step fails: + +```text +original remains active +no reversal remains +no replacement remains +``` + +--- + +# 36. Correction cash semantics + +Example: + +Original: + +```text +Receipt A +$2,000 +Jan 5 + +Dr Cash +200000 +Cr Tenant Receivable -200000 +``` + +Corrected to: + +```text +Receipt B +$2,100 +Jan 5 +``` + +Correction creates: + +```text +Reversal of A: +Dr Tenant Receivable +200000 +Cr Cash -200000 + +Receipt B: +Dr Cash +210000 +Cr Tenant Receivable -210000 +``` + +Net: + +```text +Cash +210000 +Tenant Receivable -210000 +``` + +Original financial history remains visible. + +--- + +# 37. Correcting the tenancy + +Allow correction to another tenancy belonging to the same user. + +Example: + +```text +Receipt mistakenly applied to Unit A +should have been Unit B +``` + +Result: + +```text +reverse A/R credit on Unit A +post replacement A/R credit on Unit B +``` + +Cash nets unchanged. + +Do not support moving a Receipt to another user's tenancy. + +--- + +# 38. Correcting the payer + +Allow payer correction. + +Example: + +```text +Receipt was recorded as Alice +actual payer was Bob +``` + +Replacement postings carry Bob as the Party dimension. + +Original postings remain historically attached to Alice but are reversed exactly. + +--- + +# 39. Correction UI + +Do not reuse a generic edit form. + +Provide: + +```text +Correct Payment +``` + +with explanatory copy that correction: + +```text +keeps the original record +reverses its accounting +creates a replacement +``` + +Prefill the current Receipt values. + +On success redirect to the replacement Receipt. + +Show a link back to the original. + +--- + +# 40. Void UI + +Provide: + +```text +Void Payment +``` + +with explicit wording: + +```text +Use this only if the payment record itself was entered in error. +Do not use this for money later returned to a payer. +``` + +Require confirmation. + +An optional reason may be retained in UI/audit text, but do not overload `memo` on the original Receipt. + +If storing correction reason becomes useful, add a dedicated lifecycle/audit field rather than rewriting the original memo. + +--- + +# 41. Retarget PaymentIngestion + +Keep the model name: + +```text +PaymentIngestion +``` + +for this milestone. + +Change: + +```text +tenant_payment_id +``` + +to: + +```text +receipt_id +``` + +Association: + +```ruby +belongs_to :receipt, optional: true +``` + +Remove the TenantPayment association. + +--- + +# 42. Payment ingestion confirmation + +Update: + +```text +PaymentIngestions::ConfirmService +``` + +to create a Receipt. + +Map: + +```text +ingestion.tenancy + -> receipt.tenancy + +ingestion.party + -> receipt.payer_party + +ingestion.amount + -> receipt amount + +ingestion.payment_date + -> receipt.received_on + +ingestion.payment_method + -> receipt.payment_method + +ingestion.transaction_number + -> receipt.external_reference +``` + +The parsed: + +```text +payer_name +payer_username +raw_text +``` + +remain immutable source/provenance information on the ingestion. + +--- + +# 43. Ingestion confirmation must remain atomic + +Inside the existing ingestion transaction: + +1. lock ingestion; +2. verify confirmable; +3. create/post Receipt; +4. optionally create Party aliases; +5. set: + ```text + status = confirmed + receipt_id = receipt.id + ``` +6. commit. + +If Receipt posting fails: + +```text +ingestion remains unconfirmed +Receipt rolls back +JournalEntry rolls back +Posting rolls back +aliases roll back +``` + +--- + +# 44. Make repeated confirmation idempotent + +The current behavior treats "already confirmed" as an error. + +Change it. + +If: + +```text +ingestion.confirmed? +AND ingestion.receipt exists +``` + +then repeated confirmation should: + +```text +return existing Receipt successfully +``` + +without creating anything. + +If: + +```text +confirmed? +but receipt_id missing/broken +``` + +return an integrity failure. + +Do not silently create another Receipt. + +The architecture PRD explicitly requires repeated ingestion confirmation not to duplicate financial effects. + +--- + +# 45. Ingestion provenance after correction + +If an ingestion originally confirmed: + +```text +Receipt A +``` + +and Receipt A is later corrected to Receipt B: + +```text +PaymentIngestion.receipt_id remains A +``` + +Do not rewrite it to B. + +That preserves: + +```text +"This ingestion confirmation created Receipt A." +``` + +Receipt A then points through: + +```text +superseded_by -> Receipt B +``` + +The UI may resolve/display the current replacement, but provenance should not be rewritten. + +--- + +# 46. Ingestion duplicate detection + +Replace every query against: + +```text +TenantPayment +``` + +with: + +```text +Receipt +``` + +The canonical external duplicate check becomes: + +```text +user_id +payment_method +external_reference +active Receipt +``` + +The database partial unique index remains the concurrency protection. + +Do not derive uniqueness from amount/date/payer. + +--- + +# 47. Keep parsed identity separate from durable payer identity + +`PaymentIngestion` retains: + +```text +payer_name +payer_username +``` + +Receipt stores: + +```text +payer_party_id +``` + +Do not copy the parser's free-form payer name into Receipt as another mutable truth field. + +The ingestion explains: + +```text +what the source document said +``` + +The Receipt explains: + +```text +which Party Yanushi confirmed as payer +``` + +--- + +# 48. Retarget property financial items + +Replace: + +```text +tenant_payments +payment_date +"Tenant Payment" +``` + +with: + +```text +receipts +received_on +"Payment" +``` + +in: + +```text +Properties::FinancialItemsQuery +``` + +The UI can continue showing a green "Payment" row. + +Show: + +```text +payer display name +payment method +``` + +where useful. + +Mark: + +```text +voided +corrected +``` + +records appropriately rather than making them disappear from audit-oriented history. + +--- + +# 49. Active-year query + +Replace: + +```text +years_for(:tenant_payments, :payment_date) +``` + +with: + +```text +years_for(:receipts, :received_on) +``` + +Do not lose years that contain only Receipt activity. + +--- + +# 50. Interim Schedule E query + +The final tax-reporting redesign is not Milestone 4. + +However, deleting `TenantPayment` requires preserving current behavior. + +Replace the current temporary: + +```text +property.tenant_payments +``` + +query with: + +```text +property.receipts.active +``` + +for the same interim "rents received" calculation. + +Use: + +```text +received_on +amount_cents +``` + +and convert from cents only at the query result boundary. + +Do **not** claim this is the final Schedule E architecture. + +Milestone 9 still owns the explicit tax-reporting projection. + +--- + +# 51. Void/corrected Receipts and interim reports + +A pure voided Receipt must contribute: + +```text +$0 +``` + +to current interim received-rent reporting. + +A corrected Receipt contributes only its active replacement amount. + +Therefore the temporary Schedule E query should use: + +```text +Receipt.active +``` + +rather than summing all Receipt source rows. + +This is an interim domain-table query only. + +The tenancy balance does **not** use this filter because reversals already make the accounting ledger net correctly. + +--- + +# 52. Tenant balance requires no redesign + +Do not alter the existing ledger-backed tenancy balance algorithm. + +Receipt posting already uses: + +```text +Tenant Receivable +``` + +so: + +```text +partial payment +prepayment +overpayment +void +correction +``` + +all naturally change balance through postings. + +No `Receipt` table query belongs in `Tenancies::BalanceQuery`. + +--- + +# 53. Payment form prefill + +Preserve current behavior: + +```text +if tenancy balance > 0: + default amount = amount owed + +if tenancy balance <= 0: + default amount = 0 +``` + +Do not cap the submitted amount. + +An overpayment is explicitly valid. + +--- + +# 54. Replace modal payment flows + +Update the property financial modal: + +```text +Record Payment +``` + +to use: + +```text +new_tenancy_receipt_path +``` + +On successful Receipt creation, refresh: + +```text +property financials +active tenancy balances +flash +``` + +as the current TenantPayment flow does. + +Keep the UX behavior; replace only the domain source. + +--- + +# 55. Seeds + +Replace seeded TenantPayments with: + +```text +Receipts::CreateService +``` + +Supply explicit payer Party. + +Do not seed Receipt rows directly. + +Do not manually seed balancing JournalEntries. + +Development seed state must satisfy the same: + +```text +Receipt + ledger atomicity +``` + +as normal application behavior. + +--- + +# 56. Delete the temporary payment bridge + +Delete: + +```text +TenantPayments::CreateService +TenantPayments::PostLegacyService +``` + +or whatever transitional posting classes remain. + +There must be exactly one normal money-receipt posting path: + +```text +Receipts::CreateService + -> Receipts::PostService +``` + +Do not keep aliases or forwarding wrappers. + +--- + +# 57. Delete `TenantPayment` + +After all callers are migrated: + +Delete: + +```text +app/models/tenant_payment.rb +app/controllers/tenant_payments_controller.rb +app/views/tenant_payments/ +TenantPayments::* services +tenant payment factories +tenant payment specs +tenant payment RBS +tenant payment routes +``` + +Drop: + +```text +tenant_payments +``` + +from the database. + +No compatibility constant: + +```ruby +TenantPayment = Receipt +``` + +--- + +# 58. No legacy `TenantPayment` journal sources + +Because there is no production data to preserve, do not build a backfill. + +Use a clean database during development/test cutover. + +After the transition there should be no newly-created: + +```text +JournalEntry.source_type = "TenantPayment" +``` + +All ordinary payment entries must be: + +```text +source_type = "Receipt" +event_type = "receipt_posted" +``` + +--- + +# 59. Development database policy + +Because the project still has no production data requiring preservation, prefer: + +```bash +bin/rails db:drop db:create db:migrate +RAILS_ENV=test bin/rails db:drop db:create db:migrate +``` + +after the destructive schema cutover. + +Do not add dual-write or source-type backfill machinery solely to preserve disposable development records. + +--- + +# 60. Receipt model tests + +Test: + +- requires User; +- requires Tenancy; +- requires payer Party; +- requires positive cents; +- requires received date; +- requires payment method; +- external reference optional; +- blank external reference normalizes nil; +- user must match tenancy owner; +- payer must belong to same user; +- payer need not participate in tenancy; +- individual payer works; +- organization payer works; +- active duplicate external reference fails; +- same reference for another user succeeds; +- voided external reference may be reused; +- posted financial fields cannot mutate; +- direct `voided_at` mutation fails; +- direct `superseded_by_id` mutation fails; +- destroy fails after posting. + +--- + +# 61. Receipt posting tests + +For `$2,000`: + +```text +Cash +200000 +Tenant Receivable -200000 +``` + +Verify both lines carry: + +```text +property +rentable_unit +tenancy +payer party +``` + +Verify: + +```text +source_type = Receipt +event_type = receipt_posted +occurred_on = received_on +``` + +--- + +# 62. Receipt creation atomicity tests + +Force Receipt validation failure: + +```text +no JournalEntry +no Posting +``` + +Force accounting failure: + +```text +no committed Receipt +no JournalEntry +no Posting +``` + +Successful create: + +```text +one Receipt +one JournalEntry +two Postings +posted_at set +``` + +--- + +# 63. Joint-payer test + +Given: + +```text +Alice and Bob share one tenancy. +``` + +Create: + +```text +Receipt Alice $500 +Receipt Bob $700 +``` + +Assert: + +```text +same tenancy balance reduced by $1,200 +Alice retained on first Receipt/postings +Bob retained on second Receipt/postings +``` + +--- + +# 64. Non-tenant payer test + +Create: + +```text +Party: Alice's Employer +not a TenancyParty +``` + +Record: + +```text +$2,000 Receipt +payer = Employer +tenancy = Alice's tenancy +``` + +Assert: + +```text +valid +tenant balance reduced +payer preserved +``` + +This proves Party and tenancy role are not incorrectly conflated. + +--- + +# 65. Partial-payment test + +```text +Rent Charge +$2,000 +Receipt -$500 + +Balance +$1,500 +``` + +--- + +# 66. Prepayment test + +```text +Receipt -$2,000 +Balance -$2,000 credit + +Later Rent +$2,000 +Balance $0 +``` + +No allocation exists. + +--- + +# 67. Overpayment test + +```text +Rent +$2,000 +Receipt -$2,500 + +Balance -$500 credit +``` + +No liability reclassification is created. + +--- + +# 68. Receipt void tests + +Test: + +- original Receipt remains; +- original JournalEntry remains; +- reversal exists; +- reversal uses original accounting date; +- amounts negate exactly; +- party dimensions negate exactly; +- Receipt gets `voided_at`; +- balance updates automatically; +- duplicate void is idempotent; +- direct source deletion remains impossible. + +--- + +# 69. Receipt correction tests + +Original: + +```text +$2,000 +``` + +Correct to: + +```text +$2,100 +``` + +Assert: + +```text +original Receipt remains +original JournalEntry remains +reversal exists +replacement Receipt exists +replacement JournalEntry exists +original.superseded_by = replacement +original.voided_at present +``` + +Net ledger: + +```text +Cash +$2,100 +Tenant Receivable -$2,100 +``` + +--- + +# 70. Correction-to-other-tenancy test + +Record Receipt against: + +```text +Tenancy A +``` + +Correct it to: + +```text +Tenancy B +``` + +Assert: + +```text +Tenancy A credit is reversed +Tenancy B receives credit +Cash net unchanged +``` + +--- + +# 71. Correction-to-other-payer test + +Correct: + +```text +payer Alice -> payer Bob +``` + +Assert: + +```text +original ledger preserved/reversed with Alice +replacement ledger carries Bob +``` + +--- + +# 72. Correction idempotency test + +Submit identical correction twice. + +Expected: + +```text +one reversal +one replacement Receipt +one replacement JournalEntry +``` + +Second call returns existing replacement. + +Submit a different second correction against the already-corrected original. + +Expected: + +```text +conflict +``` + +Do not create another sibling replacement. + +--- + +# 73. Correction concurrency test + +Two concurrent correction attempts against the same Receipt must serialize through the original Receipt lock. + +Result must be: + +```text +one winning replacement +one reversal +``` + +The loser either: + +```text +returns the same replacement if equivalent +``` + +or: + +```text +fails with correction conflict +``` + +Never create two replacements. + +--- + +# 74. Void-versus-correct concurrency test + +Concurrent: + +```text +VoidService +CorrectService +``` + +against one Receipt must not produce: + +```text +one pure void +plus one replacement +``` + +Locking must establish one terminal outcome. + +--- + +# 75. External-reference correction test + +Original active Receipt: + +```text +method: zelle +external_reference: ABC123 +``` + +Correction may create replacement with: + +```text +method: zelle +external_reference: ABC123 +``` + +because the original is voided inside the same transaction before the replacement becomes active. + +Prove the partial unique index supports this. + +--- + +# 76. Payment-ingestion tests + +Test: + +- matched ingestion creates Receipt; +- ingestion Party becomes payer; +- ingestion Tenancy becomes Receipt tenancy; +- amount maps correctly to integer cents; +- payment date maps to received date; +- transaction number maps to external reference; +- payer source text remains on ingestion; +- aliases still work; +- confirmation posts accounting exactly once; +- repeated confirmation returns same Receipt; +- duplicate external transaction cannot create second active Receipt; +- confirmation failure rolls everything back. + +--- + +# 77. Ingestion correction provenance test + +Confirm ingestion: + +```text +ingestion -> Receipt A +``` + +Correct Receipt A to Receipt B. + +Assert: + +```text +ingestion.receipt == Receipt A +Receipt A.superseded_by == Receipt B +``` + +Do not rewrite ingestion provenance. + +--- + +# 78. Receipt UI request/system scenarios + +## Manual payment + +1. Open tenancy. +2. Click Record Payment. +3. Select payer. +4. Enter amount/method/reference. +5. Submit. +6. Receipt appears. +7. Tenancy balance changes. + +## Joint tenants + +1. Tenancy has Alice and Bob. +2. Record Alice payment. +3. Record Bob payment. +4. Both identify payer correctly. + +## Third-party payer + +1. Choose non-tenant Party. +2. Record payment. +3. Receipt succeeds. + +## Overpayment + +1. Tenant owes $500. +2. Record $750. +3. Account shows $250 credit. + +## Correction + +1. Open Receipt. +2. Click Correct Payment. +3. Change amount. +4. Original becomes corrected. +5. Replacement appears. +6. Balance reflects replacement only. + +## Void + +1. Open erroneous Receipt. +2. Void it. +3. Receipt remains visible. +4. Balance reverses. + +--- + +# 79. PDF request coverage + +Render PDF for: + +```text +active Receipt +corrected original +replacement Receipt +voided Receipt +``` + +Verify payer is included. + +Verify corrected/voided history cannot be mistaken for an active current payment receipt. + +--- + +# 80. Update property financial UI + +Replace branches checking: + +```text +"Tenant Payment" +``` + +with: + +```text +"Payment" +``` + +Display: + +```text +payer +method +``` + +where useful. + +Use: + +```text +Receipt#amount +``` + +for formatting. + +Voided/corrected source rows should be clearly marked if shown. + +--- + +# 81. Update active-year behavior + +Tests must prove a year containing: + +```text +Receipt activity only +``` + +still appears in the financial year selector. + +--- + +# 82. Update interim Schedule E tests + +Until the tax milestone: + +```text +active ordinary Receipt +``` + +contributes to the existing rents-received number. + +Test: + +```text +active Receipt included +voided Receipt excluded +corrected original excluded +replacement included +``` + +Do not introduce tax-accounting redesign here. + +--- + +# 83. Update factories + +Delete: + +```text +:tenant_payment +``` + +Add: + +```text +:receipt +``` + +Traits: + +```text +:posted_receipt +:voided_receipt +:corrected_receipt +``` + +For tests requiring a financially-real Receipt, prefer: + +```text +Receipts::CreateService +``` + +instead of factory-only persistence. + +Direct factory creation is appropriate for isolated validation tests. + +--- + +# 84. Update RBS + +Add/update: + +```text +Receipt + +Receipts::CreateService +Receipts::PostService +Receipts::VoidService +Receipts::CorrectService +Receipts::PdfService + +ReceiptsController +``` + +Update: + +```text +PaymentIngestion +PaymentIngestions::ConfirmService +Property +Tenancy +Party +User +Properties::FinancialItemsQuery +Properties::ActiveYearsQuery +Properties::ScheduleESummaryQuery +``` + +Delete: + +```text +TenantPayment +TenantPayments::* +TenantPaymentsController +``` + +Regenerate Rails signatures: + +```bash +bin/rails rbs_rails:all + +bundle exec rbs validate +bundle exec steep check +``` + +Keep broad Steep coverage. + +--- + +# 85. Documentation + +Add: + +```text +documentation/double_entry_accounting/implementation_plan_milestone_4.md +``` + +Update accounting architecture documentation with: + +```text +Receipt versus Charge + +payer Party versus tenancy participants + +Receipt posting rule + +running-account semantics + +overpayments + +Receipt lifecycle + +correction versus later cash refund + +source-event immutability + +ingestion provenance + +no allocations yet +``` + +Explicitly state: + +```text +A Receipt means ordinary money received and credited to a tenancy. + +A refundable security deposit is not a Receipt. +``` + +Milestone 6 owns deposit money. + +--- + +# 86. Search for stale TenantPayment vocabulary + +Run: + +```bash +rg -n \ + 'TenantPayment|tenant_payment|tenant_payments|TenantPayments::' \ + app config db spec sig +``` + +Expected: + +```text +no live application-code hits +``` + +Historical architecture documentation may mention it as a removed model. + +--- + +# 87. Search for old payment-field names + +Run: + +```bash +rg -n \ + 'payment_date|transaction_number' \ + app config db spec sig +``` + +Review every hit. + +Payment ingestion parser fields may legitimately remain: + +```text +payment_date +transaction_number +``` + +because those are source/parser vocabulary. + +Receipt code must use: + +```text +received_on +external_reference +``` + +Do not blindly rename parser-source fields if doing so adds no domain value. + +--- + +# 88. Ensure Receipt creation is service-owned + +Run: + +```bash +rg -n \ + 'Receipt\.(new|create|create!)|receipts\.(build|create|create!)' \ + app +``` + +Review every production hit. + +No controller or ingestion service should directly persist Receipt. + +All financially-real Receipt creation must go through: + +```text +Receipts::CreateService +``` + +Correction may call that service internally. + +--- + +# 89. Verify accounting creation remains contained + +Run: + +```bash +rg -n \ + 'Posting\.(create|create!|new)|postings\.(create|create!|build)|JournalEntry\.(create|create!|new)' \ + app +``` + +Receipt services must still delegate to Milestone 2 accounting services. + +Do not leak direct accounting persistence into the new domain services. + +--- + +# 90. Verify no balance query reads Receipts + +Run: + +```bash +rg -n \ + 'Receipt|receipts' \ + app/queries/tenancies +``` + +The tenancy balance query should not need Receipt rows. + +It should continue reading: + +```text +Tenant Receivable postings +``` + +only. + +--- + +# 91. Clean database verification + +Run: + +```bash +bin/rails db:drop db:create db:migrate +RAILS_ENV=test bin/rails db:drop db:create db:migrate +``` + +Verify: + +```text +receipts exists +tenant_payments does not exist +payment_ingestions.receipt_id exists +payment_ingestions.tenant_payment_id does not exist +``` + +--- + +# 92. Manual smoke test + +Using a clean database: + +1. Create property/unit/tenancy. +2. Ensure rent charge exists. +3. Create Alice Party. +4. Create Bob Party. +5. Add Alice/Bob to tenancy. +6. Record payment from Alice. +7. Verify payer is Alice. +8. Verify balance decreases. +9. Record payment from Bob. +10. Verify payer is Bob. +11. Create Employer Party not on tenancy. +12. Record payment from Employer. +13. Verify it succeeds. +14. Overpay the account. +15. Verify negative tenant balance/credit. +16. Correct one payment amount. +17. Verify original remains and replacement is linked. +18. Verify balance reflects corrected amount only. +19. Correct a payment to another tenancy. +20. Verify receivable moves between tenancies while cash stays net unchanged. +21. Void an erroneous payment. +22. Verify balance reverses. +23. Upload and confirm an ingestion. +24. Verify its Party becomes Receipt payer. +25. Confirm the same ingestion again. +26. Verify no duplicate Receipt/posting. +27. Download a Receipt PDF. +28. Open property financial history. +29. Open Schedule E interim view. +30. Confirm no TenantPayment UI/model remains. + +--- + +# 93. 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 +``` + +Coverage must remain above the CI threshold. + +--- + +# 94. Suggested commit boundaries + +## Commit 1: Add Receipt domain + +Include: + +```text +receipts migration +Receipt model +associations +amount parsing +ownership +lifecycle protection +model specs +``` + +## Commit 2: Add Receipt accounting + +Include: + +```text +Receipts::PostService +Receipts::CreateService +posting specs +atomicity tests +payer dimensions +running-account scenarios +``` + +## Commit 3: Replace manual TenantPayment flow + +Include: + +```text +ReceiptsController +routes +forms +views +PDF +property financial UI +manual-payment tests +``` + +## Commit 4: Add void and correction + +Include: + +```text +Receipts::VoidService +Receipts::CorrectService +correction UI +void UI +lifecycle tests +concurrency tests +``` + +## Commit 5: Move ingestion to Receipt + +Include: + +```text +payment_ingestions.receipt_id +ConfirmService +duplicate detection +idempotent confirmation +provenance tests +``` + +## Commit 6: Remove TenantPayment + +Include: + +```text +TenantPayment model deletion +TenantPayments services deletion +old routes/views/specs/signatures deletion +associations/query updates +``` + +## Commit 7: Reporting compatibility and cleanup + +Include: + +```text +FinancialItemsQuery +ActiveYearsQuery +interim Schedule E query +seeds +RBS +Steep +documentation +stale-reference cleanup +full quality gate +``` + +Each commit should remain green where practical. + +--- + +# 95. Milestone 4 acceptance checklist + +## Receipt domain + +- [ ] `Receipt` exists. +- [ ] `TenantPayment` does not. +- [ ] Amount uses integer cents. +- [ ] Receipt preserves payer Party. +- [ ] Receipt belongs to Tenancy. +- [ ] Payer need not be a tenancy participant. +- [ ] User/Tenancy/Payer ownership is consistent. +- [ ] Received date is preserved. +- [ ] Payment method is preserved. +- [ ] External reference is preserved. +- [ ] Memo is supported. +- [ ] Posted Receipt financial fields are immutable. +- [ ] Receipt cannot be hard-deleted. + +## Accounting + +- [ ] Receipt posts Dr Cash. +- [ ] Receipt posts Cr Tenant Receivable. +- [ ] Receipt postings carry Property/Unit/Tenancy dimensions. +- [ ] Receipt postings carry payer Party dimension. +- [ ] Receipt creation and posting are atomic. +- [ ] Tenant balance still comes solely from ledger postings. + +## Running account + +- [ ] Partial payment works. +- [ ] Prepayment works. +- [ ] Overpayment works. +- [ ] Negative balance represents tenant credit. +- [ ] No charge allocation is required. + +## Payer identity + +- [ ] Joint tenants can each make payments. +- [ ] Their payments affect the same tenancy balance. +- [ ] Actual payer remains distinguishable. +- [ ] Non-tenant Party may pay. +- [ ] Organization Party may pay. + +## Corrections + +- [ ] Original Receipt remains. +- [ ] Original JournalEntry remains. +- [ ] Correction creates reversal. +- [ ] Correction creates replacement Receipt. +- [ ] Replacement gets a new JournalEntry. +- [ ] Original links to replacement. +- [ ] Correct amount is reflected. +- [ ] Correct payer is reflected. +- [ ] Correct tenancy is reflected. +- [ ] Correction is transactional. +- [ ] Concurrent corrections cannot create siblings. + +## Voiding + +- [ ] Void creates accounting reversal. +- [ ] Receipt remains as history. +- [ ] Balance updates automatically. +- [ ] Void is idempotent. +- [ ] Void is documented as correction, not later cash refund. + +## External identity + +- [ ] Active external references are unique per user/method. +- [ ] Legitimate repeated payments without references are allowed. +- [ ] A voided external reference may be reused. +- [ ] Corrected replacement may retain the original external reference. + +## Ingestion + +- [ ] PaymentIngestion references Receipt. +- [ ] Confirming creates Receipt. +- [ ] Parsed Party becomes payer. +- [ ] Parsed payer-name/user-name snapshots remain on ingestion. +- [ ] Confirmation is atomic. +- [ ] Repeated confirmation returns the same Receipt. +- [ ] Duplicate confirmation cannot duplicate ledger postings. +- [ ] Correcting Receipt does not rewrite ingestion provenance. + +## UI + +- [ ] Record Payment uses Receipt internally. +- [ ] Payer is selected/displayed. +- [ ] Single active tenant may be preselected. +- [ ] Multiple tenants are not guessed. +- [ ] Receipt detail works. +- [ ] PDF includes payer. +- [ ] Correct Payment workflow works. +- [ ] Void Payment workflow works. +- [ ] Corrected/voided records are visibly historical. + +## Legacy cleanup + +- [ ] `tenant_payments` table is gone. +- [ ] `TenantPayment` constant is gone. +- [ ] `TenantPaymentsController` is gone. +- [ ] TenantPayment services are gone. +- [ ] Temporary legacy posting adapter is gone. +- [ ] No newly-created `JournalEntry` uses `TenantPayment` as source. +- [ ] Property/User/Tenancy associations use Receipts. +- [ ] FinancialItemsQuery uses Receipts. +- [ ] ActiveYearsQuery uses Receipts. +- [ ] Interim Schedule E query uses active Receipts. + +## Quality + +- [ ] Receipt model specs pass. +- [ ] Posting specs pass. +- [ ] Atomicity specs pass. +- [ ] Running-account specs pass. +- [ ] Correction specs pass. +- [ ] Correction concurrency specs pass. +- [ ] Ingestion specs pass. +- [ ] Request/system specs pass. +- [ ] PDF specs pass. +- [ ] RBS validates. +- [ ] Steep passes. +- [ ] RuboCop passes. +- [ ] RSpec passes. +- [ ] Coverage remains above CI threshold. +- [ ] Security scans pass. + +--- + +# 96. Desired end state + +After Milestone 4: + +```text +Tenancy +├── Charges +│ ├── Rent +│ ├── Late fee +│ ├── Reimbursement +│ └── Other +│ +└── Receipts + ├── payer Party + └── immutable correction chain +``` + +Financially: + +```text +Rent Charge + Dr Tenant Receivable + Cr Rental Income + +Receipt + Dr Cash + Cr Tenant Receivable +``` + +A shared tenancy can therefore have: + +```text +Rent Charge +$2,000 + +Alice Receipt -$500 +Bob Receipt -$750 +Employer Receipt -$750 + +Balance $0 +``` + +without pretending all three payments came from the same tenant. + +If Bob's payment was really `$700`, Yanushi preserves: + +```text +original Bob Receipt +original journal entry +reversal +replacement Bob Receipt +replacement journal entry +``` + +rather than editing financial history. + +At that point the temporary payment bridge is gone. `Charge` explains why money is owed, `Receipt` explains who paid money into the tenancy account, and the immutable accounting ledger remains the single source of truth for the resulting balance. From 1c7fa75ab3ff31814a3453533a924c2066a214b9 Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Sun, 16 Aug 2026 11:38:13 -0700 Subject: [PATCH 2/2] Implement Milestone 4: Receipts, multi-payer ingestion, and ledger reversals - Replace legacy TenantPayment with Receipt model featuring positive amount check constraint, partial unique index on (user_id, payment_method, external_reference), and strict posting immutability - Post balanced Dr Cash / Cr Tenant Receivable journal entries tagged with both tenancy and payer party dimensions - Implement Receipts::CreateService with automatic payer party resolution for single-tenant and guarantor tenancies - Implement Receipts::VoidService and Receipts::CorrectService using row-level locking and accounting reversals dated at original received_on for bookkeeping restatement - Retarget PaymentIngestion to Receipt and update PaymentIngestions::ConfirmService - Update financial queries and Schedule E generator to aggregate from active receipts - Build full ReceiptsController and UI with PDF receipt generation - Remove all legacy TenantPayment models, controllers, views, routes, specs, and RBS definitions --- app/controllers/dashboards_controller.rb | 2 +- .../payment_documents_controller.rb | 8 +- .../payment_ingestions_controller.rb | 10 +- app/controllers/properties_controller.rb | 4 +- app/controllers/receipts_controller.rb | 278 +++++++ app/controllers/tenant_payments_controller.rb | 103 --- .../payment_ingestion_form_controller.js | 94 +-- app/models/party.rb | 1 + app/models/payment_document.rb | 11 + app/models/payment_ingestion.rb | 31 +- app/models/property.rb | 2 +- app/models/receipt.rb | 143 ++++ app/models/tenancy.rb | 4 +- app/models/tenant_payment.rb | 47 -- app/models/user.rb | 2 +- .../dashboards/property_summaries_query.rb | 2 +- app/queries/properties/active_years_query.rb | 2 +- .../properties/financial_items_query.rb | 2 +- .../properties/schedule_e_summary_query.rb | 2 +- .../payment_documents/destroy_service.rb | 42 ++ .../payment_ingestions/confirm_service.rb | 54 +- .../payment_ingestions/destroy_service.rb | 37 + .../payment_ingestions/update_service.rb | 14 +- app/services/receipts/correct_service.rb | 208 ++++++ app/services/receipts/create_service.rb | 171 +++++ app/services/receipts/post_service.rb | 55 ++ app/services/receipts/receipt_pdf_service.rb | 46 ++ app/services/receipts/void_service.rb | 73 ++ app/services/schedule_e_generator.rb | 6 +- .../tenant_payments/create_service.rb | 125 ---- .../tenant_payments/receipt_pdf_service.rb | 28 - app/views/payment_ingestions/index.html.erb | 10 +- app/views/payment_ingestions/show.html.erb | 24 +- app/views/properties/_financials.html.erb | 19 +- app/views/receipts/_form.html.erb | 73 ++ app/views/receipts/_modal_form.html.erb | 60 ++ app/views/receipts/_receipt.html.erb | 35 + app/views/receipts/correction.html.erb | 76 ++ app/views/receipts/index.html.erb | 39 + app/views/receipts/new.html.erb | 12 + app/views/receipts/show.html.erb | 141 ++++ app/views/shared/_navbar.html.erb | 6 +- app/views/tenancies/show.html.erb | 67 +- app/views/tenant_payments/_form.html.erb | 46 -- .../tenant_payments/_modal_form.html.erb | 54 -- .../tenant_payments/_tenant_payment.html.erb | 13 - app/views/tenant_payments/index.html.erb | 26 - app/views/tenant_payments/new.html.erb | 18 - app/views/tenant_payments/show.html.erb | 79 -- config/routes.rb | 10 +- db/cable_schema.rb | 53 +- db/cache_schema.rb | 53 +- db/migrate/20260816000005_create_receipts.rb | 28 + ...retarget_payment_ingestions_to_receipts.rb | 6 + .../20260816000007_drop_tenant_payments.rb | 14 + db/queue_schema.rb | 53 +- db/schema.rb | 57 +- db/seeds.rb | 4 +- sig/app/controllers/receipts_controller.rbs | 23 + .../tenant_payments_controller.rbs | 32 - sig/app/models/payment_document.rbs | 4 + sig/app/models/payment_ingestion.rbs | 4 +- sig/app/models/receipt.rbs | 30 + sig/app/models/tenant_payment.rbs | 12 - .../payment_documents/destroy_service.rbs | 15 + .../payment_ingestions/confirm_service.rbs | 4 +- .../payment_ingestions/destroy_service.rbs | 15 + sig/app/services/receipts/correct_service.rbs | 46 ++ sig/app/services/receipts/create_service.rbs | 42 ++ sig/app/services/receipts/post_service.rbs | 14 + .../services/receipts/receipt_pdf_service.rbs | 14 + sig/app/services/receipts/void_service.rbs | 14 + .../tenant_payments/create_service.rbs | 36 - .../tenant_payments/receipt_pdf_service.rbs | 14 - sig/rbs_rails/app/models/party.rbs | 8 + .../app/models/payment_ingestion.rbs | 94 +-- sig/rbs_rails/app/models/property.rbs | 12 +- sig/rbs_rails/app/models/receipt.rbs | 701 ++++++++++++++++++ sig/rbs_rails/app/models/tenancy.rbs | 12 +- sig/rbs_rails/app/models/user.rbs | 12 +- sig/rbs_rails/path_helpers.rbs | 26 +- spec/factories.rb | 25 +- spec/models/payment_document_spec.rb | 21 + spec/models/payment_ingestion_spec.rb | 186 +++-- spec/models/property_spec.rb | 2 +- spec/models/receipt_spec.rb | 276 +++++++ spec/models/tenancy_spec.rb | 20 +- spec/models/tenant_payment_spec.rb | 124 ---- spec/models/user_spec.rb | 2 +- .../property_summaries_query_spec.rb | 10 +- .../properties/active_years_query_spec.rb | 2 +- .../properties/financial_items_query_spec.rb | 6 +- .../schedule_e_summary_query_spec.rb | 2 +- spec/queries/tenancies/balance_query_spec.rb | 26 +- spec/requests/charges_spec.rb | 18 + spec/requests/payment_documents_spec.rb | 12 + spec/requests/payment_ingestions_spec.rb | 50 +- spec/requests/property_lifecycle_spec.rb | 17 +- spec/requests/receipts_spec.rb | 393 ++++++++++ spec/requests/tenancies_spec.rb | 4 +- spec/requests/tenant_payments_spec.rb | 146 ---- spec/services/charges/void_service_spec.rb | 22 + .../payment_documents/destroy_service_spec.rb | 36 + .../confirm_service_spec.rb | 179 ++++- .../destroy_service_spec.rb | 46 ++ .../payment_ingestions/update_service_spec.rb | 10 + .../services/receipts/correct_service_spec.rb | 242 ++++++ spec/services/receipts/create_service_spec.rb | 297 ++++++++ spec/services/receipts/post_service_spec.rb | 65 ++ .../receipts/receipt_pdf_service_spec.rb | 88 +++ spec/services/receipts/void_service_spec.rb | 119 +++ .../rent_charges/generate_service_spec.rb | 22 + .../generate_through_service_spec.rb | 32 +- spec/services/schedule_e_generator_spec.rb | 20 +- .../tenant_payments/create_service_spec.rb | 56 -- .../receipt_pdf_service_spec.rb | 66 -- spec/system/dashboards_spec.rb | 2 +- spec/system/receipts_spec.rb | 93 +++ spec/system/schedule_e_spec.rb | 12 +- spec/system/tenant_payments_spec.rb | 34 - 120 files changed, 5149 insertions(+), 1501 deletions(-) create mode 100644 app/controllers/receipts_controller.rb delete mode 100644 app/controllers/tenant_payments_controller.rb create mode 100644 app/models/receipt.rb delete mode 100644 app/models/tenant_payment.rb create mode 100644 app/services/payment_documents/destroy_service.rb create mode 100644 app/services/payment_ingestions/destroy_service.rb create mode 100644 app/services/receipts/correct_service.rb create mode 100644 app/services/receipts/create_service.rb create mode 100644 app/services/receipts/post_service.rb create mode 100644 app/services/receipts/receipt_pdf_service.rb create mode 100644 app/services/receipts/void_service.rb delete mode 100644 app/services/tenant_payments/create_service.rb delete mode 100644 app/services/tenant_payments/receipt_pdf_service.rb create mode 100644 app/views/receipts/_form.html.erb create mode 100644 app/views/receipts/_modal_form.html.erb create mode 100644 app/views/receipts/_receipt.html.erb create mode 100644 app/views/receipts/correction.html.erb create mode 100644 app/views/receipts/index.html.erb create mode 100644 app/views/receipts/new.html.erb create mode 100644 app/views/receipts/show.html.erb delete mode 100644 app/views/tenant_payments/_form.html.erb delete mode 100644 app/views/tenant_payments/_modal_form.html.erb delete mode 100644 app/views/tenant_payments/_tenant_payment.html.erb delete mode 100644 app/views/tenant_payments/index.html.erb delete mode 100644 app/views/tenant_payments/new.html.erb delete mode 100644 app/views/tenant_payments/show.html.erb create mode 100644 db/migrate/20260816000005_create_receipts.rb create mode 100644 db/migrate/20260816000006_retarget_payment_ingestions_to_receipts.rb create mode 100644 db/migrate/20260816000007_drop_tenant_payments.rb create mode 100644 sig/app/controllers/receipts_controller.rbs delete mode 100644 sig/app/controllers/tenant_payments_controller.rbs create mode 100644 sig/app/models/receipt.rbs delete mode 100644 sig/app/models/tenant_payment.rbs create mode 100644 sig/app/services/payment_documents/destroy_service.rbs create mode 100644 sig/app/services/payment_ingestions/destroy_service.rbs create mode 100644 sig/app/services/receipts/correct_service.rbs create mode 100644 sig/app/services/receipts/create_service.rbs create mode 100644 sig/app/services/receipts/post_service.rbs create mode 100644 sig/app/services/receipts/receipt_pdf_service.rbs create mode 100644 sig/app/services/receipts/void_service.rbs delete mode 100644 sig/app/services/tenant_payments/create_service.rbs delete mode 100644 sig/app/services/tenant_payments/receipt_pdf_service.rbs create mode 100644 sig/rbs_rails/app/models/receipt.rbs create mode 100644 spec/models/receipt_spec.rb delete mode 100644 spec/models/tenant_payment_spec.rb create mode 100644 spec/requests/receipts_spec.rb delete mode 100644 spec/requests/tenant_payments_spec.rb create mode 100644 spec/services/payment_documents/destroy_service_spec.rb create mode 100644 spec/services/payment_ingestions/destroy_service_spec.rb create mode 100644 spec/services/receipts/correct_service_spec.rb create mode 100644 spec/services/receipts/create_service_spec.rb create mode 100644 spec/services/receipts/post_service_spec.rb create mode 100644 spec/services/receipts/receipt_pdf_service_spec.rb create mode 100644 spec/services/receipts/void_service_spec.rb delete mode 100644 spec/services/tenant_payments/create_service_spec.rb delete mode 100644 spec/services/tenant_payments/receipt_pdf_service_spec.rb create mode 100644 spec/system/receipts_spec.rb delete mode 100644 spec/system/tenant_payments_spec.rb diff --git a/app/controllers/dashboards_controller.rb b/app/controllers/dashboards_controller.rb index 891a1dbb..b9ec8025 100644 --- a/app/controllers/dashboards_controller.rb +++ b/app/controllers/dashboards_controller.rb @@ -1,7 +1,7 @@ class DashboardsController < ApplicationController def index @properties = authenticated_user.properties - .includes(:expenses, :tenant_payments, tenancies: [ :parties, :tenant_payments, :charges ]) + .includes(:expenses, :receipts, tenancies: [ :parties, :receipts, :charges ]) @property_summaries = Dashboards::PropertySummariesQuery.new(properties: @properties).call end end diff --git a/app/controllers/payment_documents_controller.rb b/app/controllers/payment_documents_controller.rb index 4c7a3ca8..9e8d4046 100644 --- a/app/controllers/payment_documents_controller.rb +++ b/app/controllers/payment_documents_controller.rb @@ -1,7 +1,11 @@ class PaymentDocumentsController < ApplicationController def destroy document = authenticated_user.payment_documents.find(params[:id]) - document.destroy! - redirect_to payment_ingestions_path, notice: "Upload record was removed.", status: :see_other + result = PaymentDocuments::DestroyService.call(user: authenticated_user, document: document) + if result.success? + redirect_to payment_ingestions_path, notice: "Upload record was removed.", status: :see_other + else + redirect_to payment_ingestions_path, alert: result.failure.error, status: :see_other + end end end diff --git a/app/controllers/payment_ingestions_controller.rb b/app/controllers/payment_ingestions_controller.rb index f4dbd565..0d46d131 100644 --- a/app/controllers/payment_ingestions_controller.rb +++ b/app/controllers/payment_ingestions_controller.rb @@ -48,7 +48,7 @@ def confirm begin result = PaymentIngestions::ConfirmService.call(user: authenticated_user, ingestion: @ingestion, create_alias: create_alias) if result.success? - redirect_to payment_ingestions_path, notice: "Payment confirmed and tenant payment created successfully." + redirect_to payment_ingestions_path, notice: "Payment confirmed and payment receipt created successfully." else redirect_to payment_ingestion_path(@ingestion), alert: result.failure.error end @@ -70,8 +70,12 @@ def download end def destroy - @ingestion.destroy! - redirect_to payment_ingestions_path, notice: "Ingestion record was deleted.", status: :see_other + result = PaymentIngestions::DestroyService.call(user: authenticated_user, ingestion: @ingestion) + if result.success? + redirect_to payment_ingestions_path, notice: "Ingestion record was deleted.", status: :see_other + else + redirect_to payment_ingestion_path(@ingestion), alert: result.failure.error + end end private diff --git a/app/controllers/properties_controller.rb b/app/controllers/properties_controller.rb index 10246178..a557e86a 100644 --- a/app/controllers/properties_controller.rb +++ b/app/controllers/properties_controller.rb @@ -11,8 +11,8 @@ def show :rentable_units, :expenses, :charges, - :tenant_payments, - tenancies: %i[parties tenant_payments charges] + :receipts, + tenancies: %i[parties receipts charges] ).find(params.expect(:id)) @financial_items = @property.financial_items(@year) end diff --git a/app/controllers/receipts_controller.rb b/app/controllers/receipts_controller.rb new file mode 100644 index 00000000..b6294d42 --- /dev/null +++ b/app/controllers/receipts_controller.rb @@ -0,0 +1,278 @@ +class ReceiptsController < ApplicationController + before_action :set_tenancy, only: %i[new create] + before_action :set_receipt, only: %i[show correction correct void] + + def index + @receipts = authenticated_user.receipts + .includes(:payer_party, tenancy: { rentable_unit: :property }) + .order(received_on: :desc, created_at: :desc) + end + + def show + respond_to do |format| + format.html + format.pdf do + pdf_data = Receipts::ReceiptPdfService.call(receipt: @receipt, view_context: view_context) + send_data pdf_data, + filename: "receipt-#{@receipt.id}.pdf", + type: "application/pdf", + disposition: "inline" + end + end + end + + def new + @receipt = Receipt.new( + tenancy: @tenancy, + received_on: Date.current + ) + + if (t = @tenancy) + balance = t.current_balance + @receipt.amount = balance > 0 ? balance : nil + active_tenants = t.tenancy_parties.active.where(role: :tenant).map(&:party).compact + @receipt.payer_party = active_tenants.first if active_tenants.size == 1 + end + + load_form_collections + end + + def create + if (t = @tenancy) + if receipt_params[:tenancy_id].present? && receipt_params[:tenancy_id].to_s != t.id.to_s + @receipt = Receipt.new(receipt_params) + @receipt.tenancy = t + flash.now[:alert] = "Submitted tenancy does not match route tenancy" + load_form_collections + return respond_to do |format| + format.html { render :new, status: :unprocessable_content } + format.turbo_stream do + render turbo_stream: turbo_stream.replace("new_receipt_form", + partial: "receipts/modal_form", + locals: { receipt: @receipt, tenancy: @tenancy }), + status: :unprocessable_content + end + end + end + target_tenancy = t + else + if receipt_params[:tenancy_id].blank? + @receipt = Receipt.new(receipt_params) + flash.now[:alert] = "Tenancy is required" + load_form_collections + return respond_to do |format| + format.html { render :new, status: :unprocessable_content } + format.turbo_stream do + render turbo_stream: turbo_stream.replace("new_receipt_form", + partial: "receipts/modal_form", + locals: { receipt: @receipt, tenancy: @tenancy }), + status: :unprocessable_content + end + end + end + + target_tenancy = authenticated_user.tenancies.find_by(id: receipt_params[:tenancy_id]) + unless target_tenancy + @receipt = Receipt.new(receipt_params) + flash.now[:alert] = "Tenancy was not found" + load_form_collections + return respond_to do |format| + format.html { render :new, status: :unprocessable_content } + format.turbo_stream do + render turbo_stream: turbo_stream.replace("new_receipt_form", + partial: "receipts/modal_form", + locals: { receipt: @receipt, tenancy: @tenancy }), + status: :unprocessable_content + end + end + end + end + + target_payer = if receipt_params[:payer_party_id].present? + authenticated_user.parties.find_by(id: receipt_params[:payer_party_id]) + end + + if receipt_params[:payer_party_id].present? && target_payer.nil? + @receipt = Receipt.new(receipt_params) + @receipt.tenancy = target_tenancy + flash.now[:alert] = "Payer party was not found" + load_form_collections + return respond_to do |format| + format.html { render :new, status: :unprocessable_content } + format.turbo_stream do + render turbo_stream: turbo_stream.replace("new_receipt_form", + partial: "receipts/modal_form", + locals: { receipt: @receipt, tenancy: @tenancy }), + status: :unprocessable_content + end + end + end + + result = Receipts::CreateService.call( + tenancy: target_tenancy, + payer_party: target_payer, + amount: receipt_params[:amount], + received_on: receipt_params[:received_on], + payment_method: receipt_params[:payment_method], + external_reference: receipt_params[:external_reference], + memo: receipt_params[:memo] + ) + + if result.success? + created_receipt = result.value!.data[:receipt] + respond_to do |format| + format.html { redirect_to receipt_path(created_receipt), notice: "Payment recorded successfully." } + format.turbo_stream do + if (property = target_tenancy.property) + render turbo_stream: [ + turbo_stream.replace("property_financials", + partial: "properties/financials", + locals: { + property: property, + year: created_receipt.received_on.year, + financial_items: property.financial_items(year: created_receipt.received_on.year), + active_years: property.active_years + }), + turbo_stream.update("flash", + partial: "shared/flash", + locals: { notice: "Payment recorded successfully." }) + ] + else + redirect_to receipt_path(created_receipt), notice: "Payment recorded successfully." + end + end + end + else + @receipt = Receipt.new(receipt_params) + @receipt.tenancy = target_tenancy + @receipt.payer_party = target_payer + flash.now[:alert] = result.failure.error + load_form_collections + respond_to do |format| + format.html { render :new, status: :unprocessable_content } + format.turbo_stream do + render turbo_stream: turbo_stream.replace("new_receipt_form", + partial: "receipts/modal_form", + locals: { receipt: @receipt, tenancy: @tenancy }), + status: :unprocessable_content + end + end + end + end + + def correction + @replacement_receipt = Receipt.new( + tenancy: @receipt.tenancy, + payer_party: @receipt.payer_party, + amount_cents: @receipt.amount_cents, + received_on: @receipt.received_on, + payment_method: @receipt.payment_method, + external_reference: @receipt.external_reference, + memo: @receipt.memo + ) + load_form_collections + end + + def correct + if receipt_params[:tenancy_id].present? + target_tenancy = authenticated_user.tenancies.find_by(id: receipt_params[:tenancy_id]) + unless target_tenancy + @replacement_receipt = Receipt.new(receipt_params) + flash.now[:alert] = "Tenancy was not found" + load_form_collections + return render :correction, status: :unprocessable_content + end + else + target_tenancy = @receipt.tenancy + end + + if receipt_params[:payer_party_id].present? + target_payer = authenticated_user.parties.find_by(id: receipt_params[:payer_party_id]) + unless target_payer + @replacement_receipt = Receipt.new(receipt_params) + @replacement_receipt.tenancy = target_tenancy + flash.now[:alert] = "Payer party was not found" + load_form_collections + return render :correction, status: :unprocessable_content + end + else + target_payer = @receipt.payer_party + end + + result = Receipts::CorrectService.call( + receipt: @receipt, + tenancy: target_tenancy, + payer_party: target_payer, + amount: receipt_params[:amount], + received_on: receipt_params[:received_on], + payment_method: receipt_params[:payment_method], + external_reference: receipt_params[:external_reference], + memo: receipt_params[:memo] + ) + + if result.success? + replacement = result.value!.data[:receipt] + redirect_to receipt_path(replacement), notice: "Payment corrected successfully. Original payment ##{@receipt.id} has been reversed." + else + @replacement_receipt = Receipt.new(receipt_params) + @replacement_receipt.tenancy = target_tenancy + @replacement_receipt.payer_party = target_payer + flash.now[:alert] = result.failure.error + load_form_collections + render :correction, status: :unprocessable_content + end + end + + def void + result = Receipts::VoidService.call( + receipt: @receipt, + reason: params[:reason] + ) + + if result.success? + redirect_to receipt_path(@receipt), notice: "Payment has been voided and accounting entries reversed." + else + redirect_to receipt_path(@receipt), alert: result.failure.error + end + end + + private + + def set_tenancy + @tenancy = authenticated_user.tenancies.find(params[:tenancy_id]) if params[:tenancy_id] + end + + def set_receipt + @receipt = authenticated_user.receipts.find(params.expect(:id)) + end + + def receipt_params + receipt_p = params[:receipt] + if receipt_p.is_a?(ActionController::Parameters) + receipt_p.permit( + :tenancy_id, + :payer_party_id, + :amount, + :received_on, + :payment_method, + :external_reference, + :memo + ) + else + ActionController::Parameters.new.permit( + :tenancy_id, + :payer_party_id, + :amount, + :received_on, + :payment_method, + :external_reference, + :memo + ) + end + end + + def load_form_collections + @tenancies = authenticated_user.tenancies.includes(:parties, rentable_unit: :property) + @parties = authenticated_user.parties.order(:display_name) + end +end diff --git a/app/controllers/tenant_payments_controller.rb b/app/controllers/tenant_payments_controller.rb deleted file mode 100644 index ecc5c914..00000000 --- a/app/controllers/tenant_payments_controller.rb +++ /dev/null @@ -1,103 +0,0 @@ -class TenantPaymentsController < ApplicationController - before_action :set_tenant_payment, only: %i[show] - before_action :set_tenancy, only: %i[new create] - before_action :set_form_data, only: %i[new create] - - def index - @tenant_payments = authenticated_user.tenant_payments.includes(tenancy: { rentable_unit: :property }) - end - - def show - respond_to do |format| - format.html - format.pdf do - pdf_data = TenantPayments::ReceiptPdfService.call(tenant_payment: @tenant_payment, view_context: helpers) - send_data pdf_data, filename: "receipt_#{@tenant_payment.id}.pdf", type: "application/pdf", disposition: "inline" - end - end - end - - def new - @tenant_payment = TenantPayment.new - @tenant_payment.tenancy = @tenancy if @tenancy - if tenancy = @tenancy - owed = tenancy.current_balance - tp = @tenant_payment - tp.amount = owed > BigDecimal("0") ? owed : BigDecimal("0") - end - @tenant_payment.payment_date = Date.current - end - - def create - tenancy_id = tenant_payment_params[:tenancy_id] - tenancy = @tenancy || (tenancy_id.present? ? authenticated_user.tenancies.find(tenancy_id) : nil) - - result = TenantPayments::CreateService.call( - tenancy: tenancy, - params: tenant_payment_params - ) - - respond_to do |format| - if result.success? - @tenant_payment = result.value!.data[:tenant_payment] - if (t = @tenancy) && (property = t.property) - # Submitted from modal - year = @tenant_payment.payment_date&.year || Date.current.year - @financial_items = property.financial_items(year) - @year = year - - format.turbo_stream { - flash.now[:notice] = "Payment recorded successfully." - render turbo_stream: [ - turbo_stream.action(:close_modal, "modal-container"), - turbo_stream.update("property_financials", partial: "properties/financials", - locals: { property: property, financial_items: @financial_items, year: @year }), - turbo_stream.update("active_lease_balances", partial: "properties/lease_balances", - locals: { property: property }), - turbo_stream.append("flash-messages", partial: "shared/toast", locals: { type: :notice, message: "Payment recorded successfully." }) - ] - } - format.html { redirect_to property, notice: "Payment recorded successfully." } - else - format.html { redirect_to @tenant_payment, notice: "Payment was successfully created." } - end - format.json { render :show, status: :created, location: @tenant_payment } - else - @tenant_payment = result.failure.data&.dig(:tenant_payment) || TenantPayment.new(tenant_payment_params) - @tenant_payment.tenancy = tenancy if tenancy - format.html { render :new, status: :unprocessable_content } - format.json { render json: @tenant_payment.errors, status: :unprocessable_content } - format.turbo_stream { - render turbo_stream: turbo_stream.update("modal-frame", - partial: "tenant_payments/modal_form", - locals: { tenant_payment: @tenant_payment, tenancy: @tenancy }) - } - end - end - end - - private - - def set_tenant_payment - @tenant_payment = authenticated_user.tenant_payments.find(params.expect(:id)) - end - - def set_tenancy - t_id = params[:tenancy_id] || params[:lease_id] - @tenancy = authenticated_user.tenancies.find(t_id) if t_id.present? - end - - def set_form_data - @tenancies = authenticated_user.tenancies.includes({ rentable_unit: :property }, :parties) - end - - def tenant_payment_params - raw_params = params.require(:tenant_payment).permit( - :tenancy_id, :lease_id, :payment_date, :amount, :payment_method, :transaction_number - ) - if raw_params[:lease_id].present? && raw_params[:tenancy_id].blank? - raw_params[:tenancy_id] = raw_params.delete(:lease_id) - end - raw_params - end -end diff --git a/app/javascript/controllers/payment_ingestion_form_controller.js b/app/javascript/controllers/payment_ingestion_form_controller.js index 2e02445f..96d7e15e 100644 --- a/app/javascript/controllers/payment_ingestion_form_controller.js +++ b/app/javascript/controllers/payment_ingestion_form_controller.js @@ -7,95 +7,27 @@ export default class extends Controller { tenancyParties: Object } - connect() { - // Store original options - this.allTenancyOptions = Array.from(this.tenancySelectTarget.options).map(opt => ({ - value: opt.value, - text: opt.text - })) - this.allPartyOptions = Array.from(this.partySelectTarget.options).map(opt => ({ - value: opt.value, - text: opt.text - })) - - // Perform initial filtering based on selection - this.filterTenancies(false) - this.filterParties(false) - } - partyChanged() { - this.filterTenancies(true) - } - - tenancyChanged() { - this.filterParties(true) - } - - filterTenancies(resetSelectionIfInvalid) { const selectedPartyId = this.partySelectTarget.value - const currentSelectedTenancyId = this.tenancySelectTarget.value - - if (!selectedPartyId) { - // Restore all tenancies - this.populateSelect(this.tenancySelectTarget, this.allTenancyOptions, currentSelectedTenancyId) - return - } - - const allowedTenancyIds = this.partyTenanciesValue[selectedPartyId] || [] + const currentTenancyId = this.tenancySelectTarget.value - // Filter options - const filteredOptions = this.allTenancyOptions.filter(opt => { - return !opt.value || allowedTenancyIds.includes(parseInt(opt.value)) - }) - - const isCurrentValid = allowedTenancyIds.includes(parseInt(currentSelectedTenancyId)) - let nextSelectedId = isCurrentValid ? currentSelectedTenancyId : "" - - if (!isCurrentValid && allowedTenancyIds.length === 1) { - nextSelectedId = allowedTenancyIds[0].toString() + if (!currentTenancyId && selectedPartyId) { + const associatedTenancies = this.partyTenanciesValue[selectedPartyId] || [] + if (associatedTenancies.length === 1) { + this.tenancySelectTarget.value = associatedTenancies[0].toString() + } } - - this.populateSelect(this.tenancySelectTarget, filteredOptions, resetSelectionIfInvalid ? nextSelectedId : currentSelectedTenancyId) } - filterParties(resetSelectionIfInvalid) { + tenancyChanged() { const selectedTenancyId = this.tenancySelectTarget.value - const currentSelectedPartyId = this.partySelectTarget.value - - if (!selectedTenancyId) { - // Restore all parties - this.populateSelect(this.partySelectTarget, this.allPartyOptions, currentSelectedPartyId) - return - } - - const allowedPartyIds = this.tenancyPartiesValue[selectedTenancyId] || [] - - // Filter options - const filteredOptions = this.allPartyOptions.filter(opt => { - return !opt.value || allowedPartyIds.includes(parseInt(opt.value)) - }) - - const isCurrentValid = allowedPartyIds.includes(parseInt(currentSelectedPartyId)) - let nextSelectedId = isCurrentValid ? currentSelectedPartyId : "" + const currentPartyId = this.partySelectTarget.value - if (!isCurrentValid && allowedPartyIds.length === 1) { - nextSelectedId = allowedPartyIds[0].toString() - } - - this.populateSelect(this.partySelectTarget, filteredOptions, resetSelectionIfInvalid ? nextSelectedId : currentSelectedPartyId) - } - - populateSelect(selectElement, options, selectedValue) { - selectElement.innerHTML = "" - - options.forEach(opt => { - const option = document.createElement("option") - option.value = opt.value - option.text = opt.text - if (opt.value === selectedValue.toString()) { - option.selected = true + if (!currentPartyId && selectedTenancyId) { + const associatedParties = this.tenancyPartiesValue[selectedTenancyId] || [] + if (associatedParties.length === 1) { + this.partySelectTarget.value = associatedParties[0].toString() } - selectElement.add(option) - }) + } } } diff --git a/app/models/party.rb b/app/models/party.rb index 7d47e6ef..58f4cedf 100644 --- a/app/models/party.rb +++ b/app/models/party.rb @@ -4,6 +4,7 @@ class Party < ApplicationRecord has_many :tenancy_parties, dependent: :restrict_with_error 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 :payment_ingestions, dependent: :nullify PARTY_TYPES = %w[ diff --git a/app/models/payment_document.rb b/app/models/payment_document.rb index b6b8d1f2..ef3e81ba 100644 --- a/app/models/payment_document.rb +++ b/app/models/payment_document.rb @@ -7,6 +7,8 @@ class PaymentDocument < ApplicationRecord validates :attachment_content_type, presence: true validates :status, presence: true + before_destroy :prevent_destroy_if_confirmed_ingestions_exist, prepend: true + enum :status, { processing: "processing", success: "success", @@ -16,4 +18,13 @@ class PaymentDocument < ApplicationRecord def accounting_user user end + + private + + def prevent_destroy_if_confirmed_ingestions_exist + if payment_ingestions.confirmed.exists? + errors.add(:base, "Cannot delete document with confirmed payment ingestions") + throw(:abort) + end + end end diff --git a/app/models/payment_ingestion.rb b/app/models/payment_ingestion.rb index 138d1340..a4bc7e8a 100644 --- a/app/models/payment_ingestion.rb +++ b/app/models/payment_ingestion.rb @@ -2,7 +2,7 @@ class PaymentIngestion < ApplicationRecord belongs_to :user belongs_to :party, optional: true belongs_to :tenancy, optional: true - belongs_to :tenant_payment, optional: true + belongs_to :receipt, optional: true belongs_to :payment_document, optional: true validates :source, presence: true @@ -11,6 +11,9 @@ class PaymentIngestion < ApplicationRecord validate :ensure_not_duplicate_payment validate :validate_parse_status + validate :prevent_mutation_after_confirmed, on: :update + + before_destroy :prevent_destroy_if_confirmed enum :status, { pending: "pending", @@ -43,10 +46,8 @@ def confirm!(create_alias: false) def duplicate_exists? return false if transaction_number.blank? || payment_method.blank? - scope = TenantPayment.joins(tenancy: { rentable_unit: :property }) - .where(properties: { user_id: user_id }) - .where(payment_method: payment_method, transaction_number: transaction_number) - scope = scope.where.not(id: tenant_payment_id) if tenant_payment_id.present? + scope = Receipt.active.where(user_id: user_id, payment_method: payment_method, external_reference: transaction_number) + scope = scope.where.not(id: receipt_id) if receipt_id.present? scope.exists? end @@ -80,9 +81,27 @@ def validate_parse_status def ensure_not_duplicate_payment if duplicate_exists? - errors.add(:base, "This payment receipt has already been confirmed and recorded in a tenant payment.") + errors.add(:base, "This payment receipt has already been confirmed and recorded in a receipt.") elsif ingestion_duplicate_exists? errors.add(:base, "This payment receipt has already been uploaded and is pending review.") end end + + def prevent_mutation_after_confirmed + persisted_confirmed = persisted? && self.class.where(id: id, status: :confirmed).exists? + return unless status_was == "confirmed" || persisted_confirmed + + changed = changes_to_save.keys - %w[updated_at] + if changed.any? + errors.add(:base, "Cannot modify a confirmed payment ingestion") + end + end + + def prevent_destroy_if_confirmed + persisted_confirmed = persisted? && self.class.where(id: id, status: :confirmed).exists? + if confirmed? || persisted_confirmed + errors.add(:base, "Cannot delete a confirmed payment ingestion") + throw(:abort) + end + end end diff --git a/app/models/property.rb b/app/models/property.rb index d2cc6147..9e321645 100644 --- a/app/models/property.rb +++ b/app/models/property.rb @@ -4,7 +4,7 @@ class Property < ApplicationRecord has_many :tenancies, through: :rentable_units has_many :expenses, dependent: :restrict_with_error has_many :charges, through: :tenancies - has_many :tenant_payments, through: :tenancies + has_many :receipts, through: :tenancies has_many :accounting_postings, class_name: "Posting", dependent: :restrict_with_error ASSET_TYPES = %w[ diff --git a/app/models/receipt.rb b/app/models/receipt.rb new file mode 100644 index 00000000..95860905 --- /dev/null +++ b/app/models/receipt.rb @@ -0,0 +1,143 @@ +class Receipt < ApplicationRecord + belongs_to :user + belongs_to :tenancy + belongs_to :payer_party, class_name: "Party" + belongs_to :superseded_by, class_name: "Receipt", optional: true + + has_one :superseded_receipt, class_name: "Receipt", foreign_key: :superseded_by_id, dependent: :nullify + has_many :journal_entries, as: :source, dependent: :restrict_with_error + + normalizes :payment_method, with: ->(m) { m&.strip&.downcase } + normalizes :external_reference, with: ->(r) { r.presence&.strip } + + validates :user, presence: true + validates :tenancy, presence: true + validates :payer_party, presence: true + validates :received_on, presence: true + validates :payment_method, presence: true + validates :amount_cents, numericality: { only_integer: true, greater_than: 0 } + validates :external_reference, uniqueness: { + scope: %i[user_id payment_method], + conditions: -> { where(voided_at: nil).where.not(external_reference: nil) }, + allow_nil: true + } + + validate :user_matches_tenancy_owner + validate :payer_party_matches_user + validate :superseded_by_same_user, if: :superseded_by_id? + validate :validate_amount_format + validate :prevent_mutation_after_posting, on: :update + + before_destroy :prevent_destroy + + scope :active, -> { where(voided_at: nil) } + scope :voided, -> { where.not(voided_at: nil) } + + def tenancy=(t) + super + self.user ||= t.accounting_user if t.respond_to?(:accounting_user) + end + + def posted? + posted_at.present? + end + + def voided? + voided_at.present? + end + + def superseded? + superseded_by_id.present? + end + + def active? + !voided? + end + + def amount + return nil if amount_cents.nil? + + BigDecimal(amount_cents.to_s) / 100 + end + + def amount=(value) + if value.blank? + self.amount_cents = nil + @amount_error = nil + return + end + + val_str = value.is_a?(Numeric) ? value.to_s : value.to_s.strip + unless val_str =~ /\A-?\d+(\.\d+)?\z/ + @amount_error = "is not a valid number" + self.amount_cents = nil + return + end + + if val_str.include?(".") + decimals = val_str.split(".").last + if decimals.length > 2 && decimals[2..].to_i != 0 + @amount_error = "cannot have fractional cents" + self.amount_cents = nil + return + end + end + + @amount_error = nil + self.amount_cents = (BigDecimal(val_str) * 100).round + end + + def accounting_user + user + end + + private + + def user_matches_tenancy_owner + if tenancy.present? && user.present? && tenancy.accounting_user != user + errors.add(:tenancy, "must belong to the receipt owner") + end + end + + def payer_party_matches_user + if payer_party.present? && user.present? && payer_party.user != user + errors.add(:payer_party, "must belong to the receipt owner") + end + end + + def superseded_by_same_user + if superseded_by.present? && superseded_by.user_id != user_id + errors.add(:superseded_by, "must belong to the same user") + end + end + + def validate_amount_format + errors.add(:amount, @amount_error) if @amount_error.present? + end + + def prevent_mutation_after_posting + if posted_at_was.present? + if will_save_change_to_voided_at? + errors.add(:voided_at, "cannot be modified directly; use Receipts::VoidService or Receipts::CorrectService") + end + if will_save_change_to_posted_at? + errors.add(:posted_at, "cannot be modified directly once posted") + end + if will_save_change_to_superseded_by_id? + errors.add(:superseded_by_id, "cannot be modified directly; use Receipts::CorrectService") + end + + protected_attrs = %w[user_id tenancy_id payer_party_id amount_cents received_on payment_method external_reference memo] + if protected_attrs.any? { |attr| will_save_change_to_attribute?(attr) } + errors.add(:base, "Posted receipts are immutable records") + end + end + end + + def prevent_destroy + if posted? + errors.add(:base, "Cannot delete a posted receipt") + throw(:abort) + end + end +end diff --git a/app/models/tenancy.rb b/app/models/tenancy.rb index d9273211..b5cc3539 100644 --- a/app/models/tenancy.rb +++ b/app/models/tenancy.rb @@ -6,7 +6,7 @@ class Tenancy < ApplicationRecord has_many :rent_terms, dependent: :destroy has_many :charges, dependent: :restrict_with_error - has_many :tenant_payments, dependent: :restrict_with_error + has_many :receipts, dependent: :restrict_with_error has_many :accounting_postings, class_name: "Posting", dependent: :restrict_with_error has_many :payment_ingestions, dependent: :nullify @@ -91,7 +91,7 @@ def most_recent_rent_term end def financial_history? - charges.exists? || tenant_payments.exists? || accounting_postings.exists? + charges.exists? || receipts.exists? || accounting_postings.exists? end def balance_cents(as_of: Date.current) diff --git a/app/models/tenant_payment.rb b/app/models/tenant_payment.rb deleted file mode 100644 index ea1bde55..00000000 --- a/app/models/tenant_payment.rb +++ /dev/null @@ -1,47 +0,0 @@ -class TenantPayment < ApplicationRecord - belongs_to :tenancy - belongs_to :user - - before_validation :assign_user_from_tenancy - - validates :amount, presence: true, numericality: { greater_than: 0 } - validates :payment_date, presence: true - validates :payment_method, presence: true - validates :transaction_number, length: { maximum: 50 }, format: { with: /\A[a-zA-Z0-9_\-]*\z/, message: "must be alphanumeric with dashes or underscores" }, allow_blank: true - validates :transaction_number, uniqueness: { scope: %i[user_id payment_method] }, allow_blank: true - validate :user_matches_tenancy_owner - - has_many :journal_entries, as: :source, dependent: :restrict_with_error - - validate :prevent_mutation_after_creation, on: :update - before_destroy :prevent_destroy - - def accounting_user - user || tenancy&.property&.user - end - - private - - def prevent_mutation_after_creation - errors.add(:base, "Tenant payments are immutable once recorded.") - end - - def prevent_destroy - errors.add(:base, "Tenant payments cannot be destroyed once recorded.") - throw(:abort) - end - - def assign_user_from_tenancy - if (prop = tenancy&.property) && (prop_user = prop.user) - self.user ||= prop_user - end - end - - def user_matches_tenancy_owner - return unless user_id && (prop = tenancy&.property) - - if user_id != prop.user_id - errors.add(:user, "must match the tenancy owner") - end - end -end diff --git a/app/models/user.rb b/app/models/user.rb index 9b908ee3..42a06834 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -6,7 +6,7 @@ class User < ApplicationRecord has_many :tenancies, through: :rentable_units has_many :expenses, through: :properties has_many :charges, through: :tenancies - has_many :tenant_payments, through: :tenancies + has_many :receipts, dependent: :restrict_with_error has_many :parties, dependent: :destroy has_many :payment_ingestions, dependent: :destroy has_many :payment_documents, dependent: :destroy diff --git a/app/queries/dashboards/property_summaries_query.rb b/app/queries/dashboards/property_summaries_query.rb index 1b3b9189..870efac4 100644 --- a/app/queries/dashboards/property_summaries_query.rb +++ b/app/queries/dashboards/property_summaries_query.rb @@ -13,7 +13,7 @@ def call attr_reader :properties def summary_for(property) - income = property.tenant_payments.sum(BigDecimal("0"), &:amount) || BigDecimal("0") + income = property.receipts.active.sum(BigDecimal("0"), &:amount) || BigDecimal("0") expenses = property.expenses.sum(BigDecimal("0"), &:amount) || BigDecimal("0") active_tenancies = property.tenancies.select(&:active?) diff --git a/app/queries/properties/active_years_query.rb b/app/queries/properties/active_years_query.rb index 91a1e3cb..e3c5c115 100644 --- a/app/queries/properties/active_years_query.rb +++ b/app/queries/properties/active_years_query.rb @@ -8,7 +8,7 @@ def call(additional_years: []) years = Set.new years << Date.current.year years.merge(years_for(:charges, :charge_date)) - years.merge(years_for(:tenant_payments, :payment_date)) + years.merge(years_for(:receipts, :received_on)) years.merge(years_for(:expenses, :expense_date)) 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 7d671eec..224cda10 100644 --- a/app/queries/properties/financial_items_query.rb +++ b/app/queries/properties/financial_items_query.rb @@ -10,7 +10,7 @@ def call(year:) [ items_for(:charges, :charge_date, "Charge", start_date, end_date), - items_for(:tenant_payments, :payment_date, "Tenant Payment", start_date, end_date), + items_for(:receipts, :received_on, "Payment", start_date, end_date), items_for(:expenses, :expense_date, "Expense", start_date, end_date) ].flatten.sort_by { |item| item[:date] } end diff --git a/app/queries/properties/schedule_e_summary_query.rb b/app/queries/properties/schedule_e_summary_query.rb index 0ab392a7..714dc7e4 100644 --- a/app/queries/properties/schedule_e_summary_query.rb +++ b/app/queries/properties/schedule_e_summary_query.rb @@ -9,7 +9,7 @@ def initialize(property:) def call(year:) start_date = Date.new(year.to_i, 1, 1) end_date = start_date.end_of_year - rents_received = property.tenant_payments.where(payment_date: start_date..end_date).sum(:amount) + rents_received = BigDecimal(property.receipts.active.where(received_on: start_date..end_date).sum(:amount_cents).to_s) / 100 utility_reimbursements = BigDecimal("0") total_income = rents_received + utility_reimbursements expenses_by_category = property.expenses.where(expense_date: start_date..end_date).group(:category).sum(:amount) diff --git a/app/services/payment_documents/destroy_service.rb b/app/services/payment_documents/destroy_service.rb new file mode 100644 index 00000000..209f2464 --- /dev/null +++ b/app/services/payment_documents/destroy_service.rb @@ -0,0 +1,42 @@ +module PaymentDocuments + class DestroyService + def self.call(user:, document:) + new(user:, document:).call + end + + def initialize(user:, document:) + @user = user + @document = document + end + + def call + return failure("Document was not found.", :not_found) unless document.user_id == user.id + + document.transaction do + document.lock! + ingestions = document.payment_ingestions.lock("FOR UPDATE").to_a + + if ingestions.any?(&:confirmed?) + return failure("Cannot delete document with confirmed payment ingestions", :immutable) + end + + document.destroy! + success(document) + end + rescue ActiveRecord::RecordNotDestroyed => e + failure(e.record.errors.full_messages.to_sentence.presence || "Cannot delete document", :destroy_failed) + end + + private + + attr_reader :user, :document + + def success(data) + ServiceResult.success(data) + end + + def failure(error, code) + ServiceResult.failure(error:, code:) + end + end +end diff --git a/app/services/payment_ingestions/confirm_service.rb b/app/services/payment_ingestions/confirm_service.rb index fef29913..8569bc01 100644 --- a/app/services/payment_ingestions/confirm_service.rb +++ b/app/services/payment_ingestions/confirm_service.rb @@ -12,28 +12,48 @@ def initialize(user:, ingestion:, create_alias: false) def call return failure("Payment ingestion was not found.", :not_found) unless ingestion.user_id == user.id - return failure("Already confirmed", :already_confirmed) if ingestion.confirmed? + + if ingestion.confirmed? + if ingestion.receipt.present? + return success(ingestion.receipt) + else + return failure("Already confirmed but receipt record is missing", :confirmation_error) + end + end + return failure("Cannot confirm: missing required fields or duplicate exists", :not_confirmable) unless ingestion.confirmable? - payment = nil # : TenantPayment? + receipt = nil # : Receipt? ingestion.transaction do + ingestion.payment_document&.lock! ingestion.lock! - raise ConfirmationError, "Already confirmed" if ingestion.confirmed? + + if ingestion.confirmed? + if ingestion.receipt.present? + receipt = ingestion.receipt + next + else + raise ConfirmationError, "Already confirmed but receipt record is missing" + end + end + raise ConfirmationError, "Cannot confirm: missing required fields or duplicate exists" unless ingestion.confirmable? - created = create_payment + created = create_receipt create_aliases if create_aliases? - ingestion.update!(status: :confirmed, tenant_payment: created) - payment = created + ingestion.update!(status: :confirmed, receipt: created) + receipt = created end - if payment - success(payment) + if receipt + success(receipt) else - failure("Failed to create tenant payment", :confirmation_error) + failure("Failed to create receipt", :confirmation_error) end rescue ActiveRecord::RecordNotUnique - failure("This transaction has already been recorded in another tenant payment.", :duplicate) + failure("This transaction has already been recorded in another payment receipt.", :duplicate) + rescue ActiveRecord::RecordNotFound + failure("Payment ingestion was not found.", :not_found) rescue ConfirmationError => e failure(e.message, :confirmation_error) rescue ActiveRecord::RecordInvalid => e @@ -44,19 +64,23 @@ def call attr_reader :user, :ingestion - def create_payment + def create_receipt tenancy = ingestion.tenancy raise ConfirmationError, "Missing tenancy" unless tenancy - result = TenantPayments::CreateService.call( + party = ingestion.party + raise ConfirmationError, "Missing payer party" unless party + + result = Receipts::CreateService.call( tenancy: tenancy, + payer_party: party, amount: ingestion.amount, - payment_date: ingestion.payment_date, + received_on: ingestion.payment_date, payment_method: ingestion.payment_method, - transaction_number: ingestion.transaction_number + external_reference: ingestion.transaction_number ) if result.success? - result.value!.data[:tenant_payment] + result.value!.data[:receipt] else raise ConfirmationError, result.failure.error end diff --git a/app/services/payment_ingestions/destroy_service.rb b/app/services/payment_ingestions/destroy_service.rb new file mode 100644 index 00000000..c85366b6 --- /dev/null +++ b/app/services/payment_ingestions/destroy_service.rb @@ -0,0 +1,37 @@ +module PaymentIngestions + class DestroyService + def self.call(user:, ingestion:) + new(user:, ingestion:).call + end + + def initialize(user:, ingestion:) + @user = user + @ingestion = ingestion + end + + def call + return failure("Payment ingestion was not found.", :not_found) unless ingestion.user_id == user.id + + ingestion.with_lock do + return failure("Cannot delete a confirmed payment ingestion", :immutable) if ingestion.confirmed? + + ingestion.destroy! + success(ingestion) + end + rescue ActiveRecord::RecordNotDestroyed => e + failure(e.record.errors.full_messages.to_sentence.presence || "Cannot delete payment ingestion", :destroy_failed) + end + + private + + attr_reader :user, :ingestion + + def success(data) + ServiceResult.success(data) + end + + def failure(error, code) + ServiceResult.failure(error:, code:) + end + end +end diff --git a/app/services/payment_ingestions/update_service.rb b/app/services/payment_ingestions/update_service.rb index 20dc6f8f..37569408 100644 --- a/app/services/payment_ingestions/update_service.rb +++ b/app/services/payment_ingestions/update_service.rb @@ -13,11 +13,15 @@ def initialize(user:, ingestion:, params:) def call return failure("Payment ingestion was not found.", :not_found) unless ingestion.user_id == user.id - if ingestion.update(params) - promote_to_matched if promotable_to_matched? - success(ingestion) - else - failure(ingestion.errors.full_messages.to_sentence, :validation_error) + ingestion.with_lock do + return failure("Cannot update a confirmed payment ingestion", :immutable) if ingestion.confirmed? + + if ingestion.update(params) + promote_to_matched if promotable_to_matched? + success(ingestion) + else + failure(ingestion.errors.full_messages.to_sentence, :validation_error) + end end end diff --git a/app/services/receipts/correct_service.rb b/app/services/receipts/correct_service.rb new file mode 100644 index 00000000..9f0c42c5 --- /dev/null +++ b/app/services/receipts/correct_service.rb @@ -0,0 +1,208 @@ +module Receipts + class CorrectService + def self.call( + receipt:, + tenancy: nil, + payer_party: nil, + amount_cents: nil, + amount: nil, + received_on: nil, + payment_method: nil, + external_reference: nil, + memo: nil + ) + new( + receipt: receipt, + tenancy: tenancy, + payer_party: payer_party, + amount_cents: amount_cents, + amount: amount, + received_on: received_on, + payment_method: payment_method, + external_reference: external_reference, + memo: memo + ).call + end + + def initialize( + receipt:, + tenancy: nil, + payer_party: nil, + amount_cents: nil, + amount: nil, + received_on: nil, + payment_method: nil, + external_reference: nil, + memo: nil + ) + @receipt = receipt + @tenancy = tenancy + @payer_party = payer_party + @amount_cents = amount_cents + @amount = amount + @raw_received_on = received_on + @received_on = parse_date(received_on) + @payment_method = payment_method + @external_reference = external_reference + @memo = memo + end + + def call + unless receipt.is_a?(Receipt) && receipt.persisted? && !receipt.destroyed? + return ServiceResult.failure(error: "Receipt must be a persisted Receipt record", code: :invalid_source) + end + + journal_entry = receipt.journal_entries.find_by(event_type: "receipt_posted") + unless journal_entry + return ServiceResult.failure(error: "Journal entry not found for receipt", code: :not_found) + end + + if raw_received_on.present? && received_on.nil? + return ServiceResult.failure(error: "Received on must be a valid date", code: :invalid_input) + end + + target_tenancy = tenancy || receipt.tenancy + target_payer = payer_party || receipt.payer_party + + owner = receipt.user + unless target_tenancy&.accounting_user == owner + return ServiceResult.failure( + error: "Cannot move receipt to another user's tenancy", + code: :ownership_mismatch + ) + end + + unless target_payer&.user == owner + return ServiceResult.failure( + error: "Cannot assign payer belonging to another user", + code: :ownership_mismatch + ) + end + + target_received_on = received_on || receipt.received_on + target_method = payment_method.to_s.strip.downcase.presence || receipt.payment_method + target_external_ref = external_reference.nil? ? receipt.external_reference : external_reference.to_s.strip.presence + target_memo = memo.nil? ? receipt.memo : memo.to_s.strip.presence + + resolved_cents, parse_error = resolve_amount_cents + if parse_error + return ServiceResult.failure(error: parse_error, code: :invalid_amount) + end + + replacement_receipt = nil # : Receipt? + failure_result = nil # : Dry::Monads::Result::Failure? + + Receipt.transaction do + receipt.lock! + + if receipt.superseded? + existing_rep = receipt.superseded_by + if existing_rep && identical_replacement?(existing_rep, target_tenancy, target_payer, resolved_cents, target_received_on, target_method, target_external_ref, target_memo) + return ServiceResult.success(receipt: existing_rep, original_receipt: receipt) + else + failure_result = ServiceResult.failure( + error: "Cannot correct an already superseded receipt", + code: :already_superseded + ) + raise ActiveRecord::Rollback + end + end + + if receipt.voided? + failure_result = ServiceResult.failure( + error: "Cannot correct a voided receipt", + code: :already_voided + ) + raise ActiveRecord::Rollback + end + + reverse_result = Accounting::ReverseEntryService.call( + journal_entry: journal_entry, + occurred_on: receipt.received_on, + description: "Corrected by replacement receipt" + ) + + unless reverse_result.success? + failure_result = reverse_result + raise ActiveRecord::Rollback + end + + receipt.update_columns(voided_at: Time.current) + + create_result = Receipts::CreateService.call( + tenancy: target_tenancy, + payer_party: target_payer, + amount_cents: resolved_cents, + received_on: target_received_on, + payment_method: target_method, + external_reference: target_external_ref, + memo: target_memo + ) + + unless create_result.success? + failure_result = create_result + raise ActiveRecord::Rollback + end + + created = create_result.value!.data[:receipt] + receipt.update_columns(superseded_by_id: created.id) + replacement_receipt = created + end + + if (f = failure_result) + f + elsif replacement_receipt + ServiceResult.success(receipt: replacement_receipt, original_receipt: receipt) + else + ServiceResult.failure(error: "Failed to correct receipt", code: :correction_failed) + end + end + + private + + attr_reader :receipt, :tenancy, :payer_party, :amount_cents, :amount, + :raw_received_on, :received_on, :payment_method, :external_reference, :memo + + def parse_date(val) + return nil if val.blank? + return val if val.is_a?(Date) + return val.to_date if val.respond_to?(:to_date) + + Date.parse(val.to_s) + rescue ArgumentError, Date::Error + nil + end + + def resolve_amount_cents + if amount_cents.present? + [ amount_cents.to_i, nil ] + elsif amount.present? + val_str = amount.is_a?(Numeric) ? amount.to_s : amount.to_s.strip + unless val_str =~ /\A-?\d+(\.\d+)?\z/ + return [ 0, "Amount is not a valid number" ] + end + + if val_str.include?(".") + decimals = val_str.split(".").last + if decimals.length > 2 && decimals[2..].to_i != 0 + return [ 0, "Amount cannot have fractional cents" ] + end + end + + [ (BigDecimal(val_str) * 100).round, nil ] + else + [ receipt.amount_cents, nil ] + end + end + + def identical_replacement?(rep, t_tenancy, t_payer, t_cents, t_date, t_method, t_ref, t_memo) + rep.tenancy_id == t_tenancy.id && + rep.payer_party_id == t_payer.id && + rep.amount_cents == t_cents && + rep.received_on == t_date && + rep.payment_method.to_s.strip.downcase == t_method.to_s.strip.downcase && + rep.external_reference.to_s.strip.presence == t_ref.to_s.strip.presence && + rep.memo.to_s.strip.presence == t_memo.to_s.strip.presence + end + end +end diff --git a/app/services/receipts/create_service.rb b/app/services/receipts/create_service.rb new file mode 100644 index 00000000..3cbd8300 --- /dev/null +++ b/app/services/receipts/create_service.rb @@ -0,0 +1,171 @@ +module Receipts + class CreateService + def self.call( + tenancy:, + payer_party:, + received_on:, + payment_method:, + amount_cents: nil, + amount: nil, + external_reference: nil, + memo: nil + ) + new( + tenancy: tenancy, + payer_party: payer_party, + received_on: received_on, + payment_method: payment_method, + amount_cents: amount_cents, + amount: amount, + external_reference: external_reference, + memo: memo + ).call + end + + def initialize( + tenancy:, + payer_party:, + received_on:, + payment_method:, + amount_cents: nil, + amount: nil, + external_reference: nil, + memo: nil + ) + @tenancy = tenancy + @payer_party = payer_party + @amount_cents = amount_cents + @amount = amount + @raw_received_on = received_on + @received_on = parse_date(received_on) + @payment_method = payment_method.to_s.strip.downcase.presence + @external_reference = external_reference.to_s.strip.presence + @memo = memo.to_s.strip.presence + end + + def call + unless tenancy && tenancy.is_a?(Tenancy) && tenancy.persisted? && !tenancy.destroyed? + return ServiceResult.failure(error: "Tenancy is required", code: :invalid_input) + end + + unless payer_party && payer_party.is_a?(Party) && payer_party.persisted? && !payer_party.destroyed? + return ServiceResult.failure(error: "Payer party is required", code: :invalid_input) + end + + if raw_received_on.blank? + return ServiceResult.failure(error: "Received on date is required", code: :invalid_input) + end + + unless received_on + return ServiceResult.failure(error: "Received on must be a valid date", code: :invalid_input) + end + + if payment_method.blank? + return ServiceResult.failure(error: "Payment method is required", code: :invalid_input) + end + + resolved_cents, parse_error = resolve_amount_cents + if parse_error + return ServiceResult.failure(error: parse_error, code: :invalid_amount) + end + + if resolved_cents <= 0 + return ServiceResult.failure(error: "Amount must be greater than 0", code: :invalid_amount) + end + + created_receipt = nil # : Receipt? + created_entry = nil # : JournalEntry? + failure_result = nil # : Dry::Monads::Result::Failure? + + begin + Receipt.transaction do + receipt = Receipt.new( + tenancy: tenancy, + user: tenancy.accounting_user, + payer_party: payer_party, + amount_cents: resolved_cents, + received_on: received_on, + payment_method: payment_method, + external_reference: external_reference, + memo: memo + ) + + unless receipt.save + failure_result = ServiceResult.failure( + data: { receipt: receipt }, + error: receipt.errors.full_messages.to_sentence, + code: :validation_error + ) + raise ActiveRecord::Rollback + end + + post_result = Receipts::PostService.call(receipt: receipt) + unless post_result.success? + failure_result = ServiceResult.failure( + data: { receipt: receipt }, + error: post_result.failure.error, + code: post_result.failure.code + ) + raise ActiveRecord::Rollback + end + + journal_entry = post_result.value!.data[:journal_entry] + receipt.update_columns(posted_at: journal_entry.posted_at) + + created_receipt = receipt + created_entry = journal_entry + end + rescue ActiveRecord::RecordNotUnique + return ServiceResult.failure( + error: "A payment with this payment method and reference already exists", + code: :duplicate + ) + end + + if (f = failure_result) + f + elsif created_receipt && created_entry + ServiceResult.success(receipt: created_receipt, journal_entry: created_entry) + else + ServiceResult.failure(error: "Failed to create and post receipt", code: :creation_failed) + end + end + + private + + attr_reader :tenancy, :payer_party, :amount_cents, :amount, :raw_received_on, + :received_on, :payment_method, :external_reference, :memo + + def parse_date(val) + return nil if val.blank? + return val if val.is_a?(Date) + return val.to_date if val.respond_to?(:to_date) + + Date.parse(val.to_s) + rescue ArgumentError, Date::Error + nil + end + + def resolve_amount_cents + if amount_cents.present? + [ amount_cents.to_i, nil ] + elsif amount.present? + val_str = amount.is_a?(Numeric) ? amount.to_s : amount.to_s.strip + unless val_str =~ /\A-?\d+(\.\d+)?\z/ + return [ 0, "Amount is not a valid number" ] + end + + if val_str.include?(".") + decimals = val_str.split(".").last + if decimals.length > 2 && decimals[2..].to_i != 0 + return [ 0, "Amount cannot have fractional cents" ] + end + end + + [ (BigDecimal(val_str) * 100).round, nil ] + else + [ 0, "Amount is required" ] + end + end + end +end diff --git a/app/services/receipts/post_service.rb b/app/services/receipts/post_service.rb new file mode 100644 index 00000000..6a6dbf9c --- /dev/null +++ b/app/services/receipts/post_service.rb @@ -0,0 +1,55 @@ +module Receipts + class PostService + def self.call(receipt:) + new(receipt: receipt).call + end + + def initialize(receipt:) + @receipt = receipt + end + + def call + unless receipt.is_a?(Receipt) && receipt.persisted? && !receipt.destroyed? + return ServiceResult.failure(error: "Receipt must be a persisted Receipt record", code: :invalid_source) + end + + if receipt.voided? + return ServiceResult.failure(error: "Cannot post a voided receipt", code: :invalid_state) + end + + postings = [ + Accounting::PostingSpec.new( + account_key: "cash", + amount_cents: receipt.amount_cents, + tenancy: receipt.tenancy, + party: receipt.payer_party + ), + Accounting::PostingSpec.new( + account_key: "tenant_receivable", + amount_cents: -receipt.amount_cents, + tenancy: receipt.tenancy, + party: receipt.payer_party + ) + ] + + description = default_description + + Accounting::PostEntryService.call( + source: receipt, + event_type: "receipt_posted", + occurred_on: receipt.received_on, + postings: postings, + description: description + ) + end + + private + + attr_reader :receipt + + def default_description + method_str = receipt.payment_method.present? ? receipt.payment_method.titleize : "Payment" + "Payment received - #{method_str}" + end + end +end diff --git a/app/services/receipts/receipt_pdf_service.rb b/app/services/receipts/receipt_pdf_service.rb new file mode 100644 index 00000000..4d13e00a --- /dev/null +++ b/app/services/receipts/receipt_pdf_service.rb @@ -0,0 +1,46 @@ +module Receipts + class ReceiptPdfService + def self.call(receipt:, view_context:) + new(receipt: receipt, view_context: view_context).call + end + + def initialize(receipt:, view_context:) + @receipt = receipt + @view_context = view_context + end + + def call + pdf = Prawn::Document.new + pdf.text "Payment Receipt", size: 30, style: :bold + + if receipt.voided? && receipt.superseded? + pdf.move_down 5 + pdf.text "[CORRECTED - REPLACED BY RECEIPT ##{receipt.superseded_by_id}]", size: 12, style: :bold, color: "CC0000" + elsif receipt.voided? + pdf.move_down 5 + pdf.text "[VOIDED - INACTIVE RECORD]", size: 12, style: :bold, color: "CC0000" + elsif receipt.superseded_receipt.present? + pdf.move_down 5 + pdf.text "[REPLACEMENT FOR RECEIPT ##{receipt.superseded_receipt.id}]", size: 12, style: :bold, color: "008800" + end + + pdf.move_down 20 + pdf.text "Receipt ID: ##{receipt.id}" + pdf.text "Payment Date: #{receipt.received_on.strftime('%B %d, %Y')}" + pdf.text "Amount: #{view_context.number_to_currency(receipt.amount)}" + pdf.text "Payer: #{receipt.payer_party&.display_name}" + pdf.text "Method: #{receipt.payment_method.titleize}" + pdf.text "Transaction / Reference: #{receipt.external_reference}" if receipt.external_reference.present? + pdf.text "Memo: #{receipt.memo}" if receipt.memo.present? + pdf.move_down 10 + pdf.text "Property: #{receipt.tenancy&.property&.address}" + pdf.text "Unit: #{receipt.tenancy&.rentable_unit&.display_name}" + pdf.text "Tenancy: ##{receipt.tenancy_id}" + pdf.render + end + + private + + attr_reader :receipt, :view_context + end +end diff --git a/app/services/receipts/void_service.rb b/app/services/receipts/void_service.rb new file mode 100644 index 00000000..4575b3d1 --- /dev/null +++ b/app/services/receipts/void_service.rb @@ -0,0 +1,73 @@ +module Receipts + class VoidService + def self.call(receipt:, reason: nil) + new(receipt: receipt, reason: reason).call + end + + def initialize(receipt:, reason: nil) + @receipt = receipt + @reason = reason + end + + def call + unless receipt.is_a?(Receipt) && receipt.persisted? && !receipt.destroyed? + return ServiceResult.failure(error: "Receipt must be a persisted Receipt record", code: :invalid_source) + end + + journal_entry = receipt.journal_entries.find_by(event_type: "receipt_posted") + unless journal_entry + return ServiceResult.failure(error: "Journal entry not found for receipt", code: :not_found) + end + + reversal_entry = nil # : JournalEntry? + failure_result = nil # : Dry::Monads::Result::Failure? + + Receipt.transaction do + receipt.lock! + + if receipt.superseded? + failure_result = ServiceResult.failure( + error: "Cannot void an already superseded receipt", + code: :already_superseded + ) + raise ActiveRecord::Rollback + end + + if receipt.voided? + # Idempotent return of existing reversal + existing_reversal = journal_entry.reversal + reversal_entry = existing_reversal || journal_entry + next + end + + description = reason.presence || "Void receipt ##{receipt.id} - #{receipt.payment_method}" + + reverse_result = Accounting::ReverseEntryService.call( + journal_entry: journal_entry, + occurred_on: receipt.received_on, + description: description + ) + + unless reverse_result.success? + failure_result = reverse_result + raise ActiveRecord::Rollback + end + + reversal_entry = reverse_result.value!.data[:journal_entry] + receipt.update_columns(voided_at: Time.current) + end + + if (f = failure_result) + f + elsif reversal_entry + ServiceResult.success(receipt: receipt, journal_entry: reversal_entry) + else + ServiceResult.failure(error: "Failed to void receipt", code: :void_failed) + end + end + + private + + attr_reader :receipt, :reason + end +end diff --git a/app/services/schedule_e_generator.rb b/app/services/schedule_e_generator.rb index dc743f77..eeb06e5e 100644 --- a/app/services/schedule_e_generator.rb +++ b/app/services/schedule_e_generator.rb @@ -299,9 +299,9 @@ def date_range end def rents_received - @rents_received ||= @property.tenant_payments - .where(payment_date: date_range) - .sum(:amount) + @rents_received ||= BigDecimal(@property.receipts.active + .where(received_on: date_range) + .sum(:amount_cents).to_s) / 100 end def expenses_by_category diff --git a/app/services/tenant_payments/create_service.rb b/app/services/tenant_payments/create_service.rb deleted file mode 100644 index 909ba0b5..00000000 --- a/app/services/tenant_payments/create_service.rb +++ /dev/null @@ -1,125 +0,0 @@ -module TenantPayments - class CreateService - def self.call(tenancy: nil, amount: nil, amount_cents: nil, payment_date: nil, payment_method: "other", transaction_number: nil, description: nil, params: nil) - p = (params || {}).to_h.symbolize_keys - t = tenancy || (p[:tenancy_id].present? ? Tenancy.find_by(id: p[:tenancy_id]) : nil) - amt = amount || p[:amount] - cents = amount_cents || p[:amount_cents] - date = payment_date || p[:payment_date] || Date.current - method = p[:payment_method].presence || payment_method || "other" - txn_num = p[:transaction_number] || transaction_number - desc = description || p[:description] - - new( - tenancy: t, - amount: amt, - amount_cents: cents, - payment_date: date, - payment_method: method, - transaction_number: txn_num, - description: desc - ).call - end - - def initialize(tenancy:, amount: nil, amount_cents: nil, payment_date: nil, payment_method: "other", transaction_number: nil, description: nil) - @tenancy = tenancy - @amount = amount - @amount_cents = amount_cents - @payment_date = payment_date || Date.current - @payment_method = payment_method - @transaction_number = transaction_number - @description = description - end - - def call - unless tenancy - return ServiceResult.failure(error: "Tenancy is required", code: :invalid_input) - end - - resolved_cents = if amount_cents.present? - amount_cents.to_i - elsif amount.present? - begin - (BigDecimal(amount.to_s) * 100).round - rescue StandardError - 0 - end - else - 0 - end - - resolved_dollars = BigDecimal(resolved_cents) / 100 - - created_payment = nil # : TenantPayment? - created_entry = nil # : JournalEntry? - failure_result = nil # : Dry::Monads::Result::Failure? - - TenantPayment.transaction do - payment = TenantPayment.new( - tenancy: tenancy, - amount: resolved_dollars, - payment_date: payment_date, - payment_method: payment_method, - transaction_number: transaction_number - ) - - unless payment.save - failure_result = ServiceResult.failure( - data: { tenant_payment: payment }, - error: payment.errors.full_messages.to_sentence, - code: :validation_error - ) - raise ActiveRecord::Rollback - end - - postings = [ - Accounting::PostingSpec.new( - account_key: "cash", - amount_cents: resolved_cents, - tenancy: tenancy - ), - Accounting::PostingSpec.new( - account_key: "tenant_receivable", - amount_cents: -resolved_cents, - tenancy: tenancy - ) - ] - - desc = description.presence || "Payment received - #{payment_method.to_s.humanize}" - - post_result = Accounting::PostEntryService.call( - source: payment, - event_type: "payment_received", - occurred_on: payment.payment_date, - postings: postings, - description: desc - ) - - unless post_result.success? - failure_result = ServiceResult.failure( - data: { tenant_payment: payment }, - error: post_result.failure.error, - code: post_result.failure.code - ) - raise ActiveRecord::Rollback - end - - created_payment = payment - created_entry = post_result.value!.data[:journal_entry] - end - - if (f = failure_result) - f - elsif created_payment && created_entry - ServiceResult.success(tenant_payment: created_payment, journal_entry: created_entry) - else - ServiceResult.failure(error: "Failed to create and post tenant payment", code: :creation_failed) - end - end - - private - - attr_reader :tenancy, :amount, :amount_cents, :payment_date, - :payment_method, :transaction_number, :description - end -end diff --git a/app/services/tenant_payments/receipt_pdf_service.rb b/app/services/tenant_payments/receipt_pdf_service.rb deleted file mode 100644 index e80f3126..00000000 --- a/app/services/tenant_payments/receipt_pdf_service.rb +++ /dev/null @@ -1,28 +0,0 @@ -module TenantPayments - class ReceiptPdfService - def self.call(tenant_payment:, view_context:) - new(tenant_payment:, view_context:).call - end - - def initialize(tenant_payment:, view_context:) - @tenant_payment = tenant_payment - @view_context = view_context - end - - def call - pdf = Prawn::Document.new - pdf.text "Payment Receipt", size: 30, style: :bold - pdf.move_down 20 - pdf.text "Payment Date: #{tenant_payment.payment_date}" - pdf.text "Amount: #{view_context.number_to_currency(tenant_payment.amount)}" - pdf.text "Method: #{tenant_payment.payment_method}" - pdf.text "Transaction Number: #{tenant_payment.transaction_number}" if tenant_payment.transaction_number.present? - pdf.text "Property: #{tenant_payment.tenancy&.property&.address}" - pdf.render - end - - private - - attr_reader :tenant_payment, :view_context - end -end diff --git a/app/views/payment_ingestions/index.html.erb b/app/views/payment_ingestions/index.html.erb index 0d37fb65..b7bea7a9 100644 --- a/app/views/payment_ingestions/index.html.erb +++ b/app/views/payment_ingestions/index.html.erb @@ -7,7 +7,7 @@

Payment Ingestion

-

Upload and review payments to automate tenant payment entry.

+

Upload and review payments to automate payment receipt entry.

<%= link_to new_payment_ingestion_path, class: "btn btn-primary gap-2" do %> @@ -115,7 +115,7 @@ Parsed Payer Parsed Amount Parsed Date - Resolved Tenant + Resolved Payer Status Actions @@ -196,7 +196,7 @@ Payer Amount Date - Tenant + Payer Party Payment Method Transaction Number Actions @@ -227,8 +227,8 @@ <%= ingestion.transaction_number.presence || "—" %> - <%= link_to "Download PDF", download_payment_ingestion_path(ingestion), class: "btn btn-xs btn-outline" %> - <%= link_to "Delete Ingestion", payment_ingestion_path(ingestion), data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete this historical ingestion record? Note: This deletes the ingestion audit trail and PDF, but does NOT delete the confirmed Tenant Payment record." }, class: "btn btn-xs btn-ghost text-error" %> + <%= link_to "View", payment_ingestion_path(ingestion), class: "btn btn-xs btn-outline" %> + <%= link_to "Download PDF", download_payment_ingestion_path(ingestion), class: "btn btn-xs btn-outline" if ingestion.attachment_attached? %> <% end %> diff --git a/app/views/payment_ingestions/show.html.erb b/app/views/payment_ingestions/show.html.erb index fab7cb31..2b04cc36 100644 --- a/app/views/payment_ingestions/show.html.erb +++ b/app/views/payment_ingestions/show.html.erb @@ -30,7 +30,9 @@
<%= link_to "Back to Queue", payment_ingestions_path, class: "btn btn-outline btn-sm" %> - <%= link_to "Delete Ingestion", payment_ingestion_path(@ingestion), data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete this ingestion record?" }, class: "btn btn-error btn-outline btn-sm" %> + <% unless @ingestion.confirmed? %> + <%= link_to "Delete Ingestion", payment_ingestion_path(@ingestion), data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete this ingestion record?" }, class: "btn btn-error btn-outline btn-sm" %> + <% end %>
@@ -40,10 +42,10 @@

Transaction Confirmed!

-

This receipt was matched and verified. A tenant payment of <%= number_to_currency(@ingestion.amount) %> was created on <%= @ingestion.payment_date.strftime("%b %d, %Y") %>.

+

This receipt was matched and verified. A payment receipt of <%= number_to_currency(@ingestion.amount) %> was created on <%= @ingestion.payment_date.strftime("%b %d, %Y") %>.

- <%= link_to "View Tenant Payment", tenant_payment_path(@ingestion.tenant_payment), class: "btn btn-sm btn-outline text-success-content border-success-content/30 hover:bg-success-content/10" if @ingestion.tenant_payment %> + <%= link_to "View Payment Receipt", receipt_path(@ingestion.receipt), class: "btn btn-sm btn-outline text-success-content border-success-content/30 hover:bg-success-content/10" if @ingestion.receipt %>
<% elsif @ingestion.failed? && @ingestion.error_message.present? %> @@ -63,7 +65,7 @@

Duplicate Payment Detected

-

A tenant payment with payment method "<%= @ingestion.payment_method %>" and transaction number "<%= @ingestion.transaction_number %>" already exists in the system. Confirmation is locked to prevent double entry.

+

A payment receipt with payment method "<%= @ingestion.payment_method %>" and transaction number "<%= @ingestion.transaction_number %>" already exists in the system. Confirmation is locked to prevent double entry.

<% end %> @@ -124,7 +126,7 @@ <%= form_with(model: @ingestion, local: true, class: "space-y-4", data: { controller: "payment-ingestion-form", payment_ingestion_form_party_tenancies_value: @party_tenancies_map.to_json, payment_ingestion_form_tenancy_parties_value: @tenancy_parties_map.to_json }) do |f| %>
- + <%= f.select :party_id, @parties.map { |p| [p.display_name, p.id] }, { include_blank: "Select Party" }, class: "select select-bordered w-full", data: { payment_ingestion_form_target: "partySelect", action: "change->payment-ingestion-form#partyChanged" } %>
@@ -191,8 +193,8 @@
@@ -200,20 +202,20 @@ <% end %> <% else %>

To confirm this payment, please correct the form above to ensure all fields are complete.

- Tenant + Payer Tenancy Amount Date Not Duplicate
- +
<% end %> @@ -223,7 +225,7 @@
- Tenant / Party + Payer / Party <%= @ingestion.party&.display_name %>
diff --git a/app/views/properties/_financials.html.erb b/app/views/properties/_financials.html.erb index 6d817683..c8cee575 100644 --- a/app/views/properties/_financials.html.erb +++ b/app/views/properties/_financials.html.erb @@ -8,7 +8,7 @@ <% active_tenancy = active_tenancy_for(property) %> <% if active_tenancy %> <%= link_to "+ Record Payment", - new_tenancy_tenant_payment_path(active_tenancy), + new_tenancy_receipt_path(active_tenancy), class: "btn btn-sm btn-outline btn-primary", data: { turbo_frame: "modal-frame" } %> <% end %> @@ -52,7 +52,7 @@ <% case item[:type] %> <% when 'Charge' %>
<%= item[:type] %>
- <% when 'Tenant Payment' %> + <% when 'Payment', 'Receipt' %>
<%= item[:type] %>
<% when 'Expense' %>
<%= item[:type] %>
@@ -68,8 +68,17 @@ <% if charge.voided? %> Voided <% end %> - <% elsif item[:type] == 'Tenant Payment' %> - Tenancy #<%= item[:object].tenancy_id %> (Method: <%= item[:object].payment_method %>) + <% elsif item[:type] == 'Payment' || item[:type] == 'Receipt' %> + <% receipt = item[:object] %> + Tenancy #<%= receipt.tenancy_id %> + <% if receipt.respond_to?(:payer_party) && receipt.payer_party %> + (<%= receipt.payer_party.display_name %>, <%= receipt.payment_method&.titleize %>) + <% else %> + (Method: <%= receipt.payment_method %>) + <% end %> + <% if receipt.respond_to?(:voided?) && receipt.voided? %> + Voided + <% end %> <% elsif item[:type] == 'Expense' %> <%= item[:object].category.titleize %> - <%= item[:object].description %> <% if item[:object].reimbursed? %> @@ -82,7 +91,7 @@ -<%= number_to_currency(item[:amount]) %> <% elsif item[:type] == 'Charge' %> <%= number_to_currency(item[:amount]) %> - <% elsif item[:type] == 'Tenant Payment' %> + <% elsif item[:type] == 'Payment' || item[:type] == 'Receipt' %> +<%= number_to_currency(item[:amount]) %> <% else %> <%= number_to_currency(item[:amount]) %> diff --git a/app/views/receipts/_form.html.erb b/app/views/receipts/_form.html.erb new file mode 100644 index 00000000..23b5718b --- /dev/null +++ b/app/views/receipts/_form.html.erb @@ -0,0 +1,73 @@ +<%= form_with(model: receipt, url: (tenancy ? [tenancy, receipt] : receipts_path), local: true, class: "space-y-4") do |f| %> + <% if receipt.errors.any? %> +
+
    + <% receipt.errors.full_messages.each do |msg| %> +
  • <%= msg %>
  • + <% end %> +
+
+ <% end %> + + <% if tenancy.present? %> + <%= f.hidden_field :tenancy_id, value: tenancy.id %> +
+
+ Tenancy + #<%= tenancy.id %> — <%= tenancy.property&.address %> (<%= tenancy.rentable_unit&.display_name %>) +
+
+ Current Balance + <% bal = tenancy.current_balance %> + <% text_class = if bal > 0 then 'text-error' elsif bal < 0 then 'text-success' else 'text-base-content/70' end %> + + <%= bal < 0 ? '-' : '' %><%= number_to_currency(bal.abs) %> + +
+
+ <% else %> +
+ <%= f.label :tenancy_id, "Tenancy / Unit", class: "label font-semibold" %> + <%= f.collection_select :tenancy_id, @tenancies, :id, ->(t) { "##{t.id} - #{t.property&.address} (#{t.rentable_unit&.display_name})" }, { prompt: "Select Tenancy" }, { class: "select select-bordered w-full", required: true } %> +
+ <% end %> + +
+ <%= f.label :payer_party_id, "Payer (Party)", class: "label font-semibold" %> + <%= f.collection_select :payer_party_id, @parties, :id, :display_name, { prompt: "Select Payer" }, { class: "select select-bordered w-full", required: true } %> +
+ +
+
+ <%= f.label :amount, "Amount ($)", class: "label font-semibold" %> + <%= f.number_field :amount, step: "0.01", min: "0.01", class: "input input-bordered w-full", required: true %> +
+ +
+ <%= f.label :received_on, "Received Date", class: "label font-semibold" %> + <%= f.date_field :received_on, class: "input input-bordered w-full", required: true %> +
+
+ +
+
+ <%= f.label :payment_method, "Payment Method", class: "label font-semibold" %> + <%= f.text_field :payment_method, class: "input input-bordered w-full", placeholder: "e.g. Zelle, Venmo, Check, Cash, Wire", required: true %> +
+ +
+ <%= f.label :external_reference, "External Reference / Txn #", class: "label font-semibold" %> + <%= f.text_field :external_reference, class: "input input-bordered w-full", placeholder: "e.g. Check #1042, Zelle confirmation" %> +
+
+ +
+ <%= f.label :memo, "Memo (Optional)", class: "label font-semibold" %> + <%= f.text_area :memo, class: "textarea textarea-bordered w-full", rows: 2, placeholder: "Notes or memo regarding this payment" %> +
+ +
+ <%= f.submit "Record Payment", class: "btn btn-primary flex-1" %> + <%= link_to "Cancel", (tenancy ? tenancy_path(tenancy) : receipts_path), class: "btn btn-ghost" %> +
+<% end %> diff --git a/app/views/receipts/_modal_form.html.erb b/app/views/receipts/_modal_form.html.erb new file mode 100644 index 00000000..1ca9ec34 --- /dev/null +++ b/app/views/receipts/_modal_form.html.erb @@ -0,0 +1,60 @@ +<% tenancy ||= receipt.tenancy %> + +
+
+
+ Tenancy + #<%= tenancy.id %> +
+
+ Current Balance + <% bal = tenancy.current_balance %> + <% text_class = if bal > 0 then 'text-error' elsif bal < 0 then 'text-success' else 'text-base-content/70' end %> + + <%= bal < 0 ? '-' : '' %><%= number_to_currency(bal.abs) %> + +
+
+ + <%= form_with(model: [tenancy, receipt], data: { turbo: true }) do |form| %> + <% if receipt.errors.any? %> +
+
    + <% receipt.errors.full_messages.each do |msg| %> +
  • <%= msg %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= form.label :payer_party_id, "Payer", class: "label font-semibold" %> + <%= form.collection_select :payer_party_id, (tenancy.property.user.parties.order(:display_name)), :id, :display_name, { prompt: "Select Payer" }, { class: "select select-bordered w-full", required: true } %> +
+ +
+ <%= form.label :amount, "Amount", class: "label font-semibold" %> + <%= form.number_field :amount, step: "0.01", min: "0.01", class: "input input-bordered w-full", required: true %> +
+ +
+ <%= form.label :received_on, "Payment Date", class: "label font-semibold" %> + <%= form.date_field :received_on, class: "input input-bordered w-full", required: true %> +
+ +
+ <%= form.label :payment_method, "Payment Method", class: "label font-semibold" %> + <%= form.text_field :payment_method, class: "input input-bordered w-full", placeholder: "e.g. Zelle, Check, Venmo, Cash", required: true %> +
+ +
+ <%= form.label :external_reference, "Transaction / Check #", class: "label font-semibold" %> + <%= form.text_field :external_reference, class: "input input-bordered w-full" %> +
+ +
+ <%= form.submit "Record Payment", class: "btn btn-primary flex-1" %> + +
+ <% end %> +
diff --git a/app/views/receipts/_receipt.html.erb b/app/views/receipts/_receipt.html.erb new file mode 100644 index 00000000..3c3192fa --- /dev/null +++ b/app/views/receipts/_receipt.html.erb @@ -0,0 +1,35 @@ + + +
<%= receipt.tenancy&.property&.address %>
+
+ Unit: <%= receipt.tenancy&.rentable_unit&.display_name %> (Tenancy #<%= receipt.tenancy_id %>) +
+ + +
<%= receipt.payer_party&.display_name %>
+ + <%= receipt.received_on.strftime("%b %d, %Y") %> + + <%= number_to_currency(receipt.amount) %> + + + <%= receipt.payment_method.titleize %> + + + <%= receipt.external_reference || "—" %> + + + <% if receipt.voided? && receipt.superseded? %> + Corrected + <% elsif receipt.voided? %> + Voided + <% elsif receipt.superseded_receipt.present? %> + Replacement + <% else %> + Active + <% end %> + + + <%= link_to "View", receipt_path(receipt), class: "btn btn-xs btn-ghost" %> + + diff --git a/app/views/receipts/correction.html.erb b/app/views/receipts/correction.html.erb new file mode 100644 index 00000000..400042e6 --- /dev/null +++ b/app/views/receipts/correction.html.erb @@ -0,0 +1,76 @@ +
+
+

Correct Payment

+

Restates accounting record and preserves audit history

+
+ +
+ +
+
Correction Semantics
+
+ Correcting this payment will retain original Receipt #<%= @receipt.id %> as a voided record, reverse its ledger postings as of <%= @receipt.received_on.strftime("%b %d, %Y") %>, and create an active replacement receipt. +
+
+
+ +
+
+ <%= form_with(model: @replacement_receipt, url: correct_receipt_path(@receipt), method: :post, local: true, class: "space-y-4") do |f| %> + <% if @replacement_receipt.errors.any? %> +
+
    + <% @replacement_receipt.errors.full_messages.each do |msg| %> +
  • <%= msg %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= f.label :tenancy_id, "Tenancy / Unit", class: "label font-semibold" %> + <%= f.collection_select :tenancy_id, @tenancies, :id, ->(t) { "##{t.id} - #{t.property&.address} (#{t.rentable_unit&.display_name})" }, { selected: @replacement_receipt.tenancy_id }, { class: "select select-bordered w-full", required: true } %> +
+ +
+ <%= f.label :payer_party_id, "Payer (Party)", class: "label font-semibold" %> + <%= f.collection_select :payer_party_id, @parties, :id, :display_name, { selected: @replacement_receipt.payer_party_id }, { class: "select select-bordered w-full", required: true } %> +
+ +
+
+ <%= f.label :amount, "Amount ($)", class: "label font-semibold" %> + <%= f.number_field :amount, value: @replacement_receipt.amount, step: "0.01", min: "0.01", class: "input input-bordered w-full", required: true %> +
+ +
+ <%= f.label :received_on, "Received Date", class: "label font-semibold" %> + <%= f.date_field :received_on, class: "input input-bordered w-full", required: true %> +
+
+ +
+
+ <%= f.label :payment_method, "Payment Method", class: "label font-semibold" %> + <%= f.text_field :payment_method, class: "input input-bordered w-full", required: true %> +
+ +
+ <%= f.label :external_reference, "External Reference / Txn #", class: "label font-semibold" %> + <%= f.text_field :external_reference, class: "input input-bordered w-full" %> +
+
+ +
+ <%= f.label :memo, "Memo", class: "label font-semibold" %> + <%= f.text_area :memo, class: "textarea textarea-bordered w-full", rows: 2 %> +
+ +
+ <%= f.submit "Save Replacement Payment", class: "btn btn-primary flex-1" %> + <%= link_to "Cancel", receipt_path(@receipt), class: "btn btn-ghost" %> +
+ <% end %> +
+
+
diff --git a/app/views/receipts/index.html.erb b/app/views/receipts/index.html.erb new file mode 100644 index 00000000..06370cfa --- /dev/null +++ b/app/views/receipts/index.html.erb @@ -0,0 +1,39 @@ +<% content_for :title, "Payments & Receipts" %> + +
+
+

Payments & Receipts

+

Historical record of all payments and ledger credits

+
+ <%= link_to "+ Record Payment", new_receipt_path, class: "btn btn-primary" %> +
+ +
+ + + + + + + + + + + + + + + <% if @receipts.any? %> + <% @receipts.each do |receipt| %> + <%= render receipt %> + <% end %> + <% else %> + + + + <% end %> + +
Tenancy / PropertyPayerPayment DateAmountMethodReferenceStatusActions
+ No payment receipts recorded yet. +
+
diff --git a/app/views/receipts/new.html.erb b/app/views/receipts/new.html.erb new file mode 100644 index 00000000..cc29cf9a --- /dev/null +++ b/app/views/receipts/new.html.erb @@ -0,0 +1,12 @@ +
+
+

Record Payment

+

Posts payment directly to ledger (Dr Cash / Cr Tenant Receivable)

+
+ +
+
+ <%= render "receipts/form", receipt: @receipt, tenancy: @tenancy %> +
+
+
diff --git a/app/views/receipts/show.html.erb b/app/views/receipts/show.html.erb new file mode 100644 index 00000000..4a1b5dc2 --- /dev/null +++ b/app/views/receipts/show.html.erb @@ -0,0 +1,141 @@ +
+
+
+

Payment Details

+

Receipt ID: #<%= @receipt.id %>

+
+
+ <%= link_to "Back to Payments", receipts_path, class: "btn btn-ghost" %> +
+
+ + <% if @receipt.voided? && @receipt.superseded? %> +
+ +
+
This payment was corrected and reversed.
+
Replaced by <%= link_to "Receipt ##{@receipt.superseded_by_id}", receipt_path(@receipt.superseded_by), class: "underline font-semibold" %>
+
+
+ <% elsif @receipt.voided? %> +
+ +
+
This payment was voided.
+
Accounting postings for this receipt have been completely reversed on the ledger.
+
+
+ <% elsif @receipt.superseded_receipt.present? %> +
+ +
+
Replacement Payment
+
This receipt replaces corrected <%= link_to "Receipt ##{@receipt.superseded_receipt.id}", receipt_path(@receipt.superseded_receipt), class: "underline font-semibold" %>
+
+
+ <% end %> + +
+
+
+ +
+
+
Amount Paid
+
+ <%= number_to_currency(@receipt.amount) %> +
+
+ +
+
+
Payment Date
+
<%= @receipt.received_on.strftime("%B %d, %Y") %>
+
+
+
Payment Method
+
<%= @receipt.payment_method.titleize %>
+
+
+ +
+
Payer
+
<%= @receipt.payer_party&.display_name %>
+
+ + <% if @receipt.external_reference.present? %> +
+
External Reference / Txn #
+
+ <%= @receipt.external_reference %> +
+
+ <% end %> + + <% if @receipt.memo.present? %> +
+
Memo
+
+ <%= @receipt.memo %> +
+
+ <% end %> +
+ +
+ + +
+
+
Property Address
+
<%= @receipt.tenancy&.property&.address %>
+
<%= @receipt.tenancy&.rentable_unit&.display_name %>
+
+ +
+
Tenancy
+
+ <%= link_to "Tenancy ##{@receipt.tenancy_id}", tenancy_path(@receipt.tenancy), class: "link link-primary font-semibold" %> +
+
+ <%= @receipt.tenancy&.commencement_date&.strftime("%b %d, %Y") %> – <%= @receipt.tenancy&.termination_date ? @receipt.tenancy.termination_date.strftime("%b %d, %Y") : "Month-to-Month" %> +
+
+ +
+
Tenants on Lease
+
+ <% @receipt.tenancy&.parties&.each do |party| %> + <%= party.display_name %> + <% end %> +
+
+ +
+
Ledger Status
+
+ <% if @receipt.posted? %> + Posted on <%= @receipt.posted_at.strftime("%b %d, %Y at %l:%M %p") %> + <% else %> + Pending posting + <% end %> +
+
+
+
+ +
+
+ <%= link_to "Download PDF Receipt", receipt_path(@receipt, format: :pdf), target: "_blank", class: "btn btn-primary btn-sm" %> + <% if @receipt.active? %> + <%= link_to "Correct Payment", correction_receipt_path(@receipt), class: "btn btn-outline btn-sm" %> + <%= button_to "Void Payment", void_receipt_path(@receipt), method: :post, class: "btn btn-ghost btn-sm text-error", form: { data: { turbo_confirm: "Are you sure you want to void this payment? This will reverse all ledger entries and cannot be undone." } } %> + <% end %> +
+
+ Posted to ledger • Double-entry verified +
+
+
+
+
diff --git a/app/views/shared/_navbar.html.erb b/app/views/shared/_navbar.html.erb index abf39e27..b5c0801d 100644 --- a/app/views/shared/_navbar.html.erb +++ b/app/views/shared/_navbar.html.erb @@ -27,8 +27,8 @@ class: "btn btn-ghost btn-sm #{'btn-active' if request.path.start_with?('/tenancies')}" %>
  • - <%= link_to "Payments", tenant_payments_path, - class: "btn btn-ghost btn-sm #{'btn-active' if request.path.start_with?('/tenant_payments')}" %> + <%= link_to "Payments", receipts_path, + class: "btn btn-ghost btn-sm #{'btn-active' if request.path.start_with?('/receipts')}" %>
  • <%= link_to "Expenses", expenses_path, @@ -54,7 +54,7 @@
  • <%= link_to "Properties", properties_path %>
  • <%= link_to "Tenants & Payers", parties_path %>
  • <%= link_to "Tenancies", tenancies_path %>
  • -
  • <%= link_to "Payments", tenant_payments_path %>
  • +
  • <%= link_to "Payments", receipts_path %>
  • <%= link_to "Ingestions", payment_ingestions_path %>
  • <%= link_to "Expenses", expenses_path %>
  • diff --git a/app/views/tenancies/show.html.erb b/app/views/tenancies/show.html.erb index ee38466f..d6251aec 100644 --- a/app/views/tenancies/show.html.erb +++ b/app/views/tenancies/show.html.erb @@ -193,6 +193,71 @@
    + +
    +
    +
    +
    +

    Payments & Receipts

    +

    Payments received and applied to tenancy receivable

    +
    + <%= link_to "+ Record Payment", new_tenancy_receipt_path(@tenancy), class: "btn btn-sm btn-primary" %> +
    + +
    + + + + + + + + + + + + + + <% receipts = @tenancy.receipts.order(received_on: :desc, created_at: :desc) %> + <% if receipts.any? %> + <% receipts.each do |receipt| %> + + + + + + + + + + <% end %> + <% else %> + + + + <% end %> + +
    DatePayerMethodReferenceAmountStatusActions
    <%= receipt.received_on.strftime("%b %d, %Y") %><%= receipt.payer_party&.display_name %><%= receipt.payment_method.titleize %><%= receipt.external_reference || "—" %> + <%= number_to_currency(receipt.amount) %> + + <% if receipt.voided? && receipt.superseded? %> + Corrected + <% elsif receipt.voided? %> + Voided + <% elsif receipt.superseded_receipt.present? %> + Replacement + <% else %> + Active + <% end %> + + <%= link_to "View", receipt_path(receipt), class: "btn btn-xs btn-ghost" %> +
    + No payments recorded yet. +
    +
    +
    +
    +
    @@ -245,7 +310,7 @@
    - <%= link_to "+ Record Payment", new_tenancy_tenant_payment_path(@tenancy), class: "btn btn-primary btn-sm w-full" %> + <%= link_to "+ Record Payment", new_tenancy_receipt_path(@tenancy), class: "btn btn-primary btn-sm w-full" %> <%= link_to "+ Add Charge", new_tenancy_charge_path(@tenancy), class: "btn btn-outline btn-sm w-full" %>
    diff --git a/app/views/tenant_payments/_form.html.erb b/app/views/tenant_payments/_form.html.erb deleted file mode 100644 index 9d90c126..00000000 --- a/app/views/tenant_payments/_form.html.erb +++ /dev/null @@ -1,46 +0,0 @@ -<%= form_with(model: tenant_payment) do |form| %> - <% if tenant_payment.errors.any? %> -
    -
    -

    <%= pluralize(tenant_payment.errors.count, "error") %> prohibited this payment from being saved:

    -
      - <% tenant_payment.errors.each do |error| %> -
    • <%= error.full_message %>
    • - <% end %> -
    -
    -
    - <% end %> - -
    - <%= form.label :tenancy_id, "Tenancy / Property / Tenants", class: "label font-semibold" %> - <%= form.select :tenancy_id, - @tenancies.map { |t| ["#{t.property.address} - Tenancy ##{t.id} (#{t.parties.map(&:display_name).join(', ')})", t.id] }, - { include_blank: "Select a tenancy..." }, - { class: "select select-bordered w-full" } %> -
    - -
    - <%= form.label :payment_date, class: "label font-semibold" %> - <%= form.date_field :payment_date, class: "input input-bordered w-full" %> -
    - -
    - <%= form.label :amount, class: "label font-semibold" %> - <%= form.text_field :amount, class: "input input-bordered w-full" %> -
    - -
    - <%= form.label :payment_method, class: "label font-semibold" %> - <%= form.text_field :payment_method, class: "input input-bordered w-full", placeholder: "e.g. Zelle, Check, Cash" %> -
    - -
    - <%= form.label :transaction_number, "Transaction # (Zelle, Venmo, etc.)", class: "label font-semibold" %> - <%= form.text_field :transaction_number, class: "input input-bordered w-full" %> -
    - -
    - <%= form.submit class: "btn btn-primary w-full mt-4" %> -
    -<% end %> diff --git a/app/views/tenant_payments/_modal_form.html.erb b/app/views/tenant_payments/_modal_form.html.erb deleted file mode 100644 index 86b37435..00000000 --- a/app/views/tenant_payments/_modal_form.html.erb +++ /dev/null @@ -1,54 +0,0 @@ -<%# Modal form for creating a payment against a specific tenancy %> -<% tenancy ||= tenant_payment.tenancy %> - -
    -
    - Tenancy ID - #<%= tenancy.id %> -
    -
    - Current Balance - <% bal = tenancy.current_balance %> - <% text_class = if bal > 0 then 'text-error' elsif bal < 0 then 'text-success' else 'text-base-content/70' end %> - - <%= bal < 0 ? '-' : '' %><%= number_to_currency(bal.abs) %> - -
    -
    - -<%= form_with(model: [tenancy, tenant_payment], data: { turbo: true }) do |form| %> - <% if tenant_payment.errors.any? %> -
    -
      - <% tenant_payment.errors.each do |error| %> -
    • <%= error.full_message %>
    • - <% end %> -
    -
    - <% end %> - -
    - <%= form.label :payment_date, class: "label font-semibold" %> - <%= form.date_field :payment_date, class: "input input-bordered w-full" %> -
    - -
    - <%= form.label :amount, class: "label font-semibold" %> - <%= form.number_field :amount, step: "0.01", class: "input input-bordered w-full" %> -
    - -
    - <%= form.label :payment_method, class: "label font-semibold" %> - <%= form.text_field :payment_method, class: "input input-bordered w-full", placeholder: "e.g. Zelle, Check, Cash" %> -
    - -
    - <%= form.label :transaction_number, "Transaction # (Zelle, Venmo, etc.)", class: "label font-semibold" %> - <%= form.text_field :transaction_number, class: "input input-bordered w-full" %> -
    - -
    - <%= form.submit "Record Payment", class: "btn btn-primary flex-1" %> - -
    -<% end %> diff --git a/app/views/tenant_payments/_tenant_payment.html.erb b/app/views/tenant_payments/_tenant_payment.html.erb deleted file mode 100644 index ac8b15df..00000000 --- a/app/views/tenant_payments/_tenant_payment.html.erb +++ /dev/null @@ -1,13 +0,0 @@ - - -
    <%= tenant_payment.tenancy.property.address %>
    -
    Tenancy ID: <%= tenant_payment.tenancy_id %> (<%= tenant_payment.tenancy.parties.map(&:display_name).join(', ') %>)
    - - <%= tenant_payment.payment_date %> - <%= number_to_currency(tenant_payment.amount) %> - <%= tenant_payment.payment_method %> - <%= tenant_payment.transaction_number %> - - <%= link_to "View", tenant_payment, class: "btn btn-xs btn-ghost" %> - - diff --git a/app/views/tenant_payments/index.html.erb b/app/views/tenant_payments/index.html.erb deleted file mode 100644 index 06fc8009..00000000 --- a/app/views/tenant_payments/index.html.erb +++ /dev/null @@ -1,26 +0,0 @@ -<% content_for :title, "Payments" %> - -
    -

    Payments

    - <%= link_to "New Payment", new_tenant_payment_path, class: "btn btn-primary" %> -
    - -
    - - - - - - - - - - - - - <% @tenant_payments.each do |tenant_payment| %> - <%= render tenant_payment %> - <% end %> - -
    Tenancy / PropertyPayment DateAmountMethodTransaction #Actions
    -
    diff --git a/app/views/tenant_payments/new.html.erb b/app/views/tenant_payments/new.html.erb deleted file mode 100644 index 524885b7..00000000 --- a/app/views/tenant_payments/new.html.erb +++ /dev/null @@ -1,18 +0,0 @@ -<% if @lease %> - - <%= render "tenant_payments/modal_form", tenant_payment: @tenant_payment, lease: @lease %> - -<% else %> -
    -
    -

    New Payment

    - <%= link_to "Cancel", tenant_payments_path, class: "btn btn-ghost" %> -
    - -
    -
    - <%= render "form", tenant_payment: @tenant_payment %> -
    -
    -
    -<% end %> diff --git a/app/views/tenant_payments/show.html.erb b/app/views/tenant_payments/show.html.erb deleted file mode 100644 index a2917b89..00000000 --- a/app/views/tenant_payments/show.html.erb +++ /dev/null @@ -1,79 +0,0 @@ -
    -
    -
    -

    Payment Details

    -

    Reference: <%= dom_id(@tenant_payment) %>

    -
    -
    - <%= link_to "Back to List", tenant_payments_path, class: "btn btn-ghost" %> -
    -
    - -
    -
    -
    - -
    -
    -
    Amount Paid
    -
    <%= number_to_currency(@tenant_payment.amount) %>
    -
    - -
    -
    -
    Payment Date
    -
    <%= @tenant_payment.payment_date.strftime("%B %d, %Y") %>
    -
    -
    -
    Payment Method
    -
    <%= @tenant_payment.payment_method %>
    -
    -
    - - <% if @tenant_payment.transaction_number.present? %> -
    -
    Transaction Number
    -
    - <%= @tenant_payment.transaction_number %> -
    -
    - <% end %> -
    - -
    - - -
    -
    -
    Property Address
    -
    <%= @tenant_payment.tenancy.property.address %>
    -
    <%= @tenant_payment.tenancy.rentable_unit.display_name %>
    -
    - -
    -
    Tenancy Period
    -
    <%= @tenant_payment.tenancy.commencement_date.strftime("%b %d, %Y") %> – <%= @tenant_payment.tenancy.termination_date ? @tenant_payment.tenancy.termination_date.strftime("%b %d, %Y") : "Month-to-Month" %>
    -
    - -
    -
    Tenants
    -
    - <% @tenant_payment.tenancy.parties.each do |party| %> - <%= party.display_name %> - <% end %> -
    -
    -
    -
    - -
    -
    - <%= link_to "Download PDF Receipt", tenant_payment_path(@tenant_payment, format: :pdf), target: "_blank", class: "btn btn-primary px-8" %> -
    -
    - Posted to ledger • Immutable record -
    -
    -
    -
    -
    diff --git a/config/routes.rb b/config/routes.rb index 291dde03..d6beceff 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -15,7 +15,7 @@ resources :parties resources :tenancies do - resources :tenant_payments, only: %i[new create] + resources :receipts, only: %i[new create] resources :charges, only: %i[new create] resources :tenancy_parties, only: %i[new create edit update destroy] resources :rent_terms, only: %i[new create] @@ -24,7 +24,13 @@ resources :expenses do resources :reimbursements, only: %i[new create], controller: "expense_reimbursements" end - resources :tenant_payments, only: %i[index show new create] + resources :receipts, only: %i[index show new create] do + member do + get :correction + post :correct + post :void + end + end resources :charges, only: %i[show] do member do post :void diff --git a/db/cable_schema.rb b/db/cable_schema.rb index f1d424f7..223fc25b 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_000004) do +ActiveRecord::Schema[8.1].define(version: 2026_08_16_000007) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -130,18 +130,18 @@ t.bigint "payment_document_id" t.string "payment_method" t.text "raw_text" + t.bigint "receipt_id" t.string "receipt_type" t.string "source", null: false t.string "status", default: "pending", null: false t.bigint "tenancy_id" - t.bigint "tenant_payment_id" t.string "transaction_number" t.datetime "updated_at", null: false t.bigint "user_id", null: false t.index ["party_id"], name: "index_payment_ingestions_on_party_id" t.index ["payment_document_id"], name: "index_payment_ingestions_on_payment_document_id" + t.index ["receipt_id"], name: "index_payment_ingestions_on_receipt_id" t.index ["tenancy_id"], name: "index_payment_ingestions_on_tenancy_id" - t.index ["tenant_payment_id"], name: "index_payment_ingestions_on_tenant_payment_id" t.index ["user_id", "payment_method", "transaction_number"], name: "idx_payment_ingestions_dup_check" t.index ["user_id"], name: "index_payment_ingestions_on_user_id" end @@ -177,6 +177,31 @@ t.index ["user_id"], name: "index_properties_on_user_id" end + create_table "receipts", force: :cascade do |t| + t.bigint "amount_cents", null: false + t.datetime "created_at", null: false + t.string "external_reference" + t.text "memo" + t.bigint "payer_party_id", null: false + t.string "payment_method", null: false + t.timestamptz "posted_at" + t.date "received_on", null: false + t.bigint "superseded_by_id" + t.bigint "tenancy_id", null: false + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.timestamptz "voided_at" + t.index ["payer_party_id"], name: "index_receipts_on_payer_party_id" + t.index ["received_on"], name: "index_receipts_on_received_on" + t.index ["superseded_by_id"], name: "index_receipts_on_superseded_by_id" + t.index ["superseded_by_id"], name: "index_receipts_on_superseded_by_id_unique", unique: true, where: "(superseded_by_id IS NOT NULL)" + t.index ["tenancy_id"], name: "index_receipts_on_tenancy_id" + t.index ["user_id", "payment_method", "external_reference"], name: "index_receipts_on_user_method_external_ref_active", unique: true, where: "((external_reference IS NOT NULL) AND (voided_at IS NULL))" + t.index ["user_id"], name: "index_receipts_on_user_id" + t.index ["voided_at"], name: "index_receipts_on_voided_at" + t.check_constraint "amount_cents > 0", name: "receipts_amount_cents_positive" + end + create_table "rent_terms", force: :cascade do |t| t.bigint "amount_cents", null: false t.datetime "created_at", null: false @@ -245,20 +270,6 @@ t.index ["tenancy_id"], name: "index_tenancy_parties_on_tenancy_id" end - create_table "tenant_payments", force: :cascade do |t| - t.decimal "amount", precision: 12, scale: 2, null: false - t.datetime "created_at", null: false - t.date "payment_date", null: false - t.string "payment_method", null: false - t.bigint "tenancy_id", null: false - t.string "transaction_number" - t.datetime "updated_at", null: false - t.bigint "user_id", null: false - t.index ["tenancy_id"], name: "index_tenant_payments_on_tenancy_id" - t.index ["user_id", "payment_method", "transaction_number"], name: "index_tenant_payments_on_user_payment_method_transaction_number", unique: true, where: "(transaction_number IS NOT NULL)" - t.index ["user_id"], name: "index_tenant_payments_on_user_id" - end - create_table "users", force: :cascade do |t| t.datetime "created_at", null: false t.string "email", null: false @@ -281,8 +292,8 @@ add_foreign_key "payment_documents", "users" add_foreign_key "payment_ingestions", "parties" add_foreign_key "payment_ingestions", "payment_documents" + add_foreign_key "payment_ingestions", "receipts" add_foreign_key "payment_ingestions", "tenancies" - add_foreign_key "payment_ingestions", "tenant_payments" add_foreign_key "payment_ingestions", "users" add_foreign_key "postings", "accounts" add_foreign_key "postings", "journal_entries" @@ -291,12 +302,14 @@ add_foreign_key "postings", "rentable_units" add_foreign_key "postings", "tenancies" add_foreign_key "properties", "users" + add_foreign_key "receipts", "parties", column: "payer_party_id" + add_foreign_key "receipts", "receipts", column: "superseded_by_id" + add_foreign_key "receipts", "tenancies" + add_foreign_key "receipts", "users" add_foreign_key "rent_terms", "tenancies" add_foreign_key "rentable_units", "properties" add_foreign_key "sessions", "users" add_foreign_key "tenancies", "rentable_units" add_foreign_key "tenancy_parties", "parties" add_foreign_key "tenancy_parties", "tenancies" - add_foreign_key "tenant_payments", "tenancies" - add_foreign_key "tenant_payments", "users" end diff --git a/db/cache_schema.rb b/db/cache_schema.rb index c76f6160..91922bcc 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_000004) do +ActiveRecord::Schema[8.1].define(version: 2026_08_16_000007) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -130,18 +130,18 @@ t.bigint "payment_document_id" t.string "payment_method" t.text "raw_text" + t.bigint "receipt_id" t.string "receipt_type" t.string "source", null: false t.string "status", default: "pending", null: false t.bigint "tenancy_id" - t.bigint "tenant_payment_id" t.string "transaction_number" t.datetime "updated_at", null: false t.bigint "user_id", null: false t.index ["party_id"], name: "index_payment_ingestions_on_party_id" t.index ["payment_document_id"], name: "index_payment_ingestions_on_payment_document_id" + t.index ["receipt_id"], name: "index_payment_ingestions_on_receipt_id" t.index ["tenancy_id"], name: "index_payment_ingestions_on_tenancy_id" - t.index ["tenant_payment_id"], name: "index_payment_ingestions_on_tenant_payment_id" t.index ["user_id", "payment_method", "transaction_number"], name: "idx_payment_ingestions_dup_check" t.index ["user_id"], name: "index_payment_ingestions_on_user_id" end @@ -177,6 +177,31 @@ t.index ["user_id"], name: "index_properties_on_user_id" end + create_table "receipts", force: :cascade do |t| + t.bigint "amount_cents", null: false + t.datetime "created_at", null: false + t.string "external_reference" + t.text "memo" + t.bigint "payer_party_id", null: false + t.string "payment_method", null: false + t.timestamptz "posted_at" + t.date "received_on", null: false + t.bigint "superseded_by_id" + t.bigint "tenancy_id", null: false + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.timestamptz "voided_at" + t.index ["payer_party_id"], name: "index_receipts_on_payer_party_id" + t.index ["received_on"], name: "index_receipts_on_received_on" + t.index ["superseded_by_id"], name: "index_receipts_on_superseded_by_id" + t.index ["superseded_by_id"], name: "index_receipts_on_superseded_by_id_unique", unique: true, where: "(superseded_by_id IS NOT NULL)" + t.index ["tenancy_id"], name: "index_receipts_on_tenancy_id" + t.index ["user_id", "payment_method", "external_reference"], name: "index_receipts_on_user_method_external_ref_active", unique: true, where: "((external_reference IS NOT NULL) AND (voided_at IS NULL))" + t.index ["user_id"], name: "index_receipts_on_user_id" + t.index ["voided_at"], name: "index_receipts_on_voided_at" + t.check_constraint "amount_cents > 0", name: "receipts_amount_cents_positive" + end + create_table "rent_terms", force: :cascade do |t| t.bigint "amount_cents", null: false t.datetime "created_at", null: false @@ -246,20 +271,6 @@ t.index ["tenancy_id"], name: "index_tenancy_parties_on_tenancy_id" end - create_table "tenant_payments", force: :cascade do |t| - t.decimal "amount", precision: 12, scale: 2, null: false - t.datetime "created_at", null: false - t.date "payment_date", null: false - t.string "payment_method", null: false - t.bigint "tenancy_id", null: false - t.string "transaction_number" - t.datetime "updated_at", null: false - t.bigint "user_id", null: false - t.index ["tenancy_id"], name: "index_tenant_payments_on_tenancy_id" - t.index ["user_id", "payment_method", "transaction_number"], name: "index_tenant_payments_on_user_payment_method_transaction_number", unique: true, where: "(transaction_number IS NOT NULL)" - t.index ["user_id"], name: "index_tenant_payments_on_user_id" - end - create_table "users", force: :cascade do |t| t.datetime "created_at", null: false t.string "email", null: false @@ -282,8 +293,8 @@ add_foreign_key "payment_documents", "users" add_foreign_key "payment_ingestions", "parties" add_foreign_key "payment_ingestions", "payment_documents" + add_foreign_key "payment_ingestions", "receipts" add_foreign_key "payment_ingestions", "tenancies" - add_foreign_key "payment_ingestions", "tenant_payments" add_foreign_key "payment_ingestions", "users" add_foreign_key "postings", "accounts" add_foreign_key "postings", "journal_entries" @@ -292,12 +303,14 @@ add_foreign_key "postings", "rentable_units" add_foreign_key "postings", "tenancies" add_foreign_key "properties", "users" + add_foreign_key "receipts", "parties", column: "payer_party_id" + add_foreign_key "receipts", "receipts", column: "superseded_by_id" + add_foreign_key "receipts", "tenancies" + add_foreign_key "receipts", "users" add_foreign_key "rent_terms", "tenancies" add_foreign_key "rentable_units", "properties" add_foreign_key "sessions", "users" add_foreign_key "tenancies", "rentable_units" add_foreign_key "tenancy_parties", "parties" add_foreign_key "tenancy_parties", "tenancies" - add_foreign_key "tenant_payments", "tenancies" - add_foreign_key "tenant_payments", "users" end diff --git a/db/migrate/20260816000005_create_receipts.rb b/db/migrate/20260816000005_create_receipts.rb new file mode 100644 index 00000000..c2d60271 --- /dev/null +++ b/db/migrate/20260816000005_create_receipts.rb @@ -0,0 +1,28 @@ +class CreateReceipts < ActiveRecord::Migration[8.1] + def change + create_table :receipts do |t| + t.references :user, null: false, foreign_key: true + t.references :tenancy, null: false, foreign_key: true + t.references :payer_party, null: false, foreign_key: { to_table: :parties } + t.bigint :amount_cents, null: false + t.date :received_on, null: false + t.string :payment_method, null: false + t.string :external_reference + t.text :memo + t.timestamptz :posted_at + t.timestamptz :voided_at + t.references :superseded_by, foreign_key: { to_table: :receipts } + + t.timestamps + end + + add_check_constraint :receipts, "amount_cents > 0", name: "receipts_amount_cents_positive" + add_index :receipts, :received_on + add_index :receipts, :voided_at + add_index :receipts, :superseded_by_id, unique: true, where: "superseded_by_id IS NOT NULL", name: "index_receipts_on_superseded_by_id_unique" + add_index :receipts, %i[user_id payment_method external_reference], + unique: true, + where: "external_reference IS NOT NULL AND voided_at IS NULL", + name: "index_receipts_on_user_method_external_ref_active" + end +end diff --git a/db/migrate/20260816000006_retarget_payment_ingestions_to_receipts.rb b/db/migrate/20260816000006_retarget_payment_ingestions_to_receipts.rb new file mode 100644 index 00000000..7ff655a6 --- /dev/null +++ b/db/migrate/20260816000006_retarget_payment_ingestions_to_receipts.rb @@ -0,0 +1,6 @@ +class RetargetPaymentIngestionsToReceipts < ActiveRecord::Migration[8.1] + def change + add_reference :payment_ingestions, :receipt, null: true, foreign_key: true + remove_reference :payment_ingestions, :tenant_payment, foreign_key: true + end +end diff --git a/db/migrate/20260816000007_drop_tenant_payments.rb b/db/migrate/20260816000007_drop_tenant_payments.rb new file mode 100644 index 00000000..6532a780 --- /dev/null +++ b/db/migrate/20260816000007_drop_tenant_payments.rb @@ -0,0 +1,14 @@ +class DropTenantPayments < ActiveRecord::Migration[8.1] + def change + drop_table :tenant_payments do |t| + t.references :tenancy, null: false, foreign_key: true + t.references :user, null: false, foreign_key: true + t.decimal :amount, precision: 12, scale: 2, null: false + t.date :payment_date, null: false + t.string :payment_method, null: false + t.string :transaction_number + + t.timestamps + end + end +end diff --git a/db/queue_schema.rb b/db/queue_schema.rb index bdd9f808..6b56d0fe 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_000004) do +ActiveRecord::Schema[8.1].define(version: 2026_08_16_000007) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -130,18 +130,18 @@ t.bigint "payment_document_id" t.string "payment_method" t.text "raw_text" + t.bigint "receipt_id" t.string "receipt_type" t.string "source", null: false t.string "status", default: "pending", null: false t.bigint "tenancy_id" - t.bigint "tenant_payment_id" t.string "transaction_number" t.datetime "updated_at", null: false t.bigint "user_id", null: false t.index ["party_id"], name: "index_payment_ingestions_on_party_id" t.index ["payment_document_id"], name: "index_payment_ingestions_on_payment_document_id" + t.index ["receipt_id"], name: "index_payment_ingestions_on_receipt_id" t.index ["tenancy_id"], name: "index_payment_ingestions_on_tenancy_id" - t.index ["tenant_payment_id"], name: "index_payment_ingestions_on_tenant_payment_id" t.index ["user_id", "payment_method", "transaction_number"], name: "idx_payment_ingestions_dup_check" t.index ["user_id"], name: "index_payment_ingestions_on_user_id" end @@ -177,6 +177,31 @@ t.index ["user_id"], name: "index_properties_on_user_id" end + create_table "receipts", force: :cascade do |t| + t.bigint "amount_cents", null: false + t.datetime "created_at", null: false + t.string "external_reference" + t.text "memo" + t.bigint "payer_party_id", null: false + t.string "payment_method", null: false + t.timestamptz "posted_at" + t.date "received_on", null: false + t.bigint "superseded_by_id" + t.bigint "tenancy_id", null: false + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.timestamptz "voided_at" + t.index ["payer_party_id"], name: "index_receipts_on_payer_party_id" + t.index ["received_on"], name: "index_receipts_on_received_on" + t.index ["superseded_by_id"], name: "index_receipts_on_superseded_by_id" + t.index ["superseded_by_id"], name: "index_receipts_on_superseded_by_id_unique", unique: true, where: "(superseded_by_id IS NOT NULL)" + t.index ["tenancy_id"], name: "index_receipts_on_tenancy_id" + t.index ["user_id", "payment_method", "external_reference"], name: "index_receipts_on_user_method_external_ref_active", unique: true, where: "((external_reference IS NOT NULL) AND (voided_at IS NULL))" + t.index ["user_id"], name: "index_receipts_on_user_id" + t.index ["voided_at"], name: "index_receipts_on_voided_at" + t.check_constraint "amount_cents > 0", name: "receipts_amount_cents_positive" + end + create_table "rent_terms", force: :cascade do |t| t.bigint "amount_cents", null: false t.datetime "created_at", null: false @@ -356,20 +381,6 @@ t.index ["tenancy_id"], name: "index_tenancy_parties_on_tenancy_id" end - create_table "tenant_payments", force: :cascade do |t| - t.decimal "amount", precision: 12, scale: 2, null: false - t.datetime "created_at", null: false - t.date "payment_date", null: false - t.string "payment_method", null: false - t.bigint "tenancy_id", null: false - t.string "transaction_number" - t.datetime "updated_at", null: false - t.bigint "user_id", null: false - t.index ["tenancy_id"], name: "index_tenant_payments_on_tenancy_id" - t.index ["user_id", "payment_method", "transaction_number"], name: "index_tenant_payments_on_user_payment_method_transaction_number", unique: true, where: "(transaction_number IS NOT NULL)" - t.index ["user_id"], name: "index_tenant_payments_on_user_id" - end - create_table "users", force: :cascade do |t| t.datetime "created_at", null: false t.string "email", null: false @@ -392,8 +403,8 @@ add_foreign_key "payment_documents", "users" add_foreign_key "payment_ingestions", "parties" add_foreign_key "payment_ingestions", "payment_documents" + add_foreign_key "payment_ingestions", "receipts" add_foreign_key "payment_ingestions", "tenancies" - add_foreign_key "payment_ingestions", "tenant_payments" add_foreign_key "payment_ingestions", "users" add_foreign_key "postings", "accounts" add_foreign_key "postings", "journal_entries" @@ -402,6 +413,10 @@ add_foreign_key "postings", "rentable_units" add_foreign_key "postings", "tenancies" add_foreign_key "properties", "users" + add_foreign_key "receipts", "parties", column: "payer_party_id" + add_foreign_key "receipts", "receipts", column: "superseded_by_id" + add_foreign_key "receipts", "tenancies" + add_foreign_key "receipts", "users" add_foreign_key "rent_terms", "tenancies" add_foreign_key "rentable_units", "properties" add_foreign_key "sessions", "users" @@ -414,6 +429,4 @@ add_foreign_key "tenancies", "rentable_units" add_foreign_key "tenancy_parties", "parties" add_foreign_key "tenancy_parties", "tenancies" - add_foreign_key "tenant_payments", "tenancies" - add_foreign_key "tenant_payments", "users" end diff --git a/db/schema.rb b/db/schema.rb index 66fa7aff..204418c3 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_000004) do +ActiveRecord::Schema[8.1].define(version: 2026_08_16_000007) 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 @@ -130,18 +130,18 @@ t.bigint "payment_document_id" t.string "payment_method" t.text "raw_text" + t.bigint "receipt_id" t.string "receipt_type" t.string "source", null: false t.string "status", default: "pending", null: false t.bigint "tenancy_id" - t.bigint "tenant_payment_id" t.string "transaction_number" t.datetime "updated_at", null: false t.bigint "user_id", null: false t.index ["party_id"], name: "index_payment_ingestions_on_party_id" t.index ["payment_document_id"], name: "index_payment_ingestions_on_payment_document_id" + t.index ["receipt_id"], name: "index_payment_ingestions_on_receipt_id" t.index ["tenancy_id"], name: "index_payment_ingestions_on_tenancy_id" - t.index ["tenant_payment_id"], name: "index_payment_ingestions_on_tenant_payment_id" t.index ["user_id", "payment_method", "transaction_number"], name: "idx_payment_ingestions_dup_check" t.index ["user_id"], name: "index_payment_ingestions_on_user_id" end @@ -177,6 +177,31 @@ t.index ["user_id"], name: "index_properties_on_user_id" end + create_table "receipts", force: :cascade do |t| + t.bigint "amount_cents", null: false + t.datetime "created_at", null: false + t.string "external_reference" + t.text "memo" + t.bigint "payer_party_id", null: false + t.string "payment_method", null: false + t.timestamptz "posted_at" + t.date "received_on", null: false + t.bigint "superseded_by_id" + t.bigint "tenancy_id", null: false + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.timestamptz "voided_at" + t.index ["payer_party_id"], name: "index_receipts_on_payer_party_id" + t.index ["received_on"], name: "index_receipts_on_received_on" + t.index ["superseded_by_id"], name: "index_receipts_on_superseded_by_id" + t.index ["superseded_by_id"], name: "index_receipts_on_superseded_by_id_unique", unique: true, where: "(superseded_by_id IS NOT NULL)" + t.index ["tenancy_id"], name: "index_receipts_on_tenancy_id" + t.index ["user_id", "payment_method", "external_reference"], name: "index_receipts_on_user_method_external_ref_active", unique: true, where: "((external_reference IS NOT NULL) AND (voided_at IS NULL))" + t.index ["user_id"], name: "index_receipts_on_user_id" + t.index ["voided_at"], name: "index_receipts_on_voided_at" + t.check_constraint "amount_cents > 0", name: "receipts_amount_cents_positive" + end + create_table "rent_terms", force: :cascade do |t| t.bigint "amount_cents", null: false t.datetime "created_at", null: false @@ -235,20 +260,6 @@ t.index ["tenancy_id"], name: "index_tenancy_parties_on_tenancy_id" end - create_table "tenant_payments", force: :cascade do |t| - t.decimal "amount", precision: 12, scale: 2, null: false - t.datetime "created_at", null: false - t.date "payment_date", null: false - t.string "payment_method", null: false - t.bigint "tenancy_id", null: false - t.string "transaction_number" - t.datetime "updated_at", null: false - t.bigint "user_id", null: false - t.index ["tenancy_id"], name: "index_tenant_payments_on_tenancy_id" - t.index ["user_id", "payment_method", "transaction_number"], name: "index_tenant_payments_on_user_payment_method_transaction_number", unique: true, where: "(transaction_number IS NOT NULL)" - t.index ["user_id"], name: "index_tenant_payments_on_user_id" - end - create_table "users", force: :cascade do |t| t.datetime "created_at", null: false t.string "email", null: false @@ -271,8 +282,8 @@ add_foreign_key "payment_documents", "users" add_foreign_key "payment_ingestions", "parties" add_foreign_key "payment_ingestions", "payment_documents" + add_foreign_key "payment_ingestions", "receipts" add_foreign_key "payment_ingestions", "tenancies" - add_foreign_key "payment_ingestions", "tenant_payments" add_foreign_key "payment_ingestions", "users" add_foreign_key "postings", "accounts" add_foreign_key "postings", "journal_entries" @@ -281,12 +292,14 @@ add_foreign_key "postings", "rentable_units" add_foreign_key "postings", "tenancies" add_foreign_key "properties", "users" + add_foreign_key "receipts", "parties", column: "payer_party_id" + add_foreign_key "receipts", "receipts", column: "superseded_by_id" + add_foreign_key "receipts", "tenancies" + add_foreign_key "receipts", "users" add_foreign_key "rent_terms", "tenancies" add_foreign_key "rentable_units", "properties" add_foreign_key "sessions", "users" add_foreign_key "tenancies", "rentable_units" add_foreign_key "tenancy_parties", "parties" add_foreign_key "tenancy_parties", "tenancies" - add_foreign_key "tenant_payments", "tenancies" - add_foreign_key "tenant_payments", "users" end diff --git a/db/seeds.rb b/db/seeds.rb index 4538eb63..5fb61eaf 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -34,7 +34,7 @@ (1..11).each do |months_ago| due_date = (Date.current - months_ago.months).beginning_of_month payment_date = due_date + rand(tenancy.late_period_days || 3) - TenantPayments::CreateService.call(tenancy: tenancy, amount: 1200.0, payment_date: payment_date, payment_method: "ach") + Receipts::CreateService.call(tenancy: tenancy, payer_party: party, amount: 1200.0, received_on: payment_date, payment_method: "ach") expense_amount = rand(100...200) expense = Expense.new( @@ -47,7 +47,7 @@ reimburse_amount: expense_amount ) Expenses::SaveService.call(expense: expense) - TenantPayments::CreateService.call(tenancy: tenancy, amount: expense_amount, payment_date: payment_date, payment_method: "ach") + Receipts::CreateService.call(tenancy: tenancy, payer_party: party, amount: expense_amount, received_on: payment_date, payment_method: "ach") end Expense.create!(property: property, amount: rand(200...300), category: "repairs", expense_date: Date.current - 1.year, description: "A/C tune-up") diff --git a/sig/app/controllers/receipts_controller.rbs b/sig/app/controllers/receipts_controller.rbs new file mode 100644 index 00000000..b7e20689 --- /dev/null +++ b/sig/app/controllers/receipts_controller.rbs @@ -0,0 +1,23 @@ +class ReceiptsController < ApplicationController + @receipts: ActiveRecord::Relation + @receipt: Receipt + @replacement_receipt: Receipt + @tenancy: Tenancy? + @tenancies: ActiveRecord::Relation + @parties: ActiveRecord::Relation + + def index: () -> void + def show: () -> void + def new: () -> void + def create: () -> void + def correction: () -> void + def correct: () -> void + def void: () -> void + + private + + def set_tenancy: () -> void + def set_receipt: () -> void + def receipt_params: () -> ActionController::Parameters + def load_form_collections: () -> void +end diff --git a/sig/app/controllers/tenant_payments_controller.rbs b/sig/app/controllers/tenant_payments_controller.rbs deleted file mode 100644 index db08032e..00000000 --- a/sig/app/controllers/tenant_payments_controller.rbs +++ /dev/null @@ -1,32 +0,0 @@ -class TenantPaymentsController < ApplicationController - @tenant_payments: TenantPayment::ActiveRecord_Relation - @tenant_payment: TenantPayment - @tenancy: Tenancy? - @tenancies: Tenancy::ActiveRecord_Relation - @financial_items: Array[Hash[Symbol, untyped]] - @year: Integer - - def index: () -> untyped - - def show: () -> untyped - - def new: () -> untyped - - def edit: () -> untyped - - def create: () -> untyped - - def update: () -> untyped - - def destroy: () -> untyped - - private - - def set_tenant_payment: () -> untyped - - def set_tenancy: () -> untyped - - def set_form_data: () -> untyped - - def tenant_payment_params: () -> untyped -end diff --git a/sig/app/models/payment_document.rbs b/sig/app/models/payment_document.rbs index f0728e30..d0f97097 100644 --- a/sig/app/models/payment_document.rbs +++ b/sig/app/models/payment_document.rbs @@ -1,3 +1,7 @@ class PaymentDocument < ApplicationRecord def accounting_user: () -> User? + + private + + def prevent_destroy_if_confirmed_ingestions_exist: () -> untyped end diff --git a/sig/app/models/payment_ingestion.rbs b/sig/app/models/payment_ingestion.rbs index a1695763..2b3562cd 100644 --- a/sig/app/models/payment_ingestion.rbs +++ b/sig/app/models/payment_ingestion.rbs @@ -3,7 +3,7 @@ class PaymentIngestion def confirmable?: () -> bool - def confirm!: (?create_alias: bool) -> TenantPayment + def confirm!: (?create_alias: bool) -> Receipt def duplicate_exists?: () -> bool @@ -19,4 +19,6 @@ class PaymentIngestion def validate_parse_status: () -> untyped def ensure_not_duplicate_payment: () -> untyped + def prevent_mutation_after_confirmed: () -> untyped + def prevent_destroy_if_confirmed: () -> untyped end diff --git a/sig/app/models/receipt.rbs b/sig/app/models/receipt.rbs new file mode 100644 index 00000000..ae96d3f2 --- /dev/null +++ b/sig/app/models/receipt.rbs @@ -0,0 +1,30 @@ +class Receipt < ApplicationRecord + VALID_PAYMENT_METHODS: Array[String] + + attr_reader amount_error: String? + + def self.active: () -> ActiveRecord::Relation + def self.voided: () -> ActiveRecord::Relation + + def posted?: () -> bool + def voided?: () -> bool + def superseded?: () -> bool + def active?: () -> bool + + def amount: () -> BigDecimal? + def amount=: ((BigDecimal | Numeric | String | nil) val) -> void + + def balance_cents_as_of: (?as_of: Date) -> Integer + def balance_as_of: (?as_of: Date) -> BigDecimal + def accounting_user: () -> User? + + private + + def normalize_attributes: () -> void + def validate_not_future_dated: () -> void + def validate_amount_format: () -> void + def validate_tenancy_party_alignment: () -> void + def ensure_immutable_after_posting: () -> void + def prevent_destroy_if_posted: () -> void + def parse_amount_to_cents: ((BigDecimal | Numeric | String | nil) value) -> Integer? +end diff --git a/sig/app/models/tenant_payment.rbs b/sig/app/models/tenant_payment.rbs deleted file mode 100644 index b019c4d6..00000000 --- a/sig/app/models/tenant_payment.rbs +++ /dev/null @@ -1,12 +0,0 @@ -class TenantPayment < ApplicationRecord - def amount: () -> BigDecimal - def amount=: (BigDecimal | Numeric | String | nil) -> untyped - def accounting_user: () -> User? - - private - - def prevent_mutation_after_creation: () -> void - def prevent_destroy: () -> void - def assign_user_from_tenancy: () -> void - def user_matches_tenancy_owner: () -> void -end diff --git a/sig/app/services/payment_documents/destroy_service.rbs b/sig/app/services/payment_documents/destroy_service.rbs new file mode 100644 index 00000000..338d5441 --- /dev/null +++ b/sig/app/services/payment_documents/destroy_service.rbs @@ -0,0 +1,15 @@ +module PaymentDocuments + class DestroyService + def self.call: (user: User, document: PaymentDocument) -> Dry::Monads::Result + def initialize: (user: User, document: PaymentDocument) -> void + def call: () -> Dry::Monads::Result + + private + + attr_reader user: User + attr_reader document: PaymentDocument + + def success: (PaymentDocument data) -> Dry::Monads::Result::Success + def failure: (String error, Symbol code) -> Dry::Monads::Result::Failure + end +end diff --git a/sig/app/services/payment_ingestions/confirm_service.rbs b/sig/app/services/payment_ingestions/confirm_service.rbs index d66ec07c..35099e3b 100644 --- a/sig/app/services/payment_ingestions/confirm_service.rbs +++ b/sig/app/services/payment_ingestions/confirm_service.rbs @@ -11,7 +11,7 @@ module PaymentIngestions attr_reader user: User attr_reader ingestion: PaymentIngestion - def create_payment: () -> TenantPayment + def create_receipt: () -> Receipt def create_aliases: () -> void @@ -19,7 +19,7 @@ module PaymentIngestions def create_alias: (String? alias_name) -> void - def success: (TenantPayment data) -> Dry::Monads::Result::Success + def success: (Receipt? data) -> Dry::Monads::Result::Success def failure: (String error, Symbol code) -> Dry::Monads::Result::Failure end diff --git a/sig/app/services/payment_ingestions/destroy_service.rbs b/sig/app/services/payment_ingestions/destroy_service.rbs new file mode 100644 index 00000000..478cd548 --- /dev/null +++ b/sig/app/services/payment_ingestions/destroy_service.rbs @@ -0,0 +1,15 @@ +module PaymentIngestions + class DestroyService + def self.call: (user: User, ingestion: PaymentIngestion) -> Dry::Monads::Result + def initialize: (user: User, ingestion: PaymentIngestion) -> void + def call: () -> Dry::Monads::Result + + private + + attr_reader user: User + attr_reader ingestion: PaymentIngestion + + def success: (PaymentIngestion data) -> Dry::Monads::Result::Success + def failure: (String error, Symbol code) -> Dry::Monads::Result::Failure + end +end diff --git a/sig/app/services/receipts/correct_service.rbs b/sig/app/services/receipts/correct_service.rbs new file mode 100644 index 00000000..66a4bedd --- /dev/null +++ b/sig/app/services/receipts/correct_service.rbs @@ -0,0 +1,46 @@ +module Receipts + class CorrectService + def self.call: ( + receipt: Receipt, + ?tenancy: Tenancy?, + ?payer_party: Party?, + ?amount_cents: Integer?, + ?amount: (BigDecimal | Numeric | String)?, + ?received_on: (Date | String | Object)?, + ?payment_method: (String | Symbol)?, + ?external_reference: String?, + ?memo: String? + ) -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure) + + def initialize: ( + receipt: Receipt, + ?tenancy: Tenancy?, + ?payer_party: Party?, + ?amount_cents: Integer?, + ?amount: (BigDecimal | Numeric | String)?, + ?received_on: (Date | String | Object)?, + ?payment_method: (String | Symbol)?, + ?external_reference: String?, + ?memo: String? + ) -> void + + def call: () -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure) + + private + + attr_reader receipt: Receipt + attr_reader tenancy: Tenancy? + attr_reader payer_party: Party? + attr_reader amount_cents: Integer? + attr_reader amount: (BigDecimal | Numeric | String)? + attr_reader raw_received_on: untyped + attr_reader received_on: Date? + attr_reader payment_method: (String | Symbol)? + attr_reader external_reference: String? + attr_reader memo: String? + + def parse_date: (untyped val) -> Date? + def resolve_amount_cents: () -> [ Integer, String? ] + def identical_replacement?: (Receipt rep, Tenancy t_tenancy, Party t_payer, Integer t_cents, Date t_date, (String | Symbol)? t_method, String? t_ref, String? t_memo) -> bool + end +end diff --git a/sig/app/services/receipts/create_service.rbs b/sig/app/services/receipts/create_service.rbs new file mode 100644 index 00000000..9fa694a6 --- /dev/null +++ b/sig/app/services/receipts/create_service.rbs @@ -0,0 +1,42 @@ +module Receipts + class CreateService + def self.call: ( + tenancy: Tenancy?, + payer_party: Party?, + received_on: (Date | String | Object)?, + payment_method: (String | Symbol)?, + ?amount_cents: Integer?, + ?amount: (BigDecimal | Numeric | String)?, + ?external_reference: String?, + ?memo: String? + ) -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure) + + def initialize: ( + tenancy: Tenancy?, + payer_party: Party?, + received_on: (Date | String | Object)?, + payment_method: (String | Symbol)?, + ?amount_cents: Integer?, + ?amount: (BigDecimal | Numeric | String)?, + ?external_reference: String?, + ?memo: String? + ) -> void + + def call: () -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure) + + private + + attr_reader tenancy: Tenancy? + attr_reader payer_party: Party? + attr_reader amount_cents: Integer? + attr_reader amount: (BigDecimal | Numeric | String)? + attr_reader raw_received_on: untyped + attr_reader received_on: Date? + attr_reader payment_method: String? + attr_reader external_reference: String? + attr_reader memo: String? + + def parse_date: (untyped val) -> Date? + def resolve_amount_cents: () -> [ Integer, String? ] + end +end diff --git a/sig/app/services/receipts/post_service.rbs b/sig/app/services/receipts/post_service.rbs new file mode 100644 index 00000000..abe9449c --- /dev/null +++ b/sig/app/services/receipts/post_service.rbs @@ -0,0 +1,14 @@ +module Receipts + class PostService + def self.call: (receipt: Receipt) -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure) + + def initialize: (receipt: Receipt) -> void + + def call: () -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure) + + private + + attr_reader receipt: Receipt + def default_description: () -> String + end +end diff --git a/sig/app/services/receipts/receipt_pdf_service.rbs b/sig/app/services/receipts/receipt_pdf_service.rbs new file mode 100644 index 00000000..3ea3e8a9 --- /dev/null +++ b/sig/app/services/receipts/receipt_pdf_service.rbs @@ -0,0 +1,14 @@ +module Receipts + class ReceiptPdfService + def self.call: (receipt: Receipt, view_context: untyped) -> String + + def initialize: (receipt: Receipt, view_context: untyped) -> void + + def call: () -> String + + private + + attr_reader receipt: Receipt + attr_reader view_context: untyped + end +end diff --git a/sig/app/services/receipts/void_service.rbs b/sig/app/services/receipts/void_service.rbs new file mode 100644 index 00000000..04705800 --- /dev/null +++ b/sig/app/services/receipts/void_service.rbs @@ -0,0 +1,14 @@ +module Receipts + class VoidService + def self.call: (receipt: Receipt, ?reason: String?) -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure) + + def initialize: (receipt: Receipt, ?reason: String?) -> void + + def call: () -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure) + + private + + attr_reader receipt: Receipt + attr_reader reason: String? + end +end diff --git a/sig/app/services/tenant_payments/create_service.rbs b/sig/app/services/tenant_payments/create_service.rbs deleted file mode 100644 index 00d9e95e..00000000 --- a/sig/app/services/tenant_payments/create_service.rbs +++ /dev/null @@ -1,36 +0,0 @@ -module TenantPayments - class CreateService - def self.call: ( - ?tenancy: Tenancy?, - ?amount_cents: Integer?, - ?amount: (BigDecimal | Numeric | String)?, - ?payment_date: Date?, - ?payment_method: String?, - ?transaction_number: String?, - ?description: String?, - ?params: untyped? - ) -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure) - - def initialize: ( - tenancy: Tenancy?, - ?amount_cents: Integer?, - ?amount: (BigDecimal | Numeric | String)?, - ?payment_date: Date?, - ?payment_method: String?, - ?transaction_number: String?, - ?description: String? - ) -> void - - def call: () -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure) - - private - - attr_reader tenancy: Tenancy - attr_reader amount_cents: Integer? - attr_reader amount: (BigDecimal | Numeric | String)? - attr_reader payment_date: Date - attr_reader payment_method: String - attr_reader transaction_number: String? - attr_reader description: String? - end -end diff --git a/sig/app/services/tenant_payments/receipt_pdf_service.rbs b/sig/app/services/tenant_payments/receipt_pdf_service.rbs deleted file mode 100644 index 9a18f4ce..00000000 --- a/sig/app/services/tenant_payments/receipt_pdf_service.rbs +++ /dev/null @@ -1,14 +0,0 @@ -module TenantPayments - class ReceiptPdfService - def self.call: (tenant_payment: TenantPayment, view_context: ActionView::Base) -> String - - def initialize: (tenant_payment: TenantPayment, view_context: ActionView::Base) -> void - - def call: () -> String - - private - - attr_reader tenant_payment: TenantPayment - attr_reader view_context: ActionView::Base - end -end diff --git a/sig/rbs_rails/app/models/party.rbs b/sig/rbs_rails/app/models/party.rbs index 44ffdcdd..dbf2500c 100644 --- a/sig/rbs_rails/app/models/party.rbs +++ b/sig/rbs_rails/app/models/party.rbs @@ -425,6 +425,10 @@ class ::Party < ::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 receipts_as_payer: () -> ::Receipt::ActiveRecord_Associations_CollectionProxy + 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 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] @@ -509,6 +513,10 @@ class ::Posting < ::ApplicationRecord end class ::Posting::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy end +class ::Receipt < ::ApplicationRecord +end +class ::Receipt::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/payment_ingestion.rbs b/sig/rbs_rails/app/models/payment_ingestion.rbs index a4a63128..92298705 100644 --- a/sig/rbs_rails/app/models/payment_ingestion.rbs +++ b/sig/rbs_rails/app/models/payment_ingestion.rbs @@ -604,46 +604,6 @@ class ::PaymentIngestion < ::ApplicationRecord def party_id_for_database: () -> ::Integer? - def tenant_payment_id: () -> ::Integer? - - def tenant_payment_id=: (::Integer?) -> ::Integer? - - def tenant_payment_id?: () -> bool - - def tenant_payment_id_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool - - def tenant_payment_id_change: () -> [ ::Integer?, ::Integer? ] - - def tenant_payment_id_will_change!: () -> void - - def tenant_payment_id_was: () -> ::Integer? - - def tenant_payment_id_previously_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool - - def tenant_payment_id_previous_change: () -> ::Array[::Integer?]? - - def tenant_payment_id_previously_was: () -> ::Integer? - - def tenant_payment_id_before_last_save: () -> ::Integer? - - def tenant_payment_id_change_to_be_saved: () -> ::Array[::Integer?]? - - def tenant_payment_id_in_database: () -> ::Integer? - - def saved_change_to_tenant_payment_id: () -> ::Array[::Integer?]? - - def saved_change_to_tenant_payment_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool - - def will_save_change_to_tenant_payment_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool - - def restore_tenant_payment_id!: () -> void - - def clear_tenant_payment_id_change: () -> void - - def tenant_payment_id_before_type_cast: () -> ::Integer? - - def tenant_payment_id_for_database: () -> ::Integer? - def transaction_number: () -> ::String? def transaction_number=: (::String?) -> ::String? @@ -763,6 +723,46 @@ class ::PaymentIngestion < ::ApplicationRecord def user_id_before_type_cast: () -> ::Integer def user_id_for_database: () -> ::Integer + + def receipt_id: () -> ::Integer? + + def receipt_id=: (::Integer?) -> ::Integer? + + def receipt_id?: () -> bool + + def receipt_id_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def receipt_id_change: () -> [ ::Integer?, ::Integer? ] + + def receipt_id_will_change!: () -> void + + def receipt_id_was: () -> ::Integer? + + def receipt_id_previously_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def receipt_id_previous_change: () -> ::Array[::Integer?]? + + def receipt_id_previously_was: () -> ::Integer? + + def receipt_id_before_last_save: () -> ::Integer? + + def receipt_id_change_to_be_saved: () -> ::Array[::Integer?]? + + def receipt_id_in_database: () -> ::Integer? + + def saved_change_to_receipt_id: () -> ::Array[::Integer?]? + + def saved_change_to_receipt_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def will_save_change_to_receipt_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def restore_receipt_id!: () -> void + + def clear_receipt_id_change: () -> void + + def receipt_id_before_type_cast: () -> ::Integer? + + def receipt_id_for_database: () -> ::Integer? end include ::PaymentIngestion::GeneratedAttributeMethods module ::PaymentIngestion::GeneratedAliasAttributeMethods @@ -828,12 +828,12 @@ class ::PaymentIngestion < ::ApplicationRecord def build_tenancy: (?untyped) -> ::Tenancy def create_tenancy: (?untyped) -> ::Tenancy def create_tenancy!: (?untyped) -> ::Tenancy - def tenant_payment: () -> ::TenantPayment? - def tenant_payment=: (::TenantPayment?) -> ::TenantPayment? - def reload_tenant_payment: () -> ::TenantPayment? - def build_tenant_payment: (?untyped) -> ::TenantPayment - def create_tenant_payment: (?untyped) -> ::TenantPayment - def create_tenant_payment!: (?untyped) -> ::TenantPayment + def receipt: () -> ::Receipt? + def receipt=: (::Receipt?) -> ::Receipt? + def reload_receipt: () -> ::Receipt? + def build_receipt: (?untyped) -> ::Receipt + def create_receipt: (?untyped) -> ::Receipt + def create_receipt!: (?untyped) -> ::Receipt def payment_document: () -> ::PaymentDocument? def payment_document=: (::PaymentDocument?) -> ::PaymentDocument? def reload_payment_document: () -> ::PaymentDocument? @@ -937,7 +937,7 @@ class ::Party < ::ApplicationRecord end class ::Tenancy < ::ApplicationRecord end -class ::TenantPayment < ::ApplicationRecord +class ::Receipt < ::ApplicationRecord end class ::PaymentDocument < ::ApplicationRecord end diff --git a/sig/rbs_rails/app/models/property.rbs b/sig/rbs_rails/app/models/property.rbs index a68203cb..4a12c642 100644 --- a/sig/rbs_rails/app/models/property.rbs +++ b/sig/rbs_rails/app/models/property.rbs @@ -345,10 +345,10 @@ class ::Property < ::ApplicationRecord def charges=: (::Charge::ActiveRecord_Associations_CollectionProxy | ::Array[::Charge]) -> (::Charge::ActiveRecord_Associations_CollectionProxy | ::Array[::Charge]) def charge_ids: () -> ::Array[::Integer] def charge_ids=: (::Array[::Integer]) -> ::Array[::Integer] - def tenant_payments: () -> ::TenantPayment::ActiveRecord_Associations_CollectionProxy - def tenant_payments=: (::TenantPayment::ActiveRecord_Associations_CollectionProxy | ::Array[::TenantPayment]) -> (::TenantPayment::ActiveRecord_Associations_CollectionProxy | ::Array[::TenantPayment]) - def tenant_payment_ids: () -> ::Array[::Integer] - def tenant_payment_ids=: (::Array[::Integer]) -> ::Array[::Integer] + def receipts: () -> ::Receipt::ActiveRecord_Associations_CollectionProxy + 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 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] @@ -464,9 +464,9 @@ class ::Charge < ::ApplicationRecord end class ::Charge::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy end -class ::TenantPayment < ::ApplicationRecord +class ::Receipt < ::ApplicationRecord end -class ::TenantPayment::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy +class ::Receipt::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy end class ::Posting < ::ApplicationRecord end diff --git a/sig/rbs_rails/app/models/receipt.rbs b/sig/rbs_rails/app/models/receipt.rbs new file mode 100644 index 00000000..ec5555c7 --- /dev/null +++ b/sig/rbs_rails/app/models/receipt.rbs @@ -0,0 +1,701 @@ +# resolve-type-names: false + +class ::Receipt < ::ApplicationRecord + extend ::ActiveRecord::Base::ClassMethods[::Receipt, ::Receipt::ActiveRecord_Relation, ::Integer] + + module ::Receipt::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 user_id: () -> ::Integer + + def user_id=: (::Integer) -> ::Integer + + def user_id?: () -> bool + + def user_id_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def user_id_change: () -> [ ::Integer?, ::Integer? ] + + def user_id_will_change!: () -> void + + def user_id_was: () -> ::Integer? + + def user_id_previously_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def user_id_previous_change: () -> ::Array[::Integer?]? + + def user_id_previously_was: () -> ::Integer? + + def user_id_before_last_save: () -> ::Integer? + + def user_id_change_to_be_saved: () -> ::Array[::Integer?]? + + def user_id_in_database: () -> ::Integer? + + def saved_change_to_user_id: () -> ::Array[::Integer?]? + + def saved_change_to_user_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def will_save_change_to_user_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def restore_user_id!: () -> void + + def clear_user_id_change: () -> void + + def user_id_before_type_cast: () -> ::Integer + + def user_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 payer_party_id: () -> ::Integer + + def payer_party_id=: (::Integer) -> ::Integer + + def payer_party_id?: () -> bool + + def payer_party_id_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def payer_party_id_change: () -> [ ::Integer?, ::Integer? ] + + def payer_party_id_will_change!: () -> void + + def payer_party_id_was: () -> ::Integer? + + def payer_party_id_previously_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def payer_party_id_previous_change: () -> ::Array[::Integer?]? + + def payer_party_id_previously_was: () -> ::Integer? + + def payer_party_id_before_last_save: () -> ::Integer? + + def payer_party_id_change_to_be_saved: () -> ::Array[::Integer?]? + + def payer_party_id_in_database: () -> ::Integer? + + def saved_change_to_payer_party_id: () -> ::Array[::Integer?]? + + def saved_change_to_payer_party_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def will_save_change_to_payer_party_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def restore_payer_party_id!: () -> void + + def clear_payer_party_id_change: () -> void + + def payer_party_id_before_type_cast: () -> ::Integer + + def payer_party_id_for_database: () -> ::Integer + + 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 received_on: () -> ::Date + + def received_on=: (::Date) -> ::Date + + def received_on?: () -> bool + + def received_on_changed?: (?from: ::Date?, ?to: ::Date?) -> bool + + def received_on_change: () -> [ ::Date?, ::Date? ] + + def received_on_will_change!: () -> void + + def received_on_was: () -> ::Date? + + def received_on_previously_changed?: (?from: ::Date?, ?to: ::Date?) -> bool + + def received_on_previous_change: () -> ::Array[::Date?]? + + def received_on_previously_was: () -> ::Date? + + def received_on_before_last_save: () -> ::Date? + + def received_on_change_to_be_saved: () -> ::Array[::Date?]? + + def received_on_in_database: () -> ::Date? + + def saved_change_to_received_on: () -> ::Array[::Date?]? + + def saved_change_to_received_on?: (?from: ::Date?, ?to: ::Date?) -> bool + + def will_save_change_to_received_on?: (?from: ::Date?, ?to: ::Date?) -> bool + + def restore_received_on!: () -> void + + def clear_received_on_change: () -> void + + def received_on_before_type_cast: () -> ::Date + + def received_on_for_database: () -> ::Date + + def payment_method: () -> ::String + + def payment_method=: (::String) -> ::String + + def payment_method?: () -> bool + + def payment_method_changed?: (?from: ::String?, ?to: ::String?) -> bool + + def payment_method_change: () -> [ ::String?, ::String? ] + + def payment_method_will_change!: () -> void + + def payment_method_was: () -> ::String? + + def payment_method_previously_changed?: (?from: ::String?, ?to: ::String?) -> bool + + def payment_method_previous_change: () -> ::Array[::String?]? + + def payment_method_previously_was: () -> ::String? + + def payment_method_before_last_save: () -> ::String? + + def payment_method_change_to_be_saved: () -> ::Array[::String?]? + + def payment_method_in_database: () -> ::String? + + def saved_change_to_payment_method: () -> ::Array[::String?]? + + def saved_change_to_payment_method?: (?from: ::String?, ?to: ::String?) -> bool + + def will_save_change_to_payment_method?: (?from: ::String?, ?to: ::String?) -> bool + + def restore_payment_method!: () -> void + + def clear_payment_method_change: () -> void + + def payment_method_before_type_cast: () -> ::String + + def payment_method_for_database: () -> ::String + + 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: () -> untyped + + def posted_at=: (untyped) -> untyped + + def posted_at?: () -> bool + + def posted_at_changed?: (?from: untyped, ?to: untyped) -> bool + + def posted_at_change: () -> [ untyped, untyped ] + + def posted_at_will_change!: () -> void + + def posted_at_was: () -> untyped + + def posted_at_previously_changed?: (?from: untyped, ?to: untyped) -> bool + + def posted_at_previous_change: () -> ::Array[untyped]? + + def posted_at_previously_was: () -> untyped + + def posted_at_before_last_save: () -> untyped + + def posted_at_change_to_be_saved: () -> ::Array[untyped]? + + def posted_at_in_database: () -> untyped + + def saved_change_to_posted_at: () -> ::Array[untyped]? + + def saved_change_to_posted_at?: (?from: untyped, ?to: untyped) -> bool + + def will_save_change_to_posted_at?: (?from: untyped, ?to: untyped) -> bool + + def restore_posted_at!: () -> void + + def clear_posted_at_change: () -> void + + def posted_at_before_type_cast: () -> untyped? + + def posted_at_for_database: () -> untyped? + + def voided_at: () -> untyped + + def voided_at=: (untyped) -> untyped + + def voided_at?: () -> bool + + def voided_at_changed?: (?from: untyped, ?to: untyped) -> bool + + def voided_at_change: () -> [ untyped, untyped ] + + def voided_at_will_change!: () -> void + + def voided_at_was: () -> untyped + + def voided_at_previously_changed?: (?from: untyped, ?to: untyped) -> bool + + def voided_at_previous_change: () -> ::Array[untyped]? + + def voided_at_previously_was: () -> untyped + + def voided_at_before_last_save: () -> untyped + + def voided_at_change_to_be_saved: () -> ::Array[untyped]? + + def voided_at_in_database: () -> untyped + + def saved_change_to_voided_at: () -> ::Array[untyped]? + + def saved_change_to_voided_at?: (?from: untyped, ?to: untyped) -> bool + + def will_save_change_to_voided_at?: (?from: untyped, ?to: untyped) -> bool + + def restore_voided_at!: () -> void + + def clear_voided_at_change: () -> void + + def voided_at_before_type_cast: () -> untyped? + + def voided_at_for_database: () -> untyped? + + 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 ::Receipt::GeneratedAttributeMethods + module ::Receipt::GeneratedAliasAttributeMethods + include ::Receipt::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 ::Receipt::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 superseded_receipt: () -> ::Receipt? + def superseded_receipt=: (::Receipt?) -> ::Receipt? + def build_superseded_receipt: (?untyped) -> ::Receipt + def create_superseded_receipt: (?untyped) -> ::Receipt + def create_superseded_receipt!: (?untyped) -> ::Receipt + def reload_superseded_receipt: () -> ::Receipt? + def user: () -> ::User + def user=: (::User?) -> ::User? + def reload_user: () -> ::User? + def build_user: (?untyped) -> ::User + def create_user: (?untyped) -> ::User + def create_user!: (?untyped) -> ::User + 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 + def payer_party: () -> ::Party + def payer_party=: (::Party?) -> ::Party? + def reload_payer_party: () -> ::Party? + def build_payer_party: (?untyped) -> ::Party + def create_payer_party: (?untyped) -> ::Party + def create_payer_party!: (?untyped) -> ::Party + def superseded_by: () -> ::Receipt? + def superseded_by=: (::Receipt?) -> ::Receipt? + def reload_superseded_by: () -> ::Receipt? + def build_superseded_by: (?untyped) -> ::Receipt + def create_superseded_by: (?untyped) -> ::Receipt + def create_superseded_by!: (?untyped) -> ::Receipt + + module ::Receipt::GeneratedAssociationMethods + end + include ::Receipt::GeneratedAssociationMethods + + def self.active: () -> ::Receipt::ActiveRecord_Relation + def self.voided: () -> ::Receipt::ActiveRecord_Relation + + module ::Receipt::GeneratedRelationMethods + def active: () -> ::Receipt::ActiveRecord_Relation + + def voided: () -> ::Receipt::ActiveRecord_Relation + end + + class ::Receipt::ActiveRecord_Relation < ::ActiveRecord::Relation + include ::Enumerable[::Receipt] + include ::Receipt::GeneratedRelationMethods + include ::ActiveRecord::Relation::Methods[::Receipt, ::Integer] + end + + class ::Receipt::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy + include ::Enumerable[::Receipt] + include ::Receipt::GeneratedRelationMethods + include ::ActiveRecord::Relation::Methods[::Receipt, ::Integer] + + def build: (?::ActiveRecord::Associations::CollectionProxy::_EachPair attributes) ?{ () -> untyped } -> ::Receipt + | (::Array[::ActiveRecord::Associations::CollectionProxy::_EachPair] attributes) ?{ () -> untyped } -> ::Array[::Receipt] + def create: (?::ActiveRecord::Associations::CollectionProxy::_EachPair attributes) ?{ () -> untyped } -> ::Receipt + | (::Array[::ActiveRecord::Associations::CollectionProxy::_EachPair] attributes) ?{ () -> untyped } -> ::Array[::Receipt] + def create!: (?::ActiveRecord::Associations::CollectionProxy::_EachPair attributes) ?{ () -> untyped } -> ::Receipt + | (::Array[::ActiveRecord::Associations::CollectionProxy::_EachPair] attributes) ?{ () -> untyped } -> ::Array[::Receipt] + def reload: () -> ::Array[::Receipt] + + def replace: (::Array[::Receipt]) -> void + def delete: (*::Receipt | ::Integer) -> ::Array[::Receipt] + def destroy: (*::Receipt | ::Integer) -> ::Array[::Receipt] + def <<: (*::Receipt | ::Array[::Receipt]) -> self + def prepend: (*::Receipt | ::Array[::Receipt]) -> self + end +end + +class ::ApplicationRecord < ::ActiveRecord::Base +end +class ::JournalEntry < ::ApplicationRecord +end +class ::JournalEntry::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy +end +class ::Receipt < ::ApplicationRecord +end +class ::User < ::ApplicationRecord +end +class ::Tenancy < ::ApplicationRecord +end +class ::Party < ::ApplicationRecord +end diff --git a/sig/rbs_rails/app/models/tenancy.rbs b/sig/rbs_rails/app/models/tenancy.rbs index a8d0c777..3d276dc9 100644 --- a/sig/rbs_rails/app/models/tenancy.rbs +++ b/sig/rbs_rails/app/models/tenancy.rbs @@ -385,10 +385,10 @@ class ::Tenancy < ::ApplicationRecord def charges=: (::Charge::ActiveRecord_Associations_CollectionProxy | ::Array[::Charge]) -> (::Charge::ActiveRecord_Associations_CollectionProxy | ::Array[::Charge]) def charge_ids: () -> ::Array[::Integer] def charge_ids=: (::Array[::Integer]) -> ::Array[::Integer] - def tenant_payments: () -> ::TenantPayment::ActiveRecord_Associations_CollectionProxy - def tenant_payments=: (::TenantPayment::ActiveRecord_Associations_CollectionProxy | ::Array[::TenantPayment]) -> (::TenantPayment::ActiveRecord_Associations_CollectionProxy | ::Array[::TenantPayment]) - def tenant_payment_ids: () -> ::Array[::Integer] - def tenant_payment_ids=: (::Array[::Integer]) -> ::Array[::Integer] + def receipts: () -> ::Receipt::ActiveRecord_Associations_CollectionProxy + 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 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] @@ -493,9 +493,9 @@ class ::Charge < ::ApplicationRecord end class ::Charge::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy end -class ::TenantPayment < ::ApplicationRecord +class ::Receipt < ::ApplicationRecord end -class ::TenantPayment::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy +class ::Receipt::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy end class ::Posting < ::ApplicationRecord end diff --git a/sig/rbs_rails/app/models/user.rbs b/sig/rbs_rails/app/models/user.rbs index a46a365b..e4c7a6eb 100644 --- a/sig/rbs_rails/app/models/user.rbs +++ b/sig/rbs_rails/app/models/user.rbs @@ -313,10 +313,10 @@ class ::User < ::ApplicationRecord def charges=: (::Charge::ActiveRecord_Associations_CollectionProxy | ::Array[::Charge]) -> (::Charge::ActiveRecord_Associations_CollectionProxy | ::Array[::Charge]) def charge_ids: () -> ::Array[::Integer] def charge_ids=: (::Array[::Integer]) -> ::Array[::Integer] - def tenant_payments: () -> ::TenantPayment::ActiveRecord_Associations_CollectionProxy - def tenant_payments=: (::TenantPayment::ActiveRecord_Associations_CollectionProxy | ::Array[::TenantPayment]) -> (::TenantPayment::ActiveRecord_Associations_CollectionProxy | ::Array[::TenantPayment]) - def tenant_payment_ids: () -> ::Array[::Integer] - def tenant_payment_ids=: (::Array[::Integer]) -> ::Array[::Integer] + def receipts: () -> ::Receipt::ActiveRecord_Associations_CollectionProxy + 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 parties: () -> ::Party::ActiveRecord_Associations_CollectionProxy def parties=: (::Party::ActiveRecord_Associations_CollectionProxy | ::Array[::Party]) -> (::Party::ActiveRecord_Associations_CollectionProxy | ::Array[::Party]) def party_ids: () -> ::Array[::Integer] @@ -410,9 +410,9 @@ class ::Charge < ::ApplicationRecord end class ::Charge::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy end -class ::TenantPayment < ::ApplicationRecord +class ::Receipt < ::ApplicationRecord end -class ::TenantPayment::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy +class ::Receipt::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy end class ::Party < ::ApplicationRecord end diff --git a/sig/rbs_rails/path_helpers.rbs b/sig/rbs_rails/path_helpers.rbs index 6e530c2b..43e33bf6 100644 --- a/sig/rbs_rails/path_helpers.rbs +++ b/sig/rbs_rails/path_helpers.rbs @@ -24,8 +24,8 @@ interface ::_RbsRailsPathHelpers def new_party_path: (*untyped) -> ::String def edit_party_path: (*untyped) -> ::String def party_path: (*untyped) -> ::String - def tenancy_tenant_payments_path: (*untyped) -> ::String - def new_tenancy_tenant_payment_path: (*untyped) -> ::String + def tenancy_receipts_path: (*untyped) -> ::String + def new_tenancy_receipt_path: (*untyped) -> ::String def tenancy_charges_path: (*untyped) -> ::String def new_tenancy_charge_path: (*untyped) -> ::String def tenancy_tenancy_parties_path: (*untyped) -> ::String @@ -44,9 +44,12 @@ interface ::_RbsRailsPathHelpers def new_expense_path: (*untyped) -> ::String def edit_expense_path: (*untyped) -> ::String def expense_path: (*untyped) -> ::String - def tenant_payments_path: (*untyped) -> ::String - def new_tenant_payment_path: (*untyped) -> ::String - def tenant_payment_path: (*untyped) -> ::String + def correction_receipt_path: (*untyped) -> ::String + def correct_receipt_path: (*untyped) -> ::String + def void_receipt_path: (*untyped) -> ::String + def receipts_path: (*untyped) -> ::String + def new_receipt_path: (*untyped) -> ::String + def receipt_path: (*untyped) -> ::String def void_charge_path: (*untyped) -> ::String def charge_path: (*untyped) -> ::String def confirm_payment_ingestion_path: (*untyped) -> ::String @@ -114,8 +117,8 @@ interface ::_RbsRailsPathHelpers def new_party_url: (*untyped) -> ::String def edit_party_url: (*untyped) -> ::String def party_url: (*untyped) -> ::String - def tenancy_tenant_payments_url: (*untyped) -> ::String - def new_tenancy_tenant_payment_url: (*untyped) -> ::String + def tenancy_receipts_url: (*untyped) -> ::String + def new_tenancy_receipt_url: (*untyped) -> ::String def tenancy_charges_url: (*untyped) -> ::String def new_tenancy_charge_url: (*untyped) -> ::String def tenancy_tenancy_parties_url: (*untyped) -> ::String @@ -134,9 +137,12 @@ interface ::_RbsRailsPathHelpers def new_expense_url: (*untyped) -> ::String def edit_expense_url: (*untyped) -> ::String def expense_url: (*untyped) -> ::String - def tenant_payments_url: (*untyped) -> ::String - def new_tenant_payment_url: (*untyped) -> ::String - def tenant_payment_url: (*untyped) -> ::String + def correction_receipt_url: (*untyped) -> ::String + def correct_receipt_url: (*untyped) -> ::String + def void_receipt_url: (*untyped) -> ::String + def receipts_url: (*untyped) -> ::String + def new_receipt_url: (*untyped) -> ::String + def receipt_url: (*untyped) -> ::String def void_charge_url: (*untyped) -> ::String def charge_url: (*untyped) -> ::String def confirm_payment_ingestion_url: (*untyped) -> ::String diff --git a/spec/factories.rb b/spec/factories.rb index bf47684f..cb55fb7e 100644 --- a/spec/factories.rb +++ b/spec/factories.rb @@ -69,12 +69,29 @@ effective_until { tenancy&.termination_date } end - factory :tenant_payment do + factory :receipt do association :tenancy - amount { 1200.0 } - payment_date { Date.current } + user { tenancy.rentable_unit.property.user } + payer_party { association :party, user: user } + amount_cents { 120_000 } + received_on { Date.current } payment_method { "check" } - sequence(:transaction_number) { |n| "TXN#{n}" } + sequence(:external_reference) { |n| "REC#{n}" } + + trait :posted do + posted_at { Time.current } + end + + trait :voided do + posted_at { 1.day.ago } + voided_at { Time.current } + end + + trait :superseded do + posted_at { 1.day.ago } + voided_at { Time.current } + superseded_by { association :receipt, tenancy: tenancy, user: user, payer_party: payer_party } + end end factory :expense do diff --git a/spec/models/payment_document_spec.rb b/spec/models/payment_document_spec.rb index 490e2595..f73bd3f2 100644 --- a/spec/models/payment_document_spec.rb +++ b/spec/models/payment_document_spec.rb @@ -47,4 +47,25 @@ expect(doc.accounting_user).to eq(user) end end + + describe 'destruction' do + let(:user) { create(:user) } + let(:doc) { create(:payment_document, user: user) } + + it 'allows destroying document with unconfirmed ingestions' do + create(:payment_ingestion, user: user, payment_document: doc, status: :matched) + expect { + doc.destroy + }.to change(PaymentDocument, :count).by(-1).and change(PaymentIngestion, :count).by(-1) + end + + it 'prevents destroying document with confirmed ingestions' do + create(:payment_ingestion, user: user, payment_document: doc, status: :confirmed) + expect { + doc.destroy + }.not_to change(PaymentDocument, :count) + + expect(doc.errors[:base]).to include("Cannot delete document with confirmed payment ingestions") + end + end end diff --git a/spec/models/payment_ingestion_spec.rb b/spec/models/payment_ingestion_spec.rb index 14c81f1e..8ddac811 100644 --- a/spec/models/payment_ingestion_spec.rb +++ b/spec/models/payment_ingestion_spec.rb @@ -5,7 +5,7 @@ it { is_expected.to belong_to(:user) } it { is_expected.to belong_to(:party).optional } it { is_expected.to belong_to(:tenancy).optional } - it { is_expected.to belong_to(:tenant_payment).optional } + it { is_expected.to belong_to(:receipt).optional } it { is_expected.to belong_to(:payment_document).optional } end @@ -75,18 +75,20 @@ expect(ingestion.confirmable?).to be_falsey end - it "returns false for confirmable? when a duplicate payment exists" do - create(:tenant_payment, + it "returns false for confirmable? when a duplicate payment receipt exists" do + create(:receipt, + user: user, tenancy: tenancy, - amount: 500.0, - payment_date: Date.current, + payer_party: party, + amount_cents: 50_000, + received_on: Date.current, payment_method: "zelle", - transaction_number: "TXN123" + external_reference: "TXN123" ) expect(ingestion.confirmable?).to be_falsey end - it "confirm! creates tenant payment and updates status" do + it "confirm! creates receipt and updates status" do ingestion_to_confirm = create(:payment_ingestion, user: user, source: "pdf_upload", @@ -100,15 +102,16 @@ ) expect { - payment = ingestion_to_confirm.confirm! - expect(payment.amount).to eq(1200.0) - expect(payment.payment_method).to eq("venmo") - expect(payment.transaction_number).to eq("TXN456") - expect(payment.tenancy).to eq(tenancy) - }.to change(TenantPayment, :count).by(1) + receipt = ingestion_to_confirm.confirm! + expect(receipt.amount).to eq(1200.0) + expect(receipt.payment_method).to eq("venmo") + expect(receipt.external_reference).to eq("TXN456") + expect(receipt.tenancy).to eq(tenancy) + expect(receipt.payer_party).to eq(party) + }.to change(Receipt, :count).by(1) expect(ingestion_to_confirm.reload.status).to eq("confirmed") - expect(ingestion_to_confirm.tenant_payment).not_to be_nil + expect(ingestion_to_confirm.receipt).not_to be_nil end it "confirm! with create_alias: true creates alias" do @@ -134,38 +137,24 @@ expect(party.party_aliases.exists?(alias_name: "@samlopez")).to be_truthy end - it "confirm! rescues RecordNotUnique and raises ConfirmationError" do - create(:tenant_payment, - user: user, - tenancy: tenancy, - amount: 500.0, - payment_date: Date.current, - payment_method: "zelle", - transaction_number: "TXNDUP" - ) - - dup_ingestion = build(:payment_ingestion, + it "confirm! returns existing receipt if called again on already confirmed ingestion" do + confirmed_ingestion = create(:payment_ingestion, user: user, source: "pdf_upload", status: "matched", party: party, tenancy: tenancy, - amount: 1000.0, + amount: 1200.0, payment_date: Date.current, - payment_method: "zelle", - transaction_number: "TXNDUP" + payment_method: "venmo", + transaction_number: "TXNIDEM" ) + first_receipt = confirmed_ingestion.confirm! expect { - dup_ingestion.confirm! - }.to raise_error(PaymentIngestions::ConfirmationError) - end - - it "confirm! rescues RecordNotUnique and raises ConfirmationError when TenantPayment creation raises RecordNotUnique" do - allow(TenantPayments::CreateService).to receive(:call).and_raise(ActiveRecord::RecordNotUnique.new("Duplicate key error")) - expect { - ingestion.confirm! - }.to raise_error(PaymentIngestions::ConfirmationError, /This transaction has already been recorded/) + second_receipt = confirmed_ingestion.confirm! + expect(second_receipt).to eq(first_receipt) + }.not_to change(Receipt, :count) end end @@ -182,12 +171,14 @@ let(:tenancy_two) { create(:tenancy, rentable_unit: unit_two) } it "only flags duplicates within the same user" do - create(:tenant_payment, + create(:receipt, + user: user_two, tenancy: tenancy_two, - amount: 1000.0, - payment_date: Date.current, + payer_party: party_two, + amount_cents: 100_000, + received_on: Date.current, payment_method: "zelle", - transaction_number: "TXNSCOPED" + external_reference: "TXNSCOPED" ) ingestion = build(:payment_ingestion, @@ -205,14 +196,15 @@ expect(ingestion.duplicate_exists?).to be_falsey expect(ingestion).to be_valid - # When payment exists for user one with matching transaction number - create(:tenant_payment, + # When receipt exists for user one with matching transaction number + create(:receipt, tenancy: tenancy_one, user: user_one, - amount: 1000.0, - payment_date: Date.current, + payer_party: party_one, + amount_cents: 100_000, + received_on: Date.current, payment_method: "zelle", - transaction_number: "TXNSCOPED_MATCH" + external_reference: "TXNSCOPED_MATCH" ) ingestion.transaction_number = "TXNSCOPED_MATCH" @@ -228,7 +220,7 @@ let(:unit) { create(:rentable_unit, property: property) } let(:tenancy) { create(:tenancy, rentable_unit: unit) } - it "prevents race conditions and raises already confirmed on concurrent calls" do + it "prevents race conditions and safely handles concurrent confirmations" do ingestion = create(:payment_ingestion, user: user, source: "pdf_upload", @@ -241,26 +233,23 @@ transaction_number: "TXNRACE" ) - exceptions = [] + results = [] threads = [] 2.times do threads << Thread.new do ActiveRecord::Base.connection_pool.with_connection do - begin - PaymentIngestion.find(ingestion.id).confirm! - rescue => e - exceptions << e - end + results << PaymentIngestion.find(ingestion.id).confirm! end end end threads.each(&:join) - expect(exceptions.size).to eq(1) - expect(exceptions.first).to be_a(PaymentIngestions::ConfirmationError) - expect(exceptions.first.message).to match(/Already confirmed/) + expect(results.size).to eq(2) + expect(results.first).to be_a(Receipt) + expect(results.last).to be_a(Receipt) + expect(results.first.id).to eq(results.last.id) expect(ingestion.reload.status).to eq("confirmed") end end @@ -357,4 +346,89 @@ expect(ingestion.accounting_user).to eq(user) end end + + describe "immutability and undeletability after confirmation" do + let(:user) { create(:user) } + let(:party) { create(:party, user: user) } + let(:property) { create(:property, user: user) } + let(:unit) { create(:rentable_unit, property: property) } + let(:tenancy) { create(:tenancy, rentable_unit: unit) } + let!(:confirmed_ingestion) do + create(:payment_ingestion, + user: user, + source: "pdf_upload", + status: "confirmed", + party: party, + tenancy: tenancy, + amount: 1500.0, + payment_date: Date.new(2026, 1, 15), + payment_method: "zelle", + transaction_number: "ZEL12345" + ) + end + + it "prevents updating party_id on confirmed ingestion" do + other_party = create(:party, user: user) + confirmed_ingestion.party = other_party + expect(confirmed_ingestion).not_to be_valid + expect(confirmed_ingestion.errors[:base]).to include("Cannot modify a confirmed payment ingestion") + end + + it "prevents updating amount on confirmed ingestion" do + confirmed_ingestion.amount = 2000.0 + expect(confirmed_ingestion).not_to be_valid + expect(confirmed_ingestion.errors[:base]).to include("Cannot modify a confirmed payment ingestion") + end + + it "prevents updating payment_date on confirmed ingestion" do + confirmed_ingestion.payment_date = Date.new(2026, 2, 1) + expect(confirmed_ingestion).not_to be_valid + expect(confirmed_ingestion.errors[:base]).to include("Cannot modify a confirmed payment ingestion") + end + + it "prevents updating payment_method on confirmed ingestion" do + confirmed_ingestion.payment_method = "venmo" + expect(confirmed_ingestion).not_to be_valid + expect(confirmed_ingestion.errors[:base]).to include("Cannot modify a confirmed payment ingestion") + end + + it "prevents updating transaction_number on confirmed ingestion" do + confirmed_ingestion.transaction_number = "NEW_TXN" + expect(confirmed_ingestion).not_to be_valid + expect(confirmed_ingestion.errors[:base]).to include("Cannot modify a confirmed payment ingestion") + end + + it "prevents changing status from confirmed to matched" do + confirmed_ingestion.status = :matched + expect(confirmed_ingestion).not_to be_valid + expect(confirmed_ingestion.errors[:base]).to include("Cannot modify a confirmed payment ingestion") + end + + it "prevents updating raw_text on confirmed ingestion" do + confirmed_ingestion.raw_text = "new parsed text" + expect(confirmed_ingestion).not_to be_valid + expect(confirmed_ingestion.errors[:base]).to include("Cannot modify a confirmed payment ingestion") + end + + it "prevents updating payer_name on confirmed ingestion" do + confirmed_ingestion.payer_name = "New Payer" + expect(confirmed_ingestion).not_to be_valid + expect(confirmed_ingestion.errors[:base]).to include("Cannot modify a confirmed payment ingestion") + end + + it "prevents updating payment_document on confirmed ingestion" do + other_doc = create(:payment_document, user: user) + confirmed_ingestion.payment_document = other_doc + expect(confirmed_ingestion).not_to be_valid + expect(confirmed_ingestion.errors[:base]).to include("Cannot modify a confirmed payment ingestion") + end + + it "prevents destroying a confirmed ingestion" do + expect { + confirmed_ingestion.destroy + }.not_to change(PaymentIngestion, :count) + + expect(confirmed_ingestion.errors[:base]).to include("Cannot delete a confirmed payment ingestion") + end + end end diff --git a/spec/models/property_spec.rb b/spec/models/property_spec.rb index f007f1fd..f4929de4 100644 --- a/spec/models/property_spec.rb +++ b/spec/models/property_spec.rb @@ -7,7 +7,7 @@ it { is_expected.to have_many(:tenancies).through(:rentable_units) } it { is_expected.to have_many(:expenses).dependent(:restrict_with_error) } it { is_expected.to have_many(:charges).through(:tenancies) } - it { is_expected.to have_many(:tenant_payments).through(:tenancies) } + it { is_expected.to have_many(:receipts).through(:tenancies) } it { is_expected.to have_many(:accounting_postings).class_name("Posting").dependent(:restrict_with_error) } end diff --git a/spec/models/receipt_spec.rb b/spec/models/receipt_spec.rb new file mode 100644 index 00000000..4a664d32 --- /dev/null +++ b/spec/models/receipt_spec.rb @@ -0,0 +1,276 @@ +require "rails_helper" + +RSpec.describe Receipt, 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(:payer_party) { create(:party, user: user) } + + subject(:receipt) do + build(:receipt, + tenancy: tenancy, + user: user, + payer_party: payer_party, + amount_cents: 100_000, + received_on: Date.current, + payment_method: "zelle", + external_reference: "ZELLE123", + memo: "January Rent" + ) + end + + describe "associations" do + it { is_expected.to belong_to(:user) } + it { is_expected.to belong_to(:tenancy) } + it { is_expected.to belong_to(:payer_party).class_name("Party") } + it { is_expected.to belong_to(:superseded_by).class_name("Receipt").optional } + it { is_expected.to have_one(:superseded_receipt).class_name("Receipt").with_foreign_key(:superseded_by_id) } + it { is_expected.to have_many(:journal_entries).dependent(:restrict_with_error) } + end + + describe "validations" do + it { is_expected.to be_valid } + it { is_expected.to validate_presence_of(:received_on) } + it { is_expected.to validate_presence_of(:payment_method) } + + it "requires amount_cents to be strictly positive" do + receipt.amount_cents = 0 + expect(receipt).not_to be_valid + expect(receipt.errors[:amount_cents]).to include("must be greater than 0") + + receipt.amount_cents = -100 + expect(receipt).not_to be_valid + end + + it "validates that tenancy belongs to the same user" do + other_user = create(:user) + other_property = create(:property, user: other_user) + other_unit = create(:rentable_unit, property: other_property) + other_tenancy = create(:tenancy, rentable_unit: other_unit) + + receipt.tenancy = other_tenancy + expect(receipt).not_to be_valid + expect(receipt.errors[:tenancy]).to include("must belong to the receipt owner") + end + + it "validates that payer party belongs to the same user" do + other_user = create(:user) + other_party = create(:party, user: other_user) + + receipt.payer_party = other_party + expect(receipt).not_to be_valid + expect(receipt.errors[:payer_party]).to include("must belong to the receipt owner") + end + + it "allows payer party who is not a participant in the tenancy" do + external_party = create(:party, user: user, display_name: "Parent / Employer") + receipt.payer_party = external_party + expect(receipt).to be_valid + end + end + + describe "normalizations" do + it "downcases and strips payment_method" do + r = create(:receipt, tenancy: tenancy, payer_party: payer_party, payment_method: " ZelLE ") + expect(r.payment_method).to eq("zelle") + end + + it "strips external_reference and converts blank to nil" do + r = create(:receipt, tenancy: tenancy, payer_party: payer_party, external_reference: " REF123 ") + expect(r.external_reference).to eq("REF123") + + r2 = create(:receipt, tenancy: tenancy, payer_party: payer_party, external_reference: " ") + expect(r2.external_reference).to be_nil + end + end + + describe "external reference uniqueness" do + it "prevents duplicate active external references for the same user and method" do + create(:receipt, + user: user, + tenancy: tenancy, + payer_party: payer_party, + payment_method: "zelle", + external_reference: "TXN100" + ) + + dup = build(:receipt, + user: user, + tenancy: tenancy, + payer_party: payer_party, + payment_method: "zelle", + external_reference: "TXN100" + ) + + expect(dup).not_to be_valid + expect(dup.errors[:external_reference]).to include("has already been taken") + end + + it "allows same external reference for different payment methods" do + create(:receipt, + user: user, + tenancy: tenancy, + payer_party: payer_party, + payment_method: "zelle", + external_reference: "TXN100" + ) + + diff_method = build(:receipt, + user: user, + tenancy: tenancy, + payer_party: payer_party, + payment_method: "venmo", + external_reference: "TXN100" + ) + + expect(diff_method).to be_valid + end + + it "allows same external reference for different users" do + create(:receipt, + user: user, + tenancy: tenancy, + payer_party: payer_party, + payment_method: "zelle", + external_reference: "TXN100" + ) + + other_user = create(:user) + other_prop = create(:property, user: other_user) + other_unit = create(:rentable_unit, property: other_prop) + other_tenancy = create(:tenancy, rentable_unit: other_unit) + other_party = create(:party, user: other_user) + + other_receipt = build(:receipt, + user: other_user, + tenancy: other_tenancy, + payer_party: other_party, + payment_method: "zelle", + external_reference: "TXN100" + ) + + expect(other_receipt).to be_valid + end + + it "allows reusing external reference when prior receipt is voided" do + r1 = create(:receipt, + user: user, + tenancy: tenancy, + payer_party: payer_party, + payment_method: "zelle", + external_reference: "TXN100" + ) + r1.update_columns(voided_at: Time.current) + + r2 = build(:receipt, + user: user, + tenancy: tenancy, + payer_party: payer_party, + payment_method: "zelle", + external_reference: "TXN100" + ) + + expect(r2).to be_valid + end + end + + describe "amount helpers and fractional cent validation" do + it "returns decimal amount from amount_cents" do + receipt.amount_cents = 125_050 + expect(receipt.amount).to eq(BigDecimal("1250.50")) + end + + it "accepts valid amount assignments" do + receipt.amount = "250.75" + expect(receipt.amount_cents).to eq(25_075) + + receipt.amount = 300 + expect(receipt.amount_cents).to eq(30_000) + end + + it "rejects fractional cent assignments without silent rounding" do + receipt.amount = "100.005" + expect(receipt).not_to be_valid + expect(receipt.errors[:amount]).to include("cannot have fractional cents") + end + + it "handles malformed amount strings gracefully" do + receipt.amount = "not-a-number" + expect(receipt).not_to be_valid + expect(receipt.errors[:amount]).to include("is not a valid number") + end + end + + describe "lifecycle methods and immutability" do + let!(:posted_receipt) do + r = create(:receipt, tenancy: tenancy, payer_party: payer_party, amount_cents: 100_000) + r.update_columns(posted_at: Time.current) + r + end + + it "correctly reports posted?, voided?, superseded?, active?" do + expect(receipt.posted?).to be false + expect(receipt.active?).to be true + + expect(posted_receipt.posted?).to be true + expect(posted_receipt.voided?).to be false + expect(posted_receipt.active?).to be true + + posted_receipt.update_columns(voided_at: Time.current) + expect(posted_receipt.voided?).to be true + expect(posted_receipt.active?).to be false + end + + it "rejects changes to financial and metadata fields once posted" do + posted_receipt.amount_cents = 200_000 + expect(posted_receipt).not_to be_valid + expect(posted_receipt.errors[:base]).to include("Posted receipts are immutable records") + + posted_receipt.reload + posted_receipt.received_on = 1.week.ago.to_date + expect(posted_receipt).not_to be_valid + + posted_receipt.reload + posted_receipt.payment_method = "cash" + expect(posted_receipt).not_to be_valid + end + + it "rejects direct ActiveRecord updates to voided_at, posted_at, and superseded_by_id" do + posted_receipt.voided_at = Time.current + expect(posted_receipt).not_to be_valid + expect(posted_receipt.errors[:voided_at]).to include("cannot be modified directly; use Receipts::VoidService or Receipts::CorrectService") + + posted_receipt.reload + posted_receipt.posted_at = nil + expect(posted_receipt).not_to be_valid + expect(posted_receipt.errors[:posted_at]).to include("cannot be modified directly once posted") + + posted_receipt.reload + posted_receipt.superseded_by = create(:receipt, tenancy: tenancy, payer_party: payer_party) + expect(posted_receipt).not_to be_valid + expect(posted_receipt.errors[:superseded_by_id]).to include("cannot be modified directly; use Receipts::CorrectService") + end + + it "prevents deletion of posted receipts" do + expect { + posted_receipt.destroy + }.not_to change(Receipt, :count) + + expect(posted_receipt.errors[:base]).to include("Cannot delete a posted receipt") + end + + it "validates that superseded_by belongs to the same user" do + other_user = create(:user) + other_party = create(:party, user: other_user) + other_prop = create(:property, user: other_user) + other_unit = create(:rentable_unit, property: other_prop) + other_tenancy = create(:tenancy, rentable_unit: other_unit) + other_receipt = create(:receipt, user: other_user, tenancy: other_tenancy, payer_party: other_party) + + unposted_receipt = build(:receipt, user: user, tenancy: tenancy, payer_party: payer_party, superseded_by: other_receipt) + expect(unposted_receipt).not_to be_valid + expect(unposted_receipt.errors[:superseded_by]).to include("must belong to the same user") + end + end +end diff --git a/spec/models/tenancy_spec.rb b/spec/models/tenancy_spec.rb index 1ac79305..4a04a25b 100644 --- a/spec/models/tenancy_spec.rb +++ b/spec/models/tenancy_spec.rb @@ -8,7 +8,7 @@ it { is_expected.to have_many(:parties).through(:tenancy_parties) } it { is_expected.to have_many(:rent_terms).dependent(:destroy) } it { is_expected.to have_many(:charges).dependent(:restrict_with_error) } - it { is_expected.to have_many(:tenant_payments).dependent(:restrict_with_error) } + it { is_expected.to have_many(:receipts).dependent(:restrict_with_error) } it { is_expected.to have_many(:accounting_postings).class_name("Posting").dependent(:restrict_with_error) } it { is_expected.to have_many(:payment_ingestions).dependent(:nullify) } end @@ -222,7 +222,9 @@ end describe "#financial_history? and current_balance" do + let(:party) { create(:party, user: unit.property.user) } let(:tenancy) { create(:tenancy, rentable_unit: unit, commencement_date: Date.current.beginning_of_month) } + let!(:tenancy_party) { create(:tenancy_party, tenancy: tenancy, party: party, role: "tenant") } let!(:rent_term) do create(:rent_term, tenancy: tenancy, @@ -234,7 +236,13 @@ it "identifies presence of financial history" do expect(tenancy.financial_history?).to be false - TenantPayments::CreateService.call(tenancy: tenancy, amount: 1500.0, payment_date: Date.current) + Receipts::CreateService.call( + tenancy: tenancy, + payer_party: party, + amount: 1500.0, + received_on: Date.current, + payment_method: "other" + ) expect(tenancy.financial_history?).to be true end @@ -248,7 +256,13 @@ service_period_start: Date.current.beginning_of_month, service_period_end: Date.current.end_of_month ) - TenantPayments::CreateService.call(tenancy: tenancy, amount: 1000.0, payment_date: Date.current) + Receipts::CreateService.call( + tenancy: tenancy, + payer_party: party, + amount: 1000.0, + received_on: Date.current, + payment_method: "other" + ) # 1200 - 1000 = 200 owed expect(tenancy.current_balance).to eq(200.0) diff --git a/spec/models/tenant_payment_spec.rb b/spec/models/tenant_payment_spec.rb deleted file mode 100644 index e2751bc2..00000000 --- a/spec/models/tenant_payment_spec.rb +++ /dev/null @@ -1,124 +0,0 @@ -require "rails_helper" - -RSpec.describe TenantPayment, type: :model do - describe "associations" do - it { is_expected.to belong_to(:tenancy) } - it { is_expected.to belong_to(:user) } - end - - describe "validations" do - it { is_expected.to validate_presence_of(:amount) } - it { is_expected.to validate_numericality_of(:amount).is_greater_than(0) } - it { is_expected.to validate_presence_of(:payment_date) } - it { is_expected.to validate_presence_of(:payment_method) } - - describe "transaction number validation" do - it { is_expected.to allow_value("TXN-123_abc").for(:transaction_number) } - it { is_expected.not_to allow_value("TXN 123!").for(:transaction_number).with_message("must be alphanumeric with dashes or underscores") } - it { is_expected.to validate_length_of(:transaction_number).is_at_most(50) } - end - - describe "uniqueness and user scoping" do - let(:user_one) { create(:user) } - let(:user_two) { create(:user) } - let(:property_one) { create(:property, user: user_one) } - let(:property_two) { create(:property, user: user_two) } - let(:unit_one) { create(:rentable_unit, property: property_one) } - let(:unit_two) { create(:rentable_unit, property: property_two) } - let(:tenancy_one) { create(:tenancy, rentable_unit: unit_one) } - let(:tenancy_two) { create(:tenancy, rentable_unit: unit_two) } - - it "assigns user from tenancy on validation" do - tp = build(:tenant_payment, tenancy: tenancy_one, user: nil) - expect(tp).to be_valid - expect(tp.user).to eq(user_one) - end - - it "validates that user matches tenancy owner" do - tp = build(:tenant_payment, tenancy: tenancy_one, user: user_two) - expect(tp).not_to be_valid - expect(tp.errors[:user]).to include("must match the tenancy owner") - end - - it "allows same transaction number for different users" do - create(:tenant_payment, tenancy: tenancy_one, amount: 500, payment_method: "zelle", transaction_number: "SHARED123") - payment = build(:tenant_payment, tenancy: tenancy_two, amount: 500, payment_method: "zelle", transaction_number: "SHARED123") - expect(payment).to be_valid - end - - it "rejects duplicate transaction number for same user and payment method" do - create(:tenant_payment, tenancy: tenancy_one, amount: 500, payment_method: "zelle", transaction_number: "DUPLICATE123") - payment = build(:tenant_payment, tenancy: tenancy_one, amount: 500, payment_method: "zelle", transaction_number: "DUPLICATE123") - expect(payment).not_to be_valid - expect(payment.errors[:transaction_number]).to include("has already been taken") - end - - it "allows same transaction number for same user but different payment method" do - create(:tenant_payment, tenancy: tenancy_one, amount: 500, payment_method: "zelle", transaction_number: "SHARED123") - payment = build(:tenant_payment, tenancy: tenancy_one, amount: 500, payment_method: "check", transaction_number: "SHARED123") - expect(payment).to be_valid - end - - it "returns early from owner validation if user or tenancy is missing" do - tp = TenantPayment.new(user: nil, tenancy: nil) - tp.valid? - expect(tp.errors[:user]).not_to include("must match the tenancy owner") - end - - it "returns early from owner validation if user is present but tenancy is missing" do - tp = TenantPayment.new(user: user_one, tenancy: nil) - tp.valid? - expect(tp.errors[:user]).not_to include("must match the tenancy owner") - end - - it "handles assign_user_from_tenancy when tenancy has no property" do - orphan_tenancy = build(:tenancy, rentable_unit: nil) - tp = TenantPayment.new(tenancy: orphan_tenancy, user: nil) - tp.valid? - expect(tp.user).to be_nil - end - end - end - - describe "#accounting_user" 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 "returns the user attribute when present" do - payment = build(:tenant_payment, tenancy: tenancy, user: user) - expect(payment.accounting_user).to eq(user) - end - - it "returns tenancy owner when user is nil" do - payment = build(:tenant_payment, tenancy: tenancy, user: nil) - expect(payment.accounting_user).to eq(user) - end - - it "returns nil when user and tenancy are nil" do - payment = build(:tenant_payment, tenancy: nil, user: nil) - expect(payment.accounting_user).to be_nil - end - end - - describe "immutability" 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(:payment) { create(:tenant_payment, tenancy: tenancy, amount: 500) } - - it "prevents updating attributes on persisted payment" do - payment.amount = 600 - expect(payment.save).to be(false) - expect(payment.errors[:base]).to include("Tenant payments are immutable once recorded.") - end - - it "prevents destroying persisted payment" do - expect(payment.destroy).to be(false) - expect(payment.errors[:base]).to include("Tenant payments cannot be destroyed once recorded.") - expect(TenantPayment.exists?(payment.id)).to be(true) - end - end -end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 2d98eb78..1bea0478 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -8,7 +8,7 @@ it { is_expected.to have_many(:tenancies).through(:rentable_units) } it { is_expected.to have_many(:expenses).through(:properties) } it { is_expected.to have_many(:charges).through(:tenancies) } - it { is_expected.to have_many(:tenant_payments).through(:tenancies) } + it { is_expected.to have_many(:receipts).dependent(:restrict_with_error) } it { is_expected.to have_many(:parties).dependent(:destroy) } it { is_expected.to have_many(:payment_ingestions).dependent(:destroy) } it { is_expected.to have_many(:payment_documents).dependent(:destroy) } diff --git a/spec/queries/dashboards/property_summaries_query_spec.rb b/spec/queries/dashboards/property_summaries_query_spec.rb index ad287cfd..79bae362 100644 --- a/spec/queries/dashboards/property_summaries_query_spec.rb +++ b/spec/queries/dashboards/property_summaries_query_spec.rb @@ -4,7 +4,9 @@ let(:user) { create(:user) } let(:property) { create(:property, user: user) } let(:unit) { create(:rentable_unit, property: property) } + let(:party) { create(:party, user: user) } let(:tenancy) { create(:tenancy, rentable_unit: unit, commencement_date: Date.current.beginning_of_month, termination_date: Date.current.end_of_month) } + let!(:tenancy_party) { create(:tenancy_party, tenancy: tenancy, party: party, role: "tenant") } let!(:rent_term) do create(:rent_term, tenancy: tenancy, @@ -15,7 +17,13 @@ end it "returns property financial summaries and active tenancy balances" do - TenantPayments::CreateService.call(tenancy: tenancy, amount: 1200, payment_date: Date.current) + Receipts::CreateService.call( + tenancy: tenancy, + payer_party: party, + amount: 1200, + received_on: Date.current, + payment_method: "other" + ) create(:expense, property: property, amount: 200, expense_date: Date.current) Charges::CreateService.call( tenancy: tenancy, diff --git a/spec/queries/properties/active_years_query_spec.rb b/spec/queries/properties/active_years_query_spec.rb index 79b93f8f..21a05258 100644 --- a/spec/queries/properties/active_years_query_spec.rb +++ b/spec/queries/properties/active_years_query_spec.rb @@ -8,7 +8,7 @@ it "returns current, activity, and valid additional years" do create(:charge, :other_charge, tenancy: tenancy, charge_date: Date.new(2026, 4, 1)) - create(:tenant_payment, tenancy: tenancy, payment_date: Date.new(2025, 4, 1)) + create(:receipt, tenancy: tenancy, received_on: Date.new(2025, 4, 1)) create(:expense, property: property, expense_date: Date.new(2024, 4, 1)) create(:charge, :late_fee_charge, tenancy: tenancy, charge_date: Date.new(2023, 4, 1)) diff --git a/spec/queries/properties/financial_items_query_spec.rb b/spec/queries/properties/financial_items_query_spec.rb index 94624f66..26598677 100644 --- a/spec/queries/properties/financial_items_query_spec.rb +++ b/spec/queries/properties/financial_items_query_spec.rb @@ -8,14 +8,14 @@ it "returns all yearly financial item types sorted by date" do charge = create(:charge, :other_charge, tenancy: tenancy, charge_date: Date.new(2026, 5, 1), amount_cents: 100_000) - tenant_payment = create(:tenant_payment, tenancy: tenancy, payment_date: Date.new(2026, 5, 5), amount: 1000) + receipt = create(:receipt, tenancy: tenancy, received_on: Date.new(2026, 5, 5), amount_cents: 100_000) expense = create(:expense, property: property, expense_date: Date.new(2026, 5, 10), amount: 50) reimburse_charge = create(:charge, :reimbursement_charge, tenancy: tenancy, source_expense: expense, charge_date: Date.new(2026, 5, 10), amount_cents: 5000) create(:expense, property: property, expense_date: Date.new(2025, 5, 10), amount: 30) items = described_class.new(property: property).call(year: 2026) - expect(items.map { |item| item[:object] }).to eq([ charge, tenant_payment, reimburse_charge, expense ]) - expect(items.map { |item| item[:type] }).to eq([ "Charge", "Tenant Payment", "Charge", "Expense" ]) + expect(items.map { |item| item[:object] }).to eq([ charge, receipt, reimburse_charge, expense ]) + expect(items.map { |item| item[:type] }).to eq([ "Charge", "Payment", "Charge", "Expense" ]) end end diff --git a/spec/queries/properties/schedule_e_summary_query_spec.rb b/spec/queries/properties/schedule_e_summary_query_spec.rb index 2438e9a2..c112d99b 100644 --- a/spec/queries/properties/schedule_e_summary_query_spec.rb +++ b/spec/queries/properties/schedule_e_summary_query_spec.rb @@ -7,7 +7,7 @@ let(:tenancy) { create(:tenancy, rentable_unit: unit) } it "computes Schedule E summary values for a year" do - create(:tenant_payment, tenancy: tenancy, payment_date: Date.new(2026, 1, 1), amount: 1200) + create(:receipt, tenancy: tenancy, received_on: Date.new(2026, 1, 1), amount_cents: 120_000) create(:expense, property: property, expense_date: Date.new(2026, 1, 2), category: "repairs", amount: 200) create(:expense, property: property, expense_date: Date.new(2026, 1, 3), category: "utilities", amount: 100) create(:expense, property: property, expense_date: Date.new(2025, 1, 3), category: "utilities", amount: 999) diff --git a/spec/queries/tenancies/balance_query_spec.rb b/spec/queries/tenancies/balance_query_spec.rb index 3f48cdc0..729a3f1f 100644 --- a/spec/queries/tenancies/balance_query_spec.rb +++ b/spec/queries/tenancies/balance_query_spec.rb @@ -4,6 +4,7 @@ let(:user) { create(:user) } let(:property) { create(:property, user: user) } let(:unit) { create(:rentable_unit, property: property) } + let(:party) { create(:party, user: user) } let(:tenancy) do create(:tenancy, rentable_unit: unit, @@ -11,6 +12,7 @@ termination_date: Date.new(2026, 12, 31) ) end + let!(:tenancy_party) { create(:tenancy_party, tenancy: tenancy, party: party, role: "tenant") } let!(:rent_term) do create(:rent_term, tenancy: tenancy, @@ -53,10 +55,12 @@ service_period_end: Date.new(2026, 1, 31) ) - TenantPayments::CreateService.call( + Receipts::CreateService.call( tenancy: tenancy, + payer_party: party, amount_cents: 150_000, - payment_date: Date.new(2026, 1, 5) + received_on: Date.new(2026, 1, 5), + payment_method: "other" ) query = described_class.new(tenancy: tenancy) @@ -65,10 +69,12 @@ end it "reflects negative balance on overpayment / credit balance" do - TenantPayments::CreateService.call( + Receipts::CreateService.call( tenancy: tenancy, + payer_party: party, amount_cents: 50_000, - payment_date: Date.new(2026, 1, 5) + received_on: Date.new(2026, 1, 5), + payment_method: "other" ) query = described_class.new(tenancy: tenancy) @@ -116,5 +122,17 @@ Charges::VoidService.call(charge: charge, occurred_on: Date.new(2026, 1, 12)) expect(described_class.call(tenancy: tenancy)).to eq(0) end + + it "supports current_balance and as_of in class call" do + query = described_class.new(tenancy: tenancy) + expect(query.current_balance).to eq(0) + expect(described_class.call(tenancy: tenancy, as_of: Date.yesterday)).to eq(0) + end + + it "returns 0 if tenant_receivable account is missing" do + allow(tenancy).to receive(:accounting_user).and_return(nil) + query = described_class.new(tenancy: tenancy) + expect(query.balance_cents_as_of).to eq(0) + end end end diff --git a/spec/requests/charges_spec.rb b/spec/requests/charges_spec.rb index 1e943b35..50e51b67 100644 --- a/spec/requests/charges_spec.rb +++ b/spec/requests/charges_spec.rb @@ -169,6 +169,24 @@ expect(charge.reload).to be_voided end + it "voids the charge via json" do + post void_charge_path(charge, format: :json), params: { reason: "Customer dispute" } + expect(response).to be_successful + expect(response.parsed_body["status"]).to eq("ok") + end + + it "handles void failure gracefully" do + allow(Charges::VoidService).to receive(:call).and_return( + ServiceResult.failure(error: "Void failed", code: :void_failed) + ) + post void_charge_path(charge) + expect(response).to redirect_to(tenancy_path(tenancy)) + expect(flash[:alert]).to include("Failed to void charge") + + post void_charge_path(charge, format: :json) + expect(response).to have_http_status(:unprocessable_content) + end + it "rejects voiding another user's charge" do other_charge = Charges::CreateFeeService.call( tenancy: other_tenancy, diff --git a/spec/requests/payment_documents_spec.rb b/spec/requests/payment_documents_spec.rb index 2d100947..d73582b4 100644 --- a/spec/requests/payment_documents_spec.rb +++ b/spec/requests/payment_documents_spec.rb @@ -15,5 +15,17 @@ expect(response).to redirect_to(payment_ingestions_path) end + + it "refuses to destroy document with confirmed ingestions and sets alert" do + document = create(:payment_document, user: user) + create(:payment_ingestion, user: user, payment_document: document, status: :confirmed) + + expect { + delete payment_document_url(document) + }.not_to change(PaymentDocument, :count) + + expect(response).to redirect_to(payment_ingestions_path) + expect(flash[:alert]).to include("Cannot delete document with confirmed payment ingestions") + end end end diff --git a/spec/requests/payment_ingestions_spec.rb b/spec/requests/payment_ingestions_spec.rb index 735028c3..323736c8 100644 --- a/spec/requests/payment_ingestions_spec.rb +++ b/spec/requests/payment_ingestions_spec.rb @@ -267,6 +267,17 @@ } expect(response).to redirect_to(payment_ingestion_url(ingestion)) end + it "rejects updating a confirmed payment ingestion" do + ingestion.update_columns(status: "confirmed") + + patch payment_ingestion_url(ingestion), params: { + payment_ingestion: { + amount: 2000.0 + } + } + expect(response).to have_http_status(:unprocessable_content) + expect(ingestion.reload.amount).to eq(1300.0) + end end describe "GET /payment_ingestions/:id/download" do @@ -288,12 +299,38 @@ it "confirms payment ingestion" do expect { post confirm_payment_ingestion_url(ingestion), params: { create_alias: "0" } - }.to change(TenantPayment, :count).by(1) + }.to change(Receipt, :count).by(1) expect(response).to redirect_to(payment_ingestions_url) expect(ingestion.reload.status).to eq("confirmed") end + it "allows updating and confirming ingestion with a non-tenancy-member payer party" do + third_party_payer = create(:party, user: user, display_name: "ACME Corp") + # Note: third_party_payer is NOT a TenancyParty on tenancy + + patch payment_ingestion_url(ingestion), params: { + payment_ingestion: { + party_id: third_party_payer.id, + tenancy_id: tenancy.id, + amount: 1500.0, + payment_date: Date.current, + payment_method: "zelle" + } + } + expect(response).to redirect_to(payment_ingestion_url(ingestion)) + expect(ingestion.reload.party).to eq(third_party_payer) + + expect { + post confirm_payment_ingestion_url(ingestion), params: { create_alias: "0" } + }.to change(Receipt, :count).by(1) + + receipt = Receipt.last + expect(receipt.payer_party).to eq(third_party_payer) + expect(receipt.tenancy).to eq(tenancy) + expect(receipt.amount_cents).to eq(150_000) + end + it "handles ConfirmationError during confirm" do allow(PaymentIngestions::ConfirmService).to receive(:call).and_return( ServiceResult.failure(error: "custom confirmation error", code: :confirmation_error) @@ -319,6 +356,17 @@ expect(response).to redirect_to(payment_ingestions_url) end + + it "rejects deleting a confirmed payment ingestion" do + ingestion.update_columns(status: "confirmed") + + expect { + delete payment_ingestion_url(ingestion) + }.not_to change(PaymentIngestion, :count) + + expect(response).to redirect_to(payment_ingestion_path(ingestion)) + expect(flash[:alert]).to include("Cannot delete a confirmed payment ingestion") + end end describe "pagination" do diff --git a/spec/requests/property_lifecycle_spec.rb b/spec/requests/property_lifecycle_spec.rb index 2feb4c82..9d2da89e 100644 --- a/spec/requests/property_lifecycle_spec.rb +++ b/spec/requests/property_lifecycle_spec.rb @@ -43,20 +43,21 @@ tenancy = Tenancy.last expect(response).to redirect_to(tenancy_url(tenancy)) - # 3. Record a Tenant Payment for the tenancy + # 3. Record a Payment for the tenancy expect { - post tenant_payments_url, params: { - tenant_payment: { + post receipts_url, params: { + receipt: { tenancy_id: tenancy.id, + payer_party_id: party.id, amount: 2000, - payment_date: Date.new(2025, 1, 1), + received_on: Date.new(2025, 1, 1), payment_method: "check" } } - }.to change(TenantPayment, :count).by(1) + }.to change(Receipt, :count).by(1) - payment = TenantPayment.last - expect(response).to redirect_to(tenant_payment_url(payment)) - expect(payment.tenancy).to eq(tenancy) + receipt = Receipt.last + expect(response).to redirect_to(receipt_url(receipt)) + expect(receipt.tenancy).to eq(tenancy) end end diff --git a/spec/requests/receipts_spec.rb b/spec/requests/receipts_spec.rb new file mode 100644 index 00000000..e45c2981 --- /dev/null +++ b/spec/requests/receipts_spec.rb @@ -0,0 +1,393 @@ +require "rails_helper" + +RSpec.describe "Receipts", type: :request do + let(:user) { create(:user) } + let(:other_user) { create(:user) } + let(:property) { create(:property, user: user) } + let(:unit) { create(:rentable_unit, property: property) } + let(:tenancy) { create(:tenancy, rentable_unit: unit, commencement_date: Date.new(2026, 1, 1)) } + let(:party) { create(:party, user: user, display_name: "Alice Walker") } + let!(:tenancy_party) { create(:tenancy_party, tenancy: tenancy, party: party, role: "tenant") } + + before do + sign_in_as(user) + end + + describe "GET /receipts" do + it "renders a successful list of user's receipts" do + create(:receipt, tenancy: tenancy, payer_party: party, user: user, amount_cents: 100_000) + get receipts_url + expect(response).to be_successful + expect(response.body).to include("Payments & Receipts") + expect(response.body).to include("Alice Walker") + end + + it "does not display other user's receipts" do + other_party = create(:party, user: other_user, display_name: "Other Payer") + other_property = create(:property, user: other_user) + other_unit = create(:rentable_unit, property: other_property) + other_tenancy = create(:tenancy, rentable_unit: other_unit) + create(:receipt, tenancy: other_tenancy, payer_party: other_party, user: other_user) + + get receipts_url + expect(response).to be_successful + expect(response.body).not_to include("Other Payer") + end + end + + describe "GET /receipts/:id" do + let!(:receipt) do + res = Receipts::CreateService.call( + tenancy: tenancy, + payer_party: party, + amount_cents: 120_000, + received_on: Date.new(2026, 1, 10), + payment_method: "zelle", + external_reference: "ZEL123", + memo: "January Rent" + ) + res.value!.data[:receipt] + end + + it "renders HTML details" do + get receipt_url(receipt) + expect(response).to be_successful + expect(response.body).to include("Payment Details") + expect(response.body).to include("$1,200.00") + expect(response.body).to include("Alice Walker") + expect(response.body).to include("ZEL123") + end + + it "renders PDF receipt" do + get receipt_url(receipt, format: :pdf) + expect(response).to be_successful + expect(response.content_type).to eq("application/pdf") + expect(response.body).to start_with("%PDF-") + end + + it "returns 404 for other user's receipt" do + other_property = create(:property, user: other_user) + other_unit = create(:rentable_unit, property: other_property) + other_tenancy = create(:tenancy, rentable_unit: other_unit) + other_payer = create(:party, user: other_user) + other_receipt = create(:receipt, user: other_user, tenancy: other_tenancy, payer_party: other_payer) + + get receipt_url(other_receipt) + expect(response).to have_http_status(:not_found) + end + end + + describe "GET /receipts/new and GET /tenancies/:id/receipts/new" do + it "renders top-level new receipt page" do + get new_receipt_url + expect(response).to be_successful + expect(response.body).to include("Record Payment") + end + + it "renders nested new receipt page preselecting single active tenant" do + get new_tenancy_receipt_url(tenancy) + expect(response).to be_successful + expect(response.body).to include("Alice Walker") + expect(response.body).to include(tenancy.property.address) + end + end + + describe "POST /receipts and POST /tenancies/:id/receipts" do + it "creates receipt via top-level route" do + expect { + post receipts_url, params: { + receipt: { + tenancy_id: tenancy.id, + payer_party_id: party.id, + amount: "1500.00", + received_on: "2026-02-01", + payment_method: "check", + external_reference: "CHK99", + memo: "Feb Rent" + } + } + }.to change(Receipt, :count).by(1) + .and change(JournalEntry, :count).by(1) + + created = Receipt.last + expect(response).to redirect_to(receipt_path(created)) + expect(flash[:notice]).to eq("Payment recorded successfully.") + expect(created.amount_cents).to eq(150_000) + end + + it "creates receipt via nested tenancy route" do + expect { + post tenancy_receipts_url(tenancy), params: { + receipt: { + payer_party_id: party.id, + amount: "1200.00", + received_on: "2026-02-01", + payment_method: "zelle" + } + } + }.to change(Receipt, :count).by(1) + + expect(response).to redirect_to(receipt_path(Receipt.last)) + end + + it "rejects nested creation with mismatched tenancy_id in body" do + other_unit = create(:rentable_unit, property: property) + other_tenancy = create(:tenancy, rentable_unit: other_unit) + + expect { + post tenancy_receipts_url(tenancy), params: { + receipt: { + tenancy_id: other_tenancy.id, + payer_party_id: party.id, + amount: "1200.00", + received_on: "2026-02-01", + payment_method: "zelle" + } + } + }.not_to change(Receipt, :count) + + expect(response).to have_http_status(:unprocessable_content) + expect(response.body).to include("Submitted tenancy does not match route tenancy") + end + + it "returns 404 and creates no receipt when nested route tenancy does not exist" do + expect { + post "/tenancies/999999/receipts", params: { + receipt: { + tenancy_id: tenancy.id, + payer_party_id: party.id, + amount: "1200.00", + received_on: "2026-02-01", + payment_method: "zelle" + } + } + }.not_to change(Receipt, :count) + + expect(response).to have_http_status(:not_found) + end + + it "returns 404 and creates no receipt when nested route tenancy belongs to another user" do + other_prop = create(:property, user: other_user) + other_unit = create(:rentable_unit, property: other_prop) + other_tenancy = create(:tenancy, rentable_unit: other_unit) + + expect { + post tenancy_receipts_url(other_tenancy), params: { + receipt: { + tenancy_id: tenancy.id, + payer_party_id: party.id, + amount: "1200.00", + received_on: "2026-02-01", + payment_method: "zelle" + } + } + }.not_to change(Receipt, :count) + + expect(response).to have_http_status(:not_found) + end + + it "creates receipt via turbo_stream format" do + post tenancy_receipts_url(tenancy, format: :turbo_stream), params: { + receipt: { + payer_party_id: party.id, + amount: "1200.00", + received_on: "2026-02-01", + payment_method: "zelle" + } + } + expect(response).to be_successful + expect(response.media_type).to eq("text/vnd.turbo-stream.html") + end + + it "renders turbo_stream form on error" do + post tenancy_receipts_url(tenancy, format: :turbo_stream), params: { + receipt: { + payer_party_id: party.id, + amount: "-50.00", + received_on: "2026-02-01", + payment_method: "zelle" + } + } + expect(response).to have_http_status(:unprocessable_content) + expect(response.media_type).to eq("text/vnd.turbo-stream.html") + end + + it "renders unprocessable_content on invalid parameters" do + expect { + post receipts_url, params: { + receipt: { + tenancy_id: tenancy.id, + payer_party_id: party.id, + amount: "0", + received_on: "2026-02-01", + payment_method: "zelle" + } + } + }.not_to change(Receipt, :count) + + expect(response).to have_http_status(:unprocessable_content) + expect(response.body).to include("Amount must be greater than 0") + end + + it "rejects create with unresolvable or foreign payer_party_id" do + other_user_party = create(:party, user: other_user) + post receipts_url, params: { + receipt: { + tenancy_id: tenancy.id, + payer_party_id: other_user_party.id, + amount: "100.00", + received_on: "2026-02-01", + payment_method: "zelle" + } + } + expect(response).to have_http_status(:unprocessable_content) + expect(response.body).to include("Payer party was not found") + end + + it "rejects create with unresolvable tenancy_id" do + post receipts_url, params: { + receipt: { + tenancy_id: 999_999, + payer_party_id: party.id, + amount: "100.00", + received_on: "2026-02-01", + payment_method: "zelle" + } + } + expect(response).to have_http_status(:unprocessable_content) + expect(response.body).to include("Tenancy was not found") + end + end + + describe "GET /receipts/:id/correction and POST /receipts/:id/correct" do + let!(:receipt) do + res = Receipts::CreateService.call( + tenancy: tenancy, + payer_party: party, + amount_cents: 200_000, + received_on: Date.new(2026, 1, 10), + payment_method: "zelle", + external_reference: "ZEL100" + ) + res.value!.data[:receipt] + end + + it "renders the correction form" do + get correction_receipt_url(receipt) + expect(response).to be_successful + expect(response.body).to include("Correct Payment") + expect(response.body).to include("ZEL100") + end + + it "submits a correction and replaces the receipt" do + expect { + post correct_receipt_url(receipt), params: { + receipt: { + amount: "2100.00", + received_on: "2026-01-10", + payment_method: "zelle", + external_reference: "ZEL100" + } + } + }.to change(Receipt, :count).by(1) + + replacement = Receipt.last + expect(response).to redirect_to(receipt_path(replacement)) + expect(flash[:notice]).to include("Payment corrected successfully") + expect(receipt.reload.voided?).to be true + expect(receipt.superseded_by).to eq(replacement) + end + + it "is idempotent on repeated identical correction POST submissions" do + # First submission + post correct_receipt_url(receipt), params: { + receipt: { + amount: "2100.00", + received_on: "2026-01-10", + payment_method: "zelle", + external_reference: "ZEL100" + } + } + replacement = Receipt.last + expect(response).to redirect_to(receipt_path(replacement)) + + # Second identical submission (e.g. user double-clicked or refreshed) + expect { + post correct_receipt_url(receipt), params: { + receipt: { + amount: "2100.00", + received_on: "2026-01-10", + payment_method: "zelle", + external_reference: "ZEL100" + } + } + }.not_to change(Receipt, :count) + + expect(response).to redirect_to(receipt_path(replacement)) + end + + it "rejects correction with unresolvable or foreign payer_party_id" do + other_user_party = create(:party, user: other_user) + post correct_receipt_url(receipt), params: { + receipt: { + payer_party_id: other_user_party.id, + amount: "2100.00" + } + } + expect(response).to have_http_status(:unprocessable_content) + expect(response.body).to include("Payer party was not found") + end + + it "rejects correction with unresolvable tenancy_id" do + post correct_receipt_url(receipt), params: { + receipt: { + tenancy_id: 999_999, + amount: "2100.00" + } + } + expect(response).to have_http_status(:unprocessable_content) + expect(response.body).to include("Tenancy was not found") + end + + it "renders unprocessable_content on invalid correction" do + post correct_receipt_url(receipt), params: { + receipt: { + amount: "-100.00", + received_on: "2026-01-10", + payment_method: "zelle" + } + } + expect(response).to have_http_status(:unprocessable_content) + expect(response.body).to include("Amount must be greater than 0") + end + end + + describe "POST /receipts/:id/void" do + let!(:receipt) do + res = Receipts::CreateService.call( + tenancy: tenancy, + payer_party: party, + amount_cents: 100_000, + received_on: Date.new(2026, 1, 10), + payment_method: "zelle" + ) + res.value!.data[:receipt] + end + + it "voids the receipt" do + post void_receipt_url(receipt), params: { reason: "Mistaken entry" } + expect(response).to redirect_to(receipt_path(receipt)) + expect(flash[:notice]).to include("Payment has been voided") + expect(receipt.reload.voided?).to be true + end + + it "handles void failure gracefully" do + allow(Receipts::VoidService).to receive(:call).and_return( + ServiceResult.failure(error: "Cannot void locked receipt", code: :void_failed) + ) + post void_receipt_url(receipt) + expect(response).to redirect_to(receipt_path(receipt)) + expect(flash[:alert]).to eq("Cannot void locked receipt") + end + end +end diff --git a/spec/requests/tenancies_spec.rb b/spec/requests/tenancies_spec.rb index 3da4a344..f30518d6 100644 --- a/spec/requests/tenancies_spec.rb +++ b/spec/requests/tenancies_spec.rb @@ -248,7 +248,7 @@ end it "prevents deleting a tenancy with financial history (HTML & JSON)" do - create(:tenant_payment, tenancy: tenancy, amount: 1500.0, payment_date: Date.current) + create(:receipt, tenancy: tenancy, amount_cents: 150_000, received_on: Date.current) expect { delete tenancy_url(tenancy) @@ -256,7 +256,7 @@ expect(response).to redirect_to(tenancy_url(tenancy)) follow_redirect! - expect(response.body).to include("Cannot delete record because dependent tenant payments exist") + expect(response.body).to include("Cannot delete record because dependent receipts exist") delete tenancy_url(tenancy, format: :json) expect(response).to have_http_status(:unprocessable_content) diff --git a/spec/requests/tenant_payments_spec.rb b/spec/requests/tenant_payments_spec.rb deleted file mode 100644 index c8729a50..00000000 --- a/spec/requests/tenant_payments_spec.rb +++ /dev/null @@ -1,146 +0,0 @@ -require "rails_helper" - -RSpec.describe "TenantPayments", type: :request do - let(:user) { create(:user) } - let(:other_user) { create(:user) } - let(:property) { create(:property, user: user) } - let(:other_property) { create(:property, user: other_user) } - let(:unit) { create(:rentable_unit, property: property) } - let(:other_unit) { create(:rentable_unit, property: other_property) } - let(:tenancy) { create(:tenancy, rentable_unit: unit) } - let(:other_tenancy) { create(:tenancy, rentable_unit: other_unit) } - let!(:tenant_payment) { create(:tenant_payment, tenancy: tenancy) } - - before do - sign_in_as(user) - end - - describe "GET /tenant_payments" do - it "renders a successful response" do - get tenant_payments_url - expect(response).to be_successful - end - end - - describe "GET /tenant_payments/new" do - it "filters tenancies to only include the current user's" do - other_party = create(:party, user: other_user, display_name: "Other Tenant") - create(:tenancy_party, tenancy: other_tenancy, party: other_party, role: "tenant", effective_from: Date.current) - - get new_tenant_payment_url - expect(response).to be_successful - expect(response.body).not_to include(other_property.address) - expect(response.body).not_to include("Other Tenant") - end - - it "defaults payment amount to balance when balance is positive (tenant owes money)" do - allow_any_instance_of(Tenancy).to receive(:current_balance).and_return(600) - get new_tenant_payment_url, params: { tenancy_id: tenancy.id } - expect(response).to be_successful - expect(response.body).to include('value="600"') - end - - it "defaults payment amount to 0 when balance is negative (credit) or zero" do - allow_any_instance_of(Tenancy).to receive(:current_balance).and_return(-200) - get new_tenant_payment_url, params: { tenancy_id: tenancy.id } - expect(response).to be_successful - expect(response.body).to include('value="0.0"') - end - end - - describe "POST /tenant_payments" do - it "creates a new TenantPayment" do - expect { - post tenant_payments_url, params: { tenant_payment: { tenancy_id: tenancy.id, amount: 500, payment_date: Date.today, payment_method: "Zelle", transaction_number: "TXNTEST123" } } - }.to change(TenantPayment, :count).by(1) - - expect(response).to redirect_to(tenant_payment_url(TenantPayment.last)) - end - - it "should not create tenant payment with other user's tenancy" do - expect { - post tenant_payments_url, params: { tenant_payment: { tenancy_id: other_tenancy.id, amount: 500, payment_date: Date.today, payment_method: "Zelle", transaction_number: "TXNTEST456" } } - }.not_to change(TenantPayment, :count) - - expect(response).to have_http_status(:not_found) - end - - it "skips tenancy lookup if tenancy_id is not present" do - expect { - post tenant_payments_url, params: { tenant_payment: { tenancy_id: "", amount: 500, payment_date: Date.today, payment_method: "Zelle" } } - }.not_to change(TenantPayment, :count) - - expect(response).to have_http_status(:unprocessable_content) - end - - it "handles modal-submit success with turbo_stream" do - expect { - post tenant_payments_url, params: { - tenancy_id: tenancy.id, - tenant_payment: { tenancy_id: tenancy.id, amount: 500, payment_date: Date.today, payment_method: "Zelle" } - }, as: :turbo_stream - }.to change(TenantPayment, :count).by(1) - - expect(response).to have_http_status(:ok) - end - - it "handles modal-submit success with turbo_stream and blank payment_date" do - expect { - post tenant_payments_url, params: { - tenancy_id: tenancy.id, - tenant_payment: { tenancy_id: tenancy.id, amount: 500, payment_date: "", payment_method: "Zelle" } - }, as: :turbo_stream - }.not_to change(TenantPayment, :count) - - expect(response).to have_http_status(:ok) - end - - it "handles modal-submit success with turbo_stream when payment_date is nil but save succeeds (triggers fallback year)" do - tp_instance = TenantPayment.new(tenancy: tenancy, amount: 500, payment_method: "Zelle") - allow(tp_instance).to receive(:save).and_return(true) - allow(tp_instance).to receive(:payment_date).and_return(nil) - allow(TenantPayment).to receive(:new).and_return(tp_instance) - - post tenant_payments_url, params: { - tenancy_id: tenancy.id, - tenant_payment: { tenancy_id: tenancy.id, amount: 500, payment_method: "Zelle" } - }, as: :turbo_stream - - expect(response).to have_http_status(:ok) - expect(response.body).to include("turbo-stream") - end - end - - describe "GET /tenant_payments/:id" do - it "renders a successful HTML response" do - get tenant_payment_url(tenant_payment) - expect(response).to be_successful - end - - it "renders a successful PDF response" do - get tenant_payment_url(tenant_payment, format: :pdf) - expect(response).to be_successful - expect(response.content_type).to eq("application/pdf") - end - - it "renders a successful PDF response when transaction number is missing" do - tp_no_txn = create(:tenant_payment, tenancy: tenancy, transaction_number: nil) - get tenant_payment_url(tp_no_txn, format: :pdf) - expect(response).to be_successful - expect(response.content_type).to eq("application/pdf") - end - end - - describe "disallowed routes" do - it "does not route edit, update, or destroy" do - get "/tenant_payments/#{tenant_payment.id}/edit" - expect(response).to have_http_status(:not_found) - - patch "/tenant_payments/#{tenant_payment.id}", params: { tenant_payment: { amount: 600 } } - expect(response).to have_http_status(:not_found) - - delete "/tenant_payments/#{tenant_payment.id}" - expect(response).to have_http_status(:not_found) - end - end -end diff --git a/spec/services/charges/void_service_spec.rb b/spec/services/charges/void_service_spec.rb index 003236c9..1565f693 100644 --- a/spec/services/charges/void_service_spec.rb +++ b/spec/services/charges/void_service_spec.rb @@ -73,5 +73,27 @@ reversal = result.value!.data[:journal_entry] expect(reversal.occurred_on).to eq(Date.new(2026, 5, 10)) end + + it "returns failure for unpersisted charge" do + result = described_class.call(charge: Charge.new) + expect(result).to be_failure + expect(result.failure.code).to eq(:invalid_source) + end + + it "returns failure if journal entry is missing" do + allow(charge).to receive_message_chain(:journal_entries, :find_by).and_return(nil) + result = described_class.call(charge: charge) + expect(result).to be_failure + 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) + ) + result = described_class.call(charge: charge) + expect(result).to be_failure + expect(result.failure.code).to eq(:reverse_failed) + end end end diff --git a/spec/services/payment_documents/destroy_service_spec.rb b/spec/services/payment_documents/destroy_service_spec.rb new file mode 100644 index 00000000..f0f5bb77 --- /dev/null +++ b/spec/services/payment_documents/destroy_service_spec.rb @@ -0,0 +1,36 @@ +require "rails_helper" + +RSpec.describe PaymentDocuments::DestroyService do + let(:user) { create(:user) } + + it "destroys a document without confirmed ingestions" do + doc = create(:payment_document, user: user) + create(:payment_ingestion, user: user, payment_document: doc, status: :matched) + + expect { + result = described_class.call(user: user, document: doc) + expect(result).to be_success + }.to change(PaymentDocument, :count).by(-1).and change(PaymentIngestion, :count).by(-1) + end + + it "rejects destroying a document with confirmed ingestions" do + doc = create(:payment_document, user: user) + create(:payment_ingestion, user: user, payment_document: doc, status: :confirmed) + + expect { + result = described_class.call(user: user, document: doc) + expect(result).to be_failure + expect(result.failure.code).to eq(:immutable) + expect(result.failure.error).to eq("Cannot delete document with confirmed payment ingestions") + }.not_to change(PaymentDocument, :count) + end + + it "returns failure when document belongs to another user" do + other_user = create(:user) + doc = create(:payment_document, user: other_user) + + result = described_class.call(user: user, document: doc) + expect(result).to be_failure + expect(result.failure.code).to eq(:not_found) + end +end diff --git a/spec/services/payment_ingestions/confirm_service_spec.rb b/spec/services/payment_ingestions/confirm_service_spec.rb index d8ae1dfb..c60381d8 100644 --- a/spec/services/payment_ingestions/confirm_service_spec.rb +++ b/spec/services/payment_ingestions/confirm_service_spec.rb @@ -22,17 +22,19 @@ def build_ingestion(attributes = {}) }.merge(attributes)) end - it "creates a tenant payment and marks the ingestion confirmed" do + it "creates a receipt and marks the ingestion confirmed" do ingestion = build_ingestion(transaction_number: "TXNCONFIRM") expect { result = described_class.call(user: user, ingestion: ingestion) expect(result).to be_success - expect(result.value!.data).to be_a(TenantPayment) - }.to change(TenantPayment, :count).by(1) + expect(result.value!.data).to be_a(Receipt) + }.to change(Receipt, :count).by(1) + .and change(JournalEntry, :count).by(1) expect(ingestion.reload.status).to eq("confirmed") - expect(ingestion.tenant_payment.transaction_number).to eq("TXNCONFIRM") + expect(ingestion.receipt.external_reference).to eq("TXNCONFIRM") + expect(ingestion.receipt.payer_party).to eq(party) end it "returns failure when ingestion belongs to another user" do @@ -42,11 +44,17 @@ def build_ingestion(attributes = {}) expect(result.failure.code).to eq(:not_found) end - it "returns failure when ingestion is already confirmed" do - ingestion = build_ingestion(status: "confirmed") - result = described_class.call(user: user, ingestion: ingestion) - expect(result).to be_failure - expect(result.failure.code).to eq(:already_confirmed) + it "returns existing receipt when ingestion is already confirmed (idempotent)" do + ingestion = build_ingestion(transaction_number: "TXNIDEM") + res1 = described_class.call(user: user, ingestion: ingestion) + expect(res1).to be_success + first_receipt = res1.value!.data + + expect { + res2 = described_class.call(user: user, ingestion: ingestion) + expect(res2).to be_success + expect(res2.value!.data).to eq(first_receipt) + }.not_to change(Receipt, :count) end it "creates aliases only for candidate payer values" do @@ -63,7 +71,7 @@ def build_ingestion(attributes = {}) it "handles duplicate transaction number ActiveRecord::RecordNotUnique" do ingestion = build_ingestion(transaction_number: "DUPLICATETXN") - allow(TenantPayments::CreateService).to receive(:call).and_raise(ActiveRecord::RecordNotUnique) + allow(Receipts::CreateService).to receive(:call).and_raise(ActiveRecord::RecordNotUnique) result = described_class.call(user: user, ingestion: ingestion) expect(result).to be_failure @@ -72,9 +80,9 @@ def build_ingestion(attributes = {}) it "handles ActiveRecord::RecordInvalid" do ingestion = build_ingestion(transaction_number: "INVALIDTXN") - invalid_tp = TenantPayment.new - invalid_tp.errors.add(:amount, "is invalid") - allow(TenantPayments::CreateService).to receive(:call).and_raise(ActiveRecord::RecordInvalid.new(invalid_tp)) + invalid_r = Receipt.new + invalid_r.errors.add(:amount_cents, "is invalid") + allow(Receipts::CreateService).to receive(:call).and_raise(ActiveRecord::RecordInvalid.new(invalid_r)) result = described_class.call(user: user, ingestion: ingestion) expect(result).to be_failure @@ -90,7 +98,7 @@ def build_ingestion(attributes = {}) expect(result.failure.error).to eq("Cannot confirm: missing required fields or duplicate exists") end - it "prevents concurrent confirmation" do + it "handles concurrent confirmation idempotently" do ingestion = build_ingestion(transaction_number: "TXNRACE") results = [] @@ -102,9 +110,8 @@ def build_ingestion(attributes = {}) end end.each(&:join) - expect(results.count(&:success?)).to eq(1) - expect(results.count(&:failure?)).to eq(1) - expect(results.find(&:failure?).failure.error).to eq("Already confirmed") + expect(results.count(&:success?)).to eq(2) + expect(results.first.value!.data.id).to eq(results.last.value!.data.id) expect(ingestion.reload.status).to eq("confirmed") end @@ -116,11 +123,137 @@ def build_ingestion(attributes = {}) expect(result.failure.code).to eq(:confirmation_error) end - it "skips alias creation when party is nil" do - ingestion = build_ingestion(payer_name: "Test Payer") - allow(ingestion).to receive(:party).and_return(nil) - allow(ingestion).to receive(:confirmable?).and_return(true) - result = described_class.call(user: user, ingestion: ingestion, create_alias: true) - expect(result).to be_success + it "preserves original receipt reference on ingestion even after receipt correction" do + ingestion = build_ingestion(transaction_number: "TXNCORRECT") + confirm_res = described_class.call(user: user, ingestion: ingestion) + original_receipt = confirm_res.value!.data + + # Correct the receipt + correct_res = Receipts::CorrectService.call(receipt: original_receipt, amount_cents: 150_000) + expect(correct_res).to be_success + replacement_receipt = correct_res.value!.data[:receipt] + + expect(ingestion.reload.receipt).to eq(original_receipt) + expect(ingestion.receipt.superseded_by).to eq(replacement_receipt) + end + + describe "concurrency serialization" do + it "safely serializes concurrent confirm and update" do + ingestion = build_ingestion(amount: 1000.0, transaction_number: "TXNCONCUR1") + confirm_result = nil + update_result = nil + + t1 = Thread.new do + ActiveRecord::Base.connection_pool.with_connection do + confirm_result = described_class.call(user: user, ingestion: PaymentIngestion.find(ingestion.id)) + end + end + + t2 = Thread.new do + ActiveRecord::Base.connection_pool.with_connection do + update_result = PaymentIngestions::UpdateService.call( + user: user, + ingestion: PaymentIngestion.find(ingestion.id), + params: { amount: 2000.0 } + ) + end + end + + [ t1, t2 ].each(&:join) + + expect(ingestion.reload.status).to eq("confirmed") + expect(ingestion.receipt).to be_present + + if update_result.failure? + expect(update_result.failure.code).to eq(:immutable) + expect(ingestion.amount).to eq(1000.0) + expect(ingestion.receipt.amount).to eq(1000.0) + else + expect(update_result).to be_success + expect(ingestion.amount).to eq(2000.0) + expect(ingestion.receipt.amount).to eq(2000.0) + end + + # Invariant: Once confirmed, no further update can succeed + after_update = PaymentIngestions::UpdateService.call( + user: user, + ingestion: ingestion, + params: { amount: 3000.0 } + ) + expect(after_update).to be_failure + expect(after_update.failure.code).to eq(:immutable) + end + + it "safely serializes concurrent confirm and ingestion delete" do + ingestion = build_ingestion(transaction_number: "TXNCONCUR2") + confirm_result = nil + delete_result = nil + + t1 = Thread.new do + ActiveRecord::Base.connection_pool.with_connection do + confirm_result = described_class.call(user: user, ingestion: PaymentIngestion.find(ingestion.id)) + end + end + + t2 = Thread.new do + ActiveRecord::Base.connection_pool.with_connection do + delete_result = PaymentIngestions::DestroyService.call( + user: user, + ingestion: PaymentIngestion.find(ingestion.id) + ) + end + end + + [ t1, t2 ].each(&:join) + + if confirm_result.success? + expect(ingestion.reload.status).to eq("confirmed") + expect(ingestion.receipt).to be_present + expect(Receipt.where(id: confirm_result.value!.data.id)).to exist + expect(delete_result).to be_failure + expect(delete_result.failure.code).to eq(:immutable) + else + expect(delete_result).to be_success + expect(PaymentIngestion.where(id: ingestion.id)).to be_empty + expect(Receipt.where(external_reference: "TXNCONCUR2")).to be_empty + end + end + + it "safely serializes concurrent confirm and PaymentDocument delete" do + doc = create(:payment_document, user: user) + ingestion = build_ingestion(payment_document: doc, transaction_number: "TXNCONCUR3") + confirm_result = nil + delete_result = nil + + t1 = Thread.new do + ActiveRecord::Base.connection_pool.with_connection do + confirm_result = described_class.call(user: user, ingestion: PaymentIngestion.find(ingestion.id)) + end + end + + t2 = Thread.new do + ActiveRecord::Base.connection_pool.with_connection do + delete_result = PaymentDocuments::DestroyService.call( + user: user, + document: PaymentDocument.find(doc.id) + ) + end + end + + [ t1, t2 ].each(&:join) + + if confirm_result.success? + expect(ingestion.reload.status).to eq("confirmed") + expect(ingestion.receipt).to be_present + expect(PaymentDocument.where(id: doc.id)).to exist + expect(delete_result).to be_failure + expect(delete_result.failure.code).to eq(:immutable) + else + expect(delete_result).to be_success + expect(PaymentDocument.where(id: doc.id)).to be_empty + expect(PaymentIngestion.where(id: ingestion.id)).to be_empty + expect(Receipt.where(external_reference: "TXNCONCUR3")).to be_empty + end + end end end diff --git a/spec/services/payment_ingestions/destroy_service_spec.rb b/spec/services/payment_ingestions/destroy_service_spec.rb new file mode 100644 index 00000000..5f9cedde --- /dev/null +++ b/spec/services/payment_ingestions/destroy_service_spec.rb @@ -0,0 +1,46 @@ +require "rails_helper" + +RSpec.describe PaymentIngestions::DestroyService do + let(:user) { create(:user) } + let(:party) { create(:party, user: user) } + let(:property) { create(:property, user: user) } + let(:unit) { create(:rentable_unit, property: property) } + let(:tenancy) { create(:tenancy, rentable_unit: unit) } + + it "destroys an unconfirmed payment ingestion" do + ingestion = create(:payment_ingestion, user: user, status: :matched) + + expect { + result = described_class.call(user: user, ingestion: ingestion) + expect(result).to be_success + }.to change(PaymentIngestion, :count).by(-1) + end + + it "rejects destroying a confirmed payment ingestion" do + ingestion = create(:payment_ingestion, + user: user, + status: :confirmed, + party: party, + tenancy: tenancy, + amount: 1000.0, + payment_date: Date.current, + payment_method: "zelle" + ) + + expect { + result = described_class.call(user: user, ingestion: ingestion) + expect(result).to be_failure + expect(result.failure.code).to eq(:immutable) + expect(result.failure.error).to eq("Cannot delete a confirmed payment ingestion") + }.not_to change(PaymentIngestion, :count) + end + + it "returns failure when ingestion belongs to another user" do + other_user = create(:user) + ingestion = create(:payment_ingestion, user: other_user) + + result = described_class.call(user: user, ingestion: ingestion) + expect(result).to be_failure + expect(result.failure.code).to eq(:not_found) + end +end diff --git a/spec/services/payment_ingestions/update_service_spec.rb b/spec/services/payment_ingestions/update_service_spec.rb index cd2709ac..a0594624 100644 --- a/spec/services/payment_ingestions/update_service_spec.rb +++ b/spec/services/payment_ingestions/update_service_spec.rb @@ -47,4 +47,14 @@ expect(result).to be_failure expect(result.failure.code).to eq(:validation_error) end + + it "rejects updating a confirmed payment ingestion" do + ingestion = create(:payment_ingestion, user: user, status: :confirmed, party: party, tenancy: tenancy, amount: 500.0, payment_date: Date.current, payment_method: "zelle") + + result = described_class.call(user: user, ingestion: ingestion, params: { amount: 600.0 }) + expect(result).to be_failure + expect(result.failure.code).to eq(:immutable) + expect(result.failure.error).to eq("Cannot update a confirmed payment ingestion") + expect(ingestion.reload.amount).to eq(500.0) + end end diff --git a/spec/services/receipts/correct_service_spec.rb b/spec/services/receipts/correct_service_spec.rb new file mode 100644 index 00000000..0afddc26 --- /dev/null +++ b/spec/services/receipts/correct_service_spec.rb @@ -0,0 +1,242 @@ +require "rails_helper" + +RSpec.describe Receipts::CorrectService, type: :service do + let(:user) { create(:user) } + let(:property) { create(:property, user: user) } + let(:unit1) { create(:rentable_unit, property: property) } + let(:unit2) { create(:rentable_unit, property: property) } + let(:tenancy1) { create(:tenancy, rentable_unit: unit1, commencement_date: Date.new(2026, 1, 1)) } + let(:tenancy2) { create(:tenancy, rentable_unit: unit2, commencement_date: Date.new(2026, 1, 1)) } + let(:party_alice) { create(:party, user: user, display_name: "Alice Tenant") } + let(:party_bob) { create(:party, user: user, display_name: "Bob Tenant") } + + let!(:receipt_result) do + Receipts::CreateService.call( + tenancy: tenancy1, + payer_party: party_alice, + amount_cents: 200_000, + received_on: Date.new(2026, 1, 15), + payment_method: "zelle", + external_reference: "ZEL123", + memo: "January Rent" + ) + end + let(:receipt) { receipt_result.value!.data[:receipt] } + + describe ".call" do + it "corrects the amount by reversing the original and creating a replacement" do + result = nil + expect { + result = described_class.call( + receipt: receipt, + amount_cents: 210_000 + ) + expect(result).to be_success + }.to change(Receipt, :count).by(1) + .and change(JournalEntry, :count).by(2) + .and change(Posting, :count).by(4) + + replacement = result.value!.data[:receipt] + expect(replacement.amount_cents).to eq(210_000) + expect(replacement.payer_party).to eq(party_alice) + expect(replacement.tenancy).to eq(tenancy1) + expect(replacement.posted?).to be true + + receipt.reload + expect(receipt.voided?).to be true + expect(receipt.superseded?).to be true + expect(receipt.superseded_by).to eq(replacement) + + # Check net ledger balance for tenancy1 + # Initial: +2100 credit + expect(tenancy1.current_balance).to eq(BigDecimal("-2100.00")) + end + + it "corrects the tenancy by moving receivable credit between tenancies" do + result = described_class.call( + receipt: receipt, + tenancy: tenancy2 + ) + expect(result).to be_success + replacement = result.value!.data[:receipt] + expect(replacement.tenancy).to eq(tenancy2) + + # Tenancy 1 balance is back to $0 + expect(tenancy1.current_balance).to eq(BigDecimal("0.00")) + # Tenancy 2 has $2,000 credit + expect(tenancy2.current_balance).to eq(BigDecimal("-2000.00")) + end + + it "corrects the payer party while preserving original audit trail" do + result = described_class.call( + receipt: receipt, + payer_party: party_bob + ) + expect(result).to be_success + replacement = result.value!.data[:receipt] + expect(replacement.payer_party).to eq(party_bob) + + # Verify postings on replacement carry Bob + replacement_entry = replacement.journal_entries.find_by(event_type: "receipt_posted") + expect(replacement_entry.postings.map(&:party).uniq).to eq([ party_bob ]) + end + + it "allows replacement to retain the same external reference" do + result = described_class.call( + receipt: receipt, + amount_cents: 220_000, + external_reference: "ZEL123" + ) + expect(result).to be_success + replacement = result.value!.data[:receipt] + expect(replacement.external_reference).to eq("ZEL123") + end + + it "is idempotent for identical correction requests including string received_on" do + res1 = described_class.call( + receipt: receipt, + amount_cents: 210_000, + received_on: "2026-01-15", + payment_method: "ZELLE", + external_reference: " ZEL123 ", + memo: " Updated Memo " + ) + expect(res1).to be_success + replacement1 = res1.value!.data[:receipt] + + expect { + res2 = described_class.call( + receipt: receipt, + amount_cents: 210_000, + received_on: "2026-01-15", + payment_method: "zelle", + external_reference: "ZEL123", + memo: "Updated Memo" + ) + expect(res2).to be_success + expect(res2.value!.data[:receipt]).to eq(replacement1) + }.not_to change(Receipt, :count) + end + + it "rejects invalid received_on string" do + bad_res = described_class.call(receipt: receipt, received_on: "not-a-date") + expect(bad_res).to be_failure + expect(bad_res.failure.code).to eq(:invalid_input) + expect(bad_res.failure.error).to eq("Received on must be a valid date") + end + + it "rejects a conflicting correction on an already superseded receipt" do + described_class.call(receipt: receipt, amount_cents: 210_000) + + conflict_result = described_class.call(receipt: receipt, amount_cents: 250_000) + expect(conflict_result).to be_failure + expect(conflict_result.failure.code).to eq(:already_superseded) + end + + it "rejects correcting a voided receipt" do + Receipts::VoidService.call(receipt: receipt) + + result = described_class.call(receipt: receipt, amount_cents: 210_000) + expect(result).to be_failure + expect(result.failure.code).to eq(:already_voided) + end + + it "serializes concurrent corrections under row lock" do + results = [] + threads = 2.times.map do |i| + Thread.new do + ActiveRecord::Base.connection_pool.with_connection do + results << described_class.call( + receipt: receipt, + amount_cents: 210_000 + (i * 10_000) + ) + end + end + end + threads.each(&:join) + + successes = results.select(&:success?) + failures = results.select(&:failure?) + + expect(successes.count).to eq(1) + expect(failures.count).to eq(1) + expect(failures.first.failure.code).to eq(:already_superseded) + expect(receipt.reload.superseded_by).to be_present + end + + it "returns failure for unpersisted receipt" do + result = described_class.call(receipt: Receipt.new) + expect(result).to be_failure + expect(result.failure.code).to eq(:invalid_source) + end + + it "returns failure if journal entry is missing" do + allow(receipt).to receive_message_chain(:journal_entries, :find_by).and_return(nil) + result = described_class.call(receipt: receipt) + expect(result).to be_failure + expect(result.failure.code).to eq(:not_found) + end + + it "returns failure for invalid or fractional amount" do + bad_res = described_class.call(receipt: receipt, amount: "invalid") + expect(bad_res).to be_failure + expect(bad_res.failure.code).to eq(:invalid_amount) + + frac_res = described_class.call(receipt: receipt, amount: "100.555") + expect(frac_res).to be_failure + expect(frac_res.failure.code).to eq(:invalid_amount) + end + + it "rejects moving receipt to another user's tenancy" do + other_user = create(:user) + other_prop = create(:property, user: other_user) + other_unit = create(:rentable_unit, property: other_prop) + other_tenancy = create(:tenancy, rentable_unit: other_unit) + other_party = create(:party, user: other_user) + + expect { + result = described_class.call( + receipt: receipt, + tenancy: other_tenancy, + payer_party: other_party + ) + expect(result).to be_failure + expect(result.failure.code).to eq(:ownership_mismatch) + expect(result.failure.error).to eq("Cannot move receipt to another user's tenancy") + }.not_to change(Receipt, :count) + + expect(receipt.reload.active?).to be true + expect(receipt.voided?).to be false + expect(receipt.superseded?).to be false + expect(JournalEntry.where(event_type: "receipt_reversal")).to be_empty + end + + it "rejects assigning payer belonging to another user" do + other_user = create(:user) + other_party = create(:party, user: other_user) + + expect { + result = described_class.call( + receipt: receipt, + payer_party: other_party + ) + expect(result).to be_failure + expect(result.failure.code).to eq(:ownership_mismatch) + expect(result.failure.error).to eq("Cannot assign payer belonging to another user") + }.not_to change(Receipt, :count) + + expect(receipt.reload.active?).to be true + expect(receipt.voided?).to be false + expect(receipt.superseded?).to be false + end + + it "handles reverse service failure" do + allow(Accounting::ReverseEntryService).to receive(:call).and_return( + ServiceResult.failure(error: "Reverse failed", code: :reverse_failed) + ) + result = described_class.call(receipt: receipt, amount_cents: 210_000) + expect(result).to be_failure + expect(result.failure.code).to eq(:reverse_failed) + end + end +end diff --git a/spec/services/receipts/create_service_spec.rb b/spec/services/receipts/create_service_spec.rb new file mode 100644 index 00000000..474b7fb1 --- /dev/null +++ b/spec/services/receipts/create_service_spec.rb @@ -0,0 +1,297 @@ +require "rails_helper" + +RSpec.describe Receipts::CreateService, type: :service 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, commencement_date: Date.new(2026, 1, 1)) } + let(:party_alice) { create(:party, user: user, display_name: "Alice Tenant") } + let(:party_bob) { create(:party, user: user, display_name: "Bob Tenant") } + let(:party_employer) { create(:party, user: user, display_name: "Alice Employer") } + + before do + create(:tenancy_party, tenancy: tenancy, party: party_alice, role: "tenant") + create(:tenancy_party, tenancy: tenancy, party: party_bob, role: "tenant") + create(:rent_term, tenancy: tenancy, amount_cents: 200_000, effective_from: Date.new(2026, 1, 1)) + end + + describe ".call" do + it "atomically creates and posts a receipt" do + expect { + result = described_class.call( + tenancy: tenancy, + payer_party: party_alice, + amount_cents: 200_000, + received_on: Date.new(2026, 2, 1), + payment_method: "zelle", + external_reference: "ZEL999", + memo: "February rent payment" + ) + expect(result).to be_success + receipt = result.value!.data[:receipt] + expect(receipt).to be_persisted + expect(receipt.posted?).to be true + expect(receipt.posted_at).to be_present + expect(receipt.user).to eq(user) + }.to change(Receipt, :count).by(1) + .and change(JournalEntry, :count).by(1) + .and change(Posting, :count).by(2) + end + + it "accepts decimal amount string" do + result = described_class.call( + tenancy: tenancy, + payer_party: party_alice, + amount: "1500.50", + received_on: Date.current, + payment_method: "check" + ) + expect(result).to be_success + expect(result.value!.data[:receipt].amount_cents).to eq(150_050) + end + + it "rejects fractional cents without creating receipt or journal entries" do + expect { + result = described_class.call( + tenancy: tenancy, + payer_party: party_alice, + amount: "100.005", + received_on: Date.current, + payment_method: "check" + ) + expect(result).to be_failure + expect(result.failure.code).to eq(:invalid_amount) + }.not_to change(Receipt, :count) + + expect(JournalEntry.count).to eq(0) + end + + it "rejects non-positive amount" do + result = described_class.call( + tenancy: tenancy, + payer_party: party_alice, + amount_cents: 0, + received_on: Date.current, + payment_method: "cash" + ) + expect(result).to be_failure + expect(result.failure.code).to eq(:invalid_amount) + end + + it "rolls back everything if accounting posting fails" do + allow(Accounting::PostEntryService).to receive(:call).and_return( + ServiceResult.failure(error: "Posting failed", code: :posting_error) + ) + + expect { + result = described_class.call( + tenancy: tenancy, + payer_party: party_alice, + amount_cents: 100_000, + received_on: Date.current, + payment_method: "cash" + ) + expect(result).to be_failure + expect(result.failure.code).to eq(:posting_error) + }.not_to change(Receipt, :count) + + expect(JournalEntry.count).to eq(0) + end + + it "supports third-party payer who is not on the tenancy" do + result = described_class.call( + tenancy: tenancy, + payer_party: party_employer, + amount_cents: 200_000, + received_on: Date.current, + payment_method: "wire" + ) + expect(result).to be_success + receipt = result.value!.data[:receipt] + expect(receipt.payer_party).to eq(party_employer) + end + + it "reduces tenancy balance for joint tenants while preserving payer identity" do + # Initial rent charge: $2,000 + charge_res = Charges::CreateService.call( + tenancy: tenancy, + charge_kind: "rent", + amount_cents: 200_000, + charge_date: Date.new(2026, 1, 1), + due_on: Date.new(2026, 1, 1), + service_period_start: Date.new(2026, 1, 1), + service_period_end: Date.new(2026, 1, 31) + ) + expect(charge_res).to be_success + expect(tenancy.current_balance).to eq(BigDecimal("2000.00")) + + # Alice pays $500 + res1 = described_class.call( + tenancy: tenancy, + payer_party: party_alice, + amount_cents: 50_000, + received_on: Date.new(2026, 1, 2), + payment_method: "zelle" + ) + expect(res1).to be_success + expect(tenancy.current_balance).to eq(BigDecimal("1500.00")) + + # Bob pays $750 + res2 = described_class.call( + tenancy: tenancy, + payer_party: party_bob, + amount_cents: 75_000, + received_on: Date.new(2026, 1, 3), + payment_method: "venmo" + ) + expect(res2).to be_success + expect(tenancy.current_balance).to eq(BigDecimal("750.00")) + + # Verify payers in postings + alice_postings = res1.value!.data[:journal_entry].postings + expect(alice_postings.map(&:party).uniq).to eq([ party_alice ]) + + bob_postings = res2.value!.data[:journal_entry].postings + expect(bob_postings.map(&:party).uniq).to eq([ party_bob ]) + end + + it "handles overpayments by producing a credit balance" do + charge_res = Charges::CreateService.call( + tenancy: tenancy, + charge_kind: "rent", + amount_cents: 200_000, + charge_date: Date.current, + due_on: Date.current, + service_period_start: Date.current.beginning_of_month, + service_period_end: Date.current.end_of_month + ) + expect(charge_res).to be_success + + # Pay $2,500 + result = described_class.call( + tenancy: tenancy, + payer_party: party_alice, + amount_cents: 250_000, + received_on: Date.current, + payment_method: "check" + ) + expect(result).to be_success + expect(tenancy.current_balance).to eq(BigDecimal("-500.00")) + end + + it "rejects missing tenancy" do + result = described_class.call( + tenancy: nil, + payer_party: party_alice, + amount_cents: 100_000, + received_on: Date.current, + payment_method: "cash" + ) + expect(result).to be_failure + expect(result.failure.code).to eq(:invalid_input) + expect(result.failure.error).to eq("Tenancy is required") + end + + it "rejects missing payer party" do + result = described_class.call( + tenancy: tenancy, + payer_party: nil, + amount_cents: 100_000, + received_on: Date.current, + payment_method: "cash" + ) + expect(result).to be_failure + expect(result.failure.code).to eq(:invalid_input) + expect(result.failure.error).to eq("Payer party is required") + end + + it "rejects missing received_on" do + result = described_class.call( + tenancy: tenancy, + payer_party: party_alice, + amount_cents: 100_000, + received_on: nil, + payment_method: "cash" + ) + expect(result).to be_failure + expect(result.failure.code).to eq(:invalid_input) + expect(result.failure.error).to eq("Received on date is required") + end + + it "rejects invalid received_on string" do + result = described_class.call( + tenancy: tenancy, + payer_party: party_alice, + amount_cents: 100_000, + received_on: "invalid-date", + payment_method: "cash" + ) + expect(result).to be_failure + expect(result.failure.code).to eq(:invalid_input) + expect(result.failure.error).to eq("Received on must be a valid date") + end + + it "rejects missing or blank payment_method" do + result = described_class.call( + tenancy: tenancy, + payer_party: party_alice, + amount_cents: 100_000, + received_on: Date.current, + payment_method: " " + ) + expect(result).to be_failure + expect(result.failure.code).to eq(:invalid_input) + expect(result.failure.error).to eq("Payment method is required") + end + + it "rescues ActiveRecord::RecordNotUnique and returns duplicate failure" do + # Create initial receipt + first_result = described_class.call( + tenancy: tenancy, + payer_party: party_alice, + amount_cents: 100_000, + received_on: Date.current, + payment_method: "zelle", + external_reference: "CONF123" + ) + expect(first_result).to be_success + + # Second attempt with same method + external_reference + second_result = described_class.call( + tenancy: tenancy, + payer_party: party_alice, + amount_cents: 100_000, + received_on: Date.current, + payment_method: "zelle", + external_reference: "CONF123" + ) + expect(second_result).to be_failure + expect(second_result.failure.code).to eq(:validation_error) + + # Test race condition where RecordNotUnique is raised directly on save + allow_any_instance_of(Receipt).to receive(:save).and_raise(ActiveRecord::RecordNotUnique) + race_result = described_class.call( + tenancy: tenancy, + payer_party: party_alice, + amount_cents: 100_000, + received_on: Date.current, + payment_method: "zelle", + external_reference: "CONF456" + ) + expect(race_result).to be_failure + expect(race_result.failure.code).to eq(:duplicate) + end + + it "returns failure for invalid non-numeric amount" do + result = described_class.call( + tenancy: tenancy, + payer_party: party_alice, + received_on: Date.current, + payment_method: "cash", + amount: "invalid-amount" + ) + expect(result).to be_failure + expect(result.failure.code).to eq(:invalid_amount) + end + end +end diff --git a/spec/services/receipts/post_service_spec.rb b/spec/services/receipts/post_service_spec.rb new file mode 100644 index 00000000..04fbb1f1 --- /dev/null +++ b/spec/services/receipts/post_service_spec.rb @@ -0,0 +1,65 @@ +require "rails_helper" + +RSpec.describe Receipts::PostService, type: :service 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(:payer_party) { create(:party, user: user) } + let(:receipt) do + create(:receipt, + user: user, + tenancy: tenancy, + payer_party: payer_party, + amount_cents: 150_000, + received_on: Date.new(2026, 1, 15), + payment_method: "zelle", + external_reference: "ZEL123", + memo: "January Rent" + ) + end + + describe ".call" do + it "creates a balanced journal entry posting Dr Cash and Cr Tenant Receivable" do + result = described_class.call(receipt: receipt) + expect(result).to be_success + + entry = result.value!.data[:journal_entry] + expect(entry.source).to eq(receipt) + expect(entry.event_type).to eq("receipt_posted") + expect(entry.occurred_on).to eq(Date.new(2026, 1, 15)) + expect(entry.user).to eq(user) + + postings = entry.postings.includes(:account, :party, :tenancy, :property, :rentable_unit) + expect(postings.count).to eq(2) + + cash_posting = postings.find { |p| p.account.key == "cash" } + expect(cash_posting.amount_cents).to eq(150_000) + expect(cash_posting.party).to eq(payer_party) + expect(cash_posting.tenancy).to eq(tenancy) + expect(cash_posting.property).to eq(property) + expect(cash_posting.rentable_unit).to eq(unit) + + receivable_posting = postings.find { |p| p.account.key == "tenant_receivable" } + expect(receivable_posting.amount_cents).to eq(-150_000) + expect(receivable_posting.party).to eq(payer_party) + expect(receivable_posting.tenancy).to eq(tenancy) + expect(receivable_posting.property).to eq(property) + expect(receivable_posting.rentable_unit).to eq(unit) + end + + it "fails if receipt is voided" do + receipt.update_columns(voided_at: Time.current) + result = described_class.call(receipt: receipt) + expect(result).to be_failure + expect(result.failure.code).to eq(:invalid_state) + end + + it "fails if receipt is not a persisted Receipt" do + unpersisted = build(:receipt) + result = described_class.call(receipt: unpersisted) + expect(result).to be_failure + expect(result.failure.code).to eq(:invalid_source) + end + end +end diff --git a/spec/services/receipts/receipt_pdf_service_spec.rb b/spec/services/receipts/receipt_pdf_service_spec.rb new file mode 100644 index 00000000..553dc4b3 --- /dev/null +++ b/spec/services/receipts/receipt_pdf_service_spec.rb @@ -0,0 +1,88 @@ +require "rails_helper" + +RSpec.describe Receipts::ReceiptPdfService, type: :service do + let(:user) { create(:user) } + let(:property) { create(:property, user: user, address: "123 Main St") } + let(:unit) { create(:rentable_unit, property: property, name: "Unit 4B") } + let(:tenancy) { create(:tenancy, rentable_unit: unit) } + let(:payer_party) { create(:party, user: user, display_name: "Alice Walker") } + let(:receipt) do + create(:receipt, + user: user, + tenancy: tenancy, + payer_party: payer_party, + amount_cents: 125_000, + received_on: Date.new(2026, 1, 15), + payment_method: "zelle", + external_reference: "ZEL123", + memo: "January Rent" + ) + end + + let(:view_context) { double(number_to_currency: "$1,250.00") } + let(:pdf) { instance_double(Prawn::Document, text: nil, move_down: nil, render: "pdf-data") } + + describe ".call" do + it "renders receipt details into a PDF" do + allow(Prawn::Document).to receive(:new).and_return(pdf) + + result = described_class.call(receipt: receipt, view_context: view_context) + + expect(result).to eq("pdf-data") + expect(pdf).to have_received(:text).with("Payment Receipt", size: 30, style: :bold) + expect(pdf).to have_received(:text).with("Receipt ID: ##{receipt.id}") + expect(pdf).to have_received(:text).with("Payment Date: January 15, 2026") + expect(pdf).to have_received(:text).with("Amount: $1,250.00") + expect(pdf).to have_received(:text).with("Payer: Alice Walker") + expect(pdf).to have_received(:text).with("Method: Zelle") + expect(pdf).to have_received(:text).with("Transaction / Reference: ZEL123") + expect(pdf).to have_received(:text).with("Property: 123 Main St") + expect(pdf).to have_received(:text).with("Unit: #{unit.display_name}") + expect(pdf).to have_received(:text).with("Tenancy: ##{tenancy.id}") + end + + it "includes a clear voided notice for a voided receipt" do + receipt.update_columns(voided_at: Time.current) + allow(Prawn::Document).to receive(:new).and_return(pdf) + + result = described_class.call(receipt: receipt, view_context: view_context) + expect(result).to eq("pdf-data") + expect(pdf).to have_received(:text).with("[VOIDED - INACTIVE RECORD]", size: 12, style: :bold, color: "CC0000") + end + + it "includes a clear corrected notice for a superseded receipt" do + replacement = create(:receipt, tenancy: tenancy, payer_party: payer_party) + receipt.update_columns(voided_at: Time.current, superseded_by_id: replacement.id) + allow(Prawn::Document).to receive(:new).and_return(pdf) + + result = described_class.call(receipt: receipt, view_context: view_context) + expect(result).to eq("pdf-data") + expect(pdf).to have_received(:text).with("[CORRECTED - REPLACED BY RECEIPT ##{replacement.id}]", size: 12, style: :bold, color: "CC0000") + end + + it "includes a replacement notice for a receipt that replaces a superseded one" do + original = create(:receipt, tenancy: tenancy, payer_party: payer_party) + original.update_columns(voided_at: Time.current, superseded_by_id: receipt.id) + allow(Prawn::Document).to receive(:new).and_return(pdf) + + result = described_class.call(receipt: receipt, view_context: view_context) + expect(result).to eq("pdf-data") + expect(pdf).to have_received(:text).with("[REPLACEMENT FOR RECEIPT ##{original.id}]", size: 12, style: :bold, color: "008800") + end + + it "handles receipt without external_reference or memo" do + receipt.update_columns(external_reference: nil, memo: nil) + allow(Prawn::Document).to receive(:new).and_return(pdf) + + result = described_class.call(receipt: receipt, view_context: view_context) + expect(result).to eq("pdf-data") + end + + it "generates genuine PDF binary bytes" do + real_view_context = ActionController::Base.new.view_context + real_pdf = described_class.call(receipt: receipt, view_context: real_view_context) + expect(real_pdf).to be_present + expect(real_pdf).to start_with("%PDF-") + end + end +end diff --git a/spec/services/receipts/void_service_spec.rb b/spec/services/receipts/void_service_spec.rb new file mode 100644 index 00000000..800063bc --- /dev/null +++ b/spec/services/receipts/void_service_spec.rb @@ -0,0 +1,119 @@ +require "rails_helper" + +RSpec.describe Receipts::VoidService, type: :service do + include ActiveSupport::Testing::TimeHelpers + + let(:user) { create(:user) } + let(:property) { create(:property, user: user) } + let(:unit) { create(:rentable_unit, property: property) } + let(:tenancy) { create(:tenancy, rentable_unit: unit, commencement_date: Date.new(2026, 1, 1)) } + let(:payer_party) { create(:party, user: user) } + + let!(:receipt_result) do + Receipts::CreateService.call( + tenancy: tenancy, + payer_party: payer_party, + amount_cents: 100_000, + received_on: Date.new(2026, 1, 10), + payment_method: "zelle", + external_reference: "ZEL123", + memo: "January Rent" + ) + end + let(:receipt) { receipt_result.value!.data[:receipt] } + + describe ".call" do + it "reverses the accounting entry and marks the receipt voided" do + result = nil + expect { + result = described_class.call(receipt: receipt, reason: "Duplicate payment entered by mistake") + expect(result).to be_success + expect(receipt.reload.voided?).to be true + expect(receipt.voided_at).to be_present + }.to change(JournalEntry, :count).by(1) + .and change(Posting, :count).by(2) + + reversal_entry = result.value!.data[:journal_entry] + expect(reversal_entry).to be_present + expect(reversal_entry.occurred_on).to eq(Date.new(2026, 1, 10)) + + cash_posting = reversal_entry.postings.find { |p| p.account.key == "cash" } + expect(cash_posting.amount_cents).to eq(-100_000) + + ar_posting = reversal_entry.postings.find { |p| p.account.key == "tenant_receivable" } + expect(ar_posting.amount_cents).to eq(100_000) + end + + it "always reverses dated at original received_on regardless of when void is called" do + travel_to Date.new(2026, 6, 15) do + result = described_class.call(receipt: receipt) + expect(result).to be_success + reversal_entry = result.value!.data[:journal_entry] + expect(reversal_entry.occurred_on).to eq(Date.new(2026, 1, 10)) + end + end + + it "restores the tenancy balance automatically" do + create(:rent_term, tenancy: tenancy, amount_cents: 100_000, effective_from: Date.new(2026, 1, 1)) + Charges::CreateService.call( + tenancy: tenancy, + charge_kind: "rent", + amount_cents: 100_000, + charge_date: Date.new(2026, 1, 1), + due_on: Date.new(2026, 1, 1), + service_period_start: Date.new(2026, 1, 1), + service_period_end: Date.new(2026, 1, 31) + ) + + # Balance after payment was $0 + expect(tenancy.current_balance).to eq(BigDecimal("0.00")) + + # Void the payment + described_class.call(receipt: receipt) + + # Balance should now be $1,000 owed + expect(tenancy.current_balance).to eq(BigDecimal("1000.00")) + end + + it "is idempotent if called multiple times" do + res1 = described_class.call(receipt: receipt) + expect(res1).to be_success + + expect { + res2 = described_class.call(receipt: receipt) + expect(res2).to be_success + }.not_to change(JournalEntry, :count) + end + + it "fails if receipt is already superseded by a replacement receipt" do + replacement = create(:receipt, tenancy: tenancy, payer_party: payer_party) + receipt.update_columns(voided_at: Time.current, superseded_by_id: replacement.id) + + result = described_class.call(receipt: receipt) + expect(result).to be_failure + expect(result.failure.code).to eq(:already_superseded) + end + + it "returns failure for unpersisted receipt" do + result = described_class.call(receipt: Receipt.new) + expect(result).to be_failure + expect(result.failure.code).to eq(:invalid_source) + end + + it "returns failure if journal entry is missing" do + allow(receipt).to receive_message_chain(:journal_entries, :find_by).and_return(nil) + result = described_class.call(receipt: receipt) + expect(result).to be_failure + 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: "Reverse error", code: :reverse_error) + ) + result = described_class.call(receipt: receipt) + expect(result).to be_failure + expect(result.failure.code).to eq(:reverse_error) + end + end +end diff --git a/spec/services/rent_charges/generate_service_spec.rb b/spec/services/rent_charges/generate_service_spec.rb index 82120bbf..9e77ffb6 100644 --- a/spec/services/rent_charges/generate_service_spec.rb +++ b/spec/services/rent_charges/generate_service_spec.rb @@ -60,5 +60,27 @@ expect(result).to be_success expect(result.value!.data).to be_nil end + + it "returns failure for unpersisted tenancy" do + result = described_class.call(tenancy: Tenancy.new, service_month: Date.new(2026, 3, 1)) + expect(result).to be_failure + expect(result.failure.code).to eq(:invalid_input) + end + + it "returns failure for invalid service month" do + result = described_class.call(tenancy: tenancy, service_month: "invalid-date") + expect(result).to be_failure + expect(result.failure.code).to eq(:invalid_input) + end + + it "returns conflict failure when existing charge has different amount" do + described_class.call(tenancy: tenancy, service_month: Date.new(2026, 3, 1)) + existing = tenancy.charges.rent.first + existing.update_columns(amount_cents: 999_000) + + result = described_class.call(tenancy: tenancy, service_month: Date.new(2026, 3, 1)) + expect(result).to be_failure + expect(result.failure.code).to eq(:conflict) + end end end diff --git a/spec/services/rent_charges/generate_through_service_spec.rb b/spec/services/rent_charges/generate_through_service_spec.rb index 509cc2c6..4294d9d5 100644 --- a/spec/services/rent_charges/generate_through_service_spec.rb +++ b/spec/services/rent_charges/generate_through_service_spec.rb @@ -34,7 +34,7 @@ expect(charges.size).to eq(3) # Jan, Feb, Mar expect(tenancy.charges.rent.count).to eq(3) - expect(tenancy.charges.rent.pluck(:service_period_start)).to eq([ + expect(tenancy.charges.rent.order(:service_period_start).pluck(:service_period_start)).to eq([ Date.new(2026, 1, 1), Date.new(2026, 2, 1), Date.new(2026, 3, 1) @@ -49,5 +49,35 @@ expect(result).to be_success }.not_to change(Charge, :count) end + + it "returns failure for unpersisted tenancy" do + result = described_class.call(tenancy: Tenancy.new) + expect(result).to be_failure + expect(result.failure.code).to eq(:invalid_input) + end + + it "returns empty charges when commencement date is after through date" do + result = described_class.call(tenancy: tenancy, through: Date.new(2025, 12, 1)) + expect(result).to be_success + expect(result.value!.data[:charges]).to be_empty + end + + it "handles string and invalid through dates" do + result = described_class.call(tenancy: tenancy, through: "2026-02-15") + expect(result).to be_success + expect(result.value!.data[:charges].size).to eq(2) + + invalid_res = described_class.call(tenancy: tenancy, through: "not-a-date") + expect(invalid_res).to be_success + end + + it "propagates failure when generate service fails" do + allow(RentCharges::GenerateService).to receive(:call).and_return( + ServiceResult.failure(error: "Generation error", code: :generation_error) + ) + result = described_class.call(tenancy: tenancy, through: Date.new(2026, 2, 1)) + expect(result).to be_failure + expect(result.failure.code).to eq(:generation_error) + end end end diff --git a/spec/services/schedule_e_generator_spec.rb b/spec/services/schedule_e_generator_spec.rb index dc45c5ce..cb3fc45b 100644 --- a/spec/services/schedule_e_generator_spec.rb +++ b/spec/services/schedule_e_generator_spec.rb @@ -33,7 +33,7 @@ before do Charge.delete_all Expense.delete_all - TenantPayment.delete_all + Receipt.delete_all end def create_data_for_year(year) @@ -49,18 +49,18 @@ def create_data_for_year(year) ) end - create(:tenant_payment, + create(:receipt, tenancy: tenancy, - payment_date: date_in_year, - amount: RENT_AMOUNT, + received_on: date_in_year, + amount_cents: (RENT_AMOUNT * 100).to_i, payment_method: "check", - transaction_number: "TEST-#{year}" + external_reference: "TEST-#{year}" ) - create(:tenant_payment, + create(:receipt, tenancy: tenancy, - amount: UTILITY_AMOUNT, - payment_date: date_in_year, + amount_cents: (UTILITY_AMOUNT * 100).to_i, + received_on: date_in_year, payment_method: "zelle" ) end @@ -128,7 +128,7 @@ def read_field(doc, name) Charge.delete_all Expense.delete_all - TenantPayment.delete_all + Receipt.delete_all end end end @@ -142,7 +142,7 @@ def read_field(doc, name) it "handles net loss and covers net loss branches" do create(:expense, property: property, category: "repairs", amount: 1500, expense_date: Date.new(2025, 6, 1)) - create(:tenant_payment, tenancy: tenancy, payment_date: Date.new(2025, 6, 1), amount: 1000, payment_method: "check") + create(:receipt, tenancy: tenancy, received_on: Date.new(2025, 6, 1), amount_cents: 100_000, payment_method: "check") generator = described_class.new(property, 2025) pdf_data = generator.call diff --git a/spec/services/tenant_payments/create_service_spec.rb b/spec/services/tenant_payments/create_service_spec.rb deleted file mode 100644 index d6a90f4d..00000000 --- a/spec/services/tenant_payments/create_service_spec.rb +++ /dev/null @@ -1,56 +0,0 @@ -require "rails_helper" - -RSpec.describe TenantPayments::CreateService do - let(:user) { create(:user) } - let(:property) { create(:property, user: user) } - let(:unit) { create(:rentable_unit, property: property) } - let(:tenancy) do - create(:tenancy, - rentable_unit: unit, - commencement_date: Date.new(2026, 1, 1), - termination_date: Date.new(2026, 12, 31) - ) - end - - describe ".call" do - it "creates a TenantPayment and posts Dr Cash / Cr Tenant Receivable" do - result = described_class.call( - tenancy: tenancy, - amount: "1500.00", - payment_date: Date.new(2026, 5, 2), - payment_method: "ach", - transaction_number: "ACH12345" - ) - - expect(result).to be_success - payment = result.value!.data[:tenant_payment] - entry = result.value!.data[:journal_entry] - - expect(payment.persisted?).to be true - expect(payment.amount).to eq(1500.00) - - expect(entry.event_type).to eq("payment_received") - expect(entry.source).to eq(payment) - expect(entry.occurred_on).to eq(Date.new(2026, 5, 2)) - - dr = entry.postings.find_by(amount_cents: 150_000) - cr = entry.postings.find_by(amount_cents: -150_000) - - expect(dr.account.key).to eq("cash") - expect(cr.account.key).to eq("tenant_receivable") - expect(cr.tenancy_id).to eq(tenancy.id) - end - - it "rolls back if invalid" do - result = described_class.call( - tenancy: tenancy, - amount: -100 - ) - - expect(result).to be_failure - expect(result.failure.code).to eq(:validation_error) - expect(TenantPayment.count).to eq(0) - expect(JournalEntry.count).to eq(0) - end - end -end diff --git a/spec/services/tenant_payments/receipt_pdf_service_spec.rb b/spec/services/tenant_payments/receipt_pdf_service_spec.rb deleted file mode 100644 index 1ecc0b8e..00000000 --- a/spec/services/tenant_payments/receipt_pdf_service_spec.rb +++ /dev/null @@ -1,66 +0,0 @@ -require "rails_helper" - -RSpec.describe TenantPayments::ReceiptPdfService do - let(:user) { create(:user) } - let(:property) { create(:property, user: user, address: "123 Main St") } - let(:unit) { create(:rentable_unit, property: property) } - let(:tenancy) { create(:tenancy, rentable_unit: unit) } - let(:payment) do - create(:tenant_payment, - tenancy: tenancy, - payment_date: Date.new(2026, 5, 1), - amount: 1200, - payment_method: "zelle", - transaction_number: "TXN123" - ) - end - let(:view_context) { double(number_to_currency: "$1,200.00") } - let(:pdf) { instance_double(Prawn::Document, text: nil, move_down: nil, render: "pdf-data") } - - it "renders receipt details into a PDF" do - allow(Prawn::Document).to receive(:new).and_return(pdf) - - result = described_class.call(tenant_payment: payment, view_context: view_context) - - expect(result).to eq("pdf-data") - expect(pdf).to have_received(:text).with("Payment Receipt", size: 30, style: :bold) - expect(pdf).to have_received(:text).with("Payment Date: 2026-05-01") - expect(pdf).to have_received(:text).with("Amount: $1,200.00") - expect(pdf).to have_received(:text).with("Method: zelle") - expect(pdf).to have_received(:text).with("Transaction Number: TXN123") - expect(pdf).to have_received(:text).with("Property: 123 Main St") - end - - it "renders receipt without transaction number when blank" do - payment_without_txn = create(:tenant_payment, - tenancy: tenancy, - payment_date: Date.new(2026, 5, 1), - amount: 1200, - payment_method: "zelle", - transaction_number: nil - ) - allow(Prawn::Document).to receive(:new).and_return(pdf) - - result = described_class.call(tenant_payment: payment_without_txn, view_context: view_context) - - expect(result).to eq("pdf-data") - expect(pdf).not_to have_received(:text).with(a_string_starting_with("Transaction Number:")) - end - - it "renders receipt when tenancy has no property" do - payment_record = create(:tenant_payment, - user: user, - tenancy: tenancy, - payment_date: Date.new(2026, 5, 1), - amount: 1200, - payment_method: "zelle", - transaction_number: "TXN123" - ) - allow(payment_record.tenancy).to receive(:property).and_return(nil) - allow(Prawn::Document).to receive(:new).and_return(pdf) - - result = described_class.call(tenant_payment: payment_record, view_context: view_context) - expect(result).to eq("pdf-data") - expect(pdf).to have_received(:text).with("Property: ") - end -end diff --git a/spec/system/dashboards_spec.rb b/spec/system/dashboards_spec.rb index 00029468..51c22df9 100644 --- a/spec/system/dashboards_spec.rb +++ b/spec/system/dashboards_spec.rb @@ -6,7 +6,7 @@ let!(:unit) { create(:rentable_unit, property: property) } let!(:expense) { create(:expense, property: property, category: "repairs", amount: 250.00, expense_date: Date.today, description: "Fix door") } let!(:tenancy) { create(:tenancy, rentable_unit: unit, agreement_type: "month_to_month", commencement_date: Date.today, late_period_days: 5) } - let!(:payment) { create(:tenant_payment, tenancy: tenancy, amount: 1000.0, payment_date: Date.today, payment_method: "cash") } + let!(:payment) { create(:receipt, tenancy: tenancy, amount_cents: 100_000, received_on: Date.today, payment_method: "cash") } before do visit new_session_path diff --git a/spec/system/receipts_spec.rb b/spec/system/receipts_spec.rb new file mode 100644 index 00000000..577487b8 --- /dev/null +++ b/spec/system/receipts_spec.rb @@ -0,0 +1,93 @@ +require "rails_helper" + +RSpec.describe "Receipts", type: :system do + let!(:user) { create(:user) } + let!(:property) { create(:property, user: user, address: "999 Payment Ave") } + let!(:unit) { create(:rentable_unit, property: property, name: "Unit 1") } + let!(:party) { create(:party, user: user, display_name: "Ledger Tester") } + let!(:tenancy) { create(:tenancy, rentable_unit: unit, agreement_type: "month_to_month", commencement_date: Date.today, late_period_days: 5) } + + before do + create(:tenancy_party, tenancy: tenancy, party: party, role: "tenant", effective_from: Date.today) + visit new_session_path + fill_in "email", with: user.email + fill_in "password", with: "password" + click_on "Sign in" + end + + it "records a payment and verifies details and PDF download link" do + visit receipts_path + + click_on "+ Record Payment" + + select "##{tenancy.id} - #{property.address} (#{unit.display_name})", from: "Tenancy / Unit" + select party.display_name, from: "Payer (Party)" + fill_in "Amount ($)", with: "1000.00" + fill_in "Received Date", with: Date.today.to_s + fill_in "Payment Method", with: "Zelle" + fill_in "External Reference / Txn #", with: "ZEL-1001" + + click_on "Record Payment" + + expect(page).to have_text("Payment recorded successfully.") + expect(page).to have_text("$1,000.00") + expect(page).to have_text("Ledger Tester") + expect(page).to have_text("ZEL-1001") + expect(page).to have_link("Download PDF Receipt") + end + + it "records a payment from the tenancy show page" do + visit tenancy_path(tenancy) + + click_on "+ Record Payment", match: :first + + select party.display_name, from: "Payer (Party)" + fill_in "Amount ($)", with: "750.00" + fill_in "Received Date", with: Date.today.to_s + fill_in "Payment Method", with: "Check" + + click_on "Record Payment" + + expect(page).to have_text("Payment recorded successfully.") + expect(page).to have_text("$750.00") + end + + it "corrects a payment through the correction flow" do + res = Receipts::CreateService.call( + tenancy: tenancy, + payer_party: party, + amount_cents: 100_000, + received_on: Date.today, + payment_method: "zelle" + ) + receipt = res.value!.data[:receipt] + + visit receipt_path(receipt) + click_on "Correct Payment" + + expect(page).to have_text("Correction Semantics") + fill_in "Amount ($)", with: "1100.00" + + click_on "Save Replacement Payment" + + expect(page).to have_text("Payment corrected successfully.") + expect(page).to have_text("$1,100.00") + end + + it "voids a payment with confirmation" do + res = Receipts::CreateService.call( + tenancy: tenancy, + payer_party: party, + amount_cents: 100_000, + received_on: Date.today, + payment_method: "zelle" + ) + receipt = res.value!.data[:receipt] + + visit receipt_path(receipt) + click_on "Void Payment" + + expect(page).to have_text("Payment has been voided") + expect(page).to have_text("This payment was voided.") + end +end diff --git a/spec/system/schedule_e_spec.rb b/spec/system/schedule_e_spec.rb index 4759a719..b9784aa8 100644 --- a/spec/system/schedule_e_spec.rb +++ b/spec/system/schedule_e_spec.rb @@ -22,17 +22,17 @@ agreement_type: "fixed_term" ) - create(:tenant_payment, + create(:receipt, tenancy: tenancy, - amount: 5000.00, - payment_date: Date.new(year, 1, 5), + amount_cents: 500_000, + received_on: Date.new(year, 1, 5), payment_method: "Zelle" ) - create(:tenant_payment, + create(:receipt, tenancy: tenancy, - amount: 150.00, - payment_date: Date.new(year, 2, 10), + amount_cents: 15_000, + received_on: Date.new(year, 2, 10), payment_method: "Check" ) diff --git a/spec/system/tenant_payments_spec.rb b/spec/system/tenant_payments_spec.rb deleted file mode 100644 index 1f77ab21..00000000 --- a/spec/system/tenant_payments_spec.rb +++ /dev/null @@ -1,34 +0,0 @@ -require "rails_helper" - -RSpec.describe "TenantPayments", type: :system do - let!(:user) { create(:user) } - let!(:property) { create(:property, user: user, address: "999 Payment Ave") } - let!(:unit) { create(:rentable_unit, property: property) } - let!(:party) { create(:party, user: user, display_name: "Ledger Tester") } - let!(:tenancy) { create(:tenancy, rentable_unit: unit, agreement_type: "month_to_month", commencement_date: Date.today, late_period_days: 5) } - - before do - create(:tenancy_party, tenancy: tenancy, party: party, role: "tenant", effective_from: Date.today) - # Log in - visit new_session_path - fill_in "email", with: user.email - fill_in "password", with: "password" - click_on "Sign in" - end - - it "records a tenant payment and verifies PDF receipt link" do - visit tenant_payments_path - - click_on "New Payment" - - select "#{property.address} - Tenancy ##{tenancy.id} (#{party.display_name})", from: "Tenancy / Property / Tenants" - fill_in "Payment date", with: Date.today.to_s - fill_in "Amount", with: "1000" - fill_in "Payment method", with: "Check" - - click_on "Create Tenant payment" - - expect(page).to have_text("Payment was successfully created") - expect(page).to have_link("Download PDF Receipt") - end -end