@@ -245,7 +310,7 @@
- <%= link_to "+ Record Payment", new_tenancy_tenant_payment_path(@tenancy), class: "btn btn-primary btn-sm w-full" %>
+ <%= link_to "+ Record Payment", new_tenancy_receipt_path(@tenancy), class: "btn btn-primary btn-sm w-full" %>
<%= link_to "+ Add Charge", new_tenancy_charge_path(@tenancy), class: "btn btn-outline btn-sm w-full" %>
diff --git a/app/views/tenant_payments/_form.html.erb b/app/views/tenant_payments/_form.html.erb
deleted file mode 100644
index 9d90c126..00000000
--- a/app/views/tenant_payments/_form.html.erb
+++ /dev/null
@@ -1,46 +0,0 @@
-<%= form_with(model: tenant_payment) do |form| %>
- <% if tenant_payment.errors.any? %>
-
-
-
<%= pluralize(tenant_payment.errors.count, "error") %> prohibited this payment from being saved:
-
- <% tenant_payment.errors.each do |error| %>
- <%= error.full_message %>
- <% end %>
-
-
-
- <% end %>
-
-
- <%= form.label :tenancy_id, "Tenancy / Property / Tenants", class: "label font-semibold" %>
- <%= form.select :tenancy_id,
- @tenancies.map { |t| ["#{t.property.address} - Tenancy ##{t.id} (#{t.parties.map(&:display_name).join(', ')})", t.id] },
- { include_blank: "Select a tenancy..." },
- { class: "select select-bordered w-full" } %>
-
-
-
- <%= form.label :payment_date, class: "label font-semibold" %>
- <%= form.date_field :payment_date, class: "input input-bordered w-full" %>
-
-
-
- <%= form.label :amount, class: "label font-semibold" %>
- <%= form.text_field :amount, class: "input input-bordered w-full" %>
-
-
-
- <%= form.label :payment_method, class: "label font-semibold" %>
- <%= form.text_field :payment_method, class: "input input-bordered w-full", placeholder: "e.g. Zelle, Check, Cash" %>
-
-
-
- <%= form.label :transaction_number, "Transaction # (Zelle, Venmo, etc.)", class: "label font-semibold" %>
- <%= form.text_field :transaction_number, class: "input input-bordered w-full" %>
-
-
-
- <%= form.submit class: "btn btn-primary w-full mt-4" %>
-
-<% end %>
diff --git a/app/views/tenant_payments/_modal_form.html.erb b/app/views/tenant_payments/_modal_form.html.erb
deleted file mode 100644
index 86b37435..00000000
--- a/app/views/tenant_payments/_modal_form.html.erb
+++ /dev/null
@@ -1,54 +0,0 @@
-<%# Modal form for creating a payment against a specific tenancy %>
-<% tenancy ||= tenant_payment.tenancy %>
-
-
-
- Tenancy ID
- #<%= tenancy.id %>
-
-
- Current Balance
- <% bal = tenancy.current_balance %>
- <% text_class = if bal > 0 then 'text-error' elsif bal < 0 then 'text-success' else 'text-base-content/70' end %>
-
- <%= bal < 0 ? '-' : '' %><%= number_to_currency(bal.abs) %>
-
-
-
-
-<%= form_with(model: [tenancy, tenant_payment], data: { turbo: true }) do |form| %>
- <% if tenant_payment.errors.any? %>
-
-
- <% tenant_payment.errors.each do |error| %>
- <%= error.full_message %>
- <% end %>
-
-
- <% end %>
-
-
- <%= form.label :payment_date, class: "label font-semibold" %>
- <%= form.date_field :payment_date, class: "input input-bordered w-full" %>
-
-
-
- <%= form.label :amount, class: "label font-semibold" %>
- <%= form.number_field :amount, step: "0.01", class: "input input-bordered w-full" %>
-
-
-
- <%= form.label :payment_method, class: "label font-semibold" %>
- <%= form.text_field :payment_method, class: "input input-bordered w-full", placeholder: "e.g. Zelle, Check, Cash" %>
-
-
-
- <%= form.label :transaction_number, "Transaction # (Zelle, Venmo, etc.)", class: "label font-semibold" %>
- <%= form.text_field :transaction_number, class: "input input-bordered w-full" %>
-
-
-
- <%= form.submit "Record Payment", class: "btn btn-primary flex-1" %>
- Cancel
-
-<% end %>
diff --git a/app/views/tenant_payments/_tenant_payment.html.erb b/app/views/tenant_payments/_tenant_payment.html.erb
deleted file mode 100644
index ac8b15df..00000000
--- a/app/views/tenant_payments/_tenant_payment.html.erb
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
- <%= tenant_payment.tenancy.property.address %>
- Tenancy ID: <%= tenant_payment.tenancy_id %> (<%= tenant_payment.tenancy.parties.map(&:display_name).join(', ') %>)
-
- <%= tenant_payment.payment_date %>
- <%= number_to_currency(tenant_payment.amount) %>
- <%= tenant_payment.payment_method %>
- <%= tenant_payment.transaction_number %>
-
- <%= link_to "View", tenant_payment, class: "btn btn-xs btn-ghost" %>
-
-
diff --git a/app/views/tenant_payments/index.html.erb b/app/views/tenant_payments/index.html.erb
deleted file mode 100644
index 06fc8009..00000000
--- a/app/views/tenant_payments/index.html.erb
+++ /dev/null
@@ -1,26 +0,0 @@
-<% content_for :title, "Payments" %>
-
-
-
Payments
- <%= link_to "New Payment", new_tenant_payment_path, class: "btn btn-primary" %>
-
-
-
-
-
-
- Tenancy / Property
- Payment Date
- Amount
- Method
- Transaction #
- Actions
-
-
-
- <% @tenant_payments.each do |tenant_payment| %>
- <%= render tenant_payment %>
- <% end %>
-
-
-
diff --git a/app/views/tenant_payments/new.html.erb b/app/views/tenant_payments/new.html.erb
deleted file mode 100644
index 524885b7..00000000
--- a/app/views/tenant_payments/new.html.erb
+++ /dev/null
@@ -1,18 +0,0 @@
-<% if @lease %>
-
- <%= render "tenant_payments/modal_form", tenant_payment: @tenant_payment, lease: @lease %>
-
-<% else %>
-
-
-
New Payment
- <%= link_to "Cancel", tenant_payments_path, class: "btn btn-ghost" %>
-
-
-
-
- <%= render "form", tenant_payment: @tenant_payment %>
-
-
-
-<% end %>
diff --git a/app/views/tenant_payments/show.html.erb b/app/views/tenant_payments/show.html.erb
deleted file mode 100644
index a2917b89..00000000
--- a/app/views/tenant_payments/show.html.erb
+++ /dev/null
@@ -1,79 +0,0 @@
-
-
-
-
Payment Details
-
Reference: <%= dom_id(@tenant_payment) %>
-
-
- <%= link_to "Back to List", tenant_payments_path, class: "btn btn-ghost" %>
-
-
-
-
-
-
-
-
-
-
Amount Paid
-
<%= number_to_currency(@tenant_payment.amount) %>
-
-
-
-
-
Payment Date
-
<%= @tenant_payment.payment_date.strftime("%B %d, %Y") %>
-
-
-
Payment Method
-
<%= @tenant_payment.payment_method %>
-
-
-
- <% if @tenant_payment.transaction_number.present? %>
-
-
Transaction Number
-
- <%= @tenant_payment.transaction_number %>
-
-
- <% end %>
-
-
-
-
-
-
-
-
Property Address
-
<%= @tenant_payment.tenancy.property.address %>
-
<%= @tenant_payment.tenancy.rentable_unit.display_name %>
-
-
-
-
Tenancy Period
-
<%= @tenant_payment.tenancy.commencement_date.strftime("%b %d, %Y") %> – <%= @tenant_payment.tenancy.termination_date ? @tenant_payment.tenancy.termination_date.strftime("%b %d, %Y") : "Month-to-Month" %>
-
-
-
-
Tenants
-
- <% @tenant_payment.tenancy.parties.each do |party| %>
- <%= party.display_name %>
- <% end %>
-
-
-
-
-
-
-
- <%= link_to "Download PDF Receipt", tenant_payment_path(@tenant_payment, format: :pdf), target: "_blank", class: "btn btn-primary px-8" %>
-
-
- Posted to ledger • Immutable record
-
-
-
-
-
diff --git a/config/routes.rb b/config/routes.rb
index 291dde03..d6beceff 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -15,7 +15,7 @@
resources :parties
resources :tenancies do
- resources :tenant_payments, only: %i[new create]
+ resources :receipts, only: %i[new create]
resources :charges, only: %i[new create]
resources :tenancy_parties, only: %i[new create edit update destroy]
resources :rent_terms, only: %i[new create]
@@ -24,7 +24,13 @@
resources :expenses do
resources :reimbursements, only: %i[new create], controller: "expense_reimbursements"
end
- resources :tenant_payments, only: %i[index show new create]
+ resources :receipts, only: %i[index show new create] do
+ member do
+ get :correction
+ post :correct
+ post :void
+ end
+ end
resources :charges, only: %i[show] do
member do
post :void
diff --git a/db/cable_schema.rb b/db/cable_schema.rb
index f1d424f7..223fc25b 100644
--- a/db/cable_schema.rb
+++ b/db/cable_schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2026_08_16_000004) do
+ActiveRecord::Schema[8.1].define(version: 2026_08_16_000007) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@@ -130,18 +130,18 @@
t.bigint "payment_document_id"
t.string "payment_method"
t.text "raw_text"
+ t.bigint "receipt_id"
t.string "receipt_type"
t.string "source", null: false
t.string "status", default: "pending", null: false
t.bigint "tenancy_id"
- t.bigint "tenant_payment_id"
t.string "transaction_number"
t.datetime "updated_at", null: false
t.bigint "user_id", null: false
t.index ["party_id"], name: "index_payment_ingestions_on_party_id"
t.index ["payment_document_id"], name: "index_payment_ingestions_on_payment_document_id"
+ t.index ["receipt_id"], name: "index_payment_ingestions_on_receipt_id"
t.index ["tenancy_id"], name: "index_payment_ingestions_on_tenancy_id"
- t.index ["tenant_payment_id"], name: "index_payment_ingestions_on_tenant_payment_id"
t.index ["user_id", "payment_method", "transaction_number"], name: "idx_payment_ingestions_dup_check"
t.index ["user_id"], name: "index_payment_ingestions_on_user_id"
end
@@ -177,6 +177,31 @@
t.index ["user_id"], name: "index_properties_on_user_id"
end
+ create_table "receipts", force: :cascade do |t|
+ t.bigint "amount_cents", null: false
+ t.datetime "created_at", null: false
+ t.string "external_reference"
+ t.text "memo"
+ t.bigint "payer_party_id", null: false
+ t.string "payment_method", null: false
+ t.timestamptz "posted_at"
+ t.date "received_on", null: false
+ t.bigint "superseded_by_id"
+ t.bigint "tenancy_id", null: false
+ t.datetime "updated_at", null: false
+ t.bigint "user_id", null: false
+ t.timestamptz "voided_at"
+ t.index ["payer_party_id"], name: "index_receipts_on_payer_party_id"
+ t.index ["received_on"], name: "index_receipts_on_received_on"
+ t.index ["superseded_by_id"], name: "index_receipts_on_superseded_by_id"
+ t.index ["superseded_by_id"], name: "index_receipts_on_superseded_by_id_unique", unique: true, where: "(superseded_by_id IS NOT NULL)"
+ t.index ["tenancy_id"], name: "index_receipts_on_tenancy_id"
+ t.index ["user_id", "payment_method", "external_reference"], name: "index_receipts_on_user_method_external_ref_active", unique: true, where: "((external_reference IS NOT NULL) AND (voided_at IS NULL))"
+ t.index ["user_id"], name: "index_receipts_on_user_id"
+ t.index ["voided_at"], name: "index_receipts_on_voided_at"
+ t.check_constraint "amount_cents > 0", name: "receipts_amount_cents_positive"
+ end
+
create_table "rent_terms", force: :cascade do |t|
t.bigint "amount_cents", null: false
t.datetime "created_at", null: false
@@ -245,20 +270,6 @@
t.index ["tenancy_id"], name: "index_tenancy_parties_on_tenancy_id"
end
- create_table "tenant_payments", force: :cascade do |t|
- t.decimal "amount", precision: 12, scale: 2, null: false
- t.datetime "created_at", null: false
- t.date "payment_date", null: false
- t.string "payment_method", null: false
- t.bigint "tenancy_id", null: false
- t.string "transaction_number"
- t.datetime "updated_at", null: false
- t.bigint "user_id", null: false
- t.index ["tenancy_id"], name: "index_tenant_payments_on_tenancy_id"
- t.index ["user_id", "payment_method", "transaction_number"], name: "index_tenant_payments_on_user_payment_method_transaction_number", unique: true, where: "(transaction_number IS NOT NULL)"
- t.index ["user_id"], name: "index_tenant_payments_on_user_id"
- end
-
create_table "users", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "email", null: false
@@ -281,8 +292,8 @@
add_foreign_key "payment_documents", "users"
add_foreign_key "payment_ingestions", "parties"
add_foreign_key "payment_ingestions", "payment_documents"
+ add_foreign_key "payment_ingestions", "receipts"
add_foreign_key "payment_ingestions", "tenancies"
- add_foreign_key "payment_ingestions", "tenant_payments"
add_foreign_key "payment_ingestions", "users"
add_foreign_key "postings", "accounts"
add_foreign_key "postings", "journal_entries"
@@ -291,12 +302,14 @@
add_foreign_key "postings", "rentable_units"
add_foreign_key "postings", "tenancies"
add_foreign_key "properties", "users"
+ add_foreign_key "receipts", "parties", column: "payer_party_id"
+ add_foreign_key "receipts", "receipts", column: "superseded_by_id"
+ add_foreign_key "receipts", "tenancies"
+ add_foreign_key "receipts", "users"
add_foreign_key "rent_terms", "tenancies"
add_foreign_key "rentable_units", "properties"
add_foreign_key "sessions", "users"
add_foreign_key "tenancies", "rentable_units"
add_foreign_key "tenancy_parties", "parties"
add_foreign_key "tenancy_parties", "tenancies"
- add_foreign_key "tenant_payments", "tenancies"
- add_foreign_key "tenant_payments", "users"
end
diff --git a/db/cache_schema.rb b/db/cache_schema.rb
index c76f6160..91922bcc 100644
--- a/db/cache_schema.rb
+++ b/db/cache_schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2026_08_16_000004) do
+ActiveRecord::Schema[8.1].define(version: 2026_08_16_000007) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@@ -130,18 +130,18 @@
t.bigint "payment_document_id"
t.string "payment_method"
t.text "raw_text"
+ t.bigint "receipt_id"
t.string "receipt_type"
t.string "source", null: false
t.string "status", default: "pending", null: false
t.bigint "tenancy_id"
- t.bigint "tenant_payment_id"
t.string "transaction_number"
t.datetime "updated_at", null: false
t.bigint "user_id", null: false
t.index ["party_id"], name: "index_payment_ingestions_on_party_id"
t.index ["payment_document_id"], name: "index_payment_ingestions_on_payment_document_id"
+ t.index ["receipt_id"], name: "index_payment_ingestions_on_receipt_id"
t.index ["tenancy_id"], name: "index_payment_ingestions_on_tenancy_id"
- t.index ["tenant_payment_id"], name: "index_payment_ingestions_on_tenant_payment_id"
t.index ["user_id", "payment_method", "transaction_number"], name: "idx_payment_ingestions_dup_check"
t.index ["user_id"], name: "index_payment_ingestions_on_user_id"
end
@@ -177,6 +177,31 @@
t.index ["user_id"], name: "index_properties_on_user_id"
end
+ create_table "receipts", force: :cascade do |t|
+ t.bigint "amount_cents", null: false
+ t.datetime "created_at", null: false
+ t.string "external_reference"
+ t.text "memo"
+ t.bigint "payer_party_id", null: false
+ t.string "payment_method", null: false
+ t.timestamptz "posted_at"
+ t.date "received_on", null: false
+ t.bigint "superseded_by_id"
+ t.bigint "tenancy_id", null: false
+ t.datetime "updated_at", null: false
+ t.bigint "user_id", null: false
+ t.timestamptz "voided_at"
+ t.index ["payer_party_id"], name: "index_receipts_on_payer_party_id"
+ t.index ["received_on"], name: "index_receipts_on_received_on"
+ t.index ["superseded_by_id"], name: "index_receipts_on_superseded_by_id"
+ t.index ["superseded_by_id"], name: "index_receipts_on_superseded_by_id_unique", unique: true, where: "(superseded_by_id IS NOT NULL)"
+ t.index ["tenancy_id"], name: "index_receipts_on_tenancy_id"
+ t.index ["user_id", "payment_method", "external_reference"], name: "index_receipts_on_user_method_external_ref_active", unique: true, where: "((external_reference IS NOT NULL) AND (voided_at IS NULL))"
+ t.index ["user_id"], name: "index_receipts_on_user_id"
+ t.index ["voided_at"], name: "index_receipts_on_voided_at"
+ t.check_constraint "amount_cents > 0", name: "receipts_amount_cents_positive"
+ end
+
create_table "rent_terms", force: :cascade do |t|
t.bigint "amount_cents", null: false
t.datetime "created_at", null: false
@@ -246,20 +271,6 @@
t.index ["tenancy_id"], name: "index_tenancy_parties_on_tenancy_id"
end
- create_table "tenant_payments", force: :cascade do |t|
- t.decimal "amount", precision: 12, scale: 2, null: false
- t.datetime "created_at", null: false
- t.date "payment_date", null: false
- t.string "payment_method", null: false
- t.bigint "tenancy_id", null: false
- t.string "transaction_number"
- t.datetime "updated_at", null: false
- t.bigint "user_id", null: false
- t.index ["tenancy_id"], name: "index_tenant_payments_on_tenancy_id"
- t.index ["user_id", "payment_method", "transaction_number"], name: "index_tenant_payments_on_user_payment_method_transaction_number", unique: true, where: "(transaction_number IS NOT NULL)"
- t.index ["user_id"], name: "index_tenant_payments_on_user_id"
- end
-
create_table "users", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "email", null: false
@@ -282,8 +293,8 @@
add_foreign_key "payment_documents", "users"
add_foreign_key "payment_ingestions", "parties"
add_foreign_key "payment_ingestions", "payment_documents"
+ add_foreign_key "payment_ingestions", "receipts"
add_foreign_key "payment_ingestions", "tenancies"
- add_foreign_key "payment_ingestions", "tenant_payments"
add_foreign_key "payment_ingestions", "users"
add_foreign_key "postings", "accounts"
add_foreign_key "postings", "journal_entries"
@@ -292,12 +303,14 @@
add_foreign_key "postings", "rentable_units"
add_foreign_key "postings", "tenancies"
add_foreign_key "properties", "users"
+ add_foreign_key "receipts", "parties", column: "payer_party_id"
+ add_foreign_key "receipts", "receipts", column: "superseded_by_id"
+ add_foreign_key "receipts", "tenancies"
+ add_foreign_key "receipts", "users"
add_foreign_key "rent_terms", "tenancies"
add_foreign_key "rentable_units", "properties"
add_foreign_key "sessions", "users"
add_foreign_key "tenancies", "rentable_units"
add_foreign_key "tenancy_parties", "parties"
add_foreign_key "tenancy_parties", "tenancies"
- add_foreign_key "tenant_payments", "tenancies"
- add_foreign_key "tenant_payments", "users"
end
diff --git a/db/migrate/20260816000005_create_receipts.rb b/db/migrate/20260816000005_create_receipts.rb
new file mode 100644
index 00000000..c2d60271
--- /dev/null
+++ b/db/migrate/20260816000005_create_receipts.rb
@@ -0,0 +1,28 @@
+class CreateReceipts < ActiveRecord::Migration[8.1]
+ def change
+ create_table :receipts do |t|
+ t.references :user, null: false, foreign_key: true
+ t.references :tenancy, null: false, foreign_key: true
+ t.references :payer_party, null: false, foreign_key: { to_table: :parties }
+ t.bigint :amount_cents, null: false
+ t.date :received_on, null: false
+ t.string :payment_method, null: false
+ t.string :external_reference
+ t.text :memo
+ t.timestamptz :posted_at
+ t.timestamptz :voided_at
+ t.references :superseded_by, foreign_key: { to_table: :receipts }
+
+ t.timestamps
+ end
+
+ add_check_constraint :receipts, "amount_cents > 0", name: "receipts_amount_cents_positive"
+ add_index :receipts, :received_on
+ add_index :receipts, :voided_at
+ add_index :receipts, :superseded_by_id, unique: true, where: "superseded_by_id IS NOT NULL", name: "index_receipts_on_superseded_by_id_unique"
+ add_index :receipts, %i[user_id payment_method external_reference],
+ unique: true,
+ where: "external_reference IS NOT NULL AND voided_at IS NULL",
+ name: "index_receipts_on_user_method_external_ref_active"
+ end
+end
diff --git a/db/migrate/20260816000006_retarget_payment_ingestions_to_receipts.rb b/db/migrate/20260816000006_retarget_payment_ingestions_to_receipts.rb
new file mode 100644
index 00000000..7ff655a6
--- /dev/null
+++ b/db/migrate/20260816000006_retarget_payment_ingestions_to_receipts.rb
@@ -0,0 +1,6 @@
+class RetargetPaymentIngestionsToReceipts < ActiveRecord::Migration[8.1]
+ def change
+ add_reference :payment_ingestions, :receipt, null: true, foreign_key: true
+ remove_reference :payment_ingestions, :tenant_payment, foreign_key: true
+ end
+end
diff --git a/db/migrate/20260816000007_drop_tenant_payments.rb b/db/migrate/20260816000007_drop_tenant_payments.rb
new file mode 100644
index 00000000..6532a780
--- /dev/null
+++ b/db/migrate/20260816000007_drop_tenant_payments.rb
@@ -0,0 +1,14 @@
+class DropTenantPayments < ActiveRecord::Migration[8.1]
+ def change
+ drop_table :tenant_payments do |t|
+ t.references :tenancy, null: false, foreign_key: true
+ t.references :user, null: false, foreign_key: true
+ t.decimal :amount, precision: 12, scale: 2, null: false
+ t.date :payment_date, null: false
+ t.string :payment_method, null: false
+ t.string :transaction_number
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/queue_schema.rb b/db/queue_schema.rb
index bdd9f808..6b56d0fe 100644
--- a/db/queue_schema.rb
+++ b/db/queue_schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2026_08_16_000004) do
+ActiveRecord::Schema[8.1].define(version: 2026_08_16_000007) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@@ -130,18 +130,18 @@
t.bigint "payment_document_id"
t.string "payment_method"
t.text "raw_text"
+ t.bigint "receipt_id"
t.string "receipt_type"
t.string "source", null: false
t.string "status", default: "pending", null: false
t.bigint "tenancy_id"
- t.bigint "tenant_payment_id"
t.string "transaction_number"
t.datetime "updated_at", null: false
t.bigint "user_id", null: false
t.index ["party_id"], name: "index_payment_ingestions_on_party_id"
t.index ["payment_document_id"], name: "index_payment_ingestions_on_payment_document_id"
+ t.index ["receipt_id"], name: "index_payment_ingestions_on_receipt_id"
t.index ["tenancy_id"], name: "index_payment_ingestions_on_tenancy_id"
- t.index ["tenant_payment_id"], name: "index_payment_ingestions_on_tenant_payment_id"
t.index ["user_id", "payment_method", "transaction_number"], name: "idx_payment_ingestions_dup_check"
t.index ["user_id"], name: "index_payment_ingestions_on_user_id"
end
@@ -177,6 +177,31 @@
t.index ["user_id"], name: "index_properties_on_user_id"
end
+ create_table "receipts", force: :cascade do |t|
+ t.bigint "amount_cents", null: false
+ t.datetime "created_at", null: false
+ t.string "external_reference"
+ t.text "memo"
+ t.bigint "payer_party_id", null: false
+ t.string "payment_method", null: false
+ t.timestamptz "posted_at"
+ t.date "received_on", null: false
+ t.bigint "superseded_by_id"
+ t.bigint "tenancy_id", null: false
+ t.datetime "updated_at", null: false
+ t.bigint "user_id", null: false
+ t.timestamptz "voided_at"
+ t.index ["payer_party_id"], name: "index_receipts_on_payer_party_id"
+ t.index ["received_on"], name: "index_receipts_on_received_on"
+ t.index ["superseded_by_id"], name: "index_receipts_on_superseded_by_id"
+ t.index ["superseded_by_id"], name: "index_receipts_on_superseded_by_id_unique", unique: true, where: "(superseded_by_id IS NOT NULL)"
+ t.index ["tenancy_id"], name: "index_receipts_on_tenancy_id"
+ t.index ["user_id", "payment_method", "external_reference"], name: "index_receipts_on_user_method_external_ref_active", unique: true, where: "((external_reference IS NOT NULL) AND (voided_at IS NULL))"
+ t.index ["user_id"], name: "index_receipts_on_user_id"
+ t.index ["voided_at"], name: "index_receipts_on_voided_at"
+ t.check_constraint "amount_cents > 0", name: "receipts_amount_cents_positive"
+ end
+
create_table "rent_terms", force: :cascade do |t|
t.bigint "amount_cents", null: false
t.datetime "created_at", null: false
@@ -356,20 +381,6 @@
t.index ["tenancy_id"], name: "index_tenancy_parties_on_tenancy_id"
end
- create_table "tenant_payments", force: :cascade do |t|
- t.decimal "amount", precision: 12, scale: 2, null: false
- t.datetime "created_at", null: false
- t.date "payment_date", null: false
- t.string "payment_method", null: false
- t.bigint "tenancy_id", null: false
- t.string "transaction_number"
- t.datetime "updated_at", null: false
- t.bigint "user_id", null: false
- t.index ["tenancy_id"], name: "index_tenant_payments_on_tenancy_id"
- t.index ["user_id", "payment_method", "transaction_number"], name: "index_tenant_payments_on_user_payment_method_transaction_number", unique: true, where: "(transaction_number IS NOT NULL)"
- t.index ["user_id"], name: "index_tenant_payments_on_user_id"
- end
-
create_table "users", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "email", null: false
@@ -392,8 +403,8 @@
add_foreign_key "payment_documents", "users"
add_foreign_key "payment_ingestions", "parties"
add_foreign_key "payment_ingestions", "payment_documents"
+ add_foreign_key "payment_ingestions", "receipts"
add_foreign_key "payment_ingestions", "tenancies"
- add_foreign_key "payment_ingestions", "tenant_payments"
add_foreign_key "payment_ingestions", "users"
add_foreign_key "postings", "accounts"
add_foreign_key "postings", "journal_entries"
@@ -402,6 +413,10 @@
add_foreign_key "postings", "rentable_units"
add_foreign_key "postings", "tenancies"
add_foreign_key "properties", "users"
+ add_foreign_key "receipts", "parties", column: "payer_party_id"
+ add_foreign_key "receipts", "receipts", column: "superseded_by_id"
+ add_foreign_key "receipts", "tenancies"
+ add_foreign_key "receipts", "users"
add_foreign_key "rent_terms", "tenancies"
add_foreign_key "rentable_units", "properties"
add_foreign_key "sessions", "users"
@@ -414,6 +429,4 @@
add_foreign_key "tenancies", "rentable_units"
add_foreign_key "tenancy_parties", "parties"
add_foreign_key "tenancy_parties", "tenancies"
- add_foreign_key "tenant_payments", "tenancies"
- add_foreign_key "tenant_payments", "users"
end
diff --git a/db/schema.rb b/db/schema.rb
index 66fa7aff..204418c3 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2026_08_16_000004) do
+ActiveRecord::Schema[8.1].define(version: 2026_08_16_000007) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@@ -24,7 +24,7 @@
t.bigint "user_id", null: false
t.index ["user_id", "key"], name: "index_accounts_on_user_id_and_key", unique: true
t.index ["user_id"], name: "index_accounts_on_user_id"
- t.check_constraint "account_type::text = ANY (ARRAY['asset'::character varying, 'liability'::character varying, 'equity'::character varying, 'income'::character varying, 'expense'::character varying]::text[])", name: "check_accounts_account_type"
+ t.check_constraint "account_type::text = ANY (ARRAY['asset'::character varying::text, 'liability'::character varying::text, 'equity'::character varying::text, 'income'::character varying::text, 'expense'::character varying::text])", name: "check_accounts_account_type"
end
create_table "charges", force: :cascade do |t|
@@ -54,7 +54,7 @@
t.index ["tenancy_id"], name: "index_charges_on_tenancy_id"
t.index ["voided_at"], name: "index_charges_on_voided_at"
t.check_constraint "amount_cents > 0", name: "charges_amount_cents_positive"
- t.check_constraint "charge_kind::text = ANY (ARRAY['rent'::character varying, 'late_fee'::character varying, 'reimbursement'::character varying, 'other'::character varying]::text[])", name: "charges_charge_kind_valid"
+ t.check_constraint "charge_kind::text = ANY (ARRAY['rent'::character varying::text, 'late_fee'::character varying::text, 'reimbursement'::character varying::text, 'other'::character varying::text])", name: "charges_charge_kind_valid"
t.check_constraint "service_period_end IS NULL OR service_period_start IS NULL OR service_period_end >= service_period_start", name: "charges_service_period_valid"
end
@@ -130,18 +130,18 @@
t.bigint "payment_document_id"
t.string "payment_method"
t.text "raw_text"
+ t.bigint "receipt_id"
t.string "receipt_type"
t.string "source", null: false
t.string "status", default: "pending", null: false
t.bigint "tenancy_id"
- t.bigint "tenant_payment_id"
t.string "transaction_number"
t.datetime "updated_at", null: false
t.bigint "user_id", null: false
t.index ["party_id"], name: "index_payment_ingestions_on_party_id"
t.index ["payment_document_id"], name: "index_payment_ingestions_on_payment_document_id"
+ t.index ["receipt_id"], name: "index_payment_ingestions_on_receipt_id"
t.index ["tenancy_id"], name: "index_payment_ingestions_on_tenancy_id"
- t.index ["tenant_payment_id"], name: "index_payment_ingestions_on_tenant_payment_id"
t.index ["user_id", "payment_method", "transaction_number"], name: "idx_payment_ingestions_dup_check"
t.index ["user_id"], name: "index_payment_ingestions_on_user_id"
end
@@ -177,6 +177,31 @@
t.index ["user_id"], name: "index_properties_on_user_id"
end
+ create_table "receipts", force: :cascade do |t|
+ t.bigint "amount_cents", null: false
+ t.datetime "created_at", null: false
+ t.string "external_reference"
+ t.text "memo"
+ t.bigint "payer_party_id", null: false
+ t.string "payment_method", null: false
+ t.timestamptz "posted_at"
+ t.date "received_on", null: false
+ t.bigint "superseded_by_id"
+ t.bigint "tenancy_id", null: false
+ t.datetime "updated_at", null: false
+ t.bigint "user_id", null: false
+ t.timestamptz "voided_at"
+ t.index ["payer_party_id"], name: "index_receipts_on_payer_party_id"
+ t.index ["received_on"], name: "index_receipts_on_received_on"
+ t.index ["superseded_by_id"], name: "index_receipts_on_superseded_by_id"
+ t.index ["superseded_by_id"], name: "index_receipts_on_superseded_by_id_unique", unique: true, where: "(superseded_by_id IS NOT NULL)"
+ t.index ["tenancy_id"], name: "index_receipts_on_tenancy_id"
+ t.index ["user_id", "payment_method", "external_reference"], name: "index_receipts_on_user_method_external_ref_active", unique: true, where: "((external_reference IS NOT NULL) AND (voided_at IS NULL))"
+ t.index ["user_id"], name: "index_receipts_on_user_id"
+ t.index ["voided_at"], name: "index_receipts_on_voided_at"
+ t.check_constraint "amount_cents > 0", name: "receipts_amount_cents_positive"
+ end
+
create_table "rent_terms", force: :cascade do |t|
t.bigint "amount_cents", null: false
t.datetime "created_at", null: false
@@ -235,20 +260,6 @@
t.index ["tenancy_id"], name: "index_tenancy_parties_on_tenancy_id"
end
- create_table "tenant_payments", force: :cascade do |t|
- t.decimal "amount", precision: 12, scale: 2, null: false
- t.datetime "created_at", null: false
- t.date "payment_date", null: false
- t.string "payment_method", null: false
- t.bigint "tenancy_id", null: false
- t.string "transaction_number"
- t.datetime "updated_at", null: false
- t.bigint "user_id", null: false
- t.index ["tenancy_id"], name: "index_tenant_payments_on_tenancy_id"
- t.index ["user_id", "payment_method", "transaction_number"], name: "index_tenant_payments_on_user_payment_method_transaction_number", unique: true, where: "(transaction_number IS NOT NULL)"
- t.index ["user_id"], name: "index_tenant_payments_on_user_id"
- end
-
create_table "users", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "email", null: false
@@ -271,8 +282,8 @@
add_foreign_key "payment_documents", "users"
add_foreign_key "payment_ingestions", "parties"
add_foreign_key "payment_ingestions", "payment_documents"
+ add_foreign_key "payment_ingestions", "receipts"
add_foreign_key "payment_ingestions", "tenancies"
- add_foreign_key "payment_ingestions", "tenant_payments"
add_foreign_key "payment_ingestions", "users"
add_foreign_key "postings", "accounts"
add_foreign_key "postings", "journal_entries"
@@ -281,12 +292,14 @@
add_foreign_key "postings", "rentable_units"
add_foreign_key "postings", "tenancies"
add_foreign_key "properties", "users"
+ add_foreign_key "receipts", "parties", column: "payer_party_id"
+ add_foreign_key "receipts", "receipts", column: "superseded_by_id"
+ add_foreign_key "receipts", "tenancies"
+ add_foreign_key "receipts", "users"
add_foreign_key "rent_terms", "tenancies"
add_foreign_key "rentable_units", "properties"
add_foreign_key "sessions", "users"
add_foreign_key "tenancies", "rentable_units"
add_foreign_key "tenancy_parties", "parties"
add_foreign_key "tenancy_parties", "tenancies"
- add_foreign_key "tenant_payments", "tenancies"
- add_foreign_key "tenant_payments", "users"
end
diff --git a/db/seeds.rb b/db/seeds.rb
index 4538eb63..5fb61eaf 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -34,7 +34,7 @@
(1..11).each do |months_ago|
due_date = (Date.current - months_ago.months).beginning_of_month
payment_date = due_date + rand(tenancy.late_period_days || 3)
- TenantPayments::CreateService.call(tenancy: tenancy, amount: 1200.0, payment_date: payment_date, payment_method: "ach")
+ Receipts::CreateService.call(tenancy: tenancy, payer_party: party, amount: 1200.0, received_on: payment_date, payment_method: "ach")
expense_amount = rand(100...200)
expense = Expense.new(
@@ -47,7 +47,7 @@
reimburse_amount: expense_amount
)
Expenses::SaveService.call(expense: expense)
- TenantPayments::CreateService.call(tenancy: tenancy, amount: expense_amount, payment_date: payment_date, payment_method: "ach")
+ Receipts::CreateService.call(tenancy: tenancy, payer_party: party, amount: expense_amount, received_on: payment_date, payment_method: "ach")
end
Expense.create!(property: property, amount: rand(200...300), category: "repairs", expense_date: Date.current - 1.year, description: "A/C tune-up")
diff --git a/documentation/double_entry_accounting/implementation_plan_milestone_4.md b/documentation/double_entry_accounting/implementation_plan_milestone_4.md
new file mode 100644
index 00000000..bade567b
--- /dev/null
+++ b/documentation/double_entry_accounting/implementation_plan_milestone_4.md
@@ -0,0 +1,3095 @@
+# Implementation Plan: Milestone 4 — Receipts
+
+## Objective
+
+Replace the temporary `TenantPayment` domain with the permanent `Receipt` domain.
+
+At the end of this milestone:
+
+```text
+Party (payer)
+ │
+ ▼
+Receipt
+├── Tenancy
+├── amount_cents
+├── received_on
+├── payment_method
+├── external_reference
+└── memo
+ │
+ ▼
+JournalEntry
+├── Dr Cash
+└── Cr Tenant Receivable
+```
+
+A Receipt must preserve:
+
+```text
+who paid
+which tenancy received the credit
+how much was received
+when it was received
+how it was received
+external transaction identity
+correction/void history
+```
+
+Tenant balance remains:
+
+```text
+SUM(Tenant Receivable postings for tenancy)
+```
+
+Milestone 4 must not change that definition.
+
+---
+
+# 1. Milestone boundary
+
+Implement:
+
+- `Receipt`
+- payer identity
+- integer-cent receipt amounts
+- ordinary receipt posting
+- manual receipt/payment UI
+- partial payments
+- prepayments
+- overpayments
+- receipt voiding
+- receipt correction/replacement
+- immutable receipt history
+- receipt PDF
+- ingestion confirmation to Receipt
+- ingestion duplicate checking against Receipt
+- removal of the temporary `TenantPayment` ledger adapter
+- deletion of `TenantPayment`
+
+Do not implement:
+
+- receipt-to-charge allocations
+- security deposits
+- deposit receipts
+- returned-check/bank-return accounting
+- tenant refunds
+- cash-account reconciliation
+- arbitrary account selection
+- bank accounts
+- expense posting
+- property ledger migration to journal-entry projections
+- final Schedule E accounting semantics
+- `SourceDocument` / `ImportedTransaction` redesign
+
+Payment ingestion remains `PaymentIngestion` for now.
+
+---
+
+# 2. Important semantic distinction: correction versus later cash movement
+
+Receipt correction in this milestone means:
+
+```text
+"The original accounting record was wrong."
+```
+
+Examples:
+
+```text
+entered $2,000 instead of $2,100
+wrong payer
+wrong tenancy
+wrong received date
+duplicate receipt entered accidentally
+```
+
+Correction must **restate the bookkeeping history**.
+
+It does not represent:
+
+```text
+payment bounced later
+landlord refunded money later
+bank reversed transfer later
+```
+
+Those are new economic events and must not be modeled as Receipt corrections.
+
+Do not use `Receipts::VoidService` to represent a later real-world cash outflow.
+
+---
+
+# 3. Establish baseline
+
+Before modifying code:
+
+```bash
+git status --short
+
+bundle exec rspec
+bundle exec rbs validate
+bundle exec steep check
+bin/rubocop
+bin/brakeman --no-pager
+```
+
+Then inventory the temporary payment implementation:
+
+```bash
+rg -n \
+ 'TenantPayment|tenant_payment|tenant_payments|TenantPayments::|payment_date|transaction_number' \
+ app config db spec sig documentation
+```
+
+Pay particular attention to:
+
+```text
+TenantPayments::CreateService
+TenantPaymentsController
+TenantPayments::ReceiptPdfService
+PaymentIngestion
+PaymentIngestions::ConfirmService
+Properties::FinancialItemsQuery
+Properties::ActiveYearsQuery
+Properties::ScheduleESummaryQuery
+Property
+Tenancy
+User
+seeds
+```
+
+Do not delete `TenantPayment` until every one of these dependencies has been moved.
+
+---
+
+# 4. Receipt ownership
+
+A Receipt belongs to:
+
+```text
+User
+Tenancy
+payer Party
+```
+
+The canonical business ownership remains:
+
+```text
+Receipt
+ -> Tenancy
+ -> RentableUnit
+ -> Property
+ -> User
+```
+
+However, include `user_id` directly on `receipts`.
+
+This is intentional denormalization so the database can enforce user-scoped external transaction uniqueness without relying on an application-level join.
+
+Enforce:
+
+```text
+receipt.user_id
+==
+receipt.tenancy.accounting_user.id
+==
+receipt.payer_party.user_id
+```
+
+`user_id` is immutable.
+
+---
+
+# 5. Create `receipts`
+
+Create:
+
+```text
+receipts
+
+id
+user_id NOT NULL
+tenancy_id NOT NULL
+payer_party_id NOT NULL
+
+amount_cents BIGINT NOT NULL
+received_on DATE NOT NULL
+payment_method NOT NULL
+external_reference
+memo
+
+posted_at
+voided_at
+superseded_by_id
+
+created_at NOT NULL
+updated_at NOT NULL
+```
+
+Foreign keys:
+
+```text
+user_id -> users
+tenancy_id -> tenancies
+payer_party_id -> parties
+superseded_by_id -> receipts
+```
+
+---
+
+# 6. Receipt database constraints
+
+Add:
+
+```text
+amount_cents > 0
+```
+
+Add indexes:
+
+```text
+user_id
+tenancy_id
+payer_party_id
+received_on
+voided_at
+superseded_by_id
+```
+
+Add an active external-reference uniqueness constraint:
+
+```text
+UNIQUE (
+ user_id,
+ payment_method,
+ external_reference
+)
+WHERE
+ external_reference IS NOT NULL
+ AND voided_at IS NULL
+```
+
+Normalize blank references to `NULL`.
+
+This permits:
+
+```text
+void erroneous receipt
+record corrected replacement using same external reference
+```
+
+while still preventing two active receipts from representing the same external transaction.
+
+Add:
+
+```text
+UNIQUE(superseded_by_id)
+WHERE superseded_by_id IS NOT NULL
+```
+
+so one replacement Receipt cannot accidentally serve as the replacement for multiple originals.
+
+---
+
+# 7. Do not require external references
+
+Cash, check, or manually-recorded payments may lack a reliable external identifier.
+
+Therefore:
+
+```text
+external_reference is optional
+```
+
+Do not use:
+
+```text
+amount + date + payer
+```
+
+as a hard uniqueness key.
+
+Two legitimate receipts can have exactly the same:
+
+```text
+payer
+date
+amount
+method
+```
+
+---
+
+# 8. Implement `Receipt`
+
+Create:
+
+```text
+app/models/receipt.rb
+```
+
+Associations:
+
+```ruby
+belongs_to :user
+belongs_to :tenancy
+
+belongs_to :payer_party,
+ class_name: "Party"
+
+belongs_to :superseded_by,
+ class_name: "Receipt",
+ optional: true
+
+has_one :superseded_receipt,
+ class_name: "Receipt",
+ foreign_key: :superseded_by_id
+
+has_many :journal_entries,
+ as: :source,
+ dependent: :restrict_with_error
+```
+
+Implement:
+
+```ruby
+def accounting_user
+ user
+end
+```
+
+---
+
+# 9. Receipt validation
+
+Validate:
+
+```text
+user present
+tenancy present
+payer party present
+
+amount_cents > 0
+received_on present
+payment_method present
+```
+
+Ownership validation:
+
+```text
+tenancy.accounting_user == user
+payer_party.user == user
+```
+
+Do **not** require:
+
+```text
+payer_party participates in tenancy
+```
+
+A payer may legitimately be:
+
+```text
+parent
+employer
+guarantor
+organization
+other third party
+```
+
+The Receipt identifies who sent money.
+
+The Tenancy identifies which account received the credit.
+
+---
+
+# 10. Payment-method semantics
+
+Retain a flexible string field.
+
+Do not make Receipt dependent on a closed enum that cannot represent future payment methods.
+
+Normalize:
+
+```text
+strip whitespace
+downcase
+```
+
+Examples:
+
+```text
+cash
+check
+ach
+wire
+zelle
+venmo
+p2p
+other
+```
+
+The UI may present common choices.
+
+The persistence model should not require a schema change for a new method.
+
+---
+
+# 11. External-reference semantics
+
+Rename the old concept:
+
+```text
+transaction_number
+```
+
+to:
+
+```text
+external_reference
+```
+
+because the identifier may come from:
+
+```text
+Zelle
+Venmo
+check number
+bank transfer
+other external systems
+```
+
+Normalize:
+
+```text
+strip surrounding whitespace
+blank -> nil
+```
+
+Do not preserve the old restrictive alphanumeric/dash/underscore format unless there is a demonstrated external-system requirement.
+
+Use a reasonable maximum length, e.g.:
+
+```text
+255
+```
+
+---
+
+# 12. Receipt amount API
+
+Persist only:
+
+```text
+amount_cents
+```
+
+Provide presentation helpers:
+
+```ruby
+receipt.amount
+receipt.amount = "123.45"
+```
+
+if useful to existing views/forms.
+
+`amount` should return:
+
+```ruby
+BigDecimal(amount_cents.to_s) / 100
+```
+
+Parsing must reject values with fractional cents.
+
+For example:
+
+```text
+100 valid
+100.5 valid -> 10050
+100.50 valid -> 10050
+100.005 invalid
+abc invalid
+0 invalid
+negative invalid
+```
+
+Do not silently round user input.
+
+---
+
+# 13. Receipt lifecycle
+
+A Receipt begins:
+
+```text
+persisted
+posted_at = nil
+```
+
+only temporarily inside its creation transaction.
+
+Before transaction commit it must become:
+
+```text
+posted_at = journal_entry.posted_at
+```
+
+There must be no normal committed state:
+
+```text
+Receipt persisted
+posted_at nil
+JournalEntry absent
+```
+
+---
+
+# 14. Posted Receipt immutability
+
+Once posted, the following become immutable:
+
+```text
+user_id
+tenancy_id
+payer_party_id
+amount_cents
+received_on
+payment_method
+external_reference
+memo
+posted_at
+```
+
+Ordinary Active Record updates must reject changes.
+
+The only post-posting lifecycle fields are:
+
+```text
+voided_at
+superseded_by_id
+```
+
+and those may only be changed through Receipt lifecycle services.
+
+Direct code such as:
+
+```ruby
+receipt.update!(voided_at: Time.current)
+```
+
+must fail.
+
+This should follow the same controlled-lifecycle pattern already established for `Charge`.
+
+---
+
+# 15. Receipt scopes/helpers
+
+Implement:
+
+```ruby
+scope :active, -> { where(voided_at: nil) }
+
+def posted?
+def voided?
+def superseded?
+```
+
+Avoid persisting redundant statuses such as:
+
+```text
+active
+corrected
+void
+```
+
+Lifecycle is derived from existing fields.
+
+---
+
+# 16. Add Receipt associations to domain models
+
+## Tenancy
+
+Replace:
+
+```ruby
+has_many :tenant_payments
+```
+
+with:
+
+```ruby
+has_many :receipts,
+ dependent: :restrict_with_error
+```
+
+Update:
+
+```ruby
+financial_history?
+```
+
+to include:
+
+```text
+charges
+receipts
+accounting_postings
+```
+
+Remove `tenant_payments`.
+
+## Party
+
+Add:
+
+```ruby
+has_many :receipts_as_payer,
+ class_name: "Receipt",
+ foreign_key: :payer_party_id,
+ dependent: :restrict_with_error
+```
+
+A Party referenced by historical receipts must not be deleted.
+
+## Property
+
+Replace:
+
+```ruby
+has_many :tenant_payments, through: :tenancies
+```
+
+with:
+
+```ruby
+has_many :receipts, through: :tenancies
+```
+
+## User
+
+Replace:
+
+```ruby
+has_many :tenant_payments, through: :tenancies
+```
+
+with:
+
+```ruby
+has_many :receipts, through: :tenancies
+```
+
+---
+
+# 17. Create `Receipts::PostService`
+
+Create:
+
+```text
+app/services/receipts/post_service.rb
+```
+
+For a Receipt:
+
+```text
+Dr Cash +amount_cents
+Cr Tenant Receivable -amount_cents
+```
+
+Both PostingSpecs must contain:
+
+```text
+tenancy: receipt.tenancy
+party: receipt.payer_party
+```
+
+`PostingBuilder` will derive:
+
+```text
+property
+rentable_unit
+tenancy
+```
+
+The explicit Party dimension preserves payer identity in the ledger.
+
+---
+
+# 18. Receipt journal identity
+
+Use:
+
+```text
+source: receipt
+event_type: "receipt_posted"
+occurred_on: receipt.received_on
+```
+
+Use a deterministic description such as:
+
+```text
+Payment received - Zelle
+Payment received - Cash
+Payment received - Check
+```
+
+Do not include generated timestamps or mutable Party names in the idempotency-sensitive description.
+
+Payer identity already exists in the Posting dimension.
+
+---
+
+# 19. Create `Receipts::CreateService`
+
+Suggested API:
+
+```ruby
+Receipts::CreateService.call(
+ tenancy:,
+ payer_party:,
+ amount: nil,
+ amount_cents: nil,
+ received_on:,
+ payment_method:,
+ external_reference: nil,
+ memo: nil
+)
+```
+
+The service owns:
+
+```text
+amount parsing
+date parsing
+ownership validation
+Receipt persistence
+ledger posting
+posted_at transition
+```
+
+---
+
+# 20. Receipt creation transaction
+
+Inside one transaction:
+
+1. validate tenancy;
+2. derive user from tenancy;
+3. validate payer belongs to same user;
+4. normalize/validate amount;
+5. create Receipt;
+6. call `Receipts::PostService`;
+7. fail the transaction if accounting posting fails;
+8. mark Receipt posted;
+9. commit.
+
+Return:
+
+```text
+Receipt
+JournalEntry
+```
+
+through `ServiceResult`.
+
+If accounting fails:
+
+```text
+Receipt count unchanged
+JournalEntry count unchanged
+Posting count unchanged
+```
+
+---
+
+# 21. Do not expose accounting primitives to controllers
+
+Correct call graph:
+
+```text
+ReceiptsController
+ │
+ ▼
+Receipts::CreateService
+ │
+ ▼
+Receipts::PostService
+ │
+ ▼
+Accounting::PostEntryService
+```
+
+Never:
+
+```text
+ReceiptsController
+ -> Accounting::PostEntryService
+```
+
+---
+
+# 22. Running-account behavior remains unchanged
+
+A Receipt never requires a Charge association.
+
+Examples:
+
+```text
+Rent charge +200000 A/R
+Receipt -200000 A/R
+Balance 0
+```
+
+Prepayment:
+
+```text
+Receipt -200000
+Balance credit -200000
+
+Later rent +200000
+Balance 0
+```
+
+Overpayment:
+
+```text
+Rent +200000
+Receipt -250000
+
+Balance -50000
+```
+
+Do not introduce an unapplied-payment liability account in this milestone.
+
+---
+
+# 23. No ReceiptAllocation table yet
+
+Do not create:
+
+```text
+ReceiptAllocation
+```
+
+unless an implementation dependency proves it necessary.
+
+Current balance correctness must remain completely independent of allocation.
+
+The PRD explicitly keeps receipt-to-charge matching optional for MVP.
+
+---
+
+# 24. Manual Receipt UI
+
+Replace:
+
+```text
+TenantPaymentsController
+tenant_payments views
+tenant_payment routes
+```
+
+with:
+
+```text
+ReceiptsController
+receipts views
+receipt routes
+```
+
+User-facing wording may continue saying:
+
+```text
+Payment
+Record Payment
+Payment Details
+```
+
+where that is clearer.
+
+Internal domain vocabulary should be:
+
+```text
+Receipt
+```
+
+---
+
+# 25. Receipt routes
+
+Target:
+
+```ruby
+resources :tenancies do
+ resources :receipts, only: %i[new create]
+end
+
+resources :receipts, only: %i[index show new create] do
+ member do
+ get :correction
+ post :correct
+ post :void
+ end
+end
+```
+
+Do not provide:
+
+```text
+edit
+update
+destroy
+```
+
+for posted receipts.
+
+---
+
+# 26. Manual Receipt form
+
+Fields:
+
+```text
+Tenancy
+Payer
+Received on
+Amount
+Payment method
+External reference
+Memo
+```
+
+When nested beneath a tenancy:
+
+```text
+tenancy is fixed
+```
+
+or clearly displayed and hidden from ordinary reassignment.
+
+The correction workflow is the place for fixing an incorrectly-selected tenancy.
+
+---
+
+# 27. Payer selection
+
+The payer picker should include **all Parties belonging to the user**, not only tenancy participants.
+
+To make the common case convenient:
+
+If the tenancy has exactly one active:
+
+```text
+TenancyParty(role: tenant)
+```
+
+on the relevant date, preselect that Party.
+
+If multiple tenant-role Parties are active:
+
+```text
+do not guess
+```
+
+Require the user to choose.
+
+If the actual payer is an external Party:
+
+```text
+parent
+company
+guarantor
+etc.
+```
+
+allow selecting that Party.
+
+---
+
+# 28. Joint-tenancy requirement
+
+Explicitly test:
+
+```text
+Tenancy:
+ Alice
+ Bob
+
+Receipt 1:
+ payer = Alice
+ amount = $1,000
+
+Receipt 2:
+ payer = Bob
+ amount = $1,000
+```
+
+Both reduce:
+
+```text
+the same Tenancy Receivable balance
+```
+
+while preserving different payer identities.
+
+This is a primary Milestone 4 acceptance criterion.
+
+---
+
+# 29. Receipt detail page
+
+Show:
+
+```text
+Amount
+Received date
+Payer
+Payment method
+External reference
+Memo
+Property
+Unit
+Tenancy
+Posted status
+Void/correction history
+```
+
+If corrected:
+
+```text
+This payment was corrected.
+Replacement: Receipt #...
+```
+
+If it supersedes another:
+
+```text
+This payment replaces Receipt #...
+```
+
+If voided without replacement:
+
+```text
+Voided
+```
+
+Historical Receipts remain directly viewable.
+
+---
+
+# 30. Receipt PDF
+
+Replace:
+
+```text
+TenantPayments::ReceiptPdfService
+```
+
+with:
+
+```text
+Receipts::PdfService
+```
+
+or:
+
+```text
+Receipts::ReceiptPdfService
+```
+
+PDF should include:
+
+```text
+Payment Receipt
+Received date
+Amount
+Payer
+Method
+External reference
+Property
+Unit
+Tenancy reference
+Receipt ID
+```
+
+A corrected/voided Receipt PDF should visibly indicate that it is no longer the active version.
+
+Do not silently print a voided receipt as if it were current.
+
+---
+
+# 31. Implement `Receipts::VoidService`
+
+Void means:
+
+```text
+remove an erroneously-recorded Receipt from accounting history
+```
+
+Suggested API:
+
+```ruby
+Receipts::VoidService.call(
+ receipt:,
+ reason: nil
+)
+```
+
+Inside one transaction:
+
+1. lock Receipt;
+2. reject an already-superseded Receipt;
+3. find its `receipt_posted` JournalEntry;
+4. reverse that entry;
+5. use:
+ ```text
+ reversal occurred_on = receipt.received_on
+ ```
+6. mark Receipt `voided_at`;
+7. commit.
+
+---
+
+# 32. Why void reversal uses the original received date
+
+This is deliberate bookkeeping restatement.
+
+Suppose a duplicate `$2,000` Receipt was accidentally entered January 10 and discovered February 1.
+
+If the correction reversal were dated February 1:
+
+```text
+January as-of reports would continue showing the duplicate.
+```
+
+Instead:
+
+```text
+original posting Jan 10
+reversal posting Jan 10
+```
+
+net to zero in historical financial reporting.
+
+Audit chronology is still preserved through:
+
+```text
+JournalEntry.posted_at
+Receipt.voided_at
+```
+
+This distinguishes:
+
+```text
+accounting correction
+```
+
+from:
+
+```text
+actual later cash refund/reversal
+```
+
+---
+
+# 33. Receipt void idempotency
+
+Calling void twice must not create two reversals.
+
+If already voided with no replacement:
+
+```text
+return existing reversal successfully
+```
+
+if the request is semantically identical.
+
+Do not create multiple reversal entries.
+
+---
+
+# 34. Implement `Receipts::CorrectService`
+
+Suggested API:
+
+```ruby
+Receipts::CorrectService.call(
+ receipt:,
+ tenancy:,
+ payer_party:,
+ amount:,
+ received_on:,
+ payment_method:,
+ external_reference:,
+ memo:
+)
+```
+
+The replacement may correct:
+
+```text
+amount
+payer
+tenancy
+received date
+method
+external reference
+memo
+```
+
+---
+
+# 35. Receipt correction transaction
+
+Within one transaction:
+
+1. lock original Receipt;
+2. reject if it has already been voided without replacement;
+3. if already superseded:
+ - compare requested replacement with existing replacement;
+ - identical => idempotent success;
+ - different => conflict;
+4. reverse original journal entry at original `received_on`;
+5. mark original `voided_at`;
+6. create/post replacement Receipt;
+7. set:
+ ```text
+ original.superseded_by_id = replacement.id
+ ```
+8. commit.
+
+If any step fails:
+
+```text
+original remains active
+no reversal remains
+no replacement remains
+```
+
+---
+
+# 36. Correction cash semantics
+
+Example:
+
+Original:
+
+```text
+Receipt A
+$2,000
+Jan 5
+
+Dr Cash +200000
+Cr Tenant Receivable -200000
+```
+
+Corrected to:
+
+```text
+Receipt B
+$2,100
+Jan 5
+```
+
+Correction creates:
+
+```text
+Reversal of A:
+Dr Tenant Receivable +200000
+Cr Cash -200000
+
+Receipt B:
+Dr Cash +210000
+Cr Tenant Receivable -210000
+```
+
+Net:
+
+```text
+Cash +210000
+Tenant Receivable -210000
+```
+
+Original financial history remains visible.
+
+---
+
+# 37. Correcting the tenancy
+
+Allow correction to another tenancy belonging to the same user.
+
+Example:
+
+```text
+Receipt mistakenly applied to Unit A
+should have been Unit B
+```
+
+Result:
+
+```text
+reverse A/R credit on Unit A
+post replacement A/R credit on Unit B
+```
+
+Cash nets unchanged.
+
+Do not support moving a Receipt to another user's tenancy.
+
+---
+
+# 38. Correcting the payer
+
+Allow payer correction.
+
+Example:
+
+```text
+Receipt was recorded as Alice
+actual payer was Bob
+```
+
+Replacement postings carry Bob as the Party dimension.
+
+Original postings remain historically attached to Alice but are reversed exactly.
+
+---
+
+# 39. Correction UI
+
+Do not reuse a generic edit form.
+
+Provide:
+
+```text
+Correct Payment
+```
+
+with explanatory copy that correction:
+
+```text
+keeps the original record
+reverses its accounting
+creates a replacement
+```
+
+Prefill the current Receipt values.
+
+On success redirect to the replacement Receipt.
+
+Show a link back to the original.
+
+---
+
+# 40. Void UI
+
+Provide:
+
+```text
+Void Payment
+```
+
+with explicit wording:
+
+```text
+Use this only if the payment record itself was entered in error.
+Do not use this for money later returned to a payer.
+```
+
+Require confirmation.
+
+An optional reason may be retained in UI/audit text, but do not overload `memo` on the original Receipt.
+
+If storing correction reason becomes useful, add a dedicated lifecycle/audit field rather than rewriting the original memo.
+
+---
+
+# 41. Retarget PaymentIngestion
+
+Keep the model name:
+
+```text
+PaymentIngestion
+```
+
+for this milestone.
+
+Change:
+
+```text
+tenant_payment_id
+```
+
+to:
+
+```text
+receipt_id
+```
+
+Association:
+
+```ruby
+belongs_to :receipt, optional: true
+```
+
+Remove the TenantPayment association.
+
+---
+
+# 42. Payment ingestion confirmation
+
+Update:
+
+```text
+PaymentIngestions::ConfirmService
+```
+
+to create a Receipt.
+
+Map:
+
+```text
+ingestion.tenancy
+ -> receipt.tenancy
+
+ingestion.party
+ -> receipt.payer_party
+
+ingestion.amount
+ -> receipt amount
+
+ingestion.payment_date
+ -> receipt.received_on
+
+ingestion.payment_method
+ -> receipt.payment_method
+
+ingestion.transaction_number
+ -> receipt.external_reference
+```
+
+The parsed:
+
+```text
+payer_name
+payer_username
+raw_text
+```
+
+remain immutable source/provenance information on the ingestion.
+
+---
+
+# 43. Ingestion confirmation must remain atomic
+
+Inside the existing ingestion transaction:
+
+1. lock ingestion;
+2. verify confirmable;
+3. create/post Receipt;
+4. optionally create Party aliases;
+5. set:
+ ```text
+ status = confirmed
+ receipt_id = receipt.id
+ ```
+6. commit.
+
+If Receipt posting fails:
+
+```text
+ingestion remains unconfirmed
+Receipt rolls back
+JournalEntry rolls back
+Posting rolls back
+aliases roll back
+```
+
+---
+
+# 44. Make repeated confirmation idempotent
+
+The current behavior treats "already confirmed" as an error.
+
+Change it.
+
+If:
+
+```text
+ingestion.confirmed?
+AND ingestion.receipt exists
+```
+
+then repeated confirmation should:
+
+```text
+return existing Receipt successfully
+```
+
+without creating anything.
+
+If:
+
+```text
+confirmed?
+but receipt_id missing/broken
+```
+
+return an integrity failure.
+
+Do not silently create another Receipt.
+
+The architecture PRD explicitly requires repeated ingestion confirmation not to duplicate financial effects.
+
+---
+
+# 45. Ingestion provenance after correction
+
+If an ingestion originally confirmed:
+
+```text
+Receipt A
+```
+
+and Receipt A is later corrected to Receipt B:
+
+```text
+PaymentIngestion.receipt_id remains A
+```
+
+Do not rewrite it to B.
+
+That preserves:
+
+```text
+"This ingestion confirmation created Receipt A."
+```
+
+Receipt A then points through:
+
+```text
+superseded_by -> Receipt B
+```
+
+The UI may resolve/display the current replacement, but provenance should not be rewritten.
+
+---
+
+# 46. Ingestion duplicate detection
+
+Replace every query against:
+
+```text
+TenantPayment
+```
+
+with:
+
+```text
+Receipt
+```
+
+The canonical external duplicate check becomes:
+
+```text
+user_id
+payment_method
+external_reference
+active Receipt
+```
+
+The database partial unique index remains the concurrency protection.
+
+Do not derive uniqueness from amount/date/payer.
+
+---
+
+# 47. Keep parsed identity separate from durable payer identity
+
+`PaymentIngestion` retains:
+
+```text
+payer_name
+payer_username
+```
+
+Receipt stores:
+
+```text
+payer_party_id
+```
+
+Do not copy the parser's free-form payer name into Receipt as another mutable truth field.
+
+The ingestion explains:
+
+```text
+what the source document said
+```
+
+The Receipt explains:
+
+```text
+which Party Yanushi confirmed as payer
+```
+
+---
+
+# 48. Retarget property financial items
+
+Replace:
+
+```text
+tenant_payments
+payment_date
+"Tenant Payment"
+```
+
+with:
+
+```text
+receipts
+received_on
+"Payment"
+```
+
+in:
+
+```text
+Properties::FinancialItemsQuery
+```
+
+The UI can continue showing a green "Payment" row.
+
+Show:
+
+```text
+payer display name
+payment method
+```
+
+where useful.
+
+Mark:
+
+```text
+voided
+corrected
+```
+
+records appropriately rather than making them disappear from audit-oriented history.
+
+---
+
+# 49. Active-year query
+
+Replace:
+
+```text
+years_for(:tenant_payments, :payment_date)
+```
+
+with:
+
+```text
+years_for(:receipts, :received_on)
+```
+
+Do not lose years that contain only Receipt activity.
+
+---
+
+# 50. Interim Schedule E query
+
+The final tax-reporting redesign is not Milestone 4.
+
+However, deleting `TenantPayment` requires preserving current behavior.
+
+Replace the current temporary:
+
+```text
+property.tenant_payments
+```
+
+query with:
+
+```text
+property.receipts.active
+```
+
+for the same interim "rents received" calculation.
+
+Use:
+
+```text
+received_on
+amount_cents
+```
+
+and convert from cents only at the query result boundary.
+
+Do **not** claim this is the final Schedule E architecture.
+
+Milestone 9 still owns the explicit tax-reporting projection.
+
+---
+
+# 51. Void/corrected Receipts and interim reports
+
+A pure voided Receipt must contribute:
+
+```text
+$0
+```
+
+to current interim received-rent reporting.
+
+A corrected Receipt contributes only its active replacement amount.
+
+Therefore the temporary Schedule E query should use:
+
+```text
+Receipt.active
+```
+
+rather than summing all Receipt source rows.
+
+This is an interim domain-table query only.
+
+The tenancy balance does **not** use this filter because reversals already make the accounting ledger net correctly.
+
+---
+
+# 52. Tenant balance requires no redesign
+
+Do not alter the existing ledger-backed tenancy balance algorithm.
+
+Receipt posting already uses:
+
+```text
+Tenant Receivable
+```
+
+so:
+
+```text
+partial payment
+prepayment
+overpayment
+void
+correction
+```
+
+all naturally change balance through postings.
+
+No `Receipt` table query belongs in `Tenancies::BalanceQuery`.
+
+---
+
+# 53. Payment form prefill
+
+Preserve current behavior:
+
+```text
+if tenancy balance > 0:
+ default amount = amount owed
+
+if tenancy balance <= 0:
+ default amount = 0
+```
+
+Do not cap the submitted amount.
+
+An overpayment is explicitly valid.
+
+---
+
+# 54. Replace modal payment flows
+
+Update the property financial modal:
+
+```text
+Record Payment
+```
+
+to use:
+
+```text
+new_tenancy_receipt_path
+```
+
+On successful Receipt creation, refresh:
+
+```text
+property financials
+active tenancy balances
+flash
+```
+
+as the current TenantPayment flow does.
+
+Keep the UX behavior; replace only the domain source.
+
+---
+
+# 55. Seeds
+
+Replace seeded TenantPayments with:
+
+```text
+Receipts::CreateService
+```
+
+Supply explicit payer Party.
+
+Do not seed Receipt rows directly.
+
+Do not manually seed balancing JournalEntries.
+
+Development seed state must satisfy the same:
+
+```text
+Receipt + ledger atomicity
+```
+
+as normal application behavior.
+
+---
+
+# 56. Delete the temporary payment bridge
+
+Delete:
+
+```text
+TenantPayments::CreateService
+TenantPayments::PostLegacyService
+```
+
+or whatever transitional posting classes remain.
+
+There must be exactly one normal money-receipt posting path:
+
+```text
+Receipts::CreateService
+ -> Receipts::PostService
+```
+
+Do not keep aliases or forwarding wrappers.
+
+---
+
+# 57. Delete `TenantPayment`
+
+After all callers are migrated:
+
+Delete:
+
+```text
+app/models/tenant_payment.rb
+app/controllers/tenant_payments_controller.rb
+app/views/tenant_payments/
+TenantPayments::* services
+tenant payment factories
+tenant payment specs
+tenant payment RBS
+tenant payment routes
+```
+
+Drop:
+
+```text
+tenant_payments
+```
+
+from the database.
+
+No compatibility constant:
+
+```ruby
+TenantPayment = Receipt
+```
+
+---
+
+# 58. No legacy `TenantPayment` journal sources
+
+Because there is no production data to preserve, do not build a backfill.
+
+Use a clean database during development/test cutover.
+
+After the transition there should be no newly-created:
+
+```text
+JournalEntry.source_type = "TenantPayment"
+```
+
+All ordinary payment entries must be:
+
+```text
+source_type = "Receipt"
+event_type = "receipt_posted"
+```
+
+---
+
+# 59. Development database policy
+
+Because the project still has no production data requiring preservation, prefer:
+
+```bash
+bin/rails db:drop db:create db:migrate
+RAILS_ENV=test bin/rails db:drop db:create db:migrate
+```
+
+after the destructive schema cutover.
+
+Do not add dual-write or source-type backfill machinery solely to preserve disposable development records.
+
+---
+
+# 60. Receipt model tests
+
+Test:
+
+- requires User;
+- requires Tenancy;
+- requires payer Party;
+- requires positive cents;
+- requires received date;
+- requires payment method;
+- external reference optional;
+- blank external reference normalizes nil;
+- user must match tenancy owner;
+- payer must belong to same user;
+- payer need not participate in tenancy;
+- individual payer works;
+- organization payer works;
+- active duplicate external reference fails;
+- same reference for another user succeeds;
+- voided external reference may be reused;
+- posted financial fields cannot mutate;
+- direct `voided_at` mutation fails;
+- direct `superseded_by_id` mutation fails;
+- destroy fails after posting.
+
+---
+
+# 61. Receipt posting tests
+
+For `$2,000`:
+
+```text
+Cash +200000
+Tenant Receivable -200000
+```
+
+Verify both lines carry:
+
+```text
+property
+rentable_unit
+tenancy
+payer party
+```
+
+Verify:
+
+```text
+source_type = Receipt
+event_type = receipt_posted
+occurred_on = received_on
+```
+
+---
+
+# 62. Receipt creation atomicity tests
+
+Force Receipt validation failure:
+
+```text
+no JournalEntry
+no Posting
+```
+
+Force accounting failure:
+
+```text
+no committed Receipt
+no JournalEntry
+no Posting
+```
+
+Successful create:
+
+```text
+one Receipt
+one JournalEntry
+two Postings
+posted_at set
+```
+
+---
+
+# 63. Joint-payer test
+
+Given:
+
+```text
+Alice and Bob share one tenancy.
+```
+
+Create:
+
+```text
+Receipt Alice $500
+Receipt Bob $700
+```
+
+Assert:
+
+```text
+same tenancy balance reduced by $1,200
+Alice retained on first Receipt/postings
+Bob retained on second Receipt/postings
+```
+
+---
+
+# 64. Non-tenant payer test
+
+Create:
+
+```text
+Party: Alice's Employer
+not a TenancyParty
+```
+
+Record:
+
+```text
+$2,000 Receipt
+payer = Employer
+tenancy = Alice's tenancy
+```
+
+Assert:
+
+```text
+valid
+tenant balance reduced
+payer preserved
+```
+
+This proves Party and tenancy role are not incorrectly conflated.
+
+---
+
+# 65. Partial-payment test
+
+```text
+Rent Charge +$2,000
+Receipt -$500
+
+Balance +$1,500
+```
+
+---
+
+# 66. Prepayment test
+
+```text
+Receipt -$2,000
+Balance -$2,000 credit
+
+Later Rent +$2,000
+Balance $0
+```
+
+No allocation exists.
+
+---
+
+# 67. Overpayment test
+
+```text
+Rent +$2,000
+Receipt -$2,500
+
+Balance -$500 credit
+```
+
+No liability reclassification is created.
+
+---
+
+# 68. Receipt void tests
+
+Test:
+
+- original Receipt remains;
+- original JournalEntry remains;
+- reversal exists;
+- reversal uses original accounting date;
+- amounts negate exactly;
+- party dimensions negate exactly;
+- Receipt gets `voided_at`;
+- balance updates automatically;
+- duplicate void is idempotent;
+- direct source deletion remains impossible.
+
+---
+
+# 69. Receipt correction tests
+
+Original:
+
+```text
+$2,000
+```
+
+Correct to:
+
+```text
+$2,100
+```
+
+Assert:
+
+```text
+original Receipt remains
+original JournalEntry remains
+reversal exists
+replacement Receipt exists
+replacement JournalEntry exists
+original.superseded_by = replacement
+original.voided_at present
+```
+
+Net ledger:
+
+```text
+Cash +$2,100
+Tenant Receivable -$2,100
+```
+
+---
+
+# 70. Correction-to-other-tenancy test
+
+Record Receipt against:
+
+```text
+Tenancy A
+```
+
+Correct it to:
+
+```text
+Tenancy B
+```
+
+Assert:
+
+```text
+Tenancy A credit is reversed
+Tenancy B receives credit
+Cash net unchanged
+```
+
+---
+
+# 71. Correction-to-other-payer test
+
+Correct:
+
+```text
+payer Alice -> payer Bob
+```
+
+Assert:
+
+```text
+original ledger preserved/reversed with Alice
+replacement ledger carries Bob
+```
+
+---
+
+# 72. Correction idempotency test
+
+Submit identical correction twice.
+
+Expected:
+
+```text
+one reversal
+one replacement Receipt
+one replacement JournalEntry
+```
+
+Second call returns existing replacement.
+
+Submit a different second correction against the already-corrected original.
+
+Expected:
+
+```text
+conflict
+```
+
+Do not create another sibling replacement.
+
+---
+
+# 73. Correction concurrency test
+
+Two concurrent correction attempts against the same Receipt must serialize through the original Receipt lock.
+
+Result must be:
+
+```text
+one winning replacement
+one reversal
+```
+
+The loser either:
+
+```text
+returns the same replacement if equivalent
+```
+
+or:
+
+```text
+fails with correction conflict
+```
+
+Never create two replacements.
+
+---
+
+# 74. Void-versus-correct concurrency test
+
+Concurrent:
+
+```text
+VoidService
+CorrectService
+```
+
+against one Receipt must not produce:
+
+```text
+one pure void
+plus one replacement
+```
+
+Locking must establish one terminal outcome.
+
+---
+
+# 75. External-reference correction test
+
+Original active Receipt:
+
+```text
+method: zelle
+external_reference: ABC123
+```
+
+Correction may create replacement with:
+
+```text
+method: zelle
+external_reference: ABC123
+```
+
+because the original is voided inside the same transaction before the replacement becomes active.
+
+Prove the partial unique index supports this.
+
+---
+
+# 76. Payment-ingestion tests
+
+Test:
+
+- matched ingestion creates Receipt;
+- ingestion Party becomes payer;
+- ingestion Tenancy becomes Receipt tenancy;
+- amount maps correctly to integer cents;
+- payment date maps to received date;
+- transaction number maps to external reference;
+- payer source text remains on ingestion;
+- aliases still work;
+- confirmation posts accounting exactly once;
+- repeated confirmation returns same Receipt;
+- duplicate external transaction cannot create second active Receipt;
+- confirmation failure rolls everything back.
+
+---
+
+# 77. Ingestion correction provenance test
+
+Confirm ingestion:
+
+```text
+ingestion -> Receipt A
+```
+
+Correct Receipt A to Receipt B.
+
+Assert:
+
+```text
+ingestion.receipt == Receipt A
+Receipt A.superseded_by == Receipt B
+```
+
+Do not rewrite ingestion provenance.
+
+---
+
+# 78. Receipt UI request/system scenarios
+
+## Manual payment
+
+1. Open tenancy.
+2. Click Record Payment.
+3. Select payer.
+4. Enter amount/method/reference.
+5. Submit.
+6. Receipt appears.
+7. Tenancy balance changes.
+
+## Joint tenants
+
+1. Tenancy has Alice and Bob.
+2. Record Alice payment.
+3. Record Bob payment.
+4. Both identify payer correctly.
+
+## Third-party payer
+
+1. Choose non-tenant Party.
+2. Record payment.
+3. Receipt succeeds.
+
+## Overpayment
+
+1. Tenant owes $500.
+2. Record $750.
+3. Account shows $250 credit.
+
+## Correction
+
+1. Open Receipt.
+2. Click Correct Payment.
+3. Change amount.
+4. Original becomes corrected.
+5. Replacement appears.
+6. Balance reflects replacement only.
+
+## Void
+
+1. Open erroneous Receipt.
+2. Void it.
+3. Receipt remains visible.
+4. Balance reverses.
+
+---
+
+# 79. PDF request coverage
+
+Render PDF for:
+
+```text
+active Receipt
+corrected original
+replacement Receipt
+voided Receipt
+```
+
+Verify payer is included.
+
+Verify corrected/voided history cannot be mistaken for an active current payment receipt.
+
+---
+
+# 80. Update property financial UI
+
+Replace branches checking:
+
+```text
+"Tenant Payment"
+```
+
+with:
+
+```text
+"Payment"
+```
+
+Display:
+
+```text
+payer
+method
+```
+
+where useful.
+
+Use:
+
+```text
+Receipt#amount
+```
+
+for formatting.
+
+Voided/corrected source rows should be clearly marked if shown.
+
+---
+
+# 81. Update active-year behavior
+
+Tests must prove a year containing:
+
+```text
+Receipt activity only
+```
+
+still appears in the financial year selector.
+
+---
+
+# 82. Update interim Schedule E tests
+
+Until the tax milestone:
+
+```text
+active ordinary Receipt
+```
+
+contributes to the existing rents-received number.
+
+Test:
+
+```text
+active Receipt included
+voided Receipt excluded
+corrected original excluded
+replacement included
+```
+
+Do not introduce tax-accounting redesign here.
+
+---
+
+# 83. Update factories
+
+Delete:
+
+```text
+:tenant_payment
+```
+
+Add:
+
+```text
+:receipt
+```
+
+Traits:
+
+```text
+:posted_receipt
+:voided_receipt
+:corrected_receipt
+```
+
+For tests requiring a financially-real Receipt, prefer:
+
+```text
+Receipts::CreateService
+```
+
+instead of factory-only persistence.
+
+Direct factory creation is appropriate for isolated validation tests.
+
+---
+
+# 84. Update RBS
+
+Add/update:
+
+```text
+Receipt
+
+Receipts::CreateService
+Receipts::PostService
+Receipts::VoidService
+Receipts::CorrectService
+Receipts::PdfService
+
+ReceiptsController
+```
+
+Update:
+
+```text
+PaymentIngestion
+PaymentIngestions::ConfirmService
+Property
+Tenancy
+Party
+User
+Properties::FinancialItemsQuery
+Properties::ActiveYearsQuery
+Properties::ScheduleESummaryQuery
+```
+
+Delete:
+
+```text
+TenantPayment
+TenantPayments::*
+TenantPaymentsController
+```
+
+Regenerate Rails signatures:
+
+```bash
+bin/rails rbs_rails:all
+
+bundle exec rbs validate
+bundle exec steep check
+```
+
+Keep broad Steep coverage.
+
+---
+
+# 85. Documentation
+
+Add:
+
+```text
+documentation/double_entry_accounting/implementation_plan_milestone_4.md
+```
+
+Update accounting architecture documentation with:
+
+```text
+Receipt versus Charge
+
+payer Party versus tenancy participants
+
+Receipt posting rule
+
+running-account semantics
+
+overpayments
+
+Receipt lifecycle
+
+correction versus later cash refund
+
+source-event immutability
+
+ingestion provenance
+
+no allocations yet
+```
+
+Explicitly state:
+
+```text
+A Receipt means ordinary money received and credited to a tenancy.
+
+A refundable security deposit is not a Receipt.
+```
+
+Milestone 6 owns deposit money.
+
+---
+
+# 86. Search for stale TenantPayment vocabulary
+
+Run:
+
+```bash
+rg -n \
+ 'TenantPayment|tenant_payment|tenant_payments|TenantPayments::' \
+ app config db spec sig
+```
+
+Expected:
+
+```text
+no live application-code hits
+```
+
+Historical architecture documentation may mention it as a removed model.
+
+---
+
+# 87. Search for old payment-field names
+
+Run:
+
+```bash
+rg -n \
+ 'payment_date|transaction_number' \
+ app config db spec sig
+```
+
+Review every hit.
+
+Payment ingestion parser fields may legitimately remain:
+
+```text
+payment_date
+transaction_number
+```
+
+because those are source/parser vocabulary.
+
+Receipt code must use:
+
+```text
+received_on
+external_reference
+```
+
+Do not blindly rename parser-source fields if doing so adds no domain value.
+
+---
+
+# 88. Ensure Receipt creation is service-owned
+
+Run:
+
+```bash
+rg -n \
+ 'Receipt\.(new|create|create!)|receipts\.(build|create|create!)' \
+ app
+```
+
+Review every production hit.
+
+No controller or ingestion service should directly persist Receipt.
+
+All financially-real Receipt creation must go through:
+
+```text
+Receipts::CreateService
+```
+
+Correction may call that service internally.
+
+---
+
+# 89. Verify accounting creation remains contained
+
+Run:
+
+```bash
+rg -n \
+ 'Posting\.(create|create!|new)|postings\.(create|create!|build)|JournalEntry\.(create|create!|new)' \
+ app
+```
+
+Receipt services must still delegate to Milestone 2 accounting services.
+
+Do not leak direct accounting persistence into the new domain services.
+
+---
+
+# 90. Verify no balance query reads Receipts
+
+Run:
+
+```bash
+rg -n \
+ 'Receipt|receipts' \
+ app/queries/tenancies
+```
+
+The tenancy balance query should not need Receipt rows.
+
+It should continue reading:
+
+```text
+Tenant Receivable postings
+```
+
+only.
+
+---
+
+# 91. Clean database verification
+
+Run:
+
+```bash
+bin/rails db:drop db:create db:migrate
+RAILS_ENV=test bin/rails db:drop db:create db:migrate
+```
+
+Verify:
+
+```text
+receipts exists
+tenant_payments does not exist
+payment_ingestions.receipt_id exists
+payment_ingestions.tenant_payment_id does not exist
+```
+
+---
+
+# 92. Manual smoke test
+
+Using a clean database:
+
+1. Create property/unit/tenancy.
+2. Ensure rent charge exists.
+3. Create Alice Party.
+4. Create Bob Party.
+5. Add Alice/Bob to tenancy.
+6. Record payment from Alice.
+7. Verify payer is Alice.
+8. Verify balance decreases.
+9. Record payment from Bob.
+10. Verify payer is Bob.
+11. Create Employer Party not on tenancy.
+12. Record payment from Employer.
+13. Verify it succeeds.
+14. Overpay the account.
+15. Verify negative tenant balance/credit.
+16. Correct one payment amount.
+17. Verify original remains and replacement is linked.
+18. Verify balance reflects corrected amount only.
+19. Correct a payment to another tenancy.
+20. Verify receivable moves between tenancies while cash stays net unchanged.
+21. Void an erroneous payment.
+22. Verify balance reverses.
+23. Upload and confirm an ingestion.
+24. Verify its Party becomes Receipt payer.
+25. Confirm the same ingestion again.
+26. Verify no duplicate Receipt/posting.
+27. Download a Receipt PDF.
+28. Open property financial history.
+29. Open Schedule E interim view.
+30. Confirm no TenantPayment UI/model remains.
+
+---
+
+# 93. Final quality gate
+
+Run:
+
+```bash
+bin/rubocop
+
+bundle exec rbs validate
+bundle exec steep check
+
+bundle exec rspec
+
+bin/brakeman --no-pager
+bin/bundler-audit
+bin/importmap audit
+```
+
+Coverage must remain above the CI threshold.
+
+---
+
+# 94. Suggested commit boundaries
+
+## Commit 1: Add Receipt domain
+
+Include:
+
+```text
+receipts migration
+Receipt model
+associations
+amount parsing
+ownership
+lifecycle protection
+model specs
+```
+
+## Commit 2: Add Receipt accounting
+
+Include:
+
+```text
+Receipts::PostService
+Receipts::CreateService
+posting specs
+atomicity tests
+payer dimensions
+running-account scenarios
+```
+
+## Commit 3: Replace manual TenantPayment flow
+
+Include:
+
+```text
+ReceiptsController
+routes
+forms
+views
+PDF
+property financial UI
+manual-payment tests
+```
+
+## Commit 4: Add void and correction
+
+Include:
+
+```text
+Receipts::VoidService
+Receipts::CorrectService
+correction UI
+void UI
+lifecycle tests
+concurrency tests
+```
+
+## Commit 5: Move ingestion to Receipt
+
+Include:
+
+```text
+payment_ingestions.receipt_id
+ConfirmService
+duplicate detection
+idempotent confirmation
+provenance tests
+```
+
+## Commit 6: Remove TenantPayment
+
+Include:
+
+```text
+TenantPayment model deletion
+TenantPayments services deletion
+old routes/views/specs/signatures deletion
+associations/query updates
+```
+
+## Commit 7: Reporting compatibility and cleanup
+
+Include:
+
+```text
+FinancialItemsQuery
+ActiveYearsQuery
+interim Schedule E query
+seeds
+RBS
+Steep
+documentation
+stale-reference cleanup
+full quality gate
+```
+
+Each commit should remain green where practical.
+
+---
+
+# 95. Milestone 4 acceptance checklist
+
+## Receipt domain
+
+- [ ] `Receipt` exists.
+- [ ] `TenantPayment` does not.
+- [ ] Amount uses integer cents.
+- [ ] Receipt preserves payer Party.
+- [ ] Receipt belongs to Tenancy.
+- [ ] Payer need not be a tenancy participant.
+- [ ] User/Tenancy/Payer ownership is consistent.
+- [ ] Received date is preserved.
+- [ ] Payment method is preserved.
+- [ ] External reference is preserved.
+- [ ] Memo is supported.
+- [ ] Posted Receipt financial fields are immutable.
+- [ ] Receipt cannot be hard-deleted.
+
+## Accounting
+
+- [ ] Receipt posts Dr Cash.
+- [ ] Receipt posts Cr Tenant Receivable.
+- [ ] Receipt postings carry Property/Unit/Tenancy dimensions.
+- [ ] Receipt postings carry payer Party dimension.
+- [ ] Receipt creation and posting are atomic.
+- [ ] Tenant balance still comes solely from ledger postings.
+
+## Running account
+
+- [ ] Partial payment works.
+- [ ] Prepayment works.
+- [ ] Overpayment works.
+- [ ] Negative balance represents tenant credit.
+- [ ] No charge allocation is required.
+
+## Payer identity
+
+- [ ] Joint tenants can each make payments.
+- [ ] Their payments affect the same tenancy balance.
+- [ ] Actual payer remains distinguishable.
+- [ ] Non-tenant Party may pay.
+- [ ] Organization Party may pay.
+
+## Corrections
+
+- [ ] Original Receipt remains.
+- [ ] Original JournalEntry remains.
+- [ ] Correction creates reversal.
+- [ ] Correction creates replacement Receipt.
+- [ ] Replacement gets a new JournalEntry.
+- [ ] Original links to replacement.
+- [ ] Correct amount is reflected.
+- [ ] Correct payer is reflected.
+- [ ] Correct tenancy is reflected.
+- [ ] Correction is transactional.
+- [ ] Concurrent corrections cannot create siblings.
+
+## Voiding
+
+- [ ] Void creates accounting reversal.
+- [ ] Receipt remains as history.
+- [ ] Balance updates automatically.
+- [ ] Void is idempotent.
+- [ ] Void is documented as correction, not later cash refund.
+
+## External identity
+
+- [ ] Active external references are unique per user/method.
+- [ ] Legitimate repeated payments without references are allowed.
+- [ ] A voided external reference may be reused.
+- [ ] Corrected replacement may retain the original external reference.
+
+## Ingestion
+
+- [ ] PaymentIngestion references Receipt.
+- [ ] Confirming creates Receipt.
+- [ ] Parsed Party becomes payer.
+- [ ] Parsed payer-name/user-name snapshots remain on ingestion.
+- [ ] Confirmation is atomic.
+- [ ] Repeated confirmation returns the same Receipt.
+- [ ] Duplicate confirmation cannot duplicate ledger postings.
+- [ ] Correcting Receipt does not rewrite ingestion provenance.
+
+## UI
+
+- [ ] Record Payment uses Receipt internally.
+- [ ] Payer is selected/displayed.
+- [ ] Single active tenant may be preselected.
+- [ ] Multiple tenants are not guessed.
+- [ ] Receipt detail works.
+- [ ] PDF includes payer.
+- [ ] Correct Payment workflow works.
+- [ ] Void Payment workflow works.
+- [ ] Corrected/voided records are visibly historical.
+
+## Legacy cleanup
+
+- [ ] `tenant_payments` table is gone.
+- [ ] `TenantPayment` constant is gone.
+- [ ] `TenantPaymentsController` is gone.
+- [ ] TenantPayment services are gone.
+- [ ] Temporary legacy posting adapter is gone.
+- [ ] No newly-created `JournalEntry` uses `TenantPayment` as source.
+- [ ] Property/User/Tenancy associations use Receipts.
+- [ ] FinancialItemsQuery uses Receipts.
+- [ ] ActiveYearsQuery uses Receipts.
+- [ ] Interim Schedule E query uses active Receipts.
+
+## Quality
+
+- [ ] Receipt model specs pass.
+- [ ] Posting specs pass.
+- [ ] Atomicity specs pass.
+- [ ] Running-account specs pass.
+- [ ] Correction specs pass.
+- [ ] Correction concurrency specs pass.
+- [ ] Ingestion specs pass.
+- [ ] Request/system specs pass.
+- [ ] PDF specs pass.
+- [ ] RBS validates.
+- [ ] Steep passes.
+- [ ] RuboCop passes.
+- [ ] RSpec passes.
+- [ ] Coverage remains above CI threshold.
+- [ ] Security scans pass.
+
+---
+
+# 96. Desired end state
+
+After Milestone 4:
+
+```text
+Tenancy
+├── Charges
+│ ├── Rent
+│ ├── Late fee
+│ ├── Reimbursement
+│ └── Other
+│
+└── Receipts
+ ├── payer Party
+ └── immutable correction chain
+```
+
+Financially:
+
+```text
+Rent Charge
+ Dr Tenant Receivable
+ Cr Rental Income
+
+Receipt
+ Dr Cash
+ Cr Tenant Receivable
+```
+
+A shared tenancy can therefore have:
+
+```text
+Rent Charge +$2,000
+
+Alice Receipt -$500
+Bob Receipt -$750
+Employer Receipt -$750
+
+Balance $0
+```
+
+without pretending all three payments came from the same tenant.
+
+If Bob's payment was really `$700`, Yanushi preserves:
+
+```text
+original Bob Receipt
+original journal entry
+reversal
+replacement Bob Receipt
+replacement journal entry
+```
+
+rather than editing financial history.
+
+At that point the temporary payment bridge is gone. `Charge` explains why money is owed, `Receipt` explains who paid money into the tenancy account, and the immutable accounting ledger remains the single source of truth for the resulting balance.
diff --git a/sig/app/controllers/receipts_controller.rbs b/sig/app/controllers/receipts_controller.rbs
new file mode 100644
index 00000000..b7e20689
--- /dev/null
+++ b/sig/app/controllers/receipts_controller.rbs
@@ -0,0 +1,23 @@
+class ReceiptsController < ApplicationController
+ @receipts: ActiveRecord::Relation
+ @receipt: Receipt
+ @replacement_receipt: Receipt
+ @tenancy: Tenancy?
+ @tenancies: ActiveRecord::Relation
+ @parties: ActiveRecord::Relation
+
+ def index: () -> void
+ def show: () -> void
+ def new: () -> void
+ def create: () -> void
+ def correction: () -> void
+ def correct: () -> void
+ def void: () -> void
+
+ private
+
+ def set_tenancy: () -> void
+ def set_receipt: () -> void
+ def receipt_params: () -> ActionController::Parameters
+ def load_form_collections: () -> void
+end
diff --git a/sig/app/controllers/tenant_payments_controller.rbs b/sig/app/controllers/tenant_payments_controller.rbs
deleted file mode 100644
index db08032e..00000000
--- a/sig/app/controllers/tenant_payments_controller.rbs
+++ /dev/null
@@ -1,32 +0,0 @@
-class TenantPaymentsController < ApplicationController
- @tenant_payments: TenantPayment::ActiveRecord_Relation
- @tenant_payment: TenantPayment
- @tenancy: Tenancy?
- @tenancies: Tenancy::ActiveRecord_Relation
- @financial_items: Array[Hash[Symbol, untyped]]
- @year: Integer
-
- def index: () -> untyped
-
- def show: () -> untyped
-
- def new: () -> untyped
-
- def edit: () -> untyped
-
- def create: () -> untyped
-
- def update: () -> untyped
-
- def destroy: () -> untyped
-
- private
-
- def set_tenant_payment: () -> untyped
-
- def set_tenancy: () -> untyped
-
- def set_form_data: () -> untyped
-
- def tenant_payment_params: () -> untyped
-end
diff --git a/sig/app/models/payment_document.rbs b/sig/app/models/payment_document.rbs
index f0728e30..d0f97097 100644
--- a/sig/app/models/payment_document.rbs
+++ b/sig/app/models/payment_document.rbs
@@ -1,3 +1,7 @@
class PaymentDocument < ApplicationRecord
def accounting_user: () -> User?
+
+ private
+
+ def prevent_destroy_if_confirmed_ingestions_exist: () -> untyped
end
diff --git a/sig/app/models/payment_ingestion.rbs b/sig/app/models/payment_ingestion.rbs
index a1695763..2b3562cd 100644
--- a/sig/app/models/payment_ingestion.rbs
+++ b/sig/app/models/payment_ingestion.rbs
@@ -3,7 +3,7 @@ class PaymentIngestion
def confirmable?: () -> bool
- def confirm!: (?create_alias: bool) -> TenantPayment
+ def confirm!: (?create_alias: bool) -> Receipt
def duplicate_exists?: () -> bool
@@ -19,4 +19,6 @@ class PaymentIngestion
def validate_parse_status: () -> untyped
def ensure_not_duplicate_payment: () -> untyped
+ def prevent_mutation_after_confirmed: () -> untyped
+ def prevent_destroy_if_confirmed: () -> untyped
end
diff --git a/sig/app/models/receipt.rbs b/sig/app/models/receipt.rbs
new file mode 100644
index 00000000..ae96d3f2
--- /dev/null
+++ b/sig/app/models/receipt.rbs
@@ -0,0 +1,30 @@
+class Receipt < ApplicationRecord
+ VALID_PAYMENT_METHODS: Array[String]
+
+ attr_reader amount_error: String?
+
+ def self.active: () -> ActiveRecord::Relation
+ def self.voided: () -> ActiveRecord::Relation
+
+ def posted?: () -> bool
+ def voided?: () -> bool
+ def superseded?: () -> bool
+ def active?: () -> bool
+
+ def amount: () -> BigDecimal?
+ def amount=: ((BigDecimal | Numeric | String | nil) val) -> void
+
+ def balance_cents_as_of: (?as_of: Date) -> Integer
+ def balance_as_of: (?as_of: Date) -> BigDecimal
+ def accounting_user: () -> User?
+
+ private
+
+ def normalize_attributes: () -> void
+ def validate_not_future_dated: () -> void
+ def validate_amount_format: () -> void
+ def validate_tenancy_party_alignment: () -> void
+ def ensure_immutable_after_posting: () -> void
+ def prevent_destroy_if_posted: () -> void
+ def parse_amount_to_cents: ((BigDecimal | Numeric | String | nil) value) -> Integer?
+end
diff --git a/sig/app/models/tenant_payment.rbs b/sig/app/models/tenant_payment.rbs
deleted file mode 100644
index b019c4d6..00000000
--- a/sig/app/models/tenant_payment.rbs
+++ /dev/null
@@ -1,12 +0,0 @@
-class TenantPayment < ApplicationRecord
- def amount: () -> BigDecimal
- def amount=: (BigDecimal | Numeric | String | nil) -> untyped
- def accounting_user: () -> User?
-
- private
-
- def prevent_mutation_after_creation: () -> void
- def prevent_destroy: () -> void
- def assign_user_from_tenancy: () -> void
- def user_matches_tenancy_owner: () -> void
-end
diff --git a/sig/app/services/payment_documents/destroy_service.rbs b/sig/app/services/payment_documents/destroy_service.rbs
new file mode 100644
index 00000000..338d5441
--- /dev/null
+++ b/sig/app/services/payment_documents/destroy_service.rbs
@@ -0,0 +1,15 @@
+module PaymentDocuments
+ class DestroyService
+ def self.call: (user: User, document: PaymentDocument) -> Dry::Monads::Result
+ def initialize: (user: User, document: PaymentDocument) -> void
+ def call: () -> Dry::Monads::Result
+
+ private
+
+ attr_reader user: User
+ attr_reader document: PaymentDocument
+
+ def success: (PaymentDocument data) -> Dry::Monads::Result::Success
+ def failure: (String error, Symbol code) -> Dry::Monads::Result::Failure
+ end
+end
diff --git a/sig/app/services/payment_ingestions/confirm_service.rbs b/sig/app/services/payment_ingestions/confirm_service.rbs
index d66ec07c..35099e3b 100644
--- a/sig/app/services/payment_ingestions/confirm_service.rbs
+++ b/sig/app/services/payment_ingestions/confirm_service.rbs
@@ -11,7 +11,7 @@ module PaymentIngestions
attr_reader user: User
attr_reader ingestion: PaymentIngestion
- def create_payment: () -> TenantPayment
+ def create_receipt: () -> Receipt
def create_aliases: () -> void
@@ -19,7 +19,7 @@ module PaymentIngestions
def create_alias: (String? alias_name) -> void
- def success: (TenantPayment data) -> Dry::Monads::Result::Success
+ def success: (Receipt? data) -> Dry::Monads::Result::Success
def failure: (String error, Symbol code) -> Dry::Monads::Result::Failure
end
diff --git a/sig/app/services/payment_ingestions/destroy_service.rbs b/sig/app/services/payment_ingestions/destroy_service.rbs
new file mode 100644
index 00000000..478cd548
--- /dev/null
+++ b/sig/app/services/payment_ingestions/destroy_service.rbs
@@ -0,0 +1,15 @@
+module PaymentIngestions
+ class DestroyService
+ def self.call: (user: User, ingestion: PaymentIngestion) -> Dry::Monads::Result
+ def initialize: (user: User, ingestion: PaymentIngestion) -> void
+ def call: () -> Dry::Monads::Result
+
+ private
+
+ attr_reader user: User
+ attr_reader ingestion: PaymentIngestion
+
+ def success: (PaymentIngestion data) -> Dry::Monads::Result::Success
+ def failure: (String error, Symbol code) -> Dry::Monads::Result::Failure
+ end
+end
diff --git a/sig/app/services/receipts/correct_service.rbs b/sig/app/services/receipts/correct_service.rbs
new file mode 100644
index 00000000..66a4bedd
--- /dev/null
+++ b/sig/app/services/receipts/correct_service.rbs
@@ -0,0 +1,46 @@
+module Receipts
+ class CorrectService
+ def self.call: (
+ receipt: Receipt,
+ ?tenancy: Tenancy?,
+ ?payer_party: Party?,
+ ?amount_cents: Integer?,
+ ?amount: (BigDecimal | Numeric | String)?,
+ ?received_on: (Date | String | Object)?,
+ ?payment_method: (String | Symbol)?,
+ ?external_reference: String?,
+ ?memo: String?
+ ) -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure)
+
+ def initialize: (
+ receipt: Receipt,
+ ?tenancy: Tenancy?,
+ ?payer_party: Party?,
+ ?amount_cents: Integer?,
+ ?amount: (BigDecimal | Numeric | String)?,
+ ?received_on: (Date | String | Object)?,
+ ?payment_method: (String | Symbol)?,
+ ?external_reference: String?,
+ ?memo: String?
+ ) -> void
+
+ def call: () -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure)
+
+ private
+
+ attr_reader receipt: Receipt
+ attr_reader tenancy: Tenancy?
+ attr_reader payer_party: Party?
+ attr_reader amount_cents: Integer?
+ attr_reader amount: (BigDecimal | Numeric | String)?
+ attr_reader raw_received_on: untyped
+ attr_reader received_on: Date?
+ attr_reader payment_method: (String | Symbol)?
+ attr_reader external_reference: String?
+ attr_reader memo: String?
+
+ def parse_date: (untyped val) -> Date?
+ def resolve_amount_cents: () -> [ Integer, String? ]
+ def identical_replacement?: (Receipt rep, Tenancy t_tenancy, Party t_payer, Integer t_cents, Date t_date, (String | Symbol)? t_method, String? t_ref, String? t_memo) -> bool
+ end
+end
diff --git a/sig/app/services/receipts/create_service.rbs b/sig/app/services/receipts/create_service.rbs
new file mode 100644
index 00000000..9fa694a6
--- /dev/null
+++ b/sig/app/services/receipts/create_service.rbs
@@ -0,0 +1,42 @@
+module Receipts
+ class CreateService
+ def self.call: (
+ tenancy: Tenancy?,
+ payer_party: Party?,
+ received_on: (Date | String | Object)?,
+ payment_method: (String | Symbol)?,
+ ?amount_cents: Integer?,
+ ?amount: (BigDecimal | Numeric | String)?,
+ ?external_reference: String?,
+ ?memo: String?
+ ) -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure)
+
+ def initialize: (
+ tenancy: Tenancy?,
+ payer_party: Party?,
+ received_on: (Date | String | Object)?,
+ payment_method: (String | Symbol)?,
+ ?amount_cents: Integer?,
+ ?amount: (BigDecimal | Numeric | String)?,
+ ?external_reference: String?,
+ ?memo: String?
+ ) -> void
+
+ def call: () -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure)
+
+ private
+
+ attr_reader tenancy: Tenancy?
+ attr_reader payer_party: Party?
+ attr_reader amount_cents: Integer?
+ attr_reader amount: (BigDecimal | Numeric | String)?
+ attr_reader raw_received_on: untyped
+ attr_reader received_on: Date?
+ attr_reader payment_method: String?
+ attr_reader external_reference: String?
+ attr_reader memo: String?
+
+ def parse_date: (untyped val) -> Date?
+ def resolve_amount_cents: () -> [ Integer, String? ]
+ end
+end
diff --git a/sig/app/services/receipts/post_service.rbs b/sig/app/services/receipts/post_service.rbs
new file mode 100644
index 00000000..abe9449c
--- /dev/null
+++ b/sig/app/services/receipts/post_service.rbs
@@ -0,0 +1,14 @@
+module Receipts
+ class PostService
+ def self.call: (receipt: Receipt) -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure)
+
+ def initialize: (receipt: Receipt) -> void
+
+ def call: () -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure)
+
+ private
+
+ attr_reader receipt: Receipt
+ def default_description: () -> String
+ end
+end
diff --git a/sig/app/services/receipts/receipt_pdf_service.rbs b/sig/app/services/receipts/receipt_pdf_service.rbs
new file mode 100644
index 00000000..3ea3e8a9
--- /dev/null
+++ b/sig/app/services/receipts/receipt_pdf_service.rbs
@@ -0,0 +1,14 @@
+module Receipts
+ class ReceiptPdfService
+ def self.call: (receipt: Receipt, view_context: untyped) -> String
+
+ def initialize: (receipt: Receipt, view_context: untyped) -> void
+
+ def call: () -> String
+
+ private
+
+ attr_reader receipt: Receipt
+ attr_reader view_context: untyped
+ end
+end
diff --git a/sig/app/services/receipts/void_service.rbs b/sig/app/services/receipts/void_service.rbs
new file mode 100644
index 00000000..04705800
--- /dev/null
+++ b/sig/app/services/receipts/void_service.rbs
@@ -0,0 +1,14 @@
+module Receipts
+ class VoidService
+ def self.call: (receipt: Receipt, ?reason: String?) -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure)
+
+ def initialize: (receipt: Receipt, ?reason: String?) -> void
+
+ def call: () -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure)
+
+ private
+
+ attr_reader receipt: Receipt
+ attr_reader reason: String?
+ end
+end
diff --git a/sig/app/services/tenant_payments/create_service.rbs b/sig/app/services/tenant_payments/create_service.rbs
deleted file mode 100644
index 00d9e95e..00000000
--- a/sig/app/services/tenant_payments/create_service.rbs
+++ /dev/null
@@ -1,36 +0,0 @@
-module TenantPayments
- class CreateService
- def self.call: (
- ?tenancy: Tenancy?,
- ?amount_cents: Integer?,
- ?amount: (BigDecimal | Numeric | String)?,
- ?payment_date: Date?,
- ?payment_method: String?,
- ?transaction_number: String?,
- ?description: String?,
- ?params: untyped?
- ) -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure)
-
- def initialize: (
- tenancy: Tenancy?,
- ?amount_cents: Integer?,
- ?amount: (BigDecimal | Numeric | String)?,
- ?payment_date: Date?,
- ?payment_method: String?,
- ?transaction_number: String?,
- ?description: String?
- ) -> void
-
- def call: () -> (Dry::Monads::Result::Success | Dry::Monads::Result::Failure)
-
- private
-
- attr_reader tenancy: Tenancy
- attr_reader amount_cents: Integer?
- attr_reader amount: (BigDecimal | Numeric | String)?
- attr_reader payment_date: Date
- attr_reader payment_method: String
- attr_reader transaction_number: String?
- attr_reader description: String?
- end
-end
diff --git a/sig/app/services/tenant_payments/receipt_pdf_service.rbs b/sig/app/services/tenant_payments/receipt_pdf_service.rbs
deleted file mode 100644
index 9a18f4ce..00000000
--- a/sig/app/services/tenant_payments/receipt_pdf_service.rbs
+++ /dev/null
@@ -1,14 +0,0 @@
-module TenantPayments
- class ReceiptPdfService
- def self.call: (tenant_payment: TenantPayment, view_context: ActionView::Base) -> String
-
- def initialize: (tenant_payment: TenantPayment, view_context: ActionView::Base) -> void
-
- def call: () -> String
-
- private
-
- attr_reader tenant_payment: TenantPayment
- attr_reader view_context: ActionView::Base
- end
-end
diff --git a/sig/rbs_rails/app/models/party.rbs b/sig/rbs_rails/app/models/party.rbs
index 44ffdcdd..dbf2500c 100644
--- a/sig/rbs_rails/app/models/party.rbs
+++ b/sig/rbs_rails/app/models/party.rbs
@@ -425,6 +425,10 @@ class ::Party < ::ApplicationRecord
def accounting_postings=: (::Posting::ActiveRecord_Associations_CollectionProxy | ::Array[::Posting]) -> (::Posting::ActiveRecord_Associations_CollectionProxy | ::Array[::Posting])
def accounting_posting_ids: () -> ::Array[::Integer]
def accounting_posting_ids=: (::Array[::Integer]) -> ::Array[::Integer]
+ def receipts_as_payer: () -> ::Receipt::ActiveRecord_Associations_CollectionProxy
+ def receipts_as_payer=: (::Receipt::ActiveRecord_Associations_CollectionProxy | ::Array[::Receipt]) -> (::Receipt::ActiveRecord_Associations_CollectionProxy | ::Array[::Receipt])
+ def receipts_as_payer_ids: () -> ::Array[::Integer]
+ def receipts_as_payer_ids=: (::Array[::Integer]) -> ::Array[::Integer]
def payment_ingestions: () -> ::PaymentIngestion::ActiveRecord_Associations_CollectionProxy
def payment_ingestions=: (::PaymentIngestion::ActiveRecord_Associations_CollectionProxy | ::Array[::PaymentIngestion]) -> (::PaymentIngestion::ActiveRecord_Associations_CollectionProxy | ::Array[::PaymentIngestion])
def payment_ingestion_ids: () -> ::Array[::Integer]
@@ -509,6 +513,10 @@ class ::Posting < ::ApplicationRecord
end
class ::Posting::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy
end
+class ::Receipt < ::ApplicationRecord
+end
+class ::Receipt::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy
+end
class ::PaymentIngestion < ::ApplicationRecord
end
class ::PaymentIngestion::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy
diff --git a/sig/rbs_rails/app/models/payment_ingestion.rbs b/sig/rbs_rails/app/models/payment_ingestion.rbs
index a4a63128..92298705 100644
--- a/sig/rbs_rails/app/models/payment_ingestion.rbs
+++ b/sig/rbs_rails/app/models/payment_ingestion.rbs
@@ -604,46 +604,6 @@ class ::PaymentIngestion < ::ApplicationRecord
def party_id_for_database: () -> ::Integer?
- def tenant_payment_id: () -> ::Integer?
-
- def tenant_payment_id=: (::Integer?) -> ::Integer?
-
- def tenant_payment_id?: () -> bool
-
- def tenant_payment_id_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool
-
- def tenant_payment_id_change: () -> [ ::Integer?, ::Integer? ]
-
- def tenant_payment_id_will_change!: () -> void
-
- def tenant_payment_id_was: () -> ::Integer?
-
- def tenant_payment_id_previously_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool
-
- def tenant_payment_id_previous_change: () -> ::Array[::Integer?]?
-
- def tenant_payment_id_previously_was: () -> ::Integer?
-
- def tenant_payment_id_before_last_save: () -> ::Integer?
-
- def tenant_payment_id_change_to_be_saved: () -> ::Array[::Integer?]?
-
- def tenant_payment_id_in_database: () -> ::Integer?
-
- def saved_change_to_tenant_payment_id: () -> ::Array[::Integer?]?
-
- def saved_change_to_tenant_payment_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool
-
- def will_save_change_to_tenant_payment_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool
-
- def restore_tenant_payment_id!: () -> void
-
- def clear_tenant_payment_id_change: () -> void
-
- def tenant_payment_id_before_type_cast: () -> ::Integer?
-
- def tenant_payment_id_for_database: () -> ::Integer?
-
def transaction_number: () -> ::String?
def transaction_number=: (::String?) -> ::String?
@@ -763,6 +723,46 @@ class ::PaymentIngestion < ::ApplicationRecord
def user_id_before_type_cast: () -> ::Integer
def user_id_for_database: () -> ::Integer
+
+ def receipt_id: () -> ::Integer?
+
+ def receipt_id=: (::Integer?) -> ::Integer?
+
+ def receipt_id?: () -> bool
+
+ def receipt_id_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def receipt_id_change: () -> [ ::Integer?, ::Integer? ]
+
+ def receipt_id_will_change!: () -> void
+
+ def receipt_id_was: () -> ::Integer?
+
+ def receipt_id_previously_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def receipt_id_previous_change: () -> ::Array[::Integer?]?
+
+ def receipt_id_previously_was: () -> ::Integer?
+
+ def receipt_id_before_last_save: () -> ::Integer?
+
+ def receipt_id_change_to_be_saved: () -> ::Array[::Integer?]?
+
+ def receipt_id_in_database: () -> ::Integer?
+
+ def saved_change_to_receipt_id: () -> ::Array[::Integer?]?
+
+ def saved_change_to_receipt_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def will_save_change_to_receipt_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def restore_receipt_id!: () -> void
+
+ def clear_receipt_id_change: () -> void
+
+ def receipt_id_before_type_cast: () -> ::Integer?
+
+ def receipt_id_for_database: () -> ::Integer?
end
include ::PaymentIngestion::GeneratedAttributeMethods
module ::PaymentIngestion::GeneratedAliasAttributeMethods
@@ -828,12 +828,12 @@ class ::PaymentIngestion < ::ApplicationRecord
def build_tenancy: (?untyped) -> ::Tenancy
def create_tenancy: (?untyped) -> ::Tenancy
def create_tenancy!: (?untyped) -> ::Tenancy
- def tenant_payment: () -> ::TenantPayment?
- def tenant_payment=: (::TenantPayment?) -> ::TenantPayment?
- def reload_tenant_payment: () -> ::TenantPayment?
- def build_tenant_payment: (?untyped) -> ::TenantPayment
- def create_tenant_payment: (?untyped) -> ::TenantPayment
- def create_tenant_payment!: (?untyped) -> ::TenantPayment
+ def receipt: () -> ::Receipt?
+ def receipt=: (::Receipt?) -> ::Receipt?
+ def reload_receipt: () -> ::Receipt?
+ def build_receipt: (?untyped) -> ::Receipt
+ def create_receipt: (?untyped) -> ::Receipt
+ def create_receipt!: (?untyped) -> ::Receipt
def payment_document: () -> ::PaymentDocument?
def payment_document=: (::PaymentDocument?) -> ::PaymentDocument?
def reload_payment_document: () -> ::PaymentDocument?
@@ -937,7 +937,7 @@ class ::Party < ::ApplicationRecord
end
class ::Tenancy < ::ApplicationRecord
end
-class ::TenantPayment < ::ApplicationRecord
+class ::Receipt < ::ApplicationRecord
end
class ::PaymentDocument < ::ApplicationRecord
end
diff --git a/sig/rbs_rails/app/models/property.rbs b/sig/rbs_rails/app/models/property.rbs
index a68203cb..4a12c642 100644
--- a/sig/rbs_rails/app/models/property.rbs
+++ b/sig/rbs_rails/app/models/property.rbs
@@ -345,10 +345,10 @@ class ::Property < ::ApplicationRecord
def charges=: (::Charge::ActiveRecord_Associations_CollectionProxy | ::Array[::Charge]) -> (::Charge::ActiveRecord_Associations_CollectionProxy | ::Array[::Charge])
def charge_ids: () -> ::Array[::Integer]
def charge_ids=: (::Array[::Integer]) -> ::Array[::Integer]
- def tenant_payments: () -> ::TenantPayment::ActiveRecord_Associations_CollectionProxy
- def tenant_payments=: (::TenantPayment::ActiveRecord_Associations_CollectionProxy | ::Array[::TenantPayment]) -> (::TenantPayment::ActiveRecord_Associations_CollectionProxy | ::Array[::TenantPayment])
- def tenant_payment_ids: () -> ::Array[::Integer]
- def tenant_payment_ids=: (::Array[::Integer]) -> ::Array[::Integer]
+ def receipts: () -> ::Receipt::ActiveRecord_Associations_CollectionProxy
+ def receipts=: (::Receipt::ActiveRecord_Associations_CollectionProxy | ::Array[::Receipt]) -> (::Receipt::ActiveRecord_Associations_CollectionProxy | ::Array[::Receipt])
+ def receipt_ids: () -> ::Array[::Integer]
+ def receipt_ids=: (::Array[::Integer]) -> ::Array[::Integer]
def accounting_postings: () -> ::Posting::ActiveRecord_Associations_CollectionProxy
def accounting_postings=: (::Posting::ActiveRecord_Associations_CollectionProxy | ::Array[::Posting]) -> (::Posting::ActiveRecord_Associations_CollectionProxy | ::Array[::Posting])
def accounting_posting_ids: () -> ::Array[::Integer]
@@ -464,9 +464,9 @@ class ::Charge < ::ApplicationRecord
end
class ::Charge::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy
end
-class ::TenantPayment < ::ApplicationRecord
+class ::Receipt < ::ApplicationRecord
end
-class ::TenantPayment::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy
+class ::Receipt::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy
end
class ::Posting < ::ApplicationRecord
end
diff --git a/sig/rbs_rails/app/models/receipt.rbs b/sig/rbs_rails/app/models/receipt.rbs
new file mode 100644
index 00000000..ec5555c7
--- /dev/null
+++ b/sig/rbs_rails/app/models/receipt.rbs
@@ -0,0 +1,701 @@
+# resolve-type-names: false
+
+class ::Receipt < ::ApplicationRecord
+ extend ::ActiveRecord::Base::ClassMethods[::Receipt, ::Receipt::ActiveRecord_Relation, ::Integer]
+
+ module ::Receipt::GeneratedAttributeMethods
+ def id: () -> ::Integer
+
+ def id=: (::Integer) -> ::Integer
+
+ def id?: () -> bool
+
+ def id_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def id_change: () -> [ ::Integer?, ::Integer? ]
+
+ def id_will_change!: () -> void
+
+ def id_was: () -> ::Integer?
+
+ def id_previously_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def id_previous_change: () -> ::Array[::Integer?]?
+
+ def id_previously_was: () -> ::Integer?
+
+ def id_before_last_save: () -> ::Integer?
+
+ def id_change_to_be_saved: () -> ::Array[::Integer?]?
+
+ def id_in_database: () -> ::Integer?
+
+ def saved_change_to_id: () -> ::Array[::Integer?]?
+
+ def saved_change_to_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def will_save_change_to_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def restore_id!: () -> void
+
+ def clear_id_change: () -> void
+
+ def id_before_type_cast: () -> ::Integer
+
+ def id_for_database: () -> ::Integer
+
+ def user_id: () -> ::Integer
+
+ def user_id=: (::Integer) -> ::Integer
+
+ def user_id?: () -> bool
+
+ def user_id_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def user_id_change: () -> [ ::Integer?, ::Integer? ]
+
+ def user_id_will_change!: () -> void
+
+ def user_id_was: () -> ::Integer?
+
+ def user_id_previously_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def user_id_previous_change: () -> ::Array[::Integer?]?
+
+ def user_id_previously_was: () -> ::Integer?
+
+ def user_id_before_last_save: () -> ::Integer?
+
+ def user_id_change_to_be_saved: () -> ::Array[::Integer?]?
+
+ def user_id_in_database: () -> ::Integer?
+
+ def saved_change_to_user_id: () -> ::Array[::Integer?]?
+
+ def saved_change_to_user_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def will_save_change_to_user_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def restore_user_id!: () -> void
+
+ def clear_user_id_change: () -> void
+
+ def user_id_before_type_cast: () -> ::Integer
+
+ def user_id_for_database: () -> ::Integer
+
+ def tenancy_id: () -> ::Integer
+
+ def tenancy_id=: (::Integer) -> ::Integer
+
+ def tenancy_id?: () -> bool
+
+ def tenancy_id_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def tenancy_id_change: () -> [ ::Integer?, ::Integer? ]
+
+ def tenancy_id_will_change!: () -> void
+
+ def tenancy_id_was: () -> ::Integer?
+
+ def tenancy_id_previously_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def tenancy_id_previous_change: () -> ::Array[::Integer?]?
+
+ def tenancy_id_previously_was: () -> ::Integer?
+
+ def tenancy_id_before_last_save: () -> ::Integer?
+
+ def tenancy_id_change_to_be_saved: () -> ::Array[::Integer?]?
+
+ def tenancy_id_in_database: () -> ::Integer?
+
+ def saved_change_to_tenancy_id: () -> ::Array[::Integer?]?
+
+ def saved_change_to_tenancy_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def will_save_change_to_tenancy_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def restore_tenancy_id!: () -> void
+
+ def clear_tenancy_id_change: () -> void
+
+ def tenancy_id_before_type_cast: () -> ::Integer
+
+ def tenancy_id_for_database: () -> ::Integer
+
+ def payer_party_id: () -> ::Integer
+
+ def payer_party_id=: (::Integer) -> ::Integer
+
+ def payer_party_id?: () -> bool
+
+ def payer_party_id_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def payer_party_id_change: () -> [ ::Integer?, ::Integer? ]
+
+ def payer_party_id_will_change!: () -> void
+
+ def payer_party_id_was: () -> ::Integer?
+
+ def payer_party_id_previously_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def payer_party_id_previous_change: () -> ::Array[::Integer?]?
+
+ def payer_party_id_previously_was: () -> ::Integer?
+
+ def payer_party_id_before_last_save: () -> ::Integer?
+
+ def payer_party_id_change_to_be_saved: () -> ::Array[::Integer?]?
+
+ def payer_party_id_in_database: () -> ::Integer?
+
+ def saved_change_to_payer_party_id: () -> ::Array[::Integer?]?
+
+ def saved_change_to_payer_party_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def will_save_change_to_payer_party_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def restore_payer_party_id!: () -> void
+
+ def clear_payer_party_id_change: () -> void
+
+ def payer_party_id_before_type_cast: () -> ::Integer
+
+ def payer_party_id_for_database: () -> ::Integer
+
+ def amount_cents: () -> ::Integer
+
+ def amount_cents=: (::Integer) -> ::Integer
+
+ def amount_cents?: () -> bool
+
+ def amount_cents_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def amount_cents_change: () -> [ ::Integer?, ::Integer? ]
+
+ def amount_cents_will_change!: () -> void
+
+ def amount_cents_was: () -> ::Integer?
+
+ def amount_cents_previously_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def amount_cents_previous_change: () -> ::Array[::Integer?]?
+
+ def amount_cents_previously_was: () -> ::Integer?
+
+ def amount_cents_before_last_save: () -> ::Integer?
+
+ def amount_cents_change_to_be_saved: () -> ::Array[::Integer?]?
+
+ def amount_cents_in_database: () -> ::Integer?
+
+ def saved_change_to_amount_cents: () -> ::Array[::Integer?]?
+
+ def saved_change_to_amount_cents?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def will_save_change_to_amount_cents?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def restore_amount_cents!: () -> void
+
+ def clear_amount_cents_change: () -> void
+
+ def amount_cents_before_type_cast: () -> ::Integer
+
+ def amount_cents_for_database: () -> ::Integer
+
+ def received_on: () -> ::Date
+
+ def received_on=: (::Date) -> ::Date
+
+ def received_on?: () -> bool
+
+ def received_on_changed?: (?from: ::Date?, ?to: ::Date?) -> bool
+
+ def received_on_change: () -> [ ::Date?, ::Date? ]
+
+ def received_on_will_change!: () -> void
+
+ def received_on_was: () -> ::Date?
+
+ def received_on_previously_changed?: (?from: ::Date?, ?to: ::Date?) -> bool
+
+ def received_on_previous_change: () -> ::Array[::Date?]?
+
+ def received_on_previously_was: () -> ::Date?
+
+ def received_on_before_last_save: () -> ::Date?
+
+ def received_on_change_to_be_saved: () -> ::Array[::Date?]?
+
+ def received_on_in_database: () -> ::Date?
+
+ def saved_change_to_received_on: () -> ::Array[::Date?]?
+
+ def saved_change_to_received_on?: (?from: ::Date?, ?to: ::Date?) -> bool
+
+ def will_save_change_to_received_on?: (?from: ::Date?, ?to: ::Date?) -> bool
+
+ def restore_received_on!: () -> void
+
+ def clear_received_on_change: () -> void
+
+ def received_on_before_type_cast: () -> ::Date
+
+ def received_on_for_database: () -> ::Date
+
+ def payment_method: () -> ::String
+
+ def payment_method=: (::String) -> ::String
+
+ def payment_method?: () -> bool
+
+ def payment_method_changed?: (?from: ::String?, ?to: ::String?) -> bool
+
+ def payment_method_change: () -> [ ::String?, ::String? ]
+
+ def payment_method_will_change!: () -> void
+
+ def payment_method_was: () -> ::String?
+
+ def payment_method_previously_changed?: (?from: ::String?, ?to: ::String?) -> bool
+
+ def payment_method_previous_change: () -> ::Array[::String?]?
+
+ def payment_method_previously_was: () -> ::String?
+
+ def payment_method_before_last_save: () -> ::String?
+
+ def payment_method_change_to_be_saved: () -> ::Array[::String?]?
+
+ def payment_method_in_database: () -> ::String?
+
+ def saved_change_to_payment_method: () -> ::Array[::String?]?
+
+ def saved_change_to_payment_method?: (?from: ::String?, ?to: ::String?) -> bool
+
+ def will_save_change_to_payment_method?: (?from: ::String?, ?to: ::String?) -> bool
+
+ def restore_payment_method!: () -> void
+
+ def clear_payment_method_change: () -> void
+
+ def payment_method_before_type_cast: () -> ::String
+
+ def payment_method_for_database: () -> ::String
+
+ def external_reference: () -> ::String?
+
+ def external_reference=: (::String?) -> ::String?
+
+ def external_reference?: () -> bool
+
+ def external_reference_changed?: (?from: ::String?, ?to: ::String?) -> bool
+
+ def external_reference_change: () -> [ ::String?, ::String? ]
+
+ def external_reference_will_change!: () -> void
+
+ def external_reference_was: () -> ::String?
+
+ def external_reference_previously_changed?: (?from: ::String?, ?to: ::String?) -> bool
+
+ def external_reference_previous_change: () -> ::Array[::String?]?
+
+ def external_reference_previously_was: () -> ::String?
+
+ def external_reference_before_last_save: () -> ::String?
+
+ def external_reference_change_to_be_saved: () -> ::Array[::String?]?
+
+ def external_reference_in_database: () -> ::String?
+
+ def saved_change_to_external_reference: () -> ::Array[::String?]?
+
+ def saved_change_to_external_reference?: (?from: ::String?, ?to: ::String?) -> bool
+
+ def will_save_change_to_external_reference?: (?from: ::String?, ?to: ::String?) -> bool
+
+ def restore_external_reference!: () -> void
+
+ def clear_external_reference_change: () -> void
+
+ def external_reference_before_type_cast: () -> ::String?
+
+ def external_reference_for_database: () -> ::String?
+
+ def memo: () -> ::String?
+
+ def memo=: (::String?) -> ::String?
+
+ def memo?: () -> bool
+
+ def memo_changed?: (?from: ::String?, ?to: ::String?) -> bool
+
+ def memo_change: () -> [ ::String?, ::String? ]
+
+ def memo_will_change!: () -> void
+
+ def memo_was: () -> ::String?
+
+ def memo_previously_changed?: (?from: ::String?, ?to: ::String?) -> bool
+
+ def memo_previous_change: () -> ::Array[::String?]?
+
+ def memo_previously_was: () -> ::String?
+
+ def memo_before_last_save: () -> ::String?
+
+ def memo_change_to_be_saved: () -> ::Array[::String?]?
+
+ def memo_in_database: () -> ::String?
+
+ def saved_change_to_memo: () -> ::Array[::String?]?
+
+ def saved_change_to_memo?: (?from: ::String?, ?to: ::String?) -> bool
+
+ def will_save_change_to_memo?: (?from: ::String?, ?to: ::String?) -> bool
+
+ def restore_memo!: () -> void
+
+ def clear_memo_change: () -> void
+
+ def memo_before_type_cast: () -> ::String?
+
+ def memo_for_database: () -> ::String?
+
+ def posted_at: () -> untyped
+
+ def posted_at=: (untyped) -> untyped
+
+ def posted_at?: () -> bool
+
+ def posted_at_changed?: (?from: untyped, ?to: untyped) -> bool
+
+ def posted_at_change: () -> [ untyped, untyped ]
+
+ def posted_at_will_change!: () -> void
+
+ def posted_at_was: () -> untyped
+
+ def posted_at_previously_changed?: (?from: untyped, ?to: untyped) -> bool
+
+ def posted_at_previous_change: () -> ::Array[untyped]?
+
+ def posted_at_previously_was: () -> untyped
+
+ def posted_at_before_last_save: () -> untyped
+
+ def posted_at_change_to_be_saved: () -> ::Array[untyped]?
+
+ def posted_at_in_database: () -> untyped
+
+ def saved_change_to_posted_at: () -> ::Array[untyped]?
+
+ def saved_change_to_posted_at?: (?from: untyped, ?to: untyped) -> bool
+
+ def will_save_change_to_posted_at?: (?from: untyped, ?to: untyped) -> bool
+
+ def restore_posted_at!: () -> void
+
+ def clear_posted_at_change: () -> void
+
+ def posted_at_before_type_cast: () -> untyped?
+
+ def posted_at_for_database: () -> untyped?
+
+ def voided_at: () -> untyped
+
+ def voided_at=: (untyped) -> untyped
+
+ def voided_at?: () -> bool
+
+ def voided_at_changed?: (?from: untyped, ?to: untyped) -> bool
+
+ def voided_at_change: () -> [ untyped, untyped ]
+
+ def voided_at_will_change!: () -> void
+
+ def voided_at_was: () -> untyped
+
+ def voided_at_previously_changed?: (?from: untyped, ?to: untyped) -> bool
+
+ def voided_at_previous_change: () -> ::Array[untyped]?
+
+ def voided_at_previously_was: () -> untyped
+
+ def voided_at_before_last_save: () -> untyped
+
+ def voided_at_change_to_be_saved: () -> ::Array[untyped]?
+
+ def voided_at_in_database: () -> untyped
+
+ def saved_change_to_voided_at: () -> ::Array[untyped]?
+
+ def saved_change_to_voided_at?: (?from: untyped, ?to: untyped) -> bool
+
+ def will_save_change_to_voided_at?: (?from: untyped, ?to: untyped) -> bool
+
+ def restore_voided_at!: () -> void
+
+ def clear_voided_at_change: () -> void
+
+ def voided_at_before_type_cast: () -> untyped?
+
+ def voided_at_for_database: () -> untyped?
+
+ def superseded_by_id: () -> ::Integer?
+
+ def superseded_by_id=: (::Integer?) -> ::Integer?
+
+ def superseded_by_id?: () -> bool
+
+ def superseded_by_id_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def superseded_by_id_change: () -> [ ::Integer?, ::Integer? ]
+
+ def superseded_by_id_will_change!: () -> void
+
+ def superseded_by_id_was: () -> ::Integer?
+
+ def superseded_by_id_previously_changed?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def superseded_by_id_previous_change: () -> ::Array[::Integer?]?
+
+ def superseded_by_id_previously_was: () -> ::Integer?
+
+ def superseded_by_id_before_last_save: () -> ::Integer?
+
+ def superseded_by_id_change_to_be_saved: () -> ::Array[::Integer?]?
+
+ def superseded_by_id_in_database: () -> ::Integer?
+
+ def saved_change_to_superseded_by_id: () -> ::Array[::Integer?]?
+
+ def saved_change_to_superseded_by_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def will_save_change_to_superseded_by_id?: (?from: ::Integer?, ?to: ::Integer?) -> bool
+
+ def restore_superseded_by_id!: () -> void
+
+ def clear_superseded_by_id_change: () -> void
+
+ def superseded_by_id_before_type_cast: () -> ::Integer?
+
+ def superseded_by_id_for_database: () -> ::Integer?
+
+ def created_at: () -> ::ActiveSupport::TimeWithZone
+
+ def created_at=: (::ActiveSupport::TimeWithZone) -> ::ActiveSupport::TimeWithZone
+
+ def created_at?: () -> bool
+
+ def created_at_changed?: (?from: ::ActiveSupport::TimeWithZone?, ?to: ::ActiveSupport::TimeWithZone?) -> bool
+
+ def created_at_change: () -> [ ::ActiveSupport::TimeWithZone?, ::ActiveSupport::TimeWithZone? ]
+
+ def created_at_will_change!: () -> void
+
+ def created_at_was: () -> ::ActiveSupport::TimeWithZone?
+
+ def created_at_previously_changed?: (?from: ::ActiveSupport::TimeWithZone?, ?to: ::ActiveSupport::TimeWithZone?) -> bool
+
+ def created_at_previous_change: () -> ::Array[::ActiveSupport::TimeWithZone?]?
+
+ def created_at_previously_was: () -> ::ActiveSupport::TimeWithZone?
+
+ def created_at_before_last_save: () -> ::ActiveSupport::TimeWithZone?
+
+ def created_at_change_to_be_saved: () -> ::Array[::ActiveSupport::TimeWithZone?]?
+
+ def created_at_in_database: () -> ::ActiveSupport::TimeWithZone?
+
+ def saved_change_to_created_at: () -> ::Array[::ActiveSupport::TimeWithZone?]?
+
+ def saved_change_to_created_at?: (?from: ::ActiveSupport::TimeWithZone?, ?to: ::ActiveSupport::TimeWithZone?) -> bool
+
+ def will_save_change_to_created_at?: (?from: ::ActiveSupport::TimeWithZone?, ?to: ::ActiveSupport::TimeWithZone?) -> bool
+
+ def restore_created_at!: () -> void
+
+ def clear_created_at_change: () -> void
+
+ def created_at_before_type_cast: () -> ::Time
+
+ def created_at_for_database: () -> ::Time
+
+ def updated_at: () -> ::ActiveSupport::TimeWithZone
+
+ def updated_at=: (::ActiveSupport::TimeWithZone) -> ::ActiveSupport::TimeWithZone
+
+ def updated_at?: () -> bool
+
+ def updated_at_changed?: (?from: ::ActiveSupport::TimeWithZone?, ?to: ::ActiveSupport::TimeWithZone?) -> bool
+
+ def updated_at_change: () -> [ ::ActiveSupport::TimeWithZone?, ::ActiveSupport::TimeWithZone? ]
+
+ def updated_at_will_change!: () -> void
+
+ def updated_at_was: () -> ::ActiveSupport::TimeWithZone?
+
+ def updated_at_previously_changed?: (?from: ::ActiveSupport::TimeWithZone?, ?to: ::ActiveSupport::TimeWithZone?) -> bool
+
+ def updated_at_previous_change: () -> ::Array[::ActiveSupport::TimeWithZone?]?
+
+ def updated_at_previously_was: () -> ::ActiveSupport::TimeWithZone?
+
+ def updated_at_before_last_save: () -> ::ActiveSupport::TimeWithZone?
+
+ def updated_at_change_to_be_saved: () -> ::Array[::ActiveSupport::TimeWithZone?]?
+
+ def updated_at_in_database: () -> ::ActiveSupport::TimeWithZone?
+
+ def saved_change_to_updated_at: () -> ::Array[::ActiveSupport::TimeWithZone?]?
+
+ def saved_change_to_updated_at?: (?from: ::ActiveSupport::TimeWithZone?, ?to: ::ActiveSupport::TimeWithZone?) -> bool
+
+ def will_save_change_to_updated_at?: (?from: ::ActiveSupport::TimeWithZone?, ?to: ::ActiveSupport::TimeWithZone?) -> bool
+
+ def restore_updated_at!: () -> void
+
+ def clear_updated_at_change: () -> void
+
+ def updated_at_before_type_cast: () -> ::Time
+
+ def updated_at_for_database: () -> ::Time
+ end
+ include ::Receipt::GeneratedAttributeMethods
+ module ::Receipt::GeneratedAliasAttributeMethods
+ include ::Receipt::GeneratedAttributeMethods
+
+ alias id_value id
+
+ alias id_value= id=
+
+ alias id_value? id?
+
+ alias id_value_changed? id_changed?
+
+ alias id_value_change id_change
+
+ alias id_value_will_change! id_will_change!
+
+ alias id_value_was id_was
+
+ alias id_value_previously_changed? id_previously_changed?
+
+ alias id_value_previous_change id_previous_change
+
+ alias id_value_previously_was id_previously_was
+
+ alias id_value_before_last_save id_before_last_save
+
+ alias id_value_change_to_be_saved id_change_to_be_saved
+
+ alias id_value_in_database id_in_database
+
+ alias saved_change_to_id_value saved_change_to_id
+
+ alias saved_change_to_id_value? saved_change_to_id?
+
+ alias will_save_change_to_id_value? will_save_change_to_id?
+
+ alias restore_id_value! restore_id!
+
+ alias clear_id_value_change clear_id_change
+
+ alias id_value_before_type_cast id_before_type_cast
+
+ alias id_value_for_database id_for_database
+ end
+ include ::Receipt::GeneratedAliasAttributeMethods
+ def journal_entries: () -> ::JournalEntry::ActiveRecord_Associations_CollectionProxy
+ def journal_entries=: (::JournalEntry::ActiveRecord_Associations_CollectionProxy | ::Array[::JournalEntry]) -> (::JournalEntry::ActiveRecord_Associations_CollectionProxy | ::Array[::JournalEntry])
+ def journal_entry_ids: () -> ::Array[::Integer]
+ def journal_entry_ids=: (::Array[::Integer]) -> ::Array[::Integer]
+
+ def superseded_receipt: () -> ::Receipt?
+ def superseded_receipt=: (::Receipt?) -> ::Receipt?
+ def build_superseded_receipt: (?untyped) -> ::Receipt
+ def create_superseded_receipt: (?untyped) -> ::Receipt
+ def create_superseded_receipt!: (?untyped) -> ::Receipt
+ def reload_superseded_receipt: () -> ::Receipt?
+ def user: () -> ::User
+ def user=: (::User?) -> ::User?
+ def reload_user: () -> ::User?
+ def build_user: (?untyped) -> ::User
+ def create_user: (?untyped) -> ::User
+ def create_user!: (?untyped) -> ::User
+ def tenancy: () -> ::Tenancy
+ def tenancy=: (::Tenancy?) -> ::Tenancy?
+ def reload_tenancy: () -> ::Tenancy?
+ def build_tenancy: (?untyped) -> ::Tenancy
+ def create_tenancy: (?untyped) -> ::Tenancy
+ def create_tenancy!: (?untyped) -> ::Tenancy
+ def payer_party: () -> ::Party
+ def payer_party=: (::Party?) -> ::Party?
+ def reload_payer_party: () -> ::Party?
+ def build_payer_party: (?untyped) -> ::Party
+ def create_payer_party: (?untyped) -> ::Party
+ def create_payer_party!: (?untyped) -> ::Party
+ def superseded_by: () -> ::Receipt?
+ def superseded_by=: (::Receipt?) -> ::Receipt?
+ def reload_superseded_by: () -> ::Receipt?
+ def build_superseded_by: (?untyped) -> ::Receipt
+ def create_superseded_by: (?untyped) -> ::Receipt
+ def create_superseded_by!: (?untyped) -> ::Receipt
+
+ module ::Receipt::GeneratedAssociationMethods
+ end
+ include ::Receipt::GeneratedAssociationMethods
+
+ def self.active: () -> ::Receipt::ActiveRecord_Relation
+ def self.voided: () -> ::Receipt::ActiveRecord_Relation
+
+ module ::Receipt::GeneratedRelationMethods
+ def active: () -> ::Receipt::ActiveRecord_Relation
+
+ def voided: () -> ::Receipt::ActiveRecord_Relation
+ end
+
+ class ::Receipt::ActiveRecord_Relation < ::ActiveRecord::Relation
+ include ::Enumerable[::Receipt]
+ include ::Receipt::GeneratedRelationMethods
+ include ::ActiveRecord::Relation::Methods[::Receipt, ::Integer]
+ end
+
+ class ::Receipt::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy
+ include ::Enumerable[::Receipt]
+ include ::Receipt::GeneratedRelationMethods
+ include ::ActiveRecord::Relation::Methods[::Receipt, ::Integer]
+
+ def build: (?::ActiveRecord::Associations::CollectionProxy::_EachPair attributes) ?{ () -> untyped } -> ::Receipt
+ | (::Array[::ActiveRecord::Associations::CollectionProxy::_EachPair] attributes) ?{ () -> untyped } -> ::Array[::Receipt]
+ def create: (?::ActiveRecord::Associations::CollectionProxy::_EachPair attributes) ?{ () -> untyped } -> ::Receipt
+ | (::Array[::ActiveRecord::Associations::CollectionProxy::_EachPair] attributes) ?{ () -> untyped } -> ::Array[::Receipt]
+ def create!: (?::ActiveRecord::Associations::CollectionProxy::_EachPair attributes) ?{ () -> untyped } -> ::Receipt
+ | (::Array[::ActiveRecord::Associations::CollectionProxy::_EachPair] attributes) ?{ () -> untyped } -> ::Array[::Receipt]
+ def reload: () -> ::Array[::Receipt]
+
+ def replace: (::Array[::Receipt]) -> void
+ def delete: (*::Receipt | ::Integer) -> ::Array[::Receipt]
+ def destroy: (*::Receipt | ::Integer) -> ::Array[::Receipt]
+ def <<: (*::Receipt | ::Array[::Receipt]) -> self
+ def prepend: (*::Receipt | ::Array[::Receipt]) -> self
+ end
+end
+
+class ::ApplicationRecord < ::ActiveRecord::Base
+end
+class ::JournalEntry < ::ApplicationRecord
+end
+class ::JournalEntry::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy
+end
+class ::Receipt < ::ApplicationRecord
+end
+class ::User < ::ApplicationRecord
+end
+class ::Tenancy < ::ApplicationRecord
+end
+class ::Party < ::ApplicationRecord
+end
diff --git a/sig/rbs_rails/app/models/tenancy.rbs b/sig/rbs_rails/app/models/tenancy.rbs
index a8d0c777..3d276dc9 100644
--- a/sig/rbs_rails/app/models/tenancy.rbs
+++ b/sig/rbs_rails/app/models/tenancy.rbs
@@ -385,10 +385,10 @@ class ::Tenancy < ::ApplicationRecord
def charges=: (::Charge::ActiveRecord_Associations_CollectionProxy | ::Array[::Charge]) -> (::Charge::ActiveRecord_Associations_CollectionProxy | ::Array[::Charge])
def charge_ids: () -> ::Array[::Integer]
def charge_ids=: (::Array[::Integer]) -> ::Array[::Integer]
- def tenant_payments: () -> ::TenantPayment::ActiveRecord_Associations_CollectionProxy
- def tenant_payments=: (::TenantPayment::ActiveRecord_Associations_CollectionProxy | ::Array[::TenantPayment]) -> (::TenantPayment::ActiveRecord_Associations_CollectionProxy | ::Array[::TenantPayment])
- def tenant_payment_ids: () -> ::Array[::Integer]
- def tenant_payment_ids=: (::Array[::Integer]) -> ::Array[::Integer]
+ def receipts: () -> ::Receipt::ActiveRecord_Associations_CollectionProxy
+ def receipts=: (::Receipt::ActiveRecord_Associations_CollectionProxy | ::Array[::Receipt]) -> (::Receipt::ActiveRecord_Associations_CollectionProxy | ::Array[::Receipt])
+ def receipt_ids: () -> ::Array[::Integer]
+ def receipt_ids=: (::Array[::Integer]) -> ::Array[::Integer]
def accounting_postings: () -> ::Posting::ActiveRecord_Associations_CollectionProxy
def accounting_postings=: (::Posting::ActiveRecord_Associations_CollectionProxy | ::Array[::Posting]) -> (::Posting::ActiveRecord_Associations_CollectionProxy | ::Array[::Posting])
def accounting_posting_ids: () -> ::Array[::Integer]
@@ -493,9 +493,9 @@ class ::Charge < ::ApplicationRecord
end
class ::Charge::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy
end
-class ::TenantPayment < ::ApplicationRecord
+class ::Receipt < ::ApplicationRecord
end
-class ::TenantPayment::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy
+class ::Receipt::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy
end
class ::Posting < ::ApplicationRecord
end
diff --git a/sig/rbs_rails/app/models/user.rbs b/sig/rbs_rails/app/models/user.rbs
index a46a365b..e4c7a6eb 100644
--- a/sig/rbs_rails/app/models/user.rbs
+++ b/sig/rbs_rails/app/models/user.rbs
@@ -313,10 +313,10 @@ class ::User < ::ApplicationRecord
def charges=: (::Charge::ActiveRecord_Associations_CollectionProxy | ::Array[::Charge]) -> (::Charge::ActiveRecord_Associations_CollectionProxy | ::Array[::Charge])
def charge_ids: () -> ::Array[::Integer]
def charge_ids=: (::Array[::Integer]) -> ::Array[::Integer]
- def tenant_payments: () -> ::TenantPayment::ActiveRecord_Associations_CollectionProxy
- def tenant_payments=: (::TenantPayment::ActiveRecord_Associations_CollectionProxy | ::Array[::TenantPayment]) -> (::TenantPayment::ActiveRecord_Associations_CollectionProxy | ::Array[::TenantPayment])
- def tenant_payment_ids: () -> ::Array[::Integer]
- def tenant_payment_ids=: (::Array[::Integer]) -> ::Array[::Integer]
+ def receipts: () -> ::Receipt::ActiveRecord_Associations_CollectionProxy
+ def receipts=: (::Receipt::ActiveRecord_Associations_CollectionProxy | ::Array[::Receipt]) -> (::Receipt::ActiveRecord_Associations_CollectionProxy | ::Array[::Receipt])
+ def receipt_ids: () -> ::Array[::Integer]
+ def receipt_ids=: (::Array[::Integer]) -> ::Array[::Integer]
def parties: () -> ::Party::ActiveRecord_Associations_CollectionProxy
def parties=: (::Party::ActiveRecord_Associations_CollectionProxy | ::Array[::Party]) -> (::Party::ActiveRecord_Associations_CollectionProxy | ::Array[::Party])
def party_ids: () -> ::Array[::Integer]
@@ -410,9 +410,9 @@ class ::Charge < ::ApplicationRecord
end
class ::Charge::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy
end
-class ::TenantPayment < ::ApplicationRecord
+class ::Receipt < ::ApplicationRecord
end
-class ::TenantPayment::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy
+class ::Receipt::ActiveRecord_Associations_CollectionProxy < ::ActiveRecord::Associations::CollectionProxy
end
class ::Party < ::ApplicationRecord
end
diff --git a/sig/rbs_rails/path_helpers.rbs b/sig/rbs_rails/path_helpers.rbs
index 6e530c2b..43e33bf6 100644
--- a/sig/rbs_rails/path_helpers.rbs
+++ b/sig/rbs_rails/path_helpers.rbs
@@ -24,8 +24,8 @@ interface ::_RbsRailsPathHelpers
def new_party_path: (*untyped) -> ::String
def edit_party_path: (*untyped) -> ::String
def party_path: (*untyped) -> ::String
- def tenancy_tenant_payments_path: (*untyped) -> ::String
- def new_tenancy_tenant_payment_path: (*untyped) -> ::String
+ def tenancy_receipts_path: (*untyped) -> ::String
+ def new_tenancy_receipt_path: (*untyped) -> ::String
def tenancy_charges_path: (*untyped) -> ::String
def new_tenancy_charge_path: (*untyped) -> ::String
def tenancy_tenancy_parties_path: (*untyped) -> ::String
@@ -44,9 +44,12 @@ interface ::_RbsRailsPathHelpers
def new_expense_path: (*untyped) -> ::String
def edit_expense_path: (*untyped) -> ::String
def expense_path: (*untyped) -> ::String
- def tenant_payments_path: (*untyped) -> ::String
- def new_tenant_payment_path: (*untyped) -> ::String
- def tenant_payment_path: (*untyped) -> ::String
+ def correction_receipt_path: (*untyped) -> ::String
+ def correct_receipt_path: (*untyped) -> ::String
+ def void_receipt_path: (*untyped) -> ::String
+ def receipts_path: (*untyped) -> ::String
+ def new_receipt_path: (*untyped) -> ::String
+ def receipt_path: (*untyped) -> ::String
def void_charge_path: (*untyped) -> ::String
def charge_path: (*untyped) -> ::String
def confirm_payment_ingestion_path: (*untyped) -> ::String
@@ -114,8 +117,8 @@ interface ::_RbsRailsPathHelpers
def new_party_url: (*untyped) -> ::String
def edit_party_url: (*untyped) -> ::String
def party_url: (*untyped) -> ::String
- def tenancy_tenant_payments_url: (*untyped) -> ::String
- def new_tenancy_tenant_payment_url: (*untyped) -> ::String
+ def tenancy_receipts_url: (*untyped) -> ::String
+ def new_tenancy_receipt_url: (*untyped) -> ::String
def tenancy_charges_url: (*untyped) -> ::String
def new_tenancy_charge_url: (*untyped) -> ::String
def tenancy_tenancy_parties_url: (*untyped) -> ::String
@@ -134,9 +137,12 @@ interface ::_RbsRailsPathHelpers
def new_expense_url: (*untyped) -> ::String
def edit_expense_url: (*untyped) -> ::String
def expense_url: (*untyped) -> ::String
- def tenant_payments_url: (*untyped) -> ::String
- def new_tenant_payment_url: (*untyped) -> ::String
- def tenant_payment_url: (*untyped) -> ::String
+ def correction_receipt_url: (*untyped) -> ::String
+ def correct_receipt_url: (*untyped) -> ::String
+ def void_receipt_url: (*untyped) -> ::String
+ def receipts_url: (*untyped) -> ::String
+ def new_receipt_url: (*untyped) -> ::String
+ def receipt_url: (*untyped) -> ::String
def void_charge_url: (*untyped) -> ::String
def charge_url: (*untyped) -> ::String
def confirm_payment_ingestion_url: (*untyped) -> ::String
diff --git a/spec/factories.rb b/spec/factories.rb
index bf47684f..cb55fb7e 100644
--- a/spec/factories.rb
+++ b/spec/factories.rb
@@ -69,12 +69,29 @@
effective_until { tenancy&.termination_date }
end
- factory :tenant_payment do
+ factory :receipt do
association :tenancy
- amount { 1200.0 }
- payment_date { Date.current }
+ user { tenancy.rentable_unit.property.user }
+ payer_party { association :party, user: user }
+ amount_cents { 120_000 }
+ received_on { Date.current }
payment_method { "check" }
- sequence(:transaction_number) { |n| "TXN#{n}" }
+ sequence(:external_reference) { |n| "REC#{n}" }
+
+ trait :posted do
+ posted_at { Time.current }
+ end
+
+ trait :voided do
+ posted_at { 1.day.ago }
+ voided_at { Time.current }
+ end
+
+ trait :superseded do
+ posted_at { 1.day.ago }
+ voided_at { Time.current }
+ superseded_by { association :receipt, tenancy: tenancy, user: user, payer_party: payer_party }
+ end
end
factory :expense do
diff --git a/spec/models/payment_document_spec.rb b/spec/models/payment_document_spec.rb
index 490e2595..f73bd3f2 100644
--- a/spec/models/payment_document_spec.rb
+++ b/spec/models/payment_document_spec.rb
@@ -47,4 +47,25 @@
expect(doc.accounting_user).to eq(user)
end
end
+
+ describe 'destruction' do
+ let(:user) { create(:user) }
+ let(:doc) { create(:payment_document, user: user) }
+
+ it 'allows destroying document with unconfirmed ingestions' do
+ create(:payment_ingestion, user: user, payment_document: doc, status: :matched)
+ expect {
+ doc.destroy
+ }.to change(PaymentDocument, :count).by(-1).and change(PaymentIngestion, :count).by(-1)
+ end
+
+ it 'prevents destroying document with confirmed ingestions' do
+ create(:payment_ingestion, user: user, payment_document: doc, status: :confirmed)
+ expect {
+ doc.destroy
+ }.not_to change(PaymentDocument, :count)
+
+ expect(doc.errors[:base]).to include("Cannot delete document with confirmed payment ingestions")
+ end
+ end
end
diff --git a/spec/models/payment_ingestion_spec.rb b/spec/models/payment_ingestion_spec.rb
index 14c81f1e..8ddac811 100644
--- a/spec/models/payment_ingestion_spec.rb
+++ b/spec/models/payment_ingestion_spec.rb
@@ -5,7 +5,7 @@
it { is_expected.to belong_to(:user) }
it { is_expected.to belong_to(:party).optional }
it { is_expected.to belong_to(:tenancy).optional }
- it { is_expected.to belong_to(:tenant_payment).optional }
+ it { is_expected.to belong_to(:receipt).optional }
it { is_expected.to belong_to(:payment_document).optional }
end
@@ -75,18 +75,20 @@
expect(ingestion.confirmable?).to be_falsey
end
- it "returns false for confirmable? when a duplicate payment exists" do
- create(:tenant_payment,
+ it "returns false for confirmable? when a duplicate payment receipt exists" do
+ create(:receipt,
+ user: user,
tenancy: tenancy,
- amount: 500.0,
- payment_date: Date.current,
+ payer_party: party,
+ amount_cents: 50_000,
+ received_on: Date.current,
payment_method: "zelle",
- transaction_number: "TXN123"
+ external_reference: "TXN123"
)
expect(ingestion.confirmable?).to be_falsey
end
- it "confirm! creates tenant payment and updates status" do
+ it "confirm! creates receipt and updates status" do
ingestion_to_confirm = create(:payment_ingestion,
user: user,
source: "pdf_upload",
@@ -100,15 +102,16 @@
)
expect {
- payment = ingestion_to_confirm.confirm!
- expect(payment.amount).to eq(1200.0)
- expect(payment.payment_method).to eq("venmo")
- expect(payment.transaction_number).to eq("TXN456")
- expect(payment.tenancy).to eq(tenancy)
- }.to change(TenantPayment, :count).by(1)
+ receipt = ingestion_to_confirm.confirm!
+ expect(receipt.amount).to eq(1200.0)
+ expect(receipt.payment_method).to eq("venmo")
+ expect(receipt.external_reference).to eq("TXN456")
+ expect(receipt.tenancy).to eq(tenancy)
+ expect(receipt.payer_party).to eq(party)
+ }.to change(Receipt, :count).by(1)
expect(ingestion_to_confirm.reload.status).to eq("confirmed")
- expect(ingestion_to_confirm.tenant_payment).not_to be_nil
+ expect(ingestion_to_confirm.receipt).not_to be_nil
end
it "confirm! with create_alias: true creates alias" do
@@ -134,38 +137,24 @@
expect(party.party_aliases.exists?(alias_name: "@samlopez")).to be_truthy
end
- it "confirm! rescues RecordNotUnique and raises ConfirmationError" do
- create(:tenant_payment,
- user: user,
- tenancy: tenancy,
- amount: 500.0,
- payment_date: Date.current,
- payment_method: "zelle",
- transaction_number: "TXNDUP"
- )
-
- dup_ingestion = build(:payment_ingestion,
+ it "confirm! returns existing receipt if called again on already confirmed ingestion" do
+ confirmed_ingestion = create(:payment_ingestion,
user: user,
source: "pdf_upload",
status: "matched",
party: party,
tenancy: tenancy,
- amount: 1000.0,
+ amount: 1200.0,
payment_date: Date.current,
- payment_method: "zelle",
- transaction_number: "TXNDUP"
+ payment_method: "venmo",
+ transaction_number: "TXNIDEM"
)
+ first_receipt = confirmed_ingestion.confirm!
expect {
- dup_ingestion.confirm!
- }.to raise_error(PaymentIngestions::ConfirmationError)
- end
-
- it "confirm! rescues RecordNotUnique and raises ConfirmationError when TenantPayment creation raises RecordNotUnique" do
- allow(TenantPayments::CreateService).to receive(:call).and_raise(ActiveRecord::RecordNotUnique.new("Duplicate key error"))
- expect {
- ingestion.confirm!
- }.to raise_error(PaymentIngestions::ConfirmationError, /This transaction has already been recorded/)
+ second_receipt = confirmed_ingestion.confirm!
+ expect(second_receipt).to eq(first_receipt)
+ }.not_to change(Receipt, :count)
end
end
@@ -182,12 +171,14 @@
let(:tenancy_two) { create(:tenancy, rentable_unit: unit_two) }
it "only flags duplicates within the same user" do
- create(:tenant_payment,
+ create(:receipt,
+ user: user_two,
tenancy: tenancy_two,
- amount: 1000.0,
- payment_date: Date.current,
+ payer_party: party_two,
+ amount_cents: 100_000,
+ received_on: Date.current,
payment_method: "zelle",
- transaction_number: "TXNSCOPED"
+ external_reference: "TXNSCOPED"
)
ingestion = build(:payment_ingestion,
@@ -205,14 +196,15 @@
expect(ingestion.duplicate_exists?).to be_falsey
expect(ingestion).to be_valid
- # When payment exists for user one with matching transaction number
- create(:tenant_payment,
+ # When receipt exists for user one with matching transaction number
+ create(:receipt,
tenancy: tenancy_one,
user: user_one,
- amount: 1000.0,
- payment_date: Date.current,
+ payer_party: party_one,
+ amount_cents: 100_000,
+ received_on: Date.current,
payment_method: "zelle",
- transaction_number: "TXNSCOPED_MATCH"
+ external_reference: "TXNSCOPED_MATCH"
)
ingestion.transaction_number = "TXNSCOPED_MATCH"
@@ -228,7 +220,7 @@
let(:unit) { create(:rentable_unit, property: property) }
let(:tenancy) { create(:tenancy, rentable_unit: unit) }
- it "prevents race conditions and raises already confirmed on concurrent calls" do
+ it "prevents race conditions and safely handles concurrent confirmations" do
ingestion = create(:payment_ingestion,
user: user,
source: "pdf_upload",
@@ -241,26 +233,23 @@
transaction_number: "TXNRACE"
)
- exceptions = []
+ results = []
threads = []
2.times do
threads << Thread.new do
ActiveRecord::Base.connection_pool.with_connection do
- begin
- PaymentIngestion.find(ingestion.id).confirm!
- rescue => e
- exceptions << e
- end
+ results << PaymentIngestion.find(ingestion.id).confirm!
end
end
end
threads.each(&:join)
- expect(exceptions.size).to eq(1)
- expect(exceptions.first).to be_a(PaymentIngestions::ConfirmationError)
- expect(exceptions.first.message).to match(/Already confirmed/)
+ expect(results.size).to eq(2)
+ expect(results.first).to be_a(Receipt)
+ expect(results.last).to be_a(Receipt)
+ expect(results.first.id).to eq(results.last.id)
expect(ingestion.reload.status).to eq("confirmed")
end
end
@@ -357,4 +346,89 @@
expect(ingestion.accounting_user).to eq(user)
end
end
+
+ describe "immutability and undeletability after confirmation" do
+ let(:user) { create(:user) }
+ let(:party) { create(:party, user: user) }
+ let(:property) { create(:property, user: user) }
+ let(:unit) { create(:rentable_unit, property: property) }
+ let(:tenancy) { create(:tenancy, rentable_unit: unit) }
+ let!(:confirmed_ingestion) do
+ create(:payment_ingestion,
+ user: user,
+ source: "pdf_upload",
+ status: "confirmed",
+ party: party,
+ tenancy: tenancy,
+ amount: 1500.0,
+ payment_date: Date.new(2026, 1, 15),
+ payment_method: "zelle",
+ transaction_number: "ZEL12345"
+ )
+ end
+
+ it "prevents updating party_id on confirmed ingestion" do
+ other_party = create(:party, user: user)
+ confirmed_ingestion.party = other_party
+ expect(confirmed_ingestion).not_to be_valid
+ expect(confirmed_ingestion.errors[:base]).to include("Cannot modify a confirmed payment ingestion")
+ end
+
+ it "prevents updating amount on confirmed ingestion" do
+ confirmed_ingestion.amount = 2000.0
+ expect(confirmed_ingestion).not_to be_valid
+ expect(confirmed_ingestion.errors[:base]).to include("Cannot modify a confirmed payment ingestion")
+ end
+
+ it "prevents updating payment_date on confirmed ingestion" do
+ confirmed_ingestion.payment_date = Date.new(2026, 2, 1)
+ expect(confirmed_ingestion).not_to be_valid
+ expect(confirmed_ingestion.errors[:base]).to include("Cannot modify a confirmed payment ingestion")
+ end
+
+ it "prevents updating payment_method on confirmed ingestion" do
+ confirmed_ingestion.payment_method = "venmo"
+ expect(confirmed_ingestion).not_to be_valid
+ expect(confirmed_ingestion.errors[:base]).to include("Cannot modify a confirmed payment ingestion")
+ end
+
+ it "prevents updating transaction_number on confirmed ingestion" do
+ confirmed_ingestion.transaction_number = "NEW_TXN"
+ expect(confirmed_ingestion).not_to be_valid
+ expect(confirmed_ingestion.errors[:base]).to include("Cannot modify a confirmed payment ingestion")
+ end
+
+ it "prevents changing status from confirmed to matched" do
+ confirmed_ingestion.status = :matched
+ expect(confirmed_ingestion).not_to be_valid
+ expect(confirmed_ingestion.errors[:base]).to include("Cannot modify a confirmed payment ingestion")
+ end
+
+ it "prevents updating raw_text on confirmed ingestion" do
+ confirmed_ingestion.raw_text = "new parsed text"
+ expect(confirmed_ingestion).not_to be_valid
+ expect(confirmed_ingestion.errors[:base]).to include("Cannot modify a confirmed payment ingestion")
+ end
+
+ it "prevents updating payer_name on confirmed ingestion" do
+ confirmed_ingestion.payer_name = "New Payer"
+ expect(confirmed_ingestion).not_to be_valid
+ expect(confirmed_ingestion.errors[:base]).to include("Cannot modify a confirmed payment ingestion")
+ end
+
+ it "prevents updating payment_document on confirmed ingestion" do
+ other_doc = create(:payment_document, user: user)
+ confirmed_ingestion.payment_document = other_doc
+ expect(confirmed_ingestion).not_to be_valid
+ expect(confirmed_ingestion.errors[:base]).to include("Cannot modify a confirmed payment ingestion")
+ end
+
+ it "prevents destroying a confirmed ingestion" do
+ expect {
+ confirmed_ingestion.destroy
+ }.not_to change(PaymentIngestion, :count)
+
+ expect(confirmed_ingestion.errors[:base]).to include("Cannot delete a confirmed payment ingestion")
+ end
+ end
end
diff --git a/spec/models/property_spec.rb b/spec/models/property_spec.rb
index f007f1fd..f4929de4 100644
--- a/spec/models/property_spec.rb
+++ b/spec/models/property_spec.rb
@@ -7,7 +7,7 @@
it { is_expected.to have_many(:tenancies).through(:rentable_units) }
it { is_expected.to have_many(:expenses).dependent(:restrict_with_error) }
it { is_expected.to have_many(:charges).through(:tenancies) }
- it { is_expected.to have_many(:tenant_payments).through(:tenancies) }
+ it { is_expected.to have_many(:receipts).through(:tenancies) }
it { is_expected.to have_many(:accounting_postings).class_name("Posting").dependent(:restrict_with_error) }
end
diff --git a/spec/models/receipt_spec.rb b/spec/models/receipt_spec.rb
new file mode 100644
index 00000000..4a664d32
--- /dev/null
+++ b/spec/models/receipt_spec.rb
@@ -0,0 +1,276 @@
+require "rails_helper"
+
+RSpec.describe Receipt, type: :model do
+ let(:user) { create(:user) }
+ let(:property) { create(:property, user: user) }
+ let(:unit) { create(:rentable_unit, property: property) }
+ let(:tenancy) { create(:tenancy, rentable_unit: unit) }
+ let(:payer_party) { create(:party, user: user) }
+
+ subject(:receipt) do
+ build(:receipt,
+ tenancy: tenancy,
+ user: user,
+ payer_party: payer_party,
+ amount_cents: 100_000,
+ received_on: Date.current,
+ payment_method: "zelle",
+ external_reference: "ZELLE123",
+ memo: "January Rent"
+ )
+ end
+
+ describe "associations" do
+ it { is_expected.to belong_to(:user) }
+ it { is_expected.to belong_to(:tenancy) }
+ it { is_expected.to belong_to(:payer_party).class_name("Party") }
+ it { is_expected.to belong_to(:superseded_by).class_name("Receipt").optional }
+ it { is_expected.to have_one(:superseded_receipt).class_name("Receipt").with_foreign_key(:superseded_by_id) }
+ it { is_expected.to have_many(:journal_entries).dependent(:restrict_with_error) }
+ end
+
+ describe "validations" do
+ it { is_expected.to be_valid }
+ it { is_expected.to validate_presence_of(:received_on) }
+ it { is_expected.to validate_presence_of(:payment_method) }
+
+ it "requires amount_cents to be strictly positive" do
+ receipt.amount_cents = 0
+ expect(receipt).not_to be_valid
+ expect(receipt.errors[:amount_cents]).to include("must be greater than 0")
+
+ receipt.amount_cents = -100
+ expect(receipt).not_to be_valid
+ end
+
+ it "validates that tenancy belongs to the same user" do
+ other_user = create(:user)
+ other_property = create(:property, user: other_user)
+ other_unit = create(:rentable_unit, property: other_property)
+ other_tenancy = create(:tenancy, rentable_unit: other_unit)
+
+ receipt.tenancy = other_tenancy
+ expect(receipt).not_to be_valid
+ expect(receipt.errors[:tenancy]).to include("must belong to the receipt owner")
+ end
+
+ it "validates that payer party belongs to the same user" do
+ other_user = create(:user)
+ other_party = create(:party, user: other_user)
+
+ receipt.payer_party = other_party
+ expect(receipt).not_to be_valid
+ expect(receipt.errors[:payer_party]).to include("must belong to the receipt owner")
+ end
+
+ it "allows payer party who is not a participant in the tenancy" do
+ external_party = create(:party, user: user, display_name: "Parent / Employer")
+ receipt.payer_party = external_party
+ expect(receipt).to be_valid
+ end
+ end
+
+ describe "normalizations" do
+ it "downcases and strips payment_method" do
+ r = create(:receipt, tenancy: tenancy, payer_party: payer_party, payment_method: " ZelLE ")
+ expect(r.payment_method).to eq("zelle")
+ end
+
+ it "strips external_reference and converts blank to nil" do
+ r = create(:receipt, tenancy: tenancy, payer_party: payer_party, external_reference: " REF123 ")
+ expect(r.external_reference).to eq("REF123")
+
+ r2 = create(:receipt, tenancy: tenancy, payer_party: payer_party, external_reference: " ")
+ expect(r2.external_reference).to be_nil
+ end
+ end
+
+ describe "external reference uniqueness" do
+ it "prevents duplicate active external references for the same user and method" do
+ create(:receipt,
+ user: user,
+ tenancy: tenancy,
+ payer_party: payer_party,
+ payment_method: "zelle",
+ external_reference: "TXN100"
+ )
+
+ dup = build(:receipt,
+ user: user,
+ tenancy: tenancy,
+ payer_party: payer_party,
+ payment_method: "zelle",
+ external_reference: "TXN100"
+ )
+
+ expect(dup).not_to be_valid
+ expect(dup.errors[:external_reference]).to include("has already been taken")
+ end
+
+ it "allows same external reference for different payment methods" do
+ create(:receipt,
+ user: user,
+ tenancy: tenancy,
+ payer_party: payer_party,
+ payment_method: "zelle",
+ external_reference: "TXN100"
+ )
+
+ diff_method = build(:receipt,
+ user: user,
+ tenancy: tenancy,
+ payer_party: payer_party,
+ payment_method: "venmo",
+ external_reference: "TXN100"
+ )
+
+ expect(diff_method).to be_valid
+ end
+
+ it "allows same external reference for different users" do
+ create(:receipt,
+ user: user,
+ tenancy: tenancy,
+ payer_party: payer_party,
+ payment_method: "zelle",
+ external_reference: "TXN100"
+ )
+
+ other_user = create(:user)
+ other_prop = create(:property, user: other_user)
+ other_unit = create(:rentable_unit, property: other_prop)
+ other_tenancy = create(:tenancy, rentable_unit: other_unit)
+ other_party = create(:party, user: other_user)
+
+ other_receipt = build(:receipt,
+ user: other_user,
+ tenancy: other_tenancy,
+ payer_party: other_party,
+ payment_method: "zelle",
+ external_reference: "TXN100"
+ )
+
+ expect(other_receipt).to be_valid
+ end
+
+ it "allows reusing external reference when prior receipt is voided" do
+ r1 = create(:receipt,
+ user: user,
+ tenancy: tenancy,
+ payer_party: payer_party,
+ payment_method: "zelle",
+ external_reference: "TXN100"
+ )
+ r1.update_columns(voided_at: Time.current)
+
+ r2 = build(:receipt,
+ user: user,
+ tenancy: tenancy,
+ payer_party: payer_party,
+ payment_method: "zelle",
+ external_reference: "TXN100"
+ )
+
+ expect(r2).to be_valid
+ end
+ end
+
+ describe "amount helpers and fractional cent validation" do
+ it "returns decimal amount from amount_cents" do
+ receipt.amount_cents = 125_050
+ expect(receipt.amount).to eq(BigDecimal("1250.50"))
+ end
+
+ it "accepts valid amount assignments" do
+ receipt.amount = "250.75"
+ expect(receipt.amount_cents).to eq(25_075)
+
+ receipt.amount = 300
+ expect(receipt.amount_cents).to eq(30_000)
+ end
+
+ it "rejects fractional cent assignments without silent rounding" do
+ receipt.amount = "100.005"
+ expect(receipt).not_to be_valid
+ expect(receipt.errors[:amount]).to include("cannot have fractional cents")
+ end
+
+ it "handles malformed amount strings gracefully" do
+ receipt.amount = "not-a-number"
+ expect(receipt).not_to be_valid
+ expect(receipt.errors[:amount]).to include("is not a valid number")
+ end
+ end
+
+ describe "lifecycle methods and immutability" do
+ let!(:posted_receipt) do
+ r = create(:receipt, tenancy: tenancy, payer_party: payer_party, amount_cents: 100_000)
+ r.update_columns(posted_at: Time.current)
+ r
+ end
+
+ it "correctly reports posted?, voided?, superseded?, active?" do
+ expect(receipt.posted?).to be false
+ expect(receipt.active?).to be true
+
+ expect(posted_receipt.posted?).to be true
+ expect(posted_receipt.voided?).to be false
+ expect(posted_receipt.active?).to be true
+
+ posted_receipt.update_columns(voided_at: Time.current)
+ expect(posted_receipt.voided?).to be true
+ expect(posted_receipt.active?).to be false
+ end
+
+ it "rejects changes to financial and metadata fields once posted" do
+ posted_receipt.amount_cents = 200_000
+ expect(posted_receipt).not_to be_valid
+ expect(posted_receipt.errors[:base]).to include("Posted receipts are immutable records")
+
+ posted_receipt.reload
+ posted_receipt.received_on = 1.week.ago.to_date
+ expect(posted_receipt).not_to be_valid
+
+ posted_receipt.reload
+ posted_receipt.payment_method = "cash"
+ expect(posted_receipt).not_to be_valid
+ end
+
+ it "rejects direct ActiveRecord updates to voided_at, posted_at, and superseded_by_id" do
+ posted_receipt.voided_at = Time.current
+ expect(posted_receipt).not_to be_valid
+ expect(posted_receipt.errors[:voided_at]).to include("cannot be modified directly; use Receipts::VoidService or Receipts::CorrectService")
+
+ posted_receipt.reload
+ posted_receipt.posted_at = nil
+ expect(posted_receipt).not_to be_valid
+ expect(posted_receipt.errors[:posted_at]).to include("cannot be modified directly once posted")
+
+ posted_receipt.reload
+ posted_receipt.superseded_by = create(:receipt, tenancy: tenancy, payer_party: payer_party)
+ expect(posted_receipt).not_to be_valid
+ expect(posted_receipt.errors[:superseded_by_id]).to include("cannot be modified directly; use Receipts::CorrectService")
+ end
+
+ it "prevents deletion of posted receipts" do
+ expect {
+ posted_receipt.destroy
+ }.not_to change(Receipt, :count)
+
+ expect(posted_receipt.errors[:base]).to include("Cannot delete a posted receipt")
+ end
+
+ it "validates that superseded_by belongs to the same user" do
+ other_user = create(:user)
+ other_party = create(:party, user: other_user)
+ other_prop = create(:property, user: other_user)
+ other_unit = create(:rentable_unit, property: other_prop)
+ other_tenancy = create(:tenancy, rentable_unit: other_unit)
+ other_receipt = create(:receipt, user: other_user, tenancy: other_tenancy, payer_party: other_party)
+
+ unposted_receipt = build(:receipt, user: user, tenancy: tenancy, payer_party: payer_party, superseded_by: other_receipt)
+ expect(unposted_receipt).not_to be_valid
+ expect(unposted_receipt.errors[:superseded_by]).to include("must belong to the same user")
+ end
+ end
+end
diff --git a/spec/models/tenancy_spec.rb b/spec/models/tenancy_spec.rb
index 1ac79305..4a04a25b 100644
--- a/spec/models/tenancy_spec.rb
+++ b/spec/models/tenancy_spec.rb
@@ -8,7 +8,7 @@
it { is_expected.to have_many(:parties).through(:tenancy_parties) }
it { is_expected.to have_many(:rent_terms).dependent(:destroy) }
it { is_expected.to have_many(:charges).dependent(:restrict_with_error) }
- it { is_expected.to have_many(:tenant_payments).dependent(:restrict_with_error) }
+ it { is_expected.to have_many(:receipts).dependent(:restrict_with_error) }
it { is_expected.to have_many(:accounting_postings).class_name("Posting").dependent(:restrict_with_error) }
it { is_expected.to have_many(:payment_ingestions).dependent(:nullify) }
end
@@ -222,7 +222,9 @@
end
describe "#financial_history? and current_balance" do
+ let(:party) { create(:party, user: unit.property.user) }
let(:tenancy) { create(:tenancy, rentable_unit: unit, commencement_date: Date.current.beginning_of_month) }
+ let!(:tenancy_party) { create(:tenancy_party, tenancy: tenancy, party: party, role: "tenant") }
let!(:rent_term) do
create(:rent_term,
tenancy: tenancy,
@@ -234,7 +236,13 @@
it "identifies presence of financial history" do
expect(tenancy.financial_history?).to be false
- TenantPayments::CreateService.call(tenancy: tenancy, amount: 1500.0, payment_date: Date.current)
+ Receipts::CreateService.call(
+ tenancy: tenancy,
+ payer_party: party,
+ amount: 1500.0,
+ received_on: Date.current,
+ payment_method: "other"
+ )
expect(tenancy.financial_history?).to be true
end
@@ -248,7 +256,13 @@
service_period_start: Date.current.beginning_of_month,
service_period_end: Date.current.end_of_month
)
- TenantPayments::CreateService.call(tenancy: tenancy, amount: 1000.0, payment_date: Date.current)
+ Receipts::CreateService.call(
+ tenancy: tenancy,
+ payer_party: party,
+ amount: 1000.0,
+ received_on: Date.current,
+ payment_method: "other"
+ )
# 1200 - 1000 = 200 owed
expect(tenancy.current_balance).to eq(200.0)
diff --git a/spec/models/tenant_payment_spec.rb b/spec/models/tenant_payment_spec.rb
deleted file mode 100644
index e2751bc2..00000000
--- a/spec/models/tenant_payment_spec.rb
+++ /dev/null
@@ -1,124 +0,0 @@
-require "rails_helper"
-
-RSpec.describe TenantPayment, type: :model do
- describe "associations" do
- it { is_expected.to belong_to(:tenancy) }
- it { is_expected.to belong_to(:user) }
- end
-
- describe "validations" do
- it { is_expected.to validate_presence_of(:amount) }
- it { is_expected.to validate_numericality_of(:amount).is_greater_than(0) }
- it { is_expected.to validate_presence_of(:payment_date) }
- it { is_expected.to validate_presence_of(:payment_method) }
-
- describe "transaction number validation" do
- it { is_expected.to allow_value("TXN-123_abc").for(:transaction_number) }
- it { is_expected.not_to allow_value("TXN 123!").for(:transaction_number).with_message("must be alphanumeric with dashes or underscores") }
- it { is_expected.to validate_length_of(:transaction_number).is_at_most(50) }
- end
-
- describe "uniqueness and user scoping" do
- let(:user_one) { create(:user) }
- let(:user_two) { create(:user) }
- let(:property_one) { create(:property, user: user_one) }
- let(:property_two) { create(:property, user: user_two) }
- let(:unit_one) { create(:rentable_unit, property: property_one) }
- let(:unit_two) { create(:rentable_unit, property: property_two) }
- let(:tenancy_one) { create(:tenancy, rentable_unit: unit_one) }
- let(:tenancy_two) { create(:tenancy, rentable_unit: unit_two) }
-
- it "assigns user from tenancy on validation" do
- tp = build(:tenant_payment, tenancy: tenancy_one, user: nil)
- expect(tp).to be_valid
- expect(tp.user).to eq(user_one)
- end
-
- it "validates that user matches tenancy owner" do
- tp = build(:tenant_payment, tenancy: tenancy_one, user: user_two)
- expect(tp).not_to be_valid
- expect(tp.errors[:user]).to include("must match the tenancy owner")
- end
-
- it "allows same transaction number for different users" do
- create(:tenant_payment, tenancy: tenancy_one, amount: 500, payment_method: "zelle", transaction_number: "SHARED123")
- payment = build(:tenant_payment, tenancy: tenancy_two, amount: 500, payment_method: "zelle", transaction_number: "SHARED123")
- expect(payment).to be_valid
- end
-
- it "rejects duplicate transaction number for same user and payment method" do
- create(:tenant_payment, tenancy: tenancy_one, amount: 500, payment_method: "zelle", transaction_number: "DUPLICATE123")
- payment = build(:tenant_payment, tenancy: tenancy_one, amount: 500, payment_method: "zelle", transaction_number: "DUPLICATE123")
- expect(payment).not_to be_valid
- expect(payment.errors[:transaction_number]).to include("has already been taken")
- end
-
- it "allows same transaction number for same user but different payment method" do
- create(:tenant_payment, tenancy: tenancy_one, amount: 500, payment_method: "zelle", transaction_number: "SHARED123")
- payment = build(:tenant_payment, tenancy: tenancy_one, amount: 500, payment_method: "check", transaction_number: "SHARED123")
- expect(payment).to be_valid
- end
-
- it "returns early from owner validation if user or tenancy is missing" do
- tp = TenantPayment.new(user: nil, tenancy: nil)
- tp.valid?
- expect(tp.errors[:user]).not_to include("must match the tenancy owner")
- end
-
- it "returns early from owner validation if user is present but tenancy is missing" do
- tp = TenantPayment.new(user: user_one, tenancy: nil)
- tp.valid?
- expect(tp.errors[:user]).not_to include("must match the tenancy owner")
- end
-
- it "handles assign_user_from_tenancy when tenancy has no property" do
- orphan_tenancy = build(:tenancy, rentable_unit: nil)
- tp = TenantPayment.new(tenancy: orphan_tenancy, user: nil)
- tp.valid?
- expect(tp.user).to be_nil
- end
- end
- end
-
- describe "#accounting_user" do
- let(:user) { create(:user) }
- let(:property) { create(:property, user: user) }
- let(:unit) { create(:rentable_unit, property: property) }
- let(:tenancy) { create(:tenancy, rentable_unit: unit) }
-
- it "returns the user attribute when present" do
- payment = build(:tenant_payment, tenancy: tenancy, user: user)
- expect(payment.accounting_user).to eq(user)
- end
-
- it "returns tenancy owner when user is nil" do
- payment = build(:tenant_payment, tenancy: tenancy, user: nil)
- expect(payment.accounting_user).to eq(user)
- end
-
- it "returns nil when user and tenancy are nil" do
- payment = build(:tenant_payment, tenancy: nil, user: nil)
- expect(payment.accounting_user).to be_nil
- end
- end
-
- describe "immutability" do
- let(:user) { create(:user) }
- let(:property) { create(:property, user: user) }
- let(:unit) { create(:rentable_unit, property: property) }
- let(:tenancy) { create(:tenancy, rentable_unit: unit) }
- let(:payment) { create(:tenant_payment, tenancy: tenancy, amount: 500) }
-
- it "prevents updating attributes on persisted payment" do
- payment.amount = 600
- expect(payment.save).to be(false)
- expect(payment.errors[:base]).to include("Tenant payments are immutable once recorded.")
- end
-
- it "prevents destroying persisted payment" do
- expect(payment.destroy).to be(false)
- expect(payment.errors[:base]).to include("Tenant payments cannot be destroyed once recorded.")
- expect(TenantPayment.exists?(payment.id)).to be(true)
- end
- end
-end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 2d98eb78..1bea0478 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -8,7 +8,7 @@
it { is_expected.to have_many(:tenancies).through(:rentable_units) }
it { is_expected.to have_many(:expenses).through(:properties) }
it { is_expected.to have_many(:charges).through(:tenancies) }
- it { is_expected.to have_many(:tenant_payments).through(:tenancies) }
+ it { is_expected.to have_many(:receipts).dependent(:restrict_with_error) }
it { is_expected.to have_many(:parties).dependent(:destroy) }
it { is_expected.to have_many(:payment_ingestions).dependent(:destroy) }
it { is_expected.to have_many(:payment_documents).dependent(:destroy) }
diff --git a/spec/queries/dashboards/property_summaries_query_spec.rb b/spec/queries/dashboards/property_summaries_query_spec.rb
index ad287cfd..79bae362 100644
--- a/spec/queries/dashboards/property_summaries_query_spec.rb
+++ b/spec/queries/dashboards/property_summaries_query_spec.rb
@@ -4,7 +4,9 @@
let(:user) { create(:user) }
let(:property) { create(:property, user: user) }
let(:unit) { create(:rentable_unit, property: property) }
+ let(:party) { create(:party, user: user) }
let(:tenancy) { create(:tenancy, rentable_unit: unit, commencement_date: Date.current.beginning_of_month, termination_date: Date.current.end_of_month) }
+ let!(:tenancy_party) { create(:tenancy_party, tenancy: tenancy, party: party, role: "tenant") }
let!(:rent_term) do
create(:rent_term,
tenancy: tenancy,
@@ -15,7 +17,13 @@
end
it "returns property financial summaries and active tenancy balances" do
- TenantPayments::CreateService.call(tenancy: tenancy, amount: 1200, payment_date: Date.current)
+ Receipts::CreateService.call(
+ tenancy: tenancy,
+ payer_party: party,
+ amount: 1200,
+ received_on: Date.current,
+ payment_method: "other"
+ )
create(:expense, property: property, amount: 200, expense_date: Date.current)
Charges::CreateService.call(
tenancy: tenancy,
diff --git a/spec/queries/properties/active_years_query_spec.rb b/spec/queries/properties/active_years_query_spec.rb
index 79b93f8f..21a05258 100644
--- a/spec/queries/properties/active_years_query_spec.rb
+++ b/spec/queries/properties/active_years_query_spec.rb
@@ -8,7 +8,7 @@
it "returns current, activity, and valid additional years" do
create(:charge, :other_charge, tenancy: tenancy, charge_date: Date.new(2026, 4, 1))
- create(:tenant_payment, tenancy: tenancy, payment_date: Date.new(2025, 4, 1))
+ create(:receipt, tenancy: tenancy, received_on: Date.new(2025, 4, 1))
create(:expense, property: property, expense_date: Date.new(2024, 4, 1))
create(:charge, :late_fee_charge, tenancy: tenancy, charge_date: Date.new(2023, 4, 1))
diff --git a/spec/queries/properties/financial_items_query_spec.rb b/spec/queries/properties/financial_items_query_spec.rb
index 94624f66..26598677 100644
--- a/spec/queries/properties/financial_items_query_spec.rb
+++ b/spec/queries/properties/financial_items_query_spec.rb
@@ -8,14 +8,14 @@
it "returns all yearly financial item types sorted by date" do
charge = create(:charge, :other_charge, tenancy: tenancy, charge_date: Date.new(2026, 5, 1), amount_cents: 100_000)
- tenant_payment = create(:tenant_payment, tenancy: tenancy, payment_date: Date.new(2026, 5, 5), amount: 1000)
+ receipt = create(:receipt, tenancy: tenancy, received_on: Date.new(2026, 5, 5), amount_cents: 100_000)
expense = create(:expense, property: property, expense_date: Date.new(2026, 5, 10), amount: 50)
reimburse_charge = create(:charge, :reimbursement_charge, tenancy: tenancy, source_expense: expense, charge_date: Date.new(2026, 5, 10), amount_cents: 5000)
create(:expense, property: property, expense_date: Date.new(2025, 5, 10), amount: 30)
items = described_class.new(property: property).call(year: 2026)
- expect(items.map { |item| item[:object] }).to eq([ charge, tenant_payment, reimburse_charge, expense ])
- expect(items.map { |item| item[:type] }).to eq([ "Charge", "Tenant Payment", "Charge", "Expense" ])
+ expect(items.map { |item| item[:object] }).to eq([ charge, receipt, reimburse_charge, expense ])
+ expect(items.map { |item| item[:type] }).to eq([ "Charge", "Payment", "Charge", "Expense" ])
end
end
diff --git a/spec/queries/properties/schedule_e_summary_query_spec.rb b/spec/queries/properties/schedule_e_summary_query_spec.rb
index 2438e9a2..c112d99b 100644
--- a/spec/queries/properties/schedule_e_summary_query_spec.rb
+++ b/spec/queries/properties/schedule_e_summary_query_spec.rb
@@ -7,7 +7,7 @@
let(:tenancy) { create(:tenancy, rentable_unit: unit) }
it "computes Schedule E summary values for a year" do
- create(:tenant_payment, tenancy: tenancy, payment_date: Date.new(2026, 1, 1), amount: 1200)
+ create(:receipt, tenancy: tenancy, received_on: Date.new(2026, 1, 1), amount_cents: 120_000)
create(:expense, property: property, expense_date: Date.new(2026, 1, 2), category: "repairs", amount: 200)
create(:expense, property: property, expense_date: Date.new(2026, 1, 3), category: "utilities", amount: 100)
create(:expense, property: property, expense_date: Date.new(2025, 1, 3), category: "utilities", amount: 999)
diff --git a/spec/queries/tenancies/balance_query_spec.rb b/spec/queries/tenancies/balance_query_spec.rb
index 3f48cdc0..729a3f1f 100644
--- a/spec/queries/tenancies/balance_query_spec.rb
+++ b/spec/queries/tenancies/balance_query_spec.rb
@@ -4,6 +4,7 @@
let(:user) { create(:user) }
let(:property) { create(:property, user: user) }
let(:unit) { create(:rentable_unit, property: property) }
+ let(:party) { create(:party, user: user) }
let(:tenancy) do
create(:tenancy,
rentable_unit: unit,
@@ -11,6 +12,7 @@
termination_date: Date.new(2026, 12, 31)
)
end
+ let!(:tenancy_party) { create(:tenancy_party, tenancy: tenancy, party: party, role: "tenant") }
let!(:rent_term) do
create(:rent_term,
tenancy: tenancy,
@@ -53,10 +55,12 @@
service_period_end: Date.new(2026, 1, 31)
)
- TenantPayments::CreateService.call(
+ Receipts::CreateService.call(
tenancy: tenancy,
+ payer_party: party,
amount_cents: 150_000,
- payment_date: Date.new(2026, 1, 5)
+ received_on: Date.new(2026, 1, 5),
+ payment_method: "other"
)
query = described_class.new(tenancy: tenancy)
@@ -65,10 +69,12 @@
end
it "reflects negative balance on overpayment / credit balance" do
- TenantPayments::CreateService.call(
+ Receipts::CreateService.call(
tenancy: tenancy,
+ payer_party: party,
amount_cents: 50_000,
- payment_date: Date.new(2026, 1, 5)
+ received_on: Date.new(2026, 1, 5),
+ payment_method: "other"
)
query = described_class.new(tenancy: tenancy)
@@ -116,5 +122,17 @@
Charges::VoidService.call(charge: charge, occurred_on: Date.new(2026, 1, 12))
expect(described_class.call(tenancy: tenancy)).to eq(0)
end
+
+ it "supports current_balance and as_of in class call" do
+ query = described_class.new(tenancy: tenancy)
+ expect(query.current_balance).to eq(0)
+ expect(described_class.call(tenancy: tenancy, as_of: Date.yesterday)).to eq(0)
+ end
+
+ it "returns 0 if tenant_receivable account is missing" do
+ allow(tenancy).to receive(:accounting_user).and_return(nil)
+ query = described_class.new(tenancy: tenancy)
+ expect(query.balance_cents_as_of).to eq(0)
+ end
end
end
diff --git a/spec/requests/charges_spec.rb b/spec/requests/charges_spec.rb
index 1e943b35..50e51b67 100644
--- a/spec/requests/charges_spec.rb
+++ b/spec/requests/charges_spec.rb
@@ -169,6 +169,24 @@
expect(charge.reload).to be_voided
end
+ it "voids the charge via json" do
+ post void_charge_path(charge, format: :json), params: { reason: "Customer dispute" }
+ expect(response).to be_successful
+ expect(response.parsed_body["status"]).to eq("ok")
+ end
+
+ it "handles void failure gracefully" do
+ allow(Charges::VoidService).to receive(:call).and_return(
+ ServiceResult.failure(error: "Void failed", code: :void_failed)
+ )
+ post void_charge_path(charge)
+ expect(response).to redirect_to(tenancy_path(tenancy))
+ expect(flash[:alert]).to include("Failed to void charge")
+
+ post void_charge_path(charge, format: :json)
+ expect(response).to have_http_status(:unprocessable_content)
+ end
+
it "rejects voiding another user's charge" do
other_charge = Charges::CreateFeeService.call(
tenancy: other_tenancy,
diff --git a/spec/requests/payment_documents_spec.rb b/spec/requests/payment_documents_spec.rb
index 2d100947..d73582b4 100644
--- a/spec/requests/payment_documents_spec.rb
+++ b/spec/requests/payment_documents_spec.rb
@@ -15,5 +15,17 @@
expect(response).to redirect_to(payment_ingestions_path)
end
+
+ it "refuses to destroy document with confirmed ingestions and sets alert" do
+ document = create(:payment_document, user: user)
+ create(:payment_ingestion, user: user, payment_document: document, status: :confirmed)
+
+ expect {
+ delete payment_document_url(document)
+ }.not_to change(PaymentDocument, :count)
+
+ expect(response).to redirect_to(payment_ingestions_path)
+ expect(flash[:alert]).to include("Cannot delete document with confirmed payment ingestions")
+ end
end
end
diff --git a/spec/requests/payment_ingestions_spec.rb b/spec/requests/payment_ingestions_spec.rb
index 735028c3..323736c8 100644
--- a/spec/requests/payment_ingestions_spec.rb
+++ b/spec/requests/payment_ingestions_spec.rb
@@ -267,6 +267,17 @@
}
expect(response).to redirect_to(payment_ingestion_url(ingestion))
end
+ it "rejects updating a confirmed payment ingestion" do
+ ingestion.update_columns(status: "confirmed")
+
+ patch payment_ingestion_url(ingestion), params: {
+ payment_ingestion: {
+ amount: 2000.0
+ }
+ }
+ expect(response).to have_http_status(:unprocessable_content)
+ expect(ingestion.reload.amount).to eq(1300.0)
+ end
end
describe "GET /payment_ingestions/:id/download" do
@@ -288,12 +299,38 @@
it "confirms payment ingestion" do
expect {
post confirm_payment_ingestion_url(ingestion), params: { create_alias: "0" }
- }.to change(TenantPayment, :count).by(1)
+ }.to change(Receipt, :count).by(1)
expect(response).to redirect_to(payment_ingestions_url)
expect(ingestion.reload.status).to eq("confirmed")
end
+ it "allows updating and confirming ingestion with a non-tenancy-member payer party" do
+ third_party_payer = create(:party, user: user, display_name: "ACME Corp")
+ # Note: third_party_payer is NOT a TenancyParty on tenancy
+
+ patch payment_ingestion_url(ingestion), params: {
+ payment_ingestion: {
+ party_id: third_party_payer.id,
+ tenancy_id: tenancy.id,
+ amount: 1500.0,
+ payment_date: Date.current,
+ payment_method: "zelle"
+ }
+ }
+ expect(response).to redirect_to(payment_ingestion_url(ingestion))
+ expect(ingestion.reload.party).to eq(third_party_payer)
+
+ expect {
+ post confirm_payment_ingestion_url(ingestion), params: { create_alias: "0" }
+ }.to change(Receipt, :count).by(1)
+
+ receipt = Receipt.last
+ expect(receipt.payer_party).to eq(third_party_payer)
+ expect(receipt.tenancy).to eq(tenancy)
+ expect(receipt.amount_cents).to eq(150_000)
+ end
+
it "handles ConfirmationError during confirm" do
allow(PaymentIngestions::ConfirmService).to receive(:call).and_return(
ServiceResult.failure(error: "custom confirmation error", code: :confirmation_error)
@@ -319,6 +356,17 @@
expect(response).to redirect_to(payment_ingestions_url)
end
+
+ it "rejects deleting a confirmed payment ingestion" do
+ ingestion.update_columns(status: "confirmed")
+
+ expect {
+ delete payment_ingestion_url(ingestion)
+ }.not_to change(PaymentIngestion, :count)
+
+ expect(response).to redirect_to(payment_ingestion_path(ingestion))
+ expect(flash[:alert]).to include("Cannot delete a confirmed payment ingestion")
+ end
end
describe "pagination" do
diff --git a/spec/requests/property_lifecycle_spec.rb b/spec/requests/property_lifecycle_spec.rb
index 2feb4c82..9d2da89e 100644
--- a/spec/requests/property_lifecycle_spec.rb
+++ b/spec/requests/property_lifecycle_spec.rb
@@ -43,20 +43,21 @@
tenancy = Tenancy.last
expect(response).to redirect_to(tenancy_url(tenancy))
- # 3. Record a Tenant Payment for the tenancy
+ # 3. Record a Payment for the tenancy
expect {
- post tenant_payments_url, params: {
- tenant_payment: {
+ post receipts_url, params: {
+ receipt: {
tenancy_id: tenancy.id,
+ payer_party_id: party.id,
amount: 2000,
- payment_date: Date.new(2025, 1, 1),
+ received_on: Date.new(2025, 1, 1),
payment_method: "check"
}
}
- }.to change(TenantPayment, :count).by(1)
+ }.to change(Receipt, :count).by(1)
- payment = TenantPayment.last
- expect(response).to redirect_to(tenant_payment_url(payment))
- expect(payment.tenancy).to eq(tenancy)
+ receipt = Receipt.last
+ expect(response).to redirect_to(receipt_url(receipt))
+ expect(receipt.tenancy).to eq(tenancy)
end
end
diff --git a/spec/requests/receipts_spec.rb b/spec/requests/receipts_spec.rb
new file mode 100644
index 00000000..e45c2981
--- /dev/null
+++ b/spec/requests/receipts_spec.rb
@@ -0,0 +1,393 @@
+require "rails_helper"
+
+RSpec.describe "Receipts", type: :request do
+ let(:user) { create(:user) }
+ let(:other_user) { create(:user) }
+ let(:property) { create(:property, user: user) }
+ let(:unit) { create(:rentable_unit, property: property) }
+ let(:tenancy) { create(:tenancy, rentable_unit: unit, commencement_date: Date.new(2026, 1, 1)) }
+ let(:party) { create(:party, user: user, display_name: "Alice Walker") }
+ let!(:tenancy_party) { create(:tenancy_party, tenancy: tenancy, party: party, role: "tenant") }
+
+ before do
+ sign_in_as(user)
+ end
+
+ describe "GET /receipts" do
+ it "renders a successful list of user's receipts" do
+ create(:receipt, tenancy: tenancy, payer_party: party, user: user, amount_cents: 100_000)
+ get receipts_url
+ expect(response).to be_successful
+ expect(response.body).to include("Payments & Receipts")
+ expect(response.body).to include("Alice Walker")
+ end
+
+ it "does not display other user's receipts" do
+ other_party = create(:party, user: other_user, display_name: "Other Payer")
+ other_property = create(:property, user: other_user)
+ other_unit = create(:rentable_unit, property: other_property)
+ other_tenancy = create(:tenancy, rentable_unit: other_unit)
+ create(:receipt, tenancy: other_tenancy, payer_party: other_party, user: other_user)
+
+ get receipts_url
+ expect(response).to be_successful
+ expect(response.body).not_to include("Other Payer")
+ end
+ end
+
+ describe "GET /receipts/:id" do
+ let!(:receipt) do
+ res = Receipts::CreateService.call(
+ tenancy: tenancy,
+ payer_party: party,
+ amount_cents: 120_000,
+ received_on: Date.new(2026, 1, 10),
+ payment_method: "zelle",
+ external_reference: "ZEL123",
+ memo: "January Rent"
+ )
+ res.value!.data[:receipt]
+ end
+
+ it "renders HTML details" do
+ get receipt_url(receipt)
+ expect(response).to be_successful
+ expect(response.body).to include("Payment Details")
+ expect(response.body).to include("$1,200.00")
+ expect(response.body).to include("Alice Walker")
+ expect(response.body).to include("ZEL123")
+ end
+
+ it "renders PDF receipt" do
+ get receipt_url(receipt, format: :pdf)
+ expect(response).to be_successful
+ expect(response.content_type).to eq("application/pdf")
+ expect(response.body).to start_with("%PDF-")
+ end
+
+ it "returns 404 for other user's receipt" do
+ other_property = create(:property, user: other_user)
+ other_unit = create(:rentable_unit, property: other_property)
+ other_tenancy = create(:tenancy, rentable_unit: other_unit)
+ other_payer = create(:party, user: other_user)
+ other_receipt = create(:receipt, user: other_user, tenancy: other_tenancy, payer_party: other_payer)
+
+ get receipt_url(other_receipt)
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+
+ describe "GET /receipts/new and GET /tenancies/:id/receipts/new" do
+ it "renders top-level new receipt page" do
+ get new_receipt_url
+ expect(response).to be_successful
+ expect(response.body).to include("Record Payment")
+ end
+
+ it "renders nested new receipt page preselecting single active tenant" do
+ get new_tenancy_receipt_url(tenancy)
+ expect(response).to be_successful
+ expect(response.body).to include("Alice Walker")
+ expect(response.body).to include(tenancy.property.address)
+ end
+ end
+
+ describe "POST /receipts and POST /tenancies/:id/receipts" do
+ it "creates receipt via top-level route" do
+ expect {
+ post receipts_url, params: {
+ receipt: {
+ tenancy_id: tenancy.id,
+ payer_party_id: party.id,
+ amount: "1500.00",
+ received_on: "2026-02-01",
+ payment_method: "check",
+ external_reference: "CHK99",
+ memo: "Feb Rent"
+ }
+ }
+ }.to change(Receipt, :count).by(1)
+ .and change(JournalEntry, :count).by(1)
+
+ created = Receipt.last
+ expect(response).to redirect_to(receipt_path(created))
+ expect(flash[:notice]).to eq("Payment recorded successfully.")
+ expect(created.amount_cents).to eq(150_000)
+ end
+
+ it "creates receipt via nested tenancy route" do
+ expect {
+ post tenancy_receipts_url(tenancy), params: {
+ receipt: {
+ payer_party_id: party.id,
+ amount: "1200.00",
+ received_on: "2026-02-01",
+ payment_method: "zelle"
+ }
+ }
+ }.to change(Receipt, :count).by(1)
+
+ expect(response).to redirect_to(receipt_path(Receipt.last))
+ end
+
+ it "rejects nested creation with mismatched tenancy_id in body" do
+ other_unit = create(:rentable_unit, property: property)
+ other_tenancy = create(:tenancy, rentable_unit: other_unit)
+
+ expect {
+ post tenancy_receipts_url(tenancy), params: {
+ receipt: {
+ tenancy_id: other_tenancy.id,
+ payer_party_id: party.id,
+ amount: "1200.00",
+ received_on: "2026-02-01",
+ payment_method: "zelle"
+ }
+ }
+ }.not_to change(Receipt, :count)
+
+ expect(response).to have_http_status(:unprocessable_content)
+ expect(response.body).to include("Submitted tenancy does not match route tenancy")
+ end
+
+ it "returns 404 and creates no receipt when nested route tenancy does not exist" do
+ expect {
+ post "/tenancies/999999/receipts", params: {
+ receipt: {
+ tenancy_id: tenancy.id,
+ payer_party_id: party.id,
+ amount: "1200.00",
+ received_on: "2026-02-01",
+ payment_method: "zelle"
+ }
+ }
+ }.not_to change(Receipt, :count)
+
+ expect(response).to have_http_status(:not_found)
+ end
+
+ it "returns 404 and creates no receipt when nested route tenancy belongs to another user" do
+ other_prop = create(:property, user: other_user)
+ other_unit = create(:rentable_unit, property: other_prop)
+ other_tenancy = create(:tenancy, rentable_unit: other_unit)
+
+ expect {
+ post tenancy_receipts_url(other_tenancy), params: {
+ receipt: {
+ tenancy_id: tenancy.id,
+ payer_party_id: party.id,
+ amount: "1200.00",
+ received_on: "2026-02-01",
+ payment_method: "zelle"
+ }
+ }
+ }.not_to change(Receipt, :count)
+
+ expect(response).to have_http_status(:not_found)
+ end
+
+ it "creates receipt via turbo_stream format" do
+ post tenancy_receipts_url(tenancy, format: :turbo_stream), params: {
+ receipt: {
+ payer_party_id: party.id,
+ amount: "1200.00",
+ received_on: "2026-02-01",
+ payment_method: "zelle"
+ }
+ }
+ expect(response).to be_successful
+ expect(response.media_type).to eq("text/vnd.turbo-stream.html")
+ end
+
+ it "renders turbo_stream form on error" do
+ post tenancy_receipts_url(tenancy, format: :turbo_stream), params: {
+ receipt: {
+ payer_party_id: party.id,
+ amount: "-50.00",
+ received_on: "2026-02-01",
+ payment_method: "zelle"
+ }
+ }
+ expect(response).to have_http_status(:unprocessable_content)
+ expect(response.media_type).to eq("text/vnd.turbo-stream.html")
+ end
+
+ it "renders unprocessable_content on invalid parameters" do
+ expect {
+ post receipts_url, params: {
+ receipt: {
+ tenancy_id: tenancy.id,
+ payer_party_id: party.id,
+ amount: "0",
+ received_on: "2026-02-01",
+ payment_method: "zelle"
+ }
+ }
+ }.not_to change(Receipt, :count)
+
+ expect(response).to have_http_status(:unprocessable_content)
+ expect(response.body).to include("Amount must be greater than 0")
+ end
+
+ it "rejects create with unresolvable or foreign payer_party_id" do
+ other_user_party = create(:party, user: other_user)
+ post receipts_url, params: {
+ receipt: {
+ tenancy_id: tenancy.id,
+ payer_party_id: other_user_party.id,
+ amount: "100.00",
+ received_on: "2026-02-01",
+ payment_method: "zelle"
+ }
+ }
+ expect(response).to have_http_status(:unprocessable_content)
+ expect(response.body).to include("Payer party was not found")
+ end
+
+ it "rejects create with unresolvable tenancy_id" do
+ post receipts_url, params: {
+ receipt: {
+ tenancy_id: 999_999,
+ payer_party_id: party.id,
+ amount: "100.00",
+ received_on: "2026-02-01",
+ payment_method: "zelle"
+ }
+ }
+ expect(response).to have_http_status(:unprocessable_content)
+ expect(response.body).to include("Tenancy was not found")
+ end
+ end
+
+ describe "GET /receipts/:id/correction and POST /receipts/:id/correct" do
+ let!(:receipt) do
+ res = Receipts::CreateService.call(
+ tenancy: tenancy,
+ payer_party: party,
+ amount_cents: 200_000,
+ received_on: Date.new(2026, 1, 10),
+ payment_method: "zelle",
+ external_reference: "ZEL100"
+ )
+ res.value!.data[:receipt]
+ end
+
+ it "renders the correction form" do
+ get correction_receipt_url(receipt)
+ expect(response).to be_successful
+ expect(response.body).to include("Correct Payment")
+ expect(response.body).to include("ZEL100")
+ end
+
+ it "submits a correction and replaces the receipt" do
+ expect {
+ post correct_receipt_url(receipt), params: {
+ receipt: {
+ amount: "2100.00",
+ received_on: "2026-01-10",
+ payment_method: "zelle",
+ external_reference: "ZEL100"
+ }
+ }
+ }.to change(Receipt, :count).by(1)
+
+ replacement = Receipt.last
+ expect(response).to redirect_to(receipt_path(replacement))
+ expect(flash[:notice]).to include("Payment corrected successfully")
+ expect(receipt.reload.voided?).to be true
+ expect(receipt.superseded_by).to eq(replacement)
+ end
+
+ it "is idempotent on repeated identical correction POST submissions" do
+ # First submission
+ post correct_receipt_url(receipt), params: {
+ receipt: {
+ amount: "2100.00",
+ received_on: "2026-01-10",
+ payment_method: "zelle",
+ external_reference: "ZEL100"
+ }
+ }
+ replacement = Receipt.last
+ expect(response).to redirect_to(receipt_path(replacement))
+
+ # Second identical submission (e.g. user double-clicked or refreshed)
+ expect {
+ post correct_receipt_url(receipt), params: {
+ receipt: {
+ amount: "2100.00",
+ received_on: "2026-01-10",
+ payment_method: "zelle",
+ external_reference: "ZEL100"
+ }
+ }
+ }.not_to change(Receipt, :count)
+
+ expect(response).to redirect_to(receipt_path(replacement))
+ end
+
+ it "rejects correction with unresolvable or foreign payer_party_id" do
+ other_user_party = create(:party, user: other_user)
+ post correct_receipt_url(receipt), params: {
+ receipt: {
+ payer_party_id: other_user_party.id,
+ amount: "2100.00"
+ }
+ }
+ expect(response).to have_http_status(:unprocessable_content)
+ expect(response.body).to include("Payer party was not found")
+ end
+
+ it "rejects correction with unresolvable tenancy_id" do
+ post correct_receipt_url(receipt), params: {
+ receipt: {
+ tenancy_id: 999_999,
+ amount: "2100.00"
+ }
+ }
+ expect(response).to have_http_status(:unprocessable_content)
+ expect(response.body).to include("Tenancy was not found")
+ end
+
+ it "renders unprocessable_content on invalid correction" do
+ post correct_receipt_url(receipt), params: {
+ receipt: {
+ amount: "-100.00",
+ received_on: "2026-01-10",
+ payment_method: "zelle"
+ }
+ }
+ expect(response).to have_http_status(:unprocessable_content)
+ expect(response.body).to include("Amount must be greater than 0")
+ end
+ end
+
+ describe "POST /receipts/:id/void" do
+ let!(:receipt) do
+ res = Receipts::CreateService.call(
+ tenancy: tenancy,
+ payer_party: party,
+ amount_cents: 100_000,
+ received_on: Date.new(2026, 1, 10),
+ payment_method: "zelle"
+ )
+ res.value!.data[:receipt]
+ end
+
+ it "voids the receipt" do
+ post void_receipt_url(receipt), params: { reason: "Mistaken entry" }
+ expect(response).to redirect_to(receipt_path(receipt))
+ expect(flash[:notice]).to include("Payment has been voided")
+ expect(receipt.reload.voided?).to be true
+ end
+
+ it "handles void failure gracefully" do
+ allow(Receipts::VoidService).to receive(:call).and_return(
+ ServiceResult.failure(error: "Cannot void locked receipt", code: :void_failed)
+ )
+ post void_receipt_url(receipt)
+ expect(response).to redirect_to(receipt_path(receipt))
+ expect(flash[:alert]).to eq("Cannot void locked receipt")
+ end
+ end
+end
diff --git a/spec/requests/tenancies_spec.rb b/spec/requests/tenancies_spec.rb
index 3da4a344..f30518d6 100644
--- a/spec/requests/tenancies_spec.rb
+++ b/spec/requests/tenancies_spec.rb
@@ -248,7 +248,7 @@
end
it "prevents deleting a tenancy with financial history (HTML & JSON)" do
- create(:tenant_payment, tenancy: tenancy, amount: 1500.0, payment_date: Date.current)
+ create(:receipt, tenancy: tenancy, amount_cents: 150_000, received_on: Date.current)
expect {
delete tenancy_url(tenancy)
@@ -256,7 +256,7 @@
expect(response).to redirect_to(tenancy_url(tenancy))
follow_redirect!
- expect(response.body).to include("Cannot delete record because dependent tenant payments exist")
+ expect(response.body).to include("Cannot delete record because dependent receipts exist")
delete tenancy_url(tenancy, format: :json)
expect(response).to have_http_status(:unprocessable_content)
diff --git a/spec/requests/tenant_payments_spec.rb b/spec/requests/tenant_payments_spec.rb
deleted file mode 100644
index c8729a50..00000000
--- a/spec/requests/tenant_payments_spec.rb
+++ /dev/null
@@ -1,146 +0,0 @@
-require "rails_helper"
-
-RSpec.describe "TenantPayments", type: :request do
- let(:user) { create(:user) }
- let(:other_user) { create(:user) }
- let(:property) { create(:property, user: user) }
- let(:other_property) { create(:property, user: other_user) }
- let(:unit) { create(:rentable_unit, property: property) }
- let(:other_unit) { create(:rentable_unit, property: other_property) }
- let(:tenancy) { create(:tenancy, rentable_unit: unit) }
- let(:other_tenancy) { create(:tenancy, rentable_unit: other_unit) }
- let!(:tenant_payment) { create(:tenant_payment, tenancy: tenancy) }
-
- before do
- sign_in_as(user)
- end
-
- describe "GET /tenant_payments" do
- it "renders a successful response" do
- get tenant_payments_url
- expect(response).to be_successful
- end
- end
-
- describe "GET /tenant_payments/new" do
- it "filters tenancies to only include the current user's" do
- other_party = create(:party, user: other_user, display_name: "Other Tenant")
- create(:tenancy_party, tenancy: other_tenancy, party: other_party, role: "tenant", effective_from: Date.current)
-
- get new_tenant_payment_url
- expect(response).to be_successful
- expect(response.body).not_to include(other_property.address)
- expect(response.body).not_to include("Other Tenant")
- end
-
- it "defaults payment amount to balance when balance is positive (tenant owes money)" do
- allow_any_instance_of(Tenancy).to receive(:current_balance).and_return(600)
- get new_tenant_payment_url, params: { tenancy_id: tenancy.id }
- expect(response).to be_successful
- expect(response.body).to include('value="600"')
- end
-
- it "defaults payment amount to 0 when balance is negative (credit) or zero" do
- allow_any_instance_of(Tenancy).to receive(:current_balance).and_return(-200)
- get new_tenant_payment_url, params: { tenancy_id: tenancy.id }
- expect(response).to be_successful
- expect(response.body).to include('value="0.0"')
- end
- end
-
- describe "POST /tenant_payments" do
- it "creates a new TenantPayment" do
- expect {
- post tenant_payments_url, params: { tenant_payment: { tenancy_id: tenancy.id, amount: 500, payment_date: Date.today, payment_method: "Zelle", transaction_number: "TXNTEST123" } }
- }.to change(TenantPayment, :count).by(1)
-
- expect(response).to redirect_to(tenant_payment_url(TenantPayment.last))
- end
-
- it "should not create tenant payment with other user's tenancy" do
- expect {
- post tenant_payments_url, params: { tenant_payment: { tenancy_id: other_tenancy.id, amount: 500, payment_date: Date.today, payment_method: "Zelle", transaction_number: "TXNTEST456" } }
- }.not_to change(TenantPayment, :count)
-
- expect(response).to have_http_status(:not_found)
- end
-
- it "skips tenancy lookup if tenancy_id is not present" do
- expect {
- post tenant_payments_url, params: { tenant_payment: { tenancy_id: "", amount: 500, payment_date: Date.today, payment_method: "Zelle" } }
- }.not_to change(TenantPayment, :count)
-
- expect(response).to have_http_status(:unprocessable_content)
- end
-
- it "handles modal-submit success with turbo_stream" do
- expect {
- post tenant_payments_url, params: {
- tenancy_id: tenancy.id,
- tenant_payment: { tenancy_id: tenancy.id, amount: 500, payment_date: Date.today, payment_method: "Zelle" }
- }, as: :turbo_stream
- }.to change(TenantPayment, :count).by(1)
-
- expect(response).to have_http_status(:ok)
- end
-
- it "handles modal-submit success with turbo_stream and blank payment_date" do
- expect {
- post tenant_payments_url, params: {
- tenancy_id: tenancy.id,
- tenant_payment: { tenancy_id: tenancy.id, amount: 500, payment_date: "", payment_method: "Zelle" }
- }, as: :turbo_stream
- }.not_to change(TenantPayment, :count)
-
- expect(response).to have_http_status(:ok)
- end
-
- it "handles modal-submit success with turbo_stream when payment_date is nil but save succeeds (triggers fallback year)" do
- tp_instance = TenantPayment.new(tenancy: tenancy, amount: 500, payment_method: "Zelle")
- allow(tp_instance).to receive(:save).and_return(true)
- allow(tp_instance).to receive(:payment_date).and_return(nil)
- allow(TenantPayment).to receive(:new).and_return(tp_instance)
-
- post tenant_payments_url, params: {
- tenancy_id: tenancy.id,
- tenant_payment: { tenancy_id: tenancy.id, amount: 500, payment_method: "Zelle" }
- }, as: :turbo_stream
-
- expect(response).to have_http_status(:ok)
- expect(response.body).to include("turbo-stream")
- end
- end
-
- describe "GET /tenant_payments/:id" do
- it "renders a successful HTML response" do
- get tenant_payment_url(tenant_payment)
- expect(response).to be_successful
- end
-
- it "renders a successful PDF response" do
- get tenant_payment_url(tenant_payment, format: :pdf)
- expect(response).to be_successful
- expect(response.content_type).to eq("application/pdf")
- end
-
- it "renders a successful PDF response when transaction number is missing" do
- tp_no_txn = create(:tenant_payment, tenancy: tenancy, transaction_number: nil)
- get tenant_payment_url(tp_no_txn, format: :pdf)
- expect(response).to be_successful
- expect(response.content_type).to eq("application/pdf")
- end
- end
-
- describe "disallowed routes" do
- it "does not route edit, update, or destroy" do
- get "/tenant_payments/#{tenant_payment.id}/edit"
- expect(response).to have_http_status(:not_found)
-
- patch "/tenant_payments/#{tenant_payment.id}", params: { tenant_payment: { amount: 600 } }
- expect(response).to have_http_status(:not_found)
-
- delete "/tenant_payments/#{tenant_payment.id}"
- expect(response).to have_http_status(:not_found)
- end
- end
-end
diff --git a/spec/services/charges/void_service_spec.rb b/spec/services/charges/void_service_spec.rb
index 003236c9..1565f693 100644
--- a/spec/services/charges/void_service_spec.rb
+++ b/spec/services/charges/void_service_spec.rb
@@ -73,5 +73,27 @@
reversal = result.value!.data[:journal_entry]
expect(reversal.occurred_on).to eq(Date.new(2026, 5, 10))
end
+
+ it "returns failure for unpersisted charge" do
+ result = described_class.call(charge: Charge.new)
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:invalid_source)
+ end
+
+ it "returns failure if journal entry is missing" do
+ allow(charge).to receive_message_chain(:journal_entries, :find_by).and_return(nil)
+ result = described_class.call(charge: charge)
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:not_found)
+ end
+
+ it "handles reverse service failure" do
+ allow(Accounting::ReverseEntryService).to receive(:call).and_return(
+ ServiceResult.failure(error: "Cannot reverse", code: :reverse_failed)
+ )
+ result = described_class.call(charge: charge)
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:reverse_failed)
+ end
end
end
diff --git a/spec/services/payment_documents/destroy_service_spec.rb b/spec/services/payment_documents/destroy_service_spec.rb
new file mode 100644
index 00000000..f0f5bb77
--- /dev/null
+++ b/spec/services/payment_documents/destroy_service_spec.rb
@@ -0,0 +1,36 @@
+require "rails_helper"
+
+RSpec.describe PaymentDocuments::DestroyService do
+ let(:user) { create(:user) }
+
+ it "destroys a document without confirmed ingestions" do
+ doc = create(:payment_document, user: user)
+ create(:payment_ingestion, user: user, payment_document: doc, status: :matched)
+
+ expect {
+ result = described_class.call(user: user, document: doc)
+ expect(result).to be_success
+ }.to change(PaymentDocument, :count).by(-1).and change(PaymentIngestion, :count).by(-1)
+ end
+
+ it "rejects destroying a document with confirmed ingestions" do
+ doc = create(:payment_document, user: user)
+ create(:payment_ingestion, user: user, payment_document: doc, status: :confirmed)
+
+ expect {
+ result = described_class.call(user: user, document: doc)
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:immutable)
+ expect(result.failure.error).to eq("Cannot delete document with confirmed payment ingestions")
+ }.not_to change(PaymentDocument, :count)
+ end
+
+ it "returns failure when document belongs to another user" do
+ other_user = create(:user)
+ doc = create(:payment_document, user: other_user)
+
+ result = described_class.call(user: user, document: doc)
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:not_found)
+ end
+end
diff --git a/spec/services/payment_ingestions/confirm_service_spec.rb b/spec/services/payment_ingestions/confirm_service_spec.rb
index d8ae1dfb..c60381d8 100644
--- a/spec/services/payment_ingestions/confirm_service_spec.rb
+++ b/spec/services/payment_ingestions/confirm_service_spec.rb
@@ -22,17 +22,19 @@ def build_ingestion(attributes = {})
}.merge(attributes))
end
- it "creates a tenant payment and marks the ingestion confirmed" do
+ it "creates a receipt and marks the ingestion confirmed" do
ingestion = build_ingestion(transaction_number: "TXNCONFIRM")
expect {
result = described_class.call(user: user, ingestion: ingestion)
expect(result).to be_success
- expect(result.value!.data).to be_a(TenantPayment)
- }.to change(TenantPayment, :count).by(1)
+ expect(result.value!.data).to be_a(Receipt)
+ }.to change(Receipt, :count).by(1)
+ .and change(JournalEntry, :count).by(1)
expect(ingestion.reload.status).to eq("confirmed")
- expect(ingestion.tenant_payment.transaction_number).to eq("TXNCONFIRM")
+ expect(ingestion.receipt.external_reference).to eq("TXNCONFIRM")
+ expect(ingestion.receipt.payer_party).to eq(party)
end
it "returns failure when ingestion belongs to another user" do
@@ -42,11 +44,17 @@ def build_ingestion(attributes = {})
expect(result.failure.code).to eq(:not_found)
end
- it "returns failure when ingestion is already confirmed" do
- ingestion = build_ingestion(status: "confirmed")
- result = described_class.call(user: user, ingestion: ingestion)
- expect(result).to be_failure
- expect(result.failure.code).to eq(:already_confirmed)
+ it "returns existing receipt when ingestion is already confirmed (idempotent)" do
+ ingestion = build_ingestion(transaction_number: "TXNIDEM")
+ res1 = described_class.call(user: user, ingestion: ingestion)
+ expect(res1).to be_success
+ first_receipt = res1.value!.data
+
+ expect {
+ res2 = described_class.call(user: user, ingestion: ingestion)
+ expect(res2).to be_success
+ expect(res2.value!.data).to eq(first_receipt)
+ }.not_to change(Receipt, :count)
end
it "creates aliases only for candidate payer values" do
@@ -63,7 +71,7 @@ def build_ingestion(attributes = {})
it "handles duplicate transaction number ActiveRecord::RecordNotUnique" do
ingestion = build_ingestion(transaction_number: "DUPLICATETXN")
- allow(TenantPayments::CreateService).to receive(:call).and_raise(ActiveRecord::RecordNotUnique)
+ allow(Receipts::CreateService).to receive(:call).and_raise(ActiveRecord::RecordNotUnique)
result = described_class.call(user: user, ingestion: ingestion)
expect(result).to be_failure
@@ -72,9 +80,9 @@ def build_ingestion(attributes = {})
it "handles ActiveRecord::RecordInvalid" do
ingestion = build_ingestion(transaction_number: "INVALIDTXN")
- invalid_tp = TenantPayment.new
- invalid_tp.errors.add(:amount, "is invalid")
- allow(TenantPayments::CreateService).to receive(:call).and_raise(ActiveRecord::RecordInvalid.new(invalid_tp))
+ invalid_r = Receipt.new
+ invalid_r.errors.add(:amount_cents, "is invalid")
+ allow(Receipts::CreateService).to receive(:call).and_raise(ActiveRecord::RecordInvalid.new(invalid_r))
result = described_class.call(user: user, ingestion: ingestion)
expect(result).to be_failure
@@ -90,7 +98,7 @@ def build_ingestion(attributes = {})
expect(result.failure.error).to eq("Cannot confirm: missing required fields or duplicate exists")
end
- it "prevents concurrent confirmation" do
+ it "handles concurrent confirmation idempotently" do
ingestion = build_ingestion(transaction_number: "TXNRACE")
results = []
@@ -102,9 +110,8 @@ def build_ingestion(attributes = {})
end
end.each(&:join)
- expect(results.count(&:success?)).to eq(1)
- expect(results.count(&:failure?)).to eq(1)
- expect(results.find(&:failure?).failure.error).to eq("Already confirmed")
+ expect(results.count(&:success?)).to eq(2)
+ expect(results.first.value!.data.id).to eq(results.last.value!.data.id)
expect(ingestion.reload.status).to eq("confirmed")
end
@@ -116,11 +123,137 @@ def build_ingestion(attributes = {})
expect(result.failure.code).to eq(:confirmation_error)
end
- it "skips alias creation when party is nil" do
- ingestion = build_ingestion(payer_name: "Test Payer")
- allow(ingestion).to receive(:party).and_return(nil)
- allow(ingestion).to receive(:confirmable?).and_return(true)
- result = described_class.call(user: user, ingestion: ingestion, create_alias: true)
- expect(result).to be_success
+ it "preserves original receipt reference on ingestion even after receipt correction" do
+ ingestion = build_ingestion(transaction_number: "TXNCORRECT")
+ confirm_res = described_class.call(user: user, ingestion: ingestion)
+ original_receipt = confirm_res.value!.data
+
+ # Correct the receipt
+ correct_res = Receipts::CorrectService.call(receipt: original_receipt, amount_cents: 150_000)
+ expect(correct_res).to be_success
+ replacement_receipt = correct_res.value!.data[:receipt]
+
+ expect(ingestion.reload.receipt).to eq(original_receipt)
+ expect(ingestion.receipt.superseded_by).to eq(replacement_receipt)
+ end
+
+ describe "concurrency serialization" do
+ it "safely serializes concurrent confirm and update" do
+ ingestion = build_ingestion(amount: 1000.0, transaction_number: "TXNCONCUR1")
+ confirm_result = nil
+ update_result = nil
+
+ t1 = Thread.new do
+ ActiveRecord::Base.connection_pool.with_connection do
+ confirm_result = described_class.call(user: user, ingestion: PaymentIngestion.find(ingestion.id))
+ end
+ end
+
+ t2 = Thread.new do
+ ActiveRecord::Base.connection_pool.with_connection do
+ update_result = PaymentIngestions::UpdateService.call(
+ user: user,
+ ingestion: PaymentIngestion.find(ingestion.id),
+ params: { amount: 2000.0 }
+ )
+ end
+ end
+
+ [ t1, t2 ].each(&:join)
+
+ expect(ingestion.reload.status).to eq("confirmed")
+ expect(ingestion.receipt).to be_present
+
+ if update_result.failure?
+ expect(update_result.failure.code).to eq(:immutable)
+ expect(ingestion.amount).to eq(1000.0)
+ expect(ingestion.receipt.amount).to eq(1000.0)
+ else
+ expect(update_result).to be_success
+ expect(ingestion.amount).to eq(2000.0)
+ expect(ingestion.receipt.amount).to eq(2000.0)
+ end
+
+ # Invariant: Once confirmed, no further update can succeed
+ after_update = PaymentIngestions::UpdateService.call(
+ user: user,
+ ingestion: ingestion,
+ params: { amount: 3000.0 }
+ )
+ expect(after_update).to be_failure
+ expect(after_update.failure.code).to eq(:immutable)
+ end
+
+ it "safely serializes concurrent confirm and ingestion delete" do
+ ingestion = build_ingestion(transaction_number: "TXNCONCUR2")
+ confirm_result = nil
+ delete_result = nil
+
+ t1 = Thread.new do
+ ActiveRecord::Base.connection_pool.with_connection do
+ confirm_result = described_class.call(user: user, ingestion: PaymentIngestion.find(ingestion.id))
+ end
+ end
+
+ t2 = Thread.new do
+ ActiveRecord::Base.connection_pool.with_connection do
+ delete_result = PaymentIngestions::DestroyService.call(
+ user: user,
+ ingestion: PaymentIngestion.find(ingestion.id)
+ )
+ end
+ end
+
+ [ t1, t2 ].each(&:join)
+
+ if confirm_result.success?
+ expect(ingestion.reload.status).to eq("confirmed")
+ expect(ingestion.receipt).to be_present
+ expect(Receipt.where(id: confirm_result.value!.data.id)).to exist
+ expect(delete_result).to be_failure
+ expect(delete_result.failure.code).to eq(:immutable)
+ else
+ expect(delete_result).to be_success
+ expect(PaymentIngestion.where(id: ingestion.id)).to be_empty
+ expect(Receipt.where(external_reference: "TXNCONCUR2")).to be_empty
+ end
+ end
+
+ it "safely serializes concurrent confirm and PaymentDocument delete" do
+ doc = create(:payment_document, user: user)
+ ingestion = build_ingestion(payment_document: doc, transaction_number: "TXNCONCUR3")
+ confirm_result = nil
+ delete_result = nil
+
+ t1 = Thread.new do
+ ActiveRecord::Base.connection_pool.with_connection do
+ confirm_result = described_class.call(user: user, ingestion: PaymentIngestion.find(ingestion.id))
+ end
+ end
+
+ t2 = Thread.new do
+ ActiveRecord::Base.connection_pool.with_connection do
+ delete_result = PaymentDocuments::DestroyService.call(
+ user: user,
+ document: PaymentDocument.find(doc.id)
+ )
+ end
+ end
+
+ [ t1, t2 ].each(&:join)
+
+ if confirm_result.success?
+ expect(ingestion.reload.status).to eq("confirmed")
+ expect(ingestion.receipt).to be_present
+ expect(PaymentDocument.where(id: doc.id)).to exist
+ expect(delete_result).to be_failure
+ expect(delete_result.failure.code).to eq(:immutable)
+ else
+ expect(delete_result).to be_success
+ expect(PaymentDocument.where(id: doc.id)).to be_empty
+ expect(PaymentIngestion.where(id: ingestion.id)).to be_empty
+ expect(Receipt.where(external_reference: "TXNCONCUR3")).to be_empty
+ end
+ end
end
end
diff --git a/spec/services/payment_ingestions/destroy_service_spec.rb b/spec/services/payment_ingestions/destroy_service_spec.rb
new file mode 100644
index 00000000..5f9cedde
--- /dev/null
+++ b/spec/services/payment_ingestions/destroy_service_spec.rb
@@ -0,0 +1,46 @@
+require "rails_helper"
+
+RSpec.describe PaymentIngestions::DestroyService do
+ let(:user) { create(:user) }
+ let(:party) { create(:party, user: user) }
+ let(:property) { create(:property, user: user) }
+ let(:unit) { create(:rentable_unit, property: property) }
+ let(:tenancy) { create(:tenancy, rentable_unit: unit) }
+
+ it "destroys an unconfirmed payment ingestion" do
+ ingestion = create(:payment_ingestion, user: user, status: :matched)
+
+ expect {
+ result = described_class.call(user: user, ingestion: ingestion)
+ expect(result).to be_success
+ }.to change(PaymentIngestion, :count).by(-1)
+ end
+
+ it "rejects destroying a confirmed payment ingestion" do
+ ingestion = create(:payment_ingestion,
+ user: user,
+ status: :confirmed,
+ party: party,
+ tenancy: tenancy,
+ amount: 1000.0,
+ payment_date: Date.current,
+ payment_method: "zelle"
+ )
+
+ expect {
+ result = described_class.call(user: user, ingestion: ingestion)
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:immutable)
+ expect(result.failure.error).to eq("Cannot delete a confirmed payment ingestion")
+ }.not_to change(PaymentIngestion, :count)
+ end
+
+ it "returns failure when ingestion belongs to another user" do
+ other_user = create(:user)
+ ingestion = create(:payment_ingestion, user: other_user)
+
+ result = described_class.call(user: user, ingestion: ingestion)
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:not_found)
+ end
+end
diff --git a/spec/services/payment_ingestions/update_service_spec.rb b/spec/services/payment_ingestions/update_service_spec.rb
index cd2709ac..a0594624 100644
--- a/spec/services/payment_ingestions/update_service_spec.rb
+++ b/spec/services/payment_ingestions/update_service_spec.rb
@@ -47,4 +47,14 @@
expect(result).to be_failure
expect(result.failure.code).to eq(:validation_error)
end
+
+ it "rejects updating a confirmed payment ingestion" do
+ ingestion = create(:payment_ingestion, user: user, status: :confirmed, party: party, tenancy: tenancy, amount: 500.0, payment_date: Date.current, payment_method: "zelle")
+
+ result = described_class.call(user: user, ingestion: ingestion, params: { amount: 600.0 })
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:immutable)
+ expect(result.failure.error).to eq("Cannot update a confirmed payment ingestion")
+ expect(ingestion.reload.amount).to eq(500.0)
+ end
end
diff --git a/spec/services/receipts/correct_service_spec.rb b/spec/services/receipts/correct_service_spec.rb
new file mode 100644
index 00000000..0afddc26
--- /dev/null
+++ b/spec/services/receipts/correct_service_spec.rb
@@ -0,0 +1,242 @@
+require "rails_helper"
+
+RSpec.describe Receipts::CorrectService, type: :service do
+ let(:user) { create(:user) }
+ let(:property) { create(:property, user: user) }
+ let(:unit1) { create(:rentable_unit, property: property) }
+ let(:unit2) { create(:rentable_unit, property: property) }
+ let(:tenancy1) { create(:tenancy, rentable_unit: unit1, commencement_date: Date.new(2026, 1, 1)) }
+ let(:tenancy2) { create(:tenancy, rentable_unit: unit2, commencement_date: Date.new(2026, 1, 1)) }
+ let(:party_alice) { create(:party, user: user, display_name: "Alice Tenant") }
+ let(:party_bob) { create(:party, user: user, display_name: "Bob Tenant") }
+
+ let!(:receipt_result) do
+ Receipts::CreateService.call(
+ tenancy: tenancy1,
+ payer_party: party_alice,
+ amount_cents: 200_000,
+ received_on: Date.new(2026, 1, 15),
+ payment_method: "zelle",
+ external_reference: "ZEL123",
+ memo: "January Rent"
+ )
+ end
+ let(:receipt) { receipt_result.value!.data[:receipt] }
+
+ describe ".call" do
+ it "corrects the amount by reversing the original and creating a replacement" do
+ result = nil
+ expect {
+ result = described_class.call(
+ receipt: receipt,
+ amount_cents: 210_000
+ )
+ expect(result).to be_success
+ }.to change(Receipt, :count).by(1)
+ .and change(JournalEntry, :count).by(2)
+ .and change(Posting, :count).by(4)
+
+ replacement = result.value!.data[:receipt]
+ expect(replacement.amount_cents).to eq(210_000)
+ expect(replacement.payer_party).to eq(party_alice)
+ expect(replacement.tenancy).to eq(tenancy1)
+ expect(replacement.posted?).to be true
+
+ receipt.reload
+ expect(receipt.voided?).to be true
+ expect(receipt.superseded?).to be true
+ expect(receipt.superseded_by).to eq(replacement)
+
+ # Check net ledger balance for tenancy1
+ # Initial: +2100 credit
+ expect(tenancy1.current_balance).to eq(BigDecimal("-2100.00"))
+ end
+
+ it "corrects the tenancy by moving receivable credit between tenancies" do
+ result = described_class.call(
+ receipt: receipt,
+ tenancy: tenancy2
+ )
+ expect(result).to be_success
+ replacement = result.value!.data[:receipt]
+ expect(replacement.tenancy).to eq(tenancy2)
+
+ # Tenancy 1 balance is back to $0
+ expect(tenancy1.current_balance).to eq(BigDecimal("0.00"))
+ # Tenancy 2 has $2,000 credit
+ expect(tenancy2.current_balance).to eq(BigDecimal("-2000.00"))
+ end
+
+ it "corrects the payer party while preserving original audit trail" do
+ result = described_class.call(
+ receipt: receipt,
+ payer_party: party_bob
+ )
+ expect(result).to be_success
+ replacement = result.value!.data[:receipt]
+ expect(replacement.payer_party).to eq(party_bob)
+
+ # Verify postings on replacement carry Bob
+ replacement_entry = replacement.journal_entries.find_by(event_type: "receipt_posted")
+ expect(replacement_entry.postings.map(&:party).uniq).to eq([ party_bob ])
+ end
+
+ it "allows replacement to retain the same external reference" do
+ result = described_class.call(
+ receipt: receipt,
+ amount_cents: 220_000,
+ external_reference: "ZEL123"
+ )
+ expect(result).to be_success
+ replacement = result.value!.data[:receipt]
+ expect(replacement.external_reference).to eq("ZEL123")
+ end
+
+ it "is idempotent for identical correction requests including string received_on" do
+ res1 = described_class.call(
+ receipt: receipt,
+ amount_cents: 210_000,
+ received_on: "2026-01-15",
+ payment_method: "ZELLE",
+ external_reference: " ZEL123 ",
+ memo: " Updated Memo "
+ )
+ expect(res1).to be_success
+ replacement1 = res1.value!.data[:receipt]
+
+ expect {
+ res2 = described_class.call(
+ receipt: receipt,
+ amount_cents: 210_000,
+ received_on: "2026-01-15",
+ payment_method: "zelle",
+ external_reference: "ZEL123",
+ memo: "Updated Memo"
+ )
+ expect(res2).to be_success
+ expect(res2.value!.data[:receipt]).to eq(replacement1)
+ }.not_to change(Receipt, :count)
+ end
+
+ it "rejects invalid received_on string" do
+ bad_res = described_class.call(receipt: receipt, received_on: "not-a-date")
+ expect(bad_res).to be_failure
+ expect(bad_res.failure.code).to eq(:invalid_input)
+ expect(bad_res.failure.error).to eq("Received on must be a valid date")
+ end
+
+ it "rejects a conflicting correction on an already superseded receipt" do
+ described_class.call(receipt: receipt, amount_cents: 210_000)
+
+ conflict_result = described_class.call(receipt: receipt, amount_cents: 250_000)
+ expect(conflict_result).to be_failure
+ expect(conflict_result.failure.code).to eq(:already_superseded)
+ end
+
+ it "rejects correcting a voided receipt" do
+ Receipts::VoidService.call(receipt: receipt)
+
+ result = described_class.call(receipt: receipt, amount_cents: 210_000)
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:already_voided)
+ end
+
+ it "serializes concurrent corrections under row lock" do
+ results = []
+ threads = 2.times.map do |i|
+ Thread.new do
+ ActiveRecord::Base.connection_pool.with_connection do
+ results << described_class.call(
+ receipt: receipt,
+ amount_cents: 210_000 + (i * 10_000)
+ )
+ end
+ end
+ end
+ threads.each(&:join)
+
+ successes = results.select(&:success?)
+ failures = results.select(&:failure?)
+
+ expect(successes.count).to eq(1)
+ expect(failures.count).to eq(1)
+ expect(failures.first.failure.code).to eq(:already_superseded)
+ expect(receipt.reload.superseded_by).to be_present
+ end
+
+ it "returns failure for unpersisted receipt" do
+ result = described_class.call(receipt: Receipt.new)
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:invalid_source)
+ end
+
+ it "returns failure if journal entry is missing" do
+ allow(receipt).to receive_message_chain(:journal_entries, :find_by).and_return(nil)
+ result = described_class.call(receipt: receipt)
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:not_found)
+ end
+
+ it "returns failure for invalid or fractional amount" do
+ bad_res = described_class.call(receipt: receipt, amount: "invalid")
+ expect(bad_res).to be_failure
+ expect(bad_res.failure.code).to eq(:invalid_amount)
+
+ frac_res = described_class.call(receipt: receipt, amount: "100.555")
+ expect(frac_res).to be_failure
+ expect(frac_res.failure.code).to eq(:invalid_amount)
+ end
+
+ it "rejects moving receipt to another user's tenancy" do
+ other_user = create(:user)
+ other_prop = create(:property, user: other_user)
+ other_unit = create(:rentable_unit, property: other_prop)
+ other_tenancy = create(:tenancy, rentable_unit: other_unit)
+ other_party = create(:party, user: other_user)
+
+ expect {
+ result = described_class.call(
+ receipt: receipt,
+ tenancy: other_tenancy,
+ payer_party: other_party
+ )
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:ownership_mismatch)
+ expect(result.failure.error).to eq("Cannot move receipt to another user's tenancy")
+ }.not_to change(Receipt, :count)
+
+ expect(receipt.reload.active?).to be true
+ expect(receipt.voided?).to be false
+ expect(receipt.superseded?).to be false
+ expect(JournalEntry.where(event_type: "receipt_reversal")).to be_empty
+ end
+
+ it "rejects assigning payer belonging to another user" do
+ other_user = create(:user)
+ other_party = create(:party, user: other_user)
+
+ expect {
+ result = described_class.call(
+ receipt: receipt,
+ payer_party: other_party
+ )
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:ownership_mismatch)
+ expect(result.failure.error).to eq("Cannot assign payer belonging to another user")
+ }.not_to change(Receipt, :count)
+
+ expect(receipt.reload.active?).to be true
+ expect(receipt.voided?).to be false
+ expect(receipt.superseded?).to be false
+ end
+
+ it "handles reverse service failure" do
+ allow(Accounting::ReverseEntryService).to receive(:call).and_return(
+ ServiceResult.failure(error: "Reverse failed", code: :reverse_failed)
+ )
+ result = described_class.call(receipt: receipt, amount_cents: 210_000)
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:reverse_failed)
+ end
+ end
+end
diff --git a/spec/services/receipts/create_service_spec.rb b/spec/services/receipts/create_service_spec.rb
new file mode 100644
index 00000000..474b7fb1
--- /dev/null
+++ b/spec/services/receipts/create_service_spec.rb
@@ -0,0 +1,297 @@
+require "rails_helper"
+
+RSpec.describe Receipts::CreateService, type: :service do
+ let(:user) { create(:user) }
+ let(:property) { create(:property, user: user) }
+ let(:unit) { create(:rentable_unit, property: property) }
+ let(:tenancy) { create(:tenancy, rentable_unit: unit, commencement_date: Date.new(2026, 1, 1)) }
+ let(:party_alice) { create(:party, user: user, display_name: "Alice Tenant") }
+ let(:party_bob) { create(:party, user: user, display_name: "Bob Tenant") }
+ let(:party_employer) { create(:party, user: user, display_name: "Alice Employer") }
+
+ before do
+ create(:tenancy_party, tenancy: tenancy, party: party_alice, role: "tenant")
+ create(:tenancy_party, tenancy: tenancy, party: party_bob, role: "tenant")
+ create(:rent_term, tenancy: tenancy, amount_cents: 200_000, effective_from: Date.new(2026, 1, 1))
+ end
+
+ describe ".call" do
+ it "atomically creates and posts a receipt" do
+ expect {
+ result = described_class.call(
+ tenancy: tenancy,
+ payer_party: party_alice,
+ amount_cents: 200_000,
+ received_on: Date.new(2026, 2, 1),
+ payment_method: "zelle",
+ external_reference: "ZEL999",
+ memo: "February rent payment"
+ )
+ expect(result).to be_success
+ receipt = result.value!.data[:receipt]
+ expect(receipt).to be_persisted
+ expect(receipt.posted?).to be true
+ expect(receipt.posted_at).to be_present
+ expect(receipt.user).to eq(user)
+ }.to change(Receipt, :count).by(1)
+ .and change(JournalEntry, :count).by(1)
+ .and change(Posting, :count).by(2)
+ end
+
+ it "accepts decimal amount string" do
+ result = described_class.call(
+ tenancy: tenancy,
+ payer_party: party_alice,
+ amount: "1500.50",
+ received_on: Date.current,
+ payment_method: "check"
+ )
+ expect(result).to be_success
+ expect(result.value!.data[:receipt].amount_cents).to eq(150_050)
+ end
+
+ it "rejects fractional cents without creating receipt or journal entries" do
+ expect {
+ result = described_class.call(
+ tenancy: tenancy,
+ payer_party: party_alice,
+ amount: "100.005",
+ received_on: Date.current,
+ payment_method: "check"
+ )
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:invalid_amount)
+ }.not_to change(Receipt, :count)
+
+ expect(JournalEntry.count).to eq(0)
+ end
+
+ it "rejects non-positive amount" do
+ result = described_class.call(
+ tenancy: tenancy,
+ payer_party: party_alice,
+ amount_cents: 0,
+ received_on: Date.current,
+ payment_method: "cash"
+ )
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:invalid_amount)
+ end
+
+ it "rolls back everything if accounting posting fails" do
+ allow(Accounting::PostEntryService).to receive(:call).and_return(
+ ServiceResult.failure(error: "Posting failed", code: :posting_error)
+ )
+
+ expect {
+ result = described_class.call(
+ tenancy: tenancy,
+ payer_party: party_alice,
+ amount_cents: 100_000,
+ received_on: Date.current,
+ payment_method: "cash"
+ )
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:posting_error)
+ }.not_to change(Receipt, :count)
+
+ expect(JournalEntry.count).to eq(0)
+ end
+
+ it "supports third-party payer who is not on the tenancy" do
+ result = described_class.call(
+ tenancy: tenancy,
+ payer_party: party_employer,
+ amount_cents: 200_000,
+ received_on: Date.current,
+ payment_method: "wire"
+ )
+ expect(result).to be_success
+ receipt = result.value!.data[:receipt]
+ expect(receipt.payer_party).to eq(party_employer)
+ end
+
+ it "reduces tenancy balance for joint tenants while preserving payer identity" do
+ # Initial rent charge: $2,000
+ charge_res = Charges::CreateService.call(
+ tenancy: tenancy,
+ charge_kind: "rent",
+ amount_cents: 200_000,
+ charge_date: Date.new(2026, 1, 1),
+ due_on: Date.new(2026, 1, 1),
+ service_period_start: Date.new(2026, 1, 1),
+ service_period_end: Date.new(2026, 1, 31)
+ )
+ expect(charge_res).to be_success
+ expect(tenancy.current_balance).to eq(BigDecimal("2000.00"))
+
+ # Alice pays $500
+ res1 = described_class.call(
+ tenancy: tenancy,
+ payer_party: party_alice,
+ amount_cents: 50_000,
+ received_on: Date.new(2026, 1, 2),
+ payment_method: "zelle"
+ )
+ expect(res1).to be_success
+ expect(tenancy.current_balance).to eq(BigDecimal("1500.00"))
+
+ # Bob pays $750
+ res2 = described_class.call(
+ tenancy: tenancy,
+ payer_party: party_bob,
+ amount_cents: 75_000,
+ received_on: Date.new(2026, 1, 3),
+ payment_method: "venmo"
+ )
+ expect(res2).to be_success
+ expect(tenancy.current_balance).to eq(BigDecimal("750.00"))
+
+ # Verify payers in postings
+ alice_postings = res1.value!.data[:journal_entry].postings
+ expect(alice_postings.map(&:party).uniq).to eq([ party_alice ])
+
+ bob_postings = res2.value!.data[:journal_entry].postings
+ expect(bob_postings.map(&:party).uniq).to eq([ party_bob ])
+ end
+
+ it "handles overpayments by producing a credit balance" do
+ charge_res = Charges::CreateService.call(
+ tenancy: tenancy,
+ charge_kind: "rent",
+ amount_cents: 200_000,
+ charge_date: Date.current,
+ due_on: Date.current,
+ service_period_start: Date.current.beginning_of_month,
+ service_period_end: Date.current.end_of_month
+ )
+ expect(charge_res).to be_success
+
+ # Pay $2,500
+ result = described_class.call(
+ tenancy: tenancy,
+ payer_party: party_alice,
+ amount_cents: 250_000,
+ received_on: Date.current,
+ payment_method: "check"
+ )
+ expect(result).to be_success
+ expect(tenancy.current_balance).to eq(BigDecimal("-500.00"))
+ end
+
+ it "rejects missing tenancy" do
+ result = described_class.call(
+ tenancy: nil,
+ payer_party: party_alice,
+ amount_cents: 100_000,
+ received_on: Date.current,
+ payment_method: "cash"
+ )
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:invalid_input)
+ expect(result.failure.error).to eq("Tenancy is required")
+ end
+
+ it "rejects missing payer party" do
+ result = described_class.call(
+ tenancy: tenancy,
+ payer_party: nil,
+ amount_cents: 100_000,
+ received_on: Date.current,
+ payment_method: "cash"
+ )
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:invalid_input)
+ expect(result.failure.error).to eq("Payer party is required")
+ end
+
+ it "rejects missing received_on" do
+ result = described_class.call(
+ tenancy: tenancy,
+ payer_party: party_alice,
+ amount_cents: 100_000,
+ received_on: nil,
+ payment_method: "cash"
+ )
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:invalid_input)
+ expect(result.failure.error).to eq("Received on date is required")
+ end
+
+ it "rejects invalid received_on string" do
+ result = described_class.call(
+ tenancy: tenancy,
+ payer_party: party_alice,
+ amount_cents: 100_000,
+ received_on: "invalid-date",
+ payment_method: "cash"
+ )
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:invalid_input)
+ expect(result.failure.error).to eq("Received on must be a valid date")
+ end
+
+ it "rejects missing or blank payment_method" do
+ result = described_class.call(
+ tenancy: tenancy,
+ payer_party: party_alice,
+ amount_cents: 100_000,
+ received_on: Date.current,
+ payment_method: " "
+ )
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:invalid_input)
+ expect(result.failure.error).to eq("Payment method is required")
+ end
+
+ it "rescues ActiveRecord::RecordNotUnique and returns duplicate failure" do
+ # Create initial receipt
+ first_result = described_class.call(
+ tenancy: tenancy,
+ payer_party: party_alice,
+ amount_cents: 100_000,
+ received_on: Date.current,
+ payment_method: "zelle",
+ external_reference: "CONF123"
+ )
+ expect(first_result).to be_success
+
+ # Second attempt with same method + external_reference
+ second_result = described_class.call(
+ tenancy: tenancy,
+ payer_party: party_alice,
+ amount_cents: 100_000,
+ received_on: Date.current,
+ payment_method: "zelle",
+ external_reference: "CONF123"
+ )
+ expect(second_result).to be_failure
+ expect(second_result.failure.code).to eq(:validation_error)
+
+ # Test race condition where RecordNotUnique is raised directly on save
+ allow_any_instance_of(Receipt).to receive(:save).and_raise(ActiveRecord::RecordNotUnique)
+ race_result = described_class.call(
+ tenancy: tenancy,
+ payer_party: party_alice,
+ amount_cents: 100_000,
+ received_on: Date.current,
+ payment_method: "zelle",
+ external_reference: "CONF456"
+ )
+ expect(race_result).to be_failure
+ expect(race_result.failure.code).to eq(:duplicate)
+ end
+
+ it "returns failure for invalid non-numeric amount" do
+ result = described_class.call(
+ tenancy: tenancy,
+ payer_party: party_alice,
+ received_on: Date.current,
+ payment_method: "cash",
+ amount: "invalid-amount"
+ )
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:invalid_amount)
+ end
+ end
+end
diff --git a/spec/services/receipts/post_service_spec.rb b/spec/services/receipts/post_service_spec.rb
new file mode 100644
index 00000000..04fbb1f1
--- /dev/null
+++ b/spec/services/receipts/post_service_spec.rb
@@ -0,0 +1,65 @@
+require "rails_helper"
+
+RSpec.describe Receipts::PostService, type: :service do
+ let(:user) { create(:user) }
+ let(:property) { create(:property, user: user) }
+ let(:unit) { create(:rentable_unit, property: property) }
+ let(:tenancy) { create(:tenancy, rentable_unit: unit) }
+ let(:payer_party) { create(:party, user: user) }
+ let(:receipt) do
+ create(:receipt,
+ user: user,
+ tenancy: tenancy,
+ payer_party: payer_party,
+ amount_cents: 150_000,
+ received_on: Date.new(2026, 1, 15),
+ payment_method: "zelle",
+ external_reference: "ZEL123",
+ memo: "January Rent"
+ )
+ end
+
+ describe ".call" do
+ it "creates a balanced journal entry posting Dr Cash and Cr Tenant Receivable" do
+ result = described_class.call(receipt: receipt)
+ expect(result).to be_success
+
+ entry = result.value!.data[:journal_entry]
+ expect(entry.source).to eq(receipt)
+ expect(entry.event_type).to eq("receipt_posted")
+ expect(entry.occurred_on).to eq(Date.new(2026, 1, 15))
+ expect(entry.user).to eq(user)
+
+ postings = entry.postings.includes(:account, :party, :tenancy, :property, :rentable_unit)
+ expect(postings.count).to eq(2)
+
+ cash_posting = postings.find { |p| p.account.key == "cash" }
+ expect(cash_posting.amount_cents).to eq(150_000)
+ expect(cash_posting.party).to eq(payer_party)
+ expect(cash_posting.tenancy).to eq(tenancy)
+ expect(cash_posting.property).to eq(property)
+ expect(cash_posting.rentable_unit).to eq(unit)
+
+ receivable_posting = postings.find { |p| p.account.key == "tenant_receivable" }
+ expect(receivable_posting.amount_cents).to eq(-150_000)
+ expect(receivable_posting.party).to eq(payer_party)
+ expect(receivable_posting.tenancy).to eq(tenancy)
+ expect(receivable_posting.property).to eq(property)
+ expect(receivable_posting.rentable_unit).to eq(unit)
+ end
+
+ it "fails if receipt is voided" do
+ receipt.update_columns(voided_at: Time.current)
+ result = described_class.call(receipt: receipt)
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:invalid_state)
+ end
+
+ it "fails if receipt is not a persisted Receipt" do
+ unpersisted = build(:receipt)
+ result = described_class.call(receipt: unpersisted)
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:invalid_source)
+ end
+ end
+end
diff --git a/spec/services/receipts/receipt_pdf_service_spec.rb b/spec/services/receipts/receipt_pdf_service_spec.rb
new file mode 100644
index 00000000..553dc4b3
--- /dev/null
+++ b/spec/services/receipts/receipt_pdf_service_spec.rb
@@ -0,0 +1,88 @@
+require "rails_helper"
+
+RSpec.describe Receipts::ReceiptPdfService, type: :service do
+ let(:user) { create(:user) }
+ let(:property) { create(:property, user: user, address: "123 Main St") }
+ let(:unit) { create(:rentable_unit, property: property, name: "Unit 4B") }
+ let(:tenancy) { create(:tenancy, rentable_unit: unit) }
+ let(:payer_party) { create(:party, user: user, display_name: "Alice Walker") }
+ let(:receipt) do
+ create(:receipt,
+ user: user,
+ tenancy: tenancy,
+ payer_party: payer_party,
+ amount_cents: 125_000,
+ received_on: Date.new(2026, 1, 15),
+ payment_method: "zelle",
+ external_reference: "ZEL123",
+ memo: "January Rent"
+ )
+ end
+
+ let(:view_context) { double(number_to_currency: "$1,250.00") }
+ let(:pdf) { instance_double(Prawn::Document, text: nil, move_down: nil, render: "pdf-data") }
+
+ describe ".call" do
+ it "renders receipt details into a PDF" do
+ allow(Prawn::Document).to receive(:new).and_return(pdf)
+
+ result = described_class.call(receipt: receipt, view_context: view_context)
+
+ expect(result).to eq("pdf-data")
+ expect(pdf).to have_received(:text).with("Payment Receipt", size: 30, style: :bold)
+ expect(pdf).to have_received(:text).with("Receipt ID: ##{receipt.id}")
+ expect(pdf).to have_received(:text).with("Payment Date: January 15, 2026")
+ expect(pdf).to have_received(:text).with("Amount: $1,250.00")
+ expect(pdf).to have_received(:text).with("Payer: Alice Walker")
+ expect(pdf).to have_received(:text).with("Method: Zelle")
+ expect(pdf).to have_received(:text).with("Transaction / Reference: ZEL123")
+ expect(pdf).to have_received(:text).with("Property: 123 Main St")
+ expect(pdf).to have_received(:text).with("Unit: #{unit.display_name}")
+ expect(pdf).to have_received(:text).with("Tenancy: ##{tenancy.id}")
+ end
+
+ it "includes a clear voided notice for a voided receipt" do
+ receipt.update_columns(voided_at: Time.current)
+ allow(Prawn::Document).to receive(:new).and_return(pdf)
+
+ result = described_class.call(receipt: receipt, view_context: view_context)
+ expect(result).to eq("pdf-data")
+ expect(pdf).to have_received(:text).with("[VOIDED - INACTIVE RECORD]", size: 12, style: :bold, color: "CC0000")
+ end
+
+ it "includes a clear corrected notice for a superseded receipt" do
+ replacement = create(:receipt, tenancy: tenancy, payer_party: payer_party)
+ receipt.update_columns(voided_at: Time.current, superseded_by_id: replacement.id)
+ allow(Prawn::Document).to receive(:new).and_return(pdf)
+
+ result = described_class.call(receipt: receipt, view_context: view_context)
+ expect(result).to eq("pdf-data")
+ expect(pdf).to have_received(:text).with("[CORRECTED - REPLACED BY RECEIPT ##{replacement.id}]", size: 12, style: :bold, color: "CC0000")
+ end
+
+ it "includes a replacement notice for a receipt that replaces a superseded one" do
+ original = create(:receipt, tenancy: tenancy, payer_party: payer_party)
+ original.update_columns(voided_at: Time.current, superseded_by_id: receipt.id)
+ allow(Prawn::Document).to receive(:new).and_return(pdf)
+
+ result = described_class.call(receipt: receipt, view_context: view_context)
+ expect(result).to eq("pdf-data")
+ expect(pdf).to have_received(:text).with("[REPLACEMENT FOR RECEIPT ##{original.id}]", size: 12, style: :bold, color: "008800")
+ end
+
+ it "handles receipt without external_reference or memo" do
+ receipt.update_columns(external_reference: nil, memo: nil)
+ allow(Prawn::Document).to receive(:new).and_return(pdf)
+
+ result = described_class.call(receipt: receipt, view_context: view_context)
+ expect(result).to eq("pdf-data")
+ end
+
+ it "generates genuine PDF binary bytes" do
+ real_view_context = ActionController::Base.new.view_context
+ real_pdf = described_class.call(receipt: receipt, view_context: real_view_context)
+ expect(real_pdf).to be_present
+ expect(real_pdf).to start_with("%PDF-")
+ end
+ end
+end
diff --git a/spec/services/receipts/void_service_spec.rb b/spec/services/receipts/void_service_spec.rb
new file mode 100644
index 00000000..800063bc
--- /dev/null
+++ b/spec/services/receipts/void_service_spec.rb
@@ -0,0 +1,119 @@
+require "rails_helper"
+
+RSpec.describe Receipts::VoidService, type: :service do
+ include ActiveSupport::Testing::TimeHelpers
+
+ let(:user) { create(:user) }
+ let(:property) { create(:property, user: user) }
+ let(:unit) { create(:rentable_unit, property: property) }
+ let(:tenancy) { create(:tenancy, rentable_unit: unit, commencement_date: Date.new(2026, 1, 1)) }
+ let(:payer_party) { create(:party, user: user) }
+
+ let!(:receipt_result) do
+ Receipts::CreateService.call(
+ tenancy: tenancy,
+ payer_party: payer_party,
+ amount_cents: 100_000,
+ received_on: Date.new(2026, 1, 10),
+ payment_method: "zelle",
+ external_reference: "ZEL123",
+ memo: "January Rent"
+ )
+ end
+ let(:receipt) { receipt_result.value!.data[:receipt] }
+
+ describe ".call" do
+ it "reverses the accounting entry and marks the receipt voided" do
+ result = nil
+ expect {
+ result = described_class.call(receipt: receipt, reason: "Duplicate payment entered by mistake")
+ expect(result).to be_success
+ expect(receipt.reload.voided?).to be true
+ expect(receipt.voided_at).to be_present
+ }.to change(JournalEntry, :count).by(1)
+ .and change(Posting, :count).by(2)
+
+ reversal_entry = result.value!.data[:journal_entry]
+ expect(reversal_entry).to be_present
+ expect(reversal_entry.occurred_on).to eq(Date.new(2026, 1, 10))
+
+ cash_posting = reversal_entry.postings.find { |p| p.account.key == "cash" }
+ expect(cash_posting.amount_cents).to eq(-100_000)
+
+ ar_posting = reversal_entry.postings.find { |p| p.account.key == "tenant_receivable" }
+ expect(ar_posting.amount_cents).to eq(100_000)
+ end
+
+ it "always reverses dated at original received_on regardless of when void is called" do
+ travel_to Date.new(2026, 6, 15) do
+ result = described_class.call(receipt: receipt)
+ expect(result).to be_success
+ reversal_entry = result.value!.data[:journal_entry]
+ expect(reversal_entry.occurred_on).to eq(Date.new(2026, 1, 10))
+ end
+ end
+
+ it "restores the tenancy balance automatically" do
+ create(:rent_term, tenancy: tenancy, amount_cents: 100_000, effective_from: Date.new(2026, 1, 1))
+ Charges::CreateService.call(
+ tenancy: tenancy,
+ charge_kind: "rent",
+ amount_cents: 100_000,
+ charge_date: Date.new(2026, 1, 1),
+ due_on: Date.new(2026, 1, 1),
+ service_period_start: Date.new(2026, 1, 1),
+ service_period_end: Date.new(2026, 1, 31)
+ )
+
+ # Balance after payment was $0
+ expect(tenancy.current_balance).to eq(BigDecimal("0.00"))
+
+ # Void the payment
+ described_class.call(receipt: receipt)
+
+ # Balance should now be $1,000 owed
+ expect(tenancy.current_balance).to eq(BigDecimal("1000.00"))
+ end
+
+ it "is idempotent if called multiple times" do
+ res1 = described_class.call(receipt: receipt)
+ expect(res1).to be_success
+
+ expect {
+ res2 = described_class.call(receipt: receipt)
+ expect(res2).to be_success
+ }.not_to change(JournalEntry, :count)
+ end
+
+ it "fails if receipt is already superseded by a replacement receipt" do
+ replacement = create(:receipt, tenancy: tenancy, payer_party: payer_party)
+ receipt.update_columns(voided_at: Time.current, superseded_by_id: replacement.id)
+
+ result = described_class.call(receipt: receipt)
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:already_superseded)
+ end
+
+ it "returns failure for unpersisted receipt" do
+ result = described_class.call(receipt: Receipt.new)
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:invalid_source)
+ end
+
+ it "returns failure if journal entry is missing" do
+ allow(receipt).to receive_message_chain(:journal_entries, :find_by).and_return(nil)
+ result = described_class.call(receipt: receipt)
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:not_found)
+ end
+
+ it "handles reverse service failure" do
+ allow(Accounting::ReverseEntryService).to receive(:call).and_return(
+ ServiceResult.failure(error: "Reverse error", code: :reverse_error)
+ )
+ result = described_class.call(receipt: receipt)
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:reverse_error)
+ end
+ end
+end
diff --git a/spec/services/rent_charges/generate_service_spec.rb b/spec/services/rent_charges/generate_service_spec.rb
index 82120bbf..9e77ffb6 100644
--- a/spec/services/rent_charges/generate_service_spec.rb
+++ b/spec/services/rent_charges/generate_service_spec.rb
@@ -60,5 +60,27 @@
expect(result).to be_success
expect(result.value!.data).to be_nil
end
+
+ it "returns failure for unpersisted tenancy" do
+ result = described_class.call(tenancy: Tenancy.new, service_month: Date.new(2026, 3, 1))
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:invalid_input)
+ end
+
+ it "returns failure for invalid service month" do
+ result = described_class.call(tenancy: tenancy, service_month: "invalid-date")
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:invalid_input)
+ end
+
+ it "returns conflict failure when existing charge has different amount" do
+ described_class.call(tenancy: tenancy, service_month: Date.new(2026, 3, 1))
+ existing = tenancy.charges.rent.first
+ existing.update_columns(amount_cents: 999_000)
+
+ result = described_class.call(tenancy: tenancy, service_month: Date.new(2026, 3, 1))
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:conflict)
+ end
end
end
diff --git a/spec/services/rent_charges/generate_through_service_spec.rb b/spec/services/rent_charges/generate_through_service_spec.rb
index 509cc2c6..4294d9d5 100644
--- a/spec/services/rent_charges/generate_through_service_spec.rb
+++ b/spec/services/rent_charges/generate_through_service_spec.rb
@@ -34,7 +34,7 @@
expect(charges.size).to eq(3) # Jan, Feb, Mar
expect(tenancy.charges.rent.count).to eq(3)
- expect(tenancy.charges.rent.pluck(:service_period_start)).to eq([
+ expect(tenancy.charges.rent.order(:service_period_start).pluck(:service_period_start)).to eq([
Date.new(2026, 1, 1),
Date.new(2026, 2, 1),
Date.new(2026, 3, 1)
@@ -49,5 +49,35 @@
expect(result).to be_success
}.not_to change(Charge, :count)
end
+
+ it "returns failure for unpersisted tenancy" do
+ result = described_class.call(tenancy: Tenancy.new)
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:invalid_input)
+ end
+
+ it "returns empty charges when commencement date is after through date" do
+ result = described_class.call(tenancy: tenancy, through: Date.new(2025, 12, 1))
+ expect(result).to be_success
+ expect(result.value!.data[:charges]).to be_empty
+ end
+
+ it "handles string and invalid through dates" do
+ result = described_class.call(tenancy: tenancy, through: "2026-02-15")
+ expect(result).to be_success
+ expect(result.value!.data[:charges].size).to eq(2)
+
+ invalid_res = described_class.call(tenancy: tenancy, through: "not-a-date")
+ expect(invalid_res).to be_success
+ end
+
+ it "propagates failure when generate service fails" do
+ allow(RentCharges::GenerateService).to receive(:call).and_return(
+ ServiceResult.failure(error: "Generation error", code: :generation_error)
+ )
+ result = described_class.call(tenancy: tenancy, through: Date.new(2026, 2, 1))
+ expect(result).to be_failure
+ expect(result.failure.code).to eq(:generation_error)
+ end
end
end
diff --git a/spec/services/schedule_e_generator_spec.rb b/spec/services/schedule_e_generator_spec.rb
index dc45c5ce..cb3fc45b 100644
--- a/spec/services/schedule_e_generator_spec.rb
+++ b/spec/services/schedule_e_generator_spec.rb
@@ -33,7 +33,7 @@
before do
Charge.delete_all
Expense.delete_all
- TenantPayment.delete_all
+ Receipt.delete_all
end
def create_data_for_year(year)
@@ -49,18 +49,18 @@ def create_data_for_year(year)
)
end
- create(:tenant_payment,
+ create(:receipt,
tenancy: tenancy,
- payment_date: date_in_year,
- amount: RENT_AMOUNT,
+ received_on: date_in_year,
+ amount_cents: (RENT_AMOUNT * 100).to_i,
payment_method: "check",
- transaction_number: "TEST-#{year}"
+ external_reference: "TEST-#{year}"
)
- create(:tenant_payment,
+ create(:receipt,
tenancy: tenancy,
- amount: UTILITY_AMOUNT,
- payment_date: date_in_year,
+ amount_cents: (UTILITY_AMOUNT * 100).to_i,
+ received_on: date_in_year,
payment_method: "zelle"
)
end
@@ -128,7 +128,7 @@ def read_field(doc, name)
Charge.delete_all
Expense.delete_all
- TenantPayment.delete_all
+ Receipt.delete_all
end
end
end
@@ -142,7 +142,7 @@ def read_field(doc, name)
it "handles net loss and covers net loss branches" do
create(:expense, property: property, category: "repairs", amount: 1500, expense_date: Date.new(2025, 6, 1))
- create(:tenant_payment, tenancy: tenancy, payment_date: Date.new(2025, 6, 1), amount: 1000, payment_method: "check")
+ create(:receipt, tenancy: tenancy, received_on: Date.new(2025, 6, 1), amount_cents: 100_000, payment_method: "check")
generator = described_class.new(property, 2025)
pdf_data = generator.call
diff --git a/spec/services/tenant_payments/create_service_spec.rb b/spec/services/tenant_payments/create_service_spec.rb
deleted file mode 100644
index d6a90f4d..00000000
--- a/spec/services/tenant_payments/create_service_spec.rb
+++ /dev/null
@@ -1,56 +0,0 @@
-require "rails_helper"
-
-RSpec.describe TenantPayments::CreateService do
- let(:user) { create(:user) }
- let(:property) { create(:property, user: user) }
- let(:unit) { create(:rentable_unit, property: property) }
- let(:tenancy) do
- create(:tenancy,
- rentable_unit: unit,
- commencement_date: Date.new(2026, 1, 1),
- termination_date: Date.new(2026, 12, 31)
- )
- end
-
- describe ".call" do
- it "creates a TenantPayment and posts Dr Cash / Cr Tenant Receivable" do
- result = described_class.call(
- tenancy: tenancy,
- amount: "1500.00",
- payment_date: Date.new(2026, 5, 2),
- payment_method: "ach",
- transaction_number: "ACH12345"
- )
-
- expect(result).to be_success
- payment = result.value!.data[:tenant_payment]
- entry = result.value!.data[:journal_entry]
-
- expect(payment.persisted?).to be true
- expect(payment.amount).to eq(1500.00)
-
- expect(entry.event_type).to eq("payment_received")
- expect(entry.source).to eq(payment)
- expect(entry.occurred_on).to eq(Date.new(2026, 5, 2))
-
- dr = entry.postings.find_by(amount_cents: 150_000)
- cr = entry.postings.find_by(amount_cents: -150_000)
-
- expect(dr.account.key).to eq("cash")
- expect(cr.account.key).to eq("tenant_receivable")
- expect(cr.tenancy_id).to eq(tenancy.id)
- end
-
- it "rolls back if invalid" do
- result = described_class.call(
- tenancy: tenancy,
- amount: -100
- )
-
- expect(result).to be_failure
- expect(result.failure.code).to eq(:validation_error)
- expect(TenantPayment.count).to eq(0)
- expect(JournalEntry.count).to eq(0)
- end
- end
-end
diff --git a/spec/services/tenant_payments/receipt_pdf_service_spec.rb b/spec/services/tenant_payments/receipt_pdf_service_spec.rb
deleted file mode 100644
index 1ecc0b8e..00000000
--- a/spec/services/tenant_payments/receipt_pdf_service_spec.rb
+++ /dev/null
@@ -1,66 +0,0 @@
-require "rails_helper"
-
-RSpec.describe TenantPayments::ReceiptPdfService do
- let(:user) { create(:user) }
- let(:property) { create(:property, user: user, address: "123 Main St") }
- let(:unit) { create(:rentable_unit, property: property) }
- let(:tenancy) { create(:tenancy, rentable_unit: unit) }
- let(:payment) do
- create(:tenant_payment,
- tenancy: tenancy,
- payment_date: Date.new(2026, 5, 1),
- amount: 1200,
- payment_method: "zelle",
- transaction_number: "TXN123"
- )
- end
- let(:view_context) { double(number_to_currency: "$1,200.00") }
- let(:pdf) { instance_double(Prawn::Document, text: nil, move_down: nil, render: "pdf-data") }
-
- it "renders receipt details into a PDF" do
- allow(Prawn::Document).to receive(:new).and_return(pdf)
-
- result = described_class.call(tenant_payment: payment, view_context: view_context)
-
- expect(result).to eq("pdf-data")
- expect(pdf).to have_received(:text).with("Payment Receipt", size: 30, style: :bold)
- expect(pdf).to have_received(:text).with("Payment Date: 2026-05-01")
- expect(pdf).to have_received(:text).with("Amount: $1,200.00")
- expect(pdf).to have_received(:text).with("Method: zelle")
- expect(pdf).to have_received(:text).with("Transaction Number: TXN123")
- expect(pdf).to have_received(:text).with("Property: 123 Main St")
- end
-
- it "renders receipt without transaction number when blank" do
- payment_without_txn = create(:tenant_payment,
- tenancy: tenancy,
- payment_date: Date.new(2026, 5, 1),
- amount: 1200,
- payment_method: "zelle",
- transaction_number: nil
- )
- allow(Prawn::Document).to receive(:new).and_return(pdf)
-
- result = described_class.call(tenant_payment: payment_without_txn, view_context: view_context)
-
- expect(result).to eq("pdf-data")
- expect(pdf).not_to have_received(:text).with(a_string_starting_with("Transaction Number:"))
- end
-
- it "renders receipt when tenancy has no property" do
- payment_record = create(:tenant_payment,
- user: user,
- tenancy: tenancy,
- payment_date: Date.new(2026, 5, 1),
- amount: 1200,
- payment_method: "zelle",
- transaction_number: "TXN123"
- )
- allow(payment_record.tenancy).to receive(:property).and_return(nil)
- allow(Prawn::Document).to receive(:new).and_return(pdf)
-
- result = described_class.call(tenant_payment: payment_record, view_context: view_context)
- expect(result).to eq("pdf-data")
- expect(pdf).to have_received(:text).with("Property: ")
- end
-end
diff --git a/spec/system/dashboards_spec.rb b/spec/system/dashboards_spec.rb
index 00029468..51c22df9 100644
--- a/spec/system/dashboards_spec.rb
+++ b/spec/system/dashboards_spec.rb
@@ -6,7 +6,7 @@
let!(:unit) { create(:rentable_unit, property: property) }
let!(:expense) { create(:expense, property: property, category: "repairs", amount: 250.00, expense_date: Date.today, description: "Fix door") }
let!(:tenancy) { create(:tenancy, rentable_unit: unit, agreement_type: "month_to_month", commencement_date: Date.today, late_period_days: 5) }
- let!(:payment) { create(:tenant_payment, tenancy: tenancy, amount: 1000.0, payment_date: Date.today, payment_method: "cash") }
+ let!(:payment) { create(:receipt, tenancy: tenancy, amount_cents: 100_000, received_on: Date.today, payment_method: "cash") }
before do
visit new_session_path
diff --git a/spec/system/receipts_spec.rb b/spec/system/receipts_spec.rb
new file mode 100644
index 00000000..577487b8
--- /dev/null
+++ b/spec/system/receipts_spec.rb
@@ -0,0 +1,93 @@
+require "rails_helper"
+
+RSpec.describe "Receipts", type: :system do
+ let!(:user) { create(:user) }
+ let!(:property) { create(:property, user: user, address: "999 Payment Ave") }
+ let!(:unit) { create(:rentable_unit, property: property, name: "Unit 1") }
+ let!(:party) { create(:party, user: user, display_name: "Ledger Tester") }
+ let!(:tenancy) { create(:tenancy, rentable_unit: unit, agreement_type: "month_to_month", commencement_date: Date.today, late_period_days: 5) }
+
+ before do
+ create(:tenancy_party, tenancy: tenancy, party: party, role: "tenant", effective_from: Date.today)
+ visit new_session_path
+ fill_in "email", with: user.email
+ fill_in "password", with: "password"
+ click_on "Sign in"
+ end
+
+ it "records a payment and verifies details and PDF download link" do
+ visit receipts_path
+
+ click_on "+ Record Payment"
+
+ select "##{tenancy.id} - #{property.address} (#{unit.display_name})", from: "Tenancy / Unit"
+ select party.display_name, from: "Payer (Party)"
+ fill_in "Amount ($)", with: "1000.00"
+ fill_in "Received Date", with: Date.today.to_s
+ fill_in "Payment Method", with: "Zelle"
+ fill_in "External Reference / Txn #", with: "ZEL-1001"
+
+ click_on "Record Payment"
+
+ expect(page).to have_text("Payment recorded successfully.")
+ expect(page).to have_text("$1,000.00")
+ expect(page).to have_text("Ledger Tester")
+ expect(page).to have_text("ZEL-1001")
+ expect(page).to have_link("Download PDF Receipt")
+ end
+
+ it "records a payment from the tenancy show page" do
+ visit tenancy_path(tenancy)
+
+ click_on "+ Record Payment", match: :first
+
+ select party.display_name, from: "Payer (Party)"
+ fill_in "Amount ($)", with: "750.00"
+ fill_in "Received Date", with: Date.today.to_s
+ fill_in "Payment Method", with: "Check"
+
+ click_on "Record Payment"
+
+ expect(page).to have_text("Payment recorded successfully.")
+ expect(page).to have_text("$750.00")
+ end
+
+ it "corrects a payment through the correction flow" do
+ res = Receipts::CreateService.call(
+ tenancy: tenancy,
+ payer_party: party,
+ amount_cents: 100_000,
+ received_on: Date.today,
+ payment_method: "zelle"
+ )
+ receipt = res.value!.data[:receipt]
+
+ visit receipt_path(receipt)
+ click_on "Correct Payment"
+
+ expect(page).to have_text("Correction Semantics")
+ fill_in "Amount ($)", with: "1100.00"
+
+ click_on "Save Replacement Payment"
+
+ expect(page).to have_text("Payment corrected successfully.")
+ expect(page).to have_text("$1,100.00")
+ end
+
+ it "voids a payment with confirmation" do
+ res = Receipts::CreateService.call(
+ tenancy: tenancy,
+ payer_party: party,
+ amount_cents: 100_000,
+ received_on: Date.today,
+ payment_method: "zelle"
+ )
+ receipt = res.value!.data[:receipt]
+
+ visit receipt_path(receipt)
+ click_on "Void Payment"
+
+ expect(page).to have_text("Payment has been voided")
+ expect(page).to have_text("This payment was voided.")
+ end
+end
diff --git a/spec/system/schedule_e_spec.rb b/spec/system/schedule_e_spec.rb
index 4759a719..b9784aa8 100644
--- a/spec/system/schedule_e_spec.rb
+++ b/spec/system/schedule_e_spec.rb
@@ -22,17 +22,17 @@
agreement_type: "fixed_term"
)
- create(:tenant_payment,
+ create(:receipt,
tenancy: tenancy,
- amount: 5000.00,
- payment_date: Date.new(year, 1, 5),
+ amount_cents: 500_000,
+ received_on: Date.new(year, 1, 5),
payment_method: "Zelle"
)
- create(:tenant_payment,
+ create(:receipt,
tenancy: tenancy,
- amount: 150.00,
- payment_date: Date.new(year, 2, 10),
+ amount_cents: 15_000,
+ received_on: Date.new(year, 2, 10),
payment_method: "Check"
)
diff --git a/spec/system/tenant_payments_spec.rb b/spec/system/tenant_payments_spec.rb
deleted file mode 100644
index 1f77ab21..00000000
--- a/spec/system/tenant_payments_spec.rb
+++ /dev/null
@@ -1,34 +0,0 @@
-require "rails_helper"
-
-RSpec.describe "TenantPayments", type: :system do
- let!(:user) { create(:user) }
- let!(:property) { create(:property, user: user, address: "999 Payment Ave") }
- let!(:unit) { create(:rentable_unit, property: property) }
- let!(:party) { create(:party, user: user, display_name: "Ledger Tester") }
- let!(:tenancy) { create(:tenancy, rentable_unit: unit, agreement_type: "month_to_month", commencement_date: Date.today, late_period_days: 5) }
-
- before do
- create(:tenancy_party, tenancy: tenancy, party: party, role: "tenant", effective_from: Date.today)
- # Log in
- visit new_session_path
- fill_in "email", with: user.email
- fill_in "password", with: "password"
- click_on "Sign in"
- end
-
- it "records a tenant payment and verifies PDF receipt link" do
- visit tenant_payments_path
-
- click_on "New Payment"
-
- select "#{property.address} - Tenancy ##{tenancy.id} (#{party.display_name})", from: "Tenancy / Property / Tenants"
- fill_in "Payment date", with: Date.today.to_s
- fill_in "Amount", with: "1000"
- fill_in "Payment method", with: "Check"
-
- click_on "Create Tenant payment"
-
- expect(page).to have_text("Payment was successfully created")
- expect(page).to have_link("Download PDF Receipt")
- end
-end