diff --git a/app/components/goals/card_component.rb b/app/components/goals/card_component.rb
index 0f68d4839..7ebca94dd 100644
--- a/app/components/goals/card_component.rb
+++ b/app/components/goals/card_component.rb
@@ -1,7 +1,4 @@
class Goals::CardComponent < ApplicationComponent
- RING_SIZE = 64
- RING_STROKE = 6
-
def initialize(goal:, filterable: true)
@goal = goal
@filterable = filterable
@@ -13,11 +10,13 @@ class Goals::CardComponent < ApplicationComponent
goal.progress_percent
end
- def ring_color
+ # Maps goal status to a DS::ProgressRing tone (the ring geometry/colors now
+ # live in that primitive — see #1899).
+ def ring_tone
case goal.status
- when :reached, :on_track then "var(--color-success)"
- when :behind then "var(--color-warning)"
- else "var(--color-gray-400)"
+ when :reached, :on_track then :success
+ when :behind then :warning
+ else :neutral
end
end
@@ -66,19 +65,6 @@ class Goals::CardComponent < ApplicationComponent
end
end
- def ring_circumference
- @ring_circumference ||= 2 * Math::PI * ring_radius
- end
-
- def ring_radius
- @ring_radius ||= (RING_SIZE - RING_STROKE) / 2.0
- end
-
- def ring_offset
- pct = [ [ progress_percent.to_i, 0 ].max, 100 ].min
- ring_circumference * (1 - pct / 100.0)
- end
-
def pace_line
return nil if goal.archived? || goal.paused? || goal.completed? || goal.status == :reached
diff --git a/app/controllers/api/v1/imports_controller.rb b/app/controllers/api/v1/imports_controller.rb
index 5f1e9cf3e..3c92bff20 100644
--- a/app/controllers/api/v1/imports_controller.rb
+++ b/app/controllers/api/v1/imports_controller.rb
@@ -35,14 +35,14 @@ class Api::V1::ImportsController < Api::V1::BaseController
rescue StandardError => e
Rails.logger.error "ImportsController#index error: #{e.message}"
- render json: { error: "internal_server_error", message: e.message }, status: :internal_server_error
+ render json: { error: "internal_server_error", message: "An unexpected error occurred." }, status: :internal_server_error
end
def show
render :show
rescue StandardError => e
Rails.logger.error "ImportsController#show error: #{e.message}"
- render json: { error: "internal_server_error", message: e.message }, status: :internal_server_error
+ render json: { error: "internal_server_error", message: "An unexpected error occurred." }, status: :internal_server_error
end
def rows
@@ -58,7 +58,7 @@ class Api::V1::ImportsController < Api::V1::BaseController
render :rows
rescue StandardError => e
Rails.logger.error "ImportsController#rows error: #{e.message}"
- render json: { error: "internal_server_error", message: e.message }, status: :internal_server_error
+ render json: { error: "internal_server_error", message: "An unexpected error occurred." }, status: :internal_server_error
end
def create
@@ -133,7 +133,7 @@ class Api::V1::ImportsController < Api::V1::BaseController
rescue StandardError => e
Rails.logger.error "ImportsController#create error: #{e.message}"
- render json: { error: "internal_server_error", message: e.message }, status: :internal_server_error
+ render json: { error: "internal_server_error", message: "An unexpected error occurred." }, status: :internal_server_error
end
def preflight
@@ -156,7 +156,7 @@ class Api::V1::ImportsController < Api::V1::BaseController
render json: {
error: "internal_server_error",
- message: "Error: #{e.message}"
+ message: "Import preflight could not be completed."
}, status: :internal_server_error
end
@@ -242,7 +242,7 @@ class Api::V1::ImportsController < Api::V1::BaseController
end
begin
- @import.publish_later if @import.publishable? && params[:publish] == "true"
+ @import.publish_later if params[:publish] == "true"
rescue Import::MaxRowCountExceededError
render json: {
error: "max_row_count_exceeded",
@@ -250,6 +250,22 @@ class Api::V1::ImportsController < Api::V1::BaseController
import_id: @import.id
}, status: :unprocessable_entity
return
+ rescue SureImport::PreflightError
+ render json: {
+ error: "preflight_failed",
+ message: "Import was uploaded but did not pass Sure NDJSON preflight.",
+ errors: sure_import_error_lines,
+ import_id: @import.id
+ }, status: :unprocessable_entity
+ return
+ rescue SureImport::NotPublishableError => e
+ Rails.logger.warn "Sure import not publishable for import #{@import.id}: #{e.message}"
+ render json: {
+ error: "not_publishable",
+ message: "Import was uploaded but has no publishable records.",
+ import_id: @import.id
+ }, status: :unprocessable_entity
+ return
rescue StandardError => e
Rails.logger.error "Sure import publish failed for import #{@import.id}: #{e.message}"
restore_pending_sure_import_after_publish_failure
@@ -284,6 +300,8 @@ class Api::V1::ImportsController < Api::V1::BaseController
@import.update_column(:status, "pending") if @import&.persisted? && @import.importing?
end
+ def sure_import_error_lines = @import.error.to_s.lines.map(&:strip).reject(&:blank?)
+
def clean_up_failed_sure_import(import)
return unless import
diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb
index c31df1eb0..d1b76204e 100644
--- a/app/controllers/reports_controller.rb
+++ b/app/controllers/reports_controller.rb
@@ -111,17 +111,18 @@ class ReportsController < ApplicationController
@previous_period = build_previous_period
# Get aggregated data
- @current_income_totals = Current.family.income_statement.income_totals(period: @period)
- @current_expense_totals = Current.family.income_statement.expense_totals(period: @period)
+ @income_statement = Current.family.income_statement(user: Current.user)
+ @current_income_totals = @income_statement.income_totals(period: @period)
+ @current_expense_totals = @income_statement.expense_totals(period: @period)
- @previous_income_totals = Current.family.income_statement.income_totals(period: @previous_period)
- @previous_expense_totals = Current.family.income_statement.expense_totals(period: @previous_period)
+ @previous_income_totals = @income_statement.income_totals(period: @previous_period)
+ @previous_expense_totals = @income_statement.expense_totals(period: @previous_period)
# Calculate summary metrics
@summary_metrics = build_summary_metrics
# Build trend data (last 6 months)
- @trends_data = build_trends_data
+ @trends_data = build_trends_data(income_statement: @income_statement)
# Net worth metrics
@net_worth_metrics = build_net_worth_metrics
@@ -320,7 +321,7 @@ class ReportsController < ApplicationController
nil
end
- def build_trends_data
+ def build_trends_data(income_statement:)
# Generate month-by-month data based on the current period filter
trends = []
@@ -337,8 +338,8 @@ class ReportsController < ApplicationController
period = Period.custom(start_date: month_start, end_date: month_end)
- income = Current.family.income_statement.income_totals(period: period).total
- expenses = Current.family.income_statement.expense_totals(period: period).total
+ income = income_statement.income_totals(period: period).total
+ expenses = income_statement.expense_totals(period: period).total
trends << {
month: month_start.strftime("%b %Y"),
@@ -379,8 +380,12 @@ class ReportsController < ApplicationController
trades = apply_entry_filters(trades)
# Get sort parameters
- sort_by = params[:sort_by] || "amount"
- sort_direction = params[:sort_direction] || "desc"
+ sort_by = %w[amount count].include?(params[:sort_by]) ? params[:sort_by] : "amount"
+ sort_direction = %w[asc desc].include?(params[:sort_direction]) ? params[:sort_direction] : "desc"
+ sort_logic = ->(item) do
+ value = (sort_by == "count") ? item[:count] : item[:total]
+ sort_direction == "asc" ? (value || 0) : -(value || 0)
+ end
# Group by category (tracking parent relationship) and type
# Structure: { [parent_category_id, type] => { parent_data, subcategories: { subcategory_id => data } } }
@@ -447,16 +452,12 @@ class ReportsController < ApplicationController
# Convert to array and sort subcategories
result = grouped_data.values.map do |parent_data|
- subcategories = parent_data[:subcategories].values.sort_by { |s| sort_direction == "asc" ? s[:total] : -s[:total] }
+ subcategories = parent_data[:subcategories].values.sort_by(&sort_logic)
parent_data.merge(subcategories: subcategories)
end
- # Sort by amount (total) with the specified direction
- if sort_direction == "asc"
- result.sort_by { |g| g[:total] }
- else
- result.sort_by { |g| -g[:total] }
- end
+ # Sort by the chosen key with the specified direction
+ result.sort_by(&sort_logic)
end
def build_investment_metrics
@@ -466,7 +467,6 @@ class ReportsController < ApplicationController
return { has_investments: false } unless investment_accounts.any?
period_totals = investment_statement.totals(period: @period)
-
{
has_investments: true,
portfolio_value: investment_statement.portfolio_value_money,
diff --git a/app/javascript/controllers/sankey_chart_controller.js b/app/javascript/controllers/sankey_chart_controller.js
index da4e7fb5d..b7d12c036 100644
--- a/app/javascript/controllers/sankey_chart_controller.js
+++ b/app/javascript/controllers/sankey_chart_controller.js
@@ -1,6 +1,7 @@
import { Controller } from "@hotwired/stimulus";
import * as d3 from "d3";
import { sankey } from "d3-sankey";
+import { CHART_TOOLTIP_CLASSES } from "utils/chart_tooltip";
import { sankeyNodeHasChildren, zoomSankeyData } from "utils/sankey_zoom";
// Connects to data-controller="sankey-chart"
@@ -509,10 +510,9 @@ export default class extends Controller {
this.tooltip = d3
.select(dialog || document.body)
.append("div")
- .attr(
- "class",
- "bg-container text-primary text-sm font-sans absolute p-3 rounded-xl shadow-lg shadow-border-xs pointer-events-none z-50 top-0 privacy-sensitive",
- )
+ // Shared visual contract + this chart's positioning class; opacity is
+ // toggled via inline style below.
+ .attr("class", `${CHART_TOOLTIP_CLASSES} top-0`)
.style("opacity", 0)
.style("pointer-events", "none");
}
diff --git a/app/javascript/controllers/time_series_chart_controller.js b/app/javascript/controllers/time_series_chart_controller.js
index 23b75e3eb..1bf6f8d89 100644
--- a/app/javascript/controllers/time_series_chart_controller.js
+++ b/app/javascript/controllers/time_series_chart_controller.js
@@ -1,5 +1,6 @@
import { Controller } from "@hotwired/stimulus";
import * as d3 from "d3";
+import { CHART_TOOLTIP_CLASSES } from "utils/chart_tooltip";
const parseLocalDate = d3.timeParse("%Y-%m-%d");
@@ -287,10 +288,8 @@ export default class extends Controller {
this._d3Tooltip = d3
.select(`#${this.element.id}`)
.append("div")
- .attr(
- "class",
- "bg-container text-primary text-sm font-sans absolute p-3 rounded-xl shadow-lg shadow-border-xs pointer-events-none opacity-0 top-0 z-50 privacy-sensitive",
- );
+ // Shared visual contract + this chart's initial-hidden / positioning classes.
+ .attr("class", `${CHART_TOOLTIP_CLASSES} opacity-0 top-0`);
}
_trackMouseForShowingTooltip() {
diff --git a/app/javascript/utils/chart_tooltip.js b/app/javascript/utils/chart_tooltip.js
new file mode 100644
index 000000000..e0dad3199
--- /dev/null
+++ b/app/javascript/utils/chart_tooltip.js
@@ -0,0 +1,25 @@
+// Single source of truth for the cursor-following tooltip used by the chart
+// controllers (time-series, sankey, and goal-projection once it lands from the
+// goals work). Keeping the visual contract here stops the bg / text / border /
+// privacy-sensitive classes from drifting apart across the controllers, the way
+// they had before (time-series was missing `text-primary` and `z-50`).
+//
+// This is the VISUAL contract only. Callers append their own behavioural
+// classes (initial `opacity-0`, `top-0`, …) or set them via inline styles,
+// because how each chart shows/hides and positions its tooltip differs.
+//
+// Not to be confused with DS::Tooltip — that is the info-icon hint primitive
+// (bg-inverse, aria-describedby, anchored to a static trigger). This is a
+// data-card surface created and updated inside D3 handler code.
+export const CHART_TOOLTIP_CLASSES =
+ "bg-container text-primary text-sm font-sans absolute p-3 rounded-xl shadow-lg shadow-border-xs pointer-events-none z-50 privacy-sensitive";
+
+// Convenience factory for the raw-DOM idiom (no d3.select). Creates a hidden
+// tooltip div carrying the shared contract and appends it to `parent`.
+export function createChartTooltip(parent) {
+ const tooltip = document.createElement("div");
+ tooltip.className = CHART_TOOLTIP_CLASSES;
+ tooltip.style.display = "none";
+ parent.appendChild(tooltip);
+ return tooltip;
+}
diff --git a/app/models/family/data_exporter.rb b/app/models/family/data_exporter.rb
index 0435e1ec3..404218056 100644
--- a/app/models/family/data_exporter.rb
+++ b/app/models/family/data_exporter.rb
@@ -300,27 +300,38 @@ class Family::DataExporter
}.to_json
end
- # Export transactions with full data (exclude split parents, export children instead)
- exportable_transactions.includes(:category, :merchant, :tags, entry: :account).find_each do |transaction|
+ ndjson_exportable_transactions.includes(
+ :category,
+ :merchant,
+ :tags,
+ entry: [
+ :account,
+ { child_entries: { entryable: :tags } }
+ ]
+ ).find_each do |transaction|
+ transaction_data = {
+ id: transaction.id,
+ entry_id: transaction.entry.id,
+ account_id: transaction.entry.account_id,
+ date: transaction.entry.date,
+ amount: transaction.entry.amount,
+ currency: transaction.entry.currency,
+ name: transaction.entry.name,
+ notes: transaction.entry.notes,
+ excluded: transaction.entry.excluded,
+ category_id: transaction.category_id,
+ merchant_id: transaction.merchant_id,
+ tag_ids: transaction.tag_ids,
+ kind: transaction.kind,
+ created_at: transaction.created_at,
+ updated_at: transaction.updated_at
+ }
+ split_lines = serialize_split_lines_for_export(transaction.entry)
+ transaction_data[:split_lines] = split_lines if split_lines.any?
+
lines << {
type: "Transaction",
- data: {
- id: transaction.id,
- entry_id: transaction.entry.id,
- account_id: transaction.entry.account_id,
- date: transaction.entry.date,
- amount: transaction.entry.amount,
- currency: transaction.entry.currency,
- name: transaction.entry.name,
- notes: transaction.entry.notes,
- excluded: transaction.entry.excluded,
- category_id: transaction.category_id,
- merchant_id: transaction.merchant_id,
- tag_ids: transaction.tag_ids,
- kind: transaction.kind,
- created_at: transaction.created_at,
- updated_at: transaction.updated_at
- }
+ data: transaction_data
}.to_json
end
@@ -456,6 +467,42 @@ class Family::DataExporter
@family.transactions.merge(Entry.excluding_split_parents)
end
+ def ndjson_exportable_transactions
+ @family.transactions.joins(:entry).where(entries: { parent_entry_id: nil })
+ end
+
+ def serialize_split_lines_for_export(parent_entry)
+ child_entries = split_child_entries_for_export(parent_entry)
+ return [] if child_entries.empty?
+
+ child_entries.map do |child_entry|
+ transaction = child_entry.entryable
+ {
+ id: transaction.id,
+ entry_id: child_entry.id,
+ amount: child_entry.amount,
+ currency: child_entry.currency,
+ name: child_entry.name,
+ notes: child_entry.notes,
+ excluded: child_entry.excluded,
+ category_id: transaction.category_id,
+ merchant_id: transaction.merchant_id,
+ tag_ids: transaction.tag_ids,
+ kind: transaction.kind,
+ created_at: transaction.created_at,
+ updated_at: transaction.updated_at
+ }
+ end
+ end
+
+ def split_child_entries_for_export(parent_entry)
+ if parent_entry.association(:child_entries).loaded?
+ parent_entry.child_entries.sort_by { |entry| [ entry.created_at, entry.id ] }
+ else
+ parent_entry.child_entries.order(:created_at, :id).to_a
+ end
+ end
+
def family_transaction_ids
@family_transaction_ids ||= exportable_transactions.select(:id)
end
diff --git a/app/models/family/data_importer.rb b/app/models/family/data_importer.rb
index 8ee468dc8..4de55a9bc 100644
--- a/app/models/family/data_importer.rb
+++ b/app/models/family/data_importer.rb
@@ -2,7 +2,15 @@ require "set"
class Family::DataImporter
SUPPORTED_TYPES = %w[Account Balance Category Tag Merchant RecurringTransaction Transaction Transfer RejectedTransfer Trade Holding Valuation Budget BudgetCategory Rule].freeze
- ACCOUNTABLE_TYPES = Accountable::TYPES.freeze
+ ACCOUNTABLE_TYPE_CLASSES = {
+ "Depository" => Depository, "Investment" => Investment, "Crypto" => Crypto,
+ "Property" => Property, "Vehicle" => Vehicle, "OtherAsset" => OtherAsset,
+ "CreditCard" => CreditCard, "Loan" => Loan, "OtherLiability" => OtherLiability
+ }.freeze
+
+ def self.accountable_class_for(type)
+ ACCOUNTABLE_TYPE_CLASSES[type.to_s]
+ end
def initialize(family, ndjson_content)
@family = family
@@ -78,11 +86,9 @@ class Family::DataImporter
accountable_data = data["accountable"] || {}
accountable_type = data["accountable_type"]
- # Skip if accountable type is not valid
- next unless ACCOUNTABLE_TYPES.include?(accountable_type)
+ accountable_class = self.class.accountable_class_for(accountable_type)
+ next unless accountable_class
- # Build accountable
- accountable_class = accountable_type.constantize
accountable = accountable_class.new
accountable.subtype = accountable_data["subtype"] if accountable.respond_to?(:subtype=) && accountable_data["subtype"]
@@ -110,8 +116,9 @@ class Family::DataImporter
account.save!
# Set opening balance if we have a historical balance and the import
- # does not provide an explicit opening-anchor valuation for this account.
- if data["balance"].present? && !@imported_opening_anchor_account_ids.include?(old_id)
+ # does not provide either an explicit opening-anchor valuation or an
+ # authoritative balance-history stream for this account.
+ if data["balance"].present? && !skip_opening_balance_import?(old_id, data)
manager = Account::OpeningBalanceManager.new(account)
result = manager.set_opening_balance(
balance: data["balance"].to_d,
@@ -190,8 +197,8 @@ class Family::DataImporter
classification_unused: data["classification_unused"] || data["classification"] || "expense",
lucide_icon: data["lucide_icon"] || "shapes"
)
-
category.save!
+
@id_mappings[:categories][old_id] = category.id
end
@@ -216,8 +223,8 @@ class Family::DataImporter
name: data["name"],
color: data["color"] || Tag::COLORS.sample
)
-
tag.save!
+
@id_mappings[:tags][old_id] = tag.id
end
end
@@ -232,8 +239,8 @@ class Family::DataImporter
color: data["color"],
logo_url: data["logo_url"]
)
-
merchant.save!
+
@id_mappings[:merchants][old_id] = merchant.id
end
end
@@ -324,10 +331,7 @@ class Family::DataImporter
end
# Map tag IDs (optional)
- new_tag_ids = []
- if data["tag_ids"].present?
- new_tag_ids = Array(data["tag_ids"]).map { |old_tag_id| @id_mappings[:tags][old_tag_id] }.compact
- end
+ new_tag_ids = mapped_tag_ids(data["tag_ids"])
transaction = Transaction.new(
category_id: new_category_id,
@@ -348,13 +352,80 @@ class Family::DataImporter
entry.save!
- # Add tags through the tagging association
- new_tag_ids.each do |tag_id|
+ @id_mappings[:transactions][old_id] = transaction.id
+ split_rows = importable_split_rows(data)
+
+ if split_rows.any?
+ @created_entries << entry
+ import_split_lines!(entry, split_rows, fallback_tag_ids: new_tag_ids)
+ else
+ new_tag_ids.each do |tag_id|
+ transaction.taggings.create!(tag_id: tag_id)
+ end
+
+ @created_entries << entry
+ end
+ end
+ end
+
+ def mapped_tag_ids(old_tag_ids)
+ Array(old_tag_ids).map { |old_tag_id| @id_mappings[:tags][old_tag_id] }.compact
+ end
+
+ def importable_split_rows(data)
+ rows = data["split_lines"].presence || data["splitLines"].presence || data["splits"].presence
+ Array(rows).filter_map do |row|
+ next unless row.is_a?(Hash)
+
+ amount = row["amount"] || row["amount_money"] || row["amount_decimal"]
+ next if amount.blank?
+
+ category_id = remap_optional_id(:categories, row["category_id"])
+ merchant_id = remap_optional_id(:merchants, row["merchant_id"])
+
+ {
+ old_id: row["id"],
+ name: row["name"].presence || row["memo"].presence || row["description"].presence || "Imported split",
+ amount: amount.to_d,
+ category_id: category_id,
+ merchant_id: merchant_id,
+ merchant_id_provided: row.key?("merchant_id"),
+ notes: row["notes"],
+ excluded: boolean_import_value(row, "excluded", default: false),
+ tag_ids: mapped_tag_ids(row["tag_ids"]),
+ tag_ids_provided: row.key?("tag_ids"),
+ kind: row["kind"]
+ }
+ end
+ end
+
+ def import_split_lines!(entry, split_rows, fallback_tag_ids:)
+ children = entry.split!(
+ split_rows.map do |row|
+ {
+ name: row[:name],
+ amount: row[:amount],
+ category_id: row[:category_id],
+ excluded: row[:excluded]
+ }
+ end
+ )
+
+ children.zip(split_rows).each do |child_entry, row|
+ transaction = child_entry.entryable
+ transaction.update!(
+ merchant_id: row[:merchant_id_provided] ? row[:merchant_id] : transaction.merchant_id,
+ kind: row[:kind].presence || transaction.kind
+ )
+ child_entry.update!(notes: row[:notes]) if row[:notes].present?
+
+ tag_ids = row[:tag_ids_provided] ? row[:tag_ids] : fallback_tag_ids
+ tag_ids.each do |tag_id|
transaction.taggings.create!(tag_id: tag_id)
end
- @created_entries << entry
- @id_mappings[:transactions][old_id] = transaction.id
+ @id_mappings[:transactions][row[:old_id]] = transaction.id if row[:old_id].present?
+ @created_entries << child_entry
end
end
@@ -540,6 +611,12 @@ class Family::DataImporter
end
end
+ def skip_opening_balance_import?(old_id, data)
+ @imported_opening_anchor_account_ids.include?(old_id) ||
+ truthy?(data["skip_opening_balance_import"]) ||
+ truthy?(data["authoritative_balance_history"])
+ end
+
def opening_balance_date_for(old_id, data)
explicit_date = parse_import_date(
data["opening_balance_date"] || data["opening_balance_on"]
diff --git a/app/models/import/preflight.rb b/app/models/import/preflight.rb
index 69051eac3..20199f80b 100644
--- a/app/models/import/preflight.rb
+++ b/app/models/import/preflight.rb
@@ -232,68 +232,21 @@ class Import::Preflight
end
def sure_import_preflight_payload(content, filename, content_type)
- line_counts = Hash.new(0)
- errors = []
- valid_rows_count = 0
- nonblank_rows_count = 0
-
- content.each_line.with_index(1) do |line, line_number|
- next if line.strip.blank?
-
- nonblank_rows_count += 1
- record = JSON.parse(line)
-
- unless record.is_a?(Hash)
- errors << {
- code: "invalid_ndjson_record",
- message: "Line #{line_number} must be a JSON object."
- }
- next
- end
-
- if record["type"].blank? || !record.key?("data")
- errors << {
- code: "invalid_ndjson_record",
- message: "Line #{line_number} must include type and data."
- }
- next
- end
-
- valid_rows_count += 1
- line_counts[record["type"]] += 1
- rescue JSON::ParserError => e
- errors << {
- code: "invalid_json",
- message: "Line #{line_number} is not valid JSON: #{e.message}"
- }
- end
-
- if nonblank_rows_count.zero?
- errors << {
- code: "no_data_rows",
- message: "No data rows were found."
- }
- end
-
- entity_counts = SureImport.dry_run_totals_from_line_type_counts(line_counts)
- unsupported_types = line_counts.keys - SureImport.importable_ndjson_types
- warnings = []
- warnings << "No importable records were found." if nonblank_rows_count.positive? && entity_counts.values.sum.zero?
- warnings << "Some records use unsupported types: #{unsupported_types.join(', ')}" if unsupported_types.any?
- warnings << "Row count exceeds this import type's publish limit." if nonblank_rows_count > SureImport.max_row_count
+ result = SureImport::Preflight.new(
+ family: family,
+ content: content
+ ).call
+ stats = result.stats
+ warnings = result.warnings.dup
+ warnings << "No importable records were found." if stats[:rows_count].positive? && (stats[:entity_counts] || {}).values.sum.zero?
+ warnings << "Row count exceeds this import type's publish limit." if stats[:rows_count] > SureImport.max_row_count
{
type: "SureImport",
- valid: errors.empty?,
+ valid: result.valid?,
content: content_payload(filename, content_type, content),
- stats: {
- rows_count: nonblank_rows_count,
- valid_rows_count: valid_rows_count,
- invalid_rows_count: nonblank_rows_count - valid_rows_count,
- entity_counts: entity_counts,
- record_type_counts: line_counts
- },
- errors: errors,
+ stats: stats,
+ errors: result.errors,
warnings: warnings
}
end
diff --git a/app/models/income_statement.rb b/app/models/income_statement.rb
index dd1cf459a..ff3f3db56 100644
--- a/app/models/income_statement.rb
+++ b/app/models/income_statement.rb
@@ -143,7 +143,7 @@ class IncomeStatement
def build_period_total(classification:, period:)
# Exclude pending transactions from budget calculations
- totals = totals_query(transactions_scope: family.transactions.visible.excluding_pending.in_period(period), date_range: period.date_range).select { |t| t.classification == classification }
+ totals = totals_for_period(period).select { |t| t.classification == classification }
classification_total = totals.sum(&:total)
uncategorized_category = family.categories.uncategorized
@@ -186,6 +186,15 @@ class IncomeStatement
)
end
+ def totals_for_period(period)
+ @totals_for_period ||= {}
+ @totals_for_period[period_cache_key(period)] ||=
+ totals_query(
+ transactions_scope: family.transactions.visible.excluding_pending.in_period(period),
+ date_range: period.date_range
+ )
+ end
+
def family_stats(interval: "month")
@family_stats ||= {}
@family_stats[interval] ||= Rails.cache.fetch([
diff --git a/app/models/recurring_transaction.rb b/app/models/recurring_transaction.rb
index 090d31549..f05df6fcb 100644
--- a/app/models/recurring_transaction.rb
+++ b/app/models/recurring_transaction.rb
@@ -260,29 +260,15 @@ class RecurringTransaction < ApplicationRecord
# Find matching transactions for this recurring pattern
def matching_transactions
- # For manual recurring with amount variance, match within range
- # For automatic recurring, match exact amount
- base = account.present? ? account.entries : family.entries
+ # Recurring transfers can't be matched by single-account name/amount —
+ # future occurrences carry arbitrary names — so match the Transfer pair.
+ return transfer_matching_transactions if transfer?
- entries = if manual? && has_amount_variance?
- base
- .where(entryable_type: "Transaction")
- .where(currency: currency)
- .where("entries.amount BETWEEN ? AND ?", expected_amount_min, expected_amount_max)
- .where("EXTRACT(DAY FROM entries.date) BETWEEN ? AND ?",
- [ expected_day_of_month - 2, 1 ].max,
- [ expected_day_of_month + 2, 31 ].min)
- .order(date: :desc)
- else
- base
- .where(entryable_type: "Transaction")
- .where(currency: currency)
- .where("entries.amount = ?", amount)
- .where("EXTRACT(DAY FROM entries.date) BETWEEN ? AND ?",
- [ expected_day_of_month - 2, 1 ].max,
- [ expected_day_of_month + 2, 31 ].min)
- .order(date: :desc)
- end
+ # Amount/cadence-scoped Transaction entries on this account (or family).
+ base = account.present? ? account.entries : family.entries
+ entries = day_of_month_scope(
+ amount_window_scope(base.where(entryable_type: "Transaction").where(currency: currency))
+ ).order(date: :desc)
# Filter by merchant or name
if merchant_id.present?
@@ -401,6 +387,47 @@ class RecurringTransaction < ApplicationRecord
end
private
+ # Issue #1590: a recurring transfer's future occurrences rarely share the
+ # seed's name (user free-text, importer wording, the auto-matcher's
+ # "Transfer to ..."), so name-based matching returns [] and the Cleaner
+ # would wrongly inactivate a still-active transfer. Match the Transfer
+ # *pair* instead — an outflow on the source account paired with an inflow
+ # on the destination account, within the usual amount/cadence window — and
+ # return the outflow entries (the occurrence-date carrier, consistent with
+ # create_from_transfer).
+ def transfer_matching_transactions
+ return Entry.none unless account && destination_account
+
+ outflow_entries = day_of_month_scope(
+ amount_window_scope(account.entries.where(entryable_type: "Transaction").where(currency: currency))
+ ).order(date: :desc)
+
+ paired_outflow_transaction_ids = Transfer
+ .where(outflow_transaction_id: outflow_entries.select(:entryable_id))
+ .where(inflow_transaction_id:
+ destination_account.entries.where(entryable_type: "Transaction").select(:entryable_id))
+ .pluck(:outflow_transaction_id)
+
+ outflow_entries.where(entryable_id: paired_outflow_transaction_ids)
+ end
+
+ # Transaction entries whose amount fits the pattern: exact, or within the
+ # configured variance band for manual recurring rows.
+ def amount_window_scope(relation)
+ if manual? && has_amount_variance?
+ relation.where("entries.amount BETWEEN ? AND ?", expected_amount_min, expected_amount_max)
+ else
+ relation.where("entries.amount = ?", amount)
+ end
+ end
+
+ # Entries whose day-of-month lands within ±2 days of the expected day.
+ def day_of_month_scope(relation)
+ relation.where("EXTRACT(DAY FROM entries.date) BETWEEN ? AND ?",
+ [ expected_day_of_month - 2, 1 ].max,
+ [ expected_day_of_month + 2, 31 ].min)
+ end
+
def monetizable_currency
currency
end
diff --git a/app/models/recurring_transaction/cleaner.rb b/app/models/recurring_transaction/cleaner.rb
index dd22dcb89..ec5dfd917 100644
--- a/app/models/recurring_transaction/cleaner.rb
+++ b/app/models/recurring_transaction/cleaner.rb
@@ -9,18 +9,15 @@ class RecurringTransaction
# Mark recurring transactions as inactive if they haven't occurred recently
# Uses 2 months for automatic recurring, 6 months for manual recurring.
#
- # Transfer rows (destination_account_id present) are skipped: their
- # `matching_transactions` helper looks at single-account name/amount
- # which never matches a Transfer pair, so the Cleaner would
- # incorrectly mark a still-recurring transfer inactive at the
- # 6-month threshold. Issue #1590 tracks pair-detection-aware
- # matching for recurring transfers.
+ # Transfer rows (destination_account_id present) are included: as of issue
+ # #1590, `matching_transactions` detects the Transfer pair, so a still-active
+ # transfer keeps surfacing recent matches and stays active, while one whose
+ # pair has genuinely stopped is correctly retired.
def cleanup_stale_transactions
stale_count = 0
family.recurring_transactions
.active
- .where(destination_account_id: nil)
.find_each do |recurring_transaction|
next unless recurring_transaction.should_be_inactive?
diff --git a/app/models/sure_import.rb b/app/models/sure_import.rb
index 0fab88a27..c738e6156 100644
--- a/app/models/sure_import.rb
+++ b/app/models/sure_import.rb
@@ -1,5 +1,10 @@
class SureImport < Import
- MAX_NDJSON_SIZE = 10.megabytes
+ NotPublishableError = Class.new(StandardError)
+ PreflightError = Class.new(StandardError)
+
+ DEFAULT_MAX_NDJSON_SIZE_MB = 10
+ DEFAULT_MAX_ROW_COUNT = 100_000
+ MAX_NDJSON_SIZE = DEFAULT_MAX_NDJSON_SIZE_MB.megabytes
IMPORTABLE_NDJSON_TYPES = {
"Account" => :accounts,
"Balance" => :balances,
@@ -30,11 +35,11 @@ class SureImport < Import
class << self
def max_row_count
- 100_000
+ positive_integer_env("SURE_IMPORT_MAX_ROWS", DEFAULT_MAX_ROW_COUNT)
end
def max_ndjson_size
- MAX_NDJSON_SIZE
+ positive_integer_env("SURE_IMPORT_MAX_NDJSON_SIZE_MB", DEFAULT_MAX_NDJSON_SIZE_MB).megabytes
end
# Counts JSON lines by top-level "type" (used for dry-run summaries and row limits).
@@ -90,6 +95,12 @@ class SureImport < Import
false
end
end
+
+ private
+ def positive_integer_env(name, default)
+ value = ENV[name].to_i
+ value.positive? ? value : default
+ end
end
def requires_csv_workflow?
@@ -133,6 +144,37 @@ class SureImport < Import
raise
end
+ def publish_later
+ raise MaxRowCountExceededError if row_count_exceeded?
+
+ validate_sure_preflight!
+ raise NotPublishableError, "Import was uploaded but has no publishable records." unless publishable?
+
+ previous_status = status
+ update! status: :importing
+
+ begin
+ ImportJob.perform_later(self)
+ rescue StandardError
+ update! status: previous_status
+ raise
+ end
+ end
+
+ def publish
+ raise MaxRowCountExceededError if row_count_exceeded?
+
+ validate_sure_preflight!
+
+ import!
+
+ family.sync_later
+
+ update! status: :complete
+ rescue StandardError => error
+ update! status: :failed, error: error.message
+ end
+
def uploaded?
return false unless ndjson_file.attached?
@@ -163,6 +205,13 @@ class SureImport < Import
self.class.max_row_count
end
+ def sure_preflight
+ SureImport::Preflight.new(
+ family: family,
+ content: ndjson_blob_string
+ ).call
+ end
+
# Row total for max-row enforcement (counts every parsed line with a "type", including unsupported types).
def sync_ndjson_rows_count!
return unless ndjson_file.attached?
@@ -302,4 +351,12 @@ class SureImport < Import
@ndjson_blob_id = blob_id
@ndjson_blob_string = ndjson_file.download.force_encoding(Encoding::UTF_8)
end
+
+ def validate_sure_preflight!
+ result = sure_preflight
+ return if result.valid?
+
+ update! status: :failed, error: result.error_message
+ raise PreflightError, result.error_message
+ end
end
diff --git a/app/models/sure_import/preflight.rb b/app/models/sure_import/preflight.rb
new file mode 100644
index 000000000..d12eac05c
--- /dev/null
+++ b/app/models/sure_import/preflight.rb
@@ -0,0 +1,312 @@
+# 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
diff --git a/app/views/budget_categories/_budget_category.html.erb b/app/views/budget_categories/_budget_category.html.erb
index ff209f009..6b55b971e 100644
--- a/app/views/budget_categories/_budget_category.html.erb
+++ b/app/views/budget_categories/_budget_category.html.erb
@@ -26,20 +26,11 @@
<%= budget_category.category.name %>
<% if budget_category.over_budget? %>
-
- <%= icon("alert-circle", size: "sm", color: "red") %>
- <%= t("reports.budget_performance.status.over") %>
-
+ <%= render DS::Pill.new(label: t("reports.budget_performance.status.over"), tone: :error, marker: false, icon: "alert-circle") %>
<% elsif budget_category.near_limit? %>
-
- <%= icon("alert-triangle", size: "sm", color: "yellow") %>
- <%= t("reports.budget_performance.status.warning") %>
-
+ <%= render DS::Pill.new(label: t("reports.budget_performance.status.warning"), tone: :warning, marker: false, icon: "alert-triangle") %>
<% else %>
-
- <%= icon("check-circle", size: "sm", color: "green") %>
- <%= t("reports.budget_performance.status.good") %>
-
+ <%= render DS::Pill.new(label: t("reports.budget_performance.status.good"), tone: :success, marker: false, icon: "check-circle") %>
<% end %>
diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb
index c51f4977a..60a9409a7 100644
--- a/app/views/sessions/new.html.erb
+++ b/app/views/sessions/new.html.erb
@@ -55,34 +55,27 @@
<% providers.each do |provider| %>
<% provider_id = provider[:id].to_s %>
<% provider_name = provider[:name].to_s %>
+ <% is_google = provider_id == "google" || provider[:strategy].to_s == "google_oauth2" %>
+ <% default_label = is_google ? t(".google_auth_connect") : t(".#{provider_id}", default: provider[:name].to_s.titleize) %>
- <% if provider_id == "google" || provider[:strategy].to_s == "google_oauth2" %>
-