Files
sure/app/models/recurring_transaction/frequency_preset.rb
T
Brandon 686205c0ff feat(bills): schema and domain core for the bills subsystem (#3201)
* feat(bills): schema and domain core for the bills subsystem

First of three chunks carved out of #3083. This one carries the schema and the
domain layer: no bills pages, no calendar feed, no assistant tools. Nothing here
is reachable from the UI yet, so it changes no user-visible behavior on its own.

Schema, in a single migration with a full down:

- recurrence_rules, recurring_occurrences, recurring_allocations,
  recurring_price_changes and recurring_match_rejections
- bill columns on recurring_transactions (bill_type, payment_url, autopay,
  notes, anchor and end conditions, weekend adjustment, dedup scope)
- the four data backfills, in their original order

Domain layer:

- Schedule, the pure date PORO every cadence resolves through, and
  FrequencyPreset for the labels
- OccurrenceGenerator, Matcher, Allocator, PriceChangeDetector, Classifier,
  DeclaredBill, HistoryBackfiller and PaycheckPlanner
- Pipeline, tying detection to generation, plus the nightly job and rake task

Existing detection code changed in three places, each a bug this schema exposes:

- Cleaner used a flat two-month staleness threshold, which silently retired
  every quarterly and annual series
- SubscriptionAuditGenerator used a flat 45-day overdue threshold, meaningless
  at both ends of the frequency range
- CashFlowWarningGenerator read one projected entry per series, which only
  equalled the monthly amount because every series was monthly; weekly bills
  were under-counted fourfold in its 30-day projection

The JSON API travels with the model rather than the UI, because the status enum
widens here. The API accepts only active and inactive on write; suggested,
paused and ended are lifecycle states owned by detection, so the documented
enum stays truthful.

Uniqueness keys gain dedup_scope alongside amount, never instead of it: a
series that is not price-forked carries a blank scope, so amount is what keeps
two different prices apart.

Suite 7,550 runs, 0 failures. Rubocop and brakeman clean. Eager loading
verified, and the migration reverses and re-applies. Includes the first review round: orphan repair matches income and refuses coincidental twins, session imports persist occurrence mappings across chunks, semimonthly anchors canonicalize, classifier keywords match whole words, and the down refuses rather than failing when price-forked rows exist.

* Address second review round

Bound the cross-currency default allocation by the entry leftover and the
occurrence remainder, matching the same-currency path. Let keyword stems
carry a suffix again after the word-boundary fix silenced them. Skip an
incoherent recurrence rule row instead of rolling back the whole import.
Check rollback collisions per restored index so a refusal cannot land
after the bills tables are dropped. Replay the closed_at test through a
real second import. Preload the orphan repair associations and move the
allocator errors to locale keys.

* Match index NULL semantics in the rollback collision checks

GROUP BY treats NULLs as equal but the restored unique indexes do not:
account_id is nullable and indexed, so two accountless rows can never
collide under any of them. Excluding NULL accounts keeps the guard from
refusing a rollback PostgreSQL can perform. Verified live both ways:
accountless duplicates roll back, a real collision still refuses.

* Address maintainer review

Scope the payable debt-destination subquery to the row and its family
instead of scanning every account in the installation. Batch the cash
flow generator remaining-amount sums into one grouped query, matching
the two sibling sites. Enforce both window bounds in the after_count
branch so a future-anchored plan cannot leak past the requested end
date. Skip the explicit regeneration when the day column change will
fire the model callback anyway. Add the missing locale entry for the
allocation currency validation.
2026-08-31 23:41:38 +02:00

211 lines
9.0 KiB
Ruby

class RecurringTransaction
# Translates between the frequency picker's presets and recurrence_rules
# rows. A preset is a named rule shape (biweekly = one weekly rule with
# interval 2); anything the picker cannot express reads back as CUSTOM and
# is left untouched.
class FrequencyPreset
PRESETS = %w[monthly weekly biweekly semimonthly quarterly semiannual annual].freeze
CUSTOM = "custom"
Detection = Data.define(:key, :day_of_month, :second_day_of_month, :weekday, :month_of_year)
class << self
# Reads the series' rules back into picker values. Zero rules is the
# legacy implicit monthly.
def detect(recurring)
rules = live_rules(recurring)
case
when rules.empty?
detection(key: "monthly", day_of_month: recurring.expected_day_of_month)
when rules.size == 1
detect_single(rules.first)
when rules.size == 2 && rules.all? { |rule| monthly_day_rule?(rule) }
days = canonical_semimonthly_days(rules.map(&:day_of_month))
if days.uniq.size == 1
detection(key: "monthly", day_of_month: days.first)
else
detection(key: "semimonthly", day_of_month: days.first, second_day_of_month: days.last)
end
else
detection(key: CUSTOM)
end
end
# Replaces the series' rules with the preset's shape. Assigns only, so the
# caller's save persists atomically and invalid input surfaces as normal
# validation errors. An unchanged cadence is a no-op.
#
# Returns true only when it actually rewrote the cadence, so a caller can
# tell a deliberate schedule change from an unrelated edit.
def apply(recurring, preset:, day_of_month: nil, second_day_of_month: nil, weekday: nil, month_of_year: nil)
return false if preset.blank? || preset == CUSTOM
return false unless PRESETS.include?(preset)
reference = recurring.anchor_date || recurring.last_occurrence_date || Date.current
target = target_detection(recurring, reference, preset,
day_of_month: presence_int(day_of_month),
second_day_of_month: presence_int(second_day_of_month),
weekday: presence_int(weekday),
month_of_year: presence_int(month_of_year))
return false if target == detect(recurring)
write(recurring, target, reference)
true
end
# Human-readable cadence, e.g. "Every 2 weeks on Friday".
def label(recurring)
found = detect(recurring)
case found.key
when "monthly"
I18n.t("recurring_transactions.frequency.monthly", day: day_phrase(found.day_of_month))
when "weekly"
I18n.t("recurring_transactions.frequency.weekly", weekday: weekday_name(found.weekday))
when "biweekly"
I18n.t("recurring_transactions.frequency.biweekly", weekday: weekday_name(found.weekday))
when "semimonthly"
I18n.t("recurring_transactions.frequency.semimonthly",
first: day_phrase(found.day_of_month), second: day_phrase(found.second_day_of_month))
when "quarterly"
I18n.t("recurring_transactions.frequency.quarterly", day: day_phrase(found.day_of_month))
when "semiannual"
I18n.t("recurring_transactions.frequency.semiannual", day: day_phrase(found.day_of_month))
when "annual"
I18n.t("recurring_transactions.frequency.annual",
month: I18n.t("date.month_names")[found.month_of_year], day: day_phrase(found.day_of_month))
else
I18n.t("recurring_transactions.frequency.custom")
end
end
private
def detection(key:, day_of_month: nil, second_day_of_month: nil, weekday: nil, month_of_year: nil)
Detection.new(key:, day_of_month:, second_day_of_month:, weekday:, month_of_year:)
end
def detect_single(rule)
case rule.frequency
when "weekly"
case rule.interval
when 1 then detection(key: "weekly", weekday: rule.weekday)
when 2 then detection(key: "biweekly", weekday: rule.weekday)
else detection(key: CUSTOM)
end
when "monthly"
return detection(key: CUSTOM) unless rule.day_of_month.present?
case rule.interval
when 1 then detection(key: "monthly", day_of_month: rule.day_of_month)
when 3 then detection(key: "quarterly", day_of_month: rule.day_of_month)
when 6 then detection(key: "semiannual", day_of_month: rule.day_of_month)
else detection(key: CUSTOM)
end
when "yearly"
if rule.interval == 1 && rule.day_of_month.present?
detection(key: "annual", day_of_month: rule.day_of_month, month_of_year: rule.month_of_year)
else
detection(key: CUSTOM)
end
end
end
def live_rules(recurring)
recurring.recurrence_rules.reject(&:marked_for_destruction?)
end
# The Detection the submitted form values resolve to, with the same
# defaulting write() will use, so equality against detect() is exact.
def target_detection(recurring, reference, preset, day_of_month:, second_day_of_month:, weekday:, month_of_year:)
case preset
when "monthly", "quarterly", "semiannual"
detection(key: preset, day_of_month: day_of_month || recurring.expected_day_of_month)
when "weekly", "biweekly"
detection(key: preset, weekday: weekday)
when "semimonthly"
days = canonical_semimonthly_days([ day_of_month || 1, second_day_of_month || 15 ])
if days.uniq.size == 1
detection(key: "monthly", day_of_month: days.first)
else
detection(key: preset, day_of_month: days.first, second_day_of_month: days.last)
end
when "annual"
detection(key: preset, day_of_month: day_of_month || reference.day,
month_of_year: month_of_year || reference.month)
end
end
def write(recurring, target, reference)
recurring.rules_rewritten = true
recurring.recurrence_rules.each(&:mark_for_destruction)
case target.key
when "monthly"
build_rule(recurring, frequency: "monthly", day_of_month: target.day_of_month)
when "weekly"
build_rule(recurring, frequency: "weekly", weekday: target.weekday)
when "biweekly"
recurring.anchor_date ||= reference
build_rule(recurring, frequency: "weekly", weekday: target.weekday, interval: 2)
when "semimonthly"
build_rule(recurring, frequency: "monthly", day_of_month: target.day_of_month)
build_rule(recurring, frequency: "monthly", day_of_month: target.second_day_of_month, position: 1)
when "quarterly"
recurring.anchor_date ||= reference
build_rule(recurring, frequency: "monthly", day_of_month: target.day_of_month, interval: 3)
when "semiannual"
recurring.anchor_date ||= reference
build_rule(recurring, frequency: "monthly", day_of_month: target.day_of_month, interval: 6)
when "annual"
build_rule(recurring, frequency: "yearly", day_of_month: target.day_of_month,
month_of_year: target.month_of_year)
end
recurring.expected_day_of_month = authoritative_day(target.key, target.day_of_month, reference)
end
# LAST sorts as the day it stands for, the end of the month, so
# (15, LAST) and (LAST, 15) are one schedule. Without one canonical
# order each reapply rewrote the rules instead of no-opping. Two equal
# anchors are one monthly schedule, not a semimonthly pair.
def canonical_semimonthly_days(days)
days.sort_by { |day| day == RecurrenceRule::LAST ? 32 : day }
end
def monthly_day_rule?(rule)
rule.frequency == "monthly" && rule.interval == 1 && rule.day_of_month.present?
end
def build_rule(recurring, position: 0, **attrs)
recurring.recurrence_rules.build(position: position, **attrs)
end
# expected_day_of_month stays NOT NULL and authoritative for monthly
# cadences; other cadences populate it but do not schedule from it.
def authoritative_day(preset, day, reference)
case preset
when "monthly", "semimonthly", "quarterly", "semiannual", "annual"
day == RecurrenceRule::LAST ? 31 : day
else
reference.day
end
end
def presence_int(value)
value.present? ? value.to_i : nil
end
def day_phrase(day)
return I18n.t("recurring_transactions.frequency.last_day") if day == RecurrenceRule::LAST
day.ordinalize
end
def weekday_name(weekday)
I18n.t("date.day_names")[weekday]
end
end
end
end