From 46ab1d83733060a2beb47ff0dfa5c7e3e20273b8 Mon Sep 17 00:00:00 2001 From: eureka928 Date: Mon, 26 Jan 2026 09:46:20 +0100 Subject: [PATCH 001/108] Add AI cache clearing infrastructure Add ClearAiCacheJob for async cache clearing with low priority. Extend Enrichable concern with clear_ai_cache methods to unlock AI-enriched attributes and delete AI enrichment records. Trigger automatic cache clearing when OpenAI model setting changes. --- app/jobs/clear_ai_cache_job.rb | 15 ++++++++++++ app/models/concerns/enrichable.rb | 39 +++++++++++++++++++++++++++++++ app/models/setting.rb | 14 +++++++++++ 3 files changed, 68 insertions(+) create mode 100644 app/jobs/clear_ai_cache_job.rb diff --git a/app/jobs/clear_ai_cache_job.rb b/app/jobs/clear_ai_cache_job.rb new file mode 100644 index 000000000..bff65fa79 --- /dev/null +++ b/app/jobs/clear_ai_cache_job.rb @@ -0,0 +1,15 @@ +class ClearAiCacheJob < ApplicationJob + queue_as :low_priority + + def perform(family) + Rails.logger.info("Clearing AI cache for family #{family.id}") + + # Clear AI enrichment data for transactions + Transaction.clear_ai_cache(family) + Rails.logger.info("Cleared AI cache for transactions") + + # Clear AI enrichment data for entries + Entry.clear_ai_cache(family) + Rails.logger.info("Cleared AI cache for entries") + end +end diff --git a/app/models/concerns/enrichable.rb b/app/models/concerns/enrichable.rb index f305a4faa..d9e0fc38b 100644 --- a/app/models/concerns/enrichable.rb +++ b/app/models/concerns/enrichable.rb @@ -15,6 +15,8 @@ module Enrichable InvalidAttributeError = Class.new(StandardError) included do + has_many :data_enrichments, as: :enrichable, dependent: :destroy + scope :enrichable, ->(attrs) { attrs = Array(attrs).map(&:to_s) json_condition = attrs.each_with_object({}) { |attr, hash| hash[attr] = true } @@ -22,6 +24,28 @@ module Enrichable } end + class_methods do + def clear_ai_cache(family) + # Get all records that belong to this family + records = if respond_to?(:joins) + case name + when "Transaction" + joins(entry: :account).where(accounts: { family_id: family.id }) + when "Entry" + joins(:account).where(accounts: { family_id: family.id }) + else + none + end + else + none + end + + records.find_each do |record| + record.clear_ai_cache + end + end + end + # Convenience method for a single attribute def enrich_attribute(attr, value, source:, metadata: {}) enrich_attributes({ attr => value }, source:, metadata:) @@ -124,6 +148,21 @@ module Enrichable end end + def clear_ai_cache + ActiveRecord::Base.transaction do + # Find attributes that were locked by AI enrichment + ai_enriched_attrs = data_enrichments.where(source: "ai").pluck(:attribute_name).uniq + + # Remove locks for AI-enriched attributes + ai_enriched_attrs.each do |attr| + unlock_attr!(attr) if locked?(attr) + end + + # Delete AI enrichment records + data_enrichments.where(source: "ai").delete_all + end + end + private def log_enrichment(attribute_name:, attribute_value:, source:, metadata: {}) de = DataEnrichment.find_or_create_by( diff --git a/app/models/setting.rb b/app/models/setting.rb index 857949b5a..9a9facfb8 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -80,6 +80,8 @@ class Setting < RailsSettings::Base class << self alias_method :raw_onboarding_state, :onboarding_state alias_method :raw_onboarding_state=, :onboarding_state= + alias_method :raw_openai_model, :openai_model + alias_method :raw_openai_model=, :openai_model= def onboarding_state value = raw_onboarding_state @@ -94,6 +96,18 @@ class Setting < RailsSettings::Base self.raw_onboarding_state = state end + def openai_model=(value) + old_value = raw_openai_model + self.raw_openai_model = value + + if old_value != value && old_value.present? + Rails.logger.info("OpenAI model changed from #{old_value} to #{value}, clearing AI cache for all families") + Family.find_each do |family| + ClearAiCacheJob.perform_later(family) + end + end + end + # Support dynamic field access via bracket notation # First checks if it's a declared field, then falls back to individual dynamic entries def [](key) From b511b3add97075b4406cf9be5ff21686afb49539 Mon Sep 17 00:00:00 2001 From: eureka928 Date: Mon, 26 Jan 2026 09:46:26 +0100 Subject: [PATCH 002/108] Add clear_ai_cache endpoint to rules controller Add POST /rules/clear_ai_cache route and controller action to trigger AI cache clearing for the current family. --- app/controllers/rules_controller.rb | 5 +++++ config/routes.rb | 1 + 2 files changed, 6 insertions(+) diff --git a/app/controllers/rules_controller.rb b/app/controllers/rules_controller.rb index 23889dc5a..b516e44f1 100644 --- a/app/controllers/rules_controller.rb +++ b/app/controllers/rules_controller.rb @@ -128,6 +128,11 @@ class RulesController < ApplicationController redirect_back_or_to rules_path, notice: t("rules.apply_all.success") end + def clear_ai_cache + ClearAiCacheJob.perform_later(Current.family) + redirect_to rules_path, notice: "AI cache is being cleared. This may take a few moments." + end + private def set_rule @rule = Current.family.rules.find(params[:id]) diff --git a/config/routes.rb b/config/routes.rb index 96ad6a28d..b718be561 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -292,6 +292,7 @@ Rails.application.routes.draw do delete :destroy_all get :confirm_all post :apply_all + post :clear_ai_cache end end From 329fe9832a40511647388919a0f1a1e309222f71 Mon Sep 17 00:00:00 2001 From: eureka928 Date: Mon, 26 Jan 2026 09:46:31 +0100 Subject: [PATCH 003/108] Add Reset AI cache button to rules index Add menu button with confirmation dialog to reset AI cache. Fix menu_item to safely handle non-standard confirm values. --- app/components/DS/menu_item.rb | 3 ++- app/views/rules/index.html.erb | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/app/components/DS/menu_item.rb b/app/components/DS/menu_item.rb index 5b099e7af..5aa3959b3 100644 --- a/app/components/DS/menu_item.rb +++ b/app/components/DS/menu_item.rb @@ -50,7 +50,8 @@ class DS::MenuItem < DesignSystemComponent data = merged_opts.delete(:data) || {} if confirm.present? - data = data.merge(turbo_confirm: confirm.to_data_attribute) + confirm_value = confirm.respond_to?(:to_data_attribute) ? confirm.to_data_attribute : confirm + data = data.merge(turbo_confirm: confirm_value) end if frame.present? diff --git a/app/views/rules/index.html.erb b/app/views/rules/index.html.erb index 0acacb0fc..0b10c4557 100644 --- a/app/views/rules/index.html.erb +++ b/app/views/rules/index.html.erb @@ -3,6 +3,17 @@
<% if @rules.any? %> <%= render DS::Menu.new do |menu| %> + <% menu.with_item( + variant: "button", + text: "Reset AI cache", + href: clear_ai_cache_rules_path, + icon: "refresh-cw", + method: :post, + confirm: CustomConfirm.new( + title: "Reset AI cache?", + body: "Are you sure you want to reset the AI cache? This will allow AI rules to re-process all transactions. This may incur additional API costs.", + btn_text: "Reset Cache" + )) %> <% menu.with_item( variant: "button", text: "Delete all rules", From b82757f58ee6791e6a0621e946949072be8d9996 Mon Sep 17 00:00:00 2001 From: eureka928 Date: Mon, 26 Jan 2026 09:49:05 +0100 Subject: [PATCH 004/108] Use i18n for AI cache reset strings Extract hardcoded strings to locale file for proper internationalization. --- app/controllers/rules_controller.rb | 2 +- app/views/rules/index.html.erb | 8 ++++---- config/locales/views/rules/en.yml | 6 ++++++ 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/app/controllers/rules_controller.rb b/app/controllers/rules_controller.rb index b516e44f1..07c355a56 100644 --- a/app/controllers/rules_controller.rb +++ b/app/controllers/rules_controller.rb @@ -130,7 +130,7 @@ class RulesController < ApplicationController def clear_ai_cache ClearAiCacheJob.perform_later(Current.family) - redirect_to rules_path, notice: "AI cache is being cleared. This may take a few moments." + redirect_to rules_path, notice: t("rules.clear_ai_cache.success") end private diff --git a/app/views/rules/index.html.erb b/app/views/rules/index.html.erb index 0b10c4557..2b5867bd5 100644 --- a/app/views/rules/index.html.erb +++ b/app/views/rules/index.html.erb @@ -5,14 +5,14 @@ <%= render DS::Menu.new do |menu| %> <% menu.with_item( variant: "button", - text: "Reset AI cache", + text: t("rules.clear_ai_cache.button"), href: clear_ai_cache_rules_path, icon: "refresh-cw", method: :post, confirm: CustomConfirm.new( - title: "Reset AI cache?", - body: "Are you sure you want to reset the AI cache? This will allow AI rules to re-process all transactions. This may incur additional API costs.", - btn_text: "Reset Cache" + title: t("rules.clear_ai_cache.confirm_title"), + body: t("rules.clear_ai_cache.confirm_body"), + btn_text: t("rules.clear_ai_cache.confirm_button") )) %> <% menu.with_item( variant: "button", diff --git a/config/locales/views/rules/en.yml b/config/locales/views/rules/en.yml index 5cbd252d5..e91fd4246 100644 --- a/config/locales/views/rules/en.yml +++ b/config/locales/views/rules/en.yml @@ -37,3 +37,9 @@ en: pending: Pending success: Success failed: Failed + clear_ai_cache: + button: Reset AI cache + confirm_title: Reset AI cache? + confirm_body: Are you sure you want to reset the AI cache? This will allow AI rules to re-process all transactions. This may incur additional API costs. + confirm_button: Reset Cache + success: AI cache is being cleared. This may take a few moments. From ed8185cf2bbbab42335fb0253e4813e62a9a9b0c Mon Sep 17 00:00:00 2001 From: eureka928 Date: Mon, 26 Jan 2026 09:50:05 +0100 Subject: [PATCH 005/108] Optimize clear_ai_cache to batch unlock attributes Replace N individual update calls with single update_column for better performance. --- app/models/concerns/enrichable.rb | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/models/concerns/enrichable.rb b/app/models/concerns/enrichable.rb index d9e0fc38b..04c557dc6 100644 --- a/app/models/concerns/enrichable.rb +++ b/app/models/concerns/enrichable.rb @@ -153,9 +153,10 @@ module Enrichable # Find attributes that were locked by AI enrichment ai_enriched_attrs = data_enrichments.where(source: "ai").pluck(:attribute_name).uniq - # Remove locks for AI-enriched attributes - ai_enriched_attrs.each do |attr| - unlock_attr!(attr) if locked?(attr) + # Batch unlock all AI-enriched attributes in a single update + if ai_enriched_attrs.any? + new_locked_attrs = locked_attributes.except(*ai_enriched_attrs) + update_column(:locked_attributes, new_locked_attrs) if new_locked_attrs != locked_attributes end # Delete AI enrichment records From 029d09685ee558abd2d8bb822df5e83519ba0c04 Mon Sep 17 00:00:00 2001 From: eureka928 Date: Mon, 26 Jan 2026 10:14:06 +0100 Subject: [PATCH 006/108] Address PR review feedback - Preserve user locks: Only unlock attributes where current value still matches what AI set. If user changed the value, they took ownership. - Add nil guard clause for family parameter in ClearAiCacheJob - Add partial failure handling so one model's failure doesn't block the other --- app/jobs/clear_ai_cache_job.rb | 21 +++++++++++++++++---- app/models/concerns/enrichable.rb | 18 ++++++++++++------ 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/app/jobs/clear_ai_cache_job.rb b/app/jobs/clear_ai_cache_job.rb index bff65fa79..059293818 100644 --- a/app/jobs/clear_ai_cache_job.rb +++ b/app/jobs/clear_ai_cache_job.rb @@ -2,14 +2,27 @@ class ClearAiCacheJob < ApplicationJob queue_as :low_priority def perform(family) + if family.nil? + Rails.logger.warn("ClearAiCacheJob called with nil family, skipping") + return + end + Rails.logger.info("Clearing AI cache for family #{family.id}") # Clear AI enrichment data for transactions - Transaction.clear_ai_cache(family) - Rails.logger.info("Cleared AI cache for transactions") + begin + Transaction.clear_ai_cache(family) + Rails.logger.info("Cleared AI cache for transactions") + rescue => e + Rails.logger.error("Failed to clear AI cache for transactions: #{e.message}") + end # Clear AI enrichment data for entries - Entry.clear_ai_cache(family) - Rails.logger.info("Cleared AI cache for entries") + begin + Entry.clear_ai_cache(family) + Rails.logger.info("Cleared AI cache for entries") + rescue => e + Rails.logger.error("Failed to clear AI cache for entries: #{e.message}") + end end end diff --git a/app/models/concerns/enrichable.rb b/app/models/concerns/enrichable.rb index 04c557dc6..d963c663d 100644 --- a/app/models/concerns/enrichable.rb +++ b/app/models/concerns/enrichable.rb @@ -150,17 +150,23 @@ module Enrichable def clear_ai_cache ActiveRecord::Base.transaction do - # Find attributes that were locked by AI enrichment - ai_enriched_attrs = data_enrichments.where(source: "ai").pluck(:attribute_name).uniq + ai_enrichments = data_enrichments.where(source: "ai") - # Batch unlock all AI-enriched attributes in a single update - if ai_enriched_attrs.any? - new_locked_attrs = locked_attributes.except(*ai_enriched_attrs) + # Only unlock attributes where current value still matches what AI set + # If user changed the value, they took ownership - don't unlock + attrs_to_unlock = ai_enrichments.select do |enrichment| + current_value = send(enrichment.attribute_name) rescue self[enrichment.attribute_name] + current_value.to_s == enrichment.value.to_s + end.map(&:attribute_name).uniq + + # Batch unlock in a single update + if attrs_to_unlock.any? + new_locked_attrs = locked_attributes.except(*attrs_to_unlock) update_column(:locked_attributes, new_locked_attrs) if new_locked_attrs != locked_attributes end # Delete AI enrichment records - data_enrichments.where(source: "ai").delete_all + ai_enrichments.delete_all end end From 23e9749ed18c13c84bffadab322dd0850e474efa Mon Sep 17 00:00:00 2001 From: eureka928 Date: Mon, 26 Jan 2026 10:17:28 +0100 Subject: [PATCH 007/108] Refactor clear_ai_cache to use family_scope pattern - Move family-scoped queries to models via family_scope class method - Remove hardcoded model names from Enrichable concern - Replace inline rescue with proper respond_to? check - Add count tracking for better logging --- app/jobs/clear_ai_cache_job.rb | 8 ++++---- app/models/concerns/enrichable.rb | 27 +++++++++++---------------- app/models/entry.rb | 5 +++++ app/models/transaction.rb | 5 +++++ 4 files changed, 25 insertions(+), 20 deletions(-) diff --git a/app/jobs/clear_ai_cache_job.rb b/app/jobs/clear_ai_cache_job.rb index 059293818..478acba64 100644 --- a/app/jobs/clear_ai_cache_job.rb +++ b/app/jobs/clear_ai_cache_job.rb @@ -11,16 +11,16 @@ class ClearAiCacheJob < ApplicationJob # Clear AI enrichment data for transactions begin - Transaction.clear_ai_cache(family) - Rails.logger.info("Cleared AI cache for transactions") + count = Transaction.clear_ai_cache(family) + Rails.logger.info("Cleared AI cache for #{count} transactions") rescue => e Rails.logger.error("Failed to clear AI cache for transactions: #{e.message}") end # Clear AI enrichment data for entries begin - Entry.clear_ai_cache(family) - Rails.logger.info("Cleared AI cache for entries") + count = Entry.clear_ai_cache(family) + Rails.logger.info("Cleared AI cache for #{count} entries") rescue => e Rails.logger.error("Failed to clear AI cache for entries: #{e.message}") end diff --git a/app/models/concerns/enrichable.rb b/app/models/concerns/enrichable.rb index d963c663d..a354d4245 100644 --- a/app/models/concerns/enrichable.rb +++ b/app/models/concerns/enrichable.rb @@ -25,24 +25,18 @@ module Enrichable end class_methods do - def clear_ai_cache(family) - # Get all records that belong to this family - records = if respond_to?(:joins) - case name - when "Transaction" - joins(entry: :account).where(accounts: { family_id: family.id }) - when "Entry" - joins(:account).where(accounts: { family_id: family.id }) - else - none - end - else - none - end + # Override in models to define family-scoped query + def family_scope(family) + none + end - records.find_each do |record| + def clear_ai_cache(family) + count = 0 + family_scope(family).find_each do |record| record.clear_ai_cache + count += 1 end + count end end @@ -155,7 +149,8 @@ module Enrichable # Only unlock attributes where current value still matches what AI set # If user changed the value, they took ownership - don't unlock attrs_to_unlock = ai_enrichments.select do |enrichment| - current_value = send(enrichment.attribute_name) rescue self[enrichment.attribute_name] + attr_name = enrichment.attribute_name + current_value = respond_to?(attr_name) ? send(attr_name) : self[attr_name] current_value.to_s == enrichment.value.to_s end.map(&:attribute_name).uniq diff --git a/app/models/entry.rb b/app/models/entry.rb index 31118db45..1436eb545 100644 --- a/app/models/entry.rb +++ b/app/models/entry.rb @@ -66,6 +66,11 @@ class Entry < ApplicationRecord pending.where("entries.date < ?", days.days.ago.to_date) } + # Family-scoped query for Enrichable#clear_ai_cache + def self.family_scope(family) + joins(:account).where(accounts: { family_id: family.id }) + end + # Auto-exclude stale pending transactions for an account # Called during sync to clean up pending transactions that never posted # @param account [Account] The account to clean up diff --git a/app/models/transaction.rb b/app/models/transaction.rb index 4218e980e..4f94bb488 100644 --- a/app/models/transaction.rb +++ b/app/models/transaction.rb @@ -47,6 +47,11 @@ class Transaction < ApplicationRecord SQL } + # Family-scoped query for Enrichable#clear_ai_cache + def self.family_scope(family) + joins(entry: :account).where(accounts: { family_id: family.id }) + end + # Overarching grouping method for all transfer-type transactions def transfer? funds_movement? || cc_payment? || loan_payment? From 4d867c193c24556834950d203f83d4c863562c66 Mon Sep 17 00:00:00 2001 From: Fabien Le Frapper Date: Mon, 26 Jan 2026 10:26:48 +0100 Subject: [PATCH 008/108] Increase min year for home built (#783) --- app/views/properties/_form.html.erb | 2 +- app/views/properties/_overview_fields.html.erb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/properties/_form.html.erb b/app/views/properties/_form.html.erb index ea7b7ae04..7dc2b8b58 100644 --- a/app/views/properties/_form.html.erb +++ b/app/views/properties/_form.html.erb @@ -13,7 +13,7 @@ <%= property_form.number_field :year_built, label: t("properties.form.year_built"), placeholder: t("properties.form.year_built_placeholder"), - min: 1800, + min: 1500, max: Time.current.year %>
diff --git a/app/views/properties/_overview_fields.html.erb b/app/views/properties/_overview_fields.html.erb index cd263ae90..7f7201d7d 100644 --- a/app/views/properties/_overview_fields.html.erb +++ b/app/views/properties/_overview_fields.html.erb @@ -17,7 +17,7 @@ <%= property_form.number_field :year_built, label: "Year Built (optional)", placeholder: "1990", - min: 1800, + min: 1500, max: Time.current.year %> From 02c71bca0a243f254bf0e39420d8a47d13f2cead Mon Sep 17 00:00:00 2001 From: eureka928 Date: Mon, 26 Jan 2026 10:41:14 +0100 Subject: [PATCH 009/108] Add AI Cache Management documentation Document the AI cache reset feature including what it does, when to use it, how to reset via UI, and cost implications. --- docs/hosting/ai.md | 63 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/docs/hosting/ai.md b/docs/hosting/ai.md index 7273ab1ab..23ab1c12f 100644 --- a/docs/hosting/ai.md +++ b/docs/hosting/ai.md @@ -287,6 +287,69 @@ For self-hosted deployments, you can configure AI settings through the web inter **Note:** Settings in the UI override environment variables. If you change settings in the UI, those values take precedence. +## AI Cache Management + +Sure caches AI-generated results (like auto-categorization and merchant detection) to avoid redundant API calls and costs. However, there are situations where you may want to clear this cache. + +### What is the AI Cache? + +When AI rules process transactions, Sure stores: +- **Enrichment records**: Which attributes were set by AI (category, merchant, etc.) +- **Attribute locks**: Prevents rules from re-processing already-handled transactions + +This caching means: +- Transactions won't be sent to the LLM repeatedly +- Your API costs are minimized +- Processing is faster on subsequent rule runs + +### When to Reset the AI Cache + +You might want to reset the cache when: + +1. **Switching LLM models**: Different models may produce better categorizations +2. **Improving prompts**: After system updates with better prompts +3. **Fixing miscategorizations**: When AI made systematic errors +4. **Testing**: During development or evaluation of AI features + +> [!CAUTION] +> Resetting the AI cache will cause all transactions to be re-processed by AI rules on the next run. This **will incur API costs** if using a cloud provider. + +### How to Reset the AI Cache + +**Via UI (Recommended):** +1. Go to **Settings** → **Rules** +2. Click the menu button (three dots) +3. Select **Reset AI cache** +4. Confirm the action + +The cache is cleared asynchronously in the background. You'll see a confirmation message when the process starts. + +**Automatic Reset:** +The AI cache is automatically cleared for all users when the OpenAI model setting is changed. This ensures that the new model processes transactions fresh. + +### What Happens When Cache is Reset + +1. **AI-locked attributes are unlocked**: Transactions can be re-enriched +2. **AI enrichment records are deleted**: The history of AI changes is cleared +3. **User edits are preserved**: If you manually changed a category after AI set it, your change is kept + +### Cost Implications + +Before resetting the cache, consider: + +| Scenario | Approximate Cost | +|----------|------------------| +| 100 transactions | $0.05-0.20 | +| 1,000 transactions | $0.50-2.00 | +| 10,000 transactions | $5.00-20.00 | + +*Costs vary by model. Use `gpt-4o-mini` for lower costs.* + +**Tips to minimize costs:** +- Use narrow rule filters before running AI actions +- Reset cache only when necessary +- Consider using local LLMs for bulk re-processing + ## Observability with Langfuse Sure includes built-in support for [Langfuse](https://langfuse.com/), an open-source LLM observability platform. From 51579d3731867bcada1d842e26cf4c8c5028e6dd Mon Sep 17 00:00:00 2001 From: Dream <42954461+eureka928@users.noreply.github.com> Date: Mon, 26 Jan 2026 10:53:05 -0500 Subject: [PATCH 010/108] FR: Add transaction type as rule condition option (#790) * Add transaction type condition filter for rules Add ability to filter rules by transaction type (income, expense, transfer). This allows users to create rules that differentiate between transactions with the same name but different types. - Add Rule::ConditionFilter::TransactionType with select dropdown - Register in TransactionResource condition_filters - Add tests for income, expense, and transfer filtering Closes #373 * Address PR review feedback for transaction type filter - Fix income filter to exclude transfers and investment_contribution - Fix expense filter to include investment_contribution regardless of sign - Add i18n for option and operator labels - Add tests for edge cases (transfer inflows, investment contributions) Logic now matches Transaction::Search#apply_type_filter for consistency. --- .../rule/condition_filter/transaction_type.rb | 42 +++++ .../rule/registry/transaction_resource.rb | 1 + config/locales/views/rules/en.yml | 6 + test/models/rule/condition_test.rb | 167 ++++++++++++++++++ 4 files changed, 216 insertions(+) create mode 100644 app/models/rule/condition_filter/transaction_type.rb diff --git a/app/models/rule/condition_filter/transaction_type.rb b/app/models/rule/condition_filter/transaction_type.rb new file mode 100644 index 000000000..4c852d236 --- /dev/null +++ b/app/models/rule/condition_filter/transaction_type.rb @@ -0,0 +1,42 @@ +class Rule::ConditionFilter::TransactionType < Rule::ConditionFilter + # Transfer kinds matching Transaction#transfer? method + TRANSFER_KINDS = %w[funds_movement cc_payment loan_payment].freeze + + def type + "select" + end + + def options + [ + [ I18n.t("rules.condition_filters.transaction_type.income"), "income" ], + [ I18n.t("rules.condition_filters.transaction_type.expense"), "expense" ], + [ I18n.t("rules.condition_filters.transaction_type.transfer"), "transfer" ] + ] + end + + def operators + [ [ I18n.t("rules.condition_filters.transaction_type.equal_to"), "=" ] ] + end + + def prepare(scope) + scope.with_entry + end + + def apply(scope, operator, value) + # Logic matches Transaction::Search#apply_type_filter for consistency + case value + when "income" + # Negative amounts, excluding transfers and investment_contribution + scope.where("entries.amount < 0") + .where.not(kind: TRANSFER_KINDS + %w[investment_contribution]) + when "expense" + # Positive amounts OR investment_contribution (regardless of sign), excluding transfers + scope.where("entries.amount >= 0 OR transactions.kind = 'investment_contribution'") + .where.not(kind: TRANSFER_KINDS) + when "transfer" + scope.where(kind: TRANSFER_KINDS) + else + scope + end + end +end diff --git a/app/models/rule/registry/transaction_resource.rb b/app/models/rule/registry/transaction_resource.rb index fac1d6667..3e0117fc7 100644 --- a/app/models/rule/registry/transaction_resource.rb +++ b/app/models/rule/registry/transaction_resource.rb @@ -7,6 +7,7 @@ class Rule::Registry::TransactionResource < Rule::Registry [ Rule::ConditionFilter::TransactionName.new(rule), Rule::ConditionFilter::TransactionAmount.new(rule), + Rule::ConditionFilter::TransactionType.new(rule), Rule::ConditionFilter::TransactionMerchant.new(rule), Rule::ConditionFilter::TransactionCategory.new(rule), Rule::ConditionFilter::TransactionDetails.new(rule), diff --git a/config/locales/views/rules/en.yml b/config/locales/views/rules/en.yml index 5cbd252d5..905dca236 100644 --- a/config/locales/views/rules/en.yml +++ b/config/locales/views/rules/en.yml @@ -37,3 +37,9 @@ en: pending: Pending success: Success failed: Failed + condition_filters: + transaction_type: + income: Income + expense: Expense + transfer: Transfer + equal_to: Equal to diff --git a/test/models/rule/condition_test.rb b/test/models/rule/condition_test.rb index 353963629..774e77812 100644 --- a/test/models/rule/condition_test.rb +++ b/test/models/rule/condition_test.rb @@ -331,4 +331,171 @@ class Rule::ConditionTest < ActiveSupport::TestCase assert_equal 4, filtered.count assert_not filtered.map(&:id).include?(transaction_entry.transaction.id) end + + test "applies transaction_type condition for income" do + scope = @rule_scope + + condition = Rule::Condition.new( + rule: @transaction_rule, + condition_type: "transaction_type", + operator: "=", + value: "income" + ) + + scope = condition.prepare(scope) + filtered = condition.apply(scope) + + # transaction2 has amount -200 (income) + assert_equal 1, filtered.count + assert filtered.all? { |t| t.entry.amount.negative? } + end + + test "applies transaction_type condition for expense" do + scope = @rule_scope + + condition = Rule::Condition.new( + rule: @transaction_rule, + condition_type: "transaction_type", + operator: "=", + value: "expense" + ) + + scope = condition.prepare(scope) + filtered = condition.apply(scope) + + # transaction1, 3, 4, 5 have positive amounts (expenses) + assert_equal 4, filtered.count + assert filtered.all? { |t| t.entry.amount.positive? && !t.transfer? } + end + + test "applies transaction_type condition for transfer" do + scope = @rule_scope + + # Create a transfer transaction + transfer_entry = create_transaction( + date: Date.current, + account: @account, + amount: 500, + name: "Transfer to savings" + ) + transfer_entry.transaction.update!(kind: "funds_movement") + + condition = Rule::Condition.new( + rule: @transaction_rule, + condition_type: "transaction_type", + operator: "=", + value: "transfer" + ) + + scope = condition.prepare(scope) + filtered = condition.apply(scope) + + assert_equal 1, filtered.count + assert_equal transfer_entry.transaction.id, filtered.first.id + assert filtered.first.transfer? + end + + test "transaction_type expense excludes transfers" do + scope = @rule_scope + + # Create a transfer with positive amount (would look like expense) + transfer_entry = create_transaction( + date: Date.current, + account: @account, + amount: 500, + name: "Transfer to savings" + ) + transfer_entry.transaction.update!(kind: "funds_movement") + + condition = Rule::Condition.new( + rule: @transaction_rule, + condition_type: "transaction_type", + operator: "=", + value: "expense" + ) + + scope = condition.prepare(scope) + filtered = condition.apply(scope) + + # Should NOT include the transfer even though it has positive amount + assert_not filtered.map(&:id).include?(transfer_entry.transaction.id) + end + + test "transaction_type income excludes transfers" do + scope = @rule_scope + + # Create a transfer inflow (negative amount) + transfer_entry = create_transaction( + date: Date.current, + account: @account, + amount: -500, + name: "Transfer from savings" + ) + transfer_entry.transaction.update!(kind: "funds_movement") + + condition = Rule::Condition.new( + rule: @transaction_rule, + condition_type: "transaction_type", + operator: "=", + value: "income" + ) + + scope = condition.prepare(scope) + filtered = condition.apply(scope) + + # Should NOT include the transfer even though it has negative amount + assert_not filtered.map(&:id).include?(transfer_entry.transaction.id) + end + + test "transaction_type expense includes investment_contribution regardless of amount sign" do + scope = @rule_scope + + # Create investment contribution with negative amount (inflow from provider) + contribution_entry = create_transaction( + date: Date.current, + account: @account, + amount: -1000, + name: "401k contribution" + ) + contribution_entry.transaction.update!(kind: "investment_contribution") + + condition = Rule::Condition.new( + rule: @transaction_rule, + condition_type: "transaction_type", + operator: "=", + value: "expense" + ) + + scope = condition.prepare(scope) + filtered = condition.apply(scope) + + # Should include investment_contribution even with negative amount + assert filtered.map(&:id).include?(contribution_entry.transaction.id) + end + + test "transaction_type income excludes investment_contribution" do + scope = @rule_scope + + # Create investment contribution with negative amount + contribution_entry = create_transaction( + date: Date.current, + account: @account, + amount: -1000, + name: "401k contribution" + ) + contribution_entry.transaction.update!(kind: "investment_contribution") + + condition = Rule::Condition.new( + rule: @transaction_rule, + condition_type: "transaction_type", + operator: "=", + value: "income" + ) + + scope = condition.prepare(scope) + filtered = condition.apply(scope) + + # Should NOT include investment_contribution even with negative amount + assert_not filtered.map(&:id).include?(contribution_entry.transaction.id) + end end From 51c7f7a3f04d888c4296a3f5186a3e7776059b4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Tue, 27 Jan 2026 12:04:11 +0100 Subject: [PATCH 011/108] Bump Helm chart version in pre-release workflow (#792) * Update chart version in pre-release bump Keep Helm chart version and appVersion aligned with app releases. * Publish Helm chart with releases Package the Helm chart on tag releases, upload it to GitHub Pages, and attach it to the GitHub Release assets. * Move Helm chart release to helm workflow Publish Helm chart packages from the helm-release workflow on tags and keep publish.yml focused on app release assets. * Derive nightly chart version from latest release Use the most recent v* tag as the base for nightly Helm chart versions. --- .github/workflows/helm-release.yaml | 30 +++++++++++++++++++++++++++-- .github/workflows/publish.yml | 16 +++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/.github/workflows/helm-release.yaml b/.github/workflows/helm-release.yaml index 8c28a4928..9fbd90c29 100644 --- a/.github/workflows/helm-release.yaml +++ b/.github/workflows/helm-release.yaml @@ -6,6 +6,8 @@ on: - main paths: - 'charts/**' + tags: + - 'v*' workflow_dispatch: jobs: @@ -36,13 +38,22 @@ jobs: id: version run: | # Generate version like: 0.0.0-nightly.20251213.173045 - VERSION="0.0.0-nightly.$(date -u +'%Y%m%d.%H%M%S')" + if [[ "${GITHUB_REF_TYPE}" == "tag" && "${GITHUB_REF_NAME}" == v* ]]; then + VERSION="${GITHUB_REF_NAME#v}" + else + BASE_VERSION="$(git tag -l 'v*' | sed 's/^v//' | sort -V | tail -n 1)" + if [[ -z "${BASE_VERSION}" ]]; then + BASE_VERSION="0.0.0" + fi + VERSION="${BASE_VERSION}-nightly.$(date -u +'%Y%m%d.%H%M%S')" + fi echo "version=$VERSION" >> $GITHUB_OUTPUT echo "Generated version: $VERSION" - name: Update Chart.yaml version run: | sed -i "s/^version:.*/version: ${{ steps.version.outputs.version }}/" charts/sure/Chart.yaml + sed -i "s/^appVersion:.*/appVersion: \"${{ steps.version.outputs.version }}\"/" charts/sure/Chart.yaml cat charts/sure/Chart.yaml - name: Add Helm repositories @@ -83,5 +94,20 @@ jobs: git config user.name "$GIT_USER_NAME" git config user.email "$GIT_USER_EMAIL" git add . - git commit -m "Release nightly: ${{ steps.version.outputs.version }}" + if git diff --cached --quiet; then + echo "No Helm chart updates to publish." + exit 0 + fi + if [[ "${GITHUB_REF_TYPE}" == "tag" && "${GITHUB_REF_NAME}" == v* ]]; then + git commit -m "Release chart for ${{ github.ref_name }}" + else + git commit -m "Release nightly: ${{ steps.version.outputs.version }}" + fi git push + + - name: Upload chart to GitHub Release + if: startsWith(github.ref, 'refs/tags/v') + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + files: .cr-release-packages/*.tgz diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2c3479071..4cdbca01a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -349,12 +349,19 @@ jobs: - name: Bump pre-release version run: | VERSION_FILE="config/initializers/version.rb" + CHART_FILE="charts/sure/Chart.yaml" # Ensure version file exists if [ ! -f "$VERSION_FILE" ]; then echo "ERROR: Version file not found: $VERSION_FILE" exit 1 fi + + # Ensure chart file exists + if [ ! -f "$CHART_FILE" ]; then + echo "ERROR: Chart file not found: $CHART_FILE" + exit 1 + fi # Extract current version CURRENT_VERSION=$(grep -oP '"\K[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc)\.[0-9]+' "$VERSION_FILE") @@ -394,12 +401,21 @@ jobs: echo "Updated version.rb:" grep "semver" "$VERSION_FILE" + # Update Helm chart version and appVersion + sed -i -E "s/^version: .*/version: ${NEW_VERSION}/" "$CHART_FILE" + sed -i -E "s/^appVersion: .*/appVersion: \"${NEW_VERSION}\"/" "$CHART_FILE" + + # Verify the change + echo "Updated Chart.yaml:" + grep -E "^(version|appVersion):" "$CHART_FILE" + - name: Commit and push version bump run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add config/initializers/version.rb + git add charts/sure/Chart.yaml # Check if there are changes to commit if git diff --cached --quiet; then From 33df3b781ea941c2bd8818bb164d34456163ae68 Mon Sep 17 00:00:00 2001 From: charsel <1127835+Charsel@users.noreply.github.com> Date: Tue, 27 Jan 2026 12:28:33 +0100 Subject: [PATCH 012/108] fix: Handle uncategorized transactions filter correctly (#802) * fix: Handle uncategorized transactions filter correctly When filtering for 'Uncategorized' transactions, the filter was not working because 'Uncategorized' is a virtual category (Category.uncategorized returns a non-persisted Category object) and does not exist in the database. The filter was attempting to match 'categories.name IN (Uncategorized)' which returned zero results. This fix removes 'Uncategorized' from the category names array before querying the database, allowing the existing 'category_id IS NULL' condition to work correctly. Fixes filtering for uncategorized transactions while maintaining backward compatibility with all other category filters. * test: Add comprehensive tests for Uncategorized filter - Test filtering for only uncategorized transactions - Test combining uncategorized with real categories - Test excluding uncategorized when not in filter - Ensures fix prevents regression * refactor: Use Category.uncategorized.name for i18n support - Replace hard-coded 'Uncategorized' string with Category.uncategorized.name - Conditionally build SQL query based on include_uncategorized flag - Avoid adding category_id IS NULL clause when not needed - Update tests to use Category.uncategorized.name for consistency - Cleaner logic: only include uncategorized condition when requested Addresses code review feedback on i18n support and query optimization. * test: Fix travel category fixture error Create travel category dynamically instead of using non-existent fixture * style: Fix rubocop spacing in array brackets --------- Co-authored-by: Charsel --- app/models/transaction/search.rb | 44 ++++++++----- test/models/transaction/search_test.rb | 86 +++++++++++++++++++++++++- 2 files changed, 112 insertions(+), 18 deletions(-) diff --git a/app/models/transaction/search.rb b/app/models/transaction/search.rb index 242527c16..287dae467 100644 --- a/app/models/transaction/search.rb +++ b/app/models/transaction/search.rb @@ -102,28 +102,38 @@ class Transaction::Search def apply_category_filter(query, categories) return query unless categories.present? + # Remove "Uncategorized" from category names to query the database + uncategorized_name = Category.uncategorized.name + include_uncategorized = categories.include?(uncategorized_name) + real_categories = categories - [ uncategorized_name ] + # Get parent category IDs for the given category names - parent_category_ids = family.categories.where(name: categories).pluck(:id) + parent_category_ids = family.categories.where(name: real_categories).pluck(:id) + + uncategorized_condition = "(categories.id IS NULL AND transactions.kind NOT IN ('funds_movement', 'cc_payment'))" # Build condition based on whether parent_category_ids is empty if parent_category_ids.empty? - query = query.left_joins(:category).where( - "categories.name IN (?) OR ( - categories.id IS NULL AND (transactions.kind NOT IN ('funds_movement', 'cc_payment')) - )", - categories - ) + if include_uncategorized + query = query.left_joins(:category).where( + "categories.name IN (?) OR #{uncategorized_condition}", + real_categories.presence || [] + ) + else + query = query.left_joins(:category).where(categories: { name: real_categories }) + end else - query = query.left_joins(:category).where( - "categories.name IN (?) OR categories.parent_id IN (?) OR ( - categories.id IS NULL AND (transactions.kind NOT IN ('funds_movement', 'cc_payment')) - )", - categories, parent_category_ids - ) - end - - if categories.exclude?("Uncategorized") - query = query.where.not(category_id: nil) + if include_uncategorized + query = query.left_joins(:category).where( + "categories.name IN (?) OR categories.parent_id IN (?) OR #{uncategorized_condition}", + real_categories, parent_category_ids + ) + else + query = query.left_joins(:category).where( + "categories.name IN (?) OR categories.parent_id IN (?)", + real_categories, parent_category_ids + ) + end end query diff --git a/test/models/transaction/search_test.rb b/test/models/transaction/search_test.rb index bdfb264ac..164398d5a 100644 --- a/test/models/transaction/search_test.rb +++ b/test/models/transaction/search_test.rb @@ -114,7 +114,7 @@ class Transaction::SearchTest < ActiveSupport::TestCase ) # Search for uncategorized transactions - uncategorized_results = Transaction::Search.new(@family, filters: { categories: [ "Uncategorized" ] }).transactions_scope + uncategorized_results = Transaction::Search.new(@family, filters: { categories: [ Category.uncategorized.name ] }).transactions_scope uncategorized_ids = uncategorized_results.pluck(:id) # Should include standard uncategorized transactions @@ -126,6 +126,90 @@ class Transaction::SearchTest < ActiveSupport::TestCase assert_not_includes uncategorized_ids, uncategorized_transfer.entryable.id end + test "filtering for only Uncategorized returns only uncategorized transactions" do + # Create a mix of categorized and uncategorized transactions + categorized = create_transaction( + account: @checking_account, + amount: 100, + category: categories(:food_and_drink) + ) + + uncategorized = create_transaction( + account: @checking_account, + amount: 200 + ) + + # Filter for only uncategorized + results = Transaction::Search.new(@family, filters: { categories: [ Category.uncategorized.name ] }).transactions_scope + result_ids = results.pluck(:id) + + # Should only include uncategorized transaction + assert_includes result_ids, uncategorized.entryable.id + assert_not_includes result_ids, categorized.entryable.id + assert_equal 1, result_ids.size + end + + test "filtering for Uncategorized plus a real category returns both" do + # Create a travel category for testing + travel_category = @family.categories.create!( + name: "Travel", + color: "#3b82f6", + classification: "expense" + ) + + # Create transactions with different categories + food_transaction = create_transaction( + account: @checking_account, + amount: 100, + category: categories(:food_and_drink) + ) + + travel_transaction = create_transaction( + account: @checking_account, + amount: 150, + category: travel_category + ) + + uncategorized = create_transaction( + account: @checking_account, + amount: 200 + ) + + # Filter for food category + uncategorized + results = Transaction::Search.new(@family, filters: { categories: [ "Food & Drink", Category.uncategorized.name ] }).transactions_scope + result_ids = results.pluck(:id) + + # Should include both food and uncategorized + assert_includes result_ids, food_transaction.entryable.id + assert_includes result_ids, uncategorized.entryable.id + # Should NOT include travel + assert_not_includes result_ids, travel_transaction.entryable.id + assert_equal 2, result_ids.size + end + + test "filtering excludes uncategorized when not in filter" do + # Create a mix of transactions + categorized = create_transaction( + account: @checking_account, + amount: 100, + category: categories(:food_and_drink) + ) + + uncategorized = create_transaction( + account: @checking_account, + amount: 200 + ) + + # Filter for only food category (without Uncategorized) + results = Transaction::Search.new(@family, filters: { categories: [ "Food & Drink" ] }).transactions_scope + result_ids = results.pluck(:id) + + # Should only include categorized transaction + assert_includes result_ids, categorized.entryable.id + assert_not_includes result_ids, uncategorized.entryable.id + assert_equal 1, result_ids.size + end + test "new family-based API works correctly" do # Create transactions for testing transaction1 = create_transaction( From aef582f5537a76e4b33c1279edae7df5770ee2fd Mon Sep 17 00:00:00 2001 From: Alessio Cappa <104093777+alessiocappa@users.noreply.github.com> Date: Tue, 27 Jan 2026 12:32:35 +0100 Subject: [PATCH 013/108] feat: Move upcoming recurring transactions in a dedicated tab (#771) * feat: Move upcoming transactions in a dedicated tab * Adjust formatting * feat: adjust visibility on mobile * feat: change translation label * feat: show only upcoming transactions expected in the next 10 days * feat: show upcoming transactions tab only when option enabled * feat: render empty partial when there are no recurring transactions * feat: align icon sizing and spacing between transactions and upcoming sections * feat: add missing localitazion labels * fix: move filter on upcoming transactions in controller * fix: add missing localitazion labels --- app/controllers/transactions_controller.rb | 4 +- .../recurring_transactions/_empty.html.erb | 4 + .../_projected_transaction.html.erb | 21 ++-- app/views/transactions/_list.html.erb | 53 ++++++++++ app/views/transactions/_upcoming.html.erb | 29 +++++ app/views/transactions/index.html.erb | 100 ++++-------------- .../views/recurring_transactions/en.yml | 5 +- config/locales/views/transactions/en.yml | 7 ++ 8 files changed, 129 insertions(+), 94 deletions(-) create mode 100644 app/views/recurring_transactions/_empty.html.erb create mode 100644 app/views/transactions/_list.html.erb create mode 100644 app/views/transactions/_upcoming.html.erb diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index a2210f3f6..0b775826d 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -24,11 +24,11 @@ class TransactionsController < ApplicationController @pagy, @transactions = pagy(base_scope, limit: safe_per_page) - # Load projected recurring transactions for next month + # Load projected recurring transactions for next 10 days @projected_recurring = Current.family.recurring_transactions .active .where("next_expected_date <= ? AND next_expected_date >= ?", - 1.month.from_now.to_date, + 10.days.from_now.to_date, Date.current) .includes(:merchant) end diff --git a/app/views/recurring_transactions/_empty.html.erb b/app/views/recurring_transactions/_empty.html.erb new file mode 100644 index 000000000..60c296e9b --- /dev/null +++ b/app/views/recurring_transactions/_empty.html.erb @@ -0,0 +1,4 @@ +
+

<%= t(".title") %>

+

<%= t(".description") %>

+
diff --git a/app/views/recurring_transactions/_projected_transaction.html.erb b/app/views/recurring_transactions/_projected_transaction.html.erb index 19a0eaa52..0f7b8b59d 100644 --- a/app/views/recurring_transactions/_projected_transaction.html.erb +++ b/app/views/recurring_transactions/_projected_transaction.html.erb @@ -1,19 +1,19 @@ <%# locals: (recurring_transaction:) %> -
-
+
+
- <%= content_tag :div, class: ["flex items-center gap-2"] do %> + <%= content_tag :div, class: ["flex items-center gap-3 lg:gap-4"] do %> <% if recurring_transaction.merchant.present? %> <% if recurring_transaction.merchant.logo_url.present? %> <%= image_tag Setting.transform_brand_fetch_url(recurring_transaction.merchant.logo_url), - class: "w-6 h-6 rounded-full", + class: "w-9 h-9 rounded-full", loading: "lazy" %> <% else %> <%= render DS::FilledIcon.new( variant: :text, text: recurring_transaction.merchant.name, - size: "sm", + size: "lg", rounded: true ) %> <% end %> @@ -21,27 +21,28 @@ <%= render DS::FilledIcon.new( variant: :text, text: recurring_transaction.name, - size: "sm", + size: "lg", rounded: true ) %> <% end %>
-
+
<%= recurring_transaction.merchant.present? ? recurring_transaction.merchant.name : recurring_transaction.name %>
- + <%= t("recurring_transactions.projected") %>
- <%= t("recurring_transactions.expected_on", date: l(recurring_transaction.next_expected_date, format: :short)) %> + <%= days_left = (recurring_transaction.next_expected_date.to_date - Time.zone.today).to_i + days_left.zero? ? t("recurring_transactions.expected_today") : t("recurring_transactions.expected_in", count: days_left) %>
@@ -53,7 +54,7 @@ <%= t("recurring_transactions.recurring") %>
-
+
<% display_amount = recurring_transaction.manual? && recurring_transaction.expected_amount_avg.present? ? recurring_transaction.expected_amount_avg : recurring_transaction.amount %> <%= content_tag :p, format_money(-Money.new(display_amount, recurring_transaction.currency)), class: ["font-medium", display_amount.negative? ? "text-success" : "text-subdued"] %>
diff --git a/app/views/transactions/_list.html.erb b/app/views/transactions/_list.html.erb new file mode 100644 index 000000000..1b0589e2a --- /dev/null +++ b/app/views/transactions/_list.html.erb @@ -0,0 +1,53 @@ +<%# locals: (transactions:, projected_recurring:, q:, pagy:) %> +
" + 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 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: t(".drag_drop_title"), subtitle: t(".drag_drop_subtitle") %> + + <%= render "transactions/searches/search" %> + + + + <% if @pagy.count > 0 || (@projected_recurring.any? && @q.blank?) %> +
+ <% if @transactions.any? %> +
+
+ <%= check_box_tag "selection_entry", + class: "checkbox checkbox--light hidden lg:block", + data: { + action: "bulk-select#togglePageSelection", + checkbox_toggle_target: "selectionEntry" + } %> +

transaction

+
+ + +

<%= t("transactions.show.amount") %>

+
+ <% end %> + +
+ <%= entries_by_date(@transactions.map(&:entry), totals: true) do |entries| %> + <%= render entries %> + <% end %> +
+
+ <% else %> + <%= render "entries/empty" %> + <% end %> + +
+ <%= render "shared/pagination", pagy: @pagy %> +
+
\ No newline at end of file diff --git a/app/views/transactions/_upcoming.html.erb b/app/views/transactions/_upcoming.html.erb new file mode 100644 index 000000000..894883ff7 --- /dev/null +++ b/app/views/transactions/_upcoming.html.erb @@ -0,0 +1,29 @@ +<% if @projected_recurring.any? %> +
+
+

<%= t("transactions.list.transaction") %>

+ +

<%= t("transactions.show.amount") %>

+
+
+ <% @projected_recurring.group_by(&:next_expected_date).sort.each do |date, transactions| %> +
+
+

+ <%= tag.span I18n.l(date, format: :long) %> + · + <%= tag.span transactions.size %> +

+
+
+ <% transactions.each do |recurring_transaction| %> + <%= render "recurring_transactions/projected_transaction", recurring_transaction: recurring_transaction %> + <% end %> +
+
+ <% end %> +
+
+<% else %> + <%= render "recurring_transactions/empty" %> +<% end %> \ No newline at end of file diff --git a/app/views/transactions/index.html.erb b/app/views/transactions/index.html.erb index 0cac7a7c8..671710c06 100644 --- a/app/views/transactions/index.html.erb +++ b/app/views/transactions/index.html.erb @@ -44,88 +44,26 @@ <%= render "summary", totals: @search.totals %> + <% if Current.family.recurring_transactions_disabled? %> + <%= render "transactions/list", transactions: @transactions, projected_recurring: @projected_recurring, q: params[:q], pagy: @pagy %> + <% else %> + <%= render DS::Tabs.new(active_tab: params[:tab].presence || "transactions") do |tabs| %> + <% tabs.with_nav(classes: "max-w-fit") do |nav| %> + <% nav.with_btn(id: "transactions", label: t("transactions.show.tab_transactions"), classes: "px-6") %> + <% nav.with_btn(id: "upcoming", label: t("transactions.show.tab_upcoming"), classes: "px-6") %> + <% end %> -
" - 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 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" %> - - - - <% if @pagy.count > 0 || (@projected_recurring.any? && @q.blank?) %> -
- <% if @transactions.any? %> -
-
- <%= check_box_tag "selection_entry", - class: "checkbox checkbox--light hidden lg:block", - data: { - action: "bulk-select#togglePageSelection", - checkbox_toggle_target: "selectionEntry" - } %> -

transaction

-
- - -

amount

-
- <% end %> - -
- <% if @projected_recurring.any? && @q.blank? %> -
"> -
- -

<%= t("recurring_transactions.upcoming") %>

-
-
- <% @projected_recurring.group_by(&:next_expected_date).sort.each do |date, transactions| %> -
-
<%= l(date, format: :long) %>
- <% transactions.each do |recurring_transaction| %> - <%= render "recurring_transactions/projected_transaction", recurring_transaction: recurring_transaction %> - <% end %> -
- <% end %> -
-
- <% end %> - - <%= entries_by_date(@transactions.map(&:entry), totals: true) do |entries| %> - <%= render entries %> - <% end %> + <% tabs.with_panel(tab_id: "transactions") do %> +
+ <%= render "transactions/list", transactions: @transactions, projected_recurring: @projected_recurring, q: params[:q], pagy: @pagy %>
-
- <% else %> - <%= render "entries/empty" %> - <% end %> + <% end %> -
- <%= render "shared/pagination", pagy: @pagy %> -
-
+ <% tabs.with_panel(tab_id: "upcoming") do %> +
+ <%= render "transactions/upcoming" %> +
+ <% end %> + <% end %> + <% end %>
diff --git a/config/locales/views/recurring_transactions/en.yml b/config/locales/views/recurring_transactions/en.yml index 34749bc71..504d321d9 100644 --- a/config/locales/views/recurring_transactions/en.yml +++ b/config/locales/views/recurring_transactions/en.yml @@ -5,7 +5,10 @@ en: upcoming: Upcoming Recurring Transactions projected: Projected recurring: Recurring - expected_on: Expected on %{date} + expected_today: "Expected today" + expected_in: + one: "Expected in %{count} day" + other: "Expected in %{count} days" day_of_month: Day %{day} of month identify_patterns: Identify Patterns cleanup_stale: Clean Up Stale diff --git a/config/locales/views/transactions/en.yml b/config/locales/views/transactions/en.yml index 00078d609..1dfa2704f 100644 --- a/config/locales/views/transactions/en.yml +++ b/config/locales/views/transactions/en.yml @@ -49,6 +49,8 @@ en: overview: Overview settings: Settings tags_label: Tags + tab_transactions: Transactions + tab_upcoming: Upcoming uncategorized: "(uncategorized)" activity_labels: buy: Buy @@ -95,6 +97,11 @@ en: transaction: transaction transactions: transactions import: Import + list: + drag_drop_title: Drop CSV to import + drag_drop_subtitle: Upload transactions directly + transaction: transaction + transactions: transactions toggle_recurring_section: Toggle upcoming recurring transactions search: filters: From eeff4edbea103ed160c47a6834b020eb3a7c2f0c Mon Sep 17 00:00:00 2001 From: MkDev11 <94194147+MkDev11@users.noreply.github.com> Date: Tue, 27 Jan 2026 09:45:50 -0500 Subject: [PATCH 014/108] Add warning for TwelveData plan-restricted tickers (#803) * Add warning for TwelveData plan-restricted tickers Fixes #800 - Add Security::PlanRestrictionTracker concern using Rails cache - Detect plan upgrade errors during Security::Price::Importer sync - Display amber warning on /settings/hosting with affected tickers - Include unit tests for the new functionality * Scope plan restriction cache by provider Addresses review feedback: - Cache key now includes provider name to support multiple data providers - Methods now require provider parameter for proper scoping - Added tests for provider-scoped restrictions - Added documentation explaining instance-level API key architecture * Fix RuboCop array bracket spacing * Fix empty array bracket spacing * Move plan upgrade detection to Provider::TwelveData * Fix provider scoping tests to use direct cache writes --------- Co-authored-by: mkdev11 --- .../settings/hostings_controller.rb | 1 + app/models/family.rb | 21 ++++ app/models/provider/twelve_data.rb | 16 +++ app/models/security.rb | 2 +- .../security/plan_restriction_tracker.rb | 82 +++++++++++++ app/models/security/price/importer.rb | 13 +- .../hostings/_twelve_data_settings.html.erb | 28 +++++ config/locales/views/settings/hostings/en.yml | 4 + .../security/plan_restriction_tracker_test.rb | 115 ++++++++++++++++++ 9 files changed, 280 insertions(+), 2 deletions(-) create mode 100644 app/models/security/plan_restriction_tracker.rb create mode 100644 test/models/security/plan_restriction_tracker_test.rb diff --git a/app/controllers/settings/hostings_controller.rb b/app/controllers/settings/hostings_controller.rb index 02e61350e..22936cfb4 100644 --- a/app/controllers/settings/hostings_controller.rb +++ b/app/controllers/settings/hostings_controller.rb @@ -25,6 +25,7 @@ class Settings::HostingsController < ApplicationController if @show_twelve_data_settings twelve_data_provider = Provider::Registry.get_provider(:twelve_data) @twelve_data_usage = twelve_data_provider&.usage + @plan_restricted_securities = Current.family.securities_with_plan_restrictions(provider: "TwelveData") end if @show_yahoo_finance_settings diff --git a/app/models/family.rb b/app/models/family.rb index a69e9629b..af9f0aa98 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -141,6 +141,27 @@ class Family < ApplicationRecord (requires_exchange_rates_data_provider? && ExchangeRate.provider.nil?) end + # Returns securities with plan restrictions for a specific provider + # @param provider [String] The provider name (e.g., "TwelveData") + # @return [Array] Array of hashes with ticker, name, required_plan, provider + def securities_with_plan_restrictions(provider:) + security_ids = trades.joins(:security).pluck("securities.id").uniq + return [] if security_ids.empty? + + restrictions = Security.plan_restrictions_for(security_ids, provider: provider) + return [] if restrictions.empty? + + Security.where(id: restrictions.keys).map do |security| + restriction = restrictions[security.id] + { + ticker: security.ticker, + name: security.name, + required_plan: restriction[:required_plan], + provider: restriction[:provider] + } + end + end + def oldest_entry_date entries.order(:date).first&.date || Date.current end diff --git a/app/models/provider/twelve_data.rb b/app/models/provider/twelve_data.rb index 8f9d81a42..fef92e1bc 100644 --- a/app/models/provider/twelve_data.rb +++ b/app/models/provider/twelve_data.rb @@ -6,6 +6,22 @@ class Provider::TwelveData < Provider InvalidExchangeRateError = Class.new(Error) InvalidSecurityPriceError = Class.new(Error) + # Pattern to detect plan upgrade errors in API responses + PLAN_UPGRADE_PATTERN = /available starting with (\w+)/i + + # Returns true if the error message indicates a plan upgrade is required + def self.plan_upgrade_required?(error_message) + return false if error_message.blank? + PLAN_UPGRADE_PATTERN.match?(error_message) + end + + # Extracts the required plan name from an error message, or nil if not found + def self.extract_required_plan(error_message) + return nil if error_message.blank? + match = error_message.match(PLAN_UPGRADE_PATTERN) + match ? match[1] : nil + end + def initialize(api_key) @api_key = api_key end diff --git a/app/models/security.rb b/app/models/security.rb index 35c2a3876..fb26d4d3e 100644 --- a/app/models/security.rb +++ b/app/models/security.rb @@ -1,5 +1,5 @@ class Security < ApplicationRecord - include Provided + include Provided, PlanRestrictionTracker # ISO 10383 MIC codes mapped to user-friendly exchange names # Source: https://www.iso20022.org/market-identifier-codes diff --git a/app/models/security/plan_restriction_tracker.rb b/app/models/security/plan_restriction_tracker.rb new file mode 100644 index 000000000..1783f8e5d --- /dev/null +++ b/app/models/security/plan_restriction_tracker.rb @@ -0,0 +1,82 @@ +# Tracks securities that require a higher plan to fetch prices from data providers. +# Uses Rails cache to store restriction info, keyed by provider and security ID. +# This allows the settings page to warn users about tickers that need a paid plan. +# +# Note: Currently API keys are configured at the instance level (not per-family), +# so restrictions are shared across all families using the same provider. +module Security::PlanRestrictionTracker + extend ActiveSupport::Concern + + CACHE_KEY_PREFIX = "security_plan_restriction" + CACHE_EXPIRY = 7.days + + # Map provider names to their classes for plan detection + PROVIDER_CLASSES = { + "TwelveData" => Provider::TwelveData + }.freeze + + class_methods do + # Records that a security requires a higher plan to fetch data + # @param security_id [Integer] The security ID + # @param error_message [String] The error message from the provider + # @param provider [String] The provider name (e.g., "TwelveData") + def record_plan_restriction(security_id:, error_message:, provider:) + provider_class = PROVIDER_CLASSES[provider] + return unless provider_class&.respond_to?(:extract_required_plan) + + required_plan = provider_class.extract_required_plan(error_message) + return unless required_plan + + cache_key = plan_restriction_cache_key(provider, security_id) + Rails.cache.write(cache_key, { + required_plan: required_plan, + provider: provider, + recorded_at: Time.current.iso8601 + }, expires_in: CACHE_EXPIRY) + end + + # Clears the plan restriction for a security (e.g., if user upgrades their plan) + # @param security_id [Integer] The security ID + # @param provider [String] The provider name + def clear_plan_restriction(security_id, provider:) + Rails.cache.delete(plan_restriction_cache_key(provider, security_id)) + end + + # Returns the plan restriction info for a security, or nil if none + # @param security_id [Integer] The security ID + # @param provider [String] The provider name + def plan_restriction_for(security_id, provider:) + Rails.cache.read(plan_restriction_cache_key(provider, security_id)) + end + + # Returns all plan-restricted securities from a collection of security IDs for a provider + # @param security_ids [Array] Security IDs to check + # @param provider [String] The provider name + # @return [Hash] security_id => restriction_info + def plan_restrictions_for(security_ids, provider:) + return {} if security_ids.blank? + + restrictions = {} + security_ids.each do |id| + restriction = plan_restriction_for(id, provider: provider) + restrictions[id] = restriction if restriction.present? + end + restrictions + end + + # Checks if an error message indicates a plan upgrade is required for a provider + # @param error_message [String] The error message + # @param provider [String] The provider name + def plan_upgrade_required?(error_message, provider:) + provider_class = PROVIDER_CLASSES[provider] + return false unless provider_class&.respond_to?(:plan_upgrade_required?) + provider_class.plan_upgrade_required?(error_message) + end + + private + + def plan_restriction_cache_key(provider, security_id) + "#{CACHE_KEY_PREFIX}/#{provider.downcase}/#{security_id}" + end + end +end diff --git a/app/models/security/price/importer.rb b/app/models/security/price/importer.rb index def62fc03..bc5840c0c 100644 --- a/app/models/security/price/importer.rb +++ b/app/models/security/price/importer.rb @@ -111,9 +111,20 @@ class Security::Price::Importer ) if response.success? + Security.clear_plan_restriction(security.id, provider: security_provider.class.name.demodulize) response.data.index_by(&:date) else - Rails.logger.warn("#{security_provider.class.name} could not fetch prices for #{security.ticker} between #{provider_fetch_start_date} and #{end_date}. Provider error: #{response.error.message}") + error_message = response.error.message + Rails.logger.warn("#{security_provider.class.name} could not fetch prices for #{security.ticker} between #{provider_fetch_start_date} and #{end_date}. Provider error: #{error_message}") + + if Security.plan_upgrade_required?(error_message, provider: security_provider.class.name.demodulize) + Security.record_plan_restriction( + security_id: security.id, + error_message: error_message, + provider: security_provider.class.name.demodulize + ) + end + Sentry.capture_exception(MissingSecurityPriceError.new("Could not fetch prices for ticker"), level: :warning) do |scope| scope.set_tags(security_id: security.id) scope.set_context("security", { id: security.id, start_date: start_date, end_date: end_date }) diff --git a/app/views/settings/hostings/_twelve_data_settings.html.erb b/app/views/settings/hostings/_twelve_data_settings.html.erb index 90b524f2f..b6275491a 100644 --- a/app/views/settings/hostings/_twelve_data_settings.html.erb +++ b/app/views/settings/hostings/_twelve_data_settings.html.erb @@ -60,6 +60,34 @@ <%= t(".plan", plan: @twelve_data_usage.data.plan) %>

+ + <% if @plan_restricted_securities.present? %> +
+
+ <%= icon("alert-triangle", size: "sm", class: "text-amber-600 mt-0.5") %> +
+

<%= t(".plan_upgrade_warning_title") %>

+

<%= t(".plan_upgrade_warning_description") %>

+
    + <% @plan_restricted_securities.each do |security| %> +
  • + <%= security[:ticker] %> + <% if security[:name].present? %> + (<%= security[:name] %>) + <% end %> + — <%= t(".requires_plan", plan: security[:required_plan]) %> +
  • + <% end %> +
+

+ + <%= t(".view_pricing") %> + +

+
+
+
+ <% end %>
<% end %>
diff --git a/config/locales/views/settings/hostings/en.yml b/config/locales/views/settings/hostings/en.yml index d70260ba3..8f3fcec32 100644 --- a/config/locales/views/settings/hostings/en.yml +++ b/config/locales/views/settings/hostings/en.yml @@ -72,6 +72,10 @@ en: label: API Key placeholder: Enter your API key here plan: "%{plan} plan" + plan_upgrade_warning_title: Some tickers require a paid plan + plan_upgrade_warning_description: The following tickers in your portfolio cannot sync prices with your current Twelve Data plan. + requires_plan: requires %{plan} plan + view_pricing: View Twelve Data pricing title: Twelve Data update: failure: Invalid setting value diff --git a/test/models/security/plan_restriction_tracker_test.rb b/test/models/security/plan_restriction_tracker_test.rb new file mode 100644 index 000000000..264957980 --- /dev/null +++ b/test/models/security/plan_restriction_tracker_test.rb @@ -0,0 +1,115 @@ +require "test_helper" + +class Security::PlanRestrictionTrackerTest < ActiveSupport::TestCase + setup do + # Use memory store for testing + @original_cache = Rails.cache + Rails.cache = ActiveSupport::Cache::MemoryStore.new + end + + teardown do + Rails.cache = @original_cache + end + + test "plan_upgrade_required? detects Grow plan message" do + message = "This endpoint is available starting with Grow subscription." + assert Security.plan_upgrade_required?(message, provider: "TwelveData") + end + + test "plan_upgrade_required? detects Pro plan message" do + message = "API error (code: 400): available starting with Pro plan" + assert Security.plan_upgrade_required?(message, provider: "TwelveData") + end + + test "plan_upgrade_required? returns false for other errors" do + message = "Some other error message" + assert_not Security.plan_upgrade_required?(message, provider: "TwelveData") + end + + test "plan_upgrade_required? returns false for nil" do + assert_not Security.plan_upgrade_required?(nil, provider: "TwelveData") + end + + test "plan_upgrade_required? returns false for unknown provider" do + message = "This endpoint is available starting with Grow subscription." + assert_not Security.plan_upgrade_required?(message, provider: "UnknownProvider") + end + + test "record_plan_restriction stores restriction in cache" do + Security.record_plan_restriction( + security_id: 999, + error_message: "This endpoint is available starting with Grow subscription.", + provider: "TwelveData" + ) + + restriction = Security.plan_restriction_for(999, provider: "TwelveData") + assert_not_nil restriction + assert_equal "Grow", restriction[:required_plan] + assert_equal "TwelveData", restriction[:provider] + end + + test "clear_plan_restriction removes restriction from cache" do + Security.record_plan_restriction( + security_id: 999, + error_message: "available starting with Pro", + provider: "TwelveData" + ) + + Security.clear_plan_restriction(999, provider: "TwelveData") + assert_nil Security.plan_restriction_for(999, provider: "TwelveData") + end + + test "plan_restrictions_for returns multiple restrictions" do + Security.record_plan_restriction(security_id: 1001, error_message: "available starting with Grow", provider: "TwelveData") + Security.record_plan_restriction(security_id: 1002, error_message: "available starting with Pro", provider: "TwelveData") + + restrictions = Security.plan_restrictions_for([ 1001, 1002, 9999 ], provider: "TwelveData") + + assert_equal 2, restrictions.keys.count + assert_equal "Grow", restrictions[1001][:required_plan] + assert_equal "Pro", restrictions[1002][:required_plan] + assert_nil restrictions[9999] + end + + test "plan_restrictions_for returns empty hash for empty input" do + assert_equal({}, Security.plan_restrictions_for([], provider: "TwelveData")) + assert_equal({}, Security.plan_restrictions_for(nil, provider: "TwelveData")) + end + + test "record_plan_restriction does nothing for non-plan errors" do + Security.record_plan_restriction( + security_id: 999, + error_message: "Some other error", + provider: "TwelveData" + ) + + assert_nil Security.plan_restriction_for(999, provider: "TwelveData") + end + + test "restrictions are scoped by provider" do + # Record restriction for TwelveData + Security.record_plan_restriction(security_id: 999, error_message: "available starting with Grow", provider: "TwelveData") + + # Simulate a different provider by directly writing to cache (tests cache key scoping) + Rails.cache.write("security_plan_restriction/otherprovider/999", { required_plan: "Pro", provider: "OtherProvider" }, expires_in: 7.days) + + twelve_data_restriction = Security.plan_restriction_for(999, provider: "TwelveData") + other_restriction = Security.plan_restriction_for(999, provider: "OtherProvider") + + assert_equal "Grow", twelve_data_restriction[:required_plan] + assert_equal "Pro", other_restriction[:required_plan] + end + + test "clearing restriction for one provider does not affect another" do + # Record restriction for TwelveData + Security.record_plan_restriction(security_id: 999, error_message: "available starting with Grow", provider: "TwelveData") + + # Simulate another provider by directly writing to cache + Rails.cache.write("security_plan_restriction/otherprovider/999", { required_plan: "Pro", provider: "OtherProvider" }, expires_in: 7.days) + + Security.clear_plan_restriction(999, provider: "TwelveData") + + assert_nil Security.plan_restriction_for(999, provider: "TwelveData") + assert_not_nil Security.plan_restriction_for(999, provider: "OtherProvider") + end +end From f6c38344cdef04539d071f36bdb5fd8f0560d297 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Wed, 28 Jan 2026 00:46:06 +0100 Subject: [PATCH 015/108] Update PostHog script configuration settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Juan José Mata --- app/views/shared/_posthog.html.erb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/views/shared/_posthog.html.erb b/app/views/shared/_posthog.html.erb index 0b73624a3..8258d48a2 100644 --- a/app/views/shared/_posthog.html.erb +++ b/app/views/shared/_posthog.html.erb @@ -1,9 +1,9 @@ From ef4f5f7b8b798be03f6376c0c59ee473ee092354 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Wed, 28 Jan 2026 17:25:02 +0100 Subject: [PATCH 016/108] feat: CORS support (#813) * feat: Add CORS support for Flutter mobile client Add rack-cors gem and configure CORS for API and OAuth endpoints to enable cross-origin requests from mobile clients and other external applications. https://claude.ai/code/session_01RJ6MKLkjBv7x5AQLEUn8AF * feat: Add /sessions/* to CORS for webview authentication Enable CORS for session endpoints to support webview-based authentication flows in the Flutter mobile client. https://claude.ai/code/session_01RJ6MKLkjBv7x5AQLEUn8AF * test: Add integration tests for CORS configuration Test that CORS middleware is configured and returns proper headers for API, OAuth, and session endpoints including preflight requests. https://claude.ai/code/session_01RJ6MKLkjBv7x5AQLEUn8AF * Gemfile.lock --------- Co-authored-by: Claude --- Gemfile | 1 + Gemfile.lock | 4 ++ config/initializers/cors.rb | 36 ++++++++++++++++++ test/integration/cors_test.rb | 72 +++++++++++++++++++++++++++++++++++ 4 files changed, 113 insertions(+) create mode 100644 config/initializers/cors.rb create mode 100644 test/integration/cors_test.rb diff --git a/Gemfile b/Gemfile index 075e52a1e..64dd5099e 100644 --- a/Gemfile +++ b/Gemfile @@ -60,6 +60,7 @@ gem "countries" # OAuth & API Security gem "doorkeeper" gem "rack-attack", "~> 6.6" +gem "rack-cors" gem "pundit" gem "faraday" gem "faraday-retry" diff --git a/Gemfile.lock b/Gemfile.lock index 540bb38c4..dfc8ee3c9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -477,6 +477,9 @@ GEM rack (3.1.18) rack-attack (6.7.0) rack (>= 1.0, < 4) + rack-cors (3.0.0) + logger + rack (>= 3.0.14) rack-mini-profiler (4.0.0) rack (>= 1.2.0) rack-oauth2 (2.2.1) @@ -822,6 +825,7 @@ DEPENDENCIES puma (>= 5.0) pundit rack-attack (~> 6.6) + rack-cors rack-mini-profiler rails (~> 7.2.2) rails-settings-cached diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb new file mode 100644 index 000000000..8ffb294d4 --- /dev/null +++ b/config/initializers/cors.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +# CORS configuration for API access from mobile clients (Flutter) and other external apps. +# +# This enables Cross-Origin Resource Sharing for the /api, /oauth, and /sessions endpoints, +# allowing the Flutter mobile client and other authorized clients to communicate +# with the Rails backend. + +Rails.application.config.middleware.insert_before 0, Rack::Cors do + allow do + # Allow requests from any origin for API endpoints + # Mobile apps and development environments need flexible CORS + origins "*" + + # API endpoints for mobile client and third-party integrations + resource "/api/*", + headers: :any, + methods: %i[get post put patch delete options head], + expose: %w[X-Request-Id X-Runtime], + max_age: 86400 + + # OAuth endpoints for authentication flows + resource "/oauth/*", + headers: :any, + methods: %i[get post put patch delete options head], + expose: %w[X-Request-Id X-Runtime], + max_age: 86400 + + # Session endpoints for webview-based authentication + resource "/sessions/*", + headers: :any, + methods: %i[get post delete options head], + expose: %w[X-Request-Id X-Runtime], + max_age: 86400 + end +end diff --git a/test/integration/cors_test.rb b/test/integration/cors_test.rb new file mode 100644 index 000000000..e16d98d3c --- /dev/null +++ b/test/integration/cors_test.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require "test_helper" + +class CorsTest < ActionDispatch::IntegrationTest + test "rack cors is configured in middleware stack" do + middleware_classes = Rails.application.middleware.map(&:klass) + assert_includes middleware_classes, Rack::Cors, "Rack::Cors should be in middleware stack" + end + + test "cors headers are returned for api endpoints" do + get "/api/v1/usage", headers: { "Origin" => "http://localhost:3000" } + + assert_equal "*", response.headers["Access-Control-Allow-Origin"] + assert response.headers["Access-Control-Expose-Headers"].present? + end + + test "cors preflight request is handled for api endpoints" do + # Simulate a preflight OPTIONS request + options "/api/v1/transactions", + headers: { + "Origin" => "http://localhost:3000", + "Access-Control-Request-Method" => "POST", + "Access-Control-Request-Headers" => "Content-Type, Authorization" + } + + assert_response :ok + assert_equal "*", response.headers["Access-Control-Allow-Origin"] + assert response.headers["Access-Control-Allow-Methods"].present? + assert_includes response.headers["Access-Control-Allow-Methods"], "POST" + end + + test "cors headers are returned for oauth endpoints" do + post "/oauth/token", + params: { grant_type: "authorization_code", code: "test" }, + headers: { "Origin" => "http://localhost:3000" } + + assert_equal "*", response.headers["Access-Control-Allow-Origin"] + end + + test "cors preflight request is handled for oauth endpoints" do + options "/oauth/token", + headers: { + "Origin" => "http://localhost:3000", + "Access-Control-Request-Method" => "POST", + "Access-Control-Request-Headers" => "Content-Type" + } + + assert_response :ok + assert_equal "*", response.headers["Access-Control-Allow-Origin"] + end + + test "cors headers are returned for session endpoints" do + post "/sessions", + params: { email: "test@example.com", password: "password" }, + headers: { "Origin" => "http://localhost:3000" } + + assert_equal "*", response.headers["Access-Control-Allow-Origin"] + end + + test "cors preflight request is handled for session endpoints" do + options "/sessions/new", + headers: { + "Origin" => "http://localhost:3000", + "Access-Control-Request-Method" => "GET", + "Access-Control-Request-Headers" => "Content-Type" + } + + assert_response :ok + assert_equal "*", response.headers["Access-Control-Allow-Origin"] + end +end From 4adc4199ee31ce6f6e668978376b44b0d3d7c4cc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 28 Jan 2026 18:19:20 +0000 Subject: [PATCH 017/108] Bump version to next iteration after v0.6.8-alpha.1 release --- charts/sure/Chart.yaml | 4 ++-- config/initializers/version.rb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/charts/sure/Chart.yaml b/charts/sure/Chart.yaml index 216c26ab8..443186098 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: 0.6.7-alpha -appVersion: "0.6.7-alpha" +version: 0.6.8-alpha.2 +appVersion: "0.6.8-alpha.2" kubeVersion: ">=1.25.0-0" diff --git a/config/initializers/version.rb b/config/initializers/version.rb index 9e1bbf18a..6410cb60e 100644 --- a/config/initializers/version.rb +++ b/config/initializers/version.rb @@ -14,7 +14,7 @@ module Sure private def semver - "0.6.8-alpha.1" + "0.6.8-alpha.2" end end end From bdfb0e64bc66dc69214c9f6ffe63d335d80dd1f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Thu, 29 Jan 2026 13:12:05 +0100 Subject: [PATCH 018/108] Add 'web' to valid device types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flutter comes in as `web` when testing / using the Chrome profile Signed-off-by: Juan José Mata --- app/models/mobile_device.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/mobile_device.rb b/app/models/mobile_device.rb index da94291c1..07dffcbe4 100644 --- a/app/models/mobile_device.rb +++ b/app/models/mobile_device.rb @@ -11,7 +11,7 @@ class MobileDevice < ApplicationRecord validates :device_id, presence: true, uniqueness: { scope: :user_id } validates :device_name, presence: true - validates :device_type, presence: true, inclusion: { in: %w[ios android] } + validates :device_type, presence: true, inclusion: { in: %w[ios android web] } before_validation :set_last_seen_at, on: :create From a86329d632a27cd698d38f55519cb6f892856e67 Mon Sep 17 00:00:00 2001 From: StalkerSea <35916410+StalkerSea@users.noreply.github.com> Date: Thu, 29 Jan 2026 08:16:49 -0600 Subject: [PATCH 019/108] PWA: Update manifest, meta tags, and fix UI layout issues (#801) * pwa(cleanup): enforce LF, head meta + icons, manifest orientation, remove static webmanifest * pwa(cleanup): add gitattributes, head meta/icons, manifest orientation; remove static manifest; small nav & dashboard fixes * pwa(cleanup): improve transaction drawer header layout with inline close button * fix: address PR review feedback - Add dom_id to transaction header for Turbo Stream updates (Codex) - Add pending badge next to date when transaction is pending (CodeRabbit) - Make close button keyboard-focusable by removing tabindex=-1 (CodeRabbit) - Fix settings nav horizontal scroll with flex-nowrap space-x-1 (CodeRabbit) * fix: localize 'Linked with Plaid' tooltip string (CodeRabbit) * Update .gitattributes Better comment smh * fix: align transaction/transfer dialog icons and update transfer drawer pattern - Fix icon alignment in transaction header (items-center instead of items-start) - Make transfer/linked icons consistent size and color - Update transfers/show.html.erb to use frame: drawer with hide_close_icon pattern - Match transfer dialog header layout with transaction details * fix: enhance header layout This in the transaction and transfer views, with consistent icon placement * fix: remove fixed height from HTML document class basically a regression issue pretty sure * fix: update dialog rendering to use 'frame' and hide close icon in headers * fix: update transaction type tabs layout for improved responsiveness * fix: conditionally render transaction type tabs based on account type --- .gitattributes | 5 +++ app/views/holdings/show.html.erb | 4 +- app/views/layouts/shared/_head.html.erb | 12 ++++-- app/views/layouts/shared/_htmldoc.html.erb | 2 +- .../dashboard/_investment_summary.html.erb | 8 ++-- app/views/pwa/manifest.json.erb | 3 +- app/views/settings/_settings_nav.html.erb | 14 ++++--- .../shared/_transaction_type_tabs.html.erb | 20 +++++---- app/views/trades/show.html.erb | 4 +- app/views/transactions/_header.html.erb | 41 ++++++++++++------- app/views/transactions/show.html.erb | 4 +- app/views/transfers/_form.html.erb | 21 ++++++++-- app/views/transfers/show.html.erb | 20 +++++---- app/views/valuations/show.html.erb | 4 +- config/locales/views/transactions/en.yml | 1 + public/site.webmanifest | 19 --------- 16 files changed, 106 insertions(+), 76 deletions(-) delete mode 100644 public/site.webmanifest diff --git a/.gitattributes b/.gitattributes index 8dc432343..767c681d5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,3 +7,8 @@ db/schema.rb linguist-generated vendor/* linguist-vendored config/credentials/*.yml.enc diff=rails_credentials config/credentials.yml.enc diff=rails_credentials + +# Ensure consistent line endings for scripts and Ruby files to avoid shebang issues on Windows +bin/* text eol=lf +*.sh text eol=lf +*.rb text eol=lf diff --git a/app/views/holdings/show.html.erb b/app/views/holdings/show.html.erb index 66a1ca725..67b4329ba 100644 --- a/app/views/holdings/show.html.erb +++ b/app/views/holdings/show.html.erb @@ -1,5 +1,5 @@ -<%= render DS::Dialog.new(variant: "drawer") do |dialog| %> - <% dialog.with_header do %> +<%= render DS::Dialog.new(frame: "drawer") do |dialog| %> + <% dialog.with_header(hide_close_icon: true) do %>
<%= tag.h3 @holding.name, class: "text-2xl font-medium text-primary" %> diff --git a/app/views/layouts/shared/_head.html.erb b/app/views/layouts/shared/_head.html.erb index a878b66a9..b96ce2c61 100644 --- a/app/views/layouts/shared/_head.html.erb +++ b/app/views/layouts/shared/_head.html.erb @@ -19,13 +19,19 @@ + - - + <%# Use theme colors that match both light and dark modes %> + + + - + <%# Provide multiple iOS icons (standard 180x180 and larger) %> + + + <% if Rails.env.production? && (posthog_config = Rails.configuration.x.posthog).try(:api_key).present? %> <%= render "shared/posthog", posthog_api_key: posthog_config.api_key, posthog_host: posthog_config.host %> diff --git a/app/views/layouts/shared/_htmldoc.html.erb b/app/views/layouts/shared/_htmldoc.html.erb index 5520735eb..f58f9c0d0 100644 --- a/app/views/layouts/shared/_htmldoc.html.erb +++ b/app/views/layouts/shared/_htmldoc.html.erb @@ -7,7 +7,7 @@ data-theme="<%= theme %>" data-controller="theme" data-theme-user-preference-value="<%= Current.user&.theme || "system" %>" - class="h-[100vh] text-primary bg-surface overflow-hidden overscroll-none font-sans <%= @os %>"> + class="text-primary bg-surface overflow-hidden overscroll-none font-sans <%= @os %>"> <%= render "layouts/shared/head" %> <%= yield :head %> diff --git a/app/views/pages/dashboard/_investment_summary.html.erb b/app/views/pages/dashboard/_investment_summary.html.erb index 30b83a067..f10f0d776 100644 --- a/app/views/pages/dashboard/_investment_summary.html.erb +++ b/app/views/pages/dashboard/_investment_summary.html.erb @@ -83,9 +83,9 @@ <%= t(".period_activity", period: period.label) %>
-
+
-
+
<%= icon "trending-up", size: "sm", color: "green" %>
@@ -94,7 +94,7 @@
-
+
<%= icon "trending-down", size: "sm", color: "orange" %>
@@ -103,7 +103,7 @@
-
+
<%= icon "arrow-left-right", size: "sm", color: "blue" %>
diff --git a/app/views/pwa/manifest.json.erb b/app/views/pwa/manifest.json.erb index 05382503b..e7960c255 100644 --- a/app/views/pwa/manifest.json.erb +++ b/app/views/pwa/manifest.json.erb @@ -16,8 +16,9 @@ ], "start_url": "/", "display": "standalone", - "display_override": ["fullscreen", "minimal-ui"], + "display_override": ["window-controls-overlay", "minimal-ui"], "scope": "/", + "orientation": "any", "description": "<%= j product_name %> is your personal finance assistant.", "theme_color": "#F9F9F9", "background_color": "#F9F9F9" diff --git a/app/views/settings/_settings_nav.html.erb b/app/views/settings/_settings_nav.html.erb index de5442173..90608e652 100644 --- a/app/views/settings/_settings_nav.html.erb +++ b/app/views/settings/_settings_nav.html.erb @@ -84,8 +84,8 @@ nav_sections = [ <% end %> -
diff --git a/app/views/shared/_transaction_type_tabs.html.erb b/app/views/shared/_transaction_type_tabs.html.erb index b2cfe0fdb..820c23071 100644 --- a/app/views/shared/_transaction_type_tabs.html.erb +++ b/app/views/shared/_transaction_type_tabs.html.erb @@ -1,24 +1,30 @@ <%# locals: (active_tab:, account_id: nil) %> -
+
<%= link_to new_transaction_path(nature: "outflow", account_id: account_id), data: { turbo_frame: :modal }, - class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-sm #{active_tab == 'expense' ? 'bg-container text-primary shadow-sm' : 'hover:bg-container text-subdued hover:text-primary hover:shadow-sm'}" do %> + class: "flex-1 min-w-0 flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-sm #{active_tab == 'expense' ? 'bg-container text-primary shadow-sm' : 'hover:bg-container text-subdued hover:text-primary hover:shadow-sm'}" do %> <%= icon "minus-circle" %> - <%= tag.span t("shared.transaction_tabs.expense") %> + <%= tag.span class: "truncate" do %> + <%= t("shared.transaction_tabs.expense") %> + <% end %> <% end %> <%= link_to new_transaction_path(nature: "inflow", account_id: account_id), data: { turbo_frame: :modal }, - class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-sm #{active_tab == 'income' ? 'bg-container text-primary shadow-sm' : 'hover:bg-container text-subdued hover:text-primary hover:shadow-sm'}" do %> + class: "flex-1 min-w-0 flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-sm #{active_tab == 'income' ? 'bg-container text-primary shadow-sm' : 'hover:bg-container text-subdued hover:text-primary hover:shadow-sm'}" do %> <%= icon "plus-circle" %> - <%= tag.span t("shared.transaction_tabs.income") %> + <%= tag.span class: "truncate" do %> + <%= t("shared.transaction_tabs.income") %> + <% end %> <% end %> <%= link_to new_transfer_path, data: { turbo_frame: :modal }, - class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-sm #{active_tab == 'transfer' ? 'bg-container text-primary shadow-sm' : 'hover:bg-container text-subdued hover:text-primary hover:shadow-sm'}" do %> + class: "flex-1 min-w-0 flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-sm #{active_tab == 'transfer' ? 'bg-container text-primary shadow-sm' : 'hover:bg-container text-subdued hover:text-primary hover:shadow-sm'}" do %> <%= icon "arrow-right-left" %> - <%= tag.span t("shared.transaction_tabs.transfer") %> + <%= tag.span class: "truncate" do %> + <%= t("shared.transaction_tabs.transfer") %> + <% end %> <% end %>
diff --git a/app/views/trades/show.html.erb b/app/views/trades/show.html.erb index c60543441..d5eea69f6 100644 --- a/app/views/trades/show.html.erb +++ b/app/views/trades/show.html.erb @@ -1,5 +1,5 @@ -<%= render DS::Dialog.new(variant: "drawer") do |dialog| %> - <% dialog.with_header do %> +<%= render DS::Dialog.new(frame: "drawer") do |dialog| %> + <% dialog.with_header(hide_close_icon: true) do %> <%= render "trades/header", entry: @entry %> <% end %> diff --git a/app/views/transactions/_header.html.erb b/app/views/transactions/_header.html.erb index fd710e63e..3ad9530ae 100644 --- a/app/views/transactions/_header.html.erb +++ b/app/views/transactions/_header.html.erb @@ -1,8 +1,8 @@ <%# locals: (entry:) %> -<%= tag.header class: "mb-4 space-y-1", id: dom_id(entry, :header) do %> -
-

+
+
+

<%= format_money -entry.amount_money %> @@ -10,20 +10,31 @@ <%= entry.currency %> + + <% if entry.transaction.transfer? %> + <%= icon "arrow-left-right", size: "sm", class: "text-secondary" %> + <% end %> + + <% if entry.linked? %> + + <%= icon("refresh-ccw", size: "sm") %> + + <% end %>

- <% if entry.transaction.transfer? %> - <%= icon "arrow-left-right", class: "mt-1" %> - <% end %> - - <% if entry.linked? %> - - <%= icon("refresh-ccw", size: "sm") %> +
+ + <%= I18n.l(entry.date, format: :long) %> - <% end %> + + <% if entry.transaction.pending? %> + "> + <%= icon "clock", size: "sm", color: "current" %> + <%= t("transactions.transaction.pending") %> + + <% end %> +
- - <%= I18n.l(entry.date, format: :long) %> - -<% end %> + <%= render DS::Button.new(variant: "icon", icon: "x", data: { action: "DS--dialog#close" }) %> +
diff --git a/app/views/transactions/show.html.erb b/app/views/transactions/show.html.erb index 5ec582353..0b875ebc8 100644 --- a/app/views/transactions/show.html.erb +++ b/app/views/transactions/show.html.erb @@ -1,5 +1,5 @@ -<%= render DS::Dialog.new(variant: "drawer") do |dialog| %> - <% dialog.with_header do %> +<%= render DS::Dialog.new(frame: "drawer") do |dialog| %> + <% dialog.with_header(hide_close_icon: true) do %> <%= render "transactions/header", entry: @entry %> <% end %> diff --git a/app/views/transfers/_form.html.erb b/app/views/transfers/_form.html.erb index 4158303e4..aff9d0675 100644 --- a/app/views/transfers/_form.html.erb +++ b/app/views/transfers/_form.html.erb @@ -6,9 +6,24 @@

<% end %> -
- <%= render "shared/transaction_type_tabs", active_tab: "transfer" %> -
+ <%# Hide expense/income tabs when creating a transfer involving an investment or crypto account. %> + <% show_type_tabs = true %> + <% account_ids = [] %> + <% account_ids << @from_account_id if defined?(@from_account_id) && @from_account_id.present? %> + <% account_ids << params[:from_account_id] if params[:from_account_id].present? %> + <% account_ids << params[:to_account_id] if params[:to_account_id].present? %> + <% account_ids << transfer.from_account_id if transfer.respond_to?(:from_account_id) && transfer.from_account_id.present? %> + <% account_ids << transfer.to_account_id if transfer.respond_to?(:to_account_id) && transfer.to_account_id.present? %> + + <% if account_ids.any? && Current.family.accounts.where(id: account_ids).any? { |a| a.investment? || a.crypto? } %> + <% show_type_tabs = false %> + <% end %> + + <% if show_type_tabs %> +
+ <%= render "shared/transaction_type_tabs", active_tab: "transfer" %> +
+ <% end %>
<%= f.collection_select :from_account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".from"), selected: @from_account_id }, required: true %> diff --git a/app/views/transfers/show.html.erb b/app/views/transfers/show.html.erb index a74b96462..f8a8248eb 100644 --- a/app/views/transfers/show.html.erb +++ b/app/views/transfers/show.html.erb @@ -1,8 +1,8 @@ -<%= render DS::Dialog.new(variant: "drawer") do |dialog| %> - <% dialog.with_header do %> -
-
-

+<%= render DS::Dialog.new(frame: "drawer") do |dialog| %> + <% dialog.with_header(hide_close_icon: true) do %> +
+
+

<%= format_money @transfer.amount_abs %> @@ -10,14 +10,16 @@ <%= @transfer.amount_abs.currency.iso_code %> + + <%= icon "arrow-left-right", size: "sm", class: "text-secondary" %>

- <%= icon "arrow-left-right", size: "sm" %> + + <%= @transfer.name %> +
- - <%= @transfer.name %> - + <%= render DS::Button.new(variant: "icon", icon: "x", data: { action: "DS--dialog#close" }) %>
<% end %> diff --git a/app/views/valuations/show.html.erb b/app/views/valuations/show.html.erb index 089d98a43..a5977c23c 100644 --- a/app/views/valuations/show.html.erb +++ b/app/views/valuations/show.html.erb @@ -1,7 +1,7 @@ <% entry, account = @entry, @entry.account %> -<%= render DS::Dialog.new(variant: "drawer") do |dialog| %> - <% dialog.with_header do %> +<%= render DS::Dialog.new(frame: "drawer") do |dialog| %> + <% dialog.with_header(hide_close_icon: true) do %> <%= render "valuations/header", entry: @entry %> <% end %> diff --git a/config/locales/views/transactions/en.yml b/config/locales/views/transactions/en.yml index 1dfa2704f..02287d644 100644 --- a/config/locales/views/transactions/en.yml +++ b/config/locales/views/transactions/en.yml @@ -76,6 +76,7 @@ en: transaction: pending: Pending pending_tooltip: Pending transaction — may change when posted + linked_with_plaid: Linked with Plaid activity_type_tooltip: Investment activity type possible_duplicate: Duplicate? potential_duplicate_tooltip: This may be a duplicate of another transaction diff --git a/public/site.webmanifest b/public/site.webmanifest deleted file mode 100644 index 15a5afe8a..000000000 --- a/public/site.webmanifest +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "", - "short_name": "", - "icons": [ - { - "src": "/android-chrome-192x192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "/android-chrome-512x512.png", - "sizes": "512x512", - "type": "image/png" - } - ], - "theme_color": "#ffffff", - "background_color": "#ffffff", - "display": "fullscreen" -} From 440d4427fbfc89433e0b7c44d612aafd2775322c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Fri, 30 Jan 2026 00:34:46 +0100 Subject: [PATCH 020/108] Disable brakeman --ensure-latest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comment out the ensure-latest argument to allow CI to use Gemfile.lock. Signed-off-by: Juan José Mata --- bin/brakeman | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bin/brakeman b/bin/brakeman index ace1c9ba0..3955a5f56 100755 --- a/bin/brakeman +++ b/bin/brakeman @@ -2,6 +2,7 @@ require "rubygems" require "bundler/setup" -ARGV.unshift("--ensure-latest") +# Disable so CI listens to Gemfile.lock +# ARGV.unshift("--ensure-latest") load Gem.bin_path("brakeman", "brakeman") From 47897fadd00bb9683eee819d6628a9b35d6d5cd2 Mon Sep 17 00:00:00 2001 From: Alessio Cappa <104093777+alessiocappa@users.noreply.github.com> Date: Fri, 30 Jan 2026 12:20:30 +0100 Subject: [PATCH 021/108] feat: Change protected indicator styling (#828) * feat: Change protected indicator styling * fix: apply review comment --- .../entries/_protection_indicator.html.erb | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/app/views/entries/_protection_indicator.html.erb b/app/views/entries/_protection_indicator.html.erb index 922408c2b..96243f953 100644 --- a/app/views/entries/_protection_indicator.html.erb +++ b/app/views/entries/_protection_indicator.html.erb @@ -3,26 +3,31 @@ <%# Protection indicator - shows when entry is protected from sync overwrites %> <%= turbo_frame_tag dom_id(entry, :protection) do %> <% if entry.protected_from_sync? && !entry.excluded? %> -
- - <%= icon "lock", size: "sm", class: "text-secondary" %> - <%= t("entries.protection.title") %> - <%= icon "chevron-down", size: "sm", class: "text-secondary transition-transform [[open]>&]:rotate-180" %> +
+ +
+ <%= icon "lock", size: "sm", color: "info" %> + <%= t("entries.protection.title") %> +
+ <%= icon "chevron-down", color: "info", class: "transition-transform [[open]>&]:rotate-180" %>
-
-

+

+

<%= t("entries.protection.description") %>

<% if entry.locked_field_names.any? %> -
-

<%= t("entries.protection.locked_fields_label") %>

- <% entry.locked_fields_with_timestamps.each do |field, timestamp| %> -
- <%= field.humanize %> - <%= timestamp.respond_to?(:strftime) ? l(timestamp.to_date, format: :long) : timestamp %> -
- <% end %> +
+
+

<%= t("entries.protection.locked_fields_label") %>

+ <% entry.locked_fields_with_timestamps.each do |field, timestamp| %> +
+ <%= entry.class.human_attribute_name(field) %> +
+ <%= timestamp.respond_to?(:strftime) ? l(timestamp.to_date, format: :long) : timestamp %> +
+ <% end %> +
<% end %> From 04931e27eb70e7e4335e3931d473863ba188ebd9 Mon Sep 17 00:00:00 2001 From: Dream <42954461+eureka928@users.noreply.github.com> Date: Fri, 30 Jan 2026 06:22:51 -0500 Subject: [PATCH 022/108] Fix Errno::ENOENT when git is not installed (#838) Rescue Errno::ENOENT in commit_sha so environments without git (e.g. Docker containers) don't crash on boot. --- config/initializers/version.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/initializers/version.rb b/config/initializers/version.rb index 6410cb60e..63637835d 100644 --- a/config/initializers/version.rb +++ b/config/initializers/version.rb @@ -10,6 +10,8 @@ module Sure else `git rev-parse HEAD`.chomp end + rescue Errno::ENOENT + nil end private From 02cd84568e003715960b35bbd9ea9959abe657a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Fri, 30 Jan 2026 12:29:46 +0100 Subject: [PATCH 023/108] Add deterministic API key for uptime monitoring (#834) * Preserve existing demo data by default Add SKIP_CLEAR environment variable to demo_data rake tasks. Defaults to true (preserving existing data). Set SKIP_CLEAR=0 to wipe data before generating new demo data. https://claude.ai/code/session_01GcoMc2SH3czPrbeGkHbmpE * Add deterministic instatus.com API key for demo data Create a read-only API key named "instatus.com" with a fixed value when generating demo data. This allows uptime monitoring tools to use a hardcoded API key that doesn't change between demo data runs. The key is idempotent - if it already exists, it will be reused. https://claude.ai/code/session_01GcoMc2SH3czPrbeGkHbmpE * OK to name instatus to a point * Remove all Instatus references * Rename to create_monitoring_api_key! and scope lookup to admin_user - Rename create_instatus_api_key! to create_monitoring_api_key! (snake_case) - Scope API key lookup to admin_user instead of global ApiKey lookup - Each family's admin now has their own monitoring API key https://claude.ai/code/session_01GcoMc2SH3czPrbeGkHbmpE --------- Co-authored-by: Claude --- app/models/demo/generator.rb | 33 +++++++++++++++++++++++++++++++++ lib/tasks/demo_data.rake | 15 +++++++++------ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb index 6e623cdcd..77ce710ee 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -1,6 +1,10 @@ require "securerandom" class Demo::Generator + # Deterministic API key for uptime monitoring + # This key is always the same so it can be hardcoded in monitoring tools + MONITORING_API_KEY = "demo_monitoring_key_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" + # @param seed [Integer, String, nil] Seed value used to initialise the internal PRNG. If nil, the ENV variable DEMO_DATA_SEED will # be honoured and default to a random seed when not present. # @@ -93,6 +97,9 @@ class Demo::Generator puts "👥 Creating demo family..." family = create_family_and_users!("Demo Family", email, onboarded: true, subscribed: true) + puts "🔑 Creating monitoring API key..." + create_monitoring_api_key!(family) + puts "📊 Creating realistic financial data..." create_realistic_categories!(family) create_realistic_accounts!(family) @@ -181,6 +188,32 @@ class Demo::Generator family end + def create_monitoring_api_key!(family) + admin_user = family.users.find_by(role: "admin") + return unless admin_user + + # Find existing key scoped to this admin user by the deterministic display_key value + existing_key = admin_user.api_keys.find_by(display_key: MONITORING_API_KEY) + + if existing_key + puts " → Use existing monitoring API key" + return existing_key + end + + # Revoke any existing web API keys for this user to avoid one-per-source validation error + admin_user.api_keys.active.where(source: "web").find_each(&:revoke!) + + api_key = admin_user.api_keys.create!( + name: "monitoring", + key: MONITORING_API_KEY, + scopes: [ "read" ], + source: "web" + ) + + puts " → Created monitoring API key: #{MONITORING_API_KEY}" + api_key + end + def create_realistic_categories!(family) # Income categories (3 total) @salary_cat = family.categories.create!(name: "Salary", color: "#10b981", classification: "income") diff --git a/lib/tasks/demo_data.rake b/lib/tasks/demo_data.rake index 810cabfb2..7e5c2f4b3 100644 --- a/lib/tasks/demo_data.rake +++ b/lib/tasks/demo_data.rake @@ -2,9 +2,10 @@ namespace :demo_data do desc "Load empty demo dataset (no financial data)" task empty: :environment do start = Time.now - puts "🚀 Loading EMPTY demo data…" + skip_clear = ENV.fetch("SKIP_CLEAR", "1") == "1" + puts "🚀 Loading EMPTY demo data#{skip_clear ? ' (preserving existing data)' : ' (clearing existing data)'}…" - Demo::Generator.new.generate_empty_data! + Demo::Generator.new.generate_empty_data!(skip_clear: skip_clear) puts "✅ Done in #{(Time.now - start).round(2)}s" end @@ -12,9 +13,10 @@ namespace :demo_data do desc "Load new-user demo dataset (family created but not onboarded)" task new_user: :environment do start = Time.now - puts "🚀 Loading NEW-USER demo data…" + skip_clear = ENV.fetch("SKIP_CLEAR", "1") == "1" + puts "🚀 Loading NEW-USER demo data#{skip_clear ? ' (preserving existing data)' : ' (clearing existing data)'}…" - Demo::Generator.new.generate_new_user_data! + Demo::Generator.new.generate_new_user_data!(skip_clear: skip_clear) puts "✅ Done in #{(Time.now - start).round(2)}s" end @@ -23,10 +25,11 @@ namespace :demo_data do task default: :environment do start = Time.now seed = ENV.fetch("SEED", Random.new_seed) - puts "🚀 Loading FULL demo data (seed=#{seed})…" + skip_clear = ENV.fetch("SKIP_CLEAR", "1") == "1" + puts "🚀 Loading FULL demo data (seed=#{seed})#{skip_clear ? ' (preserving existing data)' : ' (clearing existing data)'}…" generator = Demo::Generator.new(seed: seed) - generator.generate_default_data! + generator.generate_default_data!(skip_clear: skip_clear) validate_demo_data From 9f5fdd4d13d25d0a89e41db11bcf794c76b524b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pere=20Montpe=C3=B3?= Date: Fri, 30 Jan 2026 18:54:15 +0100 Subject: [PATCH 024/108] feat: add valuations API endpoints for managing account reconciliations (#745) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add valuations API endpoints for managing account reconciliations * refactor: formatting * fix: make account extraction clearer * feat: validation and error handling improvements * feat: transaction * feat: error handling * Add API documentation LLM context * Make it easier for people * feat: transaction in creation * feat: add OpenAPI spec for Valuations API * fix: update notes validation to check for key presence * Prevent double render * All other docs use `apiKeyAuth` * More `apiKeyAuth` * Remove testing assertions from API doc specs * fix: correct valuation entry references --------- Signed-off-by: Juan José Mata Co-authored-by: Juan José Mata --- AGENTS.md | 11 + CLAUDE.md | 40 ++- .../api/v1/valuations_controller.rb | 218 +++++++++++++ .../v1/valuations/_valuation.json.jbuilder | 19 ++ .../api/v1/valuations/show.json.jbuilder | 3 + config/routes.rb | 1 + docs/api/openapi.yaml | 178 +++++++++++ spec/requests/api/v1/accounts_spec.rb | 14 +- spec/requests/api/v1/categories_spec.rb | 43 +-- spec/requests/api/v1/chats_spec.rb | 32 +- spec/requests/api/v1/imports_spec.rb | 35 +-- spec/requests/api/v1/tags_spec.rb | 32 +- spec/requests/api/v1/transactions_spec.rb | 42 +-- spec/requests/api/v1/valuations_spec.rb | 287 ++++++++++++++++++ spec/swagger_helper.rb | 15 + .../api/v1/valuations_controller_test.rb | 212 +++++++++++++ 16 files changed, 1014 insertions(+), 168 deletions(-) create mode 100644 app/controllers/api/v1/valuations_controller.rb create mode 100644 app/views/api/v1/valuations/_valuation.json.jbuilder create mode 100644 app/views/api/v1/valuations/show.json.jbuilder create mode 100644 spec/requests/api/v1/valuations_spec.rb create mode 100644 test/controllers/api/v1/valuations_controller_test.rb diff --git a/AGENTS.md b/AGENTS.md index 0e380458f..0d6500834 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,17 @@ - Never commit secrets. Start from `.env.local.example`; use `.env.local` for development only. - Run `bin/brakeman` before major PRs. Prefer environment variables over hard-coded values. +## API Development Guidelines + +### OpenAPI Documentation (MANDATORY) +When adding or modifying API endpoints in `app/controllers/api/v1/`, you **MUST** create or update corresponding OpenAPI request specs for **DOCUMENTATION ONLY**: + +1. **Location**: `spec/requests/api/v1/{resource}_spec.rb` +2. **Framework**: RSpec with rswag for OpenAPI generation +3. **Schemas**: Define reusable schemas in `spec/swagger_helper.rb` +4. **Generated Docs**: `docs/api/openapi.yaml` +5. **Regenerate**: Run `RAILS_ENV=test bundle exec rake rswag:specs:swaggerize` after changes + ## Providers: Pending Transactions and FX Metadata (SimpleFIN/Plaid/Lunchflow) - Pending detection diff --git a/CLAUDE.md b/CLAUDE.md index eb1688d90..10e448f22 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,6 +82,7 @@ The application provides both internal and external APIs: - External API: `/api/v1/` namespace with Doorkeeper OAuth and API key authentication - API responses use Jbuilder templates for JSON rendering - Rate limiting via Rack Attack with configurable limits per API key +- **OpenAPI Documentation**: All API endpoints MUST have corresponding OpenAPI specs in `spec/requests/api/` using rswag. See `docs/api/openapi.yaml` for the generated documentation. ### Sync & Import System Two primary data ingestion methods: @@ -164,6 +165,7 @@ Sidekiq handles asynchronous tasks: - Test helpers in `test/support/` for common scenarios - Only test critical code paths that significantly increase confidence - Write tests as you go, when required +- **API Endpoints require OpenAPI specs** in `spec/requests/api/` for documentation purposes ONLY, not test (uses RSpec + rswag) ### Performance Considerations - Database queries optimized with proper indexes @@ -323,4 +325,40 @@ end ### Stubs and Mocks - Use `mocha` gem - Prefer `OpenStruct` for mock instances -- Only mock what's necessary \ No newline at end of file +- Only mock what's necessary + +## API Development Guidelines + +### OpenAPI Documentation (MANDATORY) +When adding or modifying API endpoints in `app/controllers/api/v1/`, you **MUST** create or update corresponding OpenAPI request specs: + +1. **Location**: `spec/requests/api/v1/{resource}_spec.rb` +2. **Framework**: RSpec with rswag for OpenAPI generation +3. **Schemas**: Define reusable schemas in `spec/swagger_helper.rb` +4. **Generated Docs**: `docs/api/openapi.yaml` + +**Example structure for a new API endpoint:** +```ruby +# spec/requests/api/v1/widgets_spec.rb +require 'swagger_helper' + +RSpec.describe 'API V1 Widgets', type: :request do + path '/api/v1/widgets' do + get 'List widgets' do + tags 'Widgets' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + + response '200', 'widgets listed' do + schema '$ref' => '#/components/schemas/WidgetCollection' + run_test! + end + end + end +end +``` + +**Regenerate OpenAPI docs after changes:** +```bash +RAILS_ENV=test bundle exec rake rswag:specs:swaggerize +``` \ No newline at end of file diff --git a/app/controllers/api/v1/valuations_controller.rb b/app/controllers/api/v1/valuations_controller.rb new file mode 100644 index 000000000..633a90461 --- /dev/null +++ b/app/controllers/api/v1/valuations_controller.rb @@ -0,0 +1,218 @@ +# frozen_string_literal: true + +class Api::V1::ValuationsController < Api::V1::BaseController + before_action :ensure_read_scope, only: [ :show ] + before_action :ensure_write_scope, only: [ :create, :update ] + before_action :set_valuation, only: [ :show, :update ] + + def show + render :show + rescue => e + Rails.logger.error "ValuationsController#show error: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + + render json: { + error: "internal_server_error", + message: "Error: #{e.message}" + }, status: :internal_server_error + end + + def create + unless valuation_account_id.present? + render json: { + error: "validation_failed", + message: "Account ID is required", + errors: [ "Account ID is required" ] + }, status: :unprocessable_entity + return + end + + unless valuation_params[:amount].present? + render json: { + error: "validation_failed", + message: "Amount is required", + errors: [ "Amount is required" ] + }, status: :unprocessable_entity + return + end + + unless valuation_params[:date].present? + render json: { + error: "validation_failed", + message: "Date is required", + errors: [ "Date is required" ] + }, status: :unprocessable_entity + return + end + + account = current_resource_owner.family.accounts.find(valuation_account_id) + + create_success = false + error_payload = nil + + ActiveRecord::Base.transaction do + result = account.create_reconciliation( + balance: valuation_params[:amount], + date: valuation_params[:date] + ) + + unless result.success? + error_payload = { + error: "validation_failed", + message: "Valuation could not be created", + errors: [ result.error_message ] + } + raise ActiveRecord::Rollback + end + + @entry = account.entries.valuations.find_by!(date: valuation_params[:date]) + @valuation = @entry.entryable + + if valuation_params.key?(:notes) + unless @entry.update(notes: valuation_params[:notes]) + error_payload = { + error: "validation_failed", + message: "Valuation could not be created", + errors: @entry.errors.full_messages + } + raise ActiveRecord::Rollback + end + end + + create_success = true + end + + unless create_success + render json: error_payload, status: :unprocessable_entity + return + end + + render :show, status: :created + + rescue ActiveRecord::RecordNotFound + render json: { + error: "not_found", + message: "Account or valuation entry not found" + }, status: :not_found + rescue => e + Rails.logger.error "ValuationsController#create error: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + + render json: { + error: "internal_server_error", + message: "Error: #{e.message}" + }, status: :internal_server_error + end + + def update + if valuation_params[:date].present? || valuation_params[:amount].present? + unless valuation_params[:date].present? && valuation_params[:amount].present? + render json: { + error: "validation_failed", + message: "Both amount and date are required when updating reconciliation", + errors: [ "Amount and date must both be provided" ] + }, status: :unprocessable_entity + return + end + + update_success = false + error_payload = nil + updated_entry = nil + + ActiveRecord::Base.transaction do + result = @entry.account.update_reconciliation( + @entry, + balance: valuation_params[:amount], + date: valuation_params[:date] + ) + + unless result.success? + error_payload = { + error: "validation_failed", + message: "Valuation could not be updated", + errors: [ result.error_message ] + } + raise ActiveRecord::Rollback + end + + updated_entry = @entry.account.entries.valuations.find_by!(date: valuation_params[:date]) + + if valuation_params.key?(:notes) + unless updated_entry.update(notes: valuation_params[:notes]) + error_payload = { + error: "validation_failed", + message: "Valuation could not be updated", + errors: updated_entry.errors.full_messages + } + raise ActiveRecord::Rollback + end + end + + update_success = true + end + + unless update_success + render json: error_payload, status: :unprocessable_entity + return + end + + @entry = updated_entry + @valuation = @entry.entryable + render :show + else + if valuation_params.key?(:notes) + unless @entry.update(notes: valuation_params[:notes]) + render json: { + error: "validation_failed", + message: "Valuation could not be updated", + errors: @entry.errors.full_messages + }, status: :unprocessable_entity + return + end + end + @entry.reload + @valuation = @entry.entryable + render :show + end + + rescue => e + Rails.logger.error "ValuationsController#update error: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + + render json: { + error: "internal_server_error", + message: "Error: #{e.message}" + }, status: :internal_server_error + end + + private + + def set_valuation + @entry = current_resource_owner.family + .entries + .where(entryable_type: "Valuation") + .find(params[:id]) + @valuation = @entry.entryable + rescue ActiveRecord::RecordNotFound + render json: { + error: "not_found", + message: "Valuation not found" + }, status: :not_found + end + + def ensure_read_scope + authorize_scope!(:read) + end + + def ensure_write_scope + authorize_scope!(:write) + end + + def valuation_account_id + params.dig(:valuation, :account_id) + end + + def valuation_params + params.require(:valuation).permit(:amount, :date, :notes) + end +end diff --git a/app/views/api/v1/valuations/_valuation.json.jbuilder b/app/views/api/v1/valuations/_valuation.json.jbuilder new file mode 100644 index 000000000..1e63fb537 --- /dev/null +++ b/app/views/api/v1/valuations/_valuation.json.jbuilder @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +json.id valuation.entry.id +json.date valuation.entry.date +json.amount valuation.entry.amount_money.format +json.currency valuation.entry.currency +json.notes valuation.entry.notes +json.kind valuation.kind + +# Account information +json.account do + json.id valuation.entry.account.id + json.name valuation.entry.account.name + json.account_type valuation.entry.account.accountable_type.underscore +end + +# Additional metadata +json.created_at valuation.created_at.iso8601 +json.updated_at valuation.updated_at.iso8601 diff --git a/app/views/api/v1/valuations/show.json.jbuilder b/app/views/api/v1/valuations/show.json.jbuilder new file mode 100644 index 000000000..57d981b0d --- /dev/null +++ b/app/views/api/v1/valuations/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! "valuation", valuation: @valuation diff --git a/config/routes.rb b/config/routes.rb index 9bcb56f94..9ee83db62 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -363,6 +363,7 @@ Rails.application.routes.draw do resources :tags, only: %i[index show create update destroy] resources :transactions, only: [ :index, :show, :create, :update, :destroy ] + resources :valuations, only: [ :create, :update, :show ] resources :imports, only: [ :index, :show, :create ] resource :usage, only: [ :show ], controller: :usage post :sync, to: "sync#create" diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b9ba48301..1123305ad 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -471,6 +471,42 @@ components: "$ref": "#/components/schemas/Transaction" pagination: "$ref": "#/components/schemas/Pagination" + Valuation: + type: object + required: + - id + - date + - amount + - currency + - kind + - account + - created_at + - updated_at + properties: + id: + type: string + format: uuid + description: Entry ID for the valuation + date: + type: string + format: date + amount: + type: string + currency: + type: string + notes: + type: string + nullable: true + kind: + type: string + account: + "$ref": "#/components/schemas/Account" + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time DeleteResponse: type: object required: @@ -1582,3 +1618,145 @@ paths: application/json: schema: "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/valuations": + post: + summary: Create valuation + tags: + - Valuations + security: + - apiKeyAuth: [] + parameters: + - name: Authorization + in: header + required: true + schema: + type: string + description: Bearer token with write scope + responses: + '201': + description: valuation created + content: + application/json: + schema: + "$ref": "#/components/schemas/Valuation" + '422': + description: validation error - missing date + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '404': + description: account not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + requestBody: + content: + application/json: + schema: + type: object + properties: + valuation: + type: object + properties: + account_id: + type: string + format: uuid + description: Account ID (required) + amount: + type: number + description: Valuation amount (required) + date: + type: string + format: date + description: Valuation date (required) + notes: + type: string + description: Additional notes + required: + - account_id + - amount + - date + required: + - valuation + required: true + "/api/v1/valuations/{id}": + parameters: + - name: Authorization + in: header + required: true + schema: + type: string + description: Bearer token + - name: id + in: path + required: true + description: Valuation ID (entry ID) + schema: + type: string + get: + summary: Retrieve a valuation + tags: + - Valuations + security: + - apiKeyAuth: [] + responses: + '200': + description: valuation retrieved + content: + application/json: + schema: + "$ref": "#/components/schemas/Valuation" + '404': + description: valuation not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + patch: + summary: Update a valuation + tags: + - Valuations + security: + - apiKeyAuth: [] + parameters: [] + responses: + '200': + description: valuation updated with amount and date + content: + application/json: + schema: + "$ref": "#/components/schemas/Valuation" + '422': + description: validation error - only one of amount/date provided + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '404': + description: valuation not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + requestBody: + content: + application/json: + schema: + type: object + properties: + valuation: + type: object + properties: + amount: + type: number + description: New valuation amount (must provide with date) + date: + type: string + format: date + description: New valuation date (must provide with amount) + notes: + type: string + description: Additional notes + required: true diff --git a/spec/requests/api/v1/accounts_spec.rb b/spec/requests/api/v1/accounts_spec.rb index 00528841d..80927388d 100644 --- a/spec/requests/api/v1/accounts_spec.rb +++ b/spec/requests/api/v1/accounts_spec.rb @@ -76,12 +76,7 @@ RSpec.describe 'API V1 Accounts', type: :request do response '200', 'accounts listed' do schema '$ref' => '#/components/schemas/AccountCollection' - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('accounts')).to be_present - expect(payload.fetch('accounts').length).to eq(3) - expect(payload.fetch('pagination')).to include('page', 'per_page', 'total_count', 'total_pages') - end + run_test! end response '200', 'accounts paginated' do @@ -90,12 +85,7 @@ RSpec.describe 'API V1 Accounts', type: :request do let(:page) { 1 } let(:per_page) { 2 } - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('accounts').length).to eq(2) - expect(payload.dig('pagination', 'per_page')).to eq(2) - expect(payload.dig('pagination', 'total_count')).to eq(3) - end + run_test! end end end diff --git a/spec/requests/api/v1/categories_spec.rb b/spec/requests/api/v1/categories_spec.rb index 98584e8af..6868e3979 100644 --- a/spec/requests/api/v1/categories_spec.rb +++ b/spec/requests/api/v1/categories_spec.rb @@ -83,11 +83,7 @@ RSpec.describe 'API V1 Categories', type: :request do response '200', 'categories listed' do schema '$ref' => '#/components/schemas/CategoryCollection' - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('categories')).to be_present - expect(payload.fetch('pagination')).to include('page', 'per_page', 'total_count', 'total_pages') - end + run_test! end response '200', 'categories filtered by classification' do @@ -95,12 +91,7 @@ RSpec.describe 'API V1 Categories', type: :request do let(:classification) { 'expense' } - run_test! do |response| - payload = JSON.parse(response.body) - payload.fetch('categories').each do |category| - expect(category.fetch('classification')).to eq('expense') - end - end + run_test! end response '200', 'root categories only' do @@ -108,12 +99,7 @@ RSpec.describe 'API V1 Categories', type: :request do let(:roots_only) { true } - run_test! do |response| - payload = JSON.parse(response.body) - payload.fetch('categories').each do |category| - expect(category.fetch('parent')).to be_nil - end - end + run_test! end response '200', 'categories filtered by parent' do @@ -121,12 +107,7 @@ RSpec.describe 'API V1 Categories', type: :request do let(:parent_id) { parent_category.id } - run_test! do |response| - payload = JSON.parse(response.body) - payload.fetch('categories').each do |category| - expect(category.dig('parent', 'id')).to eq(parent_category.id) - end - end + run_test! end end end @@ -144,13 +125,7 @@ RSpec.describe 'API V1 Categories', type: :request do response '200', 'category retrieved' do schema '$ref' => '#/components/schemas/CategoryDetail' - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('id')).to eq(parent_category.id) - expect(payload.fetch('name')).to eq('Food & Drink') - expect(payload.fetch('classification')).to eq('expense') - expect(payload.fetch('subcategories_count')).to eq(1) - end + run_test! end response '200', 'subcategory retrieved with parent' do @@ -158,13 +133,7 @@ RSpec.describe 'API V1 Categories', type: :request do let(:id) { subcategory.id } - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('id')).to eq(subcategory.id) - expect(payload.fetch('name')).to eq('Restaurants') - expect(payload.dig('parent', 'id')).to eq(parent_category.id) - expect(payload.dig('parent', 'name')).to eq('Food & Drink') - end + run_test! end response '404', 'category not found' do diff --git a/spec/requests/api/v1/chats_spec.rb b/spec/requests/api/v1/chats_spec.rb index 96af30f17..2f38069c6 100644 --- a/spec/requests/api/v1/chats_spec.rb +++ b/spec/requests/api/v1/chats_spec.rb @@ -83,11 +83,7 @@ RSpec.describe 'API V1 Chats', type: :request do response '200', 'chats listed' do schema '$ref' => '#/components/schemas/ChatCollection' - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('chats')).to be_present - expect(payload.fetch('pagination')).to include('page', 'per_page', 'total_count', 'total_pages') - end + run_test! end response '403', 'AI features disabled' do @@ -132,11 +128,7 @@ RSpec.describe 'API V1 Chats', type: :request do response '201', 'chat created' do schema '$ref' => '#/components/schemas/ChatDetail' - run_test! do |response| - payload = JSON.parse(response.body) - chat_record = Chat.find(payload.fetch('id')) - expect(chat_record.messages.first.content).to eq('Can you help me plan a summer trip?') - end + run_test! end response '422', 'validation error' do @@ -162,10 +154,7 @@ RSpec.describe 'API V1 Chats', type: :request do response '200', 'chat retrieved' do schema '$ref' => '#/components/schemas/ChatDetail' - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('messages').size).to be >= 1 - end + run_test! end response '404', 'chat not found' do @@ -197,10 +186,7 @@ RSpec.describe 'API V1 Chats', type: :request do response '200', 'chat updated' do schema '$ref' => '#/components/schemas/ChatDetail' - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('title')).to eq('Updated budget plan') - end + run_test! end response '404', 'chat not found' do @@ -269,10 +255,7 @@ RSpec.describe 'API V1 Chats', type: :request do response '201', 'message created' do schema '$ref' => '#/components/schemas/MessageResponse' - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('ai_response_status')).to eq('pending') - end + run_test! end response '404', 'chat not found' do @@ -310,10 +293,7 @@ RSpec.describe 'API V1 Chats', type: :request do allow_any_instance_of(AssistantMessage).to receive(:valid?).and_return(true) end - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('message')).to eq('Retry initiated') - end + run_test! end response '404', 'chat not found' do diff --git a/spec/requests/api/v1/imports_spec.rb b/spec/requests/api/v1/imports_spec.rb index 4ed8e7431..44580e747 100644 --- a/spec/requests/api/v1/imports_spec.rb +++ b/spec/requests/api/v1/imports_spec.rb @@ -81,11 +81,7 @@ RSpec.describe 'API V1 Imports', type: :request do response '200', 'imports listed' do schema '$ref' => '#/components/schemas/ImportCollection' - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('data')).to be_present - expect(payload.fetch('meta')).to include('current_page', 'total_count', 'total_pages', 'per_page') - end + run_test! end response '200', 'imports filtered by status' do @@ -93,12 +89,7 @@ RSpec.describe 'API V1 Imports', type: :request do let(:status) { 'pending' } - run_test! do |response| - payload = JSON.parse(response.body) - payload.fetch('data').each do |import| - expect(import.fetch('status')).to eq('pending') - end - end + run_test! end response '200', 'imports filtered by type' do @@ -106,12 +97,7 @@ RSpec.describe 'API V1 Imports', type: :request do let(:type) { 'TransactionImport' } - run_test! do |response| - payload = JSON.parse(response.body) - payload.fetch('data').each do |import| - expect(import.fetch('type')).to eq('TransactionImport') - end - end + run_test! end end @@ -203,12 +189,7 @@ RSpec.describe 'API V1 Imports', type: :request do } end - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.dig('data', 'id')).to be_present - expect(payload.dig('data', 'type')).to eq('TransactionImport') - expect(payload.dig('data', 'status')).to eq('pending') - end + run_test! end response '422', 'validation error - file too large' do @@ -240,13 +221,7 @@ RSpec.describe 'API V1 Imports', type: :request do response '200', 'import retrieved' do schema '$ref' => '#/components/schemas/ImportResponse' - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.dig('data', 'id')).to eq(pending_import.id) - expect(payload.dig('data', 'type')).to eq('TransactionImport') - expect(payload.dig('data', 'configuration')).to be_present - expect(payload.dig('data', 'stats')).to be_present - end + run_test! end response '404', 'import not found' do diff --git a/spec/requests/api/v1/tags_spec.rb b/spec/requests/api/v1/tags_spec.rb index 3e723489f..947a67b6a 100644 --- a/spec/requests/api/v1/tags_spec.rb +++ b/spec/requests/api/v1/tags_spec.rb @@ -54,12 +54,7 @@ RSpec.describe 'API V1 Tags', type: :request do response '200', 'tags listed' do schema '$ref' => '#/components/schemas/TagCollection' - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload).to be_an(Array) - expect(payload.length).to eq(3) - expect(payload.first).to include('id', 'name', 'color', 'created_at', 'updated_at') - end + run_test! end end @@ -95,11 +90,7 @@ RSpec.describe 'API V1 Tags', type: :request do } end - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('name')).to eq('Business') - expect(payload.fetch('color')).to eq('#8b5cf6') - end + run_test! end response '201', 'tag created with auto-assigned color' do @@ -113,11 +104,7 @@ RSpec.describe 'API V1 Tags', type: :request do } end - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('name')).to eq('Travel') - expect(payload.fetch('color')).to be_present - end + run_test! end response '422', 'validation error - missing name' do @@ -149,12 +136,7 @@ RSpec.describe 'API V1 Tags', type: :request do response '200', 'tag retrieved' do schema '$ref' => '#/components/schemas/TagDetail' - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('id')).to eq(essential_tag.id) - expect(payload.fetch('name')).to eq('Essential') - expect(payload.fetch('color')).to eq('#22c55e') - end + run_test! end response '404', 'tag not found' do @@ -199,11 +181,7 @@ RSpec.describe 'API V1 Tags', type: :request do response '200', 'tag updated' do schema '$ref' => '#/components/schemas/TagDetail' - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('name')).to eq('Must Have') - expect(payload.fetch('color')).to eq('#10b981') - end + run_test! end response '404', 'tag not found' do diff --git a/spec/requests/api/v1/transactions_spec.rb b/spec/requests/api/v1/transactions_spec.rb index c9d8823e2..575aaf4f4 100644 --- a/spec/requests/api/v1/transactions_spec.rb +++ b/spec/requests/api/v1/transactions_spec.rb @@ -132,11 +132,7 @@ RSpec.describe 'API V1 Transactions', type: :request do response '200', 'transactions listed' do schema '$ref' => '#/components/schemas/TransactionCollection' - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('transactions')).to be_present - expect(payload.fetch('pagination')).to include('page', 'per_page', 'total_count', 'total_pages') - end + run_test! end response '200', 'transactions filtered by account' do @@ -144,10 +140,7 @@ RSpec.describe 'API V1 Transactions', type: :request do let(:account_id) { account.id } - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('transactions')).to be_present - end + run_test! end response '200', 'transactions filtered by date range' do @@ -156,10 +149,7 @@ RSpec.describe 'API V1 Transactions', type: :request do let(:start_date) { (Date.current - 7.days).to_s } let(:end_date) { Date.current.to_s } - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('transactions')).to be_present - end + run_test! end end @@ -209,11 +199,7 @@ RSpec.describe 'API V1 Transactions', type: :request do response '201', 'transaction created' do schema '$ref' => '#/components/schemas/Transaction' - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('name')).to eq('Test purchase') - expect(payload.fetch('account').fetch('id')).to eq(account.id) - end + run_test! end response '422', 'validation error - missing account_id' do @@ -261,14 +247,7 @@ RSpec.describe 'API V1 Transactions', type: :request do response '200', 'transaction retrieved' do schema '$ref' => '#/components/schemas/Transaction' - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('id')).to eq(transaction.id) - expect(payload.fetch('name')).to eq('Grocery shopping') - expect(payload.fetch('category').fetch('name')).to eq('Groceries') - expect(payload.fetch('merchant').fetch('name')).to eq('Whole Foods') - expect(payload.fetch('tags').first.fetch('name')).to eq('Essential') - end + run_test! end response '404', 'transaction not found' do @@ -321,11 +300,7 @@ RSpec.describe 'API V1 Transactions', type: :request do response '200', 'transaction updated' do schema '$ref' => '#/components/schemas/Transaction' - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('name')).to eq('Updated grocery shopping') - expect(payload.fetch('notes')).to eq('Weekly groceries') - end + run_test! end response '404', 'transaction not found' do @@ -347,10 +322,7 @@ RSpec.describe 'API V1 Transactions', type: :request do response '200', 'transaction deleted' do schema '$ref' => '#/components/schemas/DeleteResponse' - run_test! do |response| - payload = JSON.parse(response.body) - expect(payload.fetch('message')).to eq('Transaction deleted successfully') - end + run_test! end response '404', 'transaction not found' do diff --git a/spec/requests/api/v1/valuations_spec.rb b/spec/requests/api/v1/valuations_spec.rb new file mode 100644 index 000000000..9250c14f8 --- /dev/null +++ b/spec/requests/api/v1/valuations_spec.rb @@ -0,0 +1,287 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'API V1 Valuations', type: :request do + let(:family) do + Family.create!( + name: 'API Family', + currency: 'USD', + locale: 'en', + date_format: '%m-%d-%Y' + ) + end + + let(:user) do + family.users.create!( + email: 'api-user@example.com', + password: 'password123', + password_confirmation: 'password123' + ) + end + + let(:oauth_application) do + Doorkeeper::Application.create!( + name: 'API Docs', + redirect_uri: 'https://example.com/callback', + scopes: 'read read_write' + ) + end + + let(:access_token) do + Doorkeeper::AccessToken.create!( + application: oauth_application, + resource_owner_id: user.id, + scopes: 'read_write', + expires_in: 2.hours, + token: SecureRandom.hex(32) + ) + end + + let(:Authorization) { "Bearer #{access_token.token}" } + + let(:account) do + Account.create!( + family: family, + name: 'Investment Account', + balance: 10000, + currency: 'USD', + accountable: Investment.create! + ) + end + + let!(:valuation_entry) do + account.entries.create!( + name: 'Investment Reconciliation', + date: Date.current, + amount: 10000, + currency: 'USD', + entryable: Valuation.new( + kind: 'reconciliation' + ) + ) + end + + let!(:valuation) { valuation_entry.entryable } + let!(:valuation_id) { valuation_entry.id } + + path '/api/v1/valuations' do + post 'Create valuation' do + tags 'Valuations' + security [ { apiKeyAuth: [] } ] + consumes 'application/json' + produces 'application/json' + parameter name: :Authorization, in: :header, required: true, schema: { type: :string }, + description: 'Bearer token with write scope' + parameter name: :body, in: :body, required: true, schema: { + type: :object, + properties: { + valuation: { + type: :object, + properties: { + account_id: { type: :string, format: :uuid, description: 'Account ID (required)' }, + amount: { type: :number, description: 'Valuation amount (required)' }, + date: { type: :string, format: :date, description: 'Valuation date (required)' }, + notes: { type: :string, description: 'Additional notes' } + }, + required: %w[account_id amount date] + } + }, + required: %w[valuation] + } + + let(:body) do + { + valuation: { + account_id: account.id, + amount: 15000.00, + date: Date.current.to_s + } + } + end + + response '201', 'valuation created' do + schema '$ref' => '#/components/schemas/Valuation' + + run_test! do |response| + data = JSON.parse(response.body) + created_id = data.fetch('id') + get "/api/v1/valuations/#{created_id}", headers: { 'Authorization' => Authorization } + expect(response).to have_http_status(:ok) + fetched = JSON.parse(response.body) + expect(fetched['id']).to eq(created_id) + end + end + + response '422', 'validation error - missing account_id' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:body) do + { + valuation: { + amount: 15000.00, + date: Date.current.to_s + } + } + end + + run_test! + end + + response '422', 'validation error - missing amount' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:body) do + { + valuation: { + account_id: account.id, + date: Date.current.to_s + } + } + end + + run_test! + end + + response '422', 'validation error - missing date' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:body) do + { + valuation: { + account_id: account.id, + amount: 15000.00 + } + } + end + + run_test! + end + + response '404', 'account not found' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:body) do + { + valuation: { + account_id: SecureRandom.uuid, + amount: 15000.00, + date: Date.current.to_s + } + } + end + + run_test! + end + end + end + + path '/api/v1/valuations/{id}' do + parameter name: :Authorization, in: :header, required: true, schema: { type: :string }, + description: 'Bearer token' + parameter name: :id, in: :path, type: :string, required: true, description: 'Valuation ID (entry ID)' + + get 'Retrieve a valuation' do + tags 'Valuations' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + + let(:id) { valuation_id } + + response '200', 'valuation retrieved' do + schema '$ref' => '#/components/schemas/Valuation' + + run_test! + end + + response '404', 'valuation not found' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:id) { SecureRandom.uuid } + + run_test! + end + end + + patch 'Update a valuation' do + tags 'Valuations' + security [ { apiKeyAuth: [] } ] + consumes 'application/json' + produces 'application/json' + + let(:id) { valuation_id } + + parameter name: :body, in: :body, required: true, schema: { + type: :object, + properties: { + valuation: { + type: :object, + properties: { + amount: { type: :number, description: 'New valuation amount (must provide with date)' }, + date: { type: :string, format: :date, description: 'New valuation date (must provide with amount)' }, + notes: { type: :string, description: 'Additional notes' } + } + } + } + } + + response '200', 'valuation updated with notes' do + schema '$ref' => '#/components/schemas/Valuation' + + let(:body) do + { + valuation: { + notes: 'Quarterly valuation update' + } + } + end + + run_test! + end + + response '200', 'valuation updated with amount and date' do + schema '$ref' => '#/components/schemas/Valuation' + + let(:body) do + { + valuation: { + amount: 12000.00, + date: (Date.current - 1.day).to_s + } + } + end + + run_test! + end + + response '422', 'validation error - only one of amount/date provided' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:body) do + { + valuation: { + amount: 12000.00 + } + } + end + + run_test! + end + + response '404', 'valuation not found' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:id) { SecureRandom.uuid } + let(:body) do + { + valuation: { + notes: 'This will fail' + } + } + end + + run_test! + end + end + end +end diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb index 5e54cfefd..575032edd 100644 --- a/spec/swagger_helper.rb +++ b/spec/swagger_helper.rb @@ -316,6 +316,21 @@ RSpec.configure do |config| pagination: { '$ref' => '#/components/schemas/Pagination' } } }, + Valuation: { + type: :object, + required: %w[id date amount currency kind account created_at updated_at], + properties: { + id: { type: :string, format: :uuid }, + date: { type: :string, format: :date }, + amount: { type: :string }, + currency: { type: :string }, + notes: { type: :string, nullable: true }, + kind: { type: :string }, + account: { '$ref' => '#/components/schemas/Account' }, + created_at: { type: :string, format: :'date-time' }, + updated_at: { type: :string, format: :'date-time' } + } + }, DeleteResponse: { type: :object, required: %w[message], diff --git a/test/controllers/api/v1/valuations_controller_test.rb b/test/controllers/api/v1/valuations_controller_test.rb new file mode 100644 index 000000000..0864470c1 --- /dev/null +++ b/test/controllers/api/v1/valuations_controller_test.rb @@ -0,0 +1,212 @@ +# frozen_string_literal: true + +require "test_helper" + +class Api::V1::ValuationsControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:family_admin) + @family = @user.family + @account = @family.accounts.first + @valuation = @family.entries.valuations.first.entryable + + # Destroy existing active API keys to avoid validation errors + @user.api_keys.active.destroy_all + + # Create fresh API keys instead of using fixtures to avoid parallel test conflicts (rate limiting in test) + @api_key = ApiKey.create!( + user: @user, + name: "Test Read-Write Key", + scopes: [ "read_write" ], + display_key: "test_rw_#{SecureRandom.hex(8)}" + ) + + @read_only_api_key = ApiKey.create!( + user: @user, + name: "Test Read-Only Key", + scopes: [ "read" ], + display_key: "test_ro_#{SecureRandom.hex(8)}", + source: "mobile" # Use different source to allow multiple keys + ) + + # Clear any existing rate limit data + Redis.new.del("api_rate_limit:#{@api_key.id}") + Redis.new.del("api_rate_limit:#{@read_only_api_key.id}") + end + + # CREATE action tests + test "should create valuation with valid parameters" do + valuation_params = { + valuation: { + account_id: @account.id, + amount: 10000.00, + date: Date.current, + notes: "Quarterly statement" + } + } + + assert_difference("@family.entries.valuations.count", 1) do + post api_v1_valuations_url, + params: valuation_params, + headers: api_headers(@api_key) + end + + assert_response :created + response_data = JSON.parse(response.body) + assert_equal Date.current.to_s, response_data["date"] + assert_equal @account.id, response_data["account"]["id"] + end + + test "should reject create with read-only API key" do + valuation_params = { + valuation: { + account_id: @account.id, + amount: 10000.00, + date: Date.current + } + } + + post api_v1_valuations_url, + params: valuation_params, + headers: api_headers(@read_only_api_key) + assert_response :forbidden + end + + test "should reject create with invalid account_id" do + valuation_params = { + valuation: { + account_id: 999999, + amount: 10000.00, + date: Date.current + } + } + + post api_v1_valuations_url, + params: valuation_params, + headers: api_headers(@api_key) + assert_response :not_found + end + + test "should reject create with invalid parameters" do + valuation_params = { + valuation: { + # Missing required fields + account_id: @account.id + } + } + + post api_v1_valuations_url, + params: valuation_params, + headers: api_headers(@api_key) + assert_response :unprocessable_entity + end + + test "should reject create without API key" do + post api_v1_valuations_url, params: { valuation: { account_id: @account.id } } + assert_response :unauthorized + end + + # UPDATE action tests + test "should update valuation with valid parameters" do + entry = @valuation.entry + update_params = { + valuation: { + amount: 15000.00, + date: Date.current + } + } + + put api_v1_valuation_url(entry), + params: update_params, + headers: api_headers(@api_key) + assert_response :success + + response_data = JSON.parse(response.body) + assert_equal Date.current.to_s, response_data["date"] + end + + test "should update valuation notes only" do + entry = @valuation.entry + update_params = { + valuation: { + notes: "Updated notes" + } + } + + put api_v1_valuation_url(entry), + params: update_params, + headers: api_headers(@api_key) + assert_response :success + + response_data = JSON.parse(response.body) + assert_equal "Updated notes", response_data["notes"] + end + + test "should reject update with read-only API key" do + entry = @valuation.entry + update_params = { + valuation: { + amount: 15000.00 + } + } + + put api_v1_valuation_url(entry), + params: update_params, + headers: api_headers(@read_only_api_key) + assert_response :forbidden + end + + test "should reject update for non-existent valuation" do + put api_v1_valuation_url(999999), + params: { valuation: { amount: 15000.00 } }, + headers: api_headers(@api_key) + assert_response :not_found + end + + test "should reject update without API key" do + entry = @valuation.entry + put api_v1_valuation_url(entry), params: { valuation: { amount: 15000.00 } } + assert_response :unauthorized + end + + # JSON structure tests + test "valuation JSON should have expected structure" do + # Create a new valuation to test the structure + entry = @account.entries.create!( + name: Valuation.build_reconciliation_name(@account.accountable_type), + date: Date.current, + amount: 10000, + currency: @account.currency, + entryable: Valuation.new(kind: :reconciliation) + ) + + get api_v1_valuation_url(entry), headers: api_headers(@api_key) + assert_response :success + + valuation_data = JSON.parse(response.body) + + # Basic fields + assert_equal entry.id, valuation_data["id"] + assert valuation_data.key?("id") + assert valuation_data.key?("date") + assert valuation_data.key?("amount") + assert valuation_data.key?("currency") + assert valuation_data.key?("kind") + assert valuation_data.key?("created_at") + assert valuation_data.key?("updated_at") + + # Account information + assert valuation_data.key?("account") + assert valuation_data["account"].key?("id") + assert valuation_data["account"].key?("name") + assert valuation_data["account"].key?("account_type") + + # Optional fields should be present (even if nil) + assert valuation_data.key?("notes") + end + + private + + def api_headers(api_key) + { "X-Api-Key" => api_key.display_key } + end +end From 6f8858b1a6df2ecc86c35a0913b37614e416626d Mon Sep 17 00:00:00 2001 From: MkDev11 <94194147+MkDev11@users.noreply.github.com> Date: Fri, 30 Jan 2026 14:44:25 -0500 Subject: [PATCH 025/108] feat/Add AI-Powered Bank Statement Import (step 1, PDF import & analysis) (#808) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Add PDF import with AI-powered document analysis This enhances the import functionality to support PDF files with AI-powered document analysis. When a PDF is uploaded, it is processed by AI to: - Identify the document type (bank statement, credit card statement, etc.) - Generate a summary of the document contents - Extract key metadata (institution, dates, balances, transaction count) After processing, an email is sent to the user asking for next steps. Key changes: - Add PdfImport model for handling PDF document imports - Add Provider::Openai::PdfProcessor for AI document analysis - Add ProcessPdfJob for async PDF processing - Add PdfImportMailer for user notification emails - Update imports controller to detect and handle PDF uploads - Add PDF import option to the new import page - Add i18n translations for all new strings - Add comprehensive tests for the new functionality * Add bank statement import with AI extraction - Create ImportBankStatement assistant function for MCP - Add BankStatementExtractor with chunked processing for small context windows - Register function in assistant configurable - Make PdfImport#pdf_file_content public for extractor access - Increase OpenAI request timeout to 600s for slow local models - Increase DB connection pool to 20 for concurrent operations Tested with M-Pesa bank statement via remote Ollama (qwen3:8b): - Successfully extracted 18 transactions - Generated CSV and created TransactionImport - Works with 3000 char chunks for small context windows * Add pdf-reader gem dependency The BankStatementExtractor uses PDF::Reader to parse bank statement PDFs, but the gem was not properly declared in the Gemfile. This would cause NameError in production when processing bank statements. Added pdf-reader ~> 2.12 to Gemfile dependencies. * Fix transaction deduplication to preserve legitimate duplicates The previous deduplication logic removed ALL duplicate transactions based on [date, amount, name], which would drop legitimate same-day duplicates like multiple ATM withdrawals or card authorizations. Changed to only deduplicate transactions that appear in consecutive chunks (chunking artifacts) while preserving all legitimate duplicates within the same chunk or non-adjacent chunks. * Refactor bank statement extraction to use public provider method Address code review feedback: - Add public extract_bank_statement method to Provider::Openai - Remove direct access to private client via send(:client) - Update ImportBankStatement to use new public method - Add require 'set' to BankStatementExtractor - Remove PII-sensitive content from error logs - Add defensive check for nil response.error - Handle oversized PDF pages in chunking logic - Remove unused process_native and process_generic methods - Update email copy to reflect feature availability - Add guard for nil document_type in email template - Document pdf-reader gem rationale in Gemfile Tested with both OpenAI (gpt-4o) and Ollama (qwen3:8b): - OpenAI: 49 transactions extracted in 30s - Ollama: 40 transactions extracted in 368s - All encapsulation and error handling working correctly * Update schema.rb with ai_summary and document_type columns * Address PR #808 review comments - Rename :csv_file to :import_file across controllers/views/tests - Add PDF test fixture (sample_bank_statement.pdf) - Add supports_pdf_processing? method for graceful degradation - Revert unrelated database.yml pool change (600->3) - Remove month_start_day schema bleed from other PR - Fix PdfProcessor: use .strip instead of .strip_heredoc - Add server-side PDF magic byte validation - Conditionally show PDF import option when AI provider available - Fix ProcessPdfJob: sanitize errors, handle update failure - Move pdf_file attachment from Import to PdfImport - Document deduplication logic limitations - Fix ImportBankStatement: catch specific exceptions only - Remove unnecessary require 'set' - Remove dead json_schema method from PdfProcessor - Reduce default OpenAI timeout from 600s to 60s - Fix nil guard in text mailer template - Add require 'csv' to ImportBankStatement - Remove Gemfile pdf-reader comment * Fix RuboCop indentation in ProcessPdfJob * Refactor PDF import check to use model predicate method Replace is_a?(PdfImport) type check with requires_csv_workflow? predicate that leverages STI inheritance for cleaner controller logic. * Fix missing 'unknown' locale key and schema version mismatch - Add 'unknown: Unknown Document' to document_types locale - Fix schema version to match latest migration (2026_01_24_180211) * Document OPENAI_REQUEST_TIMEOUT env variable Added to .env.local.example and docs/hosting/ai.md * Rename ALLOWED_MIME_TYPES to ALLOWED_CSV_MIME_TYPES for clarity * Add comment explaining requires_csv_workflow? predicate * Remove redundant required_column_keys from PdfImport Base class already returns [] by default * Add ENV toggle to disable PDF processing for non-vision endpoints OPENAI_SUPPORTS_PDF_PROCESSING=false can be used for OpenAI-compatible endpoints (e.g., Ollama) that don't support vision/PDF processing. * Wire up transaction extraction for PDF bank statements - Add extracted_data JSONB column to imports - Add extract_transactions method to PdfImport - Call extraction in ProcessPdfJob for bank statements - Store transactions in extracted_data for later review * Fix ProcessPdfJob retry logic, sanitize and localize errors - Allow retries after partial success (classification ok, extraction failed) - Log sanitized error message instead of raw message to avoid data leakage - Use i18n for user-facing error messages * Add vision-capable model validation for PDF processing * Fix drag-and-drop test to use correct field name csv_file * Schema bleedover from another branch * Fix drag-drop import form field name to match controller * Add vision capability guard to process_pdf method --------- Co-authored-by: Claude Co-authored-by: mkdev11 Co-authored-by: Juan José Mata --- .env.local.example | 2 + Gemfile | 1 + Gemfile.lock | 13 + app/controllers/api/v1/imports_controller.rb | 2 +- app/controllers/import/uploads_controller.rb | 4 +- app/controllers/imports_controller.rb | 51 +++- app/jobs/process_pdf_job.rb | 54 ++++ app/mailers/pdf_import_mailer.rb | 12 + app/models/assistant/configurable.rb | 3 +- .../function/import_bank_statement.rb | 188 +++++++++++++ app/models/import.rb | 16 +- app/models/pdf_import.rb | 110 ++++++++ app/models/provider/llm_concept.rb | 10 + app/models/provider/openai.rb | 63 +++++ .../openai/bank_statement_extractor.rb | 213 ++++++++++++++ app/models/provider/openai/pdf_processor.rb | 265 ++++++++++++++++++ app/views/import/uploads/show.html.erb | 2 +- app/views/imports/_pdf_import.html.erb | 84 ++++++ app/views/imports/new.html.erb | 29 ++ app/views/imports/show.html.erb | 6 +- .../pdf_import_mailer/next_steps.html.erb | 30 ++ .../pdf_import_mailer/next_steps.text.erb | 28 ++ app/views/transactions/_list.html.erb | 2 +- .../locales/mailers/pdf_import_mailer/en.yml | 5 + config/locales/views/imports/en.yml | 39 +++ config/locales/views/pdf_import_mailer/en.yml | 17 ++ .../20260116100000_add_pdf_import_support.rb | 6 + ...129200129_add_extracted_data_to_imports.rb | 5 + db/schema.rb | 5 +- docs/hosting/ai.md | 3 + .../import/uploads_controller_test.rb | 4 +- .../files/imports/sample_bank_statement.pdf | Bin 0 -> 52633 bytes test/fixtures/imports.yml | 12 + test/jobs/process_pdf_job_test.rb | 35 +++ test/mailers/pdf_import_mailer_test.rb | 21 ++ test/models/pdf_import_test.rb | 69 +++++ test/system/drag_and_drop_import_test.rb | 4 +- 37 files changed, 1388 insertions(+), 25 deletions(-) create mode 100644 app/jobs/process_pdf_job.rb create mode 100644 app/mailers/pdf_import_mailer.rb create mode 100644 app/models/assistant/function/import_bank_statement.rb create mode 100644 app/models/pdf_import.rb create mode 100644 app/models/provider/openai/bank_statement_extractor.rb create mode 100644 app/models/provider/openai/pdf_processor.rb create mode 100644 app/views/imports/_pdf_import.html.erb create mode 100644 app/views/pdf_import_mailer/next_steps.html.erb create mode 100644 app/views/pdf_import_mailer/next_steps.text.erb create mode 100644 config/locales/mailers/pdf_import_mailer/en.yml create mode 100644 config/locales/views/pdf_import_mailer/en.yml create mode 100644 db/migrate/20260116100000_add_pdf_import_support.rb create mode 100644 db/migrate/20260129200129_add_extracted_data_to_imports.rb create mode 100644 test/fixtures/files/imports/sample_bank_statement.pdf create mode 100644 test/jobs/process_pdf_job_test.rb create mode 100644 test/mailers/pdf_import_mailer_test.rb create mode 100644 test/models/pdf_import_test.rb diff --git a/.env.local.example b/.env.local.example index e91e7cf0e..b9ddcabf3 100644 --- a/.env.local.example +++ b/.env.local.example @@ -28,6 +28,8 @@ TWELVE_DATA_API_KEY = OPENAI_ACCESS_TOKEN = OPENAI_URI_BASE = OPENAI_MODEL = +# OPENAI_REQUEST_TIMEOUT: Request timeout in seconds (default: 60) +# OPENAI_SUPPORTS_PDF_PROCESSING: Set to false for endpoints without vision support (default: true) # (example: LM Studio/Docker config) OpenAI-compatible API endpoint config # OPENAI_URI_BASE = http://host.docker.internal:1234/ diff --git a/Gemfile b/Gemfile index 64dd5099e..f179d6798 100644 --- a/Gemfile +++ b/Gemfile @@ -81,6 +81,7 @@ gem "rotp", "~> 6.3" gem "rqrcode", "~> 3.0" gem "activerecord-import" gem "rubyzip", "~> 2.3" +gem "pdf-reader", "~> 2.12" # OpenID Connect, OAuth & SAML authentication gem "omniauth", "~> 2.1" diff --git a/Gemfile.lock b/Gemfile.lock index dfc8ee3c9..852d372c1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,7 @@ GEM remote: https://rubygems.org/ specs: + Ascii85 (2.0.1) aasm (5.5.1) concurrent-ruby (~> 1.0) actioncable (7.2.2.2) @@ -79,6 +80,7 @@ GEM addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) aes_key_wrap (1.1.0) + afm (1.0.0) after_commit_everywhere (1.6.0) activerecord (>= 4.2) activesupport @@ -232,6 +234,7 @@ GEM globalid (1.2.1) activesupport (>= 6.1) hashdiff (1.2.0) + hashery (2.1.2) hashie (5.0.0) heapy (0.2.0) thor @@ -446,6 +449,12 @@ GEM parser (3.3.8.0) ast (~> 2.4.1) racc + pdf-reader (2.15.1) + Ascii85 (>= 1.0, < 3.0, != 2.0.0) + afm (>= 0.2.1, < 2) + hashery (~> 2.0) + ruby-rc4 + ttfunk pg (1.5.9) plaid (41.0.0) faraday (>= 1.0.1, < 3.0) @@ -629,6 +638,7 @@ GEM faraday (>= 1) faraday-multipart (>= 1) ruby-progressbar (1.13.0) + ruby-rc4 (0.1.5) ruby-saml (1.18.1) nokogiri (>= 1.13.10) rexml @@ -712,6 +722,8 @@ GEM unicode-display_width (>= 1.1.1, < 4) thor (1.4.0) timeout (0.4.3) + ttfunk (1.8.0) + bigdecimal (~> 3.1) turbo-rails (2.0.16) actionpack (>= 7.1.0) railties (>= 7.1.0) @@ -818,6 +830,7 @@ DEPENDENCIES omniauth_openid_connect ostruct pagy + pdf-reader (~> 2.12) pg (~> 1.5) plaid posthog-ruby diff --git a/app/controllers/api/v1/imports_controller.rb b/app/controllers/api/v1/imports_controller.rb index 2b6a5a5af..b3b048bba 100644 --- a/app/controllers/api/v1/imports_controller.rb +++ b/app/controllers/api/v1/imports_controller.rb @@ -67,7 +67,7 @@ class Api::V1::ImportsController < Api::V1::BaseController }, status: :unprocessable_entity end - unless Import::ALLOWED_MIME_TYPES.include?(file.content_type) + unless Import::ALLOWED_CSV_MIME_TYPES.include?(file.content_type) return render json: { error: "invalid_file_type", message: "Invalid file type. Please upload a CSV file." diff --git a/app/controllers/import/uploads_controller.rb b/app/controllers/import/uploads_controller.rb index e51b52787..a9a185d51 100644 --- a/app/controllers/import/uploads_controller.rb +++ b/app/controllers/import/uploads_controller.rb @@ -33,7 +33,7 @@ class Import::UploadsController < ApplicationController end def csv_str - @csv_str ||= upload_params[:csv_file]&.read || upload_params[:raw_file_str] + @csv_str ||= upload_params[:import_file]&.read || upload_params[:raw_file_str] end def csv_valid?(str) @@ -48,6 +48,6 @@ class Import::UploadsController < ApplicationController end def upload_params - params.require(:import).permit(:raw_file_str, :csv_file, :col_sep) + params.require(:import).permit(:raw_file_str, :import_file, :col_sep) end end diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb index 227e94866..88a346838 100644 --- a/app/controllers/imports_controller.rb +++ b/app/controllers/imports_controller.rb @@ -25,6 +25,18 @@ class ImportsController < ApplicationController end def create + file = import_params[:import_file] + + # Handle PDF file uploads - process with AI + if file.present? && Import::ALLOWED_PDF_MIME_TYPES.include?(file.content_type) + unless valid_pdf_file?(file) + redirect_to new_import_path, alert: t("imports.create.invalid_pdf") + return + end + create_pdf_import(file) + return + end + type = params.dig(:import, :type).to_s type = "TransactionImport" unless Import::TYPES.include?(type) @@ -35,35 +47,35 @@ class ImportsController < ApplicationController date_format: Current.family.date_format, ) - if import_params[:csv_file].present? - file = import_params[:csv_file] - + if file.present? if file.size > Import::MAX_CSV_SIZE import.destroy - redirect_to new_import_path, alert: "File is too large. Maximum size is #{Import::MAX_CSV_SIZE / 1.megabyte}MB." + redirect_to new_import_path, alert: t("imports.create.file_too_large", max_size: Import::MAX_CSV_SIZE / 1.megabyte) return end - unless Import::ALLOWED_MIME_TYPES.include?(file.content_type) + unless Import::ALLOWED_CSV_MIME_TYPES.include?(file.content_type) import.destroy - redirect_to new_import_path, alert: "Invalid file type. Please upload a CSV file." + redirect_to new_import_path, alert: t("imports.create.invalid_file_type") return end # Stream reading is not fully applicable here as we store the raw string in the DB, # but we have validated size beforehand to prevent memory exhaustion from massive files. import.update!(raw_file_str: file.read) - redirect_to import_configuration_path(import), notice: "CSV uploaded successfully." + redirect_to import_configuration_path(import), notice: t("imports.create.csv_uploaded") else redirect_to import_upload_path(import) end end def show + return unless @import.requires_csv_workflow? + if !@import.uploaded? - redirect_to import_upload_path(@import), alert: "Please finalize your file upload." + redirect_to import_upload_path(@import), alert: t("imports.show.finalize_upload") elsif !@import.publishable? - redirect_to import_confirm_path(@import), alert: "Please finalize your mappings before proceeding." + redirect_to import_confirm_path(@import), alert: t("imports.show.finalize_mappings") end end @@ -93,6 +105,25 @@ class ImportsController < ApplicationController end def import_params - params.require(:import).permit(:csv_file) + params.require(:import).permit(:import_file) + end + + def create_pdf_import(file) + if file.size > Import::MAX_PDF_SIZE + redirect_to new_import_path, alert: t("imports.create.pdf_too_large", max_size: Import::MAX_PDF_SIZE / 1.megabyte) + return + end + + pdf_import = Current.family.imports.create!(type: "PdfImport") + pdf_import.pdf_file.attach(file) + pdf_import.process_with_ai_later + + redirect_to import_path(pdf_import), notice: t("imports.create.pdf_processing") + end + + def valid_pdf_file?(file) + header = file.read(5) + file.rewind + header&.start_with?("%PDF-") end end diff --git a/app/jobs/process_pdf_job.rb b/app/jobs/process_pdf_job.rb new file mode 100644 index 000000000..25c31f11f --- /dev/null +++ b/app/jobs/process_pdf_job.rb @@ -0,0 +1,54 @@ +class ProcessPdfJob < ApplicationJob + queue_as :medium_priority + + def perform(pdf_import) + return unless pdf_import.is_a?(PdfImport) + return unless pdf_import.pdf_uploaded? + return if pdf_import.status == "complete" + return if pdf_import.ai_processed? && (!pdf_import.bank_statement? || pdf_import.has_extracted_transactions?) + + pdf_import.update!(status: :importing) + + begin + pdf_import.process_with_ai + + # For bank statements, extract transactions + if pdf_import.bank_statement? + Rails.logger.info("ProcessPdfJob: Extracting transactions for bank statement import #{pdf_import.id}") + pdf_import.extract_transactions + Rails.logger.info("ProcessPdfJob: Extracted #{pdf_import.extracted_transactions.size} transactions") + end + + # Find the user who created this import (first admin or any user in the family) + user = pdf_import.family.users.find_by(role: :admin) || pdf_import.family.users.first + + if user + pdf_import.send_next_steps_email(user) + end + + pdf_import.update!(status: :complete) + rescue StandardError => e + sanitized_error = sanitize_error_message(e) + Rails.logger.error("PDF processing failed for import #{pdf_import.id}: #{e.class.name} - #{sanitized_error}") + begin + pdf_import.update!(status: :failed, error: sanitized_error) + rescue StandardError => update_error + Rails.logger.error("Failed to update import status: #{update_error.message}") + end + raise + end + end + + private + + def sanitize_error_message(error) + case error + when RuntimeError, ArgumentError + I18n.t("imports.pdf_import.processing_failed_with_message", + message: error.message.truncate(500)) + else + I18n.t("imports.pdf_import.processing_failed_generic", + error: error.class.name.demodulize) + end + end +end diff --git a/app/mailers/pdf_import_mailer.rb b/app/mailers/pdf_import_mailer.rb new file mode 100644 index 000000000..5f9f759d7 --- /dev/null +++ b/app/mailers/pdf_import_mailer.rb @@ -0,0 +1,12 @@ +class PdfImportMailer < ApplicationMailer + def next_steps + @user = params[:user] + @pdf_import = params[:pdf_import] + @import_url = import_url(@pdf_import) + + mail( + to: @user.email, + subject: t(".subject", product: product_name) + ) + end +end diff --git a/app/models/assistant/configurable.rb b/app/models/assistant/configurable.rb index a2898c30b..2aae1eb06 100644 --- a/app/models/assistant/configurable.rb +++ b/app/models/assistant/configurable.rb @@ -19,7 +19,8 @@ module Assistant::Configurable Assistant::Function::GetAccounts, Assistant::Function::GetHoldings, Assistant::Function::GetBalanceSheet, - Assistant::Function::GetIncomeStatement + Assistant::Function::GetIncomeStatement, + Assistant::Function::ImportBankStatement ] end diff --git a/app/models/assistant/function/import_bank_statement.rb b/app/models/assistant/function/import_bank_statement.rb new file mode 100644 index 000000000..b0cd02906 --- /dev/null +++ b/app/models/assistant/function/import_bank_statement.rb @@ -0,0 +1,188 @@ +require "csv" + +class Assistant::Function::ImportBankStatement < Assistant::Function + class << self + def name + "import_bank_statement" + end + + def description + <<~INSTRUCTIONS + Use this to import transactions from a bank statement PDF that has already been uploaded. + + This function will: + 1. Extract transaction data from the PDF using AI + 2. Create a transaction import with the extracted data + 3. Return the import ID and extracted transactions for review + + The PDF must have already been uploaded via the PDF import feature. + Only use this for PDFs that are identified as bank statements. + + Example: + + ``` + import_bank_statement({ + pdf_import_id: "abc123-def456", + account_id: "xyz789" + }) + ``` + + If account_id is not provided, you should ask the user which account to import to. + INSTRUCTIONS + end + end + + def strict_mode? + false + end + + def params_schema + build_schema( + required: [ "pdf_import_id" ], + properties: { + pdf_import_id: { + type: "string", + description: "The ID of the PDF import to extract transactions from" + }, + account_id: { + type: "string", + description: "The ID of the account to import transactions into. If not provided, will return available accounts." + } + } + ) + end + + def call(params = {}) + pdf_import = family.imports.find_by(id: params["pdf_import_id"], type: "PdfImport") + + unless pdf_import + return { + success: false, + error: "PDF import not found", + message: "Could not find a PDF import with ID: #{params["pdf_import_id"]}" + } + end + + unless pdf_import.document_type == "bank_statement" + return { + success: false, + error: "not_bank_statement", + message: "This PDF is not a bank statement. Document type: #{pdf_import.document_type}", + available_actions: [ "Use a different PDF that is a bank statement" ] + } + end + + # If no account specified, return available accounts + if params["account_id"].blank? + return { + success: false, + error: "account_required", + message: "Please specify which account to import transactions into", + available_accounts: family.accounts.visible.depository.map { |a| { id: a.id, name: a.name } } + } + end + + account = family.accounts.find_by(id: params["account_id"]) + unless account + return { + success: false, + error: "account_not_found", + message: "Account not found", + available_accounts: family.accounts.visible.depository.map { |a| { id: a.id, name: a.name } } + } + end + + # Extract transactions from the PDF using provider + provider = Provider::Registry.get_provider(:openai) + unless provider + return { + success: false, + error: "provider_not_configured", + message: "OpenAI provider is not configured" + } + end + + response = provider.extract_bank_statement( + pdf_content: pdf_import.pdf_file_content, + model: openai_model, + family: family + ) + + unless response.success? + error_message = response.error&.message || "Unknown extraction error" + return { + success: false, + error: "extraction_failed", + message: "Failed to extract transactions: #{error_message}" + } + end + + result = response.data + + if result[:transactions].blank? + return { + success: false, + error: "no_transactions_found", + message: "Could not extract any transactions from the bank statement" + } + end + + # Create a CSV from extracted transactions + csv_content = generate_csv(result[:transactions]) + + # Create a TransactionImport + import = family.imports.create!( + type: "TransactionImport", + account: account, + raw_file_str: csv_content, + date_col_label: "date", + amount_col_label: "amount", + name_col_label: "name", + category_col_label: "category", + notes_col_label: "notes", + date_format: "%Y-%m-%d", + signage_convention: "inflows_positive" + ) + + import.generate_rows_from_csv + + { + success: true, + import_id: import.id, + transaction_count: result[:transactions].size, + transactions_preview: result[:transactions].first(5), + statement_period: result[:period], + account_holder: result[:account_holder], + message: "Successfully extracted #{result[:transactions].size} transactions. Import created with ID: #{import.id}. Review and publish when ready." + } + rescue Provider::ProviderError, Faraday::Error, Timeout::Error, RuntimeError => e + Rails.logger.error("ImportBankStatement error: #{e.class.name} - #{e.message}") + Rails.logger.error(e.backtrace.first(10).join("\n")) + { + success: false, + error: "extraction_failed", + message: "Failed to extract transactions: #{e.message.truncate(200)}" + } + end + + private + + def generate_csv(transactions) + CSV.generate do |csv| + csv << %w[date amount name category notes] + transactions.each do |txn| + csv << [ + txn[:date], + txn[:amount], + txn[:name] || txn[:description], + txn[:category], + txn[:notes] + ] + end + end + end + + def openai_model + ENV["OPENAI_MODEL"].presence || Provider::Openai::DEFAULT_MODEL + end +end diff --git a/app/models/import.rb b/app/models/import.rb index 141a1ce05..203ed3a1a 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -3,9 +3,13 @@ class Import < ApplicationRecord MappingError = Class.new(StandardError) MAX_CSV_SIZE = 10.megabytes - ALLOWED_MIME_TYPES = %w[text/csv text/plain application/vnd.ms-excel application/csv].freeze + MAX_PDF_SIZE = 25.megabytes + ALLOWED_CSV_MIME_TYPES = %w[text/csv text/plain application/vnd.ms-excel application/csv].freeze + ALLOWED_PDF_MIME_TYPES = %w[application/pdf].freeze - TYPES = %w[TransactionImport TradeImport AccountImport MintImport CategoryImport RuleImport].freeze + DOCUMENT_TYPES = %w[bank_statement credit_card_statement investment_statement financial_document contract other].freeze + + TYPES = %w[TransactionImport TradeImport AccountImport MintImport CategoryImport RuleImport PdfImport].freeze SIGNAGE_CONVENTIONS = %w[inflows_positive inflows_negative] SEPARATORS = [ [ "Comma (,)", "," ], [ "Semicolon (;)", ";" ] ].freeze @@ -134,6 +138,14 @@ class Import < ApplicationRecord [] end + # Returns false for import types that don't need CSV column mapping (e.g., PdfImport). + # Override in subclasses that handle data extraction differently. + def requires_csv_workflow? + true + end + + # Subclasses that require CSV workflow must override this. + # Non-CSV imports (e.g., PdfImport) can return []. def column_keys raise NotImplementedError, "Subclass must implement column_keys" end diff --git a/app/models/pdf_import.rb b/app/models/pdf_import.rb new file mode 100644 index 000000000..8b25e8bfa --- /dev/null +++ b/app/models/pdf_import.rb @@ -0,0 +1,110 @@ +class PdfImport < Import + has_one_attached :pdf_file + + validates :document_type, inclusion: { in: DOCUMENT_TYPES }, allow_nil: true + + def pdf_uploaded? + pdf_file.attached? + end + + def ai_processed? + ai_summary.present? + end + + def process_with_ai_later + ProcessPdfJob.perform_later(self) + end + + def process_with_ai + provider = Provider::Registry.get_provider(:openai) + raise "AI provider not configured" unless provider + raise "AI provider does not support PDF processing" unless provider.supports_pdf_processing? + + response = provider.process_pdf( + pdf_content: pdf_file_content, + family: family + ) + + unless response.success? + error_message = response.error&.message || "Unknown PDF processing error" + raise error_message + end + + result = response.data + update!( + ai_summary: result.summary, + document_type: result.document_type + ) + + result + end + + def extract_transactions + return unless bank_statement? + + provider = Provider::Registry.get_provider(:openai) + raise "AI provider not configured" unless provider + + response = provider.extract_bank_statement( + pdf_content: pdf_file_content, + family: family + ) + + unless response.success? + error_message = response.error&.message || "Unknown extraction error" + raise error_message + end + + update!(extracted_data: response.data) + response.data + end + + def bank_statement? + document_type == "bank_statement" + end + + def has_extracted_transactions? + extracted_data.present? && extracted_data["transactions"].present? + end + + def extracted_transactions + extracted_data&.dig("transactions") || [] + end + + def send_next_steps_email(user) + PdfImportMailer.with( + user: user, + pdf_import: self + ).next_steps.deliver_later + end + + def uploaded? + pdf_uploaded? + end + + def configured? + ai_processed? + end + + def cleaned? + ai_processed? + end + + def publishable? + false + end + + def column_keys + [] + end + + def requires_csv_workflow? + false + end + + def pdf_file_content + return nil unless pdf_file.attached? + + pdf_file.download + end +end diff --git a/app/models/provider/llm_concept.rb b/app/models/provider/llm_concept.rb index dbd6f0458..5faf233dd 100644 --- a/app/models/provider/llm_concept.rb +++ b/app/models/provider/llm_concept.rb @@ -13,6 +13,16 @@ module Provider::LlmConcept raise NotImplementedError, "Subclasses must implement #auto_detect_merchants" end + PdfProcessingResult = Data.define(:summary, :document_type, :extracted_data) + + def supports_pdf_processing? + false + end + + def process_pdf(pdf_content:, family: nil) + raise NotImplementedError, "Provider does not support PDF processing" + end + ChatMessage = Data.define(:id, :output_text) ChatStreamChunk = Data.define(:type, :data, :usage) ChatResponse = Data.define(:id, :model, :messages, :function_requests) diff --git a/app/models/provider/openai.rb b/app/models/provider/openai.rb index 9ba1d23b0..08ac224f9 100644 --- a/app/models/provider/openai.rb +++ b/app/models/provider/openai.rb @@ -8,6 +8,9 @@ class Provider::Openai < Provider DEFAULT_OPENAI_MODEL_PREFIXES = %w[gpt-4 gpt-5 o1 o3] DEFAULT_MODEL = "gpt-4.1" + # Models that support PDF/vision input (not all OpenAI models have vision capabilities) + VISION_CAPABLE_MODEL_PREFIXES = %w[gpt-4o gpt-4-turbo gpt-4.1 gpt-5 o1 o3].freeze + # Returns the effective model that would be used by the provider # Uses the same logic as Provider::Registry and the initializer def self.effective_model @@ -18,6 +21,7 @@ class Provider::Openai < Provider def initialize(access_token, uri_base: nil, model: nil) client_options = { access_token: access_token } client_options[:uri_base] = uri_base if uri_base.present? + client_options[:request_timeout] = ENV.fetch("OPENAI_REQUEST_TIMEOUT", 60).to_i @client = ::OpenAI::Client.new(**client_options) @uri_base = uri_base @@ -112,6 +116,65 @@ class Provider::Openai < Provider end end + # Can be disabled via ENV for OpenAI-compatible endpoints that don't support vision + # Only vision-capable models (gpt-4o, gpt-4-turbo, gpt-4.1, etc.) support PDF input + def supports_pdf_processing? + return false unless ENV.fetch("OPENAI_SUPPORTS_PDF_PROCESSING", "true").to_s.downcase.in?(%w[true 1 yes]) + + # Custom providers manage their own model capabilities + return true if custom_provider? + + # Check if the configured model supports vision/PDF input + VISION_CAPABLE_MODEL_PREFIXES.any? { |prefix| @default_model.start_with?(prefix) } + end + + def process_pdf(pdf_content:, model: "", family: nil) + raise "Model does not support PDF/vision processing" unless supports_pdf_processing? + + with_provider_response do + effective_model = model.presence || @default_model + + trace = create_langfuse_trace( + name: "openai.process_pdf", + input: { pdf_size: pdf_content&.bytesize } + ) + + result = PdfProcessor.new( + client, + model: effective_model, + pdf_content: pdf_content, + custom_provider: custom_provider?, + langfuse_trace: trace, + family: family + ).process + + trace&.update(output: result.to_h) + + result + end + end + + def extract_bank_statement(pdf_content:, model: "", family: nil) + with_provider_response do + effective_model = model.presence || @default_model + + trace = create_langfuse_trace( + name: "openai.extract_bank_statement", + input: { pdf_size: pdf_content&.bytesize } + ) + + result = BankStatementExtractor.new( + client: client, + pdf_content: pdf_content, + model: effective_model + ).extract + + trace&.update(output: { transaction_count: result[:transactions].size }) + + result + end + end + def chat_response( prompt, model:, diff --git a/app/models/provider/openai/bank_statement_extractor.rb b/app/models/provider/openai/bank_statement_extractor.rb new file mode 100644 index 000000000..59456d80b --- /dev/null +++ b/app/models/provider/openai/bank_statement_extractor.rb @@ -0,0 +1,213 @@ +class Provider::Openai::BankStatementExtractor + MAX_CHARS_PER_CHUNK = 3000 + attr_reader :client, :pdf_content, :model + + def initialize(client:, pdf_content:, model:) + @client = client + @pdf_content = pdf_content + @model = model + end + + def extract + pages = extract_pages_from_pdf + raise Provider::Openai::Error, "Could not extract text from PDF" if pages.empty? + + chunks = build_chunks(pages) + Rails.logger.info("BankStatementExtractor: Processing #{chunks.size} chunk(s) from #{pages.size} page(s)") + + all_transactions = [] + metadata = {} + + chunks.each_with_index do |chunk, index| + Rails.logger.info("BankStatementExtractor: Processing chunk #{index + 1}/#{chunks.size}") + result = process_chunk(chunk, index == 0) + + # Tag transactions with chunk index for deduplication + tagged_transactions = (result[:transactions] || []).map { |t| t.merge(chunk_index: index) } + all_transactions.concat(tagged_transactions) + + if index == 0 + metadata = { + account_holder: result[:account_holder], + account_number: result[:account_number], + bank_name: result[:bank_name], + opening_balance: result[:opening_balance], + closing_balance: result[:closing_balance], + period: result[:period] + } + end + + if result[:closing_balance].present? + metadata[:closing_balance] = result[:closing_balance] + end + if result.dig(:period, :end_date).present? + metadata[:period] ||= {} + metadata[:period][:end_date] = result.dig(:period, :end_date) + end + end + + { + transactions: deduplicate_transactions(all_transactions), + period: metadata[:period] || {}, + account_holder: metadata[:account_holder], + account_number: metadata[:account_number], + bank_name: metadata[:bank_name], + opening_balance: metadata[:opening_balance], + closing_balance: metadata[:closing_balance] + } + end + + private + + def extract_pages_from_pdf + return [] if pdf_content.blank? + + reader = PDF::Reader.new(StringIO.new(pdf_content)) + reader.pages.map(&:text).reject(&:blank?) + rescue => e + Rails.logger.error("Failed to extract text from PDF: #{e.message}") + [] + end + + def build_chunks(pages) + chunks = [] + current_chunk = [] + current_size = 0 + + pages.each do |page_text| + if page_text.length > MAX_CHARS_PER_CHUNK + chunks << current_chunk.join("\n\n") if current_chunk.any? + current_chunk = [] + current_size = 0 + chunks << page_text + next + end + + if current_size + page_text.length > MAX_CHARS_PER_CHUNK && current_chunk.any? + chunks << current_chunk.join("\n\n") + current_chunk = [] + current_size = 0 + end + + current_chunk << page_text + current_size += page_text.length + end + + chunks << current_chunk.join("\n\n") if current_chunk.any? + chunks + end + + def process_chunk(text, is_first_chunk) + params = { + model: model, + messages: [ + { role: "system", content: is_first_chunk ? instructions_with_metadata : instructions_transactions_only }, + { role: "user", content: "Extract transactions:\n\n#{text}" } + ], + response_format: { type: "json_object" } + } + + response = client.chat(parameters: params) + content = response.dig("choices", 0, "message", "content") + + raise Provider::Openai::Error, "No response from AI" if content.blank? + + parsed = parse_json_response(content) + + { + transactions: normalize_transactions(parsed["transactions"] || []), + period: { + start_date: parsed.dig("statement_period", "start_date"), + end_date: parsed.dig("statement_period", "end_date") + }, + account_holder: parsed["account_holder"], + account_number: parsed["account_number"], + bank_name: parsed["bank_name"], + opening_balance: parsed["opening_balance"], + closing_balance: parsed["closing_balance"] + } + end + + def parse_json_response(content) + cleaned = content.gsub(%r{^```json\s*}i, "").gsub(/```\s*$/, "").strip + JSON.parse(cleaned) + rescue JSON::ParserError => e + Rails.logger.error("BankStatementExtractor JSON parse error: #{e.message} (content_length=#{content.to_s.bytesize})") + { "transactions" => [] } + end + + def deduplicate_transactions(transactions) + # Deduplicates transactions that appear in consecutive chunks (chunking artifacts). + # + # KNOWN LIMITATION: Legitimate duplicate transactions (same date, amount, merchant) + # that happen to appear in adjacent chunks will be incorrectly deduplicated. + # This is an acceptable trade-off since chunking artifacts are more common than + # true same-day duplicates at chunk boundaries. Transactions within the same + # chunk are always preserved regardless of similarity. + seen = Set.new + transactions.select do |t| + # Create key without chunk_index for deduplication + key = [ t[:date], t[:amount], t[:name], t[:chunk_index] ] + + # Check if we've seen this exact transaction in a different chunk + duplicate = seen.any? do |prev_key| + prev_key[0..2] == key[0..2] && (prev_key[3] - key[3]).abs <= 1 + end + + seen << key + !duplicate + end.map { |t| t.except(:chunk_index) } + end + + def normalize_transactions(transactions) + transactions.map do |txn| + { + date: parse_date(txn["date"]), + amount: parse_amount(txn["amount"]), + name: txn["description"] || txn["name"] || txn["merchant"], + category: infer_category(txn), + notes: txn["reference"] || txn["notes"] + } + end.compact + end + + def parse_date(date_str) + return nil if date_str.blank? + + Date.parse(date_str).strftime("%Y-%m-%d") + rescue ArgumentError + nil + end + + def parse_amount(amount) + return nil if amount.nil? + + if amount.is_a?(Numeric) + amount.to_f + else + amount.to_s.gsub(/[^0-9.\-]/, "").to_f + end + end + + def infer_category(txn) + txn["category"] || txn["type"] + end + + def instructions_with_metadata + <<~INSTRUCTIONS.strip + Extract bank statement data as JSON. Return: + {"bank_name":"...","account_holder":"...","account_number":"last 4 digits","statement_period":{"start_date":"YYYY-MM-DD","end_date":"YYYY-MM-DD"},"opening_balance":0.00,"closing_balance":0.00,"transactions":[{"date":"YYYY-MM-DD","description":"...","amount":-0.00}]} + + Rules: Negative amounts for debits/expenses, positive for credits/deposits. Dates as YYYY-MM-DD. Extract ALL transactions. JSON only, no markdown. + INSTRUCTIONS + end + + def instructions_transactions_only + <<~INSTRUCTIONS.strip + Extract transactions from bank statement text as JSON. Return: + {"transactions":[{"date":"YYYY-MM-DD","description":"...","amount":-0.00}]} + + Rules: Negative amounts for debits/expenses, positive for credits/deposits. Dates as YYYY-MM-DD. Extract ALL transactions. JSON only, no markdown. + INSTRUCTIONS + end +end diff --git a/app/models/provider/openai/pdf_processor.rb b/app/models/provider/openai/pdf_processor.rb new file mode 100644 index 000000000..b99caa77c --- /dev/null +++ b/app/models/provider/openai/pdf_processor.rb @@ -0,0 +1,265 @@ +class Provider::Openai::PdfProcessor + include Provider::Openai::Concerns::UsageRecorder + + attr_reader :client, :model, :pdf_content, :custom_provider, :langfuse_trace, :family + + def initialize(client, model: "", pdf_content: nil, custom_provider: false, langfuse_trace: nil, family: nil) + @client = client + @model = model + @pdf_content = pdf_content + @custom_provider = custom_provider + @langfuse_trace = langfuse_trace + @family = family + end + + def process + span = langfuse_trace&.span(name: "process_pdf_api_call", input: { + model: model.presence || Provider::Openai::DEFAULT_MODEL, + pdf_size: pdf_content&.bytesize + }) + + # Try text extraction first (works with all models) + # Fall back to vision API with images if text extraction fails (for scanned PDFs) + response = begin + process_with_text_extraction + rescue Provider::Openai::Error => e + Rails.logger.warn("Text extraction failed: #{e.message}, trying vision API with images") + process_with_vision + end + + span&.end(output: response.to_h) + response + rescue => e + span&.end(output: { error: e.message }, level: "ERROR") + raise + end + + def instructions + <<~INSTRUCTIONS.strip + You are a financial document analysis assistant. Your job is to analyze uploaded PDF documents + and provide a structured summary of what the document contains. + + For each document, you must determine: + + 1. **Document Type**: Classify the document as one of the following: + - `bank_statement`: A bank account statement showing transactions, balances, and account activity + - `credit_card_statement`: A credit card statement showing charges, payments, and balances + - `investment_statement`: An investment/brokerage statement showing holdings, trades, or portfolio performance + - `financial_document`: General financial documents like tax forms, receipts, invoices, or financial reports + - `contract`: Legal agreements, loan documents, terms of service, or policy documents + - `other`: Any document that doesn't fit the above categories + + 2. **Summary**: Provide a concise summary of the document that includes: + - The issuing institution or company name (if identifiable) + - The date range or statement period (if applicable) + - Key financial figures (account balances, total transactions, etc.) + - The account holder's name (if visible, use "Account Holder" if redacted) + - Any notable items or important information + + 3. **Extracted Data**: If the document is a statement with transactions, extract key metadata: + - Number of transactions (if countable) + - Statement period (start and end dates) + - Opening and closing balances (if visible) + - Currency used + + IMPORTANT GUIDELINES: + - Be factual and precise - only report what you can clearly see in the document + - If information is unclear or redacted, note it as "not clearly visible" or "redacted" + - Do NOT make assumptions about data you cannot see + - For statements with many transactions, provide a count rather than listing each one + - Focus on providing actionable information that helps the user understand what they uploaded + - If the document is unreadable or the PDF is corrupted, indicate this clearly + + Respond with ONLY valid JSON in this exact format (no markdown code blocks, no other text): + { + "document_type": "bank_statement|credit_card_statement|investment_statement|financial_document|contract|other", + "summary": "A clear, concise summary of the document contents...", + "extracted_data": { + "institution_name": "Name of bank/company or null", + "statement_period_start": "YYYY-MM-DD or null", + "statement_period_end": "YYYY-MM-DD or null", + "transaction_count": number or null, + "opening_balance": number or null, + "closing_balance": number or null, + "currency": "USD/EUR/etc or null", + "account_holder": "Name or null" + } + } + INSTRUCTIONS + end + + private + + PdfProcessingResult = Provider::LlmConcept::PdfProcessingResult + + def process_with_text_extraction + effective_model = model.presence || Provider::Openai::DEFAULT_MODEL + + # Extract text from PDF using pdf-reader gem + pdf_text = extract_text_from_pdf + raise Provider::Openai::Error, "Could not extract text from PDF" if pdf_text.blank? + + # Truncate if too long (max ~100k chars to stay within token limits) + pdf_text = pdf_text.truncate(100_000) if pdf_text.length > 100_000 + + params = { + model: effective_model, + messages: [ + { role: "system", content: instructions }, + { + role: "user", + content: "Please analyze the following document text and provide a structured summary:\n\n#{pdf_text}" + } + ], + response_format: { type: "json_object" } + } + + response = client.chat(parameters: params) + + Rails.logger.info("Tokens used to process PDF: #{response.dig("usage", "total_tokens")}") + + record_usage( + effective_model, + response.dig("usage"), + operation: "process_pdf", + metadata: { pdf_size: pdf_content&.bytesize } + ) + + parse_response_generic(response) + end + + def extract_text_from_pdf + return nil if pdf_content.blank? + + reader = PDF::Reader.new(StringIO.new(pdf_content)) + text_parts = [] + + reader.pages.each_with_index do |page, index| + text_parts << "--- Page #{index + 1} ---" + text_parts << page.text + end + + text_parts.join("\n\n") + rescue => e + Rails.logger.error("Failed to extract text from PDF: #{e.message}") + nil + end + + def process_with_vision + effective_model = model.presence || Provider::Openai::DEFAULT_MODEL + + # Convert PDF to images using pdftoppm + images_base64 = convert_pdf_to_images + raise Provider::Openai::Error, "Could not convert PDF to images" if images_base64.blank? + + # Build message content with images (max 5 pages to avoid token limits) + content = [] + images_base64.first(5).each do |img_base64| + content << { + type: "image_url", + image_url: { + url: "data:image/png;base64,#{img_base64}", + detail: "low" + } + } + end + content << { + type: "text", + text: "Please analyze this PDF document (#{images_base64.size} pages total, showing first #{[ images_base64.size, 5 ].min}) and respond with valid JSON only." + } + + # Note: response_format is not compatible with vision, so we ask for JSON in the prompt + params = { + model: effective_model, + messages: [ + { role: "system", content: instructions + "\n\nIMPORTANT: Respond with valid JSON only, no markdown or other formatting." }, + { role: "user", content: content } + ], + max_tokens: 4096 + } + + response = client.chat(parameters: params) + + Rails.logger.info("Tokens used to process PDF via vision: #{response.dig("usage", "total_tokens")}") + + record_usage( + effective_model, + response.dig("usage"), + operation: "process_pdf_vision", + metadata: { pdf_size: pdf_content&.bytesize, pages: images_base64.size } + ) + + parse_response_generic(response) + end + + def convert_pdf_to_images + return [] if pdf_content.blank? + + Dir.mktmpdir do |tmpdir| + pdf_path = File.join(tmpdir, "input.pdf") + File.binwrite(pdf_path, pdf_content) + + # Convert PDF to PNG images using pdftoppm + output_prefix = File.join(tmpdir, "page") + system("pdftoppm", "-png", "-r", "150", pdf_path, output_prefix) + + # Read all generated images + image_files = Dir.glob(File.join(tmpdir, "page-*.png")).sort + image_files.map do |img_path| + Base64.strict_encode64(File.binread(img_path)) + end + end + rescue => e + Rails.logger.error("Failed to convert PDF to images: #{e.message}") + [] + end + + def parse_response_generic(response) + raw = response.dig("choices", 0, "message", "content") + parsed = parse_json_flexibly(raw) + + build_result(parsed) + end + + def build_result(parsed) + PdfProcessingResult.new( + summary: parsed["summary"], + document_type: normalize_document_type(parsed["document_type"]), + extracted_data: parsed["extracted_data"] || {} + ) + end + + def normalize_document_type(doc_type) + return "other" if doc_type.blank? + + normalized = doc_type.to_s.strip.downcase.gsub(/\s+/, "_") + Import::DOCUMENT_TYPES.include?(normalized) ? normalized : "other" + end + + def parse_json_flexibly(raw) + return {} if raw.blank? + + # Try direct parse first + JSON.parse(raw) + rescue JSON::ParserError + # Try to extract JSON from markdown code blocks + if raw =~ /```(?:json)?\s*(\{[\s\S]*?\})\s*```/m + begin + return JSON.parse($1) + rescue JSON::ParserError + # Continue to next strategy + end + end + + # Try to find any JSON object + if raw =~ /(\{[\s\S]*\})/m + begin + return JSON.parse($1) + rescue JSON::ParserError + # Fall through to error + end + end + + raise Provider::Openai::Error, "Could not parse JSON from PDF processing response: #{raw.truncate(200)}" + end +end diff --git a/app/views/import/uploads/show.html.erb b/app/views/import/uploads/show.html.erb index 7227ff352..338654578 100644 --- a/app/views/import/uploads/show.html.erb +++ b/app/views/import/uploads/show.html.erb @@ -44,7 +44,7 @@

- <%= form.file_field :csv_file, class: "hidden", "data-auto-submit-form-target": "auto", "data-file-upload-target": "input", "data-drag-and-drop-import-target": "input" %> + <%= form.file_field :import_file, class: "hidden", "data-auto-submit-form-target": "auto", "data-file-upload-target": "input", "data-drag-and-drop-import-target": "input" %>
diff --git a/app/views/imports/_pdf_import.html.erb b/app/views/imports/_pdf_import.html.erb new file mode 100644 index 000000000..f2b1ea969 --- /dev/null +++ b/app/views/imports/_pdf_import.html.erb @@ -0,0 +1,84 @@ +<%# locals: (import:) %> + +
+
+ <% if import.importing? || import.pending? %> +
+ <%= icon "loader", class: "animate-pulse" %> +
+ +
+

<%= t("imports.pdf_import.processing_title") %>

+

<%= t("imports.pdf_import.processing_description") %>

+
+ +
+ <%= render DS::Link.new(text: t("imports.pdf_import.check_status"), href: import_path(import), variant: "primary", full_width: true) %> + <%= render DS::Link.new(text: t("imports.pdf_import.back_to_dashboard"), href: root_path, variant: "secondary", full_width: true) %> +
+ + <% elsif import.failed? %> +
+ <%= icon "x", class: "text-destructive" %> +
+ +
+

<%= t("imports.pdf_import.failed_title") %>

+

<%= t("imports.pdf_import.failed_description") %>

+ <% if import.error.present? %> +

<%= import.error %>

+ <% end %> +
+ +
+ <%= render DS::Link.new(text: t("imports.pdf_import.try_again"), href: new_import_path, variant: "primary", full_width: true) %> + <%= button_to t("imports.pdf_import.delete_import"), import_path(import), method: :delete, class: "btn btn--secondary w-full" %> +
+ + <% elsif import.complete? && import.ai_processed? %> +
+ <%= icon "check", class: "text-success" %> +
+ +
+

<%= t("imports.pdf_import.complete_title") %>

+

<%= t("imports.pdf_import.complete_description") %>

+
+ +
+
+

<%= t("imports.pdf_import.document_type_label") %>

+

+ <%= t("imports.document_types.#{import.document_type}") %> +

+
+ +
+

<%= t("imports.pdf_import.summary_label") %>

+

+ <%= import.ai_summary %> +

+
+
+ +
+

<%= t("imports.pdf_import.email_sent_notice") %>

+
+ +
+ <%= render DS::Link.new(text: t("imports.pdf_import.back_to_imports"), href: imports_path, variant: "primary", full_width: true) %> + <%= button_to t("imports.pdf_import.delete_import"), import_path(import), method: :delete, class: "btn btn--secondary w-full" %> +
+ + <% else %> +
+

<%= t("imports.pdf_import.unknown_state_title") %>

+

<%= t("imports.pdf_import.unknown_state_description") %>

+
+ +
+ <%= render DS::Link.new(text: t("imports.pdf_import.back_to_imports"), href: imports_path, variant: "primary", full_width: true) %> +
+ <% end %> +
+
diff --git a/app/views/imports/new.html.erb b/app/views/imports/new.html.erb index d6910c0ff..91fc9f5ac 100644 --- a/app/views/imports/new.html.erb +++ b/app/views/imports/new.html.erb @@ -140,6 +140,35 @@ <%= render "shared/ruler" %> <% end %> + + <% if (params[:type].nil? || params[:type] == "PdfImport") && Provider::Registry.get_provider(:openai)&.supports_pdf_processing? %> +
  • + <%= styled_form_with url: imports_path, scope: :import, multipart: true, class: "w-full" do |form| %> + <%= form.hidden_field :type, value: "PdfImport" %> + + <% end %> + + <%= render "shared/ruler" %> +
  • + <% end %>

    <% end %> diff --git a/app/views/imports/show.html.erb b/app/views/imports/show.html.erb index ffa552975..d4863585b 100644 --- a/app/views/imports/show.html.erb +++ b/app/views/imports/show.html.erb @@ -2,9 +2,11 @@ <%= render "imports/nav", import: @import %> <% end %> -<%= content_for :previous_path, import_confirm_path(@import) %> +<%= content_for :previous_path, @import.is_a?(PdfImport) ? imports_path : import_confirm_path(@import) %> -<% if @import.importing? %> +<% if @import.is_a?(PdfImport) %> + <%= render "imports/pdf_import", import: @import %> +<% elsif @import.importing? %> <%= render "imports/importing", import: @import %> <% elsif @import.complete? %> <%= render "imports/success", import: @import %> diff --git a/app/views/pdf_import_mailer/next_steps.html.erb b/app/views/pdf_import_mailer/next_steps.html.erb new file mode 100644 index 000000000..595cbcb59 --- /dev/null +++ b/app/views/pdf_import_mailer/next_steps.html.erb @@ -0,0 +1,30 @@ +

    <%= t(".greeting", name: @user.display_name) %>

    + +

    <%= t(".intro", product: product_name) %>

    + +

    <%= t(".document_type_label") %>

    +

    <%= @pdf_import.document_type.present? ? t("imports.document_types.#{@pdf_import.document_type}") : t("imports.document_types.other") %>

    + +

    <%= t(".summary_label") %>

    +

    <%= @pdf_import.ai_summary %>

    + +<% if @pdf_import.document_type.in?(%w[bank_statement credit_card_statement investment_statement]) %> +

    <%= t(".transactions_note") %>

    +<% else %> +

    <%= t(".document_stored_note") %>

    +<% end %> + +

    <%= t(".next_steps_label") %>

    +

    <%= t(".next_steps_intro") %>

    + +
      + <% if @pdf_import.document_type.in?(%w[bank_statement credit_card_statement investment_statement]) %> +
    • <%= t(".option_extract_transactions") %>
    • + <% end %> +
    • <%= t(".option_keep_reference") %>
    • +
    • <%= t(".option_delete") %>
    • +
    + +<%= link_to t(".view_import_button"), @import_url, class: "button" %> + + diff --git a/app/views/pdf_import_mailer/next_steps.text.erb b/app/views/pdf_import_mailer/next_steps.text.erb new file mode 100644 index 000000000..add337d78 --- /dev/null +++ b/app/views/pdf_import_mailer/next_steps.text.erb @@ -0,0 +1,28 @@ +<%= t(".greeting", name: @user.display_name) %> + +<%= t(".intro", product: product_name) %> + +<%= t(".document_type_label") %> +<%= @pdf_import.document_type ? t("imports.document_types.#{@pdf_import.document_type}") : t("imports.document_types.unknown") %> + +<%= t(".summary_label") %> +<%= @pdf_import.ai_summary %> + +<% if @pdf_import.document_type.in?(%w[bank_statement credit_card_statement investment_statement]) %> +<%= t(".transactions_note") %> +<% else %> +<%= t(".document_stored_note") %> +<% end %> + +<%= t(".next_steps_label") %> +<%= t(".next_steps_intro") %> + +<% if @pdf_import.document_type.in?(%w[bank_statement credit_card_statement investment_statement]) %> +- <%= t(".option_extract_transactions") %> +<% end %> +- <%= t(".option_keep_reference") %> +- <%= t(".option_delete") %> + +<%= t(".view_import_button") %>: <%= @import_url %> + +<%= t(".footer_note") %> diff --git a/app/views/transactions/_list.html.erb b/app/views/transactions/_list.html.erb index 1b0589e2a..b3f761065 100644 --- a/app/views/transactions/_list.html.erb +++ b/app/views/transactions/_list.html.erb @@ -7,7 +7,7 @@ <%= 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" %> + <%= f.file_field "import[import_file]", class: "hidden", data: { drag_and_drop_import_target: "input" }, accept: ".csv" %> <% end %> <%= render "imports/drag_drop_overlay", title: t(".drag_drop_title"), subtitle: t(".drag_drop_subtitle") %> diff --git a/config/locales/mailers/pdf_import_mailer/en.yml b/config/locales/mailers/pdf_import_mailer/en.yml new file mode 100644 index 000000000..1399d306b --- /dev/null +++ b/config/locales/mailers/pdf_import_mailer/en.yml @@ -0,0 +1,5 @@ +--- +en: + pdf_import_mailer: + next_steps: + subject: "Your PDF document has been analyzed - %{product}" diff --git a/config/locales/views/imports/en.yml b/config/locales/views/imports/en.yml index cd8fb8bd6..a40f3cbf0 100644 --- a/config/locales/views/imports/en.yml +++ b/config/locales/views/imports/en.yml @@ -102,12 +102,51 @@ en: import_portfolio: Import investments import_rules: Import rules import_transactions: Import transactions + import_pdf: Import PDF document + import_pdf_description: AI-powered document analysis resume: Resume %{type} sources: Sources title: New CSV Import + create: + file_too_large: File is too large. Maximum size is %{max_size}MB. + invalid_file_type: Invalid file type. Please upload a CSV file. + csv_uploaded: CSV uploaded successfully. + pdf_too_large: PDF file is too large. Maximum size is %{max_size}MB. + pdf_processing: Your PDF is being processed. You will receive an email when analysis is complete. + invalid_pdf: The uploaded file is not a valid PDF. + show: + finalize_upload: Please finalize your file upload. + finalize_mappings: Please finalize your mappings before proceeding. ready: description: Here's a summary of the new items that will be added to your account once you publish this import. title: Confirm your import data errors: custom_column_requires_inflow: "Custom column imports require an inflow column to be selected" + document_types: + bank_statement: Bank Statement + credit_card_statement: Credit Card Statement + investment_statement: Investment Statement + financial_document: Financial Document + contract: Contract + other: Other Document + unknown: Unknown Document + pdf_import: + processing_title: Processing your PDF + processing_description: We're analyzing your document using AI. This may take a moment. You'll receive an email when the analysis is complete. + check_status: Check status + back_to_dashboard: Back to dashboard + failed_title: Processing failed + failed_description: We were unable to process your PDF document. Please try again or contact support. + try_again: Try again + delete_import: Delete import + complete_title: Document analyzed + complete_description: We've analyzed your PDF and here's what we found. + document_type_label: Document Type + summary_label: Summary + email_sent_notice: An email has been sent to you with next steps. + back_to_imports: Back to imports + unknown_state_title: Unknown state + unknown_state_description: This import is in an unexpected state. Please return to imports. + processing_failed_with_message: "%{message}" + processing_failed_generic: "Processing failed: %{error}" diff --git a/config/locales/views/pdf_import_mailer/en.yml b/config/locales/views/pdf_import_mailer/en.yml new file mode 100644 index 000000000..5298e9e32 --- /dev/null +++ b/config/locales/views/pdf_import_mailer/en.yml @@ -0,0 +1,17 @@ +--- +en: + pdf_import_mailer: + next_steps: + greeting: "Hi %{name}," + intro: "We've finished analyzing the PDF document you uploaded to %{product}." + document_type_label: Document Type + summary_label: AI Summary + transactions_note: This document appears to contain transactions. You can extract and review them now. + document_stored_note: This document has been stored for your reference. It can be used to provide context in future AI conversations. + next_steps_label: What's Next? + next_steps_intro: "You have several options:" + option_extract_transactions: Extract transactions from this statement + option_keep_reference: Keep this document for reference in future AI conversations + option_delete: Delete this import if you no longer need it + view_import_button: View Import Details + footer_note: This is an automated message. Please do not reply directly to this email. diff --git a/db/migrate/20260116100000_add_pdf_import_support.rb b/db/migrate/20260116100000_add_pdf_import_support.rb new file mode 100644 index 000000000..f9d561ee9 --- /dev/null +++ b/db/migrate/20260116100000_add_pdf_import_support.rb @@ -0,0 +1,6 @@ +class AddPdfImportSupport < ActiveRecord::Migration[7.2] + def change + add_column :imports, :ai_summary, :text + add_column :imports, :document_type, :string + end +end diff --git a/db/migrate/20260129200129_add_extracted_data_to_imports.rb b/db/migrate/20260129200129_add_extracted_data_to_imports.rb new file mode 100644 index 000000000..aafea804f --- /dev/null +++ b/db/migrate/20260129200129_add_extracted_data_to_imports.rb @@ -0,0 +1,5 @@ +class AddExtractedDataToImports < ActiveRecord::Migration[7.2] + def change + add_column :imports, :extracted_data, :jsonb + end +end diff --git a/db/schema.rb b/db/schema.rb index 9b7bcb296..3277c7385 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_24_180211) do +ActiveRecord::Schema[7.2].define(version: 2026_01_29_200129) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -660,6 +660,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_24_180211) do t.integer "rows_to_skip", default: 0, null: false t.integer "rows_count", default: 0, null: false t.string "amount_type_identifier_value" + t.text "ai_summary" + t.string "document_type" + t.jsonb "extracted_data" t.index ["family_id"], name: "index_imports_on_family_id" end diff --git a/docs/hosting/ai.md b/docs/hosting/ai.md index 23ab1c12f..1106361a1 100644 --- a/docs/hosting/ai.md +++ b/docs/hosting/ai.md @@ -91,6 +91,9 @@ Sure supports any OpenAI-compatible API endpoint. Here are tested providers: ```bash OPENAI_ACCESS_TOKEN=sk-proj-... # No other configuration needed + +# Optional: Request timeout in seconds (default: 60) +# OPENAI_REQUEST_TIMEOUT=60 ``` **Recommended models:** diff --git a/test/controllers/import/uploads_controller_test.rb b/test/controllers/import/uploads_controller_test.rb index 647815d45..6aa3be8b6 100644 --- a/test/controllers/import/uploads_controller_test.rb +++ b/test/controllers/import/uploads_controller_test.rb @@ -26,7 +26,7 @@ class Import::UploadsControllerTest < ActionDispatch::IntegrationTest test "uploads valid csv by file" do patch import_upload_url(@import), params: { import: { - csv_file: file_fixture_upload("imports/valid.csv"), + import_file: file_fixture_upload("imports/valid.csv"), col_sep: "," } } @@ -38,7 +38,7 @@ class Import::UploadsControllerTest < ActionDispatch::IntegrationTest test "invalid csv cannot be uploaded" do patch import_upload_url(@import), params: { import: { - csv_file: file_fixture_upload("imports/invalid.csv"), + import_file: file_fixture_upload("imports/invalid.csv"), col_sep: "," } } diff --git a/test/fixtures/files/imports/sample_bank_statement.pdf b/test/fixtures/files/imports/sample_bank_statement.pdf new file mode 100644 index 0000000000000000000000000000000000000000..377c27b4e2ca394bacc7e72f5f14c1d5cda35506 GIT binary patch literal 52633 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

    a67%)A2k;A?47uojK|4^P9GjoOkn{Gt*p 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 literal 0 HcmV?d00001 diff --git a/test/fixtures/imports.yml b/test/fixtures/imports.yml index b01725327..3e9e185a9 100644 --- a/test/fixtures/imports.yml +++ b/test/fixtures/imports.yml @@ -12,3 +12,15 @@ account: family: dylan_family type: AccountImport status: pending + +pdf: + family: dylan_family + type: PdfImport + status: pending + +pdf_processed: + family: dylan_family + type: PdfImport + status: complete + ai_summary: "This is a bank statement from Chase Bank for the period January 1-31, 2024. It shows 15 transactions with an opening balance of $5,000 and closing balance of $4,500." + document_type: bank_statement diff --git a/test/jobs/process_pdf_job_test.rb b/test/jobs/process_pdf_job_test.rb new file mode 100644 index 000000000..c6374de23 --- /dev/null +++ b/test/jobs/process_pdf_job_test.rb @@ -0,0 +1,35 @@ +require "test_helper" + +class ProcessPdfJobTest < ActiveJob::TestCase + include ActionMailer::TestHelper + + setup do + @import = imports(:pdf) + @family = @import.family + end + + test "skips non-PdfImport imports" do + transaction_import = imports(:transaction) + + ProcessPdfJob.perform_now(transaction_import) + + assert_equal "pending", transaction_import.reload.status + end + + test "skips if PDF not uploaded" do + assert_not @import.pdf_uploaded? + + ProcessPdfJob.perform_now(@import) + + assert_equal "pending", @import.reload.status + end + + test "skips if already processed" do + processed_import = imports(:pdf_processed) + + ProcessPdfJob.perform_now(processed_import) + + # Should not change status since already complete + assert_equal "complete", processed_import.reload.status + end +end diff --git a/test/mailers/pdf_import_mailer_test.rb b/test/mailers/pdf_import_mailer_test.rb new file mode 100644 index 000000000..d5d118b27 --- /dev/null +++ b/test/mailers/pdf_import_mailer_test.rb @@ -0,0 +1,21 @@ +require "test_helper" + +class PdfImportMailerTest < ActionMailer::TestCase + setup do + @user = users(:family_admin) + @pdf_import = imports(:pdf_processed) + end + + test "next_steps email is sent to user" do + mail = PdfImportMailer.with(user: @user, pdf_import: @pdf_import).next_steps + + assert_equal [ @user.email ], mail.to + assert_includes mail.subject, "analyzed" + end + + test "next_steps email contains document summary" do + mail = PdfImportMailer.with(user: @user, pdf_import: @pdf_import).next_steps + + assert_match @pdf_import.ai_summary, mail.body.encoded + end +end diff --git a/test/models/pdf_import_test.rb b/test/models/pdf_import_test.rb new file mode 100644 index 000000000..3138c884b --- /dev/null +++ b/test/models/pdf_import_test.rb @@ -0,0 +1,69 @@ +require "test_helper" + +class PdfImportTest < ActiveSupport::TestCase + include ActiveJob::TestHelper + + setup do + @import = imports(:pdf) + @processed_import = imports(:pdf_processed) + end + + test "pdf_uploaded? returns false when no file attached" do + assert_not @import.pdf_uploaded? + end + + test "ai_processed? returns false when no summary present" do + assert_not @import.ai_processed? + end + + test "ai_processed? returns true when summary present" do + assert @processed_import.ai_processed? + end + + test "uploaded? delegates to pdf_uploaded?" do + assert_not @import.uploaded? + end + + test "configured? returns true when AI processed" do + assert @processed_import.configured? + assert_not @import.configured? + end + + test "cleaned? returns true when AI processed" do + assert @processed_import.cleaned? + assert_not @import.cleaned? + end + + test "publishable? always returns false for PDF imports" do + assert_not @import.publishable? + assert_not @processed_import.publishable? + end + + test "column_keys returns empty array" do + assert_equal [], @import.column_keys + end + + test "required_column_keys returns empty array" do + assert_equal [], @import.required_column_keys + end + + test "document_type validates against allowed types" do + @import.document_type = "bank_statement" + assert @import.valid? + + @import.document_type = "invalid_type" + assert_not @import.valid? + assert @import.errors[:document_type].present? + end + + test "document_type allows nil" do + @import.document_type = nil + assert @import.valid? + end + + test "process_with_ai_later enqueues ProcessPdfJob" do + assert_enqueued_with job: ProcessPdfJob, args: [ @import ] do + @import.process_with_ai_later + end + end +end diff --git a/test/system/drag_and_drop_import_test.rb b/test/system/drag_and_drop_import_test.rb index 6a2b94e9b..6a44a3d5b 100644 --- a/test/system/drag_and_drop_import_test.rb +++ b/test/system/drag_and_drop_import_test.rb @@ -20,12 +20,12 @@ class DragAndDropImportTest < ApplicationSystemTestCase execute_script(" var form = document.querySelector('form[action=\"#{imports_path}\"]'); form.classList.remove('hidden'); - var input = document.querySelector('input[name=\"import[csv_file]\"]'); + var input = document.querySelector('input[name=\"import[import_file]\"]'); input.classList.remove('hidden'); input.style.display = 'block'; ") - attach_file "import[csv_file]", file_path + attach_file "import[import_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()") From 5d06112281433b54b83cdb53f0cf4522f6e311a0 Mon Sep 17 00:00:00 2001 From: Dream <42954461+eureka928@users.noreply.github.com> Date: Sat, 31 Jan 2026 03:41:28 -0500 Subject: [PATCH 026/108] Fix budget category totals to account for refunds and reimbursements (#824) * Fix budget category totals to net refunds against expenses Budget spending calculations now subtract refunds (negative transactions classified as income) from expense totals in the same category. Previously, refunds were excluded entirely, causing budgets to show gross spending instead of net spending. Fixes #314 * Handle missing git binary in commit_sha initializer Rescues Errno::ENOENT when git is not installed, falling back to BUILD_COMMIT_SHA env var or "unknown". Fixes crash in Docker development containers that lack git. * Revert "Handle missing git binary in commit_sha initializer" This reverts commit 7e58458faae2c93e79cc4a6b76406835f97ba149. * Subtract uncategorized refunds from overall budget spending Uncategorized refunds were not being netted against actual_spending because the synthetic uncategorized category has no persisted ID and wasn't matched by the budget_categories ID set. Now checks for category.uncategorized? in addition to the ID lookup. * perf: optimize budget category actual spending calculation --- app/models/budget.rb | 23 ++++++- test/models/budget_test.rb | 124 +++++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 2 deletions(-) diff --git a/app/models/budget.rb b/app/models/budget.rb index acdd128d1..33f34eb2f 100644 --- a/app/models/budget.rb +++ b/app/models/budget.rb @@ -155,11 +155,14 @@ class Budget < ApplicationRecord end def actual_spending - expense_totals.total + [ expense_totals.total - refunds_in_expense_categories, 0 ].max end def budget_category_actual_spending(budget_category) - expense_totals.category_totals.find { |ct| ct.category.id == budget_category.category.id }&.total || 0 + cat_id = budget_category.category_id + expense = expense_totals_by_category[cat_id]&.total || 0 + refund = income_totals_by_category[cat_id]&.total || 0 + [ expense - refund, 0 ].max end def category_median_monthly_expense(category) @@ -235,6 +238,14 @@ class Budget < ApplicationRecord end private + def refunds_in_expense_categories + expense_category_ids = budget_categories.map(&:category_id).to_set + income_totals.category_totals + .reject { |ct| ct.category.subcategory? } + .select { |ct| expense_category_ids.include?(ct.category.id) || ct.category.uncategorized? } + .sum(&:total) + end + def income_statement @income_statement ||= family.income_statement end @@ -246,4 +257,12 @@ class Budget < ApplicationRecord def income_totals @income_totals ||= family.income_statement.income_totals(period: period) end + + def expense_totals_by_category + @expense_totals_by_category ||= expense_totals.category_totals.index_by { |ct| ct.category.id } + end + + def income_totals_by_category + @income_totals_by_category ||= income_totals.category_totals.index_by { |ct| ct.category.id } + end end diff --git a/test/models/budget_test.rb b/test/models/budget_test.rb index cff5cd6cc..cd3e95307 100644 --- a/test/models/budget_test.rb +++ b/test/models/budget_test.rb @@ -75,6 +75,130 @@ class BudgetTest < ActiveSupport::TestCase assert_nil budget.previous_budget_param end + test "actual_spending nets refunds against expenses in same category" do + family = families(:dylan_family) + budget = Budget.find_or_bootstrap(family, start_date: Date.current.beginning_of_month) + + healthcare = Category.create!( + name: "Healthcare #{Time.now.to_f}", + family: family, + color: "#e74c3c", + classification: "expense" + ) + + budget.sync_budget_categories + budget_category = budget.budget_categories.find_by(category: healthcare) + budget_category.update!(budgeted_spending: 200) + + account = accounts(:depository) + + # Create a $500 expense + Entry.create!( + account: account, + entryable: Transaction.create!(category: healthcare), + date: Date.current, + name: "Doctor visit", + amount: 500, + currency: "USD" + ) + + # Create a $200 refund (negative amount = income classification in the SQL) + Entry.create!( + account: account, + entryable: Transaction.create!(category: healthcare), + date: Date.current, + name: "Insurance reimbursement", + amount: -200, + currency: "USD" + ) + + # Clear memoized values + budget = Budget.find(budget.id) + budget.sync_budget_categories + + # Budget category should show net spending: $500 - $200 = $300 + assert_equal 300, budget.budget_category_actual_spending( + budget.budget_categories.find_by(category: healthcare) + ) + end + + test "budget_category_actual_spending does not go below zero" do + family = families(:dylan_family) + budget = Budget.find_or_bootstrap(family, start_date: Date.current.beginning_of_month) + + category = Category.create!( + name: "Returns Only #{Time.now.to_f}", + family: family, + color: "#3498db", + classification: "expense" + ) + + budget.sync_budget_categories + budget_category = budget.budget_categories.find_by(category: category) + budget_category.update!(budgeted_spending: 100) + + account = accounts(:depository) + + # Only a refund, no expense + Entry.create!( + account: account, + entryable: Transaction.create!(category: category), + date: Date.current, + name: "Full refund", + amount: -50, + currency: "USD" + ) + + budget = Budget.find(budget.id) + budget.sync_budget_categories + + assert_equal 0, budget.budget_category_actual_spending( + budget.budget_categories.find_by(category: category) + ) + end + + test "actual_spending subtracts uncategorized refunds" do + family = families(:dylan_family) + budget = Budget.find_or_bootstrap(family, start_date: Date.current.beginning_of_month) + account = accounts(:depository) + + # Create an uncategorized expense + Entry.create!( + account: account, + entryable: Transaction.create!(category: nil), + date: Date.current, + name: "Uncategorized purchase", + amount: 400, + currency: "USD" + ) + + # Create an uncategorized refund + Entry.create!( + account: account, + entryable: Transaction.create!(category: nil), + date: Date.current, + name: "Uncategorized refund", + amount: -150, + currency: "USD" + ) + + budget = Budget.find(budget.id) + budget.sync_budget_categories + + # The uncategorized refund should reduce overall actual_spending + # Other fixtures may contribute spending, so check that the net + # uncategorized amount (400 - 150 = 250) is reflected by comparing + # with and without the refund rather than asserting an exact total. + spending_with_refund = budget.actual_spending + + # Remove the refund and check spending increases + Entry.find_by(name: "Uncategorized refund").destroy! + budget = Budget.find(budget.id) + spending_without_refund = budget.actual_spending + + assert_equal 150, spending_without_refund - spending_with_refund + end + test "previous_budget_param returns param when date is valid" do budget = Budget.create!( family: @family, From ea80e92b6b6173e3b820f37e5c090e40614c9413 Mon Sep 17 00:00:00 2001 From: Abdallatif Sulaiman Date: Sat, 31 Jan 2026 10:45:39 +0200 Subject: [PATCH 027/108] Add Palestine to country options in preferences (#845) Co-authored-by: Abdallatif Sulaiman --- app/helpers/languages_helper.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb index 0d7b84f65..33126717c 100644 --- a/app/helpers/languages_helper.rb +++ b/app/helpers/languages_helper.rb @@ -301,6 +301,7 @@ module LanguagesHelper NO: "🇳🇴 Norway", OM: "🇴🇲 Oman", PK: "🇵🇰 Pakistan", + PS: "🇵🇸 Palestine", PW: "🇵🇼 Palau", PA: "🇵🇦 Panama", PG: "🇵🇬 Papua New Guinea", From 8a8ae5495451ae681982e89feafad615dba4d789 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Sat, 31 Jan 2026 09:50:40 +0100 Subject: [PATCH 028/108] refactor: Use `first!` for transfer lookup (#837) Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> --- app/controllers/transfers_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/transfers_controller.rb b/app/controllers/transfers_controller.rb index db4343584..248f8d4ad 100644 --- a/app/controllers/transfers_controller.rb +++ b/app/controllers/transfers_controller.rb @@ -55,7 +55,7 @@ class TransfersController < ApplicationController @transfer = Transfer .where(id: params[:id]) .where(inflow_transaction_id: Current.family.transactions.select(:id)) - .first + .first! end def transfer_params From 4f60aef6a4e1652848bc758d80a73cdcd16660a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Sat, 31 Jan 2026 10:27:24 +0100 Subject: [PATCH 029/108] chore: normalize line endings in tags_controller_test.rb (#848) Convert CRLF to LF line endings for consistency. https://claude.ai/code/session_012VFw2enx8PaDGqoHYqZ6jP Co-authored-by: Claude --- .../api/v1/tags_controller_test.rb | 524 +++++++++--------- 1 file changed, 262 insertions(+), 262 deletions(-) diff --git a/test/controllers/api/v1/tags_controller_test.rb b/test/controllers/api/v1/tags_controller_test.rb index c9d6666cd..6fcb36bbd 100644 --- a/test/controllers/api/v1/tags_controller_test.rb +++ b/test/controllers/api/v1/tags_controller_test.rb @@ -1,262 +1,262 @@ -# frozen_string_literal: true - -require "test_helper" - -class Api::V1::TagsControllerTest < ActionDispatch::IntegrationTest - setup do - @user = users(:family_admin) - @other_family_user = users(:empty) - - @oauth_app = Doorkeeper::Application.create!( - name: "Test App", - redirect_uri: "https://example.com/callback", - scopes: "read read_write" - ) - - @read_token = Doorkeeper::AccessToken.create!( - application: @oauth_app, - resource_owner_id: @user.id, - scopes: "read" - ) - - @read_write_token = Doorkeeper::AccessToken.create!( - application: @oauth_app, - resource_owner_id: @user.id, - scopes: "read_write" - ) - - @tag = @user.family.tags.create!(name: "Test Tag #{SecureRandom.hex(4)}", color: "#3b82f6") - end - - # Index action tests - test "index requires authentication" do - get api_v1_tags_url - - assert_response :unauthorized - end - - test "index returns user's family tags successfully" do - get api_v1_tags_url, headers: read_headers - - assert_response :success - - tags = JSON.parse(response.body) - assert_kind_of Array, tags - assert tags.length >= 1 - - tag = tags.first - assert tag.key?("id") - assert tag.key?("name") - assert tag.key?("color") - assert tag.key?("created_at") - assert tag.key?("updated_at") - end - - test "index does not return tags from other families" do - other_tag = @other_family_user.family.tags.create!(name: "Other Tag", color: "#3b82f6") - - get api_v1_tags_url, headers: read_headers - - assert_response :success - tags = JSON.parse(response.body) - tag_ids = tags.map { |t| t["id"] } - - assert_includes tag_ids, @tag.id - assert_not_includes tag_ids, other_tag.id - end - - # Show action tests - test "show requires authentication" do - get api_v1_tag_url(@tag) - - assert_response :unauthorized - end - - test "show returns tag successfully" do - get api_v1_tag_url(@tag), headers: read_headers - - assert_response :success - - tag = JSON.parse(response.body) - assert_equal @tag.id, tag["id"] - assert_equal @tag.name, tag["name"] - assert_equal "#3b82f6", tag["color"] - end - - test "show returns 404 for non-existent tag" do - get api_v1_tag_url(id: SecureRandom.uuid), headers: read_headers - - assert_response :not_found - end - - test "show returns 404 for tag from another family" do - other_tag = @other_family_user.family.tags.create!(name: "Other Tag", color: "#3b82f6") - - get api_v1_tag_url(other_tag), headers: read_headers - - assert_response :not_found - end - - # Create action tests - test "create requires authentication" do - post api_v1_tags_url, params: { tag: { name: "New Tag" } } - - assert_response :unauthorized - end - - test "create requires read_write scope" do - post api_v1_tags_url, - params: { tag: { name: "New Tag", color: "#4da568" } }, - headers: read_headers - - assert_response :forbidden - end - - test "create tag successfully" do - tag_name = "New Tag #{SecureRandom.hex(4)}" - - assert_difference -> { @user.family.tags.count }, 1 do - post api_v1_tags_url, - params: { tag: { name: tag_name, color: "#4da568" } }, - headers: read_write_headers - end - - assert_response :created - - tag = JSON.parse(response.body) - assert_equal tag_name, tag["name"] - assert_equal "#4da568", tag["color"] - end - - test "create tag with auto-assigned color" do - tag_name = "Auto Color Tag #{SecureRandom.hex(4)}" - - post api_v1_tags_url, - params: { tag: { name: tag_name } }, - headers: read_write_headers - - assert_response :created - - tag = JSON.parse(response.body) - assert_equal tag_name, tag["name"] - assert tag["color"].present? - end - - test "create fails with duplicate name" do - post api_v1_tags_url, - params: { tag: { name: @tag.name } }, - headers: read_write_headers - - assert_response :unprocessable_entity - end - - # Update action tests - test "update requires authentication" do - patch api_v1_tag_url(@tag), params: { tag: { name: "Updated" } } - - assert_response :unauthorized - end - - test "update requires read_write scope" do - patch api_v1_tag_url(@tag), - params: { tag: { name: "Updated" } }, - headers: read_headers - - assert_response :forbidden - end - - test "update tag successfully" do - new_name = "Updated Tag #{SecureRandom.hex(4)}" - - patch api_v1_tag_url(@tag), - params: { tag: { name: new_name, color: "#db5a54" } }, - headers: read_write_headers - - assert_response :success - - tag = JSON.parse(response.body) - assert_equal new_name, tag["name"] - assert_equal "#db5a54", tag["color"] - end - - test "update tag partially" do - original_name = @tag.name - - patch api_v1_tag_url(@tag), - params: { tag: { color: "#eb5429" } }, - headers: read_write_headers - - assert_response :success - - tag = JSON.parse(response.body) - assert_equal original_name, tag["name"] - assert_equal "#eb5429", tag["color"] - end - - test "update returns 404 for non-existent tag" do - patch api_v1_tag_url(id: SecureRandom.uuid), - params: { tag: { name: "Not Found" } }, - headers: read_write_headers - - assert_response :not_found - end - - test "update returns 404 for tag from another family" do - other_tag = @other_family_user.family.tags.create!(name: "Other Tag", color: "#3b82f6") - - patch api_v1_tag_url(other_tag), - params: { tag: { name: "Hacker Update" } }, - headers: read_write_headers - - assert_response :not_found - end - - # Destroy action tests - test "destroy requires authentication" do - delete api_v1_tag_url(@tag) - - assert_response :unauthorized - end - - test "destroy requires read_write scope" do - delete api_v1_tag_url(@tag), headers: read_headers - - assert_response :forbidden - end - - test "destroy tag successfully" do - tag_to_delete = @user.family.tags.create!(name: "Delete Me #{SecureRandom.hex(4)}", color: "#c44fe9") - - assert_difference -> { @user.family.tags.count }, -1 do - delete api_v1_tag_url(tag_to_delete), headers: read_write_headers - end - - assert_response :no_content - end - - test "destroy returns 404 for non-existent tag" do - delete api_v1_tag_url(id: SecureRandom.uuid), headers: read_write_headers - - assert_response :not_found - end - - test "destroy returns 404 for tag from another family" do - other_tag = @other_family_user.family.tags.create!(name: "Other Tag", color: "#3b82f6") - - assert_no_difference -> { @other_family_user.family.tags.count } do - delete api_v1_tag_url(other_tag), headers: read_write_headers - end - - assert_response :not_found - end - - private - - def read_headers - { "Authorization" => "Bearer #{@read_token.token}" } - end - - def read_write_headers - { "Authorization" => "Bearer #{@read_write_token.token}" } - end -end +# frozen_string_literal: true + +require "test_helper" + +class Api::V1::TagsControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:family_admin) + @other_family_user = users(:empty) + + @oauth_app = Doorkeeper::Application.create!( + name: "Test App", + redirect_uri: "https://example.com/callback", + scopes: "read read_write" + ) + + @read_token = Doorkeeper::AccessToken.create!( + application: @oauth_app, + resource_owner_id: @user.id, + scopes: "read" + ) + + @read_write_token = Doorkeeper::AccessToken.create!( + application: @oauth_app, + resource_owner_id: @user.id, + scopes: "read_write" + ) + + @tag = @user.family.tags.create!(name: "Test Tag #{SecureRandom.hex(4)}", color: "#3b82f6") + end + + # Index action tests + test "index requires authentication" do + get api_v1_tags_url + + assert_response :unauthorized + end + + test "index returns user's family tags successfully" do + get api_v1_tags_url, headers: read_headers + + assert_response :success + + tags = JSON.parse(response.body) + assert_kind_of Array, tags + assert tags.length >= 1 + + tag = tags.first + assert tag.key?("id") + assert tag.key?("name") + assert tag.key?("color") + assert tag.key?("created_at") + assert tag.key?("updated_at") + end + + test "index does not return tags from other families" do + other_tag = @other_family_user.family.tags.create!(name: "Other Tag", color: "#3b82f6") + + get api_v1_tags_url, headers: read_headers + + assert_response :success + tags = JSON.parse(response.body) + tag_ids = tags.map { |t| t["id"] } + + assert_includes tag_ids, @tag.id + assert_not_includes tag_ids, other_tag.id + end + + # Show action tests + test "show requires authentication" do + get api_v1_tag_url(@tag) + + assert_response :unauthorized + end + + test "show returns tag successfully" do + get api_v1_tag_url(@tag), headers: read_headers + + assert_response :success + + tag = JSON.parse(response.body) + assert_equal @tag.id, tag["id"] + assert_equal @tag.name, tag["name"] + assert_equal "#3b82f6", tag["color"] + end + + test "show returns 404 for non-existent tag" do + get api_v1_tag_url(id: SecureRandom.uuid), headers: read_headers + + assert_response :not_found + end + + test "show returns 404 for tag from another family" do + other_tag = @other_family_user.family.tags.create!(name: "Other Tag", color: "#3b82f6") + + get api_v1_tag_url(other_tag), headers: read_headers + + assert_response :not_found + end + + # Create action tests + test "create requires authentication" do + post api_v1_tags_url, params: { tag: { name: "New Tag" } } + + assert_response :unauthorized + end + + test "create requires read_write scope" do + post api_v1_tags_url, + params: { tag: { name: "New Tag", color: "#4da568" } }, + headers: read_headers + + assert_response :forbidden + end + + test "create tag successfully" do + tag_name = "New Tag #{SecureRandom.hex(4)}" + + assert_difference -> { @user.family.tags.count }, 1 do + post api_v1_tags_url, + params: { tag: { name: tag_name, color: "#4da568" } }, + headers: read_write_headers + end + + assert_response :created + + tag = JSON.parse(response.body) + assert_equal tag_name, tag["name"] + assert_equal "#4da568", tag["color"] + end + + test "create tag with auto-assigned color" do + tag_name = "Auto Color Tag #{SecureRandom.hex(4)}" + + post api_v1_tags_url, + params: { tag: { name: tag_name } }, + headers: read_write_headers + + assert_response :created + + tag = JSON.parse(response.body) + assert_equal tag_name, tag["name"] + assert tag["color"].present? + end + + test "create fails with duplicate name" do + post api_v1_tags_url, + params: { tag: { name: @tag.name } }, + headers: read_write_headers + + assert_response :unprocessable_entity + end + + # Update action tests + test "update requires authentication" do + patch api_v1_tag_url(@tag), params: { tag: { name: "Updated" } } + + assert_response :unauthorized + end + + test "update requires read_write scope" do + patch api_v1_tag_url(@tag), + params: { tag: { name: "Updated" } }, + headers: read_headers + + assert_response :forbidden + end + + test "update tag successfully" do + new_name = "Updated Tag #{SecureRandom.hex(4)}" + + patch api_v1_tag_url(@tag), + params: { tag: { name: new_name, color: "#db5a54" } }, + headers: read_write_headers + + assert_response :success + + tag = JSON.parse(response.body) + assert_equal new_name, tag["name"] + assert_equal "#db5a54", tag["color"] + end + + test "update tag partially" do + original_name = @tag.name + + patch api_v1_tag_url(@tag), + params: { tag: { color: "#eb5429" } }, + headers: read_write_headers + + assert_response :success + + tag = JSON.parse(response.body) + assert_equal original_name, tag["name"] + assert_equal "#eb5429", tag["color"] + end + + test "update returns 404 for non-existent tag" do + patch api_v1_tag_url(id: SecureRandom.uuid), + params: { tag: { name: "Not Found" } }, + headers: read_write_headers + + assert_response :not_found + end + + test "update returns 404 for tag from another family" do + other_tag = @other_family_user.family.tags.create!(name: "Other Tag", color: "#3b82f6") + + patch api_v1_tag_url(other_tag), + params: { tag: { name: "Hacker Update" } }, + headers: read_write_headers + + assert_response :not_found + end + + # Destroy action tests + test "destroy requires authentication" do + delete api_v1_tag_url(@tag) + + assert_response :unauthorized + end + + test "destroy requires read_write scope" do + delete api_v1_tag_url(@tag), headers: read_headers + + assert_response :forbidden + end + + test "destroy tag successfully" do + tag_to_delete = @user.family.tags.create!(name: "Delete Me #{SecureRandom.hex(4)}", color: "#c44fe9") + + assert_difference -> { @user.family.tags.count }, -1 do + delete api_v1_tag_url(tag_to_delete), headers: read_write_headers + end + + assert_response :no_content + end + + test "destroy returns 404 for non-existent tag" do + delete api_v1_tag_url(id: SecureRandom.uuid), headers: read_write_headers + + assert_response :not_found + end + + test "destroy returns 404 for tag from another family" do + other_tag = @other_family_user.family.tags.create!(name: "Other Tag", color: "#3b82f6") + + assert_no_difference -> { @other_family_user.family.tags.count } do + delete api_v1_tag_url(other_tag), headers: read_write_headers + end + + assert_response :not_found + end + + private + + def read_headers + { "Authorization" => "Bearer #{@read_token.token}" } + end + + def read_write_headers + { "Authorization" => "Bearer #{@read_write_token.token}" } + end +end From 38938fe97187b0146c7056106eabf95095eed454 Mon Sep 17 00:00:00 2001 From: Lazy Bone <89256478+dwvwdv@users.noreply.github.com> Date: Sat, 31 Jan 2026 20:25:52 +0800 Subject: [PATCH 030/108] Add API key authentication support to mobile app (#850) * feat: Add API key login option to mobile app Add a "Via API Key Login" button on the login screen that opens a dialog for entering an API key. The API key is validated by making a test request to /api/v1/accounts with the X-Api-Key header, and on success is persisted in secure storage. All HTTP services now use a centralized ApiConfig.getAuthHeaders() helper that returns the correct auth header (X-Api-Key or Bearer) based on the current auth mode. https://claude.ai/code/session_01DnyCzdMjVpSsbBZK3XbzUH * fix: Improve API key dialog context handling and controller disposal - Use outer context for SnackBar so it displays on the main screen instead of behind the dialog - Explicitly dispose TextEditingController to prevent memory leaks - Close dialog on failure before showing error SnackBar for better UX - Avoid StatefulBuilder context parameter shadowing https://claude.ai/code/session_01DnyCzdMjVpSsbBZK3XbzUH * fix: Use user-friendly error message in API key login catch block Log the technical exception details via LogService.instance.error and show a generic "Unable to connect" message to the user instead of exposing the raw exception string. https://claude.ai/code/session_01DnyCzdMjVpSsbBZK3XbzUH --------- Co-authored-by: Claude --- mobile/lib/providers/auth_provider.dart | 71 +++++++++++- mobile/lib/screens/login_screen.dart | 108 +++++++++++++++++- mobile/lib/services/accounts_service.dart | 5 +- mobile/lib/services/api_config.dart | 32 ++++++ mobile/lib/services/auth_service.dart | 70 ++++++++++++ mobile/lib/services/chat_service.dart | 29 ++--- mobile/lib/services/transactions_service.dart | 9 +- 7 files changed, 285 insertions(+), 39 deletions(-) diff --git a/mobile/lib/providers/auth_provider.dart b/mobile/lib/providers/auth_provider.dart index 8e58589e0..bfc24670f 100644 --- a/mobile/lib/providers/auth_provider.dart +++ b/mobile/lib/providers/auth_provider.dart @@ -3,6 +3,7 @@ import '../models/user.dart'; import '../models/auth_tokens.dart'; import '../services/auth_service.dart'; import '../services/device_service.dart'; +import '../services/api_config.dart'; import '../services/log_service.dart'; class AuthProvider with ChangeNotifier { @@ -11,6 +12,8 @@ class AuthProvider with ChangeNotifier { User? _user; AuthTokens? _tokens; + String? _apiKey; + bool _isApiKeyAuth = false; bool _isLoading = true; bool _isInitializing = true; // Track initial auth check separately String? _errorMessage; @@ -21,7 +24,10 @@ class AuthProvider with ChangeNotifier { AuthTokens? get tokens => _tokens; bool get isLoading => _isLoading; bool get isInitializing => _isInitializing; // Expose initialization state - bool get isAuthenticated => _tokens != null && !_tokens!.isExpired; + bool get isApiKeyAuth => _isApiKeyAuth; + bool get isAuthenticated => + (_isApiKeyAuth && _apiKey != null) || + (_tokens != null && !_tokens!.isExpired); String? get errorMessage => _errorMessage; bool get mfaRequired => _mfaRequired; bool get showMfaInput => _showMfaInput; // Expose MFA input state @@ -36,16 +42,28 @@ class AuthProvider with ChangeNotifier { notifyListeners(); try { - _tokens = await _authService.getStoredTokens(); - _user = await _authService.getStoredUser(); + final authMode = await _authService.getStoredAuthMode(); - // If tokens exist but are expired, try to refresh - if (_tokens != null && _tokens!.isExpired) { - await _refreshToken(); + if (authMode == 'api_key') { + _apiKey = await _authService.getStoredApiKey(); + if (_apiKey != null) { + _isApiKeyAuth = true; + ApiConfig.setApiKeyAuth(_apiKey!); + } + } else { + _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; + _apiKey = null; + _isApiKeyAuth = false; } _isLoading = false; @@ -121,6 +139,40 @@ class AuthProvider with ChangeNotifier { } } + Future loginWithApiKey({ + required String apiKey, + }) async { + _errorMessage = null; + _isLoading = true; + notifyListeners(); + + try { + final result = await _authService.loginWithApiKey(apiKey: apiKey); + + LogService.instance.debug('AuthProvider', 'API key login result: $result'); + + if (result['success'] == true) { + _apiKey = apiKey; + _isApiKeyAuth = true; + ApiConfig.setApiKeyAuth(apiKey); + _isLoading = false; + notifyListeners(); + return true; + } else { + _errorMessage = result['error'] as String?; + _isLoading = false; + notifyListeners(); + return false; + } + } catch (e, stackTrace) { + LogService.instance.error('AuthProvider', 'API key login error: $e\n$stackTrace'); + _errorMessage = 'Unable to connect. Please check your network and try again.'; + _isLoading = false; + notifyListeners(); + return false; + } + } + Future signup({ required String email, required String password, @@ -167,8 +219,11 @@ class AuthProvider with ChangeNotifier { await _authService.logout(); _tokens = null; _user = null; + _apiKey = null; + _isApiKeyAuth = false; _errorMessage = null; _mfaRequired = false; + ApiConfig.clearApiKeyAuth(); notifyListeners(); } @@ -197,6 +252,10 @@ class AuthProvider with ChangeNotifier { } Future getValidAccessToken() async { + if (_isApiKeyAuth && _apiKey != null) { + return _apiKey; + } + if (_tokens == null) return null; if (_tokens!.isExpired) { diff --git a/mobile/lib/screens/login_screen.dart b/mobile/lib/screens/login_screen.dart index c524bada7..e4c8fc6de 100644 --- a/mobile/lib/screens/login_screen.dart +++ b/mobile/lib/screens/login_screen.dart @@ -27,6 +27,103 @@ class _LoginScreenState extends State { super.dispose(); } + void _showApiKeyDialog() { + final apiKeyController = TextEditingController(); + final outerContext = context; + bool isLoading = false; + + showDialog( + context: context, + builder: (dialogContext) { + return StatefulBuilder( + builder: (_, setDialogState) { + return AlertDialog( + title: const Text('API Key Login'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Enter your API key to sign in.', + style: Theme.of(outerContext).textTheme.bodyMedium?.copyWith( + color: Theme.of(outerContext).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + TextField( + controller: apiKeyController, + decoration: const InputDecoration( + labelText: 'API Key', + prefixIcon: Icon(Icons.vpn_key_outlined), + ), + obscureText: true, + maxLines: 1, + enabled: !isLoading, + ), + ], + ), + actions: [ + TextButton( + onPressed: isLoading + ? null + : () { + apiKeyController.dispose(); + Navigator.of(dialogContext).pop(); + }, + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: isLoading + ? null + : () async { + final apiKey = apiKeyController.text.trim(); + if (apiKey.isEmpty) return; + + setDialogState(() { + isLoading = true; + }); + + final authProvider = Provider.of( + outerContext, + listen: false, + ); + final success = await authProvider.loginWithApiKey( + apiKey: apiKey, + ); + + if (!dialogContext.mounted) return; + + final errorMsg = authProvider.errorMessage; + apiKeyController.dispose(); + Navigator.of(dialogContext).pop(); + + if (!success && mounted) { + ScaffoldMessenger.of(outerContext).showSnackBar( + SnackBar( + content: Text( + errorMsg ?? 'Invalid API key', + ), + backgroundColor: + Theme.of(outerContext).colorScheme.error, + ), + ); + } + }, + child: isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Sign In'), + ), + ], + ); + }, + ); + }, + ); + } + Future _handleLogin() async { if (!_formKey.currentState!.validate()) return; @@ -263,7 +360,16 @@ class _LoginScreenState extends State { }, ), - const SizedBox(height: 24), + const SizedBox(height: 12), + + // API Key Login Button + TextButton.icon( + onPressed: _showApiKeyDialog, + icon: const Icon(Icons.vpn_key_outlined, size: 18), + label: const Text('Via API Key Login'), + ), + + const SizedBox(height: 16), // Backend URL info Container( diff --git a/mobile/lib/services/accounts_service.dart b/mobile/lib/services/accounts_service.dart index 1f5af6e82..a98f31d28 100644 --- a/mobile/lib/services/accounts_service.dart +++ b/mobile/lib/services/accounts_service.dart @@ -16,10 +16,7 @@ class AccountsService { final response = await http.get( url, - headers: { - 'Authorization': 'Bearer $accessToken', - 'Accept': 'application/json', - }, + headers: ApiConfig.getAuthHeaders(accessToken), ).timeout(const Duration(seconds: 30)); if (response.statusCode == 200) { diff --git a/mobile/lib/services/api_config.dart b/mobile/lib/services/api_config.dart index 09e4db7f8..a51c50219 100644 --- a/mobile/lib/services/api_config.dart +++ b/mobile/lib/services/api_config.dart @@ -13,6 +13,38 @@ class ApiConfig { _baseUrl = url; } + // API key authentication mode + static bool _isApiKeyAuth = false; + static String? _apiKeyValue; + + static bool get isApiKeyAuth => _isApiKeyAuth; + + static void setApiKeyAuth(String apiKey) { + _isApiKeyAuth = true; + _apiKeyValue = apiKey; + } + + static void clearApiKeyAuth() { + _isApiKeyAuth = false; + _apiKeyValue = null; + } + + /// Returns the correct auth headers based on the current auth mode. + /// In API key mode, uses X-Api-Key header. + /// In token mode, uses Authorization: Bearer header. + static Map getAuthHeaders(String token) { + if (_isApiKeyAuth && _apiKeyValue != null) { + return { + 'X-Api-Key': _apiKeyValue!, + 'Accept': 'application/json', + }; + } + return { + 'Authorization': 'Bearer $token', + 'Accept': 'application/json', + }; + } + /// 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 { diff --git a/mobile/lib/services/auth_service.dart b/mobile/lib/services/auth_service.dart index 789030abe..5c28ff28d 100644 --- a/mobile/lib/services/auth_service.dart +++ b/mobile/lib/services/auth_service.dart @@ -12,6 +12,8 @@ class AuthService { final FlutterSecureStorage _storage = const FlutterSecureStorage(); static const String _tokenKey = 'auth_tokens'; static const String _userKey = 'user_data'; + static const String _apiKeyKey = 'api_key'; + static const String _authModeKey = 'auth_mode'; Future> login({ required String email, @@ -286,9 +288,64 @@ class AuthService { } } + Future> loginWithApiKey({ + required String apiKey, + }) async { + try { + final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/accounts'); + + final response = await http.get( + url, + headers: { + 'X-Api-Key': apiKey, + 'Accept': 'application/json', + }, + ).timeout(const Duration(seconds: 30)); + + LogService.instance.debug('AuthService', 'API key login response status: ${response.statusCode}'); + + if (response.statusCode == 200) { + await _saveApiKey(apiKey); + return { + 'success': true, + }; + } else if (response.statusCode == 401) { + return { + 'success': false, + 'error': 'Invalid API key', + }; + } else { + return { + 'success': false, + 'error': 'Login failed (status ${response.statusCode})', + }; + } + } on SocketException catch (e, stackTrace) { + LogService.instance.error('AuthService', 'API key login SocketException: $e\n$stackTrace'); + return { + 'success': false, + 'error': 'Network unavailable', + }; + } on TimeoutException catch (e, stackTrace) { + LogService.instance.error('AuthService', 'API key login TimeoutException: $e\n$stackTrace'); + return { + 'success': false, + 'error': 'Request timed out', + }; + } catch (e, stackTrace) { + LogService.instance.error('AuthService', 'API key login 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); + await _storage.delete(key: _apiKeyKey); + await _storage.delete(key: _authModeKey); } Future getStoredTokens() async { @@ -331,4 +388,17 @@ class AuthService { }), ); } + + Future _saveApiKey(String apiKey) async { + await _storage.write(key: _apiKeyKey, value: apiKey); + await _storage.write(key: _authModeKey, value: 'api_key'); + } + + Future getStoredApiKey() async { + return await _storage.read(key: _apiKeyKey); + } + + Future getStoredAuthMode() async { + return await _storage.read(key: _authModeKey); + } } diff --git a/mobile/lib/services/chat_service.dart b/mobile/lib/services/chat_service.dart index bb2366c15..080378c51 100644 --- a/mobile/lib/services/chat_service.dart +++ b/mobile/lib/services/chat_service.dart @@ -18,10 +18,7 @@ class ChatService { final response = await http.get( url, - headers: { - 'Authorization': 'Bearer $accessToken', - 'Accept': 'application/json', - }, + headers: ApiConfig.getAuthHeaders(accessToken), ).timeout(const Duration(seconds: 30)); if (response.statusCode == 200) { @@ -78,10 +75,7 @@ class ChatService { final response = await http.get( url, - headers: { - 'Authorization': 'Bearer $accessToken', - 'Accept': 'application/json', - }, + headers: ApiConfig.getAuthHeaders(accessToken), ).timeout(const Duration(seconds: 30)); if (response.statusCode == 200) { @@ -144,8 +138,7 @@ class ChatService { final response = await http.post( url, headers: { - 'Authorization': 'Bearer $accessToken', - 'Accept': 'application/json', + ...ApiConfig.getAuthHeaders(accessToken), 'Content-Type': 'application/json', }, body: jsonEncode(body), @@ -199,8 +192,7 @@ class ChatService { final response = await http.post( url, headers: { - 'Authorization': 'Bearer $accessToken', - 'Accept': 'application/json', + ...ApiConfig.getAuthHeaders(accessToken), 'Content-Type': 'application/json', }, body: jsonEncode({ @@ -255,8 +247,7 @@ class ChatService { final response = await http.patch( url, headers: { - 'Authorization': 'Bearer $accessToken', - 'Accept': 'application/json', + ...ApiConfig.getAuthHeaders(accessToken), 'Content-Type': 'application/json', }, body: jsonEncode({ @@ -309,10 +300,7 @@ class ChatService { final response = await http.delete( url, - headers: { - 'Authorization': 'Bearer $accessToken', - 'Accept': 'application/json', - }, + headers: ApiConfig.getAuthHeaders(accessToken), ).timeout(const Duration(seconds: 30)); if (response.statusCode == 204) { @@ -356,10 +344,7 @@ class ChatService { final response = await http.post( url, - headers: { - 'Authorization': 'Bearer $accessToken', - 'Accept': 'application/json', - }, + headers: ApiConfig.getAuthHeaders(accessToken), ).timeout(const Duration(seconds: 30)); if (response.statusCode == 202) { diff --git a/mobile/lib/services/transactions_service.dart b/mobile/lib/services/transactions_service.dart index e22c6d964..c36d86ac2 100644 --- a/mobile/lib/services/transactions_service.dart +++ b/mobile/lib/services/transactions_service.dart @@ -32,9 +32,8 @@ class TransactionsService { final response = await http.post( url, headers: { + ...ApiConfig.getAuthHeaders(accessToken), 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'Authorization': 'Bearer $accessToken', }, body: jsonEncode(body), ).timeout(const Duration(seconds: 30)); @@ -99,9 +98,8 @@ class TransactionsService { final response = await http.get( url, headers: { + ...ApiConfig.getAuthHeaders(accessToken), 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'Authorization': 'Bearer $accessToken', }, ).timeout(const Duration(seconds: 30)); @@ -162,9 +160,8 @@ class TransactionsService { final response = await http.delete( url, headers: { + ...ApiConfig.getAuthHeaders(accessToken), 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'Authorization': 'Bearer $accessToken', }, ).timeout(const Duration(seconds: 30)); From a0c26990e5e932f420c8703c802a8776de369bd4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 31 Jan 2026 15:19:24 +0000 Subject: [PATCH 031/108] Bump version to next iteration after v0.6.7-rc.2 release --- charts/sure/Chart.yaml | 4 ++-- config/initializers/version.rb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/charts/sure/Chart.yaml b/charts/sure/Chart.yaml index 443186098..95bab97ac 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: 0.6.8-alpha.2 -appVersion: "0.6.8-alpha.2" +version: 0.6.8-alpha.3 +appVersion: "0.6.8-alpha.3" kubeVersion: ">=1.25.0-0" diff --git a/config/initializers/version.rb b/config/initializers/version.rb index 63637835d..6c2500749 100644 --- a/config/initializers/version.rb +++ b/config/initializers/version.rb @@ -16,7 +16,7 @@ module Sure private def semver - "0.6.8-alpha.2" + "0.6.8-alpha.3" end end end From ea0c70de8a7e886d491701a7bfb3ddf4138605bf Mon Sep 17 00:00:00 2001 From: Alessio Cappa <104093777+alessiocappa@users.noreply.github.com> Date: Sat, 31 Jan 2026 18:22:54 +0100 Subject: [PATCH 032/108] fix: Fix layout UI issues for rule creation (#852) * fix: Fix layout UI issues for rule creation * Update app/views/rule/conditions/_condition.html.erb Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Alessio Cappa <104093777+alessiocappa@users.noreply.github.com> * Update app/views/rule/actions/_action.html.erb Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Alessio Cappa <104093777+alessiocappa@users.noreply.github.com> --------- Signed-off-by: Alessio Cappa <104093777+alessiocappa@users.noreply.github.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- app/views/rule/actions/_action.html.erb | 2 +- app/views/rule/conditions/_condition.html.erb | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/views/rule/actions/_action.html.erb b/app/views/rule/actions/_action.html.erb index 56fee76d3..2923c9263 100644 --- a/app/views/rule/actions/_action.html.erb +++ b/app/views/rule/actions/_action.html.erb @@ -6,7 +6,7 @@

  • <%= form.hidden_field :_destroy, value: false, data: { rule__actions_target: "destroyField" } %> -
    +
    <%= form.select :action_type, rule.action_executors.map { |executor| [ executor.label, executor.key ] }, {}, data: { action: "rule--actions#handleActionTypeChange" } %>
    diff --git a/app/views/rule/conditions/_condition.html.erb b/app/views/rule/conditions/_condition.html.erb index 84eff713d..e940b406b 100644 --- a/app/views/rule/conditions/_condition.html.erb +++ b/app/views/rule/conditions/_condition.html.erb @@ -13,16 +13,16 @@
    <% end %> -
    +
    <%= form.hidden_field :_destroy, value: false, data: { rule__conditions_target: "destroyField" } %> -
    +
    <%= form.select :condition_type, rule.condition_filters.map { |filter| [ filter.label, filter.key ] }, {}, data: { action: "rule--conditions#handleConditionTypeChange" } %>
    - <%= form.select :operator, condition.operators, { container_class: "w-fit min-w-36" }, data: { rule__conditions_target: "operatorSelect", action: "rule--conditions#handleOperatorChange" } %> + <%= form.select :operator, condition.operators, { container_class: "w-full" }, data: { rule__conditions_target: "operatorSelect", action: "rule--conditions#handleOperatorChange" } %> -
    +
    <% if condition.filter.type == "select" %> <%= form.select :value, condition.options, {} %> <% else %> From 70f483c6035d3102cbd9e431280c10ecc1c21070 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Sun, 1 Feb 2026 18:59:12 +0100 Subject: [PATCH 033/108] Add tests for uncategorized transaction filter across locales (#862) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: Add tests for uncategorized filter across all locales Adds two tests to catch the bug where filtering for "Uncategorized" transactions fails when the user's locale is not English: 1. Tests that filtering with the locale-specific uncategorized name works correctly in all SUPPORTED_LOCALES 2. Tests that filtering with the English "Uncategorized" parameter works regardless of the current locale (catches the French bug) https://claude.ai/code/session_01JcKj4776k5Es8Cscbm4kUo * fix: Fix uncategorized filter for French, Catalan, and Dutch locales The uncategorized filter was failing when the URL parameter contained "Uncategorized" (English) but the user's locale was different. This affected 3 locales with non-English translations: - French: "Non catégorisé" - Catalan: "Sense categoria" - Dutch: "Ongecategoriseerd" The fix adds Category.all_uncategorized_names which returns all possible uncategorized name translations across supported locales, and updates the search filter to check against all variants instead of just the current locale's translation. https://claude.ai/code/session_01JcKj4776k5Es8Cscbm4kUo --------- Co-authored-by: Claude --- app/models/category.rb | 8 +++ app/models/transaction/search.rb | 8 +-- test/models/transaction/search_test.rb | 93 ++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 4 deletions(-) diff --git a/app/models/category.rb b/app/models/category.rb index 9a83488fc..c4239879d 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -109,6 +109,14 @@ class Category < ApplicationRecord I18n.t(UNCATEGORIZED_NAME_KEY) end + # Returns all possible uncategorized names across all supported locales + # Used to detect uncategorized filter regardless of URL parameter language + def all_uncategorized_names + LanguagesHelper::SUPPORTED_LOCALES.map do |locale| + I18n.t(UNCATEGORIZED_NAME_KEY, locale: locale) + end.uniq + end + # Helper to get the localized name for other investments def other_investments_name I18n.t(OTHER_INVESTMENTS_NAME_KEY) diff --git a/app/models/transaction/search.rb b/app/models/transaction/search.rb index 287dae467..1bee4ecb6 100644 --- a/app/models/transaction/search.rb +++ b/app/models/transaction/search.rb @@ -102,10 +102,10 @@ class Transaction::Search def apply_category_filter(query, categories) return query unless categories.present? - # Remove "Uncategorized" from category names to query the database - uncategorized_name = Category.uncategorized.name - include_uncategorized = categories.include?(uncategorized_name) - real_categories = categories - [ uncategorized_name ] + # Check for "Uncategorized" in any supported locale (handles URL params in different languages) + all_uncategorized_names = Category.all_uncategorized_names + include_uncategorized = (categories & all_uncategorized_names).any? + real_categories = categories - all_uncategorized_names # Get parent category IDs for the given category names parent_category_ids = family.categories.where(name: real_categories).pluck(:id) diff --git a/test/models/transaction/search_test.rb b/test/models/transaction/search_test.rb index 164398d5a..c933c368c 100644 --- a/test/models/transaction/search_test.rb +++ b/test/models/transaction/search_test.rb @@ -494,4 +494,97 @@ class Transaction::SearchTest < ActiveSupport::TestCase # Should not match unrelated transactions assert_not_includes result_ids, no_match.entryable.id end + + test "uncategorized filter returns same results across all supported locales" do + # Create uncategorized transactions + uncategorized1 = create_transaction( + account: @checking_account, + amount: 100, + kind: "standard" + ) + + uncategorized2 = create_transaction( + account: @checking_account, + amount: 200, + kind: "standard" + ) + + # Create a categorized transaction to ensure filter is working + categorized = create_transaction( + account: @checking_account, + amount: 300, + category: categories(:food_and_drink), + kind: "standard" + ) + + # Get the expected count using English locale (known working case) + I18n.with_locale(:en) do + english_uncategorized_name = Category.uncategorized.name + english_results = Transaction::Search.new(@family, filters: { categories: [ english_uncategorized_name ] }).transactions_scope + @expected_count = english_results.count + assert_equal 2, @expected_count, "English locale should return 2 uncategorized transactions" + end + + # Test every supported locale returns the same count when filtering by that locale's uncategorized name + LanguagesHelper::SUPPORTED_LOCALES.each do |locale| + I18n.with_locale(locale) do + localized_uncategorized_name = Category.uncategorized.name + results = Transaction::Search.new(@family, filters: { categories: [ localized_uncategorized_name ] }).transactions_scope + result_count = results.count + + assert_equal @expected_count, result_count, + "Locale '#{locale}' with uncategorized name '#{localized_uncategorized_name}' should return #{@expected_count} transactions but got #{result_count}" + end + end + end + + test "uncategorized filter works with English parameter name regardless of current locale" do + # This tests the bug where URL contains English "Uncategorized" but user's locale is different + # Bug: /transactions/?q[categories][]=Uncategorized fails when locale is French + + # Create uncategorized transactions + uncategorized1 = create_transaction( + account: @checking_account, + amount: 100, + kind: "standard" + ) + + uncategorized2 = create_transaction( + account: @checking_account, + amount: 200, + kind: "standard" + ) + + # Create a categorized transaction to ensure filter is working + categorized = create_transaction( + account: @checking_account, + amount: 300, + category: categories(:food_and_drink), + kind: "standard" + ) + + # Get the English uncategorized name (this is what URLs typically contain) + english_uncategorized_name = I18n.t("models.category.uncategorized", locale: :en) + + # Get the expected count using English locale (known working case) + expected_count = nil + I18n.with_locale(:en) do + results = Transaction::Search.new(@family, filters: { categories: [ english_uncategorized_name ] }).transactions_scope + expected_count = results.count + assert_equal 2, expected_count, "English locale should return 2 uncategorized transactions" + end + + # Test that using the English parameter name works in every supported locale + # This catches the bug where French locale fails with English "Uncategorized" parameter + LanguagesHelper::SUPPORTED_LOCALES.each do |locale| + I18n.with_locale(locale) do + # Simulate URL parameter: q[categories][]=Uncategorized (English, regardless of user's locale) + results = Transaction::Search.new(@family, filters: { categories: [ english_uncategorized_name ] }).transactions_scope + result_count = results.count + + assert_equal expected_count, result_count, + "Locale '#{locale}' should return #{expected_count} transactions when filtering with English 'Uncategorized' parameter, but got #{result_count}" + end + end + end end From 77269fa60a24fa803d7585ea7c4a201b8596575a Mon Sep 17 00:00:00 2001 From: Lazy Bone <89256478+dwvwdv@users.noreply.github.com> Date: Mon, 2 Feb 2026 06:13:10 +0800 Subject: [PATCH 034/108] feat(mobile): Add transaction display on calendar date tap (#817) Implement two-tap interaction for calendar dates: - First tap selects a date (highlighted with thicker primary color border) - Second tap on same date shows AlertDialog with transactions for that day Each transaction displays with: - Color-coded icon (red minus for expenses, green plus for income) - Transaction name as title - Notes as subtitle (if present) - Amount with color matching expense/income Selection is cleared when changing account, account type, or month. https://claude.ai/code/session_019m7ZrCakU6h9xLwD1NTx9i Co-authored-by: Claude --- mobile/lib/screens/calendar_screen.dart | 221 ++++++++++++++++++++---- 1 file changed, 183 insertions(+), 38 deletions(-) diff --git a/mobile/lib/screens/calendar_screen.dart b/mobile/lib/screens/calendar_screen.dart index 26754e08f..611d54f86 100644 --- a/mobile/lib/screens/calendar_screen.dart +++ b/mobile/lib/screens/calendar_screen.dart @@ -22,6 +22,8 @@ class _CalendarScreenState extends State { Map _dailyChanges = {}; bool _isLoading = false; String _accountType = 'asset'; // 'asset' or 'liability' + DateTime? _selectedDate; // Track selected date for tap interaction + List _transactions = []; // Store transactions for filtering @override void initState() { @@ -90,6 +92,9 @@ class _CalendarScreenState extends State { _log.debug('CalendarScreen', 'Sample transaction - name: ${transactions.first.name}, amount: ${transactions.first.amount}, nature: ${transactions.first.nature}'); } + // Store transactions for date filtering + _transactions = List.from(transactions); + _calculateDailyChanges(transactions); _log.info('CalendarScreen', 'Calculated ${_dailyChanges.length} days with changes'); } @@ -155,15 +160,141 @@ class _CalendarScreenState extends State { void _previousMonth() { setState(() { _currentMonth = DateTime(_currentMonth.year, _currentMonth.month - 1); + _selectedDate = null; // Clear selection when changing month }); } void _nextMonth() { setState(() { _currentMonth = DateTime(_currentMonth.year, _currentMonth.month + 1); + _selectedDate = null; // Clear selection when changing month }); } + void _onDayCellTap(DateTime date) { + if (_selectedDate != null && + _selectedDate!.year == date.year && + _selectedDate!.month == date.month && + _selectedDate!.day == date.day) { + // Second tap on same date - show transactions dialog + _showTransactionsDialog(date); + } else { + // First tap - select the date + setState(() { + _selectedDate = date; + }); + } + } + + List _getTransactionsForDate(DateTime date) { + final dateKey = DateFormat('yyyy-MM-dd').format(date); + return _transactions.where((transaction) { + try { + final transactionDate = DateTime.parse(transaction.date); + final transactionDateKey = DateFormat('yyyy-MM-dd').format(transactionDate); + return transactionDateKey == dateKey; + } catch (e) { + return false; + } + }).toList(); + } + + void _showTransactionsDialog(DateTime date) { + final transactions = _getTransactionsForDate(date); + final formattedDate = DateFormat('yyyy-MM-dd').format(date); + final colorScheme = Theme.of(context).colorScheme; + + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text( + formattedDate, + style: Theme.of(context).textTheme.titleLarge, + ), + content: SizedBox( + width: double.maxFinite, + child: transactions.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + 'No transactions on this day', + style: TextStyle( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ) + : ListView.builder( + shrinkWrap: true, + itemCount: transactions.length, + itemBuilder: (context, index) { + final transaction = transactions[index]; + return _buildTransactionTile(transaction); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ); + }, + ); + } + + Widget _buildTransactionTile(Transaction transaction) { + // Parse amount to determine if positive or negative + String trimmedAmount = transaction.amount.trim(); + trimmedAmount = trimmedAmount.replaceAll('\u2212', '-'); + bool isNegative = trimmedAmount.startsWith('-') || trimmedAmount.endsWith('-'); + + // For asset accounts, flip the sign interpretation + if (_selectedAccount?.isAsset == true || _selectedAccount?.isLiability == true) { + isNegative = !isNegative; + } + + final isExpense = isNegative; + final iconData = isExpense ? Icons.remove_circle : Icons.add_circle; + final iconColor = isExpense ? Colors.red : Colors.green; + final amountColor = isExpense ? Colors.red.shade700 : Colors.green.shade700; + + return ListTile( + leading: Icon( + iconData, + color: iconColor, + size: 28, + ), + title: Text( + transaction.name, + style: const TextStyle( + fontWeight: FontWeight.w500, + ), + ), + subtitle: transaction.notes != null && transaction.notes!.isNotEmpty + ? Text( + transaction.notes!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 12, + ), + ) + : null, + trailing: Text( + transaction.amount, + style: TextStyle( + color: amountColor, + fontWeight: FontWeight.bold, + ), + ), + ); + } + double _getTotalForMonth() { double total = 0.0; final yearMonth = DateFormat('yyyy-MM').format(_currentMonth); @@ -231,6 +362,8 @@ class _CalendarScreenState extends State { final filteredAccounts = _getFilteredAccounts(accountsProvider.accounts); _selectedAccount = filteredAccounts.isNotEmpty ? filteredAccounts.first : null; _dailyChanges = {}; + _transactions = []; + _selectedDate = null; // Clear selection when changing account type }); if (_selectedAccount != null) { _loadTransactionsForAccount(); @@ -275,6 +408,8 @@ class _CalendarScreenState extends State { setState(() { _selectedAccount = newAccount; _dailyChanges = {}; + _transactions = []; + _selectedDate = null; // Clear selection when changing account }); _loadTransactionsForAccount(); }, @@ -405,6 +540,7 @@ class _CalendarScreenState extends State { return Expanded( child: _buildDayCell( + date, dayNumber, change, hasChange, @@ -421,10 +557,16 @@ class _CalendarScreenState extends State { ); } - Widget _buildDayCell(int day, double change, bool hasChange, ColorScheme colorScheme) { + Widget _buildDayCell(DateTime date, int day, double change, bool hasChange, ColorScheme colorScheme) { Color? backgroundColor; Color? textColor; + // Check if this date is selected + final isSelected = _selectedDate != null && + _selectedDate!.year == date.year && + _selectedDate!.month == date.month && + _selectedDate!.day == date.day; + if (hasChange) { if (change > 0) { backgroundColor = Colors.green.withValues(alpha: 0.2); @@ -435,47 +577,50 @@ class _CalendarScreenState extends State { } } - return Container( - margin: const EdgeInsets.all(2), - decoration: BoxDecoration( - color: backgroundColor ?? colorScheme.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: colorScheme.outlineVariant, - width: 1, + return GestureDetector( + onTap: () => _onDayCellTap(date), + child: Container( + margin: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: backgroundColor ?? colorScheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isSelected ? Theme.of(context).primaryColor : colorScheme.outlineVariant, + width: isSelected ? 3 : 1, + ), ), - ), - child: Padding( - padding: const EdgeInsets.all(4), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - day.toString(), - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14, - color: colorScheme.onSurface, - ), - ), - if (hasChange) ...[ - const SizedBox(height: 2), - Flexible( - child: FittedBox( - fit: BoxFit.scaleDown, - child: Text( - _formatAmount(change), - style: TextStyle( - fontSize: 10, - color: textColor, - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), + child: Padding( + padding: const EdgeInsets.all(4), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + day.toString(), + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: colorScheme.onSurface, ), ), + if (hasChange) ...[ + const SizedBox(height: 2), + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + _formatAmount(change), + style: TextStyle( + fontSize: 10, + color: textColor, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ), + ), + ], ], - ], + ), ), ), ); From 81cf4738620c1a1c71b15493762e045ec4d56059 Mon Sep 17 00:00:00 2001 From: Lazy Bone <89256478+dwvwdv@users.noreply.github.com> Date: Mon, 2 Feb 2026 06:16:02 +0800 Subject: [PATCH 035/108] fix: Use getValidAccessToken() in connectivity banner sync button (#851) * feat(mobile): Add transaction display on calendar date tap Implement two-tap interaction for calendar dates: - First tap selects a date (highlighted with thicker primary color border) - Second tap on same date shows AlertDialog with transactions for that day Each transaction displays with: - Color-coded icon (red minus for expenses, green plus for income) - Transaction name as title - Notes as subtitle (if present) - Amount with color matching expense/income Selection is cleared when changing account, account type, or month. https://claude.ai/code/session_019m7ZrCakU6h9xLwD1NTx9i * feat(mobile): optimize asset/liability display with filters - Add NetWorthCard widget with placeholder for future net worth API - Add side-by-side Assets/Liabilities display with tap-to-filter - Implement CurrencyFilter widget for multi-select currency filtering - Replace old _SummaryCard with new unified design - Remove _CollapsibleSectionHeader in favor of filter-based navigation The net worth section shows a placeholder as the API endpoint is not yet available. Users can now filter accounts by type (assets/liabilities) and by currency. https://claude.ai/code/session_01W8cQSCzmgTmTqwRJ8Ycpx3 * fix(mobile): remove unused variables and add const - Remove unused _totalAssets, _totalLiabilities, _getPrimaryCurrency - Add const to Text('All') widget https://claude.ai/code/session_01W8cQSCzmgTmTqwRJ8Ycpx3 * feat(mobile): enhance dashboard with icons, long-press breakdown, and grouped view - NetWorthCard: replace text labels with trending icons, add colored bottom borders for asset (green) and liability (red) sections - Add long-press gesture on asset/liability areas to show full currency breakdown in a bottom sheet popup - Add collapsible account type grouping (Crypto, Bank, Investment, etc.) with type-specific icons and expand/collapse headers - Add PreferencesService for persisting display settings - Add "Group by Account Type" toggle in Settings screen - Wire settings change to dashboard via GlobalKey for live updates https://claude.ai/code/session_01W8cQSCzmgTmTqwRJ8Ycpx3 * refactor(mobile): remove welcome header from dashboard Strip the Welcome greeting and subtitle to let the financial overview take immediate focus. https://claude.ai/code/session_01W8cQSCzmgTmTqwRJ8Ycpx3 * feat(mobile): compact filter buttons with scroll-wheel currency switcher - Remove trending icons from asset/liability filter buttons - Increase amount font size to titleMedium bold - Reduce Net Worth section and filter button padding - Show single currency at a time with ListWheelScrollView for scrolling between currencies (wheel-picker style) - Absorb scroll events via NotificationListener to prevent triggering pull-to-refresh - Keep icons in the long-press currency breakdown popup https://claude.ai/code/session_01W8cQSCzmgTmTqwRJ8Ycpx3 * feat: Add API key login option to mobile app Add a "Via API Key Login" button on the login screen that opens a dialog for entering an API key. The API key is validated by making a test request to /api/v1/accounts with the X-Api-Key header, and on success is persisted in secure storage. All HTTP services now use a centralized ApiConfig.getAuthHeaders() helper that returns the correct auth header (X-Api-Key or Bearer) based on the current auth mode. https://claude.ai/code/session_01DnyCzdMjVpSsbBZK3XbzUH * fix: Improve API key dialog context handling and controller disposal - Use outer context for SnackBar so it displays on the main screen instead of behind the dialog - Explicitly dispose TextEditingController to prevent memory leaks - Close dialog on failure before showing error SnackBar for better UX - Avoid StatefulBuilder context parameter shadowing https://claude.ai/code/session_01DnyCzdMjVpSsbBZK3XbzUH * fix: Use user-friendly error message in API key login catch block Log the technical exception details via LogService.instance.error and show a generic "Unable to connect" message to the user instead of exposing the raw exception string. https://claude.ai/code/session_01DnyCzdMjVpSsbBZK3XbzUH * fix: Use getValidAccessToken() in connectivity banner sync button Replace direct authProvider.tokens?.accessToken access with getValidAccessToken() so the Sync Now button works in API-key auth mode where _tokens is null. https://claude.ai/code/session_01DnyCzdMjVpSsbBZK3XbzUH * Revert "fix: Use getValidAccessToken() in connectivity banner sync button" This reverts commit 7015c160f0215db242ad06716793d0137edcd1bb. * Reapply "fix: Use getValidAccessToken() in connectivity banner sync button" This reverts commit b29e010de326445f0b13f242670ad8d3c22f17f9. * fix: Use getValidAccessToken() in connectivity banner sync button Replace direct authProvider.tokens?.accessToken access with getValidAccessToken() so the Sync Now button works in API-key auth mode where _tokens is null. https://claude.ai/code/session_01DnyCzdMjVpSsbBZK3XbzUH * fix(mobile): prevent bottom sheet overflow with ConstrainedBox Use ConstrainedBox + ListView.separated with shrinkWrap for the currency breakdown popup. Few currencies: sheet sizes to content. Many currencies: caps at 50% screen height and scrolls. Also add isScrollControlled and useSafeArea to showModalBottomSheet. https://claude.ai/code/session_01W8cQSCzmgTmTqwRJ8Ycpx3 * fix: Prevent multiple syncs and handle auth errors in connectivity banner Set _isSyncing immediately on tap to disable the button during token refresh, wrap getValidAccessToken() in try/catch with user-facing error snackbar, and await _handleSync so errors propagate correctly. https://claude.ai/code/session_01GgVgjqwyXhWMZN3eWfaMCk --------- Signed-off-by: Lazy Bone <89256478+dwvwdv@users.noreply.github.com> Co-authored-by: Claude --- mobile/lib/screens/dashboard_screen.dart | 585 +++++++----------- .../lib/screens/main_navigation_screen.dart | 25 +- mobile/lib/screens/settings_screen.dart | 53 +- mobile/lib/services/preferences_service.dart | 30 + mobile/lib/widgets/connectivity_banner.dart | 41 +- mobile/lib/widgets/currency_filter.dart | 134 ++++ mobile/lib/widgets/net_worth_card.dart | 315 ++++++++++ 7 files changed, 820 insertions(+), 363 deletions(-) create mode 100644 mobile/lib/services/preferences_service.dart create mode 100644 mobile/lib/widgets/currency_filter.dart create mode 100644 mobile/lib/widgets/net_worth_card.dart diff --git a/mobile/lib/screens/dashboard_screen.dart b/mobile/lib/screens/dashboard_screen.dart index e62717c40..2ab6c6d68 100644 --- a/mobile/lib/screens/dashboard_screen.dart +++ b/mobile/lib/screens/dashboard_screen.dart @@ -5,8 +5,11 @@ import '../providers/auth_provider.dart'; import '../providers/accounts_provider.dart'; import '../providers/transactions_provider.dart'; import '../services/log_service.dart'; +import '../services/preferences_service.dart'; import '../widgets/account_card.dart'; import '../widgets/connectivity_banner.dart'; +import '../widgets/net_worth_card.dart'; +import '../widgets/currency_filter.dart'; import 'transaction_form_screen.dart'; import 'transactions_list_screen.dart'; import 'log_viewer_screen.dart'; @@ -15,21 +18,28 @@ class DashboardScreen extends StatefulWidget { const DashboardScreen({super.key}); @override - State createState() => _DashboardScreenState(); + DashboardScreenState createState() => DashboardScreenState(); } -class _DashboardScreenState extends State { +class DashboardScreenState extends State { final LogService _log = LogService.instance; - bool _assetsExpanded = true; - bool _liabilitiesExpanded = true; bool _showSyncSuccess = false; int _previousPendingCount = 0; TransactionsProvider? _transactionsProvider; + // Filter state + AccountFilter _accountFilter = AccountFilter.all; + Set _selectedCurrencies = {}; + + // Group by type state + bool _groupByType = false; + final Set _collapsedGroups = {}; + @override void initState() { super.initState(); _loadAccounts(); + _loadPreferences(); // Listen for sync completion to show success indicator WidgetsBinding.instance.addPostFrameCallback((_) { @@ -92,6 +102,19 @@ class _DashboardScreenState extends State { } } + Future _loadPreferences() async { + final groupByType = await PreferencesService.instance.getGroupByType(); + if (mounted) { + setState(() { + _groupByType = groupByType; + }); + } + } + + void reloadPreferences() { + _loadPreferences(); + } + Future _handleRefresh() async { await _performManualSync(); } @@ -173,7 +196,7 @@ class _DashboardScreenState extends State { } } - List _formatCurrencyItem(String currency, double amount) { + String _formatAmount(String currency, double amount) { final symbol = _getCurrencySymbol(currency); final isSmallAmount = amount.abs() < 1 && amount != 0; final formattedAmount = amount.toStringAsFixed(isSmallAmount ? 4 : 0); @@ -186,7 +209,40 @@ class _DashboardScreenState extends State { ); final finalAmount = parts.length > 1 ? '$integerPart.${parts[1]}' : integerPart; - return [currency, '$symbol$finalAmount']; + return '$symbol$finalAmount $currency'; + } + + Set _getAllCurrencies(AccountsProvider accountsProvider) { + final currencies = {}; + for (var account in accountsProvider.accounts) { + currencies.add(account.currency); + } + return currencies; + } + + List _getFilteredAccounts(AccountsProvider accountsProvider) { + var accounts = accountsProvider.accounts.toList(); + + // Filter by account type + switch (_accountFilter) { + case AccountFilter.assets: + accounts = accounts.where((a) => a.isAsset).toList(); + break; + case AccountFilter.liabilities: + accounts = accounts.where((a) => a.isLiability).toList(); + break; + case AccountFilter.all: + // Show all accounts (assets and liabilities) + accounts = accounts.where((a) => a.isAsset || a.isLiability).toList(); + break; + } + + // Filter by currency if any selected + if (_selectedCurrencies.isNotEmpty) { + accounts = accounts.where((a) => _selectedCurrencies.contains(a.currency)).toList(); + } + + return accounts; } String _getCurrencySymbol(String currency) { @@ -449,124 +505,41 @@ class _DashboardScreenState extends State { onRefresh: _handleRefresh, child: CustomScrollView( slivers: [ - // Welcome header + // Net Worth Card with Asset/Liability filter 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), - ), - ], - ), + child: NetWorthCard( + assetTotalsByCurrency: accountsProvider.assetTotalsByCurrency, + liabilityTotalsByCurrency: accountsProvider.liabilityTotalsByCurrency, + currentFilter: _accountFilter, + onFilterChanged: (filter) { + setState(() { + _accountFilter = filter; + }); + }, + formatAmount: _formatAmount, ), ), - // Summary cards + // Currency filter 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, - ), - ], - ), + child: CurrencyFilter( + availableCurrencies: _getAllCurrencies(accountsProvider), + selectedCurrencies: _selectedCurrencies, + onSelectionChanged: (currencies) { + setState(() { + _selectedCurrencies = currencies; + }); + }, ), ), - // 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, - ), - ), - ), - ], + // Spacing + const SliverToBoxAdapter( + child: SizedBox(height: 8), + ), - // 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), + // Filtered accounts section + ..._buildFilteredAccountsSection(accountsProvider), // Bottom padding const SliverToBoxAdapter( @@ -583,253 +556,221 @@ class _DashboardScreenState extends State { ); } - List _buildUncategorizedSection(AccountsProvider accountsProvider) { - final uncategorized = accountsProvider.accounts - .where((a) => !a.isAsset && !a.isLiability) - .toList(); + List _buildFilteredAccountsSection(AccountsProvider accountsProvider) { + final filteredAccounts = _getFilteredAccounts(accountsProvider); - if (uncategorized.isEmpty) { - return []; + if (filteredAccounts.isEmpty) { + return [ + SliverToBoxAdapter( + child: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + children: [ + Icon( + Icons.account_balance_wallet_outlined, + size: 48, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + 'No accounts match the current filter', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ), + ]; } + // Sort accounts: by type, then currency, then balance + filteredAccounts.sort((a, b) { + if (a.isAsset && !b.isAsset) return -1; + if (!a.isAsset && b.isAsset) return 1; + int typeComparison = a.accountType.compareTo(b.accountType); + if (typeComparison != 0) return typeComparison; + int currencyComparison = a.currency.compareTo(b.currency); + if (currencyComparison != 0) return currencyComparison; + return b.balanceAsDouble.compareTo(a.balanceAsDouble); + }); + + if (_groupByType) { + return _buildGroupedAccountsList(filteredAccounts); + } + + return _buildFlatAccountsList(filteredAccounts); + } + + List _buildFlatAccountsList(List accounts) { 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]; + final account = accounts[index]; return AccountCard( account: account, onTap: () => _handleAccountTap(account), onSwipe: () => _handleAccountSwipe(account), ); }, - childCount: uncategorized.length, + childCount: accounts.length, ), ), ), ]; } -} -class _SummaryCard extends StatelessWidget { - final String title; - final Map totals; - final Color color; - final List Function(String currency, double amount) formatCurrencyItem; + List _buildGroupedAccountsList(List accounts) { + // Group accounts by accountType + final groups = >{}; + for (final account in accounts) { + groups.putIfAbsent(account.accountType, () => []).add(account); + } - const _SummaryCard({ - required this.title, - required this.totals, - required this.color, - required this.formatCurrencyItem, - }); + final slivers = []; + for (final entry in groups.entries) { + final accountType = entry.key; + final groupAccounts = entry.value; + final isCollapsed = _collapsedGroups.contains(accountType); - @override - Widget build(BuildContext context) { - final entries = totals.entries.toList(); - final rows = []; + // Use first account to get display name and icon + final displayName = groupAccounts.first.displayAccountType; - // 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); + slivers.add( + SliverToBoxAdapter( + child: _CollapsibleTypeHeader( + title: displayName, + count: groupAccounts.length, + accountType: accountType, + isCollapsed: isCollapsed, + onToggle: () { + setState(() { + if (isCollapsed) { + _collapsedGroups.remove(accountType); + } else { + _collapsedGroups.add(accountType); + } + }); + }, + ), + ), + ); - 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, - ), - ), - ], - ), + if (!isCollapsed) { + slivers.add( + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final account = groupAccounts[index]; + return AccountCard( + account: account, + onTap: () => _handleAccountTap(account), + onSwipe: () => _handleAccountSwipe(account), + ); + }, + childCount: groupAccounts.length, ), - 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, - ], - ), - ), - ], - ), - ); + return slivers; } } -class _CollapsibleSectionHeader extends StatelessWidget { +class _CollapsibleTypeHeader extends StatelessWidget { final String title; final int count; - final Color color; - final bool isExpanded; + final String accountType; + final bool isCollapsed; final VoidCallback onToggle; - const _CollapsibleSectionHeader({ + const _CollapsibleTypeHeader({ required this.title, required this.count, - required this.color, - required this.isExpanded, + required this.accountType, + required this.isCollapsed, required this.onToggle, }); + IconData _getTypeIcon() { + switch (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; + } + } + @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return InkWell( onTap: onToggle, child: Padding( - padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: Row( children: [ - Container( - width: 4, - height: 24, - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(2), - ), + Icon( + _getTypeIcon(), + size: 18, + color: colorScheme.onSurfaceVariant, ), - const SizedBox(width: 12), + const SizedBox(width: 10), Text( title, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), ), const SizedBox(width: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1), decoration: BoxDecoration( - color: color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), + color: colorScheme.primaryContainer.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(10), ), child: Text( count.toString(), style: TextStyle( - color: color, + color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.bold, - fontSize: 12, + fontSize: 11, ), ), ), const Spacer(), Icon( - isExpanded ? Icons.expand_less : Icons.expand_more, - color: color, + isCollapsed ? Icons.expand_more : Icons.expand_less, + size: 20, + color: colorScheme.onSurfaceVariant, ), ], ), @@ -837,57 +778,3 @@ class _CollapsibleSectionHeader extends StatelessWidget { ); } } - -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/main_navigation_screen.dart b/mobile/lib/screens/main_navigation_screen.dart index a253bf614..281524b27 100644 --- a/mobile/lib/screens/main_navigation_screen.dart +++ b/mobile/lib/screens/main_navigation_screen.dart @@ -13,13 +13,21 @@ class MainNavigationScreen extends StatefulWidget { class _MainNavigationScreenState extends State { int _currentIndex = 0; + int _previousIndex = 0; + final _dashboardKey = GlobalKey(); - final List _screens = [ - const DashboardScreen(), - const ChatListScreen(), - const MoreScreen(), - const SettingsScreen(), - ]; + late final List _screens; + + @override + void initState() { + super.initState(); + _screens = [ + DashboardScreen(key: _dashboardKey), + const ChatListScreen(), + const MoreScreen(), + const SettingsScreen(), + ]; + } @override Widget build(BuildContext context) { @@ -31,9 +39,14 @@ class _MainNavigationScreenState extends State { bottomNavigationBar: NavigationBar( selectedIndex: _currentIndex, onDestinationSelected: (index) { + _previousIndex = _currentIndex; setState(() { _currentIndex = index; }); + // Reload preferences when switching back to dashboard from settings + if (index == 0 && _previousIndex == 3) { + _dashboardKey.currentState?.reloadPreferences(); + } }, destinations: const [ NavigationDestination( diff --git a/mobile/lib/screens/settings_screen.dart b/mobile/lib/screens/settings_screen.dart index 0f5518c8c..38a996ac8 100644 --- a/mobile/lib/screens/settings_screen.dart +++ b/mobile/lib/screens/settings_screen.dart @@ -3,10 +3,33 @@ import 'package:provider/provider.dart'; import '../providers/auth_provider.dart'; import '../services/offline_storage_service.dart'; import '../services/log_service.dart'; +import '../services/preferences_service.dart'; -class SettingsScreen extends StatelessWidget { +class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); + @override + State createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + bool _groupByType = false; + + @override + void initState() { + super.initState(); + _loadPreferences(); + } + + Future _loadPreferences() async { + final value = await PreferencesService.instance.getGroupByType(); + if (mounted) { + setState(() { + _groupByType = value; + }); + } + } + Future _handleClearLocalData(BuildContext context) async { final confirmed = await showDialog( context: context, @@ -165,6 +188,34 @@ class SettingsScreen extends StatelessWidget { const Divider(), + // Display Settings Section + const Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + 'Display', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.grey, + ), + ), + ), + + SwitchListTile( + secondary: const Icon(Icons.view_list), + title: const Text('Group by Account Type'), + subtitle: const Text('Group accounts by type (Crypto, Bank, etc.)'), + value: _groupByType, + onChanged: (value) async { + await PreferencesService.instance.setGroupByType(value); + setState(() { + _groupByType = value; + }); + }, + ), + + const Divider(), + // Data Management Section const Padding( padding: EdgeInsets.fromLTRB(16, 16, 16, 8), diff --git a/mobile/lib/services/preferences_service.dart b/mobile/lib/services/preferences_service.dart new file mode 100644 index 000000000..15558385e --- /dev/null +++ b/mobile/lib/services/preferences_service.dart @@ -0,0 +1,30 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class PreferencesService { + static const _groupByTypeKey = 'dashboard_group_by_type'; + + static PreferencesService? _instance; + SharedPreferences? _prefs; + + PreferencesService._(); + + static PreferencesService get instance { + _instance ??= PreferencesService._(); + return _instance!; + } + + Future get _preferences async { + _prefs ??= await SharedPreferences.getInstance(); + return _prefs!; + } + + Future getGroupByType() async { + final prefs = await _preferences; + return prefs.getBool(_groupByTypeKey) ?? false; + } + + Future setGroupByType(bool value) async { + final prefs = await _preferences; + await prefs.setBool(_groupByTypeKey, value); + } +} diff --git a/mobile/lib/widgets/connectivity_banner.dart b/mobile/lib/widgets/connectivity_banner.dart index ece413fd8..eb8fb01f1 100644 --- a/mobile/lib/widgets/connectivity_banner.dart +++ b/mobile/lib/widgets/connectivity_banner.dart @@ -23,13 +23,14 @@ class _ConnectivityBannerState extends State { backgroundColor: Colors.orange, ), ); + if (mounted) { + setState(() { + _isSyncing = false; + }); + } return; } - setState(() { - _isSyncing = true; - }); - try { await transactionsProvider.syncTransactions(accessToken: accessToken); @@ -101,11 +102,37 @@ class _ConnectivityBannerState extends State { return TextButton( onPressed: _isSyncing ? null - : () => _handleSync( + : () async { + setState(() { + _isSyncing = true; + }); + + String? accessToken; + try { + accessToken = await authProvider.getValidAccessToken(); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Unable to authenticate. Please try again.'), + backgroundColor: Colors.red, + ), + ); + if (mounted) { + setState(() { + _isSyncing = false; + }); + } + return; + } + + if (!context.mounted) return; + await _handleSync( context, - authProvider.tokens?.accessToken, + accessToken, transactionsProvider, - ), + ); + }, style: TextButton.styleFrom( foregroundColor: Colors.blue.shade900, ), diff --git a/mobile/lib/widgets/currency_filter.dart b/mobile/lib/widgets/currency_filter.dart new file mode 100644 index 000000000..ad2e86b47 --- /dev/null +++ b/mobile/lib/widgets/currency_filter.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; + +class CurrencyFilter extends StatelessWidget { + final Set availableCurrencies; + final Set selectedCurrencies; + final ValueChanged> onSelectionChanged; + + const CurrencyFilter({ + super.key, + required this.availableCurrencies, + required this.selectedCurrencies, + required this.onSelectionChanged, + }); + + String _getCurrencySymbol(String currency) { + switch (currency.toUpperCase()) { + case 'USD': + return '\$'; + case 'TWD': + return 'NT\$'; + case 'BTC': + return '₿'; + case 'ETH': + return 'Ξ'; + case 'EUR': + return '€'; + case 'GBP': + return '£'; + case 'JPY': + return '¥'; + case 'CNY': + return '¥'; + default: + return ''; + } + } + + @override + Widget build(BuildContext context) { + if (availableCurrencies.length <= 1) { + return const SizedBox.shrink(); + } + + final sortedCurrencies = availableCurrencies.toList()..sort(); + final colorScheme = Theme.of(context).colorScheme; + final isAllSelected = selectedCurrencies.isEmpty || + selectedCurrencies.length == availableCurrencies.length; + + return Container( + height: 44, + margin: const EdgeInsets.symmetric(horizontal: 16), + child: ListView( + scrollDirection: Axis.horizontal, + children: [ + // "All" chip + Padding( + padding: const EdgeInsets.only(right: 8), + child: FilterChip( + label: const Text('All'), + selected: isAllSelected, + onSelected: (_) { + onSelectionChanged({}); + }, + backgroundColor: colorScheme.surfaceContainerHighest, + selectedColor: colorScheme.primaryContainer, + checkmarkColor: colorScheme.onPrimaryContainer, + labelStyle: TextStyle( + color: isAllSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + fontWeight: isAllSelected ? FontWeight.bold : FontWeight.normal, + ), + side: BorderSide( + color: isAllSelected + ? colorScheme.primary + : colorScheme.outline.withValues(alpha: 0.3), + ), + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + ), + + // Currency chips + ...sortedCurrencies.map((currency) { + final isSelected = + selectedCurrencies.contains(currency) && !isAllSelected; + final symbol = _getCurrencySymbol(currency); + final displayText = symbol.isNotEmpty ? '$currency ($symbol)' : currency; + + return Padding( + padding: const EdgeInsets.only(right: 8), + child: FilterChip( + label: Text(displayText), + selected: isSelected, + onSelected: (_) { + final newSelection = Set.from(selectedCurrencies); + if (isSelected) { + newSelection.remove(currency); + } else { + // If currently showing all, start fresh with just this one + if (isAllSelected) { + newSelection.clear(); + } + newSelection.add(currency); + } + // If all currencies selected, treat as "All" + if (newSelection.length == availableCurrencies.length) { + onSelectionChanged({}); + } else { + onSelectionChanged(newSelection); + } + }, + backgroundColor: colorScheme.surfaceContainerHighest, + selectedColor: colorScheme.primaryContainer, + checkmarkColor: colorScheme.onPrimaryContainer, + labelStyle: TextStyle( + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + side: BorderSide( + color: isSelected + ? colorScheme.primary + : colorScheme.outline.withValues(alpha: 0.3), + ), + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + ); + }), + ], + ), + ); + } +} diff --git a/mobile/lib/widgets/net_worth_card.dart b/mobile/lib/widgets/net_worth_card.dart new file mode 100644 index 000000000..0d1c8ccda --- /dev/null +++ b/mobile/lib/widgets/net_worth_card.dart @@ -0,0 +1,315 @@ +import 'package:flutter/material.dart'; + +enum AccountFilter { all, assets, liabilities } + +class NetWorthCard extends StatelessWidget { + final Map assetTotalsByCurrency; + final Map liabilityTotalsByCurrency; + final AccountFilter currentFilter; + final ValueChanged onFilterChanged; + final String Function(String currency, double amount) formatAmount; + + const NetWorthCard({ + super.key, + required this.assetTotalsByCurrency, + required this.liabilityTotalsByCurrency, + required this.currentFilter, + required this.onFilterChanged, + required this.formatAmount, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: colorScheme.outline.withValues(alpha: 0.2), + ), + ), + child: Column( + children: [ + // Net Worth Section (Placeholder) + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 12), + child: Column( + children: [ + Text( + 'Net Worth', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 2), + Text( + '--', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ], + ), + ), + + // Divider + Divider( + height: 1, + color: colorScheme.outline.withValues(alpha: 0.2), + ), + + // Assets & Liabilities Row + IntrinsicHeight( + child: Row( + children: [ + // Assets + Expanded( + child: _FilterButton( + totals: assetTotalsByCurrency, + color: Colors.green, + isSelected: currentFilter == AccountFilter.assets, + onTap: () { + if (currentFilter == AccountFilter.assets) { + onFilterChanged(AccountFilter.all); + } else { + onFilterChanged(AccountFilter.assets); + } + }, + onLongPress: () => _showCurrencyBreakdown( + context, + 'Assets', + assetTotalsByCurrency, + Colors.green, + ), + formatAmount: formatAmount, + ), + ), + + // Vertical Divider + VerticalDivider( + width: 1, + color: colorScheme.outline.withValues(alpha: 0.2), + ), + + // Liabilities + Expanded( + child: _FilterButton( + totals: liabilityTotalsByCurrency, + color: Colors.red, + isSelected: currentFilter == AccountFilter.liabilities, + onTap: () { + if (currentFilter == AccountFilter.liabilities) { + onFilterChanged(AccountFilter.all); + } else { + onFilterChanged(AccountFilter.liabilities); + } + }, + onLongPress: () => _showCurrencyBreakdown( + context, + 'Liabilities', + liabilityTotalsByCurrency, + Colors.red, + ), + formatAmount: formatAmount, + ), + ), + ], + ), + ), + ], + ), + ); + } + + void _showCurrencyBreakdown( + BuildContext context, + String title, + Map totals, + Color color, + ) { + final sortedEntries = totals.entries.toList() + ..sort((a, b) => b.value.abs().compareTo(a.value.abs())); + + if (sortedEntries.isEmpty) return; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) { + final colorScheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Handle bar + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 20), + + // Title with icon + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + title == 'Assets' ? Icons.trending_up : Icons.trending_down, + color: color, + size: 20, + ), + const SizedBox(width: 8), + Text( + title, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + color: color, + ), + ), + ], + ), + const SizedBox(height: 20), + + // Currency list (scrollable when many entries) + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.5, + ), + child: ListView.separated( + shrinkWrap: true, + itemCount: sortedEntries.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final entry = sortedEntries[index]; + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + entry.key, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w500, + color: colorScheme.onSurfaceVariant, + ), + ), + Text( + formatAmount(entry.key, entry.value), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ); + }, + ), + ), + ], + ), + ); + }, + ); + } +} + +class _FilterButton extends StatelessWidget { + final Map totals; + final Color color; + final bool isSelected; + final VoidCallback onTap; + final VoidCallback onLongPress; + final String Function(String currency, double amount) formatAmount; + + const _FilterButton({ + required this.totals, + required this.color, + required this.isSelected, + required this.onTap, + required this.onLongPress, + required this.formatAmount, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + final sortedEntries = totals.entries.toList() + ..sort((a, b) => b.value.abs().compareTo(a.value.abs())); + + return Container( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: color.withValues(alpha: 0.6), + width: 3, + ), + ), + ), + child: Material( + color: isSelected ? color.withValues(alpha: 0.1) : Colors.transparent, + child: GestureDetector( + onTap: onTap, + onLongPress: sortedEntries.isNotEmpty ? onLongPress : null, + behavior: HitTestBehavior.opaque, + child: SizedBox( + height: 48, + child: sortedEntries.isEmpty + ? Center( + child: Text( + '--', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ) + : sortedEntries.length == 1 + ? Center( + child: Text( + formatAmount(sortedEntries.first.key, sortedEntries.first.value), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ) + : NotificationListener( + onNotification: (_) => true, + child: ListWheelScrollView.useDelegate( + itemExtent: 32, + diameterRatio: 1.5, + perspective: 0.003, + physics: const FixedExtentScrollPhysics(), + childDelegate: ListWheelChildBuilderDelegate( + childCount: sortedEntries.length, + builder: (context, index) { + final entry = sortedEntries[index]; + return Center( + child: Text( + formatAmount(entry.key, entry.value), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, + ), + ), + ), + ), + ), + ), + ); + } +} From ad386c6e27eb12cb394b111bf373bdc8ad40f7bd Mon Sep 17 00:00:00 2001 From: AdamWHY2K Date: Sun, 1 Feb 2026 22:48:54 +0000 Subject: [PATCH 036/108] fix: Lunchflow pending transaction duplicates, missing from search and filter (#859) * fix: lunchflow parity with simplefin/plaid pending behaviour * fix: don't suggest duplicate if both entries are pending * refactor: reuse the same external_id for re-synced pending transactions * chore: replace illogical duplicate collision test with multiple sync test * fix: prevent duplicates when users edit pending lunchflow transactions * chore: add test for preventing duplicates when users edit pending lunchflow transactions * fix: normalise extra hash keys for pending detection --- app/models/account/provider_import_adapter.rb | 20 ++++- app/models/entry.rb | 4 + app/models/entry_search.rb | 2 + app/models/lunchflow_entry/processor.rb | 18 ++++- app/models/transaction/search.rb | 2 + test/models/lunchflow_entry/processor_test.rb | 80 +++++++++++++++---- 6 files changed, 101 insertions(+), 25 deletions(-) diff --git a/app/models/account/provider_import_adapter.rb b/app/models/account/provider_import_adapter.rb index c66fe69ef..2d814e9e8 100644 --- a/app/models/account/provider_import_adapter.rb +++ b/app/models/account/provider_import_adapter.rb @@ -77,10 +77,14 @@ class Account::ProviderImportAdapter end # If still a new entry and this is a POSTED transaction, check for matching pending transactions - incoming_pending = extra.is_a?(Hash) && ( - ActiveModel::Type::Boolean.new.cast(extra.dig("simplefin", "pending")) || - ActiveModel::Type::Boolean.new.cast(extra.dig("plaid", "pending")) - ) + incoming_pending = false + if extra.is_a?(Hash) + pending_extra = extra.with_indifferent_access + incoming_pending = + ActiveModel::Type::Boolean.new.cast(pending_extra.dig("simplefin", "pending")) || + ActiveModel::Type::Boolean.new.cast(pending_extra.dig("plaid", "pending")) || + ActiveModel::Type::Boolean.new.cast(pending_extra.dig("lunchflow", "pending")) + end if entry.new_record? && !incoming_pending pending_match = nil @@ -686,6 +690,7 @@ class Account::ProviderImportAdapter .where(<<~SQL.squish) (transactions.extra -> 'simplefin' ->> 'pending')::boolean = true OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true + OR (transactions.extra -> 'lunchflow' ->> 'pending')::boolean = true SQL .order(date: :desc) # Prefer most recent pending transaction @@ -731,6 +736,7 @@ class Account::ProviderImportAdapter .where(<<~SQL.squish) (transactions.extra -> 'simplefin' ->> 'pending')::boolean = true OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true + OR (transactions.extra -> 'lunchflow' ->> 'pending')::boolean = true SQL # If merchant_id is provided, prioritize matching by merchant @@ -799,6 +805,7 @@ class Account::ProviderImportAdapter .where(<<~SQL.squish) (transactions.extra -> 'simplefin' ->> 'pending')::boolean = true OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true + OR (transactions.extra -> 'lunchflow' ->> 'pending')::boolean = true SQL # For low confidence, require BOTH merchant AND name match (stronger signal needed) @@ -836,6 +843,11 @@ class Account::ProviderImportAdapter # Don't overwrite if already has a suggestion (keep first one found) return if existing_extra["potential_posted_match"].present? + # Don't suggest if the posted entry is also still pending (pending→pending match) + # Suggestions are only for pending→posted reconciliation + posted_transaction = posted_entry.entryable + return if posted_transaction.is_a?(Transaction) && posted_transaction.pending? + pending_transaction.update!( extra: existing_extra.merge( "potential_posted_match" => { diff --git a/app/models/entry.rb b/app/models/entry.rb index 7f83d0d2c..2e4887098 100644 --- a/app/models/entry.rb +++ b/app/models/entry.rb @@ -42,6 +42,7 @@ class Entry < ApplicationRecord .where(<<~SQL.squish) (transactions.extra -> 'simplefin' ->> 'pending')::boolean = true OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true + OR (transactions.extra -> 'lunchflow' ->> 'pending')::boolean = true SQL } @@ -56,6 +57,7 @@ class Entry < ApplicationRecord AND ( (t.extra -> 'simplefin' ->> 'pending')::boolean = true OR (t.extra -> 'plaid' ->> 'pending')::boolean = true + OR (t.extra -> 'lunchflow' ->> 'pending')::boolean = true ) ) SQL @@ -118,6 +120,7 @@ class Entry < ApplicationRecord .where(<<~SQL.squish) (transactions.extra -> 'simplefin' ->> 'pending')::boolean IS NOT TRUE AND (transactions.extra -> 'plaid' ->> 'pending')::boolean IS NOT TRUE + AND (transactions.extra -> 'lunchflow' ->> 'pending')::boolean IS NOT TRUE SQL .limit(2) # Only need to know if 0, 1, or 2+ candidates .to_a # Load limited records to avoid COUNT(*) on .size @@ -164,6 +167,7 @@ class Entry < ApplicationRecord .where(<<~SQL.squish) (transactions.extra -> 'simplefin' ->> 'pending')::boolean IS NOT TRUE AND (transactions.extra -> 'plaid' ->> 'pending')::boolean IS NOT TRUE + AND (transactions.extra -> 'lunchflow' ->> 'pending')::boolean IS NOT TRUE SQL # Match by name similarity (first 3 words) diff --git a/app/models/entry_search.rb b/app/models/entry_search.rb index b082cac34..0c67bd546 100644 --- a/app/models/entry_search.rb +++ b/app/models/entry_search.rb @@ -70,6 +70,7 @@ class EntrySearch AND ( (t.extra -> 'simplefin' ->> 'pending')::boolean = true OR (t.extra -> 'plaid' ->> 'pending')::boolean = true + OR (t.extra -> 'lunchflow' ->> 'pending')::boolean = true ) ) SQL @@ -82,6 +83,7 @@ class EntrySearch AND ( (t.extra -> 'simplefin' ->> 'pending')::boolean = true OR (t.extra -> 'plaid' ->> 'pending')::boolean = true + OR (t.extra -> 'lunchflow' ->> 'pending')::boolean = true ) ) SQL diff --git a/app/models/lunchflow_entry/processor.rb b/app/models/lunchflow_entry/processor.rb index 108581b87..6164752d2 100644 --- a/app/models/lunchflow_entry/processor.rb +++ b/app/models/lunchflow_entry/processor.rb @@ -73,10 +73,20 @@ class LunchflowEntry::Processor base_temp_id = content_hash_for_transaction(data) temp_id_with_prefix = "lunchflow_pending_#{base_temp_id}" - # Handle collisions: if this external_id already exists for this account, - # append a counter to make it unique. This prevents multiple pending transactions - # with identical attributes (e.g., two same-day Uber rides) from colliding. - # We check both the account's entries and the current raw payload being processed. + # Check if entry with this external_id already exists + # If it does AND it's still pending, reuse the same ID for re-sync. + # The import adapter's skip logic will handle user edits correctly. + # We DON'T check if attributes match - user edits should not cause duplicates. + if entry_exists_with_external_id?(temp_id_with_prefix) + existing_entry = account.entries.find_by(external_id: temp_id_with_prefix, source: "lunchflow") + if existing_entry && existing_entry.entryable.is_a?(Transaction) && existing_entry.entryable.pending? + Rails.logger.debug "Lunchflow: Reusing ID #{temp_id_with_prefix} for re-synced pending transaction" + return temp_id_with_prefix + end + end + + # Handle true collisions: multiple different transactions with same attributes + # (e.g., two Uber rides on the same day for the same amount within the same sync) final_id = temp_id_with_prefix counter = 1 diff --git a/app/models/transaction/search.rb b/app/models/transaction/search.rb index 1bee4ecb6..fa51fb55d 100644 --- a/app/models/transaction/search.rb +++ b/app/models/transaction/search.rb @@ -185,11 +185,13 @@ class Transaction::Search pending_condition = <<~SQL.squish (transactions.extra -> 'simplefin' ->> 'pending')::boolean = true OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true + OR (transactions.extra -> 'lunchflow' ->> 'pending')::boolean = true SQL confirmed_condition = <<~SQL.squish (transactions.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true AND (transactions.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true + AND (transactions.extra -> 'lunchflow' ->> 'pending')::boolean IS DISTINCT FROM true SQL case statuses.sort diff --git a/test/models/lunchflow_entry/processor_test.rb b/test/models/lunchflow_entry/processor_test.rb index 2508202d6..a597b2f58 100644 --- a/test/models/lunchflow_entry/processor_test.rb +++ b/test/models/lunchflow_entry/processor_test.rb @@ -151,15 +151,10 @@ class LunchflowEntry::ProcessorTest < ActiveSupport::TestCase # Verify the entry has a generated external_id (since we can't have blank IDs) assert result.external_id.present? assert_match /^lunchflow_pending_[a-f0-9]{32}$/, result.external_id - - # Note: Calling the processor again with identical data will trigger collision - # detection and create a SECOND entry (with _1 suffix). In real syncs, the - # importer's deduplication prevents this. For true idempotency testing, - # use the importer, not the processor directly. end - test "generates unique IDs for multiple pending transactions with identical attributes" do - # Two pending transactions with same merchant, amount, date (e.g., two Uber rides) + test "does not duplicate pending transaction when synced multiple times" do + # Create a pending transaction transaction_data = { id: "", accountId: 456, @@ -178,9 +173,14 @@ class LunchflowEntry::ProcessorTest < ActiveSupport::TestCase ).process assert_not_nil result1 - assert_match /^lunchflow_pending_[a-f0-9]{32}$/, result1.external_id + transaction1 = result1.entryable + assert transaction1.pending? + assert_equal true, transaction1.extra.dig("lunchflow", "pending") - # Process second transaction with IDENTICAL attributes + # Count entries before second sync + entries_before = @account.entries.where(source: "lunchflow").count + + # Second sync - same pending transaction (still hasn't posted) result2 = LunchflowEntry::Processor.new( transaction_data, lunchflow_account: @lunchflow_account @@ -188,15 +188,61 @@ class LunchflowEntry::ProcessorTest < ActiveSupport::TestCase assert_not_nil result2 - # Should create a DIFFERENT entry (not update the first one) - assert_not_equal result1.id, result2.id, "Should create separate entries for distinct pending transactions" + # Should return the SAME entry, not create a duplicate + assert_equal result1.id, result2.id, "Should update existing pending transaction, not create duplicate" - # Second should have a counter appended to avoid collision - assert_match /^lunchflow_pending_[a-f0-9]{32}_\d+$/, result2.external_id - assert_not_equal result1.external_id, result2.external_id, "Should generate different external_ids to avoid collision" + # Verify no new entries were created + entries_after = @account.entries.where(source: "lunchflow").count + assert_equal entries_before, entries_after, "Should not create duplicate entry on re-sync" + end - # Verify both transactions exist - entries = @account.entries.where(source: "lunchflow", "entries.date": "2025-01-15") - assert_equal 2, entries.count, "Should have created 2 separate entries" + test "does not duplicate pending transaction when user has edited it" do + # User imports a pending transaction, then edits it (name, amount, date) + # Next sync should update the same entry, not create a duplicate + transaction_data = { + id: "", + accountId: 456, + amount: -25.50, + currency: "USD", + date: "2025-01-20", + merchant: "Coffee Shop", + description: "Morning coffee", + isPending: true + } + + # First sync - import the pending transaction + result1 = LunchflowEntry::Processor.new( + transaction_data, + lunchflow_account: @lunchflow_account + ).process + + assert_not_nil result1 + original_external_id = result1.external_id + + # User edits the transaction (common scenario) + result1.update!(name: "Coffee Shop Downtown", amount: 26.00) + result1.reload + + # Verify the edits were applied + assert_equal "Coffee Shop Downtown", result1.name + assert_equal 26.00, result1.amount + + entries_before = @account.entries.where(source: "lunchflow").count + + # Second sync - same pending transaction data from provider (unchanged) + result2 = LunchflowEntry::Processor.new( + transaction_data, + lunchflow_account: @lunchflow_account + ).process + + assert_not_nil result2 + + # Should return the SAME entry (same external_id, not a _1 suffix) + assert_equal result1.id, result2.id, "Should reuse existing entry even when user edited it" + assert_equal original_external_id, result2.external_id, "Should not create new external_id for user-edited entry" + + # Verify no duplicate was created + entries_after = @account.entries.where(source: "lunchflow").count + assert_equal entries_before, entries_after, "Should not create duplicate when user has edited pending transaction" end end From 408bdd6788562cbce3cc487b9fd312ddb946f538 Mon Sep 17 00:00:00 2001 From: Number Eight <55629655+CylonN8@users.noreply.github.com> Date: Sun, 1 Feb 2026 23:50:39 +0100 Subject: [PATCH 037/108] fix: transaction UI padding and mobile selection bar position (#847) * style: adjust the bottom position of the transaction selection bar and remove unnecessary padding from transaction forms * fix: prevent overlap with the navbar in PWA mode * fix: prevent selection bar overlap with navbar in PWA mode * Update _selection_bar.html.erb Signed-off-by: Number Eight <55629655+CylonN8@users.noreply.github.com> * Update _selection_bar.html.erb Signed-off-by: Number Eight <55629655+CylonN8@users.noreply.github.com> --------- Signed-off-by: Number Eight <55629655+CylonN8@users.noreply.github.com> --- app/views/entries/_selection_bar.html.erb | 2 +- .../transactions/_selection_bar.html.erb | 2 +- .../transactions/bulk_updates/new.html.erb | 4 +- app/views/transactions/show.html.erb | 76 +++++++++---------- 4 files changed, 40 insertions(+), 44 deletions(-) diff --git a/app/views/entries/_selection_bar.html.erb b/app/views/entries/_selection_bar.html.erb index 4db508efb..45c51d0e4 100644 --- a/app/views/entries/_selection_bar.html.erb +++ b/app/views/entries/_selection_bar.html.erb @@ -1,4 +1,4 @@ -
    +
    <%= check_box_tag "entry_selection", 1, true, class: "checkbox checkbox--light", data: { action: "bulk-select#deselectAll" } %> diff --git a/app/views/transactions/_selection_bar.html.erb b/app/views/transactions/_selection_bar.html.erb index 1e29c9d65..062b442ad 100644 --- a/app/views/transactions/_selection_bar.html.erb +++ b/app/views/transactions/_selection_bar.html.erb @@ -1,4 +1,4 @@ -
    +
    <%= check_box_tag "entry_selection", 1, true, class: "checkbox checkbox--light", data: { action: "bulk-select#deselectAll" } %> diff --git a/app/views/transactions/bulk_updates/new.html.erb b/app/views/transactions/bulk_updates/new.html.erb index 11e3bf3ff..7b6c1e080 100644 --- a/app/views/transactions/bulk_updates/new.html.erb +++ b/app/views/transactions/bulk_updates/new.html.erb @@ -5,9 +5,7 @@ <%= styled_form_with url: transactions_bulk_update_path, scope: "bulk_update", class: "h-full flex flex-col justify-between gap-4", data: { turbo_frame: "_top" } do |form| %>
    <%= render DS::Disclosure.new(title: "Overview", open: true) do %> -
    - <%= form.date_field :date, label: "Date", max: Date.current %> -
    + <%= form.date_field :date, label: "Date", max: Date.current %> <% end %> <%= render DS::Disclosure.new(title: "Transactions", open: true) do %> diff --git a/app/views/transactions/show.html.erb b/app/views/transactions/show.html.erb index 0b875ebc8..f2914d9b8 100644 --- a/app/views/transactions/show.html.erb +++ b/app/views/transactions/show.html.erb @@ -48,7 +48,7 @@ <%= render "entries/protection_indicator", entry: @entry, unlock_path: unlock_transaction_path(@entry.transaction) %> <% dialog.with_section(title: t(".overview"), open: true) do %> -
    +
    <%= styled_form_with model: @entry, url: transaction_path(@entry), class: "space-y-2", @@ -95,49 +95,47 @@ <% end %> <% dialog.with_section(title: t(".details")) do %> -
    - <%= styled_form_with model: @entry, - url: transaction_path(@entry), - class: "space-y-2", - data: { controller: "auto-submit-form" } do |f| %> - <% unless @entry.transaction.transfer? %> - <%= f.select :account, - options_for_select( - Current.family.accounts.alphabetically.pluck(:name, :id), - @entry.account_id - ), - { label: t(".account_label") }, - { disabled: true } %> + <%= styled_form_with model: @entry, + url: transaction_path(@entry), + class: "space-y-2", + data: { controller: "auto-submit-form" } do |f| %> + <% unless @entry.transaction.transfer? %> + <%= f.select :account, + options_for_select( + Current.family.accounts.alphabetically.pluck(:name, :id), + @entry.account_id + ), + { label: t(".account_label") }, + { disabled: true } %> - <%= f.fields_for :entryable do |ef| %> + <%= f.fields_for :entryable do |ef| %> - <%= ef.collection_select :merchant_id, - Current.family.available_merchants.alphabetically, - :id, :name, - { include_blank: t(".none"), - label: t(".merchant_label"), - class: "text-subdued" }, - "data-auto-submit-form-target": "auto" %> - - <%= ef.select :tag_ids, - Current.family.tags.alphabetically.pluck(:name, :id), - { - include_blank: t(".none"), - multiple: true, - label: t(".tags_label") - }, - { "data-controller": "multi-select", "data-auto-submit-form-target": "auto" } %> - <% end %> - <% end %> - - <%= f.text_area :notes, - label: t(".note_label"), - placeholder: t(".note_placeholder"), - rows: 5, + <%= ef.collection_select :merchant_id, + Current.family.available_merchants.alphabetically, + :id, :name, + { include_blank: t(".none"), + label: t(".merchant_label"), + class: "text-subdued" }, "data-auto-submit-form-target": "auto" %> + <%= ef.select :tag_ids, + Current.family.tags.alphabetically.pluck(:name, :id), + { + include_blank: t(".none"), + multiple: true, + label: t(".tags_label") + }, + { "data-controller": "multi-select", "data-auto-submit-form-target": "auto" } %> + <% end %> <% end %> -
    + + <%= f.text_area :notes, + label: t(".note_label"), + placeholder: t(".note_placeholder"), + rows: 5, + "data-auto-submit-form-target": "auto" %> + + <% end %> <% end %> <% if (details = build_transaction_extra_details(@entry)) %> From 2ac3f3dff01ccaa0beb6a2b578f92275acfbc9ee Mon Sep 17 00:00:00 2001 From: Lazy Bone <89256478+dwvwdv@users.noreply.github.com> Date: Mon, 2 Feb 2026 16:47:01 +0800 Subject: [PATCH 038/108] Add account filtering and net worth card to dashboard (#818) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mobile): optimize asset/liability display with filters - Add NetWorthCard widget with placeholder for future net worth API - Add side-by-side Assets/Liabilities display with tap-to-filter - Implement CurrencyFilter widget for multi-select currency filtering - Replace old _SummaryCard with new unified design - Remove _CollapsibleSectionHeader in favor of filter-based navigation The net worth section shows a placeholder as the API endpoint is not yet available. Users can now filter accounts by type (assets/liabilities) and by currency. https://claude.ai/code/session_01W8cQSCzmgTmTqwRJ8Ycpx3 * fix(mobile): remove unused variables and add const - Remove unused _totalAssets, _totalLiabilities, _getPrimaryCurrency - Add const to Text('All') widget https://claude.ai/code/session_01W8cQSCzmgTmTqwRJ8Ycpx3 * feat(mobile): enhance dashboard with icons, long-press breakdown, and grouped view - NetWorthCard: replace text labels with trending icons, add colored bottom borders for asset (green) and liability (red) sections - Add long-press gesture on asset/liability areas to show full currency breakdown in a bottom sheet popup - Add collapsible account type grouping (Crypto, Bank, Investment, etc.) with type-specific icons and expand/collapse headers - Add PreferencesService for persisting display settings - Add "Group by Account Type" toggle in Settings screen - Wire settings change to dashboard via GlobalKey for live updates https://claude.ai/code/session_01W8cQSCzmgTmTqwRJ8Ycpx3 * refactor(mobile): remove welcome header from dashboard Strip the Welcome greeting and subtitle to let the financial overview take immediate focus. https://claude.ai/code/session_01W8cQSCzmgTmTqwRJ8Ycpx3 * feat(mobile): compact filter buttons with scroll-wheel currency switcher - Remove trending icons from asset/liability filter buttons - Increase amount font size to titleMedium bold - Reduce Net Worth section and filter button padding - Show single currency at a time with ListWheelScrollView for scrolling between currencies (wheel-picker style) - Absorb scroll events via NotificationListener to prevent triggering pull-to-refresh - Keep icons in the long-press currency breakdown popup https://claude.ai/code/session_01W8cQSCzmgTmTqwRJ8Ycpx3 * fix(mobile): prevent bottom sheet overflow with ConstrainedBox Use ConstrainedBox + ListView.separated with shrinkWrap for the currency breakdown popup. Few currencies: sheet sizes to content. Many currencies: caps at 50% screen height and scrolls. Also add isScrollControlled and useSafeArea to showModalBottomSheet. https://claude.ai/code/session_01W8cQSCzmgTmTqwRJ8Ycpx3 * fix(mobile): reload dashboard preferences on any tab switch to Home Previously only reloaded when navigating directly from Settings to Home. Now reloads whenever the Home tab is selected, covering paths like Settings -> More -> Home. https://claude.ai/code/session_01W8cQSCzmgTmTqwRJ8Ycpx3 * chore(mobile): simplify net worth placeholder to single line Replace the two-line Net Worth / -- placeholder with a compact "Net Worth — coming soon" label while the API endpoint is pending. https://claude.ai/code/session_01W8cQSCzmgTmTqwRJ8Ycpx3 --------- Co-authored-by: Claude --- .../lib/screens/main_navigation_screen.dart | 6 ++--- mobile/lib/widgets/net_worth_card.dart | 22 +++++-------------- 2 files changed, 7 insertions(+), 21 deletions(-) diff --git a/mobile/lib/screens/main_navigation_screen.dart b/mobile/lib/screens/main_navigation_screen.dart index 281524b27..8d002851f 100644 --- a/mobile/lib/screens/main_navigation_screen.dart +++ b/mobile/lib/screens/main_navigation_screen.dart @@ -13,7 +13,6 @@ class MainNavigationScreen extends StatefulWidget { class _MainNavigationScreenState extends State { int _currentIndex = 0; - int _previousIndex = 0; final _dashboardKey = GlobalKey(); late final List _screens; @@ -39,12 +38,11 @@ class _MainNavigationScreenState extends State { bottomNavigationBar: NavigationBar( selectedIndex: _currentIndex, onDestinationSelected: (index) { - _previousIndex = _currentIndex; setState(() { _currentIndex = index; }); - // Reload preferences when switching back to dashboard from settings - if (index == 0 && _previousIndex == 3) { + // Reload preferences whenever switching back to dashboard + if (index == 0) { _dashboardKey.currentState?.reloadPreferences(); } }, diff --git a/mobile/lib/widgets/net_worth_card.dart b/mobile/lib/widgets/net_worth_card.dart index 0d1c8ccda..241a7b91e 100644 --- a/mobile/lib/widgets/net_worth_card.dart +++ b/mobile/lib/widgets/net_worth_card.dart @@ -36,23 +36,11 @@ class NetWorthCard extends StatelessWidget { // Net Worth Section (Placeholder) Padding( padding: const EdgeInsets.fromLTRB(16, 14, 16, 12), - child: Column( - children: [ - Text( - 'Net Worth', - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 2), - Text( - '--', - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - ), - ], + child: Text( + 'Net Worth — coming soon', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), ), From 146d6203fd4575b1c25ecd759db8f4129204f8f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Mon, 2 Feb 2026 16:15:14 +0100 Subject: [PATCH 039/108] Still `alpha.2` out there --- charts/sure/Chart.yaml | 4 ++-- config/initializers/version.rb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/charts/sure/Chart.yaml b/charts/sure/Chart.yaml index 95bab97ac..443186098 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: 0.6.8-alpha.3 -appVersion: "0.6.8-alpha.3" +version: 0.6.8-alpha.2 +appVersion: "0.6.8-alpha.2" kubeVersion: ">=1.25.0-0" diff --git a/config/initializers/version.rb b/config/initializers/version.rb index 6c2500749..63637835d 100644 --- a/config/initializers/version.rb +++ b/config/initializers/version.rb @@ -16,7 +16,7 @@ module Sure private def semver - "0.6.8-alpha.3" + "0.6.8-alpha.2" end end end From 0afdb1d0fde51ed8baf265b44d3ccc54637fe962 Mon Sep 17 00:00:00 2001 From: MkDev11 <94194147+MkDev11@users.noreply.github.com> Date: Mon, 2 Feb 2026 10:27:02 -0500 Subject: [PATCH 040/108] Feature/pdf import transaction rows (#846) * Add import row generation from PDF extracted data - Add generate_rows_from_extracted_data method to PdfImport - Add import! method to create transactions from PDF rows - Update ProcessPdfJob to generate rows after extraction - Update configured?, cleaned?, publishable? for PDF workflow - Add column_keys, required_column_keys, mapping_steps - Set bank statements to pending status for user review - Add tests for new functionality Closes #844 * Add tests for BankStatementExtractor - Test transaction extraction from PDF content - Test deduplication across chunk boundaries - Test amount normalization for various formats - Test graceful handling of malformed JSON responses - Test error handling for empty/nil PDF content * Fix supports_pdf_processing? to validate effective model The validation was always checking @default_model, but process_pdf allows overriding the model via parameter. This could cause a vision-capable override model to be rejected, or a non-vision-capable override to pass validation only to fail during processing. Changes: - supports_pdf_processing? now accepts optional model parameter - process_pdf passes effective model to validation - Raise Provider::Openai::Error inside with_provider_response for consistent error handling Addresses review feedback from PR#808 * Fix insert_all! bug: explicitly set import_id Rails insert_all! on associations does NOT auto-set the foreign key. Added import_id explicitly and use Import::Row.insert_all! directly. Also reload rows before counting to ensure accurate count. * Fix pending status showing as processing for bank statements with rows When bank statement PDF imports have extracted rows, show a 'Ready for Review' screen with a link to the confirm path instead of the 'Processing' spinner. This addresses the PR feedback that users couldn't reach the review flow even though rows were created. * Gate publishable? on account.present? to prevent import failure PDF imports are created without an account, and import! raises if account is missing. This prevents users from hitting publish and having the job fail. * Wrap generate_rows_from_extracted_data in transaction for atomicity - Clear rows and reset count even when no transactions extracted - Use transaction block to prevent partial updates on failure - Use mapped_rows.size instead of reload for count * Localize transactions count string with i18n helper * Add AccountMapping step for PDF imports when account is nil PDF imports need account selection before publishing. This adds Import::AccountMapping to mapping_steps when account is nil, matching the behavior of TransactionImport and TradeImport. Addresses PR#846 feedback about account selection for PDF imports. * Only include CategoryMapping when rows have non-empty categories PDF extraction doesn't extract categories from bank statements, so the CategoryMapping step would show empty. Now we only include CategoryMapping if rows actually have non-empty category values. This prevents showing an empty mapping step for PDF imports. * Fix PDF import UI flow and account selection - Add direct account selection in PDF import UI instead of AccountMapping - AccountMapping designed for CSV imports with multiple account values - PDF imports need single account for all transactions - Add update action and route for imports controller - Fix controller to handle pdf_import param format from form_with - Show Publish button when import is publishable (account set) - Fix stepper nav: Upload/Configure/Clean non-clickable for PDF imports - Redirect PDF imports from configuration step (auto-configured) - Improve AI prompt to recognize M-PESA/mobile money as bank statements - Fix migration ordering for import_rows table columns * Add guard for invalid account_id in imports#update Prevents silently clearing account when invalid ID is passed. Returns error message instead of confusing 'Account saved' notice. * Localize step names in import nav and add account guard - Use t() helper for all step names (Upload, Configure, Clean, Map, Confirm) - Add guard for invalid account_id in imports#update - Prevents silently clearing account when invalid ID is passed * Make category column migrations idempotent Check if columns exist before adding to prevent duplicate column errors when migrations are re-run with new timestamps. * Add match_path for PDF import step highlighting Fixes step detection when path is nil by using separate match_path for current step highlighting while keeping links disabled. * Rename category migrations and update to Rails 7.2 - Rename class to EnsureCategoryFieldsOnImportRows to avoid conflicts - Rename class to EnsureCategoryIconOnImportRows - Update migration version from 7.1 to 7.2 per guidelines - Rename files to match class names - Add match_path for PDF import step highlighting * Use primary (black) style for Create Account and Save buttons * Remove match_path from auto-completed PDF steps Only step 4 (Confirm) needs match_path for active-step detection. Steps 1-3 are purely informational and always complete. * Add fallback for document type translation Handles nil or unexpected document_type values gracefully. Also removes match_path from auto-completed PDF steps. * Use index-based step number for mobile indicator Fixes 'Step 5 of 4' issue when Map step is dynamically removed. * Fix hostings_controller_test: use blank? instead of nil Setting returns empty string not nil for unset values. * Localize step progress label and use design token * Fix button styling: use design system Tailwind classes btn--primary and btn--secondary CSS classes don't exist. Use actual design system classes from DS::Buttonish. * Fix CRLF line endings in tags_controller_test.rb --------- Co-authored-by: mkdev11 --- .../import/configurations_controller.rb | 2 + app/controllers/imports_controller.rb | 18 +- app/jobs/process_pdf_job.rb | 13 +- app/models/pdf_import.rb | 87 +++++++- app/models/provider/openai.rb | 9 +- app/models/provider/openai/pdf_processor.rb | 2 +- app/views/imports/_nav.html.erb | 40 ++-- app/views/imports/_pdf_import.html.erb | 67 +++++- config/routes.rb | 2 +- ...0000_add_category_fields_to_import_rows.rb | 7 - ...000001_add_category_icon_to_import_rows.rb | 5 - ...9_ensure_category_fields_on_import_rows.rb | 7 + ...220_ensure_category_icon_on_import_rows.rb | 5 + .../settings/hostings_controller_test.rb | 2 +- test/fixtures/imports.yml | 21 ++ test/models/pdf_import_test.rb | 84 +++++++- .../openai/bank_statement_extractor_test.rb | 197 ++++++++++++++++++ 17 files changed, 515 insertions(+), 53 deletions(-) delete mode 100644 db/migrate/20240701000000_add_category_fields_to_import_rows.rb delete mode 100644 db/migrate/20240701000001_add_category_icon_to_import_rows.rb create mode 100644 db/migrate/20240925112219_ensure_category_fields_on_import_rows.rb create mode 100644 db/migrate/20240925112220_ensure_category_icon_on_import_rows.rb create mode 100644 test/models/provider/openai/bank_statement_extractor_test.rb diff --git a/app/controllers/import/configurations_controller.rb b/app/controllers/import/configurations_controller.rb index 12e47a477..6602e3fbe 100644 --- a/app/controllers/import/configurations_controller.rb +++ b/app/controllers/import/configurations_controller.rb @@ -4,6 +4,8 @@ class Import::ConfigurationsController < ApplicationController before_action :set_import def show + # PDF imports are auto-configured from AI extraction, skip to clean step + redirect_to import_clean_path(@import) if @import.is_a?(PdfImport) end def update diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb index 88a346838..f1a217529 100644 --- a/app/controllers/imports_controller.rb +++ b/app/controllers/imports_controller.rb @@ -1,7 +1,23 @@ class ImportsController < ApplicationController include SettingsHelper - before_action :set_import, only: %i[show publish destroy revert apply_template] + before_action :set_import, only: %i[show update publish destroy revert apply_template] + + def update + # Handle both pdf_import[account_id] and import[account_id] param formats + account_id = params.dig(:pdf_import, :account_id) || params.dig(:import, :account_id) + + if account_id.present? + account = Current.family.accounts.find_by(id: account_id) + unless account + redirect_back_or_to import_path(@import), alert: t("imports.update.invalid_account", default: "Account not found.") + return + end + @import.update!(account: account) + end + + redirect_to import_path(@import), notice: t("imports.update.account_saved", default: "Account saved.") + end def publish @import.publish_later diff --git a/app/jobs/process_pdf_job.rb b/app/jobs/process_pdf_job.rb index 25c31f11f..8fb4fccef 100644 --- a/app/jobs/process_pdf_job.rb +++ b/app/jobs/process_pdf_job.rb @@ -5,18 +5,22 @@ class ProcessPdfJob < ApplicationJob return unless pdf_import.is_a?(PdfImport) return unless pdf_import.pdf_uploaded? return if pdf_import.status == "complete" - return if pdf_import.ai_processed? && (!pdf_import.bank_statement? || pdf_import.has_extracted_transactions?) + return if pdf_import.ai_processed? && (!pdf_import.bank_statement? || pdf_import.rows_count > 0) pdf_import.update!(status: :importing) begin pdf_import.process_with_ai - # For bank statements, extract transactions + # For bank statements, extract transactions and generate import rows if pdf_import.bank_statement? Rails.logger.info("ProcessPdfJob: Extracting transactions for bank statement import #{pdf_import.id}") pdf_import.extract_transactions Rails.logger.info("ProcessPdfJob: Extracted #{pdf_import.extracted_transactions.size} transactions") + + pdf_import.generate_rows_from_extracted_data + pdf_import.sync_mappings + Rails.logger.info("ProcessPdfJob: Generated #{pdf_import.rows_count} import rows") end # Find the user who created this import (first admin or any user in the family) @@ -26,7 +30,10 @@ class ProcessPdfJob < ApplicationJob pdf_import.send_next_steps_email(user) end - pdf_import.update!(status: :complete) + # Bank statements with rows go to pending for user review/publish + # Non-bank statements are marked complete (no further action needed) + final_status = pdf_import.bank_statement? && pdf_import.rows_count > 0 ? :pending : :complete + pdf_import.update!(status: final_status) rescue StandardError => e sanitized_error = sanitize_error_message(e) Rails.logger.error("PDF processing failed for import #{pdf_import.id}: #{e.class.name} - #{sanitized_error}") diff --git a/app/models/pdf_import.rb b/app/models/pdf_import.rb index 8b25e8bfa..0e0250462 100644 --- a/app/models/pdf_import.rb +++ b/app/models/pdf_import.rb @@ -3,6 +3,34 @@ class PdfImport < Import validates :document_type, inclusion: { in: DOCUMENT_TYPES }, allow_nil: true + def import! + raise "Account required for PDF import" unless account.present? + + transaction do + mappings.each(&:create_mappable!) + + new_transactions = rows.map do |row| + category = mappings.categories.mappable_for(row.category) + + Transaction.new( + category: category, + entry: Entry.new( + account: account, + date: row.date_iso, + amount: row.signed_amount, + name: row.name, + currency: row.currency, + notes: row.notes, + import: self, + import_locked: true + ) + ) + end + + Transaction.import!(new_transactions, recursive: true) if new_transactions.any? + end + end + def pdf_uploaded? pdf_file.attached? end @@ -71,6 +99,34 @@ class PdfImport < Import extracted_data&.dig("transactions") || [] end + def generate_rows_from_extracted_data + transaction do + rows.destroy_all + + unless has_extracted_transactions? + update_column(:rows_count, 0) + return + end + + currency = account&.currency || family.currency + + mapped_rows = extracted_transactions.map do |txn| + { + import_id: id, + date: format_date_for_import(txn["date"]), + amount: txn["amount"].to_s, + name: txn["name"].to_s, + category: txn["category"].to_s, + notes: txn["notes"].to_s, + currency: currency + } + end + + Import::Row.insert_all!(mapped_rows) if mapped_rows.any? + update_column(:rows_count, mapped_rows.size) + end + end + def send_next_steps_email(user) PdfImportMailer.with( user: user, @@ -83,19 +139,19 @@ class PdfImport < Import end def configured? - ai_processed? + ai_processed? && rows_count > 0 end def cleaned? - ai_processed? + configured? && rows.all?(&:valid?) end def publishable? - false + account.present? && bank_statement? && cleaned? && mappings.all?(&:valid?) end def column_keys - [] + %i[date amount name category notes] end def requires_csv_workflow? @@ -107,4 +163,27 @@ class PdfImport < Import pdf_file.download end + + def required_column_keys + %i[date amount] + end + + def mapping_steps + base = [] + # Only include CategoryMapping if rows have non-empty categories + base << Import::CategoryMapping if rows.where.not(category: [ nil, "" ]).exists? + # Note: PDF imports use direct account selection in the UI, not AccountMapping + # AccountMapping is designed for CSV imports where rows have different account values + base + end + + private + + def format_date_for_import(date_str) + return "" if date_str.blank? + + Date.parse(date_str).strftime(date_format) + rescue ArgumentError + date_str.to_s + end end diff --git a/app/models/provider/openai.rb b/app/models/provider/openai.rb index 08ac224f9..6ec10333d 100644 --- a/app/models/provider/openai.rb +++ b/app/models/provider/openai.rb @@ -118,21 +118,20 @@ class Provider::Openai < Provider # Can be disabled via ENV for OpenAI-compatible endpoints that don't support vision # Only vision-capable models (gpt-4o, gpt-4-turbo, gpt-4.1, etc.) support PDF input - def supports_pdf_processing? + def supports_pdf_processing?(model: @default_model) return false unless ENV.fetch("OPENAI_SUPPORTS_PDF_PROCESSING", "true").to_s.downcase.in?(%w[true 1 yes]) # Custom providers manage their own model capabilities return true if custom_provider? - # Check if the configured model supports vision/PDF input - VISION_CAPABLE_MODEL_PREFIXES.any? { |prefix| @default_model.start_with?(prefix) } + # Check if the specified model supports vision/PDF input + VISION_CAPABLE_MODEL_PREFIXES.any? { |prefix| model.start_with?(prefix) } end def process_pdf(pdf_content:, model: "", family: nil) - raise "Model does not support PDF/vision processing" unless supports_pdf_processing? - with_provider_response do effective_model = model.presence || @default_model + raise Error, "Model does not support PDF/vision processing: #{effective_model}" unless supports_pdf_processing?(model: effective_model) trace = create_langfuse_trace( name: "openai.process_pdf", diff --git a/app/models/provider/openai/pdf_processor.rb b/app/models/provider/openai/pdf_processor.rb index b99caa77c..f65510e87 100644 --- a/app/models/provider/openai/pdf_processor.rb +++ b/app/models/provider/openai/pdf_processor.rb @@ -42,7 +42,7 @@ class Provider::Openai::PdfProcessor For each document, you must determine: 1. **Document Type**: Classify the document as one of the following: - - `bank_statement`: A bank account statement showing transactions, balances, and account activity + - `bank_statement`: A bank account statement showing transactions, balances, and account activity. This includes mobile money statements (like M-PESA, Venmo, PayPal, Cash App), digital wallet statements, and any statement showing a list of financial transactions with dates and amounts. - `credit_card_statement`: A credit card statement showing charges, payments, and balances - `investment_statement`: An investment/brokerage statement showing holdings, trades, or portfolio performance - `financial_document`: General financial documents like tax forms, receipts, invoices, or financial reports diff --git a/app/views/imports/_nav.html.erb b/app/views/imports/_nav.html.erb index 898c78255..26435d8b6 100644 --- a/app/views/imports/_nav.html.erb +++ b/app/views/imports/_nav.html.erb @@ -1,18 +1,29 @@ <%# locals: (import:) %> -<% steps = [ - { name: "Upload", path: import_upload_path(import), is_complete: import.uploaded?, step_number: 1 }, - { name: "Configure", path: import_configuration_path(import), is_complete: import.configured?, step_number: 2 }, - { name: "Clean", path: import_clean_path(import), is_complete: import.cleaned?, step_number: 3 }, - { name: "Map", path: import_confirm_path(import), is_complete: import.publishable?, step_number: 4 }, - { name: "Confirm", path: import_path(import), is_complete: import.complete?, step_number: 5 } -].reject { |step| step[:name] == "Map" && import.mapping_steps.empty? } %> +<% steps = if import.is_a?(PdfImport) + # PDF imports have a simplified flow: Upload -> Confirm + # Upload/Configure/Clean are always complete for processed PDF imports + [ + { name: t("imports.steps.upload", default: "Upload"), path: nil, is_complete: import.pdf_uploaded?, step_number: 1 }, + { name: t("imports.steps.configure", default: "Configure"), path: nil, is_complete: import.configured?, step_number: 2 }, + { name: t("imports.steps.clean", default: "Clean"), path: nil, is_complete: import.cleaned?, step_number: 3 }, + { name: t("imports.steps.confirm", default: "Confirm"), path: import_path(import), is_complete: import.complete?, step_number: 4 } + ] +else + [ + { name: t("imports.steps.upload", default: "Upload"), path: import_upload_path(import), is_complete: import.uploaded?, step_number: 1 }, + { name: t("imports.steps.configure", default: "Configure"), path: import_configuration_path(import), is_complete: import.configured?, step_number: 2 }, + { name: t("imports.steps.clean", default: "Clean"), path: import_clean_path(import), is_complete: import.cleaned?, step_number: 3 }, + { name: t("imports.steps.map", default: "Map"), key: "Map", path: import_confirm_path(import), is_complete: import.publishable?, step_number: 4 }, + { name: t("imports.steps.confirm", default: "Confirm"), path: import_path(import), is_complete: import.complete?, step_number: 5 } + ].reject { |step| step[:key] == "Map" && import.mapping_steps.empty? } +end %> <% content_for :mobile_import_progress do %> - <% active_step = steps.detect { |s| request.path.eql?(s[:path]) } %> - <% if active_step.present? %> + <% active_step_index = steps.index { |s| request.path.eql?(s[:match_path] || s[:path]) } %> + <% if active_step_index %>
    - Step <%= active_step[:step_number] %> of <%= steps.size %> + <%= t("imports.steps.progress", step: active_step_index + 1, total: steps.size, default: "Step %{step} of %{total}") %>
    <% end %> <% end %> @@ -20,7 +31,7 @@
    -

    How things work here

    +

    <%= t(".how_it_works") %>

    @@ -64,18 +64,18 @@
    -

    Today

    -

    You'll get free access to Sure for 45 days on our AWS.

    +

    <%= t(".today") %>

    +

    <%= t(".today_description") %>

    -

    In 40 days (<%= 40.days.from_now.strftime("%B %d") %>)

    -

    We'll notify you to remind you to export your data.

    +

    <%= t(".in_40_days", date: l(40.days.from_now.to_date, format: :long)) %>

    +

    <%= t(".in_40_days_description") %>

    -

    In 45 days (<%= 45.days.from_now.strftime("%B %d") %>)

    -

    We delete your data — contribute to continue using Sure here!

    +

    <%= t(".in_45_days", date: l(45.days.from_now.to_date, format: :long)) %>

    +

    <%= t(".in_45_days_description") %>

    diff --git a/app/views/registrations/new.html.erb b/app/views/registrations/new.html.erb index f1fb414ae..9c811087c 100644 --- a/app/views/registrations/new.html.erb +++ b/app/views/registrations/new.html.erb @@ -74,19 +74,19 @@
    <%= icon("check", size: "sm") %> - Minimum 8 characters + <%= t(".password_requirements.length") %>
    <%= icon("check", size: "sm") %> - Upper and lowercase letters + <%= t(".password_requirements.case") %>
    <%= icon("check", size: "sm") %> - A number (0-9) + <%= t(".password_requirements.number") %>
    <%= icon("check", size: "sm") %> - A special character (!, @, #, $, %, etc) + <%= t(".password_requirements.special") %>
    diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index ce92c5e54..c51f4977a 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -80,7 +80,7 @@ <% if provider[:icon].present? %> <%= icon provider[:icon], size: "sm" %> <% end %> - <%= provider[:label].presence || provider[:name].to_s.titleize %> + <%= provider[:label].presence || t(".#{provider_id}", default: provider[:name].to_s.titleize) %> <% end %> <% end %> <% end %> diff --git a/config/auth.yml b/config/auth.yml index ebcbc6ea0..162364ea6 100644 --- a/config/auth.yml +++ b/config/auth.yml @@ -35,7 +35,7 @@ default: &default - id: "oidc" strategy: "openid_connect" name: "openid_connect" - label: <%= ENV.fetch("OIDC_BUTTON_LABEL", "Sign in with OpenID Connect") %> + label: <%= ENV.fetch("OIDC_BUTTON_LABEL", "") %> icon: <%= ENV.fetch("OIDC_BUTTON_ICON", "key") %> # Per-provider credentials (optional, falls back to global OIDC_* vars) issuer: <%= ENV["OIDC_ISSUER"] %> diff --git a/config/locales/views/oidc_accounts/ca.yml b/config/locales/views/oidc_accounts/ca.yml index 27f6429a7..06ad64409 100644 --- a/config/locales/views/oidc_accounts/ca.yml +++ b/config/locales/views/oidc_accounts/ca.yml @@ -2,4 +2,32 @@ ca: oidc_accounts: link: - account_creation_disabled: La creació de comptes nous mitjançant inici de sessió únic està inhabilitada. Contacta amb un administrador per crear el teu compte. + title_link: Enllaçar compte OIDC + title_create: Crear compte + verify_heading: Verificar la vostra identitat + verify_description_html: "Per enllaçar el vostre compte %{provider}%{email_suffix}, verifiqueu la vostra identitat introduint la contrasenya." + email_suffix_html: " (%{email})" + email_label: Correu electrònic + email_placeholder: Introduïu el vostre correu electrònic + password_label: Contrasenya + password_placeholder: Introduïu la vostra contrasenya + verify_hint: Això garanteix que només vós pugueu enllaçar comptes externs al vostre perfil. + submit_link: Enllaçar compte + create_heading: Crear compte nou + create_description_html: "No s'ha trobat cap compte amb el correu electrònic %{email}. Feu clic a baix per crear un compte nou amb la vostra identitat %{provider}." + info_email: "Correu electrònic:" + info_name: "Nom:" + submit_create: Crear compte + account_creation_disabled: La creació de comptes mitjançant l'inici de sessió únic està desactivada. Contacteu amb un administrador. + cancel: Cancel·lar + new_user: + title: Completar el compte + heading: Crear el compte + description: Confirmeu les vostres dades per completar la creació del compte amb la vostra identitat %{provider}. + email_label: Correu electrònic (del proveïdor SSO) + first_name_label: Nom + first_name_placeholder: Introduïu el vostre nom + last_name_label: Cognom + last_name_placeholder: Introduïu el vostre cognom + submit: Crear compte + cancel: Cancel·lar \ No newline at end of file diff --git a/config/locales/views/oidc_accounts/de.yml b/config/locales/views/oidc_accounts/de.yml new file mode 100644 index 000000000..476197ec0 --- /dev/null +++ b/config/locales/views/oidc_accounts/de.yml @@ -0,0 +1,33 @@ +--- +de: + oidc_accounts: + link: + title_link: OIDC-Konto verknüpfen + title_create: Konto erstellen + verify_heading: Identität bestätigen + verify_description_html: "Um Ihr %{provider}-Konto%{email_suffix} zu verknüpfen, bestätigen Sie bitte Ihre Identität mit Ihrem Passwort." + email_suffix_html: " (%{email})" + email_label: E-Mail + email_placeholder: E-Mail-Adresse eingeben + password_label: Passwort + password_placeholder: Passwort eingeben + verify_hint: Dies stellt sicher, dass nur Sie externe Konten mit Ihrem Profil verknüpfen können. + submit_link: Konto verknüpfen + create_heading: Neues Konto erstellen + create_description_html: "Kein Konto mit der E-Mail %{email} gefunden. Klicken Sie unten, um ein neues Konto mit Ihrer %{provider}-Identität zu erstellen." + info_email: "E-Mail:" + info_name: "Name:" + submit_create: Konto erstellen + account_creation_disabled: Die Kontoerstellung über Single Sign-On ist deaktiviert. Bitte kontaktieren Sie einen Administrator. + cancel: Abbrechen + new_user: + title: Konto vervollständigen + heading: Konto erstellen + description: Bitte bestätigen Sie Ihre Daten, um die Kontoerstellung mit Ihrer %{provider}-Identität abzuschließen. + email_label: E-Mail (vom SSO-Anbieter) + first_name_label: Vorname + first_name_placeholder: Vorname eingeben + last_name_label: Nachname + last_name_placeholder: Nachname eingeben + submit: Konto erstellen + cancel: Abbrechen \ No newline at end of file diff --git a/config/locales/views/oidc_accounts/en.yml b/config/locales/views/oidc_accounts/en.yml index 618baf7e2..4ed91677c 100644 --- a/config/locales/views/oidc_accounts/en.yml +++ b/config/locales/views/oidc_accounts/en.yml @@ -2,7 +2,24 @@ en: oidc_accounts: link: + title_link: Link OIDC Account + title_create: Create Account + verify_heading: Verify Your Identity + verify_description_html: "To link your %{provider} account%{email_suffix}, please verify your identity by entering your password." + email_suffix_html: " (%{email})" + email_label: Email + email_placeholder: Enter your email + password_label: Password + password_placeholder: Enter your password + verify_hint: This helps ensure that only you can link external accounts to your profile. + submit_link: Link Account + create_heading: Create New Account + create_description_html: "No account found with the email %{email}. Click below to create a new account using your %{provider} identity." + info_email: "Email:" + info_name: "Name:" + submit_create: Create Account account_creation_disabled: New account creation via single sign-on is disabled. Please contact an administrator to create your account. + cancel: Cancel new_user: title: Complete Your Account heading: Create Your Account diff --git a/config/locales/views/oidc_accounts/es.yml b/config/locales/views/oidc_accounts/es.yml new file mode 100644 index 000000000..22d9dd914 --- /dev/null +++ b/config/locales/views/oidc_accounts/es.yml @@ -0,0 +1,33 @@ +--- +es: + oidc_accounts: + link: + title_link: Vincular cuenta OIDC + title_create: Crear cuenta + verify_heading: Verificar su identidad + verify_description_html: "Para vincular su cuenta de %{provider}%{email_suffix}, verifique su identidad ingresando su contraseña." + email_suffix_html: " (%{email})" + email_label: Correo electrónico + email_placeholder: Ingrese su correo electrónico + password_label: Contraseña + password_placeholder: Ingrese su contraseña + verify_hint: Esto garantiza que solo usted pueda vincular cuentas externas a su perfil. + submit_link: Vincular cuenta + create_heading: Crear cuenta nueva + create_description_html: "No se encontró ninguna cuenta con el correo electrónico %{email}. Haga clic a continuación para crear una cuenta nueva con su identidad de %{provider}." + info_email: "Correo electrónico:" + info_name: "Nombre:" + submit_create: Crear cuenta + account_creation_disabled: La creación de cuentas mediante inicio de sesión único está desactivada. Contacte a un administrador. + cancel: Cancelar + new_user: + title: Completar su cuenta + heading: Crear su cuenta + description: Confirme sus datos para completar la creación de cuenta con su identidad de %{provider}. + email_label: Correo electrónico (del proveedor SSO) + first_name_label: Nombre + first_name_placeholder: Ingrese su nombre + last_name_label: Apellido + last_name_placeholder: Ingrese su apellido + submit: Crear cuenta + cancel: Cancelar \ No newline at end of file diff --git a/config/locales/views/oidc_accounts/fr.yml b/config/locales/views/oidc_accounts/fr.yml index b06a5d3dd..9260f97af 100644 --- a/config/locales/views/oidc_accounts/fr.yml +++ b/config/locales/views/oidc_accounts/fr.yml @@ -2,4 +2,32 @@ fr: oidc_accounts: link: - account_creation_disabled: La création de nouveaux comptes via l'authentification unique est désactivée. Veuillez contacter un administrateur pour créer votre compte. + title_link: Associer un compte OIDC + title_create: Créer un compte + verify_heading: Vérifier votre identité + verify_description_html: "Pour associer votre compte %{provider}%{email_suffix}, veuillez vérifier votre identité en saisissant votre mot de passe." + email_suffix_html: " (%{email})" + email_label: Adresse e-mail + email_placeholder: Saisissez votre adresse e-mail + password_label: Mot de passe + password_placeholder: Saisissez votre mot de passe + verify_hint: Cela garantit que vous seul pouvez associer des comptes externes à votre profil. + submit_link: Associer le compte + create_heading: Créer un nouveau compte + create_description_html: "Aucun compte trouvé avec l'adresse e-mail %{email}. Cliquez ci-dessous pour créer un nouveau compte avec votre identité %{provider}." + info_email: "E-mail :" + info_name: "Nom :" + submit_create: Créer un compte + account_creation_disabled: La création de compte via l'authentification unique est désactivée. Veuillez contacter un administrateur. + cancel: Annuler + new_user: + title: Compléter votre compte + heading: Créer votre compte + description: Veuillez confirmer vos informations pour finaliser la création de compte avec votre identité %{provider}. + email_label: E-mail (du fournisseur SSO) + first_name_label: Prénom + first_name_placeholder: Saisissez votre prénom + last_name_label: Nom de famille + last_name_placeholder: Saisissez votre nom de famille + submit: Créer un compte + cancel: Annuler \ No newline at end of file diff --git a/config/locales/views/oidc_accounts/nb.yml b/config/locales/views/oidc_accounts/nb.yml new file mode 100644 index 000000000..806d3735f --- /dev/null +++ b/config/locales/views/oidc_accounts/nb.yml @@ -0,0 +1,33 @@ +--- +nb: + oidc_accounts: + link: + title_link: Koble til OIDC-konto + title_create: Opprett konto + verify_heading: Bekreft identiteten din + verify_description_html: "For å koble til %{provider}-kontoen din%{email_suffix}, bekreft identiteten din ved å skrive inn passordet ditt." + email_suffix_html: " (%{email})" + email_label: E-postadresse + email_placeholder: Skriv inn e-postadressen din + password_label: Passord + password_placeholder: Skriv inn passordet ditt + verify_hint: Dette sikrer at bare du kan koble eksterne kontoer til profilen din. + submit_link: Koble til konto + create_heading: Opprett ny konto + create_description_html: "Ingen konto funnet med e-postadressen %{email}. Klikk nedenfor for å opprette en ny konto med din %{provider}-identitet." + info_email: "E-post:" + info_name: "Navn:" + submit_create: Opprett konto + account_creation_disabled: Kontooppretting via enkel pålogging er deaktivert. Kontakt en administrator. + cancel: Avbryt + new_user: + title: Fullfør kontoen din + heading: Opprett kontoen din + description: Bekreft opplysningene dine for å fullføre kontoopprettingen med din %{provider}-identitet. + email_label: E-post (fra SSO-leverandør) + first_name_label: Fornavn + first_name_placeholder: Skriv inn fornavnet ditt + last_name_label: Etternavn + last_name_placeholder: Skriv inn etternavnet ditt + submit: Opprett konto + cancel: Avbryt \ No newline at end of file diff --git a/config/locales/views/oidc_accounts/nl.yml b/config/locales/views/oidc_accounts/nl.yml index b0c510dd9..31b48587c 100644 --- a/config/locales/views/oidc_accounts/nl.yml +++ b/config/locales/views/oidc_accounts/nl.yml @@ -2,11 +2,28 @@ nl: oidc_accounts: link: - account_creation_disabled: Nieuwe accountaanmaak via single sign-on is uitgeschakeld. Neem contact op met een beheerder om uw account aan te maken. + title_link: OIDC-account koppelen + title_create: Account aanmaken + verify_heading: Verifieer uw identiteit + verify_description_html: "Om uw %{provider}-account%{email_suffix} te koppelen, verifieert u uw identiteit door uw wachtwoord in te voeren." + email_suffix_html: " (%{email})" + email_label: E-mailadres + email_placeholder: Voer uw e-mailadres in + password_label: Wachtwoord + password_placeholder: Voer uw wachtwoord in + verify_hint: Dit zorgt ervoor dat alleen u externe accounts aan uw profiel kunt koppelen. + submit_link: Account koppelen + create_heading: Nieuw account aanmaken + create_description_html: "Geen account gevonden met het e-mailadres %{email}. Klik hieronder om een nieuw account aan te maken met uw %{provider}-identiteit." + info_email: "E-mail:" + info_name: "Naam:" + submit_create: Account aanmaken + account_creation_disabled: Het aanmaken van accounts via single sign-on is uitgeschakeld. Neem contact op met een beheerder. + cancel: Annuleren new_user: - title: Voltooi uw account + title: Account voltooien heading: Account aanmaken - description: Bevestig uw gegevens om het aanmaken van uw account met uw %{provider} identiteit te voltooien. + description: Bevestig uw gegevens om het aanmaken van uw account met uw %{provider}-identiteit te voltooien. email_label: E-mail (van SSO-provider) first_name_label: Voornaam first_name_placeholder: Voer uw voornaam in diff --git a/config/locales/views/oidc_accounts/pt-BR.yml b/config/locales/views/oidc_accounts/pt-BR.yml new file mode 100644 index 000000000..791f8a1e7 --- /dev/null +++ b/config/locales/views/oidc_accounts/pt-BR.yml @@ -0,0 +1,33 @@ +--- +pt-BR: + oidc_accounts: + link: + title_link: Vincular conta OIDC + title_create: Criar conta + verify_heading: Verificar sua identidade + verify_description_html: "Para vincular sua conta %{provider}%{email_suffix}, verifique sua identidade digitando sua senha." + email_suffix_html: " (%{email})" + email_label: E-mail + email_placeholder: Digite seu e-mail + password_label: Senha + password_placeholder: Digite sua senha + verify_hint: Isso garante que apenas você possa vincular contas externas ao seu perfil. + submit_link: Vincular conta + create_heading: Criar nova conta + create_description_html: "Nenhuma conta encontrada com o e-mail %{email}. Clique abaixo para criar uma nova conta usando sua identidade %{provider}." + info_email: "E-mail:" + info_name: "Nome:" + submit_create: Criar conta + account_creation_disabled: A criação de contas via login único está desativada. Entre em contato com um administrador. + cancel: Cancelar + new_user: + title: Completar sua conta + heading: Criar sua conta + description: Confirme seus dados para concluir a criação da conta com sua identidade %{provider}. + email_label: E-mail (do provedor SSO) + first_name_label: Nome + first_name_placeholder: Digite seu nome + last_name_label: Sobrenome + last_name_placeholder: Digite seu sobrenome + submit: Criar conta + cancel: Cancelar \ No newline at end of file diff --git a/config/locales/views/oidc_accounts/ro.yml b/config/locales/views/oidc_accounts/ro.yml new file mode 100644 index 000000000..55cc8fe31 --- /dev/null +++ b/config/locales/views/oidc_accounts/ro.yml @@ -0,0 +1,33 @@ +--- +ro: + oidc_accounts: + link: + title_link: Asociază contul OIDC + title_create: Creează cont + verify_heading: Verifică-ți identitatea + verify_description_html: "Pentru a asocia contul tău %{provider}%{email_suffix}, verifică-ți identitatea introducând parola." + email_suffix_html: " (%{email})" + email_label: Adresă de e-mail + email_placeholder: Introdu adresa de e-mail + password_label: Parolă + password_placeholder: Introdu parola + verify_hint: Aceasta asigură că doar tu poți asocia conturi externe la profilul tău. + submit_link: Asociază contul + create_heading: Creează cont nou + create_description_html: "Nu a fost găsit niciun cont cu adresa de e-mail %{email}. Apasă mai jos pentru a crea un cont nou cu identitatea ta %{provider}." + info_email: "E-mail:" + info_name: "Nume:" + submit_create: Creează cont + account_creation_disabled: Crearea conturilor prin autentificare unică este dezactivată. Contactează un administrator. + cancel: Anulează + new_user: + title: Finalizează contul + heading: Creează-ți contul + description: Confirmă datele tale pentru a finaliza crearea contului cu identitatea ta %{provider}. + email_label: E-mail (de la furnizorul SSO) + first_name_label: Prenume + first_name_placeholder: Introdu prenumele + last_name_label: Nume de familie + last_name_placeholder: Introdu numele de familie + submit: Creează cont + cancel: Anulează \ No newline at end of file diff --git a/config/locales/views/oidc_accounts/tr.yml b/config/locales/views/oidc_accounts/tr.yml new file mode 100644 index 000000000..d7f0d6e28 --- /dev/null +++ b/config/locales/views/oidc_accounts/tr.yml @@ -0,0 +1,33 @@ +--- +tr: + oidc_accounts: + link: + title_link: OIDC Hesabını Bağla + title_create: Hesap Oluştur + verify_heading: Kimliğinizi Doğrulayın + verify_description_html: "%{provider} hesabınızı%{email_suffix} bağlamak için şifrenizi girerek kimliğinizi doğrulayın." + email_suffix_html: " (%{email})" + email_label: E-posta + email_placeholder: E-posta adresinizi girin + password_label: Şifre + password_placeholder: Şifrenizi girin + verify_hint: Bu, yalnızca sizin harici hesapları profilinize bağlayabilmenizi sağlar. + submit_link: Hesabı Bağla + create_heading: Yeni Hesap Oluştur + create_description_html: "%{email} e-posta adresiyle bir hesap bulunamadı. %{provider} kimliğinizle yeni bir hesap oluşturmak için aşağıya tıklayın." + info_email: "E-posta:" + info_name: "Ad:" + submit_create: Hesap Oluştur + account_creation_disabled: Tek oturum açma ile hesap oluşturma devre dışı bırakıldı. Lütfen bir yöneticiyle iletişime geçin. + cancel: İptal + new_user: + title: Hesabınızı Tamamlayın + heading: Hesabınızı Oluşturun + description: "%{provider} kimliğinizle hesap oluşturmayı tamamlamak için bilgilerinizi onaylayın." + email_label: E-posta (SSO sağlayıcısından) + first_name_label: Ad + first_name_placeholder: Adınızı girin + last_name_label: Soyad + last_name_placeholder: Soyadınızı girin + submit: Hesap Oluştur + cancel: İptal \ No newline at end of file diff --git a/config/locales/views/oidc_accounts/zh-CN.yml b/config/locales/views/oidc_accounts/zh-CN.yml new file mode 100644 index 000000000..06668b929 --- /dev/null +++ b/config/locales/views/oidc_accounts/zh-CN.yml @@ -0,0 +1,33 @@ +--- +zh-CN: + oidc_accounts: + link: + title_link: 关联 OIDC 账户 + title_create: 创建账户 + verify_heading: 验证您的身份 + verify_description_html: "要关联您的 %{provider} 账户%{email_suffix},请输入密码验证您的身份。" + email_suffix_html: "(%{email})" + email_label: 电子邮箱 + email_placeholder: 请输入您的电子邮箱 + password_label: 密码 + password_placeholder: 请输入您的密码 + verify_hint: 这可确保只有您本人能够将外部账户关联到您的个人资料。 + submit_link: 关联账户 + create_heading: 创建新账户 + create_description_html: "未找到使用电子邮箱 %{email} 的账户。点击下方使用您的 %{provider} 身份创建新账户。" + info_email: 电子邮箱: + info_name: 姓名: + submit_create: 创建账户 + account_creation_disabled: 通过单点登录创建账户已禁用。请联系管理员。 + cancel: 取消 + new_user: + title: 完善您的账户 + heading: 创建您的账户 + description: 请确认您的信息以完成使用 %{provider} 身份创建账户。 + email_label: 电子邮箱(来自 SSO 提供商) + first_name_label: 名 + first_name_placeholder: 请输入您的名 + last_name_label: 姓 + last_name_placeholder: 请输入您的姓 + submit: 创建账户 + cancel: 取消 \ No newline at end of file diff --git a/config/locales/views/oidc_accounts/zh-TW.yml b/config/locales/views/oidc_accounts/zh-TW.yml index 5a26ed67e..c643168fb 100644 --- a/config/locales/views/oidc_accounts/zh-TW.yml +++ b/config/locales/views/oidc_accounts/zh-TW.yml @@ -2,4 +2,32 @@ zh-TW: oidc_accounts: link: - account_creation_disabled: 透過單一登入建立新帳戶的功能已被禁用。請聯絡管理員為您建立帳戶。 + title_link: 連結 OIDC 帳戶 + title_create: 建立帳戶 + verify_heading: 驗證您的身分 + verify_description_html: "若要連結您的 %{provider} 帳戶%{email_suffix},請輸入密碼驗證您的身分。" + email_suffix_html: "(%{email})" + email_label: 電子郵件 + email_placeholder: 請輸入您的電子郵件 + password_label: 密碼 + password_placeholder: 請輸入您的密碼 + verify_hint: 這可確保只有您本人能夠將外部帳戶連結到您的個人檔案。 + submit_link: 連結帳戶 + create_heading: 建立新帳戶 + create_description_html: "找不到使用電子郵件 %{email} 的帳戶。點擊下方使用您的 %{provider} 身分建立新帳戶。" + info_email: 電子郵件: + info_name: 姓名: + submit_create: 建立帳戶 + account_creation_disabled: 透過單一登入建立帳戶已停用。請聯絡管理員。 + cancel: 取消 + new_user: + title: 完成您的帳戶 + heading: 建立您的帳戶 + description: 請確認您的資訊以完成使用 %{provider} 身分建立帳戶。 + email_label: 電子郵件(來自 SSO 提供者) + first_name_label: 名 + first_name_placeholder: 請輸入您的名 + last_name_label: 姓 + last_name_placeholder: 請輸入您的姓 + submit: 建立帳戶 + cancel: 取消 \ No newline at end of file diff --git a/config/locales/views/onboardings/ca.yml b/config/locales/views/onboardings/ca.yml index 9e2cdf146..9036a1efe 100644 --- a/config/locales/views/onboardings/ca.yml +++ b/config/locales/views/onboardings/ca.yml @@ -2,27 +2,60 @@ ca: onboardings: header: - sign_out: Tanca la sessió + sign_out: Tancar sessió + setup: Configuració + preferences: Preferències + goals: Objectius + start: Inici + logout: + sign_out: Tancar sessió + show: + title: Configurem el teu compte + subtitle: Primer de tot, completem el teu perfil. + first_name: Nom + first_name_placeholder: Nom + last_name: Cognom + last_name_placeholder: Cognom + household_name: Nom de la llar + household_name_placeholder: Nom de la llar + country: País + submit: Continuar preferences: + title: Configura les teves preferències + subtitle: Configurem les teves preferències. + example: Compte d'exemple + preview: Previsualitza com es mostren les dades segons les preferències. + color_theme: Tema de colors + theme_system: Sistema + theme_light: Clar + theme_dark: Fosc + locale: Idioma currency: Moneda date_format: Format de data - example: Compte d'exemple - locale: Idioma - preview: Previsualitza com es mostren les dades segons les preferències. - submit: Completa - subtitle: Configurem les teves preferències. - title: Configura les teves preferències - profile: - country: País - first_name: Nom - household_name: Nom de la llar - last_name: Cognom - profile_image: Imatge de perfil - submit: Continua - subtitle: Completem el teu perfil. - title: Configurem el més bàsic - show: - message: Estem molt contents que siguis aquí. Al següent pas et farem unes preguntes - per completar el teu perfil i deixar-ho tot a punt. - setup: Configura el compte - title: Benvingut/da a %{product_name} + submit: Completar + goals: + title: Què t'ha portat aquí? + subtitle: Selecciona un o més objectius que tens amb %{product_name} com a eina de finances personals. + unified_accounts: Veure tots els meus comptes en un sol lloc + cashflow: Entendre el flux de caixa i les despeses + budgeting: Gestionar plans financers i pressupostos + partner: Gestionar finances amb la parella + investments: Seguir les inversions + ai_insights: Deixar que la IA m'ajudi a entendre les meves finances + optimization: Analitzar i optimitzar comptes + reduce_stress: Reduir l'estrès financer o l'ansietat + submit: Següent + trial: + title: Prova Sure durant 45 dies + data_deletion: Les dades s'eliminaran després + description_html: A partir d'avui pots provar el producte a fons.
    Si t'agrada, allotja'l tu mateix o contribueix per continuar usant-lo aquí. + try_button: Provar Sure durant 45 dies + continue_trial: Continuar la prova + upgrade: Actualitzar + how_it_works: Com funciona + today: Avui + today_description: Tindràs accés gratuït a Sure durant 45 dies al nostre AWS. + in_40_days: En 40 dies (%{date}) + in_40_days_description: Et notificarem per recordar-te d'exportar les teves dades. + in_45_days: En 45 dies (%{date}) + in_45_days_description: Eliminarem les teves dades — contribueix per continuar usant Sure aquí! \ No newline at end of file diff --git a/config/locales/views/onboardings/de.yml b/config/locales/views/onboardings/de.yml index 79c46bcee..845e9a3fb 100644 --- a/config/locales/views/onboardings/de.yml +++ b/config/locales/views/onboardings/de.yml @@ -3,25 +3,59 @@ de: onboardings: header: sign_out: Abmelden + setup: Setup + preferences: Einstellungen + goals: Ziele + start: Start + logout: + sign_out: Abmelden + show: + title: Lass uns dein Konto einrichten + subtitle: Zuerst vervollständigen wir dein Profil. + first_name: Vorname + first_name_placeholder: Vorname + last_name: Nachname + last_name_placeholder: Nachname + household_name: Haushaltsname + household_name_placeholder: Haushaltsname + country: Land + submit: Weiter preferences: + title: Einstellungen konfigurieren + subtitle: Lass uns deine Einstellungen konfigurieren. + example: Beispielkonto + preview: Vorschau wie deine Daten basierend auf den Einstellungen angezeigt werden. + color_theme: Farbschema + theme_system: System + theme_light: Hell + theme_dark: Dunkel + locale: Sprache currency: Währung date_format: Datumsformat - example: Beispielkonto - locale: Sprache - preview: Vorschau wie deine Daten basierend auf den Einstellungen angezeigt werden submit: Abschließen - subtitle: Lass uns deine Einstellungen konfigurieren - title: Einstellungen konfigurieren - profile: - country: Land - first_name: Vorname - household_name: Haushaltsname - last_name: Nachname - profile_image: Profilbild + goals: + title: Was sind deine Ziele? + subtitle: Wähle ein oder mehrere Ziele aus, die du mit %{product_name} als dein persönliches Finanztool erreichen möchtest. + unified_accounts: Alle meine Konten an einem Ort sehen + cashflow: Cashflow und Ausgaben verstehen + budgeting: Finanzpläne und Budgets verwalten + partner: Finanzen gemeinsam mit Partner verwalten + investments: Investments verfolgen + ai_insights: KI nutzen um Einblicke zu erhalten + optimization: Konten analysieren und optimieren + reduce_stress: Finanziellen Stress reduzieren submit: Weiter - subtitle: Lass uns dein Profil vervollständigen - title: Lass uns die Grundlagen einrichten - show: - message: Wir freuen uns sehr dass du hier bist Im nächsten Schritt stellen wir dir ein paar Fragen um dein Profil zu vervollständigen und alles für dich einzurichten - setup: Konto einrichten - title: Willkommen bei %{product_name} + trial: + title: Sure 45 Tage kostenlos testen + data_deletion: Daten werden danach gelöscht + description_html: Ab heute kannst du das Produkt ausgiebig testen.
    Wenn es dir gefällt, hoste es selbst oder unterstütze uns, um es hier weiter zu nutzen. + try_button: Sure 45 Tage testen + continue_trial: Testversion fortsetzen + upgrade: Upgrade + how_it_works: So funktioniert es + today: Heute + today_description: Du erhältst 45 Tage kostenlos Zugang zu Sure auf unserer AWS. + in_40_days: In 40 Tagen (%{date}) + in_40_days_description: Wir erinnern dich daran, deine Daten zu exportieren. + in_45_days: In 45 Tagen (%{date}) + in_45_days_description: Wir löschen deine Daten — unterstütze uns, um Sure hier weiter zu nutzen! \ No newline at end of file diff --git a/config/locales/views/onboardings/en.yml b/config/locales/views/onboardings/en.yml index 498aab469..296c0b80c 100644 --- a/config/locales/views/onboardings/en.yml +++ b/config/locales/views/onboardings/en.yml @@ -2,27 +2,60 @@ en: onboardings: header: - sign_out: Log out + sign_out: Sign out + setup: Setup + preferences: Preferences + goals: Goals + start: Start + logout: + sign_out: Sign out + show: + title: Let's set up your account + subtitle: First things first, let's get your profile set up. + first_name: First name + first_name_placeholder: First name + last_name: Last name + last_name_placeholder: Last name + household_name: Household name + household_name_placeholder: Household name + country: Country + submit: Continue preferences: + title: Configure your preferences + subtitle: Let's configure your preferences. + example: Example account + preview: Preview how data displays based on preferences. + color_theme: Color theme + theme_system: System + theme_light: Light + theme_dark: Dark + locale: Language currency: Currency date_format: Date format - example: Example account - locale: Language - preview: Preview how data displays based on preferences. submit: Complete - subtitle: Let's configure your preferences. - title: Configure your preferences - profile: - country: Country - first_name: First Name - household_name: Household Name - last_name: Last Name - profile_image: Profile Image - submit: Continue - subtitle: Let's complete your profile. - title: Let's set up the basics - show: - message: We’re really excited you’re here. In the next step we’ll ask you a - few questions to complete your profile and then get you all set up. - setup: Set up account - title: Meet %{product_name} + goals: + title: What brings you here? + subtitle: Select one or more goals that you have with using %{product_name} as your personal finance tool. + unified_accounts: See all my accounts in one place + cashflow: Understand cashflow and expenses + budgeting: Manage financial plans and budgeting + partner: Manage finances with a partner + investments: Track investments + ai_insights: Let AI help me understand my finances + optimization: Analyze and optimize accounts + reduce_stress: Reduce financial stress or anxiety + submit: Next + trial: + title: Try Sure for 45 days + data_deletion: Data will be deleted then + description_html: Starting today you can give the product a good look.
    If you like it, self-host or contribute to continue using it here. + try_button: Try Sure for 45 days + continue_trial: Continue trial + upgrade: Upgrade + how_it_works: How things work here + today: Today + today_description: You'll get free access to Sure for 45 days on our AWS. + in_40_days: In 40 days (%{date}) + in_40_days_description: We'll notify you to remind you to export your data. + in_45_days: In 45 days (%{date}) + in_45_days_description: We delete your data — contribute to continue using Sure here! \ No newline at end of file diff --git a/config/locales/views/onboardings/es.yml b/config/locales/views/onboardings/es.yml index 7c8265ac6..1fc48268a 100644 --- a/config/locales/views/onboardings/es.yml +++ b/config/locales/views/onboardings/es.yml @@ -3,26 +3,59 @@ es: onboardings: header: sign_out: Cerrar sesión + setup: Configuración + preferences: Preferencias + goals: Objetivos + start: Inicio + logout: + sign_out: Cerrar sesión + show: + title: Configuremos tu cuenta + subtitle: Primero, completemos tu perfil. + first_name: Nombre + first_name_placeholder: Nombre + last_name: Apellido + last_name_placeholder: Apellido + household_name: Nombre del hogar + household_name_placeholder: Nombre del hogar + country: País + submit: Continuar preferences: + title: Configura tus preferencias + subtitle: Configuremos tus preferencias. + example: Cuenta de ejemplo + preview: Vista previa de cómo se muestran los datos según las preferencias. + color_theme: Tema de color + theme_system: Sistema + theme_light: Claro + theme_dark: Oscuro + locale: Idioma currency: Moneda date_format: Formato de fecha - example: Cuenta de ejemplo - locale: Idioma - preview: Previsualiza cómo se muestran los datos según tus preferencias. submit: Completar - subtitle: Vamos a configurar tus preferencias. - title: Configura tus preferencias - profile: - country: País - first_name: Nombre - household_name: Nombre del grupo familiar - last_name: Apellidos - profile_image: Imagen de perfil - submit: Continuar - subtitle: Vamos a completar tu perfil. - title: Vamos a configurar lo básico - show: - message: Estamos muy emocionados de que estés aquí. En el siguiente paso te - haremos unas preguntas para completar tu perfil y luego configuraremos todo para ti. - setup: Configurar cuenta - title: Bienvenido a %{product_name} + goals: + title: ¿Qué te trae por aquí? + subtitle: Selecciona uno o más objetivos que tienes con %{product_name} como tu herramienta de finanzas personales. + unified_accounts: Ver todas mis cuentas en un solo lugar + cashflow: Entender el flujo de caja y los gastos + budgeting: Gestionar planes financieros y presupuestos + partner: Gestionar finanzas con mi pareja + investments: Seguir las inversiones + ai_insights: Dejar que la IA me ayude a entender mis finanzas + optimization: Analizar y optimizar cuentas + reduce_stress: Reducir el estrés financiero o la ansiedad + submit: Siguiente + trial: + title: Prueba Sure durante 45 días + data_deletion: Los datos se eliminarán después + description_html: A partir de hoy puedes probar el producto a fondo.
    Si te gusta, alójalo tú mismo o contribuye para seguir usándolo aquí. + try_button: Probar Sure durante 45 días + continue_trial: Continuar prueba + upgrade: Actualizar + how_it_works: Cómo funciona + today: Hoy + today_description: Tendrás acceso gratuito a Sure durante 45 días en nuestro AWS. + in_40_days: En 40 días (%{date}) + in_40_days_description: Te notificaremos para recordarte exportar tus datos. + in_45_days: En 45 días (%{date}) + in_45_days_description: Eliminamos tus datos — ¡contribuye para seguir usando Sure aquí! \ No newline at end of file diff --git a/config/locales/views/onboardings/fr.yml b/config/locales/views/onboardings/fr.yml index 64c4f3ead..72598204a 100644 --- a/config/locales/views/onboardings/fr.yml +++ b/config/locales/views/onboardings/fr.yml @@ -2,26 +2,60 @@ fr: onboardings: header: - sign_out: Déconnexion - preferences: - currency: Monnaie - date_format: Format de date - example: Compte d'exemple - locale: Langue - preview: Prévisualiser la façon dont les données s'affichent en fonction des préférences. - submit: Terminer - subtitle: Configurons vos préférences. - title: Configurez vos préférences - profile: - country: Pays - first_name: Prénom - household_name: Nom du foyer (si applicable) - last_name: Nom de famille - profile_image: Photo de profil - submit: Continuer - subtitle: Complétons votre profil. - title: Configurons les bases + sign_out: Se déconnecter + setup: Configuration + preferences: Préférences + goals: Objectifs + start: Démarrer + logout: + sign_out: Se déconnecter show: - message: Nous sommes vraiment excités que vous soyez ici. Dans la prochaine étape, nous allons vous poser quelques questions pour compléter votre profil et ensuite configurer votre compte. - setup: Configurer le compte - title: Rencontrez %{product_name} + title: Configurons votre compte + subtitle: Commençons par compléter votre profil. + first_name: Prénom + first_name_placeholder: Prénom + last_name: Nom de famille + last_name_placeholder: Nom de famille + household_name: Nom du foyer + household_name_placeholder: Nom du foyer + country: Pays + submit: Continuer + preferences: + title: Configurez vos préférences + subtitle: Configurons vos préférences. + example: Compte exemple + preview: Aperçu de l'affichage des données selon vos préférences. + color_theme: Thème de couleur + theme_system: Système + theme_light: Clair + theme_dark: Sombre + locale: Langue + currency: Devise + date_format: Format de date + submit: Terminer + goals: + title: Qu'est-ce qui vous amène ici ? + subtitle: Sélectionnez un ou plusieurs objectifs que vous souhaitez atteindre avec %{product_name} comme outil de finances personnelles. + unified_accounts: Voir tous mes comptes en un seul endroit + cashflow: Comprendre les flux de trésorerie et les dépenses + budgeting: Gérer les plans financiers et les budgets + partner: Gérer les finances avec un partenaire + investments: Suivre les investissements + ai_insights: Laisser l'IA m'aider à comprendre mes finances + optimization: Analyser et optimiser les comptes + reduce_stress: Réduire le stress financier ou l'anxiété + submit: Suivant + trial: + title: Essayez Sure pendant 45 jours + data_deletion: Les données seront supprimées ensuite + description_html: À partir d'aujourd'hui, vous pouvez tester le produit en profondeur.
    Si vous l'aimez, hébergez-le vous-même ou contribuez pour continuer à l'utiliser ici. + try_button: Essayer Sure pendant 45 jours + continue_trial: Continuer l'essai + upgrade: Mettre à niveau + how_it_works: Comment ça fonctionne + today: Aujourd'hui + today_description: Vous aurez un accès gratuit à Sure pendant 45 jours sur notre AWS. + in_40_days: Dans 40 jours (%{date}) + in_40_days_description: Nous vous notifierons pour vous rappeler d'exporter vos données. + in_45_days: Dans 45 jours (%{date}) + in_45_days_description: Nous supprimons vos données — contribuez pour continuer à utiliser Sure ici ! \ No newline at end of file diff --git a/config/locales/views/onboardings/nb.yml b/config/locales/views/onboardings/nb.yml index 1d0223d61..bac186108 100644 --- a/config/locales/views/onboardings/nb.yml +++ b/config/locales/views/onboardings/nb.yml @@ -3,26 +3,59 @@ nb: onboardings: header: sign_out: Logg ut + setup: Oppsett + preferences: Innstillinger + goals: Mål + start: Start + logout: + sign_out: Logg ut + show: + title: La oss sette opp kontoen din + subtitle: Først, la oss fullføre profilen din. + first_name: Fornavn + first_name_placeholder: Fornavn + last_name: Etternavn + last_name_placeholder: Etternavn + household_name: Husholdningsnavn + household_name_placeholder: Husholdningsnavn + country: Land + submit: Fortsett preferences: + title: Konfigurer innstillingene dine + subtitle: La oss konfigurere innstillingene dine. + example: Eksempelkonto + preview: Forhåndsvisning av hvordan data vises basert på innstillinger. + color_theme: Fargetema + theme_system: System + theme_light: Lys + theme_dark: Mørk + locale: Språk currency: Valuta date_format: Datoformat - example: Eksempelkonto - locale: Språk - preview: Forhåndsvis hvordan data vises basert på preferanser. submit: Fullfør - subtitle: La oss konfigurere preferansene dine. - title: Konfigurer preferansene dine - profile: - country: Land - first_name: Fornavn - household_name: Husholdningsnavn - last_name: Etternavn - profile_image: Profilbilde - submit: Fortsett - subtitle: La oss fullføre profilen din. - title: La oss sette opp det grunnleggende - show: - message: Vi er veldig glade for at du er her. I neste trinn vil vi stille deg noen - spørsmål for å fullføre profilen din og deretter få deg i gang. - setup: Sett opp konto - title: Møt %{product_name} \ No newline at end of file + goals: + title: Hva bringer deg hit? + subtitle: Velg ett eller flere mål du har med %{product_name} som ditt personlige økonomverktøy. + unified_accounts: Se alle kontoene mine på ett sted + cashflow: Forstå kontantstrøm og utgifter + budgeting: Administrere økonomiplaner og budsjetter + partner: Administrere økonomi med en partner + investments: Følge investeringer + ai_insights: La AI hjelpe meg å forstå økonomien min + optimization: Analysere og optimalisere kontoer + reduce_stress: Redusere økonomisk stress eller angst + submit: Neste + trial: + title: Prøv Sure i 45 dager + data_deletion: Data slettes deretter + description_html: Fra i dag kan du teste produktet grundig.
    Hvis du liker det, kan du hoste det selv eller bidra for å fortsette å bruke det her. + try_button: Prøv Sure i 45 dager + continue_trial: Fortsett prøveperioden + upgrade: Oppgrader + how_it_works: Slik fungerer det + today: I dag + today_description: Du får gratis tilgang til Sure i 45 dager på vår AWS. + in_40_days: Om 40 dager (%{date}) + in_40_days_description: Vi varsler deg for å minne deg på å eksportere dataene dine. + in_45_days: Om 45 dager (%{date}) + in_45_days_description: Vi sletter dataene dine — bidra for å fortsette å bruke Sure her! \ No newline at end of file diff --git a/config/locales/views/onboardings/nl.yml b/config/locales/views/onboardings/nl.yml index ebe8727b7..8ee999f33 100644 --- a/config/locales/views/onboardings/nl.yml +++ b/config/locales/views/onboardings/nl.yml @@ -3,25 +3,59 @@ nl: onboardings: header: sign_out: Uitloggen + setup: Instellen + preferences: Voorkeuren + goals: Doelen + start: Start + logout: + sign_out: Uitloggen + show: + title: Laten we je account instellen + subtitle: Laten we eerst je profiel voltooien. + first_name: Voornaam + first_name_placeholder: Voornaam + last_name: Achternaam + last_name_placeholder: Achternaam + household_name: Huishoudnaam + household_name_placeholder: Huishoudnaam + country: Land + submit: Doorgaan preferences: + title: Configureer je voorkeuren + subtitle: Laten we je voorkeuren configureren. + example: Voorbeeldaccount + preview: Voorbeeld van hoe gegevens worden weergegeven op basis van voorkeuren. + color_theme: Kleurthema + theme_system: Systeem + theme_light: Licht + theme_dark: Donker + locale: Taal currency: Valuta date_format: Datumformaat - example: Voorbeeldaccount - locale: Taal - preview: Voorbeeld van hoe gegevens worden weergegeven op basis van voorkeuren. submit: Voltooien - subtitle: Laten we uw voorkeuren configureren. - title: Configureer uw voorkeuren - profile: - country: Land - first_name: Voornaam - household_name: Naam van huishouden - last_name: Achternaam - profile_image: Profielfoto - submit: Doorgaan - subtitle: Laten we uw profiel voltooien. - title: Laten we de basis instellen - show: - message: We zijn erg blij dat u hier bent. In de volgende stap stellen we u een paar vragen om uw profiel te voltooien en u helemaal klaar te maken. - setup: Account instellen - title: Maak kennis met %{product_name} + goals: + title: Wat brengt je hier? + subtitle: Selecteer een of meer doelen die je hebt met %{product_name} als je persoonlijke financiële tool. + unified_accounts: Al mijn rekeningen op één plek zien + cashflow: Cashflow en uitgaven begrijpen + budgeting: Financiële plannen en budgetten beheren + partner: Financiën samen met een partner beheren + investments: Investeringen volgen + ai_insights: AI laten helpen om mijn financiën te begrijpen + optimization: Rekeningen analyseren en optimaliseren + reduce_stress: Financiële stress of angst verminderen + submit: Volgende + trial: + title: Probeer Sure 45 dagen + data_deletion: Gegevens worden daarna verwijderd + description_html: Vanaf vandaag kun je het product uitgebreid testen.
    Als je het leuk vindt, host het zelf of draag bij om het hier te blijven gebruiken. + try_button: Probeer Sure 45 dagen + continue_trial: Proefperiode voortzetten + upgrade: Upgraden + how_it_works: Hoe het werkt + today: Vandaag + today_description: Je krijgt 45 dagen gratis toegang tot Sure op onze AWS. + in_40_days: Over 40 dagen (%{date}) + in_40_days_description: We sturen je een herinnering om je gegevens te exporteren. + in_45_days: Over 45 dagen (%{date}) + in_45_days_description: We verwijderen je gegevens — draag bij om Sure hier te blijven gebruiken! \ No newline at end of file diff --git a/config/locales/views/onboardings/pt-BR.yml b/config/locales/views/onboardings/pt-BR.yml index b69ad4bc7..8b25f6a18 100644 --- a/config/locales/views/onboardings/pt-BR.yml +++ b/config/locales/views/onboardings/pt-BR.yml @@ -3,26 +3,59 @@ pt-BR: onboardings: header: sign_out: Sair + setup: Configuração + preferences: Preferências + goals: Objetivos + start: Iniciar + logout: + sign_out: Sair + show: + title: Vamos configurar sua conta + subtitle: Primeiro, vamos completar seu perfil. + first_name: Nome + first_name_placeholder: Nome + last_name: Sobrenome + last_name_placeholder: Sobrenome + household_name: Nome da família + household_name_placeholder: Nome da família + country: País + submit: Continuar preferences: + title: Configure suas preferências + subtitle: Vamos configurar suas preferências. + example: Conta de exemplo + preview: Visualização de como os dados são exibidos com base nas preferências. + color_theme: Tema de cores + theme_system: Sistema + theme_light: Claro + theme_dark: Escuro + locale: Idioma currency: Moeda date_format: Formato de data - example: Conta exemplo - locale: Idioma - preview: Visualize como os dados são exibidos com base nas preferências. submit: Concluir - subtitle: Vamos configurar suas preferências. - title: Configure suas preferências - profile: - country: País - first_name: Primeiro Nome - household_name: Nome da Família - last_name: Sobrenome - profile_image: Imagem do Perfil - submit: Continuar - subtitle: Vamos completar seu perfil. - title: Vamos configurar o básico - show: - message: Estamos muito empolgados por você estar aqui. No próximo passo, faremos - algumas perguntas para completar seu perfil e então deixar tudo configurado. - setup: Configurar conta - title: Conheça o %{product_name} + goals: + title: O que te traz aqui? + subtitle: Selecione um ou mais objetivos que você tem com o %{product_name} como sua ferramenta de finanças pessoais. + unified_accounts: Ver todas as minhas contas em um só lugar + cashflow: Entender fluxo de caixa e despesas + budgeting: Gerenciar planos financeiros e orçamentos + partner: Gerenciar finanças com um parceiro + investments: Acompanhar investimentos + ai_insights: Deixar a IA me ajudar a entender minhas finanças + optimization: Analisar e otimizar contas + reduce_stress: Reduzir estresse financeiro ou ansiedade + submit: Próximo + trial: + title: Experimente o Sure por 45 dias + data_deletion: Os dados serão excluídos depois + description_html: A partir de hoje você pode testar o produto a fundo.
    Se gostar, hospede você mesmo ou contribua para continuar usando aqui. + try_button: Experimentar Sure por 45 dias + continue_trial: Continuar teste + upgrade: Atualizar + how_it_works: Como funciona + today: Hoje + today_description: Você terá acesso gratuito ao Sure por 45 dias em nosso AWS. + in_40_days: Em 40 dias (%{date}) + in_40_days_description: Notificaremos você para lembrar de exportar seus dados. + in_45_days: Em 45 dias (%{date}) + in_45_days_description: Excluímos seus dados — contribua para continuar usando o Sure aqui! \ No newline at end of file diff --git a/config/locales/views/onboardings/ro.yml b/config/locales/views/onboardings/ro.yml index 4fd5e5dca..d79984012 100644 --- a/config/locales/views/onboardings/ro.yml +++ b/config/locales/views/onboardings/ro.yml @@ -3,25 +3,59 @@ ro: onboardings: header: sign_out: Deconectare + setup: Configurare + preferences: Preferințe + goals: Obiective + start: Start + logout: + sign_out: Deconectare + show: + title: Să-ți configurăm contul + subtitle: Mai întâi, să-ți completăm profilul. + first_name: Prenume + first_name_placeholder: Prenume + last_name: Nume de familie + last_name_placeholder: Nume de familie + household_name: Numele gospodăriei + household_name_placeholder: Numele gospodăriei + country: Țară + submit: Continuă preferences: + title: Configurează preferințele + subtitle: Să-ți configurăm preferințele. + example: Cont exemplu + preview: Previzualizare a modului în care sunt afișate datele pe baza preferințelor. + color_theme: Tema de culoare + theme_system: Sistem + theme_light: Deschis + theme_dark: Întunecat + locale: Limbă currency: Monedă date_format: Format dată - example: Cont exemplu - locale: Limbă - preview: Previzualizează cum sunt afișate datele în funcție de preferințe. - submit: Finalizează - subtitle: Să configurăm preferințele tale. - title: Configurează-ți preferințele - profile: - country: Țară - first_name: Prenume - household_name: Numele gospodăriei - last_name: Nume de familie - profile_image: Imagine de profil - submit: Continuă - subtitle: Să-ți completăm profilul. - title: Să configurăm elementele de bază - show: - message: Suntem încântați că ești aici. În pasul următor îți vom pune câteva întrebări pentru a-ți completa profilul și apoi te vom pregăti. - setup: Configurează contul - title: Fă cunoștință cu %{product_name} + submit: Finalizare + goals: + title: Ce te aduce aici? + subtitle: Selectează unul sau mai multe obiective pe care le ai cu %{product_name} ca instrument de finanțe personale. + unified_accounts: Să-mi văd toate conturile într-un singur loc + cashflow: Să înțeleg fluxul de numerar și cheltuielile + budgeting: Să gestionez planuri financiare și bugete + partner: Să gestionez finanțele împreună cu partenerul + investments: Să urmăresc investițiile + ai_insights: Să las AI să mă ajute să-mi înțeleg finanțele + optimization: Să analizez și să optimizez conturile + reduce_stress: Să reduc stresul financiar sau anxietatea + submit: Următorul + trial: + title: Încearcă Sure timp de 45 de zile + data_deletion: Datele vor fi șterse după aceea + description_html: Începând de azi poți testa produsul în profunzime.
    Dacă îți place, găzduiește-l singur sau contribuie pentru a continua să-l folosești aici. + try_button: Încearcă Sure timp de 45 de zile + continue_trial: Continuă perioada de probă + upgrade: Actualizează + how_it_works: Cum funcționează + today: Astăzi + today_description: Vei avea acces gratuit la Sure timp de 45 de zile pe AWS-ul nostru. + in_40_days: În 40 de zile (%{date}) + in_40_days_description: Te vom notifica să-ți amintim să-ți exporți datele. + in_45_days: În 45 de zile (%{date}) + in_45_days_description: Îți ștergem datele — contribuie pentru a continua să folosești Sure aici! \ No newline at end of file diff --git a/config/locales/views/onboardings/tr.yml b/config/locales/views/onboardings/tr.yml index 8bf76abbb..351fedc9a 100644 --- a/config/locales/views/onboardings/tr.yml +++ b/config/locales/views/onboardings/tr.yml @@ -3,25 +3,59 @@ tr: onboardings: header: sign_out: Çıkış yap + setup: Kurulum + preferences: Tercihler + goals: Hedefler + start: Başla + logout: + sign_out: Çıkış yap + show: + title: Hesabınızı kuralım + subtitle: Önce profilinizi tamamlayalım. + first_name: Ad + first_name_placeholder: Ad + last_name: Soyad + last_name_placeholder: Soyad + household_name: Hane adı + household_name_placeholder: Hane adı + country: Ülke + submit: Devam et preferences: + title: Tercihlerinizi yapılandırın + subtitle: Tercihlerinizi yapılandıralım. + example: Örnek hesap + preview: Tercihlere göre verilerin nasıl görüntüleneceğinin önizlemesi. + color_theme: Renk teması + theme_system: Sistem + theme_light: Açık + theme_dark: Koyu + locale: Dil currency: Para birimi date_format: Tarih formatı - example: Örnek hesap - locale: Dil - preview: Tercihlere göre verilerin nasıl görüneceğini önizleyin. submit: Tamamla - subtitle: Tercihlerinizi yapılandıralım. - title: Tercihlerinizi yapılandırın - profile: - country: Ülke - first_name: Ad - household_name: Hane Adı - last_name: Soyad - profile_image: Profil Resmi - submit: Devam et - subtitle: Profilinizi tamamlayalım. - title: Temel bilgileri ayarlayalım - show: - message: Burada olduğunuz için çok heyecanlıyız. Sonraki adımda profilinizi tamamlamak için size birkaç soru soracağız ve ardından her şeyi ayarlayacağız. - setup: Hesabı ayarla - title: Maybe ile Tanışın \ No newline at end of file + goals: + title: Sizi buraya ne getirdi? + subtitle: "%{product_name}'i kişisel finans aracınız olarak kullanmak için bir veya daha fazla hedef seçin." + unified_accounts: Tüm hesaplarımı tek bir yerde görmek + cashflow: Nakit akışını ve harcamaları anlamak + budgeting: Finansal planları ve bütçeleri yönetmek + partner: Bir partnerle birlikte finansları yönetmek + investments: Yatırımları takip etmek + ai_insights: AI'ın finanslarımı anlamama yardım etmesini sağlamak + optimization: Hesapları analiz etmek ve optimize etmek + reduce_stress: Finansal stresi veya kaygıyı azaltmak + submit: Sonraki + trial: + title: Sure'u 45 gün deneyin + data_deletion: Veriler daha sonra silinecek + description_html: Bugünden itibaren ürünü detaylı test edebilirsiniz.
    Beğenirseniz, kendiniz barındırın veya burada kullanmaya devam etmek için katkıda bulunun. + try_button: Sure'u 45 gün dene + continue_trial: Denemeye devam et + upgrade: Yükselt + how_it_works: Nasıl çalışır + today: Bugün + today_description: AWS'mizde Sure'a 45 gün ücretsiz erişim elde edeceksiniz. + in_40_days: 40 gün içinde (%{date}) + in_40_days_description: Verilerinizi dışa aktarmanızı hatırlatmak için sizi bilgilendireceğiz. + in_45_days: 45 gün içinde (%{date}) + in_45_days_description: Verilerinizi siliyoruz — Sure'u burada kullanmaya devam etmek için katkıda bulunun! \ No newline at end of file diff --git a/config/locales/views/onboardings/zh-CN.yml b/config/locales/views/onboardings/zh-CN.yml index 7ee58a4d4..f2728ba95 100644 --- a/config/locales/views/onboardings/zh-CN.yml +++ b/config/locales/views/onboardings/zh-CN.yml @@ -3,25 +3,59 @@ zh-CN: onboardings: header: sign_out: 退出登录 + setup: 设置 + preferences: 偏好设置 + goals: 目标 + start: 开始 + logout: + sign_out: 退出登录 + show: + title: 让我们设置您的账户 + subtitle: 首先,让我们完善您的个人资料。 + first_name: 名 + first_name_placeholder: 名 + last_name: 姓 + last_name_placeholder: 姓 + household_name: 家庭名称 + household_name_placeholder: 家庭名称 + country: 国家 + submit: 继续 preferences: + title: 配置您的偏好设置 + subtitle: 让我们配置您的偏好设置。 + example: 示例账户 + preview: 根据偏好设置预览数据显示方式。 + color_theme: 颜色主题 + theme_system: 跟随系统 + theme_light: 浅色 + theme_dark: 深色 + locale: 语言 currency: 货币 date_format: 日期格式 - example: 示例账户 - locale: 语言 - preview: 预览偏好设置下的数据展示效果。 - submit: 完成设置 - subtitle: 现在来配置您的偏好设置。 - title: 配置偏好设置 - profile: - country: 国家/地区 - first_name: 名字 - household_name: 家庭名称 - last_name: 姓氏 - profile_image: 个人头像 - submit: 继续 - subtitle: 现在来完成您的个人资料。 - title: 基础信息设置 - show: - message: 很高兴您的加入!接下来我们将引导您完成几个步骤:完善个人资料,然后进行初始设置。 - setup: 开始设置 - title: 欢迎使用 %{product_name} + submit: 完成 + goals: + title: 是什么让您来到这里? + subtitle: 选择一个或多个您使用 %{product_name} 作为个人财务工具的目标。 + unified_accounts: 在一个地方查看所有账户 + cashflow: 了解现金流和支出 + budgeting: 管理财务计划和预算 + partner: 与伴侣共同管理财务 + investments: 跟踪投资 + ai_insights: 让 AI 帮助我了解我的财务状况 + optimization: 分析和优化账户 + reduce_stress: 减轻财务压力或焦虑 + submit: 下一步 + trial: + title: 免费试用 Sure 45 天 + data_deletion: 届时数据将被删除 + description_html: 从今天开始,您可以深入体验产品。
    如果您喜欢,可以自行托管或贡献以继续在这里使用。 + try_button: 试用 Sure 45 天 + continue_trial: 继续试用 + upgrade: 升级 + how_it_works: 运作方式 + today: 今天 + today_description: 您将在我们的 AWS 上获得 45 天免费访问 Sure 的权限。 + in_40_days: 40 天后(%{date}) + in_40_days_description: 我们会通知您提醒导出数据。 + in_45_days: 45 天后(%{date}) + in_45_days_description: 我们将删除您的数据 — 贡献以继续在这里使用 Sure! \ No newline at end of file diff --git a/config/locales/views/onboardings/zh-TW.yml b/config/locales/views/onboardings/zh-TW.yml index 9846cd484..bf09e6294 100644 --- a/config/locales/views/onboardings/zh-TW.yml +++ b/config/locales/views/onboardings/zh-TW.yml @@ -3,25 +3,59 @@ zh-TW: onboardings: header: sign_out: 登出 - preferences: - currency: 幣別 - date_format: 日期格式 - example: 帳戶範例 - locale: 語言 - preview: 預覽根據偏好設定顯示的資料。 - submit: 完成 - subtitle: 讓我們來設定您的偏好設定。 - title: 設定您的偏好設定 - profile: - country: 國家 - first_name: 名字 - household_name: 家戶名稱 - last_name: 姓氏 - profile_image: 個人頭像 - submit: 繼續 - subtitle: 讓我們完成您的個人資料。 - title: 進行基礎設定 + setup: 設定 + preferences: 偏好設定 + goals: 目標 + start: 開始 + logout: + sign_out: 登出 show: - message: 我們很高興您的加入!接下來我們會詢問幾個問題來完善您的個人資料,並完成所有設定。 - setup: 開始設定帳號 - title: 認識 %{product_name} + title: 讓我們設定您的帳戶 + subtitle: 首先,讓我們完善您的個人檔案。 + first_name: 名 + first_name_placeholder: 名 + last_name: 姓 + last_name_placeholder: 姓 + household_name: 家庭名稱 + household_name_placeholder: 家庭名稱 + country: 國家 + submit: 繼續 + preferences: + title: 設定您的偏好 + subtitle: 讓我們設定您的偏好。 + example: 範例帳戶 + preview: 根據偏好設定預覽資料顯示方式。 + color_theme: 顏色主題 + theme_system: 跟隨系統 + theme_light: 淺色 + theme_dark: 深色 + locale: 語言 + currency: 貨幣 + date_format: 日期格式 + submit: 完成 + goals: + title: 是什麼讓您來到這裡? + subtitle: 選擇一個或多個您使用 %{product_name} 作為個人財務工具的目標。 + unified_accounts: 在一個地方查看所有帳戶 + cashflow: 了解現金流和支出 + budgeting: 管理財務計劃和預算 + partner: 與伴侶共同管理財務 + investments: 追蹤投資 + ai_insights: 讓 AI 幫助我了解我的財務狀況 + optimization: 分析和優化帳戶 + reduce_stress: 減輕財務壓力或焦慮 + submit: 下一步 + trial: + title: 免費試用 Sure 45 天 + data_deletion: 屆時資料將被刪除 + description_html: 從今天開始,您可以深入體驗產品。
    如果您喜歡,可以自行託管或貢獻以繼續在這裡使用。 + try_button: 試用 Sure 45 天 + continue_trial: 繼續試用 + upgrade: 升級 + how_it_works: 運作方式 + today: 今天 + today_description: 您將在我們的 AWS 上獲得 45 天免費存取 Sure 的權限。 + in_40_days: 40 天後(%{date}) + in_40_days_description: 我們會通知您提醒匯出資料。 + in_45_days: 45 天後(%{date}) + in_45_days_description: 我們將刪除您的資料 — 貢獻以繼續在這裡使用 Sure! \ No newline at end of file diff --git a/config/locales/views/registrations/ca.yml b/config/locales/views/registrations/ca.yml index 1bd8668e9..5e977ef2f 100644 --- a/config/locales/views/registrations/ca.yml +++ b/config/locales/views/registrations/ca.yml @@ -24,3 +24,8 @@ ca: welcome_body: Per començar, has de registrar un compte nou. Després podràs configurar opcions addicionals dins l'aplicació. welcome_title: Benvingut/da a Self Hosted %{product_name}! + password_requirements: + length: Mínim 8 caràcters + case: Majúscules i minúscules + number: Un número (0-9) + special: "Un caràcter especial (!, @, #, $, %, etc)" diff --git a/config/locales/views/registrations/de.yml b/config/locales/views/registrations/de.yml index 8fc0e85b1..f0747f360 100644 --- a/config/locales/views/registrations/de.yml +++ b/config/locales/views/registrations/de.yml @@ -8,6 +8,7 @@ de: user: create: Weiter registrations: + closed: Die Anmeldung ist derzeit geschlossen. create: failure: Beim Registrieren ist ein Problem aufgetreten invalid_invite_code: Ungültiger Einladungscode bitte versuche es erneut @@ -22,3 +23,8 @@ de: welcome_body: Um zu beginnen musst du ein neues Konto erstellen Danach kannst du zusätzliche Einstellungen in der App konfigurieren welcome_title: Willkommen bei Self Hosted %{product_name} password_placeholder: Passwort eingeben + password_requirements: + length: Mindestens 8 Zeichen + case: Groß- und Kleinbuchstaben + number: Eine Zahl (0-9) + special: "Ein Sonderzeichen (!, @, #, $, %, etc)" diff --git a/config/locales/views/registrations/en.yml b/config/locales/views/registrations/en.yml index 6b6d4f2ec..742044a02 100644 --- a/config/locales/views/registrations/en.yml +++ b/config/locales/views/registrations/en.yml @@ -24,3 +24,8 @@ en: then be able to configure additional settings within the app. welcome_title: Welcome to Self Hosted %{product_name}! password_placeholder: Enter your password + password_requirements: + length: Minimum 8 characters + case: Upper and lowercase letters + number: A number (0-9) + special: "A special character (!, @, #, $, %, etc)" diff --git a/config/locales/views/registrations/es.yml b/config/locales/views/registrations/es.yml index b9d3b885c..fc4b1169d 100644 --- a/config/locales/views/registrations/es.yml +++ b/config/locales/views/registrations/es.yml @@ -24,3 +24,8 @@ es: configurar ajustes adicionales dentro de la aplicación. welcome_title: ¡Bienvenido a Self Hosted %{product_name}! password_placeholder: Introduce tu contraseña + password_requirements: + length: Mínimo 8 caracteres + case: Mayúsculas y minúsculas + number: Un número (0-9) + special: "Un carácter especial (!, @, #, $, %, etc)" diff --git a/config/locales/views/registrations/fr.yml b/config/locales/views/registrations/fr.yml index 0a57616c8..06ec380f5 100644 --- a/config/locales/views/registrations/fr.yml +++ b/config/locales/views/registrations/fr.yml @@ -23,3 +23,8 @@ fr: welcome_body: Pour commencer, vous devez créer un nouveau compte. Vous pourrez ensuite configurer des paramètres supplémentaires à l'intérieur de l'application. welcome_title: Bienvenue sur %{product_name} ! password_placeholder: Entrez votre mot de passe + password_requirements: + length: Minimum 8 caractères + case: Majuscules et minuscules + number: Un chiffre (0-9) + special: "Un caractère spécial (!, @, #, $, %, etc)" diff --git a/config/locales/views/registrations/nb.yml b/config/locales/views/registrations/nb.yml index a915358cf..fb5852aec 100644 --- a/config/locales/views/registrations/nb.yml +++ b/config/locales/views/registrations/nb.yml @@ -24,3 +24,8 @@ nb: da kunne konfigurere flere innstillinger i appen. welcome_title: Velkommen til Self Hosted %{product_name}! password_placeholder: Angi passordet ditt + password_requirements: + length: Minimum 8 tegn + case: Store og små bokstaver + number: Et tall (0-9) + special: "Et spesialtegn (!, @, #, $, %, etc)" diff --git a/config/locales/views/registrations/nl.yml b/config/locales/views/registrations/nl.yml index 320cd156b..fcdf5c985 100644 --- a/config/locales/views/registrations/nl.yml +++ b/config/locales/views/registrations/nl.yml @@ -23,3 +23,8 @@ nl: welcome_body: Om te beginnen moet u zich aanmelden voor een nieuw account. U kunt daarna aanvullende instellingen binnen de app configureren. welcome_title: Welkom bij Self Hosted %{product_name}! password_placeholder: Voer uw wachtwoord in + password_requirements: + length: Minimaal 8 tekens + case: Hoofdletters en kleine letters + number: Een cijfer (0-9) + special: "Een speciaal teken (!, @, #, $, %, etc)" diff --git a/config/locales/views/registrations/pt-BR.yml b/config/locales/views/registrations/pt-BR.yml index 9aea20658..c39e455a1 100644 --- a/config/locales/views/registrations/pt-BR.yml +++ b/config/locales/views/registrations/pt-BR.yml @@ -23,3 +23,8 @@ pt-BR: poderá configurar configurações adicionais dentro do aplicativo. welcome_title: Bem-vindo ao Self Hosted %{product_name}! password_placeholder: Digite sua senha + password_requirements: + length: Mínimo 8 caracteres + case: Letras maiúsculas e minúsculas + number: Um número (0-9) + special: "Um caractere especial (!, @, #, $, %, etc)" diff --git a/config/locales/views/registrations/ro.yml b/config/locales/views/registrations/ro.yml index 189b78c5a..5f3ece26b 100644 --- a/config/locales/views/registrations/ro.yml +++ b/config/locales/views/registrations/ro.yml @@ -23,3 +23,8 @@ ro: welcome_body: Pentru a începe, trebuie să îți creezi un cont nou. Apoi vei putea configura setări suplimentare în aplicație. welcome_title: Bine ai venit la Self Hosted Maybe! password_placeholder: Introdu parola + password_requirements: + length: Minim 8 caractere + case: Litere mari și mici + number: O cifră (0-9) + special: "Un caracter special (!, @, #, $, %, etc)" diff --git a/config/locales/views/registrations/tr.yml b/config/locales/views/registrations/tr.yml index df236371d..61dc038aa 100644 --- a/config/locales/views/registrations/tr.yml +++ b/config/locales/views/registrations/tr.yml @@ -23,3 +23,8 @@ tr: welcome_body: Başlamak için yeni bir hesap oluşturmalısınız. Daha sonra uygulama içinde ek ayarları yapılandırabileceksiniz. welcome_title: Self Hosted %{product_name}'ye Hoş Geldiniz! password_placeholder: Şifrenizi girin + password_requirements: + length: En az 8 karakter + case: Büyük ve küçük harfler + number: Bir rakam (0-9) + special: "Bir özel karakter (!, @, #, $, %, etc)" diff --git a/config/locales/views/registrations/zh-CN.yml b/config/locales/views/registrations/zh-CN.yml index d4344273a..84c3bd567 100644 --- a/config/locales/views/registrations/zh-CN.yml +++ b/config/locales/views/registrations/zh-CN.yml @@ -23,3 +23,8 @@ zh-CN: title: 创建您的账户 welcome_body: 开始使用前,您需要注册一个新账户。注册后即可在应用内配置其他设置。 welcome_title: 欢迎使用自托管版 %{product_name}! + password_requirements: + length: 至少8个字符 + case: 大写和小写字母 + number: 一个数字 (0-9) + special: "一个特殊字符 (!, @, #, $, %, etc)" diff --git a/config/locales/views/registrations/zh-TW.yml b/config/locales/views/registrations/zh-TW.yml index 9912f0d72..b16b299ae 100644 --- a/config/locales/views/registrations/zh-TW.yml +++ b/config/locales/views/registrations/zh-TW.yml @@ -23,3 +23,8 @@ zh-TW: welcome_body: 在開始之前,您必須註冊一個新帳號。註冊完成後,您將能在應用程式內進行進階設定。 welcome_title: 歡迎使用自行代管的 %{product_name}! password_placeholder: 輸入您的密碼 + password_requirements: + length: 至少8個字元 + case: 大寫和小寫字母 + number: 一個數字 (0-9) + special: "一個特殊字元 (!, @, #, $, %, etc)" diff --git a/config/locales/views/sessions/ca.yml b/config/locales/views/sessions/ca.yml index 2f2c28ba6..8e87f4b4a 100644 --- a/config/locales/views/sessions/ca.yml +++ b/config/locales/views/sessions/ca.yml @@ -25,6 +25,7 @@ ca: no_auth_methods_enabled: Actualment no hi ha cap mètode d'autenticació habilitat. Contacta amb un administrador. openid_connect: Inicia sessió amb OpenID Connect + oidc: Inicia sessió amb OpenID Connect password: Contrasenya password_placeholder: Introdueix la teva contrasenya submit: Inicia sessió diff --git a/config/locales/views/sessions/de.yml b/config/locales/views/sessions/de.yml index 99b95fa05..1d307a7de 100644 --- a/config/locales/views/sessions/de.yml +++ b/config/locales/views/sessions/de.yml @@ -18,4 +18,5 @@ de: title: Melde dich bei deinem Konto an password_placeholder: Passwort eingeben openid_connect: Mit OpenID Connect anmelden + oidc: Mit OpenID Connect anmelden google_auth_connect: Mit Google anmelden diff --git a/config/locales/views/sessions/en.yml b/config/locales/views/sessions/en.yml index 32bbeae7e..beb947b88 100644 --- a/config/locales/views/sessions/en.yml +++ b/config/locales/views/sessions/en.yml @@ -24,6 +24,7 @@ en: title: Sure password_placeholder: Enter your password openid_connect: Sign in with OpenID Connect + oidc: Sign in with OpenID Connect google_auth_connect: Sign in with Google local_login_admin_only: Local login is restricted to administrators. no_auth_methods_enabled: No authentication methods are currently enabled. Please contact an administrator. diff --git a/config/locales/views/sessions/es.yml b/config/locales/views/sessions/es.yml index 5eafcf9d7..d7dbbee6b 100644 --- a/config/locales/views/sessions/es.yml +++ b/config/locales/views/sessions/es.yml @@ -19,6 +19,7 @@ es: title: Inicia sesión en tu cuenta password_placeholder: Introduce tu contraseña openid_connect: Inicia sesión con OpenID Connect + oidc: Inicia sesión con OpenID Connect google_auth_connect: Inicia sesión con Google local_login_admin_only: El inicio de sesión local está restringido a administradores. no_auth_methods_enabled: No hay métodos de autenticación habilitados actualmente. Ponte en contacto con un administrador. diff --git a/config/locales/views/sessions/fr.yml b/config/locales/views/sessions/fr.yml index b56a355df..8e57be388 100644 --- a/config/locales/views/sessions/fr.yml +++ b/config/locales/views/sessions/fr.yml @@ -13,3 +13,5 @@ fr: submit: Se connecter title: Connectez-vous à votre compte password_placeholder: Entrez votre mot de passe + openid_connect: Se connecter avec OpenID Connect + oidc: Se connecter avec OpenID Connect diff --git a/config/locales/views/sessions/nb.yml b/config/locales/views/sessions/nb.yml index c78d4a277..d3c88b1fe 100644 --- a/config/locales/views/sessions/nb.yml +++ b/config/locales/views/sessions/nb.yml @@ -1,8 +1,8 @@ ---- -nb: - sessions: - create: - invalid_credentials: Ugyldig e-post eller passord. +--- +nb: + sessions: + create: + invalid_credentials: Ugyldig e-post eller passord. destroy: logout_successful: Du har blitt logget ut. openid_connect: @@ -16,3 +16,4 @@ nb: title: Logg inn på kontoen din password_placeholder: Angi passordet ditt openid_connect: Logg inn med OpenID Connect + oidc: Logg inn med OpenID Connect diff --git a/config/locales/views/sessions/nl.yml b/config/locales/views/sessions/nl.yml index 6c7ff3397..4cefd4864 100644 --- a/config/locales/views/sessions/nl.yml +++ b/config/locales/views/sessions/nl.yml @@ -24,6 +24,7 @@ nl: title: "%{product_name}" password_placeholder: Voer uw wachtwoord in openid_connect: Inloggen met OpenID Connect + oidc: Inloggen met OpenID Connect google_auth_connect: Inloggen met Google local_login_admin_only: Lokale login is beperkt tot beheerders. no_auth_methods_enabled: Er zijn momenteel geen authenticatiemethoden ingeschakeld. Neem contact op met een beheerder. diff --git a/config/locales/views/sessions/pt-BR.yml b/config/locales/views/sessions/pt-BR.yml index 65691fb61..b9f85d83b 100644 --- a/config/locales/views/sessions/pt-BR.yml +++ b/config/locales/views/sessions/pt-BR.yml @@ -18,6 +18,7 @@ pt-BR: title: Entre na sua conta password_placeholder: Digite sua senha openid_connect: Entrar com OpenID Connect + oidc: Entrar com OpenID Connect google_auth_connect: Entrar com Google demo_banner_title: "Modo de Demonstração Ativo" demo_banner_message: "Este é um ambiente de demonstração. As credenciais de login foram preenchidas para sua conveniência. Por favor, não insira informações reais ou sensíveis." diff --git a/config/locales/views/sessions/ro.yml b/config/locales/views/sessions/ro.yml index a74d33b38..33ca3babd 100644 --- a/config/locales/views/sessions/ro.yml +++ b/config/locales/views/sessions/ro.yml @@ -18,4 +18,5 @@ ro: title: Conectează-te la contul tău password_placeholder: Introdu parola openid_connect: Conectează-te cu OpenID Connect + oidc: Conectează-te cu OpenID Connect google_auth_connect: Conectează-te cu Google diff --git a/config/locales/views/sessions/tr.yml b/config/locales/views/sessions/tr.yml index 91bd43b7b..f354dd02c 100644 --- a/config/locales/views/sessions/tr.yml +++ b/config/locales/views/sessions/tr.yml @@ -16,3 +16,4 @@ tr: title: Hesabınıza giriş yapın password_placeholder: Şifrenizi girin openid_connect: OpenID Connect ile giriş yap + oidc: OpenID Connect ile giriş yap diff --git a/config/locales/views/sessions/zh-CN.yml b/config/locales/views/sessions/zh-CN.yml index d8ca1cba1..f683ee870 100644 --- a/config/locales/views/sessions/zh-CN.yml +++ b/config/locales/views/sessions/zh-CN.yml @@ -15,6 +15,7 @@ zh-CN: forgot_password: 忘记密码? google_auth_connect: 使用 Google 登录 openid_connect: 使用 OpenID Connect 登录 + oidc: 使用 OpenID Connect 登录 password: 密码 password_placeholder: 请输入密码 submit: 登录 diff --git a/config/locales/views/sessions/zh-TW.yml b/config/locales/views/sessions/zh-TW.yml index 31bdf64cc..e6aac1a71 100644 --- a/config/locales/views/sessions/zh-TW.yml +++ b/config/locales/views/sessions/zh-TW.yml @@ -19,6 +19,7 @@ zh-TW: title: 登入 password_placeholder: 輸入您的密碼 openid_connect: 透過 OpenID Connect 登入 + oidc: 透過 OpenID Connect 登入 google_auth_connect: 透過 Google 帳號登入 local_login_admin_only: 本地登入僅限管理員使用。 no_auth_methods_enabled: 目前未啟用任何驗證方式。請聯絡管理員。 diff --git a/test/system/onboardings_test.rb b/test/system/onboardings_test.rb index 2bff29bdc..19410b53a 100644 --- a/test/system/onboardings_test.rb +++ b/test/system/onboardings_test.rb @@ -8,21 +8,28 @@ class OnboardingsTest < ApplicationSystemTestCase # Reset onboarding state @user.update!(set_onboarding_preferences_at: nil) + # Force English locale for tests + I18n.locale = :en + sign_in @user end + teardown do + I18n.locale = I18n.default_locale + end + test "can complete the full onboarding flow" do # Start at the main onboarding page visit onboarding_path - assert_text "Let's set up your account" - assert_button "Continue" + assert_text I18n.t("onboardings.show.title") + assert_button I18n.t("onboardings.show.submit") # Navigate to preferences - click_button "Continue" + click_button I18n.t("onboardings.show.submit") assert_current_path preferences_onboarding_path - assert_text "Configure your preferences" + assert_text I18n.t("onboardings.preferences.title") # Test that the chart renders without errors (this would catch the Series bug) assert_selector "[data-controller='time-series-chart']" @@ -31,14 +38,14 @@ class OnboardingsTest < ApplicationSystemTestCase select "English (en)", from: "user_family_attributes_locale" select "United States Dollar (USD)", from: "user_family_attributes_currency" select "MM/DD/YYYY", from: "user_family_attributes_date_format" - select "Light", from: "user_theme" + select_theme("light") # Submit preferences - click_button "Complete" + click_button I18n.t("onboardings.preferences.submit") # Should redirect to goals page assert_current_path goals_onboarding_path - assert_text "What brings you here?" + assert_text I18n.t("onboardings.goals.title") end test "preferences page renders chart without errors" do @@ -59,7 +66,7 @@ class OnboardingsTest < ApplicationSystemTestCase end # Verify the preview example shows - assert_text "Example" + assert_text I18n.t("onboardings.preferences.example") assert_text "$2,325.25" assert_text "+$78.90" end @@ -72,7 +79,7 @@ class OnboardingsTest < ApplicationSystemTestCase # The preview should update (this tests the JavaScript controller) # Note: This would require the onboarding controller to handle currency changes - assert_text "Example" + assert_text I18n.t("onboardings.preferences.example") end test "can change date format and see preview update" do @@ -82,17 +89,17 @@ class OnboardingsTest < ApplicationSystemTestCase select "DD/MM/YYYY", from: "user_family_attributes_date_format" # The preview should update - assert_text "Example" + assert_text I18n.t("onboardings.preferences.example") end test "can change theme" do visit preferences_onboarding_path - # Change theme - select "Dark", from: "user_theme" + # Change theme using value instead of label + select_theme("dark") # Theme should be applied (this tests the JavaScript controller) - assert_text "Example" + assert_text I18n.t("onboardings.preferences.example") end test "preferences form validation" do @@ -100,7 +107,7 @@ class OnboardingsTest < ApplicationSystemTestCase # Clear required fields and try to submit select "", from: "user_family_attributes_locale" - click_button "Complete" + click_button I18n.t("onboardings.preferences.submit") # Should stay on preferences page with validation errors (may have query params) assert_match %r{/onboarding/preferences}, current_path @@ -113,7 +120,7 @@ class OnboardingsTest < ApplicationSystemTestCase select "Spanish (es)", from: "user_family_attributes_locale" select "Euro (EUR)", from: "user_family_attributes_currency" select "DD/MM/YYYY", from: "user_family_attributes_date_format" - select "Dark", from: "user_theme" + select_theme("dark") # Button text is in Spanish due to locale preview click_button I18n.t("onboardings.preferences.submit", locale: :es) @@ -138,20 +145,20 @@ class OnboardingsTest < ApplicationSystemTestCase visit goals_onboarding_path - assert_text "What brings you here?" - assert_button "Next" + assert_text I18n.t("onboardings.goals.title") + assert_button I18n.t("onboardings.goals.submit") end test "trial page renders correctly" do visit trial_onboarding_path - assert_text "Try Sure" + assert_text "Sure" end test "navigation between onboarding steps" do # Start at main onboarding visit onboarding_path - click_button "Continue" + click_button I18n.t("onboardings.show.submit") # Should be at preferences assert_current_path preferences_onboarding_path @@ -160,7 +167,7 @@ class OnboardingsTest < ApplicationSystemTestCase select "English (en)", from: "user_family_attributes_locale" select "United States Dollar (USD)", from: "user_family_attributes_currency" select "MM/DD/YYYY", from: "user_family_attributes_date_format" - click_button "Complete" + click_button I18n.t("onboardings.preferences.submit") # Should be at goals assert_current_path goals_onboarding_path @@ -177,17 +184,22 @@ class OnboardingsTest < ApplicationSystemTestCase visit preferences_onboarding_path # Should have logout option (rendered as a button component) - assert_text "Sign out" + assert_text I18n.t("onboardings.logout.sign_out") end private + def select_theme(value) + find("#user_theme", visible: :all) + .find("option[value='#{value}']", visible: :all) + .select_option + end def sign_in(user) visit new_session_path within %(form[action='#{sessions_path}']) do - fill_in "Email", with: user.email - fill_in "Password", with: user_password_test - click_on "Log in" + fill_in I18n.t("sessions.new.email"), with: user.email + fill_in I18n.t("sessions.new.password"), with: user_password_test + click_on I18n.t("sessions.new.submit") end # Wait for successful login From 34dcf5110aa9239be46a1ca068418e19c30e2502 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 3 Feb 2026 14:22:05 +0000 Subject: [PATCH 045/108] Bump version to next iteration after v0.6.8-alpha.2 release --- charts/sure/Chart.yaml | 4 ++-- config/initializers/version.rb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/charts/sure/Chart.yaml b/charts/sure/Chart.yaml index 443186098..95bab97ac 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: 0.6.8-alpha.2 -appVersion: "0.6.8-alpha.2" +version: 0.6.8-alpha.3 +appVersion: "0.6.8-alpha.3" kubeVersion: ">=1.25.0-0" diff --git a/config/initializers/version.rb b/config/initializers/version.rb index 63637835d..6c2500749 100644 --- a/config/initializers/version.rb +++ b/config/initializers/version.rb @@ -16,7 +16,7 @@ module Sure private def semver - "0.6.8-alpha.2" + "0.6.8-alpha.3" end end end From 0fb9d60ee65a4499538541adf740e8466f470bf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Tue, 3 Feb 2026 15:45:25 +0100 Subject: [PATCH 046/108] Use `dependent: :purge_later` for ActiveRecord attachments (#882) * Use dependent: :purge_later for user profile_image cleanup This is a simpler alternative to PR #787's callback-based approach. Instead of adding a custom callback and method, we use Rails' built-in `dependent: :purge_later` option which is already used by FamilyExport and other models in the codebase. This single-line change ensures orphaned ActiveStorage attachments are automatically purged when a user is destroyed, without the overhead of querying all attachments manually. https://claude.ai/code/session_01Np3deHEAJqCBfz3aY7c3Tk * Add dependent: :purge_later to all ActiveStorage attachments Extends the attachment cleanup from PR #787 to cover ALL models with ActiveStorage attachments, not just User.profile_image. Models updated: - PdfImport.pdf_file - prevents orphaned PDF files from imports - Account.logo - prevents orphaned account logos - PlaidItem.logo, SimplefinItem.logo, SnaptradeItem.logo, CoinstatsItem.logo, CoinbaseItem.logo, LunchflowItem.logo, MercuryItem.logo, EnableBankingItem.logo - prevents orphaned provider logos This ensures that when a family is deleted (cascade from last user purge), all associated storage files are properly cleaned up via Rails' built-in dependent: :purge_later mechanism. https://claude.ai/code/session_01Np3deHEAJqCBfz3aY7c3Tk * Make sure `Provider` generator adds it * Fix tests --------- Co-authored-by: Claude --- app/models/account.rb | 2 +- app/models/coinbase_item.rb | 2 +- app/models/coinstats_item.rb | 2 +- app/models/enable_banking_item.rb | 2 +- app/models/lunchflow_item.rb | 2 +- app/models/mercury_item.rb | 2 +- app/models/pdf_import.rb | 2 +- app/models/plaid_item.rb | 2 +- app/models/simplefin_item.rb | 2 +- app/models/snaptrade_item.rb | 2 +- app/models/user.rb | 2 +- .../family/templates/item_model.rb.tt | 2 +- test/models/account_test.rb | 19 +++++++- test/models/pdf_import_test.rb | 17 +++++++ test/models/user_test.rb | 48 +++++++++++++++++++ 15 files changed, 95 insertions(+), 13 deletions(-) diff --git a/app/models/account.rb b/app/models/account.rb index dd95023a2..d2a86eaf2 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -36,7 +36,7 @@ class Account < ApplicationRecord manual.where.not(status: :pending_deletion) } - has_one_attached :logo + has_one_attached :logo, dependent: :purge_later delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy delegate :subtype, to: :accountable, allow_nil: true diff --git a/app/models/coinbase_item.rb b/app/models/coinbase_item.rb index 667223a23..f67aeb753 100644 --- a/app/models/coinbase_item.rb +++ b/app/models/coinbase_item.rb @@ -24,7 +24,7 @@ class CoinbaseItem < ApplicationRecord validates :api_secret, presence: true belongs_to :family - has_one_attached :logo + has_one_attached :logo, dependent: :purge_later has_many :coinbase_accounts, dependent: :destroy has_many :accounts, through: :coinbase_accounts diff --git a/app/models/coinstats_item.rb b/app/models/coinstats_item.rb index 72da54d1f..c2c9f51fd 100644 --- a/app/models/coinstats_item.rb +++ b/app/models/coinstats_item.rb @@ -22,7 +22,7 @@ class CoinstatsItem < ApplicationRecord validates :api_key, presence: true belongs_to :family - has_one_attached :logo + has_one_attached :logo, dependent: :purge_later has_many :coinstats_accounts, dependent: :destroy has_many :accounts, through: :coinstats_accounts diff --git a/app/models/enable_banking_item.rb b/app/models/enable_banking_item.rb index 8e84cdb62..7de761bde 100644 --- a/app/models/enable_banking_item.rb +++ b/app/models/enable_banking_item.rb @@ -17,7 +17,7 @@ class EnableBankingItem < ApplicationRecord validates :client_certificate, presence: true, on: :create belongs_to :family - has_one_attached :logo + has_one_attached :logo, dependent: :purge_later has_many :enable_banking_accounts, dependent: :destroy has_many :accounts, through: :enable_banking_accounts diff --git a/app/models/lunchflow_item.rb b/app/models/lunchflow_item.rb index 9f4f4cc7a..1165ba836 100644 --- a/app/models/lunchflow_item.rb +++ b/app/models/lunchflow_item.rb @@ -14,7 +14,7 @@ class LunchflowItem < ApplicationRecord validates :api_key, presence: true, on: :create belongs_to :family - has_one_attached :logo + has_one_attached :logo, dependent: :purge_later has_many :lunchflow_accounts, dependent: :destroy has_many :accounts, through: :lunchflow_accounts diff --git a/app/models/mercury_item.rb b/app/models/mercury_item.rb index a6bcaeb99..2c781265a 100644 --- a/app/models/mercury_item.rb +++ b/app/models/mercury_item.rb @@ -21,7 +21,7 @@ class MercuryItem < ApplicationRecord validates :token, presence: true, on: :create belongs_to :family - has_one_attached :logo + has_one_attached :logo, dependent: :purge_later has_many :mercury_accounts, dependent: :destroy has_many :accounts, through: :mercury_accounts diff --git a/app/models/pdf_import.rb b/app/models/pdf_import.rb index 0e0250462..90cb3379d 100644 --- a/app/models/pdf_import.rb +++ b/app/models/pdf_import.rb @@ -1,5 +1,5 @@ class PdfImport < Import - has_one_attached :pdf_file + has_one_attached :pdf_file, dependent: :purge_later validates :document_type, inclusion: { in: DOCUMENT_TYPES }, allow_nil: true diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index c7f325134..4ac59d0cf 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -17,7 +17,7 @@ class PlaidItem < ApplicationRecord before_destroy :remove_plaid_item belongs_to :family - has_one_attached :logo + has_one_attached :logo, dependent: :purge_later has_many :plaid_accounts, dependent: :destroy has_many :legacy_accounts, through: :plaid_accounts, source: :account diff --git a/app/models/simplefin_item.rb b/app/models/simplefin_item.rb index 07039ad77..253b6098c 100644 --- a/app/models/simplefin_item.rb +++ b/app/models/simplefin_item.rb @@ -20,7 +20,7 @@ class SimplefinItem < ApplicationRecord before_destroy :remove_simplefin_item belongs_to :family - has_one_attached :logo + has_one_attached :logo, dependent: :purge_later has_many :simplefin_accounts, dependent: :destroy has_many :legacy_accounts, through: :simplefin_accounts, source: :account diff --git a/app/models/snaptrade_item.rb b/app/models/snaptrade_item.rb index 365d6af3f..0c627d7d3 100644 --- a/app/models/snaptrade_item.rb +++ b/app/models/snaptrade_item.rb @@ -29,7 +29,7 @@ class SnaptradeItem < ApplicationRecord # via ensure_user_registered!, so we don't validate them on create belongs_to :family - has_one_attached :logo + has_one_attached :logo, dependent: :purge_later has_many :snaptrade_accounts, dependent: :destroy has_many :linked_accounts, through: :snaptrade_accounts diff --git a/app/models/user.rb b/app/models/user.rb index 3ab9276f8..df8f9fc5e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -59,7 +59,7 @@ class User < ApplicationRecord User.exists? ? fallback_role : :super_admin end - has_one_attached :profile_image do |attachable| + has_one_attached :profile_image, dependent: :purge_later do |attachable| attachable.variant :thumbnail, resize_to_fill: [ 300, 300 ], convert: :webp, saver: { quality: 80 } attachable.variant :small, resize_to_fill: [ 72, 72 ], convert: :webp, saver: { quality: 80 }, preprocessed: true end diff --git a/lib/generators/provider/family/templates/item_model.rb.tt b/lib/generators/provider/family/templates/item_model.rb.tt index 9cfbf1997..9d4a8e437 100644 --- a/lib/generators/provider/family/templates/item_model.rb.tt +++ b/lib/generators/provider/family/templates/item_model.rb.tt @@ -27,7 +27,7 @@ class <%= class_name %>Item < ApplicationRecord <% end -%> belongs_to :family - has_one_attached :logo + has_one_attached :logo, dependent: :purge_later has_many :<%= file_name %>_accounts, dependent: :destroy has_many :accounts, through: :<%= file_name %>_accounts diff --git a/test/models/account_test.rb b/test/models/account_test.rb index 8a52192af..5a41f432e 100644 --- a/test/models/account_test.rb +++ b/test/models/account_test.rb @@ -1,7 +1,7 @@ require "test_helper" class AccountTest < ActiveSupport::TestCase - include SyncableInterfaceTest, EntriesTestHelper + include SyncableInterfaceTest, EntriesTestHelper, ActiveJob::TestHelper setup do @account = @syncable = accounts(:depository) @@ -155,4 +155,21 @@ class AccountTest < ActiveSupport::TestCase assert @account.taxable? assert_not @account.tax_advantaged? end + + test "destroying account purges attached logo" do + @account.logo.attach( + io: StringIO.new("fake-logo-content"), + filename: "logo.png", + content_type: "image/png" + ) + + attachment_id = @account.logo.id + assert ActiveStorage::Attachment.exists?(attachment_id) + + perform_enqueued_jobs do + @account.destroy! + end + + assert_not ActiveStorage::Attachment.exists?(attachment_id) + end end diff --git a/test/models/pdf_import_test.rb b/test/models/pdf_import_test.rb index a2491ee1a..37281b5cc 100644 --- a/test/models/pdf_import_test.rb +++ b/test/models/pdf_import_test.rb @@ -132,4 +132,21 @@ class PdfImportTest < ActiveSupport::TestCase assert_nil @import.account assert_not_includes @import.mapping_steps, Import::AccountMapping end + + test "destroying import purges attached pdf_file" do + @import.pdf_file.attach( + io: StringIO.new("fake-pdf-content"), + filename: "statement.pdf", + content_type: "application/pdf" + ) + + attachment_id = @import.pdf_file.id + assert ActiveStorage::Attachment.exists?(attachment_id) + + perform_enqueued_jobs do + @import.destroy! + end + + assert_not ActiveStorage::Attachment.exists?(attachment_id) + end end diff --git a/test/models/user_test.rb b/test/models/user_test.rb index a1c0c496e..53b49ed9a 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -1,10 +1,17 @@ require "test_helper" class UserTest < ActiveSupport::TestCase + include ActiveJob::TestHelper + def setup @user = users(:family_admin) end + def teardown + clear_enqueued_jobs + clear_performed_jobs + end + test "should be valid" do assert @user.valid?, @user.errors.full_messages.to_sentence end @@ -348,4 +355,45 @@ class UserTest < ActiveSupport::TestCase assert_equal :member, User.role_for_new_family_creator(fallback_role: :member) assert_equal "custom_role", User.role_for_new_family_creator(fallback_role: "custom_role") end + + # ActiveStorage attachment cleanup tests + test "purging a user removes attached profile image" do + user = users(:family_admin) + user.profile_image.attach( + io: StringIO.new("profile-image-data"), + filename: "profile.png", + content_type: "image/png" + ) + + attachment_id = user.profile_image.id + assert ActiveStorage::Attachment.exists?(attachment_id) + + perform_enqueued_jobs do + user.purge + end + + assert_not User.exists?(user.id) + assert_not ActiveStorage::Attachment.exists?(attachment_id) + end + + test "purging the last user cascades to remove family and its export attachments" do + family = Family.create!(name: "Solo Family", locale: "en", date_format: "%m-%d-%Y", currency: "USD") + user = User.create!(family: family, email: "solo@example.com", password: "password123") + export = family.family_exports.create! + export.export_file.attach( + io: StringIO.new("export-data"), + filename: "export.zip", + content_type: "application/zip" + ) + + export_attachment_id = export.export_file.id + assert ActiveStorage::Attachment.exists?(export_attachment_id) + + perform_enqueued_jobs do + user.purge + end + + assert_not Family.exists?(family.id) + assert_not ActiveStorage::Attachment.exists?(export_attachment_id) + end end From fb9468d80b1c4fd6d1230ec37bc9e41b996f3904 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Tue, 3 Feb 2026 17:08:53 +0100 Subject: [PATCH 047/108] Update Romanian localization for profile subtitle placeholder (#885) Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> --- config/locales/views/settings/ro.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/locales/views/settings/ro.yml b/config/locales/views/settings/ro.yml index 911acc019..758bb7d5c 100644 --- a/config/locales/views/settings/ro.yml +++ b/config/locales/views/settings/ro.yml @@ -86,7 +86,7 @@ ro: last_name: Nume de familie page_title: Informații profil pending: În așteptare - profile_subtitle: Personalizează-ți aspectul pe %{product} + profile_subtitle: Personalizează-ți aspectul pe %{product_name} profile_title: Personal remove_invitation: Anulează invitația remove_member: Elimină membrul From b08285a10f22d2e6556c695da0a05764490adb49 Mon Sep 17 00:00:00 2001 From: David Gil Date: Wed, 4 Feb 2026 22:55:50 +0100 Subject: [PATCH 048/108] API v1: add amount_cents + signed_amount_cents to transactions (#899) * feat(api): add amount_cents + signed_amount_cents to transactions * fix: use currency.minor_unit_conversion for amount_cents - Replace hardcoded *100 with currency.minor_unit_conversion - Handles JPY (0 decimals), KWD/BHD (3 decimals), etc. correctly - Add assert_amount_cents_fields helper to validate sign/scale invariants --- .../transactions/_transaction.json.jbuilder | 11 +++++++++ .../api/v1/transactions_controller_test.rb | 23 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/app/views/api/v1/transactions/_transaction.json.jbuilder b/app/views/api/v1/transactions/_transaction.json.jbuilder index 2a1c9779e..617f47505 100644 --- a/app/views/api/v1/transactions/_transaction.json.jbuilder +++ b/app/views/api/v1/transactions/_transaction.json.jbuilder @@ -3,6 +3,17 @@ json.id transaction.id json.date transaction.entry.date json.amount transaction.entry.amount_money.format + +# Agent/automation-friendly numeric fields (avoid localized parsing and clarify sign) +# `amount` in v1 is a localized string and may follow an accounting sign convention. +# Expose minor units (cents) as integers to make the API agent-friendly. +# Uses currency.minor_unit_conversion (e.g. 100 for USD/EUR, 1 for JPY, 1000 for KWD). +amount_money = transaction.entry.amount_money +conversion_factor = amount_money.currency.minor_unit_conversion +amount_cents = (amount_money.amount * conversion_factor).round(0).to_i.abs +json.amount_cents amount_cents +json.signed_amount_cents(transaction.entry.classification == "income" ? amount_cents : -amount_cents) + json.currency transaction.entry.currency json.name transaction.entry.name json.notes transaction.entry.notes diff --git a/test/controllers/api/v1/transactions_controller_test.rb b/test/controllers/api/v1/transactions_controller_test.rb index b028562a6..9b681d4de 100644 --- a/test/controllers/api/v1/transactions_controller_test.rb +++ b/test/controllers/api/v1/transactions_controller_test.rb @@ -41,6 +41,10 @@ class Api::V1::TransactionsControllerTest < ActionDispatch::IntegrationTest response_data = JSON.parse(response.body) assert response_data.key?("transactions") assert response_data.key?("pagination") + + # Agent-friendly numeric fields (validate type + sign invariants) + first = response_data["transactions"].first + assert_amount_cents_fields(first) assert response_data["pagination"].key?("page") assert response_data["pagination"].key?("per_page") assert response_data["pagination"].key?("total_count") @@ -130,6 +134,7 @@ class Api::V1::TransactionsControllerTest < ActionDispatch::IntegrationTest assert_equal @transaction.id, response_data["id"] assert response_data.key?("name") assert response_data.key?("amount") + assert_amount_cents_fields(response_data) assert response_data.key?("date") assert response_data.key?("account") end @@ -358,4 +363,22 @@ end def api_headers(api_key) { "X-Api-Key" => api_key.display_key } end + + # Validates agent-friendly numeric fields: type, sign invariants + def assert_amount_cents_fields(txn_json) + assert txn_json.key?("amount_cents"), "Expected amount_cents field" + assert txn_json.key?("signed_amount_cents"), "Expected signed_amount_cents field" + assert_kind_of Integer, txn_json["amount_cents"] + assert_kind_of Integer, txn_json["signed_amount_cents"] + assert_operator txn_json["amount_cents"], :>=, 0, "amount_cents must be non-negative" + assert_equal txn_json["amount_cents"].abs, txn_json["signed_amount_cents"].abs, + "Absolute values of amount_cents and signed_amount_cents must match" + if txn_json["classification"] == "income" + assert_operator txn_json["signed_amount_cents"], :>=, 0, + "income transactions should have non-negative signed_amount_cents" + else + assert_operator txn_json["signed_amount_cents"], :<=, 0, + "non-income transactions should have non-positive signed_amount_cents" + end + end end From ee6afb48fd9e1bf450e07ab8bea2e648f6c35453 Mon Sep 17 00:00:00 2001 From: LPW Date: Wed, 4 Feb 2026 17:40:01 -0500 Subject: [PATCH 049/108] Add encryption support to provider account models (#815) * Enable encryption for raw payloads in account models. * Add backfill support for Snaptrade, Coinbase, Coinstats, and Mercury accounts. --- app/models/coinbase_account.rb | 8 +++++++- app/models/coinstats_account.rb | 8 +++++++- app/models/mercury_account.rb | 8 +++++++- app/models/snaptrade_account.rb | 10 +++++++++- lib/tasks/security_backfill.rake | 4 ++++ 5 files changed, 34 insertions(+), 4 deletions(-) diff --git a/app/models/coinbase_account.rb b/app/models/coinbase_account.rb index 82809cf51..c66423feb 100644 --- a/app/models/coinbase_account.rb +++ b/app/models/coinbase_account.rb @@ -1,5 +1,11 @@ class CoinbaseAccount < ApplicationRecord - include CurrencyNormalizable + include CurrencyNormalizable, Encryptable + + # Encrypt raw payloads if ActiveRecord encryption is configured + if encryption_ready? + encrypts :raw_payload + encrypts :raw_transactions_payload + end belongs_to :coinbase_item diff --git a/app/models/coinstats_account.rb b/app/models/coinstats_account.rb index 71c54b5a3..86231321f 100644 --- a/app/models/coinstats_account.rb +++ b/app/models/coinstats_account.rb @@ -1,7 +1,13 @@ # Represents a single crypto token/coin within a CoinStats wallet. # Each wallet address may have multiple CoinstatsAccounts (one per token). class CoinstatsAccount < ApplicationRecord - include CurrencyNormalizable + include CurrencyNormalizable, Encryptable + + # Encrypt raw payloads if ActiveRecord encryption is configured + if encryption_ready? + encrypts :raw_payload + encrypts :raw_transactions_payload + end belongs_to :coinstats_item diff --git a/app/models/mercury_account.rb b/app/models/mercury_account.rb index 43577ecc5..a4635cfc7 100644 --- a/app/models/mercury_account.rb +++ b/app/models/mercury_account.rb @@ -1,5 +1,11 @@ class MercuryAccount < ApplicationRecord - include CurrencyNormalizable + include CurrencyNormalizable, Encryptable + + # Encrypt raw payloads if ActiveRecord encryption is configured + if encryption_ready? + encrypts :raw_payload + encrypts :raw_transactions_payload + end belongs_to :mercury_item diff --git a/app/models/snaptrade_account.rb b/app/models/snaptrade_account.rb index 6e888cb1d..40ceb4f9f 100644 --- a/app/models/snaptrade_account.rb +++ b/app/models/snaptrade_account.rb @@ -1,7 +1,15 @@ class SnaptradeAccount < ApplicationRecord - include CurrencyNormalizable + include CurrencyNormalizable, Encryptable include SnaptradeAccount::DataHelpers + # Encrypt raw payloads if ActiveRecord encryption is configured + if encryption_ready? + encrypts :raw_payload + encrypts :raw_transactions_payload + encrypts :raw_holdings_payload + encrypts :raw_activities_payload + end + belongs_to :snaptrade_item # Association through account_providers for linking to Sure accounts diff --git a/lib/tasks/security_backfill.rake b/lib/tasks/security_backfill.rake index 3b79ecc8f..ee2a26191 100644 --- a/lib/tasks/security_backfill.rake +++ b/lib/tasks/security_backfill.rake @@ -56,6 +56,10 @@ namespace :security do results[:simplefin_accounts] = backfill_model(SimplefinAccount, %i[raw_payload raw_transactions_payload raw_holdings_payload], batch_size, dry_run) results[:lunchflow_accounts] = backfill_model(LunchflowAccount, %i[raw_payload raw_transactions_payload], batch_size, dry_run) results[:enable_banking_accounts] = backfill_model(EnableBankingAccount, %i[raw_payload raw_transactions_payload], batch_size, dry_run) + results[:snaptrade_accounts] = backfill_model(SnaptradeAccount, %i[raw_payload raw_transactions_payload raw_holdings_payload raw_activities_payload], batch_size, dry_run) + results[:coinbase_accounts] = backfill_model(CoinbaseAccount, %i[raw_payload raw_transactions_payload], batch_size, dry_run) + results[:coinstats_accounts] = backfill_model(CoinstatsAccount, %i[raw_payload raw_transactions_payload], batch_size, dry_run) + results[:mercury_accounts] = backfill_model(MercuryAccount, %i[raw_payload raw_transactions_payload], batch_size, dry_run) puts({ ok: true, From 68efe71cdbdad26490b684d36392c93e3c7ea305 Mon Sep 17 00:00:00 2001 From: MkDev11 <94194147+MkDev11@users.noreply.github.com> Date: Wed, 4 Feb 2026 17:58:09 -0500 Subject: [PATCH 050/108] feat: Customizable Budget Month Start Day (#810) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add customizable budget month start day (#253) Allow users to set a custom month-to-date start date (1st-28th) for budgeting and MTD calculations. Useful for users who want budget periods aligned with their pay schedule (e.g., 25th to 24th). Changes: - Add month_start_day column to families table (default: 1) - Add database check constraint for valid range (1-28) - Add Family#uses_custom_month_start?, custom_month_start_for, custom_month_end_for, current_custom_month_period helper methods - Add Period.current_month_for(family), last_month_for(family) methods - Update Budget model for custom month boundaries in find_or_bootstrap, param_to_date, budget_date_valid?, current?, and name methods - Add month_start_day setting to Settings > Preferences UI - Add warning message when custom month start day is configured - Add comprehensive tests with travel_to for date robustness Fixes #253 * Add /api/v1/user endpoint for Flutter mobile app and PWA Expose user preferences including month_start_day via API endpoint following existing pattern for default_period. This allows Flutter mobile app and PWA to read/update user preferences through a consistent API contract. Endpoints: - GET /api/v1/user - Read user preferences including family settings - PATCH /api/v1/user - Update user preferences Response includes: id, email, first_name, last_name, default_period, locale, and family settings (currency, timezone, date_format, country, month_start_day). * Update Periodable to use family-aware MTD periods When users select 'current_month' or 'last_month' period filters on dashboard/reports, now respects the family's custom month_start_day setting instead of using static calendar month boundaries. This ensures MTD filter on dashboard is consistent with how budgets calculate their periods when custom month start day is configured. * Fix param_to_date to correctly map budget params to custom periods When a family uses a custom start day, the previous implementation called custom_month_start_for on the 1st of the month, which incorrectly shifted dates before the start day to the previous month. Now we directly construct the date using family.month_start_day, so 'jan-2026' with month_start_day=25 correctly returns Jan 25, 2026 instead of Dec 25, 2025. * Fix param_to_date and use Current pattern in API controller - Fix param_to_date to directly construct date with family.month_start_day instead of using custom_month_start_for which incorrectly shifted dates - Replace current_user with Current.user/Current.family in API controller to follow project convention used in other API v1 controllers * Add i18n for budget name method Use I18n.t for localizable budget period names to follow project conventions for user-facing strings. * Remove unused budget_end variable in budget_date_valid? * Use Date.current for timezone consistency in Budget#current? * Address PR review feedback - Remove API users endpoint (mobile won't use yet) - Remove user route from config/routes.rb - Remove ai_summary/document_type schema bleed from pdf-import-ai branch * Pass family to param_to_date for custom month logic * Run migration to add month_start_day column to schema * Schema regressions --------- Co-authored-by: mkdev11 Co-authored-by: Juan José Mata --- .../budget_categories_controller.rb | 2 +- app/controllers/budgets_controller.rb | 2 +- app/controllers/concerns/periodable.rb | 10 ++- app/controllers/users_controller.rb | 2 +- app/models/budget.rb | 49 ++++++++++--- app/models/family.rb | 26 +++++++ app/models/period.rb | 16 +++++ app/views/settings/preferences/show.html.erb | 11 +++ config/locales/views/budgets/en.yml | 10 +++ config/locales/views/settings/en.yml | 3 + ...7213817_add_month_start_day_to_families.rb | 6 ++ db/schema.rb | 2 + test/models/family/month_start_day_test.rb | 70 +++++++++++++++++++ 13 files changed, 195 insertions(+), 14 deletions(-) create mode 100644 config/locales/views/budgets/en.yml create mode 100644 db/migrate/20260127213817_add_month_start_day_to_families.rb create mode 100644 test/models/family/month_start_day_test.rb diff --git a/app/controllers/budget_categories_controller.rb b/app/controllers/budget_categories_controller.rb index e8cc83e6b..b779a0224 100644 --- a/app/controllers/budget_categories_controller.rb +++ b/app/controllers/budget_categories_controller.rb @@ -42,7 +42,7 @@ class BudgetCategoriesController < ApplicationController end def set_budget - start_date = Budget.param_to_date(params[:budget_month_year]) + start_date = Budget.param_to_date(params[:budget_month_year], family: Current.family) @budget = Current.family.budgets.find_by(start_date: start_date) end end diff --git a/app/controllers/budgets_controller.rb b/app/controllers/budgets_controller.rb index 9ec26e831..1ec8e81b6 100644 --- a/app/controllers/budgets_controller.rb +++ b/app/controllers/budgets_controller.rb @@ -35,7 +35,7 @@ class BudgetsController < ApplicationController end def set_budget - start_date = Budget.param_to_date(params[:month_year]) + start_date = Budget.param_to_date(params[:month_year], family: Current.family) @budget = Budget.find_or_bootstrap(Current.family, start_date: start_date) raise ActiveRecord::RecordNotFound unless @budget end diff --git a/app/controllers/concerns/periodable.rb b/app/controllers/concerns/periodable.rb index 8cf02395f..88be0f05c 100644 --- a/app/controllers/concerns/periodable.rb +++ b/app/controllers/concerns/periodable.rb @@ -7,7 +7,15 @@ module Periodable private def set_period - @period = Period.from_key(params[:period] || Current.user&.default_period) + period_key = params[:period] || Current.user&.default_period + + @period = if period_key == "current_month" + Period.current_month_for(Current.family) + elsif period_key == "last_month" + Period.last_month_for(Current.family) + else + Period.from_key(period_key) + end rescue Period::InvalidKeyError @period = Period.last_30_days end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 59fa68bdb..f74c7e6e1 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -106,7 +106,7 @@ class UsersController < ApplicationController params.require(:user).permit( :first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, :show_sidebar, :default_period, :default_account_order, :show_ai_sidebar, :ai_enabled, :theme, :set_onboarding_preferences_at, :set_onboarding_goals_at, :locale, - family_attributes: [ :name, :currency, :country, :date_format, :timezone, :locale, :id ], + family_attributes: [ :name, :currency, :country, :date_format, :timezone, :locale, :month_start_day, :id ], goals: [] ) end diff --git a/app/models/budget.rb b/app/models/budget.rb index 33f34eb2f..c345801fc 100644 --- a/app/models/budget.rb +++ b/app/models/budget.rb @@ -19,24 +19,41 @@ class Budget < ApplicationRecord date.strftime(PARAM_DATE_FORMAT).downcase end - def param_to_date(param) - Date.strptime(param, PARAM_DATE_FORMAT).beginning_of_month + def param_to_date(param, family: nil) + base_date = Date.strptime(param, PARAM_DATE_FORMAT) + if family&.uses_custom_month_start? + Date.new(base_date.year, base_date.month, family.month_start_day) + else + base_date.beginning_of_month + end end def budget_date_valid?(date, family:) - beginning_of_month = date.beginning_of_month - - beginning_of_month >= oldest_valid_budget_date(family) && beginning_of_month <= Date.current.end_of_month + if family.uses_custom_month_start? + budget_start = family.custom_month_start_for(date) + budget_start >= oldest_valid_budget_date(family) && budget_start <= family.custom_month_end_for(Date.current) + else + beginning_of_month = date.beginning_of_month + beginning_of_month >= oldest_valid_budget_date(family) && beginning_of_month <= Date.current.end_of_month + end end def find_or_bootstrap(family, start_date:) return nil unless budget_date_valid?(start_date, family: family) Budget.transaction do + if family.uses_custom_month_start? + budget_start = family.custom_month_start_for(start_date) + budget_end = family.custom_month_end_for(start_date) + else + budget_start = start_date.beginning_of_month + budget_end = start_date.end_of_month + end + budget = Budget.find_or_create_by!( family: family, - start_date: start_date.beginning_of_month, - end_date: start_date.end_of_month + start_date: budget_start, + end_date: budget_end ) do |b| b.currency = family.currency end @@ -49,7 +66,6 @@ class Budget < ApplicationRecord private def oldest_valid_budget_date(family) - # Allow going back to either the earliest entry date OR 2 years ago, whichever is earlier two_years_ago = 2.years.ago.beginning_of_month oldest_entry_date = family.oldest_entry_date.beginning_of_month [ two_years_ago, oldest_entry_date ].min @@ -95,7 +111,15 @@ class Budget < ApplicationRecord end def name - start_date.strftime("%B %Y") + if family.uses_custom_month_start? + I18n.t( + "budgets.name.custom_range", + start: start_date.strftime("%b %d"), + end_date: end_date.strftime("%b %d, %Y") + ) + else + I18n.t("budgets.name.month_year", month: start_date.strftime("%B %Y")) + end end def initialized? @@ -111,7 +135,12 @@ class Budget < ApplicationRecord end def current? - start_date == Date.today.beginning_of_month && end_date == Date.today.end_of_month + if family.uses_custom_month_start? + current_period = family.current_custom_month_period + start_date == current_period.start_date && end_date == current_period.end_date + else + start_date == Date.current.beginning_of_month && end_date == Date.current.end_of_month + end end def previous_budget_param diff --git a/app/models/family.rb b/app/models/family.rb index af9f0aa98..6741393ca 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -40,6 +40,32 @@ class Family < ApplicationRecord validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) } validates :date_format, inclusion: { in: DATE_FORMATS.map(&:last) } + validates :month_start_day, inclusion: { in: 1..28 } + + def uses_custom_month_start? + month_start_day != 1 + end + + def custom_month_start_for(date) + if date.day >= month_start_day + Date.new(date.year, date.month, month_start_day) + else + previous_month = date - 1.month + Date.new(previous_month.year, previous_month.month, month_start_day) + end + end + + def custom_month_end_for(date) + start_date = custom_month_start_for(date) + next_month_start = start_date + 1.month + next_month_start - 1.day + end + + def current_custom_month_period + start_date = custom_month_start_for(Date.current) + end_date = custom_month_end_for(Date.current) + Period.custom(start_date: start_date, end_date: end_date) + end def assigned_merchants merchant_ids = transactions.where.not(merchant_id: nil).pluck(:merchant_id).uniq diff --git a/app/models/period.rb b/app/models/period.rb index 3e369f410..4188478f2 100644 --- a/app/models/period.rb +++ b/app/models/period.rb @@ -116,6 +116,22 @@ class Period def as_options all.map { |period| [ period.label_short, period.key ] } end + + def current_month_for(family) + return from_key("current_month") unless family&.uses_custom_month_start? + + family.current_custom_month_period + end + + def last_month_for(family) + return from_key("last_month") unless family&.uses_custom_month_start? + + current_start = family.custom_month_start_for(Date.current) + last_month_date = current_start - 1.day + start_date = family.custom_month_start_for(last_month_date) + end_date = family.custom_month_end_for(last_month_date) + custom(start_date: start_date, end_date: end_date) + end end PERIODS.each do |key, period| diff --git a/app/views/settings/preferences/show.html.erb b/app/views/settings/preferences/show.html.erb index 769b31801..a9f44e029 100644 --- a/app/views/settings/preferences/show.html.erb +++ b/app/views/settings/preferences/show.html.erb @@ -40,6 +40,17 @@ { label: t(".country") }, { data: { auto_submit_form_target: "auto" } } %> + <%= family_form.select :month_start_day, + (1..28).map { |day| [day.ordinalize, day] }, + { label: t(".month_start_day"), hint: t(".month_start_day_hint") }, + { data: { auto_submit_form_target: "auto" } } %> + + <% if @user.family.uses_custom_month_start? %> +
    + <%= t(".month_start_day_warning") %> +
    + <% end %> +

    Please note, we are still working on translations for various languages.

    <% end %> <% end %> diff --git a/config/locales/views/budgets/en.yml b/config/locales/views/budgets/en.yml new file mode 100644 index 000000000..6f98a5686 --- /dev/null +++ b/config/locales/views/budgets/en.yml @@ -0,0 +1,10 @@ +--- +en: + budgets: + name: + custom_range: "%{start} - %{end_date}" + month_year: "%{month}" + show: + tabs: + actual: Actual + budgeted: Budgeted diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index ca407957b..27d8cee29 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -44,6 +44,9 @@ en: theme_system: System theme_title: Theme timezone: Timezone + month_start_day: Budget month starts on + month_start_day_hint: Set when your budget month starts (e.g., payday) + month_start_day_warning: Your budgets and MTD calculations will use this custom start day instead of the 1st of each month. profiles: destroy: cannot_remove_self: You cannot remove yourself from the account. diff --git a/db/migrate/20260127213817_add_month_start_day_to_families.rb b/db/migrate/20260127213817_add_month_start_day_to_families.rb new file mode 100644 index 000000000..2b97fe6c6 --- /dev/null +++ b/db/migrate/20260127213817_add_month_start_day_to_families.rb @@ -0,0 +1,6 @@ +class AddMonthStartDayToFamilies < ActiveRecord::Migration[7.2] + def change + add_column :families, :month_start_day, :integer, default: 1, null: false + add_check_constraint :families, "month_start_day >= 1 AND month_start_day <= 28", name: "month_start_day_range" + end +end diff --git a/db/schema.rb b/db/schema.rb index 3277c7385..873fc72b2 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -499,6 +499,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_29_200129) do t.datetime "latest_sync_activity_at", default: -> { "CURRENT_TIMESTAMP" } t.datetime "latest_sync_completed_at", default: -> { "CURRENT_TIMESTAMP" } t.boolean "recurring_transactions_disabled", default: false, null: false + t.integer "month_start_day", default: 1, null: false + t.check_constraint "month_start_day >= 1 AND month_start_day <= 28", name: "month_start_day_range" end create_table "family_exports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| diff --git a/test/models/family/month_start_day_test.rb b/test/models/family/month_start_day_test.rb new file mode 100644 index 000000000..ca4d3c8a8 --- /dev/null +++ b/test/models/family/month_start_day_test.rb @@ -0,0 +1,70 @@ +require "test_helper" + +class Family::MonthStartDayTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + end + + test "month_start_day defaults to 1" do + assert_equal 1, @family.month_start_day + end + + test "validates month_start_day is between 1 and 28" do + @family.month_start_day = 0 + assert_not @family.valid? + + @family.month_start_day = 29 + assert_not @family.valid? + + @family.month_start_day = 15 + assert @family.valid? + end + + test "uses_custom_month_start? returns false when month_start_day is 1" do + @family.month_start_day = 1 + assert_not @family.uses_custom_month_start? + end + + test "uses_custom_month_start? returns true when month_start_day is not 1" do + @family.month_start_day = 25 + assert @family.uses_custom_month_start? + end + + test "custom_month_start_for returns correct start date when day is after month_start_day" do + @family.month_start_day = 15 + + travel_to Date.new(2026, 1, 20) do + result = @family.custom_month_start_for(Date.current) + assert_equal Date.new(2026, 1, 15), result + end + end + + test "custom_month_start_for returns correct start date when day is before month_start_day" do + @family.month_start_day = 15 + + travel_to Date.new(2026, 1, 10) do + result = @family.custom_month_start_for(Date.current) + assert_equal Date.new(2025, 12, 15), result + end + end + + test "custom_month_end_for returns one day before next custom month start" do + @family.month_start_day = 15 + + travel_to Date.new(2026, 1, 20) do + result = @family.custom_month_end_for(Date.current) + assert_equal Date.new(2026, 2, 14), result + end + end + + test "current_custom_month_period returns correct period" do + @family.month_start_day = 25 + + travel_to Date.new(2026, 1, 27) do + period = @family.current_custom_month_period + + assert_equal Date.new(2026, 1, 25), period.start_date + assert_equal Date.new(2026, 2, 24), period.end_date + end + end +end From d09765a14cd4bec8d37a5fefe58b637a8bfd62c2 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Thu, 5 Feb 2026 19:47:01 +0100 Subject: [PATCH 051/108] Add mailer subject tests and refine i18n keys (#910) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add mailer subject tests and refine i18n keys * Linter * Fix test * More fixes * More fixes --------- Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> Co-authored-by: Juan José Mata --- app/mailers/invitation_mailer.rb | 2 +- app/mailers/pdf_import_mailer.rb | 2 +- .../invitation_mailer/invite_email.html.erb | 2 +- .../locales/mailers/pdf_import_mailer/en.yml | 2 +- test/mailers/invitation_mailer_test.rb | 18 ++++++++++++++++++ test/mailers/pdf_import_mailer_test.rb | 4 ++++ 6 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 test/mailers/invitation_mailer_test.rb diff --git a/app/mailers/invitation_mailer.rb b/app/mailers/invitation_mailer.rb index 9b90676c5..0fb7589f6 100644 --- a/app/mailers/invitation_mailer.rb +++ b/app/mailers/invitation_mailer.rb @@ -8,7 +8,7 @@ class InvitationMailer < ApplicationMailer subject: t( ".subject", inviter: @invitation.inviter.display_name, - product: product_name + product_name: product_name ) ) end diff --git a/app/mailers/pdf_import_mailer.rb b/app/mailers/pdf_import_mailer.rb index 5f9f759d7..aeae3f5d6 100644 --- a/app/mailers/pdf_import_mailer.rb +++ b/app/mailers/pdf_import_mailer.rb @@ -6,7 +6,7 @@ class PdfImportMailer < ApplicationMailer mail( to: @user.email, - subject: t(".subject", product: product_name) + subject: t(".subject", product_name: product_name) ) end end diff --git a/app/views/invitation_mailer/invite_email.html.erb b/app/views/invitation_mailer/invite_email.html.erb index b6741f236..dafb379ee 100644 --- a/app/views/invitation_mailer/invite_email.html.erb +++ b/app/views/invitation_mailer/invite_email.html.erb @@ -1,4 +1,4 @@ -

    <%= t(".greeting", product: product_name) %>

    +

    <%= t(".greeting", product_name: product_name) %>

    <%= t( diff --git a/config/locales/mailers/pdf_import_mailer/en.yml b/config/locales/mailers/pdf_import_mailer/en.yml index 1399d306b..401b03efc 100644 --- a/config/locales/mailers/pdf_import_mailer/en.yml +++ b/config/locales/mailers/pdf_import_mailer/en.yml @@ -2,4 +2,4 @@ en: pdf_import_mailer: next_steps: - subject: "Your PDF document has been analyzed - %{product}" + subject: "Your PDF document has been analyzed - %{product_name}" diff --git a/test/mailers/invitation_mailer_test.rb b/test/mailers/invitation_mailer_test.rb new file mode 100644 index 000000000..485f20329 --- /dev/null +++ b/test/mailers/invitation_mailer_test.rb @@ -0,0 +1,18 @@ +require "test_helper" + +class InvitationMailerTest < ActionMailer::TestCase + test "invite_email" do + invitation = invitations(:one) + + mail = InvitationMailer.invite_email(invitation) + + assert_equal I18n.t( + "invitation_mailer.invite_email.subject", + inviter: invitation.inviter.display_name, + product_name: Rails.configuration.x.product_name + ), mail.subject + assert_equal [ invitation.email ], mail.to + assert_equal [ "hello@example.com" ], mail.from + assert_match "accept", mail.body.encoded + end +end diff --git a/test/mailers/pdf_import_mailer_test.rb b/test/mailers/pdf_import_mailer_test.rb index d5d118b27..1aa9c8561 100644 --- a/test/mailers/pdf_import_mailer_test.rb +++ b/test/mailers/pdf_import_mailer_test.rb @@ -9,6 +9,10 @@ class PdfImportMailerTest < ActionMailer::TestCase test "next_steps email is sent to user" do mail = PdfImportMailer.with(user: @user, pdf_import: @pdf_import).next_steps + assert_equal I18n.t( + "pdf_import_mailer.next_steps.subject", + product_name: Rails.configuration.x.product_name + ), mail.subject assert_equal [ @user.email ], mail.to assert_includes mail.subject, "analyzed" end From 4938c44a68d2de71498f3044279a03f27671f037 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Fri, 6 Feb 2026 00:00:04 +0100 Subject: [PATCH 052/108] Normalize whitespace in text rule matching (#890) Normalize whitespace in text-based rule filters so transaction names with irregular spacing still match. Refs #886 --- app/models/rule/condition_filter.rb | 18 ++++++++++++++++-- test/models/rule_test.rb | 24 ++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/app/models/rule/condition_filter.rb b/app/models/rule/condition_filter.rb index 6fa463a50..ad0574386 100644 --- a/app/models/rule/condition_filter.rb +++ b/app/models/rule/condition_filter.rb @@ -72,10 +72,12 @@ class Rule::ConditionFilter "#{field} #{sanitize_operator(operator)}" ) else - sanitized_value = operator == "like" ? "%#{ActiveRecord::Base.sanitize_sql_like(value)}%" : value + normalized_value = normalize_value(value) + normalized_field = normalize_field(field) + sanitized_value = operator == "like" ? "%#{ActiveRecord::Base.sanitize_sql_like(normalized_value)}%" : normalized_value ActiveRecord::Base.sanitize_sql_for_conditions([ - "#{field} #{sanitize_operator(operator)} ?", + "#{normalized_field} #{sanitize_operator(operator)} ?", sanitized_value ]) end @@ -93,4 +95,16 @@ class Rule::ConditionFilter operator end end + + def normalize_value(value) + return value unless type == "text" + + value.to_s.gsub(/\s+/, " ").strip + end + + def normalize_field(field) + return field unless type == "text" + + "BTRIM(REGEXP_REPLACE(#{field}, '[[:space:]]+', ' ', 'g'))" + end end diff --git a/test/models/rule_test.rb b/test/models/rule_test.rb index 731199fab..408cb8cca 100644 --- a/test/models/rule_test.rb +++ b/test/models/rule_test.rb @@ -94,6 +94,30 @@ class RuleTest < ActiveSupport::TestCase assert_not transaction_entry.excluded, "Transaction should not be excluded when attribute is locked" end + test "transaction name rules normalize whitespace in comparisons" do + transaction_entry = create_transaction( + date: Date.current, + account: @account, + name: "Company - Mobile", + amount: 80 + ) + + 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: "Company - Mobile") ], + actions: [ Rule::Action.new(action_type: "set_transaction_category", value: @groceries_category.id) ] + ) + + assert_equal 1, rule.affected_resource_count + + rule.apply + transaction_entry.reload + + assert_equal @groceries_category, transaction_entry.transaction.category + end + # Artificial limitation put in place to prevent users from creating overly complex rules # Rules should be shallow and wide test "no nested compound conditions" do From ca3abd5d8b38b6f198115462e5bdb0dbb24ed6a8 Mon Sep 17 00:00:00 2001 From: Dream <42954461+eureka928@users.noreply.github.com> Date: Thu, 5 Feb 2026 18:45:11 -0500 Subject: [PATCH 053/108] Add Google Sign-In (SSO) support to Flutter mobile app (#860) * Add mobile SSO support to sessions controller Add /auth/mobile/:provider route and mobile_sso_start action that captures device params in session and renders an auto-submitting POST form to OmniAuth (required by omniauth-rails_csrf_protection). Modify openid_connect callback to detect mobile_sso session, issue Doorkeeper tokens via MobileDevice, and redirect to sureapp://oauth/callback with tokens. Handles MFA users and unlinked accounts with error redirects. Validates provider name against configured SSO providers and device info before proceeding. * Add SSO auth flow to Flutter service and provider Add buildSsoUrl() and handleSsoCallback() to AuthService for constructing the mobile SSO URL and parsing tokens from the deep link callback. Add startSsoLogin() and handleSsoCallback() to AuthProvider for launching browser-based SSO and processing the redirect. * Register deep link listener for SSO callback Listen for sureapp://oauth/* deep links via app_links package, handling both cold start (getInitialLink) and warm (uriLinkStream) scenarios. Routes callbacks to AuthProvider.handleSsoCallback(). * Add Google Sign-In button to Flutter login screen Add "or" divider and outlined Google Sign-In button that triggers browser-based SSO via startSsoLogin('google_oauth2'). Add app_links and url_launcher dependencies to pubspec.yaml. * Fix mobile SSO failure handling to redirect back to app When OmniAuth fails during mobile SSO flow, redirect to sureapp://oauth/callback with the error instead of the web login page. Cleans up mobile_sso session data on failure. * Address PR review feedback for mobile SSO flow - Use strong params for device info in mobile_sso_start - Guard against nil session data in handle_mobile_sso_callback - Add error handling for AppLinks initialization and stream - Handle launchUrl false return value in SSO login - Use user-friendly error messages instead of exposing exceptions - Reject empty token strings in SSO callback validation * Consolidate mobile device token logic into MobileDevice model Extract duplicated device upsert and token issuance code from AuthController and SessionsController into MobileDevice. Add CALLBACK_URL constant and URL builder helpers to eliminate repeated deep-link strings. Add mobile SSO integration tests covering the full flow, MFA rejection, unlinked accounts, and failure handling. * Fix CI: resolve Brakeman redirect warnings and rubocop empty line Move mobile SSO redirect into a private controller method with an inline string literal so Brakeman can statically verify the target. Remove unused URL builder helpers from MobileDevice. Fix extra empty line at end of AuthController class body. * Use authorization code exchange for mobile SSO and add signup error handling Replace passing plaintext tokens in mobile SSO redirect URLs with a one-time authorization code pattern. Tokens are now stored server-side in Rails.cache (5min TTL) and exchanged via a secure POST to /api/v1/auth/sso_exchange. Also wraps device/token creation in the signup action with error handling and sanitizes device error messages. * Add error handling for login device registration and blank SSO code guard * Address PR #860 review: fix SSO race condition, add OpenAPI spec, and cleanup - Fix race condition in sso_exchange by checking Rails.cache.delete return value to ensure only one request can consume an authorization code - Use strong parameters (params.require) for sso_exchange code param - Move inline HTML from mobile_sso_start to a proper view template - Clear stale session[:mobile_sso] flag on web login paths to prevent abandoned mobile flows from hijacking subsequent web SSO logins - Add OpenAPI/rswag spec for all auth API endpoints Co-Authored-By: Claude Opus 4.5 * Fix mobile SSO test to match authorization code exchange pattern The test was asserting tokens directly in the callback URL, but the code uses an authorization code exchange pattern. Updated to exchange the code via the sso_exchange API endpoint. Also swaps in a MemoryStore for this test since the test environment uses null_store which discards writes. Co-Authored-By: Claude Opus 4.5 * Refactor mobile OAuth to use single shared application Replace per-device Doorkeeper::Application creation with a shared "Sure Mobile" OAuth app. Device tracking uses mobile_device_id on access tokens instead of oauth_application_id on mobile_devices. --------- Co-authored-by: Claude Opus 4.5 --- app/controllers/api/v1/auth_controller.rb | 91 ++-- app/controllers/sessions_controller.rb | 101 ++++- app/models/mobile_device.rb | 63 ++- app/views/sessions/mobile_sso_start.html.erb | 8 + config/routes.rb | 2 + ...0203204605_refactor_mobile_device_oauth.rb | 7 + db/schema.rb | 6 +- db/seeds/oauth_applications.rb | 8 +- mobile/lib/main.dart | 37 ++ mobile/lib/providers/auth_provider.dart | 55 +++ mobile/lib/screens/login_screen.dart | 38 ++ mobile/lib/services/auth_service.dart | 94 ++++ mobile/pubspec.yaml | 2 + spec/requests/api/v1/auth_spec.rb | 212 +++++++++ .../api/v1/auth_controller_test.rb | 20 +- test/controllers/sessions_controller_test.rb | 425 ++++++++++++++++++ 16 files changed, 1100 insertions(+), 69 deletions(-) create mode 100644 app/views/sessions/mobile_sso_start.html.erb create mode 100644 db/migrate/20260203204605_refactor_mobile_device_oauth.rb create mode 100644 spec/requests/api/v1/auth_spec.rb diff --git a/app/controllers/api/v1/auth_controller.rb b/app/controllers/api/v1/auth_controller.rb index a12e290ad..590b332ee 100644 --- a/app/controllers/api/v1/auth_controller.rb +++ b/app/controllers/api/v1/auth_controller.rb @@ -46,8 +46,13 @@ module Api InviteCode.claim!(params[:invite_code]) if params[:invite_code].present? # Create device and OAuth token - device = create_or_update_device(user) - token_response = create_oauth_token_for_device(user, device) + begin + device = MobileDevice.upsert_device!(user, device_params) + token_response = device.issue_token! + rescue ActiveRecord::RecordInvalid => e + render json: { error: "Failed to register device: #{e.message}" }, status: :unprocessable_entity + return + end render json: token_response.merge( user: { @@ -84,8 +89,13 @@ module Api end # Create device and OAuth token - device = create_or_update_device(user) - token_response = create_oauth_token_for_device(user, device) + begin + device = MobileDevice.upsert_device!(user, device_params) + token_response = device.issue_token! + rescue ActiveRecord::RecordInvalid => e + render json: { error: "Failed to register device: #{e.message}" }, status: :unprocessable_entity + return + end render json: token_response.merge( user: { @@ -100,6 +110,44 @@ module Api end end + def sso_exchange + code = sso_exchange_params + + if code.blank? + render json: { error: "invalid_or_expired_code", message: "Authorization code is required" }, status: :unauthorized + return + end + + cache_key = "mobile_sso:#{code}" + cached = Rails.cache.read(cache_key) + + unless cached.present? + render json: { error: "invalid_or_expired_code", message: "Authorization code is invalid or expired" }, status: :unauthorized + return + end + + # Atomic delete — only the request that successfully deletes the key may proceed. + # This prevents a race where two concurrent requests both read the same code. + unless Rails.cache.delete(cache_key) + render json: { error: "invalid_or_expired_code", message: "Authorization code is invalid or expired" }, status: :unauthorized + return + end + + render json: { + access_token: cached[:access_token], + refresh_token: cached[:refresh_token], + token_type: cached[:token_type], + expires_in: cached[:expires_in], + created_at: cached[:created_at], + user: { + id: cached[:user_id], + email: cached[:user_email], + first_name: cached[:user_first_name], + last_name: cached[:user_last_name] + } + } + end + def refresh # Find the refresh token refresh_token = params[:refresh_token] @@ -121,6 +169,7 @@ module Api new_token = Doorkeeper::AccessToken.create!( application: access_token.application, resource_owner_id: access_token.resource_owner_id, + mobile_device_id: access_token.mobile_device_id, expires_in: 30.days.to_i, scopes: access_token.scopes, use_refresh_token: true @@ -173,38 +222,12 @@ module Api required_fields.all? { |field| device[field].present? } end - def create_or_update_device(user) - # Handle both string and symbol keys - device_data = params[:device].permit(:device_id, :device_name, :device_type, :os_version, :app_version) - - device = user.mobile_devices.find_or_initialize_by(device_id: device_data[:device_id]) - device.update!(device_data.merge(last_seen_at: Time.current)) - device + def device_params + params.require(:device).permit(:device_id, :device_name, :device_type, :os_version, :app_version) end - def create_oauth_token_for_device(user, device) - # Create OAuth application for this device if needed - oauth_app = device.create_oauth_application! - - # Revoke any existing tokens for this device - device.revoke_all_tokens! - - # Create new access token with 30-day expiration - access_token = Doorkeeper::AccessToken.create!( - application: oauth_app, - resource_owner_id: user.id, - expires_in: 30.days.to_i, - scopes: "read_write", - use_refresh_token: true - ) - - { - access_token: access_token.plaintext_token, - refresh_token: access_token.plaintext_refresh_token, - token_type: "Bearer", - expires_in: access_token.expires_in, - created_at: access_token.created_at.to_i - } + def sso_exchange_params + params.require(:code) end end end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 2d9007668..30703f515 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,6 +1,6 @@ class SessionsController < ApplicationController before_action :set_session, only: :destroy - skip_authentication only: %i[index new create openid_connect failure post_logout] + skip_authentication only: %i[index new create openid_connect failure post_logout mobile_sso_start] layout "auth" @@ -10,6 +10,9 @@ class SessionsController < ApplicationController end def new + # Clear any stale mobile SSO session flag from an abandoned mobile flow + session.delete(:mobile_sso) + begin demo = Rails.application.config_for(:demo) @prefill_demo_credentials = demo_host_match?(demo) @@ -29,6 +32,9 @@ class SessionsController < ApplicationController end def create + # Clear any stale mobile SSO session flag from an abandoned mobile flow + session.delete(:mobile_sso) + user = nil if AuthConfig.local_login_enabled? @@ -104,6 +110,34 @@ class SessionsController < ApplicationController redirect_to new_session_path, notice: t(".logout_successful") end + def mobile_sso_start + provider = params[:provider].to_s + configured_providers = Rails.configuration.x.auth.sso_providers.map { |p| p[:name].to_s } + + unless configured_providers.include?(provider) + mobile_sso_redirect(error: "invalid_provider", message: "SSO provider not configured") + return + end + + device_params = params.permit(:device_id, :device_name, :device_type, :os_version, :app_version) + unless device_params[:device_id].present? && device_params[:device_name].present? && device_params[:device_type].present? + mobile_sso_redirect(error: "missing_device_info", message: "Device information is required") + return + end + + session[:mobile_sso] = { + device_id: device_params[:device_id], + device_name: device_params[:device_name], + device_type: device_params[:device_type], + os_version: device_params[:os_version], + app_version: device_params[:app_version] + } + + # Render auto-submitting form to POST to OmniAuth (required by omniauth-rails_csrf_protection) + @provider = provider + render layout: false + end + def openid_connect auth = request.env["omniauth.auth"] @@ -122,13 +156,24 @@ class SessionsController < ApplicationController oidc_identity.record_authentication! oidc_identity.sync_user_attributes!(auth) + # Log successful SSO login + SsoAuditLog.log_login!(user: user, provider: auth.provider, request: request) + + # Mobile SSO: issue Doorkeeper tokens and redirect to app + if session[:mobile_sso].present? + if user.otp_required? + session.delete(:mobile_sso) + mobile_sso_redirect(error: "mfa_not_supported", message: "MFA users should sign in with email and password") + else + handle_mobile_sso_callback(user) + end + return + end + # Store id_token and provider for RP-initiated logout session[:id_token_hint] = auth.credentials&.id_token if auth.credentials&.id_token session[:sso_login_provider] = auth.provider - # Log successful SSO login - SsoAuditLog.log_login!(user: user, provider: auth.provider, request: request) - # MFA check: If user has MFA enabled, require verification if user.otp_required? session[:mfa_user_id] = user.id @@ -138,6 +183,13 @@ class SessionsController < ApplicationController redirect_to root_path end else + # Mobile SSO with no linked identity - redirect back with error + if session[:mobile_sso].present? + session.delete(:mobile_sso) + mobile_sso_redirect(error: "account_not_linked", message: "Please link your Google account from the web app first") + return + end + # No existing OIDC identity - need to link to account # Store auth data in session and redirect to linking page session[:pending_oidc_auth] = { @@ -164,6 +216,13 @@ class SessionsController < ApplicationController reason: sanitized_reason ) + # Mobile SSO: redirect back to the app with error instead of web login page + if session[:mobile_sso].present? + session.delete(:mobile_sso) + mobile_sso_redirect(error: sanitized_reason, message: "SSO authentication failed") + return + end + message = case sanitized_reason when "sso_provider_unavailable" t("sessions.failure.sso_provider_unavailable") @@ -177,6 +236,40 @@ class SessionsController < ApplicationController end private + def handle_mobile_sso_callback(user) + device_info = session.delete(:mobile_sso) + + unless device_info.present? + mobile_sso_redirect(error: "missing_session", message: "Mobile SSO session expired") + return + end + + device = MobileDevice.upsert_device!(user, device_info.symbolize_keys) + token_response = device.issue_token! + + # Store tokens behind a one-time authorization code instead of passing in URL + authorization_code = SecureRandom.urlsafe_base64(32) + Rails.cache.write( + "mobile_sso:#{authorization_code}", + token_response.merge( + user_id: user.id, + user_email: user.email, + user_first_name: user.first_name, + user_last_name: user.last_name + ), + expires_in: 5.minutes + ) + + mobile_sso_redirect(code: authorization_code) + rescue ActiveRecord::RecordInvalid => e + Rails.logger.warn("[Mobile SSO] Device save failed: #{e.record.errors.full_messages.join(', ')}") + mobile_sso_redirect(error: "device_error", message: "Unable to register device") + end + + def mobile_sso_redirect(params = {}) + redirect_to "sureapp://oauth/callback?#{params.to_query}", allow_other_host: true + end + def set_session @session = Current.user.sessions.find(params[:id]) end diff --git a/app/models/mobile_device.rb b/app/models/mobile_device.rb index 07dffcbe4..18f0c93a3 100644 --- a/app/models/mobile_device.rb +++ b/app/models/mobile_device.rb @@ -7,7 +7,6 @@ class MobileDevice < ApplicationRecord end belongs_to :user - belongs_to :oauth_application, class_name: "Doorkeeper::Application", optional: true validates :device_id, presence: true, uniqueness: { scope: :user_id } validates :device_name, presence: true @@ -15,8 +14,27 @@ class MobileDevice < ApplicationRecord before_validation :set_last_seen_at, on: :create + CALLBACK_URL = "sureapp://oauth/callback" + scope :active, -> { where("last_seen_at > ?", 90.days.ago) } + def self.shared_oauth_application + @shared_oauth_application ||= Doorkeeper::Application.find_by!(name: "Sure Mobile") + end + + def self.upsert_device!(user, attrs) + device = user.mobile_devices.find_or_initialize_by(device_id: attrs[:device_id]) + device.assign_attributes( + device_name: attrs[:device_name], + device_type: attrs[:device_type], + os_version: attrs[:os_version], + app_version: attrs[:app_version], + last_seen_at: Time.current + ) + device.save! + device + end + def active? last_seen_at > 90.days.ago end @@ -25,26 +43,9 @@ class MobileDevice < ApplicationRecord update_column(:last_seen_at, Time.current) end - def create_oauth_application! - return oauth_application if oauth_application.present? - - app = Doorkeeper::Application.create!( - name: "Mobile App - #{device_id}", - redirect_uri: "sureapp://oauth/callback", # Custom scheme for mobile - scopes: "read_write", # Use the configured scope - confidential: false # Public client for mobile - ) - - # Store the association - update!(oauth_application: app) - app - end - def active_tokens - return Doorkeeper::AccessToken.none unless oauth_application - Doorkeeper::AccessToken - .where(application: oauth_application) + .where(mobile_device_id: id) .where(resource_owner_id: user_id) .where(revoked_at: nil) .where("expires_in IS NULL OR created_at + expires_in * interval '1 second' > ?", Time.current) @@ -54,6 +55,30 @@ class MobileDevice < ApplicationRecord active_tokens.update_all(revoked_at: Time.current) end + # Issues a fresh Doorkeeper access token for this device, revoking any + # previous tokens. Returns a hash with token details ready for an API + # response or deep-link callback. + def issue_token! + revoke_all_tokens! + + access_token = Doorkeeper::AccessToken.create!( + application: self.class.shared_oauth_application, + resource_owner_id: user_id, + mobile_device_id: id, + expires_in: 30.days.to_i, + scopes: "read_write", + use_refresh_token: true + ) + + { + access_token: access_token.plaintext_token, + refresh_token: access_token.plaintext_refresh_token, + token_type: "Bearer", + expires_in: access_token.expires_in, + created_at: access_token.created_at.to_i + } + end + private def set_last_seen_at diff --git a/app/views/sessions/mobile_sso_start.html.erb b/app/views/sessions/mobile_sso_start.html.erb new file mode 100644 index 000000000..74fa9ebfd --- /dev/null +++ b/app/views/sessions/mobile_sso_start.html.erb @@ -0,0 +1,8 @@ + + +

    + +
    + +
    + diff --git a/config/routes.rb b/config/routes.rb index 632b5ca20..d5363cb35 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -118,6 +118,7 @@ Rails.application.routes.draw do resource :registration, only: %i[new create] resources :sessions, only: %i[index new create destroy] + get "/auth/mobile/:provider", to: "sessions#mobile_sso_start" match "/auth/:provider/callback", to: "sessions#openid_connect", via: %i[get post] match "/auth/failure", to: "sessions#failure", via: %i[get post] get "/auth/logout/callback", to: "sessions#post_logout" @@ -355,6 +356,7 @@ Rails.application.routes.draw do post "auth/signup", to: "auth#signup" post "auth/login", to: "auth#login" post "auth/refresh", to: "auth#refresh" + post "auth/sso_exchange", to: "auth#sso_exchange" # Production API endpoints resources :accounts, only: [ :index, :show ] diff --git a/db/migrate/20260203204605_refactor_mobile_device_oauth.rb b/db/migrate/20260203204605_refactor_mobile_device_oauth.rb new file mode 100644 index 000000000..45c7c9c03 --- /dev/null +++ b/db/migrate/20260203204605_refactor_mobile_device_oauth.rb @@ -0,0 +1,7 @@ +class RefactorMobileDeviceOauth < ActiveRecord::Migration[7.2] + def change + add_column :oauth_access_tokens, :mobile_device_id, :uuid + add_index :oauth_access_tokens, :mobile_device_id + remove_column :mobile_devices, :oauth_application_id, :integer + end +end diff --git a/db/schema.rb b/db/schema.rb index 873fc72b2..6cee63d21 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_29_200129) do +ActiveRecord::Schema[7.2].define(version: 2026_02_03_204605) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -855,8 +855,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_29_200129) do t.datetime "last_seen_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.integer "oauth_application_id" - t.index ["oauth_application_id"], name: "index_mobile_devices_on_oauth_application_id" t.index ["user_id", "device_id"], name: "index_mobile_devices_on_user_id_and_device_id", unique: true t.index ["user_id"], name: "index_mobile_devices_on_user_id" end @@ -885,7 +883,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_29_200129) do t.datetime "created_at", null: false t.datetime "revoked_at" t.string "previous_refresh_token", default: "", null: false + t.uuid "mobile_device_id" t.index ["application_id"], name: "index_oauth_access_tokens_on_application_id" + t.index ["mobile_device_id"], name: "index_oauth_access_tokens_on_mobile_device_id" t.index ["refresh_token"], name: "index_oauth_access_tokens_on_refresh_token", unique: true t.index ["resource_owner_id"], name: "index_oauth_access_tokens_on_resource_owner_id" t.index ["token"], name: "index_oauth_access_tokens_on_token", unique: true diff --git a/db/seeds/oauth_applications.rb b/db/seeds/oauth_applications.rb index 1e82b70d6..40d1d187e 100644 --- a/db/seeds/oauth_applications.rb +++ b/db/seeds/oauth_applications.rb @@ -1,14 +1,14 @@ # Create OAuth applications for Sure's first-party apps # These are the only OAuth apps that will exist - external developers use API keys -# Sure iOS App -ios_app = Doorkeeper::Application.find_or_create_by(name: "Sure iOS") do |app| +# Sure Mobile App (shared across iOS and Android) +mobile_app = Doorkeeper::Application.find_or_create_by(name: "Sure Mobile") do |app| app.redirect_uri = "sureapp://oauth/callback" - app.scopes = "read_accounts read_transactions read_balances" + app.scopes = "read_write" app.confidential = false # Public client (mobile app) end puts "Created OAuth applications:" -puts "iOS App - Client ID: #{ios_app.uid}" +puts "Mobile App - Client ID: #{mobile_app.uid}" puts "" puts "External developers should use API keys instead of OAuth." diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index d9ef30c70..4c7489b3b 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -1,3 +1,5 @@ +import 'dart:async'; +import 'package:app_links/app_links.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'providers/auth_provider.dart'; @@ -146,11 +148,46 @@ class AppWrapper extends StatefulWidget { class _AppWrapperState extends State { bool _isCheckingConfig = true; bool _hasBackendUrl = false; + late final AppLinks _appLinks; + StreamSubscription? _linkSubscription; @override void initState() { super.initState(); _checkBackendConfig(); + _initDeepLinks(); + } + + @override + void dispose() { + _linkSubscription?.cancel(); + super.dispose(); + } + + void _initDeepLinks() { + _appLinks = AppLinks(); + + // Handle deep link that launched the app (cold start) + _appLinks.getInitialLink().then((uri) { + if (uri != null) _handleDeepLink(uri); + }).catchError((e, stackTrace) { + LogService.instance.error('DeepLinks', 'Initial link error: $e\n$stackTrace'); + }); + + // Listen for deep links while app is running + _linkSubscription = _appLinks.uriLinkStream.listen( + (uri) => _handleDeepLink(uri), + onError: (e, stackTrace) { + LogService.instance.error('DeepLinks', 'Link stream error: $e\n$stackTrace'); + }, + ); + } + + void _handleDeepLink(Uri uri) { + if (uri.scheme == 'sureapp' && uri.host == 'oauth') { + final authProvider = Provider.of(context, listen: false); + authProvider.handleSsoCallback(uri); + } } Future _checkBackendConfig() async { diff --git a/mobile/lib/providers/auth_provider.dart b/mobile/lib/providers/auth_provider.dart index bfc24670f..0fa47fd63 100644 --- a/mobile/lib/providers/auth_provider.dart +++ b/mobile/lib/providers/auth_provider.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../models/user.dart'; import '../models/auth_tokens.dart'; import '../services/auth_service.dart'; @@ -215,6 +216,60 @@ class AuthProvider with ChangeNotifier { } } + Future startSsoLogin(String provider) async { + _errorMessage = null; + _isLoading = true; + notifyListeners(); + + try { + final deviceInfo = await _deviceService.getDeviceInfo(); + final ssoUrl = _authService.buildSsoUrl( + provider: provider, + deviceInfo: deviceInfo, + ); + + final launched = await launchUrl(Uri.parse(ssoUrl), mode: LaunchMode.externalApplication); + if (!launched) { + _errorMessage = 'Unable to open browser for sign-in.'; + } + } catch (e, stackTrace) { + LogService.instance.error('AuthProvider', 'SSO launch error: $e\n$stackTrace'); + _errorMessage = 'Unable to start sign-in. Please try again.'; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + Future handleSsoCallback(Uri uri) async { + _errorMessage = null; + _isLoading = true; + notifyListeners(); + + try { + final result = await _authService.handleSsoCallback(uri); + + 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, stackTrace) { + LogService.instance.error('AuthProvider', 'SSO callback error: $e\n$stackTrace'); + _errorMessage = 'Sign-in failed. Please try again.'; + _isLoading = false; + notifyListeners(); + return false; + } + } + Future logout() async { await _authService.logout(); _tokens = null; diff --git a/mobile/lib/screens/login_screen.dart b/mobile/lib/screens/login_screen.dart index e4c8fc6de..f3089293a 100644 --- a/mobile/lib/screens/login_screen.dart +++ b/mobile/lib/screens/login_screen.dart @@ -371,6 +371,44 @@ class _LoginScreenState extends State { const SizedBox(height: 16), + // Divider with "or" + Row( + children: [ + Expanded(child: Divider(color: colorScheme.outlineVariant)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'or', + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + ), + Expanded(child: Divider(color: colorScheme.outlineVariant)), + ], + ), + + const SizedBox(height: 16), + + // Google Sign-In button + Consumer( + builder: (context, authProvider, _) { + return OutlinedButton.icon( + onPressed: authProvider.isLoading + ? null + : () => authProvider.startSsoLogin('google_oauth2'), + icon: const Icon(Icons.g_mobiledata, size: 24), + label: const Text('Sign in with Google'), + style: OutlinedButton.styleFrom( + minimumSize: const Size(double.infinity, 50), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ); + }, + ), + + const SizedBox(height: 24), + // Backend URL info Container( padding: const EdgeInsets.all(12), diff --git a/mobile/lib/services/auth_service.dart b/mobile/lib/services/auth_service.dart index 5c28ff28d..c6b6c8859 100644 --- a/mobile/lib/services/auth_service.dart +++ b/mobile/lib/services/auth_service.dart @@ -341,6 +341,100 @@ class AuthService { } } + String buildSsoUrl({ + required String provider, + required Map deviceInfo, + }) { + final params = { + 'device_id': deviceInfo['device_id']!, + 'device_name': deviceInfo['device_name']!, + 'device_type': deviceInfo['device_type']!, + 'os_version': deviceInfo['os_version']!, + 'app_version': deviceInfo['app_version']!, + }; + final uri = Uri.parse('${ApiConfig.baseUrl}/auth/mobile/$provider') + .replace(queryParameters: params); + return uri.toString(); + } + + Future> handleSsoCallback(Uri uri) async { + final params = uri.queryParameters; + + if (params.containsKey('error')) { + return { + 'success': false, + 'error': params['message'] ?? params['error'] ?? 'SSO login failed', + }; + } + + final code = params['code']; + if (code == null || code.isEmpty) { + return { + 'success': false, + 'error': 'Invalid SSO callback response', + }; + } + + // Exchange authorization code for tokens via secure POST + try { + final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/auth/sso_exchange'); + final response = await http.post( + url, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: jsonEncode({'code': code}), + ).timeout(const Duration(seconds: 30)); + + if (response.statusCode != 200) { + final errorData = jsonDecode(response.body); + return { + 'success': false, + 'error': errorData['message'] ?? 'Token exchange failed', + }; + } + + final data = jsonDecode(response.body); + + final tokens = AuthTokens.fromJson({ + 'access_token': data['access_token'], + 'refresh_token': data['refresh_token'], + 'token_type': data['token_type'] ?? 'Bearer', + 'expires_in': data['expires_in'] ?? 0, + 'created_at': data['created_at'] ?? 0, + }); + await _saveTokens(tokens); + + final user = User.fromJson(data['user']); + await _saveUser(user); + + return { + 'success': true, + 'tokens': tokens, + 'user': user, + }; + } on SocketException catch (e, stackTrace) { + LogService.instance.error('AuthService', 'SSO exchange SocketException: $e\n$stackTrace'); + return { + 'success': false, + 'error': 'Network unavailable', + }; + } on TimeoutException catch (e, stackTrace) { + LogService.instance.error('AuthService', 'SSO exchange TimeoutException: $e\n$stackTrace'); + return { + 'success': false, + 'error': 'Request timed out', + }; + } catch (e, stackTrace) { + LogService.instance.error('AuthService', 'SSO exchange unexpected error: $e\n$stackTrace'); + return { + 'success': false, + 'error': 'Failed to exchange authorization code', + }; + } + } + Future logout() async { await _storage.delete(key: _tokenKey); await _storage.delete(key: _userKey); diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 2c8876e50..1720033ce 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -20,6 +20,8 @@ dependencies: path: ^1.9.1 connectivity_plus: ^7.0.0 uuid: ^4.5.2 + app_links: ^6.4.0 + url_launcher: ^6.2.5 dev_dependencies: flutter_test: diff --git a/spec/requests/api/v1/auth_spec.rb b/spec/requests/api/v1/auth_spec.rb new file mode 100644 index 000000000..38f797bd1 --- /dev/null +++ b/spec/requests/api/v1/auth_spec.rb @@ -0,0 +1,212 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'API V1 Auth', type: :request do + path '/api/v1/auth/signup' do + post 'Sign up a new user' do + tags 'Auth' + consumes 'application/json' + produces 'application/json' + parameter name: :body, in: :body, required: true, schema: { + type: :object, + properties: { + user: { + type: :object, + properties: { + email: { type: :string, format: :email, description: 'User email address' }, + password: { type: :string, description: 'Password (min 8 chars, mixed case, number, special char)' }, + first_name: { type: :string }, + last_name: { type: :string } + }, + required: %w[email password] + }, + device: { + type: :object, + properties: { + device_id: { type: :string, description: 'Unique device identifier' }, + device_name: { type: :string, description: 'Human-readable device name' }, + device_type: { type: :string, description: 'Device type (e.g. ios, android)' }, + os_version: { type: :string }, + app_version: { type: :string } + }, + required: %w[device_id device_name device_type os_version app_version] + }, + invite_code: { type: :string, nullable: true, description: 'Invite code (required when invites are enforced)' } + }, + required: %w[user device] + } + + response '201', 'user created' do + schema type: :object, + properties: { + access_token: { type: :string }, + refresh_token: { type: :string }, + token_type: { type: :string }, + expires_in: { type: :integer }, + created_at: { type: :integer }, + user: { + type: :object, + properties: { + id: { type: :string, format: :uuid }, + email: { type: :string }, + first_name: { type: :string }, + last_name: { type: :string } + } + } + } + run_test! + end + + response '422', 'validation error' do + schema '$ref' => '#/components/schemas/ErrorResponse' + run_test! + end + + response '403', 'invite code required or invalid' do + schema '$ref' => '#/components/schemas/ErrorResponse' + run_test! + end + end + end + + path '/api/v1/auth/login' do + post 'Log in with email and password' do + tags 'Auth' + consumes 'application/json' + produces 'application/json' + parameter name: :body, in: :body, required: true, schema: { + type: :object, + properties: { + email: { type: :string, format: :email }, + password: { type: :string }, + otp_code: { type: :string, nullable: true, description: 'TOTP code if MFA is enabled' }, + device: { + type: :object, + properties: { + device_id: { type: :string }, + device_name: { type: :string }, + device_type: { type: :string }, + os_version: { type: :string }, + app_version: { type: :string } + }, + required: %w[device_id device_name device_type os_version app_version] + } + }, + required: %w[email password device] + } + + response '200', 'login successful' do + schema type: :object, + properties: { + access_token: { type: :string }, + refresh_token: { type: :string }, + token_type: { type: :string }, + expires_in: { type: :integer }, + created_at: { type: :integer }, + user: { + type: :object, + properties: { + id: { type: :string, format: :uuid }, + email: { type: :string }, + first_name: { type: :string }, + last_name: { type: :string } + } + } + } + run_test! + end + + response '401', 'invalid credentials or MFA required' do + schema '$ref' => '#/components/schemas/ErrorResponse' + run_test! + end + end + end + + path '/api/v1/auth/sso_exchange' do + post 'Exchange mobile SSO authorization code for tokens' do + tags 'Auth' + consumes 'application/json' + produces 'application/json' + description 'Exchanges a one-time authorization code (received via deep link after mobile SSO) for OAuth tokens. The code is single-use and expires after 5 minutes.' + parameter name: :body, in: :body, required: true, schema: { + type: :object, + properties: { + code: { type: :string, description: 'One-time authorization code from mobile SSO callback' } + }, + required: %w[code] + } + + response '200', 'tokens issued' do + schema type: :object, + properties: { + access_token: { type: :string }, + refresh_token: { type: :string }, + token_type: { type: :string }, + expires_in: { type: :integer }, + created_at: { type: :integer }, + user: { + type: :object, + properties: { + id: { type: :string, format: :uuid }, + email: { type: :string }, + first_name: { type: :string }, + last_name: { type: :string } + } + } + } + run_test! + end + + response '401', 'invalid or expired code' do + schema '$ref' => '#/components/schemas/ErrorResponse' + run_test! + end + end + end + + path '/api/v1/auth/refresh' do + post 'Refresh an access token' do + tags 'Auth' + consumes 'application/json' + produces 'application/json' + parameter name: :body, in: :body, required: true, schema: { + type: :object, + properties: { + refresh_token: { type: :string, description: 'The refresh token from a previous login or refresh' }, + device: { + type: :object, + properties: { + device_id: { type: :string } + }, + required: %w[device_id] + } + }, + required: %w[refresh_token device] + } + + response '200', 'token refreshed' do + schema type: :object, + properties: { + access_token: { type: :string }, + refresh_token: { type: :string }, + token_type: { type: :string }, + expires_in: { type: :integer }, + created_at: { type: :integer } + } + run_test! + end + + response '401', 'invalid refresh token' do + schema '$ref' => '#/components/schemas/ErrorResponse' + run_test! + end + + response '400', 'missing refresh token' do + schema '$ref' => '#/components/schemas/ErrorResponse' + run_test! + end + end + end +end diff --git a/test/controllers/api/v1/auth_controller_test.rb b/test/controllers/api/v1/auth_controller_test.rb index 959de1086..272534f53 100644 --- a/test/controllers/api/v1/auth_controller_test.rb +++ b/test/controllers/api/v1/auth_controller_test.rb @@ -11,12 +11,22 @@ class Api::V1::AuthControllerTest < ActionDispatch::IntegrationTest os_version: "17.0", app_version: "1.0.0" } + + # Ensure the shared OAuth application exists + @shared_app = Doorkeeper::Application.find_or_create_by!(name: "Sure Mobile") do |app| + app.redirect_uri = "sureapp://oauth/callback" + app.scopes = "read_write" + app.confidential = false + end + + # Clear the memoized class variable so it picks up the test record + MobileDevice.instance_variable_set(:@shared_oauth_application, nil) end test "should signup new user and return OAuth tokens" do assert_difference("User.count", 1) do assert_difference("MobileDevice.count", 1) do - assert_difference("Doorkeeper::Application.count", 1) do + assert_no_difference("Doorkeeper::Application.count") do assert_difference("Doorkeeper::AccessToken.count", 1) do post "/api/v1/auth/signup", params: { user: { @@ -279,10 +289,10 @@ class Api::V1::AuthControllerTest < ActionDispatch::IntegrationTest # Create an existing device and token device = user.mobile_devices.create!(@device_info) - oauth_app = device.create_oauth_application! existing_token = Doorkeeper::AccessToken.create!( - application: oauth_app, + application: @shared_app, resource_owner_id: user.id, + mobile_device_id: device.id, expires_in: 30.days.to_i, scopes: "read_write" ) @@ -350,12 +360,12 @@ class Api::V1::AuthControllerTest < ActionDispatch::IntegrationTest test "should refresh access token with valid refresh token" do user = users(:family_admin) device = user.mobile_devices.create!(@device_info) - oauth_app = device.create_oauth_application! # Create initial token initial_token = Doorkeeper::AccessToken.create!( - application: oauth_app, + application: @shared_app, resource_owner_id: user.id, + mobile_device_id: device.id, expires_in: 30.days.to_i, scopes: "read_write", use_refresh_token: true diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index 3f2da7351..8bdad7bf0 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -3,6 +3,16 @@ require "test_helper" class SessionsControllerTest < ActionDispatch::IntegrationTest setup do @user = users(:family_admin) + + # Ensure the shared OAuth application exists + Doorkeeper::Application.find_or_create_by!(name: "Sure Mobile") do |app| + app.redirect_uri = "sureapp://oauth/callback" + app.scopes = "read_write" + app.confidential = false + end + + # Clear the memoized class variable so it picks up the test record + MobileDevice.instance_variable_set(:@shared_oauth_application, nil) end teardown do @@ -210,6 +220,421 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest assert_equal "Could not authenticate via OpenID Connect.", flash[:alert] end + # ── Mobile SSO: mobile_sso_start ── + + test "mobile_sso_start renders auto-submit form for valid provider" do + Rails.configuration.x.auth.stubs(:sso_providers).returns([ + { name: "google_oauth2", strategy: "google_oauth2", label: "Google" } + ]) + + get "/auth/mobile/google_oauth2", params: { + device_id: "test-device-123", + device_name: "Pixel 8", + device_type: "android", + os_version: "14", + app_version: "1.0.0" + } + + assert_response :success + assert_match %r{action="/auth/google_oauth2"}, @response.body + assert_match %r{method="post"}, @response.body + assert_match /authenticity_token/, @response.body + end + + test "mobile_sso_start stores device info in session" do + Rails.configuration.x.auth.stubs(:sso_providers).returns([ + { name: "google_oauth2", strategy: "google_oauth2", label: "Google" } + ]) + + get "/auth/mobile/google_oauth2", params: { + device_id: "test-device-123", + device_name: "Pixel 8", + device_type: "android", + os_version: "14", + app_version: "1.0.0" + } + + assert_equal "test-device-123", session[:mobile_sso][:device_id] + assert_equal "Pixel 8", session[:mobile_sso][:device_name] + assert_equal "android", session[:mobile_sso][:device_type] + assert_equal "14", session[:mobile_sso][:os_version] + assert_equal "1.0.0", session[:mobile_sso][:app_version] + end + + test "mobile_sso_start redirects with error for invalid provider" do + Rails.configuration.x.auth.stubs(:sso_providers).returns([ + { name: "google_oauth2", strategy: "google_oauth2", label: "Google" } + ]) + + get "/auth/mobile/unknown_provider", params: { + device_id: "test-device-123", + device_name: "Pixel 8", + device_type: "android" + } + + assert_redirected_to %r{\Asureapp://oauth/callback\?error=invalid_provider} + end + + test "mobile_sso_start redirects with error when device_id is missing" do + Rails.configuration.x.auth.stubs(:sso_providers).returns([ + { name: "google_oauth2", strategy: "google_oauth2", label: "Google" } + ]) + + get "/auth/mobile/google_oauth2", params: { + device_name: "Pixel 8", + device_type: "android" + } + + assert_redirected_to %r{\Asureapp://oauth/callback\?error=missing_device_info} + end + + test "mobile_sso_start redirects with error when device_name is missing" do + Rails.configuration.x.auth.stubs(:sso_providers).returns([ + { name: "google_oauth2", strategy: "google_oauth2", label: "Google" } + ]) + + get "/auth/mobile/google_oauth2", params: { + device_id: "test-device-123", + device_type: "android" + } + + assert_redirected_to %r{\Asureapp://oauth/callback\?error=missing_device_info} + end + + test "mobile_sso_start redirects with error when device_type is missing" do + Rails.configuration.x.auth.stubs(:sso_providers).returns([ + { name: "google_oauth2", strategy: "google_oauth2", label: "Google" } + ]) + + get "/auth/mobile/google_oauth2", params: { + device_id: "test-device-123", + device_name: "Pixel 8" + } + + assert_redirected_to %r{\Asureapp://oauth/callback\?error=missing_device_info} + end + + # ── Mobile SSO: openid_connect callback with mobile_sso session ── + + test "mobile SSO issues Doorkeeper tokens for linked user" do + # Test environment uses null_store; swap in a memory store so the + # authorization code round-trip (write in controller, read in sso_exchange) works. + original_cache = Rails.cache + Rails.cache = ActiveSupport::Cache::MemoryStore.new + + oidc_identity = oidc_identities(:bob_google) + + setup_omniauth_mock( + provider: oidc_identity.provider, + uid: oidc_identity.uid, + email: @user.email, + name: "Bob Dylan", + first_name: "Bob", + last_name: "Dylan" + ) + + # Simulate mobile_sso session data (would be set by mobile_sso_start) + post sessions_path, params: { email: @user.email, password: user_password_test } + delete session_url(@user.sessions.last) + + # We need to set the session directly via a custom approach: + # Hit mobile_sso_start first, then trigger the OIDC callback + Rails.configuration.x.auth.stubs(:sso_providers).returns([ + { name: "openid_connect", strategy: "openid_connect", label: "Google" } + ]) + + get "/auth/mobile/openid_connect", params: { + device_id: "flutter-device-001", + device_name: "Pixel 8", + device_type: "android", + os_version: "14", + app_version: "1.0.0" + } + + assert_response :success + + # Now trigger the OIDC callback — session[:mobile_sso] is set from the previous request + get "/auth/openid_connect/callback" + + assert_response :redirect + redirect_url = @response.redirect_url + + assert redirect_url.start_with?("sureapp://oauth/callback?"), "Expected redirect to sureapp:// but got #{redirect_url}" + + uri = URI.parse(redirect_url) + callback_params = Rack::Utils.parse_query(uri.query) + + assert callback_params["code"].present?, "Expected authorization code in callback" + + # Exchange the authorization code for tokens via the API (as the mobile app would) + post "/api/v1/auth/sso_exchange", params: { code: callback_params["code"] }, as: :json + + assert_response :success + token_data = JSON.parse(@response.body) + + assert token_data["access_token"].present?, "Expected access_token in response" + assert token_data["refresh_token"].present?, "Expected refresh_token in response" + assert_equal "Bearer", token_data["token_type"] + assert_equal 30.days.to_i, token_data["expires_in"] + assert_equal @user.id, token_data["user"]["id"] + assert_equal @user.email, token_data["user"]["email"] + assert_equal @user.first_name, token_data["user"]["first_name"] + assert_equal @user.last_name, token_data["user"]["last_name"] + ensure + Rails.cache = original_cache + end + + test "mobile SSO creates a MobileDevice record" do + oidc_identity = oidc_identities(:bob_google) + + setup_omniauth_mock( + provider: oidc_identity.provider, + uid: oidc_identity.uid, + email: @user.email, + name: "Bob Dylan" + ) + + Rails.configuration.x.auth.stubs(:sso_providers).returns([ + { name: "openid_connect", strategy: "openid_connect", label: "Google" } + ]) + + get "/auth/mobile/openid_connect", params: { + device_id: "flutter-device-002", + device_name: "iPhone 15", + device_type: "ios", + os_version: "17.2", + app_version: "1.0.0" + } + + assert_difference "MobileDevice.count", 1 do + get "/auth/openid_connect/callback" + end + + device = @user.mobile_devices.find_by(device_id: "flutter-device-002") + assert device.present?, "Expected MobileDevice to be created" + assert_equal "iPhone 15", device.device_name + assert_equal "ios", device.device_type + assert_equal "17.2", device.os_version + assert_equal "1.0.0", device.app_version + end + + test "mobile SSO uses the shared OAuth application" do + oidc_identity = oidc_identities(:bob_google) + + setup_omniauth_mock( + provider: oidc_identity.provider, + uid: oidc_identity.uid, + email: @user.email, + name: "Bob Dylan" + ) + + Rails.configuration.x.auth.stubs(:sso_providers).returns([ + { name: "openid_connect", strategy: "openid_connect", label: "Google" } + ]) + + get "/auth/mobile/openid_connect", params: { + device_id: "flutter-device-003", + device_name: "Pixel 8", + device_type: "android" + } + + assert_no_difference "Doorkeeper::Application.count" do + get "/auth/openid_connect/callback" + end + + device = @user.mobile_devices.find_by(device_id: "flutter-device-003") + assert device.active_tokens.any?, "Expected device to have active tokens via shared app" + end + + test "mobile SSO revokes previous tokens for existing device" do + oidc_identity = oidc_identities(:bob_google) + + setup_omniauth_mock( + provider: oidc_identity.provider, + uid: oidc_identity.uid, + email: @user.email, + name: "Bob Dylan" + ) + + Rails.configuration.x.auth.stubs(:sso_providers).returns([ + { name: "openid_connect", strategy: "openid_connect", label: "Google" } + ]) + + # First login — creates device and token + get "/auth/mobile/openid_connect", params: { + device_id: "flutter-device-004", + device_name: "Pixel 8", + device_type: "android" + } + get "/auth/openid_connect/callback" + + device = @user.mobile_devices.find_by(device_id: "flutter-device-004") + first_token = Doorkeeper::AccessToken.where( + mobile_device_id: device.id, + resource_owner_id: @user.id, + revoked_at: nil + ).last + + assert first_token.present?, "Expected first access token" + + # Second login with same device — should revoke old token + setup_omniauth_mock( + provider: oidc_identity.provider, + uid: oidc_identity.uid, + email: @user.email, + name: "Bob Dylan" + ) + + get "/auth/mobile/openid_connect", params: { + device_id: "flutter-device-004", + device_name: "Pixel 8", + device_type: "android" + } + get "/auth/openid_connect/callback" + + first_token.reload + assert first_token.revoked_at.present?, "Expected first token to be revoked" + end + + test "mobile SSO redirects MFA user with error" do + @user.setup_mfa! + @user.enable_mfa! + + oidc_identity = oidc_identities(:bob_google) + + setup_omniauth_mock( + provider: oidc_identity.provider, + uid: oidc_identity.uid, + email: @user.email, + name: "Bob Dylan" + ) + + Rails.configuration.x.auth.stubs(:sso_providers).returns([ + { name: "openid_connect", strategy: "openid_connect", label: "Google" } + ]) + + get "/auth/mobile/openid_connect", params: { + device_id: "flutter-device-005", + device_name: "Pixel 8", + device_type: "android" + } + get "/auth/openid_connect/callback" + + assert_response :redirect + redirect_url = @response.redirect_url + + assert redirect_url.start_with?("sureapp://oauth/callback?"), "Expected redirect to sureapp://" + params = Rack::Utils.parse_query(URI.parse(redirect_url).query) + assert_equal "mfa_not_supported", params["error"] + assert_nil session[:mobile_sso], "Expected mobile_sso session to be cleared" + end + + test "mobile SSO redirects with error when OIDC identity not linked" do + user_without_oidc = users(:new_email) + + setup_omniauth_mock( + provider: "openid_connect", + uid: "unlinked-uid-99999", + email: user_without_oidc.email, + name: "New User" + ) + + Rails.configuration.x.auth.stubs(:sso_providers).returns([ + { name: "openid_connect", strategy: "openid_connect", label: "Google" } + ]) + + get "/auth/mobile/openid_connect", params: { + device_id: "flutter-device-006", + device_name: "Pixel 8", + device_type: "android" + } + get "/auth/openid_connect/callback" + + assert_response :redirect + redirect_url = @response.redirect_url + + assert redirect_url.start_with?("sureapp://oauth/callback?"), "Expected redirect to sureapp://" + params = Rack::Utils.parse_query(URI.parse(redirect_url).query) + assert_equal "account_not_linked", params["error"] + assert_nil session[:mobile_sso], "Expected mobile_sso session to be cleared" + end + + test "mobile SSO does not create a web session" do + oidc_identity = oidc_identities(:bob_google) + + setup_omniauth_mock( + provider: oidc_identity.provider, + uid: oidc_identity.uid, + email: @user.email, + name: "Bob Dylan" + ) + + Rails.configuration.x.auth.stubs(:sso_providers).returns([ + { name: "openid_connect", strategy: "openid_connect", label: "Google" } + ]) + + @user.sessions.destroy_all + + get "/auth/mobile/openid_connect", params: { + device_id: "flutter-device-007", + device_name: "Pixel 8", + device_type: "android" + } + + assert_no_difference "Session.count" do + get "/auth/openid_connect/callback" + end + end + + # ── Mobile SSO: failure action ── + + test "failure redirects mobile SSO to app with error" do + # Simulate mobile_sso session being set + Rails.configuration.x.auth.stubs(:sso_providers).returns([ + { name: "google_oauth2", strategy: "google_oauth2", label: "Google" } + ]) + + get "/auth/mobile/google_oauth2", params: { + device_id: "flutter-device-008", + device_name: "Pixel 8", + device_type: "android" + } + + # Now simulate a failure callback + get "/auth/failure", params: { message: "sso_failed", strategy: "google_oauth2" } + + assert_response :redirect + redirect_url = @response.redirect_url + assert redirect_url.start_with?("sureapp://oauth/callback?"), "Expected redirect to sureapp://" + params = Rack::Utils.parse_query(URI.parse(redirect_url).query) + assert_equal "sso_failed", params["error"] + assert_nil session[:mobile_sso], "Expected mobile_sso session to be cleared" + end + + test "failure without mobile SSO session redirects to web login" do + get "/auth/failure", params: { message: "sso_failed", strategy: "google_oauth2" } + + assert_redirected_to new_session_path + end + + test "failure sanitizes unknown error reasons" do + Rails.configuration.x.auth.stubs(:sso_providers).returns([ + { name: "google_oauth2", strategy: "google_oauth2", label: "Google" } + ]) + + get "/auth/mobile/google_oauth2", params: { + device_id: "flutter-device-009", + device_name: "Pixel 8", + device_type: "android" + } + + get "/auth/failure", params: { message: "xss_attempt