diff --git a/app/furniture/tobias.rb b/app/furniture/tobias.rb new file mode 100644 index 000000000..0dd845ca6 --- /dev/null +++ b/app/furniture/tobias.rb @@ -0,0 +1,2 @@ +class Tobias +end diff --git a/app/furniture/tobias/beneficiary.rb b/app/furniture/tobias/beneficiary.rb new file mode 100644 index 000000000..422e4a7fd --- /dev/null +++ b/app/furniture/tobias/beneficiary.rb @@ -0,0 +1,10 @@ +class Tobias + class Beneficiary < ApplicationRecord + self.table_name = "tobias_beneficiaries" + + belongs_to :trust, inverse_of: :beneficiaries + + has_many :payments, inverse_of: :beneficiary + has_many :payouts, through: :payments + end +end diff --git a/app/furniture/tobias/payment.rb b/app/furniture/tobias/payment.rb new file mode 100644 index 000000000..394c759c0 --- /dev/null +++ b/app/furniture/tobias/payment.rb @@ -0,0 +1,10 @@ +class Tobias + class Payment < ApplicationRecord + self.table_name = "tobias_payments" + + belongs_to :payout, inverse_of: :payments + belongs_to :beneficiary, inverse_of: :payments + + monetize :amount_cents + end +end diff --git a/app/furniture/tobias/payout.rb b/app/furniture/tobias/payout.rb new file mode 100644 index 000000000..e2b3fbb43 --- /dev/null +++ b/app/furniture/tobias/payout.rb @@ -0,0 +1,20 @@ +class Tobias + class Payout < ApplicationRecord + self.table_name = "tobias_payouts" + + belongs_to :trust + has_many :beneficiaries, through: :trust + has_many :payments, inverse_of: :payout, dependent: :destroy + + monetize :amount_cents + + def issue + return if payments.present? + + per_beneficiary_amount = (amount / beneficiaries.count) + beneficiaries.each do |beneficiary| + payments.create_with(amount: per_beneficiary_amount).find_or_create_by(beneficiary: beneficiary) + end + end + end +end diff --git a/app/furniture/tobias/trust.rb b/app/furniture/tobias/trust.rb new file mode 100644 index 000000000..357c690fd --- /dev/null +++ b/app/furniture/tobias/trust.rb @@ -0,0 +1,8 @@ +class Tobias + class Trust < ApplicationRecord + self.table_name = "tobias_trusts" + + has_many :beneficiaries, inverse_of: :trust, dependent: :destroy + has_many :payouts, inverse_of: :trust, dependent: :destroy + end +end diff --git a/db/migrate/20240127063826_create_tobias_payouts.rb b/db/migrate/20240127063826_create_tobias_payouts.rb new file mode 100644 index 000000000..ad6eadffb --- /dev/null +++ b/db/migrate/20240127063826_create_tobias_payouts.rb @@ -0,0 +1,27 @@ +class CreateTobiasPayouts < ActiveRecord::Migration[7.1] + def change + create_table :tobias_trusts, id: :uuid do |t| + t.timestamps + end + + create_table :tobias_beneficiaries, id: :uuid do |t| + t.references :trust, type: :uuid, foreign_key: {to_table: :tobias_trusts} + + t.timestamps + end + + create_table :tobias_payouts, id: :uuid do |t| + t.monetize :amount + t.references :trust, type: :uuid, foreign_key: {to_table: :tobias_trusts} + t.timestamps + end + + create_table :tobias_payments, id: :uuid do |t| + t.references :payout, type: :uuid, foreign_key: {to_table: :tobias_payouts} + t.references :beneficiary, type: :uuid, foreign_key: {to_table: :tobias_beneficiaries} + t.monetize :amount + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index c1fdcaeb2..c52f70b69 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -333,6 +333,38 @@ t.index ["slug", "client_id"], name: "index_spaces_on_slug_and_client_id", unique: true end + create_table "tobias_beneficiaries", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "trust_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["trust_id"], name: "index_tobias_beneficiaries_on_trust_id" + end + + create_table "tobias_payments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "payout_id" + t.uuid "beneficiary_id" + t.integer "amount_cents", default: 0, null: false + t.string "amount_currency", default: "USD", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["beneficiary_id"], name: "index_tobias_payments_on_beneficiary_id" + t.index ["payout_id"], name: "index_tobias_payments_on_payout_id" + end + + create_table "tobias_payouts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.integer "amount_cents", default: 0, null: false + t.string "amount_currency", default: "USD", null: false + t.uuid "trust_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["trust_id"], name: "index_tobias_payouts_on_trust_id" + end + + create_table "tobias_trusts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "utility_hookups", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "space_id" t.string "name", null: false @@ -368,4 +400,8 @@ add_foreign_key "rooms", "media", column: "hero_image_id" add_foreign_key "space_agreements", "spaces" add_foreign_key "spaces", "rooms", column: "entrance_id" + add_foreign_key "tobias_beneficiaries", "tobias_trusts", column: "trust_id" + add_foreign_key "tobias_payments", "tobias_beneficiaries", column: "beneficiary_id" + add_foreign_key "tobias_payments", "tobias_payouts", column: "payout_id" + add_foreign_key "tobias_payouts", "tobias_trusts", column: "trust_id" end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 24c3d8ae7..23a18427c 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,6 +1,7 @@ require "faker" require "pundit/rspec" require "simplecov" +require "money-rails/test_helpers" SimpleCov.start do enable_coverage :branch diff --git a/spec/tobias/beneficiary_spec.rb b/spec/tobias/beneficiary_spec.rb new file mode 100644 index 000000000..1bb9a325a --- /dev/null +++ b/spec/tobias/beneficiary_spec.rb @@ -0,0 +1,15 @@ +require "rails_helper" + +RSpec.describe Tobias::Beneficiary, type: :model do + describe "#trust" do + it { is_expected.to belong_to(:trust).inverse_of(:beneficiaries) } + end + + describe "#payments" do + it { is_expected.to have_many(:payments).inverse_of(:beneficiary) } + end + + describe "#payouts" do + it { is_expected.to have_many(:payouts).through(:payments) } + end +end diff --git a/spec/tobias/factories/beneficiary_factory.rb b/spec/tobias/factories/beneficiary_factory.rb new file mode 100644 index 000000000..b330f1abb --- /dev/null +++ b/spec/tobias/factories/beneficiary_factory.rb @@ -0,0 +1,4 @@ +FactoryBot.define do + factory :tobias_beneficiary, class: "Tobias::Beneficiary" do + end +end diff --git a/spec/tobias/factories/payout_factory.rb b/spec/tobias/factories/payout_factory.rb new file mode 100644 index 000000000..f027e7300 --- /dev/null +++ b/spec/tobias/factories/payout_factory.rb @@ -0,0 +1,7 @@ +require_relative "trust_factory" + +FactoryBot.define do + factory :tobias_payout, class: "Tobias::Payout" do + association(:trust, factory: :tobias_trust) + end +end diff --git a/spec/tobias/factories/trust_factory.rb b/spec/tobias/factories/trust_factory.rb new file mode 100644 index 000000000..7fd94abab --- /dev/null +++ b/spec/tobias/factories/trust_factory.rb @@ -0,0 +1,4 @@ +FactoryBot.define do + factory :tobias_trust, class: "Tobias::Trust" do + end +end diff --git a/spec/tobias/issuing_payouts_system_spec.rb b/spec/tobias/issuing_payouts_system_spec.rb new file mode 100644 index 000000000..d5f7dd05e --- /dev/null +++ b/spec/tobias/issuing_payouts_system_spec.rb @@ -0,0 +1,23 @@ +require "rails_helper" +require_relative "factories/trust_factory" +require_relative "factories/beneficiary_factory" + +# @see https://github.com/zinc-collective/tobias/issues/11 +RSpec.describe "Tobias: Issuing a Payout", type: :system do + scenario "Issuing a `Payout` to multiple `Beneficiaries`" do # rubocop:disable RSpec/Capybara/FeatureMethods,RSpec/ExampleLength + trust = create(:tobias_trust) + beneficiaries = create_list(:tobias_beneficiary, 10, trust:) + visit(polymorphic_path(trust.location)) + click_link("New Payout") + fill_in("Amount", with: 250_00) + + click_button("Create Payout") + + click_button("Issue Payout") + + expect(page).to have_content("$25.00 Payments Issued to 10 Beneficiaries") + beneficiaries.each do |beneficiary| + expect(page).to have_content("#{beneficiary.name} $25.00 Pending") + end + end +end diff --git a/spec/tobias/payment_spec.rb b/spec/tobias/payment_spec.rb new file mode 100644 index 000000000..19607f969 --- /dev/null +++ b/spec/tobias/payment_spec.rb @@ -0,0 +1,11 @@ +require "rails_helper" + +RSpec.describe Tobias::Payment, type: :model do + describe "#payout" do + it { is_expected.to belong_to(:payout).inverse_of(:payments) } + end + + describe "#amount" do + it { is_expected.to monetize(:amount) } + end +end diff --git a/spec/tobias/payout_spec.rb b/spec/tobias/payout_spec.rb new file mode 100644 index 000000000..cbb4af317 --- /dev/null +++ b/spec/tobias/payout_spec.rb @@ -0,0 +1,63 @@ +require "rails_helper" +require_relative "factories/payout_factory" +require_relative "factories/beneficiary_factory" + +RSpec.describe Tobias::Payout, type: :model do + describe "#payments" do + it { is_expected.to have_many(:payments).inverse_of(:payout).dependent(:destroy) } + end + + describe "#beneficiaries" do + it { is_expected.to have_many(:beneficiaries).through(:trust) } + end + + describe "#amount" do + it { is_expected.to monetize(:amount) } + end + + describe "#issue" do + it "issues a Payment to each Beneficiary for their share of the #amount" do + payout = create(:tobias_payout, amount_cents: 150_00) + + beneficiaries = create_list(:tobias_beneficiary, 10, trust: payout.trust) + + payout.issue + + beneficiaries.each do |beneficiary| + expect(beneficiary.payments).to exist(amount_cents: 15_00) + end + end + + context "when the Payout#amount does not divide evenly" do + it "rounds down so that it can" do + payout = create(:tobias_payout, amount_cents: 3_33) + + beneficiaries = create_list(:tobias_beneficiary, 2, trust: payout.trust) + + payout.issue + + beneficiaries.each do |beneficiary| + expect(beneficiary.payments).to exist(amount_cents: 1_66) + end + end + end + + context "when running twice" do + it "does not issue multiple payouts, even when beneficiaries are added" do + payout = create(:tobias_payout, amount_cents: 100_00) + + create_list(:tobias_beneficiary, 2, trust: payout.trust) + + payout.issue + + create(:tobias_beneficiary, trust: payout.trust) + + # ActiveRecord appears to be caching the `payout.beneficiaries` results + # Reload busts that cache. + payout.reload + + expect { payout.issue }.not_to(change(payout.payments, :count)) + end + end + end +end diff --git a/spec/tobias/trust_spec.rb b/spec/tobias/trust_spec.rb new file mode 100644 index 000000000..1ae161b9b --- /dev/null +++ b/spec/tobias/trust_spec.rb @@ -0,0 +1,11 @@ +require "rails_helper" + +RSpec.describe Tobias::Trust, type: :model do + describe "#benificiaries" do + it { is_expected.to have_many(:beneficiaries).inverse_of(:trust).dependent(:destroy) } + end + + describe "#payouts" do + it { is_expected.to have_many(:payouts).inverse_of(:trust).dependent(:destroy) } + end +end