mirror of
https://github.com/we-promise/sure.git
synced 2026-06-04 02:09:01 +00:00
313 lines
12 KiB
Ruby
313 lines
12 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "set"
|
|
|
|
class SureImport::Preflight
|
|
Result = Struct.new(:errors, :warnings, :stats, keyword_init: true) do
|
|
def valid? = errors.empty?
|
|
def error_messages = errors.map { |error| error[:message] }
|
|
def error_message = valid? ? "" : ([ "Sure import preflight failed:" ] + error_messages).join("\n")
|
|
def payload = { valid: valid?, stats: stats, errors: errors, warnings: warnings }
|
|
end
|
|
|
|
REQUIRED_FIELDS = {
|
|
"Account" => %w[id name balance accountable_type],
|
|
"Balance" => %w[account_id date balance],
|
|
"Category" => %w[id name],
|
|
"Tag" => %w[id name],
|
|
"Merchant" => %w[id name],
|
|
"RecurringTransaction" => %w[id amount expected_day_of_month last_occurrence_date next_expected_date],
|
|
"Transaction" => %w[id account_id date amount],
|
|
"Transfer" => %w[inflow_transaction_id outflow_transaction_id],
|
|
"RejectedTransfer" => %w[inflow_transaction_id outflow_transaction_id],
|
|
"Trade" => %w[account_id date amount qty price ticker],
|
|
"Holding" => %w[account_id date amount qty price ticker],
|
|
"Valuation" => %w[account_id date amount],
|
|
"Budget" => %w[id start_date end_date],
|
|
"BudgetCategory" => %w[budget_id category_id],
|
|
"Rule" => %w[name]
|
|
}.freeze
|
|
|
|
TAXONOMY_TYPES = { "Category" => :categories, "Tag" => :tags, "Merchant" => :merchants }.freeze
|
|
|
|
SOURCE_ID_TYPES = TAXONOMY_TYPES.merge(
|
|
"Account" => :accounts,
|
|
"RecurringTransaction" => :recurring_transactions,
|
|
"Transaction" => :transactions,
|
|
"Budget" => :budgets
|
|
).freeze
|
|
|
|
REFERENCE_FIELDS = {
|
|
"Balance" => { accounts: %w[account_id] },
|
|
"Category" => { categories: %w[parent_id] },
|
|
"RecurringTransaction" => { accounts: %w[account_id], merchants: %w[merchant_id] },
|
|
"Transaction" => { accounts: %w[account_id], categories: %w[category_id], merchants: %w[merchant_id] },
|
|
"Transfer" => { transactions: %w[inflow_transaction_id outflow_transaction_id] },
|
|
"RejectedTransfer" => { transactions: %w[inflow_transaction_id outflow_transaction_id] },
|
|
"Trade" => { accounts: %w[account_id] },
|
|
"Holding" => { accounts: %w[account_id] },
|
|
"Valuation" => { accounts: %w[account_id] },
|
|
"BudgetCategory" => { budgets: %w[budget_id], categories: %w[category_id] }
|
|
}.freeze
|
|
|
|
def initialize(family:, content:)
|
|
@family = family
|
|
@content = content.to_s
|
|
@errors = []
|
|
@warnings = []
|
|
@line_counts = Hash.new(0)
|
|
@records = Hash.new { |hash, key| hash[key] = [] }
|
|
@source_ids = Hash.new { |hash, key| hash[key] = Set.new }
|
|
@source_id_locations = Hash.new { |hash, key| hash[key] = Hash.new { |ids, id| ids[id] = [] } }
|
|
@rows_count = 0
|
|
@valid_rows_count = 0
|
|
end
|
|
|
|
def call
|
|
parse_records
|
|
validate_taxonomy_collisions
|
|
validate_duplicate_taxonomy_names
|
|
validate_duplicate_source_ids
|
|
validate_required_fields
|
|
validate_accountables
|
|
validate_split_lines
|
|
validate_references
|
|
validate_duplicate_valuations
|
|
Result.new(
|
|
errors: @errors,
|
|
warnings: @warnings,
|
|
stats: {
|
|
rows_count: @rows_count,
|
|
valid_rows_count: @valid_rows_count,
|
|
invalid_rows_count: @rows_count - @valid_rows_count,
|
|
entity_counts: SureImport.dry_run_totals_from_line_type_counts(@line_counts),
|
|
record_type_counts: @line_counts
|
|
}
|
|
)
|
|
end
|
|
|
|
private
|
|
attr_reader :family
|
|
|
|
def parse_records
|
|
@content.each_line.with_index(1) do |line, line_number|
|
|
next if line.strip.blank?
|
|
@rows_count += 1
|
|
record = JSON.parse(line)
|
|
unless record.is_a?(Hash)
|
|
add_error(:invalid_ndjson_record, "Line #{line_number} must be a JSON object.")
|
|
next
|
|
end
|
|
|
|
type = record["type"]
|
|
data = record["data"]
|
|
if type.blank? || !record.key?("data")
|
|
add_error(:invalid_ndjson_record, "Line #{line_number} must include type and data.")
|
|
next
|
|
end
|
|
|
|
@line_counts[type] += 1
|
|
unless Family::DataImporter::SUPPORTED_TYPES.include?(type)
|
|
add_error(:unsupported_record_type, "Line #{line_number} has unsupported record type #{type}.")
|
|
next
|
|
end
|
|
|
|
unless data.is_a?(Hash)
|
|
add_error(:invalid_ndjson_record, "Line #{line_number} data must be a JSON object.")
|
|
next
|
|
end
|
|
|
|
@valid_rows_count += 1
|
|
@records[type] << { line_number: line_number, data: data }
|
|
mapping_key = SOURCE_ID_TYPES[type]
|
|
track_source_id(mapping_key, data["id"], "Line #{line_number} #{type}") if mapping_key && data["id"].present?
|
|
add_split_line_source_ids(data, line_number) if type == "Transaction"
|
|
rescue JSON::ParserError => e
|
|
add_error(:invalid_json, "Line #{line_number} is not valid JSON: #{e.message}")
|
|
end
|
|
|
|
add_error(:no_data_rows, "No data rows were found.") if @rows_count.zero?
|
|
end
|
|
|
|
def track_source_id(mapping_key, id, location)
|
|
id = id.to_s
|
|
@source_ids[mapping_key].add(id)
|
|
@source_id_locations[mapping_key][id] << location
|
|
end
|
|
|
|
def add_split_line_source_ids(data, line_number)
|
|
split_lines = split_lines_value(data)
|
|
return unless split_lines.is_a?(Array)
|
|
split_lines.each_with_index do |split_line, index|
|
|
next unless split_line.is_a?(Hash) && split_line["id"].present?
|
|
track_source_id(:transactions, split_line["id"], "Line #{line_number} Transaction split line #{index + 1}")
|
|
end
|
|
end
|
|
|
|
def validate_taxonomy_collisions
|
|
TAXONOMY_TYPES.each do |type, association|
|
|
existing_names = family.public_send(association).pluck(:name).to_set
|
|
@records[type].each do |record|
|
|
name = record[:data]["name"].to_s
|
|
next if name.blank? || !existing_names.include?(name)
|
|
add_error(
|
|
:existing_taxonomy_collision,
|
|
"Line #{record[:line_number]} #{type} name #{name.inspect} already exists in this family."
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
def validate_duplicate_taxonomy_names
|
|
TAXONOMY_TYPES.each_key do |type|
|
|
grouped = @records[type].group_by { |record| record[:data]["name"].to_s }
|
|
grouped.each do |name, records|
|
|
next if name.blank? || records.one?
|
|
lines = records.map { |record| record[:line_number] }.join(", ")
|
|
add_error(:duplicate_taxonomy_name, "#{type} name #{name.inspect} appears more than once in the NDJSON on lines #{lines}.")
|
|
end
|
|
end
|
|
end
|
|
|
|
def validate_duplicate_source_ids
|
|
@source_id_locations.each do |mapping_key, ids|
|
|
ids.each do |id, locations|
|
|
next if locations.one?
|
|
add_error(
|
|
:duplicate_source_id,
|
|
"#{mapping_key.to_s.singularize.tr('_', ' ')} source id #{id.inspect} appears more than once (#{locations.join(', ')})."
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
def validate_required_fields
|
|
@records.each do |type, records|
|
|
required_fields = REQUIRED_FIELDS.fetch(type, [])
|
|
records.each do |record|
|
|
missing = required_fields.select { |field| blank_required_value?(record[:data][field]) }
|
|
next if missing.empty?
|
|
add_error(:missing_required_fields, "Line #{record[:line_number]} #{type} is missing required field(s): #{missing.join(', ')}.")
|
|
end
|
|
end
|
|
end
|
|
|
|
def validate_accountables
|
|
@records["Account"].each do |record|
|
|
data = record[:data]
|
|
accountable_type = data["accountable_type"].to_s
|
|
accountable_class = Family::DataImporter.accountable_class_for(accountable_type)
|
|
unless accountable_class
|
|
add_error(:invalid_accountable_type, "Line #{record[:line_number]} Account has invalid accountable_type #{accountable_type.inspect}.")
|
|
next
|
|
end
|
|
|
|
subtype = data.dig("accountable", "subtype").presence || data["subtype"].presence
|
|
next if subtype.blank?
|
|
subtype_map = accountable_class.const_defined?(:SUBTYPES) ? accountable_class::SUBTYPES : {}
|
|
next if subtype_map.blank? || subtype_map.key?(subtype)
|
|
add_error(:invalid_accountable_subtype, "Line #{record[:line_number]} Account has invalid #{accountable_type} subtype #{subtype.inspect}.")
|
|
end
|
|
end
|
|
|
|
def validate_split_lines
|
|
@records["Transaction"].each do |record|
|
|
split_lines = split_lines_value(record[:data])
|
|
next if split_lines.blank?
|
|
unless split_lines.is_a?(Array)
|
|
add_error(:invalid_split_lines, "Line #{record[:line_number]} Transaction split_lines must be an array.")
|
|
next
|
|
end
|
|
|
|
complete_amounts = true
|
|
split_lines.each_with_index do |split_line, index|
|
|
unless split_line.is_a?(Hash)
|
|
add_error(:invalid_split_line, "Line #{record[:line_number]} Transaction split line #{index + 1} must be a JSON object.")
|
|
complete_amounts = false
|
|
next
|
|
end
|
|
|
|
next unless blank_required_value?(split_line_amount(split_line))
|
|
add_error(:missing_required_fields, "Line #{record[:line_number]} Transaction split line #{index + 1} is missing required field(s): amount.")
|
|
complete_amounts = false
|
|
end
|
|
|
|
validate_split_line_total(record, split_lines) if complete_amounts && record[:data]["amount"].present?
|
|
end
|
|
end
|
|
|
|
def validate_split_line_total(record, split_lines)
|
|
expected_amount = record[:data]["amount"].to_d
|
|
split_total = split_lines.sum { |split_line| split_line_amount(split_line).to_d }
|
|
return if split_total == expected_amount
|
|
add_error(
|
|
:split_amount_mismatch,
|
|
"Line #{record[:line_number]} Transaction split line amounts must sum to transaction amount #{expected_amount.to_s('F')} but sum to #{split_total.to_s('F')}."
|
|
)
|
|
end
|
|
|
|
def validate_references
|
|
@records.each do |type, records|
|
|
reference_fields = REFERENCE_FIELDS.fetch(type, {})
|
|
records.each do |record|
|
|
reference_fields.each do |mapping_key, fields|
|
|
fields.each do |field|
|
|
validate_reference(record, type, mapping_key, field, record[:data][field])
|
|
end
|
|
end
|
|
|
|
validate_tag_references(record, type)
|
|
validate_split_line_references(record) if type == "Transaction"
|
|
end
|
|
end
|
|
end
|
|
|
|
def validate_reference(record, type, mapping_key, field, value)
|
|
return if value.blank?
|
|
return if @source_ids[mapping_key].include?(value.to_s)
|
|
add_error(:missing_reference, "Line #{record[:line_number]} #{type} references missing #{field} #{value.inspect}.")
|
|
end
|
|
|
|
def validate_tag_references(record, type)
|
|
Array(record[:data]["tag_ids"]).each do |tag_id|
|
|
validate_reference(record, type, :tags, "tag_ids", tag_id)
|
|
end
|
|
end
|
|
|
|
def validate_split_line_references(record)
|
|
split_lines = split_lines_value(record[:data])
|
|
return unless split_lines.is_a?(Array)
|
|
Array(split_lines).each do |split_line|
|
|
next unless split_line.is_a?(Hash)
|
|
validate_reference(record, "Transaction split line", :categories, "category_id", split_line["category_id"])
|
|
validate_reference(record, "Transaction split line", :merchants, "merchant_id", split_line["merchant_id"])
|
|
Array(split_line["tag_ids"]).each do |tag_id|
|
|
validate_reference(record, "Transaction split line", :tags, "tag_ids", tag_id)
|
|
end
|
|
end
|
|
end
|
|
|
|
def split_lines_value(data) = data["split_lines"].presence || data["splitLines"].presence || data["splits"].presence
|
|
|
|
def split_line_amount(split_line) = split_line["amount"] || split_line["amount_money"] || split_line["amount_decimal"]
|
|
|
|
def validate_duplicate_valuations
|
|
seen = {}
|
|
@records["Valuation"].each do |record|
|
|
account_id = record[:data]["account_id"]
|
|
date = record[:data]["date"]
|
|
next if account_id.blank? || date.blank?
|
|
key = [ account_id.to_s, date.to_s ]
|
|
if seen.key?(key)
|
|
add_error(:duplicate_valuation, "Line #{record[:line_number]} duplicates valuation for account #{account_id.inspect} on #{date}; first seen on line #{seen[key]}.")
|
|
else
|
|
seen[key] = record[:line_number]
|
|
end
|
|
end
|
|
end
|
|
|
|
def blank_required_value?(value) = value.blank?
|
|
|
|
def add_error(code, message) = @errors << { code: code.to_s, message: message }
|
|
end
|