From 1d4b61297db6b4ce66a38aa878e66126529158fa Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Sat, 15 Aug 2026 18:37:40 -0700 Subject: [PATCH 1/2] Add implementation plan for double-entry accounting milestone 2 --- .../implementation_plan_milestone_2.md | 2328 +++++++++++++++++ 1 file changed, 2328 insertions(+) create mode 100644 documentation/double_entry_accounting/implementation_plan_milestone_2.md diff --git a/documentation/double_entry_accounting/implementation_plan_milestone_2.md b/documentation/double_entry_accounting/implementation_plan_milestone_2.md new file mode 100644 index 00000000..a729555e --- /dev/null +++ b/documentation/double_entry_accounting/implementation_plan_milestone_2.md @@ -0,0 +1,2328 @@ +# Implementation Plan: Milestone 2 — Accounting Foundation + +## 1. Objective + +At the end of this milestone, Yanushi should have this infrastructure: + +```text +User +├── Account +│ +└── JournalEntry + └── Posting + ├── Account + ├── Property? + ├── RentableUnit? + ├── Tenancy? + └── Party? +``` + +and these application services: + +```text +Accounting::ChartOfAccounts +Accounting::PostingSpec +Accounting::PostingBuilder +Accounting::PostEntryService +Accounting::ReverseEntryService +``` + +The system must guarantee, through those boundaries: + +```text +every posted journal entry balances + +posting the same source event twice is idempotent + +conflicting reuse of an idempotency key fails loudly + +journal entries and postings cannot be edited or deleted + +reversals preserve the original entry + +dimensions belong to the same user and form a consistent hierarchy + +entries and postings are persisted atomically +``` + +Milestone 2 should **not change any current property, tenancy, payment, expense, ingestion, Schedule E, or dashboard behavior**. + +--- + +# 2. Read and establish the baseline + +Before making changes, inspect: + +```text +db/schema.rb + +app/models/user.rb +app/models/property.rb +app/models/rentable_unit.rb +app/models/tenancy.rb +app/models/party.rb + +app/services/service_result.rb +app/services/service_result_types.rb + +spec/factories.rb + +sig/README.md +Steepfile +.github/workflows/ci.yml +``` + +The current `User` already acts as the ownership root for properties, parties, and the surviving financial models. Accounts and journal entries should use that same ownership root rather than being property-owned. + +Run: + +```bash +git status --short +bundle exec rspec +bundle exec rbs validate +bundle exec steep check +bin/rubocop +bin/brakeman --no-pager +``` + +Record any pre-existing failures. + +CI currently requires the complete RSpec suite, Steep, RuboCop, security scans, and at least 95% line coverage. + +--- + +# 3. Explicit Milestone 2 design decisions + +Treat these as fixed decisions unless implementation uncovers a genuine contradiction. + +### Ledger ownership + +Accounts and journal entries belong to `User`. + +Do **not** create one chart of accounts per property. + +Property, unit, tenancy, and party are posting dimensions. + +### Amount representation + +All ledger amounts use: + +```text +bigint amount_cents +``` + +Convention: + +```text +positive = debit +negative = credit +``` + +A zero posting is invalid. + +### Entry balancing + +For every journal entry: + +```text +sum(postings.amount_cents) == 0 +``` + +and there must be at least two postings. + +### Drafts + +There are no persisted draft journal entries. + +Build the complete entry in memory, validate it, and atomically persist the journal entry plus all postings. + +### Immutability + +Once persisted: + +```text +JournalEntry cannot be updated +JournalEntry cannot be destroyed + +Posting cannot be updated +Posting cannot be destroyed +``` + +Corrections occur through reversal/new events later. + +### Idempotency + +The identity of a posting event is: + +```text +user +source_type +source_id +event_type +``` + +That tuple must be unique. + +### Reversal + +A reversal is itself a new journal entry. + +It contains exactly the negation of the original postings. + +The original entry remains untouched. + +### Dimensions + +Canonical hierarchy: + +```text +Tenancy + -> RentableUnit + -> Property +``` + +If a caller supplies `tenancy`, the accounting layer derives its unit and property. + +If a caller supplies `rentable_unit`, the accounting layer derives its property. + +If callers redundantly provide parent dimensions, they must match. + +### No ledger integration yet + +Existing `TenantPayment`, `Expense`, `TenantCharge`, `ScheduledRent`, and ingestion flows continue behaving exactly as today. + +Milestones 3+ will post their replacement domain events. + +--- + +# 4. Create the accounting schema + +Create one migration for the Milestone 2 accounting foundation. + +Do not modify existing financial tables. + +## 4.1 `accounts` + +Create: + +```text +accounts + +id +user_id NOT NULL +key NOT NULL +name NOT NULL +account_type NOT NULL +active NOT NULL DEFAULT true +created_at NOT NULL +updated_at NOT NULL +``` + +Add: + +```text +FK user_id -> users +unique index(user_id, key) +index(user_id) +``` + +Add a database check restricting `account_type` to: + +```text +asset +liability +equity +income +expense +``` + +Do not add a property ID. + +Do not add mutable account numbers or arbitrary hierarchy yet. + +--- + +# 5. Define the system chart of accounts + +Create: + +```text +app/services/accounting/chart_of_accounts.rb +``` + +Centralize definitions there rather than scattering account strings throughout application code. + +Initial definitions: + +```text +cash + Cash + asset + +tenant_receivable + Tenant Receivable + asset + +security_deposits_held + Security Deposits Held + liability + +rental_income + Rental Income + income + +late_fee_income + Late Fee Income + income + +reimbursement_income + Reimbursement Income + income + +expense_advertising + Advertising + expense + +expense_cleaning_maintenance + Cleaning and Maintenance + expense + +expense_insurance + Insurance + expense + +expense_legal_professional + Legal and Professional + expense + +expense_management + Management + expense + +expense_repairs + Repairs + expense + +expense_supplies + Supplies + expense + +expense_taxes + Taxes + expense + +expense_utilities + Utilities + expense + +expense_other + Other Expense + expense + +opening_balance_equity + Opening Balance Equity + equity +``` + +Provision all of these now even though later milestones will use them incrementally. + +That avoids changing the accounting vocabulary every time another financial source starts posting. + +--- + +# 6. Implement `Account` + +Create: + +```text +app/models/account.rb +``` + +Associations: + +```ruby +belongs_to :user +has_many :postings, dependent: :restrict_with_error +``` + +Use a string-backed validated enum for `account_type`, consistent with the Milestone 1 enum pattern. + +Validate: + +```text +user present +key present +key format: lowercase letters/numbers/underscores only +key unique within user +name present +account_type valid +``` + +Normalize: + +```text +key -> strip/downcase +name -> strip +``` + +Once created, these fields are accounting identity and should not change: + +```text +user_id +key +account_type +``` + +Allow later modification of: + +```text +name +active +``` + +Do not build an account-management UI. + +Do not permit account deletion once postings exist. + +--- + +# 7. Provision accounts for every user + +`Accounting::ChartOfAccounts` should expose something equivalent to: + +```ruby +Accounting::ChartOfAccounts.ensure_for(user) +``` + +It must be idempotent. + +Calling it repeatedly should: + +```text +create missing system accounts +leave existing correct accounts alone +never create duplicates +``` + +If an existing account has the expected key but the **wrong account type**, fail loudly. Do not silently mutate accounting identity. + +A changed display name is not an integrity problem. + +Because Yanushi currently has no centralized public user-registration resource in its routes, use a narrow `User` creation callback to establish this invariant: + +```text +after_create -> ensure chart of accounts +``` + +Use the callback inside the user creation transaction, not `after_create_commit`, so failure to provision the required accounting foundation also fails creation of the user. + +Keep the actual logic in `Accounting::ChartOfAccounts`; the callback should only invoke it. + +Also update `db/seeds.rb` to call `ensure_for(user)` explicitly after finding or creating the development user, so running seeds repairs an older development database idempotently. The current seed can reuse an already-existing user. + +--- + +# 8. Add User associations + +Update `User`: + +```ruby +has_many :accounts, dependent: :restrict_with_error +has_many :journal_entries, dependent: :restrict_with_error +``` + +Do not cascade-delete accounting records. + +A user with accounting history is no longer something the ORM should casually erase. + +--- + +# 9. Create `journal_entries` + +Schema: + +```text +journal_entries + +id +user_id NOT NULL + +source_type NOT NULL +source_id NOT NULL +event_type NOT NULL + +occurred_on NOT NULL +description + +posted_at NOT NULL + +reversal_of_id NULL + +created_at NOT NULL +``` + +Do **not** add `updated_at`. + +Journal entries are immutable. + +Add: + +```text +FK user_id -> users +FK reversal_of_id -> journal_entries + +index(user_id) +index(occurred_on) + +unique index( + user_id, + source_type, + source_id, + event_type +) + +unique partial index(reversal_of_id) +WHERE reversal_of_id IS NOT NULL +``` + +Use a clearly named source-event unique index, for example: + +```text +idx_journal_entries_source_event +``` + +Add a check: + +```text +source_id > 0 +``` + +if all Yanushi source events use ordinary positive Active Record IDs. + +--- + +# 10. Do not make `source` a Rails polymorphic association yet + +Store: + +```text +source_type +source_id +``` + +as durable source identity, but do not initially declare: + +```ruby +belongs_to :source, polymorphic: true +``` + +There is no database FK behind a polymorphic association, and later source lifecycle behavior should be explicit on the actual domain models. + +The posting service should derive: + +```text +source_type = source.class.base_class.name +source_id = source.id +``` + +when given a source object. + +A reversal uses: + +```text +source_type: JournalEntry +source_id: original entry ID +event_type: reversal +``` + +This preserves generic audit identity without pretending the database has referential integrity it does not actually possess. + +--- + +# 11. Implement `JournalEntry` + +Create: + +```text +app/models/journal_entry.rb +``` + +Associations: + +```ruby +belongs_to :user + +has_many :postings, + dependent: :restrict_with_error + +belongs_to :reversal_of, + class_name: "JournalEntry", + optional: true + +has_one :reversal, + class_name: "JournalEntry", + foreign_key: :reversal_of_id +``` + +Validate presence of: + +```text +user +source_type +source_id +event_type +occurred_on +posted_at +``` + +Do **not** attempt to validate cross-row balance in this model. + +That invariant belongs to the posting boundary where all posting lines are known together. + +Convenience methods may include: + +```text +reversed? +reversal? +``` + +derived from associations rather than persisted booleans. + +Do not add: + +```text +status +draft +balanced +reversed_at +``` + +Those create redundant state. + +--- + +# 12. Create `postings` + +Schema: + +```text +postings + +id +journal_entry_id NOT NULL +account_id NOT NULL +amount_cents BIGINT NOT NULL + +property_id NULL +rentable_unit_id NULL +tenancy_id NULL +party_id NULL + +memo NULL + +created_at NOT NULL +``` + +Again, no `updated_at`. + +Add foreign keys to: + +```text +journal_entries +accounts +properties +rentable_units +tenancies +parties +``` + +Add indexes: + +```text +journal_entry_id +account_id +property_id +rentable_unit_id +tenancy_id +party_id + +(account_id, property_id) +(account_id, tenancy_id) +``` + +Do not over-index every possible dimension combination yet. Later real query shapes can justify additional composite indexes. + +Add a database check: + +```text +amount_cents <> 0 +``` + +--- + +# 13. Implement `Posting` + +Create: + +```text +app/models/posting.rb +``` + +Associations: + +```ruby +belongs_to :journal_entry +belongs_to :account + +belongs_to :property, optional: true +belongs_to :rentable_unit, optional: true +belongs_to :tenancy, optional: true +belongs_to :party, optional: true +``` + +Validate: + +```text +amount_cents != 0 +amount_cents is an integer + +account belongs to journal_entry.user + +all supplied dimensions belong to journal_entry.user +``` + +Do not impose normal-balance rules such as: + +```text +asset account must have positive amount +income must have negative amount +``` + +Either side of an account can legitimately be posted. + +--- + +# 14. Canonical dimension rules + +Implement dimension normalization in the accounting builder. + +### Property only + +Valid: + +```text +property = P +``` + +Persist: + +```text +property_id = P.id +``` + +### Rentable unit + +Caller may supply: + +```text +rentable_unit = U +``` + +Builder derives: + +```text +property = U.property +``` + +Persist both. + +### Tenancy + +Caller may supply: + +```text +tenancy = T +``` + +Builder derives: + +```text +rentable_unit = T.rentable_unit +property = T.rentable_unit.property +``` + +Persist all three. + +### Party + +`party` is independent. + +A payer can be a party without being a tenancy participant, so do **not** require: + +```text +party belongs to tenancy +``` + +Require only that the party belongs to the same user. + +### Contradictions + +If the caller supplies: + +```text +tenancy: tenancy_a +property: property_b +``` + +and they disagree, reject the posting specification. + +Never silently let explicitly contradictory data be overwritten by inferred values. + +--- + +# 15. Add dimension lifecycle associations + +Add accounting-history restrictions to Milestone 1 models. + +For example: + +```ruby +Property + has_many :accounting_postings, + class_name: "Posting", + dependent: :restrict_with_error +``` + +Do the same for: + +```text +RentableUnit +Tenancy +Party +``` + +This is important even before normal application events begin posting. Once the ledger references a domain identity, that identity must not be hard-deleted. + +`Property` already restricts deletion when expenses exist, and `Tenancy` restricts deletion when legacy financial records exist. Extend that lifecycle protection to accounting postings rather than introducing another path around it. + +Update any existing `financial_history?` helpers so ledger postings count as financial history. + +--- + +# 16. Enforce JournalEntry and Posting immutability + +Use one consistent mechanism for both models. + +After persistence: + +```text +update -> raise/prevent +destroy -> raise/prevent +touch -> prevent +``` + +This should apply even if the change appears harmless. + +Do not make fields such as: + +```text +description +memo +occurred_on +``` + +editable after posting. + +Test: + +```ruby +entry.update!(description: "changed") +``` + +fails. + +Test: + +```ruby +entry.destroy! +``` + +fails. + +Test the same for a posting. + +The application-level protection does not need to defend against deliberately bypassing Active Record with raw SQL or `update_columns`; normal Yanushi application code is the threat boundary for this milestone. + +--- + +# 17. Create `Accounting::PostingSpec` + +Do not make the central posting API accept arbitrary hashes throughout the codebase. + +Create a small typed value object: + +```text +app/services/accounting/posting_spec.rb +``` + +Conceptually: + +```ruby +Accounting::PostingSpec.new( + account_key:, + amount_cents:, + property: nil, + rentable_unit: nil, + tenancy: nil, + party: nil, + memo: nil +) +``` + +Its job is to describe one requested line before account resolution and dimension normalization. + +Keep it persistence-free. + +Add an RBS signature. + +--- + +# 18. Create `Accounting::PostingBuilder` + +Create: + +```text +app/services/accounting/posting_builder.rb +``` + +Input: + +```text +user +Array +``` + +Output should be normalized posting attributes ready for persistence. + +Responsibilities: + +1. Require at least two posting specs. +2. Require each amount to be nonzero integer cents. +3. Sum all amounts and require exactly zero. +4. Resolve each `account_key` through the supplied user's accounts. +5. Reject an unknown account key. +6. Reject an inactive account. +7. Normalize dimensions. +8. Validate ownership. +9. Reject contradictory dimensions. +10. Produce deterministic normalized posting attributes. + +It must **not persist anything**. + +Example: + +```ruby +specs = [ + Accounting::PostingSpec.new( + account_key: "tenant_receivable", + amount_cents: 200_000, + tenancy: tenancy + ), + Accounting::PostingSpec.new( + account_key: "rental_income", + amount_cents: -200_000, + tenancy: tenancy + ) +] +``` + +Both resulting postings should contain: + +```text +property_id +rentable_unit_id +tenancy_id +``` + +derived from the tenancy. + +No Milestone 2 production code should actually post that rent event; this is only the low-level shape later poster services will use. + +--- + +# 19. `PostingBuilder` failure semantics + +Follow the repository's existing `ServiceResult` convention rather than adding a second service-result framework. `ServiceResult` already provides structured `Success`/`Failure` values with `data`, `error`, and `code`. + +Useful failure codes include: + +```text +:invalid_postings +:unbalanced_entry +:missing_account +:inactive_account +:ownership_mismatch +:dimension_mismatch +``` + +The exact list can be smaller if several conditions naturally map to one validation code. + +Failures must occur before persistence. + +--- + +# 20. Create `Accounting::PostEntryService` + +Create: + +```text +app/services/accounting/post_entry_service.rb +``` + +Suggested API: + +```ruby +Accounting::PostEntryService.call( + user:, + source:, + event_type:, + occurred_on:, + description: nil, + postings: +) +``` + +where: + +```text +source is a persisted ApplicationRecord +postings is Array +``` + +Responsibilities: + +1. Reject an unpersisted source. +2. Derive the source identity. +3. Normalize description/event type. +4. Call `PostingBuilder`. +5. Check whether the source event was already posted. +6. Validate an existing entry against the requested event if found. +7. If no existing entry exists, atomically create: + - `JournalEntry` + - every `Posting` +8. Set: + ```text + posted_at = Time.current + ``` +9. Return the journal entry. + +Do not expose this service through a controller or route. + +--- + +# 21. Make idempotency stronger than `find-or-return` + +A naïve implementation like: + +```ruby +existing = JournalEntry.find_by(source identity) +return existing if existing +``` + +is insufficient. + +Suppose a buggy retry uses the same source identity but asks for: + +```text +first invocation: $2,000 +second invocation: $2,100 +``` + +Returning the first entry would hide an accounting inconsistency. + +When the source-event identity already exists, compare the requested normalized entry against the persisted entry. + +Compare at least: + +```text +occurred_on +normalized description +posting count + +for every posting: + account + amount_cents + property + rentable_unit + tenancy + party + memo +``` + +Sort posting representations canonically before comparison so line ordering doesn't matter. + +If identical: + +```text +return existing journal entry successfully +``` + +If different: + +```text +fail with :idempotency_conflict +``` + +This makes retries safe without hiding changes in financial effect. + +--- + +# 22. Protect idempotency under concurrency + +The unique database index is the final arbiter. + +Use this flow: + +```text +1. check existing +2. attempt transactional create +3. if unique constraint loses a race: + leave failed transaction + reload existing entry outside it + compare requested content + return existing if equal + otherwise idempotency conflict +``` + +Do not rescue `RecordNotUnique` and then query inside the same aborted PostgreSQL transaction. + +Add a concurrency spec if practical. + +Two concurrent invocations for the same source event must result in: + +```text +1 JournalEntry +N Postings +``` + +not two entries. + +--- + +# 23. Atomic persistence + +`PostEntryService` should persist one entry using one database transaction: + +```text +BEGIN + +create journal entry +create posting 1 +create posting 2 +... +validate expected posting count + +COMMIT +``` + +If any posting fails: + +```text +ROLLBACK everything +``` + +Test a case where one posting is invalid after another line could otherwise have been created. + +After the failure: + +```text +JournalEntry count unchanged +Posting count unchanged +``` + +--- + +# 24. Prevent partial construction through nested AR behavior + +Do not rely on: + +```ruby +journal_entry.save! +# then later... +posting.save! +``` + +across multiple transactions. + +Do not expose a public workflow that creates a `JournalEntry` and lets the caller append postings later. + +`JournalEntry` has no meaningful persisted state without its complete postings. + +The internal service boundary is: + +```text +PostingSpec[] -> complete immutable JournalEntry +``` + +--- + +# 25. Create `Accounting::ReverseEntryService` + +Create: + +```text +app/services/accounting/reverse_entry_service.rb +``` + +Suggested API: + +```ruby +Accounting::ReverseEntryService.call( + journal_entry:, + occurred_on:, + description: nil +) +``` + +Make `occurred_on` explicit. + +Do not silently assume that a correction always belongs on today's accounting date. + +--- + +# 26. Reversal behavior + +Inside a transaction: + +1. Lock the original journal entry. +2. Reject attempting to reverse an entry that is itself a reversal for MVP. +3. Check whether a reversal already exists. +4. If it exists, return it idempotently. +5. For every original posting create: + ```text + same account + same dimensions + same memo + amount_cents * -1 + ``` +6. Create the reversal journal entry with: + ```text + source_type: "JournalEntry" + source_id: original.id + event_type: "reversal" + reversal_of_id: original.id + ``` +7. Persist atomically. +8. Return the new entry. + +Example: + +```text +Original + +Tenant Receivable +200000 +Rental Income -200000 + +Reversal + +Tenant Receivable -200000 +Rental Income +200000 +``` + +Do not modify the original row. + +--- + +# 27. Only one reversal per entry + +Use both: + +```text +application validation/service check +``` + +and: + +```text +unique DB index on reversal_of_id +``` + +to ensure an original entry cannot acquire two reversal entries. + +Two simultaneous reversal requests should either: + +```text +one creates reversal +other returns same reversal +``` + +or otherwise resolve idempotently. + +They must not create two offsetting entries. + +--- + +# 28. Do not support reversal-of-reversal yet + +For this milestone: + +```text +entry.reversal_of_id.present? +``` + +means it cannot itself be passed to `ReverseEntryService`. + +If a future correction needs to reinstate a reversed transaction, the domain should create a new source event and post it. + +This keeps the audit graph simple: + +```text +source event + -> original journal entry + -> one reversal +``` + +rather than arbitrarily nested reversal chains. + +--- + +# 29. Update factories + +Add: + +```text +:account +:journal_entry +:posting +``` + +Be careful with `:user`. + +If user creation now provisions system accounts automatically, an `:account` factory using a standard system key may collide with provisioning. + +Use an explicitly non-system factory key by default, e.g.: + +```text +test_asset_1 +``` + +or define traits that reuse a provisioned system account instead of attempting to recreate it. + +Prefer helpers such as: + +```ruby +user.accounts.find_by!(key: "cash") +``` + +in accounting service specs. + +Do not manually create journal entries/postings in most specs. Exercise `PostEntryService`. + +Direct model construction should be limited to model-validation/immutability tests. + +--- + +# 30. Update seeds + +After locating/creating the development user: + +```ruby +Accounting::ChartOfAccounts.ensure_for(user) +``` + +The existing legacy seed financial activity should remain legacy for this milestone. It currently creates `ScheduledRent`, `TenantPayment`, and `Expense` rows directly; do not add corresponding journal entries yet. + +That separation is important. + +Otherwise Milestone 2 would accidentally become a partial migration where seed data behaves differently from production application paths. + +--- + +# 31. Do not alter existing financial queries + +Leave these kinds of queries alone: + +```text +Properties::FinancialItemsQuery +Properties::ScheduleESummaryQuery +Tenancies::BalanceQuery +``` + +They should continue reading the legacy financial models for now. + +`Tenancy#current_balance` currently delegates to the legacy tenancy balance query. Keep that behavior until the milestone that explicitly replaces tenant balance with Tenant Receivable postings. + +Do not make some balances ledger-backed while others remain legacy-backed. + +--- + +# 32. Do not add accounting UI + +No: + +```text +AccountsController +JournalEntriesController +PostingsController +/accounting routes +journal-entry editor +manual journal entry form +``` + +Milestone 2 is infrastructure. + +Normal users should see no behavioral difference. + +Developer-level model/service inspection is sufficient. + +--- + +# 33. Model test matrix: Account + +Add tests proving: + +- [ ] Account belongs to user. +- [ ] `key` is required. +- [ ] `name` is required. +- [ ] Valid account types are accepted. +- [ ] Invalid account types become validation errors. +- [ ] Account key is unique per user. +- [ ] Same account key may exist for another user. +- [ ] Key is normalized. +- [ ] `user_id` cannot change after creation. +- [ ] `key` cannot change after creation. +- [ ] `account_type` cannot change after creation. +- [ ] `name` may change. +- [ ] `active` may change. +- [ ] Account with postings cannot be destroyed. + +--- + +# 34. Service test matrix: Chart of Accounts + +Test: + +- [ ] New user receives all system accounts. +- [ ] Every required key has correct account type. +- [ ] Calling `ensure_for` twice creates no duplicates. +- [ ] Missing system account is restored. +- [ ] Existing key with wrong account type causes failure. +- [ ] Same chart is provisioned independently for two users. +- [ ] Failed chart provisioning rolls back user creation. + +Do not assert account IDs or creation order. + +Keys are the stable identity. + +--- + +# 35. Model test matrix: JournalEntry + +Test: + +- [ ] Requires user. +- [ ] Requires source type. +- [ ] Requires positive source ID. +- [ ] Requires event type. +- [ ] Requires occurred date. +- [ ] Requires posted time. +- [ ] Source-event tuple is unique. +- [ ] Cannot update persisted entry. +- [ ] Cannot destroy persisted entry. +- [ ] Can identify its reversal. +- [ ] Can identify that it is itself a reversal. +- [ ] Only one reversal may reference an original entry. + +Do not pretend a model spec alone proves balancing. + +--- + +# 36. Model test matrix: Posting + +Test: + +- [ ] Requires journal entry. +- [ ] Requires account. +- [ ] Requires nonzero integer amount. +- [ ] Positive amount is allowed. +- [ ] Negative amount is allowed. +- [ ] Zero is rejected at model and DB levels. +- [ ] Optional dimensions are permitted. +- [ ] Account must belong to journal-entry user. +- [ ] Property must belong to journal-entry user. +- [ ] Unit must belong to journal-entry user's property. +- [ ] Tenancy hierarchy must be coherent. +- [ ] Party must belong to journal-entry user. +- [ ] Party need not be a participant in the tenancy. +- [ ] Persisted posting cannot update. +- [ ] Persisted posting cannot destroy. + +--- + +# 37. `PostingBuilder` test matrix + +Test at least: + +### Balanced entry + +```text ++200000 +-200000 += 0 +``` + +succeeds. + +### Unbalanced + +```text ++200000 +-199999 += 1 +``` + +fails. + +### One line + +```text +0 total is impossible with nonzero one-line entry +``` + +but explicitly test the two-line minimum. + +### Zero posting + +Reject. + +### Missing account + +Reject. + +### Inactive account + +Reject. + +### Other user's account + +Impossible through key resolution; explicitly prove the builder resolves only against supplied user. + +### Tenancy dimension derivation + +Given only: + +```text +tenancy +``` + +output includes: + +```text +property +rentable_unit +tenancy +``` + +### Contradictory property + +Reject. + +### Contradictory unit + +Reject. + +### Cross-user party + +Reject. + +--- + +# 38. `PostEntryService` test matrix + +Use a persisted existing model as a harmless test source, but do **not** connect that model's lifecycle to posting. + +For example an `Expense` fixture may act as the source identity solely within service specs. + +Test: + +- [ ] Balanced specifications create one entry and all postings. +- [ ] `posted_at` is set. +- [ ] Source type and ID are persisted. +- [ ] Event type is persisted. +- [ ] `occurred_on` is preserved. +- [ ] Description is preserved. +- [ ] Repeating the exact call returns the same entry. +- [ ] Exact retry creates no additional postings. +- [ ] Same source identity with changed amount fails `idempotency_conflict`. +- [ ] Same source identity with changed account fails. +- [ ] Same source identity with changed dimensions fails. +- [ ] Same source identity with changed occurred date fails. +- [ ] Invalid posting produces no journal entry. +- [ ] One invalid line rolls the entire operation back. +- [ ] Other user's dimensions fail. +- [ ] Missing/inactive accounts fail normally. + +--- + +# 39. Add a concurrency idempotency test + +Use two independent database connections/threads if the existing test setup supports it reliably. + +Run the same `PostEntryService` event concurrently. + +Assert: + +```text +JournalEntry.where(source identity).count == 1 +``` + +and that its postings exist exactly once. + +If thread-based database concurrency is too flaky for the test environment, at minimum test the `RecordNotUnique` recovery branch explicitly. + +Do not leave the race recovery untested merely because the happy-path uniqueness spec passes. + +--- + +# 40. `ReverseEntryService` test matrix + +Test: + +- [ ] Original entry remains unchanged. +- [ ] Reversal has `reversal_of_id`. +- [ ] Reversal source identity references original journal entry. +- [ ] Every amount is negated exactly. +- [ ] Accounts are identical. +- [ ] Property dimensions are identical. +- [ ] Unit dimensions are identical. +- [ ] Tenancy dimensions are identical. +- [ ] Party dimensions are identical. +- [ ] Memos are retained. +- [ ] Reversal is balanced. +- [ ] Explicit reversal `occurred_on` is used. +- [ ] Calling reversal twice returns one reversal. +- [ ] Reversal-of-reversal is rejected. +- [ ] Concurrent reversal attempts cannot produce two reversals. + +--- + +# 41. Add global accounting invariant tests + +Create a shared matcher/helper or focused spec that asserts for every created journal entry in the accounting service suite: + +```ruby +entry.postings.sum(:amount_cents) == 0 +``` + +Also verify: + +```text +entry.postings.count >= 2 +``` + +Consider a generated test over dozens or hundreds of randomly-sized balanced posting sets: + +```text +random debits +random credits +final balancing line +``` + +Every accepted set must persist with sum zero. + +Mutating one amount by one cent must cause rejection. + +The important property is the invariant, not any one example. + +--- + +# 42. Add database-constraint tests + +Application validation is not enough for the simple invariants the database can enforce. + +Test direct SQL/validation bypass where useful for: + +```text +accounts.account_type constraint +postings.amount_cents <> 0 +journal_entries source-event uniqueness +journal_entries reversal_of uniqueness +foreign keys +``` + +You do **not** need a database trigger to enforce cross-row balancing in this milestone. + +Keep that invariant at the posting service boundary as the PRD intended. + +--- + +# 43. Locking rules + +Document one accounting lock policy. + +### Ordinary posting + +No account lock is needed. + +Source-specific services added in later milestones should lock their domain source before invoking accounting posting. + +For Milestone 2, source-event uniqueness protects the generic posting service. + +### Reversal + +Lock: + +```text +original JournalEntry +``` + +before checking/creating its reversal. + +### Chart provisioning + +Unique `(user_id, key)` is the final protection. + +Do not introduce broad user/account locks unless a concurrency test demonstrates a need. + +--- + +# 44. Error handling + +Do not rescue every exception into `ServiceResult`. + +Expected input/business failures should return normal failures: + +```text +unbalanced +invalid dimension +missing account +inactive account +idempotency conflict +already reversed / invalid reversal +``` + +Unexpected persistence/invariant failures should propagate or otherwise fail loudly. + +In particular: + +```text +"somehow produced an unbalanced persisted specification" +``` + +is a programming error, not a friendly validation state. + +--- + +# 45. Update `financial_history?` + +Milestone 1 currently treats surviving financial records as financial history when deciding whether tenancy deletion is permissible. + +Extend this concept. + +For `Tenancy`: + +```text +financial_history? += +legacy financial history +OR accounting_postings.exists? +``` + +For `Property`, its existing deletion restrictions plus accounting-posting association should prevent deletion once ledger history references it. + +For `RentableUnit` and `Party`, accounting postings should similarly protect referenced identities. + +Do not add duplicated boolean `has_accounting_history`. + +Derive it. + +--- + +# 46. Keep source deletion concerns deferred to source integration + +Milestone 2 knows only that a journal entry has: + +```text +source_type +source_id +``` + +It does not yet need to modify every legacy financial model to restrict deletion because none of those models automatically posts yet. + +When `Charge`, `Receipt`, `Expense`, and security-deposit events acquire journal entries in their respective milestones, each source model must then restrict destruction once posted. + +Do not prematurely wire legacy `TenantPayment` or `Expense` deletion to journal entries merely because accounting specs use one as a test source. + +--- + +# 47. Update RBS + +Add hand-written signatures under `sig/app` for: + +```text +Account +JournalEntry +Posting + +Accounting::ChartOfAccounts +Accounting::PostingSpec +Accounting::PostingBuilder +Accounting::PostEntryService +Accounting::ReverseEntryService +``` + +Update signatures for: + +```text +User +Property +RentableUnit +Tenancy +Party +``` + +to include accounting associations where those classes are typed. + +Add new application files to the `Steepfile` check list as appropriate. + +The repository requires regenerating Rails signatures after schema changes. + +Run: + +```bash +bin/rails rbs_rails:all +bundle exec rbs validate +bundle exec steep check +``` + +Do not hand-edit generated `sig/rbs_rails` output. + +--- + +# 48. Update documentation + +Add: + +```text +documentation/accounting_architecture.md +``` + +Document: + +## Signed posting convention + +```text +positive = debit +negative = credit +``` + +## Balance rule + +```text +sum(postings.amount_cents) == 0 +``` + +## Account ownership + +```text +Account -> User +``` + +## Dimensions + +```text +Property +RentableUnit +Tenancy +Party +``` + +are dimensions, not accounts. + +## Canonical dimensional derivation + +```text +Tenancy -> Unit -> Property +``` + +## Source-event identity + +```text +user + source_type + source_id + event_type +``` + +## Idempotency + +Same identity + same financial event: + +```text +return existing +``` + +Same identity + different content: + +```text +error +``` + +## Immutability + +Posted entries never change. + +## Reversals + +Corrections negate the original without modifying it. + +## How future financial events should integrate + +Every future poster must answer: + +```text +What is the immutable domain source event? + +What date is occurred_on? + +Which accounts change? + +What are the signed amounts? + +Which dimensions apply? + +What source event/event_type provides idempotency? + +What reversal/correction semantics apply? +``` + +--- + +# 49. Update README only minimally + +Do not rewrite the public README to claim financial reporting is ledger-backed yet. + +That would be false until later milestones; the current README still describes the existing scheduled-rent/payment/expense financial experience. + +A short development-facing reference to the accounting architecture document is enough. + +--- + +# 50. Explicitly avoid these changes + +Do **not** implement during Milestone 2: + +- `Charge` +- `Receipt` +- `SecurityDeposit` +- `SecurityDepositTransaction` +- `PropertyTaxProfile` +- receipt allocations +- rent-charge generation +- expense posting +- payment posting +- security-deposit posting +- tenant-balance migration +- property-ledger migration +- Schedule E migration +- cash-basis tax mapping +- source-document redesign +- arbitrary/manual journal entries +- bank accounts or reconciliation +- custom chart-of-accounts UI +- accounting controller/routes +- cached account balances +- opening-balance workflow + +Most importantly: + +```text +do not dual-write legacy models into the ledger +``` + +Milestone 2 is complete infrastructure, not partial adoption. + +--- + +# 51. Suggested implementation order + +I would have an agent perform the work in this order: + +### Phase A: schema and primitive models + +1. Add accounting migration. +2. Add `Account`. +3. Add `JournalEntry`. +4. Add `Posting`. +5. Add associations to `User` and dimension models. +6. Regenerate schema. +7. Add basic model specs. + +Get this green before building services. + +### Phase B: chart of accounts + +8. Add `Accounting::ChartOfAccounts`. +9. Add user provisioning. +10. Update seeds. +11. Add provisioning specs. + +### Phase C: posting construction + +12. Add `Accounting::PostingSpec`. +13. Add `Accounting::PostingBuilder`. +14. Implement dimension normalization. +15. Implement balancing. +16. Add builder tests. + +### Phase D: persistence/idempotency + +17. Add `Accounting::PostEntryService`. +18. Add atomic transaction. +19. Add source-event uniqueness recovery. +20. Add existing-entry equivalence comparison. +21. Add atomicity and idempotency tests. +22. Add concurrency coverage. + +### Phase E: reversals/immutability + +23. Enforce journal/posting immutability. +24. Add `Accounting::ReverseEntryService`. +25. Add single-reversal protection. +26. Add reversal tests. + +### Phase F: integration hygiene + +27. Extend financial-history deletion protection. +28. Update RBS. +29. Update Steep configuration. +30. Add accounting architecture documentation. +31. Run full quality suite. +32. Search for accidental ledger integration with legacy financial events. + +--- + +# 52. Search before finalizing + +Run: + +```bash +rg -n \ + 'JournalEntry|Posting|PostEntry|PostingBuilder|account_key|amount_cents' \ + app +``` + +Review every hit. + +Expected production callers at this milestone should be limited to accounting infrastructure and model associations. + +There should **not** yet be code like: + +```text +TenantPayment -> PostEntryService +Expense -> PostEntryService +ScheduledRent -> PostEntryService +TenantCharge -> PostEntryService +PaymentIngestion -> PostEntryService +``` + +If such integrations appear, remove them and defer them to their proper milestone. + +--- + +# 53. Verify no raw posting creation leaks out + +Run: + +```bash +rg -n \ + 'Posting\.(create|create!|new)|postings\.(create|create!|build)|JournalEntry\.(create|create!|new)' \ + app +``` + +Outside: + +```text +Accounting::PostEntryService +Accounting::ReverseEntryService +``` + +there should be no production code directly persisting journal entries or postings. + +Model specs may do so when explicitly testing model constraints. + +This is an important architectural acceptance criterion. + +--- + +# 54. Clean database verification + +Run: + +```bash +bin/rails db:drop db:create db:migrate +RAILS_ENV=test bin/rails db:drop db:create db:migrate +``` + +Then: + +```bash +bin/rails runner ' + user = User.create!( + email: "accounting-test@example.com", + password: "password123" + ) + + puts user.accounts.order(:key).pluck(:key, :account_type) +' +``` + +Verify every defined system account exists exactly once. + +Then exercise a development-only runner example: + +```text +build a balanced two-line event +post it +post it again +verify same JournalEntry ID +reverse it +verify aggregate sum across original + reversal = 0 +``` + +Do not add that example as application functionality. + +--- + +# 55. 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 +``` + +Verify line coverage remains: + +```text +>= 95% +``` + +which is the current CI requirement. + +--- + +# 56. Suggested commit boundaries + +### Commit 1: Add accounting schema and models + +```text +Account +JournalEntry +Posting +database constraints +associations +immutability +model specs +``` + +### Commit 2: Provision the chart of accounts + +```text +Accounting::ChartOfAccounts +User provisioning +seeds +account provisioning specs +``` + +### Commit 3: Add balanced posting infrastructure + +```text +PostingSpec +PostingBuilder +dimension normalization +ownership checks +balance validation +builder specs +``` + +### Commit 4: Add atomic idempotent posting + +```text +PostEntryService +source-event uniqueness +conflict detection +transaction rollback +concurrency behavior +specs +``` + +### Commit 5: Add reversals + +```text +ReverseEntryService +single-reversal semantics +reversal concurrency +specs +``` + +### Commit 6: Finish integration and documentation + +```text +financial-history restrictions +RBS +Steep +generated Rails signatures +accounting architecture docs +cleanup +``` + +Each commit should ideally leave the suite green. + +--- + +# 57. Milestone 2 acceptance checklist + +## Schema + +- [ ] `accounts` exists. +- [ ] `journal_entries` exists. +- [ ] `postings` exists. +- [ ] All accounting money is bigint cents. +- [ ] Posting zero amounts are DB-rejected. +- [ ] Source-event identity is uniquely indexed. +- [ ] `reversal_of_id` permits at most one reversal. +- [ ] Dimension foreign keys exist. + +## Accounts + +- [ ] Accounts belong to users. +- [ ] System chart is centrally defined. +- [ ] Every newly-created user receives the chart. +- [ ] Provisioning is idempotent. +- [ ] Stable keys cannot change. +- [ ] Account types cannot change. +- [ ] Used accounts cannot be deleted. +- [ ] No account-management UI exists. + +## Posting construction + +- [ ] Posting specs are typed value objects. +- [ ] Unknown account keys fail. +- [ ] Inactive accounts fail. +- [ ] Every accepted entry contains at least two lines. +- [ ] Every accepted entry sums exactly to zero. +- [ ] Zero lines fail. +- [ ] Cross-user dimensions fail. +- [ ] Tenancy automatically derives unit/property dimensions. +- [ ] Explicitly contradictory dimensions fail. + +## Persistence + +- [ ] Entry and all postings persist in one transaction. +- [ ] Any line failure rolls the whole operation back. +- [ ] Exact retry returns the existing entry. +- [ ] Exact retry creates no duplicate postings. +- [ ] Conflicting retry fails as an idempotency conflict. +- [ ] Concurrent identical posting cannot duplicate the entry. + +## Immutability + +- [ ] Journal entries cannot be updated. +- [ ] Journal entries cannot be destroyed. +- [ ] Postings cannot be updated. +- [ ] Postings cannot be destroyed. +- [ ] Dimension records referenced by postings cannot be casually deleted. + +## Reversals + +- [ ] Reversal creates a new journal entry. +- [ ] Original remains unchanged. +- [ ] Every reversal posting is the exact negation of its original. +- [ ] Dimensions remain identical. +- [ ] Reversal balances. +- [ ] One original can have at most one reversal. +- [ ] Repeated reversal requests are idempotent. +- [ ] Reversal-of-reversal is rejected for MVP. + +## Boundaries + +- [ ] `TenantPayment` does not post. +- [ ] `TenantCharge` does not post. +- [ ] `ScheduledRent` does not post. +- [ ] `Expense` does not post. +- [ ] Payment ingestion does not post. +- [ ] Tenant balance is still legacy-backed. +- [ ] Property financial ledger is still legacy-backed. +- [ ] Schedule E is still legacy-backed. +- [ ] No accounting UI/routes were added. +- [ ] No production code outside the accounting services directly creates postings/journal entries. + +## Quality + +- [ ] Model specs pass. +- [ ] Posting-builder specs pass. +- [ ] Idempotency specs pass. +- [ ] Atomicity specs pass. +- [ ] Concurrency behavior is covered. +- [ ] Reversal specs pass. +- [ ] RBS validates. +- [ ] Steep passes. +- [ ] RuboCop passes. +- [ ] RSpec passes. +- [ ] Coverage remains ≥95%. +- [ ] Security scans pass. +- [ ] Accounting architecture documentation exists. + +--- + +# 58. Desired end state + +At the end of Milestone 2, this should work internally: + +```text +Source event X + │ + ▼ +Accounting::PostEntryService + │ + ├── validates source-event identity + ├── resolves system accounts + ├── normalizes dimensions + ├── verifies ownership + ├── verifies sum == 0 + ├── detects idempotent retry/conflict + │ + ▼ +JournalEntry +├── Posting +200000 Tenant Receivable +│ ├── Property +│ ├── RentableUnit +│ └── Tenancy +│ +└── Posting -200000 Rental Income + ├── Property + ├── RentableUnit + └── Tenancy +``` + +And: + +```text +Accounting::ReverseEntryService + │ + ▼ + +new immutable JournalEntry +with exact opposite postings +``` + +But the actual application should still look and behave exactly as it did before Milestone 2. + +That is the key acceptance boundary: **Milestone 2 proves that Yanushi has a trustworthy accounting substrate. Milestone 3 is the first milestone that should begin moving actual rental-domain financial truth onto it.** From 7a5f9937dbbfd969ec626647217341a899a9f0af Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Sat, 15 Aug 2026 18:49:55 -0700 Subject: [PATCH 2/2] Implement Milestone 2 double-entry accounting foundation - Add accounts, journal_entries, and postings tables with check constraints and unique indexes - Implement Account, JournalEntry, and Posting primitive models with strict immutability and dimension validation - Implement Accounting::ChartOfAccounts provisioning service with 17 system accounts - Implement Accounting::PostingSpec and Accounting::PostingBuilder for specification normalization and validation - Implement Accounting::PostEntryService with balanced multi-line persistence, idempotency, and concurrency collision recovery - Implement Accounting::ReverseEntryService for atomic reversals linking via reversal_of_id - Add comprehensive unit, invariant, and database constraint specs - Update RBS signatures, Steepfile, and documentation/accounting_architecture.md --- Steepfile | 91 +-- app/controllers/tenancies_controller.rb | 6 +- app/models/account.rb | 36 ++ app/models/expense.rb | 4 + app/models/journal_entry.rb | 42 ++ app/models/party.rb | 5 + app/models/party_alias.rb | 4 + app/models/payment_document.rb | 4 + app/models/payment_ingestion.rb | 4 + app/models/posting.rb | 89 +++ app/models/property.rb | 5 + app/models/rent_term.rb | 12 +- app/models/rentable_unit.rb | 5 + app/models/scheduled_rent.rb | 7 +- app/models/session.rb | 4 + app/models/tenancy.rb | 7 +- app/models/tenancy_party.rb | 4 + app/models/tenant_charge.rb | 4 + app/models/tenant_payment.rb | 12 +- app/models/user.rb | 14 + app/services/accounting/chart_of_accounts.rb | 69 +++ app/services/accounting/post_entry_service.rb | 201 +++++++ app/services/accounting/posting_builder.rb | 172 ++++++ app/services/accounting/posting_spec.rb | 15 + .../accounting/reverse_entry_service.rb | 124 ++++ .../payment_ingestions/confirm_service.rb | 9 +- .../tenancy_parties/create_service.rb | 20 +- .../tenant_payments/receipt_pdf_service.rb | 2 +- db/cable_schema.rb | 62 +- db/cache_schema.rb | 62 +- ...0816000001_create_accounting_foundation.rb | 82 +++ db/queue_schema.rb | 62 +- db/schema.rb | 62 +- db/seeds.rb | 2 + documentation/accounting_architecture.md | 64 +++ sig/app/models/account.rbs | 8 + sig/app/models/expense.rbs | 1 + sig/app/models/journal_entry.rbs | 9 + sig/app/models/party.rbs | 1 + sig/app/models/party_alias.rbs | 1 + sig/app/models/payment_document.rbs | 3 + sig/app/models/payment_ingestion.rbs | 1 + sig/app/models/posting.rbs | 14 + sig/app/models/property.rbs | 1 + sig/app/models/rent_term.rbs | 1 + sig/app/models/rentable_unit.rbs | 1 + sig/app/models/scheduled_rent.rbs | 1 + sig/app/models/session.rbs | 3 + sig/app/models/tenancy.rbs | 1 + sig/app/models/tenancy_party.rbs | 1 + sig/app/models/tenant_charge.rbs | 3 + sig/app/models/tenant_payment.rbs | 11 +- sig/app/models/user.rbs | 2 + .../properties/financial_items_query.rbs | 4 +- .../services/accounting/chart_of_accounts.rbs | 17 + .../accounting/post_entry_service.rbs | 19 + .../services/accounting/posting_builder.rbs | 12 + sig/app/services/accounting/posting_spec.rbs | 13 + .../accounting/reverse_entry_service.rbs | 16 + sig/rbs_rails/app/models/account.rbs | 468 +++++++++++++++ sig/rbs_rails/app/models/journal_entry.rbs | 519 +++++++++++++++++ sig/rbs_rails/app/models/party.rbs | 8 + sig/rbs_rails/app/models/posting.rbs | 537 ++++++++++++++++++ sig/rbs_rails/app/models/property.rbs | 8 + sig/rbs_rails/app/models/rentable_unit.rbs | 8 + sig/rbs_rails/app/models/tenancy.rbs | 8 + sig/rbs_rails/app/models/user.rbs | 16 + sig/shims/active_record.rbs | 6 + spec/factories.rb | 24 + spec/models/account_spec.rb | 111 ++++ spec/models/accounting_db_constraints_spec.rb | 132 +++++ spec/models/accounting_invariants_spec.rb | 63 ++ spec/models/expense_spec.rb | 15 + spec/models/journal_entry_spec.rb | 107 ++++ spec/models/party_alias_spec.rb | 15 + spec/models/party_spec.rb | 9 + spec/models/payment_document_spec.rb | 9 + spec/models/payment_ingestion_spec.rb | 5 + spec/models/posting_spec.rb | 153 +++++ spec/models/property_spec.rb | 4 + spec/models/rent_term_spec.rb | 21 +- spec/models/rentable_unit_spec.rb | 15 + spec/models/scheduled_rent_spec.rb | 17 +- spec/models/session_spec.rb | 1 + spec/models/tenancy_party_spec.rb | 18 + spec/models/tenancy_spec.rb | 13 + spec/models/tenant_charge_spec.rb | 17 + spec/models/tenant_payment_spec.rb | 22 + spec/models/user_spec.rb | 7 + .../accounting/chart_of_accounts_spec.rb | 73 +++ .../accounting/post_entry_service_spec.rb | 393 +++++++++++++ .../accounting/posting_builder_spec.rb | 224 ++++++++ .../accounting/reverse_entry_service_spec.rb | 279 +++++++++ .../tenancy_parties/create_service_spec.rb | 13 + 94 files changed, 4728 insertions(+), 121 deletions(-) create mode 100644 app/models/account.rb create mode 100644 app/models/journal_entry.rb create mode 100644 app/models/posting.rb create mode 100644 app/services/accounting/chart_of_accounts.rb create mode 100644 app/services/accounting/post_entry_service.rb create mode 100644 app/services/accounting/posting_builder.rb create mode 100644 app/services/accounting/posting_spec.rb create mode 100644 app/services/accounting/reverse_entry_service.rb create mode 100644 db/migrate/20260816000001_create_accounting_foundation.rb create mode 100644 documentation/accounting_architecture.md create mode 100644 sig/app/models/account.rbs create mode 100644 sig/app/models/journal_entry.rbs create mode 100644 sig/app/models/payment_document.rbs create mode 100644 sig/app/models/posting.rbs create mode 100644 sig/app/models/session.rbs create mode 100644 sig/app/models/tenant_charge.rbs create mode 100644 sig/app/services/accounting/chart_of_accounts.rbs create mode 100644 sig/app/services/accounting/post_entry_service.rbs create mode 100644 sig/app/services/accounting/posting_builder.rbs create mode 100644 sig/app/services/accounting/posting_spec.rbs create mode 100644 sig/app/services/accounting/reverse_entry_service.rbs create mode 100644 sig/rbs_rails/app/models/account.rbs create mode 100644 sig/rbs_rails/app/models/journal_entry.rbs create mode 100644 sig/rbs_rails/app/models/posting.rbs create mode 100644 spec/models/account_spec.rb create mode 100644 spec/models/accounting_db_constraints_spec.rb create mode 100644 spec/models/accounting_invariants_spec.rb create mode 100644 spec/models/journal_entry_spec.rb create mode 100644 spec/models/posting_spec.rb create mode 100644 spec/services/accounting/chart_of_accounts_spec.rb create mode 100644 spec/services/accounting/post_entry_service_spec.rb create mode 100644 spec/services/accounting/posting_builder_spec.rb create mode 100644 spec/services/accounting/reverse_entry_service_spec.rb diff --git a/Steepfile b/Steepfile index e693be26..d2e2d2e8 100644 --- a/Steepfile +++ b/Steepfile @@ -6,87 +6,12 @@ target :app do library "date" library "bigdecimal" - check "app/services/service_result.rb" - check "app/services/service_result_types.rb" - check "app/services/payment_ingestions.rb" - check "app/services/payment_ingestions/ingestion_result.rb" - check "app/services/payment_ingestions/parsers/base.rb" - check "app/services/payment_ingestions/parsers/chase_statement.rb" - check "app/services/payment_ingestions/parsers/venmo.rb" - check "app/services/payment_ingestions/parsers/zelle.rb" - check "app/queries/dashboards/property_summaries_query.rb" - check "app/queries/payment_ingestions/form_data_query.rb" - check "app/queries/payment_ingestions/index_query.rb" - check "app/queries/properties/active_years_query.rb" - check "app/queries/properties/financial_items_query.rb" - check "app/queries/properties/schedule_e_summary_query.rb" - check "app/queries/tenancies/balance_query.rb" - check "app/services/payment_ingestions/confirm_service.rb" - check "app/services/payment_ingestions/ingestion.rb" - check "app/services/payment_ingestions/tenant_resolver.rb" - check "app/services/payment_ingestions/update_service.rb" - check "app/services/payment_ingestions/upload_service.rb" - check "app/services/expenses/save_service.rb" - check "app/services/expenses/tenant_charge_service.rb" - check "app/services/properties/create_service.rb" - check "app/services/rent_terms/change_service.rb" - check "app/services/schedule_e_generator.rb" - check "app/services/tenancies/create_service.rb" - check "app/services/tenancies/update_service.rb" - check "app/services/tenancy_parties/create_service.rb" - check "app/services/tenancy_parties/destroy_service.rb" - check "app/services/tenancy_parties/update_service.rb" - check "app/services/tenant_payments/receipt_pdf_service.rb" - check "app/jobs/application_job.rb" - check "app/jobs/ingest_payment_document_job.rb" - check "app/models/application_record.rb" - check "app/models/current.rb" - check "app/models/expense.rb" - check "app/models/party_alias.rb" - check "app/models/party.rb" - check "app/models/payment_document.rb" - check "app/models/payment_ingestion.rb" - check "app/models/property.rb" - check "app/models/rent_term.rb" - check "app/models/rentable_unit.rb" - check "app/models/scheduled_rent.rb" - check "app/models/session.rb" - check "app/models/tenancy_party.rb" - check "app/models/tenancy.rb" - check "app/models/tenant_charge.rb" - check "app/models/tenant_payment.rb" - check "app/models/user.rb" - check "app/controllers/application_controller.rb" - check "app/controllers/dashboards_controller.rb" - check "app/controllers/expenses_controller.rb" - check "app/controllers/parties_controller.rb" - check "app/controllers/passwords_controller.rb" - check "app/controllers/payment_documents_controller.rb" - check "app/controllers/payment_ingestions_controller.rb" - check "app/controllers/properties_controller.rb" - check "app/controllers/rent_terms_controller.rb" - check "app/controllers/rentable_units_controller.rb" - check "app/controllers/scheduled_rents_controller.rb" - check "app/controllers/sessions_controller.rb" - check "app/controllers/tenancies_controller.rb" - check "app/controllers/tenancy_parties_controller.rb" - check "app/controllers/tenant_charges_controller.rb" - check "app/controllers/tenant_payments_controller.rb" - check "app/mailers/application_mailer.rb" - check "app/mailers/passwords_mailer.rb" - check "app/controllers/concerns/authentication.rb" - check "app/helpers/application_helper.rb" - check "app/helpers/dashboards_helper.rb" - check "app/helpers/expenses_helper.rb" - check "app/helpers/parties_helper.rb" - check "app/helpers/payment_ingestions_helper.rb" - check "app/helpers/properties_helper.rb" - check "app/helpers/rent_payments_helper.rb" - check "app/helpers/rent_terms_helper.rb" - check "app/helpers/rentable_units_helper.rb" - check "app/helpers/scheduled_rents_helper.rb" - check "app/helpers/tenancies_helper.rb" - check "app/helpers/tenancy_parties_helper.rb" - check "app/helpers/utility_payments_helper.rb" - check "app/channels/application_cable/connection.rb" + check "app/channels" + check "app/controllers" + check "app/helpers" + check "app/jobs" + check "app/mailers" + check "app/models" + check "app/queries" + check "app/services" end diff --git a/app/controllers/tenancies_controller.rb b/app/controllers/tenancies_controller.rb index 2185afb1..30ce06a7 100644 --- a/app/controllers/tenancies_controller.rb +++ b/app/controllers/tenancies_controller.rb @@ -125,9 +125,9 @@ def participants_params effective_until: p[:effective_until] } end - elsif params[:tenancy][:party_ids].present? - party_ids = params[:tenancy][:party_ids] - party_ids.compact_blank.map do |party_id| + elsif (pids = params[:tenancy][:party_ids]).present? + pids.to_a.filter_map do |party_id| + next if party_id.blank? { party_id: party_id, role: "tenant" } end else diff --git a/app/models/account.rb b/app/models/account.rb new file mode 100644 index 00000000..6ca36fd1 --- /dev/null +++ b/app/models/account.rb @@ -0,0 +1,36 @@ +class Account < ApplicationRecord + belongs_to :user + has_many :postings, dependent: :restrict_with_error + + ACCOUNT_TYPES = %w[ + asset + liability + equity + income + expense + ].freeze + + enum :account_type, ACCOUNT_TYPES.index_by(&:itself), prefix: false, validate: true + + validates :key, presence: true, + uniqueness: { scope: :user_id, case_sensitive: false }, + format: { with: /\A[a-z0-9_]+\z/, message: "must contain only lowercase letters, numbers, and underscores" } + validates :name, presence: true + validates :account_type, presence: true + validate :identity_fields_immutable, on: :update + + normalizes :key, with: ->(k) { k.strip.downcase } + normalizes :name, with: ->(n) { n.strip } + + def accounting_user + user + end + + private + + def identity_fields_immutable + errors.add(:user_id, "cannot be changed") if user_id_changed? + errors.add(:key, "cannot be changed") if key_changed? + errors.add(:account_type, "cannot be changed") if account_type_changed? + end +end diff --git a/app/models/expense.rb b/app/models/expense.rb index 1e37d9a7..1de3c71c 100644 --- a/app/models/expense.rb +++ b/app/models/expense.rb @@ -54,4 +54,8 @@ def reimburse_lease_id=(val) def reimburse_amount @reimburse_amount.presence || tenant_charge&.amount || amount end + + def accounting_user + property&.user + end end diff --git a/app/models/journal_entry.rb b/app/models/journal_entry.rb new file mode 100644 index 00000000..f459571f --- /dev/null +++ b/app/models/journal_entry.rb @@ -0,0 +1,42 @@ +class JournalEntry < ApplicationRecord + belongs_to :user + belongs_to :reversal_of, class_name: "JournalEntry", optional: true + has_one :reversal, class_name: "JournalEntry", foreign_key: :reversal_of_id, dependent: :restrict_with_error + has_many :postings, dependent: :restrict_with_error + + validates :source_type, presence: true + validates :source_id, presence: true, numericality: { only_integer: true, greater_than: 0 } + validates :event_type, presence: true + validates :occurred_on, presence: true + validates :posted_at, presence: true + validates :source_id, uniqueness: { + scope: %i[user_id source_type event_type], + message: "has already been posted for this event" + } + validates :reversal_of_id, uniqueness: { + allow_nil: true, + message: "has already been reversed" + } + + before_update :prevent_mutation + before_destroy :prevent_mutation + + def reversed? + reversal.present? + end + + def reversal? + reversal_of_id.present? + end + + def accounting_user + user + end + + private + + def prevent_mutation + errors.add(:base, "Posted journal entries are immutable") + throw :abort + end +end diff --git a/app/models/party.rb b/app/models/party.rb index 1843a81c..7d47e6ef 100644 --- a/app/models/party.rb +++ b/app/models/party.rb @@ -3,6 +3,7 @@ class Party < ApplicationRecord has_many :party_aliases, dependent: :destroy 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 :payment_ingestions, dependent: :nullify PARTY_TYPES = %w[ @@ -34,4 +35,8 @@ def alias_candidate?(alias_name) !party_aliases.where("LOWER(TRIM(alias_name)) = ?", clean_name).exists? end end + + def accounting_user + user + end end diff --git a/app/models/party_alias.rb b/app/models/party_alias.rb index 6f6d9f57..43d0004d 100644 --- a/app/models/party_alias.rb +++ b/app/models/party_alias.rb @@ -5,4 +5,8 @@ class PartyAlias < ApplicationRecord validates :alias_name, uniqueness: { scope: :party_id, case_sensitive: false } normalizes :alias_name, with: ->(name) { name.strip } + + def accounting_user + party&.user + end end diff --git a/app/models/payment_document.rb b/app/models/payment_document.rb index eea3995c..b6b8d1f2 100644 --- a/app/models/payment_document.rb +++ b/app/models/payment_document.rb @@ -12,4 +12,8 @@ class PaymentDocument < ApplicationRecord success: "success", failed: "failed" } + + def accounting_user + user + end end diff --git a/app/models/payment_ingestion.rb b/app/models/payment_ingestion.rb index 30056466..138d1340 100644 --- a/app/models/payment_ingestion.rb +++ b/app/models/payment_ingestion.rb @@ -66,6 +66,10 @@ def attachment_image? payment_document&.attachment_content_type&.start_with?("image/") end + def accounting_user + user + end + private def validate_parse_status diff --git a/app/models/posting.rb b/app/models/posting.rb new file mode 100644 index 00000000..2e31ca55 --- /dev/null +++ b/app/models/posting.rb @@ -0,0 +1,89 @@ +class Posting < ApplicationRecord + belongs_to :journal_entry + belongs_to :account + belongs_to :property, optional: true + belongs_to :rentable_unit, optional: true + belongs_to :tenancy, optional: true + belongs_to :party, optional: true + + validates :amount_cents, presence: true, numericality: { only_integer: true, other_than: 0 } + validate :account_belongs_to_journal_entry_user + validate :dimensions_belong_to_journal_entry_user + validate :dimension_hierarchy_coherent + + before_update :prevent_mutation + before_destroy :prevent_mutation + + def debit? + amount_cents.positive? + end + + def credit? + amount_cents.negative? + end + + def debit_amount + amount_cents.positive? ? amount_cents : nil + end + + def credit_amount + amount_cents.negative? ? -amount_cents : nil + end + + def accounting_user + journal_entry&.user || account&.user + end + + private + + def prevent_mutation + errors.add(:base, "Posted journal postings are immutable") + throw :abort + end + + def account_belongs_to_journal_entry_user + return unless account && journal_entry&.user_id + + if account.user_id != journal_entry.user_id + errors.add(:account, "must belong to the journal entry user") + end + end + + def dimensions_belong_to_journal_entry_user + return unless journal_entry + + user_id = journal_entry.user_id + + if (p = property) && p.user_id != user_id + errors.add(:property, "must belong to the journal entry user") + end + + if (u = rentable_unit) && u.property.user_id != user_id + errors.add(:rentable_unit, "must belong to the journal entry user") + end + + if (t = tenancy) && t.rentable_unit.property.user_id != user_id + errors.add(:tenancy, "must belong to the journal entry user") + end + + if (prt = party) && prt.user_id != user_id + errors.add(:party, "must belong to the journal entry user") + end + end + + def dimension_hierarchy_coherent + if (u = rentable_unit) && (p = property) && u.property_id != p.id + errors.add(:property, "does not match rentable unit property") + end + + if (t = tenancy) + if (u = rentable_unit) && t.rentable_unit_id != u.id + errors.add(:rentable_unit, "does not match tenancy rentable unit") + end + + if (p = property) && t.rentable_unit.property_id != p.id + errors.add(:property, "does not match tenancy property") + end + end + end +end diff --git a/app/models/property.rb b/app/models/property.rb index 384cb369..b70176a1 100644 --- a/app/models/property.rb +++ b/app/models/property.rb @@ -6,6 +6,7 @@ class Property < ApplicationRecord has_many :scheduled_rents, through: :tenancies has_many :tenant_payments, through: :tenancies has_many :tenant_charges, through: :tenancies + has_many :accounting_postings, class_name: "Posting", dependent: :restrict_with_error ASSET_TYPES = %w[ single_family @@ -37,4 +38,8 @@ def schedule_e_summary(*args, year: nil) target_year = year || args.first || Date.current.year Properties::ScheduleESummaryQuery.new(property: self).call(year: target_year) end + + def accounting_user + user + end end diff --git a/app/models/rent_term.rb b/app/models/rent_term.rb index 8ec91868..016dacea 100644 --- a/app/models/rent_term.rb +++ b/app/models/rent_term.rb @@ -24,9 +24,13 @@ def amount def amount=(val) if val.present? && val.to_s.strip.present? - self.amount_cents = (BigDecimal(val.to_s) * 100).round rescue nil + begin + self.amount_cents = (BigDecimal(val.to_s) * 100).round + rescue StandardError + self.amount_cents = 0 + end else - self.amount_cents = nil + self.amount_cents = 0 end end @@ -45,6 +49,10 @@ def due_date_for(year, month) Date.new(year, month, [ due_day, max_days ].min) end + def accounting_user + tenancy&.property&.user + end + private def effective_until_after_effective_from diff --git a/app/models/rentable_unit.rb b/app/models/rentable_unit.rb index 39e8cc93..7a4cd9c8 100644 --- a/app/models/rentable_unit.rb +++ b/app/models/rentable_unit.rb @@ -1,6 +1,7 @@ class RentableUnit < ApplicationRecord belongs_to :property has_many :tenancies, dependent: :restrict_with_error + has_many :accounting_postings, class_name: "Posting", dependent: :restrict_with_error validates :name, presence: true, uniqueness: { scope: :property_id, case_sensitive: false } validates :square_footage, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true @@ -16,4 +17,8 @@ def display_name def occupied?(as_of = Date.current) tenancies.any? { |t| t.active?(as_of) } end + + def accounting_user + property&.user + end end diff --git a/app/models/scheduled_rent.rb b/app/models/scheduled_rent.rb index bf94071c..aa3c7440 100644 --- a/app/models/scheduled_rent.rb +++ b/app/models/scheduled_rent.rb @@ -20,6 +20,11 @@ def late?(as_of: Date.current) end def display_name - "#{tenancy.property&.address} - #{due_date}" + prop_addr = tenancy&.property&.address || "Property" + "#{prop_addr} - #{due_date}" + end + + def accounting_user + tenancy&.property&.user end end diff --git a/app/models/session.rb b/app/models/session.rb index cf376fb2..95db1696 100644 --- a/app/models/session.rb +++ b/app/models/session.rb @@ -1,3 +1,7 @@ class Session < ApplicationRecord belongs_to :user + + def accounting_user + user + end end diff --git a/app/models/tenancy.rb b/app/models/tenancy.rb index d2b89bdc..1b13a07c 100644 --- a/app/models/tenancy.rb +++ b/app/models/tenancy.rb @@ -8,6 +8,7 @@ class Tenancy < ApplicationRecord has_many :scheduled_rents, dependent: :restrict_with_error has_many :tenant_payments, dependent: :restrict_with_error has_many :tenant_charges, dependent: :restrict_with_error + has_many :accounting_postings, class_name: "Posting", dependent: :restrict_with_error has_many :payment_ingestions, dependent: :nullify AGREEMENT_TYPES = %w[ @@ -91,7 +92,7 @@ def most_recent_rent_term end def financial_history? - tenant_payments.exists? || tenant_charges.exists? || scheduled_rents.exists? + tenant_payments.exists? || tenant_charges.exists? || scheduled_rents.exists? || accounting_postings.exists? end def total_credits(as_of: Date.current) @@ -110,6 +111,10 @@ def current_balance balance_as_of(Date.current) end + def accounting_user + rentable_unit&.property&.user + end + private def balance_query diff --git a/app/models/tenancy_party.rb b/app/models/tenancy_party.rb index 3196b796..f45be3c4 100644 --- a/app/models/tenancy_party.rb +++ b/app/models/tenancy_party.rb @@ -32,6 +32,10 @@ def active?(date = Date.current, as_of: nil) starts_on <= target_date && (ends_on.nil? || ends_on >= target_date) end + def accounting_user + tenancy&.property&.user + end + private def effective_until_after_effective_from diff --git a/app/models/tenant_charge.rb b/app/models/tenant_charge.rb index 742255c7..6e092869 100644 --- a/app/models/tenant_charge.rb +++ b/app/models/tenant_charge.rb @@ -4,4 +4,8 @@ class TenantCharge < ApplicationRecord validates :amount, presence: true, numericality: { greater_than: 0 } validates :charge_date, presence: true + + def accounting_user + tenancy&.property&.user + end end diff --git a/app/models/tenant_payment.rb b/app/models/tenant_payment.rb index 08f307fd..7027c1e3 100644 --- a/app/models/tenant_payment.rb +++ b/app/models/tenant_payment.rb @@ -11,16 +11,22 @@ class TenantPayment < ApplicationRecord validates :transaction_number, uniqueness: { scope: %i[user_id payment_method] }, allow_blank: true validate :user_matches_tenancy_owner + def accounting_user + user || tenancy&.property&.user + end + private def assign_user_from_tenancy - self.user ||= tenancy.property&.user if tenancy&.property + if (prop = tenancy&.property) && (prop_user = prop.user) + self.user ||= prop_user + end end def user_matches_tenancy_owner - return unless user_id && tenancy&.property&.user_id + return unless user_id && (prop = tenancy&.property) - if user_id != tenancy&.property&.user_id + if user_id != prop.user_id errors.add(:user, "must match the tenancy owner") end end diff --git a/app/models/user.rb b/app/models/user.rb index 5e26e881..ff64b242 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -11,9 +11,23 @@ class User < ApplicationRecord has_many :parties, dependent: :destroy has_many :payment_ingestions, dependent: :destroy has_many :payment_documents, dependent: :destroy + has_many :accounts, dependent: :restrict_with_error + has_many :journal_entries, dependent: :restrict_with_error validates :email, presence: true, uniqueness: true validates :password_digest, presence: true normalizes :email, with: ->(e) { e.strip.downcase } + + after_create :provision_chart_of_accounts + + def accounting_user + self + end + + private + + def provision_chart_of_accounts + Accounting::ChartOfAccounts.ensure_for(self) + end end diff --git a/app/services/accounting/chart_of_accounts.rb b/app/services/accounting/chart_of_accounts.rb new file mode 100644 index 00000000..de92b8ba --- /dev/null +++ b/app/services/accounting/chart_of_accounts.rb @@ -0,0 +1,69 @@ +module Accounting + class ChartOfAccounts + AccountTypeMismatchError = Class.new(StandardError) + + SYSTEM_ACCOUNTS = [ + { key: "cash", name: "Cash", account_type: "asset" }.freeze, + { key: "tenant_receivable", name: "Tenant Receivable", account_type: "asset" }.freeze, + { key: "security_deposits_held", name: "Security Deposits Held", account_type: "liability" }.freeze, + { key: "rental_income", name: "Rental Income", account_type: "income" }.freeze, + { key: "late_fee_income", name: "Late Fee Income", account_type: "income" }.freeze, + { key: "reimbursement_income", name: "Reimbursement Income", account_type: "income" }.freeze, + { key: "expense_advertising", name: "Advertising", account_type: "expense" }.freeze, + { key: "expense_cleaning_maintenance", name: "Cleaning and Maintenance", account_type: "expense" }.freeze, + { key: "expense_insurance", name: "Insurance", account_type: "expense" }.freeze, + { key: "expense_legal_professional", name: "Legal and Professional", account_type: "expense" }.freeze, + { key: "expense_management", name: "Management", account_type: "expense" }.freeze, + { key: "expense_repairs", name: "Repairs", account_type: "expense" }.freeze, + { key: "expense_supplies", name: "Supplies", account_type: "expense" }.freeze, + { key: "expense_taxes", name: "Taxes", account_type: "expense" }.freeze, + { key: "expense_utilities", name: "Utilities", account_type: "expense" }.freeze, + { key: "expense_other", name: "Other Expense", account_type: "expense" }.freeze, + { key: "opening_balance_equity", name: "Opening Balance Equity", account_type: "equity" }.freeze + ].freeze + + SYSTEM_KEYS = SYSTEM_ACCOUNTS.map { |defn| defn[:key] }.freeze + + def self.ensure_for(user) + new(user).ensure_accounts + end + + def initialize(user) + @user = user + end + + def ensure_accounts + existing_accounts = user.accounts.reload.index_by(&:key) + + Account.transaction do + SYSTEM_ACCOUNTS.each do |defn| + key = defn[:key] + account_type = defn[:account_type] + name = defn[:name] + + existing = existing_accounts[key] + if existing + if existing.account_type != account_type + raise AccountTypeMismatchError, + "Account '#{key}' for user #{user.id} has type '#{existing.account_type}', expected '#{account_type}'" + end + else + created = user.accounts.create!( + key: key, + name: name, + account_type: account_type, + active: true + ) + existing_accounts[key] = created + end + end + end + + user.accounts.where(key: SYSTEM_KEYS) + end + + private + + attr_reader :user + end +end diff --git a/app/services/accounting/post_entry_service.rb b/app/services/accounting/post_entry_service.rb new file mode 100644 index 00000000..41f980d9 --- /dev/null +++ b/app/services/accounting/post_entry_service.rb @@ -0,0 +1,201 @@ +module Accounting + class PostEntryService + def self.call(source:, event_type:, occurred_on:, postings:, user: nil, description: nil) + new( + user: user, + source: source, + event_type: event_type, + occurred_on: occurred_on, + postings: postings, + description: description + ).call + end + + def initialize(source:, event_type:, occurred_on:, postings:, user: nil, description: nil) + @user = user + @source = source + @event_type = event_type.to_s + @raw_occurred_on = occurred_on + @postings = postings + @description = description + end + + def call + unless source.is_a?(ActiveRecord::Base) && source.persisted? && !source.destroyed? + return ServiceResult.failure(error: "Source must be a persisted ActiveRecord object", code: :invalid_source) + end + + unless source.respond_to?(:accounting_user) + return ServiceResult.failure(error: "Source must implement accounting_user", code: :invalid_source) + end + + source_user = source.public_send(:accounting_user) # : User? + unless source_user.is_a?(User) && source_user.persisted? && !source_user.destroyed? + return ServiceResult.failure(error: "Source accounting_user must be a persisted user", code: :invalid_source) + end + + if (u = user) && u.id != source_user.id + return ServiceResult.failure(error: "Source does not belong to user", code: :ownership_mismatch) + end + + target_user = source_user + + if event_type.blank? + return ServiceResult.failure(error: "Event type is required", code: :invalid_input) + end + + occurred_on = parse_date(raw_occurred_on) + unless occurred_on + return ServiceResult.failure(error: "Occurred on must be a valid date", code: :invalid_input) + end + + builder_result = PostingBuilder.call(user: target_user, postings: postings) + return builder_result unless builder_result.success? + + normalized_postings = builder_result.value!.data[:postings] + source_type = source.class.base_class.name + source_id = source.id + + # 1. Pre-check existing entry + existing_entry = JournalEntry.find_by( + user_id: target_user.id, + source_type: source_type, + source_id: source_id, + event_type: event_type + ) + + if existing_entry + return verify_idempotency(existing_entry, occurred_on, normalized_postings) + end + + # 2. Attempt creation in transaction + created_entry = nil + begin + JournalEntry.transaction do + entry = target_user.journal_entries.create!( + source_type: source_type, + source_id: source_id, + event_type: event_type, + occurred_on: occurred_on, + description: description, + posted_at: Time.current + ) + created_entry = entry + + normalized_postings.each do |p_attrs| + entry.postings.create!( + account_id: p_attrs[:account_id], + amount_cents: p_attrs[:amount_cents], + property_id: p_attrs[:property_id], + rentable_unit_id: p_attrs[:rentable_unit_id], + tenancy_id: p_attrs[:tenancy_id], + party_id: p_attrs[:party_id], + memo: p_attrs[:memo] + ) + end + end + + ServiceResult.success(journal_entry: created_entry) + rescue ActiveRecord::RecordNotUnique + # Concurrency race: lost insertion race to concurrent process + existing = JournalEntry.find_by!( + user_id: target_user.id, + source_type: source_type, + source_id: source_id, + event_type: event_type + ) + verify_idempotency(existing, occurred_on, normalized_postings) + rescue ActiveRecord::RecordInvalid => e + ServiceResult.failure(error: e.record.errors.full_messages.to_sentence, code: :validation_error) + end + end + + private + + attr_reader :user, :source, :event_type, :raw_occurred_on, :postings, :description + + 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 verify_idempotency(entry, resolved_occurred_on, requested_postings) + if entry.occurred_on != resolved_occurred_on + return ServiceResult.failure( + error: "Posting event already exists with different occurred_on date", + code: :idempotency_conflict, + data: { journal_entry: entry } + ) + end + + if entry.description.to_s.strip != description.to_s.strip + return ServiceResult.failure( + error: "Posting event already exists with different description", + code: :idempotency_conflict, + data: { journal_entry: entry } + ) + end + + existing_postings = entry.postings.to_a + if existing_postings.size != requested_postings.size + return ServiceResult.failure( + error: "Posting event already exists with different number of postings", + code: :idempotency_conflict, + data: { journal_entry: entry } + ) + end + + # Canonical sort for comparison + sort_key = ->(p) { + [ + p[:account_id], + p[:amount_cents], + p[:property_id] || 0, + p[:rentable_unit_id] || 0, + p[:tenancy_id] || 0, + p[:party_id] || 0, + p[:memo].to_s.strip + ] + } + + existing_sorted = existing_postings.map { |p| + { + account_id: p.account_id, + amount_cents: p.amount_cents, + property_id: p.property_id, + rentable_unit_id: p.rentable_unit_id, + tenancy_id: p.tenancy_id, + party_id: p.party_id, + memo: p.memo + } + }.sort_by(&sort_key) + + requested_sorted = requested_postings.map { |p| + { + account_id: p[:account_id], + amount_cents: p[:amount_cents], + property_id: p[:property_id], + rentable_unit_id: p[:rentable_unit_id], + tenancy_id: p[:tenancy_id], + party_id: p[:party_id], + memo: p[:memo] + } + }.sort_by(&sort_key) + + if existing_sorted == requested_sorted + ServiceResult.success(journal_entry: entry) + else + ServiceResult.failure( + error: "Posting event already exists with different posting lines", + code: :idempotency_conflict, + data: { journal_entry: entry } + ) + end + end + end +end diff --git a/app/services/accounting/posting_builder.rb b/app/services/accounting/posting_builder.rb new file mode 100644 index 00000000..82d4bebc --- /dev/null +++ b/app/services/accounting/posting_builder.rb @@ -0,0 +1,172 @@ +module Accounting + class PostingBuilder + def self.call(user:, postings:) + new(user: user, postings: postings).call + end + + def initialize(user:, postings:) + @user = user + @postings = Array(postings) + end + + def call + if postings.length < 2 + return ServiceResult.failure( + error: "A journal entry must have at least two postings", + code: :invalid_postings + ) + end + + total_sum = 0 + normalized_postings = [] # : Array[Hash[Symbol, untyped]] + user_accounts = user.accounts.reload.index_by(&:key) + + postings.each do |p| + cents = p.amount_cents + unless cents.is_a?(Integer) && cents != 0 + return ServiceResult.failure( + error: "Posting amount must be a non-zero integer in cents", + code: :invalid_postings + ) + end + + total_sum += cents + + account = user_accounts[p.account_key] + unless account + return ServiceResult.failure( + error: "Account '#{p.account_key}' not found for user", + code: :missing_account + ) + end + + unless account.active? + return ServiceResult.failure( + error: "Account '#{p.account_key}' is inactive", + code: :inactive_account + ) + end + + # Dimension persistence validation + [ p.property, p.rentable_unit, p.tenancy, p.party ].each do |dim| + next unless dim + + unless dim.is_a?(ActiveRecord::Base) && dim.persisted? && !dim.destroyed? + return ServiceResult.failure( + error: "Dimension must be a persisted record", + code: :invalid_dimension + ) + end + end + + # Ownership validation + if p.property && p.property.user_id != user.id + return ServiceResult.failure( + error: "Property does not belong to user", + code: :ownership_mismatch + ) + end + + if p.rentable_unit && p.rentable_unit.property&.user_id != user.id + return ServiceResult.failure( + error: "Rentable unit does not belong to user", + code: :ownership_mismatch + ) + end + + if p.tenancy && p.tenancy.rentable_unit&.property&.user_id != user.id + return ServiceResult.failure( + error: "Tenancy does not belong to user", + code: :ownership_mismatch + ) + end + + if p.party && p.party.user_id != user.id + return ServiceResult.failure( + error: "Party does not belong to user", + code: :ownership_mismatch + ) + end + + # Dimension derivation & contradiction check + resolved_property = p.property + resolved_unit = p.rentable_unit + resolved_tenancy = p.tenancy + + if resolved_tenancy + expected_unit = resolved_tenancy.rentable_unit + unless expected_unit&.persisted? && !expected_unit.destroyed? + return ServiceResult.failure( + error: "Dimension must be a persisted record", + code: :invalid_dimension + ) + end + + if resolved_unit && resolved_unit.id != expected_unit.id + return ServiceResult.failure( + error: "Contradictory dimensions: tenancy does not belong to specified rentable unit", + code: :dimension_mismatch + ) + end + resolved_unit = expected_unit + + expected_prop = expected_unit.property + unless expected_prop&.persisted? && !expected_prop.destroyed? + return ServiceResult.failure( + error: "Dimension must be a persisted record", + code: :invalid_dimension + ) + end + + if resolved_property && resolved_property.id != expected_prop.id + return ServiceResult.failure( + error: "Contradictory dimensions: tenancy does not belong to specified property", + code: :dimension_mismatch + ) + end + resolved_property = expected_prop + elsif resolved_unit + expected_prop = resolved_unit.property + unless expected_prop&.persisted? && !expected_prop.destroyed? + return ServiceResult.failure( + error: "Dimension must be a persisted record", + code: :invalid_dimension + ) + end + + if resolved_property && resolved_property.id != expected_prop.id + return ServiceResult.failure( + error: "Contradictory dimensions: rentable unit does not belong to specified property", + code: :dimension_mismatch + ) + end + resolved_property = expected_prop + end + + normalized_postings << { + account: account, + account_id: account.id, + amount_cents: cents, + property_id: resolved_property&.id, + rentable_unit_id: resolved_unit&.id, + tenancy_id: resolved_tenancy&.id, + party_id: p.party&.id, + memo: p.memo + } + end + + if total_sum != 0 + return ServiceResult.failure( + error: "Journal entry is unbalanced: net sum is #{total_sum}", + code: :unbalanced_entry + ) + end + + ServiceResult.success(postings: normalized_postings) + end + + private + + attr_reader :user, :postings + end +end diff --git a/app/services/accounting/posting_spec.rb b/app/services/accounting/posting_spec.rb new file mode 100644 index 00000000..69e0a7ee --- /dev/null +++ b/app/services/accounting/posting_spec.rb @@ -0,0 +1,15 @@ +module Accounting + class PostingSpec + attr_reader :account_key, :amount_cents, :property, :rentable_unit, :tenancy, :party, :memo + + def initialize(account_key:, amount_cents:, property: nil, rentable_unit: nil, tenancy: nil, party: nil, memo: nil) + @account_key = account_key.to_s + @amount_cents = amount_cents + @property = property + @rentable_unit = rentable_unit + @tenancy = tenancy + @party = party + @memo = memo + end + end +end diff --git a/app/services/accounting/reverse_entry_service.rb b/app/services/accounting/reverse_entry_service.rb new file mode 100644 index 00000000..81a2e584 --- /dev/null +++ b/app/services/accounting/reverse_entry_service.rb @@ -0,0 +1,124 @@ +module Accounting + class ReverseEntryService + def self.call(journal_entry:, occurred_on:, description: nil) + new( + journal_entry: journal_entry, + occurred_on: occurred_on, + description: description + ).call + end + + def initialize(journal_entry:, occurred_on:, description: nil) + @journal_entry = journal_entry + @raw_occurred_on = occurred_on + @description = description + end + + def call + unless journal_entry.is_a?(JournalEntry) && journal_entry.persisted? && !journal_entry.destroyed? + return ServiceResult.failure( + error: "Journal entry must be a persisted record", + code: :invalid_source + ) + end + + occurred_on = parse_date(raw_occurred_on) + unless occurred_on + return ServiceResult.failure( + error: "Occurred on must be a valid date", + code: :invalid_input + ) + end + + if journal_entry.reversal? + return ServiceResult.failure( + error: "Cannot reverse a reversal journal entry", + code: :invalid_reversal + ) + end + + if occurred_on < journal_entry.occurred_on + return ServiceResult.failure( + error: "Reversal occurred_on cannot precede original entry date", + code: :invalid_date + ) + end + + resolved_desc = description.presence || "Reversal of entry #{journal_entry.id}" + + begin + reversal = nil + + JournalEntry.transaction do + journal_entry.lock! + + existing_reversal = journal_entry.reversal + if existing_reversal + return verify_idempotency(existing_reversal, occurred_on, resolved_desc) + end + + user = journal_entry.user + + reversal = user.journal_entries.create!( + source_type: "JournalEntry", + source_id: journal_entry.id, + event_type: "reversal", + occurred_on: occurred_on, + description: resolved_desc, + reversal_of_id: journal_entry.id, + posted_at: Time.current + ) + + journal_entry.postings.find_each do |original_posting| + reversal.postings.create!( + account_id: original_posting.account_id, + amount_cents: -original_posting.amount_cents, + property_id: original_posting.property_id, + rentable_unit_id: original_posting.rentable_unit_id, + tenancy_id: original_posting.tenancy_id, + party_id: original_posting.party_id, + memo: original_posting.memo + ) + end + end + + ServiceResult.success(journal_entry: reversal) + rescue ActiveRecord::RecordNotUnique + existing = journal_entry.reload.reversal + if existing + verify_idempotency(existing, occurred_on, resolved_desc) + else + ServiceResult.failure(error: "Reversal conflict occurred", code: :idempotency_conflict) + end + rescue ActiveRecord::RecordInvalid => e + ServiceResult.failure(error: e.record.errors.full_messages.to_sentence, code: :validation_error) + end + end + + private + + attr_reader :journal_entry, :raw_occurred_on, :description + + 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 verify_idempotency(existing, resolved_occurred_on, resolved_desc) + if existing.occurred_on == resolved_occurred_on && existing.description.to_s.strip == resolved_desc.strip + ServiceResult.success(journal_entry: existing) + else + ServiceResult.failure( + error: "Reversal already exists with different details", + code: :idempotency_conflict, + data: { journal_entry: existing } + ) + end + end + end +end diff --git a/app/services/payment_ingestions/confirm_service.rb b/app/services/payment_ingestions/confirm_service.rb index 5c7fa12a..9d9b2e50 100644 --- a/app/services/payment_ingestions/confirm_service.rb +++ b/app/services/payment_ingestions/confirm_service.rb @@ -15,15 +15,16 @@ def call return failure("Already confirmed", :already_confirmed) if ingestion.confirmed? return failure("Cannot confirm: missing required fields or duplicate exists", :not_confirmable) unless ingestion.confirmable? - payment = ingestion.transaction do + payment = nil # : TenantPayment? + ingestion.transaction do ingestion.lock! raise ConfirmationError, "Already confirmed" if ingestion.confirmed? raise ConfirmationError, "Cannot confirm: missing required fields or duplicate exists" unless ingestion.confirmable? - created_payment = create_payment + created = create_payment create_aliases if create_aliases? - ingestion.update!(status: :confirmed, tenant_payment: created_payment) - created_payment + ingestion.update!(status: :confirmed, tenant_payment: created) + payment = created end if payment diff --git a/app/services/tenancy_parties/create_service.rb b/app/services/tenancy_parties/create_service.rb index 4101299b..702d539e 100644 --- a/app/services/tenancy_parties/create_service.rb +++ b/app/services/tenancy_parties/create_service.rb @@ -23,31 +23,35 @@ def call ) end + created_party = nil Tenancy.transaction do - tenancy.reload + rentable_unit = tenancy.rentable_unit + rentable_unit.lock! if rentable_unit + tenancy.lock! role = params[:role].presence || "tenant" eff_from = params[:effective_from].presence || tenancy.commencement_date eff_until = params[:effective_until].presence || tenancy.termination_date - created_tp = tenancy.tenancy_parties.new( + tp = tenancy.tenancy_parties.new( party: party, role: role, effective_from: eff_from, effective_until: eff_until ) - if created_tp.save - ServiceResult.success({ tenancy_party: created_tp }) - else - ServiceResult.failure( - data: { tenancy_party: created_tp }, - error: created_tp.errors.full_messages.to_sentence, + unless tp.save + return ServiceResult.failure( + data: { tenancy_party: tp }, + error: tp.errors.full_messages.to_sentence, code: :validation_error ) end + created_party = tp end + + ServiceResult.success({ tenancy_party: created_party }) end private diff --git a/app/services/tenant_payments/receipt_pdf_service.rb b/app/services/tenant_payments/receipt_pdf_service.rb index 4a63843c..e80f3126 100644 --- a/app/services/tenant_payments/receipt_pdf_service.rb +++ b/app/services/tenant_payments/receipt_pdf_service.rb @@ -17,7 +17,7 @@ def call 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.text "Property: #{tenant_payment.tenancy&.property&.address}" pdf.render end diff --git a/db/cable_schema.rb b/db/cable_schema.rb index 519a3021..2e35392e 100644 --- a/db/cable_schema.rb +++ b/db/cable_schema.rb @@ -10,10 +10,23 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_08_15_000001) do +ActiveRecord::Schema[8.1].define(version: 2026_08_16_000001) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" + create_table "accounts", force: :cascade do |t| + t.string "account_type", null: false + t.boolean "active", default: true, null: false + t.datetime "created_at", null: false + t.string "key", null: false + t.string "name", null: false + t.datetime "updated_at", null: false + 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" + end + create_table "expenses", force: :cascade do |t| t.decimal "amount", precision: 12, scale: 2 t.string "category" @@ -25,6 +38,23 @@ t.index ["property_id"], name: "index_expenses_on_property_id" end + create_table "journal_entries", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "description" + t.string "event_type", null: false + t.date "occurred_on", null: false + t.datetime "posted_at", null: false + t.bigint "reversal_of_id" + t.bigint "source_id", null: false + t.string "source_type", null: false + t.bigint "user_id", null: false + t.index ["occurred_on"], name: "index_journal_entries_on_occurred_on" + t.index ["reversal_of_id"], name: "idx_journal_entries_single_reversal", unique: true, where: "(reversal_of_id IS NOT NULL)" + t.index ["user_id", "source_type", "source_id", "event_type"], name: "idx_journal_entries_source_event", unique: true + t.index ["user_id"], name: "index_journal_entries_on_user_id" + t.check_constraint "source_id > 0", name: "check_journal_entries_source_id_positive" + end + create_table "parties", force: :cascade do |t| t.datetime "created_at", null: false t.string "display_name", null: false @@ -85,6 +115,27 @@ t.index ["user_id"], name: "index_payment_ingestions_on_user_id" end + create_table "postings", force: :cascade do |t| + t.bigint "account_id", null: false + t.bigint "amount_cents", null: false + t.datetime "created_at", null: false + t.bigint "journal_entry_id", null: false + t.string "memo" + t.bigint "party_id" + t.bigint "property_id" + t.bigint "rentable_unit_id" + t.bigint "tenancy_id" + t.index ["account_id", "property_id"], name: "index_postings_on_account_id_and_property_id" + t.index ["account_id", "tenancy_id"], name: "index_postings_on_account_id_and_tenancy_id" + t.index ["account_id"], name: "index_postings_on_account_id" + t.index ["journal_entry_id"], name: "index_postings_on_journal_entry_id" + t.index ["party_id"], name: "index_postings_on_party_id" + t.index ["property_id"], name: "index_postings_on_property_id" + t.index ["rentable_unit_id"], name: "index_postings_on_rentable_unit_id" + t.index ["tenancy_id"], name: "index_postings_on_tenancy_id" + t.check_constraint "amount_cents <> 0", name: "check_postings_amount_cents_nonzero" + end + create_table "properties", force: :cascade do |t| t.string "address", null: false t.string "asset_type", null: false @@ -207,7 +258,10 @@ t.index ["email"], name: "index_users_on_email", unique: true end + add_foreign_key "accounts", "users" add_foreign_key "expenses", "properties" + add_foreign_key "journal_entries", "journal_entries", column: "reversal_of_id" + add_foreign_key "journal_entries", "users" add_foreign_key "parties", "users" add_foreign_key "party_aliases", "parties" add_foreign_key "payment_documents", "users" @@ -216,6 +270,12 @@ 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" + add_foreign_key "postings", "parties" + add_foreign_key "postings", "properties" + add_foreign_key "postings", "rentable_units" + add_foreign_key "postings", "tenancies" add_foreign_key "properties", "users" add_foreign_key "rent_terms", "tenancies" add_foreign_key "rentable_units", "properties" diff --git a/db/cache_schema.rb b/db/cache_schema.rb index e722a32f..03a6bb3e 100644 --- a/db/cache_schema.rb +++ b/db/cache_schema.rb @@ -10,10 +10,23 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_08_15_000001) do +ActiveRecord::Schema[8.1].define(version: 2026_08_16_000001) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" + create_table "accounts", force: :cascade do |t| + t.string "account_type", null: false + t.boolean "active", default: true, null: false + t.datetime "created_at", null: false + t.string "key", null: false + t.string "name", null: false + t.datetime "updated_at", null: false + 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" + end + create_table "expenses", force: :cascade do |t| t.decimal "amount", precision: 12, scale: 2 t.string "category" @@ -25,6 +38,23 @@ t.index ["property_id"], name: "index_expenses_on_property_id" end + create_table "journal_entries", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "description" + t.string "event_type", null: false + t.date "occurred_on", null: false + t.datetime "posted_at", null: false + t.bigint "reversal_of_id" + t.bigint "source_id", null: false + t.string "source_type", null: false + t.bigint "user_id", null: false + t.index ["occurred_on"], name: "index_journal_entries_on_occurred_on" + t.index ["reversal_of_id"], name: "idx_journal_entries_single_reversal", unique: true, where: "(reversal_of_id IS NOT NULL)" + t.index ["user_id", "source_type", "source_id", "event_type"], name: "idx_journal_entries_source_event", unique: true + t.index ["user_id"], name: "index_journal_entries_on_user_id" + t.check_constraint "source_id > 0", name: "check_journal_entries_source_id_positive" + end + create_table "parties", force: :cascade do |t| t.datetime "created_at", null: false t.string "display_name", null: false @@ -85,6 +115,27 @@ t.index ["user_id"], name: "index_payment_ingestions_on_user_id" end + create_table "postings", force: :cascade do |t| + t.bigint "account_id", null: false + t.bigint "amount_cents", null: false + t.datetime "created_at", null: false + t.bigint "journal_entry_id", null: false + t.string "memo" + t.bigint "party_id" + t.bigint "property_id" + t.bigint "rentable_unit_id" + t.bigint "tenancy_id" + t.index ["account_id", "property_id"], name: "index_postings_on_account_id_and_property_id" + t.index ["account_id", "tenancy_id"], name: "index_postings_on_account_id_and_tenancy_id" + t.index ["account_id"], name: "index_postings_on_account_id" + t.index ["journal_entry_id"], name: "index_postings_on_journal_entry_id" + t.index ["party_id"], name: "index_postings_on_party_id" + t.index ["property_id"], name: "index_postings_on_property_id" + t.index ["rentable_unit_id"], name: "index_postings_on_rentable_unit_id" + t.index ["tenancy_id"], name: "index_postings_on_tenancy_id" + t.check_constraint "amount_cents <> 0", name: "check_postings_amount_cents_nonzero" + end + create_table "properties", force: :cascade do |t| t.string "address", null: false t.string "asset_type", null: false @@ -208,7 +259,10 @@ t.index ["email"], name: "index_users_on_email", unique: true end + add_foreign_key "accounts", "users" add_foreign_key "expenses", "properties" + add_foreign_key "journal_entries", "journal_entries", column: "reversal_of_id" + add_foreign_key "journal_entries", "users" add_foreign_key "parties", "users" add_foreign_key "party_aliases", "parties" add_foreign_key "payment_documents", "users" @@ -217,6 +271,12 @@ 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" + add_foreign_key "postings", "parties" + add_foreign_key "postings", "properties" + add_foreign_key "postings", "rentable_units" + add_foreign_key "postings", "tenancies" add_foreign_key "properties", "users" add_foreign_key "rent_terms", "tenancies" add_foreign_key "rentable_units", "properties" diff --git a/db/migrate/20260816000001_create_accounting_foundation.rb b/db/migrate/20260816000001_create_accounting_foundation.rb new file mode 100644 index 00000000..c7c07d05 --- /dev/null +++ b/db/migrate/20260816000001_create_accounting_foundation.rb @@ -0,0 +1,82 @@ +class CreateAccountingFoundation < ActiveRecord::Migration[8.1] + def change + create_table :accounts do |t| + t.bigint :user_id, null: false + t.string :key, null: false + t.string :name, null: false + t.string :account_type, null: false + t.boolean :active, default: true, null: false + + t.timestamps + end + + add_index :accounts, :user_id + add_index :accounts, %i[user_id key], unique: true + add_foreign_key :accounts, :users + add_check_constraint :accounts, + "account_type IN ('asset', 'liability', 'equity', 'income', 'expense')", + name: "check_accounts_account_type" + + create_table :journal_entries do |t| + t.bigint :user_id, null: false + t.string :source_type, null: false + t.bigint :source_id, null: false + t.string :event_type, null: false + t.date :occurred_on, null: false + t.string :description + t.datetime :posted_at, null: false + t.bigint :reversal_of_id + + t.datetime :created_at, null: false + end + + add_index :journal_entries, :user_id + add_index :journal_entries, :occurred_on + add_index :journal_entries, %i[user_id source_type source_id event_type], + unique: true, + name: "idx_journal_entries_source_event" + add_index :journal_entries, :reversal_of_id, + unique: true, + where: "reversal_of_id IS NOT NULL", + name: "idx_journal_entries_single_reversal" + add_foreign_key :journal_entries, :users + add_foreign_key :journal_entries, :journal_entries, column: :reversal_of_id + add_check_constraint :journal_entries, + "source_id > 0", + name: "check_journal_entries_source_id_positive" + + create_table :postings do |t| + t.bigint :journal_entry_id, null: false + t.bigint :account_id, null: false + t.bigint :amount_cents, null: false + + t.bigint :property_id + t.bigint :rentable_unit_id + t.bigint :tenancy_id + t.bigint :party_id + + t.string :memo + + t.datetime :created_at, null: false + end + + add_index :postings, :journal_entry_id + add_index :postings, :account_id + add_index :postings, :property_id + add_index :postings, :rentable_unit_id + add_index :postings, :tenancy_id + add_index :postings, :party_id + add_index :postings, %i[account_id property_id] + add_index :postings, %i[account_id tenancy_id] + + add_foreign_key :postings, :journal_entries + add_foreign_key :postings, :accounts + add_foreign_key :postings, :properties + add_foreign_key :postings, :rentable_units + add_foreign_key :postings, :tenancies + add_foreign_key :postings, :parties + add_check_constraint :postings, + "amount_cents <> 0", + name: "check_postings_amount_cents_nonzero" + end +end diff --git a/db/queue_schema.rb b/db/queue_schema.rb index a2352106..c064a9fd 100644 --- a/db/queue_schema.rb +++ b/db/queue_schema.rb @@ -10,10 +10,23 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_08_15_000001) do +ActiveRecord::Schema[8.1].define(version: 2026_08_16_000001) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" + create_table "accounts", force: :cascade do |t| + t.string "account_type", null: false + t.boolean "active", default: true, null: false + t.datetime "created_at", null: false + t.string "key", null: false + t.string "name", null: false + t.datetime "updated_at", null: false + 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" + end + create_table "expenses", force: :cascade do |t| t.decimal "amount", precision: 12, scale: 2 t.string "category" @@ -25,6 +38,23 @@ t.index ["property_id"], name: "index_expenses_on_property_id" end + create_table "journal_entries", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "description" + t.string "event_type", null: false + t.date "occurred_on", null: false + t.datetime "posted_at", null: false + t.bigint "reversal_of_id" + t.bigint "source_id", null: false + t.string "source_type", null: false + t.bigint "user_id", null: false + t.index ["occurred_on"], name: "index_journal_entries_on_occurred_on" + t.index ["reversal_of_id"], name: "idx_journal_entries_single_reversal", unique: true, where: "(reversal_of_id IS NOT NULL)" + t.index ["user_id", "source_type", "source_id", "event_type"], name: "idx_journal_entries_source_event", unique: true + t.index ["user_id"], name: "index_journal_entries_on_user_id" + t.check_constraint "source_id > 0", name: "check_journal_entries_source_id_positive" + end + create_table "parties", force: :cascade do |t| t.datetime "created_at", null: false t.string "display_name", null: false @@ -85,6 +115,27 @@ t.index ["user_id"], name: "index_payment_ingestions_on_user_id" end + create_table "postings", force: :cascade do |t| + t.bigint "account_id", null: false + t.bigint "amount_cents", null: false + t.datetime "created_at", null: false + t.bigint "journal_entry_id", null: false + t.string "memo" + t.bigint "party_id" + t.bigint "property_id" + t.bigint "rentable_unit_id" + t.bigint "tenancy_id" + t.index ["account_id", "property_id"], name: "index_postings_on_account_id_and_property_id" + t.index ["account_id", "tenancy_id"], name: "index_postings_on_account_id_and_tenancy_id" + t.index ["account_id"], name: "index_postings_on_account_id" + t.index ["journal_entry_id"], name: "index_postings_on_journal_entry_id" + t.index ["party_id"], name: "index_postings_on_party_id" + t.index ["property_id"], name: "index_postings_on_property_id" + t.index ["rentable_unit_id"], name: "index_postings_on_rentable_unit_id" + t.index ["tenancy_id"], name: "index_postings_on_tenancy_id" + t.check_constraint "amount_cents <> 0", name: "check_postings_amount_cents_nonzero" + end + create_table "properties", force: :cascade do |t| t.string "address", null: false t.string "asset_type", null: false @@ -318,7 +369,10 @@ t.index ["email"], name: "index_users_on_email", unique: true end + add_foreign_key "accounts", "users" add_foreign_key "expenses", "properties" + add_foreign_key "journal_entries", "journal_entries", column: "reversal_of_id" + add_foreign_key "journal_entries", "users" add_foreign_key "parties", "users" add_foreign_key "party_aliases", "parties" add_foreign_key "payment_documents", "users" @@ -327,6 +381,12 @@ 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" + add_foreign_key "postings", "parties" + add_foreign_key "postings", "properties" + add_foreign_key "postings", "rentable_units" + add_foreign_key "postings", "tenancies" add_foreign_key "properties", "users" add_foreign_key "rent_terms", "tenancies" add_foreign_key "rentable_units", "properties" diff --git a/db/schema.rb b/db/schema.rb index b4450191..870e17f6 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,10 +10,23 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_08_15_000001) do +ActiveRecord::Schema[8.1].define(version: 2026_08_16_000001) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" + create_table "accounts", force: :cascade do |t| + t.string "account_type", null: false + t.boolean "active", default: true, null: false + t.datetime "created_at", null: false + t.string "key", null: false + t.string "name", null: false + t.datetime "updated_at", null: false + 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" + end + create_table "expenses", force: :cascade do |t| t.decimal "amount", precision: 12, scale: 2 t.string "category" @@ -25,6 +38,23 @@ t.index ["property_id"], name: "index_expenses_on_property_id" end + create_table "journal_entries", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "description" + t.string "event_type", null: false + t.date "occurred_on", null: false + t.datetime "posted_at", null: false + t.bigint "reversal_of_id" + t.bigint "source_id", null: false + t.string "source_type", null: false + t.bigint "user_id", null: false + t.index ["occurred_on"], name: "index_journal_entries_on_occurred_on" + t.index ["reversal_of_id"], name: "idx_journal_entries_single_reversal", unique: true, where: "(reversal_of_id IS NOT NULL)" + t.index ["user_id", "source_type", "source_id", "event_type"], name: "idx_journal_entries_source_event", unique: true + t.index ["user_id"], name: "index_journal_entries_on_user_id" + t.check_constraint "source_id > 0", name: "check_journal_entries_source_id_positive" + end + create_table "parties", force: :cascade do |t| t.datetime "created_at", null: false t.string "display_name", null: false @@ -85,6 +115,27 @@ t.index ["user_id"], name: "index_payment_ingestions_on_user_id" end + create_table "postings", force: :cascade do |t| + t.bigint "account_id", null: false + t.bigint "amount_cents", null: false + t.datetime "created_at", null: false + t.bigint "journal_entry_id", null: false + t.string "memo" + t.bigint "party_id" + t.bigint "property_id" + t.bigint "rentable_unit_id" + t.bigint "tenancy_id" + t.index ["account_id", "property_id"], name: "index_postings_on_account_id_and_property_id" + t.index ["account_id", "tenancy_id"], name: "index_postings_on_account_id_and_tenancy_id" + t.index ["account_id"], name: "index_postings_on_account_id" + t.index ["journal_entry_id"], name: "index_postings_on_journal_entry_id" + t.index ["party_id"], name: "index_postings_on_party_id" + t.index ["property_id"], name: "index_postings_on_property_id" + t.index ["rentable_unit_id"], name: "index_postings_on_rentable_unit_id" + t.index ["tenancy_id"], name: "index_postings_on_tenancy_id" + t.check_constraint "amount_cents <> 0", name: "check_postings_amount_cents_nonzero" + end + create_table "properties", force: :cascade do |t| t.string "address", null: false t.string "asset_type", null: false @@ -197,7 +248,10 @@ t.index ["email"], name: "index_users_on_email", unique: true end + add_foreign_key "accounts", "users" add_foreign_key "expenses", "properties" + add_foreign_key "journal_entries", "journal_entries", column: "reversal_of_id" + add_foreign_key "journal_entries", "users" add_foreign_key "parties", "users" add_foreign_key "party_aliases", "parties" add_foreign_key "payment_documents", "users" @@ -206,6 +260,12 @@ 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" + add_foreign_key "postings", "parties" + add_foreign_key "postings", "properties" + add_foreign_key "postings", "rentable_units" + add_foreign_key "postings", "tenancies" add_foreign_key "properties", "users" add_foreign_key "rent_terms", "tenancies" add_foreign_key "rentable_units", "properties" diff --git a/db/seeds.rb b/db/seeds.rb index 4f9f7c7c..5614176d 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -6,6 +6,8 @@ user = User.find_by(email: "me@kylechong.com") if user.nil? user = User.create!(email: "me@kylechong.com", password: "password123") + else + Accounting::ChartOfAccounts.ensure_for(user) end property = Property.create!(user: user, address: "1#{rand(1000)} Main St", asset_type: "single_family", square_footage: 1500) diff --git a/documentation/accounting_architecture.md b/documentation/accounting_architecture.md new file mode 100644 index 00000000..08f86f00 --- /dev/null +++ b/documentation/accounting_architecture.md @@ -0,0 +1,64 @@ +# Double-Entry Accounting Engine Architecture (Milestone 2) + +## Overview + +Milestone 2 introduces the core double-entry accounting foundation into Yanushi. The accounting subsystem is ledger-based, immutable, strictly balanced, and idempotent. + +## Core Models + +### 1. Account (`accounts`) +- **Ownership**: Belongs to `User`. +- **Account Types**: `asset`, `liability`, `equity`, `income`, `expense`. +- **Fields**: `user_id`, `key`, `name`, `account_type`, `active`, `created_at`, `updated_at`. +- **Immutability**: `user_id`, `key`, and `account_type` are immutable once created. +- **System Accounts**: Provisioned automatically on `User` creation via `Accounting::ChartOfAccounts.ensure_for(user)`. + +### 2. JournalEntry (`journal_entries`) +- **Ownership**: Belongs to `User`. +- **Source Event Uniqueness**: Unique index on `(user_id, source_type, source_id, event_type)`. +- **Reversal Tracking**: Optional `reversal_of_id` uniquely indexed to enforce at most one reversal per entry. +- **Fields**: `user_id`, `source_type`, `source_id`, `event_type`, `occurred_on`, `posted_at`, `description`, `reversal_of_id`, `created_at`. +- **Immutability**: No `updated_at` column. `before_update` and `before_destroy` abort modifications on persisted records. + +### 3. Posting (`postings`) +- **Signed Amounts**: `amount_cents` (bigint) where positive is debit, negative is credit. Database check constraint enforces `amount_cents <> 0`. +- **Dimensions**: + - `property_id` (`Property`) + - `rentable_unit_id` (`RentableUnit`) + - `tenancy_id` (`Tenancy`) + - `party_id` (`Party`) + - `memo` (`text`) +- **Dimension Hierarchy**: Automatically derived and validated (`Tenancy` -> `RentableUnit` -> `Property`). Contradictory dimensions or cross-user dimensions are rejected. +- **Immutability**: No `updated_at` column. `before_update` and `before_destroy` abort modifications on persisted records. + +## Key Services + +### `Accounting::ChartOfAccounts` +- `Accounting::ChartOfAccounts.ensure_for(user)` ensures the 17 standard system accounts exist for a user. +- Idempotent and safe to run repeatedly. + +### `Accounting::PostingSpec` +- Pure value object representing an unpersisted posting specification (`account_key`, `amount_cents`, `property`, `rentable_unit`, `tenancy`, `party`, `memo`). + +### `Accounting::PostingBuilder` +- Validates at least 2 lines, non-zero amounts, and balanced net sum ($\sum \text{amount\_cents} = 0$). +- Resolves account keys against active user accounts. +- Derives and validates dimension hierarchies and ownership. + +### `Accounting::PostEntryService` +- Orchestrates entry creation in a single database transaction. +- Compares existing entries on identical source identity: returns existing entry if payload matches exactly, or fails with `:idempotency_conflict` if payload differs. +- Handles concurrent insertion collisions (`ActiveRecord::RecordNotUnique`) seamlessly. + +### `Accounting::ReverseEntryService` +- Reverses an existing journal entry atomically by creating a new `JournalEntry` linked via `reversal_of_id`. +- Replicates all posting dimensions while negating `amount_cents` (`amount_cents * -1`). +- Reversal-of-reversal is forbidden. Idempotent on repeat execution. + +## Invariants & Database Constraints + +- Check constraints in PostgreSQL: + - `check_accounts_account_type`: `account_type IN ('asset', 'liability', 'equity', 'income', 'expense')` + - `check_postings_amount_cents_nonzero`: `amount_cents <> 0` + - `check_journal_entries_source_id_positive`: `source_id > 0` +- Deletion Protection: Foreign keys on dimensions restrict cascading deletion of properties, rentable units, tenancies, or parties referenced in accounting postings. diff --git a/sig/app/models/account.rbs b/sig/app/models/account.rbs new file mode 100644 index 00000000..3dd90f1d --- /dev/null +++ b/sig/app/models/account.rbs @@ -0,0 +1,8 @@ +class Account < ApplicationRecord + ACCOUNT_TYPES: Array[String] + def accounting_user: () -> User? + + private + + def identity_fields_immutable: () -> void +end diff --git a/sig/app/models/expense.rbs b/sig/app/models/expense.rbs index 024e5d78..0e080e15 100644 --- a/sig/app/models/expense.rbs +++ b/sig/app/models/expense.rbs @@ -11,4 +11,5 @@ class Expense < ApplicationRecord def reimburse_lease_id=: (untyped val) -> void def reimburse_amount: () -> (BigDecimal | String) + def accounting_user: () -> User? end diff --git a/sig/app/models/journal_entry.rbs b/sig/app/models/journal_entry.rbs new file mode 100644 index 00000000..af458d57 --- /dev/null +++ b/sig/app/models/journal_entry.rbs @@ -0,0 +1,9 @@ +class JournalEntry < ApplicationRecord + def reversed?: () -> bool + def reversal?: () -> bool + def accounting_user: () -> User? + + private + + def prevent_mutation: () -> void +end diff --git a/sig/app/models/party.rbs b/sig/app/models/party.rbs index 111f365e..c0b2f57a 100644 --- a/sig/app/models/party.rbs +++ b/sig/app/models/party.rbs @@ -2,4 +2,5 @@ class Party < ApplicationRecord PARTY_TYPES: Array[String] def alias_candidate?: (String? alias_name) -> bool + def accounting_user: () -> User? end diff --git a/sig/app/models/party_alias.rbs b/sig/app/models/party_alias.rbs index 34bca754..5f1e7680 100644 --- a/sig/app/models/party_alias.rbs +++ b/sig/app/models/party_alias.rbs @@ -1,2 +1,3 @@ class PartyAlias < ApplicationRecord + def accounting_user: () -> User? end diff --git a/sig/app/models/payment_document.rbs b/sig/app/models/payment_document.rbs new file mode 100644 index 00000000..f0728e30 --- /dev/null +++ b/sig/app/models/payment_document.rbs @@ -0,0 +1,3 @@ +class PaymentDocument < ApplicationRecord + def accounting_user: () -> User? +end diff --git a/sig/app/models/payment_ingestion.rbs b/sig/app/models/payment_ingestion.rbs index 2c2aeeb2..a1695763 100644 --- a/sig/app/models/payment_ingestion.rbs +++ b/sig/app/models/payment_ingestion.rbs @@ -12,6 +12,7 @@ class PaymentIngestion def attachment_attached?: () -> bool def attachment_image?: () -> bool? + def accounting_user: () -> User? private diff --git a/sig/app/models/posting.rbs b/sig/app/models/posting.rbs new file mode 100644 index 00000000..04ed239d --- /dev/null +++ b/sig/app/models/posting.rbs @@ -0,0 +1,14 @@ +class Posting < ApplicationRecord + def debit?: () -> bool + def credit?: () -> bool + def debit_amount: () -> Integer? + def credit_amount: () -> Integer? + def accounting_user: () -> User? + + private + + def prevent_mutation: () -> void + def account_belongs_to_journal_entry_user: () -> void + def dimensions_belong_to_journal_entry_user: () -> void + def dimension_hierarchy_coherent: () -> void +end diff --git a/sig/app/models/property.rbs b/sig/app/models/property.rbs index 22b2b05f..f4193965 100644 --- a/sig/app/models/property.rbs +++ b/sig/app/models/property.rbs @@ -4,4 +4,5 @@ class Property < ApplicationRecord def financial_items: (*untyped, ?year: Integer?) -> Array[untyped] def active_years: (?Array[untyped]) -> Array[Integer] def schedule_e_summary: (*untyped, ?year: Integer?) -> Properties::ScheduleESummaryQuery::Result + def accounting_user: () -> User? end diff --git a/sig/app/models/rent_term.rbs b/sig/app/models/rent_term.rbs index 67d6e9b9..eec70f63 100644 --- a/sig/app/models/rent_term.rbs +++ b/sig/app/models/rent_term.rbs @@ -6,6 +6,7 @@ class RentTerm < ApplicationRecord def amount_cents=: (Integer?) -> Integer? def active?: (?Date date, ?as_of: untyped) -> bool def due_date_for: (Integer year, Integer month) -> Date + def accounting_user: () -> User? private diff --git a/sig/app/models/rentable_unit.rbs b/sig/app/models/rentable_unit.rbs index b8ffaf5c..bacdc782 100644 --- a/sig/app/models/rentable_unit.rbs +++ b/sig/app/models/rentable_unit.rbs @@ -1,4 +1,5 @@ class RentableUnit < ApplicationRecord def display_name: () -> String def occupied?: (?Date as_of) -> bool + def accounting_user: () -> User? end diff --git a/sig/app/models/scheduled_rent.rbs b/sig/app/models/scheduled_rent.rbs index 6337e10f..be08f159 100644 --- a/sig/app/models/scheduled_rent.rbs +++ b/sig/app/models/scheduled_rent.rbs @@ -4,4 +4,5 @@ class ScheduledRent def late?: (?as_of: Date) -> bool def display_name: () -> String + def accounting_user: () -> User? end diff --git a/sig/app/models/session.rbs b/sig/app/models/session.rbs new file mode 100644 index 00000000..911c46a3 --- /dev/null +++ b/sig/app/models/session.rbs @@ -0,0 +1,3 @@ +class Session < ApplicationRecord + def accounting_user: () -> User? +end diff --git a/sig/app/models/tenancy.rbs b/sig/app/models/tenancy.rbs index 1cc18c76..98f7cc97 100644 --- a/sig/app/models/tenancy.rbs +++ b/sig/app/models/tenancy.rbs @@ -10,6 +10,7 @@ class Tenancy < ApplicationRecord def total_debits: (?as_of: Date) -> BigDecimal def balance_as_of: (?Date date) -> BigDecimal def current_balance: () -> BigDecimal + def accounting_user: () -> User? private diff --git a/sig/app/models/tenancy_party.rbs b/sig/app/models/tenancy_party.rbs index bbbde918..348ed5ff 100644 --- a/sig/app/models/tenancy_party.rbs +++ b/sig/app/models/tenancy_party.rbs @@ -2,6 +2,7 @@ class TenancyParty < ApplicationRecord ROLES: Array[String] def active?: (?Date date, ?as_of: untyped) -> bool + def accounting_user: () -> User? private diff --git a/sig/app/models/tenant_charge.rbs b/sig/app/models/tenant_charge.rbs new file mode 100644 index 00000000..8dc11a3a --- /dev/null +++ b/sig/app/models/tenant_charge.rbs @@ -0,0 +1,3 @@ +class TenantCharge < ApplicationRecord + def accounting_user: () -> User? +end diff --git a/sig/app/models/tenant_payment.rbs b/sig/app/models/tenant_payment.rbs index 991c1905..aab53923 100644 --- a/sig/app/models/tenant_payment.rbs +++ b/sig/app/models/tenant_payment.rbs @@ -1,7 +1,10 @@ -class TenantPayment - private +class TenantPayment < ApplicationRecord + def amount: () -> BigDecimal + def amount=: (BigDecimal | Numeric | String | nil) -> untyped + def accounting_user: () -> User? - def assign_user_from_tenancy: () -> untyped + private - def user_matches_tenancy_owner: () -> untyped + def assign_user_from_tenancy: () -> void + def user_matches_tenancy_owner: () -> void end diff --git a/sig/app/models/user.rbs b/sig/app/models/user.rbs index 3111b954..e107b61c 100644 --- a/sig/app/models/user.rbs +++ b/sig/app/models/user.rbs @@ -1,3 +1,5 @@ class User def self.find_by_password_reset_token!: (untyped token) -> User + def provision_chart_of_accounts: () -> untyped + def accounting_user: () -> User end diff --git a/sig/app/queries/properties/financial_items_query.rbs b/sig/app/queries/properties/financial_items_query.rbs index ced00c8d..604d586e 100644 --- a/sig/app/queries/properties/financial_items_query.rbs +++ b/sig/app/queries/properties/financial_items_query.rbs @@ -1,13 +1,13 @@ module Properties class FinancialItemsQuery def initialize: (property: Property) -> void - def call: (year: Integer | String) -> Array[untyped] + def call: (year: Integer | String) -> Array[Hash[Symbol, untyped]] private attr_reader property: Property - def items_for: (Symbol association_name, Symbol date_attribute, String type, Date start_date, Date end_date) -> Array[untyped] + def items_for: (Symbol association_name, Symbol date_attribute, String type, Date start_date, Date end_date) -> Array[Hash[Symbol, untyped]] def records_for: (Symbol association_name, Symbol date_attribute, Date start_date, Date end_date) -> untyped end end diff --git a/sig/app/services/accounting/chart_of_accounts.rbs b/sig/app/services/accounting/chart_of_accounts.rbs new file mode 100644 index 00000000..c4190d27 --- /dev/null +++ b/sig/app/services/accounting/chart_of_accounts.rbs @@ -0,0 +1,17 @@ +module Accounting + class ChartOfAccounts + class AccountTypeMismatchError < StandardError + end + + SYSTEM_ACCOUNTS: Array[Hash[Symbol, String]] + SYSTEM_KEYS: Array[String] + + def self.ensure_for: (User user) -> untyped + def initialize: (User user) -> void + def ensure_accounts: () -> untyped + + private + + attr_reader user: User + end +end diff --git a/sig/app/services/accounting/post_entry_service.rbs b/sig/app/services/accounting/post_entry_service.rbs new file mode 100644 index 00000000..ce09907e --- /dev/null +++ b/sig/app/services/accounting/post_entry_service.rbs @@ -0,0 +1,19 @@ +module Accounting + class PostEntryService + def self.call: (source: untyped, event_type: untyped, occurred_on: Date | String | untyped, postings: Array[PostingSpec] | untyped, ?user: User?, ?description: String?) -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure) + def initialize: (source: untyped, event_type: untyped, occurred_on: Date | String | untyped, postings: Array[PostingSpec] | untyped, ?user: User?, ?description: String?) -> void + def call: () -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure) + + private + + attr_reader user: User? + attr_reader source: untyped + attr_reader event_type: String + attr_reader raw_occurred_on: untyped + attr_reader postings: Array[PostingSpec] | untyped + attr_reader description: String? + + def parse_date: (untyped val) -> Date? + def verify_idempotency: (JournalEntry entry, Date resolved_occurred_on, Array[Hash[Symbol, untyped]] requested_postings) -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure) + end +end diff --git a/sig/app/services/accounting/posting_builder.rbs b/sig/app/services/accounting/posting_builder.rbs new file mode 100644 index 00000000..c5512b6b --- /dev/null +++ b/sig/app/services/accounting/posting_builder.rbs @@ -0,0 +1,12 @@ +module Accounting + class PostingBuilder + def self.call: (user: User, postings: Array[PostingSpec] | untyped) -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure) + def initialize: (user: User, postings: Array[PostingSpec] | untyped) -> void + def call: () -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure) + + private + + attr_reader user: User + attr_reader postings: Array[PostingSpec] + end +end diff --git a/sig/app/services/accounting/posting_spec.rbs b/sig/app/services/accounting/posting_spec.rbs new file mode 100644 index 00000000..748bb310 --- /dev/null +++ b/sig/app/services/accounting/posting_spec.rbs @@ -0,0 +1,13 @@ +module Accounting + class PostingSpec + attr_reader account_key: String + attr_reader amount_cents: Integer + attr_reader property: Property? + attr_reader rentable_unit: RentableUnit? + attr_reader tenancy: Tenancy? + attr_reader party: Party? + attr_reader memo: String? + + def initialize: (account_key: untyped, amount_cents: Integer, ?property: Property?, ?rentable_unit: RentableUnit?, ?tenancy: Tenancy?, ?party: Party?, ?memo: String?) -> void + end +end diff --git a/sig/app/services/accounting/reverse_entry_service.rbs b/sig/app/services/accounting/reverse_entry_service.rbs new file mode 100644 index 00000000..1e531f24 --- /dev/null +++ b/sig/app/services/accounting/reverse_entry_service.rbs @@ -0,0 +1,16 @@ +module Accounting + class ReverseEntryService + def self.call: (journal_entry: JournalEntry, occurred_on: Date | String | untyped, ?description: String?) -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure) + def initialize: (journal_entry: JournalEntry, occurred_on: Date | String | untyped, ?description: String?) -> void + def call: () -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure) + + private + + attr_reader journal_entry: JournalEntry + attr_reader raw_occurred_on: untyped + attr_reader description: String? + + def parse_date: (untyped val) -> Date? + def verify_idempotency: (JournalEntry existing, Date resolved_occurred_on, String resolved_desc) -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure) + end +end diff --git a/sig/rbs_rails/app/models/account.rbs b/sig/rbs_rails/app/models/account.rbs new file mode 100644 index 00000000..0f66f3e0 --- /dev/null +++ b/sig/rbs_rails/app/models/account.rbs @@ -0,0 +1,468 @@ +# resolve-type-names: false + +class ::Account < ::ApplicationRecord + extend ::ActiveRecord::Base::ClassMethods[::Account, ::Account::ActiveRecord_Relation, ::Integer] + + module ::Account::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 key: () -> ::String + + def key=: (::String) -> ::String + + def key?: () -> bool + + def key_changed?: (?from: ::String?, ?to: ::String?) -> bool + + def key_change: () -> [ ::String?, ::String? ] + + def key_will_change!: () -> void + + def key_was: () -> ::String? + + def key_previously_changed?: (?from: ::String?, ?to: ::String?) -> bool + + def key_previous_change: () -> ::Array[::String?]? + + def key_previously_was: () -> ::String? + + def key_before_last_save: () -> ::String? + + def key_change_to_be_saved: () -> ::Array[::String?]? + + def key_in_database: () -> ::String? + + def saved_change_to_key: () -> ::Array[::String?]? + + def saved_change_to_key?: (?from: ::String?, ?to: ::String?) -> bool + + def will_save_change_to_key?: (?from: ::String?, ?to: ::String?) -> bool + + def restore_key!: () -> void + + def clear_key_change: () -> void + + def key_before_type_cast: () -> ::String + + def key_for_database: () -> ::String + + def name: () -> ::String + + def name=: (::String) -> ::String + + def name?: () -> bool + + def name_changed?: (?from: ::String?, ?to: ::String?) -> bool + + def name_change: () -> [ ::String?, ::String? ] + + def name_will_change!: () -> void + + def name_was: () -> ::String? + + def name_previously_changed?: (?from: ::String?, ?to: ::String?) -> bool + + def name_previous_change: () -> ::Array[::String?]? + + def name_previously_was: () -> ::String? + + def name_before_last_save: () -> ::String? + + def name_change_to_be_saved: () -> ::Array[::String?]? + + def name_in_database: () -> ::String? + + def saved_change_to_name: () -> ::Array[::String?]? + + def saved_change_to_name?: (?from: ::String?, ?to: ::String?) -> bool + + def will_save_change_to_name?: (?from: ::String?, ?to: ::String?) -> bool + + def restore_name!: () -> void + + def clear_name_change: () -> void + + def name_before_type_cast: () -> ::String + + def name_for_database: () -> ::String + + def account_type: () -> ::String + + def account_type=: (::String | ::Symbol) -> ::String + + def account_type?: () -> bool + + def account_type_changed?: (?from: ::String?, ?to: ::String?) -> bool + + def account_type_change: () -> [ ::String?, ::String? ] + + def account_type_will_change!: () -> void + + def account_type_was: () -> ::String? + + def account_type_previously_changed?: (?from: ::String?, ?to: ::String?) -> bool + + def account_type_previous_change: () -> ::Array[::String?]? + + def account_type_previously_was: () -> ::String? + + def account_type_before_last_save: () -> ::String? + + def account_type_change_to_be_saved: () -> ::Array[::String?]? + + def account_type_in_database: () -> ::String? + + def saved_change_to_account_type: () -> ::Array[::String?]? + + def saved_change_to_account_type?: (?from: ::String?, ?to: ::String?) -> bool + + def will_save_change_to_account_type?: (?from: ::String?, ?to: ::String?) -> bool + + def restore_account_type!: () -> void + + def clear_account_type_change: () -> void + + def account_type_before_type_cast: () -> ::String + + def account_type_for_database: () -> ::String + + def active: () -> bool + + def active=: (bool) -> bool + + def active?: () -> bool + + def active_changed?: (?from: bool?, ?to: bool?) -> bool + + def active_change: () -> [ bool?, bool? ] + + def active_will_change!: () -> void + + def active_was: () -> bool? + + def active_previously_changed?: (?from: bool?, ?to: bool?) -> bool + + def active_previous_change: () -> ::Array[bool?]? + + def active_previously_was: () -> bool? + + def active_before_last_save: () -> bool? + + def active_change_to_be_saved: () -> ::Array[bool?]? + + def active_in_database: () -> bool? + + def saved_change_to_active: () -> ::Array[bool?]? + + def saved_change_to_active?: (?from: bool?, ?to: bool?) -> bool + + def will_save_change_to_active?: (?from: bool?, ?to: bool?) -> bool + + def restore_active!: () -> void + + def clear_active_change: () -> void + + def active_before_type_cast: () -> bool + + def active_for_database: () -> bool + + 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 ::Account::GeneratedAttributeMethods + module ::Account::GeneratedAliasAttributeMethods + include ::Account::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 ::Account::GeneratedAliasAttributeMethods + def postings: () -> ::Posting::ActiveRecord_Associations_CollectionProxy + def postings=: (::Posting::ActiveRecord_Associations_CollectionProxy | ::Array[::Posting]) -> (::Posting::ActiveRecord_Associations_CollectionProxy | ::Array[::Posting]) + def posting_ids: () -> ::Array[::Integer] + def posting_ids=: (::Array[::Integer]) -> ::Array[::Integer] + + def 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 + + module ::Account::GeneratedAssociationMethods + end + include ::Account::GeneratedAssociationMethods + + def asset!: () -> bool + def asset?: () -> bool + def liability!: () -> bool + def liability?: () -> bool + def equity!: () -> bool + def equity?: () -> bool + def income!: () -> bool + def income?: () -> bool + def expense!: () -> bool + def expense?: () -> bool + def self.account_types: () -> ::ActiveSupport::HashWithIndifferentAccess[::String, ::String] + def self.asset: () -> ::Account::ActiveRecord_Relation + def self.not_asset: () -> ::Account::ActiveRecord_Relation + def self.liability: () -> ::Account::ActiveRecord_Relation + def self.not_liability: () -> ::Account::ActiveRecord_Relation + def self.equity: () -> ::Account::ActiveRecord_Relation + def self.not_equity: () -> ::Account::ActiveRecord_Relation + def self.income: () -> ::Account::ActiveRecord_Relation + def self.not_income: () -> ::Account::ActiveRecord_Relation + def self.expense: () -> ::Account::ActiveRecord_Relation + def self.not_expense: () -> ::Account::ActiveRecord_Relation + + module ::Account::GeneratedRelationMethods + def account_types: () -> ::ActiveSupport::HashWithIndifferentAccess[::String, ::String] + + def asset: () -> ::Account::ActiveRecord_Relation + + def not_asset: () -> ::Account::ActiveRecord_Relation + + def liability: () -> ::Account::ActiveRecord_Relation + + def not_liability: () -> ::Account::ActiveRecord_Relation + + def equity: () -> ::Account::ActiveRecord_Relation + + def not_equity: () -> ::Account::ActiveRecord_Relation + + def income: () -> ::Account::ActiveRecord_Relation + + def not_income: () -> ::Account::ActiveRecord_Relation + + def expense: () -> ::Account::ActiveRecord_Relation + + def not_expense: () -> ::Account::ActiveRecord_Relation + end + + class ::Account::ActiveRecord_Relation < ::ActiveRecord::Relation + include ::Enumerable[::Account] + include ::Account::GeneratedRelationMethods + include ::ActiveRecord::Relation::Methods[::Account, ::Integer] + end + + class ::Account::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy + include ::Enumerable[::Account] + include ::Account::GeneratedRelationMethods + include ::ActiveRecord::Relation::Methods[::Account, ::Integer] + + def build: (?::ActiveRecord::Associations::CollectionProxy::_EachPair attributes) ?{ () -> untyped } -> ::Account + | (::Array[::ActiveRecord::Associations::CollectionProxy::_EachPair] attributes) ?{ () -> untyped } -> ::Array[::Account] + def create: (?::ActiveRecord::Associations::CollectionProxy::_EachPair attributes) ?{ () -> untyped } -> ::Account + | (::Array[::ActiveRecord::Associations::CollectionProxy::_EachPair] attributes) ?{ () -> untyped } -> ::Array[::Account] + def create!: (?::ActiveRecord::Associations::CollectionProxy::_EachPair attributes) ?{ () -> untyped } -> ::Account + | (::Array[::ActiveRecord::Associations::CollectionProxy::_EachPair] attributes) ?{ () -> untyped } -> ::Array[::Account] + def reload: () -> ::Array[::Account] + + def replace: (::Array[::Account]) -> void + def delete: (*::Account | ::Integer) -> ::Array[::Account] + def destroy: (*::Account | ::Integer) -> ::Array[::Account] + def <<: (*::Account | ::Array[::Account]) -> self + def prepend: (*::Account | ::Array[::Account]) -> self + end +end + +class ::ApplicationRecord < ::ActiveRecord::Base +end +class ::Posting < ::ApplicationRecord +end +class ::Posting::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy +end +class ::User < ::ApplicationRecord +end diff --git a/sig/rbs_rails/app/models/journal_entry.rbs b/sig/rbs_rails/app/models/journal_entry.rbs new file mode 100644 index 00000000..53440963 --- /dev/null +++ b/sig/rbs_rails/app/models/journal_entry.rbs @@ -0,0 +1,519 @@ +# resolve-type-names: false + +class ::JournalEntry < ::ApplicationRecord + extend ::ActiveRecord::Base::ClassMethods[::JournalEntry, ::JournalEntry::ActiveRecord_Relation, ::Integer] + + module ::JournalEntry::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 source_type: () -> ::String + + def source_type=: (::String) -> ::String + + def source_type?: () -> bool + + def source_type_changed?: (?from: ::String?, ?to: ::String?) -> bool + + def source_type_change: () -> [ ::String?, ::String? ] + + def source_type_will_change!: () -> void + + def source_type_was: () -> ::String? + + def source_type_previously_changed?: (?from: ::String?, ?to: ::String?) -> bool + + def source_type_previous_change: () -> ::Array[::String?]? + + def source_type_previously_was: () -> ::String? + + def source_type_before_last_save: () -> ::String? + + def source_type_change_to_be_saved: () -> ::Array[::String?]? + + def source_type_in_database: () -> ::String? + + def saved_change_to_source_type: () -> ::Array[::String?]? + + def saved_change_to_source_type?: (?from: ::String?, ?to: ::String?) -> bool + + def will_save_change_to_source_type?: (?from: ::String?, ?to: ::String?) -> bool + + def restore_source_type!: () -> void + + def clear_source_type_change: () -> void + + def source_type_before_type_cast: () -> ::String + + def source_type_for_database: () -> ::String + + def source_id: () -> ::Integer + + def source_id=: (::Integer) -> ::Integer + + def source_id?: () -> bool + + def source_id_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def source_id_change: () -> [ ::Integer?, ::Integer? ] + + def source_id_will_change!: () -> void + + def source_id_was: () -> ::Integer? + + def source_id_previously_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def source_id_previous_change: () -> ::Array[::Integer?]? + + def source_id_previously_was: () -> ::Integer? + + def source_id_before_last_save: () -> ::Integer? + + def source_id_change_to_be_saved: () -> ::Array[::Integer?]? + + def source_id_in_database: () -> ::Integer? + + def saved_change_to_source_id: () -> ::Array[::Integer?]? + + def saved_change_to_source_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def will_save_change_to_source_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def restore_source_id!: () -> void + + def clear_source_id_change: () -> void + + def source_id_before_type_cast: () -> ::Integer + + def source_id_for_database: () -> ::Integer + + def event_type: () -> ::String + + def event_type=: (::String) -> ::String + + def event_type?: () -> bool + + def event_type_changed?: (?from: ::String?, ?to: ::String?) -> bool + + def event_type_change: () -> [ ::String?, ::String? ] + + def event_type_will_change!: () -> void + + def event_type_was: () -> ::String? + + def event_type_previously_changed?: (?from: ::String?, ?to: ::String?) -> bool + + def event_type_previous_change: () -> ::Array[::String?]? + + def event_type_previously_was: () -> ::String? + + def event_type_before_last_save: () -> ::String? + + def event_type_change_to_be_saved: () -> ::Array[::String?]? + + def event_type_in_database: () -> ::String? + + def saved_change_to_event_type: () -> ::Array[::String?]? + + def saved_change_to_event_type?: (?from: ::String?, ?to: ::String?) -> bool + + def will_save_change_to_event_type?: (?from: ::String?, ?to: ::String?) -> bool + + def restore_event_type!: () -> void + + def clear_event_type_change: () -> void + + def event_type_before_type_cast: () -> ::String + + def event_type_for_database: () -> ::String + + def occurred_on: () -> ::Date + + def occurred_on=: (::Date) -> ::Date + + def occurred_on?: () -> bool + + def occurred_on_changed?: (?from: ::Date?, ?to: ::Date?) -> bool + + def occurred_on_change: () -> [ ::Date?, ::Date? ] + + def occurred_on_will_change!: () -> void + + def occurred_on_was: () -> ::Date? + + def occurred_on_previously_changed?: (?from: ::Date?, ?to: ::Date?) -> bool + + def occurred_on_previous_change: () -> ::Array[::Date?]? + + def occurred_on_previously_was: () -> ::Date? + + def occurred_on_before_last_save: () -> ::Date? + + def occurred_on_change_to_be_saved: () -> ::Array[::Date?]? + + def occurred_on_in_database: () -> ::Date? + + def saved_change_to_occurred_on: () -> ::Array[::Date?]? + + def saved_change_to_occurred_on?: (?from: ::Date?, ?to: ::Date?) -> bool + + def will_save_change_to_occurred_on?: (?from: ::Date?, ?to: ::Date?) -> bool + + def restore_occurred_on!: () -> void + + def clear_occurred_on_change: () -> void + + def occurred_on_before_type_cast: () -> ::Date + + def occurred_on_for_database: () -> ::Date + + def description: () -> ::String? + + def description=: (::String?) -> ::String? + + def description?: () -> bool + + def description_changed?: (?from: ::String?, ?to: ::String?) -> bool + + def description_change: () -> [ ::String?, ::String? ] + + def description_will_change!: () -> void + + def description_was: () -> ::String? + + def description_previously_changed?: (?from: ::String?, ?to: ::String?) -> bool + + def description_previous_change: () -> ::Array[::String?]? + + def description_previously_was: () -> ::String? + + def description_before_last_save: () -> ::String? + + def description_change_to_be_saved: () -> ::Array[::String?]? + + def description_in_database: () -> ::String? + + def saved_change_to_description: () -> ::Array[::String?]? + + def saved_change_to_description?: (?from: ::String?, ?to: ::String?) -> bool + + def will_save_change_to_description?: (?from: ::String?, ?to: ::String?) -> bool + + def restore_description!: () -> void + + def clear_description_change: () -> void + + def description_before_type_cast: () -> ::String? + + def description_for_database: () -> ::String? + + def posted_at: () -> ::ActiveSupport::TimeWithZone + + def posted_at=: (::ActiveSupport::TimeWithZone) -> ::ActiveSupport::TimeWithZone + + def posted_at?: () -> bool + + def posted_at_changed?: (?from: ::ActiveSupport::TimeWithZone?, ?to: ::ActiveSupport::TimeWithZone?) -> bool + + def posted_at_change: () -> [ ::ActiveSupport::TimeWithZone?, ::ActiveSupport::TimeWithZone? ] + + def posted_at_will_change!: () -> void + + def posted_at_was: () -> ::ActiveSupport::TimeWithZone? + + def posted_at_previously_changed?: (?from: ::ActiveSupport::TimeWithZone?, ?to: ::ActiveSupport::TimeWithZone?) -> bool + + def posted_at_previous_change: () -> ::Array[::ActiveSupport::TimeWithZone?]? + + def posted_at_previously_was: () -> ::ActiveSupport::TimeWithZone? + + def posted_at_before_last_save: () -> ::ActiveSupport::TimeWithZone? + + def posted_at_change_to_be_saved: () -> ::Array[::ActiveSupport::TimeWithZone?]? + + def posted_at_in_database: () -> ::ActiveSupport::TimeWithZone? + + def saved_change_to_posted_at: () -> ::Array[::ActiveSupport::TimeWithZone?]? + + def saved_change_to_posted_at?: (?from: ::ActiveSupport::TimeWithZone?, ?to: ::ActiveSupport::TimeWithZone?) -> bool + + def will_save_change_to_posted_at?: (?from: ::ActiveSupport::TimeWithZone?, ?to: ::ActiveSupport::TimeWithZone?) -> bool + + def restore_posted_at!: () -> void + + def clear_posted_at_change: () -> void + + def posted_at_before_type_cast: () -> ::Time + + def posted_at_for_database: () -> ::Time + + def reversal_of_id: () -> ::Integer? + + def reversal_of_id=: (::Integer?) -> ::Integer? + + def reversal_of_id?: () -> bool + + def reversal_of_id_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def reversal_of_id_change: () -> [ ::Integer?, ::Integer? ] + + def reversal_of_id_will_change!: () -> void + + def reversal_of_id_was: () -> ::Integer? + + def reversal_of_id_previously_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def reversal_of_id_previous_change: () -> ::Array[::Integer?]? + + def reversal_of_id_previously_was: () -> ::Integer? + + def reversal_of_id_before_last_save: () -> ::Integer? + + def reversal_of_id_change_to_be_saved: () -> ::Array[::Integer?]? + + def reversal_of_id_in_database: () -> ::Integer? + + def saved_change_to_reversal_of_id: () -> ::Array[::Integer?]? + + def saved_change_to_reversal_of_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def will_save_change_to_reversal_of_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def restore_reversal_of_id!: () -> void + + def clear_reversal_of_id_change: () -> void + + def reversal_of_id_before_type_cast: () -> ::Integer? + + def reversal_of_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 + end + include ::JournalEntry::GeneratedAttributeMethods + module ::JournalEntry::GeneratedAliasAttributeMethods + include ::JournalEntry::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 ::JournalEntry::GeneratedAliasAttributeMethods + def postings: () -> ::Posting::ActiveRecord_Associations_CollectionProxy + def postings=: (::Posting::ActiveRecord_Associations_CollectionProxy | ::Array[::Posting]) -> (::Posting::ActiveRecord_Associations_CollectionProxy | ::Array[::Posting]) + def posting_ids: () -> ::Array[::Integer] + def posting_ids=: (::Array[::Integer]) -> ::Array[::Integer] + + def reversal: () -> ::JournalEntry? + def reversal=: (::JournalEntry?) -> ::JournalEntry? + def build_reversal: (?untyped) -> ::JournalEntry + def create_reversal: (?untyped) -> ::JournalEntry + def create_reversal!: (?untyped) -> ::JournalEntry + def reload_reversal: () -> ::JournalEntry? + 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 reversal_of: () -> ::JournalEntry? + def reversal_of=: (::JournalEntry?) -> ::JournalEntry? + def reload_reversal_of: () -> ::JournalEntry? + def build_reversal_of: (?untyped) -> ::JournalEntry + def create_reversal_of: (?untyped) -> ::JournalEntry + def create_reversal_of!: (?untyped) -> ::JournalEntry + + module ::JournalEntry::GeneratedAssociationMethods + end + include ::JournalEntry::GeneratedAssociationMethods + + module ::JournalEntry::GeneratedRelationMethods + end + + class ::JournalEntry::ActiveRecord_Relation < ::ActiveRecord::Relation + include ::Enumerable[::JournalEntry] + include ::JournalEntry::GeneratedRelationMethods + include ::ActiveRecord::Relation::Methods[::JournalEntry, ::Integer] + end + + class ::JournalEntry::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy + include ::Enumerable[::JournalEntry] + include ::JournalEntry::GeneratedRelationMethods + include ::ActiveRecord::Relation::Methods[::JournalEntry, ::Integer] + + def build: (?::ActiveRecord::Associations::CollectionProxy::_EachPair attributes) ?{ () -> untyped } -> ::JournalEntry + | (::Array[::ActiveRecord::Associations::CollectionProxy::_EachPair] attributes) ?{ () -> untyped } -> ::Array[::JournalEntry] + def create: (?::ActiveRecord::Associations::CollectionProxy::_EachPair attributes) ?{ () -> untyped } -> ::JournalEntry + | (::Array[::ActiveRecord::Associations::CollectionProxy::_EachPair] attributes) ?{ () -> untyped } -> ::Array[::JournalEntry] + def create!: (?::ActiveRecord::Associations::CollectionProxy::_EachPair attributes) ?{ () -> untyped } -> ::JournalEntry + | (::Array[::ActiveRecord::Associations::CollectionProxy::_EachPair] attributes) ?{ () -> untyped } -> ::Array[::JournalEntry] + def reload: () -> ::Array[::JournalEntry] + + def replace: (::Array[::JournalEntry]) -> void + def delete: (*::JournalEntry | ::Integer) -> ::Array[::JournalEntry] + def destroy: (*::JournalEntry | ::Integer) -> ::Array[::JournalEntry] + def <<: (*::JournalEntry | ::Array[::JournalEntry]) -> self + def prepend: (*::JournalEntry | ::Array[::JournalEntry]) -> self + end +end + +class ::ApplicationRecord < ::ActiveRecord::Base +end +class ::Posting < ::ApplicationRecord +end +class ::Posting::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy +end +class ::JournalEntry < ::ApplicationRecord +end +class ::User < ::ApplicationRecord +end diff --git a/sig/rbs_rails/app/models/party.rbs b/sig/rbs_rails/app/models/party.rbs index 6f5be47c..44ffdcdd 100644 --- a/sig/rbs_rails/app/models/party.rbs +++ b/sig/rbs_rails/app/models/party.rbs @@ -421,6 +421,10 @@ class ::Party < ::ApplicationRecord def tenancies=: (::Tenancy::ActiveRecord_Associations_CollectionProxy | ::Array[::Tenancy]) -> (::Tenancy::ActiveRecord_Associations_CollectionProxy | ::Array[::Tenancy]) def tenancy_ids: () -> ::Array[::Integer] def tenancy_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] + def accounting_posting_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] @@ -501,6 +505,10 @@ class ::Tenancy < ::ApplicationRecord end class ::Tenancy::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy end +class ::Posting < ::ApplicationRecord +end +class ::Posting::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/posting.rbs b/sig/rbs_rails/app/models/posting.rbs new file mode 100644 index 00000000..731e49cb --- /dev/null +++ b/sig/rbs_rails/app/models/posting.rbs @@ -0,0 +1,537 @@ +# resolve-type-names: false + +class ::Posting < ::ApplicationRecord + extend ::ActiveRecord::Base::ClassMethods[::Posting, ::Posting::ActiveRecord_Relation, ::Integer] + + module ::Posting::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 journal_entry_id: () -> ::Integer + + def journal_entry_id=: (::Integer) -> ::Integer + + def journal_entry_id?: () -> bool + + def journal_entry_id_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def journal_entry_id_change: () -> [ ::Integer?, ::Integer? ] + + def journal_entry_id_will_change!: () -> void + + def journal_entry_id_was: () -> ::Integer? + + def journal_entry_id_previously_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def journal_entry_id_previous_change: () -> ::Array[::Integer?]? + + def journal_entry_id_previously_was: () -> ::Integer? + + def journal_entry_id_before_last_save: () -> ::Integer? + + def journal_entry_id_change_to_be_saved: () -> ::Array[::Integer?]? + + def journal_entry_id_in_database: () -> ::Integer? + + def saved_change_to_journal_entry_id: () -> ::Array[::Integer?]? + + def saved_change_to_journal_entry_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def will_save_change_to_journal_entry_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def restore_journal_entry_id!: () -> void + + def clear_journal_entry_id_change: () -> void + + def journal_entry_id_before_type_cast: () -> ::Integer + + def journal_entry_id_for_database: () -> ::Integer + + def account_id: () -> ::Integer + + def account_id=: (::Integer) -> ::Integer + + def account_id?: () -> bool + + def account_id_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def account_id_change: () -> [ ::Integer?, ::Integer? ] + + def account_id_will_change!: () -> void + + def account_id_was: () -> ::Integer? + + def account_id_previously_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def account_id_previous_change: () -> ::Array[::Integer?]? + + def account_id_previously_was: () -> ::Integer? + + def account_id_before_last_save: () -> ::Integer? + + def account_id_change_to_be_saved: () -> ::Array[::Integer?]? + + def account_id_in_database: () -> ::Integer? + + def saved_change_to_account_id: () -> ::Array[::Integer?]? + + def saved_change_to_account_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def will_save_change_to_account_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def restore_account_id!: () -> void + + def clear_account_id_change: () -> void + + def account_id_before_type_cast: () -> ::Integer + + def account_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 property_id: () -> ::Integer? + + def property_id=: (::Integer?) -> ::Integer? + + def property_id?: () -> bool + + def property_id_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def property_id_change: () -> [ ::Integer?, ::Integer? ] + + def property_id_will_change!: () -> void + + def property_id_was: () -> ::Integer? + + def property_id_previously_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def property_id_previous_change: () -> ::Array[::Integer?]? + + def property_id_previously_was: () -> ::Integer? + + def property_id_before_last_save: () -> ::Integer? + + def property_id_change_to_be_saved: () -> ::Array[::Integer?]? + + def property_id_in_database: () -> ::Integer? + + def saved_change_to_property_id: () -> ::Array[::Integer?]? + + def saved_change_to_property_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def will_save_change_to_property_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def restore_property_id!: () -> void + + def clear_property_id_change: () -> void + + def property_id_before_type_cast: () -> ::Integer? + + def property_id_for_database: () -> ::Integer? + + def rentable_unit_id: () -> ::Integer? + + def rentable_unit_id=: (::Integer?) -> ::Integer? + + def rentable_unit_id?: () -> bool + + def rentable_unit_id_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def rentable_unit_id_change: () -> [ ::Integer?, ::Integer? ] + + def rentable_unit_id_will_change!: () -> void + + def rentable_unit_id_was: () -> ::Integer? + + def rentable_unit_id_previously_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def rentable_unit_id_previous_change: () -> ::Array[::Integer?]? + + def rentable_unit_id_previously_was: () -> ::Integer? + + def rentable_unit_id_before_last_save: () -> ::Integer? + + def rentable_unit_id_change_to_be_saved: () -> ::Array[::Integer?]? + + def rentable_unit_id_in_database: () -> ::Integer? + + def saved_change_to_rentable_unit_id: () -> ::Array[::Integer?]? + + def saved_change_to_rentable_unit_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def will_save_change_to_rentable_unit_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def restore_rentable_unit_id!: () -> void + + def clear_rentable_unit_id_change: () -> void + + def rentable_unit_id_before_type_cast: () -> ::Integer? + + def rentable_unit_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 party_id: () -> ::Integer? + + def party_id=: (::Integer?) -> ::Integer? + + def party_id?: () -> bool + + def party_id_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def party_id_change: () -> [ ::Integer?, ::Integer? ] + + def party_id_will_change!: () -> void + + def party_id_was: () -> ::Integer? + + def party_id_previously_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def party_id_previous_change: () -> ::Array[::Integer?]? + + def party_id_previously_was: () -> ::Integer? + + def party_id_before_last_save: () -> ::Integer? + + def party_id_change_to_be_saved: () -> ::Array[::Integer?]? + + def party_id_in_database: () -> ::Integer? + + def saved_change_to_party_id: () -> ::Array[::Integer?]? + + def saved_change_to_party_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def will_save_change_to_party_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool + + def restore_party_id!: () -> void + + def clear_party_id_change: () -> void + + def party_id_before_type_cast: () -> ::Integer? + + def party_id_for_database: () -> ::Integer? + + def 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 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 + end + include ::Posting::GeneratedAttributeMethods + module ::Posting::GeneratedAliasAttributeMethods + include ::Posting::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 ::Posting::GeneratedAliasAttributeMethods + + def journal_entry: () -> ::JournalEntry + def journal_entry=: (::JournalEntry?) -> ::JournalEntry? + def reload_journal_entry: () -> ::JournalEntry? + def build_journal_entry: (?untyped) -> ::JournalEntry + def create_journal_entry: (?untyped) -> ::JournalEntry + def create_journal_entry!: (?untyped) -> ::JournalEntry + def account: () -> ::Account + def account=: (::Account?) -> ::Account? + def reload_account: () -> ::Account? + def build_account: (?untyped) -> ::Account + def create_account: (?untyped) -> ::Account + def create_account!: (?untyped) -> ::Account + def property: () -> ::Property? + def property=: (::Property?) -> ::Property? + def reload_property: () -> ::Property? + def build_property: (?untyped) -> ::Property + def create_property: (?untyped) -> ::Property + def create_property!: (?untyped) -> ::Property + def rentable_unit: () -> ::RentableUnit? + def rentable_unit=: (::RentableUnit?) -> ::RentableUnit? + def reload_rentable_unit: () -> ::RentableUnit? + def build_rentable_unit: (?untyped) -> ::RentableUnit + def create_rentable_unit: (?untyped) -> ::RentableUnit + def create_rentable_unit!: (?untyped) -> ::RentableUnit + 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 party: () -> ::Party? + def party=: (::Party?) -> ::Party? + def reload_party: () -> ::Party? + def build_party: (?untyped) -> ::Party + def create_party: (?untyped) -> ::Party + def create_party!: (?untyped) -> ::Party + + module ::Posting::GeneratedAssociationMethods + end + include ::Posting::GeneratedAssociationMethods + + module ::Posting::GeneratedRelationMethods + end + + class ::Posting::ActiveRecord_Relation < ::ActiveRecord::Relation + include ::Enumerable[::Posting] + include ::Posting::GeneratedRelationMethods + include ::ActiveRecord::Relation::Methods[::Posting, ::Integer] + end + + class ::Posting::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy + include ::Enumerable[::Posting] + include ::Posting::GeneratedRelationMethods + include ::ActiveRecord::Relation::Methods[::Posting, ::Integer] + + def build: (?::ActiveRecord::Associations::CollectionProxy::_EachPair attributes) ?{ () -> untyped } -> ::Posting + | (::Array[::ActiveRecord::Associations::CollectionProxy::_EachPair] attributes) ?{ () -> untyped } -> ::Array[::Posting] + def create: (?::ActiveRecord::Associations::CollectionProxy::_EachPair attributes) ?{ () -> untyped } -> ::Posting + | (::Array[::ActiveRecord::Associations::CollectionProxy::_EachPair] attributes) ?{ () -> untyped } -> ::Array[::Posting] + def create!: (?::ActiveRecord::Associations::CollectionProxy::_EachPair attributes) ?{ () -> untyped } -> ::Posting + | (::Array[::ActiveRecord::Associations::CollectionProxy::_EachPair] attributes) ?{ () -> untyped } -> ::Array[::Posting] + def reload: () -> ::Array[::Posting] + + def replace: (::Array[::Posting]) -> void + def delete: (*::Posting | ::Integer) -> ::Array[::Posting] + def destroy: (*::Posting | ::Integer) -> ::Array[::Posting] + def <<: (*::Posting | ::Array[::Posting]) -> self + def prepend: (*::Posting | ::Array[::Posting]) -> self + end +end + +class ::ApplicationRecord < ::ActiveRecord::Base +end +class ::JournalEntry < ::ApplicationRecord +end +class ::Account < ::ApplicationRecord +end +class ::Property < ::ApplicationRecord +end +class ::RentableUnit < ::ApplicationRecord +end +class ::Tenancy < ::ApplicationRecord +end +class ::Party < ::ApplicationRecord +end diff --git a/sig/rbs_rails/app/models/property.rbs b/sig/rbs_rails/app/models/property.rbs index db0bc975..a6449bef 100644 --- a/sig/rbs_rails/app/models/property.rbs +++ b/sig/rbs_rails/app/models/property.rbs @@ -353,6 +353,10 @@ class ::Property < ::ApplicationRecord def tenant_charges=: (::TenantCharge::ActiveRecord_Associations_CollectionProxy | ::Array[::TenantCharge]) -> (::TenantCharge::ActiveRecord_Associations_CollectionProxy | ::Array[::TenantCharge]) def tenant_charge_ids: () -> ::Array[::Integer] def tenant_charge_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] + def accounting_posting_ids=: (::Array[::Integer]) -> ::Array[::Integer] def user: () -> ::User def user=: (::User?) -> ::User? @@ -472,5 +476,9 @@ class ::TenantCharge < ::ApplicationRecord end class ::TenantCharge::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy end +class ::Posting < ::ApplicationRecord +end +class ::Posting::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy +end class ::User < ::ApplicationRecord end diff --git a/sig/rbs_rails/app/models/rentable_unit.rbs b/sig/rbs_rails/app/models/rentable_unit.rbs index 79066a07..fd82bdf3 100644 --- a/sig/rbs_rails/app/models/rentable_unit.rbs +++ b/sig/rbs_rails/app/models/rentable_unit.rbs @@ -373,6 +373,10 @@ class ::RentableUnit < ::ApplicationRecord def tenancies=: (::Tenancy::ActiveRecord_Associations_CollectionProxy | ::Array[::Tenancy]) -> (::Tenancy::ActiveRecord_Associations_CollectionProxy | ::Array[::Tenancy]) def tenancy_ids: () -> ::Array[::Integer] def tenancy_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] + def accounting_posting_ids=: (::Array[::Integer]) -> ::Array[::Integer] def property: () -> ::Property def property=: (::Property?) -> ::Property? @@ -421,5 +425,9 @@ class ::Tenancy < ::ApplicationRecord end class ::Tenancy::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy end +class ::Posting < ::ApplicationRecord +end +class ::Posting::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy +end class ::Property < ::ApplicationRecord end diff --git a/sig/rbs_rails/app/models/tenancy.rbs b/sig/rbs_rails/app/models/tenancy.rbs index d3bf7a4d..a0cfc28e 100644 --- a/sig/rbs_rails/app/models/tenancy.rbs +++ b/sig/rbs_rails/app/models/tenancy.rbs @@ -393,6 +393,10 @@ class ::Tenancy < ::ApplicationRecord def tenant_charges=: (::TenantCharge::ActiveRecord_Associations_CollectionProxy | ::Array[::TenantCharge]) -> (::TenantCharge::ActiveRecord_Associations_CollectionProxy | ::Array[::TenantCharge]) def tenant_charge_ids: () -> ::Array[::Integer] def tenant_charge_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] + def accounting_posting_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] @@ -501,6 +505,10 @@ class ::TenantCharge < ::ApplicationRecord end class ::TenantCharge::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy end +class ::Posting < ::ApplicationRecord +end +class ::Posting::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/user.rbs b/sig/rbs_rails/app/models/user.rbs index 926cb4c2..72e528f3 100644 --- a/sig/rbs_rails/app/models/user.rbs +++ b/sig/rbs_rails/app/models/user.rbs @@ -333,6 +333,14 @@ class ::User < ::ApplicationRecord def payment_documents=: (::PaymentDocument::ActiveRecord_Associations_CollectionProxy | ::Array[::PaymentDocument]) -> (::PaymentDocument::ActiveRecord_Associations_CollectionProxy | ::Array[::PaymentDocument]) def payment_document_ids: () -> ::Array[::Integer] def payment_document_ids=: (::Array[::Integer]) -> ::Array[::Integer] + def accounts: () -> ::Account::ActiveRecord_Associations_CollectionProxy + def accounts=: (::Account::ActiveRecord_Associations_CollectionProxy | ::Array[::Account]) -> (::Account::ActiveRecord_Associations_CollectionProxy | ::Array[::Account]) + def account_ids: () -> ::Array[::Integer] + def account_ids=: (::Array[::Integer]) -> ::Array[::Integer] + def journal_entries: () -> ::JournalEntry::ActiveRecord_Associations_CollectionProxy + def journal_entries=: (::JournalEntry::ActiveRecord_Associations_CollectionProxy | ::Array[::JournalEntry]) -> (::JournalEntry::ActiveRecord_Associations_CollectionProxy | ::Array[::JournalEntry]) + def journal_entry_ids: () -> ::Array[::Integer] + def journal_entry_ids=: (::Array[::Integer]) -> ::Array[::Integer] module ::User::GeneratedAssociationMethods end @@ -426,3 +434,11 @@ class ::PaymentDocument < ::ApplicationRecord end class ::PaymentDocument::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy end +class ::Account < ::ApplicationRecord +end +class ::Account::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy +end +class ::JournalEntry < ::ApplicationRecord +end +class ::JournalEntry::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy +end diff --git a/sig/shims/active_record.rbs b/sig/shims/active_record.rbs index 4ed72619..e8197f95 100644 --- a/sig/shims/active_record.rbs +++ b/sig/shims/active_record.rbs @@ -1,4 +1,10 @@ module ActiveRecord + class Base + def accounting_user: () -> User? + def user: () -> User? + def user_id: () -> Integer? + end + class Relation module Methods[Model, PrimaryKey] def each: () { (Model) -> void } -> void diff --git a/spec/factories.rb b/spec/factories.rb index ef41a2c0..300472cd 100644 --- a/spec/factories.rb +++ b/spec/factories.rb @@ -111,4 +111,28 @@ source { "pdf_upload" } status { "pending" } end + + factory :account do + association :user + sequence(:key) { |n| "custom_account_#{n}" } + sequence(:name) { |n| "Custom Account #{n}" } + account_type { "asset" } + active { true } + end + + factory :journal_entry do + association :user + source_type { "Expense" } + sequence(:source_id) { |n| 1000 + n } + event_type { "expense_posted" } + occurred_on { Date.current } + posted_at { Time.current } + description { "Test journal entry" } + end + + factory :posting do + association :journal_entry + account { association :account, user: journal_entry.user } + amount_cents { 10_000 } + end end diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb new file mode 100644 index 00000000..89a33ec5 --- /dev/null +++ b/spec/models/account_spec.rb @@ -0,0 +1,111 @@ +require "rails_helper" + +RSpec.describe Account, type: :model do + let(:user) { create(:user) } + let(:account) { create(:account, user: user, key: "cash_main", name: "Cash Main", account_type: "asset") } + + describe "associations" do + it { is_expected.to belong_to(:user) } + it { is_expected.to have_many(:postings).dependent(:restrict_with_error) } + end + + describe "validations" do + subject { build(:account, user: user) } + + it { is_expected.to validate_presence_of(:key) } + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_presence_of(:account_type) } + + it "validates uniqueness of key scoped to user" do + create(:account, user: user, key: "operating_cash") + dup = build(:account, user: user, key: "operating_cash") + expect(dup).not_to be_valid + expect(dup.errors[:key]).to include("has already been taken") + end + + it "permits the same key for different users" do + other_user = create(:user) + create(:account, user: user, key: "operating_cash") + other_account = build(:account, user: other_user, key: "operating_cash") + expect(other_account).to be_valid + end + + it "validates key format allows lowercase letters, numbers, and underscores" do + valid_account = build(:account, user: user, key: "valid_key_123") + expect(valid_account).to be_valid + + invalid_account = build(:account, user: user, key: "Invalid-Key!") + expect(invalid_account).not_to be_valid + expect(invalid_account.errors[:key]).to include("must contain only lowercase letters, numbers, and underscores") + end + + it "accepts all valid account types" do + Account::ACCOUNT_TYPES.each do |type| + acc = build(:account, user: user, key: "type_#{type}", account_type: type) + expect(acc).to be_valid + end + end + + it "rejects invalid account types" do + acc = build(:account, user: user, account_type: "invalid_type") + expect(acc).not_to be_valid + expect(acc.errors[:account_type]).to be_present + end + end + + describe "normalizations" do + it "normalizes key and name" do + acc = create(:account, user: user, key: " CUSTOM_CASH_1 ", name: " My Cash Account ") + expect(acc.key).to eq("custom_cash_1") + expect(acc.name).to eq("My Cash Account") + end + end + + describe "immutability on update" do + let!(:persisted_account) { create(:account, user: user, key: "test_immutability", account_type: "asset") } + + it "prevents changing user_id" do + other_user = create(:user) + persisted_account.user = other_user + expect(persisted_account).not_to be_valid + expect(persisted_account.errors[:user_id]).to include("cannot be changed") + end + + it "prevents changing key" do + persisted_account.key = "new_key" + expect(persisted_account).not_to be_valid + expect(persisted_account.errors[:key]).to include("cannot be changed") + end + + it "prevents changing account_type" do + persisted_account.account_type = "liability" + expect(persisted_account).not_to be_valid + expect(persisted_account.errors[:account_type]).to include("cannot be changed") + end + + it "allows changing name and active" do + persisted_account.update!(name: "Updated Name", active: false) + expect(persisted_account.reload.name).to eq("Updated Name") + expect(persisted_account.active).to be(false) + end + end + + describe "deletion protection" do + let(:journal_entry) { create(:journal_entry, user: user) } + + it "cannot be destroyed when postings exist" do + create(:posting, journal_entry: journal_entry, account: account, amount_cents: 5000) + + expect { + account.destroy + }.not_to change(Account, :count) + expect(account.errors[:base]).to be_present + end + end + + describe "#accounting_user" do + it "returns the account owner" do + expect(account.accounting_user).to eq(user) + end + end +end diff --git a/spec/models/accounting_db_constraints_spec.rb b/spec/models/accounting_db_constraints_spec.rb new file mode 100644 index 00000000..c7d9fc12 --- /dev/null +++ b/spec/models/accounting_db_constraints_spec.rb @@ -0,0 +1,132 @@ +require "rails_helper" + +RSpec.describe "Accounting Database Constraints", type: :model do + let!(:user) { create(:user) } + let(:property) { create(:property, user: user) } + let(:unit) { create(:rentable_unit, property: property) } + let(:tenancy) { create(:tenancy, rentable_unit: unit) } + let(:party) { create(:party, user: user) } + let(:cash_account) { user.accounts.find_by!(key: "cash") } + + let!(:journal_entry) do + create(:journal_entry, + user: user, + source_type: "Expense", + source_id: 999, + event_type: "expense_posted", + occurred_on: Date.current, + posted_at: Time.current + ) + end + + describe "accounts check constraint" do + it "enforces account_type check constraint at PostgreSQL level" do + expect { + ActiveRecord::Base.connection.execute( + "INSERT INTO accounts (user_id, key, name, account_type, active, created_at, updated_at) " \ + "VALUES (#{user.id}, 'invalid_raw_key', 'Invalid Account', 'bogus_type', true, NOW(), NOW())" + ) + }.to raise_error(ActiveRecord::StatementInvalid, /check_accounts_account_type/) + end + end + + describe "postings check constraint" do + it "enforces amount_cents <> 0 at PostgreSQL level" do + expect { + ActiveRecord::Base.connection.execute( + "INSERT INTO postings (journal_entry_id, account_id, amount_cents, created_at) " \ + "VALUES (#{journal_entry.id}, #{cash_account.id}, 0, NOW())" + ) + }.to raise_error(ActiveRecord::StatementInvalid, /check_postings_amount_cents_nonzero/) + end + end + + describe "journal_entries check constraint" do + it "enforces source_id > 0 at PostgreSQL level" do + expect { + ActiveRecord::Base.connection.execute( + "INSERT INTO journal_entries (user_id, source_type, source_id, event_type, occurred_on, posted_at, created_at) " \ + "VALUES (#{user.id}, 'Expense', 0, 'test_event', CURRENT_DATE, NOW(), NOW())" + ) + }.to raise_error(ActiveRecord::StatementInvalid, /check_journal_entries_source_id_positive/) + end + end + + describe "journal_entries unique index constraints" do + it "enforces unique source-event tuple at PostgreSQL level" do + expect { + ActiveRecord::Base.connection.execute( + "INSERT INTO journal_entries (user_id, source_type, source_id, event_type, occurred_on, posted_at, created_at) " \ + "VALUES (#{user.id}, 'Expense', 999, 'expense_posted', CURRENT_DATE, NOW(), NOW())" + ) + }.to raise_error(ActiveRecord::RecordNotUnique, /idx_journal_entries_source_event/) + end + + it "enforces single reversal_of_id constraint at PostgreSQL level" do + reversal1 = create(:journal_entry, + user: user, + source_type: "JournalEntry", + source_id: journal_entry.id, + event_type: "reversal", + reversal_of: journal_entry + ) + + expect { + ActiveRecord::Base.connection.execute( + "INSERT INTO journal_entries (user_id, source_type, source_id, event_type, occurred_on, posted_at, reversal_of_id, created_at) " \ + "VALUES (#{user.id}, 'JournalEntry', #{journal_entry.id}, 'reversal_dup', CURRENT_DATE, NOW(), #{journal_entry.id}, NOW())" + ) + }.to raise_error(ActiveRecord::RecordNotUnique, /idx_journal_entries_single_reversal/) + end + end + + describe "dimension lifecycle deletion protection" do + let!(:posting) do + create(:posting, + journal_entry: journal_entry, + account: cash_account, + amount_cents: 10_000, + property: property, + rentable_unit: unit, + tenancy: tenancy, + party: party + ) + end + + it "prevents deleting Property when referenced by postings" do + expect { + property.destroy + }.not_to change(Property, :count) + expect(property.destroyed?).to be(false) + + prop_direct = create(:property, user: user) + create(:posting, journal_entry: journal_entry, account: cash_account, property: prop_direct) + expect { + prop_direct.destroy + }.not_to change(Property, :count) + expect(prop_direct.errors[:base]).to be_present + end + + it "prevents deleting RentableUnit when referenced by postings" do + expect { + unit.destroy + }.not_to change(RentableUnit, :count) + expect(unit.errors[:base]).to be_present + end + + it "prevents deleting Tenancy when referenced by postings" do + expect { + tenancy.destroy + }.not_to change(Tenancy, :count) + expect(tenancy.errors[:base]).to be_present + expect(tenancy.financial_history?).to be(true) + end + + it "prevents deleting Party when referenced by postings" do + expect { + party.destroy + }.not_to change(Party, :count) + expect(party.errors[:base]).to be_present + end + end +end diff --git a/spec/models/accounting_invariants_spec.rb b/spec/models/accounting_invariants_spec.rb new file mode 100644 index 00000000..fc14b1ee --- /dev/null +++ b/spec/models/accounting_invariants_spec.rb @@ -0,0 +1,63 @@ +require "rails_helper" + +RSpec.describe "Accounting Invariants", 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(:dummy_source) { create(:expense, property: property) } + + describe "double-entry balancing invariants" do + it "requires sum(postings.amount_cents) == 0 across randomized multi-line entries" do + account_keys = Accounting::ChartOfAccounts::SYSTEM_KEYS.sample(5) + + 10.times do |i| + # Generate 2 to 6 lines of random cents + line_count = rand(2..6) + amounts = Array.new(line_count - 1) { rand(-500_000..500_000) }.reject(&:zero?) + balancing_amount = -amounts.sum + + # Ensure balancing line is non-zero + if balancing_amount == 0 + amounts[0] += 100 + balancing_amount = -amounts.sum + end + + all_amounts = amounts + [ balancing_amount ] + + specs = all_amounts.map do |cents| + Accounting::PostingSpec.new( + account_key: account_keys.sample, + amount_cents: cents, + tenancy: tenancy + ) + end + + result = Accounting::PostEntryService.call( + user: user, + source: dummy_source, + event_type: "invariant_test_#{i}", + occurred_on: Date.current, + postings: specs + ) + + expect(result).to be_success + entry = result.value!.data[:journal_entry] + expect(entry.postings.count).to be >= 2 + expect(entry.postings.sum(:amount_cents)).to eq(0) + end + end + + it "rejects any entry mutated by even 1 cent" do + specs = [ + Accounting::PostingSpec.new(account_key: "tenant_receivable", amount_cents: 200_000, tenancy: tenancy), + Accounting::PostingSpec.new(account_key: "rental_income", amount_cents: -200_001, tenancy: tenancy) + ] + + result = Accounting::PostingBuilder.call(user: user, postings: specs) + expect(result).to be_failure + expect(result.failure.code).to eq(:unbalanced_entry) + expect(result.failure.error).to include("unbalanced: net sum is -1") + end + end +end diff --git a/spec/models/expense_spec.rb b/spec/models/expense_spec.rb index 5f97cef5..3580ce48 100644 --- a/spec/models/expense_spec.rb +++ b/spec/models/expense_spec.rb @@ -184,4 +184,19 @@ def save_with_tenant_charge!(expense) expect(expense.reimburse_tenancy_id).to eq(456) end end + + describe "#accounting_user" do + let(:user) { create(:user) } + let(:property) { create(:property, user: user) } + let(:expense) { create(:expense, property: property) } + + it "returns the property user" do + expect(expense.accounting_user).to eq(user) + end + + it "returns nil when property is absent" do + orphan = build(:expense, property: nil) + expect(orphan.accounting_user).to be_nil + end + end end diff --git a/spec/models/journal_entry_spec.rb b/spec/models/journal_entry_spec.rb new file mode 100644 index 00000000..e0c3c355 --- /dev/null +++ b/spec/models/journal_entry_spec.rb @@ -0,0 +1,107 @@ +require "rails_helper" + +RSpec.describe JournalEntry, type: :model do + let(:user) { create(:user) } + let(:journal_entry) do + create(:journal_entry, + user: user, + source_type: "Expense", + source_id: 123, + event_type: "expense_posted", + occurred_on: Date.current, + posted_at: Time.current + ) + end + + describe "associations" do + it { is_expected.to belong_to(:user) } + it { is_expected.to belong_to(:reversal_of).class_name("JournalEntry").optional } + it { is_expected.to have_one(:reversal).class_name("JournalEntry").with_foreign_key(:reversal_of_id).dependent(:restrict_with_error) } + it { is_expected.to have_many(:postings).dependent(:restrict_with_error) } + end + + describe "validations" do + subject { build(:journal_entry, user: user) } + + it { is_expected.to validate_presence_of(:source_type) } + it { is_expected.to validate_presence_of(:source_id) } + it { is_expected.to validate_presence_of(:event_type) } + it { is_expected.to validate_presence_of(:occurred_on) } + it { is_expected.to validate_presence_of(:posted_at) } + + it "requires source_id to be positive" do + entry = build(:journal_entry, user: user, source_id: 0) + expect(entry).not_to be_valid + expect(entry.errors[:source_id]).to include("must be greater than 0") + + entry.source_id = -5 + expect(entry).not_to be_valid + end + + it "validates uniqueness of source-event tuple scoped to user" do + create(:journal_entry, user: user, source_type: "Expense", source_id: 101, event_type: "expense_posted") + duplicate = build(:journal_entry, user: user, source_type: "Expense", source_id: 101, event_type: "expense_posted") + expect(duplicate).not_to be_valid + expect(duplicate.errors[:source_id]).to include("has already been posted for this event") + end + + it "allows the same source event for a different user" do + other_user = create(:user) + create(:journal_entry, user: user, source_type: "Expense", source_id: 101, event_type: "expense_posted") + other_entry = build(:journal_entry, user: other_user, source_type: "Expense", source_id: 101, event_type: "expense_posted") + expect(other_entry).to be_valid + end + + it "enforces that an entry can only be reversed once" do + original = create(:journal_entry, user: user, source_type: "Expense", source_id: 201, event_type: "expense_posted") + create(:journal_entry, user: user, source_type: "JournalEntry", source_id: original.id, event_type: "reversal", reversal_of: original) + + second_reversal = build(:journal_entry, user: user, source_type: "JournalEntry", source_id: original.id, event_type: "reversal_2", reversal_of: original) + expect(second_reversal).not_to be_valid + expect(second_reversal.errors[:reversal_of_id]).to include("has already been reversed") + end + end + + describe "immutability" do + let!(:persisted) { create(:journal_entry, user: user, description: "Initial") } + + it "prevents updating attributes" do + expect { + persisted.update!(description: "Modified") + }.to raise_error(ActiveRecord::RecordNotSaved) + expect(persisted.reload.description).to eq("Initial") + end + + it "prevents deletion" do + expect { + persisted.destroy! + }.to raise_error(ActiveRecord::RecordNotDestroyed) + expect(JournalEntry.exists?(persisted.id)).to be(true) + end + end + + describe "reversal helpers" do + let(:original) { create(:journal_entry, user: user, source_type: "Expense", source_id: 301, event_type: "expense_posted") } + + it "correctly identifies reversal state" do + expect(original.reversed?).to be(false) + expect(original.reversal?).to be(false) + + reversal = create(:journal_entry, + user: user, + source_type: "JournalEntry", + source_id: original.id, + event_type: "reversal", + reversal_of: original + ) + + expect(original.reload.reversed?).to be(true) + expect(reversal.reversal?).to be(true) + expect(reversal.reversed?).to be(false) + end + + it "returns user for accounting_user" do + expect(original.accounting_user).to eq(user) + end + end +end diff --git a/spec/models/party_alias_spec.rb b/spec/models/party_alias_spec.rb index f348bf99..e7e78938 100644 --- a/spec/models/party_alias_spec.rb +++ b/spec/models/party_alias_spec.rb @@ -28,4 +28,19 @@ expect(other).to be_valid end end + + describe "#accounting_user" do + let(:user) { create(:user) } + let(:party) { create(:party, user: user) } + let(:party_alias) { create(:party_alias, party: party, alias_name: "Johnny") } + + it "returns the user who owns the party" do + expect(party_alias.accounting_user).to eq(user) + end + + it "returns nil when party is absent" do + orphan = build(:party_alias, party: nil) + expect(orphan.accounting_user).to be_nil + end + end end diff --git a/spec/models/party_spec.rb b/spec/models/party_spec.rb index 29f51a28..767a56ca 100644 --- a/spec/models/party_spec.rb +++ b/spec/models/party_spec.rb @@ -111,4 +111,13 @@ expect(unloaded_party.alias_candidate?("Alice S.")).to be true end end + + describe "#accounting_user" do + let(:user) { create(:user) } + let(:party) { create(:party, user: user) } + + it "returns the user who owns the party" do + expect(party.accounting_user).to eq(user) + end + end end diff --git a/spec/models/payment_document_spec.rb b/spec/models/payment_document_spec.rb index b03330eb..490e2595 100644 --- a/spec/models/payment_document_spec.rb +++ b/spec/models/payment_document_spec.rb @@ -38,4 +38,13 @@ failed: "failed" ).backed_by_column_of_type(:string) } end + + describe '#accounting_user' do + let(:user) { create(:user) } + let(:doc) { build(:payment_document, user: user) } + + it 'returns the user' do + expect(doc.accounting_user).to eq(user) + end + end end diff --git a/spec/models/payment_ingestion_spec.rb b/spec/models/payment_ingestion_spec.rb index 4aaa1c6b..c277f155 100644 --- a/spec/models/payment_ingestion_spec.rb +++ b/spec/models/payment_ingestion_spec.rb @@ -344,5 +344,10 @@ expect(party.party_aliases.exists?(alias_name: "Samantha Lopez Custom Alias")).to be_truthy end + + it "returns the user via #accounting_user" do + ingestion = build(:payment_ingestion, user: user) + expect(ingestion.accounting_user).to eq(user) + end end end diff --git a/spec/models/posting_spec.rb b/spec/models/posting_spec.rb new file mode 100644 index 00000000..52f51df0 --- /dev/null +++ b/spec/models/posting_spec.rb @@ -0,0 +1,153 @@ +require "rails_helper" + +RSpec.describe Posting, type: :model do + let(:user) { create(:user) } + let(:journal_entry) { create(:journal_entry, user: user) } + let(:account) { create(:account, user: user, key: "bank_cash", account_type: "asset") } + let(:property) { create(:property, user: user) } + let(:unit) { create(:rentable_unit, property: property) } + let(:tenancy) { create(:tenancy, rentable_unit: unit) } + let(:party) { create(:party, user: user) } + + describe "associations" do + it { is_expected.to belong_to(:journal_entry) } + it { is_expected.to belong_to(:account) } + it { is_expected.to belong_to(:property).optional } + it { is_expected.to belong_to(:rentable_unit).optional } + it { is_expected.to belong_to(:tenancy).optional } + it { is_expected.to belong_to(:party).optional } + end + + describe "validations" do + it "validates presence and nonzero integer amount_cents" do + valid_debit = build(:posting, journal_entry: journal_entry, account: account, amount_cents: 10_000) + expect(valid_debit).to be_valid + expect(valid_debit.debit?).to be(true) + expect(valid_debit.credit?).to be(false) + expect(valid_debit.debit_amount).to eq(10_000) + expect(valid_debit.credit_amount).to be_nil + + valid_credit = build(:posting, journal_entry: journal_entry, account: account, amount_cents: -10_000) + expect(valid_credit).to be_valid + expect(valid_credit.credit?).to be(true) + expect(valid_credit.debit?).to be(false) + expect(valid_credit.credit_amount).to eq(10_000) + expect(valid_debit.credit_amount).to be_nil + + zero_posting = build(:posting, journal_entry: journal_entry, account: account, amount_cents: 0) + expect(zero_posting).not_to be_valid + expect(zero_posting.errors[:amount_cents]).to include("must be other than 0") + + nil_posting = build(:posting, journal_entry: journal_entry, account: account, amount_cents: nil) + expect(nil_posting).not_to be_valid + end + + it "validates that account belongs to journal_entry user" do + other_user = create(:user) + other_account = create(:account, user: other_user, key: "other_cash", account_type: "asset") + + posting = build(:posting, journal_entry: journal_entry, account: other_account) + expect(posting).not_to be_valid + expect(posting.errors[:account]).to include("must belong to the journal entry user") + end + + it "validates that property belongs to journal_entry user" do + other_user = create(:user) + other_property = create(:property, user: other_user) + + posting = build(:posting, journal_entry: journal_entry, account: account, property: other_property) + expect(posting).not_to be_valid + expect(posting.errors[:property]).to include("must belong to the journal entry user") + end + + it "validates that rentable_unit belongs to journal_entry user" do + other_user = create(:user) + other_unit = create(:rentable_unit, property: create(:property, user: other_user)) + + posting = build(:posting, journal_entry: journal_entry, account: account, rentable_unit: other_unit) + expect(posting).not_to be_valid + expect(posting.errors[:rentable_unit]).to include("must belong to the journal entry user") + end + + it "validates that tenancy belongs to journal_entry user" do + other_user = create(:user) + other_tenancy = create(:tenancy, rentable_unit: create(:rentable_unit, property: create(:property, user: other_user))) + + posting = build(:posting, journal_entry: journal_entry, account: account, tenancy: other_tenancy) + expect(posting).not_to be_valid + expect(posting.errors[:tenancy]).to include("must belong to the journal entry user") + end + + it "validates that party belongs to journal_entry user" do + other_user = create(:user) + other_party = create(:party, user: other_user) + + posting = build(:posting, journal_entry: journal_entry, account: account, party: other_party) + expect(posting).not_to be_valid + expect(posting.errors[:party]).to include("must belong to the journal entry user") + end + + it "permits party that belongs to user even if not a participant in the tenancy" do + non_participant_party = create(:party, user: user) + posting = build(:posting, journal_entry: journal_entry, account: account, tenancy: tenancy, party: non_participant_party) + expect(posting).to be_valid + end + + it "rejects contradictory unit and property dimensions" do + other_property = create(:property, user: user) + posting = build(:posting, journal_entry: journal_entry, account: account, property: other_property, rentable_unit: unit) + expect(posting).not_to be_valid + expect(posting.errors[:property]).to include("does not match rentable unit property") + end + + it "rejects contradictory tenancy and unit dimensions" do + other_unit = create(:rentable_unit, property: property, name: "Unit Other") + posting = build(:posting, journal_entry: journal_entry, account: account, tenancy: tenancy, rentable_unit: other_unit) + expect(posting).not_to be_valid + expect(posting.errors[:rentable_unit]).to include("does not match tenancy rentable unit") + end + + it "rejects contradictory tenancy and property dimensions" do + other_property = create(:property, user: user) + posting = build(:posting, journal_entry: journal_entry, account: account, tenancy: tenancy, property: other_property) + expect(posting).not_to be_valid + expect(posting.errors[:property]).to include("does not match tenancy property") + end + end + + describe "immutability" do + let!(:persisted) { create(:posting, journal_entry: journal_entry, account: account, amount_cents: 25_000, memo: "Initial") } + + it "prevents updating attributes" do + expect { + persisted.update!(memo: "Changed memo") + }.to raise_error(ActiveRecord::RecordNotSaved) + expect(persisted.reload.memo).to eq("Initial") + end + + it "prevents deletion" do + expect { + persisted.destroy! + }.to raise_error(ActiveRecord::RecordNotDestroyed) + expect(Posting.exists?(persisted.id)).to be(true) + end + end + + describe "#accounting_user" do + let(:posting) { build(:posting, journal_entry: journal_entry, account: account, amount_cents: 10_000) } + + it "returns the user via journal_entry or account" do + expect(posting.accounting_user).to eq(user) + end + + it "returns user from account when journal_entry is absent" do + orphan = build(:posting, journal_entry: nil, account: account, amount_cents: 10_000) + expect(orphan.accounting_user).to eq(user) + end + + it "returns nil when journal_entry and account are absent" do + orphan = build(:posting, journal_entry: nil, account: nil, amount_cents: 10_000) + expect(orphan.accounting_user).to be_nil + end + end +end diff --git a/spec/models/property_spec.rb b/spec/models/property_spec.rb index 1cc6ce53..6ed6fa30 100644 --- a/spec/models/property_spec.rb +++ b/spec/models/property_spec.rb @@ -57,5 +57,9 @@ summary = property.schedule_e_summary(year: Date.current.year) expect(summary.total_income).to eq(0) end + + it "returns the owning user via #accounting_user" do + expect(property.accounting_user).to eq(user) + end end end diff --git a/spec/models/rent_term_spec.rb b/spec/models/rent_term_spec.rb index 2713db99..445b451d 100644 --- a/spec/models/rent_term_spec.rb +++ b/spec/models/rent_term_spec.rb @@ -110,10 +110,10 @@ expect(term.amount_cents).to eq(175050) term.amount = nil - expect(term.amount_cents).to be_nil + expect(term.amount_cents).to eq(0) term.amount = " " - expect(term.amount_cents).to be_nil + expect(term.amount_cents).to eq(0) end end @@ -131,4 +131,21 @@ expect(term.active?(as_of: { fallback: true })).to be_in([ true, false ]) 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, agreement_type: "month_to_month", commencement_date: Date.new(2025, 1, 1), termination_date: nil) } + let(:term) { create(:rent_term, tenancy: tenancy, amount_cents: 100_000, effective_from: Date.new(2025, 1, 1)) } + + it "returns the user owning the property" do + expect(term.accounting_user).to eq(user) + end + + it "returns nil when tenancy is absent" do + orphan_term = build(:rent_term, tenancy: nil) + expect(orphan_term.accounting_user).to be_nil + end + end end diff --git a/spec/models/rentable_unit_spec.rb b/spec/models/rentable_unit_spec.rb index b928b50f..1460b122 100644 --- a/spec/models/rentable_unit_spec.rb +++ b/spec/models/rentable_unit_spec.rb @@ -79,4 +79,19 @@ expect(unit.occupied?(Date.current)).to be true end end + + describe "#accounting_user" do + let(:user) { create(:user) } + let(:property) { create(:property, user: user) } + let(:unit) { create(:rentable_unit, property: property) } + + it "returns the property user" do + expect(unit.accounting_user).to eq(user) + end + + it "returns nil if property is absent" do + orphan = build(:rentable_unit, property: nil) + expect(orphan.accounting_user).to be_nil + end + end end diff --git a/spec/models/scheduled_rent_spec.rb b/spec/models/scheduled_rent_spec.rb index 41f8405d..a1006372 100644 --- a/spec/models/scheduled_rent_spec.rb +++ b/spec/models/scheduled_rent_spec.rb @@ -73,9 +73,20 @@ expect(rent.display_name).to eq("#{property.address} - 2026-05-01") end - it "returns display name when tenancy has no property" do - allow(tenancy).to receive(:property).and_return(nil) - expect(rent.display_name).to eq(" - 2026-05-01") + it "falls back to default when tenancy/property is absent" do + orphan_rent = build(:scheduled_rent, tenancy: nil, due_date: Date.new(2026, 5, 1)) + expect(orphan_rent.display_name).to eq("Property - 2026-05-01") + end + end + + describe "#accounting_user" do + it "returns the user of the tenancy property" do + expect(rent.accounting_user).to eq(user) + end + + it "returns nil when tenancy is absent" do + orphan_rent = build(:scheduled_rent, tenancy: nil) + expect(orphan_rent.accounting_user).to be_nil end end end diff --git a/spec/models/session_spec.rb b/spec/models/session_spec.rb index 5ba28f39..271ae88c 100644 --- a/spec/models/session_spec.rb +++ b/spec/models/session_spec.rb @@ -10,6 +10,7 @@ user = create(:user) session = create(:session, user: user) expect(session.user).to eq(user) + expect(session.accounting_user).to eq(user) end end end diff --git a/spec/models/tenancy_party_spec.rb b/spec/models/tenancy_party_spec.rb index 953df2f9..3826fc6d 100644 --- a/spec/models/tenancy_party_spec.rb +++ b/spec/models/tenancy_party_spec.rb @@ -121,4 +121,22 @@ 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, agreement_type: "month_to_month", commencement_date: Date.new(2025, 1, 1), termination_date: nil) } + let(:party) { create(:party, user: user) } + let(:tenancy_party) { create(:tenancy_party, tenancy: tenancy, party: party, role: "tenant", effective_from: Date.new(2025, 1, 1)) } + + it "returns the user owning the property" do + expect(tenancy_party.accounting_user).to eq(user) + end + + it "returns nil when tenancy is absent" do + orphan = build(:tenancy_party, tenancy: nil, party: party) + expect(orphan.accounting_user).to be_nil + end + end end diff --git a/spec/models/tenancy_spec.rb b/spec/models/tenancy_spec.rb index 9a452ea0..729bc5fb 100644 --- a/spec/models/tenancy_spec.rb +++ b/spec/models/tenancy_spec.rb @@ -238,5 +238,18 @@ expect(tenancy.current_balance).to eq(300.0) end end + + describe "#accounting_user" do + let(:tenancy) { create(:tenancy, rentable_unit: unit) } + + it "returns the user owning the property" do + expect(tenancy.accounting_user).to eq(unit.property.user) + end + + it "returns nil when rentable_unit is absent" do + orphan = build(:tenancy, rentable_unit: nil) + expect(orphan.accounting_user).to be_nil + end + end end end diff --git a/spec/models/tenant_charge_spec.rb b/spec/models/tenant_charge_spec.rb index bf37ad77..ca4fe82e 100644 --- a/spec/models/tenant_charge_spec.rb +++ b/spec/models/tenant_charge_spec.rb @@ -21,4 +21,21 @@ expect(tc).not_to be_valid end end + + describe "#accounting_user" do + it "returns the user who owns the tenancy property" do + user = create(:user) + property = create(:property, user: user) + unit = create(:rentable_unit, property: property) + tenancy = create(:tenancy, rentable_unit: unit) + expense = create(:expense, property: property) + charge = create(:tenant_charge, tenancy: tenancy, expense: expense) + expect(charge.accounting_user).to eq(user) + end + + it "returns nil if tenancy is absent" do + charge = build(:tenant_charge, tenancy: nil) + expect(charge.accounting_user).to be_nil + end + end end diff --git a/spec/models/tenant_payment_spec.rb b/spec/models/tenant_payment_spec.rb index 1e1477a1..8f3d455c 100644 --- a/spec/models/tenant_payment_spec.rb +++ b/spec/models/tenant_payment_spec.rb @@ -79,4 +79,26 @@ 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 end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 22b0eb35..e13f3860 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -29,4 +29,11 @@ expect(user.email).to eq("downcased@example.com") end end + + describe "#accounting_user" do + it "returns self" do + user = create(:user) + expect(user.accounting_user).to eq(user) + end + end end diff --git a/spec/services/accounting/chart_of_accounts_spec.rb b/spec/services/accounting/chart_of_accounts_spec.rb new file mode 100644 index 00000000..48e8e33e --- /dev/null +++ b/spec/services/accounting/chart_of_accounts_spec.rb @@ -0,0 +1,73 @@ +require "rails_helper" + +RSpec.describe Accounting::ChartOfAccounts do + let!(:user) { create(:user) } + + describe "user provisioning on create" do + it "automatically provisions all 17 system accounts on user creation" do + expect(user.accounts.count).to eq(17) + + described_class::SYSTEM_ACCOUNTS.each do |defn| + acc = user.accounts.find_by(key: defn[:key]) + expect(acc).to be_present + expect(acc.name).to eq(defn[:name]) + expect(acc.account_type).to eq(defn[:account_type]) + expect(acc.active).to be(true) + end + end + + it "provisions charts independently for different users" do + other_user = create(:user) + expect(user.accounts.count).to eq(17) + expect(other_user.accounts.count).to eq(17) + + cash1 = user.accounts.find_by(key: "cash") + cash2 = other_user.accounts.find_by(key: "cash") + expect(cash1.id).not_to eq(cash2.id) + end + + it "rolls back user creation if chart provisioning fails" do + allow(described_class).to receive(:ensure_for).and_raise(StandardError, "Provisioning exploded") + + expect { + User.create!(email: "rollback-test@example.com", password: "password") + }.to raise_error(StandardError, "Provisioning exploded") + + expect(User.find_by(email: "rollback-test@example.com")).to be_nil + end + end + + describe ".ensure_for" do + it "is idempotent and creates no duplicate accounts on repeated calls" do + expect { + described_class.ensure_for(user) + }.not_to change(Account, :count) + end + + it "restores missing system accounts without modifying existing ones" do + cash_account = user.accounts.find_by(key: "cash") + cash_account.postings.destroy_all + cash_account.delete + + expect(user.accounts.find_by(key: "cash")).to be_nil + + expect { + described_class.ensure_for(user) + }.to change { Account.where(user: user).count }.by(1) + + restored_cash = user.accounts.find_by(key: "cash") + expect(restored_cash).to be_present + expect(restored_cash.name).to eq("Cash") + expect(restored_cash.account_type).to eq("asset") + end + + it "raises AccountTypeMismatchError if an existing key has the wrong account type" do + cash_account = user.accounts.find_by(key: "cash") + cash_account.update_columns(account_type: "liability") + + expect { + described_class.ensure_for(user) + }.to raise_error(Accounting::ChartOfAccounts::AccountTypeMismatchError, /Account 'cash' for user #{user.id} has type 'liability', expected 'asset'/) + end + end +end diff --git a/spec/services/accounting/post_entry_service_spec.rb b/spec/services/accounting/post_entry_service_spec.rb new file mode 100644 index 00000000..a9de6f14 --- /dev/null +++ b/spec/services/accounting/post_entry_service_spec.rb @@ -0,0 +1,393 @@ +require "rails_helper" + +RSpec.describe Accounting::PostEntryService 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(:dummy_source) { create(:expense, property: property) } + + let(:postings) do + [ + Accounting::PostingSpec.new( + account_key: "tenant_receivable", + amount_cents: 200_000, + tenancy: tenancy + ), + Accounting::PostingSpec.new( + account_key: "rental_income", + amount_cents: -200_000, + tenancy: tenancy + ) + ] + end + + describe ".call" do + it "persists a balanced journal entry and postings atomically" do + expect { + result = described_class.call( + user: user, + source: dummy_source, + event_type: "rent_assessed", + occurred_on: Date.new(2026, 1, 1), + description: "January 2026 Rent", + postings: postings + ) + + expect(result).to be_success + entry = result.value!.data[:journal_entry] + expect(entry).to be_persisted + expect(entry.user_id).to eq(user.id) + expect(entry.source_type).to eq("Expense") + expect(entry.source_id).to eq(dummy_source.id) + expect(entry.event_type).to eq("rent_assessed") + expect(entry.occurred_on).to eq(Date.new(2026, 1, 1)) + expect(entry.description).to eq("January 2026 Rent") + expect(entry.posted_at).to be_present + expect(entry.postings.count).to eq(2) + expect(entry.postings.sum(:amount_cents)).to eq(0) + }.to change(JournalEntry, :count).by(1).and change(Posting, :count).by(2) + end + + it "is idempotent on exact retry and returns the existing entry without creating new records" do + first_result = described_class.call( + user: user, + source: dummy_source, + event_type: "rent_assessed", + occurred_on: Date.new(2026, 1, 1), + description: "January 2026 Rent", + postings: postings + ) + expect(first_result).to be_success + first_entry = first_result.value!.data[:journal_entry] + + expect { + retry_result = described_class.call( + user: user, + source: dummy_source, + event_type: "rent_assessed", + occurred_on: Date.new(2026, 1, 1), + description: "January 2026 Rent", + postings: postings + ) + + expect(retry_result).to be_success + expect(retry_result.value!.data[:journal_entry].id).to eq(first_entry.id) + }.not_to change(JournalEntry, :count) + end + + it "fails with :idempotency_conflict when repeating same source identity with changed amount" do + described_class.call( + user: user, + source: dummy_source, + event_type: "rent_assessed", + occurred_on: Date.new(2026, 1, 1), + postings: postings + ) + + conflicting_postings = [ + Accounting::PostingSpec.new(account_key: "tenant_receivable", amount_cents: 250_000, tenancy: tenancy), + Accounting::PostingSpec.new(account_key: "rental_income", amount_cents: -250_000, tenancy: tenancy) + ] + + result = described_class.call( + user: user, + source: dummy_source, + event_type: "rent_assessed", + occurred_on: Date.new(2026, 1, 1), + postings: conflicting_postings + ) + + expect(result).to be_failure + expect(result.failure.code).to eq(:idempotency_conflict) + expect(result.failure.error).to include("already exists with different") + end + + it "fails with :idempotency_conflict when repeating same source identity with changed date" do + described_class.call( + user: user, + source: dummy_source, + event_type: "rent_assessed", + occurred_on: Date.new(2026, 1, 1), + postings: postings + ) + + result = described_class.call( + user: user, + source: dummy_source, + event_type: "rent_assessed", + occurred_on: Date.new(2026, 2, 1), + postings: postings + ) + + expect(result).to be_failure + expect(result.failure.code).to eq(:idempotency_conflict) + end + + it "rejects a source belonging to a different user" do + other_user = create(:user) + other_property = create(:property, user: other_user) + other_expense = create(:expense, property: other_property) + + result = described_class.call( + user: user, + source: other_expense, + event_type: "expense_posted", + occurred_on: Date.current, + postings: postings + ) + + expect(result).to be_failure + expect(result.failure.code).to eq(:ownership_mismatch) + expect(result.failure.error).to include("Source does not belong to user") + end + + it "rejects a RentTerm belonging to a different user when user is passed explicitly" 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, agreement_type: "month_to_month", commencement_date: Date.new(2026, 1, 1), termination_date: nil) + other_term = create(:rent_term, tenancy: other_tenancy, amount_cents: 150_000, effective_from: Date.new(2026, 1, 1)) + + result = described_class.call( + user: user, + source: other_term, + event_type: "rent_scheduled", + occurred_on: Date.current, + postings: postings + ) + + expect(result).to be_failure + expect(result.failure.code).to eq(:ownership_mismatch) + expect(result.failure.error).to include("Source does not belong to user") + end + + it "rejects a source that does not implement accounting_user" do + dummy_record = create(:user) + dummy_record.singleton_class.undef_method(:accounting_user) + + result = described_class.call( + user: user, + source: dummy_record, + event_type: "custom_event", + occurred_on: Date.current, + postings: postings + ) + + expect(result).to be_failure + expect(result.failure.code).to eq(:invalid_source) + expect(result.failure.error).to include("Source must implement accounting_user") + end + + it "fails when source accounting_user is nil or unpersisted" do + orphan_expense = create(:expense, property: property) + allow(orphan_expense).to receive(:accounting_user).and_return(nil) + + result = described_class.call( + user: user, + source: orphan_expense, + event_type: "expense_posted", + occurred_on: Date.current, + postings: postings + ) + + expect(result).to be_failure + expect(result.failure.code).to eq(:invalid_source) + expect(result.failure.error).to include("Source accounting_user must be a persisted user") + end + + it "fails when event_type is blank" do + result = described_class.call( + user: user, + source: dummy_source, + event_type: "", + occurred_on: Date.current, + postings: postings + ) + expect(result).to be_failure + expect(result.failure.code).to eq(:invalid_input) + end + + it "fails when occurred_on is nil" do + result = described_class.call( + user: user, + source: dummy_source, + event_type: "rent_assessed", + occurred_on: nil, + postings: postings + ) + expect(result).to be_failure + expect(result.failure.code).to eq(:invalid_input) + end + + it "fails when occurred_on is an invalid date string" do + result = described_class.call( + user: user, + source: dummy_source, + event_type: "rent_assessed", + occurred_on: "not-a-date", + postings: postings + ) + expect(result).to be_failure + expect(result.failure.code).to eq(:invalid_input) + expect(result.failure.error).to include("Occurred on must be a valid date") + end + + it "fails when source is destroyed" do + dummy_source.destroy + result = described_class.call( + user: user, + source: dummy_source, + event_type: "rent_assessed", + occurred_on: Date.current, + postings: postings + ) + expect(result).to be_failure + expect(result.failure.code).to eq(:invalid_source) + end + + it "accepts a string date for occurred_on" do + result = described_class.call( + user: user, + source: dummy_source, + event_type: "rent_assessed_string_date", + occurred_on: "2026-03-01", + postings: postings + ) + expect(result).to be_success + expect(result.value!.data[:journal_entry].occurred_on).to eq(Date.new(2026, 3, 1)) + end + + it "fails with :idempotency_conflict when repeating same source identity with changed description" do + described_class.call( + user: user, + source: dummy_source, + event_type: "rent_assessed", + occurred_on: Date.new(2026, 1, 1), + description: "Initial description", + postings: postings + ) + + result = described_class.call( + user: user, + source: dummy_source, + event_type: "rent_assessed", + occurred_on: Date.new(2026, 1, 1), + description: "Altered description", + postings: postings + ) + + expect(result).to be_failure + expect(result.failure.code).to eq(:idempotency_conflict) + end + + it "fails with :idempotency_conflict when repeating same source identity with different posting count" do + described_class.call( + user: user, + source: dummy_source, + event_type: "rent_assessed", + occurred_on: Date.new(2026, 1, 1), + postings: postings + ) + + three_line_postings = [ + Accounting::PostingSpec.new(account_key: "tenant_receivable", amount_cents: 100_000, tenancy: tenancy), + Accounting::PostingSpec.new(account_key: "tenant_receivable", amount_cents: 100_000, tenancy: tenancy), + Accounting::PostingSpec.new(account_key: "rental_income", amount_cents: -200_000, tenancy: tenancy) + ] + + result = described_class.call( + user: user, + source: dummy_source, + event_type: "rent_assessed", + occurred_on: Date.new(2026, 1, 1), + postings: three_line_postings + ) + + expect(result).to be_failure + expect(result.failure.code).to eq(:idempotency_conflict) + end + + it "handles RecordInvalid during journal entry creation" do + allow(JournalEntry).to receive(:transaction).and_raise(ActiveRecord::RecordInvalid.new(JournalEntry.new)) + + result = described_class.call( + user: user, + source: dummy_source, + event_type: "rent_assessed_invalid", + occurred_on: Date.current, + postings: postings + ) + expect(result).to be_failure + expect(result.failure.code).to eq(:validation_error) + end + + it "rolls back everything if one posting fails persistence" do + unpersisted = build(:expense, property: property) + result = described_class.call( + user: user, + source: unpersisted, + event_type: "rent_assessed", + occurred_on: Date.current, + postings: postings + ) + expect(result).to be_failure + expect(result.failure.code).to eq(:invalid_source) + end + + it "rolls back journal entry if posting creation fails mid-transaction" do + allow_any_instance_of(Posting).to receive(:valid?).and_return(true) + allow_any_instance_of(Posting).to receive(:save!).and_raise(ActiveRecord::RecordInvalid.new(Posting.new)) + + expect { + result = described_class.call( + user: user, + source: dummy_source, + event_type: "rent_assessed", + occurred_on: Date.current, + postings: postings + ) + expect(result).to be_failure + }.not_to change(JournalEntry, :count) + end + + describe "concurrency race recovery" do + it "recovers from RecordNotUnique race when parallel process commits first" do + # Another process committed the entry in parallel + parallel_entry = JournalEntry.create!( + user: user, + source_type: "Expense", + source_id: dummy_source.id, + event_type: "rent_assessed", + occurred_on: Date.new(2026, 1, 1), + posted_at: Time.current + ) + parallel_entry.postings.create!(account: user.accounts.find_by!(key: "tenant_receivable"), amount_cents: 200_000, tenancy: tenancy, property: property, rentable_unit: unit) + parallel_entry.postings.create!(account: user.accounts.find_by!(key: "rental_income"), amount_cents: -200_000, tenancy: tenancy, property: property, rentable_unit: unit) + + call_count = 0 + allow(JournalEntry).to receive(:find_by).and_wrap_original do |original_method, *args| + call_count += 1 + if call_count == 1 + nil # Initial pre-check returns nil, simulating race before creation + else + original_method.call(*args) + end + end + + allow(user.journal_entries).to receive(:create!).and_raise(ActiveRecord::RecordNotUnique, "PG::UniqueViolation") + + result = described_class.call( + user: user, + source: dummy_source, + event_type: "rent_assessed", + occurred_on: Date.new(2026, 1, 1), + postings: postings + ) + + expect(result).to be_success + expect(result.value!.data[:journal_entry].id).to eq(parallel_entry.id) + end + end + end +end diff --git a/spec/services/accounting/posting_builder_spec.rb b/spec/services/accounting/posting_builder_spec.rb new file mode 100644 index 00000000..f6a9ded5 --- /dev/null +++ b/spec/services/accounting/posting_builder_spec.rb @@ -0,0 +1,224 @@ +require "rails_helper" + +RSpec.describe Accounting::PostingBuilder do + let!(:user) { create(:user) } + let(:property) { create(:property, user: user) } + let(:unit) { create(:rentable_unit, property: property) } + let(:tenancy) { create(:tenancy, rentable_unit: unit) } + let(:party) { create(:party, user: user) } + + describe ".call" do + it "successfully normalizes and balances a valid two-line entry" do + specs = [ + Accounting::PostingSpec.new(account_key: "tenant_receivable", amount_cents: 200_000, tenancy: tenancy), + Accounting::PostingSpec.new(account_key: "rental_income", amount_cents: -200_000, tenancy: tenancy) + ] + + result = described_class.call(user: user, postings: specs) + expect(result).to be_success + + postings = result.value!.data[:postings] + expect(postings.size).to eq(2) + + p1, p2 = postings + expect(p1[:amount_cents]).to eq(200_000) + expect(p1[:account_id]).to eq(user.accounts.find_by(key: "tenant_receivable").id) + expect(p1[:property_id]).to eq(property.id) + expect(p1[:rentable_unit_id]).to eq(unit.id) + expect(p1[:tenancy_id]).to eq(tenancy.id) + + expect(p2[:amount_cents]).to eq(-200_000) + expect(p2[:account_id]).to eq(user.accounts.find_by(key: "rental_income").id) + expect(p2[:property_id]).to eq(property.id) + expect(p2[:rentable_unit_id]).to eq(unit.id) + expect(p2[:tenancy_id]).to eq(tenancy.id) + end + + it "rejects entries with fewer than two lines" do + specs = [ + Accounting::PostingSpec.new(account_key: "cash", amount_cents: 0) + ] + + result = described_class.call(user: user, postings: specs) + expect(result).to be_failure + expect(result.failure.code).to eq(:invalid_postings) + expect(result.failure.error).to include("at least two postings") + end + + it "rejects zero or non-integer amounts" do + specs = [ + Accounting::PostingSpec.new(account_key: "cash", amount_cents: 0), + Accounting::PostingSpec.new(account_key: "rental_income", amount_cents: 0) + ] + + result = described_class.call(user: user, postings: specs) + expect(result).to be_failure + expect(result.failure.code).to eq(:invalid_postings) + expect(result.failure.error).to include("non-zero integer") + end + + it "rejects unbalanced entries" do + specs = [ + Accounting::PostingSpec.new(account_key: "cash", amount_cents: 200_000), + Accounting::PostingSpec.new(account_key: "rental_income", amount_cents: -199_999) + ] + + result = described_class.call(user: user, postings: specs) + expect(result).to be_failure + expect(result.failure.code).to eq(:unbalanced_entry) + expect(result.failure.error).to include("unbalanced") + end + + it "rejects unknown account keys" do + specs = [ + Accounting::PostingSpec.new(account_key: "non_existent_key", amount_cents: 10_000), + Accounting::PostingSpec.new(account_key: "cash", amount_cents: -10_000) + ] + + result = described_class.call(user: user, postings: specs) + expect(result).to be_failure + expect(result.failure.code).to eq(:missing_account) + expect(result.failure.error).to include("Account 'non_existent_key' not found") + end + + it "rejects inactive accounts" do + cash_account = user.accounts.find_by(key: "cash") + cash_account.update!(active: false) + + specs = [ + Accounting::PostingSpec.new(account_key: "cash", amount_cents: 10_000), + Accounting::PostingSpec.new(account_key: "rental_income", amount_cents: -10_000) + ] + + result = described_class.call(user: user, postings: specs) + expect(result).to be_failure + expect(result.failure.code).to eq(:inactive_account) + expect(result.failure.error).to include("Account 'cash' is inactive") + end + + it "derives property from rentable_unit when tenancy is not specified" do + specs = [ + Accounting::PostingSpec.new(account_key: "cash", amount_cents: 50_000, rentable_unit: unit), + Accounting::PostingSpec.new(account_key: "rental_income", amount_cents: -50_000, rentable_unit: unit) + ] + + result = described_class.call(user: user, postings: specs) + expect(result).to be_success + postings = result.value!.data[:postings] + expect(postings.first[:property_id]).to eq(property.id) + expect(postings.first[:rentable_unit_id]).to eq(unit.id) + expect(postings.first[:tenancy_id]).to be_nil + end + + it "rejects contradictory unit and property dimensions" do + other_property = create(:property, user: user) + specs = [ + Accounting::PostingSpec.new(account_key: "cash", amount_cents: 50_000, property: other_property, rentable_unit: unit), + Accounting::PostingSpec.new(account_key: "rental_income", amount_cents: -50_000) + ] + + result = described_class.call(user: user, postings: specs) + expect(result).to be_failure + expect(result.failure.code).to eq(:dimension_mismatch) + expect(result.failure.error).to include("rentable unit does not belong to specified property") + end + + it "rejects contradictory tenancy and unit dimensions" do + other_unit = create(:rentable_unit, property: property, name: "Other Unit") + specs = [ + Accounting::PostingSpec.new(account_key: "cash", amount_cents: 50_000, tenancy: tenancy, rentable_unit: other_unit), + Accounting::PostingSpec.new(account_key: "rental_income", amount_cents: -50_000) + ] + + result = described_class.call(user: user, postings: specs) + expect(result).to be_failure + expect(result.failure.code).to eq(:dimension_mismatch) + expect(result.failure.error).to include("tenancy does not belong to specified rentable unit") + end + + it "rejects contradictory tenancy and property dimensions" do + other_property = create(:property, user: user) + specs = [ + Accounting::PostingSpec.new(account_key: "cash", amount_cents: 50_000, tenancy: tenancy, property: other_property), + Accounting::PostingSpec.new(account_key: "rental_income", amount_cents: -50_000) + ] + + result = described_class.call(user: user, postings: specs) + expect(result).to be_failure + expect(result.failure.code).to eq(:dimension_mismatch) + expect(result.failure.error).to include("tenancy does not belong to specified property") + end + + it "rejects cross-user dimensions for property, unit, tenancy, and party" 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) + other_party = create(:party, user: other_user) + + specs_prop = [ + Accounting::PostingSpec.new(account_key: "cash", amount_cents: 50_000, property: other_property), + Accounting::PostingSpec.new(account_key: "rental_income", amount_cents: -50_000) + ] + expect(described_class.call(user: user, postings: specs_prop).failure.code).to eq(:ownership_mismatch) + + specs_unit = [ + Accounting::PostingSpec.new(account_key: "cash", amount_cents: 50_000, rentable_unit: other_unit), + Accounting::PostingSpec.new(account_key: "rental_income", amount_cents: -50_000) + ] + expect(described_class.call(user: user, postings: specs_unit).failure.code).to eq(:ownership_mismatch) + + specs_tenancy = [ + Accounting::PostingSpec.new(account_key: "cash", amount_cents: 50_000, tenancy: other_tenancy), + Accounting::PostingSpec.new(account_key: "rental_income", amount_cents: -50_000) + ] + expect(described_class.call(user: user, postings: specs_tenancy).failure.code).to eq(:ownership_mismatch) + + specs_party = [ + Accounting::PostingSpec.new(account_key: "cash", amount_cents: 50_000, party: other_party), + Accounting::PostingSpec.new(account_key: "rental_income", amount_cents: -50_000) + ] + expect(described_class.call(user: user, postings: specs_party).failure.code).to eq(:ownership_mismatch) + end + + it "rejects unpersisted dimensions" do + unsaved_property = build(:property, user: user) + unsaved_unit = build(:rentable_unit, property: property) + unsaved_tenancy = build(:tenancy, rentable_unit: unit) + unsaved_party = build(:party, user: user) + + expect(described_class.call(user: user, postings: [ + Accounting::PostingSpec.new(account_key: "cash", amount_cents: 50_000, property: unsaved_property), + Accounting::PostingSpec.new(account_key: "rental_income", amount_cents: -50_000) + ]).failure.code).to eq(:invalid_dimension) + + expect(described_class.call(user: user, postings: [ + Accounting::PostingSpec.new(account_key: "cash", amount_cents: 50_000, rentable_unit: unsaved_unit), + Accounting::PostingSpec.new(account_key: "rental_income", amount_cents: -50_000) + ]).failure.code).to eq(:invalid_dimension) + + expect(described_class.call(user: user, postings: [ + Accounting::PostingSpec.new(account_key: "cash", amount_cents: 50_000, tenancy: unsaved_tenancy), + Accounting::PostingSpec.new(account_key: "rental_income", amount_cents: -50_000) + ]).failure.code).to eq(:invalid_dimension) + + expect(described_class.call(user: user, postings: [ + Accounting::PostingSpec.new(account_key: "cash", amount_cents: 50_000, party: unsaved_party), + Accounting::PostingSpec.new(account_key: "rental_income", amount_cents: -50_000) + ]).failure.code).to eq(:invalid_dimension) + end + + it "rejects destroyed dimensions" do + temp_party = create(:party, user: user) + temp_party.destroy + + result = described_class.call(user: user, postings: [ + Accounting::PostingSpec.new(account_key: "cash", amount_cents: 50_000, party: temp_party), + Accounting::PostingSpec.new(account_key: "rental_income", amount_cents: -50_000) + ]) + + expect(result).to be_failure + expect(result.failure.code).to eq(:invalid_dimension) + end + end +end diff --git a/spec/services/accounting/reverse_entry_service_spec.rb b/spec/services/accounting/reverse_entry_service_spec.rb new file mode 100644 index 00000000..9b4595cb --- /dev/null +++ b/spec/services/accounting/reverse_entry_service_spec.rb @@ -0,0 +1,279 @@ +require "rails_helper" + +RSpec.describe Accounting::ReverseEntryService do + let!(:user) { create(:user) } + let(:property) { create(:property, user: user) } + let(:unit) { create(:rentable_unit, property: property) } + let(:tenancy) { create(:tenancy, rentable_unit: unit) } + let(:party) { create(:party, user: user) } + let(:dummy_source) { create(:expense, property: property) } + + let!(:original_entry) do + entry = create(:journal_entry, + user: user, + source_type: "Expense", + source_id: dummy_source.id, + event_type: "expense_posted", + occurred_on: Date.new(2026, 1, 15), + description: "Original repair expense" + ) + + entry.postings.create!( + account: user.accounts.find_by!(key: "expense_repairs"), + amount_cents: 50_000, + property: property, + rentable_unit: unit, + tenancy: tenancy, + party: party, + memo: "Plumbing repair" + ) + + entry.postings.create!( + account: user.accounts.find_by!(key: "cash"), + amount_cents: -50_000, + property: property, + rentable_unit: unit, + tenancy: tenancy, + party: party, + memo: "Plumbing payment" + ) + + entry + end + + describe ".call" do + it "creates a balanced reversal entry that negates the original postings without modifying original" do + original_desc = original_entry.description + original_postings_count = original_entry.postings.count + + result = described_class.call( + journal_entry: original_entry, + occurred_on: Date.new(2026, 1, 20), + description: "Reversing incorrect expense" + ) + + expect(result).to be_success + reversal = result.value!.data[:journal_entry] + + # Original entry check + expect(original_entry.reload.description).to eq(original_desc) + expect(original_entry.postings.count).to eq(original_postings_count) + expect(original_entry.reversed?).to be(true) + expect(original_entry.reversal).to eq(reversal) + + # Reversal entry check + expect(reversal).to be_persisted + expect(reversal.user_id).to eq(user.id) + expect(reversal.source_type).to eq("JournalEntry") + expect(reversal.source_id).to eq(original_entry.id) + expect(reversal.event_type).to eq("reversal") + expect(reversal.reversal_of_id).to eq(original_entry.id) + expect(reversal.occurred_on).to eq(Date.new(2026, 1, 20)) + expect(reversal.description).to eq("Reversing incorrect expense") + expect(reversal.reversal?).to be(true) + expect(reversal.reversed?).to be(false) + + # Reversal postings check + expect(reversal.postings.count).to eq(2) + expect(reversal.postings.sum(:amount_cents)).to eq(0) + + repairs_rev = reversal.postings.find_by(account: user.accounts.find_by(key: "expense_repairs")) + expect(repairs_rev.amount_cents).to eq(-50_000) + expect(repairs_rev.property_id).to eq(property.id) + expect(repairs_rev.rentable_unit_id).to eq(unit.id) + expect(repairs_rev.tenancy_id).to eq(tenancy.id) + expect(repairs_rev.party_id).to eq(party.id) + expect(repairs_rev.memo).to eq("Plumbing repair") + + cash_rev = reversal.postings.find_by(account: user.accounts.find_by(key: "cash")) + expect(cash_rev.amount_cents).to eq(50_000) + expect(cash_rev.property_id).to eq(property.id) + expect(cash_rev.rentable_unit_id).to eq(unit.id) + expect(cash_rev.tenancy_id).to eq(tenancy.id) + expect(cash_rev.party_id).to eq(party.id) + expect(cash_rev.memo).to eq("Plumbing payment") + + # Aggregate net across original + reversal is zero + total_cents = original_entry.postings.sum(:amount_cents) + reversal.postings.sum(:amount_cents) + expect(total_cents).to eq(0) + end + + it "is idempotent and returns the existing reversal on repeated exact calls" do + first_result = described_class.call( + journal_entry: original_entry, + occurred_on: Date.new(2026, 1, 20), + description: "Reversal memo" + ) + expect(first_result).to be_success + first_reversal = first_result.value!.data[:journal_entry] + + expect { + second_result = described_class.call( + journal_entry: original_entry, + occurred_on: Date.new(2026, 1, 20), + description: "Reversal memo" + ) + expect(second_result).to be_success + expect(second_result.value!.data[:journal_entry].id).to eq(first_reversal.id) + }.not_to change(JournalEntry, :count) + end + + it "fails with :idempotency_conflict when repeating reversal with changed occurred_on" do + described_class.call( + journal_entry: original_entry, + occurred_on: Date.new(2026, 1, 20) + ) + + result = described_class.call( + journal_entry: original_entry, + occurred_on: Date.new(2026, 2, 15) + ) + + expect(result).to be_failure + expect(result.failure.code).to eq(:idempotency_conflict) + expect(result.failure.error).to include("Reversal already exists with different details") + end + + it "fails with :idempotency_conflict when repeating reversal with changed description" do + described_class.call( + journal_entry: original_entry, + occurred_on: Date.new(2026, 1, 20), + description: "Initial description" + ) + + result = described_class.call( + journal_entry: original_entry, + occurred_on: Date.new(2026, 1, 20), + description: "Altered description" + ) + + expect(result).to be_failure + expect(result.failure.code).to eq(:idempotency_conflict) + end + + it "rejects reversal with occurred_on earlier than original journal entry occurred_on" do + result = described_class.call( + journal_entry: original_entry, + occurred_on: original_entry.occurred_on - 1.day + ) + + expect(result).to be_failure + expect(result.failure.code).to eq(:invalid_date) + expect(result.failure.error).to include("cannot precede original entry date") + end + + it "fails when occurred_on is an invalid date string" do + result = described_class.call( + journal_entry: original_entry, + occurred_on: "not-a-date" + ) + expect(result).to be_failure + expect(result.failure.code).to eq(:invalid_input) + expect(result.failure.error).to include("Occurred on must be a valid date") + end + + it "accepts a string date for occurred_on" do + result = described_class.call( + journal_entry: original_entry, + occurred_on: "2026-01-20" + ) + expect(result).to be_success + expect(result.value!.data[:journal_entry].occurred_on).to eq(Date.new(2026, 1, 20)) + end + + it "rejects missing occurred_on date" do + result = described_class.call( + journal_entry: original_entry, + occurred_on: nil + ) + expect(result).to be_failure + expect(result.failure.code).to eq(:invalid_input) + expect(result.failure.error).to include("Occurred on must be a valid date") + end + + it "handles RecordInvalid during reversal creation" do + allow(JournalEntry).to receive(:transaction).and_raise(ActiveRecord::RecordInvalid.new(JournalEntry.new)) + + result = described_class.call( + journal_entry: original_entry, + occurred_on: Date.new(2026, 1, 20) + ) + expect(result).to be_failure + expect(result.failure.code).to eq(:validation_error) + end + + it "recovers when RecordNotUnique race occurs" do + allow(user.journal_entries).to receive(:create!).and_wrap_original do |_orig, *args| + # Simulate parallel process creating reversal first + reversal = user.journal_entries.create!( + source_type: "JournalEntry", + source_id: original_entry.id, + event_type: "reversal", + occurred_on: Date.new(2026, 1, 20), + reversal_of_id: original_entry.id, + posted_at: Time.current + ) + raise ActiveRecord::RecordNotUnique, "PG::UniqueViolation" + end + + result = described_class.call( + journal_entry: original_entry, + occurred_on: Date.new(2026, 1, 20) + ) + expect(result).to be_success + expect(result.value!.data[:journal_entry].reversal_of_id).to eq(original_entry.id) + end + + it "rejects reversing a reversal entry" do + rev_result = described_class.call( + journal_entry: original_entry, + occurred_on: Date.new(2026, 1, 20) + ) + expect(rev_result).to be_success + reversal_entry = rev_result.value!.data[:journal_entry] + + result = described_class.call( + journal_entry: reversal_entry, + occurred_on: Date.new(2026, 1, 25) + ) + + expect(result).to be_failure + expect(result.failure.code).to eq(:invalid_reversal) + expect(result.failure.error).to include("Cannot reverse a reversal") + end + + it "rejects unpersisted journal entry" do + unpersisted = build(:journal_entry, user: user) + result = described_class.call( + journal_entry: unpersisted, + occurred_on: Date.current + ) + expect(result).to be_failure + expect(result.failure.code).to eq(:invalid_source) + end + + it "rejects destroyed journal entry" do + entry = create(:journal_entry, user: user, source_type: "Expense", source_id: 999, event_type: "test") + entry.delete + result = described_class.call( + journal_entry: entry, + occurred_on: Date.current + ) + expect(result).to be_failure + expect(result.failure.code).to eq(:invalid_source) + end + + it "handles RecordNotUnique when reversal cannot be found" do + allow_any_instance_of(ActiveRecord::Associations::CollectionProxy).to receive(:create!).and_raise(ActiveRecord::RecordNotUnique, "PG::UniqueViolation") + allow_any_instance_of(JournalEntry).to receive(:reversal).and_return(nil) + + result = described_class.call( + journal_entry: original_entry, + occurred_on: Date.new(2026, 1, 20) + ) + + expect(result).to be_failure + expect(result.failure.code).to eq(:idempotency_conflict) + end + end +end diff --git a/spec/services/tenancy_parties/create_service_spec.rb b/spec/services/tenancy_parties/create_service_spec.rb index 317d6d05..b5abc414 100644 --- a/spec/services/tenancy_parties/create_service_spec.rb +++ b/spec/services/tenancy_parties/create_service_spec.rb @@ -35,6 +35,19 @@ ) expect(result).to be_success + tp = result.value!.data[:tenancy_party] + expect(tp.effective_until).to eq(Date.new(2025, 12, 31)) + end + + it "derives owner when user is omitted with explicit effective_until" do + result = described_class.call( + tenancy: tenancy, + params: { party_id: party.id, role: "tenant", effective_from: Date.new(2025, 1, 1), effective_until: Date.new(2025, 6, 30) } + ) + + expect(result).to be_success + tp = result.value!.data[:tenancy_party] + expect(tp.effective_until).to eq(Date.new(2025, 6, 30)) end it "fails when tenancy has no property and user is omitted" do