mirror of
https://github.com/we-promise/sure.git
synced 2026-09-06 15:14:19 +00:00
* fix(import): tolerate null Rule names and orphaned rejected transfers Importing a full all.ndjson export aborted on data that is actually valid. The preflight listed name as a required field for Rule, but rules.name is nullable and the model allows it, so a single rule with "name": null blocked the entire import. Separately, a RejectedTransfer whose referenced transaction had been deleted raised a hard missing_reference error in preflight and a MissingReferenceError in strict mode, even though the importer already had a skip path for it. Require Rule.id instead of Rule.name in preflight, matching the field the importer actually needs. Treat RejectedTransfer references as advisory: a missing referenced transaction becomes a warning, and the importer resolves the references with required: false so the orphaned row is skipped and counted instead of raising. Fixes #2721 * fix(import): keep SureImport preflight warnings as strings at the API boundary The orphaned-RejectedTransfer path emits warnings as {code, message} hashes via add_warning, but the published OpenAPI contract documents /api/v1/imports/preflight warnings as strings. sure_import_preflight_payload copied them through unchanged, so the endpoint returned a heterogeneous array once that path was exercised and contract-generated clients could fail to deserialize. Map warnings to their human-readable message at the API boundary so the array stays homogeneous strings, matching the documented schema. The internal {code, message} shape is unchanged. Adds a regression test. * i18n(import): localize missing-reference preflight messages The missing-reference warning and error are user-facing (returned in Result#payload[:warnings]/[:errors]) but were hard-coded. Move the full templates to config/locales/models/sure_import/preflight/en.yml and interpolate line, type, field, and value, per the i18n coding guideline. Warning key and error behavior are unchanged, and the rendered text is identical. --------- Co-authored-by: agentloop <agentloop@localhost> Co-authored-by: pro3958 <pro3958@users.noreply.github.com>
330 lines
13 KiB
Ruby
330 lines
13 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[id]
|
|
}.freeze
|
|
|
|
# Record types whose references are advisory: the importer skips rows whose
|
|
# referenced records are absent instead of failing, so a dangling reference is
|
|
# a warning rather than a blocking error.
|
|
SOFT_REFERENCE_TYPES = %w[RejectedTransfer].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)
|
|
interpolations = {
|
|
line: record[:line_number],
|
|
type: type,
|
|
field: field,
|
|
value: value.inspect
|
|
}
|
|
if SOFT_REFERENCE_TYPES.include?(type)
|
|
add_warning(:skipped_missing_reference, I18n.t("sure_import.preflight.skipped_missing_reference", **interpolations))
|
|
else
|
|
add_error(:missing_reference, I18n.t("sure_import.preflight.missing_reference", **interpolations))
|
|
end
|
|
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 }
|
|
|
|
def add_warning(code, message) = @warnings << { code: code.to_s, message: message }
|
|
end
|