Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
80a885c
docs: intent and plan for cents-instead-of-decimals
eirvandelden Oct 16, 2026
67d5c40
Add failing migration test for 1,5 stored as 150 cents
eirvandelden Oct 16, 2026
a252602
Convert money columns to integer cents
eirvandelden Oct 16, 2026
c64b1fb
Declare money attributes as cents and move fixtures to cents
eirvandelden Oct 16, 2026
5898b41
Show money from cents in views, helpers and forms
eirvandelden Oct 16, 2026
60edcc7
Compute dashboard and budget suggestions in exact cents
eirvandelden Oct 16, 2026
56a370c
Read bank and packing slip amounts as cents
eirvandelden Oct 16, 2026
4684d19
Keep MCP tools and JSON in euros over cents
eirvandelden Oct 16, 2026
d9bf5ce
Move model and view tests to cents
eirvandelden Oct 16, 2026
d9814fd
Move integration tests to cents and add amount form tests
eirvandelden Oct 17, 2026
6def09a
Add real-data after snapshot script
eirvandelden Oct 17, 2026
dcaedc3
docs: plan step 15, JSON decimal input
eirvandelden Oct 17, 2026
bd5b5e3
Read numeric JSON amounts as euros in cents
eirvandelden Oct 17, 2026
c5717c8
Test the euro amounts that two assistant tools print
eirvandelden Oct 17, 2026
45d4ff3
Test the amount in the transaction JSON partial
eirvandelden Oct 17, 2026
854a8c6
Show one message for an unreadable amount
eirvandelden Oct 17, 2026
99220aa
Keep transactions without an amount out of the amount search
eirvandelden Oct 17, 2026
f8ff88c
Name five tests after the plan Proof list
eirvandelden Oct 17, 2026
60ca4a9
Review for cents-instead-of-decimals
eirvandelden Oct 17, 2026
e95037f
Move appropriation charge tests from main to cents
eirvandelden Oct 22, 2026
abdcdcd
Return a copy from euros_to_cents instead of mutating its parameter
eirvandelden Oct 22, 2026
02e1772
Review for cents-instead-of-decimals
eirvandelden Oct 22, 2026
9159722
Remove change artifacts for cents-instead-of-decimals
eirvandelden Oct 22, 2026
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
5 changes: 4 additions & 1 deletion app/controllers/chattels_controller.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Manages household chattels (valuable items with warranties and insurance)
class ChattelsController < ApplicationController
include MoneyParams

before_action :set_chattel, only: %i[show edit update destroy]
before_action :set_brands_in_use, only: %i[show new create edit update]

Expand Down Expand Up @@ -92,9 +94,10 @@ def set_brands_in_use

# Only allow a list of trusted parameters through.
def chattel_params
params.require(:chattel).permit(:name, :brand, :kind, :model_number, :serial_number,
permitted = params.require(:chattel).permit(:name, :brand, :kind, :model_number, :serial_number,
:purchase_transaction_id, :purchased_at,
:warranty_expires_at, :left_possession_at,
:purchase_price, :notes, :warranty_document)
euros_to_cents(permitted, :purchase_price)
end
end
23 changes: 23 additions & 0 deletions app/controllers/concerns/money_params.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Reads numeric JSON money amounts, which are euros, as cents.
module MoneyParams
extend ActiveSupport::Concern

private

# Converts numeric values under the given keys with Cents.from_euros. A number with a
# fraction of a cent becomes text, so the model refuses it as not an amount.
#
# @param permitted [ActionController::Parameters]
# @param keys [Array<Symbol>]
# @return [ActionController::Parameters] a copy; the given parameters stay unchanged
def euros_to_cents(permitted, *keys)
numbers = permitted.slice(*keys).to_h.select { |_key, value| value.is_a?(Numeric) }
permitted.merge(numbers.transform_values { |number| cents_from(number) })
end

def cents_from(number)
Cents.from_euros(number)
rescue ArgumentError
number.to_s
end
end
7 changes: 5 additions & 2 deletions app/controllers/transactions_controller.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Manages transaction resources
class TransactionsController < ApplicationController
include MoneyParams

before_action :set_transaction, only: %i[show edit update destroy]

# Lists all transactions with optional type, category, account, and date range filtering
Expand Down Expand Up @@ -87,7 +89,8 @@ def set_transaction

def transaction_params
key = (params.keys & %w[debit credit transfer transaction])[0]
params.require(key).permit(:id, :debit_account_id, :credit_account_id, :amount, :booked_at, :interest_at,
:category_id, :note, :type, :proof_of_purchase)
permitted = params.require(key).permit(:id, :debit_account_id, :credit_account_id, :amount, :booked_at,
:interest_at, :category_id, :note, :type, :proof_of_purchase)
euros_to_cents(permitted, :amount)
end
end
29 changes: 29 additions & 0 deletions app/helpers/money_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Shows money that is stored as Integer cents.
module MoneyHelper
# Renders cents as currency, the way the euro amount rendered before cents.
#
# @param cents [Integer, nil]
# @param options [Hash] passed to number_to_currency
# @return [String, nil]
def cents_to_currency(cents, **options)
number_to_currency(Cents.to_euros(cents), **options)
end

# The value to put in a money form field.
#
# Text the user typed that could not be read comes back unchanged, so the form
# shows what they typed. Otherwise the cents show as euros with two decimals.
#
# @param record [ActiveRecord::Base]
# @param attribute [Symbol]
# @param separator [String] decimal separator of the field: the locale's for text fields, "." for number fields
# @return [String, nil]
def cents_field_value(record, attribute, separator:)
cents = record.public_send(attribute)
typed = record.public_send(:"#{attribute}_before_type_cast")
return typed if cents.nil? && typed.is_a?(String) && typed.present?
return if cents.nil?

format("%.2f", Cents.to_euros(cents)).tr(".", separator)
end
end
2 changes: 1 addition & 1 deletion app/helpers/products_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,6 @@ def price_chart_points(prices, highest)
private

def chart_y(price, highest)
(CHART_HEIGHT - (price / highest * CHART_HEIGHT)).round(1)
(CHART_HEIGHT - (price.to_d / highest * CHART_HEIGHT)).round(1)
end
end
2 changes: 1 addition & 1 deletion app/helpers/todos_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@ module TodosHelper
def todo_description(item)
return item.record.to_s unless item.kind == :transaction

safe_join([ item.record.note&.truncate(40), number_to_currency(item.record.uncategorized_amount) ].compact, " ")
safe_join([ item.record.note&.truncate(40), cents_to_currency(item.record.uncategorized_amount) ].compact, " ")
end
end
10 changes: 7 additions & 3 deletions app/models/appropriation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
# Credits and split parts of credits are charged to it, and debits are refunded to it, until the books
# use double-entry bookkeeping; what is left never carries over to another year.
class Appropriation < ApplicationRecord
include Monetary

monetary :amount

belongs_to :person, optional: true

has_many :transactions, dependent: :restrict_with_error
Expand All @@ -11,7 +15,7 @@ class Appropriation < ApplicationRecord
validates :purpose, presence: true
validates :recipient, presence: true
validates :budget_year, presence: true, numericality: { only_integer: true }
validates :amount, presence: true, numericality: { greater_than: 0 }
validates :amount, presence: true, comparison: { greater_than: 0 }
validate :managed_person_required
validate :one_per_purpose_person_and_year
validate :legacy_recipient_is_unchanged
Expand Down Expand Up @@ -67,12 +71,12 @@ def recipient_name

# Charged credits and credit parts, minus charged debits
#
# @return [BigDecimal]
# @return [Integer] cents
def charged = @charged ||= charged_on("Credit") - charged_on("Debit")

# What is left of the amount appropriated; negative when overspent
#
# @return [BigDecimal]
# @return [Integer] cents
def balance = amount - charged

# Whether more was charged than appropriated
Expand Down
12 changes: 7 additions & 5 deletions app/models/budget.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,17 +60,19 @@ def plan(category:, amount:)
#
# Scales to the budget's period length (defaults to 30 days for open-ended budgets).
#
# @return [Hash{Category => BigDecimal}]
# Divides exactly and rounds once, to whole cents, at the end.
#
# @return [Hash{Category => Integer}] cents
def suggested_amounts
lookback = suggestion_lookback_range
period_days = suggestion_period_days

[ Debit, Credit ].each_with_object({}) do |klass, result|
exact = [ Debit, Credit ].each_with_object(Hash.new(0)) do |klass, result|
suggestion_totals_by_parent(klass, lookback).each do |parent, total|
daily_avg = total.to_f / suggestion_lookback_days(lookback)
result[parent] = ((result[parent] || 0) + daily_avg * period_days).round(2)
result[parent] += Rational(total * period_days, suggestion_lookback_days(lookback))
end
end
exact.transform_values(&:round)
end

# Applies suggested amounts to budget_categories in memory (does not save).
Expand Down Expand Up @@ -219,6 +221,6 @@ def suggestion_totals_by_parent(klass, lookback)
end

def suggestion_lookback_days(range)
((range.end.to_date - range.begin.to_date).to_i + 1).to_f
(range.end.to_date - range.begin.to_date).to_i + 1
end
end
6 changes: 5 additions & 1 deletion app/models/budget_category.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@
# Associates a Category with a Budget and stores the planned spending amount.
# The category must be a top-level (parent) category.
class BudgetCategory < ApplicationRecord
include Monetary

monetary :amount

belongs_to :budget
belongs_to :category

validates :amount, presence: true, numericality: { greater_than: 0 }
validates :amount, presence: true, comparison: { greater_than: 0 }
validates :budget_id, uniqueness: { scope: :category_id }
validate :category_must_be_parent
validate :category_must_not_be_transfer
Expand Down
71 changes: 71 additions & 0 deletions app/models/cents.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Conversions between money text, decimal euros and whole cents.
#
# Money is stored and calculated as Integer cents. This module holds the one rule
# for reading what a user types and for showing cents as euros.
module Cents
AMOUNT = /\A(?<sign>-)?(?<digits>\d[\d.,]*)\z/
GROUPED = /\A\d{1,3}([.,]\d{3})+\z/
SEPARATOR_WITH_CENTS = /\A(?<whole>.*?)[.,](?<cents>\d{1,2})\z/

# Reads money text such as "1,50", "1.5" or "1.234,56" as cents.
#
# The last "," or "." followed by one or two digits is the decimal separator;
# every other separator must start a group of exactly three digits. A lone
# separator followed by three digits ("1.500") is ambiguous and is refused.
#
# @param text [String, nil]
# @return [Integer, nil] cents, or nil when the text is not an amount
def self.parse(text)
match = AMOUNT.match(text.to_s.strip) or return
whole, cents = split_decimals(match[:digits])
return unless whole

value = (whole.to_i * 100) + cents
match[:sign] ? -value : value
end

# Converts a number in euros to cents without float noise.
#
# @param euros [Numeric]
# @return [Integer] cents
# @raise [ArgumentError] when the amount has a fraction of a cent
def self.from_euros(euros)
cents = BigDecimal(euros.to_s) * 100
raise ArgumentError, "#{euros} has a fraction of a cent" unless cents.frac.zero?

cents.to_i
end

# @param cents [Integer, nil]
# @return [BigDecimal, nil] euros
def self.to_euros(cents)
return if cents.nil?

BigDecimal(cents) / 100
end

# @return [Array(String, Integer), nil] whole euros as digits and the cents
def self.split_decimals(digits)
match = SEPARATOR_WITH_CENTS.match(digits)
return whole_euros(digits) unless match

whole = match[:whole]
return unless whole.match?(/\A\d*\z/) || grouped?(whole)

[ whole.delete(".,").presence || "0", match[:cents].ljust(2, "0").to_i ]
end

# @return [Array(String, Integer), nil]
def self.whole_euros(digits)
return [ digits, 0 ] if digits.match?(/\A\d+\z/)
return [ digits.delete(".,"), 0 ] if grouped?(digits) && digits.count(".,") > 1

nil
end

def self.grouped?(text)
text.match?(GROUPED) && text.delete("0-9").chars.uniq.one?
end

private_class_method :split_decimals, :whole_euros, :grouped?
end
25 changes: 25 additions & 0 deletions app/models/cents/type.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Attribute type for money stored as Integer cents.
#
# Integers pass through as cents, text is read as euros through Cents.parse, and
# any other number raises, so that 12 and 12.0 can never mean different amounts.
class Cents::Type < ActiveModel::Type::Value
# @return [Symbol]
def type = :integer

# @param value [Integer, String, nil]
# @return [Integer, nil] cents
# @raise [ArgumentError] for a Float, BigDecimal or other non-Integer number
def cast(value)
case value
when nil, Integer then value
when String then Cents.parse(value)
when Numeric then raise ArgumentError, "convert #{value.class} euros with Cents.from_euros"
end
end

# @return [Integer, nil]
def serialize(value) = cast(value)

# @return [Integer, nil]
def deserialize(value) = value&.to_i
end
5 changes: 4 additions & 1 deletion app/models/chattel.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
class Chattel < ApplicationRecord
include Searchable
include PdfAttachmentValidatable
include Monetary

monetary :purchase_price

searchable_on :name, :brand, :kind, :model_number, :serial_number, :notes

Expand All @@ -9,7 +12,7 @@ class Chattel < ApplicationRecord
has_one_attached :warranty_document

validates :name, presence: true
validates :purchase_price, numericality: { greater_than: 0 }, allow_nil: true
validates :purchase_price, comparison: { greater_than: 0 }, allow_nil: true
validates_pdf_attachment_of :warranty_document

scope :active, -> { where(left_possession_at: nil) }
Expand Down
11 changes: 7 additions & 4 deletions app/models/concerns/linkable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,14 @@ def suggested_transfers
# Both params are optional and combined with AND when both present.
#
# @param query [String, nil] Matches note, creditor name, or debitor name
# @param amount [String, nil] Exact amount match
# @param amount [String, nil] Exact amount match, in euros as typed ("12,50")
# @return [ActiveRecord::Relation<Transaction>]
def searchable_transfers(query: nil, amount: nil)
return Transaction.none if query.blank? && amount.blank?

cents = Cents.parse(amount)
return Transaction.none if amount.present? && cents.nil?

scope = Transaction.unscoped
.where(type: "Transfer")
.where.missing(:reverse_transaction_links)
Expand All @@ -60,15 +63,15 @@ def searchable_transfers(query: nil, amount: nil)
)
end

scope = scope.where(amount: amount.to_d) if amount.present?
scope = scope.where(amount: cents) if cents
scope
end

# Remaining amount not yet covered by linked transfers
#
# @return [BigDecimal] 0 when fully covered, positive when underpull
# @return [Integer] cents, 0 when fully covered, positive when underpull
def link_balance
amount - covered_amount
amount.to_i - covered_amount
end

# Whether linked transfers fully cover this transaction's amount
Expand Down
34 changes: 34 additions & 0 deletions app/models/concerns/monetary.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Declares money attributes stored as Integer cents
#
# monetary :amount
#
# A user's text such as "1,50" is read as 150. Text that is not an amount fails
# validation instead of silently becoming blank.
module Monetary
extend ActiveSupport::Concern

class_methods do
# @param names [Array<Symbol>] attributes that hold cents
# @return [void]
def monetary(*names)
names.each do |name|
attribute name, Cents::Type.new
validate { errors.add(name, :not_an_amount) if unreadable_amount?(name) }
after_validation { keep_only_not_an_amount(name) if unreadable_amount?(name) }
end
end
end

private

# An unreadable amount is also blank, so other validators add messages that only repeat the problem
def keep_only_not_an_amount(name)
errors.delete(name)
errors.add(name, :not_an_amount)
end

def unreadable_amount?(name)
typed = public_send(:"#{name}_before_type_cast")
typed.is_a?(String) && typed.present? && public_send(name).nil?
end
end
Loading
Loading