mirror of
https://github.com/we-promise/sure.git
synced 2026-07-12 12:55:20 +00:00
* fix: only mark overlapping statement periods as duplicate * fix: treat non-overlapping statement periods as covered
202 lines
6.6 KiB
Ruby
202 lines
6.6 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class AccountStatement::Coverage
|
|
Month = Struct.new(:date, :status, :statements, :ambiguous_statements, keyword_init: true) do
|
|
def expected?
|
|
status != "not_expected"
|
|
end
|
|
|
|
def covered?
|
|
status == "covered"
|
|
end
|
|
|
|
def missing?
|
|
status == "missing"
|
|
end
|
|
|
|
def duplicate?
|
|
status == "duplicate"
|
|
end
|
|
|
|
def ambiguous?
|
|
status == "ambiguous"
|
|
end
|
|
|
|
def mismatched?
|
|
status == "mismatched"
|
|
end
|
|
|
|
def not_expected?
|
|
status == "not_expected"
|
|
end
|
|
end
|
|
|
|
attr_reader :account, :start_month, :end_month, :expected_start_month, :expected_end_month, :selected_year, :available_years
|
|
|
|
class << self
|
|
def for_year(account, year)
|
|
expected_end_month = default_expected_end_month
|
|
expected_start_month = default_expected_start_month(account, fallback_end_month: expected_end_month)
|
|
available_years = years_between(expected_start_month, expected_end_month)
|
|
selected_year = resolve_year_value(year, available_years)
|
|
|
|
new(
|
|
account,
|
|
start_month: Date.new(selected_year, 1, 1),
|
|
end_month: Date.new(selected_year, 12, 1),
|
|
expected_start_month: expected_start_month,
|
|
expected_end_month: expected_end_month,
|
|
selected_year: selected_year,
|
|
available_years: available_years
|
|
)
|
|
end
|
|
|
|
def years_for(account)
|
|
expected_end_month = default_expected_end_month
|
|
expected_start_month = default_expected_start_month(account, fallback_end_month: expected_end_month)
|
|
|
|
years_between(expected_start_month, expected_end_month)
|
|
end
|
|
|
|
def resolve_year(account, year)
|
|
resolve_year_value(year, years_for(account))
|
|
end
|
|
|
|
def default_expected_end_month
|
|
Date.current.prev_month.beginning_of_month
|
|
end
|
|
|
|
def default_expected_start_month(account, fallback_end_month: default_expected_end_month)
|
|
candidates = [
|
|
account.entries.minimum(:date),
|
|
account.balances.minimum(:date),
|
|
account.account_statements.where.not(period_start_on: nil).minimum(:period_start_on),
|
|
account.family.account_statements.unmatched.where(suggested_account: account).where.not(period_start_on: nil).minimum(:period_start_on)
|
|
].compact
|
|
|
|
start_month = (candidates.min || fallback_end_month.advance(months: -11)).to_date.beginning_of_month
|
|
start_month > fallback_end_month ? fallback_end_month : start_month
|
|
end
|
|
|
|
private
|
|
|
|
def years_between(start_month, end_month)
|
|
(start_month.year..end_month.year).to_a.reverse
|
|
end
|
|
|
|
def resolve_year_value(year, available_years)
|
|
requested_year = year.to_i if year.present?
|
|
|
|
available_years.include?(requested_year) ? requested_year : available_years.first
|
|
end
|
|
end
|
|
|
|
def initialize(account, start_month: nil, end_month: nil, expected_start_month: nil, expected_end_month: nil, selected_year: nil, available_years: nil)
|
|
raise ArgumentError, "account is required" if account.nil?
|
|
|
|
@account = account
|
|
@expected_end_month = (expected_end_month || end_month || self.class.default_expected_end_month).to_date.beginning_of_month
|
|
resolved_expected_start_month = (expected_start_month || start_month || self.class.default_expected_start_month(account, fallback_end_month: @expected_end_month)).to_date.beginning_of_month
|
|
@expected_start_month = resolved_expected_start_month > @expected_end_month ? @expected_end_month : resolved_expected_start_month
|
|
@start_month = (start_month || @expected_start_month).to_date.beginning_of_month
|
|
@end_month = (end_month || @expected_end_month).to_date.beginning_of_month
|
|
@selected_year = selected_year
|
|
@available_years = available_years || self.class.years_for(account)
|
|
end
|
|
|
|
def months
|
|
@months ||= begin
|
|
current = start_month
|
|
result = []
|
|
|
|
while current <= end_month
|
|
result << build_month(current)
|
|
current = current.next_month
|
|
end
|
|
|
|
result
|
|
end
|
|
end
|
|
|
|
def summary_counts
|
|
months.group_by(&:status).transform_values(&:count)
|
|
end
|
|
|
|
private
|
|
|
|
def build_month(month)
|
|
return Month.new(date: month, status: "not_expected", statements: [], ambiguous_statements: []) unless expected_month?(month)
|
|
|
|
linked_statements = statements_covering(linked_statement_scope, month)
|
|
ambiguous_statements = statements_covering(ambiguous_statement_scope, month)
|
|
|
|
status = if linked_statements.many? && overlapping_statements?(linked_statements)
|
|
"duplicate"
|
|
elsif linked_statements.any? { |statement| statement.reconciliation_mismatched?(balance_lookup: balance_lookup) }
|
|
"mismatched"
|
|
elsif linked_statements.any?
|
|
"covered"
|
|
elsif ambiguous_statements.any?
|
|
"ambiguous"
|
|
else
|
|
"missing"
|
|
end
|
|
|
|
Month.new(date: month, status: status, statements: linked_statements, ambiguous_statements: ambiguous_statements)
|
|
end
|
|
|
|
def expected_month?(month)
|
|
month >= expected_start_month && month <= expected_end_month
|
|
end
|
|
|
|
def linked_statement_scope
|
|
@linked_statement_scope ||= account.account_statements
|
|
.where("period_start_on <= ? AND period_end_on >= ?", end_month.end_of_month, start_month)
|
|
.ordered
|
|
.to_a
|
|
end
|
|
|
|
def ambiguous_statement_scope
|
|
@ambiguous_statement_scope ||= account.family.account_statements
|
|
.unmatched
|
|
.where(suggested_account: account)
|
|
.where("period_start_on <= ? AND period_end_on >= ?", end_month.end_of_month, start_month)
|
|
.ordered
|
|
.to_a
|
|
end
|
|
|
|
def statements_covering(statements, month)
|
|
month_start = month.to_date.beginning_of_month
|
|
month_end = month_start.end_of_month
|
|
|
|
statements.select do |statement|
|
|
statement.period_start_on.present? &&
|
|
statement.period_end_on.present? &&
|
|
statement.period_start_on <= month_end &&
|
|
statement.period_end_on >= month_start
|
|
end
|
|
end
|
|
|
|
def overlapping_statements?(statements)
|
|
statements.combination(2).any? do |a, b|
|
|
a.period_start_on <= b.period_end_on &&
|
|
b.period_start_on <= a.period_end_on
|
|
end
|
|
end
|
|
|
|
def balance_lookup
|
|
@balance_lookup ||= begin
|
|
currencies = linked_statement_scope.map(&:statement_currency).compact.uniq
|
|
dates = linked_statement_scope.flat_map { |statement| [ statement.period_start_on, statement.period_end_on ] }.compact.uniq
|
|
balances = if currencies.any? && dates.any?
|
|
account.balances.where(currency: currencies, date: dates).to_a
|
|
else
|
|
[]
|
|
end
|
|
by_key = balances.index_by { |balance| [ balance.date, balance.currency ] }
|
|
|
|
->(date, currency) { by_key[[ date, currency ]] }
|
|
end
|
|
end
|
|
end
|