<%
# Build params hash for links
base_params = {
@@ -85,7 +85,7 @@
<%= group[:category_name] %>
- (<%= group[:count] %> <%= t("reports.transactions_breakdown.table.transactions") %>)
+ (<%= t("reports.transactions_breakdown.table.entries", count: group[:count]) %>)
@@ -139,7 +139,7 @@
<%= group[:category_name] %>
- (<%= group[:count] %> <%= t("reports.transactions_breakdown.table.transactions") %>)
+ (<%= t("reports.transactions_breakdown.table.entries", count: group[:count]) %>)
diff --git a/app/views/reports/_trends_insights.html.erb b/app/views/reports/_trends_insights.html.erb
index 0a02928c4..61364c537 100644
--- a/app/views/reports/_trends_insights.html.erb
+++ b/app/views/reports/_trends_insights.html.erb
@@ -1,4 +1,4 @@
-
+
<%# Month-over-Month Trends %>
@@ -78,121 +78,5 @@
<% end %>
-
- <%# Spending Patterns %>
-
-
- <%= t("reports.trends.spending_patterns") %>
-
-
- <% if spending_patterns[:weekday_count] + spending_patterns[:weekend_count] > 0 %>
-
- <%# Weekday Spending %>
-
-
- <%= icon("calendar", class: "w-5 h-5 text-primary") %>
-
<%= t("reports.trends.weekday_spending") %>
-
-
-
-
-
<%= t("reports.trends.total") %>
-
- <%= Money.new(spending_patterns[:weekday_total], Current.family.currency).format %>
-
-
-
-
-
<%= t("reports.trends.avg_per_transaction") %>
-
- <%= Money.new(spending_patterns[:weekday_avg], Current.family.currency).format %>
-
-
-
-
-
<%= t("reports.trends.transactions") %>
-
- <%= spending_patterns[:weekday_count] %>
-
-
-
-
-
- <%# Weekend Spending %>
-
-
- <%= icon("calendar-check", class: "w-5 h-5 text-primary") %>
-
<%= t("reports.trends.weekend_spending") %>
-
-
-
-
-
<%= t("reports.trends.total") %>
-
- <%= Money.new(spending_patterns[:weekend_total], Current.family.currency).format %>
-
-
-
-
-
<%= t("reports.trends.avg_per_transaction") %>
-
- <%= Money.new(spending_patterns[:weekend_avg], Current.family.currency).format %>
-
-
-
-
-
<%= t("reports.trends.transactions") %>
-
- <%= spending_patterns[:weekend_count] %>
-
-
-
-
-
-
- <%# Comparison Insight %>
- <% if spending_patterns[:weekday_avg] > 0 && spending_patterns[:weekend_avg] > 0 %>
-
-
- <%= icon("lightbulb", class: "w-5 h-5 text-warning mt-0.5") %>
-
-
- <%= t("reports.trends.insight_title") %>
-
-
- <%
- weekday = spending_patterns[:weekday_avg].to_f
- weekend = spending_patterns[:weekend_avg].to_f
-
- if weekend > weekday
- percent_diff = ((weekend - weekday) / weekday * 100).round(0)
- if percent_diff > 20
- message = t("reports.trends.insight_higher_weekend", percent: percent_diff)
- else
- message = t("reports.trends.insight_similar")
- end
- elsif weekday > weekend
- percent_diff = ((weekday - weekend) / weekend * 100).round(0)
- if percent_diff > 20
- message = t("reports.trends.insight_higher_weekday", percent: percent_diff)
- else
- message = t("reports.trends.insight_similar")
- end
- else
- message = t("reports.trends.insight_similar")
- end
- %>
- <%= message %>
-
-
-
-
- <% end %>
- <% else %>
-
- <%= t("reports.trends.no_spending_data") %>
-
- <% end %>
-
diff --git a/app/views/reports/index.html.erb b/app/views/reports/index.html.erb
index 8f79a8013..032547cf5 100644
--- a/app/views/reports/index.html.erb
+++ b/app/views/reports/index.html.erb
@@ -1,5 +1,103 @@
+<% content_for :head do %>
+
+<% end %>
+
<% content_for :page_header do %>
-
+
<%= t("reports.index.title") %>
@@ -17,7 +115,7 @@
<% end %>
<%# Period Navigation Tabs %>
-
+
<%= render DS::Link.new(
text: t("reports.index.periods.monthly"),
variant: @period_type == :monthly ? "secondary" : "ghost",
@@ -52,7 +150,7 @@
<%# Custom Date Range Picker (only shown when custom is selected) %>
<% if @period_type == :custom %>
- <%= form_with url: reports_path, method: :get, data: { controller: "auto-submit-form" }, class: "flex items-center gap-3 bg-surface-inset p-3 rounded-lg" do |f| %>
+ <%= form_with url: reports_path, method: :get, data: { controller: "auto-submit-form" }, class: "reports-print-hide flex items-center gap-3 bg-surface-inset p-3 rounded-lg" do |f| %>
<%= f.hidden_field :period_type, value: :custom %>
@@ -84,7 +182,7 @@
<% end %>
-
+
<% if Current.family.transactions.any? %>
<%# Summary Dashboard - Always visible, not collapsible %>
@@ -113,6 +211,9 @@
data-action="
dragstart->reports-sortable#dragStart
dragend->reports-sortable#dragEnd
+ touchstart->reports-sortable#touchStart
+ touchmove->reports-sortable#touchMove
+ touchend->reports-sortable#touchEnd
keydown->reports-sortable#handleKeyDown">
@@ -131,12 +232,7 @@
">
<%= icon("grip-vertical", size: "sm") %>
diff --git a/app/views/rule/actions/_action.html.erb b/app/views/rule/actions/_action.html.erb
index 0f3ebeac3..56fee76d3 100644
--- a/app/views/rule/actions/_action.html.erb
+++ b/app/views/rule/actions/_action.html.erb
@@ -11,13 +11,16 @@
<%= form.select :action_type, rule.action_executors.map { |executor| [ executor.label, executor.key ] }, {}, data: { action: "rule--actions#handleActionTypeChange" } %>
- <%= tag.div class: class_names("min-w-1/2 flex items-center gap-2"),
+ <%= tag.div class: class_names("min-w-1/2 flex items-center gap-2", { "hidden": action.executor.type == "function" }),
data: { rule__actions_target: "actionValue" } do %>
- <%# Initial rendering based on rule.action_executors.first from the rule form. %>
- <%# This is currently always SetTransactionCategory from transaction_resource.rb, which is a select type. %>
+ <%# Initial rendering based on the action executor type. %>
<%# Subsequent renders are injected by the Stimulus controller, which uses the templates from below. %>
to
- <%= form.select :value, action.options || [], {} %>
+ <% if action.executor.type == "select" %>
+ <%= form.select :value, action.options || [], {} %>
+ <% elsif action.executor.type == "text" %>
+ <%= form.text_field :value, placeholder: t("rules.actions.value_placeholder") %>
+ <% end %>
<% end %>
@@ -35,7 +38,7 @@
to
- <%= form.text_field :value, placeholder: "Enter a value" %>
+ <%= form.text_field :value, placeholder: t("rules.actions.value_placeholder") %>
<%# The function type doesn't need an input, so no template is required.%>
diff --git a/app/views/rules/confirm_all.html.erb b/app/views/rules/confirm_all.html.erb
new file mode 100644
index 000000000..c88778767
--- /dev/null
+++ b/app/views/rules/confirm_all.html.erb
@@ -0,0 +1,48 @@
+<%= render DS::Dialog.new do |dialog| %>
+ <% dialog.with_header(title: t("rules.apply_all.confirm_title")) %>
+
+ <% dialog.with_body do %>
+
+ <%= t("rules.apply_all.confirm_message",
+ count: @rules.count,
+ transactions: @total_affected_count) %>
+
+
+ <% if @rules.any? { |r| r.actions.any? { |a| a.action_type == "auto_categorize" } } %>
+
+
+ <%= icon "info", class: "w-4 h-4 text-blue-600 mt-0.5 flex-shrink-0" %>
+
+
<%= t("rules.apply_all.ai_cost_title") %>
+ <% if @estimated_cost.present? %>
+
+ <%= t("rules.apply_all.ai_cost_message", transactions: @total_affected_count) %>
+ <%= t("rules.apply_all.estimated_cost", cost: sprintf("%.4f", @estimated_cost)) %>
+
+ <% else %>
+
+ <%= t("rules.apply_all.ai_cost_message", transactions: @total_affected_count) %>
+ <% if @selected_model.present? %>
+ <%= t("rules.apply_all.cost_unavailable_model", model: @selected_model) %>
+ <% else %>
+ <%= t("rules.apply_all.cost_unavailable_no_provider") %>
+ <% end %>
+ <%= t("rules.apply_all.cost_warning") %>
+
+ <% end %>
+
+ <%= link_to t("rules.apply_all.view_usage"), settings_llm_usage_path, class: "underline hover:text-blue-800" %>
+
+
+
+
+ <% end %>
+
+ <%= render DS::Button.new(
+ text: t("rules.apply_all.confirm_button"),
+ href: apply_all_rules_path,
+ method: :post,
+ full_width: true,
+ data: { turbo_frame: "_top" }) %>
+ <% end %>
+<% end %>
diff --git a/app/views/rules/index.html.erb b/app/views/rules/index.html.erb
index f40020abe..0acacb0fc 100644
--- a/app/views/rules/index.html.erb
+++ b/app/views/rules/index.html.erb
@@ -11,6 +11,13 @@
method: :delete,
confirm: CustomConfirm.for_resource_deletion("all rules", high_severity: true)) %>
<% end %>
+ <%= render DS::Link.new(
+ text: t("rules.apply_all.button"),
+ variant: "secondary",
+ href: confirm_all_rules_path,
+ icon: "play",
+ frame: :modal
+ ) %>
<% end %>
<%= render DS::Link.new(
text: "New rule",
diff --git a/app/views/settings/bank_sync/_provider_link.html.erb b/app/views/settings/bank_sync/_provider_link.html.erb
index 56064246d..32a2caf59 100644
--- a/app/views/settings/bank_sync/_provider_link.html.erb
+++ b/app/views/settings/bank_sync/_provider_link.html.erb
@@ -5,7 +5,8 @@
"Lunch Flow" => "#6471eb",
"Plaid" => "#4da568",
"SimpleFin" => "#e99537",
- "Enable Banking" => "#6471eb"
+ "Enable Banking" => "#6471eb",
+ "CoinStats" => "#FF9332" # https://coinstats.app/press-kit/
} %>
<% provider_color = provider_colors[provider_link[:name]] || "#6B7280" %>
diff --git a/app/views/settings/providers/_coinstats_panel.html.erb b/app/views/settings/providers/_coinstats_panel.html.erb
new file mode 100644
index 000000000..c5359e104
--- /dev/null
+++ b/app/views/settings/providers/_coinstats_panel.html.erb
@@ -0,0 +1,54 @@
+
+
+
<%= t("coinstats_items.new.setup_instructions") %>
+
+ <%= t("coinstats_items.new.step1_html").html_safe %>
+ <%= t("coinstats_items.new.step2") %>
+ <%= t("coinstats_items.new.step3_html", accounts_url: accounts_path).html_safe %>
+
+
+
+ <% error_msg = local_assigns[:error_message] || @error_message %>
+ <% if error_msg.present? %>
+
+ <% end %>
+
+ <%
+ # Get or initialize a coinstats_item for this family
+ # - If family has an item WITH credentials, use it (for updates)
+ # - If family has an item WITHOUT credentials, use it (to add credentials)
+ # - If family has no items at all, create a new one
+ coinstats_item = Current.family.coinstats_items.first_or_initialize(name: t("coinstats_items.new.default_name"))
+ is_new_record = coinstats_item.new_record?
+ %>
+
+ <%= styled_form_with model: coinstats_item,
+ url: is_new_record ? coinstats_items_path : coinstats_item_path(coinstats_item),
+ scope: :coinstats_item,
+ method: is_new_record ? :post : :patch,
+ data: { turbo: true },
+ class: "space-y-3" do |form| %>
+ <%= form.text_field :api_key,
+ label: t("coinstats_items.new.api_key_label"),
+ placeholder: t("coinstats_items.new.api_key_placeholder"),
+ type: :password %>
+
+
+ <%= form.submit is_new_record ? t("coinstats_items.new.configure") : t("coinstats_items.new.update_configuration"),
+ class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover focus:outline-none focus:ring-2 focus:ring-gray-900 focus:ring-offset-2 transition-colors" %>
+
+ <% end %>
+
+ <% items = local_assigns[:coinstats_items] || @coinstats_items || Current.family.coinstats_items.where.not(api_key: [nil, ""]) %>
+
+ <% if items&.any? %>
+
+
<%= t("coinstats_items.new.status_configured_html", accounts_url: accounts_path).html_safe %>
+ <% else %>
+
+
<%= t("coinstats_items.new.status_not_configured") %>
+ <% end %>
+
+
diff --git a/app/views/settings/providers/show.html.erb b/app/views/settings/providers/show.html.erb
index 559a54972..f02401171 100644
--- a/app/views/settings/providers/show.html.erb
+++ b/app/views/settings/providers/show.html.erb
@@ -1,9 +1,9 @@
-<%= content_for :page_title, "Bank Sync Providers" %>
+<%= content_for :page_title, "Sync Providers" %>
- Configure credentials for third-party bank sync providers. Settings configured here will override environment variables.
+ Configure credentials for third-party sync providers. Settings configured here will override environment variables.
@@ -31,4 +31,10 @@
<%= render "settings/providers/enable_banking_panel" %>
<% end %>
+
+ <%= settings_section title: "CoinStats", collapsible: true, open: false do %>
+
+ <%= render "settings/providers/coinstats_panel" %>
+
+ <% end %>
diff --git a/app/views/simplefin_items/_simplefin_item.html.erb b/app/views/simplefin_items/_simplefin_item.html.erb
index 93510054c..db3a0b731 100644
--- a/app/views/simplefin_items/_simplefin_item.html.erb
+++ b/app/views/simplefin_items/_simplefin_item.html.erb
@@ -16,9 +16,31 @@
<% end %>
+ <%# Compute unlinked count early for badge display %>
+ <% header_unlinked_count = if defined?(@simplefin_unlinked_count_map) && @simplefin_unlinked_count_map
+ @simplefin_unlinked_count_map[simplefin_item.id] || 0
+ else
+ begin
+ simplefin_item.simplefin_accounts
+ .left_joins(:account, :account_provider)
+ .where(accounts: { id: nil }, account_providers: { id: nil })
+ .count
+ rescue => e
+ 0
+ end
+ end %>
+
<%= tag.p simplefin_item.name, class: "font-medium text-primary" %>
+ <% if header_unlinked_count.to_i > 0 %>
+ <%= link_to setup_accounts_simplefin_item_path(simplefin_item),
+ data: { turbo_frame: :modal },
+ class: "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-warning/10 text-warning hover:bg-warning/20 transition-colors" do %>
+ <%= icon "alert-circle", size: "xs" %>
+
<%= header_unlinked_count %> <%= header_unlinked_count == 1 ? "account" : "accounts" %> need setup
+ <% end %>
+ <% end %>
<% if simplefin_item.scheduled_for_deletion? %>
<%= t(".deletion_in_progress") %>
<% end %>
@@ -77,6 +99,11 @@
<%= icon "alert-triangle", size: "sm", color: "warning" %>
<%= tag.span t(".requires_update") %>
+ <% elsif (stale_status = simplefin_item.stale_sync_status)[:stale] %>
+
+ <%= icon "alert-circle", size: "sm", color: "warning" %>
+ <%= tag.span stale_status[:message], class: "text-sm" %>
+
<% elsif simplefin_item.rate_limited_message.present? %>
<%= icon "clock", size: "sm", color: "warning" %>
@@ -144,7 +171,7 @@
<%= render "accounts/index/account_groups", accounts: simplefin_item.accounts %>
<% end %>
- <%# Sync summary (collapsible)
+ <%# Sync summary (collapsible) - using shared ProviderSyncSummary component
Prefer controller-provided map; fallback to latest sync stats so Turbo broadcasts
can render the summary without requiring a full page refresh. %>
<% stats = if defined?(@simplefin_sync_stats_map) && @simplefin_sync_stats_map
@@ -153,64 +180,11 @@
# `latest_sync` is private on Syncable; access via association for broadcast renders.
simplefin_item.syncs.ordered.first&.sync_stats || {}
end %>
- <% if stats.present? %>
-
-
-
- <%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
- Sync summary
-
-
- <% if simplefin_item.last_synced_at %>
- Last sync: <%= time_ago_in_words(simplefin_item.last_synced_at) %> ago
- <% end %>
-
-
-
-
-
Accounts
-
- Total: <%= stats["total_accounts"].to_i %>
- Linked: <%= stats["linked_accounts"].to_i %>
- Unlinked: <%= stats["unlinked_accounts"].to_i %>
- <% institutions = simplefin_item.connected_institutions %>
- Institutions: <%= institutions.size %>
-
-
-
-
Transactions
-
- Seen: <%= stats["tx_seen"].to_i %>
- Imported: <%= stats["tx_imported"].to_i %>
- Updated: <%= stats["tx_updated"].to_i %>
- Skipped: <%= stats["tx_skipped"].to_i %>
-
-
-
-
Holdings
-
- Processed: <%= stats["holdings_processed"].to_i %>
-
-
-
-
Health
-
- <% if stats["rate_limited"].present? || stats["rate_limited_at"].present? %>
- <% ts = stats["rate_limited_at"] %>
- <% ago = (ts.present? ? (begin; time_ago_in_words(Time.parse(ts)); rescue StandardError; nil; end) : nil) %>
- Rate limited <%= ago ? "(#{ago} ago)" : "recently" %>
- <% end %>
- <% total_errors = stats["total_errors"].to_i %>
- <% if total_errors > 0 %>
- Errors: <%= total_errors %>
- <% else %>
- Errors: 0
- <% end %>
-
-
-
-
- <% end %>
+ <%= render ProviderSyncSummary.new(
+ stats: stats,
+ provider_item: simplefin_item,
+ institutions_count: simplefin_item.connected_institutions.size
+ ) %>
<%# Compute unlinked SimpleFin accounts (no legacy account and no AccountProvider link)
# Prefer controller-provided map; fallback to a local query so the card stays accurate after Turbo broadcasts %>
diff --git a/app/views/simplefin_items/_stale_account_row.html.erb b/app/views/simplefin_items/_stale_account_row.html.erb
new file mode 100644
index 000000000..afb0e42c6
--- /dev/null
+++ b/app/views/simplefin_items/_stale_account_row.html.erb
@@ -0,0 +1,65 @@
+<% account = simplefin_account.current_account %>
+<% transaction_count = account&.entries&.where(entryable_type: "Transaction")&.count || 0 %>
+
+
+
+
+
+ <%= simplefin_account.name %>
+ <% if simplefin_account.org_data.present? && simplefin_account.org_data['name'].present? %>
+ • <%= simplefin_account.org_data["name"] %>
+ <% end %>
+
+
+ <%= number_to_currency(simplefin_account.current_balance || 0, unit: simplefin_account.currency || "USD") %>
+ •
+ <%= t("simplefin_items.setup_accounts.stale_accounts.transaction_count", count: transaction_count) %>
+
+
+
+
+
+
+ <%= t("simplefin_items.setup_accounts.stale_accounts.action_prompt") %>
+
+
+
+ <%= radio_button_tag "stale_account_actions[#{simplefin_account.id}][action]",
+ "delete",
+ false,
+ class: "form-radio accent-primary",
+ data: { action: "change->stale-account-action#updateTargetVisibility" } %>
+ <%= t("simplefin_items.setup_accounts.stale_accounts.action_delete") %>
+
+
+ <% if target_accounts&.any? %>
+
+
+ <%= radio_button_tag "stale_account_actions[#{simplefin_account.id}][action]",
+ "move",
+ false,
+ class: "form-radio accent-primary",
+ data: { action: "change->stale-account-action#updateTargetVisibility",
+ stale_account_action_target: "moveRadio" } %>
+ <%= t("simplefin_items.setup_accounts.stale_accounts.action_move") %>
+
+ <%= select_tag "stale_account_actions[#{simplefin_account.id}][target_account_id]",
+ options_from_collection_for_select(target_accounts, :id, :name),
+ class: "appearance-none bg-container border border-primary rounded-md px-2 py-1 text-sm text-primary focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none ml-6 max-w-[200px] truncate disabled:opacity-50 disabled:cursor-not-allowed",
+ disabled: true,
+ data: { stale_account_action_target: "targetSelect" } %>
+
+ <% end %>
+
+
+ <%= radio_button_tag "stale_account_actions[#{simplefin_account.id}][action]",
+ "skip",
+ true,
+ class: "form-radio accent-primary",
+ data: { action: "change->stale-account-action#updateTargetVisibility" } %>
+ <%= t("simplefin_items.setup_accounts.stale_accounts.action_skip") %>
+
+
+
diff --git a/app/views/simplefin_items/_subtype_select.html.erb b/app/views/simplefin_items/_subtype_select.html.erb
index f5c30548f..9899cf423 100644
--- a/app/views/simplefin_items/_subtype_select.html.erb
+++ b/app/views/simplefin_items/_subtype_select.html.erb
@@ -1,27 +1,27 @@
-
+<%# Use centralized @inferred_map for subtype pre-selection %>
+<% inferred = @inferred_map&.dig(simplefin_account.id) || {} %>
+<% selected_value = "" %>
+<% if inferred[:confidence] == :high && inferred[:type] == account_type && inferred[:subtype].present? %>
+ <% selected_value = inferred[:subtype] %>
+<% end %>
+<% needs_attention = subtype_config[:options].present? && selected_value.blank? %>
+
+
<% if subtype_config[:options].present? %>
- <%= label_tag "account_subtypes[#{simplefin_account.id}]", subtype_config[:label],
- class: "block text-sm font-medium text-primary mb-2" %>
- <% selected_value = "" %>
- <% if account_type == "Depository" %>
- <% n = simplefin_account.name.to_s.downcase %>
- <% selected_value = "" %>
- <% if n =~ /\bchecking\b|\bchequing\b|\bck\b|demand\s+deposit/ %>
- <% selected_value = "checking" %>
- <% elsif n =~ /\bsavings\b|\bsv\b/ %>
- <% selected_value = "savings" %>
- <% elsif n =~ /money\s+market|\bmm\b/ %>
- <% selected_value = "money_market" %>
+
">
+ <% if needs_attention %>
+
+ <%= icon "alert-circle", size: "xs" %>
+ Please select a <%= account_type == "Depository" ? "subtype" : "type" %>
+
<% end %>
- <% elsif account_type == "Investment" %>
- <% inferred = @inferred_map&.dig(simplefin_account.id) || {} %>
- <% if inferred[:confidence] == :high && inferred[:type] == "Investment" && inferred[:subtype].present? %>
- <% selected_value = inferred[:subtype] %>
- <% end %>
- <% end %>
- <%= select_tag "account_subtypes[#{simplefin_account.id}]",
- options_for_select([["Select #{account_type == 'Depository' ? 'subtype' : 'type'}", ""]] + subtype_config[:options], selected_value),
- { class: "appearance-none bg-container border border-primary rounded-md px-3 py-2 text-sm leading-6 text-primary focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none w-full" } %>
+ <%= label_tag "account_subtypes[#{simplefin_account.id}]", subtype_config[:label],
+ class: "block text-sm font-medium text-primary mb-2" %>
+ <%= select_tag "account_subtypes[#{simplefin_account.id}]",
+ options_for_select([["Select #{account_type == 'Depository' ? 'subtype' : 'type'}", ""]] + subtype_config[:options], selected_value),
+ { class: "appearance-none bg-container border border-primary rounded-md px-3 py-2 text-sm leading-6 text-primary focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none w-full",
+ data: { action: "change->account-type-selector#clearWarning" } } %>
+
<% else %>
<%= subtype_config[:message] %>
<% end %>
diff --git a/app/views/simplefin_items/setup_accounts.html.erb b/app/views/simplefin_items/setup_accounts.html.erb
index a18dbaff1..1d30108e0 100644
--- a/app/views/simplefin_items/setup_accounts.html.erb
+++ b/app/views/simplefin_items/setup_accounts.html.erb
@@ -39,27 +39,31 @@
-
+
- <%= icon "calendar", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %>
+ <%= icon "clock", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %>
-
- Historical Data Range:
+
+ Transaction History:
+
+
+ SimpleFin typically provides 60-90 days of transaction history, depending on your bank.
+ After initial setup, new transactions will sync automatically going forward.
+ Historical data availability varies by institution and account type.
- <%= form.date_field :sync_start_date,
- label: "Start syncing transactions from:",
- value: @simplefin_item.sync_start_date || 1.year.ago.to_date,
- min: 3.years.ago.to_date,
- max: Date.current,
- class: "w-full max-w-xs",
- help_text: "Select how far back you want to sync transaction history. Maximum 3 years of history available." %>
<% @simplefin_accounts.each do |simplefin_account| %>
-
+ <% inferred = @inferred_map[simplefin_account.id] || {} %>
+ <% selected_type = inferred[:confidence] == :high ? inferred[:type] : "skip" %>
+ <%# Check if this account needs user attention (type selected but subtype missing) %>
+ <% types_with_subtypes = %w[Depository Investment Loan] %>
+ <% needs_subtype_attention = selected_type != "skip" && types_with_subtypes.include?(selected_type) && inferred[:subtype].blank? %>
+
+
">
@@ -78,8 +82,6 @@
<%= label_tag "account_types[#{simplefin_account.id}]", "Account Type:",
class: "block text-sm font-medium text-primary mb-2" %>
- <% inferred = @inferred_map[simplefin_account.id] || {} %>
- <% selected_type = inferred[:confidence] == :high ? inferred[:type] : "skip" %>
<%= select_tag "account_types[#{simplefin_account.id}]",
options_for_select(@account_type_options, selected_type),
{ class: "appearance-none bg-container border border-primary rounded-md px-3 py-2 text-sm leading-6 text-primary focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none w-full",
@@ -97,6 +99,29 @@
<% end %>
+
+ <% if @stale_simplefin_accounts&.any? %>
+
+
+
+
+ <%= icon "alert-triangle", size: "sm", class: "text-warning mt-0.5 flex-shrink-0" %>
+
+
+ <%= t("simplefin_items.setup_accounts.stale_accounts.title") %>
+
+
+ <%= t("simplefin_items.setup_accounts.stale_accounts.description") %>
+
+
+
+
+
+ <% @stale_simplefin_accounts.each do |simplefin_account| %>
+ <%= render "stale_account_row", simplefin_account: simplefin_account, target_accounts: @target_accounts %>
+ <% end %>
+
+ <% end %>
diff --git a/app/views/trades/_trade.html.erb b/app/views/trades/_trade.html.erb
index 02245edf6..ff5ab18cb 100644
--- a/app/views/trades/_trade.html.erb
+++ b/app/views/trades/_trade.html.erb
@@ -30,7 +30,7 @@
- <%= render "categories/badge", category: trade_category %>
+ <%= render "categories/badge", category: trade.category || trade_category %>
diff --git a/app/views/trades/show.html.erb b/app/views/trades/show.html.erb
index 227470fba..9d5cf3d03 100644
--- a/app/views/trades/show.html.erb
+++ b/app/views/trades/show.html.erb
@@ -43,6 +43,11 @@
step: "any",
precision: 10,
disabled: @entry.linked? %>
+
+ <%= ef.select :category_id,
+ Current.family.categories.expenses.alphabetically.map { |c| [c.name, c.id] },
+ { include_blank: t(".no_category"), label: t(".category_label") },
+ { data: { "auto-submit-form-target": "auto" } } %>
<% end %>
<% end %>
diff --git a/app/views/transactions/bulk_updates/new.html.erb b/app/views/transactions/bulk_updates/new.html.erb
index 84394a8cc..e217e4dba 100644
--- a/app/views/transactions/bulk_updates/new.html.erb
+++ b/app/views/transactions/bulk_updates/new.html.erb
@@ -13,7 +13,7 @@
<%= render DS::Disclosure.new(title: "Transactions", open: true) do %>
<%= form.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: "Select a category", label: "Category", class: "text-subdued" } %>
- <%= form.collection_select :merchant_id, Current.family.merchants.alphabetically, :id, :name, { prompt: "Select a merchant", label: "Merchant", class: "text-subdued" } %>
+ <%= form.collection_select :merchant_id, Current.family.available_merchants.alphabetically, :id, :name, { prompt: "Select a merchant", label: "Merchant", class: "text-subdued" } %>
<%= form.select :tag_ids, Current.family.tags.alphabetically.pluck(:name, :id), { include_blank: "None", multiple: true, label: "Tags", container_class: "h-40" } %>
<%= form.text_area :notes, label: "Notes", placeholder: "Enter a note that will be applied to selected transactions", rows: 5 %>
diff --git a/app/views/transactions/index.html.erb b/app/views/transactions/index.html.erb
index 7251c4c0a..ead305941 100644
--- a/app/views/transactions/index.html.erb
+++ b/app/views/transactions/index.html.erb
@@ -46,10 +46,18 @@
<%= render "summary", totals: @search.totals %>
"
data-bulk-select-plural-label-value="<%= t(".transactions") %>"
- class="flex flex-col bg-container rounded-xl shadow-border-xs px-3 py-4 lg:p-4">
+ class="flex flex-col bg-container rounded-xl shadow-border-xs px-3 py-4 lg:p-4 relative group">
+
+ <%= form_with url: imports_path, method: :post, class: "hidden", data: { drag_and_drop_import_target: "form" } do |f| %>
+ <%= f.hidden_field "import[type]", value: "TransactionImport" %>
+ <%= f.file_field "import[csv_file]", class: "hidden", data: { drag_and_drop_import_target: "input" }, accept: ".csv" %>
+ <% end %>
+
+ <%= render "imports/drag_drop_overlay", title: "Drop CSV to import", subtitle: "Upload transactions directly" %>
+
<%= render "transactions/searches/search" %>
diff --git a/app/views/transactions/show.html.erb b/app/views/transactions/show.html.erb
index d923278bb..1d040c953 100644
--- a/app/views/transactions/show.html.erb
+++ b/app/views/transactions/show.html.erb
@@ -69,7 +69,7 @@
<%= f.fields_for :entryable do |ef| %>
<%= ef.collection_select :merchant_id,
- [@entry.transaction.merchant, *Current.family.merchants.alphabetically].compact,
+ Current.family.available_merchants.alphabetically,
:id, :name,
{ include_blank: t(".none"),
label: t(".merchant_label"),
diff --git a/charts/sure/CHANGELOG.md b/charts/sure/CHANGELOG.md
new file mode 100644
index 000000000..3e5d6f603
--- /dev/null
+++ b/charts/sure/CHANGELOG.md
@@ -0,0 +1,12 @@
+### 0.0.0
+
+- First (nightly/test) releases via
+
+### 0.6.5
+
+- First version/release that aligns versions with monorepo
+- CNPG: render `Cluster.spec.backup` from `cnpg.cluster.backup`.
+ - If `backup.method` is omitted and `backup.volumeSnapshot` is present, the chart will infer `method: volumeSnapshot`.
+ - For snapshot backups, `backup.volumeSnapshot.className` is required (template fails early if missing).
+ - Example-only keys like `backup.ttl` and `backup.volumeSnapshot.enabled` are stripped to avoid CRD warnings.
+- CNPG: render `Cluster.spec.plugins` from `cnpg.cluster.plugins` (enables barman-cloud plugin / WAL archiver configuration).
diff --git a/charts/sure/Chart.yaml b/charts/sure/Chart.yaml
index 1208c08b5..f16dcb51d 100644
--- a/charts/sure/Chart.yaml
+++ b/charts/sure/Chart.yaml
@@ -2,8 +2,8 @@ apiVersion: v2
name: sure
description: Official Helm chart for deploying the Sure Rails app (web + Sidekiq) on Kubernetes with optional HA PostgreSQL (CloudNativePG) and Redis.
type: application
-version: 1.0.0
-appVersion: "0.6.5"
+version: 0.6.7-alpha
+appVersion: "0.6.7-alpha"
kubeVersion: ">=1.25.0-0"
diff --git a/charts/sure/README.md b/charts/sure/README.md
index 0f2c105ba..3182e39bd 100644
--- a/charts/sure/README.md
+++ b/charts/sure/README.md
@@ -144,6 +144,11 @@ cnpg:
storage:
size: 20Gi
storageClassName: longhorn
+ # Optional: enable CNPG volume snapshot backups (requires a VolumeSnapshotClass)
+ backup:
+ method: volumeSnapshot
+ volumeSnapshot:
+ className: longhorn
# Synchronous replication for stronger durability
minSyncReplicas: 1
maxSyncReplicas: 2
@@ -187,6 +192,26 @@ simplefin:
- The chart configures credentials via `spec.bootstrap.initdb.secret` rather than `managed.roles`. The operator expects the referenced Secret to contain `username` and `password` keys (configurable via values).
- This chart generates the application DB Secret when `cnpg.cluster.secret.enabled=true` using the keys defined at `cnpg.cluster.secret.usernameKey` (default `username`) and `cnpg.cluster.secret.passwordKey` (default `password`). If you use an existing Secret (`cnpg.cluster.existingSecret`), ensure it contains these keys. The Cluster CR references the Secret by name and maps the keys accordingly.
- If the CNPG operator is already installed cluster‑wide, you may set `cnpg.enabled=false` and keep `cnpg.cluster.enabled=true`. The chart will still render the `Cluster` CR and compute the in‑cluster `DATABASE_URL`.
+- For backups, CNPG requires `spec.backup.method` to be explicit (for example `volumeSnapshot` or `barmanObjectStore`). This chart will infer `method: volumeSnapshot` if a `backup.volumeSnapshot` block is present.
+ - For snapshot backups, `backup.volumeSnapshot.className` must be set (the chart will fail the render if it is missing).
+ - The CNPG `spec.backup` schema does not support keys like `ttl` or `volumeSnapshot.enabled`; this chart strips those keys to avoid CRD warnings.
+ - Unknown `backup.method` values are passed through and left for CNPG to validate.
+
+Example (barman-cloud plugin for WAL archiving + snapshot backups):
+
+```yaml
+cnpg:
+ cluster:
+ plugins:
+ - name: barman-cloud.cloudnative-pg.io
+ isWALArchiver: true
+ parameters:
+ barmanObjectName: minio-backups # references an ObjectStore CR
+ backup:
+ method: volumeSnapshot
+ volumeSnapshot:
+ className: longhorn
+```
Additional default hardening:
diff --git a/charts/sure/templates/_helpers.tpl b/charts/sure/templates/_helpers.tpl
index 2b183d102..2b202bc90 100644
--- a/charts/sure/templates/_helpers.tpl
+++ b/charts/sure/templates/_helpers.tpl
@@ -69,7 +69,7 @@ app.kubernetes.io/instance: {{ .Release.Name }}
{{- printf "redis://default:$(REDIS_PASSWORD)@%s:6379/0" $host -}}
{{- else if .Values.redisSimple.enabled -}}
{{- $host := printf "%s-redis.%s.svc.cluster.local" (include "sure.fullname" .) .Release.Namespace -}}
- {{- printf "redis://default:$(REDIS_PASSWORD)@%s:%d/0" $host (.Values.redisSimple.service.port | default 6379) -}}
+ {{- printf "redis://default:$(REDIS_PASSWORD)@%s:%d/0" $host (int (.Values.redisSimple.service.port | default 6379)) -}}
{{- else -}}
{{- "" -}}
{{- end -}}
diff --git a/charts/sure/templates/cnpg-cluster.yaml b/charts/sure/templates/cnpg-cluster.yaml
index 574858546..a19c789be 100644
--- a/charts/sure/templates/cnpg-cluster.yaml
+++ b/charts/sure/templates/cnpg-cluster.yaml
@@ -15,9 +15,53 @@ spec:
{{- end }}
storage:
size: {{ .Values.cnpg.cluster.storage.size | default "10Gi" }}
- {{- if .Values.cnpg.cluster.storage.storageClassName }}
+ {{ if .Values.cnpg.cluster.storage.storageClassName }}
storageClass: {{ .Values.cnpg.cluster.storage.storageClassName }}
- {{- end }}
+ {{ end }}
+ {{ with .Values.cnpg.cluster.backup }}
+ {{- $backup := deepCopy . }}
+ {{- /* CNPG `spec.backup` does not support these historical/example keys */ -}}
+ {{- $_ := unset $backup "ttl" -}}
+ {{- if and (hasKey $backup "volumeSnapshot") (kindIs "map" $backup.volumeSnapshot) -}}
+ {{- $_ := unset $backup.volumeSnapshot "enabled" -}}
+ {{- end -}}
+
+ {{- $method := (get $backup "method") -}}
+ {{- if not $method -}}
+ {{- if hasKey $backup "volumeSnapshot" -}}
+ {{- $_ := set $backup "method" "volumeSnapshot" -}}
+ {{- $method = "volumeSnapshot" -}}
+ {{- else if hasKey $backup "barmanObjectStore" -}}
+ {{- $_ := set $backup "method" "barmanObjectStore" -}}
+ {{- $method = "barmanObjectStore" -}}
+ {{- end -}}
+ {{- end -}}
+
+ {{- if not $method -}}
+ {{- fail "cnpg.cluster.backup is set but no backup method could be inferred. Set cnpg.cluster.backup.method explicitly (e.g. volumeSnapshot or barmanObjectStore)." -}}
+ {{- end -}}
+
+ {{- if and (eq $method "volumeSnapshot") (not (hasKey $backup "volumeSnapshot")) -}}
+ {{- fail "cnpg.cluster.backup.method=volumeSnapshot requires cnpg.cluster.backup.volumeSnapshot to be set" -}}
+ {{- end -}}
+ {{- if and (eq $method "barmanObjectStore") (not (hasKey $backup "barmanObjectStore")) -}}
+ {{- fail "cnpg.cluster.backup.method=barmanObjectStore requires cnpg.cluster.backup.barmanObjectStore to be set" -}}
+ {{- end -}}
+ {{- if eq $method "volumeSnapshot" -}}
+ {{- if kindIs "map" $backup.volumeSnapshot -}}
+ {{- $_ := required "cnpg.cluster.backup.volumeSnapshot.className is required for volumeSnapshot backups" $backup.volumeSnapshot.className -}}
+ {{- else -}}
+ {{- fail "cnpg.cluster.backup.volumeSnapshot must be a map/object" -}}
+ {{- end -}}
+ {{- end -}}
+
+ backup:
+ {{- toYaml $backup | nindent 4 }}
+ {{- end }}
+ {{ with .Values.cnpg.cluster.plugins }}
+ plugins:
+ {{- toYaml . | nindent 4 }}
+ {{ end }}
{{- with .Values.cnpg.cluster.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 4 }}
diff --git a/charts/sure/templates/web-deployment.yaml b/charts/sure/templates/web-deployment.yaml
index 3c2cb2654..326b7e6c5 100644
--- a/charts/sure/templates/web-deployment.yaml
+++ b/charts/sure/templates/web-deployment.yaml
@@ -10,8 +10,13 @@ spec:
strategy:
type: RollingUpdate
rollingUpdate:
- maxUnavailable: 0
- maxSurge: 25%
+ {{- if and .Values.web.strategy .Values.web.strategy.rollingUpdate }}
+ maxUnavailable: {{ .Values.web.strategy.rollingUpdate.maxUnavailable | default 1 }}
+ maxSurge: {{ .Values.web.strategy.rollingUpdate.maxSurge | default 0 }}
+ {{- else }}
+ maxUnavailable: 1
+ maxSurge: 0
+ {{- end }}
selector:
matchLabels:
app.kubernetes.io/component: web
diff --git a/charts/sure/templates/worker-deployment.yaml b/charts/sure/templates/worker-deployment.yaml
index 15c38194e..a358e28b6 100644
--- a/charts/sure/templates/worker-deployment.yaml
+++ b/charts/sure/templates/worker-deployment.yaml
@@ -6,7 +6,17 @@ metadata:
{{- include "sure.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.worker.replicas }}
- revisionHistoryLimit: 2
+ revisionHistoryLimit: {{ .Values.worker.revisionHistoryLimit | default 2 }}
+ strategy:
+ type: RollingUpdate
+ rollingUpdate:
+ {{- if and .Values.worker.strategy .Values.worker.strategy.rollingUpdate }}
+ maxUnavailable: {{ .Values.worker.strategy.rollingUpdate.maxUnavailable | default 1 }}
+ maxSurge: {{ .Values.worker.strategy.rollingUpdate.maxSurge | default 0 }}
+ {{- else }}
+ maxUnavailable: 1
+ maxSurge: 0
+ {{- end }}
selector:
matchLabels:
app.kubernetes.io/component: worker
diff --git a/charts/sure/tests/cnpg-backup_test.yaml b/charts/sure/tests/cnpg-backup_test.yaml
new file mode 100644
index 000000000..242413fa7
--- /dev/null
+++ b/charts/sure/tests/cnpg-backup_test.yaml
@@ -0,0 +1,73 @@
+suite: CNPG Cluster backup rendering
+templates:
+ - templates/cnpg-cluster.yaml
+
+tests:
+ - it: renders no spec.backup when cnpg.cluster.backup is unset
+ set:
+ cnpg:
+ cluster:
+ enabled: true
+ asserts:
+ - notExists:
+ path: spec.backup
+
+ - it: defaults method to volumeSnapshot when volumeSnapshot is present
+ set:
+ cnpg:
+ cluster:
+ enabled: true
+ backup:
+ volumeSnapshot:
+ className: longhorn
+ asserts:
+ - equal:
+ path: spec.backup.method
+ value: volumeSnapshot
+ - equal:
+ path: spec.backup.volumeSnapshot.className
+ value: longhorn
+
+ - it: strips unsupported keys (ttl and volumeSnapshot.enabled)
+ set:
+ cnpg:
+ cluster:
+ enabled: true
+ backup:
+ method: volumeSnapshot
+ ttl: 168h
+ volumeSnapshot:
+ enabled: true
+ className: longhorn
+ asserts:
+ - equal:
+ path: spec.backup.method
+ value: volumeSnapshot
+ - notExists:
+ path: spec.backup.ttl
+ - notExists:
+ path: spec.backup.volumeSnapshot.enabled
+
+ - it: fails when volumeSnapshot backups are enabled without className
+ set:
+ cnpg:
+ cluster:
+ enabled: true
+ backup:
+ method: volumeSnapshot
+ volumeSnapshot: {}
+ asserts:
+ - failedTemplate:
+ errorMessage: cnpg.cluster.backup.volumeSnapshot.className is required for volumeSnapshot backups
+
+ - it: renders unknown backup method as-is (CNPG will validate)
+ set:
+ cnpg:
+ cluster:
+ enabled: true
+ backup:
+ method: madeUpMethod
+ asserts:
+ - equal:
+ path: spec.backup.method
+ value: madeUpMethod
diff --git a/charts/sure/tests/cnpg-plugins_test.yaml b/charts/sure/tests/cnpg-plugins_test.yaml
new file mode 100644
index 000000000..79dc0d3b3
--- /dev/null
+++ b/charts/sure/tests/cnpg-plugins_test.yaml
@@ -0,0 +1,34 @@
+suite: CNPG Cluster plugins rendering
+templates:
+ - templates/cnpg-cluster.yaml
+
+tests:
+ - it: renders no spec.plugins when cnpg.cluster.plugins is unset
+ set:
+ cnpg:
+ cluster:
+ enabled: true
+ asserts:
+ - notExists:
+ path: spec.plugins
+
+ - it: renders spec.plugins when cnpg.cluster.plugins is set
+ set:
+ cnpg:
+ cluster:
+ enabled: true
+ plugins:
+ - name: barman-cloud.cloudnative-pg.io
+ isWALArchiver: true
+ parameters:
+ barmanObjectName: minio-backups
+ asserts:
+ - equal:
+ path: spec.plugins[0].name
+ value: barman-cloud.cloudnative-pg.io
+ - equal:
+ path: spec.plugins[0].isWALArchiver
+ value: true
+ - equal:
+ path: spec.plugins[0].parameters.barmanObjectName
+ value: minio-backups
diff --git a/charts/sure/values.yaml b/charts/sure/values.yaml
index 87faec82a..23b5ad8d7 100644
--- a/charts/sure/values.yaml
+++ b/charts/sure/values.yaml
@@ -9,7 +9,7 @@ fullnameOverride: ""
image:
repository: ghcr.io/we-promise/sure
- tag: "0.6.5"
+ tag: "0.6.6"
pullPolicy: IfNotPresent
# Optional: imagePullSecrets to pull from private registries
imagePullSecrets: []
@@ -65,6 +65,53 @@ cnpg:
storage:
size: 10Gi
storageClassName: ""
+ # Optional CNPG backup configuration (rendered as `.spec.backup` on the Cluster CR)
+ # Example (CNPG volume snapshots):
+ # backup:
+ # method: volumeSnapshot
+ # volumeSnapshot:
+ # className: longhorn
+ #
+ # Example (CNPG barmanObjectStore backups):
+ # backup:
+ # method: barmanObjectStore
+ # barmanObjectStore:
+ # destinationPath: s3://my-bucket/cnpg
+ # endpointURL: https://s3.us-east-1.amazonaws.com
+ # s3Credentials:
+ # accessKeyId:
+ # name: my-s3-creds
+ # key: ACCESS_KEY_ID
+ # secretAccessKey:
+ # name: my-s3-creds
+ # key: SECRET_ACCESS_KEY
+ #
+ # NOTE:
+ # - The CNPG Cluster `spec.backup` schema does not support `enabled` or `ttl` keys.
+ # If you add them, this chart will ignore them to avoid CRD warnings.
+ # - This chart only hard-validates required fields for supported methods; unknown `method` values
+ # are passed through for CNPG to validate.
+ backup: {}
+
+ # Optional CNPG plugin configuration (rendered as `.spec.plugins` on the Cluster CR)
+ # Example (barman-cloud plugin as WAL archiver):
+ # plugins:
+ # - name: barman-cloud.cloudnative-pg.io
+ # isWALArchiver: true
+ # parameters:
+ # barmanObjectName: minio-backups
+ #
+ # Example (complete setup: WAL archiving via barman-cloud plugin + snapshot backups):
+ # plugins:
+ # - name: barman-cloud.cloudnative-pg.io
+ # isWALArchiver: true
+ # parameters:
+ # barmanObjectName: minio-backups # references an ObjectStore CR
+ # backup:
+ # method: volumeSnapshot
+ # volumeSnapshot:
+ # className: longhorn
+ plugins: []
# auth config for application user
appUser: sure
appDatabase: sure
diff --git a/config/initializers/version.rb b/config/initializers/version.rb
index fc9696696..53e1b6bcb 100644
--- a/config/initializers/version.rb
+++ b/config/initializers/version.rb
@@ -14,7 +14,7 @@ module Sure
private
def semver
- "0.6.6-alpha.8"
+ "0.6.7-alpha.6"
end
end
end
diff --git a/config/locales/models/category/en.yml b/config/locales/models/category/en.yml
new file mode 100644
index 000000000..dc86eba56
--- /dev/null
+++ b/config/locales/models/category/en.yml
@@ -0,0 +1,6 @@
+---
+en:
+ models:
+ category:
+ uncategorized: Uncategorized
+ other_investments: Other Investments
diff --git a/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml
index 517ff6825..688ca8459 100644
--- a/config/locales/views/accounts/en.yml
+++ b/config/locales/views/accounts/en.yml
@@ -102,7 +102,7 @@ en:
title: Unlink account from provider?
description_html: "You are about to unlink %{account_name} from %{provider_name} . This will convert it to a manual account."
warning_title: What this means
- warning_no_sync: The account will no longer sync automatically with your bank
+ warning_no_sync: The account will no longer sync automatically with your provider
warning_manual_updates: You will need to add transactions and update balances manually
warning_transactions_kept: All existing transactions and balances will be preserved
warning_can_delete: After unlinking, you will be able to delete the account if needed
diff --git a/config/locales/views/coinstats_items/en.yml b/config/locales/views/coinstats_items/en.yml
new file mode 100644
index 000000000..00add222d
--- /dev/null
+++ b/config/locales/views/coinstats_items/en.yml
@@ -0,0 +1,63 @@
+---
+en:
+ coinstats_items:
+ create:
+ success: CoinStats provider connection configured successfully.
+ default_name: CoinStats Connection
+ errors:
+ validation_failed: "Validation failed: %{message}."
+ update:
+ success: CoinStats provider connection updated successfully.
+ errors:
+ validation_failed: "Validation failed: %{message}."
+ destroy:
+ success: CoinStats provider connection scheduled for deletion.
+ link_wallet:
+ success: "%{count} crypto wallet(s) linked successfully."
+ missing_params: "Missing required parameters: address and blockchain."
+ failed: Crypto wallet linking failed.
+ error: "Crypto wallet linking failed: %{message}."
+ new:
+ title: Link a Crypto Wallet with CoinStats
+ blockchain_fetch_error: Failed load Blockchains. Please try again later.
+ address_label: Address
+ address_placeholder: Required
+ blockchain_label: Blockchain
+ blockchain_placeholder: Required
+ blockchain_select_blank: Select a Blockchain
+ link: Link Crypto Wallet
+ not_configured_title: CoinStats provider connection not configured
+ not_configured_message: To link a crypto wallet, you must first configure the CoinStats provider connection.
+ not_configured_step1_html: Go to Settings → Providers
+ not_configured_step2_html: Locate the CoinStats provider
+ not_configured_step3_html: Follow the provided setup Instructions to complete provider configuration
+ go_to_settings: Go to Provider Settings
+ setup_instructions: "Setup Instructions:"
+ step1_html: Visit the CoinStats Public API Dashboard to obtain an API key.
+ step2: Enter your API key below and click Configure.
+ step3_html: After a successful connection, visit the Accounts tab to set up crypto wallets.
+ api_key_label: API Key
+ api_key_placeholder: Required
+ configure: Configure
+ update_configuration: Reconfigure
+ default_name: CoinStats Connection
+ status_configured_html: Ready to use
+ status_not_configured: Not configured
+ coinstats_item:
+ deletion_in_progress: Crypto wallet data is being deleted…
+ provider_name: CoinStats
+ syncing: Syncing…
+ sync_status:
+ no_accounts: No crypto wallets found
+ all_synced:
+ one: "%{count} crypto wallet synced"
+ other: "%{count} crypto wallets synced"
+ partial_sync: "%{linked_count} crypto wallets synced, %{unlinked_count} need setup"
+ reconnect: Reconnect
+ status: Last synced %{timestamp} ago
+ status_never: Never synced
+ status_with_summary: "Last synced %{timestamp} ago • %{summary}"
+ update_api_key: Update API Key
+ delete: Delete
+ no_wallets_title: No crypto wallets connected
+ no_wallets_message: No crypto wallets are currently connected to CoinStats.
diff --git a/config/locales/views/components/en.yml b/config/locales/views/components/en.yml
new file mode 100644
index 000000000..296d151d6
--- /dev/null
+++ b/config/locales/views/components/en.yml
@@ -0,0 +1,29 @@
+---
+en:
+ provider_sync_summary:
+ title: Sync summary
+ last_sync: "Last sync: %{time_ago} ago"
+ accounts:
+ title: Accounts
+ total: "Total: %{count}"
+ linked: "Linked: %{count}"
+ unlinked: "Unlinked: %{count}"
+ institutions: "Institutions: %{count}"
+ transactions:
+ title: Transactions
+ seen: "Seen: %{count}"
+ imported: "Imported: %{count}"
+ updated: "Updated: %{count}"
+ skipped: "Skipped: %{count}"
+ holdings:
+ title: Holdings
+ found: "Found: %{count}"
+ processed: "Processed: %{count}"
+ health:
+ title: Health
+ rate_limited: "Rate limited %{time_ago}"
+ recently: recently
+ errors: "Errors: %{count}"
+ data_warnings: "Data warnings: %{count}"
+ notices: "Notices: %{count}"
+ view_data_quality: View data quality details
diff --git a/config/locales/views/merchants/en.yml b/config/locales/views/merchants/en.yml
index 4f48c9c21..4f2bec525 100644
--- a/config/locales/views/merchants/en.yml
+++ b/config/locales/views/merchants/en.yml
@@ -6,6 +6,7 @@ en:
success: New merchant created successfully
destroy:
success: Merchant deleted successfully
+ unlinked_success: Merchant removed from your transactions
edit:
title: Edit merchant
form:
@@ -13,12 +14,16 @@ en:
index:
empty: No merchants yet
new: New merchant
+ merge: Merge merchants
title: Merchants
family_title: Family merchants
family_empty: No family merchants yet
provider_title: Provider merchants
provider_empty: No provider merchants linked to this family yet
provider_read_only: Provider merchants are synced from your connected institutions. They cannot be edited here.
+ provider_info: These merchants were automatically detected by your bank connections or AI. You can edit them to create your own copy, or remove them to unlink from your transactions.
+ unlinked_title: Recently unlinked
+ unlinked_info: These merchants were recently removed from your transactions. They will disappear from this list after 30 days unless re-assigned to a transaction.
table:
merchant: Merchant
actions: Actions
@@ -30,7 +35,26 @@ en:
confirm_title: Delete merchant?
delete: Delete merchant
edit: Edit merchant
+ merge:
+ title: Merge merchants
+ description: Select a target merchant and the merchants to merge into it. All transactions from merged merchants will be reassigned to the target.
+ target_label: Merge into (target)
+ select_target: Select target merchant...
+ sources_label: Merchants to merge
+ sources_hint: Selected merchants will be merged into the target. Family merchants will be deleted, provider merchants will be unlinked.
+ submit: Merge selected
new:
title: New merchant
+ perform_merge:
+ success: Successfully merged %{count} merchants
+ no_merchants_selected: No merchants selected to merge
+ target_not_found: Target merchant not found
+ invalid_merchants: Invalid merchants selected
+ provider_merchant:
+ edit: Edit
+ remove: Remove
+ remove_confirm_title: Remove merchant?
+ remove_confirm_body: Are you sure you want to remove %{name}? This will unlink all associated transactions from this merchant but will not delete the merchant itself.
update:
success: Merchant updated successfully
+ converted_success: Merchant converted and updated successfully
diff --git a/config/locales/views/pages/en.yml b/config/locales/views/pages/en.yml
index 8fa38a416..f19322678 100644
--- a/config/locales/views/pages/en.yml
+++ b/config/locales/views/pages/en.yml
@@ -35,3 +35,16 @@ en:
categories: "Categories"
value: "Value"
weight: "Weight"
+ investment_summary:
+ title: "Investments"
+ total_return: "Total Return"
+ holding: "Holding"
+ weight: "Weight"
+ value: "Value"
+ return: "Return"
+ period_activity: "%{period} Activity"
+ contributions: "Contributions"
+ withdrawals: "Withdrawals"
+ trades: "Trades"
+ no_investments: "No investment accounts"
+ add_investment: "Add an investment account to track your portfolio"
diff --git a/config/locales/views/password_resets/en.yml b/config/locales/views/password_resets/en.yml
index 89b152b49..c491edca8 100644
--- a/config/locales/views/password_resets/en.yml
+++ b/config/locales/views/password_resets/en.yml
@@ -2,6 +2,7 @@
en:
password_resets:
disabled: Password reset via Sure is disabled. Please reset your password through your identity provider.
+ sso_only_user: Your account uses SSO for authentication. Please contact your administrator to manage your credentials.
edit:
title: Reset password
new:
diff --git a/config/locales/views/reports/en.yml b/config/locales/views/reports/en.yml
index 5b4ed7a49..2d34539f7 100644
--- a/config/locales/views/reports/en.yml
+++ b/config/locales/views/reports/en.yml
@@ -69,8 +69,8 @@ en:
add_transaction: Add Transaction
add_account: Add Account
transactions_breakdown:
- title: Transactions Breakdown
- no_transactions: No transactions found for the selected period and filters
+ title: Activity Breakdown
+ no_transactions: No activity found for the selected period and filters
filters:
title: Filters
category: Category
@@ -102,12 +102,37 @@ en:
expense: Expenses
income: Income
uncategorized: Uncategorized
- transactions: transactions
+ entries:
+ one: entry
+ other: entries
percentage: "% of Total"
pagination:
- showing: Showing %{count} transactions
+ showing:
+ one: Showing %{count} entry
+ other: Showing %{count} entries
previous: Previous
next: Next
+ net_worth:
+ title: Net Worth
+ current_net_worth: Current Net Worth
+ period_change: Period Change
+ assets_vs_liabilities: Assets vs Liabilities
+ total_assets: Assets
+ total_liabilities: Liabilities
+ no_assets: No assets
+ no_liabilities: No liabilities
+ investment_performance:
+ title: Investment Performance
+ portfolio_value: Portfolio Value
+ total_return: Total Return
+ contributions: Period Contributions
+ withdrawals: Period Withdrawals
+ top_holdings: Top Holdings
+ holding: Holding
+ weight: Weight
+ value: Value
+ return: Return
+ accounts: Investment Accounts
google_sheets_instructions:
title_with_key: "✅ Copy URL for Google Sheets"
title_no_key: "⚠️ API Key Required"
diff --git a/config/locales/views/rules/en.yml b/config/locales/views/rules/en.yml
index c571eb0f6..5cbd252d5 100644
--- a/config/locales/views/rules/en.yml
+++ b/config/locales/views/rules/en.yml
@@ -2,6 +2,21 @@
en:
rules:
no_action: No Action
+ actions:
+ value_placeholder: Enter a value
+ apply_all:
+ button: Apply All
+ confirm_title: Apply All Rules
+ confirm_message: You are about to apply %{count} rules affecting %{transactions} unique transactions. Please confirm if you wish to proceed.
+ confirm_button: Confirm and Apply All
+ success: All rules have been queued for execution
+ ai_cost_title: AI Cost Estimation
+ ai_cost_message: This will use AI to categorize up to %{transactions} transactions.
+ estimated_cost: "Estimated cost: ~$%{cost}"
+ cost_unavailable_model: Cost estimation unavailable for model "%{model}".
+ cost_unavailable_no_provider: Cost estimation unavailable (no LLM provider configured).
+ cost_warning: You may incur costs, please check with the model provider for the most up-to-date prices.
+ view_usage: View usage history
recent_runs:
title: Recent Runs
description: View the execution history of your rules including success/failure status and transaction counts.
diff --git a/config/locales/views/simplefin_items/en.yml b/config/locales/views/simplefin_items/en.yml
index 5ff871805..a84c335e3 100644
--- a/config/locales/views/simplefin_items/en.yml
+++ b/config/locales/views/simplefin_items/en.yml
@@ -2,43 +2,60 @@
en:
simplefin_items:
new:
- title: Connect SimpleFin
+ title: Connect SimpleFIN
setup_token: Setup token
- setup_token_placeholder: paste your SimpleFin setup token
+ setup_token_placeholder: paste your SimpleFIN setup token
connect: Connect
cancel: Cancel
create:
- success: SimpleFin connection added successfully! Your accounts will appear shortly as they sync in the background.
+ success: SimpleFIN connection added successfully! Your accounts will appear shortly as they sync in the background.
errors:
- blank_token: Please enter a SimpleFin setup token.
- invalid_token: Invalid setup token. Please check that you copied the complete token from SimpleFin Bridge.
+ blank_token: Please enter a SimpleFIN setup token.
+ invalid_token: Invalid setup token. Please check that you copied the complete token from SimpleFIN Bridge.
token_compromised: The setup token may be compromised, expired, or already used. Please create a new one.
create_failed: "Failed to connect: %{message}"
unexpected: An unexpected error occurred. Please try again or contact support.
destroy:
- success: SimpleFin connection will be removed
+ success: SimpleFIN connection will be removed
update:
- success: SimpleFin connection updated successfully! Your accounts are being reconnected.
+ success: SimpleFIN connection updated successfully! Your accounts are being reconnected.
errors:
- blank_token: Please enter a SimpleFin setup token.
- invalid_token: Invalid setup token. Please check that you copied the complete token from SimpleFin Bridge.
+ blank_token: Please enter a SimpleFIN setup token.
+ invalid_token: Invalid setup token. Please check that you copied the complete token from SimpleFIN Bridge.
token_compromised: The setup token may be compromised, expired, or already used. Please create a new one.
update_failed: "Failed to update connection: %{message}"
unexpected: An unexpected error occurred. Please try again or contact support.
edit:
setup_token:
- label: "SimpleFin Setup Token:"
- placeholder: "Paste your SimpleFin setup token here..."
+ label: "SimpleFIN Setup Token:"
+ placeholder: "Paste your SimpleFIN setup token here..."
help_text: "The token should be a long string starting with letters and numbers"
+ setup_accounts:
+ stale_accounts:
+ title: "Accounts No Longer in SimpleFIN"
+ description: "These accounts exist in your database but are no longer provided by SimpleFIN. This can happen when account configurations change upstream."
+ action_prompt: "What would you like to do?"
+ action_delete: "Delete account and all transactions"
+ action_move: "Move transactions to:"
+ action_skip: "Skip for now"
+ transaction_count:
+ one: "%{count} transaction"
+ other: "%{count} transactions"
complete_account_setup:
all_skipped: "All accounts were skipped. No accounts were created."
no_accounts: "No accounts to set up."
- success: "Successfully created %{count} SimpleFIN account(s)! Your transactions and holdings are being imported in the background."
+ success:
+ one: "Successfully created %{count} SimpleFIN account! Your transactions and holdings are being imported in the background."
+ other: "Successfully created %{count} SimpleFIN accounts! Your transactions and holdings are being imported in the background."
+ stale_accounts_processed: "Stale accounts: %{deleted} deleted, %{moved} moved."
+ stale_accounts_errors:
+ one: "%{count} stale account action failed. Check logs for details."
+ other: "%{count} stale account actions failed. Check logs for details."
simplefin_item:
add_new: Add new connection
confirm_accept: Delete connection
confirm_body: This will permanently delete all the accounts in this group and all associated data.
- confirm_title: Delete SimpleFin connection?
+ confirm_title: Delete SimpleFIN connection?
delete: Delete
deletion_in_progress: "(deletion in progress...)"
error: Error occurred while syncing data
@@ -46,7 +63,7 @@ en:
no_accounts_title: No accounts found
requires_update: Reconnect
setup_needed: New accounts ready to set up
- setup_description: Choose account types for your newly imported SimpleFin accounts.
+ setup_description: Choose account types for your newly imported SimpleFIN accounts.
setup_action: Set Up New Accounts
status: Last synced %{timestamp} ago
status_never: Never synced
@@ -68,4 +85,4 @@ en:
success: Account successfully linked to SimpleFIN
errors:
only_manual: Only manual accounts can be linked
- invalid_simplefin_account: Invalid SimpleFIN account selected
\ No newline at end of file
+ invalid_simplefin_account: Invalid SimpleFIN account selected
diff --git a/config/locales/views/simplefin_items/update.en.yml b/config/locales/views/simplefin_items/update.en.yml
index 8a92c5444..cea719954 100644
--- a/config/locales/views/simplefin_items/update.en.yml
+++ b/config/locales/views/simplefin_items/update.en.yml
@@ -1,7 +1,7 @@
en:
simplefin_items:
update:
- success: "SimpleFin connection updated."
+ success: "SimpleFIN connection updated."
errors:
- blank_token: "Missing SimpleFin access token. Please provide a token or use Link Existing Accounts to proceed."
- update_failed: "Failed to update SimpleFin connection: %{message}"
+ blank_token: "Missing SimpleFIN access token. Please provide a token or use Link Existing Accounts to proceed."
+ update_failed: "Failed to update SimpleFIN connection: %{message}"
diff --git a/config/locales/views/trades/en.yml b/config/locales/views/trades/en.yml
index 959558653..514351455 100644
--- a/config/locales/views/trades/en.yml
+++ b/config/locales/views/trades/en.yml
@@ -24,6 +24,7 @@ en:
title: New transaction
show:
additional: Additional
+ category_label: Category
cost_per_share_label: Cost per Share
date_label: Date
delete: Delete
@@ -32,6 +33,7 @@ en:
details: Details
exclude_subtitle: This trade will not be included in reports and calculations
exclude_title: Exclude from analytics
+ no_category: No category
note_label: Note
note_placeholder: Add any additional notes here...
quantity_label: Quantity
diff --git a/config/locales/views/transactions/en.yml b/config/locales/views/transactions/en.yml
index e66407248..4572ed8c6 100644
--- a/config/locales/views/transactions/en.yml
+++ b/config/locales/views/transactions/en.yml
@@ -1,6 +1,7 @@
---
en:
transactions:
+ unknown_name: Unknown transaction
form:
account: Account
account_prompt: Select an Account
diff --git a/config/routes.rb b/config/routes.rb
index f591ea2f4..8a5188faf 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -2,6 +2,16 @@ require "sidekiq/web"
require "sidekiq/cron/web"
Rails.application.routes.draw do
+ # CoinStats routes
+ resources :coinstats_items, only: [ :index, :new, :create, :update, :destroy ] do
+ collection do
+ post :link_wallet
+ end
+ member do
+ post :sync
+ end
+ end
+
resources :enable_banking_items, only: [ :new, :create, :update, :destroy ] do
collection do
get :callback
@@ -135,7 +145,12 @@ Rails.application.routes.draw do
resources :budget_categories, only: %i[index show update]
end
- resources :family_merchants, only: %i[index new create edit update destroy]
+ resources :family_merchants, only: %i[index new create edit update destroy] do
+ collection do
+ get :merge
+ post :perform_merge
+ end
+ end
resources :transfers, only: %i[new create destroy show update]
@@ -211,6 +226,8 @@ Rails.application.routes.draw do
collection do
delete :destroy_all
+ get :confirm_all
+ post :apply_all
end
end
@@ -270,11 +287,12 @@ Rails.application.routes.draw do
post "auth/refresh", to: "auth#refresh"
# Production API endpoints
- resources :accounts, only: [ :index ]
+ resources :accounts, only: [ :index, :show ]
resources :categories, only: [ :index, :show ]
resources :transactions, only: [ :index, :show, :create, :update, :destroy ]
- resource :usage, only: [ :show ], controller: "usage"
- resource :sync, only: [ :create ], controller: "sync"
+ resources :imports, only: [ :index, :show, :create ]
+ resource :usage, only: [ :show ], controller: :usage
+ post :sync, to: "sync#create"
resources :chats, only: [ :index, :show, :create, :update, :destroy ] do
resources :messages, only: [ :create ] do
diff --git a/config/schedule.yml b/config/schedule.yml
index 095820d19..b12d75ee7 100644
--- a/config/schedule.yml
+++ b/config/schedule.yml
@@ -24,3 +24,15 @@ sync_all_accounts:
class: "SyncAllJob"
queue: "scheduled"
description: "Syncs all accounts for all families"
+
+sync_hourly:
+ cron: "0 * * * *" # every hour at the top of the hour
+ class: "SyncHourlyJob"
+ queue: "scheduled"
+ description: "Syncs provider items that opt-in to hourly syncing"
+
+clean_data:
+ cron: "0 3 * * *" # daily at 3:00 AM
+ class: "DataCleanerJob"
+ queue: "scheduled"
+ description: "Cleans up old data (e.g., expired merchant associations)"
diff --git a/db/migrate/20251125141213_add_category_to_trades.rb b/db/migrate/20251125141213_add_category_to_trades.rb
new file mode 100644
index 000000000..01ecb82fd
--- /dev/null
+++ b/db/migrate/20251125141213_add_category_to_trades.rb
@@ -0,0 +1,7 @@
+class AddCategoryToTrades < ActiveRecord::Migration[7.2]
+ def change
+ unless column_exists?(:trades, :category_id)
+ add_reference :trades, :category, null: true, foreign_key: true, type: :uuid
+ end
+ end
+end
diff --git a/db/migrate/20251221060111_create_coinstats_items_and_accounts.rb b/db/migrate/20251221060111_create_coinstats_items_and_accounts.rb
new file mode 100644
index 000000000..a670798a1
--- /dev/null
+++ b/db/migrate/20251221060111_create_coinstats_items_and_accounts.rb
@@ -0,0 +1,60 @@
+class CreateCoinstatsItemsAndAccounts < ActiveRecord::Migration[7.2]
+ def change
+ # Create provider items table (stores per-family connection credentials)
+ create_table :coinstats_items, id: :uuid do |t|
+ t.references :family, null: false, foreign_key: true, type: :uuid
+ t.string :name
+
+ # Institution metadata
+ t.string :institution_id
+ t.string :institution_name
+ t.string :institution_domain
+ t.string :institution_url
+ t.string :institution_color
+
+ # Status and lifecycle
+ t.string :status, default: "good"
+ t.boolean :scheduled_for_deletion, default: false
+ t.boolean :pending_account_setup, default: false
+
+ # Sync settings
+ t.datetime :sync_start_date
+
+ # Raw data storage
+ t.jsonb :raw_payload
+ t.jsonb :raw_institution_payload
+
+ # Provider-specific credential fields
+ t.string :api_key, null: false
+
+ t.timestamps
+ end
+
+ add_index :coinstats_items, :status
+
+ # Create provider accounts table (stores individual account data from provider)
+ create_table :coinstats_accounts, id: :uuid do |t|
+ t.references :coinstats_item, null: false, foreign_key: true, type: :uuid
+
+ # Account identification
+ t.string :name
+ t.string :account_id
+
+ # Account details
+ t.string :currency
+ t.decimal :current_balance, precision: 19, scale: 4
+ t.string :account_status
+ t.string :account_type
+ t.string :provider
+
+ # Metadata and raw data
+ t.jsonb :institution_metadata
+ t.jsonb :raw_payload
+ t.jsonb :raw_transactions_payload
+
+ t.timestamps
+ end
+
+ add_index :coinstats_accounts, :account_id
+ end
+end
diff --git a/db/migrate/20251224215026_add_rows_count_to_imports.rb b/db/migrate/20251224215026_add_rows_count_to_imports.rb
new file mode 100644
index 000000000..20566c99f
--- /dev/null
+++ b/db/migrate/20251224215026_add_rows_count_to_imports.rb
@@ -0,0 +1,16 @@
+class AddRowsCountToImports < ActiveRecord::Migration[7.2]
+ def up
+ add_column :imports, :rows_count, :integer, default: 0, null: false
+
+ say_with_time "Backfilling rows_count for imports" do
+ Import.reset_column_information
+ Import.find_each do |import|
+ Import.reset_counters(import.id, :rows)
+ end
+ end
+ end
+
+ def down
+ remove_column :imports, :rows_count
+ end
+end
diff --git a/db/migrate/20260106152346_add_provisional_to_security_prices.rb b/db/migrate/20260106152346_add_provisional_to_security_prices.rb
new file mode 100644
index 000000000..665d13452
--- /dev/null
+++ b/db/migrate/20260106152346_add_provisional_to_security_prices.rb
@@ -0,0 +1,5 @@
+class AddProvisionalToSecurityPrices < ActiveRecord::Migration[7.2]
+ def change
+ add_column :security_prices, :provisional, :boolean, default: false, null: false
+ end
+end
diff --git a/db/migrate/20260108000000_cleanup_orphaned_currency_balances.rb b/db/migrate/20260108000000_cleanup_orphaned_currency_balances.rb
new file mode 100644
index 000000000..e493e0578
--- /dev/null
+++ b/db/migrate/20260108000000_cleanup_orphaned_currency_balances.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+# This migration cleans up orphaned balance records that have a currency different
+# from their account's current currency. This can happen when linked accounts
+# (SimpleFIN, Lunchflow, Enable Banking, Plaid) were created with an initial sync
+# before the correct currency was known from the provider.
+#
+# The fix in Account.create_and_sync with skip_initial_sync: true prevents this
+# going forward, but existing data needs to be cleaned up.
+class CleanupOrphanedCurrencyBalances < ActiveRecord::Migration[7.2]
+ def up
+ # Skip in test environment with empty database (CI)
+ return say "Skipping in test environment - no data to clean" if Rails.env.test? && account_count.zero?
+
+ # First, identify affected accounts for logging
+ affected_accounts = execute(<<~SQL).to_a
+ SELECT DISTINCT
+ a.id,
+ a.name,
+ a.currency as account_currency,
+ b.currency as orphaned_currency,
+ COUNT(b.id) as orphaned_balance_count
+ FROM accounts a
+ JOIN balances b ON a.id = b.account_id
+ WHERE b.currency != a.currency
+ AND (
+ a.simplefin_account_id IS NOT NULL
+ OR a.plaid_account_id IS NOT NULL
+ OR EXISTS (SELECT 1 FROM account_providers WHERE account_id = a.id)
+ )
+ GROUP BY a.id, a.name, a.currency, b.currency
+ ORDER BY a.name
+ SQL
+
+ if affected_accounts.any?
+ say "Found #{affected_accounts.size} account-currency combinations with orphaned balances:"
+ affected_accounts.each do |row|
+ say " - #{row['name']}: #{row['orphaned_balance_count']} balances in #{row['orphaned_currency']} (account is #{row['account_currency']})"
+ end
+
+ # Delete orphaned balances where currency doesn't match account currency
+ # Only for linked accounts (provider-connected accounts)
+ execute(<<~SQL)
+ DELETE FROM balances
+ WHERE id IN (
+ SELECT b.id
+ FROM balances b
+ JOIN accounts a ON b.account_id = a.id
+ WHERE b.currency != a.currency
+ AND (
+ a.simplefin_account_id IS NOT NULL
+ OR a.plaid_account_id IS NOT NULL
+ OR EXISTS (SELECT 1 FROM account_providers WHERE account_id = a.id)
+ )
+ )
+ SQL
+
+ say "Deleted orphaned balances from linked accounts"
+
+ # Get unique account IDs that need re-sync
+ account_ids = affected_accounts.map { |row| row["id"] }.uniq
+
+ # Schedule re-sync for affected accounts to regenerate correct balances
+ # Only if Account model is available and responds to sync_later
+ if defined?(Account) && Account.respond_to?(:where)
+ say "Scheduling re-sync for #{account_ids.size} affected accounts..."
+ Account.where(id: account_ids).find_each do |account|
+ account.sync_later if account.respond_to?(:sync_later)
+ end
+ say "Scheduled re-sync for #{account_ids.size} affected accounts"
+ else
+ say "Skipping re-sync scheduling (Account model not available)"
+ say "Please manually sync affected accounts: #{account_ids.join(', ')}"
+ end
+ else
+ say "No orphaned currency balances found - database is clean"
+ end
+ end
+
+ def down
+ say "This migration cannot be fully reversed."
+ say "The deleted balances will be regenerated by the scheduled syncs."
+ say "If syncs haven't run yet, you may need to manually trigger them."
+ end
+
+ private
+
+ def account_count
+ execute("SELECT COUNT(*) FROM accounts").first["count"].to_i
+ rescue
+ 0
+ end
+end
diff --git a/db/migrate/20260109100000_add_holdings_columns_to_lunchflow_accounts.rb b/db/migrate/20260109100000_add_holdings_columns_to_lunchflow_accounts.rb
new file mode 100644
index 000000000..6ded6aca2
--- /dev/null
+++ b/db/migrate/20260109100000_add_holdings_columns_to_lunchflow_accounts.rb
@@ -0,0 +1,6 @@
+class AddHoldingsColumnsToLunchflowAccounts < ActiveRecord::Migration[7.2]
+ def change
+ add_column :lunchflow_accounts, :holdings_supported, :boolean, default: true, null: false
+ add_column :lunchflow_accounts, :raw_holdings_payload, :jsonb
+ end
+end
diff --git a/db/migrate/20260109135841_add_website_url_to_securities.rb b/db/migrate/20260109135841_add_website_url_to_securities.rb
new file mode 100644
index 000000000..e2ed37b10
--- /dev/null
+++ b/db/migrate/20260109135841_add_website_url_to_securities.rb
@@ -0,0 +1,5 @@
+class AddWebsiteUrlToSecurities < ActiveRecord::Migration[7.2]
+ def change
+ add_column :securities, :website_url, :string
+ end
+end
diff --git a/db/migrate/20260109144012_create_family_merchant_associations.rb b/db/migrate/20260109144012_create_family_merchant_associations.rb
new file mode 100644
index 000000000..8cbc32ced
--- /dev/null
+++ b/db/migrate/20260109144012_create_family_merchant_associations.rb
@@ -0,0 +1,13 @@
+class CreateFamilyMerchantAssociations < ActiveRecord::Migration[7.2]
+ def change
+ create_table :family_merchant_associations, id: :uuid do |t|
+ t.references :family, null: false, foreign_key: true, type: :uuid
+ t.references :merchant, null: false, foreign_key: true, type: :uuid
+ t.datetime :unlinked_at
+
+ t.timestamps
+ end
+
+ add_index :family_merchant_associations, [ :family_id, :merchant_id ], unique: true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 1ff598f3c..5d003e829 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.2].define(version: 2026_01_03_170412) do
+ActiveRecord::Schema[7.2].define(version: 2026_01_10_122603) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -197,6 +197,45 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_03_170412) do
t.index ["user_id"], name: "index_chats_on_user_id"
end
+ create_table "coinstats_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.uuid "coinstats_item_id", null: false
+ t.string "name"
+ t.string "account_id"
+ t.string "currency"
+ t.decimal "current_balance", precision: 19, scale: 4
+ t.string "account_status"
+ t.string "account_type"
+ t.string "provider"
+ t.jsonb "institution_metadata"
+ t.jsonb "raw_payload"
+ t.jsonb "raw_transactions_payload"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id"], name: "index_coinstats_accounts_on_account_id"
+ t.index ["coinstats_item_id"], name: "index_coinstats_accounts_on_coinstats_item_id"
+ end
+
+ create_table "coinstats_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.uuid "family_id", null: false
+ t.string "name"
+ t.string "institution_id"
+ t.string "institution_name"
+ t.string "institution_domain"
+ t.string "institution_url"
+ t.string "institution_color"
+ t.string "status", default: "good"
+ t.boolean "scheduled_for_deletion", default: false
+ t.boolean "pending_account_setup", default: false
+ t.datetime "sync_start_date"
+ t.jsonb "raw_payload"
+ t.jsonb "raw_institution_payload"
+ t.string "api_key", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["family_id"], name: "index_coinstats_items_on_family_id"
+ t.index ["status"], name: "index_coinstats_items_on_status"
+ end
+
create_table "credit_cards", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
@@ -422,6 +461,17 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_03_170412) do
t.index ["family_id"], name: "index_family_exports_on_family_id"
end
+ create_table "family_merchant_associations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.uuid "family_id", null: false
+ t.uuid "merchant_id", null: false
+ t.datetime "unlinked_at"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["family_id", "merchant_id"], name: "idx_on_family_id_merchant_id_23e883e08f", unique: true
+ t.index ["family_id"], name: "index_family_merchant_associations_on_family_id"
+ t.index ["merchant_id"], name: "index_family_merchant_associations_on_merchant_id"
+ end
+
create_table "flipper_features", force: :cascade do |t|
t.string "key", null: false
t.datetime "created_at", null: false
@@ -554,6 +604,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_03_170412) do
t.string "exchange_operating_mic_col_label"
t.string "amount_type_strategy", default: "signed_amount"
t.string "amount_type_inflow_value"
+ t.integer "rows_count", default: 0, null: false
t.index ["family_id"], name: "index_imports_on_family_id"
end
@@ -630,6 +681,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_03_170412) do
t.jsonb "raw_transactions_payload"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
+ t.boolean "holdings_supported", default: true, null: false
+ t.jsonb "raw_holdings_payload"
t.index ["account_id"], name: "index_lunchflow_accounts_on_account_id"
t.index ["lunchflow_item_id"], name: "index_lunchflow_accounts_on_lunchflow_item_id"
end
@@ -928,6 +981,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_03_170412) do
t.datetime "failed_fetch_at"
t.integer "failed_fetch_count", default: 0, null: false
t.datetime "last_health_check_at"
+ t.string "website_url"
t.index "upper((ticker)::text), COALESCE(upper((exchange_operating_mic)::text), ''::text)", name: "index_securities_on_ticker_and_exchange_operating_mic_unique", unique: true
t.index ["country_code"], name: "index_securities_on_country_code"
t.index ["exchange_operating_mic"], name: "index_securities_on_exchange_operating_mic"
@@ -940,6 +994,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_03_170412) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.uuid "security_id"
+ t.boolean "provisional", default: false, null: false
t.index ["security_id", "date", "currency"], name: "index_security_prices_on_security_id_and_date_and_currency", unique: true
t.index ["security_id"], name: "index_security_prices_on_security_id"
end
@@ -1221,6 +1276,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_03_170412) do
add_foreign_key "budgets", "families"
add_foreign_key "categories", "families"
add_foreign_key "chats", "users"
+ add_foreign_key "coinstats_accounts", "coinstats_items"
+ add_foreign_key "coinstats_items", "families"
add_foreign_key "enable_banking_accounts", "enable_banking_items"
add_foreign_key "enable_banking_items", "families"
add_foreign_key "entries", "accounts", on_delete: :cascade
@@ -1230,6 +1287,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_03_170412) do
add_foreign_key "eval_runs", "eval_datasets"
add_foreign_key "eval_samples", "eval_datasets"
add_foreign_key "family_exports", "families"
+ add_foreign_key "family_merchant_associations", "families"
+ add_foreign_key "family_merchant_associations", "merchants"
add_foreign_key "holdings", "account_providers"
add_foreign_key "holdings", "accounts", on_delete: :cascade
add_foreign_key "holdings", "securities"
diff --git a/docs/api/imports.md b/docs/api/imports.md
new file mode 100644
index 000000000..f8f824148
--- /dev/null
+++ b/docs/api/imports.md
@@ -0,0 +1,101 @@
+# Imports API Documentation
+
+The Imports API allows external applications to programmatically upload and process financial data from CSV files. This API supports creating transaction imports, configuring column mappings, and triggering the import process.
+
+## Authentication requirements
+
+All import endpoints require an OAuth2 access token or API key that grants the appropriate scope (`read` or `read_write`).
+
+## Available endpoints
+
+| Endpoint | Scope | Description |
+| --- | --- | --- |
+| `GET /api/v1/imports` | `read` | List imports with filtering and pagination. |
+| `GET /api/v1/imports/{id}` | `read` | Retrieve a single import with configuration and statistics. |
+| `POST /api/v1/imports` | `read_write` | Create a new import and optionally trigger processing. |
+
+## Filtering options
+
+The `GET /api/v1/imports` endpoint supports the following query parameters:
+
+| Parameter | Type | Description |
+| --- | --- | --- |
+| `page` | integer | Page number (default: 1) |
+| `per_page` | integer | Items per page (default: 25, max: 100) |
+| `status` | string | Filter by status: `pending`, `importing`, `complete`, `failed`, `reverting`, `revert_failed` |
+| `type` | string | Filter by import type: `TransactionImport`, `TradeImport`, etc. |
+
+## Import object
+
+An import response includes configuration and processing statistics:
+
+```json
+{
+ "data": {
+ "id": "uuid",
+ "type": "TransactionImport",
+ "status": "pending",
+ "created_at": "2024-01-15T10:30:00Z",
+ "updated_at": "2024-01-15T10:30:00Z",
+ "account_id": "uuid",
+ "configuration": {
+ "date_col_label": "date",
+ "amount_col_label": "amount",
+ "name_col_label": "name",
+ "category_col_label": "category",
+ "tags_col_label": "tags",
+ "notes_col_label": "notes",
+ "account_col_label": null,
+ "date_format": "%m/%d/%Y",
+ "number_format": "1,234.56",
+ "signage_convention": "inflows_positive"
+ },
+ "stats": {
+ "rows_count": 150,
+ "valid_rows_count": 150
+ }
+ }
+}
+```
+
+## Creating an import
+
+When creating an import, you must provide the file content and the column mappings.
+
+### Parameters
+
+| Parameter | Type | Description |
+| --- | --- | --- |
+| `raw_file_content` | string | The raw CSV content as a string. |
+| `file` | file | Alternatively, the CSV file can be uploaded as a multipart form-data part. |
+| `account_id` | uuid | Optional. The ID of the account to import into. |
+| `date_col_label` | string | The header name for the date column. |
+| `amount_col_label` | string | The header name for the amount column. |
+| `name_col_label` | string | The header name for the transaction name column. |
+| `publish` | boolean | If `true`, the import will be automatically queued for processing if configuration is valid. |
+
+Example request body:
+
+```json
+{
+ "raw_file_content": "date,amount,name\n01/01/2024,10.00,Test",
+ "date_col_label": "date",
+ "amount_col_label": "amount",
+ "name_col_label": "name",
+ "account_id": "uuid",
+ "publish": "true"
+}
+```
+
+## Error responses
+
+Errors conform to the shared `ErrorResponse` schema:
+
+```json
+{
+ "error": "error_code",
+ "message": "Human readable error message",
+ "errors": ["Optional array of validation errors"]
+}
+```
+
diff --git a/docs/hosting/docker.md b/docs/hosting/docker.md
index d1f6ef06c..ed129e8ed 100644
--- a/docs/hosting/docker.md
+++ b/docs/hosting/docker.md
@@ -191,3 +191,7 @@ docker volume rm sure_postgres-data # this is the name of the volume the DB is m
docker compose up
docker compose exec db psql -U sure_user -d sure_development -c "SELECT 1;" # This will verify that the issue is fixed
```
+
+### Slow `.csv` import (processing rows taking longer than expected)
+
+Importing comma-separated-value file(s) requires the `sure-worker` container to communicate with Redis. Check your worker logs for any unexpected errors, such as connection timeouts or Redis communication failures.
diff --git a/lib/tasks/dev_sync_stats.rake b/lib/tasks/dev_sync_stats.rake
new file mode 100644
index 000000000..f7c353a7b
--- /dev/null
+++ b/lib/tasks/dev_sync_stats.rake
@@ -0,0 +1,315 @@
+# frozen_string_literal: true
+
+# Helper module for sync stats rake tasks
+module DevSyncStatsHelpers
+ extend self
+
+ def generate_fake_stats_for_items(item_class, provider_name, include_issues: false)
+ items = item_class.all
+ if items.empty?
+ puts " No #{item_class.name} items found, skipping..."
+ return
+ end
+
+ items.each do |item|
+ # Create or find a sync record
+ sync = item.syncs.ordered.first
+ if sync.nil?
+ sync = item.syncs.create!(status: :completed, completed_at: Time.current)
+ end
+
+ stats = generate_fake_stats(provider_name, include_issues: include_issues)
+ sync.update!(sync_stats: stats, status: :completed, completed_at: Time.current)
+
+ item_name = item.respond_to?(:name) ? item.name : item.try(:institution_name) || item.id
+ puts " Generated stats for #{item_class.name} ##{item.id} (#{item_name})"
+ end
+ end
+
+ def generate_fake_stats(provider_name, include_issues: false)
+ # Base stats that all providers have
+ stats = {
+ "total_accounts" => rand(3..15),
+ "linked_accounts" => rand(2..10),
+ "unlinked_accounts" => rand(0..3),
+ "import_started" => true,
+ "window_start" => 1.hour.ago.iso8601,
+ "window_end" => Time.current.iso8601
+ }
+
+ # Ensure linked + unlinked <= total
+ stats["linked_accounts"] = [ stats["linked_accounts"], stats["total_accounts"] ].min
+ stats["unlinked_accounts"] = stats["total_accounts"] - stats["linked_accounts"]
+
+ # Add transaction stats for most providers
+ unless provider_name == "coinstats"
+ stats.merge!(
+ "tx_seen" => rand(50..500),
+ "tx_imported" => rand(10..100),
+ "tx_updated" => rand(0..50),
+ "tx_skipped" => rand(0..5)
+ )
+ # Ensure seen = imported + updated
+ stats["tx_seen"] = stats["tx_imported"] + stats["tx_updated"]
+ end
+
+ # Add holdings stats for investment-capable providers
+ if %w[simplefin plaid coinstats].include?(provider_name)
+ stats["holdings_found"] = rand(5..50)
+ end
+
+ # Add issues if requested
+ if include_issues
+ # Random chance of rate limiting
+ if rand < 0.3
+ stats["rate_limited"] = true
+ stats["rate_limited_at"] = rand(1..24).hours.ago.iso8601
+ end
+
+ # Random errors
+ if rand < 0.4
+ error_count = rand(1..3)
+ stats["errors"] = error_count.times.map do
+ {
+ "message" => [
+ "Connection timeout",
+ "Invalid credentials",
+ "Rate limit exceeded",
+ "Temporary API error"
+ ].sample,
+ "category" => %w[api_error connection_error auth_error].sample
+ }
+ end
+ stats["total_errors"] = error_count
+ else
+ stats["total_errors"] = 0
+ end
+
+ # Data quality warnings
+ if rand < 0.5
+ stats["data_warnings"] = rand(1..8)
+ stats["notices"] = rand(0..3)
+ stats["data_quality_details"] = stats["data_warnings"].times.map do |i|
+ start_date = rand(30..180).days.ago.to_date
+ end_date = start_date + rand(14..60).days
+ gap_days = (end_date - start_date).to_i
+
+ {
+ "message" => "No transactions between #{start_date} and #{end_date} (#{gap_days} days)",
+ "severity" => gap_days > 30 ? "warning" : "info"
+ }
+ end
+ end
+ else
+ stats["total_errors"] = 0
+ end
+
+ stats
+ end
+end
+
+namespace :dev do
+ namespace :sync_stats do
+ desc "Generate fake sync stats for testing the sync summary UI"
+ task generate: :environment do
+ unless Rails.env.development?
+ puts "This task is only available in development mode"
+ exit 1
+ end
+
+ puts "Generating fake sync stats for testing..."
+
+ DevSyncStatsHelpers.generate_fake_stats_for_items(PlaidItem, "plaid")
+ DevSyncStatsHelpers.generate_fake_stats_for_items(SimplefinItem, "simplefin")
+ DevSyncStatsHelpers.generate_fake_stats_for_items(LunchflowItem, "lunchflow")
+ DevSyncStatsHelpers.generate_fake_stats_for_items(EnableBankingItem, "enable_banking")
+ DevSyncStatsHelpers.generate_fake_stats_for_items(CoinstatsItem, "coinstats")
+
+ puts "Done! Refresh your browser to see the sync summaries."
+ end
+
+ desc "Clear all sync stats from syncs"
+ task clear: :environment do
+ unless Rails.env.development?
+ puts "This task is only available in development mode"
+ exit 1
+ end
+
+ puts "Clearing all sync stats..."
+ Sync.where.not(sync_stats: nil).update_all(sync_stats: nil)
+ puts "Done!"
+ end
+
+ desc "Generate fake sync stats with errors and warnings for testing"
+ task generate_with_issues: :environment do
+ unless Rails.env.development?
+ puts "This task is only available in development mode"
+ exit 1
+ end
+
+ puts "Generating fake sync stats with errors and warnings..."
+
+ DevSyncStatsHelpers.generate_fake_stats_for_items(PlaidItem, "plaid", include_issues: true)
+ DevSyncStatsHelpers.generate_fake_stats_for_items(SimplefinItem, "simplefin", include_issues: true)
+ DevSyncStatsHelpers.generate_fake_stats_for_items(LunchflowItem, "lunchflow", include_issues: true)
+ DevSyncStatsHelpers.generate_fake_stats_for_items(EnableBankingItem, "enable_banking", include_issues: true)
+ DevSyncStatsHelpers.generate_fake_stats_for_items(CoinstatsItem, "coinstats", include_issues: true)
+
+ puts "Done! Refresh your browser to see the sync summaries with issues."
+ end
+
+ desc "Create fake provider items with sync stats for testing (use when you have no provider connections)"
+ task create_test_providers: :environment do
+ unless Rails.env.development?
+ puts "This task is only available in development mode"
+ exit 1
+ end
+
+ family = Family.first
+ unless family
+ puts "No family found. Please create a user account first."
+ exit 1
+ end
+
+ puts "Creating fake provider items for family: #{family.name || family.id}..."
+
+ # Create a fake SimpleFIN item
+ simplefin_item = family.simplefin_items.create!(
+ name: "Test SimpleFIN Connection",
+ access_url: "https://test.simplefin.org/fake"
+ )
+ puts " Created SimplefinItem: #{simplefin_item.name}"
+
+ # Create fake SimpleFIN accounts
+ 3.times do |i|
+ simplefin_item.simplefin_accounts.create!(
+ name: "Test Account #{i + 1}",
+ account_id: "test-account-#{SecureRandom.hex(8)}",
+ currency: "USD",
+ current_balance: rand(1000..50000),
+ account_type: %w[checking savings credit_card].sample
+ )
+ end
+ puts " Created 3 SimplefinAccounts"
+
+ # Create a fake Plaid item (requires access_token)
+ plaid_item = family.plaid_items.create!(
+ name: "Test Plaid Connection",
+ access_token: "test-access-token-#{SecureRandom.hex(16)}",
+ plaid_id: "test-plaid-id-#{SecureRandom.hex(8)}"
+ )
+ puts " Created PlaidItem: #{plaid_item.name}"
+
+ # Create fake Plaid accounts
+ 2.times do |i|
+ plaid_item.plaid_accounts.create!(
+ name: "Test Plaid Account #{i + 1}",
+ plaid_id: "test-plaid-account-#{SecureRandom.hex(8)}",
+ currency: "USD",
+ current_balance: rand(1000..50000),
+ plaid_type: %w[depository credit investment].sample,
+ plaid_subtype: "checking"
+ )
+ end
+ puts " Created 2 PlaidAccounts"
+
+ # Create a fake Lunchflow item
+ lunchflow_item = family.lunchflow_items.create!(
+ name: "Test Lunchflow Connection",
+ api_key: "test-api-key-#{SecureRandom.hex(16)}"
+ )
+ puts " Created LunchflowItem: #{lunchflow_item.name}"
+
+ # Create fake Lunchflow accounts
+ 2.times do |i|
+ lunchflow_item.lunchflow_accounts.create!(
+ name: "Test Lunchflow Account #{i + 1}",
+ account_id: "test-lunchflow-#{SecureRandom.hex(8)}",
+ currency: "USD",
+ current_balance: rand(1000..50000)
+ )
+ end
+ puts " Created 2 LunchflowAccounts"
+
+ # Create a fake CoinStats item
+ coinstats_item = family.coinstats_items.create!(
+ name: "Test CoinStats Connection",
+ api_key: "test-coinstats-key-#{SecureRandom.hex(16)}",
+ institution_name: "CoinStats"
+ )
+ puts " Created CoinstatsItem: #{coinstats_item.name}"
+
+ # Create fake CoinStats accounts (wallets)
+ 3.times do |i|
+ coinstats_item.coinstats_accounts.create!(
+ name: "Test Wallet #{i + 1}",
+ account_id: "test-wallet-#{SecureRandom.hex(8)}",
+ currency: "USD",
+ current_balance: rand(100..10000),
+ account_type: %w[wallet exchange defi].sample
+ )
+ end
+ puts " Created 3 CoinstatsAccounts"
+
+ # Create a fake EnableBanking item
+ begin
+ enable_banking_item = family.enable_banking_items.create!(
+ name: "Test EnableBanking Connection",
+ institution_name: "Test Bank EU",
+ institution_id: "test-bank-#{SecureRandom.hex(8)}",
+ country_code: "DE",
+ aspsp_name: "Test Bank",
+ aspsp_id: "test-aspsp-#{SecureRandom.hex(8)}",
+ application_id: "test-app-#{SecureRandom.hex(8)}",
+ client_certificate: "-----BEGIN CERTIFICATE-----\nTEST_CERTIFICATE\n-----END CERTIFICATE-----"
+ )
+ puts " Created EnableBankingItem: #{enable_banking_item.institution_name}"
+
+ # Create fake EnableBanking accounts
+ 2.times do |i|
+ uid = "test-eb-uid-#{SecureRandom.hex(8)}"
+ enable_banking_item.enable_banking_accounts.create!(
+ name: "Test EU Account #{i + 1}",
+ uid: uid,
+ account_id: "test-eb-account-#{SecureRandom.hex(8)}",
+ currency: "EUR",
+ current_balance: rand(1000..50000),
+ iban: "DE#{rand(10..99)}#{SecureRandom.hex(10).upcase[0..17]}"
+ )
+ end
+ puts " Created 2 EnableBankingAccounts"
+ rescue => e
+ puts " Failed to create EnableBankingItem: #{e.message}"
+ end
+
+ puts "\nNow generating sync stats for the test providers..."
+ DevSyncStatsHelpers.generate_fake_stats_for_items(SimplefinItem, "simplefin", include_issues: true)
+ DevSyncStatsHelpers.generate_fake_stats_for_items(PlaidItem, "plaid", include_issues: false)
+ DevSyncStatsHelpers.generate_fake_stats_for_items(LunchflowItem, "lunchflow", include_issues: false)
+ DevSyncStatsHelpers.generate_fake_stats_for_items(CoinstatsItem, "coinstats", include_issues: true)
+ DevSyncStatsHelpers.generate_fake_stats_for_items(EnableBankingItem, "enable_banking", include_issues: false)
+
+ puts "\nDone! Visit /accounts to see the sync summaries."
+ end
+
+ desc "Remove all test provider items created by create_test_providers"
+ task remove_test_providers: :environment do
+ unless Rails.env.development?
+ puts "This task is only available in development mode"
+ exit 1
+ end
+
+ puts "Removing test provider items..."
+
+ # Remove items that start with "Test "
+ count = 0
+ count += SimplefinItem.where("name LIKE ?", "Test %").destroy_all.count
+ count += PlaidItem.where("name LIKE ?", "Test %").destroy_all.count
+ count += LunchflowItem.where("name LIKE ?", "Test %").destroy_all.count
+ count += CoinstatsItem.where("name LIKE ?", "Test %").destroy_all.count
+ count += EnableBankingItem.where("name LIKE ? OR institution_name LIKE ?", "Test %", "Test %").destroy_all.count
+
+ puts "Removed #{count} test provider items. Done!"
+ end
+ end
+end
diff --git a/lib/tasks/rules.rake b/lib/tasks/rules.rake
new file mode 100644
index 000000000..0a5a5c79a
--- /dev/null
+++ b/lib/tasks/rules.rake
@@ -0,0 +1,33 @@
+namespace :rules do
+ desc "Apply all rules for a family"
+ task :apply_all, [ :family_id ] => :environment do |_t, args|
+ family_id = args[:family_id]
+
+ if family_id.blank?
+ puts "Usage: bin/rails rules:apply_all[family_id]"
+ exit 1
+ end
+
+ family = Family.find(family_id)
+ rules = family.rules
+
+ if rules.empty?
+ puts "No rules found for family #{family_id}"
+ exit 0
+ end
+
+ puts "Applying #{rules.count} rules for family #{family_id}..."
+
+ rules.find_each do |rule|
+ print " Applying rule '#{rule.name || rule.id}'... "
+ begin
+ RuleJob.perform_now(rule, ignore_attribute_locks: true, execution_type: "manual")
+ puts "done"
+ rescue => e
+ puts "failed: #{e.message}"
+ end
+ end
+
+ puts "Finished applying all rules"
+ end
+end
diff --git a/mobile/.gitignore b/mobile/.gitignore
new file mode 100644
index 000000000..39d59563b
--- /dev/null
+++ b/mobile/.gitignore
@@ -0,0 +1,49 @@
+# Flutter / Dart
+.dart_tool/
+.packages
+.pub-cache/
+.pub/
+build/
+.flutter-plugins
+.flutter-plugins-dependencies
+*.iml
+.metadata
+
+# Android
+android/.gradle/
+android/local.properties
+android/app/build/
+android/gradlew
+android/gradlew.bat
+android/key.properties
+android/app/*.keystore
+android/app/*.jks
+keystore-base64.txt
+
+# iOS
+ios/Pods/
+ios/.symlinks/
+ios/Flutter/Flutter.framework
+ios/Flutter/Flutter.podspec
+ios/Flutter/Generated.xcconfig
+ios/Runner.xcworkspace/
+ios/Podfile.lock
+
+# IDE
+.idea/
+*.iml
+.vscode/
+
+# macOS
+.DS_Store
+*/.DS_Store
+
+# Environment
+.env
+.env.*
+
+# Logs
+*.log
+
+# Miscellaneous
+_codeql_detected_source_root
diff --git a/mobile/README.md b/mobile/README.md
new file mode 100644
index 000000000..5504313e1
--- /dev/null
+++ b/mobile/README.md
@@ -0,0 +1,196 @@
+# Sure Mobile
+
+A Flutter mobile application for [Sure](https://github.com/we-promise/sure) personal finance management system. This is the mobile client that connects to the Sure backend server.
+
+## About
+
+This app is a mobile companion to the [Sure personal finance management system](https://github.com/we-promise/sure). It provides basic functionality to:
+
+- **Login** - Authenticate with your Sure Finance server
+- **View Balance** - See all your accounts and their balances
+
+For more detailed technical documentation, see [docs/TECHNICAL_GUIDE.md](docs/TECHNICAL_GUIDE.md).
+
+## Features
+
+- 🔐 Secure authentication with OAuth 2.0
+- 📱 Cross-platform support (Android & iOS)
+- 💰 View all linked accounts
+- 🎨 Material Design 3 with light/dark theme support
+- 🔄 Token refresh for persistent sessions
+- 🔒 Two-factor authentication (MFA) support
+
+## Requirements
+
+- Flutter SDK >= 3.0.0
+- Dart SDK >= 3.0.0
+- Android SDK (for Android builds)
+- Xcode (for iOS builds)
+
+## Getting Started
+
+### 1. Install Flutter
+
+Follow the official Flutter installation guide: https://docs.flutter.dev/get-started/install
+
+### 2. Install Dependencies
+
+```bash
+flutter pub get
+
+# For iOS development, also install CocoaPods dependencies
+cd ios
+pod install
+cd ..
+```
+
+### 3. Generate App Icons
+
+```bash
+flutter pub run flutter_launcher_icons
+```
+
+This step generates the app icons for all platforms based on the source icon in `assets/icon/app_icon.png`. This is required before building the app locally.
+
+### 4. Configure API Endpoint
+
+Edit `lib/services/api_config.dart` to point to your Sure Finance server:
+
+```dart
+// For local development with Android emulator
+static String _baseUrl = 'http://10.0.2.2:3000';
+
+// For local development with iOS simulator
+static String _baseUrl = 'http://localhost:3000';
+
+// For production
+static String _baseUrl = 'https://your-sure-server.com';
+```
+
+### 5. Run the App
+
+```bash
+# For Android
+flutter run -d android
+
+# For iOS
+flutter run -d ios
+
+# For web (development only)
+flutter run -d chrome
+```
+
+## Project Structure
+
+```
+.
+├── lib/
+│ ├── main.dart # App entry point
+│ ├── models/ # Data models
+│ │ ├── account.dart
+│ │ ├── auth_tokens.dart
+│ │ └── user.dart
+│ ├── providers/ # State management
+│ │ ├── auth_provider.dart
+│ │ └── accounts_provider.dart
+│ ├── screens/ # UI screens
+│ │ ├── login_screen.dart
+│ │ └── dashboard_screen.dart
+│ ├── services/ # API services
+│ │ ├── api_config.dart
+│ │ ├── auth_service.dart
+│ │ ├── accounts_service.dart
+│ │ └── device_service.dart
+│ └── widgets/ # Reusable widgets
+│ └── account_card.dart
+├── android/ # Android configuration
+├── ios/ # iOS configuration
+├── pubspec.yaml # Dependencies
+└── README.md
+```
+
+## API Integration
+
+This app integrates with the Sure Finance Rails API:
+
+### Authentication
+- `POST /api/v1/auth/login` - User authentication
+- `POST /api/v1/auth/signup` - User registration
+- `POST /api/v1/auth/refresh` - Token refresh
+
+### Accounts
+- `GET /api/v1/accounts` - Fetch user accounts
+
+### Transactions
+- `GET /api/v1/transactions` - Get all transactions (optionally filter by `account_id` query parameter)
+- `POST /api/v1/transactions` - Create a new transaction
+- `PUT /api/v1/transactions/:id` - Update an existing transaction
+- `DELETE /api/v1/transactions/:id` - Delete a transaction
+
+#### Transaction POST Request Format
+```json
+{
+ "transaction": {
+ "account_id": "2980ffb0-f595-4572-be0e-7b9b9c53949b", // required
+ "name": "test", // required
+ "date": "2025-07-15", // required
+ "amount": 100, // optional, defaults to 0
+ "currency": "AUD", // optional, defaults to your profile currency
+ "nature": "expense" // optional, defaults to "expense", other option is "income"
+ }
+}
+```
+
+## CI/CD
+
+The app includes automated CI/CD via GitHub Actions (`.github/workflows/flutter-build.yml`):
+
+- **Triggers**: On push/PR to `main` branch when Flutter files change
+- **Android Build**: Generates release APK and AAB artifacts
+- **iOS Build**: Generates iOS release build (unsigned)
+- **Quality Checks**: Code analysis and tests run before building
+
+### Downloading Build Artifacts
+
+After a successful CI run, download artifacts from the GitHub Actions workflow:
+- `app-release-apk` - Android APK file
+- `app-release-aab` - Android App Bundle (for Play Store)
+- `ios-build-unsigned` - iOS app bundle (unsigned, see [iOS build guide](docs/iOS_BUILD.md) for signing)
+
+## Building for Release
+
+### Android
+
+```bash
+flutter build apk --release
+# or for App Bundle
+flutter build appbundle --release
+```
+
+### iOS
+
+```bash
+# Ensure CocoaPods dependencies are installed first
+cd ios && pod install && cd ..
+
+# Build iOS release
+flutter build ios --release
+```
+
+For detailed iOS build instructions, troubleshooting, and CI/CD setup, see [docs/iOS_BUILD.md](docs/iOS_BUILD.md).
+
+## Future Expansion
+
+This app provides a foundation for additional features:
+
+- Transaction history
+- Account sync
+- Budget management
+- Investment tracking
+- AI chat assistant
+- Push notifications
+- Biometric authentication
+
+## License
+
+This project is distributed under the AGPLv3 license.
diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml
new file mode 100644
index 000000000..55a32b575
--- /dev/null
+++ b/mobile/analysis_options.yaml
@@ -0,0 +1,8 @@
+include: package:flutter_lints/flutter.yaml
+
+linter:
+ rules:
+ - avoid_print
+ - prefer_const_constructors
+ - prefer_const_declarations
+ - prefer_final_fields
diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle
new file mode 100644
index 000000000..4d3d96942
--- /dev/null
+++ b/mobile/android/app/build.gradle
@@ -0,0 +1,73 @@
+plugins {
+ id "com.android.application"
+ id "kotlin-android"
+ id "dev.flutter.flutter-gradle-plugin"
+}
+
+def keystoreProperties = new Properties()
+def keystorePropertiesFile = rootProject.file('key.properties')
+def hasKeystore = false
+if (keystorePropertiesFile.exists()) {
+ try {
+ keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
+ hasKeystore = keystoreProperties['storeFile'] != null &&
+ keystoreProperties['keyAlias'] != null &&
+ keystoreProperties['keyPassword'] != null &&
+ keystoreProperties['storePassword'] != null &&
+ file(keystoreProperties['storeFile']).exists()
+ } catch (Exception e) {
+ println("Warning: Failed to load key.properties: ${e.message}")
+ }
+}
+
+android {
+ namespace "am.sure.mobile"
+ compileSdk = 36
+ ndkVersion flutter.ndkVersion
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ kotlinOptions {
+ jvmTarget = '1.8'
+ }
+
+ if (hasKeystore) {
+ signingConfigs {
+ release {
+ keyAlias keystoreProperties['keyAlias']
+ keyPassword keystoreProperties['keyPassword']
+ storeFile file(keystoreProperties['storeFile'])
+ storePassword keystoreProperties['storePassword']
+ }
+ }
+ }
+
+ defaultConfig {
+ applicationId "am.sure.mobile"
+ minSdkVersion 24
+ targetSdkVersion flutter.targetSdkVersion
+ versionCode 1
+ versionName "1.0.0"
+ }
+
+ buildTypes {
+ release {
+ if (hasKeystore) {
+ signingConfig signingConfigs.release
+ }
+ minifyEnabled false
+ shrinkResources false
+ }
+ }
+}
+
+flutter {
+ source '../..'
+}
+
+dependencies {
+ implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
+}
diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..193287f5f
--- /dev/null
+++ b/mobile/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mobile/android/app/src/main/kotlin/am/sure/mobile/MainActivity.kt b/mobile/android/app/src/main/kotlin/am/sure/mobile/MainActivity.kt
new file mode 100644
index 000000000..65cd6f9ee
--- /dev/null
+++ b/mobile/android/app/src/main/kotlin/am/sure/mobile/MainActivity.kt
@@ -0,0 +1,5 @@
+package am.sure.mobile
+
+import io.flutter.embedding.android.FlutterActivity
+
+class MainActivity: FlutterActivity()
diff --git a/mobile/android/app/src/main/res/drawable/launch_background.xml b/mobile/android/app/src/main/res/drawable/launch_background.xml
new file mode 100644
index 000000000..5782842b8
--- /dev/null
+++ b/mobile/android/app/src/main/res/drawable/launch_background.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..b9a37545a
Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..8fab5e751
Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..a80577c67
Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..b1ed54fb0
Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..d6018c915
Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/mobile/android/app/src/main/res/values/styles.xml b/mobile/android/app/src/main/res/values/styles.xml
new file mode 100644
index 000000000..ff81bae86
--- /dev/null
+++ b/mobile/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/mobile/android/build.gradle b/mobile/android/build.gradle
new file mode 100644
index 000000000..68b15a841
--- /dev/null
+++ b/mobile/android/build.gradle
@@ -0,0 +1,31 @@
+buildscript {
+ ext.kotlin_version = '1.9.0'
+ repositories {
+ google()
+ mavenCentral()
+ }
+
+ dependencies {
+ classpath 'com.android.tools.build:gradle:8.1.0'
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
+ }
+}
+
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.buildDir = '../build'
+subprojects {
+ project.buildDir = "${rootProject.buildDir}/${project.name}"
+}
+subprojects {
+ project.evaluationDependsOn(':app')
+}
+
+tasks.register("clean", Delete) {
+ delete rootProject.buildDir
+}
diff --git a/mobile/android/gradle.properties b/mobile/android/gradle.properties
new file mode 100644
index 000000000..598d13fee
--- /dev/null
+++ b/mobile/android/gradle.properties
@@ -0,0 +1,3 @@
+org.gradle.jvmargs=-Xmx4G
+android.useAndroidX=true
+android.enableJetifier=true
diff --git a/mobile/android/gradle/wrapper/gradle-wrapper.properties b/mobile/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 000000000..d951fac2b
--- /dev/null
+++ b/mobile/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/mobile/android/settings.gradle b/mobile/android/settings.gradle
new file mode 100644
index 000000000..2919a5408
--- /dev/null
+++ b/mobile/android/settings.gradle
@@ -0,0 +1,27 @@
+pluginManagement {
+ def flutterSdkPath = {
+ def properties = new Properties()
+ file("local.properties").withInputStream { properties.load(it) }
+ def flutterSdkPath = properties.getProperty("flutter.sdk")
+ assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
+ return flutterSdkPath
+ }
+ settings.ext.flutterSdkPath = flutterSdkPath()
+
+ includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle")
+
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+plugins {
+ id "dev.flutter.flutter-plugin-loader" version "1.0.0"
+ id "com.android.application" version "8.5.0" apply false
+ id "org.jetbrains.kotlin.android" version "1.9.24" apply false
+}
+
+rootProject.name = 'sure_mobile'
+include ':app'
diff --git a/mobile/assets/icon/app_icon.png b/mobile/assets/icon/app_icon.png
new file mode 100644
index 000000000..330e4a504
Binary files /dev/null and b/mobile/assets/icon/app_icon.png differ
diff --git a/mobile/assets/images/.gitkeep b/mobile/assets/images/.gitkeep
new file mode 100644
index 000000000..c8c6c5497
--- /dev/null
+++ b/mobile/assets/images/.gitkeep
@@ -0,0 +1,2 @@
+# Placeholder for assets
+This directory contains image assets for the Sure Mobile app.
diff --git a/mobile/docs/SIGNING_SETUP.md b/mobile/docs/SIGNING_SETUP.md
new file mode 100644
index 000000000..c223a60a2
--- /dev/null
+++ b/mobile/docs/SIGNING_SETUP.md
@@ -0,0 +1,65 @@
+# Android Signing Setup Guide
+
+## GitHub Secrets Configuration
+
+To enable CI/CD automatic signing of APK/AAB files, you need to configure the following Secrets in your GitHub repository:
+
+### Step 1: Get Keystore Base64 Encoding
+
+The base64 encoding of your keystore has been generated in the `keystore-base64.txt` file in the project root directory.
+
+View the content:
+```bash
+cat keystore-base64.txt
+```
+
+### Step 2: Add Secrets on GitHub
+
+Navigate to your GitHub repository:
+1. Click on **Settings**
+2. In the left menu, click on **Secrets and variables** > **Actions**
+3. Click the **New repository secret** button
+4. Add the following four secrets:
+
+| Secret Name | Value |
+|------------|-----|
+| `KEYSTORE_BASE64` | The base64 string copied from `keystore-base64.txt` |
+| `KEY_STORE_PASSWORD` | Your keystore password |
+| `KEY_PASSWORD` | Your key password |
+| `KEY_ALIAS` | Your key alias |
+
+### Step 3: Verify Setup
+
+After completing the setup, push code to the main branch or create a Pull Request. The CI/CD will automatically:
+1. Run tests
+2. Build signed APK
+3. Build signed AAB
+4. Upload build artifacts to GitHub Actions artifacts
+
+## Local Build
+
+Local build is already configured, with signing information in the `android/key.properties` file.
+
+Build signed versions locally:
+```bash
+flutter build apk --release
+flutter build appbundle --release
+```
+
+## Security Notes
+
+- ✅ `key.properties` and keystore files have been added to `.gitignore`
+- ✅ These files will not be committed to the Git repository
+- ✅ CI/CD uses GitHub Secrets to securely store signing information
+- ⚠️ Please keep the `keystore-base64.txt` file safe; you can delete it after setting up GitHub Secrets
+
+## Keystore Information
+
+- **File Location**: `android/app/upload-keystore.jks`
+- **Validity**: 10,000 days
+
+⚠️ **Important Notice**:
+- Please keep your keystore password, key password, and alias safe
+- This information is only stored locally in the `android/key.properties` file (added to .gitignore)
+- GitHub Secrets also need to be configured with this information
+- Be sure to back up your keystore file - losing it will prevent you from updating published applications!
diff --git a/mobile/docs/TECHNICAL_GUIDE.md b/mobile/docs/TECHNICAL_GUIDE.md
new file mode 100644
index 000000000..6abc9a8cf
--- /dev/null
+++ b/mobile/docs/TECHNICAL_GUIDE.md
@@ -0,0 +1,443 @@
+# Sure Mobile - Technical Documentation
+
+## Project Overview
+
+Sure Mobile is the mobile application for the [Sure Personal Finance Management System](https://github.com/we-promise/sure), developed with Flutter framework and supporting both Android and iOS platforms. This application provides core mobile functionality for the Sure finance management system, allowing users to view and manage their financial accounts anytime, anywhere.
+
+### Backend Relationship
+
+This application is a client app for the Sure Finance Management System and requires connection to the Sure backend server (Rails API) to function properly. Backend project: https://github.com/we-promise/sure
+
+## Core Features
+
+### 1. Backend Configuration
+- **Server Address Configuration**: Configure Sure backend server URL on first launch
+- **Connection Testing**: Provides connection test functionality to verify server availability
+- **Address Persistence**: Server address is saved locally and automatically loaded on next startup
+
+### 2. User Authentication
+- **Login**: Support email and password login
+- **Two-Factor Authentication (MFA)**: Support OTP verification code secondary verification
+- **User Registration**: Support new user registration (backend supported)
+- **Token Management**:
+ - Access Token for API request authentication
+ - Refresh Token for refreshing expired Access Tokens
+ - Tokens securely stored in device's secure storage
+- **Auto-login**: Automatically checks local tokens on app startup and logs in if valid
+- **Device Information Tracking**: Records device information on login for backend session management
+
+### 3. Account Management
+- **Account List Display**: Shows all user financial accounts
+- **Account Classification**:
+ - **Asset Accounts**: Bank accounts, investment accounts, cryptocurrency, real estate, vehicles, etc.
+ - **Liability Accounts**: Credit cards, loans, etc.
+ - **Other Accounts**: Uncategorized accounts
+- **Account Type Support**:
+ - Depository
+ - Credit Card
+ - Investment
+ - Loan
+ - Property
+ - Vehicle
+ - Crypto
+ - Other assets/liabilities
+- **Balance Display**: Shows current balance and currency type for each account
+- **Pull to Refresh**: Supports pull-to-refresh for account data
+
+## Technical Architecture
+
+### Tech Stack
+- **Framework**: Flutter 3.0+
+- **Language**: Dart 3.0+
+- **State Management**: Provider
+- **Network Requests**: http
+- **Local Storage**:
+ - shared_preferences (non-sensitive data, like server URL)
+ - flutter_secure_storage (sensitive data, like tokens)
+
+### Project Structure
+
+```
+lib/
+├── main.dart # App entry point
+├── models/ # Data models
+│ ├── account.dart # Account model
+│ ├── auth_tokens.dart # Authentication token model
+│ └── user.dart # User model
+├── providers/ # State management
+│ ├── auth_provider.dart # Authentication state management
+│ └── accounts_provider.dart # Accounts state management
+├── screens/ # Screens
+│ ├── backend_config_screen.dart # Backend configuration screen
+│ ├── login_screen.dart # Login screen
+│ └── dashboard_screen.dart # Main screen (account list)
+├── services/ # Business services
+│ ├── api_config.dart # API configuration
+│ ├── auth_service.dart # Authentication service
+│ ├── accounts_service.dart # Accounts service
+│ └── device_service.dart # Device information service
+└── widgets/ # Reusable widgets
+ └── account_card.dart # Account card widget
+```
+
+## Application Flow Details
+
+### Startup Flow
+
+```
+App Launch
+ ↓
+Initialize ApiConfig (load saved backend URL)
+ ↓
+Check if backend URL is configured
+ ├─ No → Show backend configuration screen
+ │ ↓
+ │ Enter and test URL
+ │ ↓
+ │ Save configuration
+ │ ↓
+ └─ Yes → Check Token
+ ├─ Invalid or not exists → Show login screen
+ │ ↓
+ │ User login
+ │ ↓
+ │ Save tokens and user info
+ │ ↓
+ └─ Valid → Enter Dashboard
+```
+
+### Authentication Flow
+
+#### 1. Login Flow (login_screen.dart)
+
+```
+User enters email and password
+ ↓
+Click login button
+ ↓
+AuthProvider.login()
+ ↓
+Collect device information (DeviceService)
+ ↓
+Call AuthService.login()
+ ↓
+Send POST /api/v1/auth/login
+ ├─ Success (200)
+ │ ↓
+ │ Save Access Token and Refresh Token
+ │ ↓
+ │ Save user information
+ │ ↓
+ │ Navigate to dashboard
+ │
+ ├─ MFA Required (401 + mfa_required)
+ │ ↓
+ │ Show OTP input field
+ │ ↓
+ │ User enters verification code
+ │ ↓
+ │ Re-login (with OTP)
+ │
+ └─ Failure
+ ↓
+ Show error message
+```
+
+#### 2. Token Refresh Flow (auth_provider.dart)
+
+```
+Need to access API
+ ↓
+Check if Access Token is expired
+ ├─ Not expired → Use directly
+ │
+ └─ Expired
+ ↓
+ Get Refresh Token
+ ↓
+ Call AuthService.refreshToken()
+ ↓
+ Send POST /api/v1/auth/refresh
+ ├─ Success
+ │ ↓
+ │ Save new tokens
+ │ ↓
+ │ Return new Access Token
+ │
+ └─ Failure
+ ↓
+ Clear tokens
+ ↓
+ Return to login screen
+```
+
+### Account Data Flow
+
+#### 1. Fetch Account List (dashboard_screen.dart)
+
+```
+Enter dashboard
+ ↓
+_loadAccounts()
+ ↓
+Get valid Access Token from AuthProvider
+ ├─ Token invalid
+ │ ↓
+ │ Logout and return to login screen
+ │
+ └─ Token valid
+ ↓
+ AccountsProvider.fetchAccounts()
+ ↓
+ Call AccountsService.getAccounts()
+ ↓
+ Send GET /api/v1/accounts
+ ├─ Success (200)
+ │ ↓
+ │ Parse account data
+ │ ↓
+ │ Group by classification (asset/liability)
+ │ ↓
+ │ Update UI
+ │
+ ├─ Unauthorized (401)
+ │ ↓
+ │ Clear local data
+ │ ↓
+ │ Return to login screen
+ │
+ └─ Other errors
+ ↓
+ Show error message
+```
+
+#### 2. Account Classification Logic (accounts_provider.dart)
+
+```dart
+// Asset accounts: classification == 'asset'
+List get assetAccounts =>
+ accounts.where((a) => a.isAsset).toList();
+
+// Liability accounts: classification == 'liability'
+List get liabilityAccounts =>
+ accounts.where((a) => a.isLiability).toList();
+
+// Uncategorized accounts
+List get uncategorizedAccounts =>
+ accounts.where((a) => !a.isAsset && !a.isLiability).toList();
+```
+
+### UI State Management
+
+The app uses Provider for state management, with two main providers:
+
+#### AuthProvider (auth_provider.dart)
+Manages authentication-related state:
+- `isAuthenticated`: Whether user is logged in
+- `isLoading`: Whether loading is in progress
+- `user`: Current user information
+- `errorMessage`: Error message
+- `mfaRequired`: Whether MFA verification is required
+
+#### AccountsProvider (accounts_provider.dart)
+Manages account data state:
+- `accounts`: All accounts list
+- `isLoading`: Whether loading is in progress
+- `errorMessage`: Error message
+- `assetAccounts`: Asset accounts list
+- `liabilityAccounts`: Liability accounts list
+
+## API Endpoints
+
+The app interacts with the backend through the following API endpoints:
+
+### Authentication
+- `POST /api/v1/auth/login` - User login
+- `POST /api/v1/auth/signup` - User registration
+- `POST /api/v1/auth/refresh` - Refresh token
+
+### Accounts
+- `GET /api/v1/accounts` - Get account list (supports pagination)
+
+### Health Check
+- `GET /sessions/new` - Verify backend service availability
+
+## Data Models
+
+### Account Model
+```dart
+class Account {
+ final String id; // Account ID (UUID)
+ final String name; // Account name
+ final String balance; // Balance (string format)
+ final String currency; // Currency type (e.g., USD, TWD)
+ final String? classification; // Classification (asset/liability)
+ final String accountType; // Account type (depository, credit_card, etc.)
+}
+```
+
+### AuthTokens Model
+```dart
+class AuthTokens {
+ final String accessToken; // Access token
+ final String refreshToken; // Refresh token
+ final int expiresIn; // Expiration time (seconds)
+ final DateTime expiresAt; // Expiration timestamp
+}
+```
+
+### User Model
+```dart
+class User {
+ final String id; // User ID (UUID)
+ final String email; // Email
+ final String firstName; // First name
+ final String lastName; // Last name
+}
+```
+
+## Security Mechanisms
+
+### 1. Secure Token Storage
+- Uses `flutter_secure_storage` for encrypted token storage
+- Tokens are never saved in plain text in regular storage
+- Sensitive data is automatically cleared when app is uninstalled
+
+### 2. Token Expiration Handling
+- Access Token is automatically refreshed using Refresh Token after expiration
+- Requires re-login when Refresh Token is invalid
+- All API requests check token validity
+
+### 3. Device Tracking
+- Records device information on each login (device ID, model, OS)
+- Backend can manage user sessions based on device information
+
+### 4. HTTPS Support
+- Production environment enforces HTTPS
+- Development environment supports HTTP (local testing only)
+
+## Theme & UI
+
+### Material Design 3
+The app follows Material Design 3 specifications:
+- Dynamic color scheme (based on seed color #6366F1)
+- Rounded cards (12px border radius)
+- Responsive layout
+- Dark mode support (follows system)
+
+### Responsive Design
+- Pull-to-refresh support
+- Loading state indicators
+- Error state display
+- Empty state prompts
+
+## Development & Debugging
+
+### Environment Configuration
+
+#### Android Emulator
+```dart
+// lib/services/api_config.dart
+static String _baseUrl = 'http://10.0.2.2:3000';
+```
+
+#### iOS Simulator
+```dart
+static String _baseUrl = 'http://localhost:3000';
+```
+
+#### Physical Device
+```dart
+static String _baseUrl = 'http://YOUR_COMPUTER_IP:3000';
+// Or use production URL
+static String _baseUrl = 'https://your-domain.com';
+```
+
+### Common Commands
+
+```bash
+# Install dependencies
+flutter pub get
+
+# Run app
+flutter run
+
+# Build APK
+flutter build apk --release
+
+# Build App Bundle
+flutter build appbundle --release
+
+# Build iOS
+flutter build ios --release
+
+# Code analysis
+flutter analyze
+
+# Run tests
+flutter test
+```
+
+### Debugging Tips
+
+1. **View Network Requests**:
+ - Android Studio: Use Network Profiler
+ - Or add `print()` statements in code
+
+2. **View Stored Data**:
+ ```dart
+ // Add at debugging point
+ final prefs = await SharedPreferences.getInstance();
+ print('Backend URL: ${prefs.getString('backend_url')}');
+ ```
+
+3. **Clear Local Data**:
+ ```bash
+ # Android
+ adb shell pm clear com.example.sure_mobile
+
+ # iOS Simulator
+ # Long press app icon -> Delete app -> Reinstall
+ ```
+
+## CI/CD
+
+The project is configured with GitHub Actions automated builds:
+
+### Trigger Conditions
+- Push to `main` branch
+- Pull Request to `main` branch
+- Only triggers when Flutter-related files change
+
+### Build Process
+1. Code analysis (`flutter analyze`)
+2. Run tests (`flutter test`)
+3. Android Release build (APK + AAB)
+4. iOS Release build (unsigned)
+5. Upload build artifacts
+
+### Download Build Artifacts
+Available on GitHub Actions page:
+- `app-release-apk`: Android APK file
+- `app-release-aab`: Android App Bundle (for Google Play)
+- `ios-build-unsigned`: iOS app bundle (requires signing for distribution)
+
+## Future Extensions
+
+### Planned Features
+- **Transaction History**: View and manage transaction history
+- **Account Sync**: Support automatic bank account synchronization
+- **Budget Management**: Set and track budgets
+- **Investment Tracking**: View investment returns
+- **AI Assistant**: Financial advice and analysis
+- **Push Notifications**: Transaction alerts and account change notifications
+- **Biometric Authentication**: Fingerprint/Face ID quick login
+- **Multi-language Support**: Chinese/English interface switching
+- **Chart Analysis**: Financial data visualization
+
+### Technical Improvements
+- Offline mode support
+- Data caching optimization
+- More robust error handling
+- Unit tests and integration tests
+- Performance optimization
diff --git a/mobile/docs/iOS_BUILD.md b/mobile/docs/iOS_BUILD.md
new file mode 100644
index 000000000..e766f632d
--- /dev/null
+++ b/mobile/docs/iOS_BUILD.md
@@ -0,0 +1,155 @@
+# iOS Build Guide
+
+## Issue Diagnosis: module 'flutter_secure_storage' not found
+
+### Root Cause
+This error occurs because CocoaPods dependencies have not been installed. `flutter_secure_storage` is a Flutter plugin that requires native platform support, and its iOS native code must be installed via CocoaPods.
+
+### Solution
+
+#### First-time Setup or After Dependency Updates
+```bash
+# 1. Get Flutter dependencies
+flutter pub get
+
+# 2. Navigate to iOS directory and install CocoaPods dependencies
+cd ios
+pod install
+cd ..
+```
+
+#### Clean Build (if encountering issues)
+```bash
+# Clean Flutter build cache
+flutter clean
+
+# Re-fetch dependencies
+flutter pub get
+
+# Clean and reinstall Pods
+cd ios
+rm -rf Pods Podfile.lock
+pod install
+cd ..
+```
+
+## Local Building
+
+### Method 1: Using Flutter CLI
+```bash
+# Debug mode
+flutter build ios --debug
+
+# Release mode (requires Apple Developer certificate)
+flutter build ios --release
+
+# Release mode (no code signing, for build testing only)
+flutter build ios --release --no-codesign
+```
+
+### Method 2: Using Xcode
+1. Ensure you have run `pod install`
+2. Open `ios/Runner.xcworkspace` (**Note: NOT .xcodeproj**)
+3. Select target device or simulator
+4. Click Run button or press Cmd+R
+
+## CI/CD Automated Builds
+
+### GitHub Actions Workflow
+
+The project is configured with automated iOS build process, triggered by:
+- Push to `main` branch
+- Pull Requests
+- Manual trigger (workflow_dispatch)
+
+#### Build Steps
+1. **Environment Setup**: macOS runner + Flutter 3.32.4
+2. **Dependency Installation**: `flutter pub get` + `pod install`
+3. **Code Analysis**: `flutter analyze`
+4. **Test Execution**: `flutter test`
+5. **iOS Build**: `flutter build ios --release --no-codesign`
+6. **Artifact Upload**: Built .app file saved as artifact for 30 days
+
+#### Viewing Build Artifacts
+1. Go to GitHub Actions page
+2. Select the corresponding workflow run
+3. Download `ios-build-unsigned` artifact
+
+**Note**: CI-built versions are not code-signed and cannot be installed directly on physical devices.
+
+## Code Signing and Distribution
+
+### Configuring Code Signing
+To publish to the App Store or install on physical devices, you need:
+
+1. **Apple Developer Account** (Individual or Enterprise)
+2. **Developer Certificates**
+ - Development Certificate
+ - Distribution Certificate
+3. **Provisioning Profile**
+4. **App ID** registered in Apple Developer Portal
+
+### Configuration in Xcode
+1. Open `ios/Runner.xcworkspace`
+2. Select Runner target
+3. Go to "Signing & Capabilities" tab
+4. Set Team (requires Apple ID login)
+5. Set Bundle Identifier
+6. Xcode will automatically manage certificates and Provisioning Profile
+
+### Building IPA for Distribution
+```bash
+# Build and archive using Xcode
+flutter build ipa --release
+
+# IPA file location
+# build/ios/ipa/*.ipa
+```
+
+## System Requirements
+
+### Development Environment
+- macOS 12.0 or higher
+- Xcode 14.0 or higher
+- CocoaPods 1.11 or higher
+- Flutter 3.32.4 (recommended)
+
+### Minimum iOS Version
+- iOS 12.0 (defined in `ios/Podfile`)
+
+## Common Issues
+
+### Q: Why use .xcworkspace instead of .xcodeproj?
+A: When a project uses CocoaPods, Pod dependencies are organized into a separate Xcode project. The `.xcworkspace` file contains both the main project and the Pods project, and must be used to ensure all dependencies are properly loaded.
+
+### Q: What to do after updating pubspec.yaml?
+A: After adding or updating dependencies, you need to run:
+```bash
+flutter pub get
+cd ios && pod install && cd ..
+```
+
+### Q: What if CI build fails?
+A: Common causes:
+1. Flutter version mismatch
+2. Dependency conflicts
+3. Pod installation failure
+4. Code analysis or test failures
+
+Check GitHub Actions logs for detailed error information.
+
+### Q: How to configure code signing in CI?
+A: You need to configure GitHub Secrets:
+- Apple certificate (.p12 format, base64 encoded)
+- Provisioning Profile
+- Certificate password
+- Keychain setup
+
+This requires additional configuration steps. Currently, CI uses the `--no-codesign` option for unsigned builds.
+
+## Related Documentation
+
+- [Flutter iOS Deployment Documentation](https://docs.flutter.dev/deployment/ios)
+- [CocoaPods Official Guide](https://guides.cocoapods.org/)
+- [Apple Developer Documentation](https://developer.apple.com/documentation/)
+- [flutter_secure_storage Plugin Documentation](https://pub.dev/packages/flutter_secure_storage)
diff --git a/mobile/ios/Flutter/AppFrameworkInfo.plist b/mobile/ios/Flutter/AppFrameworkInfo.plist
new file mode 100644
index 000000000..7c5696400
--- /dev/null
+++ b/mobile/ios/Flutter/AppFrameworkInfo.plist
@@ -0,0 +1,26 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ App
+ CFBundleIdentifier
+ io.flutter.flutter.app
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ App
+ CFBundlePackageType
+ FMWK
+ CFBundleShortVersionString
+ 1.0
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ 1.0
+ MinimumOSVersion
+ 12.0
+
+
diff --git a/mobile/ios/Flutter/Debug.xcconfig b/mobile/ios/Flutter/Debug.xcconfig
new file mode 100644
index 000000000..ec97fc6f3
--- /dev/null
+++ b/mobile/ios/Flutter/Debug.xcconfig
@@ -0,0 +1,2 @@
+#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
+#include "Generated.xcconfig"
diff --git a/mobile/ios/Flutter/Release.xcconfig b/mobile/ios/Flutter/Release.xcconfig
new file mode 100644
index 000000000..c4855bfe2
--- /dev/null
+++ b/mobile/ios/Flutter/Release.xcconfig
@@ -0,0 +1,2 @@
+#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
+#include "Generated.xcconfig"
diff --git a/mobile/ios/Podfile b/mobile/ios/Podfile
new file mode 100644
index 000000000..1cadfb61e
--- /dev/null
+++ b/mobile/ios/Podfile
@@ -0,0 +1,41 @@
+# Uncomment this line to define a global platform for your project
+platform :ios, '13.0'
+
+# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
+ENV['COCOAPODS_DISABLE_STATS'] = 'true'
+
+project 'Runner', {
+ 'Debug' => :debug,
+ 'Profile' => :release,
+ 'Release' => :release
+}
+
+def flutter_root
+ generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
+ unless File.exist?(generated_xcode_build_settings_path)
+ raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
+ end
+
+ File.foreach(generated_xcode_build_settings_path) do |line|
+ matches = line.match(/FLUTTER_ROOT\=(.*)/)
+ return matches[1].strip if matches
+ end
+ raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
+end
+
+require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
+
+flutter_ios_podfile_setup
+
+target 'Runner' do
+ use_frameworks!
+ use_modular_headers!
+
+ flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
+end
+
+post_install do |installer|
+ installer.pods_project.targets.each do |target|
+ flutter_additional_ios_build_settings(target)
+ end
+end
diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj
new file mode 100644
index 000000000..030d8a5b5
--- /dev/null
+++ b/mobile/ios/Runner.xcodeproj/project.pbxproj
@@ -0,0 +1,491 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 54;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
+ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
+ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
+ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
+ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
+ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+ 9705A1C41CF9048500538489 /* Embed Frameworks */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 10;
+ files = (
+ );
+ name = "Embed Frameworks";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
+/* Begin PBXFileReference section */
+ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; };
+ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; };
+ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; };
+ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; };
+ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; };
+ 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; };
+ 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; };
+ 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; };
+ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; };
+ 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 97C146EB1CF9000F007C117D /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 9740EEB11CF90186004384FC /* Flutter */ = {
+ isa = PBXGroup;
+ children = (
+ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
+ 9740EEB21CF90195004384FC /* Debug.xcconfig */,
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
+ 9740EEB31CF90195004384FC /* Generated.xcconfig */,
+ );
+ name = Flutter;
+ sourceTree = "";
+ };
+ 97C146E51CF9000F007C117D = {
+ isa = PBXGroup;
+ children = (
+ 9740EEB11CF90186004384FC /* Flutter */,
+ 97C146F01CF9000F007C117D /* Runner */,
+ 97C146EF1CF9000F007C117D /* Products */,
+ );
+ sourceTree = "";
+ };
+ 97C146EF1CF9000F007C117D /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 97C146EE1CF9000F007C117D /* Runner.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ 97C146F01CF9000F007C117D /* Runner */ = {
+ isa = PBXGroup;
+ children = (
+ 97C146FA1CF9000F007C117D /* Main.storyboard */,
+ 97C146FD1CF9000F007C117D /* Assets.xcassets */,
+ 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
+ 97C147021CF9000F007C117D /* Info.plist */,
+ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
+ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
+ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
+ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
+ );
+ path = Runner;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 97C146ED1CF9000F007C117D /* Runner */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
+ buildPhases = (
+ 9740EEB61CF901F6004384FC /* Run Script */,
+ 97C146EA1CF9000F007C117D /* Sources */,
+ 97C146EB1CF9000F007C117D /* Frameworks */,
+ 97C146EC1CF9000F007C117D /* Resources */,
+ 9705A1C41CF9048500538489 /* Embed Frameworks */,
+ 3B06AD1E1E4923F5004D2608 /* Thin Binary */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = Runner;
+ productName = Runner;
+ productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 97C146E61CF9000F007C117D /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = YES;
+ LastUpgradeCheck = 1510;
+ ORGANIZATIONNAME = "";
+ TargetAttributes = {
+ 97C146ED1CF9000F007C117D = {
+ CreatedOnToolsVersion = 7.3.1;
+ LastSwiftMigration = 1100;
+ };
+ };
+ };
+ buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
+ compatibilityVersion = "Xcode 14.0";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 97C146E51CF9000F007C117D;
+ productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 97C146ED1CF9000F007C117D /* Runner */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 97C146EC1CF9000F007C117D /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
+ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
+ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
+ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
+ );
+ name = "Thin Binary";
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
+ };
+ 9740EEB61CF901F6004384FC /* Run Script */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ );
+ name = "Run Script";
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
+ };
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 97C146EA1CF9000F007C117D /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
+ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXVariantGroup section */
+ 97C146FA1CF9000F007C117D /* Main.storyboard */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 97C146FB1CF9000F007C117D /* Base */,
+ );
+ name = Main.storyboard;
+ sourceTree = "";
+ };
+ 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 97C147001CF9000F007C117D /* Base */,
+ );
+ name = LaunchScreen.storyboard;
+ sourceTree = "";
+ };
+/* End PBXVariantGroup section */
+
+/* Begin XCBuildConfiguration section */
+ 249021D3217E4FDB00AE95B9 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 12.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = iphoneos;
+ SUPPORTED_PLATFORMS = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Profile;
+ };
+ 249021D4217E4FDB00AE95B9 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = am.sure.mobile;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Profile;
+ };
+ 97C147031CF9000F007C117D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 12.0;
+ MTL_ENABLE_DEBUG_INFO = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 97C147041CF9000F007C117D /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 12.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = iphoneos;
+ SUPPORTED_PLATFORMS = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ 97C147061CF9000F007C117D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = am.sure.mobile;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Debug;
+ };
+ 97C147071CF9000F007C117D /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = am.sure.mobile;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 97C147031CF9000F007C117D /* Debug */,
+ 97C147041CF9000F007C117D /* Release */,
+ 249021D3217E4FDB00AE95B9 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 97C147061CF9000F007C117D /* Debug */,
+ 97C147071CF9000F007C117D /* Release */,
+ 249021D4217E4FDB00AE95B9 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = 97C146E61CF9000F007C117D /* Project object */;
+}
diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift
new file mode 100644
index 000000000..b63630348
--- /dev/null
+++ b/mobile/ios/Runner/AppDelegate.swift
@@ -0,0 +1,13 @@
+import UIKit
+import Flutter
+
+@main
+@objc class AppDelegate: FlutterAppDelegate {
+ override func application(
+ _ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
+ ) -> Bool {
+ GeneratedPluginRegistrant.register(with: self)
+ return super.application(application, didFinishLaunchingWithOptions: launchOptions)
+ }
+}
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 000000000..fc2c9e836
--- /dev/null
+++ b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,62 @@
+{
+ "images": [
+ {
+ "size": "20x20",
+ "idiom": "iphone",
+ "filename": "Icon-App-20x20@2x.png",
+ "scale": "2x"
+ },
+ {
+ "size": "20x20",
+ "idiom": "iphone",
+ "filename": "Icon-App-20x20@3x.png",
+ "scale": "3x"
+ },
+ {
+ "size": "29x29",
+ "idiom": "iphone",
+ "filename": "Icon-App-29x29@2x.png",
+ "scale": "2x"
+ },
+ {
+ "size": "29x29",
+ "idiom": "iphone",
+ "filename": "Icon-App-29x29@3x.png",
+ "scale": "3x"
+ },
+ {
+ "size": "40x40",
+ "idiom": "iphone",
+ "filename": "Icon-App-40x40@2x.png",
+ "scale": "2x"
+ },
+ {
+ "size": "40x40",
+ "idiom": "iphone",
+ "filename": "Icon-App-40x40@3x.png",
+ "scale": "3x"
+ },
+ {
+ "size": "60x60",
+ "idiom": "iphone",
+ "filename": "Icon-App-60x60@2x.png",
+ "scale": "2x"
+ },
+ {
+ "size": "60x60",
+ "idiom": "iphone",
+ "filename": "Icon-App-60x60@3x.png",
+ "scale": "3x"
+ },
+ {
+ "size": "1024x1024",
+ "idiom": "ios-marketing",
+ "filename": "Icon-App-1024x1024@1x.png",
+ "scale": "1x"
+ }
+ ],
+ "info": {
+ "version": 1,
+ "author": "xcode"
+ }
+}
diff --git a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
new file mode 100644
index 000000000..0bedcf2fd
--- /dev/null
+++ b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage.png",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage@3x.png",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
diff --git a/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard b/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard
new file mode 100644
index 000000000..e1a975c65
--- /dev/null
+++ b/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mobile/ios/Runner/Base.lproj/Main.storyboard b/mobile/ios/Runner/Base.lproj/Main.storyboard
new file mode 100644
index 000000000..e8a8c40b6
--- /dev/null
+++ b/mobile/ios/Runner/Base.lproj/Main.storyboard
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mobile/ios/Runner/GeneratedPluginRegistrant.h b/mobile/ios/Runner/GeneratedPluginRegistrant.h
new file mode 100644
index 000000000..3ab55947f
--- /dev/null
+++ b/mobile/ios/Runner/GeneratedPluginRegistrant.h
@@ -0,0 +1,5 @@
+#import
+
+@interface GeneratedPluginRegistrant : NSObject
++ (void)registerWithRegistry:(NSObject*)registry;
+@end
diff --git a/mobile/ios/Runner/GeneratedPluginRegistrant.m b/mobile/ios/Runner/GeneratedPluginRegistrant.m
new file mode 100644
index 000000000..efe65eccc
--- /dev/null
+++ b/mobile/ios/Runner/GeneratedPluginRegistrant.m
@@ -0,0 +1,14 @@
+//
+// Generated file. Do not edit.
+//
+
+// clang-format off
+
+#import "GeneratedPluginRegistrant.h"
+
+@implementation GeneratedPluginRegistrant
+
++ (void)registerWithRegistry:(NSObject*)registry {
+}
+
+@end
diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist
new file mode 100644
index 000000000..01700d6e4
--- /dev/null
+++ b/mobile/ios/Runner/Info.plist
@@ -0,0 +1,62 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Sure Finance
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ sure_mobile
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(FLUTTER_BUILD_NAME)
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ $(FLUTTER_BUILD_NUMBER)
+ LSRequiresIPhoneOS
+
+ UILaunchStoryboardName
+ LaunchScreen
+ UIMainStoryboardFile
+ Main
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UIViewControllerBasedStatusBarAppearance
+
+ CADisableMinimumFrameDurationOnPhone
+
+ UIApplicationSupportsIndirectInputEvents
+
+ CFBundleURLTypes
+
+
+ CFBundleTypeRole
+ Editor
+ CFBundleURLSchemes
+
+ sureapp
+
+
+
+
+
diff --git a/mobile/ios/Runner/Runner-Bridging-Header.h b/mobile/ios/Runner/Runner-Bridging-Header.h
new file mode 100644
index 000000000..9f3ddcf75
--- /dev/null
+++ b/mobile/ios/Runner/Runner-Bridging-Header.h
@@ -0,0 +1,3 @@
+#import
+#import
+#import "GeneratedPluginRegistrant.h"
diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart
new file mode 100644
index 000000000..36b71a378
--- /dev/null
+++ b/mobile/lib/main.dart
@@ -0,0 +1,181 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+import 'providers/auth_provider.dart';
+import 'providers/accounts_provider.dart';
+import 'providers/transactions_provider.dart';
+import 'screens/backend_config_screen.dart';
+import 'screens/login_screen.dart';
+import 'screens/dashboard_screen.dart';
+import 'services/api_config.dart';
+
+void main() async {
+ WidgetsFlutterBinding.ensureInitialized();
+ await ApiConfig.initialize();
+ runApp(const SureApp());
+}
+
+class SureApp extends StatelessWidget {
+ const SureApp({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return MultiProvider(
+ providers: [
+ ChangeNotifierProvider(create: (_) => AuthProvider()),
+ ChangeNotifierProvider(create: (_) => AccountsProvider()),
+ ChangeNotifierProvider(create: (_) => TransactionsProvider()),
+ ],
+ child: MaterialApp(
+ title: 'Sure Finance',
+ debugShowCheckedModeBanner: false,
+ theme: ThemeData(
+ colorScheme: ColorScheme.fromSeed(
+ seedColor: const Color(0xFF6366F1),
+ brightness: Brightness.light,
+ ),
+ useMaterial3: true,
+ appBarTheme: const AppBarTheme(
+ centerTitle: true,
+ elevation: 0,
+ ),
+ cardTheme: CardThemeData(
+ elevation: 2,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(12),
+ ),
+ ),
+ inputDecorationTheme: InputDecorationTheme(
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(12),
+ ),
+ filled: true,
+ ),
+ elevatedButtonTheme: ElevatedButtonThemeData(
+ style: ElevatedButton.styleFrom(
+ minimumSize: const Size(double.infinity, 50),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(12),
+ ),
+ ),
+ ),
+ ),
+ darkTheme: ThemeData(
+ colorScheme: ColorScheme.fromSeed(
+ seedColor: const Color(0xFF6366F1),
+ brightness: Brightness.dark,
+ ),
+ useMaterial3: true,
+ appBarTheme: const AppBarTheme(
+ centerTitle: true,
+ elevation: 0,
+ ),
+ cardTheme: CardThemeData(
+ elevation: 2,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(12),
+ ),
+ ),
+ inputDecorationTheme: InputDecorationTheme(
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(12),
+ ),
+ filled: true,
+ ),
+ elevatedButtonTheme: ElevatedButtonThemeData(
+ style: ElevatedButton.styleFrom(
+ minimumSize: const Size(double.infinity, 50),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(12),
+ ),
+ ),
+ ),
+ ),
+ themeMode: ThemeMode.system,
+ routes: {
+ '/config': (context) => const BackendConfigScreen(),
+ '/login': (context) => const LoginScreen(),
+ '/dashboard': (context) => const DashboardScreen(),
+ },
+ home: const AppWrapper(),
+ ),
+ );
+ }
+}
+
+class AppWrapper extends StatefulWidget {
+ const AppWrapper({super.key});
+
+ @override
+ State createState() => _AppWrapperState();
+}
+
+class _AppWrapperState extends State {
+ bool _isCheckingConfig = true;
+ bool _hasBackendUrl = false;
+
+ @override
+ void initState() {
+ super.initState();
+ _checkBackendConfig();
+ }
+
+ Future _checkBackendConfig() async {
+ final hasUrl = await ApiConfig.initialize();
+ if (mounted) {
+ setState(() {
+ _hasBackendUrl = hasUrl;
+ _isCheckingConfig = false;
+ });
+ }
+ }
+
+ void _onBackendConfigSaved() {
+ setState(() {
+ _hasBackendUrl = true;
+ });
+ }
+
+ void _goToBackendConfig() {
+ setState(() {
+ _hasBackendUrl = false;
+ });
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ if (_isCheckingConfig) {
+ return const Scaffold(
+ body: Center(
+ child: CircularProgressIndicator(),
+ ),
+ );
+ }
+
+ if (!_hasBackendUrl) {
+ return BackendConfigScreen(
+ onConfigSaved: _onBackendConfigSaved,
+ );
+ }
+
+ return Consumer(
+ builder: (context, authProvider, _) {
+ // Only show loading spinner during initial auth check
+ if (authProvider.isInitializing) {
+ return const Scaffold(
+ body: Center(
+ child: CircularProgressIndicator(),
+ ),
+ );
+ }
+
+ if (authProvider.isAuthenticated) {
+ return const DashboardScreen();
+ }
+
+ return LoginScreen(
+ onGoToSettings: _goToBackendConfig,
+ );
+ },
+ );
+ }
+}
diff --git a/mobile/lib/models/account.dart b/mobile/lib/models/account.dart
new file mode 100644
index 000000000..046aa0601
--- /dev/null
+++ b/mobile/lib/models/account.dart
@@ -0,0 +1,66 @@
+class Account {
+ final String id;
+ final String name;
+ final String balance;
+ final String currency;
+ final String? classification;
+ final String accountType;
+
+ Account({
+ required this.id,
+ required this.name,
+ required this.balance,
+ required this.currency,
+ this.classification,
+ required this.accountType,
+ });
+
+ factory Account.fromJson(Map json) {
+ return Account(
+ id: json['id'].toString(),
+ name: json['name'] as String,
+ balance: json['balance'] as String,
+ currency: json['currency'] as String,
+ classification: json['classification'] as String?,
+ accountType: json['account_type'] as String,
+ );
+ }
+
+ bool get isAsset => classification == 'asset';
+ bool get isLiability => classification == 'liability';
+
+ double get balanceAsDouble {
+ try {
+ // Remove commas and any other non-numeric characters except dots and minus signs
+ final cleanedBalance = balance.replaceAll(RegExp(r'[^\d.-]'), '');
+ return double.parse(cleanedBalance);
+ } catch (e) {
+ return 0.0;
+ }
+ }
+
+ String get displayAccountType {
+ switch (accountType) {
+ case 'depository':
+ return 'Bank Account';
+ case 'credit_card':
+ return 'Credit Card';
+ case 'investment':
+ return 'Investment';
+ case 'loan':
+ return 'Loan';
+ case 'property':
+ return 'Property';
+ case 'vehicle':
+ return 'Vehicle';
+ case 'crypto':
+ return 'Crypto';
+ case 'other_asset':
+ return 'Other Asset';
+ case 'other_liability':
+ return 'Other Liability';
+ default:
+ return accountType;
+ }
+ }
+}
diff --git a/mobile/lib/models/auth_tokens.dart b/mobile/lib/models/auth_tokens.dart
new file mode 100644
index 000000000..0a3263bee
--- /dev/null
+++ b/mobile/lib/models/auth_tokens.dart
@@ -0,0 +1,53 @@
+class AuthTokens {
+ final String accessToken;
+ final String refreshToken;
+ final String tokenType;
+ final int expiresIn;
+ final int createdAt;
+
+ AuthTokens({
+ required this.accessToken,
+ required this.refreshToken,
+ required this.tokenType,
+ required this.expiresIn,
+ required this.createdAt,
+ });
+
+ factory AuthTokens.fromJson(Map json) {
+ return AuthTokens(
+ accessToken: json['access_token'] as String,
+ refreshToken: json['refresh_token'] as String,
+ tokenType: json['token_type'] as String,
+ expiresIn: _parseToInt(json['expires_in']),
+ createdAt: _parseToInt(json['created_at']),
+ );
+ }
+
+ /// Helper method to parse a value to int, handling both String and int types
+ static int _parseToInt(dynamic value) {
+ if (value is int) {
+ return value;
+ } else if (value is String) {
+ return int.parse(value);
+ } else {
+ throw FormatException('Cannot parse $value to int');
+ }
+ }
+
+ Map toJson() {
+ return {
+ 'access_token': accessToken,
+ 'refresh_token': refreshToken,
+ 'token_type': tokenType,
+ 'expires_in': expiresIn,
+ 'created_at': createdAt,
+ };
+ }
+
+ bool get isExpired {
+ final expirationTime = DateTime.fromMillisecondsSinceEpoch(
+ (createdAt + expiresIn) * 1000,
+ );
+ return DateTime.now().isAfter(expirationTime);
+ }
+}
diff --git a/mobile/lib/models/transaction.dart b/mobile/lib/models/transaction.dart
new file mode 100644
index 000000000..8d2e93b3a
--- /dev/null
+++ b/mobile/lib/models/transaction.dart
@@ -0,0 +1,50 @@
+class Transaction {
+ final String? id;
+ final String accountId;
+ final String name;
+ final String date;
+ final String amount;
+ final String currency;
+ final String nature; // "expense" or "income"
+ final String? notes;
+
+ Transaction({
+ this.id,
+ required this.accountId,
+ required this.name,
+ required this.date,
+ required this.amount,
+ required this.currency,
+ required this.nature,
+ this.notes,
+ });
+
+ factory Transaction.fromJson(Map json) {
+ return Transaction(
+ id: json['id']?.toString(),
+ accountId: json['account_id']?.toString() ?? '',
+ name: json['name']?.toString() ?? '',
+ date: json['date']?.toString() ?? '',
+ amount: json['amount']?.toString() ?? '0',
+ currency: json['currency']?.toString() ?? '',
+ nature: json['nature']?.toString() ?? 'expense',
+ notes: json['notes']?.toString(),
+ );
+ }
+
+ Map toJson() {
+ return {
+ if (id != null) 'id': id,
+ 'account_id': accountId,
+ 'name': name,
+ 'date': date,
+ 'amount': amount,
+ 'currency': currency,
+ 'nature': nature,
+ if (notes != null) 'notes': notes,
+ };
+ }
+
+ bool get isExpense => nature == 'expense';
+ bool get isIncome => nature == 'income';
+}
diff --git a/mobile/lib/models/user.dart b/mobile/lib/models/user.dart
new file mode 100644
index 000000000..e932bcda3
--- /dev/null
+++ b/mobile/lib/models/user.dart
@@ -0,0 +1,32 @@
+class User {
+ final String id;
+ final String email;
+ final String? firstName;
+ final String? lastName;
+
+ User({
+ required this.id,
+ required this.email,
+ this.firstName,
+ this.lastName,
+ });
+
+ factory User.fromJson(Map json) {
+ return User(
+ id: json['id'].toString(),
+ email: json['email'] as String,
+ firstName: json['first_name'] as String?,
+ lastName: json['last_name'] as String?,
+ );
+ }
+
+ String get displayName {
+ if (firstName != null && lastName != null) {
+ return '$firstName $lastName';
+ }
+ if (firstName != null) {
+ return firstName!;
+ }
+ return email;
+ }
+}
diff --git a/mobile/lib/providers/accounts_provider.dart b/mobile/lib/providers/accounts_provider.dart
new file mode 100644
index 000000000..b5d465443
--- /dev/null
+++ b/mobile/lib/providers/accounts_provider.dart
@@ -0,0 +1,118 @@
+import 'package:flutter/foundation.dart';
+import '../models/account.dart';
+import '../services/accounts_service.dart';
+
+class AccountsProvider with ChangeNotifier {
+ final AccountsService _accountsService = AccountsService();
+
+ List _accounts = [];
+ bool _isLoading = false;
+ bool _isInitializing = true; // Track if we've fetched accounts at least once
+ String? _errorMessage;
+ Map? _pagination;
+
+ List get accounts => _accounts;
+ bool get isLoading => _isLoading;
+ bool get isInitializing => _isInitializing; // Expose initialization state
+ String? get errorMessage => _errorMessage;
+ Map? get pagination => _pagination;
+
+ List get assetAccounts {
+ final assets = _accounts.where((a) => a.isAsset).toList();
+ _sortAccounts(assets);
+ return assets;
+ }
+
+ List get liabilityAccounts {
+ final liabilities = _accounts.where((a) => a.isLiability).toList();
+ _sortAccounts(liabilities);
+ return liabilities;
+ }
+
+ Map get assetTotalsByCurrency {
+ final totals = {};
+ for (var account in _accounts.where((a) => a.isAsset)) {
+ totals[account.currency] = (totals[account.currency] ?? 0.0) + account.balanceAsDouble;
+ }
+ return totals;
+ }
+
+ Map get liabilityTotalsByCurrency {
+ final totals = {};
+ for (var account in _accounts.where((a) => a.isLiability)) {
+ totals[account.currency] = (totals[account.currency] ?? 0.0) + account.balanceAsDouble;
+ }
+ return totals;
+ }
+
+ void _sortAccounts(List accounts) {
+ accounts.sort((a, b) {
+ // 1. Sort by account type
+ int typeComparison = a.accountType.compareTo(b.accountType);
+ if (typeComparison != 0) return typeComparison;
+
+ // 2. Sort by currency
+ int currencyComparison = a.currency.compareTo(b.currency);
+ if (currencyComparison != 0) return currencyComparison;
+
+ // 3. Sort by balance (descending - highest first)
+ int balanceComparison = b.balanceAsDouble.compareTo(a.balanceAsDouble);
+ if (balanceComparison != 0) return balanceComparison;
+
+ // 4. Sort by name
+ return a.name.compareTo(b.name);
+ });
+ }
+
+ Future fetchAccounts({
+ required String accessToken,
+ int page = 1,
+ int perPage = 25,
+ }) async {
+ _isLoading = true;
+ _errorMessage = null;
+ notifyListeners();
+
+ try {
+ final result = await _accountsService.getAccounts(
+ accessToken: accessToken,
+ page: page,
+ perPage: perPage,
+ );
+
+ if (result['success'] == true && result.containsKey('accounts')) {
+ _accounts = (result['accounts'] as List?)?.cast() ?? [];
+ _pagination = result['pagination'] as Map?;
+ _isLoading = false;
+ _isInitializing = false; // Mark as initialized after first fetch
+ notifyListeners();
+ return true;
+ } else {
+ _errorMessage = result['error'] as String? ?? 'Failed to fetch accounts';
+ _isLoading = false;
+ _isInitializing = false; // Mark as initialized even on error
+ notifyListeners();
+ return false;
+ }
+ } catch (e) {
+ _errorMessage = 'Connection error. Please check your internet connection.';
+ _isLoading = false;
+ _isInitializing = false; // Mark as initialized even on error
+ notifyListeners();
+ return false;
+ }
+ }
+
+ void clearAccounts() {
+ _accounts = [];
+ _pagination = null;
+ _errorMessage = null;
+ _isInitializing = true; // Reset initialization state on clear
+ notifyListeners();
+ }
+
+ void clearError() {
+ _errorMessage = null;
+ notifyListeners();
+ }
+}
diff --git a/mobile/lib/providers/auth_provider.dart b/mobile/lib/providers/auth_provider.dart
new file mode 100644
index 000000000..24b0a4e6f
--- /dev/null
+++ b/mobile/lib/providers/auth_provider.dart
@@ -0,0 +1,213 @@
+import 'package:flutter/foundation.dart';
+import '../models/user.dart';
+import '../models/auth_tokens.dart';
+import '../services/auth_service.dart';
+import '../services/device_service.dart';
+
+class AuthProvider with ChangeNotifier {
+ final AuthService _authService = AuthService();
+ final DeviceService _deviceService = DeviceService();
+
+ User? _user;
+ AuthTokens? _tokens;
+ bool _isLoading = true;
+ bool _isInitializing = true; // Track initial auth check separately
+ String? _errorMessage;
+ bool _mfaRequired = false;
+ bool _showMfaInput = false; // Track if we should show MFA input field
+
+ User? get user => _user;
+ AuthTokens? get tokens => _tokens;
+ bool get isLoading => _isLoading;
+ bool get isInitializing => _isInitializing; // Expose initialization state
+ bool get isAuthenticated => _tokens != null && !_tokens!.isExpired;
+ String? get errorMessage => _errorMessage;
+ bool get mfaRequired => _mfaRequired;
+ bool get showMfaInput => _showMfaInput; // Expose MFA input state
+
+ AuthProvider() {
+ _loadStoredAuth();
+ }
+
+ Future _loadStoredAuth() async {
+ _isLoading = true;
+ _isInitializing = true;
+ notifyListeners();
+
+ try {
+ _tokens = await _authService.getStoredTokens();
+ _user = await _authService.getStoredUser();
+
+ // If tokens exist but are expired, try to refresh
+ if (_tokens != null && _tokens!.isExpired) {
+ await _refreshToken();
+ }
+ } catch (e) {
+ _tokens = null;
+ _user = null;
+ }
+
+ _isLoading = false;
+ _isInitializing = false;
+ notifyListeners();
+ }
+
+ Future login({
+ required String email,
+ required String password,
+ String? otpCode,
+ }) async {
+ _errorMessage = null;
+ _mfaRequired = false;
+ _isLoading = true;
+ // Don't reset _showMfaInput if we're submitting OTP code
+ if (otpCode == null) {
+ _showMfaInput = false;
+ }
+ notifyListeners();
+
+ try {
+ final deviceInfo = await _deviceService.getDeviceInfo();
+ final result = await _authService.login(
+ email: email,
+ password: password,
+ deviceInfo: deviceInfo,
+ otpCode: otpCode,
+ );
+
+ debugPrint('Login result: $result'); // Debug log
+
+ if (result['success'] == true) {
+ _tokens = result['tokens'] as AuthTokens?;
+ _user = result['user'] as User?;
+ _mfaRequired = false;
+ _showMfaInput = false; // Reset on successful login
+ _isLoading = false;
+ notifyListeners();
+ return true;
+ } else {
+ if (result['mfa_required'] == true) {
+ _mfaRequired = true;
+ _showMfaInput = true; // Show MFA input field
+ debugPrint('MFA required! Setting _showMfaInput to true'); // Debug log
+
+ // If user already submitted an OTP code, this is likely an invalid OTP error
+ // Show the error message so user knows the code was wrong
+ if (otpCode != null && otpCode.isNotEmpty) {
+ // Backend returns "Two-factor authentication required" for both cases
+ // Replace with clearer message when OTP was actually submitted
+ _errorMessage = 'Invalid authentication code. Please try again.';
+ } else {
+ // First time requesting MFA - don't show error message, it's a normal flow
+ _errorMessage = null;
+ }
+ } else {
+ _errorMessage = result['error'] as String?;
+ // If user submitted an OTP (is in MFA flow) but got error, keep MFA input visible
+ if (otpCode != null) {
+ _showMfaInput = true;
+ }
+ }
+ _isLoading = false;
+ notifyListeners();
+ return false;
+ }
+ } catch (e) {
+ _errorMessage = 'Connection error: ${e.toString()}';
+ _isLoading = false;
+ notifyListeners();
+ return false;
+ }
+ }
+
+ Future signup({
+ required String email,
+ required String password,
+ required String firstName,
+ required String lastName,
+ String? inviteCode,
+ }) async {
+ _errorMessage = null;
+ _isLoading = true;
+ notifyListeners();
+
+ try {
+ final deviceInfo = await _deviceService.getDeviceInfo();
+ final result = await _authService.signup(
+ email: email,
+ password: password,
+ firstName: firstName,
+ lastName: lastName,
+ deviceInfo: deviceInfo,
+ inviteCode: inviteCode,
+ );
+
+ if (result['success'] == true) {
+ _tokens = result['tokens'] as AuthTokens?;
+ _user = result['user'] as User?;
+ _isLoading = false;
+ notifyListeners();
+ return true;
+ } else {
+ _errorMessage = result['error'] as String?;
+ _isLoading = false;
+ notifyListeners();
+ return false;
+ }
+ } catch (e) {
+ _errorMessage = 'Connection error: ${e.toString()}';
+ _isLoading = false;
+ notifyListeners();
+ return false;
+ }
+ }
+
+ Future logout() async {
+ await _authService.logout();
+ _tokens = null;
+ _user = null;
+ _errorMessage = null;
+ _mfaRequired = false;
+ notifyListeners();
+ }
+
+ Future _refreshToken() async {
+ if (_tokens == null) return false;
+
+ try {
+ final deviceInfo = await _deviceService.getDeviceInfo();
+ final result = await _authService.refreshToken(
+ refreshToken: _tokens!.refreshToken,
+ deviceInfo: deviceInfo,
+ );
+
+ if (result['success'] == true) {
+ _tokens = result['tokens'] as AuthTokens?;
+ return true;
+ } else {
+ // Token refresh failed, clear auth state
+ await logout();
+ return false;
+ }
+ } catch (e) {
+ await logout();
+ return false;
+ }
+ }
+
+ Future getValidAccessToken() async {
+ if (_tokens == null) return null;
+
+ if (_tokens!.isExpired) {
+ final refreshed = await _refreshToken();
+ if (!refreshed) return null;
+ }
+
+ return _tokens?.accessToken;
+ }
+
+ void clearError() {
+ _errorMessage = null;
+ notifyListeners();
+ }
+}
diff --git a/mobile/lib/providers/transactions_provider.dart b/mobile/lib/providers/transactions_provider.dart
new file mode 100644
index 000000000..5e4f2f01c
--- /dev/null
+++ b/mobile/lib/providers/transactions_provider.dart
@@ -0,0 +1,87 @@
+import 'dart:collection';
+import 'package:flutter/foundation.dart';
+import '../models/transaction.dart';
+import '../services/transactions_service.dart';
+
+class TransactionsProvider with ChangeNotifier {
+ final TransactionsService _transactionsService = TransactionsService();
+
+ List _transactions = [];
+ bool _isLoading = false;
+ String? _error;
+
+ List get transactions => UnmodifiableListView(_transactions);
+ bool get isLoading => _isLoading;
+ String? get error => _error;
+
+ Future fetchTransactions({
+ required String accessToken,
+ String? accountId,
+ }) async {
+ _isLoading = true;
+ _error = null;
+ notifyListeners();
+
+ final result = await _transactionsService.getTransactions(
+ accessToken: accessToken,
+ accountId: accountId,
+ );
+
+ _isLoading = false;
+
+ if (result['success'] == true && result.containsKey('transactions')) {
+ _transactions = (result['transactions'] as List?)?.cast() ?? [];
+ _error = null;
+ } else {
+ _error = result['error'] as String? ?? 'Failed to fetch transactions';
+ }
+
+ notifyListeners();
+ }
+
+ Future deleteTransaction({
+ required String accessToken,
+ required String transactionId,
+ }) async {
+ final result = await _transactionsService.deleteTransaction(
+ accessToken: accessToken,
+ transactionId: transactionId,
+ );
+
+ if (result['success'] == true) {
+ _transactions.removeWhere((t) => t.id == transactionId);
+ notifyListeners();
+ return true;
+ } else {
+ _error = result['error'] as String? ?? 'Failed to delete transaction';
+ notifyListeners();
+ return false;
+ }
+ }
+
+ Future deleteMultipleTransactions({
+ required String accessToken,
+ required List transactionIds,
+ }) async {
+ final result = await _transactionsService.deleteMultipleTransactions(
+ accessToken: accessToken,
+ transactionIds: transactionIds,
+ );
+
+ if (result['success'] == true) {
+ _transactions.removeWhere((t) => transactionIds.contains(t.id));
+ notifyListeners();
+ return true;
+ } else {
+ _error = result['error'] as String? ?? 'Failed to delete transactions';
+ notifyListeners();
+ return false;
+ }
+ }
+
+ void clearTransactions() {
+ _transactions = [];
+ _error = null;
+ notifyListeners();
+ }
+}
diff --git a/mobile/lib/screens/backend_config_screen.dart b/mobile/lib/screens/backend_config_screen.dart
new file mode 100644
index 000000000..48bf3dc66
--- /dev/null
+++ b/mobile/lib/screens/backend_config_screen.dart
@@ -0,0 +1,372 @@
+import 'package:flutter/material.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+import 'package:http/http.dart' as http;
+import '../services/api_config.dart';
+
+class BackendConfigScreen extends StatefulWidget {
+ final VoidCallback? onConfigSaved;
+
+ const BackendConfigScreen({super.key, this.onConfigSaved});
+
+ @override
+ State createState() => _BackendConfigScreenState();
+}
+
+class _BackendConfigScreenState extends State {
+ final _formKey = GlobalKey();
+ final _urlController = TextEditingController();
+ bool _isLoading = false;
+ bool _isTesting = false;
+ String? _errorMessage;
+ String? _successMessage;
+
+ @override
+ void initState() {
+ super.initState();
+ _loadSavedUrl();
+ }
+
+ @override
+ void dispose() {
+ _urlController.dispose();
+ super.dispose();
+ }
+
+ Future _loadSavedUrl() async {
+ final prefs = await SharedPreferences.getInstance();
+ final savedUrl = prefs.getString('backend_url');
+ if (mounted && savedUrl != null && savedUrl.isNotEmpty) {
+ setState(() {
+ _urlController.text = savedUrl;
+ });
+ }
+ }
+
+ Future _testConnection() async {
+ if (!_formKey.currentState!.validate()) return;
+
+ setState(() {
+ _isTesting = true;
+ _errorMessage = null;
+ _successMessage = null;
+ });
+
+ try {
+ // Normalize base URL by removing trailing slashes
+ final normalizedUrl = _urlController.text.trim().replaceAll(RegExp(r'/+$'), '');
+
+ // Check /sessions/new page to verify it's a Sure backend
+ final sessionsUrl = Uri.parse('$normalizedUrl/sessions/new');
+ final sessionsResponse = await http.get(
+ sessionsUrl,
+ headers: {'Accept': 'text/html'},
+ ).timeout(
+ const Duration(seconds: 10),
+ onTimeout: () {
+ throw Exception('Connection timeout. Please check the URL and try again.');
+ },
+ );
+
+ if (sessionsResponse.statusCode >= 200 && sessionsResponse.statusCode < 400) {
+ if (mounted) {
+ setState(() {
+ _successMessage = 'Connection successful! Sure backend is reachable.';
+ });
+ }
+ } else {
+ if (mounted) {
+ setState(() {
+ _errorMessage = 'Server responded with status ${sessionsResponse.statusCode}. Please check if this is a Sure backend server.';
+ });
+ }
+ }
+ } catch (e) {
+ if (mounted) {
+ setState(() {
+ _errorMessage = 'Connection failed: ${e.toString()}';
+ });
+ }
+ } finally {
+ if (mounted) {
+ setState(() {
+ _isTesting = false;
+ });
+ }
+ }
+ }
+
+ Future _saveAndContinue() async {
+ if (!_formKey.currentState!.validate()) return;
+
+ setState(() {
+ _isLoading = true;
+ _errorMessage = null;
+ });
+
+ try {
+ // Normalize base URL by removing trailing slashes
+ final normalizedUrl = _urlController.text.trim().replaceAll(RegExp(r'/+$'), '');
+
+ // Save URL to SharedPreferences
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setString('backend_url', normalizedUrl);
+
+ // Update ApiConfig
+ ApiConfig.setBaseUrl(normalizedUrl);
+
+ // Notify parent that config is saved
+ if (mounted && widget.onConfigSaved != null) {
+ widget.onConfigSaved!();
+ }
+ } catch (e) {
+ if (mounted) {
+ setState(() {
+ _errorMessage = 'Failed to save URL: ${e.toString()}';
+ });
+ }
+ } finally {
+ if (mounted) {
+ setState(() {
+ _isLoading = false;
+ });
+ }
+ }
+ }
+
+ String? _validateUrl(String? value) {
+ if (value == null || value.isEmpty) {
+ return 'Please enter a backend URL';
+ }
+
+ final trimmedValue = value.trim();
+
+ // Check if it starts with http:// or https://
+ if (!trimmedValue.startsWith('http://') && !trimmedValue.startsWith('https://')) {
+ return 'URL must start with http:// or https://';
+ }
+
+ // Basic URL validation
+ try {
+ final uri = Uri.parse(trimmedValue);
+ if (!uri.hasScheme || uri.host.isEmpty) {
+ return 'Please enter a valid URL';
+ }
+ } catch (e) {
+ return 'Please enter a valid URL';
+ }
+
+ return null;
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+
+ return Scaffold(
+ body: SafeArea(
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.all(24),
+ child: Form(
+ key: _formKey,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ const SizedBox(height: 48),
+ // Logo/Title
+ Icon(
+ Icons.settings_outlined,
+ size: 80,
+ color: colorScheme.primary,
+ ),
+ const SizedBox(height: 16),
+ Text(
+ 'Backend Configuration',
+ style: Theme.of(context).textTheme.headlineMedium?.copyWith(
+ fontWeight: FontWeight.bold,
+ color: colorScheme.primary,
+ ),
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 8),
+ Text(
+ 'Enter your Sure Finance backend URL',
+ style: Theme.of(context).textTheme.bodyLarge?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 48),
+
+ // Info box
+ Container(
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ color: colorScheme.primaryContainer.withValues(alpha: 0.3),
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Icon(
+ Icons.info_outline,
+ color: colorScheme.primary,
+ ),
+ const SizedBox(width: 12),
+ Text(
+ 'Example URLs',
+ style: TextStyle(
+ color: colorScheme.primary,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 12),
+ Text(
+ '• https://sure.lazyrhythm.com\n'
+ '• https://your-domain.com\n'
+ '• http://localhost:3000',
+ style: TextStyle(
+ color: colorScheme.onSurface,
+ fontFamily: 'monospace',
+ ),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 24),
+
+ // Error Message
+ if (_errorMessage != null)
+ Container(
+ padding: const EdgeInsets.all(12),
+ margin: const EdgeInsets.only(bottom: 16),
+ decoration: BoxDecoration(
+ color: colorScheme.errorContainer,
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Row(
+ children: [
+ Icon(
+ Icons.error_outline,
+ color: colorScheme.error,
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Text(
+ _errorMessage!,
+ style: TextStyle(color: colorScheme.onErrorContainer),
+ ),
+ ),
+ IconButton(
+ icon: const Icon(Icons.close),
+ onPressed: () {
+ setState(() {
+ _errorMessage = null;
+ });
+ },
+ iconSize: 20,
+ ),
+ ],
+ ),
+ ),
+
+ // Success Message
+ if (_successMessage != null)
+ Container(
+ padding: const EdgeInsets.all(12),
+ margin: const EdgeInsets.only(bottom: 16),
+ decoration: BoxDecoration(
+ color: Colors.green.withValues(alpha: 0.1),
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: Colors.green),
+ ),
+ child: Row(
+ children: [
+ const Icon(
+ Icons.check_circle_outline,
+ color: Colors.green,
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Text(
+ _successMessage!,
+ style: TextStyle(color: Colors.green[800]),
+ ),
+ ),
+ IconButton(
+ icon: const Icon(Icons.close),
+ onPressed: () {
+ setState(() {
+ _successMessage = null;
+ });
+ },
+ iconSize: 20,
+ ),
+ ],
+ ),
+ ),
+
+ // URL Field
+ TextFormField(
+ controller: _urlController,
+ keyboardType: TextInputType.url,
+ autocorrect: false,
+ textInputAction: TextInputAction.done,
+ decoration: const InputDecoration(
+ labelText: 'Backend URL',
+ prefixIcon: Icon(Icons.cloud_outlined),
+ hintText: 'https://sure.lazyrhythm.com',
+ ),
+ validator: _validateUrl,
+ onFieldSubmitted: (_) => _saveAndContinue(),
+ ),
+ const SizedBox(height: 16),
+
+ // Test Connection Button
+ OutlinedButton.icon(
+ onPressed: _isTesting || _isLoading ? null : _testConnection,
+ icon: _isTesting
+ ? const SizedBox(
+ height: 16,
+ width: 16,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ )
+ : const Icon(Icons.cable),
+ label: Text(_isTesting ? 'Testing...' : 'Test Connection'),
+ ),
+
+ const SizedBox(height: 12),
+
+ // Continue Button
+ ElevatedButton(
+ onPressed: _isLoading || _isTesting ? null : _saveAndContinue,
+ child: _isLoading
+ ? const SizedBox(
+ height: 20,
+ width: 20,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ )
+ : const Text('Continue'),
+ ),
+
+ const SizedBox(height: 24),
+
+ // Info text
+ Text(
+ 'You can change this later in the settings.',
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
+ textAlign: TextAlign.center,
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/screens/dashboard_screen.dart b/mobile/lib/screens/dashboard_screen.dart
new file mode 100644
index 000000000..ca2afd5fd
--- /dev/null
+++ b/mobile/lib/screens/dashboard_screen.dart
@@ -0,0 +1,733 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+import '../models/account.dart';
+import '../providers/auth_provider.dart';
+import '../providers/accounts_provider.dart';
+import '../widgets/account_card.dart';
+import 'transaction_form_screen.dart';
+import 'transactions_list_screen.dart';
+
+class DashboardScreen extends StatefulWidget {
+ const DashboardScreen({super.key});
+
+ @override
+ State createState() => _DashboardScreenState();
+}
+
+class _DashboardScreenState extends State {
+ bool _assetsExpanded = true;
+ bool _liabilitiesExpanded = true;
+
+ @override
+ void initState() {
+ super.initState();
+ _loadAccounts();
+ }
+
+ Future _loadAccounts() async {
+ final authProvider = Provider.of(context, listen: false);
+ final accountsProvider = Provider.of(context, listen: false);
+
+ final accessToken = await authProvider.getValidAccessToken();
+ if (accessToken == null) {
+ // Token is invalid, redirect to login
+ await authProvider.logout();
+ return;
+ }
+
+ await accountsProvider.fetchAccounts(accessToken: accessToken);
+
+ // Check if unauthorized
+ if (accountsProvider.errorMessage == 'unauthorized') {
+ await authProvider.logout();
+ }
+ }
+
+ Future _handleRefresh() async {
+ await _loadAccounts();
+ }
+
+ List _formatCurrencyItem(String currency, double amount) {
+ final symbol = _getCurrencySymbol(currency);
+ final isSmallAmount = amount.abs() < 1 && amount != 0;
+ final formattedAmount = amount.toStringAsFixed(isSmallAmount ? 4 : 0);
+
+ // Split into integer and decimal parts
+ final parts = formattedAmount.split('.');
+ final integerPart = parts[0].replaceAllMapped(
+ RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
+ (Match m) => '${m[1]},',
+ );
+
+ final finalAmount = parts.length > 1 ? '$integerPart.${parts[1]}' : integerPart;
+ return [currency, '$symbol$finalAmount'];
+ }
+
+ String _getCurrencySymbol(String currency) {
+ switch (currency.toUpperCase()) {
+ case 'USD':
+ return '\$';
+ case 'TWD':
+ return '\$';
+ case 'BTC':
+ return '₿';
+ case 'ETH':
+ return 'Ξ';
+ case 'EUR':
+ return '€';
+ case 'GBP':
+ return '£';
+ case 'JPY':
+ return '¥';
+ case 'CNY':
+ return '¥';
+ default:
+ return ' ';
+ }
+ }
+
+ Future _handleAccountTap(Account account) async {
+ final result = await showModalBottomSheet(
+ context: context,
+ isScrollControlled: true,
+ backgroundColor: Colors.transparent,
+ builder: (context) => TransactionFormScreen(account: account),
+ );
+
+ // Refresh accounts if transaction was created successfully
+ if (result == true && mounted) {
+ // Show loading indicator
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Row(
+ children: [
+ SizedBox(
+ width: 16,
+ height: 16,
+ child: CircularProgressIndicator(
+ strokeWidth: 2,
+ valueColor: AlwaysStoppedAnimation(Colors.white),
+ ),
+ ),
+ SizedBox(width: 12),
+ Text('Refreshing accounts...'),
+ ],
+ ),
+ duration: Duration(seconds: 2),
+ ),
+ );
+
+ // Small delay to ensure smooth UI transition
+ await Future.delayed(const Duration(milliseconds: 50));
+
+ // Refresh the accounts
+ await _loadAccounts();
+
+ // Hide loading snackbar and show success
+ if (mounted) {
+ ScaffoldMessenger.of(context).clearSnackBars();
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Row(
+ children: [
+ Icon(Icons.check_circle, color: Colors.white),
+ SizedBox(width: 12),
+ Text('Accounts updated'),
+ ],
+ ),
+ backgroundColor: Colors.green,
+ duration: Duration(seconds: 1),
+ ),
+ );
+ }
+ }
+ }
+
+ Future _handleAccountSwipe(Account account) async {
+ await Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (context) => TransactionsListScreen(account: account),
+ ),
+ );
+
+ // Refresh accounts when returning from transaction list
+ if (mounted) {
+ await _loadAccounts();
+ }
+ }
+
+ Future _handleLogout() async {
+ final confirmed = await showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('Sign Out'),
+ content: const Text('Are you sure you want to sign out?'),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context, false),
+ child: const Text('Cancel'),
+ ),
+ TextButton(
+ onPressed: () => Navigator.pop(context, true),
+ child: const Text('Sign Out'),
+ ),
+ ],
+ ),
+ );
+
+ if (confirmed == true && mounted) {
+ final authProvider = Provider.of(context, listen: false);
+ final accountsProvider = Provider.of(context, listen: false);
+
+ accountsProvider.clearAccounts();
+ await authProvider.logout();
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('Dashboard'),
+ actions: [
+ IconButton(
+ icon: const Icon(Icons.refresh),
+ onPressed: _handleRefresh,
+ tooltip: 'Refresh',
+ ),
+ IconButton(
+ icon: const Icon(Icons.logout),
+ onPressed: _handleLogout,
+ tooltip: 'Sign Out',
+ ),
+ ],
+ ),
+ body: Consumer2(
+ builder: (context, authProvider, accountsProvider, _) {
+ // Show loading state during initialization or when loading
+ if (accountsProvider.isInitializing || accountsProvider.isLoading) {
+ return const Center(
+ child: CircularProgressIndicator(),
+ );
+ }
+
+ // Show error state
+ if (accountsProvider.errorMessage != null &&
+ accountsProvider.errorMessage != 'unauthorized') {
+ return Center(
+ child: Padding(
+ padding: const EdgeInsets.all(24),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(
+ Icons.error_outline,
+ size: 64,
+ color: colorScheme.error,
+ ),
+ const SizedBox(height: 16),
+ Text(
+ 'Failed to load accounts',
+ style: Theme.of(context).textTheme.titleLarge,
+ ),
+ const SizedBox(height: 8),
+ Text(
+ accountsProvider.errorMessage!,
+ style: TextStyle(color: colorScheme.onSurfaceVariant),
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 24),
+ ElevatedButton.icon(
+ onPressed: _handleRefresh,
+ icon: const Icon(Icons.refresh),
+ label: const Text('Try Again'),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ // Show empty state
+ if (accountsProvider.accounts.isEmpty) {
+ return Center(
+ child: Padding(
+ padding: const EdgeInsets.all(24),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(
+ Icons.account_balance_wallet_outlined,
+ size: 64,
+ color: colorScheme.onSurfaceVariant,
+ ),
+ const SizedBox(height: 16),
+ Text(
+ 'No accounts yet',
+ style: Theme.of(context).textTheme.titleLarge,
+ ),
+ const SizedBox(height: 8),
+ Text(
+ 'Add accounts in the web app to see them here.',
+ style: TextStyle(color: colorScheme.onSurfaceVariant),
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 24),
+ ElevatedButton.icon(
+ onPressed: _handleRefresh,
+ icon: const Icon(Icons.refresh),
+ label: const Text('Refresh'),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ // Show accounts list
+ return RefreshIndicator(
+ onRefresh: _handleRefresh,
+ child: CustomScrollView(
+ slivers: [
+ // Welcome header
+ SliverToBoxAdapter(
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'Welcome${authProvider.user != null ? ', ${authProvider.user!.displayName}' : ''}',
+ style: Theme.of(context).textTheme.headlineSmall?.copyWith(
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ 'Here\'s your financial overview',
+ style: TextStyle(color: colorScheme.onSurfaceVariant),
+ ),
+ ],
+ ),
+ ),
+ ),
+
+ // Summary cards
+ SliverToBoxAdapter(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ child: Column(
+ children: [
+ if (accountsProvider.assetAccounts.isNotEmpty)
+ _SummaryCard(
+ title: 'Assets Total',
+ totals: accountsProvider.assetTotalsByCurrency,
+ color: Colors.green,
+ formatCurrencyItem: _formatCurrencyItem,
+ ),
+ if (accountsProvider.liabilityAccounts.isNotEmpty)
+ _SummaryCard(
+ title: 'Liabilities Total',
+ totals: accountsProvider.liabilityTotalsByCurrency,
+ color: Colors.red,
+ formatCurrencyItem: _formatCurrencyItem,
+ ),
+ ],
+ ),
+ ),
+ ),
+
+ // Assets section
+ if (accountsProvider.assetAccounts.isNotEmpty) ...[
+ SliverToBoxAdapter(
+ child: _CollapsibleSectionHeader(
+ title: 'Assets',
+ count: accountsProvider.assetAccounts.length,
+ color: Colors.green,
+ isExpanded: _assetsExpanded,
+ onToggle: () {
+ setState(() {
+ _assetsExpanded = !_assetsExpanded;
+ });
+ },
+ ),
+ ),
+ if (_assetsExpanded)
+ SliverPadding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ sliver: SliverList(
+ delegate: SliverChildBuilderDelegate(
+ (context, index) {
+ final account = accountsProvider.assetAccounts[index];
+ return AccountCard(
+ account: account,
+ onTap: () => _handleAccountTap(account),
+ onSwipe: () => _handleAccountSwipe(account),
+ );
+ },
+ childCount: accountsProvider.assetAccounts.length,
+ ),
+ ),
+ ),
+ ],
+
+ // Liabilities section
+ if (accountsProvider.liabilityAccounts.isNotEmpty) ...[
+ SliverToBoxAdapter(
+ child: _CollapsibleSectionHeader(
+ title: 'Liabilities',
+ count: accountsProvider.liabilityAccounts.length,
+ color: Colors.red,
+ isExpanded: _liabilitiesExpanded,
+ onToggle: () {
+ setState(() {
+ _liabilitiesExpanded = !_liabilitiesExpanded;
+ });
+ },
+ ),
+ ),
+ if (_liabilitiesExpanded)
+ SliverPadding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ sliver: SliverList(
+ delegate: SliverChildBuilderDelegate(
+ (context, index) {
+ final account = accountsProvider.liabilityAccounts[index];
+ return AccountCard(
+ account: account,
+ onTap: () => _handleAccountTap(account),
+ onSwipe: () => _handleAccountSwipe(account),
+ );
+ },
+ childCount: accountsProvider.liabilityAccounts.length,
+ ),
+ ),
+ ),
+ ],
+
+ // Uncategorized accounts
+ ..._buildUncategorizedSection(accountsProvider),
+
+ // Bottom padding
+ const SliverToBoxAdapter(
+ child: SizedBox(height: 24),
+ ),
+ ],
+ ),
+ );
+ },
+ ),
+ );
+ }
+
+ List _buildUncategorizedSection(AccountsProvider accountsProvider) {
+ final uncategorized = accountsProvider.accounts
+ .where((a) => !a.isAsset && !a.isLiability)
+ .toList();
+
+ if (uncategorized.isEmpty) {
+ return [];
+ }
+
+ return [
+ SliverToBoxAdapter(
+ child: _SimpleSectionHeader(
+ title: 'Other Accounts',
+ count: uncategorized.length,
+ color: Colors.grey,
+ ),
+ ),
+ SliverPadding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ sliver: SliverList(
+ delegate: SliverChildBuilderDelegate(
+ (context, index) {
+ final account = uncategorized[index];
+ return AccountCard(
+ account: account,
+ onTap: () => _handleAccountTap(account),
+ onSwipe: () => _handleAccountSwipe(account),
+ );
+ },
+ childCount: uncategorized.length,
+ ),
+ ),
+ ),
+ ];
+ }
+}
+
+class _SummaryCard extends StatelessWidget {
+ final String title;
+ final Map totals;
+ final Color color;
+ final List Function(String currency, double amount) formatCurrencyItem;
+
+ const _SummaryCard({
+ required this.title,
+ required this.totals,
+ required this.color,
+ required this.formatCurrencyItem,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final entries = totals.entries.toList();
+ final rows = [];
+
+ // Group currencies into pairs (2 per row)
+ for (int i = 0; i < entries.length; i += 2) {
+ final first = entries[i];
+ final firstFormatted = formatCurrencyItem(first.key, first.value);
+
+ if (i + 1 < entries.length) {
+ // Two items in this row
+ final second = entries[i + 1];
+ final secondFormatted = formatCurrencyItem(second.key, second.value);
+
+ rows.add(
+ Row(
+ children: [
+ Expanded(
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(
+ firstFormatted[0],
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ Text(
+ firstFormatted[1],
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ ],
+ ),
+ ),
+ Text(
+ ' | ',
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ fontWeight: FontWeight.w300,
+ color: color.withValues(alpha: 0.5),
+ ),
+ ),
+ Expanded(
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(
+ secondFormatted[0],
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ Text(
+ secondFormatted[1],
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ } else {
+ // Only one item in this row
+ rows.add(
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(
+ firstFormatted[0],
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ Text(
+ firstFormatted[1],
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ if (i + 2 < entries.length) {
+ rows.add(const SizedBox(height: 4));
+ }
+ }
+
+ return Container(
+ margin: const EdgeInsets.only(bottom: 12),
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ color: color.withValues(alpha: 0.1),
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(
+ color: color.withValues(alpha: 0.3),
+ width: 1,
+ ),
+ ),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Container(
+ width: 4,
+ height: 40,
+ decoration: BoxDecoration(
+ color: color,
+ borderRadius: BorderRadius.circular(2),
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ title,
+ style: Theme.of(context).textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.bold,
+ color: color,
+ ),
+ ),
+ const SizedBox(height: 8),
+ ...rows,
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _CollapsibleSectionHeader extends StatelessWidget {
+ final String title;
+ final int count;
+ final Color color;
+ final bool isExpanded;
+ final VoidCallback onToggle;
+
+ const _CollapsibleSectionHeader({
+ required this.title,
+ required this.count,
+ required this.color,
+ required this.isExpanded,
+ required this.onToggle,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return InkWell(
+ onTap: onToggle,
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
+ child: Row(
+ children: [
+ Container(
+ width: 4,
+ height: 24,
+ decoration: BoxDecoration(
+ color: color,
+ borderRadius: BorderRadius.circular(2),
+ ),
+ ),
+ const SizedBox(width: 12),
+ Text(
+ title,
+ style: Theme.of(context).textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ const SizedBox(width: 8),
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
+ decoration: BoxDecoration(
+ color: color.withValues(alpha: 0.1),
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Text(
+ count.toString(),
+ style: TextStyle(
+ color: color,
+ fontWeight: FontWeight.bold,
+ fontSize: 12,
+ ),
+ ),
+ ),
+ const Spacer(),
+ Icon(
+ isExpanded ? Icons.expand_less : Icons.expand_more,
+ color: color,
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _SimpleSectionHeader extends StatelessWidget {
+ final String title;
+ final int count;
+ final Color color;
+
+ const _SimpleSectionHeader({
+ required this.title,
+ required this.count,
+ required this.color,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
+ child: Row(
+ children: [
+ Container(
+ width: 4,
+ height: 24,
+ decoration: BoxDecoration(
+ color: color,
+ borderRadius: BorderRadius.circular(2),
+ ),
+ ),
+ const SizedBox(width: 12),
+ Text(
+ title,
+ style: Theme.of(context).textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ const SizedBox(width: 8),
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
+ decoration: BoxDecoration(
+ color: color.withValues(alpha: 0.1),
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Text(
+ count.toString(),
+ style: TextStyle(
+ color: color,
+ fontWeight: FontWeight.bold,
+ fontSize: 12,
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/screens/login_screen.dart b/mobile/lib/screens/login_screen.dart
new file mode 100644
index 000000000..c524bada7
--- /dev/null
+++ b/mobile/lib/screens/login_screen.dart
@@ -0,0 +1,311 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+import '../providers/auth_provider.dart';
+import '../services/api_config.dart';
+
+class LoginScreen extends StatefulWidget {
+ final VoidCallback? onGoToSettings;
+
+ const LoginScreen({super.key, this.onGoToSettings});
+
+ @override
+ State createState() => _LoginScreenState();
+}
+
+class _LoginScreenState extends State {
+ final _formKey = GlobalKey();
+ final _emailController = TextEditingController();
+ final _passwordController = TextEditingController();
+ final _otpController = TextEditingController();
+ bool _obscurePassword = true;
+
+ @override
+ void dispose() {
+ _emailController.dispose();
+ _passwordController.dispose();
+ _otpController.dispose();
+ super.dispose();
+ }
+
+ Future _handleLogin() async {
+ if (!_formKey.currentState!.validate()) return;
+
+ final authProvider = Provider.of(context, listen: false);
+ final hadOtpCode = authProvider.showMfaInput && _otpController.text.isNotEmpty;
+
+ final success = await authProvider.login(
+ email: _emailController.text.trim(),
+ password: _passwordController.text,
+ otpCode: authProvider.showMfaInput ? _otpController.text.trim() : null,
+ );
+
+ // Check if widget is still mounted after async operation
+ if (!mounted) return;
+
+ // Clear OTP field if login failed and user had entered an OTP code
+ // This allows user to easily try again with a new code
+ if (!success && hadOtpCode && authProvider.errorMessage != null) {
+ _otpController.clear();
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text(''),
+ actions: [
+ IconButton(
+ icon: const Icon(Icons.settings_outlined),
+ tooltip: 'Backend Settings',
+ onPressed: widget.onGoToSettings,
+ ),
+ ],
+ ),
+ body: SafeArea(
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.all(24),
+ child: Form(
+ key: _formKey,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ const SizedBox(height: 48),
+ // Logo/Title
+ Icon(
+ Icons.account_balance_wallet,
+ size: 80,
+ color: colorScheme.primary,
+ ),
+ const SizedBox(height: 16),
+ Text(
+ 'Sure Finance',
+ style: Theme.of(context).textTheme.headlineMedium?.copyWith(
+ fontWeight: FontWeight.bold,
+ color: colorScheme.primary,
+ ),
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 8),
+ Text(
+ 'Sign in to manage your finances',
+ style: Theme.of(context).textTheme.bodyLarge?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 48),
+
+ // Error Message
+ Consumer(
+ builder: (context, authProvider, _) {
+ if (authProvider.errorMessage != null) {
+ return Container(
+ padding: const EdgeInsets.all(12),
+ margin: const EdgeInsets.only(bottom: 16),
+ decoration: BoxDecoration(
+ color: colorScheme.errorContainer,
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Row(
+ children: [
+ Icon(
+ Icons.error_outline,
+ color: colorScheme.error,
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Text(
+ authProvider.errorMessage!,
+ style: TextStyle(color: colorScheme.onErrorContainer),
+ ),
+ ),
+ IconButton(
+ icon: const Icon(Icons.close),
+ onPressed: () => authProvider.clearError(),
+ iconSize: 20,
+ ),
+ ],
+ ),
+ );
+ }
+ return const SizedBox.shrink();
+ },
+ ),
+
+ // Email Field
+ TextFormField(
+ controller: _emailController,
+ keyboardType: TextInputType.emailAddress,
+ autocorrect: false,
+ textInputAction: TextInputAction.next,
+ decoration: const InputDecoration(
+ labelText: 'Email',
+ prefixIcon: Icon(Icons.email_outlined),
+ ),
+ validator: (value) {
+ if (value == null || value.isEmpty) {
+ return 'Please enter your email';
+ }
+ if (!value.contains('@')) {
+ return 'Please enter a valid email';
+ }
+ return null;
+ },
+ ),
+ const SizedBox(height: 16),
+
+ // Password and OTP Fields with Consumer
+ Consumer(
+ builder: (context, authProvider, _) {
+ final showOtp = authProvider.showMfaInput;
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ // Password Field
+ TextFormField(
+ controller: _passwordController,
+ obscureText: _obscurePassword,
+ textInputAction: showOtp
+ ? TextInputAction.next
+ : TextInputAction.done,
+ decoration: InputDecoration(
+ labelText: 'Password',
+ prefixIcon: const Icon(Icons.lock_outlined),
+ suffixIcon: IconButton(
+ icon: Icon(
+ _obscurePassword
+ ? Icons.visibility_outlined
+ : Icons.visibility_off_outlined,
+ ),
+ onPressed: () {
+ setState(() {
+ _obscurePassword = !_obscurePassword;
+ });
+ },
+ ),
+ ),
+ validator: (value) {
+ if (value == null || value.isEmpty) {
+ return 'Please enter your password';
+ }
+ return null;
+ },
+ onFieldSubmitted: showOtp ? null : (_) => _handleLogin(),
+ ),
+
+ // OTP Field (shown when MFA is required)
+ if (showOtp) ...[
+ const SizedBox(height: 16),
+ Container(
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: colorScheme.primaryContainer.withValues(alpha: 0.3),
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Row(
+ children: [
+ Icon(
+ Icons.security,
+ color: colorScheme.primary,
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Text(
+ 'Two-factor authentication is enabled. Enter your code.',
+ style: TextStyle(color: colorScheme.onSurface),
+ ),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 16),
+ TextFormField(
+ controller: _otpController,
+ keyboardType: TextInputType.number,
+ textInputAction: TextInputAction.done,
+ decoration: const InputDecoration(
+ labelText: 'Authentication Code',
+ prefixIcon: Icon(Icons.pin_outlined),
+ ),
+ validator: (value) {
+ if (showOtp && (value == null || value.isEmpty)) {
+ return 'Please enter your authentication code';
+ }
+ return null;
+ },
+ onFieldSubmitted: (_) => _handleLogin(),
+ ),
+ ],
+ ],
+ );
+ },
+ ),
+
+ const SizedBox(height: 24),
+
+ // Login Button
+ Consumer(
+ builder: (context, authProvider, _) {
+ return ElevatedButton(
+ onPressed: authProvider.isLoading ? null : _handleLogin,
+ child: authProvider.isLoading
+ ? const SizedBox(
+ height: 20,
+ width: 20,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ )
+ : const Text('Sign In'),
+ );
+ },
+ ),
+
+ const SizedBox(height: 24),
+
+ // Backend URL info
+ Container(
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: Column(
+ children: [
+ Text(
+ 'Backend URL:',
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ ApiConfig.baseUrl,
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: colorScheme.primary,
+ fontFamily: 'monospace',
+ ),
+ textAlign: TextAlign.center,
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ 'Connect to your Sure Finance server to manage your accounts.',
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
+ textAlign: TextAlign.center,
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/screens/transaction_form_screen.dart b/mobile/lib/screens/transaction_form_screen.dart
new file mode 100644
index 000000000..a0d5c7492
--- /dev/null
+++ b/mobile/lib/screens/transaction_form_screen.dart
@@ -0,0 +1,374 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+import 'package:intl/intl.dart';
+import '../models/account.dart';
+import '../providers/auth_provider.dart';
+import '../services/transactions_service.dart';
+
+class TransactionFormScreen extends StatefulWidget {
+ final Account account;
+
+ const TransactionFormScreen({
+ super.key,
+ required this.account,
+ });
+
+ @override
+ State createState() => _TransactionFormScreenState();
+}
+
+class _TransactionFormScreenState extends State {
+ final _formKey = GlobalKey();
+ final _amountController = TextEditingController();
+ final _dateController = TextEditingController();
+ final _nameController = TextEditingController();
+ final _transactionsService = TransactionsService();
+
+ String _nature = 'expense';
+ bool _showMoreFields = false;
+ bool _isSubmitting = false;
+
+ @override
+ void initState() {
+ super.initState();
+ // Set default values
+ final now = DateTime.now();
+ final formattedDate = DateFormat('yyyy/MM/dd').format(now);
+ _dateController.text = formattedDate;
+ _nameController.text = 'SureApp';
+ }
+
+ @override
+ void dispose() {
+ _amountController.dispose();
+ _dateController.dispose();
+ _nameController.dispose();
+ super.dispose();
+ }
+
+ String? _validateAmount(String? value) {
+ if (value == null || value.trim().isEmpty) {
+ return 'Please enter an amount';
+ }
+
+ final amount = double.tryParse(value.trim());
+ if (amount == null) {
+ return 'Please enter a valid number';
+ }
+
+ if (amount <= 0) {
+ return 'Amount must be greater than 0';
+ }
+
+ return null;
+ }
+
+ Future _selectDate() async {
+ final DateTime? picked = await showDatePicker(
+ context: context,
+ initialDate: DateTime.now(),
+ firstDate: DateTime(2000),
+ lastDate: DateTime(2100),
+ );
+
+ if (picked != null && mounted) {
+ setState(() {
+ _dateController.text = DateFormat('yyyy/MM/dd').format(picked);
+ });
+ }
+ }
+
+ Future _handleSubmit() async {
+ if (!_formKey.currentState!.validate()) {
+ return;
+ }
+
+ setState(() {
+ _isSubmitting = true;
+ });
+
+ try {
+ final authProvider = Provider.of(context, listen: false);
+ final accessToken = await authProvider.getValidAccessToken();
+
+ if (accessToken == null) {
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text('Session expired. Please login again.'),
+ backgroundColor: Colors.red,
+ ),
+ );
+ await authProvider.logout();
+ }
+ return;
+ }
+
+ // Convert date format from yyyy/MM/dd to yyyy-MM-dd
+ final parsedDate = DateFormat('yyyy/MM/dd').parse(_dateController.text);
+ final apiDate = DateFormat('yyyy-MM-dd').format(parsedDate);
+
+ final result = await _transactionsService.createTransaction(
+ accessToken: accessToken,
+ accountId: widget.account.id,
+ name: _nameController.text.trim(),
+ date: apiDate,
+ amount: _amountController.text.trim(),
+ currency: widget.account.currency,
+ nature: _nature,
+ notes: 'This transaction via mobile app.',
+ );
+
+ if (mounted) {
+ if (result['success'] == true) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text('Transaction created successfully'),
+ backgroundColor: Colors.green,
+ ),
+ );
+ Navigator.pop(context, true); // Return true to indicate success
+ } else {
+ final error = result['error'] ?? 'Failed to create transaction';
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(error),
+ backgroundColor: Colors.red,
+ ),
+ );
+
+ if (error == 'unauthorized') {
+ await authProvider.logout();
+ }
+ }
+ }
+ } catch (e) {
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text('Error: ${e.toString()}'),
+ backgroundColor: Colors.red,
+ ),
+ );
+ }
+ } finally {
+ if (mounted) {
+ setState(() {
+ _isSubmitting = false;
+ });
+ }
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+
+ return Container(
+ decoration: BoxDecoration(
+ color: Theme.of(context).scaffoldBackgroundColor,
+ borderRadius: const BorderRadius.only(
+ topLeft: Radius.circular(20),
+ topRight: Radius.circular(20),
+ ),
+ ),
+ child: DraggableScrollableSheet(
+ initialChildSize: 0.9,
+ minChildSize: 0.5,
+ maxChildSize: 0.95,
+ expand: false,
+ builder: (context, scrollController) {
+ return Column(
+ children: [
+ // Handle bar
+ Container(
+ margin: const EdgeInsets.only(top: 12, bottom: 8),
+ width: 40,
+ height: 4,
+ decoration: BoxDecoration(
+ color: Colors.grey[300],
+ borderRadius: BorderRadius.circular(2),
+ ),
+ ),
+ // Title
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(
+ 'New Transaction',
+ style: Theme.of(context).textTheme.titleLarge?.copyWith(
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ IconButton(
+ icon: const Icon(Icons.close),
+ onPressed: () => Navigator.pop(context),
+ ),
+ ],
+ ),
+ ),
+ const Divider(height: 1),
+ // Form content
+ Expanded(
+ child: SingleChildScrollView(
+ controller: scrollController,
+ padding: EdgeInsets.only(
+ left: 24,
+ right: 24,
+ top: 16,
+ bottom: MediaQuery.of(context).viewInsets.bottom + 24,
+ ),
+ child: Form(
+ key: _formKey,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ // Account info card
+ Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Row(
+ children: [
+ Icon(
+ Icons.account_balance_wallet,
+ color: colorScheme.primary,
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ widget.account.name,
+ style: Theme.of(context).textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ '${widget.account.balance} ${widget.account.currency}',
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ const SizedBox(height: 24),
+
+ // Transaction type selection
+ Text(
+ 'Type',
+ style: Theme.of(context).textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ const SizedBox(height: 8),
+ SegmentedButton(
+ segments: const [
+ ButtonSegment(
+ value: 'expense',
+ label: Text('Expense'),
+ icon: Icon(Icons.arrow_downward),
+ ),
+ ButtonSegment(
+ value: 'income',
+ label: Text('Income'),
+ icon: Icon(Icons.arrow_upward),
+ ),
+ ],
+ selected: {_nature},
+ onSelectionChanged: (Set newSelection) {
+ setState(() {
+ _nature = newSelection.first;
+ });
+ },
+ ),
+ const SizedBox(height: 24),
+
+ // Amount field
+ TextFormField(
+ controller: _amountController,
+ keyboardType: const TextInputType.numberWithOptions(decimal: true),
+ decoration: InputDecoration(
+ labelText: 'Amount *',
+ prefixIcon: const Icon(Icons.attach_money),
+ suffixText: widget.account.currency,
+ helperText: 'Required',
+ ),
+ validator: _validateAmount,
+ ),
+ const SizedBox(height: 24),
+
+ // More button
+ TextButton.icon(
+ onPressed: () {
+ setState(() {
+ _showMoreFields = !_showMoreFields;
+ });
+ },
+ icon: Icon(_showMoreFields ? Icons.expand_less : Icons.expand_more),
+ label: Text(_showMoreFields ? 'Less' : 'More'),
+ ),
+
+ // Optional fields (shown when More is clicked)
+ if (_showMoreFields) ...[
+ const SizedBox(height: 16),
+
+ // Date field
+ TextFormField(
+ controller: _dateController,
+ readOnly: true,
+ decoration: const InputDecoration(
+ labelText: 'Date',
+ prefixIcon: Icon(Icons.calendar_today),
+ helperText: 'Optional (default: today)',
+ ),
+ onTap: _selectDate,
+ ),
+ const SizedBox(height: 16),
+
+ // Name field
+ TextFormField(
+ controller: _nameController,
+ decoration: const InputDecoration(
+ labelText: 'Name',
+ prefixIcon: Icon(Icons.label),
+ helperText: 'Optional (default: SureApp)',
+ ),
+ ),
+ ],
+
+ const SizedBox(height: 32),
+
+ // Submit button
+ ElevatedButton(
+ onPressed: _isSubmitting ? null : _handleSubmit,
+ child: _isSubmitting
+ ? const SizedBox(
+ height: 20,
+ width: 20,
+ child: CircularProgressIndicator(
+ strokeWidth: 2,
+ ),
+ )
+ : const Text('Create Transaction'),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ],
+ );
+ },
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/screens/transactions_list_screen.dart b/mobile/lib/screens/transactions_list_screen.dart
new file mode 100644
index 000000000..4f62675ad
--- /dev/null
+++ b/mobile/lib/screens/transactions_list_screen.dart
@@ -0,0 +1,479 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+import '../models/account.dart';
+import '../models/transaction.dart';
+import '../providers/auth_provider.dart';
+import '../providers/transactions_provider.dart';
+import '../screens/transaction_form_screen.dart';
+
+class TransactionsListScreen extends StatefulWidget {
+ final Account account;
+
+ const TransactionsListScreen({
+ super.key,
+ required this.account,
+ });
+
+ @override
+ State createState() => _TransactionsListScreenState();
+}
+
+class _TransactionsListScreenState extends State {
+ bool _isSelectionMode = false;
+ final Set _selectedTransactions = {};
+
+ @override
+ void initState() {
+ super.initState();
+ _loadTransactions();
+ }
+
+ // Parse and display amount information
+ // Amount is a currency-formatted string returned by the API (e.g. may include
+ // currency symbol, grouping separators, locale-dependent decimal separator,
+ // and a sign either before or after the symbol)
+ Map _getAmountDisplayInfo(String amount, bool isAsset) {
+ try {
+ // Trim whitespace
+ String trimmedAmount = amount.trim();
+
+ // Normalize common minus characters (U+002D HYPHEN-MINUS, U+2212 MINUS SIGN)
+ trimmedAmount = trimmedAmount.replaceAll('\u2212', '-');
+
+ // Detect if the amount has a negative sign (leading or trailing)
+ bool hasNegativeSign = trimmedAmount.startsWith('-') || trimmedAmount.endsWith('-');
+
+ // Remove all non-numeric characters except decimal point and minus sign
+ String numericString = trimmedAmount.replaceAll(RegExp(r'[^\d.\-]'), '');
+
+ // Parse the numeric value
+ double numericValue = double.tryParse(numericString.replaceAll('-', '')) ?? 0.0;
+
+ // Apply the sign from the string
+ if (hasNegativeSign) {
+ numericValue = -numericValue;
+ }
+
+ // For asset accounts, flip the sign to match accounting conventions
+ if (isAsset) {
+ numericValue = -numericValue;
+ }
+
+ // Determine if the final value is positive
+ bool isPositive = numericValue >= 0;
+
+ // Get the display amount by removing the sign and currency symbols
+ String displayAmount = trimmedAmount
+ .replaceAll('-', '')
+ .replaceAll('\u2212', '')
+ .trim();
+
+ return {
+ 'isPositive': isPositive,
+ 'displayAmount': displayAmount,
+ 'color': isPositive ? Colors.green : Colors.red,
+ 'icon': isPositive ? Icons.arrow_upward : Icons.arrow_downward,
+ 'prefix': isPositive ? '' : '-',
+ };
+ } catch (e) {
+ // Fallback if parsing fails - log and return neutral state
+ debugPrint('Failed to parse amount "$amount": $e');
+ return {
+ 'isPositive': true,
+ 'displayAmount': amount,
+ 'color': Colors.grey,
+ 'icon': Icons.help_outline,
+ 'prefix': '',
+ };
+ }
+ }
+
+ Future _loadTransactions() async {
+ final authProvider = Provider.of(context, listen: false);
+ final transactionsProvider = Provider.of(context, listen: false);
+
+ final accessToken = await authProvider.getValidAccessToken();
+ if (accessToken == null) {
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text('Authentication failed: Please log in again'),
+ backgroundColor: Colors.red,
+ ),
+ );
+ }
+ return;
+ }
+
+ await transactionsProvider.fetchTransactions(
+ accessToken: accessToken,
+ accountId: widget.account.id,
+ );
+ }
+
+ void _toggleSelectionMode() {
+ setState(() {
+ _isSelectionMode = !_isSelectionMode;
+ if (!_isSelectionMode) {
+ _selectedTransactions.clear();
+ }
+ });
+ }
+
+ void _toggleTransactionSelection(String transactionId) {
+ setState(() {
+ if (_selectedTransactions.contains(transactionId)) {
+ _selectedTransactions.remove(transactionId);
+ } else {
+ _selectedTransactions.add(transactionId);
+ }
+ });
+ }
+
+ Future _deleteSelectedTransactions() async {
+ if (_selectedTransactions.isEmpty) return;
+
+ final confirmed = await showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('Delete Transactions'),
+ content: Text('Are you sure you want to delete ${_selectedTransactions.length} transaction(s)?'),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context, false),
+ child: const Text('Cancel'),
+ ),
+ TextButton(
+ onPressed: () => Navigator.pop(context, true),
+ style: TextButton.styleFrom(foregroundColor: Colors.red),
+ child: const Text('Delete'),
+ ),
+ ],
+ ),
+ );
+
+ if (confirmed != true || !mounted) return;
+
+ final authProvider = Provider.of(context, listen: false);
+ final transactionsProvider = Provider.of(context, listen: false);
+
+ final accessToken = await authProvider.getValidAccessToken();
+ if (accessToken != null) {
+ final success = await transactionsProvider.deleteMultipleTransactions(
+ accessToken: accessToken,
+ transactionIds: _selectedTransactions.toList(),
+ );
+
+ if (mounted) {
+ if (success) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text('Deleted ${_selectedTransactions.length} transaction(s)'),
+ backgroundColor: Colors.green,
+ ),
+ );
+ setState(() {
+ _selectedTransactions.clear();
+ _isSelectionMode = false;
+ });
+ } else {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text('Failed to delete transactions'),
+ backgroundColor: Colors.red,
+ ),
+ );
+ }
+ }
+ }
+ }
+
+ Future _confirmAndDeleteTransaction(Transaction transaction) async {
+ if (transaction.id == null) return false;
+
+ // Show confirmation dialog
+ final confirmed = await showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('Delete Transaction'),
+ content: Text('Are you sure you want to delete "${transaction.name}"?'),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context, false),
+ child: const Text('Cancel'),
+ ),
+ TextButton(
+ onPressed: () => Navigator.pop(context, true),
+ style: TextButton.styleFrom(foregroundColor: Colors.red),
+ child: const Text('Delete'),
+ ),
+ ],
+ ),
+ );
+
+ if (confirmed != true) return false;
+
+ // Perform the deletion
+ final scaffoldMessenger = ScaffoldMessenger.of(context);
+ final authProvider = Provider.of(context, listen: false);
+ final transactionsProvider = Provider.of(context, listen: false);
+ final accessToken = await authProvider.getValidAccessToken();
+
+ if (accessToken == null) {
+ scaffoldMessenger.showSnackBar(
+ const SnackBar(
+ content: Text('Failed to delete: No access token'),
+ backgroundColor: Colors.red,
+ ),
+ );
+ return false;
+ }
+
+ final success = await transactionsProvider.deleteTransaction(
+ accessToken: accessToken,
+ transactionId: transaction.id!,
+ );
+
+ if (mounted) {
+ scaffoldMessenger.showSnackBar(
+ SnackBar(
+ content: Text(success ? 'Transaction deleted' : 'Failed to delete transaction'),
+ backgroundColor: success ? Colors.green : Colors.red,
+ ),
+ );
+ }
+
+ return success;
+ }
+
+ void _showAddTransactionForm() {
+ showModalBottomSheet(
+ context: context,
+ isScrollControlled: true,
+ backgroundColor: Colors.transparent,
+ builder: (context) => TransactionFormScreen(account: widget.account),
+ ).then((_) {
+ if (mounted) {
+ _loadTransactions();
+ }
+ });
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+
+ return Scaffold(
+ appBar: AppBar(
+ title: Text(widget.account.name),
+ actions: [
+ if (_isSelectionMode)
+ IconButton(
+ icon: const Icon(Icons.delete),
+ onPressed: _selectedTransactions.isEmpty ? null : _deleteSelectedTransactions,
+ ),
+ IconButton(
+ icon: Icon(_isSelectionMode ? Icons.close : Icons.checklist),
+ onPressed: _toggleSelectionMode,
+ ),
+ ],
+ ),
+ body: Consumer(
+ builder: (context, transactionsProvider, child) {
+ if (transactionsProvider.isLoading) {
+ return const Center(child: CircularProgressIndicator());
+ }
+
+ if (transactionsProvider.error != null) {
+ return RefreshIndicator(
+ onRefresh: _loadTransactions,
+ child: CustomScrollView(
+ slivers: [
+ SliverFillRemaining(
+ child: Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const Icon(Icons.error_outline, size: 48, color: Colors.red),
+ const SizedBox(height: 16),
+ Text(
+ transactionsProvider.error!,
+ style: const TextStyle(color: Colors.red),
+ ),
+ const SizedBox(height: 16),
+ ElevatedButton(
+ onPressed: _loadTransactions,
+ child: const Text('Retry'),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ final transactions = transactionsProvider.transactions;
+
+ if (transactions.isEmpty) {
+ return RefreshIndicator(
+ onRefresh: _loadTransactions,
+ child: CustomScrollView(
+ slivers: [
+ SliverFillRemaining(
+ child: Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(
+ Icons.receipt_long_outlined,
+ size: 64,
+ color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
+ ),
+ const SizedBox(height: 16),
+ Text(
+ 'No transactions yet',
+ style: TextStyle(
+ fontSize: 16,
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ 'Tap + to add your first transaction',
+ style: TextStyle(
+ fontSize: 14,
+ color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ return RefreshIndicator(
+ onRefresh: _loadTransactions,
+ child: ListView.builder(
+ padding: const EdgeInsets.all(16),
+ itemCount: transactions.length,
+ itemBuilder: (context, index) {
+ final transaction = transactions[index];
+ final isSelected = transaction.id != null &&
+ _selectedTransactions.contains(transaction.id);
+ // Compute display info once to avoid duplicate parsing
+ final displayInfo = _getAmountDisplayInfo(
+ transaction.amount,
+ widget.account.isAsset,
+ );
+
+ return Dismissible(
+ key: Key(transaction.id ?? 'transaction_$index'),
+ direction: _isSelectionMode
+ ? DismissDirection.none
+ : DismissDirection.endToStart,
+ background: Container(
+ alignment: Alignment.centerRight,
+ padding: const EdgeInsets.only(right: 20),
+ decoration: BoxDecoration(
+ color: Colors.red,
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: const Icon(Icons.delete, color: Colors.white),
+ ),
+ confirmDismiss: (direction) => _confirmAndDeleteTransaction(transaction),
+ child: Card(
+ margin: const EdgeInsets.only(bottom: 12),
+ child: InkWell(
+ onTap: _isSelectionMode && transaction.id != null
+ ? () => _toggleTransactionSelection(transaction.id!)
+ : null,
+ borderRadius: BorderRadius.circular(12),
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Row(
+ children: [
+ if (_isSelectionMode)
+ Padding(
+ padding: const EdgeInsets.only(right: 12),
+ child: Checkbox(
+ value: isSelected,
+ onChanged: transaction.id != null
+ ? (value) => _toggleTransactionSelection(transaction.id!)
+ : null,
+ ),
+ ),
+ Container(
+ width: 48,
+ height: 48,
+ decoration: BoxDecoration(
+ color: (displayInfo['color'] as Color).withValues(alpha: 0.1),
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Icon(
+ displayInfo['icon'] as IconData,
+ color: displayInfo['color'] as Color,
+ ),
+ ),
+ const SizedBox(width: 16),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ transaction.name,
+ style: Theme.of(context).textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ transaction.date,
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ),
+ ),
+ Column(
+ crossAxisAlignment: CrossAxisAlignment.end,
+ children: [
+ Text(
+ '${displayInfo['prefix']}${displayInfo['displayAmount']}',
+ style: Theme.of(context).textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.bold,
+ color: displayInfo['color'] as Color,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ transaction.currency,
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ );
+ },
+ ),
+ floatingActionButton: FloatingActionButton(
+ onPressed: _showAddTransactionForm,
+ child: const Icon(Icons.add),
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/services/accounts_service.dart b/mobile/lib/services/accounts_service.dart
new file mode 100644
index 000000000..1f5af6e82
--- /dev/null
+++ b/mobile/lib/services/accounts_service.dart
@@ -0,0 +1,57 @@
+import 'dart:convert';
+import 'package:http/http.dart' as http;
+import '../models/account.dart';
+import 'api_config.dart';
+
+class AccountsService {
+ Future> getAccounts({
+ required String accessToken,
+ int page = 1,
+ int perPage = 25,
+ }) async {
+ try {
+ final url = Uri.parse(
+ '${ApiConfig.baseUrl}/api/v1/accounts?page=$page&per_page=$perPage',
+ );
+
+ final response = await http.get(
+ url,
+ headers: {
+ 'Authorization': 'Bearer $accessToken',
+ 'Accept': 'application/json',
+ },
+ ).timeout(const Duration(seconds: 30));
+
+ if (response.statusCode == 200) {
+ final responseData = jsonDecode(response.body);
+
+ final accountsList = (responseData['accounts'] as List)
+ .map((json) => Account.fromJson(json))
+ .toList();
+
+ return {
+ 'success': true,
+ 'accounts': accountsList,
+ 'pagination': responseData['pagination'],
+ };
+ } else if (response.statusCode == 401) {
+ return {
+ 'success': false,
+ 'error': 'unauthorized',
+ 'message': 'Session expired. Please login again.',
+ };
+ } else {
+ final responseData = jsonDecode(response.body);
+ return {
+ 'success': false,
+ 'error': responseData['error'] ?? 'Failed to fetch accounts',
+ };
+ }
+ } catch (e) {
+ return {
+ 'success': false,
+ 'error': 'Network error: ${e.toString()}',
+ };
+ }
+ }
+}
diff --git a/mobile/lib/services/api_config.dart b/mobile/lib/services/api_config.dart
new file mode 100644
index 000000000..09e4db7f8
--- /dev/null
+++ b/mobile/lib/services/api_config.dart
@@ -0,0 +1,37 @@
+import 'package:shared_preferences/shared_preferences.dart';
+
+class ApiConfig {
+ // Base URL for the API - can be changed to point to different environments
+ // For local development, use: http://10.0.2.2:3000 (Android emulator)
+ // For iOS simulator, use: http://localhost:3000
+ // For production, use your actual server URL
+ static String _baseUrl = 'http://10.0.2.2:3000';
+
+ static String get baseUrl => _baseUrl;
+
+ static void setBaseUrl(String url) {
+ _baseUrl = url;
+ }
+
+ /// Initialize the API configuration by loading the backend URL from storage
+ /// Returns true if a saved URL was loaded, false otherwise
+ static Future initialize() async {
+ try {
+ final prefs = await SharedPreferences.getInstance();
+ final savedUrl = prefs.getString('backend_url');
+
+ if (savedUrl != null && savedUrl.isNotEmpty) {
+ _baseUrl = savedUrl;
+ return true;
+ }
+ return false;
+ } catch (e) {
+ // If initialization fails, keep the default URL
+ return false;
+ }
+ }
+
+ // API timeout settings
+ static const Duration connectTimeout = Duration(seconds: 30);
+ static const Duration receiveTimeout = Duration(seconds: 30);
+}
diff --git a/mobile/lib/services/auth_service.dart b/mobile/lib/services/auth_service.dart
new file mode 100644
index 000000000..07d94de8c
--- /dev/null
+++ b/mobile/lib/services/auth_service.dart
@@ -0,0 +1,334 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+import 'package:flutter/foundation.dart';
+import 'package:http/http.dart' as http;
+import 'package:flutter_secure_storage/flutter_secure_storage.dart';
+import '../models/auth_tokens.dart';
+import '../models/user.dart';
+import 'api_config.dart';
+
+class AuthService {
+ final FlutterSecureStorage _storage = const FlutterSecureStorage();
+ static const String _tokenKey = 'auth_tokens';
+ static const String _userKey = 'user_data';
+
+ Future> login({
+ required String email,
+ required String password,
+ required Map deviceInfo,
+ String? otpCode,
+ }) async {
+ try {
+ final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/auth/login');
+
+ final body = {
+ 'email': email,
+ 'password': password,
+ 'device': deviceInfo,
+ };
+
+ if (otpCode != null) {
+ body['otp_code'] = otpCode;
+ }
+
+ final response = await http.post(
+ url,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/json',
+ },
+ body: jsonEncode(body),
+ ).timeout(const Duration(seconds: 30));
+
+ debugPrint('Login response status: ${response.statusCode}');
+ debugPrint('Login response body: ${response.body}');
+
+ final responseData = jsonDecode(response.body);
+
+ if (response.statusCode == 200) {
+ // Store tokens
+ final tokens = AuthTokens.fromJson(responseData);
+ await _saveTokens(tokens);
+
+ // Store user data - parse once and reuse
+ User? user;
+ if (responseData['user'] != null) {
+ user = User.fromJson(responseData['user']);
+ await _saveUser(user);
+ }
+
+ return {
+ 'success': true,
+ 'tokens': tokens,
+ 'user': user,
+ };
+ } else if (response.statusCode == 401 && responseData['mfa_required'] == true) {
+ return {
+ 'success': false,
+ 'mfa_required': true,
+ 'error': responseData['error'],
+ };
+ } else {
+ return {
+ 'success': false,
+ 'error': responseData['error'] ?? responseData['errors']?.join(', ') ?? 'Login failed',
+ };
+ }
+ } on SocketException catch (e, stackTrace) {
+ debugPrint('Login SocketException: $e\n$stackTrace');
+ return {
+ 'success': false,
+ 'error': 'Network unavailable',
+ };
+ } on TimeoutException catch (e, stackTrace) {
+ debugPrint('Login TimeoutException: $e\n$stackTrace');
+ return {
+ 'success': false,
+ 'error': 'Request timed out',
+ };
+ } on HttpException catch (e, stackTrace) {
+ debugPrint('Login HttpException: $e\n$stackTrace');
+ return {
+ 'success': false,
+ 'error': 'Invalid response from server',
+ };
+ } on FormatException catch (e, stackTrace) {
+ debugPrint('Login FormatException: $e\n$stackTrace');
+ return {
+ 'success': false,
+ 'error': 'Invalid response from server',
+ };
+ } on TypeError catch (e, stackTrace) {
+ debugPrint('Login TypeError: $e\n$stackTrace');
+ return {
+ 'success': false,
+ 'error': 'Invalid response from server',
+ };
+ } catch (e, stackTrace) {
+ debugPrint('Login unexpected error: $e\n$stackTrace');
+ return {
+ 'success': false,
+ 'error': 'An unexpected error occurred',
+ };
+ }
+ }
+
+ Future> signup({
+ required String email,
+ required String password,
+ required String firstName,
+ required String lastName,
+ required Map deviceInfo,
+ String? inviteCode,
+ }) async {
+ try {
+ final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/auth/signup');
+
+ final Map body = {
+ 'user': {
+ 'email': email,
+ 'password': password,
+ 'first_name': firstName,
+ 'last_name': lastName,
+ },
+ 'device': deviceInfo,
+ };
+
+ if (inviteCode != null) {
+ body['invite_code'] = inviteCode;
+ }
+
+ final response = await http.post(
+ url,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/json',
+ },
+ body: jsonEncode(body),
+ ).timeout(const Duration(seconds: 30));
+
+ final responseData = jsonDecode(response.body);
+
+ if (response.statusCode == 201) {
+ // Store tokens
+ final tokens = AuthTokens.fromJson(responseData);
+ await _saveTokens(tokens);
+
+ // Store user data - parse once and reuse
+ User? user;
+ if (responseData['user'] != null) {
+ user = User.fromJson(responseData['user']);
+ await _saveUser(user);
+ }
+
+ return {
+ 'success': true,
+ 'tokens': tokens,
+ 'user': user,
+ };
+ } else {
+ return {
+ 'success': false,
+ 'error': responseData['error'] ?? responseData['errors']?.join(', ') ?? 'Signup failed',
+ };
+ }
+ } on SocketException catch (e, stackTrace) {
+ debugPrint('Signup SocketException: $e\n$stackTrace');
+ return {
+ 'success': false,
+ 'error': 'Network unavailable',
+ };
+ } on TimeoutException catch (e, stackTrace) {
+ debugPrint('Signup TimeoutException: $e\n$stackTrace');
+ return {
+ 'success': false,
+ 'error': 'Request timed out',
+ };
+ } on HttpException catch (e, stackTrace) {
+ debugPrint('Signup HttpException: $e\n$stackTrace');
+ return {
+ 'success': false,
+ 'error': 'Invalid response from server',
+ };
+ } on FormatException catch (e, stackTrace) {
+ debugPrint('Signup FormatException: $e\n$stackTrace');
+ return {
+ 'success': false,
+ 'error': 'Invalid response from server',
+ };
+ } on TypeError catch (e, stackTrace) {
+ debugPrint('Signup TypeError: $e\n$stackTrace');
+ return {
+ 'success': false,
+ 'error': 'Invalid response from server',
+ };
+ } catch (e, stackTrace) {
+ debugPrint('Signup unexpected error: $e\n$stackTrace');
+ return {
+ 'success': false,
+ 'error': 'An unexpected error occurred',
+ };
+ }
+ }
+
+ Future> refreshToken({
+ required String refreshToken,
+ required Map deviceInfo,
+ }) async {
+ try {
+ final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/auth/refresh');
+
+ final response = await http.post(
+ url,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/json',
+ },
+ body: jsonEncode({
+ 'refresh_token': refreshToken,
+ 'device': deviceInfo,
+ }),
+ ).timeout(const Duration(seconds: 30));
+
+ final responseData = jsonDecode(response.body);
+
+ if (response.statusCode == 200) {
+ final tokens = AuthTokens.fromJson(responseData);
+ await _saveTokens(tokens);
+
+ return {
+ 'success': true,
+ 'tokens': tokens,
+ };
+ } else {
+ return {
+ 'success': false,
+ 'error': responseData['error'] ?? 'Token refresh failed',
+ };
+ }
+ } on SocketException catch (e, stackTrace) {
+ debugPrint('RefreshToken SocketException: $e\n$stackTrace');
+ return {
+ 'success': false,
+ 'error': 'Network unavailable',
+ };
+ } on TimeoutException catch (e, stackTrace) {
+ debugPrint('RefreshToken TimeoutException: $e\n$stackTrace');
+ return {
+ 'success': false,
+ 'error': 'Request timed out',
+ };
+ } on HttpException catch (e, stackTrace) {
+ debugPrint('RefreshToken HttpException: $e\n$stackTrace');
+ return {
+ 'success': false,
+ 'error': 'Invalid response from server',
+ };
+ } on FormatException catch (e, stackTrace) {
+ debugPrint('RefreshToken FormatException: $e\n$stackTrace');
+ return {
+ 'success': false,
+ 'error': 'Invalid response from server',
+ };
+ } on TypeError catch (e, stackTrace) {
+ debugPrint('RefreshToken TypeError: $e\n$stackTrace');
+ return {
+ 'success': false,
+ 'error': 'Invalid response from server',
+ };
+ } catch (e, stackTrace) {
+ debugPrint('RefreshToken unexpected error: $e\n$stackTrace');
+ return {
+ 'success': false,
+ 'error': 'An unexpected error occurred',
+ };
+ }
+ }
+
+ Future logout() async {
+ await _storage.delete(key: _tokenKey);
+ await _storage.delete(key: _userKey);
+ }
+
+ Future getStoredTokens() async {
+ final tokensJson = await _storage.read(key: _tokenKey);
+ if (tokensJson == null) return null;
+
+ try {
+ return AuthTokens.fromJson(jsonDecode(tokensJson));
+ } catch (e) {
+ return null;
+ }
+ }
+
+ Future getStoredUser() async {
+ final userJson = await _storage.read(key: _userKey);
+ if (userJson == null) return null;
+
+ try {
+ return User.fromJson(jsonDecode(userJson));
+ } catch (e) {
+ return null;
+ }
+ }
+
+ Future _saveTokens(AuthTokens tokens) async {
+ await _storage.write(
+ key: _tokenKey,
+ value: jsonEncode(tokens.toJson()),
+ );
+ }
+
+ Future _saveUser(User user) async {
+ await _storage.write(
+ key: _userKey,
+ value: jsonEncode({
+ 'id': user.id,
+ 'email': user.email,
+ 'first_name': user.firstName,
+ 'last_name': user.lastName,
+ }),
+ );
+ }
+}
diff --git a/mobile/lib/services/device_service.dart b/mobile/lib/services/device_service.dart
new file mode 100644
index 000000000..22b9ccbef
--- /dev/null
+++ b/mobile/lib/services/device_service.dart
@@ -0,0 +1,76 @@
+import 'dart:io';
+import 'package:flutter/foundation.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+class DeviceService {
+ static const String _deviceIdKey = 'device_id';
+
+ Future> getDeviceInfo() async {
+ final prefs = await SharedPreferences.getInstance();
+
+ // Get or generate device ID
+ String? deviceId = prefs.getString(_deviceIdKey);
+ if (deviceId == null) {
+ deviceId = _generateDeviceId();
+ await prefs.setString(_deviceIdKey, deviceId);
+ }
+
+ return {
+ 'device_id': deviceId,
+ 'device_name': _getDeviceName(),
+ 'device_type': _getDeviceType(),
+ 'os_version': _getOsVersion(),
+ 'app_version': '1.0.0',
+ };
+ }
+
+ String _generateDeviceId() {
+ // Generate a unique device ID
+ final timestamp = DateTime.now().millisecondsSinceEpoch;
+ final random = timestamp.toString().hashCode.abs();
+ return 'sure_mobile_${timestamp}_$random';
+ }
+
+ String _getDeviceName() {
+ if (kIsWeb) {
+ return 'Web Browser';
+ }
+ try {
+ if (Platform.isAndroid) {
+ return 'Android Device';
+ } else if (Platform.isIOS) {
+ return 'iOS Device';
+ }
+ } catch (e) {
+ // Platform not available
+ }
+ return 'Mobile Device';
+ }
+
+ String _getDeviceType() {
+ if (kIsWeb) {
+ return 'web';
+ }
+ try {
+ if (Platform.isAndroid) {
+ return 'android';
+ } else if (Platform.isIOS) {
+ return 'ios';
+ }
+ } catch (e) {
+ // Platform not available
+ }
+ return 'unknown';
+ }
+
+ String _getOsVersion() {
+ if (kIsWeb) {
+ return 'web';
+ }
+ try {
+ return Platform.operatingSystemVersion;
+ } catch (e) {
+ return 'unknown';
+ }
+ }
+}
diff --git a/mobile/lib/services/transactions_service.dart b/mobile/lib/services/transactions_service.dart
new file mode 100644
index 000000000..93c65c6c4
--- /dev/null
+++ b/mobile/lib/services/transactions_service.dart
@@ -0,0 +1,214 @@
+import 'dart:convert';
+import 'package:http/http.dart' as http;
+import '../models/transaction.dart';
+import 'api_config.dart';
+
+class TransactionsService {
+ Future> createTransaction({
+ required String accessToken,
+ required String accountId,
+ required String name,
+ required String date,
+ required String amount,
+ required String currency,
+ required String nature,
+ String? notes,
+ }) async {
+ final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/transactions');
+
+ final body = {
+ 'transaction': {
+ 'account_id': accountId,
+ 'name': name,
+ 'date': date,
+ 'amount': amount,
+ 'currency': currency,
+ 'nature': nature,
+ if (notes != null) 'notes': notes,
+ }
+ };
+
+ try {
+ final response = await http.post(
+ url,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/json',
+ 'Authorization': 'Bearer $accessToken',
+ },
+ body: jsonEncode(body),
+ ).timeout(const Duration(seconds: 30));
+
+ if (response.statusCode == 200 || response.statusCode == 201) {
+ final responseData = jsonDecode(response.body);
+ return {
+ 'success': true,
+ 'transaction': Transaction.fromJson(responseData),
+ };
+ } else if (response.statusCode == 401) {
+ return {
+ 'success': false,
+ 'error': 'unauthorized',
+ };
+ } else {
+ try {
+ final responseData = jsonDecode(response.body);
+ return {
+ 'success': false,
+ 'error': responseData['error'] ?? 'Failed to create transaction',
+ };
+ } catch (e) {
+ return {
+ 'success': false,
+ 'error': 'Failed to create transaction: ${response.body}',
+ };
+ }
+ }
+ } catch (e) {
+ return {
+ 'success': false,
+ 'error': 'Network error: ${e.toString()}',
+ };
+ }
+ }
+
+ Future> getTransactions({
+ required String accessToken,
+ String? accountId,
+ }) async {
+ final baseUri = Uri.parse('${ApiConfig.baseUrl}/api/v1/transactions');
+ final url = accountId != null
+ ? baseUri.replace(queryParameters: {'account_id': accountId})
+ : baseUri;
+
+ try {
+ final response = await http.get(
+ url,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/json',
+ 'Authorization': 'Bearer $accessToken',
+ },
+ ).timeout(const Duration(seconds: 30));
+
+ if (response.statusCode == 200) {
+ final responseData = jsonDecode(response.body);
+
+ // Handle both array and object responses
+ List transactionsJson;
+ if (responseData is List) {
+ transactionsJson = responseData;
+ } else if (responseData is Map && responseData.containsKey('transactions')) {
+ transactionsJson = responseData['transactions'];
+ } else {
+ transactionsJson = [];
+ }
+
+ final transactions = transactionsJson
+ .map((json) => Transaction.fromJson(json))
+ .toList();
+
+ return {
+ 'success': true,
+ 'transactions': transactions,
+ };
+ } else if (response.statusCode == 401) {
+ return {
+ 'success': false,
+ 'error': 'unauthorized',
+ };
+ } else {
+ return {
+ 'success': false,
+ 'error': 'Failed to fetch transactions',
+ };
+ }
+ } catch (e) {
+ return {
+ 'success': false,
+ 'error': 'Network error: ${e.toString()}',
+ };
+ }
+ }
+
+ Future> deleteTransaction({
+ required String accessToken,
+ required String transactionId,
+ }) async {
+ final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/transactions/$transactionId');
+
+ try {
+ final response = await http.delete(
+ url,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/json',
+ 'Authorization': 'Bearer $accessToken',
+ },
+ ).timeout(const Duration(seconds: 30));
+
+ if (response.statusCode == 200 || response.statusCode == 204) {
+ return {
+ 'success': true,
+ };
+ } else if (response.statusCode == 401) {
+ return {
+ 'success': false,
+ 'error': 'unauthorized',
+ };
+ } else {
+ try {
+ final responseData = jsonDecode(response.body);
+ return {
+ 'success': false,
+ 'error': responseData['error'] ?? 'Failed to delete transaction',
+ };
+ } catch (e) {
+ return {
+ 'success': false,
+ 'error': 'Failed to delete transaction: ${response.body}',
+ };
+ }
+ }
+ } catch (e) {
+ return {
+ 'success': false,
+ 'error': 'Network error: ${e.toString()}',
+ };
+ }
+ }
+
+ Future> deleteMultipleTransactions({
+ required String accessToken,
+ required List transactionIds,
+ }) async {
+ try {
+ final results = await Future.wait(
+ transactionIds.map((id) => deleteTransaction(
+ accessToken: accessToken,
+ transactionId: id,
+ )),
+ );
+
+ final allSuccess = results.every((result) => result['success'] == true);
+
+ if (allSuccess) {
+ return {
+ 'success': true,
+ 'deleted_count': transactionIds.length,
+ };
+ } else {
+ final failedCount = results.where((r) => r['success'] != true).length;
+ return {
+ 'success': false,
+ 'error': 'Failed to delete $failedCount transactions',
+ };
+ }
+ } catch (e) {
+ return {
+ 'success': false,
+ 'error': 'Network error: ${e.toString()}',
+ };
+ }
+ }
+}
diff --git a/mobile/lib/widgets/account_card.dart b/mobile/lib/widgets/account_card.dart
new file mode 100644
index 000000000..f3cbd8636
--- /dev/null
+++ b/mobile/lib/widgets/account_card.dart
@@ -0,0 +1,168 @@
+import 'package:flutter/material.dart';
+import '../models/account.dart';
+
+class AccountCard extends StatelessWidget {
+ final Account account;
+ final VoidCallback? onTap;
+ final VoidCallback? onSwipe;
+
+ const AccountCard({
+ super.key,
+ required this.account,
+ this.onTap,
+ this.onSwipe,
+ });
+
+ IconData _getAccountIcon() {
+ switch (account.accountType) {
+ case 'depository':
+ return Icons.account_balance;
+ case 'credit_card':
+ return Icons.credit_card;
+ case 'investment':
+ return Icons.trending_up;
+ case 'loan':
+ return Icons.receipt_long;
+ case 'property':
+ return Icons.home;
+ case 'vehicle':
+ return Icons.directions_car;
+ case 'crypto':
+ return Icons.currency_bitcoin;
+ case 'other_asset':
+ return Icons.category;
+ case 'other_liability':
+ return Icons.payment;
+ default:
+ return Icons.account_balance_wallet;
+ }
+ }
+
+ Color _getAccountColor(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+
+ if (account.isAsset) {
+ return Colors.green;
+ } else if (account.isLiability) {
+ return Colors.red;
+ }
+ return colorScheme.primary;
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ final accountColor = _getAccountColor(context);
+
+ final cardContent = Card(
+ margin: const EdgeInsets.only(bottom: 12),
+ child: InkWell(
+ onTap: onTap,
+ borderRadius: BorderRadius.circular(12),
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Row(
+ children: [
+ // Account icon
+ Container(
+ width: 48,
+ height: 48,
+ decoration: BoxDecoration(
+ color: accountColor.withValues(alpha: 0.1),
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Icon(
+ _getAccountIcon(),
+ color: accountColor,
+ size: 24,
+ ),
+ ),
+ const SizedBox(width: 16),
+
+ // Account info
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ account.name,
+ style: Theme.of(context).textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ const SizedBox(height: 4),
+ Text(
+ account.displayAccountType,
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ),
+ ),
+
+ // Balance
+ Column(
+ crossAxisAlignment: CrossAxisAlignment.end,
+ children: [
+ Text(
+ account.balance,
+ style: Theme.of(context).textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.bold,
+ color: account.isLiability ? Colors.red : null,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ account.currency,
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+
+ // If onSwipe is provided, wrap with Dismissible
+ if (onSwipe != null) {
+ return Dismissible(
+ key: Key('account_${account.id}'),
+ direction: DismissDirection.endToStart,
+ confirmDismiss: (direction) async {
+ // Don't actually dismiss, just trigger the swipe action
+ onSwipe?.call();
+ return false; // Don't remove the item
+ },
+ background: Container(
+ margin: const EdgeInsets.only(bottom: 12),
+ alignment: Alignment.centerRight,
+ padding: const EdgeInsets.only(right: 20),
+ decoration: BoxDecoration(
+ color: Colors.blue,
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: const Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(Icons.receipt_long, color: Colors.white, size: 28),
+ SizedBox(height: 4),
+ Text(
+ 'Transactions',
+ style: TextStyle(color: Colors.white, fontSize: 12),
+ ),
+ ],
+ ),
+ ),
+ child: cardContent,
+ );
+ }
+
+ return cardContent;
+ }
+}
diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock
new file mode 100644
index 000000000..27fe57ab6
--- /dev/null
+++ b/mobile/pubspec.lock
@@ -0,0 +1,578 @@
+# Generated by pub
+# See https://dart.dev/tools/pub/glossary#lockfile
+packages:
+ archive:
+ dependency: transitive
+ description:
+ name: archive
+ sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.0.7"
+ args:
+ dependency: transitive
+ description:
+ name: args
+ sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.7.0"
+ async:
+ dependency: transitive
+ description:
+ name: async
+ sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.13.0"
+ boolean_selector:
+ dependency: transitive
+ description:
+ name: boolean_selector
+ sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.2"
+ characters:
+ dependency: transitive
+ description:
+ name: characters
+ sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.4.0"
+ checked_yaml:
+ dependency: transitive
+ description:
+ name: checked_yaml
+ sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.4"
+ cli_util:
+ dependency: transitive
+ description:
+ name: cli_util
+ sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.4.2"
+ clock:
+ dependency: transitive
+ description:
+ name: clock
+ sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.2"
+ collection:
+ dependency: transitive
+ description:
+ name: collection
+ sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.19.1"
+ crypto:
+ dependency: transitive
+ description:
+ name: crypto
+ sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.0.7"
+ cupertino_icons:
+ dependency: "direct main"
+ description:
+ name: cupertino_icons
+ sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.8"
+ fake_async:
+ dependency: transitive
+ description:
+ name: fake_async
+ sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.3.3"
+ ffi:
+ dependency: transitive
+ description:
+ name: ffi
+ sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.4"
+ file:
+ dependency: transitive
+ description:
+ name: file
+ sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
+ url: "https://pub.dev"
+ source: hosted
+ version: "7.0.1"
+ flutter:
+ dependency: "direct main"
+ description: flutter
+ source: sdk
+ version: "0.0.0"
+ flutter_launcher_icons:
+ dependency: "direct dev"
+ description:
+ name: flutter_launcher_icons
+ sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.13.1"
+ flutter_lints:
+ dependency: "direct dev"
+ description:
+ name: flutter_lints
+ sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.0.2"
+ flutter_secure_storage:
+ dependency: "direct main"
+ description:
+ name: flutter_secure_storage
+ sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
+ url: "https://pub.dev"
+ source: hosted
+ version: "9.2.4"
+ flutter_secure_storage_linux:
+ dependency: transitive
+ description:
+ name: flutter_secure_storage_linux
+ sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.2.3"
+ flutter_secure_storage_macos:
+ dependency: transitive
+ description:
+ name: flutter_secure_storage_macos
+ sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.1.3"
+ flutter_secure_storage_platform_interface:
+ dependency: transitive
+ description:
+ name: flutter_secure_storage_platform_interface
+ sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.2"
+ flutter_secure_storage_web:
+ dependency: transitive
+ description:
+ name: flutter_secure_storage_web
+ sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.2.1"
+ flutter_secure_storage_windows:
+ dependency: transitive
+ description:
+ name: flutter_secure_storage_windows
+ sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.1.2"
+ flutter_test:
+ dependency: "direct dev"
+ description: flutter
+ source: sdk
+ version: "0.0.0"
+ flutter_web_plugins:
+ dependency: transitive
+ description: flutter
+ source: sdk
+ version: "0.0.0"
+ http:
+ dependency: "direct main"
+ description:
+ name: http
+ sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.6.0"
+ http_parser:
+ dependency: transitive
+ description:
+ name: http_parser
+ sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.1.2"
+ image:
+ dependency: transitive
+ description:
+ name: image
+ sha256: "51555e36056541237b15b57afc31a0f53d4f9aefd9bd00873a6dc0090e54e332"
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.6.0"
+ intl:
+ dependency: "direct main"
+ description:
+ name: intl
+ sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.18.1"
+ js:
+ dependency: transitive
+ description:
+ name: js
+ sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.6.7"
+ json_annotation:
+ dependency: transitive
+ description:
+ name: json_annotation
+ sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.9.0"
+ leak_tracker:
+ dependency: transitive
+ description:
+ name: leak_tracker
+ sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
+ url: "https://pub.dev"
+ source: hosted
+ version: "10.0.9"
+ leak_tracker_flutter_testing:
+ dependency: transitive
+ description:
+ name: leak_tracker_flutter_testing
+ sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.0.9"
+ leak_tracker_testing:
+ dependency: transitive
+ description:
+ name: leak_tracker_testing
+ sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.0.1"
+ lints:
+ dependency: transitive
+ description:
+ name: lints
+ sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.0.0"
+ matcher:
+ dependency: transitive
+ description:
+ name: matcher
+ sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.12.17"
+ material_color_utilities:
+ dependency: transitive
+ description:
+ name: material_color_utilities
+ sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.11.1"
+ meta:
+ dependency: transitive
+ description:
+ name: meta
+ sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.16.0"
+ nested:
+ dependency: transitive
+ description:
+ name: nested
+ sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.0"
+ path:
+ dependency: transitive
+ description:
+ name: path
+ sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.9.1"
+ path_provider:
+ dependency: transitive
+ description:
+ name: path_provider
+ sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.5"
+ path_provider_android:
+ dependency: transitive
+ description:
+ name: path_provider_android
+ sha256: "3b4c1fc3aa55ddc9cd4aa6759984330d5c8e66aa7702a6223c61540dc6380c37"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.2.19"
+ path_provider_foundation:
+ dependency: transitive
+ description:
+ name: path_provider_foundation
+ sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.2"
+ path_provider_linux:
+ dependency: transitive
+ description:
+ name: path_provider_linux
+ sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.2.1"
+ path_provider_platform_interface:
+ dependency: transitive
+ description:
+ name: path_provider_platform_interface
+ sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.2"
+ path_provider_windows:
+ dependency: transitive
+ description:
+ name: path_provider_windows
+ sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.3.0"
+ petitparser:
+ dependency: transitive
+ description:
+ name: petitparser
+ sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "7.0.1"
+ platform:
+ dependency: transitive
+ description:
+ name: platform
+ sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.1.6"
+ plugin_platform_interface:
+ dependency: transitive
+ description:
+ name: plugin_platform_interface
+ sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.8"
+ posix:
+ dependency: transitive
+ description:
+ name: posix
+ sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.0.3"
+ provider:
+ dependency: "direct main"
+ description:
+ name: provider
+ sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.1.5+1"
+ shared_preferences:
+ dependency: "direct main"
+ description:
+ name: shared_preferences
+ sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.5.3"
+ shared_preferences_android:
+ dependency: transitive
+ description:
+ name: shared_preferences_android
+ sha256: bd14436108211b0d4ee5038689a56d4ae3620fd72fd6036e113bf1345bc74d9e
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.13"
+ shared_preferences_foundation:
+ dependency: transitive
+ description:
+ name: shared_preferences_foundation
+ sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.5.4"
+ shared_preferences_linux:
+ dependency: transitive
+ description:
+ name: shared_preferences_linux
+ sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.1"
+ shared_preferences_platform_interface:
+ dependency: transitive
+ description:
+ name: shared_preferences_platform_interface
+ sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.1"
+ shared_preferences_web:
+ dependency: transitive
+ description:
+ name: shared_preferences_web
+ sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.3"
+ shared_preferences_windows:
+ dependency: transitive
+ description:
+ name: shared_preferences_windows
+ sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.1"
+ sky_engine:
+ dependency: transitive
+ description: flutter
+ source: sdk
+ version: "0.0.0"
+ source_span:
+ dependency: transitive
+ description:
+ name: source_span
+ sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.10.1"
+ stack_trace:
+ dependency: transitive
+ description:
+ name: stack_trace
+ sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.12.1"
+ stream_channel:
+ dependency: transitive
+ description:
+ name: stream_channel
+ sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.4"
+ string_scanner:
+ dependency: transitive
+ description:
+ name: string_scanner
+ sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.4.1"
+ term_glyph:
+ dependency: transitive
+ description:
+ name: term_glyph
+ sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.2.2"
+ test_api:
+ dependency: transitive
+ description:
+ name: test_api
+ sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.7.4"
+ typed_data:
+ dependency: transitive
+ description:
+ name: typed_data
+ sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.4.0"
+ vector_math:
+ dependency: transitive
+ description:
+ name: vector_math
+ sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.4"
+ vm_service:
+ dependency: transitive
+ description:
+ name: vm_service
+ sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
+ url: "https://pub.dev"
+ source: hosted
+ version: "15.0.0"
+ web:
+ dependency: transitive
+ description:
+ name: web
+ sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.1"
+ win32:
+ dependency: transitive
+ description:
+ name: win32
+ sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
+ url: "https://pub.dev"
+ source: hosted
+ version: "5.15.0"
+ xdg_directories:
+ dependency: transitive
+ description:
+ name: xdg_directories
+ sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.0"
+ xml:
+ dependency: transitive
+ description:
+ name: xml
+ sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.6.1"
+ yaml:
+ dependency: transitive
+ description:
+ name: yaml
+ sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.1.3"
+sdks:
+ dart: ">=3.8.0 <4.0.0"
+ flutter: ">=3.29.0"
diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml
new file mode 100644
index 000000000..fe34c87a1
--- /dev/null
+++ b/mobile/pubspec.yaml
@@ -0,0 +1,37 @@
+name: sure_mobile
+description: A mobile app for Sure personal finance management
+publish_to: 'none'
+version: 1.0.0+1
+
+environment:
+ sdk: '>=3.0.0 <4.0.0'
+ flutter: '>=3.27.0'
+
+dependencies:
+ flutter:
+ sdk: flutter
+ cupertino_icons: ^1.0.6
+ http: ^1.1.0
+ provider: ^6.1.1
+ shared_preferences: ^2.2.2
+ flutter_secure_storage: ^10.0.0
+ intl: ^0.18.1
+
+dev_dependencies:
+ flutter_test:
+ sdk: flutter
+ flutter_lints: ^6.0.0
+ flutter_launcher_icons: ^0.14.4
+
+flutter:
+ uses-material-design: true
+ assets:
+ - assets/icon/
+
+flutter_launcher_icons:
+ android: true
+ ios: true
+ image_path: "assets/icon/app_icon.png"
+ adaptive_icon_background: "#000000"
+ adaptive_icon_foreground: "assets/icon/app_icon.png"
+ remove_alpha_ios: true
diff --git a/mobile/test/widget_test.dart b/mobile/test/widget_test.dart
new file mode 100644
index 000000000..e207300da
--- /dev/null
+++ b/mobile/test/widget_test.dart
@@ -0,0 +1,9 @@
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ testWidgets('Smoke test - app can be instantiated', (WidgetTester tester) async {
+ // This is a placeholder smoke test to satisfy flutter test requirements.
+ // Add more comprehensive tests as the app grows.
+ expect(true, isTrue);
+ });
+}
diff --git a/test/controllers/api/v1/imports_controller_test.rb b/test/controllers/api/v1/imports_controller_test.rb
new file mode 100644
index 000000000..ec01ca7ba
--- /dev/null
+++ b/test/controllers/api/v1/imports_controller_test.rb
@@ -0,0 +1,206 @@
+require "test_helper"
+
+class Api::V1::ImportsControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @family = families(:dylan_family)
+ @user = users(:family_admin)
+ @account = accounts(:depository)
+ @import = imports(:transaction)
+ @token = valid_token_for(@user)
+ end
+
+ test "should list imports" do
+ get api_v1_imports_url, headers: { Authorization: "Bearer #{@token}" }
+ assert_response :success
+
+ json_response = JSON.parse(response.body)
+ assert_not_empty json_response["data"]
+ assert_equal @family.imports.count, json_response["meta"]["total_count"]
+ end
+
+ test "should show import" do
+ get api_v1_import_url(@import), headers: { Authorization: "Bearer #{@token}" }
+ assert_response :success
+
+ json_response = JSON.parse(response.body)
+ assert_equal @import.id, json_response["data"]["id"]
+ assert_equal @import.status, json_response["data"]["status"]
+ end
+
+ test "should create import with raw content" do
+ csv_content = "date,amount,name\n2023-01-01,-10.00,Test Transaction"
+
+ assert_difference("Import.count") do
+ post api_v1_imports_url,
+ params: {
+ raw_file_content: csv_content,
+ date_col_label: "date",
+ amount_col_label: "amount",
+ name_col_label: "name",
+ account_id: @account.id
+ },
+ headers: { Authorization: "Bearer #{@token}" }
+ end
+
+ assert_response :created
+ json_response = JSON.parse(response.body)
+ assert_equal "pending", json_response["data"]["status"]
+
+ created_import = Import.find(json_response["data"]["id"])
+ assert_equal csv_content, created_import.raw_file_str
+ end
+
+ test "should create import and generate rows when configured" do
+ csv_content = "date,amount,name\n2023-01-01,-10.00,Test Transaction"
+
+ assert_difference([ "Import.count", "Import::Row.count" ], 1) do
+ post api_v1_imports_url,
+ params: {
+ raw_file_content: csv_content,
+ date_col_label: "date",
+ amount_col_label: "amount",
+ name_col_label: "name",
+ account_id: @account.id
+ },
+ headers: { Authorization: "Bearer #{@token}" }
+ end
+
+ assert_response :created
+ json_response = JSON.parse(response.body)
+
+ import = Import.find(json_response["data"]["id"])
+ assert_equal 1, import.rows_count
+ assert_equal "Test Transaction", import.rows.first.name
+ assert_equal "-10.00", import.rows.first.amount # Normalized
+ end
+
+ test "should create import and auto-publish when configured and requested" do
+ csv_content = "date,amount,name\n2023-01-01,-10.00,Test Transaction"
+
+ assert_enqueued_with(job: ImportJob) do
+ post api_v1_imports_url,
+ params: {
+ raw_file_content: csv_content,
+ date_col_label: "date",
+ amount_col_label: "amount",
+ name_col_label: "name",
+ account_id: @account.id,
+ date_format: "%Y-%m-%d",
+ publish: "true"
+ },
+ headers: { Authorization: "Bearer #{@token}" }
+ end
+
+ assert_response :created
+ json_response = JSON.parse(response.body)
+ assert_equal "importing", json_response["data"]["status"]
+ end
+
+ test "should not create import for account in another family" do
+ other_family = Family.create!(name: "Other Family", currency: "USD", locale: "en")
+ other_depository = Depository.create!(subtype: "checking")
+ other_account = Account.create!(family: other_family, name: "Other Account", currency: "USD", classification: "asset", accountable: other_depository, balance: 0)
+
+ csv_content = "date,amount,name\n2023-01-01,-10.00,Test Transaction"
+
+ post api_v1_imports_url,
+ params: {
+ raw_file_content: csv_content,
+ account_id: other_account.id
+ },
+ headers: { Authorization: "Bearer #{@token}" }
+
+ assert_response :unprocessable_entity
+ json_response = JSON.parse(response.body)
+ assert_includes json_response["errors"], "Account must belong to your family"
+ end
+
+ test "should reject file upload exceeding max size" do
+ large_file = Rack::Test::UploadedFile.new(
+ StringIO.new("x" * (Import::MAX_CSV_SIZE + 1)),
+ "text/csv",
+ original_filename: "large.csv"
+ )
+
+ assert_no_difference("Import.count") do
+ post api_v1_imports_url,
+ params: { file: large_file },
+ headers: { Authorization: "Bearer #{@token}" }
+ end
+
+ assert_response :unprocessable_entity
+ json_response = JSON.parse(response.body)
+ assert_equal "file_too_large", json_response["error"]
+ end
+
+ test "should reject file upload with invalid mime type" do
+ invalid_file = Rack::Test::UploadedFile.new(
+ StringIO.new("not a csv"),
+ "application/pdf",
+ original_filename: "document.pdf"
+ )
+
+ assert_no_difference("Import.count") do
+ post api_v1_imports_url,
+ params: { file: invalid_file },
+ headers: { Authorization: "Bearer #{@token}" }
+ end
+
+ assert_response :unprocessable_entity
+ json_response = JSON.parse(response.body)
+ assert_equal "invalid_file_type", json_response["error"]
+ end
+
+ test "should reject raw content exceeding max size" do
+ # Use a small test limit to avoid Rack request size limits
+ test_limit = 1.kilobyte
+ large_content = "x" * (test_limit + 1)
+
+ original_value = Import::MAX_CSV_SIZE
+ Import.send(:remove_const, :MAX_CSV_SIZE)
+ Import.const_set(:MAX_CSV_SIZE, test_limit)
+
+ assert_no_difference("Import.count") do
+ post api_v1_imports_url,
+ params: { raw_file_content: large_content },
+ headers: { Authorization: "Bearer #{@token}" }
+ end
+
+ assert_response :unprocessable_entity
+ json_response = JSON.parse(response.body)
+ assert_equal "content_too_large", json_response["error"]
+ ensure
+ Import.send(:remove_const, :MAX_CSV_SIZE)
+ Import.const_set(:MAX_CSV_SIZE, original_value)
+ end
+
+ test "should accept file upload with valid csv mime type" do
+ csv_content = "date,amount,name\n2023-01-01,-10.00,Test Transaction"
+ valid_file = Rack::Test::UploadedFile.new(
+ StringIO.new(csv_content),
+ "text/csv",
+ original_filename: "transactions.csv"
+ )
+
+ assert_difference("Import.count") do
+ post api_v1_imports_url,
+ params: {
+ file: valid_file,
+ date_col_label: "date",
+ amount_col_label: "amount",
+ name_col_label: "name",
+ account_id: @account.id
+ },
+ headers: { Authorization: "Bearer #{@token}" }
+ end
+
+ assert_response :created
+ end
+
+ private
+
+ def valid_token_for(user)
+ application = Doorkeeper::Application.create!(name: "Test App", redirect_uri: "urn:ietf:wg:oauth:2.0:oob", scopes: "read read_write")
+ Doorkeeper::AccessToken.create!(application: application, resource_owner_id: user.id, scopes: "read read_write").token
+ end
+end
diff --git a/test/controllers/categories_controller_test.rb b/test/controllers/categories_controller_test.rb
index 5518ea731..2f7c8ff5e 100644
--- a/test/controllers/categories_controller_test.rb
+++ b/test/controllers/categories_controller_test.rb
@@ -84,7 +84,8 @@ class CategoriesControllerTest < ActionDispatch::IntegrationTest
end
test "bootstrap" do
- assert_difference "Category.count", 19 do
+ # 22 default categories minus 2 that already exist in fixtures (Income, Food & Drink)
+ assert_difference "Category.count", 20 do
post bootstrap_categories_url
end
diff --git a/test/controllers/coinstats_items_controller_test.rb b/test/controllers/coinstats_items_controller_test.rb
new file mode 100644
index 000000000..9b29ec7a9
--- /dev/null
+++ b/test/controllers/coinstats_items_controller_test.rb
@@ -0,0 +1,178 @@
+require "test_helper"
+
+class CoinstatsItemsControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ sign_in users(:family_admin)
+ @family = families(:dylan_family)
+ @coinstats_item = CoinstatsItem.create!(
+ family: @family,
+ name: "Test CoinStats Connection",
+ api_key: "test_api_key_123"
+ )
+ end
+
+ # Helper to wrap data in Provider::Response
+ def success_response(data)
+ Provider::Response.new(success?: true, data: data, error: nil)
+ end
+
+ def error_response(message)
+ Provider::Response.new(success?: false, data: nil, error: Provider::Error.new(message))
+ end
+
+ test "should get new" do
+ get new_coinstats_item_url
+ assert_response :success
+ end
+
+ test "should create coinstats item with valid api key" do
+ # Mock the API key validation
+ Provider::Coinstats.any_instance.expects(:get_blockchains).returns(success_response([])).once
+
+ assert_difference("CoinstatsItem.count", 1) do
+ post coinstats_items_url, params: {
+ coinstats_item: {
+ name: "New CoinStats Connection",
+ api_key: "valid_api_key"
+ }
+ }
+ end
+ end
+
+ test "should not create coinstats item with invalid api key" do
+ # Mock the API key validation to fail
+ Provider::Coinstats.any_instance.expects(:get_blockchains)
+ .returns(error_response("Invalid API key"))
+
+ assert_no_difference("CoinstatsItem.count") do
+ post coinstats_items_url, params: {
+ coinstats_item: {
+ name: "New CoinStats Connection",
+ api_key: "invalid_api_key"
+ }
+ }
+ end
+ end
+
+ test "should destroy coinstats item" do
+ # Schedules for deletion, doesn't actually delete immediately
+ assert_no_difference("CoinstatsItem.count") do
+ delete coinstats_item_url(@coinstats_item)
+ end
+
+ assert_redirected_to settings_providers_path
+ @coinstats_item.reload
+ assert @coinstats_item.scheduled_for_deletion?
+ end
+
+ test "should sync coinstats item" do
+ post sync_coinstats_item_url(@coinstats_item)
+ assert_redirected_to accounts_path
+ end
+
+ test "sync responds to json format" do
+ post sync_coinstats_item_url(@coinstats_item, format: :json)
+ assert_response :ok
+ end
+
+ test "should update coinstats item with valid api key" do
+ Provider::Coinstats.any_instance.expects(:get_blockchains).returns(success_response([])).once
+
+ patch coinstats_item_url(@coinstats_item), params: {
+ coinstats_item: {
+ name: "Updated Name",
+ api_key: "new_valid_api_key"
+ }
+ }
+
+ @coinstats_item.reload
+ assert_equal "Updated Name", @coinstats_item.name
+ end
+
+ test "should not update coinstats item with invalid api key" do
+ Provider::Coinstats.any_instance.expects(:get_blockchains)
+ .returns(error_response("Invalid API key"))
+
+ original_name = @coinstats_item.name
+
+ patch coinstats_item_url(@coinstats_item), params: {
+ coinstats_item: {
+ name: "Updated Name",
+ api_key: "invalid_api_key"
+ }
+ }
+
+ @coinstats_item.reload
+ assert_equal original_name, @coinstats_item.name
+ end
+
+ test "link_wallet requires all parameters" do
+ post link_wallet_coinstats_items_url, params: {
+ coinstats_item_id: @coinstats_item.id,
+ address: "0x123"
+ # missing blockchain
+ }
+
+ assert_response :unprocessable_entity
+ end
+
+ test "link_wallet with valid params creates accounts" do
+ balance_data = [
+ { coinId: "ethereum", name: "Ethereum", symbol: "ETH", amount: 1.5, price: 2000 }
+ ]
+
+ bulk_response = [
+ { blockchain: "ethereum", address: "0x123abc", connectionId: "ethereum", balances: balance_data }
+ ]
+
+ Provider::Coinstats.any_instance.expects(:get_wallet_balances)
+ .with("ethereum:0x123abc")
+ .returns(success_response(bulk_response))
+
+ Provider::Coinstats.any_instance.expects(:extract_wallet_balance)
+ .with(bulk_response, "0x123abc", "ethereum")
+ .returns(balance_data)
+
+ assert_difference("Account.count", 1) do
+ assert_difference("CoinstatsAccount.count", 1) do
+ post link_wallet_coinstats_items_url, params: {
+ coinstats_item_id: @coinstats_item.id,
+ address: "0x123abc",
+ blockchain: "ethereum"
+ }
+ end
+ end
+
+ assert_redirected_to accounts_path
+ end
+
+ test "link_wallet handles provider errors" do
+ Provider::Coinstats.any_instance.expects(:get_wallet_balances)
+ .raises(Provider::Coinstats::Error.new("Invalid API key"))
+
+ post link_wallet_coinstats_items_url, params: {
+ coinstats_item_id: @coinstats_item.id,
+ address: "0x123abc",
+ blockchain: "ethereum"
+ }
+
+ assert_response :unprocessable_entity
+ end
+
+ test "link_wallet handles no tokens found" do
+ Provider::Coinstats.any_instance.expects(:get_wallet_balances)
+ .returns(success_response([]))
+
+ Provider::Coinstats.any_instance.expects(:extract_wallet_balance)
+ .returns([])
+
+ post link_wallet_coinstats_items_url, params: {
+ coinstats_item_id: @coinstats_item.id,
+ address: "0x123abc",
+ blockchain: "ethereum"
+ }
+
+ assert_response :unprocessable_entity
+ assert_match(/No tokens found/, response.body)
+ end
+end
diff --git a/test/controllers/oidc_accounts_controller_test.rb b/test/controllers/oidc_accounts_controller_test.rb
index c81b235be..9cb283960 100644
--- a/test/controllers/oidc_accounts_controller_test.rb
+++ b/test/controllers/oidc_accounts_controller_test.rb
@@ -191,4 +191,30 @@ class OidcAccountsControllerTest < ActionController::TestCase
assert_redirected_to new_session_path
assert_equal "No pending OIDC authentication found", flash[:alert]
end
+
+ # Security: JIT users should NOT have password_digest set
+ test "JIT user is created without password_digest to prevent chained auth attacks" do
+ session[:pending_oidc_auth] = new_user_auth
+
+ post :create_user
+
+ new_user = User.find_by(email: new_user_auth["email"])
+ assert_not_nil new_user, "User should be created"
+ assert_nil new_user.password_digest, "JIT user should have nil password_digest"
+ assert new_user.sso_only?, "JIT user should be SSO-only"
+ end
+
+ test "JIT user cannot authenticate with local password" do
+ session[:pending_oidc_auth] = new_user_auth
+
+ post :create_user
+
+ new_user = User.find_by(email: new_user_auth["email"])
+
+ # Attempting to authenticate should return nil (no password set)
+ assert_nil User.authenticate_by(
+ email: new_user.email,
+ password: "anypassword"
+ ), "SSO-only user should not authenticate with password"
+ end
end
diff --git a/test/controllers/password_resets_controller_test.rb b/test/controllers/password_resets_controller_test.rb
index 42417c1c9..288c8a57e 100644
--- a/test/controllers/password_resets_controller_test.rb
+++ b/test/controllers/password_resets_controller_test.rb
@@ -48,4 +48,43 @@ class PasswordResetsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to new_session_path
assert_equal "Password reset via Sure is disabled. Please reset your password through your identity provider.", flash[:alert]
end
+
+ # Security: SSO-only users should not receive password reset emails
+ test "create does not send email for SSO-only user" do
+ sso_user = users(:sso_only)
+ assert sso_user.sso_only?, "Test user should be SSO-only"
+
+ assert_no_enqueued_emails do
+ post password_reset_path, params: { email: sso_user.email }
+ end
+
+ # Should still redirect to pending to prevent email enumeration
+ assert_redirected_to new_password_reset_url(step: "pending")
+ end
+
+ test "create sends email for user with local password" do
+ assert @user.has_local_password?, "Test user should have local password"
+
+ assert_enqueued_emails 1 do
+ post password_reset_path, params: { email: @user.email }
+ end
+
+ assert_redirected_to new_password_reset_url(step: "pending")
+ end
+
+ # Security: SSO-only users cannot set password via reset
+ test "update blocks password setting for SSO-only user" do
+ sso_user = users(:sso_only)
+ token = sso_user.generate_token_for(:password_reset)
+
+ patch password_reset_path(token: token),
+ params: { user: { password: "NewSecure1!", password_confirmation: "NewSecure1!" } }
+
+ assert_redirected_to new_session_path
+ assert_equal "Your account uses SSO for authentication. Please contact your administrator to manage your credentials.", flash[:alert]
+
+ # Verify password was not set
+ sso_user.reload
+ assert_nil sso_user.password_digest, "SSO-only user should still have nil password_digest"
+ end
end
diff --git a/test/controllers/rules_controller_test.rb b/test/controllers/rules_controller_test.rb
index a948f399d..681bac37b 100644
--- a/test/controllers/rules_controller_test.rb
+++ b/test/controllers/rules_controller_test.rb
@@ -179,4 +179,17 @@ class RulesControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to rules_url
end
+
+ test "should get confirm_all" do
+ get confirm_all_rules_url
+ assert_response :success
+ end
+
+ test "apply_all enqueues job and redirects" do
+ assert_enqueued_with(job: ApplyAllRulesJob) do
+ post apply_all_rules_url
+ end
+
+ assert_redirected_to rules_url
+ end
end
diff --git a/test/controllers/simplefin_items_controller_test.rb b/test/controllers/simplefin_items_controller_test.rb
index 43a100d88..f702a0006 100644
--- a/test/controllers/simplefin_items_controller_test.rb
+++ b/test/controllers/simplefin_items_controller_test.rb
@@ -1,6 +1,7 @@
require "test_helper"
class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
+ include ActiveJob::TestHelper
fixtures :users, :families
setup do
sign_in users(:family_admin)
@@ -154,22 +155,20 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
test "should update simplefin item with valid token" do
@simplefin_item.update!(status: :requires_update)
- # Mock the SimpleFin provider to prevent real API calls
- mock_provider = mock()
- mock_provider.expects(:claim_access_url).with("valid_token").returns("https://example.com/new_access")
- mock_provider.expects(:get_accounts).returns({ accounts: [] }).at_least_once
- Provider::Simplefin.expects(:new).returns(mock_provider).at_least_once
+ token = Base64.strict_encode64("https://example.com/claim")
- # Let the real create_simplefin_item! method run - don't mock it
+ SimplefinConnectionUpdateJob.expects(:perform_later).with(
+ family_id: @family.id,
+ old_simplefin_item_id: @simplefin_item.id,
+ setup_token: token
+ ).once
patch simplefin_item_url(@simplefin_item), params: {
- simplefin_item: { setup_token: "valid_token" }
+ simplefin_item: { setup_token: token }
}
assert_redirected_to accounts_path
- assert_equal "SimpleFin connection updated.", flash[:notice]
- @simplefin_item.reload
- assert @simplefin_item.scheduled_for_deletion?
+ assert_equal "SimpleFIN connection updated.", flash[:notice]
end
test "should handle update with invalid token" do
@@ -180,13 +179,15 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
}
assert_response :unprocessable_entity
- assert_includes response.body, I18n.t("simplefin_items.update.errors.blank_token", default: "Please enter a SimpleFin setup token")
+ assert_includes response.body, I18n.t("simplefin_items.update.errors.blank_token", default: "Please enter a SimpleFIN setup token")
end
test "should transfer accounts when updating simplefin item token" do
@simplefin_item.update!(status: :requires_update)
- # Create old SimpleFin accounts linked to Maybe accounts
+ token = Base64.strict_encode64("https://example.com/claim")
+
+ # Create old SimpleFIN accounts linked to Maybe accounts
old_simplefin_account1 = @simplefin_item.simplefin_accounts.create!(
name: "Test Checking",
account_id: "sf_account_123",
@@ -202,7 +203,7 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
account_type: "depository"
)
- # Create Maybe accounts linked to the SimpleFin accounts
+ # Create Maybe accounts linked to the SimpleFIN accounts
maybe_account1 = Account.create!(
family: @family,
name: "Checking Account",
@@ -222,13 +223,13 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
simplefin_account_id: old_simplefin_account2.id
)
- # Update old SimpleFin accounts to reference the Maybe accounts
+ # Update old SimpleFIN accounts to reference the Maybe accounts
old_simplefin_account1.update!(account: maybe_account1)
old_simplefin_account2.update!(account: maybe_account2)
# Mock only the external API calls, let business logic run
mock_provider = mock()
- mock_provider.expects(:claim_access_url).with("valid_token").returns("https://example.com/new_access")
+ mock_provider.expects(:claim_access_url).with(token).returns("https://example.com/new_access")
mock_provider.expects(:get_accounts).returns({
accounts: [
{
@@ -251,41 +252,40 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
}).at_least_once
Provider::Simplefin.expects(:new).returns(mock_provider).at_least_once
- # Perform the update
- patch simplefin_item_url(@simplefin_item), params: {
- simplefin_item: { setup_token: "valid_token" }
- }
+ # Perform the update (async job), but execute enqueued jobs inline so we can
+ # assert the link transfers.
+ perform_enqueued_jobs(only: SimplefinConnectionUpdateJob) do
+ patch simplefin_item_url(@simplefin_item), params: {
+ simplefin_item: { setup_token: token }
+ }
+ end
assert_redirected_to accounts_path
- assert_equal "SimpleFin connection updated.", flash[:notice]
+ assert_equal "SimpleFIN connection updated.", flash[:notice]
- # Verify accounts were transferred to new SimpleFin accounts
+ # Verify accounts were transferred to new SimpleFIN accounts
assert Account.exists?(maybe_account1.id), "maybe_account1 should still exist"
assert Account.exists?(maybe_account2.id), "maybe_account2 should still exist"
maybe_account1.reload
maybe_account2.reload
- # Find the new SimpleFin item that was created
+ # Find the new SimpleFIN item that was created
new_simplefin_item = @family.simplefin_items.where.not(id: @simplefin_item.id).first
- assert_not_nil new_simplefin_item, "New SimpleFin item should have been created"
+ assert_not_nil new_simplefin_item, "New SimpleFIN item should have been created"
new_sf_account1 = new_simplefin_item.simplefin_accounts.find_by(account_id: "sf_account_123")
new_sf_account2 = new_simplefin_item.simplefin_accounts.find_by(account_id: "sf_account_456")
- assert_not_nil new_sf_account1, "New SimpleFin account with ID sf_account_123 should exist"
- assert_not_nil new_sf_account2, "New SimpleFin account with ID sf_account_456 should exist"
+ assert_not_nil new_sf_account1, "New SimpleFIN account with ID sf_account_123 should exist"
+ assert_not_nil new_sf_account2, "New SimpleFIN account with ID sf_account_456 should exist"
assert_equal new_sf_account1.id, maybe_account1.simplefin_account_id
assert_equal new_sf_account2.id, maybe_account2.simplefin_account_id
- # Verify old SimpleFin accounts no longer reference Maybe accounts
- old_simplefin_account1.reload
- old_simplefin_account2.reload
- assert_nil old_simplefin_account1.current_account
- assert_nil old_simplefin_account2.current_account
+ # The old item will be deleted asynchronously; until then, legacy links should be moved.
- # Verify old SimpleFin item is scheduled for deletion
+ # Verify old SimpleFIN item is scheduled for deletion
@simplefin_item.reload
assert @simplefin_item.scheduled_for_deletion?
end
@@ -293,7 +293,9 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
test "should handle partial account matching during token update" do
@simplefin_item.update!(status: :requires_update)
- # Create old SimpleFin account
+ token = Base64.strict_encode64("https://example.com/claim")
+
+ # Create old SimpleFIN account
old_simplefin_account = @simplefin_item.simplefin_accounts.create!(
name: "Test Checking",
account_id: "sf_account_123",
@@ -302,7 +304,7 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
account_type: "depository"
)
- # Create Maybe account linked to the SimpleFin account
+ # Create Maybe account linked to the SimpleFIN account
maybe_account = Account.create!(
family: @family,
name: "Checking Account",
@@ -316,21 +318,21 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
# Mock only the external API calls, let business logic run
mock_provider = mock()
- mock_provider.expects(:claim_access_url).with("valid_token").returns("https://example.com/new_access")
+ mock_provider.expects(:claim_access_url).with(token).returns("https://example.com/new_access")
# Return empty accounts list to simulate account was removed from bank
mock_provider.expects(:get_accounts).returns({ accounts: [] }).at_least_once
Provider::Simplefin.expects(:new).returns(mock_provider).at_least_once
# Perform update
- patch simplefin_item_url(@simplefin_item), params: {
- simplefin_item: { setup_token: "valid_token" }
- }
+ perform_enqueued_jobs(only: SimplefinConnectionUpdateJob) do
+ patch simplefin_item_url(@simplefin_item), params: {
+ simplefin_item: { setup_token: token }
+ }
+ end
- assert_response :redirect
- uri2 = URI(response.redirect_url)
- assert_equal "/accounts", uri2.path
+ assert_redirected_to accounts_path
- # Verify Maybe account still linked to old SimpleFin account (no transfer occurred)
+ # Verify Maybe account still linked to old SimpleFIN account (no transfer occurred)
maybe_account.reload
old_simplefin_account.reload
assert_equal old_simplefin_account.id, maybe_account.simplefin_account_id
@@ -450,30 +452,27 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
test "update redirects to accounts after setup without forcing a modal" do
@simplefin_item.update!(status: :requires_update)
- # Mock provider to return one account so updated_item creates SFAs
- mock_provider = mock()
- mock_provider.expects(:claim_access_url).with("valid_token").returns("https://example.com/new_access")
- mock_provider.expects(:get_accounts).returns({
- accounts: [
- { id: "sf_auto_open_1", name: "Auto Open Checking", type: "depository", currency: "USD", balance: 100, transactions: [] }
- ]
- }).at_least_once
- Provider::Simplefin.expects(:new).returns(mock_provider).at_least_once
+ token = Base64.strict_encode64("https://example.com/claim")
- patch simplefin_item_url(@simplefin_item), params: { simplefin_item: { setup_token: "valid_token" } }
+ SimplefinConnectionUpdateJob.expects(:perform_later).with(
+ family_id: @family.id,
+ old_simplefin_item_id: @simplefin_item.id,
+ setup_token: token
+ ).once
- assert_response :redirect
- uri = URI(response.redirect_url)
- assert_equal "/accounts", uri.path
+ patch simplefin_item_url(@simplefin_item), params: { simplefin_item: { setup_token: token } }
+
+ assert_redirected_to accounts_path
end
test "create does not auto-open when no candidates or unlinked" do
# Mock provider interactions for item creation (no immediate account import on create)
mock_provider = mock()
- mock_provider.expects(:claim_access_url).with("valid_token").returns("https://example.com/new_access")
+ token = Base64.strict_encode64("https://example.com/claim")
+ mock_provider.expects(:claim_access_url).with(token).returns("https://example.com/new_access")
Provider::Simplefin.expects(:new).returns(mock_provider).at_least_once
- post simplefin_items_url, params: { simplefin_item: { setup_token: "valid_token" } }
+ post simplefin_items_url, params: { simplefin_item: { setup_token: token } }
assert_response :redirect
uri = URI(response.redirect_url)
@@ -485,12 +484,15 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
test "update does not auto-open when no SFAs present" do
@simplefin_item.update!(status: :requires_update)
- mock_provider = mock()
- mock_provider.expects(:claim_access_url).with("valid_token").returns("https://example.com/new_access")
- mock_provider.expects(:get_accounts).returns({ accounts: [] }).at_least_once
- Provider::Simplefin.expects(:new).returns(mock_provider).at_least_once
+ token = Base64.strict_encode64("https://example.com/claim")
- patch simplefin_item_url(@simplefin_item), params: { simplefin_item: { setup_token: "valid_token" } }
+ SimplefinConnectionUpdateJob.expects(:perform_later).with(
+ family_id: @family.id,
+ old_simplefin_item_id: @simplefin_item.id,
+ setup_token: token
+ ).once
+
+ patch simplefin_item_url(@simplefin_item), params: { simplefin_item: { setup_token: token } }
assert_response :redirect
uri = URI(response.redirect_url)
@@ -498,4 +500,201 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
q = Rack::Utils.parse_nested_query(uri.query)
assert !q.key?("open_relink_for"), "did not expect auto-open when update produced no SFAs/candidates"
end
+
+ # Stale account detection and handling tests
+
+ test "setup_accounts detects stale accounts not in upstream API" do
+ # Create a linked SimpleFIN account
+ linked_sfa = @simplefin_item.simplefin_accounts.create!(
+ name: "Old Bitcoin",
+ account_id: "stale_btc_123",
+ currency: "USD",
+ current_balance: 0,
+ account_type: "crypto"
+ )
+ linked_account = Account.create!(
+ family: @family,
+ name: "Old Bitcoin",
+ balance: 0,
+ currency: "USD",
+ accountable: Crypto.create!
+ )
+ linked_sfa.update!(account: linked_account)
+ linked_account.update!(simplefin_account_id: linked_sfa.id)
+
+ # Set raw_payload to simulate upstream API response WITHOUT the stale account
+ @simplefin_item.update!(raw_payload: {
+ accounts: [
+ { id: "active_cash_456", name: "Cash", balance: 1000, currency: "USD" }
+ ]
+ })
+
+ get setup_accounts_simplefin_item_url(@simplefin_item)
+ assert_response :success
+
+ # Should detect the stale account
+ assert_includes response.body, "Accounts No Longer in SimpleFIN"
+ assert_includes response.body, "Old Bitcoin"
+ end
+
+ test "complete_account_setup deletes stale account when delete action selected" do
+ # Create a linked SimpleFIN account that will be stale
+ stale_sfa = @simplefin_item.simplefin_accounts.create!(
+ name: "Stale Account",
+ account_id: "stale_123",
+ currency: "USD",
+ current_balance: 0,
+ account_type: "depository"
+ )
+ stale_account = Account.create!(
+ family: @family,
+ name: "Stale Account",
+ balance: 0,
+ currency: "USD",
+ accountable: Depository.create!(subtype: "checking")
+ )
+ stale_sfa.update!(account: stale_account)
+ stale_account.update!(simplefin_account_id: stale_sfa.id)
+
+ # Add a transaction to the account
+ Entry.create!(
+ account: stale_account,
+ name: "Test Transaction",
+ amount: 100,
+ currency: "USD",
+ date: Date.today,
+ entryable: Transaction.create!
+ )
+
+ # Set raw_payload without the stale account
+ @simplefin_item.update!(raw_payload: { accounts: [] })
+
+ assert_difference [ "Account.count", "SimplefinAccount.count", "Entry.count" ], -1 do
+ post complete_account_setup_simplefin_item_url(@simplefin_item), params: {
+ stale_account_actions: {
+ stale_sfa.id => { action: "delete" }
+ }
+ }
+ end
+
+ assert_redirected_to accounts_path
+ end
+
+ test "complete_account_setup moves transactions when move action selected" do
+ # Create source (stale) account
+ stale_sfa = @simplefin_item.simplefin_accounts.create!(
+ name: "Bitcoin",
+ account_id: "stale_btc",
+ currency: "USD",
+ current_balance: 0,
+ account_type: "crypto"
+ )
+ stale_account = Account.create!(
+ family: @family,
+ name: "Bitcoin",
+ balance: 0,
+ currency: "USD",
+ accountable: Crypto.create!
+ )
+ stale_sfa.update!(account: stale_account)
+ stale_account.update!(simplefin_account_id: stale_sfa.id)
+
+ # Create target account (active)
+ target_sfa = @simplefin_item.simplefin_accounts.create!(
+ name: "Cash",
+ account_id: "active_cash",
+ currency: "USD",
+ current_balance: 1000,
+ account_type: "depository"
+ )
+ target_account = Account.create!(
+ family: @family,
+ name: "Cash",
+ balance: 1000,
+ currency: "USD",
+ accountable: Depository.create!(subtype: "checking")
+ )
+ target_sfa.update!(account: target_account)
+ target_account.update!(simplefin_account_id: target_sfa.id)
+ target_sfa.ensure_account_provider!
+
+ # Add transactions to stale account
+ entry1 = Entry.create!(
+ account: stale_account,
+ name: "P2P Transfer",
+ amount: 300,
+ currency: "USD",
+ date: Date.today,
+ entryable: Transaction.create!
+ )
+ entry2 = Entry.create!(
+ account: stale_account,
+ name: "Another Transfer",
+ amount: 200,
+ currency: "USD",
+ date: Date.today - 1,
+ entryable: Transaction.create!
+ )
+
+ # Set raw_payload with only the target account (stale account missing)
+ @simplefin_item.update!(raw_payload: {
+ accounts: [
+ { id: "active_cash", name: "Cash", balance: 1000, currency: "USD" }
+ ]
+ })
+
+ # Stale account should be deleted, target account should gain entries
+ assert_difference "Account.count", -1 do
+ assert_difference "SimplefinAccount.count", -1 do
+ post complete_account_setup_simplefin_item_url(@simplefin_item), params: {
+ stale_account_actions: {
+ stale_sfa.id => { action: "move", target_account_id: target_account.id }
+ }
+ }
+ end
+ end
+
+ assert_redirected_to accounts_path
+
+ # Verify transactions were moved to target account
+ entry1.reload
+ entry2.reload
+ assert_equal target_account.id, entry1.account_id
+ assert_equal target_account.id, entry2.account_id
+ end
+
+ test "complete_account_setup skips stale account when skip action selected" do
+ # Create a linked SimpleFIN account that will be stale
+ stale_sfa = @simplefin_item.simplefin_accounts.create!(
+ name: "Stale Account",
+ account_id: "stale_skip",
+ currency: "USD",
+ current_balance: 0,
+ account_type: "depository"
+ )
+ stale_account = Account.create!(
+ family: @family,
+ name: "Stale Account",
+ balance: 0,
+ currency: "USD",
+ accountable: Depository.create!(subtype: "checking")
+ )
+ stale_sfa.update!(account: stale_account)
+ stale_account.update!(simplefin_account_id: stale_sfa.id)
+
+ @simplefin_item.update!(raw_payload: { accounts: [] })
+
+ assert_no_difference [ "Account.count", "SimplefinAccount.count" ] do
+ post complete_account_setup_simplefin_item_url(@simplefin_item), params: {
+ stale_account_actions: {
+ stale_sfa.id => { action: "skip" }
+ }
+ }
+ end
+
+ assert_redirected_to accounts_path
+ # Account and SimplefinAccount should still exist
+ assert Account.exists?(stale_account.id)
+ assert SimplefinAccount.exists?(stale_sfa.id)
+ end
end
diff --git a/test/fixtures/lunchflow_accounts.yml b/test/fixtures/lunchflow_accounts.yml
new file mode 100644
index 000000000..d95dfb9d5
--- /dev/null
+++ b/test/fixtures/lunchflow_accounts.yml
@@ -0,0 +1,6 @@
+investment_account:
+ lunchflow_item: one
+ account_id: "lf_acc_investment_1"
+ name: "Test Investment Account"
+ currency: USD
+ holdings_supported: true
diff --git a/test/fixtures/lunchflow_items.yml b/test/fixtures/lunchflow_items.yml
new file mode 100644
index 000000000..fa7e12824
--- /dev/null
+++ b/test/fixtures/lunchflow_items.yml
@@ -0,0 +1,5 @@
+one:
+ family: dylan_family
+ name: "Test Lunchflow Connection"
+ api_key: "test_api_key_123"
+ status: good
diff --git a/test/fixtures/oidc_identities.yml b/test/fixtures/oidc_identities.yml
index c2fbdb404..030cab934 100644
--- a/test/fixtures/oidc_identities.yml
+++ b/test/fixtures/oidc_identities.yml
@@ -19,3 +19,14 @@ jakob_google:
first_name: Jakob
last_name: Dylan
last_authenticated_at: <%= 2.days.ago %>
+
+sso_only_identity:
+ user: sso_only
+ provider: openid_connect
+ uid: sso-only-uid-12345
+ info:
+ email: sso-user@example.com
+ name: SSO User
+ first_name: SSO
+ last_name: User
+ last_authenticated_at: <%= 1.day.ago %>
diff --git a/test/fixtures/security/prices.yml b/test/fixtures/security/prices.yml
index 75686baca..6d05ee274 100644
--- a/test/fixtures/security/prices.yml
+++ b/test/fixtures/security/prices.yml
@@ -3,9 +3,11 @@ one:
date: <%= Date.current %>
price: 215
currency: USD
+ provisional: false
two:
security: aapl
date: <%= 1.day.ago.to_date %>
price: 214
currency: USD
+ provisional: false
diff --git a/test/fixtures/simplefin_accounts.yml b/test/fixtures/simplefin_accounts.yml
new file mode 100644
index 000000000..27f4028ba
--- /dev/null
+++ b/test/fixtures/simplefin_accounts.yml
@@ -0,0 +1,2 @@
+# Empty fixture to ensure the simplefin_accounts table is truncated during tests.
+# Tests create SimplefinAccount records explicitly in setup.
\ No newline at end of file
diff --git a/test/fixtures/simplefin_items.yml b/test/fixtures/simplefin_items.yml
new file mode 100644
index 000000000..0507db469
--- /dev/null
+++ b/test/fixtures/simplefin_items.yml
@@ -0,0 +1,2 @@
+# Empty fixture to ensure the simplefin_items table is truncated during tests.
+# Tests create SimplefinItem records explicitly in setup.
\ No newline at end of file
diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml
index b0987b1e6..a3694a0ed 100644
--- a/test/fixtures/users.yml
+++ b/test/fixtures/users.yml
@@ -43,6 +43,17 @@ new_email:
last_name: User
email: user@example.com
unconfirmed_email: new@example.com
- password_digest: $2a$12$XoNBo/cMCyzpYtvhrPAhsubG21mELX48RAcjSVCRctW8dG8wrDIla
+ password_digest: $2a$12$XoNBo/cMCyzpYtvhrPAhsubG21mELX48RAcjSVCRctW8dG8wrDIla
onboarded_at: <%= Time.current %>
+ ai_enabled: true
+
+# SSO-only user: created via JIT provisioning, no local password
+sso_only:
+ family: empty
+ first_name: SSO
+ last_name: User
+ email: sso-user@example.com
+ password_digest: ~
+ role: admin
+ onboarded_at: <%= 1.day.ago %>
ai_enabled: true
\ No newline at end of file
diff --git a/test/jobs/apply_all_rules_job_test.rb b/test/jobs/apply_all_rules_job_test.rb
new file mode 100644
index 000000000..68591c29d
--- /dev/null
+++ b/test/jobs/apply_all_rules_job_test.rb
@@ -0,0 +1,41 @@
+require "test_helper"
+
+class ApplyAllRulesJobTest < ActiveJob::TestCase
+ include EntriesTestHelper
+
+ setup do
+ @family = families(:empty)
+ @account = @family.accounts.create!(name: "Test Account", balance: 1000, currency: "USD", accountable: Depository.new)
+ @groceries_category = @family.categories.create!(name: "Groceries")
+ end
+
+ test "applies all rules for a family" do
+ # Create a rule
+ rule = Rule.create!(
+ family: @family,
+ resource_type: "transaction",
+ effective_date: 1.day.ago.to_date,
+ conditions: [ Rule::Condition.new(condition_type: "transaction_name", operator: "like", value: "Whole Foods") ],
+ actions: [ Rule::Action.new(action_type: "set_transaction_category", value: @groceries_category.id) ]
+ )
+
+ # Mock RuleJob to verify it gets called for each rule
+ RuleJob.expects(:perform_now).with(rule, ignore_attribute_locks: true, execution_type: "manual").once
+
+ ApplyAllRulesJob.perform_now(@family)
+ end
+
+ test "applies all rules with custom execution type" do
+ rule = Rule.create!(
+ family: @family,
+ resource_type: "transaction",
+ effective_date: 1.day.ago.to_date,
+ conditions: [ Rule::Condition.new(condition_type: "transaction_name", operator: "like", value: "Test") ],
+ actions: [ Rule::Action.new(action_type: "set_transaction_category", value: @groceries_category.id) ]
+ )
+
+ RuleJob.expects(:perform_now).with(rule, ignore_attribute_locks: true, execution_type: "scheduled").once
+
+ ApplyAllRulesJob.perform_now(@family, execution_type: "scheduled")
+ end
+end
diff --git a/test/jobs/sync_hourly_job_test.rb b/test/jobs/sync_hourly_job_test.rb
new file mode 100644
index 000000000..4df2c9511
--- /dev/null
+++ b/test/jobs/sync_hourly_job_test.rb
@@ -0,0 +1,33 @@
+require "test_helper"
+
+class SyncHourlyJobTest < ActiveJob::TestCase
+ test "syncs all active items for each hourly syncable class" do
+ mock_item = mock("coinstats_item")
+ mock_item.expects(:sync_later).once
+
+ mock_relation = mock("active_relation")
+ mock_relation.stubs(:find_each).yields(mock_item)
+
+ CoinstatsItem.expects(:active).returns(mock_relation)
+
+ SyncHourlyJob.perform_now
+ end
+
+ test "continues syncing other items when one fails" do
+ failing_item = mock("failing_item")
+ failing_item.expects(:sync_later).raises(StandardError.new("Test error"))
+ failing_item.stubs(:id).returns(1)
+
+ success_item = mock("success_item")
+ success_item.expects(:sync_later).once
+
+ mock_relation = mock("active_relation")
+ mock_relation.stubs(:find_each).multiple_yields([ failing_item ], [ success_item ])
+
+ CoinstatsItem.expects(:active).returns(mock_relation)
+
+ assert_nothing_raised do
+ SyncHourlyJob.perform_now
+ end
+ end
+end
diff --git a/test/models/account/market_data_importer_test.rb b/test/models/account/market_data_importer_test.rb
index 4a305e94f..828f34814 100644
--- a/test/models/account/market_data_importer_test.rb
+++ b/test/models/account/market_data_importer_test.rb
@@ -4,7 +4,8 @@ require "ostruct"
class Account::MarketDataImporterTest < ActiveSupport::TestCase
include ProviderTestHelper
- PROVIDER_BUFFER = 5.days
+ SECURITY_PRICE_BUFFER = Security::Price::Importer::PROVISIONAL_LOOKBACK_DAYS.days
+ EXCHANGE_RATE_BUFFER = 5.days
setup do
# Ensure a clean slate for deterministic assertions
@@ -37,7 +38,7 @@ class Account::MarketDataImporterTest < ActiveSupport::TestCase
ExchangeRate.create!(from_currency: "CAD", to_currency: "USD", date: existing_date, rate: 2.0)
ExchangeRate.create!(from_currency: "USD", to_currency: "CAD", date: existing_date, rate: 0.5)
- expected_start_date = (existing_date + 1.day) - PROVIDER_BUFFER
+ expected_start_date = (existing_date + 1.day) - EXCHANGE_RATE_BUFFER
end_date = Date.current.in_time_zone("America/New_York").to_date
@provider.expects(:fetch_exchange_rates)
@@ -88,7 +89,7 @@ class Account::MarketDataImporterTest < ActiveSupport::TestCase
entryable: trade
)
- expected_start_date = trade_date - PROVIDER_BUFFER
+ expected_start_date = trade_date - SECURITY_PRICE_BUFFER
end_date = Date.current.in_time_zone("America/New_York").to_date
@provider.expects(:fetch_security_prices)
@@ -138,7 +139,7 @@ class Account::MarketDataImporterTest < ActiveSupport::TestCase
entryable: trade
)
- expected_start_date = trade_date - PROVIDER_BUFFER
+ expected_start_date = trade_date - SECURITY_PRICE_BUFFER
end_date = Date.current.in_time_zone("America/New_York").to_date
# Simulate provider returning an error response
@@ -181,7 +182,7 @@ class Account::MarketDataImporterTest < ActiveSupport::TestCase
ExchangeRate.create!(from_currency: "CAD", to_currency: "USD", date: existing_date, rate: 2.0)
ExchangeRate.create!(from_currency: "USD", to_currency: "CAD", date: existing_date, rate: 0.5)
- expected_start_date = (existing_date + 1.day) - PROVIDER_BUFFER
+ expected_start_date = (existing_date + 1.day) - EXCHANGE_RATE_BUFFER
end_date = Date.current.in_time_zone("America/New_York").to_date
# Simulate provider returning an error response
diff --git a/test/models/account_provider_test.rb b/test/models/account_provider_test.rb
index 6e656df36..d0169e134 100644
--- a/test/models/account_provider_test.rb
+++ b/test/models/account_provider_test.rb
@@ -129,4 +129,49 @@ class AccountProviderTest < ActiveSupport::TestCase
assert_equal "plaid", plaid_provider.provider_name
assert_equal "simplefin", simplefin_provider.provider_name
end
+
+ test "destroying account_provider does not destroy non-coinstats provider accounts" do
+ provider = AccountProvider.create!(
+ account: @account,
+ provider: @plaid_account
+ )
+
+ plaid_account_id = @plaid_account.id
+
+ assert PlaidAccount.exists?(plaid_account_id)
+
+ provider.destroy!
+
+ # Non-CoinStats provider accounts should remain (can enter "needs setup" state)
+ assert PlaidAccount.exists?(plaid_account_id)
+ end
+
+ test "destroying account_provider destroys coinstats provider account" do
+ coinstats_item = CoinstatsItem.create!(
+ family: @family,
+ name: "Test CoinStats",
+ api_key: "test_key"
+ )
+
+ coinstats_account = CoinstatsAccount.create!(
+ coinstats_item: coinstats_item,
+ name: "Test Wallet",
+ currency: "USD",
+ current_balance: 1000
+ )
+
+ provider = AccountProvider.create!(
+ account: @account,
+ provider: coinstats_account
+ )
+
+ coinstats_account_id = coinstats_account.id
+
+ assert CoinstatsAccount.exists?(coinstats_account_id)
+
+ provider.destroy!
+
+ # CoinStats provider accounts should be destroyed to avoid orphaned records
+ assert_not CoinstatsAccount.exists?(coinstats_account_id)
+ end
end
diff --git a/test/models/account_test.rb b/test/models/account_test.rb
index 3733fc700..911b0bfa4 100644
--- a/test/models/account_test.rb
+++ b/test/models/account_test.rb
@@ -14,6 +14,64 @@ class AccountTest < ActiveSupport::TestCase
end
end
+ test "create_and_sync calls sync_later by default" do
+ Account.any_instance.expects(:sync_later).once
+
+ account = Account.create_and_sync({
+ family: @family,
+ name: "Test Account",
+ balance: 100,
+ currency: "USD",
+ accountable_type: "Depository",
+ accountable_attributes: {}
+ })
+
+ assert account.persisted?
+ assert_equal "USD", account.currency
+ assert_equal 100, account.balance
+ end
+
+ test "create_and_sync skips sync_later when skip_initial_sync is true" do
+ Account.any_instance.expects(:sync_later).never
+
+ account = Account.create_and_sync(
+ {
+ family: @family,
+ name: "Linked Account",
+ balance: 500,
+ currency: "EUR",
+ accountable_type: "Depository",
+ accountable_attributes: {}
+ },
+ skip_initial_sync: true
+ )
+
+ assert account.persisted?
+ assert_equal "EUR", account.currency
+ assert_equal 500, account.balance
+ end
+
+ test "create_and_sync creates opening anchor with correct currency" do
+ Account.any_instance.stubs(:sync_later)
+
+ account = Account.create_and_sync(
+ {
+ family: @family,
+ name: "Test Account",
+ balance: 1000,
+ currency: "GBP",
+ accountable_type: "Depository",
+ accountable_attributes: {}
+ },
+ skip_initial_sync: true
+ )
+
+ opening_anchor = account.valuations.opening_anchor.first
+ assert_not_nil opening_anchor
+ assert_equal "GBP", opening_anchor.entry.currency
+ assert_equal 1000, opening_anchor.entry.amount
+ end
+
test "gets short/long subtype label" do
investment = Investment.new(subtype: "hsa")
account = @family.accounts.create!(
diff --git a/test/models/balance/chart_series_builder_test.rb b/test/models/balance/chart_series_builder_test.rb
index fbf5019fd..b80d51bca 100644
--- a/test/models/balance/chart_series_builder_test.rb
+++ b/test/models/balance/chart_series_builder_test.rb
@@ -127,4 +127,197 @@ class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase
assert_equal expected, builder.balance_series.map { |v| v.value.amount }
end
+
+ test "uses balances matching account currency for correct chart data" do
+ # This test verifies that chart data is built from balances with proper currency.
+ # Data integrity is maintained by:
+ # 1. Account.create_and_sync with skip_initial_sync: true for linked accounts
+ # 2. Migration cleanup_orphaned_currency_balances for existing data
+ account = accounts(:depository)
+ account.balances.destroy_all
+
+ # Account is in USD, create balances in USD
+ create_balance(account: account, date: 2.days.ago.to_date, balance: 1000)
+ create_balance(account: account, date: 1.day.ago.to_date, balance: 1500)
+ create_balance(account: account, date: Date.current, balance: 2000)
+
+ builder = Balance::ChartSeriesBuilder.new(
+ account_ids: [ account.id ],
+ currency: "USD",
+ period: Period.custom(start_date: 2.days.ago.to_date, end_date: Date.current),
+ interval: "1 day"
+ )
+
+ series = builder.balance_series
+ assert_equal 3, series.size
+ assert_equal [ 1000, 1500, 2000 ], series.map { |v| v.value.amount }
+ end
+
+ test "balances are converted to target currency using exchange rates" do
+ # Create account with EUR currency
+ family = families(:dylan_family)
+ account = family.accounts.create!(
+ name: "EUR Account",
+ balance: 1000,
+ currency: "EUR",
+ accountable: Depository.new
+ )
+
+ account.balances.destroy_all
+
+ # Create balances in EUR (matching account currency)
+ create_balance(account: account, date: 1.day.ago.to_date, balance: 1000)
+ create_balance(account: account, date: Date.current, balance: 1200)
+
+ # Add exchange rate EUR -> USD
+ ExchangeRate.create!(date: 1.day.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: 1.1)
+
+ # Request chart in USD (different from account's EUR)
+ builder = Balance::ChartSeriesBuilder.new(
+ account_ids: [ account.id ],
+ currency: "USD",
+ period: Period.custom(start_date: 1.day.ago.to_date, end_date: Date.current),
+ interval: "1 day"
+ )
+
+ series = builder.balance_series
+ # EUR balances converted to USD at 1.1 rate (LOCF for today)
+ assert_equal [ 1100, 1320 ], series.map { |v| v.value.amount }
+ end
+
+ test "linked account with orphaned currency balances shows correct values after cleanup" do
+ # This test reproduces the original bug scenario:
+ # 1. Linked account created with initial sync before correct currency was known
+ # 2. Opening anchor and first sync created balances with wrong currency (USD)
+ # 3. Provider sync updated account to correct currency (EUR) and created new balances
+ # 4. Both USD and EUR balances existed - charts showed wrong values
+ #
+ # The fix:
+ # 1. skip_initial_sync prevents this going forward
+ # 2. Migration cleans up orphaned balances for existing linked accounts
+
+ # Use the connected (linked) account fixture
+ linked_account = accounts(:connected)
+ linked_account.balances.destroy_all
+
+ # Simulate the bug: account is now EUR but has old USD balances from initial sync
+ linked_account.update!(currency: "EUR")
+
+ # Create orphaned balances in wrong currency (USD) - from initial sync before currency was known
+ Balance.create!(
+ account: linked_account,
+ date: 3.days.ago.to_date,
+ balance: 1000,
+ cash_balance: 1000,
+ currency: "USD", # Wrong currency!
+ start_cash_balance: 1000,
+ start_non_cash_balance: 0,
+ cash_inflows: 0,
+ cash_outflows: 0,
+ non_cash_inflows: 0,
+ non_cash_outflows: 0,
+ net_market_flows: 0,
+ cash_adjustments: 0,
+ non_cash_adjustments: 0,
+ flows_factor: 1
+ )
+
+ Balance.create!(
+ account: linked_account,
+ date: 2.days.ago.to_date,
+ balance: 1100,
+ cash_balance: 1100,
+ currency: "USD", # Wrong currency!
+ start_cash_balance: 1100,
+ start_non_cash_balance: 0,
+ cash_inflows: 0,
+ cash_outflows: 0,
+ non_cash_inflows: 0,
+ non_cash_outflows: 0,
+ net_market_flows: 0,
+ cash_adjustments: 0,
+ non_cash_adjustments: 0,
+ flows_factor: 1
+ )
+
+ # Create correct balances in EUR - from provider sync after currency was known
+ create_balance(account: linked_account, date: 1.day.ago.to_date, balance: 5000)
+ create_balance(account: linked_account, date: Date.current, balance: 5500)
+
+ # Verify we have both currency balances (the bug state)
+ assert_equal 2, linked_account.balances.where(currency: "USD").count
+ assert_equal 2, linked_account.balances.where(currency: "EUR").count
+
+ # Simulate migration cleanup: delete orphaned balances with wrong currency
+ linked_account.balances.where.not(currency: linked_account.currency).delete_all
+
+ # Verify cleanup removed orphaned balances
+ assert_equal 0, linked_account.balances.where(currency: "USD").count
+ assert_equal 2, linked_account.balances.where(currency: "EUR").count
+
+ # Now chart should show correct EUR values
+ builder = Balance::ChartSeriesBuilder.new(
+ account_ids: [ linked_account.id ],
+ currency: "EUR",
+ period: Period.custom(start_date: 2.days.ago.to_date, end_date: Date.current),
+ interval: "1 day"
+ )
+
+ series = builder.balance_series
+ # After cleanup: only EUR balances exist, chart shows correct values
+ # Day 2 ago: 0 (no EUR balance), Day 1 ago: 5000, Today: 5500
+ assert_equal [ 0, 5000, 5500 ], series.map { |v| v.value.amount }
+ end
+
+ test "chart ignores orphaned currency balances via currency filter" do
+ # This test verifies the currency filter correctly ignores orphaned balances.
+ # The filter `b.currency = accounts.currency` ensures only valid balances are used.
+ #
+ # Bug scenario: Account currency changed from USD to EUR after initial sync,
+ # leaving orphaned USD balances. Without the filter, charts would show wrong values.
+
+ linked_account = accounts(:connected)
+ linked_account.balances.destroy_all
+
+ # Account is EUR but has orphaned USD balances (bug state)
+ linked_account.update!(currency: "EUR")
+
+ # Create orphaned USD balance (wrong currency)
+ Balance.create!(
+ account: linked_account,
+ date: 1.day.ago.to_date,
+ balance: 9999,
+ cash_balance: 9999,
+ currency: "USD", # Wrong currency - doesn't match account.currency (EUR)
+ start_cash_balance: 9999,
+ start_non_cash_balance: 0,
+ cash_inflows: 0,
+ cash_outflows: 0,
+ non_cash_inflows: 0,
+ non_cash_outflows: 0,
+ net_market_flows: 0,
+ cash_adjustments: 0,
+ non_cash_adjustments: 0,
+ flows_factor: 1
+ )
+
+ # Chart correctly ignores USD balance because account.currency is EUR
+ builder = Balance::ChartSeriesBuilder.new(
+ account_ids: [ linked_account.id ],
+ currency: "EUR",
+ period: Period.custom(start_date: 1.day.ago.to_date, end_date: Date.current),
+ interval: "1 day"
+ )
+
+ series = builder.balance_series
+
+ # Currency filter ensures orphaned USD balance (9999) is ignored
+ # Chart shows zeros because no EUR balances exist
+ assert_equal 2, series.size
+ assert_equal [ 0, 0 ], series.map { |v| v.value.amount }
+
+ # Verify the orphaned balance still exists in DB (migration will clean it up)
+ assert_equal 1, linked_account.balances.where(currency: "USD").count
+ assert_equal 0, linked_account.balances.where(currency: "EUR").count
+ end
end
diff --git a/test/models/coinstats_account/processor_test.rb b/test/models/coinstats_account/processor_test.rb
new file mode 100644
index 000000000..e0e556f96
--- /dev/null
+++ b/test/models/coinstats_account/processor_test.rb
@@ -0,0 +1,159 @@
+require "test_helper"
+
+class CoinstatsAccount::ProcessorTest < ActiveSupport::TestCase
+ setup do
+ @family = families(:dylan_family)
+ @coinstats_item = CoinstatsItem.create!(
+ family: @family,
+ name: "Test CoinStats Connection",
+ api_key: "test_api_key_123"
+ )
+ @crypto = Crypto.create!
+ @account = @family.accounts.create!(
+ accountable: @crypto,
+ name: "Test Crypto Account",
+ balance: 1000,
+ currency: "USD"
+ )
+ @coinstats_account = @coinstats_item.coinstats_accounts.create!(
+ name: "Test Wallet",
+ currency: "USD",
+ current_balance: 2500
+ )
+ AccountProvider.create!(account: @account, provider: @coinstats_account)
+ end
+
+ test "skips processing when no linked account" do
+ # Create an unlinked coinstats account
+ unlinked_account = @coinstats_item.coinstats_accounts.create!(
+ name: "Unlinked Wallet",
+ currency: "USD",
+ current_balance: 1000
+ )
+
+ processor = CoinstatsAccount::Processor.new(unlinked_account)
+
+ # Should not raise, just return early
+ assert_nothing_raised do
+ processor.process
+ end
+ end
+
+ test "updates account balance from coinstats account" do
+ @coinstats_account.update!(current_balance: 5000.50)
+
+ processor = CoinstatsAccount::Processor.new(@coinstats_account)
+ processor.process
+
+ @account.reload
+ assert_equal BigDecimal("5000.50"), @account.balance
+ assert_equal BigDecimal("5000.50"), @account.cash_balance
+ end
+
+ test "updates account currency from coinstats account" do
+ @coinstats_account.update!(currency: "EUR")
+
+ processor = CoinstatsAccount::Processor.new(@coinstats_account)
+ processor.process
+
+ @account.reload
+ assert_equal "EUR", @account.currency
+ end
+
+ test "handles zero balance" do
+ @coinstats_account.update!(current_balance: 0)
+
+ processor = CoinstatsAccount::Processor.new(@coinstats_account)
+ processor.process
+
+ @account.reload
+ assert_equal BigDecimal("0"), @account.balance
+ end
+
+ test "handles nil balance as zero" do
+ @coinstats_account.update!(current_balance: nil)
+
+ processor = CoinstatsAccount::Processor.new(@coinstats_account)
+ processor.process
+
+ @account.reload
+ assert_equal BigDecimal("0"), @account.balance
+ end
+
+ test "processes transactions" do
+ @coinstats_account.update!(raw_transactions_payload: [
+ {
+ type: "Received",
+ date: "2025-01-15T10:00:00.000Z",
+ coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
+ hash: { id: "0xabc123", explorerUrl: "https://etherscan.io/tx/0xabc123" }
+ }
+ ])
+
+ processor = CoinstatsAccount::Processor.new(@coinstats_account)
+
+ # Mock the transaction processor to verify it's called
+ CoinstatsAccount::Transactions::Processor.any_instance
+ .expects(:process)
+ .returns({ success: true, total: 1, imported: 1, failed: 0, errors: [] })
+ .once
+
+ processor.process
+ end
+
+ test "continues processing when transaction processing fails" do
+ @coinstats_account.update!(raw_transactions_payload: [
+ { type: "Received", date: "2025-01-15T10:00:00.000Z" }
+ ])
+
+ processor = CoinstatsAccount::Processor.new(@coinstats_account)
+
+ # Mock transaction processing to raise an error
+ CoinstatsAccount::Transactions::Processor.any_instance
+ .expects(:process)
+ .raises(StandardError.new("Transaction processing error"))
+
+ # Should not raise - error is caught and reported
+ assert_nothing_raised do
+ processor.process
+ end
+
+ # Balance should still be updated
+ @account.reload
+ assert_equal BigDecimal("2500"), @account.balance
+ end
+
+ test "normalizes currency codes" do
+ @coinstats_account.update!(currency: "usd")
+
+ processor = CoinstatsAccount::Processor.new(@coinstats_account)
+ processor.process
+
+ @account.reload
+ assert_equal "USD", @account.currency
+ end
+
+ test "falls back to account currency when coinstats currency is nil" do
+ @account.update!(currency: "GBP")
+ # Use update_column to bypass validation
+ @coinstats_account.update_column(:currency, "")
+
+ processor = CoinstatsAccount::Processor.new(@coinstats_account)
+ processor.process
+
+ @account.reload
+ # Empty currency falls through to account's existing currency
+ assert_equal "GBP", @account.currency
+ end
+
+ test "raises error when account update fails" do
+ # Make the account invalid by directly modifying a validation constraint
+ Account.any_instance.stubs(:update!).raises(ActiveRecord::RecordInvalid.new(@account))
+
+ processor = CoinstatsAccount::Processor.new(@coinstats_account)
+
+ assert_raises(ActiveRecord::RecordInvalid) do
+ processor.process
+ end
+ end
+end
diff --git a/test/models/coinstats_account/transactions/processor_test.rb b/test/models/coinstats_account/transactions/processor_test.rb
new file mode 100644
index 000000000..5430f52c5
--- /dev/null
+++ b/test/models/coinstats_account/transactions/processor_test.rb
@@ -0,0 +1,350 @@
+require "test_helper"
+
+class CoinstatsAccount::Transactions::ProcessorTest < ActiveSupport::TestCase
+ setup do
+ @family = families(:dylan_family)
+ @coinstats_item = CoinstatsItem.create!(
+ family: @family,
+ name: "Test CoinStats Connection",
+ api_key: "test_api_key_123"
+ )
+ @crypto = Crypto.create!
+ @account = @family.accounts.create!(
+ accountable: @crypto,
+ name: "Test ETH Account",
+ balance: 5000,
+ currency: "USD"
+ )
+ @coinstats_account = @coinstats_item.coinstats_accounts.create!(
+ name: "Ethereum Wallet",
+ currency: "USD",
+ current_balance: 5000,
+ account_id: "ethereum"
+ )
+ AccountProvider.create!(account: @account, provider: @coinstats_account)
+ end
+
+ test "returns early when no transactions payload" do
+ @coinstats_account.update!(raw_transactions_payload: nil)
+
+ processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
+ result = processor.process
+
+ assert result[:success]
+ assert_equal 0, result[:total]
+ assert_equal 0, result[:imported]
+ assert_equal 0, result[:failed]
+ assert_empty result[:errors]
+ end
+
+ test "processes transactions from raw_transactions_payload" do
+ @coinstats_account.update!(raw_transactions_payload: [
+ {
+ type: "Received",
+ date: "2025-01-15T10:00:00.000Z",
+ coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
+ hash: { id: "0xprocess1" },
+ transactions: [ { items: [ { coin: { id: "ethereum" } } ] } ]
+ }
+ ])
+
+ processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
+
+ assert_difference "Entry.count", 1 do
+ result = processor.process
+ assert result[:success]
+ assert_equal 1, result[:total]
+ assert_equal 1, result[:imported]
+ assert_equal 0, result[:failed]
+ end
+ end
+
+ test "filters transactions to only process matching coin" do
+ @coinstats_account.update!(raw_transactions_payload: [
+ {
+ type: "Received",
+ date: "2025-01-15T10:00:00.000Z",
+ coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
+ hash: { id: "0xmatch1" },
+ transactions: [ { items: [ { coin: { id: "ethereum" } } ] } ]
+ },
+ {
+ type: "Received",
+ date: "2025-01-16T10:00:00.000Z",
+ coinData: { count: 100, symbol: "USDC", currentValue: 100 },
+ hash: { id: "0xdifferent" },
+ transactions: [ { items: [ { coin: { id: "usd-coin" } } ] } ]
+ }
+ ])
+
+ processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
+
+ # Should only process the ETH transaction
+ assert_difference "Entry.count", 1 do
+ result = processor.process
+ assert result[:success]
+ assert_equal 1, result[:total]
+ end
+
+ # Verify the correct transaction was imported
+ entry = @account.entries.last
+ assert_equal "coinstats_0xmatch1", entry.external_id
+ end
+
+ test "handles transaction processing errors gracefully" do
+ @coinstats_account.update!(raw_transactions_payload: [
+ {
+ # Invalid transaction - missing required fields
+ type: "Received",
+ coinData: { count: 1.0, symbol: "ETH" },
+ transactions: [ { items: [ { coin: { id: "ethereum" } } ] } ]
+ # Missing date and hash
+ }
+ ])
+
+ processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
+
+ assert_no_difference "Entry.count" do
+ result = processor.process
+ refute result[:success]
+ assert_equal 1, result[:total]
+ assert_equal 0, result[:imported]
+ assert_equal 1, result[:failed]
+ assert_equal 1, result[:errors].count
+ end
+ end
+
+ test "processes multiple valid transactions" do
+ @coinstats_account.update!(raw_transactions_payload: [
+ {
+ type: "Received",
+ date: "2025-01-15T10:00:00.000Z",
+ coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
+ hash: { id: "0xmulti1" },
+ transactions: [ { items: [ { coin: { id: "ethereum" } } ] } ]
+ },
+ {
+ type: "Sent",
+ date: "2025-01-16T10:00:00.000Z",
+ coinData: { count: -0.5, symbol: "ETH", currentValue: 1000 },
+ hash: { id: "0xmulti2" },
+ transactions: [ { items: [ { coin: { id: "ethereum" } } ] } ]
+ }
+ ])
+
+ processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
+
+ assert_difference "Entry.count", 2 do
+ result = processor.process
+ assert result[:success]
+ assert_equal 2, result[:total]
+ assert_equal 2, result[:imported]
+ end
+ end
+
+ test "matches by coin symbol in coinData as fallback" do
+ @coinstats_account.update!(
+ name: "ETH Wallet",
+ account_id: "ethereum",
+ raw_transactions_payload: [
+ {
+ type: "Received",
+ date: "2025-01-15T10:00:00.000Z",
+ coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
+ hash: { id: "0xsymbol1" }
+ # No transactions array with coin.id
+ }
+ ]
+ )
+
+ processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
+
+ assert_difference "Entry.count", 1 do
+ result = processor.process
+ assert result[:success]
+ end
+ end
+
+ test "processes all transactions when no account_id set" do
+ @coinstats_account.update!(
+ account_id: nil,
+ raw_transactions_payload: [
+ {
+ type: "Received",
+ date: "2025-01-15T10:00:00.000Z",
+ coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
+ hash: { id: "0xnofilter1" }
+ },
+ {
+ type: "Received",
+ date: "2025-01-16T10:00:00.000Z",
+ coinData: { count: 100, symbol: "USDC", currentValue: 100 },
+ hash: { id: "0xnofilter2" }
+ }
+ ]
+ )
+
+ processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
+
+ assert_difference "Entry.count", 2 do
+ result = processor.process
+ assert result[:success]
+ assert_equal 2, result[:total]
+ end
+ end
+
+ test "tracks failed transactions with errors" do
+ @coinstats_account.update!(
+ account_id: nil,
+ raw_transactions_payload: [
+ {
+ type: "Received",
+ date: "2025-01-15T10:00:00.000Z",
+ coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
+ hash: { id: "0xvalid1" }
+ },
+ {
+ # Missing date
+ type: "Received",
+ coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
+ hash: { id: "0xinvalid" }
+ }
+ ]
+ )
+
+ processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
+
+ result = processor.process
+
+ refute result[:success]
+ assert_equal 2, result[:total]
+ assert_equal 1, result[:imported]
+ assert_equal 1, result[:failed]
+ assert_equal 1, result[:errors].count
+
+ error = result[:errors].first
+ assert_equal "0xinvalid", error[:transaction_id]
+ assert_match(/Validation error/, error[:error])
+ end
+
+ # Tests for strict symbol matching to avoid false positives
+ # (e.g., "ETH" should not match "Ethereum Classic" which has symbol "ETC")
+
+ test "symbol matching does not cause false positives with similar names" do
+ # Ethereum Classic wallet should NOT match ETH transactions
+ @coinstats_account.update!(
+ name: "Ethereum Classic (0x1234abcd...)",
+ account_id: "ethereum-classic",
+ raw_transactions_payload: [
+ {
+ type: "Received",
+ date: "2025-01-15T10:00:00.000Z",
+ coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
+ hash: { id: "0xfalsepositive1" }
+ # No coin.id, relies on symbol matching fallback
+ }
+ ]
+ )
+
+ processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
+
+ # Should NOT process - "ETH" should not match "Ethereum Classic"
+ assert_no_difference "Entry.count" do
+ result = processor.process
+ assert result[:success]
+ assert_equal 0, result[:total]
+ end
+ end
+
+ test "symbol matching works with parenthesized token format" do
+ @coinstats_account.update!(
+ name: "Ethereum (ETH)",
+ account_id: "ethereum",
+ raw_transactions_payload: [
+ {
+ type: "Received",
+ date: "2025-01-15T10:00:00.000Z",
+ coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
+ hash: { id: "0xparenthesized1" }
+ }
+ ]
+ )
+
+ processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
+
+ assert_difference "Entry.count", 1 do
+ result = processor.process
+ assert result[:success]
+ end
+ end
+
+ test "symbol matching works with symbol as whole word in name" do
+ @coinstats_account.update!(
+ name: "ETH Wallet",
+ account_id: "ethereum",
+ raw_transactions_payload: [
+ {
+ type: "Received",
+ date: "2025-01-15T10:00:00.000Z",
+ coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
+ hash: { id: "0xwholeword1" }
+ }
+ ]
+ )
+
+ processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
+
+ assert_difference "Entry.count", 1 do
+ result = processor.process
+ assert result[:success]
+ end
+ end
+
+ test "symbol matching does not match partial substrings" do
+ # WETH wallet should NOT match ETH transactions via symbol fallback
+ @coinstats_account.update!(
+ name: "WETH Wrapped Ethereum",
+ account_id: "weth",
+ raw_transactions_payload: [
+ {
+ type: "Received",
+ date: "2025-01-15T10:00:00.000Z",
+ coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
+ hash: { id: "0xpartial1" }
+ # No coin.id, relies on symbol matching fallback
+ }
+ ]
+ )
+
+ processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
+
+ # Should NOT process - "ETH" is a substring of "WETH" but not a whole word match
+ assert_no_difference "Entry.count" do
+ result = processor.process
+ assert result[:success]
+ assert_equal 0, result[:total]
+ end
+ end
+
+ test "symbol matching is case insensitive" do
+ @coinstats_account.update!(
+ name: "eth wallet",
+ account_id: "ethereum",
+ raw_transactions_payload: [
+ {
+ type: "Received",
+ date: "2025-01-15T10:00:00.000Z",
+ coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
+ hash: { id: "0xcaseinsensitive1" }
+ }
+ ]
+ )
+
+ processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
+
+ assert_difference "Entry.count", 1 do
+ result = processor.process
+ assert result[:success]
+ end
+ end
+end
diff --git a/test/models/coinstats_account_test.rb b/test/models/coinstats_account_test.rb
new file mode 100644
index 000000000..773a1bf4f
--- /dev/null
+++ b/test/models/coinstats_account_test.rb
@@ -0,0 +1,202 @@
+require "test_helper"
+
+class CoinstatsAccountTest < ActiveSupport::TestCase
+ setup do
+ @family = families(:dylan_family)
+ @coinstats_item = CoinstatsItem.create!(
+ family: @family,
+ name: "Test CoinStats Connection",
+ api_key: "test_api_key_123"
+ )
+ @coinstats_account = @coinstats_item.coinstats_accounts.create!(
+ name: "Test Wallet",
+ currency: "USD",
+ current_balance: 1000.00
+ )
+ end
+
+ test "belongs to coinstats_item" do
+ assert_equal @coinstats_item, @coinstats_account.coinstats_item
+ end
+
+ test "can have account through account_provider" do
+ crypto = Crypto.create!
+ account = @family.accounts.create!(
+ accountable: crypto,
+ name: "Linked Crypto Account",
+ balance: 1000,
+ currency: "USD"
+ )
+ AccountProvider.create!(account: account, provider: @coinstats_account)
+
+ assert_equal account, @coinstats_account.account
+ assert_equal account, @coinstats_account.current_account
+ end
+
+ test "requires name to be present" do
+ coinstats_account = @coinstats_item.coinstats_accounts.build(
+ currency: "USD"
+ )
+ coinstats_account.name = nil
+
+ assert_not coinstats_account.valid?
+ assert_includes coinstats_account.errors[:name], "can't be blank"
+ end
+
+ test "requires currency to be present" do
+ coinstats_account = @coinstats_item.coinstats_accounts.build(
+ name: "Test"
+ )
+ coinstats_account.currency = nil
+
+ assert_not coinstats_account.valid?
+ assert_includes coinstats_account.errors[:currency], "can't be blank"
+ end
+
+ test "account_id is unique per coinstats_item" do
+ @coinstats_account.update!(account_id: "unique_account_id_123")
+
+ duplicate = @coinstats_item.coinstats_accounts.build(
+ name: "Duplicate",
+ currency: "USD",
+ account_id: "unique_account_id_123"
+ )
+
+ assert_not duplicate.valid?
+ assert_includes duplicate.errors[:account_id], "has already been taken"
+ end
+
+ test "allows nil account_id for multiple accounts" do
+ second_account = @coinstats_item.coinstats_accounts.build(
+ name: "Second Account",
+ currency: "USD",
+ account_id: nil
+ )
+
+ assert second_account.valid?
+ end
+
+ test "upsert_coinstats_snapshot updates balance and metadata" do
+ snapshot = {
+ balance: 2500.50,
+ currency: "USD",
+ name: "Updated Wallet Name",
+ status: "active",
+ provider: "coinstats",
+ institution_logo: "https://example.com/logo.png"
+ }
+
+ @coinstats_account.upsert_coinstats_snapshot!(snapshot)
+ @coinstats_account.reload
+
+ assert_equal BigDecimal("2500.50"), @coinstats_account.current_balance
+ assert_equal "USD", @coinstats_account.currency
+ assert_equal "Updated Wallet Name", @coinstats_account.name
+ assert_equal "active", @coinstats_account.account_status
+ assert_equal "coinstats", @coinstats_account.provider
+ assert_equal({ "logo" => "https://example.com/logo.png" }, @coinstats_account.institution_metadata)
+ assert_equal snapshot.stringify_keys, @coinstats_account.raw_payload
+ end
+
+ test "upsert_coinstats_snapshot handles symbol keys" do
+ snapshot = {
+ balance: 3000.0,
+ currency: "USD",
+ name: "Symbol Key Wallet"
+ }
+
+ @coinstats_account.upsert_coinstats_snapshot!(snapshot)
+ @coinstats_account.reload
+
+ assert_equal BigDecimal("3000.0"), @coinstats_account.current_balance
+ assert_equal "Symbol Key Wallet", @coinstats_account.name
+ end
+
+ test "upsert_coinstats_snapshot handles string keys" do
+ snapshot = {
+ "balance" => 3500.0,
+ "currency" => "USD",
+ "name" => "String Key Wallet"
+ }
+
+ @coinstats_account.upsert_coinstats_snapshot!(snapshot)
+ @coinstats_account.reload
+
+ assert_equal BigDecimal("3500.0"), @coinstats_account.current_balance
+ assert_equal "String Key Wallet", @coinstats_account.name
+ end
+
+ test "upsert_coinstats_snapshot sets account_id from id if not already set" do
+ @coinstats_account.update!(account_id: nil)
+
+ snapshot = {
+ id: "new_token_id_123",
+ balance: 1000.0,
+ currency: "USD",
+ name: "Test"
+ }
+
+ @coinstats_account.upsert_coinstats_snapshot!(snapshot)
+ @coinstats_account.reload
+
+ assert_equal "new_token_id_123", @coinstats_account.account_id
+ end
+
+ test "upsert_coinstats_snapshot preserves existing account_id" do
+ @coinstats_account.update!(account_id: "existing_id")
+
+ snapshot = {
+ id: "different_id",
+ balance: 1000.0,
+ currency: "USD",
+ name: "Test"
+ }
+
+ @coinstats_account.upsert_coinstats_snapshot!(snapshot)
+ @coinstats_account.reload
+
+ assert_equal "existing_id", @coinstats_account.account_id
+ end
+
+ test "upsert_coinstats_transactions_snapshot stores transactions array" do
+ transactions = [
+ { type: "Received", date: "2025-01-01T10:00:00.000Z", hash: { id: "0xabc" } },
+ { type: "Sent", date: "2025-01-02T11:00:00.000Z", hash: { id: "0xdef" } }
+ ]
+
+ @coinstats_account.upsert_coinstats_transactions_snapshot!(transactions)
+ @coinstats_account.reload
+
+ assert_equal 2, @coinstats_account.raw_transactions_payload.count
+ # Keys may be strings after DB round-trip
+ first_tx = @coinstats_account.raw_transactions_payload.first
+ assert_equal "0xabc", first_tx.dig("hash", "id") || first_tx.dig(:hash, :id)
+ end
+
+ test "upsert_coinstats_transactions_snapshot extracts result from hash response" do
+ response = {
+ meta: { page: 1, limit: 100 },
+ result: [
+ { type: "Received", date: "2025-01-01T10:00:00.000Z", hash: { id: "0xabc" } }
+ ]
+ }
+
+ @coinstats_account.upsert_coinstats_transactions_snapshot!(response)
+ @coinstats_account.reload
+
+ assert_equal 1, @coinstats_account.raw_transactions_payload.count
+ assert_equal "0xabc", @coinstats_account.raw_transactions_payload.first["hash"]["id"].to_s
+ end
+
+ test "upsert_coinstats_transactions_snapshot handles empty result" do
+ response = {
+ meta: { page: 1, limit: 100 },
+ result: []
+ }
+
+ @coinstats_account.upsert_coinstats_transactions_snapshot!(response)
+ @coinstats_account.reload
+
+ assert_equal [], @coinstats_account.raw_transactions_payload
+ end
+end
diff --git a/test/models/coinstats_entry/processor_test.rb b/test/models/coinstats_entry/processor_test.rb
new file mode 100644
index 000000000..26ae19e0a
--- /dev/null
+++ b/test/models/coinstats_entry/processor_test.rb
@@ -0,0 +1,267 @@
+require "test_helper"
+
+class CoinstatsEntry::ProcessorTest < ActiveSupport::TestCase
+ setup do
+ @family = families(:dylan_family)
+ @coinstats_item = CoinstatsItem.create!(
+ family: @family,
+ name: "Test CoinStats Connection",
+ api_key: "test_api_key_123"
+ )
+ @crypto = Crypto.create!
+ @account = @family.accounts.create!(
+ accountable: @crypto,
+ name: "Test Crypto Account",
+ balance: 1000,
+ currency: "USD"
+ )
+ @coinstats_account = @coinstats_item.coinstats_accounts.create!(
+ name: "Test ETH Wallet",
+ currency: "USD",
+ current_balance: 5000,
+ institution_metadata: { "logo" => "https://example.com/eth.png" }
+ )
+ AccountProvider.create!(account: @account, provider: @coinstats_account)
+ end
+
+ test "processes received transaction" do
+ transaction_data = {
+ type: "Received",
+ date: "2025-01-15T10:00:00.000Z",
+ coinData: { count: 1.5, symbol: "ETH", currentValue: 3000 },
+ hash: { id: "0xabc123", explorerUrl: "https://etherscan.io/tx/0xabc123" }
+ }
+
+ processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
+
+ assert_difference "Entry.count", 1 do
+ processor.process
+ end
+
+ entry = @account.entries.last
+ assert_equal "coinstats_0xabc123", entry.external_id
+ assert_equal BigDecimal("-3000"), entry.amount # Negative = income
+ assert_equal "USD", entry.currency
+ assert_equal Date.new(2025, 1, 15), entry.date
+ assert_equal "Received ETH", entry.name
+ end
+
+ test "processes sent transaction" do
+ transaction_data = {
+ type: "Sent",
+ date: "2025-01-15T10:00:00.000Z",
+ coinData: { count: -0.5, symbol: "ETH", currentValue: 1000 },
+ hash: { id: "0xdef456", explorerUrl: "https://etherscan.io/tx/0xdef456" }
+ }
+
+ processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
+
+ assert_difference "Entry.count", 1 do
+ processor.process
+ end
+
+ entry = @account.entries.last
+ assert_equal BigDecimal("1000"), entry.amount # Positive = expense
+ assert_equal "Sent ETH", entry.name
+ end
+
+ test "stores extra metadata" do
+ transaction_data = {
+ type: "Received",
+ date: "2025-01-15T10:00:00.000Z",
+ coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
+ hash: { id: "0xmeta123", explorerUrl: "https://etherscan.io/tx/0xmeta123" },
+ profitLoss: { profit: 100.50, profitPercent: 5.25 },
+ fee: { count: 0.001, coin: { symbol: "ETH" }, totalWorth: 2.0 }
+ }
+
+ processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
+ processor.process
+
+ entry = @account.entries.last
+ extra = entry.transaction.extra["coinstats"]
+
+ assert_equal "0xmeta123", extra["transaction_hash"]
+ assert_equal "https://etherscan.io/tx/0xmeta123", extra["explorer_url"]
+ assert_equal "Received", extra["transaction_type"]
+ assert_equal "ETH", extra["symbol"]
+ assert_equal 1.0, extra["count"]
+ assert_equal 100.50, extra["profit"]
+ assert_equal 5.25, extra["profit_percent"]
+ assert_equal 0.001, extra["fee_amount"]
+ assert_equal "ETH", extra["fee_symbol"]
+ assert_equal 2.0, extra["fee_usd"]
+ end
+
+ test "handles UTXO transaction ID format" do
+ transaction_data = {
+ type: "Received",
+ date: "2025-01-15T10:00:00.000Z",
+ coinData: { count: 0.1, symbol: "BTC", currentValue: 4000 },
+ transactions: [
+ { items: [ { id: "utxo_tx_id_123" } ] }
+ ]
+ }
+
+ processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
+ processor.process
+
+ entry = @account.entries.last
+ assert_equal "coinstats_utxo_tx_id_123", entry.external_id
+ end
+
+ test "generates fallback ID when no hash available" do
+ transaction_data = {
+ type: "Swap",
+ date: "2025-01-15T10:00:00.000Z",
+ coinData: { count: 100, symbol: "USDC", currentValue: 100 }
+ }
+
+ processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
+ processor.process
+
+ entry = @account.entries.last
+ # Fallback IDs use a hash digest format: "coinstats_fallback_<16-char-hex>"
+ assert_match(/^coinstats_fallback_[a-f0-9]{16}$/, entry.external_id)
+ end
+
+ test "raises error when transaction missing identifier" do
+ transaction_data = {
+ type: nil,
+ date: nil,
+ coinData: { count: nil }
+ }
+
+ processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
+
+ assert_raises(ArgumentError) do
+ processor.process
+ end
+ end
+
+ test "skips processing when no linked account" do
+ unlinked_account = @coinstats_item.coinstats_accounts.create!(
+ name: "Unlinked",
+ currency: "USD"
+ )
+
+ transaction_data = {
+ type: "Received",
+ date: "2025-01-15T10:00:00.000Z",
+ coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
+ hash: { id: "0xskip123" }
+ }
+
+ processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: unlinked_account)
+
+ assert_no_difference "Entry.count" do
+ result = processor.process
+ assert_nil result
+ end
+ end
+
+ test "creates notes with transaction details" do
+ transaction_data = {
+ type: "Received",
+ date: "2025-01-15T10:00:00.000Z",
+ coinData: { count: 1.5, symbol: "ETH", currentValue: 3000 },
+ hash: { id: "0xnotes123", explorerUrl: "https://etherscan.io/tx/0xnotes123" },
+ profitLoss: { profit: 150.00, profitPercent: 10.0 },
+ fee: { count: 0.002, coin: { symbol: "ETH" }, totalWorth: 4.0 }
+ }
+
+ processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
+ processor.process
+
+ entry = @account.entries.last
+ assert_includes entry.notes, "1.5 ETH"
+ assert_includes entry.notes, "Fee: 0.002 ETH"
+ assert_includes entry.notes, "P/L: $150.0 (10.0%)"
+ assert_includes entry.notes, "Explorer: https://etherscan.io/tx/0xnotes123"
+ end
+
+ test "handles integer timestamp" do
+ timestamp = Time.new(2025, 1, 15, 10, 0, 0).to_i
+
+ transaction_data = {
+ type: "Received",
+ date: timestamp,
+ coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
+ hash: { id: "0xtimestamp123" }
+ }
+
+ processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
+ processor.process
+
+ entry = @account.entries.last
+ assert_equal Date.new(2025, 1, 15), entry.date
+ end
+
+ test "raises error for missing date" do
+ transaction_data = {
+ type: "Received",
+ coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
+ hash: { id: "0xnodate123" }
+ }
+
+ processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
+
+ assert_raises(ArgumentError) do
+ processor.process
+ end
+ end
+
+ test "builds name with symbol preferring it over coin name" do
+ transaction_data = {
+ type: "Received",
+ date: "2025-01-15T10:00:00.000Z",
+ coinData: { count: 1.0, symbol: "WETH" },
+ hash: { id: "0xname123" },
+ profitLoss: { currentValue: 2000 },
+ transactions: [
+ { items: [ { coin: { name: "Wrapped Ether" } } ] }
+ ]
+ }
+
+ processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
+ processor.process
+
+ entry = @account.entries.last
+ assert_equal "Received WETH", entry.name
+ end
+
+ test "handles swap out as outgoing transaction" do
+ transaction_data = {
+ type: "swap_out",
+ date: "2025-01-15T10:00:00.000Z",
+ coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
+ hash: { id: "0xswap123" }
+ }
+
+ processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
+ processor.process
+
+ entry = @account.entries.last
+ assert_equal BigDecimal("2000"), entry.amount # Positive = expense/outflow
+ end
+
+ test "is idempotent - does not duplicate transactions" do
+ transaction_data = {
+ type: "Received",
+ date: "2025-01-15T10:00:00.000Z",
+ coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
+ hash: { id: "0xidempotent123" }
+ }
+
+ processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
+
+ assert_difference "Entry.count", 1 do
+ processor.process
+ end
+
+ # Processing again should not create duplicate
+ assert_no_difference "Entry.count" do
+ processor.process
+ end
+ end
+end
diff --git a/test/models/coinstats_item/importer_test.rb b/test/models/coinstats_item/importer_test.rb
new file mode 100644
index 000000000..3f1de5c37
--- /dev/null
+++ b/test/models/coinstats_item/importer_test.rb
@@ -0,0 +1,480 @@
+require "test_helper"
+
+class CoinstatsItem::ImporterTest < ActiveSupport::TestCase
+ setup do
+ @family = families(:dylan_family)
+ @coinstats_item = CoinstatsItem.create!(
+ family: @family,
+ name: "Test CoinStats Connection",
+ api_key: "test_api_key_123"
+ )
+
+ @mock_provider = mock("Provider::Coinstats")
+ end
+
+ # Helper to wrap data in Provider::Response
+ def success_response(data)
+ Provider::Response.new(success?: true, data: data, error: nil)
+ end
+
+ def error_response(message)
+ Provider::Response.new(success?: false, data: nil, error: Provider::Error.new(message))
+ end
+
+ test "returns early when no linked accounts" do
+ importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider)
+
+ result = importer.import
+
+ assert result[:success]
+ assert_equal 0, result[:accounts_updated]
+ assert_equal 0, result[:transactions_imported]
+ end
+
+ test "updates linked accounts with balance data" do
+ # Create a linked coinstats account
+ crypto = Crypto.create!
+ account = @family.accounts.create!(
+ accountable: crypto,
+ name: "Test Ethereum",
+ balance: 1000,
+ currency: "USD"
+ )
+ coinstats_account = @coinstats_item.coinstats_accounts.create!(
+ name: "Ethereum Wallet",
+ currency: "USD",
+ raw_payload: { address: "0x123abc", blockchain: "ethereum" }
+ )
+ AccountProvider.create!(account: account, provider: coinstats_account)
+
+ # Mock balance response
+ balance_data = [
+ { coinId: "ethereum", name: "Ethereum", symbol: "ETH", amount: 1.5, price: 2000, imgUrl: "https://example.com/eth.png" }
+ ]
+
+ bulk_response = [
+ { blockchain: "ethereum", address: "0x123abc", connectionId: "ethereum", balances: balance_data }
+ ]
+
+ @mock_provider.expects(:get_wallet_balances)
+ .with("ethereum:0x123abc")
+ .returns(success_response(bulk_response))
+
+ @mock_provider.expects(:extract_wallet_balance)
+ .with(bulk_response, "0x123abc", "ethereum")
+ .returns(balance_data)
+
+ bulk_transactions_response = [
+ { blockchain: "ethereum", address: "0x123abc", connectionId: "ethereum", transactions: [] }
+ ]
+
+ @mock_provider.expects(:get_wallet_transactions)
+ .with("ethereum:0x123abc")
+ .returns(success_response(bulk_transactions_response))
+
+ @mock_provider.expects(:extract_wallet_transactions)
+ .with(bulk_transactions_response, "0x123abc", "ethereum")
+ .returns([])
+
+ importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider)
+ result = importer.import
+
+ assert result[:success]
+ assert_equal 1, result[:accounts_updated]
+ assert_equal 0, result[:accounts_failed]
+ end
+
+ test "skips account when missing address or blockchain" do
+ # Create a linked account with missing wallet info
+ crypto = Crypto.create!
+ account = @family.accounts.create!(
+ accountable: crypto,
+ name: "Test Crypto",
+ balance: 1000,
+ currency: "USD"
+ )
+ coinstats_account = @coinstats_item.coinstats_accounts.create!(
+ name: "Missing Info Wallet",
+ currency: "USD",
+ raw_payload: {} # Missing address and blockchain
+ )
+ AccountProvider.create!(account: account, provider: coinstats_account)
+
+ importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider)
+ result = importer.import
+
+ # The import succeeds but no accounts are updated (missing info returns success: false)
+ assert result[:success] # No exceptions = success
+ assert_equal 0, result[:accounts_updated]
+ assert_equal 0, result[:accounts_failed] # Doesn't count as "failed" - only exceptions do
+ end
+
+ test "imports transactions and merges with existing" do
+ # Create a linked coinstats account with existing transactions
+ crypto = Crypto.create!
+ account = @family.accounts.create!(
+ accountable: crypto,
+ name: "Test Ethereum",
+ balance: 1000,
+ currency: "USD"
+ )
+ coinstats_account = @coinstats_item.coinstats_accounts.create!(
+ name: "Ethereum Wallet",
+ currency: "USD",
+ account_id: "ethereum",
+ raw_payload: { address: "0x123abc", blockchain: "ethereum" },
+ raw_transactions_payload: [
+ { hash: { id: "0xexisting1" }, type: "Received", date: "2025-01-01T10:00:00.000Z", transactions: [ { items: [ { coin: { id: "ethereum" } } ] } ] }
+ ]
+ )
+ AccountProvider.create!(account: account, provider: coinstats_account)
+
+ balance_data = [
+ { coinId: "ethereum", name: "Ethereum", symbol: "ETH", amount: 2.0, price: 2500 }
+ ]
+
+ bulk_response = [
+ { blockchain: "ethereum", address: "0x123abc", connectionId: "ethereum", balances: balance_data }
+ ]
+
+ @mock_provider.expects(:get_wallet_balances)
+ .with("ethereum:0x123abc")
+ .returns(success_response(bulk_response))
+
+ @mock_provider.expects(:extract_wallet_balance)
+ .with(bulk_response, "0x123abc", "ethereum")
+ .returns(balance_data)
+
+ new_transactions = [
+ { hash: { id: "0xexisting1" }, type: "Received", date: "2025-01-01T10:00:00.000Z", transactions: [ { items: [ { coin: { id: "ethereum" } } ] } ] }, # duplicate
+ { hash: { id: "0xnew1" }, type: "Sent", date: "2025-01-02T11:00:00.000Z", transactions: [ { items: [ { coin: { id: "ethereum" } } ] } ] } # new
+ ]
+
+ bulk_transactions_response = [
+ { blockchain: "ethereum", address: "0x123abc", connectionId: "ethereum", transactions: new_transactions }
+ ]
+
+ @mock_provider.expects(:get_wallet_transactions)
+ .with("ethereum:0x123abc")
+ .returns(success_response(bulk_transactions_response))
+
+ @mock_provider.expects(:extract_wallet_transactions)
+ .with(bulk_transactions_response, "0x123abc", "ethereum")
+ .returns(new_transactions)
+
+ importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider)
+ result = importer.import
+
+ assert result[:success]
+ assert_equal 1, result[:accounts_updated]
+
+ # Should have 2 transactions (1 existing + 1 new, no duplicate)
+ coinstats_account.reload
+ assert_equal 2, coinstats_account.raw_transactions_payload.count
+ end
+
+ test "handles rate limit error during transactions fetch gracefully" do
+ crypto = Crypto.create!
+ account = @family.accounts.create!(
+ accountable: crypto,
+ name: "Test Ethereum",
+ balance: 1000,
+ currency: "USD"
+ )
+ coinstats_account = @coinstats_item.coinstats_accounts.create!(
+ name: "Ethereum Wallet",
+ currency: "USD",
+ raw_payload: { address: "0x123abc", blockchain: "ethereum" }
+ )
+ AccountProvider.create!(account: account, provider: coinstats_account)
+
+ balance_data = [
+ { coinId: "ethereum", name: "Ethereum", symbol: "ETH", amount: 1.0, price: 2000 }
+ ]
+
+ bulk_response = [
+ { blockchain: "ethereum", address: "0x123abc", connectionId: "ethereum", balances: balance_data }
+ ]
+
+ @mock_provider.expects(:get_wallet_balances)
+ .with("ethereum:0x123abc")
+ .returns(success_response(bulk_response))
+
+ @mock_provider.expects(:extract_wallet_balance)
+ .with(bulk_response, "0x123abc", "ethereum")
+ .returns(balance_data)
+
+ # Bulk transaction fetch fails with error - returns error response from fetch_transactions_for_accounts
+ @mock_provider.expects(:get_wallet_transactions)
+ .with("ethereum:0x123abc")
+ .raises(Provider::Coinstats::Error.new("Rate limited"))
+
+ # When bulk fetch fails, extract_wallet_transactions is not called (bulk_transactions_data is nil)
+
+ importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider)
+ result = importer.import
+
+ # Should still succeed since balance was updated
+ assert result[:success]
+ assert_equal 1, result[:accounts_updated]
+ assert_equal 0, result[:transactions_imported]
+ end
+
+ test "calculates balance from matching token only, not all tokens" do
+ # Create two accounts for different tokens in the same wallet
+ crypto1 = Crypto.create!
+ account1 = @family.accounts.create!(
+ accountable: crypto1,
+ name: "Ethereum (0xmu...ulti)",
+ balance: 0,
+ currency: "USD"
+ )
+ coinstats_account1 = @coinstats_item.coinstats_accounts.create!(
+ name: "Ethereum (0xmu...ulti)",
+ currency: "USD",
+ account_id: "ethereum",
+ raw_payload: { address: "0xmulti", blockchain: "ethereum" }
+ )
+ AccountProvider.create!(account: account1, provider: coinstats_account1)
+
+ crypto2 = Crypto.create!
+ account2 = @family.accounts.create!(
+ accountable: crypto2,
+ name: "Dai Stablecoin (0xmu...ulti)",
+ balance: 0,
+ currency: "USD"
+ )
+ coinstats_account2 = @coinstats_item.coinstats_accounts.create!(
+ name: "Dai Stablecoin (0xmu...ulti)",
+ currency: "USD",
+ account_id: "dai",
+ raw_payload: { address: "0xmulti", blockchain: "ethereum" }
+ )
+ AccountProvider.create!(account: account2, provider: coinstats_account2)
+
+ # Multiple tokens with different values
+ balance_data = [
+ { coinId: "ethereum", name: "Ethereum", symbol: "ETH", amount: 2.0, price: 2000 }, # $4000
+ { coinId: "dai", name: "Dai Stablecoin", symbol: "DAI", amount: 1000, price: 1 } # $1000
+ ]
+
+ # Both accounts share the same wallet address/blockchain, so only one unique wallet
+ bulk_response = [
+ { blockchain: "ethereum", address: "0xmulti", connectionId: "ethereum", balances: balance_data }
+ ]
+
+ @mock_provider.expects(:get_wallet_balances)
+ .with("ethereum:0xmulti")
+ .returns(success_response(bulk_response))
+
+ @mock_provider.expects(:extract_wallet_balance)
+ .with(bulk_response, "0xmulti", "ethereum")
+ .returns(balance_data)
+ .twice
+
+ bulk_transactions_response = [
+ { blockchain: "ethereum", address: "0xmulti", connectionId: "ethereum", transactions: [] }
+ ]
+
+ @mock_provider.expects(:get_wallet_transactions)
+ .with("ethereum:0xmulti")
+ .returns(success_response(bulk_transactions_response))
+
+ @mock_provider.expects(:extract_wallet_transactions)
+ .with(bulk_transactions_response, "0xmulti", "ethereum")
+ .returns([])
+ .twice
+
+ importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider)
+ importer.import
+
+ coinstats_account1.reload
+ coinstats_account2.reload
+
+ # Each account should have only its matching token's balance, not the total
+ # ETH: 2.0 * 2000 = $4000
+ assert_equal 4000.0, coinstats_account1.current_balance.to_f
+ # DAI: 1000 * 1 = $1000
+ assert_equal 1000.0, coinstats_account2.current_balance.to_f
+ end
+
+ test "handles api errors for individual accounts without failing entire import" do
+ crypto1 = Crypto.create!
+ account1 = @family.accounts.create!(
+ accountable: crypto1,
+ name: "Working Wallet",
+ balance: 1000,
+ currency: "USD"
+ )
+ coinstats_account1 = @coinstats_item.coinstats_accounts.create!(
+ name: "Working Wallet",
+ currency: "USD",
+ raw_payload: { address: "0xworking", blockchain: "ethereum" }
+ )
+ AccountProvider.create!(account: account1, provider: coinstats_account1)
+
+ crypto2 = Crypto.create!
+ account2 = @family.accounts.create!(
+ accountable: crypto2,
+ name: "Failing Wallet",
+ balance: 500,
+ currency: "USD"
+ )
+ coinstats_account2 = @coinstats_item.coinstats_accounts.create!(
+ name: "Failing Wallet",
+ currency: "USD",
+ raw_payload: { address: "0xfailing", blockchain: "ethereum" }
+ )
+ AccountProvider.create!(account: account2, provider: coinstats_account2)
+
+ # With multiple wallets, bulk endpoint is used
+ # Bulk response includes only the working wallet's data
+ bulk_response = [
+ {
+ blockchain: "ethereum",
+ address: "0xworking",
+ connectionId: "ethereum",
+ balances: [ { coinId: "ethereum", name: "Ethereum", amount: 1.0, price: 2000 } ]
+ }
+ # 0xfailing not included - simulates partial failure or missing data
+ ]
+
+ @mock_provider.expects(:get_wallet_balances)
+ .with("ethereum:0xworking,ethereum:0xfailing")
+ .returns(success_response(bulk_response))
+
+ @mock_provider.expects(:extract_wallet_balance)
+ .with(bulk_response, "0xworking", "ethereum")
+ .returns([ { coinId: "ethereum", name: "Ethereum", amount: 1.0, price: 2000 } ])
+
+ @mock_provider.expects(:extract_wallet_balance)
+ .with(bulk_response, "0xfailing", "ethereum")
+ .returns([]) # Empty array for missing wallet
+
+ bulk_transactions_response = [
+ {
+ blockchain: "ethereum",
+ address: "0xworking",
+ connectionId: "ethereum",
+ transactions: []
+ }
+ ]
+
+ @mock_provider.expects(:get_wallet_transactions)
+ .with("ethereum:0xworking,ethereum:0xfailing")
+ .returns(success_response(bulk_transactions_response))
+
+ @mock_provider.expects(:extract_wallet_transactions)
+ .with(bulk_transactions_response, "0xworking", "ethereum")
+ .returns([])
+
+ @mock_provider.expects(:extract_wallet_transactions)
+ .with(bulk_transactions_response, "0xfailing", "ethereum")
+ .returns([])
+
+ importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider)
+ result = importer.import
+
+ assert result[:success] # Both accounts updated (one with empty balance)
+ assert_equal 2, result[:accounts_updated]
+ assert_equal 0, result[:accounts_failed]
+ end
+
+ test "uses bulk endpoint for multiple unique wallets and falls back on error" do
+ # Create accounts with two different wallet addresses
+ crypto1 = Crypto.create!
+ account1 = @family.accounts.create!(
+ accountable: crypto1,
+ name: "Ethereum Wallet",
+ balance: 0,
+ currency: "USD"
+ )
+ coinstats_account1 = @coinstats_item.coinstats_accounts.create!(
+ name: "Ethereum Wallet",
+ currency: "USD",
+ raw_payload: { address: "0xeth123", blockchain: "ethereum" }
+ )
+ AccountProvider.create!(account: account1, provider: coinstats_account1)
+
+ crypto2 = Crypto.create!
+ account2 = @family.accounts.create!(
+ accountable: crypto2,
+ name: "Bitcoin Wallet",
+ balance: 0,
+ currency: "USD"
+ )
+ coinstats_account2 = @coinstats_item.coinstats_accounts.create!(
+ name: "Bitcoin Wallet",
+ currency: "USD",
+ raw_payload: { address: "bc1qbtc456", blockchain: "bitcoin" }
+ )
+ AccountProvider.create!(account: account2, provider: coinstats_account2)
+
+ # Bulk endpoint returns data for both wallets
+ bulk_response = [
+ {
+ blockchain: "ethereum",
+ address: "0xeth123",
+ connectionId: "ethereum",
+ balances: [ { coinId: "ethereum", name: "Ethereum", amount: 2.0, price: 2500 } ]
+ },
+ {
+ blockchain: "bitcoin",
+ address: "bc1qbtc456",
+ connectionId: "bitcoin",
+ balances: [ { coinId: "bitcoin", name: "Bitcoin", amount: 0.1, price: 45000 } ]
+ }
+ ]
+
+ @mock_provider.expects(:get_wallet_balances)
+ .with("ethereum:0xeth123,bitcoin:bc1qbtc456")
+ .returns(success_response(bulk_response))
+
+ @mock_provider.expects(:extract_wallet_balance)
+ .with(bulk_response, "0xeth123", "ethereum")
+ .returns([ { coinId: "ethereum", name: "Ethereum", amount: 2.0, price: 2500 } ])
+
+ @mock_provider.expects(:extract_wallet_balance)
+ .with(bulk_response, "bc1qbtc456", "bitcoin")
+ .returns([ { coinId: "bitcoin", name: "Bitcoin", amount: 0.1, price: 45000 } ])
+
+ bulk_transactions_response = [
+ {
+ blockchain: "ethereum",
+ address: "0xeth123",
+ connectionId: "ethereum",
+ transactions: []
+ },
+ {
+ blockchain: "bitcoin",
+ address: "bc1qbtc456",
+ connectionId: "bitcoin",
+ transactions: []
+ }
+ ]
+
+ @mock_provider.expects(:get_wallet_transactions)
+ .with("ethereum:0xeth123,bitcoin:bc1qbtc456")
+ .returns(success_response(bulk_transactions_response))
+
+ @mock_provider.expects(:extract_wallet_transactions)
+ .with(bulk_transactions_response, "0xeth123", "ethereum")
+ .returns([])
+
+ @mock_provider.expects(:extract_wallet_transactions)
+ .with(bulk_transactions_response, "bc1qbtc456", "bitcoin")
+ .returns([])
+
+ importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider)
+ result = importer.import
+
+ assert result[:success]
+ assert_equal 2, result[:accounts_updated]
+
+ # Verify balances were updated
+ coinstats_account1.reload
+ coinstats_account2.reload
+ assert_equal 5000.0, coinstats_account1.current_balance.to_f # 2.0 * 2500
+ assert_equal 4500.0, coinstats_account2.current_balance.to_f # 0.1 * 45000
+ end
+end
diff --git a/test/models/coinstats_item/syncer_test.rb b/test/models/coinstats_item/syncer_test.rb
new file mode 100644
index 000000000..1e63becad
--- /dev/null
+++ b/test/models/coinstats_item/syncer_test.rb
@@ -0,0 +1,177 @@
+require "test_helper"
+
+class CoinstatsItem::SyncerTest < ActiveSupport::TestCase
+ setup do
+ @family = families(:dylan_family)
+ @coinstats_item = CoinstatsItem.create!(
+ family: @family,
+ name: "Test CoinStats Connection",
+ api_key: "test_api_key_123"
+ )
+ @syncer = CoinstatsItem::Syncer.new(@coinstats_item)
+ end
+
+ test "perform_sync imports data from coinstats api" do
+ mock_sync = mock("sync")
+ mock_sync.stubs(:respond_to?).with(:status_text).returns(true)
+ mock_sync.stubs(:respond_to?).with(:sync_stats).returns(true)
+ mock_sync.stubs(:window_start_date).returns(nil)
+ mock_sync.stubs(:window_end_date).returns(nil)
+ mock_sync.expects(:update!).at_least_once
+
+ @coinstats_item.expects(:import_latest_coinstats_data).once
+
+ @syncer.perform_sync(mock_sync)
+ end
+
+ test "perform_sync updates pending_account_setup when unlinked accounts exist" do
+ # Create an unlinked coinstats account (no AccountProvider)
+ @coinstats_item.coinstats_accounts.create!(
+ name: "Unlinked Wallet",
+ currency: "USD"
+ )
+
+ mock_sync = mock("sync")
+ mock_sync.stubs(:respond_to?).with(:status_text).returns(true)
+ mock_sync.stubs(:respond_to?).with(:sync_stats).returns(true)
+ mock_sync.stubs(:window_start_date).returns(nil)
+ mock_sync.stubs(:window_end_date).returns(nil)
+ mock_sync.expects(:update!).at_least_once
+
+ @coinstats_item.expects(:import_latest_coinstats_data).once
+
+ @syncer.perform_sync(mock_sync)
+
+ assert @coinstats_item.reload.pending_account_setup?
+ end
+
+ test "perform_sync clears pending_account_setup when all accounts linked" do
+ @coinstats_item.update!(pending_account_setup: true)
+
+ # Create a linked coinstats account
+ crypto = Crypto.create!
+ account = @family.accounts.create!(
+ accountable: crypto,
+ name: "Test Crypto",
+ balance: 1000,
+ currency: "USD"
+ )
+ coinstats_account = @coinstats_item.coinstats_accounts.create!(
+ name: "Linked Wallet",
+ currency: "USD"
+ )
+ AccountProvider.create!(account: account, provider: coinstats_account)
+
+ mock_sync = mock("sync")
+ mock_sync.stubs(:respond_to?).with(:status_text).returns(true)
+ mock_sync.stubs(:respond_to?).with(:sync_stats).returns(true)
+ mock_sync.stubs(:window_start_date).returns(nil)
+ mock_sync.stubs(:window_end_date).returns(nil)
+ mock_sync.expects(:update!).at_least_once
+
+ @coinstats_item.expects(:import_latest_coinstats_data).once
+ @coinstats_item.expects(:process_accounts).once
+ @coinstats_item.expects(:schedule_account_syncs).once
+
+ @syncer.perform_sync(mock_sync)
+
+ refute @coinstats_item.reload.pending_account_setup?
+ end
+
+ test "perform_sync processes accounts when linked accounts exist" do
+ # Create a linked coinstats account
+ crypto = Crypto.create!
+ account = @family.accounts.create!(
+ accountable: crypto,
+ name: "Test Crypto",
+ balance: 1000,
+ currency: "USD"
+ )
+ coinstats_account = @coinstats_item.coinstats_accounts.create!(
+ name: "Linked Wallet",
+ currency: "USD"
+ )
+ AccountProvider.create!(account: account, provider: coinstats_account)
+
+ mock_sync = mock("sync")
+ mock_sync.stubs(:respond_to?).with(:status_text).returns(true)
+ mock_sync.stubs(:respond_to?).with(:sync_stats).returns(true)
+ mock_sync.stubs(:window_start_date).returns(nil)
+ mock_sync.stubs(:window_end_date).returns(nil)
+ mock_sync.expects(:update!).at_least_once
+
+ @coinstats_item.expects(:import_latest_coinstats_data).once
+ @coinstats_item.expects(:process_accounts).once
+ @coinstats_item.expects(:schedule_account_syncs).with(
+ parent_sync: mock_sync,
+ window_start_date: nil,
+ window_end_date: nil
+ ).once
+
+ @syncer.perform_sync(mock_sync)
+ end
+
+ test "perform_sync skips processing when no linked accounts" do
+ mock_sync = mock("sync")
+ mock_sync.stubs(:respond_to?).with(:status_text).returns(true)
+ mock_sync.stubs(:respond_to?).with(:sync_stats).returns(true)
+ mock_sync.stubs(:window_start_date).returns(nil)
+ mock_sync.stubs(:window_end_date).returns(nil)
+ mock_sync.expects(:update!).at_least_once
+
+ @coinstats_item.expects(:import_latest_coinstats_data).once
+ @coinstats_item.expects(:process_accounts).never
+ @coinstats_item.expects(:schedule_account_syncs).never
+
+ @syncer.perform_sync(mock_sync)
+ end
+
+ test "perform_sync records sync stats" do
+ # Create one linked and one unlinked account
+ crypto = Crypto.create!
+ account = @family.accounts.create!(
+ accountable: crypto,
+ name: "Test Crypto",
+ balance: 1000,
+ currency: "USD"
+ )
+ linked_account = @coinstats_item.coinstats_accounts.create!(
+ name: "Linked Wallet",
+ currency: "USD"
+ )
+ AccountProvider.create!(account: account, provider: linked_account)
+
+ @coinstats_item.coinstats_accounts.create!(
+ name: "Unlinked Wallet",
+ currency: "USD"
+ )
+
+ recorded_stats = nil
+ mock_sync = mock("sync")
+ mock_sync.stubs(:respond_to?).with(:status_text).returns(true)
+ mock_sync.stubs(:respond_to?).with(:sync_stats).returns(true)
+ mock_sync.stubs(:window_start_date).returns(nil)
+ mock_sync.stubs(:window_end_date).returns(nil)
+ mock_sync.expects(:update!).at_least_once.with do |args|
+ recorded_stats = args[:sync_stats] if args.key?(:sync_stats)
+ true
+ end
+
+ @coinstats_item.expects(:import_latest_coinstats_data).once
+ @coinstats_item.expects(:process_accounts).once
+ @coinstats_item.expects(:schedule_account_syncs).once
+
+ @syncer.perform_sync(mock_sync)
+
+ assert_equal 2, recorded_stats[:total_accounts]
+ assert_equal 1, recorded_stats[:linked_accounts]
+ assert_equal 1, recorded_stats[:unlinked_accounts]
+ end
+
+ test "perform_post_sync is a no-op" do
+ # Just ensure it doesn't raise
+ assert_nothing_raised do
+ @syncer.perform_post_sync
+ end
+ end
+end
diff --git a/test/models/coinstats_item/wallet_linker_test.rb b/test/models/coinstats_item/wallet_linker_test.rb
new file mode 100644
index 000000000..2281d6d9a
--- /dev/null
+++ b/test/models/coinstats_item/wallet_linker_test.rb
@@ -0,0 +1,280 @@
+require "test_helper"
+
+class CoinstatsItem::WalletLinkerTest < ActiveSupport::TestCase
+ setup do
+ @family = families(:dylan_family)
+ @coinstats_item = CoinstatsItem.create!(
+ family: @family,
+ name: "Test CoinStats Connection",
+ api_key: "test_api_key_123"
+ )
+ end
+
+ # Helper to wrap data in Provider::Response
+ def success_response(data)
+ Provider::Response.new(success?: true, data: data, error: nil)
+ end
+
+ test "link returns failure when no tokens found" do
+ Provider::Coinstats.any_instance.expects(:get_wallet_balances)
+ .with("ethereum:0x123abc")
+ .returns(success_response([]))
+
+ Provider::Coinstats.any_instance.expects(:extract_wallet_balance)
+ .with([], "0x123abc", "ethereum")
+ .returns([])
+
+ linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0x123abc", blockchain: "ethereum")
+ result = linker.link
+
+ refute result.success?
+ assert_equal 0, result.created_count
+ assert_includes result.errors, "No tokens found for wallet"
+ end
+
+ test "link creates account from single token" do
+ token_data = [
+ {
+ coinId: "ethereum",
+ name: "Ethereum",
+ symbol: "ETH",
+ amount: 1.5,
+ price: 2000,
+ imgUrl: "https://example.com/eth.png"
+ }
+ ]
+
+ bulk_response = [
+ { blockchain: "ethereum", address: "0x123abc", connectionId: "ethereum", balances: token_data }
+ ]
+
+ Provider::Coinstats.any_instance.expects(:get_wallet_balances)
+ .with("ethereum:0x123abc")
+ .returns(success_response(bulk_response))
+
+ Provider::Coinstats.any_instance.expects(:extract_wallet_balance)
+ .with(bulk_response, "0x123abc", "ethereum")
+ .returns(token_data)
+
+ linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0x123abc", blockchain: "ethereum")
+
+ assert_difference [ "Account.count", "CoinstatsAccount.count", "AccountProvider.count" ], 1 do
+ result = linker.link
+ assert result.success?
+ assert_equal 1, result.created_count
+ assert_empty result.errors
+ end
+
+ # Verify the account was created correctly
+ coinstats_account = @coinstats_item.coinstats_accounts.last
+ # Note: upsert_coinstats_snapshot! overwrites name with raw token name
+ assert_equal "Ethereum", coinstats_account.name
+ assert_equal "USD", coinstats_account.currency
+ assert_equal 3000.0, coinstats_account.current_balance.to_f # 1.5 * 2000
+
+ account = coinstats_account.account
+ # Account name is set before upsert_coinstats_snapshot so it keeps the formatted name
+ assert_equal "Ethereum (0x12...3abc)", account.name
+ assert_equal 3000.0, account.balance.to_f
+ assert_equal "USD", account.currency
+ assert_equal "Crypto", account.accountable_type
+ end
+
+ test "link creates multiple accounts from multiple tokens" do
+ token_data = [
+ { coinId: "ethereum", name: "Ethereum", symbol: "ETH", amount: 2.0, price: 2000 },
+ { coinId: "dai", name: "Dai Stablecoin", symbol: "DAI", amount: 1000, price: 1 }
+ ]
+
+ bulk_response = [
+ { blockchain: "ethereum", address: "0xmulti", connectionId: "ethereum", balances: token_data }
+ ]
+
+ Provider::Coinstats.any_instance.expects(:get_wallet_balances)
+ .with("ethereum:0xmulti")
+ .returns(success_response(bulk_response))
+
+ Provider::Coinstats.any_instance.expects(:extract_wallet_balance)
+ .with(bulk_response, "0xmulti", "ethereum")
+ .returns(token_data)
+
+ linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0xmulti", blockchain: "ethereum")
+
+ assert_difference "Account.count", 2 do
+ assert_difference "CoinstatsAccount.count", 2 do
+ result = linker.link
+ assert result.success?
+ assert_equal 2, result.created_count
+ end
+ end
+ end
+
+ test "link triggers sync after creating accounts" do
+ token_data = [
+ { coinId: "ethereum", name: "Ethereum", amount: 1.0, price: 2000 }
+ ]
+
+ bulk_response = [
+ { blockchain: "ethereum", address: "0x123", connectionId: "ethereum", balances: token_data }
+ ]
+
+ Provider::Coinstats.any_instance.expects(:get_wallet_balances).returns(success_response(bulk_response))
+ Provider::Coinstats.any_instance.expects(:extract_wallet_balance).returns(token_data)
+ @coinstats_item.expects(:sync_later).once
+
+ linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0x123", blockchain: "ethereum")
+ linker.link
+ end
+
+ test "link does not trigger sync when no accounts created" do
+ Provider::Coinstats.any_instance.expects(:get_wallet_balances).returns(success_response([]))
+ Provider::Coinstats.any_instance.expects(:extract_wallet_balance).returns([])
+ @coinstats_item.expects(:sync_later).never
+
+ linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0x123", blockchain: "ethereum")
+ linker.link
+ end
+
+ test "link stores wallet metadata in raw_payload" do
+ token_data = [
+ {
+ coinId: "ethereum",
+ name: "Ethereum",
+ symbol: "ETH",
+ amount: 1.0,
+ price: 2000,
+ imgUrl: "https://example.com/eth.png"
+ }
+ ]
+
+ bulk_response = [
+ { blockchain: "ethereum", address: "0xtest123", connectionId: "ethereum", balances: token_data }
+ ]
+
+ Provider::Coinstats.any_instance.expects(:get_wallet_balances)
+ .with("ethereum:0xtest123")
+ .returns(success_response(bulk_response))
+
+ Provider::Coinstats.any_instance.expects(:extract_wallet_balance)
+ .with(bulk_response, "0xtest123", "ethereum")
+ .returns(token_data)
+
+ linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0xtest123", blockchain: "ethereum")
+ linker.link
+
+ coinstats_account = @coinstats_item.coinstats_accounts.last
+ raw_payload = coinstats_account.raw_payload
+
+ assert_equal "0xtest123", raw_payload["address"]
+ assert_equal "ethereum", raw_payload["blockchain"]
+ assert_equal "https://example.com/eth.png", raw_payload["institution_logo"]
+ end
+
+ test "link handles account creation errors gracefully" do
+ token_data = [
+ { coinId: "ethereum", name: "Ethereum", amount: 1.0, price: 2000 },
+ { coinId: "bad", name: nil, amount: 1.0, price: 100 } # Will fail validation
+ ]
+
+ bulk_response = [
+ { blockchain: "ethereum", address: "0xtest", connectionId: "ethereum", balances: token_data }
+ ]
+
+ Provider::Coinstats.any_instance.expects(:get_wallet_balances).returns(success_response(bulk_response))
+ Provider::Coinstats.any_instance.expects(:extract_wallet_balance).returns(token_data)
+
+ # We need to mock the error scenario - name can't be blank
+ linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0xtest", blockchain: "ethereum")
+
+ result = linker.link
+
+ # Should create the valid account but have errors for the invalid one
+ assert result.success? # At least one succeeded
+ assert result.created_count >= 1
+ end
+
+ test "link builds correct account name with address suffix" do
+ token_data = [
+ { coinId: "ethereum", name: "Ethereum", amount: 1.0, price: 2000 }
+ ]
+
+ bulk_response = [
+ { blockchain: "ethereum", address: "0xABCDEF123456", connectionId: "ethereum", balances: token_data }
+ ]
+
+ Provider::Coinstats.any_instance.expects(:get_wallet_balances).returns(success_response(bulk_response))
+ Provider::Coinstats.any_instance.expects(:extract_wallet_balance).returns(token_data)
+
+ linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0xABCDEF123456", blockchain: "ethereum")
+ linker.link
+
+ # Account name includes the address suffix (created before upsert_coinstats_snapshot)
+ account = @coinstats_item.accounts.last
+ assert_equal "Ethereum (0xAB...3456)", account.name
+ end
+
+ test "link handles single token as hash instead of array" do
+ token_data = {
+ coinId: "bitcoin",
+ name: "Bitcoin",
+ symbol: "BTC",
+ amount: 0.5,
+ price: 40000
+ }
+
+ bulk_response = [
+ { blockchain: "bitcoin", address: "bc1qtest", connectionId: "bitcoin", balances: [ token_data ] }
+ ]
+
+ Provider::Coinstats.any_instance.expects(:get_wallet_balances).returns(success_response(bulk_response))
+ Provider::Coinstats.any_instance.expects(:extract_wallet_balance).returns(token_data)
+
+ linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "bc1qtest", blockchain: "bitcoin")
+
+ assert_difference "Account.count", 1 do
+ result = linker.link
+ assert result.success?
+ end
+
+ account = @coinstats_item.coinstats_accounts.last
+ assert_equal 20000.0, account.current_balance.to_f # 0.5 * 40000
+ end
+
+ test "link stores correct account_id from token" do
+ token_data = [
+ { coinId: "unique_token_123", name: "My Token", amount: 100, price: 1 }
+ ]
+
+ bulk_response = [
+ { blockchain: "ethereum", address: "0xtest", connectionId: "ethereum", balances: token_data }
+ ]
+
+ Provider::Coinstats.any_instance.expects(:get_wallet_balances).returns(success_response(bulk_response))
+ Provider::Coinstats.any_instance.expects(:extract_wallet_balance).returns(token_data)
+
+ linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0xtest", blockchain: "ethereum")
+ linker.link
+
+ coinstats_account = @coinstats_item.coinstats_accounts.last
+ assert_equal "unique_token_123", coinstats_account.account_id
+ end
+
+ test "link falls back to id field for account_id" do
+ token_data = [
+ { id: "fallback_id_456", name: "Fallback Token", amount: 50, price: 2 }
+ ]
+
+ bulk_response = [
+ { blockchain: "ethereum", address: "0xtest", connectionId: "ethereum", balances: token_data }
+ ]
+
+ Provider::Coinstats.any_instance.expects(:get_wallet_balances).returns(success_response(bulk_response))
+ Provider::Coinstats.any_instance.expects(:extract_wallet_balance).returns(token_data)
+
+ linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0xtest", blockchain: "ethereum")
+ linker.link
+
+ coinstats_account = @coinstats_item.coinstats_accounts.last
+ assert_equal "fallback_id_456", coinstats_account.account_id
+ end
+end
diff --git a/test/models/coinstats_item_test.rb b/test/models/coinstats_item_test.rb
new file mode 100644
index 000000000..37180e0df
--- /dev/null
+++ b/test/models/coinstats_item_test.rb
@@ -0,0 +1,231 @@
+require "test_helper"
+
+class CoinstatsItemTest < ActiveSupport::TestCase
+ setup do
+ @family = families(:dylan_family)
+ @coinstats_item = CoinstatsItem.create!(
+ family: @family,
+ name: "Test CoinStats Connection",
+ api_key: "test_api_key_123"
+ )
+ end
+
+ test "belongs to family" do
+ assert_equal @family, @coinstats_item.family
+ end
+
+ test "has many coinstats_accounts" do
+ account = @coinstats_item.coinstats_accounts.create!(
+ name: "Test Wallet",
+ currency: "USD",
+ current_balance: 1000.00
+ )
+
+ assert_includes @coinstats_item.coinstats_accounts, account
+ end
+
+ test "has good status by default" do
+ assert_equal "good", @coinstats_item.status
+ end
+
+ test "can be marked for deletion" do
+ refute @coinstats_item.scheduled_for_deletion?
+
+ @coinstats_item.destroy_later
+
+ assert @coinstats_item.scheduled_for_deletion?
+ end
+
+ test "is syncable" do
+ assert_respond_to @coinstats_item, :sync_later
+ assert_respond_to @coinstats_item, :syncing?
+ end
+
+ test "requires name to be present" do
+ coinstats_item = CoinstatsItem.new(family: @family, api_key: "key")
+ coinstats_item.name = nil
+
+ assert_not coinstats_item.valid?
+ assert_includes coinstats_item.errors[:name], "can't be blank"
+ end
+
+ test "requires api_key to be present" do
+ coinstats_item = CoinstatsItem.new(family: @family, name: "Test")
+ coinstats_item.api_key = nil
+
+ assert_not coinstats_item.valid?
+ assert_includes coinstats_item.errors[:api_key], "can't be blank"
+ end
+
+ test "requires api_key to be present on update" do
+ @coinstats_item.api_key = ""
+
+ assert_not @coinstats_item.valid?
+ assert_includes @coinstats_item.errors[:api_key], "can't be blank"
+ end
+
+ test "scopes work correctly" do
+ # Create one for deletion
+ item_for_deletion = CoinstatsItem.create!(
+ family: @family,
+ name: "Delete Me",
+ api_key: "delete_key",
+ scheduled_for_deletion: true
+ )
+
+ active_items = CoinstatsItem.active
+ ordered_items = CoinstatsItem.ordered
+
+ assert_includes active_items, @coinstats_item
+ refute_includes active_items, item_for_deletion
+
+ assert_equal [ @coinstats_item, item_for_deletion ].sort_by(&:created_at).reverse,
+ ordered_items.to_a
+ end
+
+ test "needs_update scope returns items requiring update" do
+ @coinstats_item.update!(status: :requires_update)
+
+ good_item = CoinstatsItem.create!(
+ family: @family,
+ name: "Good Item",
+ api_key: "good_key"
+ )
+
+ needs_update_items = CoinstatsItem.needs_update
+
+ assert_includes needs_update_items, @coinstats_item
+ refute_includes needs_update_items, good_item
+ end
+
+ test "institution display name returns name when present" do
+ assert_equal "Test CoinStats Connection", @coinstats_item.institution_display_name
+ end
+
+ test "institution display name falls back to CoinStats" do
+ # Bypass validation by using update_column
+ @coinstats_item.update_column(:name, "")
+ assert_equal "CoinStats", @coinstats_item.institution_display_name
+ end
+
+ test "credentials_configured? returns true when api_key present" do
+ assert @coinstats_item.credentials_configured?
+ end
+
+ test "credentials_configured? returns false when api_key blank" do
+ @coinstats_item.api_key = nil
+ refute @coinstats_item.credentials_configured?
+ end
+
+ test "upserts coinstats snapshot" do
+ snapshot_data = {
+ total_balance: 5000.0,
+ wallets: [ { address: "0x123", blockchain: "ethereum" } ]
+ }
+
+ @coinstats_item.upsert_coinstats_snapshot!(snapshot_data)
+ @coinstats_item.reload
+
+ # Verify key data is stored correctly (keys may be string or symbol)
+ assert_equal 5000.0, @coinstats_item.raw_payload["total_balance"]
+ assert_equal 1, @coinstats_item.raw_payload["wallets"].count
+ assert_equal "0x123", @coinstats_item.raw_payload["wallets"].first["address"]
+ end
+
+ test "has_completed_initial_setup? returns false when no accounts" do
+ refute @coinstats_item.has_completed_initial_setup?
+ end
+
+ test "has_completed_initial_setup? returns true when accounts exist" do
+ crypto = Crypto.create!
+ account = @family.accounts.create!(
+ accountable: crypto,
+ name: "Test Crypto",
+ balance: 1000,
+ currency: "USD"
+ )
+ coinstats_account = @coinstats_item.coinstats_accounts.create!(
+ name: "Test Wallet",
+ currency: "USD"
+ )
+ AccountProvider.create!(account: account, provider: coinstats_account)
+
+ assert @coinstats_item.has_completed_initial_setup?
+ end
+
+ test "linked_accounts_count returns count of accounts with provider links" do
+ # Initially no linked accounts
+ assert_equal 0, @coinstats_item.linked_accounts_count
+
+ # Create a linked account
+ crypto = Crypto.create!
+ account = @family.accounts.create!(
+ accountable: crypto,
+ name: "Test Crypto",
+ balance: 1000,
+ currency: "USD"
+ )
+ coinstats_account = @coinstats_item.coinstats_accounts.create!(
+ name: "Test Wallet",
+ currency: "USD"
+ )
+ AccountProvider.create!(account: account, provider: coinstats_account)
+
+ assert_equal 1, @coinstats_item.linked_accounts_count
+ end
+
+ test "unlinked_accounts_count returns count of accounts without provider links" do
+ # Create an unlinked account
+ @coinstats_item.coinstats_accounts.create!(
+ name: "Unlinked Wallet",
+ currency: "USD"
+ )
+
+ assert_equal 1, @coinstats_item.unlinked_accounts_count
+ end
+
+ test "sync_status_summary shows no accounts message" do
+ assert_equal "No crypto wallets found", @coinstats_item.sync_status_summary
+ end
+
+ test "sync_status_summary shows all synced message" do
+ crypto = Crypto.create!
+ account = @family.accounts.create!(
+ accountable: crypto,
+ name: "Test Crypto",
+ balance: 1000,
+ currency: "USD"
+ )
+ coinstats_account = @coinstats_item.coinstats_accounts.create!(
+ name: "Test Wallet",
+ currency: "USD"
+ )
+ AccountProvider.create!(account: account, provider: coinstats_account)
+
+ assert_equal "1 crypto wallet synced", @coinstats_item.sync_status_summary
+ end
+
+ test "sync_status_summary shows mixed status message" do
+ # Create a linked account
+ crypto = Crypto.create!
+ account = @family.accounts.create!(
+ accountable: crypto,
+ name: "Test Crypto",
+ balance: 1000,
+ currency: "USD"
+ )
+ coinstats_account = @coinstats_item.coinstats_accounts.create!(
+ name: "Linked Wallet",
+ currency: "USD"
+ )
+ AccountProvider.create!(account: account, provider: coinstats_account)
+
+ # Create an unlinked account
+ @coinstats_item.coinstats_accounts.create!(
+ name: "Unlinked Wallet",
+ currency: "USD"
+ )
+
+ assert_equal "1 crypto wallets synced, 1 need setup", @coinstats_item.sync_status_summary
+ end
+end
diff --git a/test/models/concerns/currency_normalizable_test.rb b/test/models/concerns/currency_normalizable_test.rb
new file mode 100644
index 000000000..7e916e03b
--- /dev/null
+++ b/test/models/concerns/currency_normalizable_test.rb
@@ -0,0 +1,69 @@
+require "test_helper"
+
+class CurrencyNormalizableTest < ActiveSupport::TestCase
+ # Create a test class that includes the concern
+ class TestClass
+ include CurrencyNormalizable
+
+ # Expose private method for testing
+ def test_parse_currency(value)
+ parse_currency(value)
+ end
+ end
+
+ setup do
+ @parser = TestClass.new
+ end
+
+ test "parse_currency normalizes lowercase to uppercase" do
+ assert_equal "USD", @parser.test_parse_currency("usd")
+ assert_equal "EUR", @parser.test_parse_currency("eur")
+ assert_equal "GBP", @parser.test_parse_currency("gbp")
+ end
+
+ test "parse_currency handles whitespace" do
+ assert_equal "USD", @parser.test_parse_currency(" usd ")
+ assert_equal "EUR", @parser.test_parse_currency("\teur\n")
+ end
+
+ test "parse_currency returns nil for blank values" do
+ assert_nil @parser.test_parse_currency(nil)
+ assert_nil @parser.test_parse_currency("")
+ assert_nil @parser.test_parse_currency(" ")
+ end
+
+ test "parse_currency returns nil for invalid format" do
+ assert_nil @parser.test_parse_currency("US") # Too short
+ assert_nil @parser.test_parse_currency("USDD") # Too long
+ assert_nil @parser.test_parse_currency("123") # Numbers
+ assert_nil @parser.test_parse_currency("US1") # Mixed
+ end
+
+ test "parse_currency returns nil for XXX (no currency code)" do
+ # XXX is ISO 4217 for "no currency" but not valid for monetary operations
+ assert_nil @parser.test_parse_currency("XXX")
+ assert_nil @parser.test_parse_currency("xxx")
+ end
+
+ test "parse_currency returns nil for unknown 3-letter codes" do
+ # These are 3 letters but not recognized currencies
+ assert_nil @parser.test_parse_currency("ZZZ")
+ assert_nil @parser.test_parse_currency("ABC")
+ end
+
+ test "parse_currency accepts valid ISO currencies" do
+ # Common currencies
+ assert_equal "USD", @parser.test_parse_currency("USD")
+ assert_equal "EUR", @parser.test_parse_currency("EUR")
+ assert_equal "GBP", @parser.test_parse_currency("GBP")
+ assert_equal "JPY", @parser.test_parse_currency("JPY")
+ assert_equal "CHF", @parser.test_parse_currency("CHF")
+ assert_equal "CAD", @parser.test_parse_currency("CAD")
+ assert_equal "AUD", @parser.test_parse_currency("AUD")
+
+ # Less common but valid currencies
+ assert_equal "PLN", @parser.test_parse_currency("PLN")
+ assert_equal "SEK", @parser.test_parse_currency("SEK")
+ assert_equal "NOK", @parser.test_parse_currency("NOK")
+ end
+end
diff --git a/test/models/family/syncer_test.rb b/test/models/family/syncer_test.rb
index 7fe01a7eb..026d0348f 100644
--- a/test/models/family/syncer_test.rb
+++ b/test/models/family/syncer_test.rb
@@ -27,4 +27,40 @@ class Family::SyncerTest < ActiveSupport::TestCase
assert_equal "completed", family_sync.reload.status
end
+
+ test "only applies active rules during sync" do
+ family_sync = syncs(:family)
+
+ # Create an active rule
+ active_rule = @family.rules.create!(
+ resource_type: "transaction",
+ active: true,
+ actions: [ Rule::Action.new(action_type: "exclude_transaction") ]
+ )
+
+ # Create a disabled rule
+ disabled_rule = @family.rules.create!(
+ resource_type: "transaction",
+ active: false,
+ actions: [ Rule::Action.new(action_type: "exclude_transaction") ]
+ )
+
+ syncer = Family::Syncer.new(@family)
+
+ # Stub the relation to return our specific instances so expectations work
+ @family.rules.stubs(:where).with(active: true).returns([ active_rule ])
+
+ # Expect apply_later to be called only for the active rule
+ active_rule.expects(:apply_later).once
+ disabled_rule.expects(:apply_later).never
+
+ # Mock the account and plaid item syncs to avoid side effects
+ Account.any_instance.stubs(:sync_later)
+ PlaidItem.any_instance.stubs(:sync_later)
+ SimplefinItem.any_instance.stubs(:sync_later)
+ LunchflowItem.any_instance.stubs(:sync_later)
+ EnableBankingItem.any_instance.stubs(:sync_later)
+
+ syncer.perform_sync(family_sync)
+ end
end
diff --git a/test/models/income_statement_test.rb b/test/models/income_statement_test.rb
index a2680ccb4..b152917c6 100644
--- a/test/models/income_statement_test.rb
+++ b/test/models/income_statement_test.rb
@@ -22,9 +22,10 @@ class IncomeStatementTest < ActiveSupport::TestCase
test "calculates totals for transactions" do
income_statement = IncomeStatement.new(@family)
- assert_equal Money.new(1000, @family.currency), income_statement.totals.income_money
- assert_equal Money.new(200 + 300 + 400, @family.currency), income_statement.totals.expense_money
- assert_equal 4, income_statement.totals.transactions_count
+ totals = income_statement.totals(date_range: Period.last_30_days.date_range)
+ assert_equal Money.new(1000, @family.currency), totals.income_money
+ assert_equal Money.new(200 + 300 + 400, @family.currency), totals.expense_money
+ assert_equal 4, totals.transactions_count
end
test "calculates expenses for a period" do
@@ -157,7 +158,7 @@ class IncomeStatementTest < ActiveSupport::TestCase
inflow_transaction = create_transaction(account: @credit_card_account, amount: -500, kind: "funds_movement")
income_statement = IncomeStatement.new(@family)
- totals = income_statement.totals
+ totals = income_statement.totals(date_range: Period.last_30_days.date_range)
# NOW WORKING: Excludes transfers correctly after refactoring
assert_equal 4, totals.transactions_count # Only original 4 transactions
@@ -170,7 +171,7 @@ class IncomeStatementTest < ActiveSupport::TestCase
loan_payment = create_transaction(account: @checking_account, amount: 1000, category: nil, kind: "loan_payment")
income_statement = IncomeStatement.new(@family)
- totals = income_statement.totals
+ totals = income_statement.totals(date_range: Period.last_30_days.date_range)
# CONTINUES TO WORK: Includes loan payments as expenses (loan_payment not in exclusion list)
assert_equal 5, totals.transactions_count
@@ -183,7 +184,7 @@ class IncomeStatementTest < ActiveSupport::TestCase
one_time_transaction = create_transaction(account: @checking_account, amount: 250, category: @groceries_category, kind: "one_time")
income_statement = IncomeStatement.new(@family)
- totals = income_statement.totals
+ totals = income_statement.totals(date_range: Period.last_30_days.date_range)
# NOW WORKING: Excludes one-time transactions correctly after refactoring
assert_equal 4, totals.transactions_count # Only original 4 transactions
@@ -196,7 +197,7 @@ class IncomeStatementTest < ActiveSupport::TestCase
payment_transaction = create_transaction(account: @checking_account, amount: 300, category: nil, kind: "cc_payment")
income_statement = IncomeStatement.new(@family)
- totals = income_statement.totals
+ totals = income_statement.totals(date_range: Period.last_30_days.date_range)
# NOW WORKING: Excludes payment transactions correctly after refactoring
assert_equal 4, totals.transactions_count # Only original 4 transactions
@@ -210,7 +211,7 @@ class IncomeStatementTest < ActiveSupport::TestCase
excluded_transaction_entry.update!(excluded: true)
income_statement = IncomeStatement.new(@family)
- totals = income_statement.totals
+ totals = income_statement.totals(date_range: Period.last_30_days.date_range)
# Should exclude excluded transactions
assert_equal 4, totals.transactions_count # Only original 4 transactions
@@ -278,7 +279,7 @@ class IncomeStatementTest < ActiveSupport::TestCase
create_transaction(account: @checking_account, amount: 150, category: nil)
income_statement = IncomeStatement.new(@family)
- totals = income_statement.totals
+ totals = income_statement.totals(date_range: Period.last_30_days.date_range)
# Should still include uncategorized transaction in totals
assert_equal 5, totals.transactions_count
diff --git a/test/models/lunchflow_account/investments/holdings_processor_test.rb b/test/models/lunchflow_account/investments/holdings_processor_test.rb
new file mode 100644
index 000000000..229f8cb0c
--- /dev/null
+++ b/test/models/lunchflow_account/investments/holdings_processor_test.rb
@@ -0,0 +1,265 @@
+require "test_helper"
+
+class LunchflowAccount::Investments::HoldingsProcessorTest < ActiveSupport::TestCase
+ setup do
+ @lunchflow_account = lunchflow_accounts(:investment_account)
+ @account = accounts(:investment)
+
+ # Create account_provider to link lunchflow_account to account
+ @account_provider = AccountProvider.create!(
+ account: @account,
+ provider: @lunchflow_account
+ )
+
+ # Reload to ensure associations are loaded
+ @lunchflow_account.reload
+ end
+
+ test "creates holding records from Lunchflow holdings snapshot" do
+ # Verify setup is correct
+ assert_not_nil @lunchflow_account.current_account, "Account should be linked"
+ assert_equal "Investment", @lunchflow_account.current_account.accountable_type
+
+ # Use unique dates to avoid conflicts with existing fixture holdings
+ test_holdings_payload = [
+ {
+ "security" => {
+ "name" => "iShares Inc MSCI Brazil",
+ "currency" => "USD",
+ "tickerSymbol" => "NEWTEST1",
+ "figi" => nil,
+ "cusp" => nil,
+ "isin" => nil
+ },
+ "quantity" => 5,
+ "price" => 42.15,
+ "value" => 210.75,
+ "costBasis" => 100.0,
+ "currency" => "USD",
+ "raw" => {
+ "quiltt" => {
+ "id" => "hld_test_123"
+ }
+ }
+ },
+ {
+ "security" => {
+ "name" => "Test Security",
+ "currency" => "USD",
+ "tickerSymbol" => "NEWTEST2",
+ "figi" => nil,
+ "cusp" => nil,
+ "isin" => nil
+ },
+ "quantity" => 10,
+ "price" => 150.0,
+ "value" => 1500.0,
+ "costBasis" => 1200.0,
+ "currency" => "USD",
+ "raw" => {
+ "quiltt" => {
+ "id" => "hld_test_456"
+ }
+ }
+ }
+ ]
+
+ @lunchflow_account.update!(raw_holdings_payload: test_holdings_payload)
+
+ processor = LunchflowAccount::Investments::HoldingsProcessor.new(@lunchflow_account)
+
+ assert_difference "Holding.count", 2 do
+ processor.process
+ end
+
+ holdings = Holding.where(account: @account).where.not(external_id: nil).order(:created_at)
+
+ assert_equal 2, holdings.count
+ assert_equal "USD", holdings.first.currency
+ assert_equal "lunchflow_hld_test_123", holdings.first.external_id
+ end
+
+ test "skips processing for non-investment accounts" do
+ # Create a depository account
+ depository_account = accounts(:depository)
+ depository_lunchflow_account = LunchflowAccount.create!(
+ lunchflow_item: lunchflow_items(:one),
+ account_id: "lf_depository",
+ name: "Depository",
+ currency: "USD"
+ )
+
+ AccountProvider.create!(
+ account: depository_account,
+ provider: depository_lunchflow_account
+ )
+ depository_lunchflow_account.reload
+
+ test_holdings_payload = [
+ {
+ "security" => { "name" => "Test", "tickerSymbol" => "TEST", "currency" => "USD" },
+ "quantity" => 10,
+ "price" => 100.0,
+ "value" => 1000.0,
+ "costBasis" => nil,
+ "currency" => "USD",
+ "raw" => { "quiltt" => { "id" => "hld_skip" } }
+ }
+ ]
+
+ depository_lunchflow_account.update!(raw_holdings_payload: test_holdings_payload)
+
+ processor = LunchflowAccount::Investments::HoldingsProcessor.new(depository_lunchflow_account)
+
+ assert_no_difference "Holding.count" do
+ processor.process
+ end
+ end
+
+ test "creates synthetic ticker when tickerSymbol is missing" do
+ test_holdings_payload = [
+ {
+ "security" => {
+ "name" => "Custom 401k Fund",
+ "currency" => "USD",
+ "tickerSymbol" => nil,
+ "figi" => nil,
+ "cusp" => nil,
+ "isin" => nil
+ },
+ "quantity" => 100,
+ "price" => 50.0,
+ "value" => 5000.0,
+ "costBasis" => 4500.0,
+ "currency" => "USD",
+ "raw" => {
+ "quiltt" => {
+ "id" => "hld_custom_123"
+ }
+ }
+ }
+ ]
+
+ @lunchflow_account.update!(raw_holdings_payload: test_holdings_payload)
+
+ processor = LunchflowAccount::Investments::HoldingsProcessor.new(@lunchflow_account)
+
+ assert_difference "Holding.count", 1 do
+ processor.process
+ end
+
+ holding = Holding.where(account: @account).where.not(external_id: nil).last
+ assert_equal "lunchflow_hld_custom_123", holding.external_id
+ assert_equal 100, holding.qty
+ assert_equal 5000.0, holding.amount
+ end
+
+ test "skips zero value holdings" do
+ test_holdings_payload = [
+ {
+ "security" => {
+ "name" => "Zero Position",
+ "currency" => "USD",
+ "tickerSymbol" => "ZERO",
+ "figi" => nil,
+ "cusp" => nil,
+ "isin" => nil
+ },
+ "quantity" => 0,
+ "price" => 0,
+ "value" => 0,
+ "costBasis" => nil,
+ "currency" => "USD",
+ "raw" => {
+ "quiltt" => {
+ "id" => "hld_zero"
+ }
+ }
+ }
+ ]
+
+ @lunchflow_account.update!(raw_holdings_payload: test_holdings_payload)
+
+ Security::Resolver.any_instance.stubs(:resolve).returns(securities(:aapl))
+
+ processor = LunchflowAccount::Investments::HoldingsProcessor.new(@lunchflow_account)
+
+ assert_no_difference "Holding.count" do
+ processor.process
+ end
+ end
+
+ test "handles empty holdings payload gracefully" do
+ @lunchflow_account.update!(raw_holdings_payload: [])
+
+ processor = LunchflowAccount::Investments::HoldingsProcessor.new(@lunchflow_account)
+
+ assert_no_difference "Holding.count" do
+ processor.process
+ end
+ end
+
+ test "handles nil holdings payload gracefully" do
+ @lunchflow_account.update!(raw_holdings_payload: nil)
+
+ processor = LunchflowAccount::Investments::HoldingsProcessor.new(@lunchflow_account)
+
+ assert_no_difference "Holding.count" do
+ processor.process
+ end
+ end
+
+ test "continues processing other holdings when one fails" do
+ test_holdings_payload = [
+ {
+ "security" => {
+ "name" => "Good Holding",
+ "currency" => "USD",
+ "tickerSymbol" => "GOODTEST",
+ "figi" => nil,
+ "cusp" => nil,
+ "isin" => nil
+ },
+ "quantity" => 10,
+ "price" => 100.0,
+ "value" => 1000.0,
+ "costBasis" => nil,
+ "currency" => "USD",
+ "raw" => {
+ "quiltt" => {
+ "id" => "hld_good"
+ }
+ }
+ },
+ {
+ "security" => {
+ "name" => nil, # This will cause it to skip (no name, no symbol)
+ "currency" => "USD",
+ "tickerSymbol" => nil,
+ "figi" => nil,
+ "cusp" => nil,
+ "isin" => nil
+ },
+ "quantity" => 5,
+ "price" => 50.0,
+ "value" => 250.0,
+ "costBasis" => nil,
+ "currency" => "USD",
+ "raw" => {
+ "quiltt" => {
+ "id" => "hld_bad"
+ }
+ }
+ }
+ ]
+
+ @lunchflow_account.update!(raw_holdings_payload: test_holdings_payload)
+
+ processor = LunchflowAccount::Investments::HoldingsProcessor.new(@lunchflow_account)
+
+ # Should create 1 holding (the good one)
+ assert_difference "Holding.count", 1 do
+ processor.process
+ end
+ end
+end
diff --git a/test/models/market_data_importer_test.rb b/test/models/market_data_importer_test.rb
index 2a9ff3460..37d3070fe 100644
--- a/test/models/market_data_importer_test.rb
+++ b/test/models/market_data_importer_test.rb
@@ -4,8 +4,9 @@ require "ostruct"
class MarketDataImporterTest < ActiveSupport::TestCase
include ProviderTestHelper
- SNAPSHOT_START_DATE = MarketDataImporter::SNAPSHOT_DAYS.days.ago.to_date
- PROVIDER_BUFFER = 5.days
+ SNAPSHOT_START_DATE = MarketDataImporter::SNAPSHOT_DAYS.days.ago.to_date
+ SECURITY_PRICE_BUFFER = Security::Price::Importer::PROVISIONAL_LOOKBACK_DAYS.days
+ EXCHANGE_RATE_BUFFER = 5.days
setup do
Security::Price.delete_all
@@ -39,7 +40,7 @@ class MarketDataImporterTest < ActiveSupport::TestCase
date: SNAPSHOT_START_DATE,
rate: 0.5)
- expected_start_date = (SNAPSHOT_START_DATE + 1.day) - PROVIDER_BUFFER
+ expected_start_date = (SNAPSHOT_START_DATE + 1.day) - EXCHANGE_RATE_BUFFER
end_date = Date.current.in_time_zone("America/New_York").to_date
@provider.expects(:fetch_exchange_rates)
@@ -70,7 +71,7 @@ class MarketDataImporterTest < ActiveSupport::TestCase
test "syncs security prices" do
security = Security.create!(ticker: "AAPL", exchange_operating_mic: "XNAS")
- expected_start_date = SNAPSHOT_START_DATE - PROVIDER_BUFFER
+ expected_start_date = SNAPSHOT_START_DATE - SECURITY_PRICE_BUFFER
end_date = Date.current.in_time_zone("America/New_York").to_date
@provider.expects(:fetch_security_prices)
diff --git a/test/models/provider/coinstats_adapter_test.rb b/test/models/provider/coinstats_adapter_test.rb
new file mode 100644
index 000000000..853dbcf42
--- /dev/null
+++ b/test/models/provider/coinstats_adapter_test.rb
@@ -0,0 +1,124 @@
+require "test_helper"
+
+class Provider::CoinstatsAdapterTest < ActiveSupport::TestCase
+ include ProviderAdapterTestInterface
+
+ setup do
+ @family = families(:dylan_family)
+ @coinstats_item = CoinstatsItem.create!(
+ family: @family,
+ name: "Test CoinStats Bank",
+ api_key: "test_api_key_123"
+ )
+ @coinstats_account = CoinstatsAccount.create!(
+ coinstats_item: @coinstats_item,
+ name: "CoinStats Crypto Account",
+ account_id: "cs_mock_1",
+ currency: "USD",
+ current_balance: 1000,
+ institution_metadata: {
+ "name" => "CoinStats Test Wallet",
+ "domain" => "coinstats.app",
+ "url" => "https://coinstats.app",
+ "logo" => "https://example.com/logo.png"
+ }
+ )
+ @account = accounts(:crypto)
+ @adapter = Provider::CoinstatsAdapter.new(@coinstats_account, account: @account)
+ end
+
+ def adapter
+ @adapter
+ end
+
+ # Run shared interface tests
+ test_provider_adapter_interface
+ test_syncable_interface
+ test_institution_metadata_interface
+
+ # Provider-specific tests
+ test "returns correct provider name" do
+ assert_equal "coinstats", @adapter.provider_name
+ end
+
+ test "returns correct provider type" do
+ assert_equal "CoinstatsAccount", @adapter.provider_type
+ end
+
+ test "returns coinstats item" do
+ assert_equal @coinstats_account.coinstats_item, @adapter.item
+ end
+
+ test "returns account" do
+ assert_equal @account, @adapter.account
+ end
+
+ test "can_delete_holdings? returns false" do
+ assert_equal false, @adapter.can_delete_holdings?
+ end
+
+ test "parses institution domain from institution_metadata" do
+ assert_equal "coinstats.app", @adapter.institution_domain
+ end
+
+ test "parses institution name from institution_metadata" do
+ assert_equal "CoinStats Test Wallet", @adapter.institution_name
+ end
+
+ test "parses institution url from institution_metadata" do
+ assert_equal "https://coinstats.app", @adapter.institution_url
+ end
+
+ test "returns logo_url from institution_metadata" do
+ assert_equal "https://example.com/logo.png", @adapter.logo_url
+ end
+
+ test "derives domain from url if domain is blank" do
+ @coinstats_account.update!(institution_metadata: {
+ "url" => "https://www.example.com/path"
+ })
+
+ adapter = Provider::CoinstatsAdapter.new(@coinstats_account, account: @account)
+ assert_equal "example.com", adapter.institution_domain
+ end
+
+ test "supported_account_types includes Crypto" do
+ assert_includes Provider::CoinstatsAdapter.supported_account_types, "Crypto"
+ end
+
+ test "connection_configs returns configurations when family can connect" do
+ @family.stubs(:can_connect_coinstats?).returns(true)
+
+ configs = Provider::CoinstatsAdapter.connection_configs(family: @family)
+
+ assert_equal 1, configs.length
+ assert_equal "coinstats", configs.first[:key]
+ assert_equal "CoinStats", configs.first[:name]
+ assert configs.first[:can_connect]
+ end
+
+ test "connection_configs returns empty when family cannot connect" do
+ @family.stubs(:can_connect_coinstats?).returns(false)
+
+ configs = Provider::CoinstatsAdapter.connection_configs(family: @family)
+
+ assert_equal [], configs
+ end
+
+ test "build_provider returns nil when family is nil" do
+ result = Provider::CoinstatsAdapter.build_provider(family: nil)
+ assert_nil result
+ end
+
+ test "build_provider returns nil when no coinstats_items with api_key" do
+ empty_family = families(:empty)
+ result = Provider::CoinstatsAdapter.build_provider(family: empty_family)
+ assert_nil result
+ end
+
+ test "build_provider returns Provider::Coinstats when credentials configured" do
+ result = Provider::CoinstatsAdapter.build_provider(family: @family)
+
+ assert_instance_of Provider::Coinstats, result
+ end
+end
diff --git a/test/models/provider/coinstats_test.rb b/test/models/provider/coinstats_test.rb
new file mode 100644
index 000000000..2de89c47a
--- /dev/null
+++ b/test/models/provider/coinstats_test.rb
@@ -0,0 +1,164 @@
+require "test_helper"
+
+class Provider::CoinstatsTest < ActiveSupport::TestCase
+ setup do
+ @provider = Provider::Coinstats.new("test_api_key")
+ end
+
+ test "extract_wallet_balance finds matching wallet by address and connectionId" do
+ bulk_data = [
+ {
+ blockchain: "ethereum",
+ address: "0x123abc",
+ connectionId: "ethereum",
+ balances: [
+ { coinId: "ethereum", name: "Ethereum", amount: 1.5, price: 2000 }
+ ]
+ },
+ {
+ blockchain: "bitcoin",
+ address: "bc1qxyz",
+ connectionId: "bitcoin",
+ balances: [
+ { coinId: "bitcoin", name: "Bitcoin", amount: 0.5, price: 50000 }
+ ]
+ }
+ ]
+
+ result = @provider.extract_wallet_balance(bulk_data, "0x123abc", "ethereum")
+
+ assert_equal 1, result.size
+ assert_equal "ethereum", result.first[:coinId]
+ end
+
+ test "extract_wallet_balance handles case insensitive matching" do
+ bulk_data = [
+ {
+ blockchain: "Ethereum",
+ address: "0x123ABC",
+ connectionId: "Ethereum",
+ balances: [
+ { coinId: "ethereum", name: "Ethereum", amount: 1.5, price: 2000 }
+ ]
+ }
+ ]
+
+ result = @provider.extract_wallet_balance(bulk_data, "0x123abc", "ethereum")
+
+ assert_equal 1, result.size
+ assert_equal "ethereum", result.first[:coinId]
+ end
+
+ test "extract_wallet_balance returns empty array when wallet not found" do
+ bulk_data = [
+ {
+ blockchain: "ethereum",
+ address: "0x123abc",
+ connectionId: "ethereum",
+ balances: [
+ { coinId: "ethereum", name: "Ethereum", amount: 1.5, price: 2000 }
+ ]
+ }
+ ]
+
+ result = @provider.extract_wallet_balance(bulk_data, "0xnotfound", "ethereum")
+
+ assert_equal [], result
+ end
+
+ test "extract_wallet_balance returns empty array for nil bulk_data" do
+ result = @provider.extract_wallet_balance(nil, "0x123abc", "ethereum")
+
+ assert_equal [], result
+ end
+
+ test "extract_wallet_balance returns empty array for non-array bulk_data" do
+ result = @provider.extract_wallet_balance({ error: "invalid" }, "0x123abc", "ethereum")
+
+ assert_equal [], result
+ end
+
+ test "extract_wallet_balance matches by blockchain when connectionId differs" do
+ bulk_data = [
+ {
+ blockchain: "ethereum",
+ address: "0x123abc",
+ connectionId: "eth-mainnet", # Different connectionId
+ balances: [
+ { coinId: "ethereum", name: "Ethereum", amount: 1.5, price: 2000 }
+ ]
+ }
+ ]
+
+ result = @provider.extract_wallet_balance(bulk_data, "0x123abc", "ethereum")
+
+ assert_equal 1, result.size
+ end
+
+ test "extract_wallet_transactions finds matching wallet transactions" do
+ bulk_data = [
+ {
+ blockchain: "ethereum",
+ address: "0x123abc",
+ connectionId: "ethereum",
+ transactions: [
+ { hash: { id: "0xtx1" }, type: "Received", date: "2025-01-01T10:00:00.000Z" },
+ { hash: { id: "0xtx2" }, type: "Sent", date: "2025-01-02T11:00:00.000Z" }
+ ]
+ },
+ {
+ blockchain: "bitcoin",
+ address: "bc1qxyz",
+ connectionId: "bitcoin",
+ transactions: [
+ { hash: { id: "btctx1" }, type: "Received", date: "2025-01-03T12:00:00.000Z" }
+ ]
+ }
+ ]
+
+ result = @provider.extract_wallet_transactions(bulk_data, "0x123abc", "ethereum")
+
+ assert_equal 2, result.size
+ assert_equal "0xtx1", result.first[:hash][:id]
+ end
+
+ test "extract_wallet_transactions returns empty array when wallet not found" do
+ bulk_data = [
+ {
+ blockchain: "ethereum",
+ address: "0x123abc",
+ connectionId: "ethereum",
+ transactions: [
+ { hash: { id: "0xtx1" }, type: "Received" }
+ ]
+ }
+ ]
+
+ result = @provider.extract_wallet_transactions(bulk_data, "0xnotfound", "ethereum")
+
+ assert_equal [], result
+ end
+
+ test "extract_wallet_transactions returns empty array for nil bulk_data" do
+ result = @provider.extract_wallet_transactions(nil, "0x123abc", "ethereum")
+
+ assert_equal [], result
+ end
+
+ test "extract_wallet_transactions handles case insensitive matching" do
+ bulk_data = [
+ {
+ blockchain: "Ethereum",
+ address: "0x123ABC",
+ connectionId: "Ethereum",
+ transactions: [
+ { hash: { id: "0xtx1" }, type: "Received" }
+ ]
+ }
+ ]
+
+ result = @provider.extract_wallet_transactions(bulk_data, "0x123abc", "ethereum")
+
+ assert_equal 1, result.size
+ end
+end
diff --git a/test/models/provider/simplefin_test.rb b/test/models/provider/simplefin_test.rb
new file mode 100644
index 000000000..e0cbaee23
--- /dev/null
+++ b/test/models/provider/simplefin_test.rb
@@ -0,0 +1,144 @@
+require "test_helper"
+
+class Provider::SimplefinTest < ActiveSupport::TestCase
+ setup do
+ @provider = Provider::Simplefin.new
+ @access_url = "https://example.com/simplefin/access"
+ end
+
+ test "retries on Net::ReadTimeout and succeeds on retry" do
+ # First call raises timeout, second call succeeds
+ mock_response = OpenStruct.new(code: 200, body: '{"accounts": []}')
+
+ HTTParty.expects(:get)
+ .times(2)
+ .raises(Net::ReadTimeout.new("Connection timed out"))
+ .then.returns(mock_response)
+
+ # Stub sleep to avoid actual delays in tests
+ @provider.stubs(:sleep)
+
+ result = @provider.get_accounts(@access_url)
+ assert_equal({ accounts: [] }, result)
+ end
+
+ test "retries on Net::OpenTimeout and succeeds on retry" do
+ mock_response = OpenStruct.new(code: 200, body: '{"accounts": []}')
+
+ HTTParty.expects(:get)
+ .times(2)
+ .raises(Net::OpenTimeout.new("Connection timed out"))
+ .then.returns(mock_response)
+
+ @provider.stubs(:sleep)
+
+ result = @provider.get_accounts(@access_url)
+ assert_equal({ accounts: [] }, result)
+ end
+
+ test "retries on SocketError and succeeds on retry" do
+ mock_response = OpenStruct.new(code: 200, body: '{"accounts": []}')
+
+ HTTParty.expects(:get)
+ .times(2)
+ .raises(SocketError.new("Failed to open TCP connection"))
+ .then.returns(mock_response)
+
+ @provider.stubs(:sleep)
+
+ result = @provider.get_accounts(@access_url)
+ assert_equal({ accounts: [] }, result)
+ end
+
+ test "raises SimplefinError after max retries exceeded" do
+ HTTParty.expects(:get)
+ .times(4) # Initial + 3 retries
+ .raises(Net::ReadTimeout.new("Connection timed out"))
+
+ @provider.stubs(:sleep)
+
+ error = assert_raises(Provider::Simplefin::SimplefinError) do
+ @provider.get_accounts(@access_url)
+ end
+
+ assert_equal :network_error, error.error_type
+ assert_match(/Network error after 3 retries/, error.message)
+ end
+
+ test "does not retry on non-retryable errors" do
+ HTTParty.expects(:get)
+ .times(1)
+ .raises(ArgumentError.new("Invalid argument"))
+
+ error = assert_raises(Provider::Simplefin::SimplefinError) do
+ @provider.get_accounts(@access_url)
+ end
+
+ assert_equal :request_failed, error.error_type
+ end
+
+ test "handles HTTP 429 rate limit response" do
+ mock_response = OpenStruct.new(code: 429, body: "Rate limit exceeded")
+
+ HTTParty.expects(:get).returns(mock_response)
+
+ error = assert_raises(Provider::Simplefin::SimplefinError) do
+ @provider.get_accounts(@access_url)
+ end
+
+ assert_equal :rate_limited, error.error_type
+ assert_match(/rate limit exceeded/i, error.message)
+ end
+
+ test "handles HTTP 500 server error response" do
+ mock_response = OpenStruct.new(code: 500, body: "Internal Server Error")
+
+ HTTParty.expects(:get).returns(mock_response)
+
+ error = assert_raises(Provider::Simplefin::SimplefinError) do
+ @provider.get_accounts(@access_url)
+ end
+
+ assert_equal :server_error, error.error_type
+ end
+
+ test "claim_access_url retries on network errors" do
+ setup_token = Base64.encode64("https://example.com/claim")
+ mock_response = OpenStruct.new(code: 200, body: "https://example.com/access")
+
+ HTTParty.expects(:post)
+ .times(2)
+ .raises(Net::ReadTimeout.new("Connection timed out"))
+ .then.returns(mock_response)
+
+ @provider.stubs(:sleep)
+
+ result = @provider.claim_access_url(setup_token)
+ assert_equal "https://example.com/access", result
+ end
+
+ test "exponential backoff delay increases with retries" do
+ provider = Provider::Simplefin.new
+
+ # Access private method for testing
+ delay1 = provider.send(:calculate_retry_delay, 1)
+ delay2 = provider.send(:calculate_retry_delay, 2)
+ delay3 = provider.send(:calculate_retry_delay, 3)
+
+ # Delays should increase (accounting for jitter)
+ # Base delays: 2, 4, 8 seconds (with up to 25% jitter)
+ assert delay1 >= 2 && delay1 <= 2.5, "First retry delay should be ~2s"
+ assert delay2 >= 4 && delay2 <= 5, "Second retry delay should be ~4s"
+ assert delay3 >= 8 && delay3 <= 10, "Third retry delay should be ~8s"
+ end
+
+ test "retry delay is capped at MAX_RETRY_DELAY" do
+ provider = Provider::Simplefin.new
+
+ # Test with a high retry count that would exceed max delay
+ delay = provider.send(:calculate_retry_delay, 10)
+
+ assert delay <= Provider::Simplefin::MAX_RETRY_DELAY,
+ "Delay should be capped at MAX_RETRY_DELAY (#{Provider::Simplefin::MAX_RETRY_DELAY}s)"
+ end
+end
diff --git a/test/models/rule_test.rb b/test/models/rule_test.rb
index bf082ec73..731199fab 100644
--- a/test/models/rule_test.rb
+++ b/test/models/rule_test.rb
@@ -201,4 +201,39 @@ class RuleTest < ActiveSupport::TestCase
assert_equal business_category, transaction_entry.transaction.category, "Transaction with 'business' in notes should be categorized"
assert_nil transaction_entry2.transaction.category, "Transaction without 'business' in notes should not be categorized"
end
+
+ test "total_affected_resource_count deduplicates overlapping rules" do
+ # Create transactions
+ transaction_entry1 = create_transaction(date: Date.current, account: @account, name: "Whole Foods", amount: 50)
+ transaction_entry2 = create_transaction(date: Date.current, account: @account, name: "Whole Foods", amount: 100)
+ transaction_entry3 = create_transaction(date: Date.current, account: @account, name: "Target", amount: 75)
+
+ # Rule 1: Match transactions with name "Whole Foods" (matches txn 1 and 2)
+ rule1 = Rule.create!(
+ family: @family,
+ resource_type: "transaction",
+ effective_date: 1.day.ago.to_date,
+ conditions: [ Rule::Condition.new(condition_type: "transaction_name", operator: "like", value: "Whole Foods") ],
+ actions: [ Rule::Action.new(action_type: "set_transaction_category", value: @groceries_category.id) ]
+ )
+
+ # Rule 2: Match transactions with amount > 60 (matches txn 2 and 3)
+ rule2 = Rule.create!(
+ family: @family,
+ resource_type: "transaction",
+ effective_date: 1.day.ago.to_date,
+ conditions: [ Rule::Condition.new(condition_type: "transaction_amount", operator: ">", value: 60) ],
+ actions: [ Rule::Action.new(action_type: "set_transaction_category", value: @groceries_category.id) ]
+ )
+
+ # Rule 1 affects 2 transactions, Rule 2 affects 2 transactions
+ # But transaction_entry2 is matched by both, so total unique should be 3
+ assert_equal 2, rule1.affected_resource_count
+ assert_equal 2, rule2.affected_resource_count
+ assert_equal 3, Rule.total_affected_resource_count([ rule1, rule2 ])
+ end
+
+ test "total_affected_resource_count returns zero for empty rules" do
+ assert_equal 0, Rule.total_affected_resource_count([])
+ end
end
diff --git a/test/models/security/price/importer_test.rb b/test/models/security/price/importer_test.rb
index 665a91f68..6a2fb8c42 100644
--- a/test/models/security/price/importer_test.rb
+++ b/test/models/security/price/importer_test.rb
@@ -136,8 +136,284 @@ class Security::Price::ImporterTest < ActiveSupport::TestCase
assert_equal 1, Security::Price.count
end
+ test "marks prices as not provisional when from provider" do
+ Security::Price.delete_all
+
+ provider_response = provider_success_response([
+ OpenStruct.new(security: @security, date: 1.day.ago.to_date, price: 150, currency: "USD"),
+ OpenStruct.new(security: @security, date: Date.current, price: 155, currency: "USD")
+ ])
+
+ @provider.expects(:fetch_security_prices)
+ .with(symbol: @security.ticker, exchange_operating_mic: @security.exchange_operating_mic,
+ start_date: get_provider_fetch_start_date(1.day.ago.to_date), end_date: Date.current)
+ .returns(provider_response)
+
+ Security::Price::Importer.new(
+ security: @security,
+ security_provider: @provider,
+ start_date: 1.day.ago.to_date,
+ end_date: Date.current
+ ).import_provider_prices
+
+ db_prices = Security::Price.where(security: @security).order(:date)
+ assert db_prices.all? { |p| p.provisional == false }, "All prices from provider should not be provisional"
+ end
+
+ test "marks gap-filled weekend prices as provisional" do
+ Security::Price.delete_all
+
+ # Find a recent Saturday
+ saturday = Date.current
+ saturday -= 1.day until saturday.saturday?
+ friday = saturday - 1.day
+
+ # Provider only returns Friday's price, not Saturday
+ provider_response = provider_success_response([
+ OpenStruct.new(security: @security, date: friday, price: 150, currency: "USD")
+ ])
+
+ @provider.expects(:fetch_security_prices)
+ .with(symbol: @security.ticker, exchange_operating_mic: @security.exchange_operating_mic,
+ start_date: get_provider_fetch_start_date(friday), end_date: saturday)
+ .returns(provider_response)
+
+ Security::Price::Importer.new(
+ security: @security,
+ security_provider: @provider,
+ start_date: friday,
+ end_date: saturday
+ ).import_provider_prices
+
+ saturday_price = Security::Price.find_by(security: @security, date: saturday)
+ # Weekend gap-filled prices are now provisional so they can be fixed
+ # via cascade when the next weekday sync fetches the correct Friday price
+ assert saturday_price.provisional, "Weekend gap-filled price should be provisional"
+ end
+
+ test "marks gap-filled recent weekday prices as provisional" do
+ Security::Price.delete_all
+
+ # Find a recent weekday that's not today
+ weekday = 1.day.ago.to_date
+ weekday -= 1.day while weekday.saturday? || weekday.sunday?
+
+ # Start from 2 days before the weekday
+ start_date = weekday - 1.day
+ start_date -= 1.day while start_date.saturday? || start_date.sunday?
+
+ # Provider only returns start_date price, not the weekday
+ provider_response = provider_success_response([
+ OpenStruct.new(security: @security, date: start_date, price: 150, currency: "USD")
+ ])
+
+ @provider.expects(:fetch_security_prices)
+ .with(symbol: @security.ticker, exchange_operating_mic: @security.exchange_operating_mic,
+ start_date: get_provider_fetch_start_date(start_date), end_date: weekday)
+ .returns(provider_response)
+
+ Security::Price::Importer.new(
+ security: @security,
+ security_provider: @provider,
+ start_date: start_date,
+ end_date: weekday
+ ).import_provider_prices
+
+ weekday_price = Security::Price.find_by(security: @security, date: weekday)
+ # Only recent weekdays should be provisional
+ if weekday >= 3.days.ago.to_date
+ assert weekday_price.provisional, "Gap-filled recent weekday price should be provisional"
+ else
+ assert_not weekday_price.provisional, "Gap-filled old weekday price should not be provisional"
+ end
+ end
+
+ test "retries fetch when refetchable provisional prices exist" do
+ Security::Price.delete_all
+
+ # Skip if today is a weekend
+ return if Date.current.saturday? || Date.current.sunday?
+
+ # Pre-populate with provisional price for today
+ Security::Price.create!(
+ security: @security,
+ date: Date.current,
+ price: 100,
+ currency: "USD",
+ provisional: true
+ )
+
+ # Provider now returns today's actual price
+ provider_response = provider_success_response([
+ OpenStruct.new(security: @security, date: Date.current, price: 165, currency: "USD")
+ ])
+
+ @provider.expects(:fetch_security_prices)
+ .with(symbol: @security.ticker, exchange_operating_mic: @security.exchange_operating_mic,
+ start_date: get_provider_fetch_start_date(Date.current), end_date: Date.current)
+ .returns(provider_response)
+
+ Security::Price::Importer.new(
+ security: @security,
+ security_provider: @provider,
+ start_date: Date.current,
+ end_date: Date.current
+ ).import_provider_prices
+
+ db_price = Security::Price.find_by(security: @security, date: Date.current)
+ assert_equal 165, db_price.price, "Price should be updated from provider"
+ assert_not db_price.provisional, "Price should no longer be provisional after provider returns real price"
+ end
+
+ test "skips fetch when all prices are non-provisional" do
+ Security::Price.delete_all
+
+ # Create non-provisional prices for the range
+ (3.days.ago.to_date..Date.current).each_with_index do |date, idx|
+ Security::Price.create!(security: @security, date: date, price: 100 + idx, currency: "USD", provisional: false)
+ end
+
+ @provider.expects(:fetch_security_prices).never
+
+ Security::Price::Importer.new(
+ security: @security,
+ security_provider: @provider,
+ start_date: 3.days.ago.to_date,
+ end_date: Date.current
+ ).import_provider_prices
+ end
+
+ test "does not mark old gap-filled prices as provisional" do
+ Security::Price.delete_all
+
+ # Use dates older than the lookback window
+ old_date = 10.days.ago.to_date
+ old_date -= 1.day while old_date.saturday? || old_date.sunday?
+ start_date = old_date - 1.day
+ start_date -= 1.day while start_date.saturday? || start_date.sunday?
+
+ # Provider only returns start_date price
+ provider_response = provider_success_response([
+ OpenStruct.new(security: @security, date: start_date, price: 150, currency: "USD")
+ ])
+
+ @provider.expects(:fetch_security_prices)
+ .with(symbol: @security.ticker, exchange_operating_mic: @security.exchange_operating_mic,
+ start_date: get_provider_fetch_start_date(start_date), end_date: old_date)
+ .returns(provider_response)
+
+ Security::Price::Importer.new(
+ security: @security,
+ security_provider: @provider,
+ start_date: start_date,
+ end_date: old_date
+ ).import_provider_prices
+
+ old_price = Security::Price.find_by(security: @security, date: old_date)
+ assert_not old_price.provisional, "Old gap-filled price should not be provisional"
+ end
+
+ test "provisional weekend prices get fixed via cascade from Friday" do
+ Security::Price.delete_all
+
+ # Find a recent Monday
+ monday = Date.current
+ monday += 1.day until monday.monday?
+ friday = monday - 3.days
+ saturday = monday - 2.days
+ sunday = monday - 1.day
+
+ travel_to monday do
+ # Create provisional weekend prices with WRONG values (simulating stale data)
+ Security::Price.create!(security: @security, date: saturday, price: 50, currency: "USD", provisional: true)
+ Security::Price.create!(security: @security, date: sunday, price: 50, currency: "USD", provisional: true)
+
+ # Provider returns Friday and Monday prices, but NOT weekend (markets closed)
+ provider_response = provider_success_response([
+ OpenStruct.new(security: @security, date: friday, price: 150, currency: "USD"),
+ OpenStruct.new(security: @security, date: monday, price: 155, currency: "USD")
+ ])
+
+ @provider.expects(:fetch_security_prices).returns(provider_response)
+
+ Security::Price::Importer.new(
+ security: @security,
+ security_provider: @provider,
+ start_date: friday,
+ end_date: monday
+ ).import_provider_prices
+
+ # Friday should have real price from provider
+ friday_price = Security::Price.find_by(security: @security, date: friday)
+ assert_equal 150, friday_price.price
+ assert_not friday_price.provisional, "Friday should not be provisional (real price)"
+
+ # Saturday should be gap-filled from Friday (150), not old wrong value (50)
+ saturday_price = Security::Price.find_by(security: @security, date: saturday)
+ assert_equal 150, saturday_price.price, "Saturday should use Friday's price via cascade"
+ assert saturday_price.provisional, "Saturday should be provisional (gap-filled)"
+
+ # Sunday should be gap-filled from Saturday (150)
+ sunday_price = Security::Price.find_by(security: @security, date: sunday)
+ assert_equal 150, sunday_price.price, "Sunday should use Friday's price via cascade"
+ assert sunday_price.provisional, "Sunday should be provisional (gap-filled)"
+
+ # Monday should have real price from provider
+ monday_price = Security::Price.find_by(security: @security, date: monday)
+ assert_equal 155, monday_price.price
+ assert_not monday_price.provisional, "Monday should not be provisional (real price)"
+ end
+ end
+
+ test "uses recent prices for gap-fill when effective_start_date skips old dates" do
+ Security::Price.delete_all
+
+ # Use travel_to to ensure we're on a weekday for consistent test behavior
+ # Find the next weekday if today is a weekend
+ test_date = Date.current
+ test_date += 1.day while test_date.saturday? || test_date.sunday?
+
+ travel_to test_date do
+ # Simulate: old price exists from first trade date (30 days ago) with STALE value
+ old_date = 30.days.ago.to_date
+ stale_price = 50
+
+ # Fully populate DB from old_date through yesterday so effective_start_date = today
+ # Use stale price for old dates, then recent price for recent dates
+ (old_date..1.day.ago.to_date).each do |date|
+ # Use stale price for dates older than lookback window, recent price for recent dates
+ price = date < 7.days.ago.to_date ? stale_price : 150
+ Security::Price.create!(security: @security, date: date, price: price, currency: "USD")
+ end
+
+ # Provider returns yesterday's price (155) - DIFFERENT from DB (150) to prove we use provider
+ # Provider does NOT return today (simulating market closed)
+ provider_response = provider_success_response([
+ OpenStruct.new(security: @security, date: 1.day.ago.to_date, price: 155, currency: "USD")
+ ])
+
+ @provider.expects(:fetch_security_prices).returns(provider_response)
+
+ Security::Price::Importer.new(
+ security: @security,
+ security_provider: @provider,
+ start_date: old_date,
+ end_date: Date.current
+ ).import_provider_prices
+
+ today_price = Security::Price.find_by(security: @security, date: Date.current)
+
+ # effective_start_date should be today (only missing date)
+ # start_price_value should use provider's yesterday (155), not stale old DB price (50)
+ # Today should gap-fill from that recent price
+ assert_equal 155, today_price.price, "Gap-fill should use recent provider price, not stale old price"
+ # Should be provisional since gap-filled for recent weekday
+ assert today_price.provisional, "Current weekday gap-filled price should be provisional"
+ end
+ end
+
private
def get_provider_fetch_start_date(start_date)
- start_date - 5.days
+ start_date - Security::Price::Importer::PROVISIONAL_LOOKBACK_DAYS.days
end
end
diff --git a/test/models/simplefin_account/liabilities/overpayment_analyzer_test.rb b/test/models/simplefin_account/liabilities/overpayment_analyzer_test.rb
new file mode 100644
index 000000000..6783aee6d
--- /dev/null
+++ b/test/models/simplefin_account/liabilities/overpayment_analyzer_test.rb
@@ -0,0 +1,99 @@
+require "test_helper"
+
+class SimplefinAccount::Liabilities::OverpaymentAnalyzerTest < ActiveSupport::TestCase
+ # Limit fixtures to only what's required to avoid FK validation on unrelated tables
+ fixtures :families
+ setup do
+ @family = families(:dylan_family)
+ @item = SimplefinItem.create!(family: @family, name: "SimpleFIN", access_url: "https://example.com/token")
+ @sfa = SimplefinAccount.create!(
+ simplefin_item: @item,
+ name: "Test Credit Card",
+ account_id: "cc_txn_window_1",
+ currency: "USD",
+ account_type: "credit",
+ current_balance: BigDecimal("-22.72")
+ )
+
+ # Avoid cross‑suite fixture dependency by creating a fresh credit card account
+ @acct = Account.create!(
+ family: @family,
+ name: "Test CC",
+ balance: 0,
+ cash_balance: 0,
+ currency: "USD",
+ accountable: CreditCard.new
+ )
+ # Create explicit provider link to ensure FK validity in isolation
+ AccountProvider.create!(account: @acct, provider: @sfa)
+
+ # Enable heuristic
+ Setting["simplefin_cc_overpayment_detection"] = "true"
+ # Loosen thresholds for focused unit tests
+ Setting["simplefin_cc_overpayment_min_txns"] = "1"
+ Setting["simplefin_cc_overpayment_min_payments"] = "1"
+ Setting["simplefin_cc_overpayment_statement_guard_days"] = "0"
+ end
+
+ teardown do
+ # Disable heuristic to avoid bleeding into other tests
+ Setting["simplefin_cc_overpayment_detection"] = nil
+ Setting["simplefin_cc_overpayment_min_txns"] = nil
+ Setting["simplefin_cc_overpayment_min_payments"] = nil
+ Setting["simplefin_cc_overpayment_statement_guard_days"] = nil
+ begin
+ Rails.cache.delete_matched("simplefin:sfa:#{@sfa.id}:liability_sign_hint") if @sfa&.id
+ rescue
+ # ignore cache backends without delete_matched
+ end
+ # Ensure created records are removed to avoid FK validation across examples in single-file runs
+ AccountProvider.where(account_id: @acct.id).destroy_all rescue nil
+ @acct.destroy! rescue nil
+ @sfa.destroy! rescue nil
+ @item.destroy! rescue nil
+ end
+
+ test "classifies credit when payments exceed charges roughly by observed amount" do
+ # Create transactions in Maybe convention for liabilities:
+ # charges/spend: positive; payments: negative
+ # Observed abs is 22.72; make payments exceed charges by ~22.72
+ @acct.entries.delete_all
+ @acct.entries.create!(date: 10.days.ago.to_date, name: "Store A", amount: 50, currency: "USD", entryable: Transaction.new)
+ # Ensure payments exceed charges by at least observed.abs (~22.72)
+ @acct.entries.create!(date: 8.days.ago.to_date, name: "Payment", amount: -75, currency: "USD", entryable: Transaction.new)
+
+ result = SimplefinAccount::Liabilities::OverpaymentAnalyzer.new(@sfa, observed_balance: @sfa.current_balance).call
+ assert_equal :credit, result.classification, "expected classification to be credit"
+ end
+
+ test "classifies debt when charges exceed payments" do
+ @acct.entries.delete_all
+ @acct.entries.create!(date: 12.days.ago.to_date, name: "Groceries", amount: 120, currency: "USD", entryable: Transaction.new)
+ @acct.entries.create!(date: 11.days.ago.to_date, name: "Coffee", amount: 10, currency: "USD", entryable: Transaction.new)
+ @acct.entries.create!(date: 9.days.ago.to_date, name: "Payment", amount: -50, currency: "USD", entryable: Transaction.new)
+
+ result = SimplefinAccount::Liabilities::OverpaymentAnalyzer.new(@sfa, observed_balance: BigDecimal("-80")).call
+ assert_equal :debt, result.classification, "expected classification to be debt"
+ end
+
+ test "returns unknown when insufficient transactions" do
+ @acct.entries.delete_all
+ @acct.entries.create!(date: 5.days.ago.to_date, name: "Small", amount: 1, currency: "USD", entryable: Transaction.new)
+
+ result = SimplefinAccount::Liabilities::OverpaymentAnalyzer.new(@sfa, observed_balance: BigDecimal("-5")).call
+ assert_equal :unknown, result.classification
+ end
+
+ test "fallback to raw payload when no entries present" do
+ @acct.entries.delete_all
+ # Provide raw transactions in provider convention (expenses negative, income positive)
+ # We must negate in analyzer to convert to Maybe convention.
+ @sfa.update!(raw_transactions_payload: [
+ { id: "t1", amount: -100, posted: (10.days.ago.to_date.to_s) }, # charge (-> +100)
+ { id: "t2", amount: 150, posted: (8.days.ago.to_date.to_s) } # payment (-> -150)
+ ])
+
+ result = SimplefinAccount::Liabilities::OverpaymentAnalyzer.new(@sfa, observed_balance: BigDecimal("-50")).call
+ assert_equal :credit, result.classification
+ end
+end
diff --git a/test/models/simplefin_account/transactions/processor_investment_test.rb b/test/models/simplefin_account/transactions/processor_investment_test.rb
new file mode 100644
index 000000000..587c3fc01
--- /dev/null
+++ b/test/models/simplefin_account/transactions/processor_investment_test.rb
@@ -0,0 +1,277 @@
+require "test_helper"
+
+class SimplefinAccount::Transactions::ProcessorInvestmentTest < ActiveSupport::TestCase
+ setup do
+ @family = families(:dylan_family)
+
+ # Create SimpleFIN connection
+ @simplefin_item = SimplefinItem.create!(
+ family: @family,
+ name: "Test SimpleFIN",
+ access_url: "https://example.com/access"
+ )
+
+ # Create an Investment account
+ @account = Account.create!(
+ family: @family,
+ name: "Retirement - Roth IRA",
+ currency: "USD",
+ balance: 12199.06,
+ accountable: Investment.create!(subtype: :roth_ira)
+ )
+
+ # Create SimpleFIN account linked to the Investment account
+ @simplefin_account = SimplefinAccount.create!(
+ simplefin_item: @simplefin_item,
+ name: "Roth IRA",
+ account_id: "ACT-investment-123",
+ currency: "USD",
+ account_type: "investment",
+ current_balance: 12199.06,
+ raw_transactions_payload: [
+ {
+ "id" => "TRN-921a8cdb-f331-48ee-9de2-b0b9ff1d316a",
+ "posted" => 1766417520,
+ "amount" => "1.49",
+ "description" => "Dividend Reinvestment",
+ "payee" => "Dividend",
+ "memo" => "Dividend Reinvestment",
+ "transacted_at" => 1766417520
+ },
+ {
+ "id" => "TRN-881f2417-29e3-43f9-bd1b-013e60ba7a4b",
+ "posted" => 1766113200,
+ "amount" => "1.49",
+ "description" => "Sweep of dividend payouts",
+ "payee" => "Dividend",
+ "memo" => "Dividend Payment - IEMG",
+ "transacted_at" => 1766113200
+ },
+ {
+ "id" => "TRN-e52f1326-bbb6-42a7-8148-be48c8a81832",
+ "posted" => 1765985220,
+ "amount" => "0.05",
+ "description" => "Dividend Reinvestment",
+ "payee" => "Dividend",
+ "memo" => "Dividend Reinvestment",
+ "transacted_at" => 1765985220
+ }
+ ]
+ )
+
+ # Link the account via legacy FK
+ @account.update!(simplefin_account_id: @simplefin_account.id)
+ end
+
+ test "processes dividend transactions for investment accounts" do
+ assert_equal 0, @account.entries.count, "Should start with no entries"
+
+ # Process transactions
+ processor = SimplefinAccount::Transactions::Processor.new(@simplefin_account)
+ processor.process
+
+ # Verify all 3 dividend transactions were created
+ assert_equal 3, @account.entries.count, "Should create 3 entries for dividend transactions"
+
+ # Verify entries are Transaction type (not Trade)
+ @account.entries.each do |entry|
+ assert_equal "Transaction", entry.entryable_type
+ end
+
+ # Verify external_ids are set correctly
+ external_ids = @account.entries.pluck(:external_id).sort
+ expected_ids = [
+ "simplefin_TRN-921a8cdb-f331-48ee-9de2-b0b9ff1d316a",
+ "simplefin_TRN-881f2417-29e3-43f9-bd1b-013e60ba7a4b",
+ "simplefin_TRN-e52f1326-bbb6-42a7-8148-be48c8a81832"
+ ].sort
+ assert_equal expected_ids, external_ids
+
+ # Verify source is simplefin
+ @account.entries.each do |entry|
+ assert_equal "simplefin", entry.source
+ end
+ end
+
+ test "investment transactions processor is no-op to avoid duplicate processing" do
+ # First, process with regular processor
+ SimplefinAccount::Transactions::Processor.new(@simplefin_account).process
+ initial_count = @account.entries.count
+ assert_equal 3, initial_count
+
+ # Get the first entry's updated_at before running investment processor
+ first_entry = @account.entries.first
+ original_updated_at = first_entry.updated_at
+
+ # Run the investment transactions processor - should be a no-op
+ SimplefinAccount::Investments::TransactionsProcessor.new(@simplefin_account).process
+
+ # Entry count should be unchanged
+ assert_equal initial_count, @account.entries.reload.count
+
+ # Entries should not have been modified
+ first_entry.reload
+ assert_equal original_updated_at, first_entry.updated_at
+ end
+
+ test "processes transactions correctly via SimplefinAccount::Processor for investment accounts" do
+ # Verify the full processor flow works for investment accounts
+ processor = SimplefinAccount::Processor.new(@simplefin_account)
+ processor.process
+
+ # Should create transaction entries
+ assert_equal 3, @account.entries.where(entryable_type: "Transaction").count
+
+ # Verify amounts are correctly negated (SimpleFIN positive = income = negative in Sure)
+ entry = @account.entries.find_by(external_id: "simplefin_TRN-921a8cdb-f331-48ee-9de2-b0b9ff1d316a")
+ assert_not_nil entry
+ assert_equal BigDecimal("-1.49"), entry.amount
+ end
+
+ test "logs appropriate messages during processing" do
+ # Capture log output
+ log_output = StringIO.new
+ original_logger = Rails.logger
+ Rails.logger = Logger.new(log_output)
+
+ SimplefinAccount::Transactions::Processor.new(@simplefin_account).process
+
+ Rails.logger = original_logger
+ log_content = log_output.string
+
+ # Should log start message with transaction count
+ assert_match(/Processing 3 transactions/, log_content)
+
+ # Should log completion message
+ assert_match(/Completed.*3 processed, 0 errors/, log_content)
+ end
+
+ test "handles empty raw_transactions_payload gracefully" do
+ @simplefin_account.update!(raw_transactions_payload: [])
+
+ # Should not raise an error
+ processor = SimplefinAccount::Transactions::Processor.new(@simplefin_account)
+ processor.process
+
+ assert_equal 0, @account.entries.count
+ end
+
+ test "handles nil raw_transactions_payload gracefully" do
+ @simplefin_account.update!(raw_transactions_payload: nil)
+
+ # Should not raise an error
+ processor = SimplefinAccount::Transactions::Processor.new(@simplefin_account)
+ processor.process
+
+ assert_equal 0, @account.entries.count
+ end
+
+ test "repairs stale linkage when user re-adds institution in SimpleFIN" do
+ # Simulate user re-adding institution: old SimplefinAccount is linked but has no transactions,
+ # new SimplefinAccount is unlinked but has transactions
+
+ # Make the original account "stale" (no transactions)
+ @simplefin_account.update!(raw_transactions_payload: [])
+
+ # Create a "new" SimplefinAccount with the same name but different account_id
+ # This simulates what happens when SimpleFIN generates new IDs after re-adding
+ new_simplefin_account = SimplefinAccount.create!(
+ simplefin_item: @simplefin_item,
+ name: "Roth IRA", # Same name as original
+ account_id: "ACT-investment-456-NEW", # New ID
+ currency: "USD",
+ account_type: "investment",
+ current_balance: 12199.06,
+ raw_transactions_payload: [
+ {
+ "id" => "TRN-new-transaction-001",
+ "posted" => 1766417520,
+ "amount" => "5.00",
+ "description" => "New Dividend",
+ "payee" => "Dividend",
+ "memo" => "New Dividend Payment"
+ }
+ ]
+ )
+ # New account is NOT linked (this is the problem we're fixing)
+ assert_nil new_simplefin_account.account
+
+ # Before repair: @simplefin_account is linked (but stale), new_simplefin_account is unlinked
+ assert_equal @simplefin_account.id, @account.reload.simplefin_account_id
+
+ # Process accounts - should repair the stale linkage
+ @simplefin_item.process_accounts
+
+ # After repair: new_simplefin_account should be linked
+ @account.reload
+ assert_equal new_simplefin_account.id, @account.simplefin_account_id, "Expected linkage to transfer to new_simplefin_account (#{new_simplefin_account.id}) but got #{@account.simplefin_account_id}"
+
+ # Old SimplefinAccount should still exist but be cleared of data
+ @simplefin_account.reload
+ assert_equal [], @simplefin_account.raw_transactions_payload
+
+ # Transaction from new SimplefinAccount should be created
+ assert_equal 1, @account.entries.count
+ entry = @account.entries.first
+ assert_equal "simplefin_TRN-new-transaction-001", entry.external_id
+ assert_equal BigDecimal("-5.00"), entry.amount
+ end
+
+ test "does not repair linkage when names dont match" do
+ # Make original stale
+ @simplefin_account.update!(raw_transactions_payload: [])
+
+ # Create new with DIFFERENT name
+ new_simplefin_account = SimplefinAccount.create!(
+ simplefin_item: @simplefin_item,
+ name: "Different Account Name", # Different name
+ account_id: "ACT-different-456",
+ currency: "USD",
+ account_type: "investment",
+ current_balance: 1000.00,
+ raw_transactions_payload: [
+ { "id" => "TRN-different", "posted" => 1766417520, "amount" => "10.00", "description" => "Test" }
+ ]
+ )
+
+ original_linkage = @account.simplefin_account_id
+
+ @simplefin_item.process_accounts
+
+ # Should NOT have transferred linkage because names don't match
+ @account.reload
+ assert_equal original_linkage, @account.simplefin_account_id
+ assert_equal 0, @account.entries.count
+ end
+
+ test "repairs linkage and merges transactions when both old and new have data" do
+ # Both accounts have transactions - repair should still happen and merge them
+ assert @simplefin_account.raw_transactions_payload.any?
+
+ # Create new with same name
+ new_simplefin_account = SimplefinAccount.create!(
+ simplefin_item: @simplefin_item,
+ name: "Roth IRA",
+ account_id: "ACT-investment-456-NEW",
+ currency: "USD",
+ account_type: "investment",
+ current_balance: 12199.06,
+ raw_transactions_payload: [
+ { "id" => "TRN-new", "posted" => 1766417520, "amount" => "5.00", "description" => "New" }
+ ]
+ )
+
+ @simplefin_item.process_accounts
+
+ # Should transfer linkage to new account (repair by name match)
+ @account.reload
+ assert_equal new_simplefin_account.id, @account.simplefin_account_id
+
+ # Transactions should be merged: 3 from old + 1 from new = 4 total
+ assert_equal 4, @account.entries.count
+
+ # Old account should be cleared
+ @simplefin_account.reload
+ assert_equal [], @simplefin_account.raw_transactions_payload
+ end
+end
diff --git a/test/models/simplefin_account_processor_test.rb b/test/models/simplefin_account_processor_test.rb
index 9025c959e..0b13f0e97 100644
--- a/test/models/simplefin_account_processor_test.rb
+++ b/test/models/simplefin_account_processor_test.rb
@@ -81,4 +81,101 @@ class SimplefinAccountProcessorTest < ActiveSupport::TestCase
assert_equal BigDecimal("-75.00"), acct.reload.balance
end
+
+ test "liability debt with both fields negative becomes positive (you owe)" do
+ sfin_acct = SimplefinAccount.create!(
+ simplefin_item: @item,
+ name: "BofA Visa",
+ account_id: "cc_bofa_1",
+ currency: "USD",
+ account_type: "credit",
+ current_balance: BigDecimal("-1200"),
+ available_balance: BigDecimal("-5000")
+ )
+
+ acct = accounts(:credit_card)
+ acct.update!(simplefin_account: sfin_acct)
+
+ SimplefinAccount::Processor.new(sfin_acct).send(:process_account!)
+
+ assert_equal BigDecimal("1200"), acct.reload.balance
+ end
+
+ test "liability overpayment with both fields positive becomes negative (credit)" do
+ sfin_acct = SimplefinAccount.create!(
+ simplefin_item: @item,
+ name: "BofA Visa",
+ account_id: "cc_bofa_2",
+ currency: "USD",
+ account_type: "credit",
+ current_balance: BigDecimal("75"),
+ available_balance: BigDecimal("5000")
+ )
+
+ acct = accounts(:credit_card)
+ acct.update!(simplefin_account: sfin_acct)
+
+ SimplefinAccount::Processor.new(sfin_acct).send(:process_account!)
+
+ assert_equal BigDecimal("-75"), acct.reload.balance
+ end
+
+ test "mixed signs falls back to invert observed (balance positive, avail negative => negative)" do
+ sfin_acct = SimplefinAccount.create!(
+ simplefin_item: @item,
+ name: "Chase Freedom",
+ account_id: "cc_chase_1",
+ currency: "USD",
+ account_type: "credit",
+ current_balance: BigDecimal("50"),
+ available_balance: BigDecimal("-5000")
+ )
+
+ acct = accounts(:credit_card)
+ acct.update!(simplefin_account: sfin_acct)
+
+ SimplefinAccount::Processor.new(sfin_acct).send(:process_account!)
+
+ assert_equal BigDecimal("-50"), acct.reload.balance
+ end
+
+ test "only available-balance present positive → negative (credit) for liability" do
+ sfin_acct = SimplefinAccount.create!(
+ simplefin_item: @item,
+ name: "Chase Visa",
+ account_id: "cc_chase_2",
+ currency: "USD",
+ account_type: "credit",
+ current_balance: nil,
+ available_balance: BigDecimal("25")
+ )
+
+ acct = accounts(:credit_card)
+ acct.update!(simplefin_account: sfin_acct)
+
+ SimplefinAccount::Processor.new(sfin_acct).send(:process_account!)
+
+ assert_equal BigDecimal("-25"), acct.reload.balance
+ end
+
+ test "mislinked as asset but mapper infers credit → normalize as liability" do
+ sfin_acct = SimplefinAccount.create!(
+ simplefin_item: @item,
+ name: "Visa Signature",
+ account_id: "cc_mislinked",
+ currency: "USD",
+ account_type: "credit",
+ current_balance: BigDecimal("100.00"),
+ available_balance: BigDecimal("5000.00")
+ )
+
+ # Link to an asset account intentionally
+ acct = accounts(:depository)
+ acct.update!(simplefin_account: sfin_acct)
+
+ SimplefinAccount::Processor.new(sfin_acct).send(:process_account!)
+
+ # Mapper should infer liability from name; final should be negative
+ assert_equal BigDecimal("-100.00"), acct.reload.balance
+ end
end
diff --git a/test/models/simplefin_entry/processor_test.rb b/test/models/simplefin_entry/processor_test.rb
index abea68a91..2717f6bb5 100644
--- a/test/models/simplefin_entry/processor_test.rb
+++ b/test/models/simplefin_entry/processor_test.rb
@@ -53,7 +53,9 @@ class SimplefinEntry::ProcessorTest < ActiveSupport::TestCase
assert_equal "Order #1234", sf["description"]
assert_equal({ "category" => "restaurants", "check_number" => nil }, sf["extra"])
end
- test "flags pending transaction when posted is nil and transacted_at present" do
+ test "does not flag pending when posted is nil but provider pending flag not set" do
+ # Previously we inferred pending from missing posted date, but this was too aggressive -
+ # some providers don't supply posted dates even for settled transactions
tx = {
id: "tx_pending_1",
amount: "-20.00",
@@ -70,7 +72,7 @@ class SimplefinEntry::ProcessorTest < ActiveSupport::TestCase
entry = @account.entries.find_by!(external_id: "simplefin_tx_pending_1", source: "simplefin")
sf = entry.transaction.extra.fetch("simplefin")
- assert_equal true, sf["pending"], "expected pending flag to be true"
+ assert_equal false, sf["pending"], "expected pending flag to be false when provider doesn't explicitly set pending"
end
test "captures FX metadata when tx currency differs from account currency" do
diff --git a/test/models/simplefin_item/importer_inactive_test.rb b/test/models/simplefin_item/importer_inactive_test.rb
index 4ae83db6f..158e325af 100644
--- a/test/models/simplefin_item/importer_inactive_test.rb
+++ b/test/models/simplefin_item/importer_inactive_test.rb
@@ -19,37 +19,33 @@ class SimplefinItem::ImporterInactiveTest < ActiveSupport::TestCase
assert stats.dig("inactive", "a1"), "should be inactive when closed flag present"
end
- test "marks inactive after three consecutive zero runs with no holdings" do
+ test "counts zero runs once per sync even with multiple imports" do
account_data = { id: "a2", name: "Dormant", balance: 0, "available-balance": 0, currency: "USD" }
- 2.times { importer.send(:import_account, account_data) }
- stats = @sync.reload.sync_stats
- assert_equal 2, stats.dig("zero_runs", "a2"), "should count zero runs"
- assert_equal false, stats.dig("inactive", "a2"), "should not be inactive before threshold"
+ # Multiple imports in the same sync (simulating chunked imports) should only count once
+ 5.times { importer.send(:import_account, account_data) }
- importer.send(:import_account, account_data)
stats = @sync.reload.sync_stats
- assert_equal true, stats.dig("inactive", "a2"), "should be inactive at threshold"
+ assert_equal 1, stats.dig("zero_runs", "a2"), "should only count once per sync despite multiple imports"
+ assert_equal false, stats.dig("inactive", "a2"), "should not be inactive after single count"
end
- test "resets zero_runs_count and inactive when activity returns" do
+ test "resets zero_runs and inactive when activity returns" do
account_data = { id: "a3", name: "Dormant", balance: 0, "available-balance": 0, currency: "USD" }
- 3.times { importer.send(:import_account, account_data) }
- stats = @sync.reload.sync_stats
- assert_equal true, stats.dig("inactive", "a3")
+ importer.send(:import_account, account_data)
- # Activity returns: non-zero balance or holdings
+ stats = @sync.reload.sync_stats
+ assert_equal 1, stats.dig("zero_runs", "a3")
+
+ # Activity returns: non-zero balance
active_data = { id: "a3", name: "Dormant", balance: 10, currency: "USD" }
importer.send(:import_account, active_data)
+
stats = @sync.reload.sync_stats
assert_equal 0, stats.dig("zero_runs", "a3")
assert_equal false, stats.dig("inactive", "a3")
end
-end
-
-# Additional regression: no balances present should not increment zero_runs or mark inactive
-class SimplefinItem::ImporterInactiveTest < ActiveSupport::TestCase
test "does not count zero run when both balances are missing and no holdings" do
account_data = { id: "a4", name: "Unknown", currency: "USD" } # no balance keys, no holdings
@@ -59,4 +55,64 @@ class SimplefinItem::ImporterInactiveTest < ActiveSupport::TestCase
assert_equal 0, stats.dig("zero_runs", "a4").to_i
assert_equal false, stats.dig("inactive", "a4")
end
+
+ test "skips zero balance detection for credit cards" do
+ # Create a SimplefinAccount linked to a CreditCard account
+ sfa = SimplefinAccount.create!(
+ simplefin_item: @item,
+ name: "Paid Off Card",
+ account_id: "cc1",
+ account_type: "credit",
+ currency: "USD",
+ current_balance: 0
+ )
+
+ credit_card = CreditCard.create!
+ account = @family.accounts.create!(
+ name: "Paid Off Card",
+ balance: 0,
+ currency: "USD",
+ accountable: credit_card,
+ simplefin_account_id: sfa.id
+ )
+
+ account_data = { id: "cc1", name: "Paid Off Card", balance: 0, "available-balance": 0, currency: "USD" }
+
+ # Even with zero balance and no holdings, credit cards should not trigger the counter
+ importer.send(:import_account, account_data)
+ stats = @sync.reload.sync_stats
+
+ assert_nil stats.dig("zero_runs", "cc1"), "should not count zero runs for credit cards"
+ assert_equal false, stats.dig("inactive", "cc1")
+ end
+
+ test "skips zero balance detection for loans" do
+ # Create a SimplefinAccount linked to a Loan account
+ sfa = SimplefinAccount.create!(
+ simplefin_item: @item,
+ name: "Paid Off Loan",
+ account_id: "loan1",
+ account_type: "loan",
+ currency: "USD",
+ current_balance: 0
+ )
+
+ loan = Loan.create!
+ account = @family.accounts.create!(
+ name: "Paid Off Loan",
+ balance: 0,
+ currency: "USD",
+ accountable: loan,
+ simplefin_account_id: sfa.id
+ )
+
+ account_data = { id: "loan1", name: "Paid Off Loan", balance: 0, "available-balance": 0, currency: "USD" }
+
+ # Even with zero balance and no holdings, loans should not trigger the counter
+ importer.send(:import_account, account_data)
+ stats = @sync.reload.sync_stats
+
+ assert_nil stats.dig("zero_runs", "loan1"), "should not count zero runs for loans"
+ assert_equal false, stats.dig("inactive", "loan1")
+ end
end
diff --git a/test/models/simplefin_item/importer_orphan_prune_test.rb b/test/models/simplefin_item/importer_orphan_prune_test.rb
new file mode 100644
index 000000000..626e2d1bf
--- /dev/null
+++ b/test/models/simplefin_item/importer_orphan_prune_test.rb
@@ -0,0 +1,174 @@
+require "test_helper"
+
+class SimplefinItem::ImporterOrphanPruneTest < ActiveSupport::TestCase
+ setup do
+ @family = families(:dylan_family)
+ @item = SimplefinItem.create!(family: @family, name: "SF Conn", access_url: "https://example.com/access")
+ @sync = Sync.create!(syncable: @item)
+ end
+
+ test "prunes orphaned SimplefinAccount records when upstream account_ids change" do
+ # Create an existing SimplefinAccount with an OLD account_id (simulating a previously synced account)
+ old_sfa = SimplefinAccount.create!(
+ simplefin_item: @item,
+ account_id: "ACT-old-id-12345",
+ name: "Business",
+ currency: "USD",
+ current_balance: 100,
+ account_type: "checking"
+ )
+
+ # Stub provider to return accounts with NEW account_ids (simulating re-added institution)
+ mock_provider = mock()
+ mock_provider.expects(:get_accounts).at_least_once.returns({
+ accounts: [
+ { id: "ACT-new-id-67890", name: "Business", balance: "288.41", currency: "USD", type: "checking" }
+ ]
+ })
+
+ importer = SimplefinItem::Importer.new(@item, simplefin_provider: mock_provider, sync: @sync)
+ importer.send(:perform_account_discovery)
+
+ # The old SimplefinAccount should be pruned
+ assert_nil SimplefinAccount.find_by(id: old_sfa.id), "old SimplefinAccount with stale account_id should be deleted"
+
+ # A new SimplefinAccount should exist with the new account_id
+ new_sfa = @item.simplefin_accounts.find_by(account_id: "ACT-new-id-67890")
+ assert_not_nil new_sfa, "new SimplefinAccount should be created"
+ assert_equal "Business", new_sfa.name
+
+ # Stats should reflect the pruning
+ stats = @sync.reload.sync_stats
+ assert_equal 1, stats["accounts_pruned"], "should track pruned accounts"
+ end
+
+ test "does not prune SimplefinAccount that is linked to an Account via legacy FK" do
+ # Create a SimplefinAccount with an old account_id
+ old_sfa = SimplefinAccount.create!(
+ simplefin_item: @item,
+ account_id: "ACT-old-id-12345",
+ name: "Business",
+ currency: "USD",
+ current_balance: 100,
+ account_type: "checking"
+ )
+
+ # Link it to an Account via legacy FK
+ account = Account.create!(
+ family: @family,
+ name: "Business Checking",
+ currency: "USD",
+ balance: 100,
+ accountable: Depository.create!(subtype: :checking),
+ simplefin_account_id: old_sfa.id
+ )
+
+ # Stub provider to return accounts with NEW account_ids
+ mock_provider = mock()
+ mock_provider.expects(:get_accounts).at_least_once.returns({
+ accounts: [
+ { id: "ACT-new-id-67890", name: "Business", balance: "288.41", currency: "USD", type: "checking" }
+ ]
+ })
+
+ importer = SimplefinItem::Importer.new(@item, simplefin_provider: mock_provider, sync: @sync)
+ importer.send(:perform_account_discovery)
+
+ # The old SimplefinAccount should NOT be pruned because it's linked
+ assert_not_nil SimplefinAccount.find_by(id: old_sfa.id), "linked SimplefinAccount should not be deleted"
+
+ # New SimplefinAccount should also exist
+ new_sfa = @item.simplefin_accounts.find_by(account_id: "ACT-new-id-67890")
+ assert_not_nil new_sfa, "new SimplefinAccount should be created"
+
+ # Stats should not show any pruning
+ stats = @sync.reload.sync_stats
+ assert_nil stats["accounts_pruned"], "should not prune linked accounts"
+ end
+
+ test "does not prune SimplefinAccount that is linked via AccountProvider" do
+ # Create a SimplefinAccount with an old account_id
+ old_sfa = SimplefinAccount.create!(
+ simplefin_item: @item,
+ account_id: "ACT-old-id-12345",
+ name: "Business",
+ currency: "USD",
+ current_balance: 100,
+ account_type: "checking"
+ )
+
+ # Create an Account and link via AccountProvider (new system)
+ account = Account.create!(
+ family: @family,
+ name: "Business Checking",
+ currency: "USD",
+ balance: 100,
+ accountable: Depository.create!(subtype: :checking)
+ )
+ AccountProvider.create!(account: account, provider: old_sfa)
+
+ # Stub provider to return accounts with NEW account_ids
+ mock_provider = mock()
+ mock_provider.expects(:get_accounts).at_least_once.returns({
+ accounts: [
+ { id: "ACT-new-id-67890", name: "Business", balance: "288.41", currency: "USD", type: "checking" }
+ ]
+ })
+
+ importer = SimplefinItem::Importer.new(@item, simplefin_provider: mock_provider, sync: @sync)
+ importer.send(:perform_account_discovery)
+
+ # The old SimplefinAccount should NOT be pruned because it's linked via AccountProvider
+ assert_not_nil SimplefinAccount.find_by(id: old_sfa.id), "linked SimplefinAccount should not be deleted"
+
+ # Stats should not show any pruning
+ stats = @sync.reload.sync_stats
+ assert_nil stats["accounts_pruned"], "should not prune linked accounts"
+ end
+
+ test "prunes multiple orphaned SimplefinAccounts when institution re-added with all new IDs" do
+ # Create two old SimplefinAccounts (simulating two accounts from before re-add)
+ old_sfa1 = SimplefinAccount.create!(
+ simplefin_item: @item,
+ account_id: "ACT-old-business",
+ name: "Business",
+ currency: "USD",
+ current_balance: 28.41,
+ account_type: "checking"
+ )
+
+ old_sfa2 = SimplefinAccount.create!(
+ simplefin_item: @item,
+ account_id: "ACT-old-personal",
+ name: "Personal",
+ currency: "USD",
+ current_balance: 308.43,
+ account_type: "checking"
+ )
+
+ # Stub provider to return accounts with entirely NEW account_ids
+ mock_provider = mock()
+ mock_provider.expects(:get_accounts).at_least_once.returns({
+ accounts: [
+ { id: "ACT-new-business", name: "Business", balance: "288.41", currency: "USD", type: "checking" },
+ { id: "ACT-new-personal", name: "Personal", balance: "22.43", currency: "USD", type: "checking" }
+ ]
+ })
+
+ importer = SimplefinItem::Importer.new(@item, simplefin_provider: mock_provider, sync: @sync)
+ importer.send(:perform_account_discovery)
+
+ # Both old SimplefinAccounts should be pruned
+ assert_nil SimplefinAccount.find_by(id: old_sfa1.id), "old Business SimplefinAccount should be deleted"
+ assert_nil SimplefinAccount.find_by(id: old_sfa2.id), "old Personal SimplefinAccount should be deleted"
+
+ # New SimplefinAccounts should exist
+ assert_equal 2, @item.simplefin_accounts.reload.count, "should have exactly 2 SimplefinAccounts"
+ assert_not_nil @item.simplefin_accounts.find_by(account_id: "ACT-new-business")
+ assert_not_nil @item.simplefin_accounts.find_by(account_id: "ACT-new-personal")
+
+ # Stats should reflect both pruned
+ stats = @sync.reload.sync_stats
+ assert_equal 2, stats["accounts_pruned"], "should track both pruned accounts"
+ end
+end
diff --git a/test/models/trade_import_test.rb b/test/models/trade_import_test.rb
index 54b307972..7914fe932 100644
--- a/test/models/trade_import_test.rb
+++ b/test/models/trade_import_test.rb
@@ -62,4 +62,54 @@ class TradeImportTest < ActiveSupport::TestCase
assert_equal "complete", @import.status
end
+
+ test "auto-categorizes buy trades and leaves sell trades uncategorized" do
+ aapl = securities(:aapl)
+ aapl_resolver = mock
+ aapl_resolver.stubs(:resolve).returns(aapl)
+ Security::Resolver.stubs(:new).returns(aapl_resolver)
+
+ # Create the investment category if it doesn't exist
+ account = accounts(:depository)
+ family = account.family
+ savings_category = family.categories.find_or_create_by!(name: "Savings & Investments") do |c|
+ c.color = "#059669"
+ c.classification = "expense"
+ c.lucide_icon = "piggy-bank"
+ end
+
+ import = <<~CSV
+ date,ticker,qty,price,currency,name
+ 01/01/2024,AAPL,10,150.00,USD,Apple Buy
+ 01/02/2024,AAPL,-5,160.00,USD,Apple Sell
+ CSV
+
+ @import.update!(
+ account: account,
+ raw_file_str: import,
+ date_col_label: "date",
+ ticker_col_label: "ticker",
+ qty_col_label: "qty",
+ price_col_label: "price",
+ date_format: "%m/%d/%Y",
+ signage_convention: "inflows_positive"
+ )
+
+ @import.generate_rows_from_csv
+ @import.reload
+
+ assert_difference -> { Trade.count } => 2 do
+ @import.publish
+ end
+
+ # Find trades created by this import
+ imported_trades = Trade.joins(:entry).where(entries: { import_id: @import.id })
+ buy_trade = imported_trades.find { |t| t.qty.positive? }
+ sell_trade = imported_trades.find { |t| t.qty.negative? }
+
+ assert_not_nil buy_trade, "Buy trade should have been created"
+ assert_not_nil sell_trade, "Sell trade should have been created"
+ assert_equal savings_category, buy_trade.category, "Buy trade should be auto-categorized as Savings & Investments"
+ assert_nil sell_trade.category, "Sell trade should not be auto-categorized"
+ end
end
diff --git a/test/models/transaction_import_test.rb b/test/models/transaction_import_test.rb
index c3e505d2e..8e9c8261f 100644
--- a/test/models/transaction_import_test.rb
+++ b/test/models/transaction_import_test.rb
@@ -14,6 +14,7 @@ class TransactionImportTest < ActiveSupport::TestCase
test "configured? if uploaded and rows are generated" do
@import.expects(:uploaded?).returns(true).once
+ @import.expects(:rows_count).returns(1).once
assert @import.configured?
end
diff --git a/test/models/user_test.rb b/test/models/user_test.rb
index c8d16889d..b4558744e 100644
--- a/test/models/user_test.rb
+++ b/test/models/user_test.rb
@@ -276,4 +276,59 @@ class UserTest < ActiveSupport::TestCase
assert_not @user.dashboard_section_collapsed?("net_worth_chart"),
"Should return false when section key is missing from collapsed_sections"
end
+
+ # SSO-only user security tests
+ test "sso_only? returns true for user with OIDC identity and no password" do
+ sso_user = users(:sso_only)
+ assert_nil sso_user.password_digest
+ assert sso_user.oidc_identities.exists?
+ assert sso_user.sso_only?
+ end
+
+ test "sso_only? returns false for user with password and OIDC identity" do
+ # family_admin has both password and OIDC identity
+ assert @user.password_digest.present?
+ assert @user.oidc_identities.exists?
+ assert_not @user.sso_only?
+ end
+
+ test "sso_only? returns false for user with password but no OIDC identity" do
+ user_without_oidc = users(:empty)
+ assert user_without_oidc.password_digest.present?
+ assert_not user_without_oidc.oidc_identities.exists?
+ assert_not user_without_oidc.sso_only?
+ end
+
+ test "has_local_password? returns true when password_digest is present" do
+ assert @user.has_local_password?
+ end
+
+ test "has_local_password? returns false when password_digest is nil" do
+ sso_user = users(:sso_only)
+ assert_not sso_user.has_local_password?
+ end
+
+ test "user can be created without password when skip_password_validation is true" do
+ user = User.new(
+ email: "newssuser@example.com",
+ first_name: "New",
+ last_name: "SSO User",
+ skip_password_validation: true,
+ family: families(:empty)
+ )
+ assert user.valid?, user.errors.full_messages.to_sentence
+ assert user.save
+ assert_nil user.password_digest
+ end
+
+ test "user requires password on create when skip_password_validation is false" do
+ user = User.new(
+ email: "needspassword@example.com",
+ first_name: "Needs",
+ last_name: "Password",
+ family: families(:empty)
+ )
+ assert_not user.valid?
+ assert_includes user.errors[:password], "can't be blank"
+ end
end
diff --git a/test/system/drag_and_drop_import_test.rb b/test/system/drag_and_drop_import_test.rb
new file mode 100644
index 000000000..6a2b94e9b
--- /dev/null
+++ b/test/system/drag_and_drop_import_test.rb
@@ -0,0 +1,36 @@
+require "application_system_test_case"
+
+class DragAndDropImportTest < ApplicationSystemTestCase
+ setup do
+ sign_in users(:family_admin)
+ end
+
+ test "upload csv via hidden input on transactions index" do
+ visit transactions_path
+
+ assert_selector "#transactions[data-controller*='drag-and-drop-import']"
+
+ # We can't easily simulate a true native drag-and-drop in headless chrome via Capybara without complex JS.
+ # However, we can verify that the hidden form exists and works when a file is "dropped" (input populated).
+ # The Stimulus controller's job is just to transfer the dropped file to the input and submit.
+
+ file_path = file_fixture("imports/transactions.csv")
+
+ # Manually make form and input visible
+ execute_script("
+ var form = document.querySelector('form[action=\"#{imports_path}\"]');
+ form.classList.remove('hidden');
+ var input = document.querySelector('input[name=\"import[csv_file]\"]');
+ input.classList.remove('hidden');
+ input.style.display = 'block';
+ ")
+
+ attach_file "import[csv_file]", file_path
+
+ # Submit the form manually since we bypassed the 'drop' event listener which triggers submit
+ find("form[action='#{imports_path}']").evaluate_script("this.requestSubmit()")
+
+ assert_text "CSV uploaded successfully"
+ assert_text "Configure your import"
+ end
+end