Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,5 @@ group :test do
# Code coverage; started in test/test_helper.rb only when COVERAGE=1
gem "simplecov", require: false
end

gem "money-rails", "~> 3.0"
14 changes: 14 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,16 @@ GEM
minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5)
monetize (2.0.0)
money (~> 7.0)
money (7.1.1)
bigdecimal
i18n (~> 1.9)
money-rails (3.0.0)
activesupport (>= 7.0)
monetize (~> 2.0)
money (~> 7.0)
railties (>= 7.0)
msgpack (1.8.4)
net-imap (0.6.6)
date
Expand Down Expand Up @@ -489,6 +499,7 @@ DEPENDENCIES
json (>= 2.21.2)
kamal
letter_opener
money-rails (~> 3.0)
nokogiri (>= 1.19.4)
postmark-rails (~> 0.22)
propshaft
Expand Down Expand Up @@ -597,6 +608,9 @@ CHECKSUMS
mini_magick (5.3.1) sha256=29395dfd76badcabb6403ee5aff6f681e867074f8f28ce08d78661e9e4a351c4
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1
monetize (2.0.0) sha256=152980cadff9fd11a95817d56e3d7d69b07998c4506f15d971edf05851f82aba
money (7.1.1) sha256=8ed5cd99dfbc45e82688ea711cb59ea3c396c2608cd88876502b164c90269d4e
money-rails (3.0.0) sha256=f4bd1c8e1c98d221e7bf452eeb52e240f41224e43da2a477a5659a87c0c95bae
msgpack (1.8.4) sha256=4411c22d350dd1c20250f7eada3cca2695438c2f769cf0782f0cd065d90a3e7b
net-imap (0.6.6) sha256=96aa4ee50df3060203e649efc341f53480b791d49e150f2fdebf68beb141a8df
net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3
Expand Down
2 changes: 2 additions & 0 deletions app/models/allocation.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
class Allocation < ApplicationRecord
monetize :amount_cents, allow_nil: true

belongs_to :scenario
belongs_to :allocation_category, optional: true

Expand Down
19 changes: 8 additions & 11 deletions app/models/allocation/one_time.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
class Allocation::OneTime < Allocation
validates :amount,
validates :amount_cents,
presence: true,
numericality: { only_integer: true, greater_than: 0 }
validate :within_total_giving_amount
Expand All @@ -16,7 +16,7 @@ def share_percentage
total = scenario.one_time_giving_amount
return 0 if total.zero?

(amount.to_i / total.to_f * 100).round
(amount.cents / total.cents.to_f * 100).round
end

# The most this allocation can be set to while staying within the scenario's
Expand All @@ -34,21 +34,18 @@ def max_amount(scenario = self.scenario)
private

def within_total_giving_amount
return if amount.blank? || scenario&.total_giving_amount.blank?
return if amount_cents.blank? || scenario&.total_giving_amount_cents.blank?

remaining = budget_remaining(scenario)
return if remaining.nil?

if amount > remaining
others = scenario.total_giving_amount - remaining
errors.add(:amount, "would bring one-time giving to #{others + amount}, over the total giving amount of #{scenario.total_giving_amount.to_i}")
others = scenario.one_time_allocations.where.not(id: id).sum(:amount_cents)
if others + amount_cents > scenario.total_giving_amount_cents
errors.add(:amount, "would bring one-time giving to #{(others + amount_cents) / 100.0}, over the total giving amount of #{(scenario.total_giving_amount_cents / 100.0).to_i}")
end
end

def budget_remaining(scenario = self.scenario)
return nil if scenario.blank? || scenario.total_giving_amount.blank?

others = scenario.one_time_allocations.where.not(id: id).sum(:amount)
scenario.total_giving_amount - others
others = scenario.one_time_allocations.where.not(id: id).sum(:amount_cents)
(scenario.total_giving_amount_cents - others) / 100
end
end
4 changes: 2 additions & 2 deletions app/models/allocation/ongoing.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ class Allocation::Ongoing < Allocation
validate :within_ongoing_percentage_total

def dollar_amount
(percentage.to_i / 100.0 * scenario.ongoing_giving_amount).round
scenario.ongoing_giving_amount * (percentage.to_i / 100.0)
end

def perpetuity_annual_amount
(dollar_amount * PERPETUITY_PAYOUT_RATE).round
dollar_amount * PERPETUITY_PAYOUT_RATE
end

def ongoing?
Expand Down
6 changes: 4 additions & 2 deletions app/models/scenario.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
class Scenario < ApplicationRecord
include UserSearchable

monetize :total_giving_amount_cents, allow_nil: true

belongs_to :organization
belongs_to :user
has_many :allocations, dependent: :destroy
Expand Down Expand Up @@ -33,11 +35,11 @@ def ongoing_percentage_total
end

def one_time_giving_amount
one_time_allocations.sum { |allocation| allocation.amount.to_i }
one_time_allocations.sum(Money.new(0)) { |allocation| allocation.amount }
end

def ongoing_giving_amount
total_giving_amount.to_i - one_time_giving_amount
(total_giving_amount || Money.new(0)) - one_time_giving_amount
end

private
Expand Down
2 changes: 1 addition & 1 deletion app/views/admin/scenarios/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
</td>
<td class="px-4 py-2.5 text-right tabular-nums text-ink">
<%= link_to scenario_path(scenario), data: { turbo_frame: "_top" }, class: "block" do %>
<%= scenario.total_giving_amount.present? ? number_to_currency(scenario.total_giving_amount, precision: 0) : content_tag(:span, "—", class: "text-ink-faint") %>
<%= scenario.total_giving_amount.present? ? number_to_currency(scenario.total_giving_amount) : content_tag(:span, "—", class: "text-ink-faint") %>
<% end %>
</td>
</tr>
Expand Down
4 changes: 2 additions & 2 deletions app/views/public/scenarios/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
<div class="mt-6">
<h1 class="font-serif font-medium text-3xl text-ink"><%= @scenario.name %></h1>
<p class="mt-1 text-sm text-ink-soft">Shared by <span class="font-medium text-ink"><%= @scenario.user.display_name %></span></p>
<% if @scenario.total_giving_amount.to_i.positive? %>
<% if @scenario.total_giving_amount&.positive? %>
<p class="mt-3 text-ink-soft">
Total giving <span class="font-medium text-ink"><%= number_to_currency(@scenario.total_giving_amount, precision: 0) %></span>
Total giving <span class="font-medium text-ink"><%= number_to_currency(@scenario.total_giving_amount) %></span>
</p>
<% end %>
</div>
Expand Down
10 changes: 5 additions & 5 deletions app/views/scenarios/_allocation.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<% slider_limit = remaining_ongoing_percentage(scenario, allocation) %>
<div data-controller="dialog">
<div data-controller="allocation-slider"
data-allocation-slider-ongoing-amount-value="<%= scenario.ongoing_giving_amount %>"
data-allocation-slider-ongoing-amount-value="<%= scenario.ongoing_giving_amount.to_i %>"
data-allocation-slider-payout-rate-value="<%= Allocation::Ongoing::PERPETUITY_PAYOUT_RATE %>"
data-allocation-slider-limit-value="<%= slider_limit %>"
class="rounded-lg border <%= allocation.greatest_community_need? ? "border-brand/30 bg-brand-tint" : "border-line-soft bg-surface" %> px-4 py-3 transition hover:shadow-sm">
Expand All @@ -25,7 +25,7 @@
<div class="flex items-start gap-3 shrink-0">
<div class="w-16 text-right leading-tight">
<span class="block font-semibold text-ink tabular-nums" data-allocation-slider-target="percent"><%= allocation.percentage %>%</span>
<span class="block text-sm text-ink-faint tabular-nums" data-allocation-slider-target="dollar"><%= number_to_currency(allocation.dollar_amount, precision: 0) %></span>
<span class="block text-sm text-ink-faint tabular-nums" data-allocation-slider-target="dollar"><%= number_to_currency(allocation.dollar_amount) %></span>
</div>
<% unless allocation.greatest_community_need? %>
<button type="button" data-action="dialog#open" aria-label="Edit allocation"
Expand Down Expand Up @@ -59,13 +59,13 @@
<% end %>
<div class="flex items-baseline gap-3 shrink-0">
<span class="w-10 text-right font-semibold text-ink tabular-nums" data-allocation-slider-target="percent"><%= allocation.percentage %>%</span>
<span class="w-16 text-right text-ink-soft tabular-nums" data-allocation-slider-target="dollar"><%= number_to_currency(allocation.dollar_amount, precision: 0) %></span>
<span class="w-16 text-right text-ink-soft tabular-nums" data-allocation-slider-target="dollar"><%= number_to_currency(allocation.dollar_amount) %></span>
</div>
</div>

<p class="mt-3 text-xs text-ink-faint">
<span style="color: <%= color %>">&infin;</span>
Est. <span class="font-medium text-ink-soft" data-allocation-slider-target="perpetuity"><%= number_to_currency(allocation.perpetuity_annual_amount, precision: 0) %></span>
Est. <span class="font-medium text-ink-soft" data-allocation-slider-target="perpetuity"><%= number_to_currency(allocation.perpetuity_annual_amount) %></span>
annually in perpetuity
<span class="text-ink-faint">(<%= (Allocation::Ongoing::PERPETUITY_PAYOUT_RATE * 100).to_i %>% of principal)</span>
</p>
Expand All @@ -89,7 +89,7 @@
</div>
<div class="flex items-center gap-3 shrink-0">
<span class="font-semibold text-ink">
<%= number_to_currency(allocation.amount, precision: 0) %>
<%= number_to_currency(allocation.amount) %>
</span>
<button type="button" data-action="dialog#open"
class="text-sm text-ink-faint hover:text-accent cursor-pointer bg-transparent p-0 leading-none">
Expand Down
2 changes: 1 addition & 1 deletion app/views/scenarios/_allocation_modal.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
<%= label_tag "allocation_amount_#{suffix}", "Amount", class: "block text-sm text-ink-soft" %>
<div class="relative mt-1">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-ink-faint">$</span>
<%= number_field_tag "allocation[amount]", allocation.amount, id: "allocation_amount_#{suffix}", min: 0, max: max_amount, step: "1", required: true, placeholder: "0",
<%= number_field_tag "allocation[amount]", allocation.amount.to_i, id: "allocation_amount_#{suffix}", min: 0, max: max_amount, step: "1", required: true, placeholder: "0",
inputmode: "numeric",
data: max_amount.present? ? { controller: "integer-input one-time-amount", action: "input->integer-input#transform", one_time_amount_max_value: max_amount } : { controller: "integer-input", action: "input->integer-input#transform" },
class: "block w-full rounded-md border border-line bg-surface pl-7 pr-3 py-2 shadow-sm focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/10" %>
Expand Down
34 changes: 17 additions & 17 deletions app/views/scenarios/_summary.html.erb
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
<%# locals: (scenario:) %>
<% ongoing_total = scenario.ongoing_percentage_total %>
<% ongoing_amount = scenario.ongoing_giving_amount %>
<% one_time_amount = scenario.one_time_giving_amount %>
<% total_amount = scenario.total_giving_amount.to_i %>
<% ongoing_total = scenario.ongoing_percentage_total %>
<% ongoing_amount = scenario.ongoing_giving_amount %>
<% one_time_amount = scenario.one_time_giving_amount %>
<% total_amount = scenario.total_giving_amount %>
<div class="rounded-2xl border border-line bg-surface p-6 shadow-sm">
<h2 class="font-serif font-medium text-xl text-ink">Allocation summary</h2>
<% if total_amount.positive? %>
<% if total_amount.present? && total_amount.positive? %>
<p class="mt-2 text-sm text-ink-soft">
Ongoing <span class="font-medium text-brand"><%= number_to_currency(ongoing_amount, precision: 0) %></span>
+ One-time <span class="font-medium text-ink"><%= number_to_currency(one_time_amount, precision: 0) %></span>
= Total <span class="font-medium text-ink"><%= number_to_currency(total_amount, precision: 0) %></span>
Ongoing <span class="font-medium text-brand"><%= number_to_currency(ongoing_amount) %></span>
+ One-time <span class="font-medium text-ink"><%= number_to_currency(one_time_amount) %></span>
= Total <span class="font-medium text-ink"><%= number_to_currency(total_amount) %></span>
</p>
<% else %>
<p class="mt-2 text-sm text-ink-soft">Set your total above to see a full breakdown.</p>
<% end %>

<div class="mt-5">
<h3 class="text-[11px] font-semibold uppercase tracking-wide text-ink-faint">Ongoing giving</h3>
<p class="mt-1 font-serif text-4xl font-medium text-ink"><%= number_to_currency(ongoing_amount, precision: 0) %></p>
<p class="mt-1 font-serif text-4xl font-medium text-ink"><%= number_to_currency(ongoing_amount) %></p>
<% if scenario.ongoing_allocations.any? %>
<p class="mt-1 text-sm text-ink-soft">
<%= ongoing_total %>% allocated across <%= pluralize(scenario.ongoing_allocations.count, "cause") %>
Expand Down Expand Up @@ -72,11 +72,11 @@
</span>
<span class="flex items-baseline gap-3">
<span class="font-medium text-ink"><%= allocation.percentage %>%</span>
<span class="text-ink-soft"><%= number_to_currency(allocation.dollar_amount, precision: 0) %></span>
<span class="text-ink-soft"><%= number_to_currency(allocation.dollar_amount) %></span>
</span>
</div>
<p class="ml-[18px] mt-0.5 text-xs text-ink-faint">
&infin; Est. <%= number_to_currency(allocation.perpetuity_annual_amount, precision: 0) %> /yr in perpetuity
&infin; Est. <%= number_to_currency(allocation.perpetuity_annual_amount) %> /yr in perpetuity
</p>
</li>
<% end %>
Expand All @@ -93,7 +93,7 @@
One time giving
<span class="rounded border border-line px-1.5 py-0.5 text-[10px] font-medium tracking-normal text-ink-soft">ADD-ON</span>
</h3>
<span class="font-medium text-ink"><%= number_to_currency(one_time_amount, precision: 0) %></span>
<span class="font-medium text-ink"><%= number_to_currency(one_time_amount) %></span>
</div>
<ul class="mt-3 space-y-2">
<% scenario.one_time_allocations.each do |allocation| %>
Expand All @@ -104,19 +104,19 @@
</span>
<span class="flex items-baseline gap-3">
<span class="font-medium text-ink-soft"><%= allocation.share_percentage %>%</span>
<span class="text-ink-soft"><%= number_to_currency(allocation.amount, precision: 0) %></span>
<span class="text-ink-soft"><%= number_to_currency(allocation.amount) %></span>
</span>
</li>
<% end %>
</ul>
</div>
<% end %>

<% if total_amount.positive? %>
<% if total_amount.present? && total_amount.positive? %>
<div class="mt-6 rounded-xl border border-line bg-surface-soft p-4 text-sm text-ink-soft">
Ongoing <span class="font-medium text-brand"><%= number_to_currency(ongoing_amount, precision: 0) %></span>
+ One-time <span class="font-medium text-ink"><%= number_to_currency(one_time_amount, precision: 0) %></span>
= Total <span class="font-medium text-ink"><%= number_to_currency(total_amount, precision: 0) %></span>
Ongoing <span class="font-medium text-brand"><%= number_to_currency(ongoing_amount) %></span>
+ One-time <span class="font-medium text-ink"><%= number_to_currency(one_time_amount) %></span>
= Total <span class="font-medium text-ink"><%= number_to_currency(total_amount) %></span>
</div>
<% end %>
</div>
2 changes: 1 addition & 1 deletion app/views/scenarios/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
<%= link_to scenario.name, scenario_path(scenario), class: "font-serif font-medium text-lg text-ink hover:text-brand" %>
</div>
<p class="mt-2 text-ink-soft">
<%= scenario.total_giving_amount.present? ? number_to_currency(scenario.total_giving_amount, precision: 0) : "No total set" %>
<%= scenario.total_giving_amount.present? ? number_to_currency(scenario.total_giving_amount) : "No total set" %>
</p>
<div class="mt-4 flex items-center gap-3 text-sm">
<%= link_to "Open", scenario_path(scenario), class: "text-brand hover:underline" %>
Expand Down
5 changes: 2 additions & 3 deletions app/views/scenarios/total_giving_amounts/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@
<%= form.label :total_giving_amount, "Total giving amount", class: "w-40 text-ink-soft" %>
<div class="relative max-w-md w-full">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-ink-faint">$</span>
<%= form.number_field :total_giving_amount, step: "1", min: 0, autofocus: true, placeholder: "0",
inputmode: "numeric",
data: { controller: "integer-input", action: "input->integer-input#transform" },
<%= form.number_field :total_giving_amount, value: scenario.total_giving_amount&.to_i, step: "1", min: 0, autofocus: true, placeholder: "0",
inputmode: "decimal",
class: "block w-full rounded-md border border-line bg-surface-soft pl-7 pr-3 py-2 shadow-sm focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/10" %>
</div>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<span class="w-40 text-ink-soft">Total giving amount</span>
<% if scenario.total_giving_amount.present? %>
<%= link_to edit_scenario_total_giving_amount_path(scenario), class: "group inline-flex items-center gap-2" do %>
<span class="font-medium text-ink"><%= number_to_currency(scenario.total_giving_amount, precision: 0) %></span>
<span class="font-medium text-ink"><%= number_to_currency(scenario.total_giving_amount) %></span>
<span class="text-ink-faint opacity-0 transition group-hover:opacity-100" aria-hidden="true">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931z" />
Expand Down
22 changes: 22 additions & 0 deletions config/initializers/money.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# encoding : utf-8

MoneyRails.configure do |config|
# Monetary amounts in this app are always USD, stored as integer cents in
# `_cents` columns.
config.default_currency = :usd

# Use whole-dollar formatting (no cents) for display in views.
config.no_cents_if_whole = true
end

# Make `number_to_currency` delegate to the money gem whenever it receives a
# Money object, so display formatting (currency, no_cents_if_whole) is handled
# by Money instead of Rails' numeric formatter.
module MoneyNumberToCurrency
def number_to_currency(number, options = {})
return number.format(options) if number.is_a?(Money)

super
end
end
ActionView::Helpers::NumberHelper.prepend(MoneyNumberToCurrency)
17 changes: 17 additions & 0 deletions db/migrate/20260829000000_store_amounts_in_cents.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
class StoreAmountsInCents < ActiveRecord::Migration[8.1]
def up
rename_column :scenarios, :total_giving_amount, :total_giving_amount_cents
rename_column :allocations, :amount, :amount_cents

execute "UPDATE scenarios SET total_giving_amount_cents = total_giving_amount_cents * 100"
execute "UPDATE allocations SET amount_cents = amount_cents * 100"
end

def down
execute "UPDATE scenarios SET total_giving_amount_cents = total_giving_amount_cents / 100"
execute "UPDATE allocations SET amount_cents = amount_cents / 100"

rename_column :scenarios, :total_giving_amount_cents, :total_giving_amount
rename_column :allocations, :amount_cents, :amount
end
end
6 changes: 3 additions & 3 deletions db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading