diff --git a/app/controllers/family_exports_controller.rb b/app/controllers/family_exports_controller.rb index 992d68b3d..b8bd27774 100644 --- a/app/controllers/family_exports_controller.rb +++ b/app/controllers/family_exports_controller.rb @@ -2,7 +2,7 @@ class FamilyExportsController < ApplicationController include StreamExtensions before_action :require_admin - before_action :set_export, only: [ :download ] + before_action :set_export, only: [ :download, :destroy ] def new # Modal view for initiating export @@ -13,9 +13,9 @@ class FamilyExportsController < ApplicationController FamilyDataExportJob.perform_later(@export) respond_to do |format| - format.html { redirect_to settings_profile_path, notice: "Export started. You'll be able to download it shortly." } + format.html { redirect_to imports_path, notice: "Export started. You'll be able to download it shortly." } format.turbo_stream { - stream_redirect_to settings_profile_path, notice: "Export started. You'll be able to download it shortly." + stream_redirect_to imports_path, notice: "Export started. You'll be able to download it shortly." } end end @@ -29,10 +29,15 @@ class FamilyExportsController < ApplicationController if @export.downloadable? redirect_to @export.export_file, allow_other_host: true else - redirect_to settings_profile_path, alert: "Export not ready for download" + redirect_to imports_path, alert: "Export not ready for download" end end + def destroy + @export.destroy + redirect_to imports_path, notice: "Export deleted successfully" + end + private def set_export diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb index 20e5f9c4a..7b6040743 100644 --- a/app/controllers/imports_controller.rb +++ b/app/controllers/imports_controller.rb @@ -1,4 +1,6 @@ class ImportsController < ApplicationController + include SettingsHelper + before_action :set_import, only: %i[show publish destroy revert apply_template] def publish @@ -11,7 +13,11 @@ class ImportsController < ApplicationController def index @imports = Current.family.imports - + @exports = Current.user.admin? ? Current.family.family_exports.ordered.limit(10) : nil + @breadcrumbs = [ + [ "Home", root_path ], + [ "Import/Export", imports_path ] + ] render layout: "settings" end diff --git a/app/controllers/settings/ai_prompts_controller.rb b/app/controllers/settings/ai_prompts_controller.rb new file mode 100644 index 000000000..ecd42b6f0 --- /dev/null +++ b/app/controllers/settings/ai_prompts_controller.rb @@ -0,0 +1,12 @@ +class Settings::AiPromptsController < ApplicationController + layout "settings" + + def show + @breadcrumbs = [ + [ "Home", root_path ], + [ "AI Prompts", nil ] + ] + @family = Current.family + @assistant_config = Assistant.config_for(OpenStruct.new(user: Current.user)) + end +end diff --git a/app/controllers/settings/api_keys_controller.rb b/app/controllers/settings/api_keys_controller.rb index c7258a454..cb6d889e3 100644 --- a/app/controllers/settings/api_keys_controller.rb +++ b/app/controllers/settings/api_keys_controller.rb @@ -8,7 +8,7 @@ class Settings::ApiKeysController < ApplicationController def show @breadcrumbs = [ [ "Home", root_path ], - [ "API Keys", nil ] + [ "API Key", nil ] ] @current_api_key = @api_key end diff --git a/app/controllers/settings/guides_controller.rb b/app/controllers/settings/guides_controller.rb new file mode 100644 index 000000000..a21840a91 --- /dev/null +++ b/app/controllers/settings/guides_controller.rb @@ -0,0 +1,18 @@ +class Settings::GuidesController < ApplicationController + layout "settings" + + def show + @breadcrumbs = [ + [ "Home", root_path ], + [ "Guides", nil ] + ] + markdown = Redcarpet::Markdown.new(Redcarpet::Render::HTML, + autolink: true, + tables: true, + fenced_code_blocks: true, + strikethrough: true, + superscript: true + ) + @guide_content = markdown.render(File.read(Rails.root.join("docs/onboarding/guide.md"))) + end +end diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb index a5631da4c..2839b60e0 100644 --- a/app/controllers/settings/profiles_controller.rb +++ b/app/controllers/settings/profiles_controller.rb @@ -5,6 +5,10 @@ class Settings::ProfilesController < ApplicationController @user = Current.user @users = Current.family.users.order(:created_at) @pending_invitations = Current.family.invitations.pending + @breadcrumbs = [ + [ "Home", root_path ], + [ "Profile Info", nil ] + ] end def destroy diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index af237bfc9..5b6f51186 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -1,17 +1,25 @@ module SettingsHelper SETTINGS_ORDER = [ - { name: "Account", path: :settings_profile_path }, - { name: "Preferences", path: :settings_preferences_path }, - { name: "Security", path: :settings_security_path }, - { name: "Self-Hosting", path: :settings_hosting_path, condition: :self_hosted? }, - { name: "API Key", path: :settings_api_key_path }, - { name: "Billing", path: :settings_billing_path, condition: :not_self_hosted? }, + # General section { name: "Accounts", path: :accounts_path }, - { name: "Imports", path: :imports_path }, - { name: "Tags", path: :tags_path }, + { name: "Bank Sync", path: :settings_bank_sync_path }, + { name: "Preferences", path: :settings_preferences_path }, + { name: "Profile Info", path: :settings_profile_path }, + { name: "Security", path: :settings_security_path }, + { name: "Billing", path: :settings_billing_path, condition: :not_self_hosted? }, + # Transactions section { name: "Categories", path: :categories_path }, + { name: "Tags", path: :tags_path }, { name: "Rules", path: :rules_path }, { name: "Merchants", path: :family_merchants_path }, + # Advanced section + { name: "AI Prompts", path: :settings_ai_prompts_path }, + { name: "API Key", path: :settings_api_key_path }, + { name: "Self-Hosting", path: :settings_hosting_path, condition: :self_hosted? }, + { name: "Imports", path: :imports_path }, + { name: "SimpleFin", path: :simplefin_items_path }, + # More section + { name: "Guides", path: :settings_guides_path }, { name: "What's new", path: :changelog_path }, { name: "Feedback", path: :feedback_path } ] diff --git a/app/models/family_export.rb b/app/models/family_export.rb index 292ab9e0a..2ea2a5d30 100644 --- a/app/models/family_export.rb +++ b/app/models/family_export.rb @@ -1,7 +1,7 @@ class FamilyExport < ApplicationRecord belongs_to :family - has_one_attached :export_file + has_one_attached :export_file, dependent: :purge_later enum :status, { pending: "pending", diff --git a/app/models/provider/openai.rb b/app/models/provider/openai.rb index b1c3b3232..fbcd2b9e1 100644 --- a/app/models/provider/openai.rb +++ b/app/models/provider/openai.rb @@ -14,7 +14,7 @@ class Provider::Openai < Provider MODELS.include?(model) end - def auto_categorize(transactions: [], user_categories: [], model: "gpt-4.1-mini") + def auto_categorize(transactions: [], user_categories: [], model: "") with_provider_response do raise Error, "Too many transactions to auto-categorize. Max is 25 per request." if transactions.size > 25 @@ -36,7 +36,7 @@ class Provider::Openai < Provider end end - def auto_detect_merchants(transactions: [], user_merchants: [], model: "gpt-4.1-mini") + def auto_detect_merchants(transactions: [], user_merchants: [], model: "") with_provider_response do raise Error, "Too many transactions to auto-detect merchants. Max is 25 per request." if transactions.size > 25 diff --git a/app/models/provider/openai/auto_categorizer.rb b/app/models/provider/openai/auto_categorizer.rb index 7b26b334c..63e6e1e21 100644 --- a/app/models/provider/openai/auto_categorizer.rb +++ b/app/models/provider/openai/auto_categorizer.rb @@ -1,4 +1,6 @@ class Provider::Openai::AutoCategorizer + DEFAULT_MODEL = "gpt-4.1-mini" + def initialize(client, model: "", transactions: [], user_categories: []) @client = client @model = model @@ -8,7 +10,7 @@ class Provider::Openai::AutoCategorizer def auto_categorize response = client.responses.create(parameters: { - model: model, + model: model.presence || DEFAULT_MODEL, input: [ { role: "developer", content: developer_message } ], text: { format: { @@ -26,6 +28,27 @@ class Provider::Openai::AutoCategorizer build_response(extract_categorizations(response)) end + def instructions + <<~INSTRUCTIONS.strip_heredoc + You are an assistant to a consumer personal finance app. You will be provided a list + of the user's transactions and a list of the user's categories. Your job is to auto-categorize + each transaction. + + Closely follow ALL the rules below while auto-categorizing: + + - Return 1 result per transaction + - Correlate each transaction by ID (transaction_id) + - Attempt to match the most specific category possible (i.e. subcategory over parent category) + - Category and transaction classifications should match (i.e. if transaction is an "expense", the category must have classification of "expense") + - If you don't know the category, return "null" + - You should always favor "null" over false positives + - Be slightly pessimistic. Only match a category if you're 60%+ confident it is the correct one. + - Each transaction has varying metadata that can be used to determine the category + - Note: "hint" comes from 3rd party aggregators and typically represents a category name that + may or may not match any of the user-supplied categories + INSTRUCTIONS + end + private attr_reader :client, :model, :transactions, :user_categories @@ -97,25 +120,4 @@ class Provider::Openai::AutoCategorizer ``` MESSAGE end - - def instructions - <<~INSTRUCTIONS.strip_heredoc - You are an assistant to a consumer personal finance app. You will be provided a list - of the user's transactions and a list of the user's categories. Your job is to auto-categorize - each transaction. - - Closely follow ALL the rules below while auto-categorizing: - - - Return 1 result per transaction - - Correlate each transaction by ID (transaction_id) - - Attempt to match the most specific category possible (i.e. subcategory over parent category) - - Category and transaction classifications should match (i.e. if transaction is an "expense", the category must have classification of "expense") - - If you don't know the category, return "null" - - You should always favor "null" over false positives - - Be slightly pessimistic. Only match a category if you're 60%+ confident it is the correct one. - - Each transaction has varying metadata that can be used to determine the category - - Note: "hint" comes from 3rd party aggregators and typically represents a category name that - may or may not match any of the user-supplied categories - INSTRUCTIONS - end end diff --git a/app/models/provider/openai/auto_merchant_detector.rb b/app/models/provider/openai/auto_merchant_detector.rb index 68e780b9f..d724d4281 100644 --- a/app/models/provider/openai/auto_merchant_detector.rb +++ b/app/models/provider/openai/auto_merchant_detector.rb @@ -1,4 +1,6 @@ class Provider::Openai::AutoMerchantDetector + DEFAULT_MODEL = "gpt-4.1-mini" + def initialize(client, model: "", transactions:, user_merchants:) @client = client @model = model @@ -8,7 +10,7 @@ class Provider::Openai::AutoMerchantDetector def auto_detect_merchants response = client.responses.create(parameters: { - model: model, + model: model.presence || DEFAULT_MODEL, input: [ { role: "developer", content: developer_message } ], text: { format: { @@ -26,6 +28,47 @@ class Provider::Openai::AutoMerchantDetector build_response(extract_categorizations(response)) end + def instructions + <<~INSTRUCTIONS.strip_heredoc + You are an assistant to a consumer personal finance app. + + Closely follow ALL the rules below while auto-detecting business names and website URLs: + + - Return 1 result per transaction + - Correlate each transaction by ID (transaction_id) + - Do not include the subdomain in the business_url (i.e. "amazon.com" not "www.amazon.com") + - User merchants are considered "manual" user-generated merchants and should only be used in 100% clear cases + - Be slightly pessimistic. We favor returning "null" over returning a false positive. + - NEVER return a name or URL for generic transaction names (e.g. "Paycheck", "Laundromat", "Grocery store", "Local diner") + + Determining a value: + + - First attempt to determine the name + URL from your knowledge of global businesses + - If no certain match, attempt to match one of the user-provided merchants + - If no match, return "null" + + Example 1 (known business): + + ``` + Transaction name: "Some Amazon purchases" + + Result: + - business_name: "Amazon" + - business_url: "amazon.com" + ``` + + Example 2 (generic business): + + ``` + Transaction name: "local diner" + + Result: + - business_name: null + - business_url: null + ``` + INSTRUCTIONS + end + private attr_reader :client, :model, :transactions, :user_merchants @@ -103,45 +146,4 @@ class Provider::Openai::AutoMerchantDetector Return "null" if you are not 80%+ confident in your answer. MESSAGE end - - def instructions - <<~INSTRUCTIONS.strip_heredoc - You are an assistant to a consumer personal finance app. - - Closely follow ALL the rules below while auto-detecting business names and website URLs: - - - Return 1 result per transaction - - Correlate each transaction by ID (transaction_id) - - Do not include the subdomain in the business_url (i.e. "amazon.com" not "www.amazon.com") - - User merchants are considered "manual" user-generated merchants and should only be used in 100% clear cases - - Be slightly pessimistic. We favor returning "null" over returning a false positive. - - NEVER return a name or URL for generic transaction names (e.g. "Paycheck", "Laundromat", "Grocery store", "Local diner") - - Determining a value: - - - First attempt to determine the name + URL from your knowledge of global businesses - - If no certain match, attempt to match one of the user-provided merchants - - If no match, return "null" - - Example 1 (known business): - - ``` - Transaction name: "Some Amazon purchases" - - Result: - - business_name: "Amazon" - - business_url: "amazon.com" - ``` - - Example 2 (generic business): - - ``` - Transaction name: "local diner" - - Result: - - business_name: null - - business_url: null - ``` - INSTRUCTIONS - end end diff --git a/app/views/family_exports/_list.html.erb b/app/views/family_exports/_list.html.erb index f4e979d83..339c0c039 100644 --- a/app/views/family_exports/_list.html.erb +++ b/app/views/family_exports/_list.html.erb @@ -3,13 +3,29 @@ turbo_refresh_url: family_exports_path, turbo_refresh_interval: 3000 } : {} do %> -
+
<% if exports.any? %> <% exports.each do |export| %> -
-
-

Export from <%= export.created_at.strftime("%B %d, %Y at %I:%M %p") %>

-

<%= export.filename %>

+
+
+
+

Export from <%= export.created_at.strftime("%B %d, %Y at %I:%M %p") %>

+

<%= export.filename %>

+
+ + <% if export.processing? || export.pending? %> + + <%= t(".in_progress") %> + + <% elsif export.completed? %> + + <%= t(".complete") %> + + <% elsif export.failed? %> + + <%= t(".failed") %> + + <% end %>
<% if export.processing? || export.pending? %> @@ -18,22 +34,44 @@ Exporting...
<% elsif export.completed? %> - <%= link_to download_family_export_path(export), - class: "flex items-center gap-2 text-primary hover:text-primary-hover", - data: { turbo_frame: "_top" } do %> - <%= icon "download", class: "w-5 h-5" %> - Download - <% end %> +
+ <%= button_to family_export_path(export), + method: :delete, + class: "flex items-center gap-2 text-destructive hover:text-destructive-hover", + data: { + turbo_confirm: "Are you sure you want to delete this export? This action cannot be undone.", + turbo_frame: "_top" + } do %> + <%= icon "trash-2", class: "w-5 h-5 text-destructive" %> + <% end %> + + <%= link_to download_family_export_path(export), + class: "flex items-center gap-2 text-primary hover:text-primary-hover", + data: { turbo_frame: "_top" } do %> + <%= icon "download", class: "w-5 h-5" %> + <% end %> +
<% elsif export.failed? %> -
- <%= icon "alert-circle", class: "w-4 h-4" %> - Failed +
+
+ <%= icon "alert-circle", class: "w-4 h-4" %> +
+ + <%= button_to family_export_path(export), + method: :delete, + class: "flex items-center gap-2 text-destructive hover:text-destructive-hover", + data: { + turbo_confirm: "Are you sure you want to delete this failed export?", + turbo_frame: "_top" + } do %> + <%= icon "trash-2", class: "w-5 h-5 text-destructive" %> + <% end %>
<% end %>
<% end %> <% else %> -

No exports yet

+

No exports yet.

<% end %>
<% end %> diff --git a/app/views/imports/_empty.html.erb b/app/views/imports/_empty.html.erb index dc864ea0f..517e63f56 100644 --- a/app/views/imports/_empty.html.erb +++ b/app/views/imports/_empty.html.erb @@ -1,13 +1,5 @@

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

- - <%= render DS::Link.new( - text: t(".new"), - variant: "primary", - href: new_import_path, - icon: "plus", - frame: :modal - ) %>
diff --git a/app/views/imports/_import.html.erb b/app/views/imports/_import.html.erb index 106b4d24f..026e52fb8 100644 --- a/app/views/imports/_import.html.erb +++ b/app/views/imports/_import.html.erb @@ -36,30 +36,36 @@ <% end %>
- <%= render DS::Menu.new do |menu| %> - <% menu.with_item(variant: "link", text: t(".view"), href: import_path(import), icon: "eye") %> +
+ <% if import.complete? || import.revert_failed? %> - <% menu.with_item( - variant: "button", - text: t(".revert"), - href: revert_import_path(import), - icon: "rotate-ccw", - method: :put, - confirm: CustomConfirm.new( - title: "Revert import?", - body: "This will delete transactions that were imported, but you will still be able to review and re-import your data at any time.", - btn_text: "Revert" - )) %> + <%= button_to revert_import_path(import), + method: :put, + class: "flex items-center gap-2 text-orange-500 hover:text-orange-600", + data: { + turbo_confirm: "This will delete transactions that were imported, but you will still be able to review and re-import your data at any time." + } do %> + <%= icon "rotate-ccw", class: "w-5 h-5 text-destructive" %> + <% end %> + + + <% else %> - <% menu.with_item( - variant: "button", - text: t(".delete"), - href: import_path(import), - icon: "trash-2", - method: :delete, - confirm: CustomConfirm.for_resource_deletion("import")) %> + <%= button_to import_path(import), + method: :delete, + class: "flex items-center gap-2 text-destructive hover:text-destructive-hover", + data: { + turbo_confirm: CustomConfirm.for_resource_deletion("import") + } do %> + <%= icon "trash-2", class: "w-5 h-5 text-destructive" %> + <% end %> <% end %> - <% end %> + + <%= link_to import_path(import), + class: "flex items-center gap-2 text-primary hover:text-primary-hover" do %> + <%= icon "eye", class: "w-5 h-5" %> + <% end %> +
diff --git a/app/views/imports/index.html.erb b/app/views/imports/index.html.erb index c2f2660f3..321d5f809 100644 --- a/app/views/imports/index.html.erb +++ b/app/views/imports/index.html.erb @@ -1,25 +1,39 @@ -
-

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

- - <%= render DS::Link.new( - text: "New import", - href: new_import_path, - icon: "plus", - variant: "primary", - frame: :modal - ) %> -
- -
- <% if @imports.empty? %> - <%= render partial: "imports/empty" %> - <% else %> -
-

<%= t(".imports") %> · <%= @imports.size %>

- -
- <%= render partial: "imports/import", collection: @imports.ordered, spacer_template: "shared/ruler" %> +<%= settings_section title: t(".imports") do %> +
+ <% if @imports.empty? %> + <%= render partial: "imports/empty" %> + <% else %> +
+ <%= render partial: "imports/import", collection: @imports.ordered %>
+ <% end %> + + <%= link_to new_import_path, + class: "bg-container-inset flex items-center justify-center gap-2 text-secondary mt-1 hover:bg-container-inset-hover rounded-lg px-4 py-2 w-full text-center", + data: { turbo_frame: :modal } do %> + <%= icon("plus") %> + <%= t(".new") %> + <% end %> +
+<% end %> + +<% if Current.user.admin? %> + <%= settings_section title: t(".exports") do %> +
+
+ <%= turbo_frame_tag "family_exports", src: family_exports_path, loading: :lazy do %> +
+
+
+ <% end %> +
+ + <%= link_to new_family_export_path, + class: "bg-container-inset flex items-center justify-center gap-2 text-secondary mt-1 hover:bg-container-inset-hover rounded-lg px-4 py-2 w-full text-center", + data: { turbo_frame: :modal } do %> + <%= icon("plus") %> + <%= t(".new_export") %> + <% end %>
<% end %> -
+<% end %> diff --git a/app/views/settings/_settings_nav.html.erb b/app/views/settings/_settings_nav.html.erb index 9a6e0f15d..758a810de 100644 --- a/app/views/settings/_settings_nav.html.erb +++ b/app/views/settings/_settings_nav.html.erb @@ -3,30 +3,37 @@ nav_sections = [ { header: t(".general_section_title"), items: [ - { label: t(".profile_label"), path: settings_profile_path, icon: "circle-user" }, - { label: t(".preferences_label"), path: settings_preferences_path, icon: "bolt" }, - { label: t(".security_label"), path: settings_security_path, icon: "shield-check" }, - { label: t(".api_keys_label"), path: settings_api_key_path, icon: "key" }, - { label: t(".self_hosting_label"), path: settings_hosting_path, icon: "database", if: self_hosted? }, - { label: t(".billing_label"), path: settings_billing_path, icon: "circle-dollar-sign", if: !self_hosted? }, { label: t(".accounts_label"), path: accounts_path, icon: "layers" }, { label: t(".bank_sync_label"), path: settings_bank_sync_path, icon: "banknote" }, - { label: "SimpleFin", path: simplefin_items_path, icon: "building-2" }, - { label: t(".imports_label"), path: imports_path, icon: "download" } + { label: t(".preferences_label"), path: settings_preferences_path, icon: "bolt" }, + { label: t(".profile_label"), path: settings_profile_path, icon: "circle-user" }, + { label: t(".security_label"), path: settings_security_path, icon: "shield-check" }, + { label: t(".billing_label"), path: settings_billing_path, icon: "circle-dollar-sign", if: !self_hosted? } ] }, { header: t(".transactions_section_title"), items: [ - { label: t(".tags_label"), path: tags_path, icon: "tags" }, { label: t(".categories_label"), path: categories_path, icon: "shapes" }, + { label: t(".tags_label"), path: tags_path, icon: "tags" }, { label: t(".rules_label"), path: rules_path, icon: "git-branch" }, { label: t(".merchants_label"), path: family_merchants_path, icon: "store" } ] }, + { + header: t(".advanced_section_title"), + items: [ + { label: t(".ai_prompts_label"), path: settings_ai_prompts_path, icon: "bot" }, + { label: t(".api_keys_label"), path: settings_api_key_path, icon: "key" }, + { label: t(".self_hosting_label"), path: settings_hosting_path, icon: "database", if: self_hosted? }, + { label: t(".imports_label"), path: imports_path, icon: "download" }, + { label: "SimpleFin", path: simplefin_items_path, icon: "building-2" } + ] + }, { header: t(".other_section_title"), items: [ + { label: t(".guides_label"), path: settings_guides_path, icon: "book-open" }, { label: t(".whats_new_label"), path: changelog_path, icon: "box" }, { label: t(".feedback_label"), path: feedback_path, icon: "megaphone" } ] diff --git a/app/views/settings/ai_prompts/show.html.erb b/app/views/settings/ai_prompts/show.html.erb new file mode 100644 index 000000000..230deb1a0 --- /dev/null +++ b/app/views/settings/ai_prompts/show.html.erb @@ -0,0 +1,95 @@ +<%= content_for :page_title, t(".page_title") %> + +
+
+
+

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

+
+ +
+
+ +
+
+
+ <%= icon "message-circle" %> +
+
+

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

+

<%= t(".main_system_prompt.subtitle") %>

+
+
+ +
+
+ + <%= t(".prompt_instructions") %> + <%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> + +
[<%= Provider::Openai::MODELS.join(", ") %>]
+
+
<%= @assistant_config[:instructions] %>
+
+
+
+
+ +
+ + +
+
+
+ <%= icon "brain" %> +
+
+

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

+

<%= t(".transaction_categorizer.subtitle") %>

+
+
+ +
+
+ + <%= t(".prompt_instructions") %> + <%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> + +
[<%= Provider::Openai::AutoCategorizer::DEFAULT_MODEL %>]
+
+
<%= @assistant_config[:auto_categorizer]&.instructions || Provider::Openai::AutoCategorizer.new(nil).instructions %>
+
+
+
+
+ +
+ + +
+
+
+ <%= icon "store" %> +
+
+

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

+

<%= t(".merchant_detector.subtitle") %>

+
+
+ +
+
+ + <%= t(".prompt_instructions") %> + <%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> + +
[<%= Provider::Openai::AutoMerchantDetector::DEFAULT_MODEL %>]
+
+
<%= @assistant_config[:auto_merchant]&.instructions || Provider::Openai::AutoMerchantDetector.new(nil, model: "", transactions: [], user_merchants: []).instructions %>
+
+
+
+
+
+
+
+
\ No newline at end of file diff --git a/app/views/settings/api_keys/new.html.erb b/app/views/settings/api_keys/new.html.erb index d4e2ddc7c..dd1a25317 100644 --- a/app/views/settings/api_keys/new.html.erb +++ b/app/views/settings/api_keys/new.html.erb @@ -1,6 +1,6 @@ <%= content_for :page_title, "Create New API Key" %> -<%= settings_section title: "Create New API Key", subtitle: "Generate a new API key to access your Maybe data programmatically." do %> +<%= settings_section title: nil, subtitle: "Generate a new API key to access your Maybe data programmatically." do %> <%= styled_form_with model: @api_key, url: settings_api_key_path, class: "space-y-4" do |form| %> <%= form.text_field :name, placeholder: "e.g., My Budget App, Portfolio Tracker", @@ -51,7 +51,7 @@ ) %> <%= render DS::Button.new( - text: "Create API Key", + text: "Save API Key", variant: "primary", type: "submit" ) %> diff --git a/app/views/settings/api_keys/show.html.erb b/app/views/settings/api_keys/show.html.erb index e1c829040..42dde73de 100644 --- a/app/views/settings/api_keys/show.html.erb +++ b/app/views/settings/api_keys/show.html.erb @@ -1,7 +1,9 @@ -<%= content_for :page_title, "API Key" %> - <% if @newly_created && @plain_key %> - <%= settings_section title: "API Key Created Successfully", subtitle: "Your new API key has been generated successfully." do %> +
+

API Key Created Successfully

+
+ +
@@ -51,9 +53,18 @@ ) %>
- <% end %> +
<% elsif @current_api_key %> - <%= settings_section title: "Your API Key", subtitle: "Manage your API key for programmatic access to your Maybe data." do %> +
+

Your API Key

+ <%= render DS::Link.new( + text: "Create New Key", + href: new_settings_api_key_path(regenerate: true), + variant: "secondary" + ) %> +
+ +
@@ -122,13 +133,7 @@
-
- <%= render DS::Link.new( - text: "Create New Key", - href: new_settings_api_key_path(regenerate: true), - variant: "secondary" - ) %> - +
<%= render DS::Button.new( text: "Revoke Key", href: settings_api_key_path, @@ -140,9 +145,18 @@ ) %>
- <% end %> +
<% else %> - <%= settings_section title: "Create Your API Key", subtitle: "Get programmatic access to your Maybe data" do %> +
+

API Key

+ <%= render DS::Link.new( + text: "Create API Key", + href: new_settings_api_key_path, + variant: "primary" + ) %> +
+ +
@@ -179,14 +193,6 @@
- -
- <%= render DS::Link.new( - text: "Create API Key", - href: new_settings_api_key_path, - variant: "primary" - ) %> -
- <% end %> +
<% end %> diff --git a/app/views/settings/guides/show.html.erb b/app/views/settings/guides/show.html.erb new file mode 100644 index 000000000..c6e08bbb5 --- /dev/null +++ b/app/views/settings/guides/show.html.erb @@ -0,0 +1,5 @@ +<%= content_for :page_title, "Guides" %> + +
+ <%= @guide_content.html_safe %> +
diff --git a/app/views/settings/profiles/show.html.erb b/app/views/settings/profiles/show.html.erb index 1dfe2c15c..e1f0d6536 100644 --- a/app/views/settings/profiles/show.html.erb +++ b/app/views/settings/profiles/show.html.erb @@ -122,29 +122,6 @@
<% end %> -<% if Current.user.admin? %> - <%= settings_section title: "Data Import/Export" do %> -
-
- <%= render DS::Link.new( - text: "Export data", - icon: "database", - href: new_family_export_path, - variant: "secondary", - full_width: true, - data: { turbo_frame: :modal } - ) %> -
- - <%= turbo_frame_tag "family_exports", src: family_exports_path, loading: :lazy do %> -
-
-
- <% end %> -
- <% end %> -<% end %> - <%= settings_section title: t(".danger_zone_title") do %>
<% if Current.user.admin? %> diff --git a/app/views/users/_user_menu.html.erb b/app/views/users/_user_menu.html.erb index 1e46ee0c0..9a0d515a8 100644 --- a/app/views/users/_user_menu.html.erb +++ b/app/views/users/_user_menu.html.erb @@ -30,7 +30,7 @@ <% end %> <% end %> - <% menu.with_item(variant: "link", text: "Settings", icon: "settings", href: settings_profile_path(return_to: request.fullpath)) %> + <% menu.with_item(variant: "link", text: "Settings", icon: "settings", href: accounts_path(return_to: request.fullpath)) %> <% menu.with_item(variant: "link", text: "Changelog", icon: "box", href: changelog_path) %> <% if self_hosted? %> diff --git a/config/locales/views/family_exports/en.yml b/config/locales/views/family_exports/en.yml new file mode 100644 index 000000000..79674716b --- /dev/null +++ b/config/locales/views/family_exports/en.yml @@ -0,0 +1,7 @@ +--- +en: + family_exports: + list: + in_progress: In progress + complete: Complete + failed: Failed diff --git a/config/locales/views/family_exports/nb.yml b/config/locales/views/family_exports/nb.yml new file mode 100644 index 000000000..be5e5b53e --- /dev/null +++ b/config/locales/views/family_exports/nb.yml @@ -0,0 +1,7 @@ +--- +nb: + family_exports: + list: + in_progress: Pågår + complete: Fullført + failed: Mislykket diff --git a/config/locales/views/family_exports/tr.yml b/config/locales/views/family_exports/tr.yml new file mode 100644 index 000000000..db1da6c02 --- /dev/null +++ b/config/locales/views/family_exports/tr.yml @@ -0,0 +1,7 @@ +--- +tr: + family_exports: + list: + in_progress: Devam ediyor + complete: Tamamlandı + failed: Başarısız diff --git a/config/locales/views/imports/en.yml b/config/locales/views/imports/en.yml index 972741933..e0aca782b 100644 --- a/config/locales/views/imports/en.yml +++ b/config/locales/views/imports/en.yml @@ -58,7 +58,7 @@ en: imports: empty: message: No imports yet. - new: New import + new: New Import import: complete: Complete delete: Delete @@ -71,8 +71,11 @@ en: view: View index: imports: Imports - new: New import - title: Imports + new: New Import + title: Import/Export + exports: Exports + new_export: New Export + no_exports: No exports yet. new: description: You can manually import various types of data via CSV or use one of our import templates like Mint. diff --git a/config/locales/views/imports/nb.yml b/config/locales/views/imports/nb.yml index 49cf223e3..29f15c58e 100644 --- a/config/locales/views/imports/nb.yml +++ b/config/locales/views/imports/nb.yml @@ -1,90 +1,93 @@ ---- -nb: - import: - cleans: - show: - description: Rediger dataene dine i tabellen nedenfor. Røde celler er ugyldige. - errors_notice: Du har feil i dataene dine. Hold musepekeren over feilen for å se - detaljer. - errors_notice_mobile: Du har feil i dataene dine. Trykk på feilverktøytipset for å se - detaljer. - title: Rengjør dataene dine - configurations: - mint_import: - date_format_label: Datoformat - show: - description: Velg kolonnene som tilsvarer hvert felt i CSV-filen din. - title: Konfigurer importen din - trade_import: - date_format_label: Datoformat - transaction_import: - date_format_label: Datoformat - confirms: - mappings: - create_account: Opprett konto - csv_mapping_label: "%{mapping} i CSV" - maybe_mapping_label: "%{mapping} i Sure" - no_accounts: Du har ingen kontoer ennå. Vennligst opprett en konto som - vi kan bruke for (utilordnede) rader i CSV-filen din eller gå tilbake til Rengjør-trinnet - og oppgi et kontonavn vi kan bruke. - rows_label: Rader - unassigned_account: Trenger å opprette en ny konto for utilordnede rader? - show: - account_mapping_description: Tilordne alle kontoene i den importerte filen din til - Maybes eksisterende kontoer. Du kan også legge til nye kontoer eller la dem - være ukategorisert. - account_mapping_title: Tilordne kontoene dine - account_type_mapping_description: Tilordne alle kontotypene i den importerte filen din til - Maybes - account_type_mapping_title: Tilordne kontotypene dine - category_mapping_description: Tilordne alle kategoriene i den importerte filen din til - Maybes eksisterende kategorier. Du kan også legge til nye kategorier eller la dem - være ukategorisert. - category_mapping_title: Tilordne kategoriene dine - tag_mapping_description: Tilordne alle tagene i den importerte filen din til - Maybes eksisterende tagger. Du kan også legge til nye tagger eller la dem - være ukategorisert. - tag_mapping_title: Tilordne tagene dine - uploads: - show: - description: Lim inn eller last opp CSV-filen din nedenfor. Vennligst gjennomgå instruksjonene - i tabellen nedenfor før du begynner. - instructions_1: Nedenfor er et eksempel på CSV med kolonner tilgjengelig for import. - instructions_2: CSV-filen din må ha en overskriftsrad - instructions_3: Du kan navngi kolonnene dine hva du vil. Du vil tilordne - dem på et senere trinn. - instructions_4: Kolonner merket med en stjerne (*) er obligatoriske data. - instructions_5: Ingen komma, ingen valutasymboler og ingen parenteser i tall. - title: Importer dataene dine - imports: - empty: - message: Ingen importer ennå. - new: Ny import - import: - complete: Fullført - delete: Slett - failed: Mislykket - in_progress: Pågår - label: "%{type}: %{datetime}" - revert_failed: Tilbakestilling mislykket - reverting: Tilbakestiller - uploading: Behandler rader - view: Vis - index: - imports: Importer - new: Ny import - title: Importer - new: - description: Du kan manuelt importere ulike typer data via CSV eller bruke en av - våre importmaler som Mint. - import_accounts: Importer kontoer - import_mint: Importer fra Mint - import_portfolio: Importer investeringer - import_transactions: Importer transaksjoner - resume: Fortsett %{type} - sources: Kilder - title: Ny CSV-import - ready: - description: Her er en oppsummering av de nye elementene som vil bli lagt til kontoen din - når du publiserer denne importen. +--- +nb: + import: + cleans: + show: + description: Rediger dataene dine i tabellen nedenfor. Røde celler er ugyldige. + errors_notice: Du har feil i dataene dine. Hold musepekeren over feilen for å se + detaljer. + errors_notice_mobile: Du har feil i dataene dine. Trykk på feilverktøytipset for å se + detaljer. + title: Rengjør dataene dine + configurations: + mint_import: + date_format_label: Datoformat + show: + description: Velg kolonnene som tilsvarer hvert felt i CSV-filen din. + title: Konfigurer importen din + trade_import: + date_format_label: Datoformat + transaction_import: + date_format_label: Datoformat + confirms: + mappings: + create_account: Opprett konto + csv_mapping_label: "%{mapping} i CSV" + maybe_mapping_label: "%{mapping} i Sure" + no_accounts: Du har ingen kontoer ennå. Vennligst opprett en konto som + vi kan bruke for (utilordnede) rader i CSV-filen din eller gå tilbake til Rengjør-trinnet + og oppgi et kontonavn vi kan bruke. + rows_label: Rader + unassigned_account: Trenger å opprette en ny konto for utilordnede rader? + show: + account_mapping_description: Tilordne alle kontoene i den importerte filen din til + Maybes eksisterende kontoer. Du kan også legge til nye kontoer eller la dem + være ukategorisert. + account_mapping_title: Tilordne kontoene dine + account_type_mapping_description: Tilordne alle kontotypene i den importerte filen din til + Maybes + account_type_mapping_title: Tilordne kontotypene dine + category_mapping_description: Tilordne alle kategoriene i den importerte filen din til + Maybes eksisterende kategorier. Du kan også legge til nye kategorier eller la dem + være ukategorisert. + category_mapping_title: Tilordne kategoriene dine + tag_mapping_description: Tilordne alle tagene i den importerte filen din til + Maybes eksisterende tagger. Du kan også legge til nye tagger eller la dem + være ukategorisert. + tag_mapping_title: Tilordne tagene dine + uploads: + show: + description: Lim inn eller last opp CSV-filen din nedenfor. Vennligst gjennomgå instruksjonene + i tabellen nedenfor før du begynner. + instructions_1: Nedenfor er et eksempel på CSV med kolonner tilgjengelig for import. + instructions_2: CSV-filen din må ha en overskriftsrad + instructions_3: Du kan navngi kolonnene dine hva du vil. Du vil tilordne + dem på et senere trinn. + instructions_4: Kolonner merket med en stjerne (*) er obligatoriske data. + instructions_5: Ingen komma, ingen valutasymboler og ingen parenteser i tall. + title: Importer dataene dine + imports: + empty: + message: Ingen importer ennå. + new: Ny Import + import: + complete: Fullført + delete: Slett + failed: Mislykket + in_progress: Pågår + label: "%{type}: %{datetime}" + revert_failed: Tilbakestilling mislykket + reverting: Tilbakestiller + uploading: Behandler rader + view: Vis + index: + imports: Importer + new: Ny Import + title: Importer + exports: Eksporter + new_export: Ny Eksport + no_exports: Ingen eksporter ennå. + new: + description: Du kan manuelt importere ulike typer data via CSV eller bruke en av + våre importmaler som Mint. + import_accounts: Importer kontoer + import_mint: Importer fra Mint + import_portfolio: Importer investeringer + import_transactions: Importer transaksjoner + resume: Fortsett %{type} + sources: Kilder + title: Ny CSV-import + ready: + description: Her er en oppsummering av de nye elementene som vil bli lagt til kontoen din + når du publiserer denne importen. title: Bekreft importdataene dine \ No newline at end of file diff --git a/config/locales/views/imports/tr.yml b/config/locales/views/imports/tr.yml index 066f927d7..6ded70278 100644 --- a/config/locales/views/imports/tr.yml +++ b/config/locales/views/imports/tr.yml @@ -46,7 +46,7 @@ tr: imports: empty: message: Henüz hiç içe aktarma yok. - new: Yeni içe aktarma + new: Yeni İçe Aktarma import: complete: Tamamlandı delete: Sil @@ -59,8 +59,11 @@ tr: view: Görüntüle index: imports: İçe aktarmalar - new: Yeni içe aktarma + new: Yeni İçe Aktarma title: İçe aktarmalar + exports: Dışa aktarmalar + new_export: Yeni Dışa Aktarma + no_exports: Henüz hiç dışa aktarma yok. new: description: Farklı veri türlerini CSV ile manuel olarak içe aktarabilir veya Mint gibi içe aktarma şablonlarımızı kullanabilirsiniz. import_accounts: Hesapları içe aktar diff --git a/config/locales/views/settings/api_keys/en.yml b/config/locales/views/settings/api_keys/en.yml index b814e7427..05a526420 100644 --- a/config/locales/views/settings/api_keys/en.yml +++ b/config/locales/views/settings/api_keys/en.yml @@ -14,7 +14,7 @@ en: show: title: "API Key Management" no_api_key: - title: "Create Your API Key" + title: "API Key" description: "Get programmatic access to your Maybe data with a secure API key." what_you_can_do: "What you can do with the API:" feature_1: "Access your account data programmatically" diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index 8e7b17b8d..3c18e2f00 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -1,6 +1,20 @@ --- en: settings: + ai_prompts: + show: + page_title: AI Prompts + openai_label: OpenAI + prompt_instructions: Prompt Instructions + main_system_prompt: + title: Main System Prompt + subtitle: Core instructions that define how the AI assistant behaves in all chat conversations + transaction_categorizer: + title: Transaction Categorizer + subtitle: AI automatically categorizes your transactions based on your defined categories + merchant_detector: + title: Merchant Detector + subtitle: AI identifies and enriches transaction data with merchant information billings: show: page_title: Billing @@ -59,10 +73,10 @@ en: invitation_link: Invitation link invite_member: Add member last_name: Last Name - page_title: Account + page_title: Profile Info pending: Pending profile_subtitle: Customize how you appear on Sure - profile_title: Profile + profile_title: Personal remove_invitation: Remove Invitation remove_member: Remove Member save: Save @@ -71,24 +85,27 @@ en: page_title: Security settings_nav: accounts_label: Accounts + advanced_section_title: Advanced + ai_prompts_label: AI Prompts api_key_label: API Key billing_label: Billing categories_label: Categories feedback_label: Feedback general_section_title: General - imports_label: Imports + imports_label: Import/Export logout: Logout merchants_label: Merchants + guides_label: Guides other_section_title: More preferences_label: Preferences - profile_label: Account + profile_label: Profile Info rules_label: Rules security_label: Security self_hosting_label: Self-Hosting tags_label: Tags transactions_section_title: Transactions whats_new_label: What's new - api_keys_label: API Keys + api_keys_label: API Key bank_sync_label: Bank Sync settings_nav_link_large: next: Next diff --git a/config/locales/views/settings/nb.yml b/config/locales/views/settings/nb.yml index e68b80fa6..0ffe61fed 100644 --- a/config/locales/views/settings/nb.yml +++ b/config/locales/views/settings/nb.yml @@ -1,96 +1,97 @@ ---- -nb: - settings: - billings: - show: - page_title: Fakturering - subscription_subtitle: Oppdater abonnementet og faktureringsdetaljene dine - subscription_title: Administrer abonnement - preferences: - show: - country: Land - currency: Valuta - date_format: Datoformat - general_subtitle: Konfigurer preferansene dine - general_title: Generelt - default_period: Standardperiode - language: Språk - page_title: Preferanser - theme_dark: Mørk - theme_light: Lys - theme_subtitle: Velg et foretrukket tema for appen - theme_system: System - theme_title: Tema - timezone: Tidssone - profiles: - destroy: - cannot_remove_self: Du kan ikke fjerne deg selv fra din egen konto. - member_removal_failed: Det oppsto et problem med å fjerne medlemmet. - member_removed: Medlemmet ble fjernet vellykket. - not_authorized: Du er ikke autorisert til å fjerne medlemmer. - show: - confirm_delete: - body: Er du sikker på at du vil permanent slette kontoen din? Denne handlingen er irreversibel. - title: Slett konto? - confirm_reset: - body: Er du sikker på at du vil tilbakestille kontoen din? Dette vil slette alle kontoene dine, kategorier, forhandlere, tagger og andre data. Denne handlingen kan ikke angres. - title: Tilbakestill konto? - confirm_remove_invitation: - body: Er du sikker på at du vil fjerne invitasjonen for %{email}? - title: Fjern invitasjon - confirm_remove_member: - body: Er du sikker på at du vil fjerne %{name} fra kontoen din? - title: Fjern medlem - danger_zone_title: Fareområde - delete_account: Slett konto - delete_account_warning: Sletting av kontoen din vil permanent fjerne alle - dataene dine og kan ikke angres. - reset_account: Tilbakestill konto - reset_account_warning: Tilbakestilling av kontoen din vil slette alle kontoene dine, kategorier, forhandlere, tagger og andre data, men beholde brukerkontoen din intakt. - email: E-post - first_name: Fornavn - household_form_input_placeholder: Angi husholdningsnavn - household_form_label: Husholdningsnavn - household_subtitle: Inviter familiemedlemmer, partnere og andre individer. Inviterte - kan logge inn på husholdningen din og få tilgang til dine delte kontoer. - household_title: Husholdning - invitation_link: Invitasjonslenke - invite_member: Legg til medlem - last_name: Etternavn - page_title: Konto - pending: Venter - profile_subtitle: Tilpass hvordan du vises på Sure - profile_title: Profil - remove_invitation: Fjern invitasjon - remove_member: Fjern medlem - save: Lagre - securities: - show: - page_title: Sikkerhet - settings_nav: - accounts_label: Kontoer - api_key_label: API-nøkkel - billing_label: Fakturering - categories_label: Kategorier - feedback_label: Tilbakemelding - general_section_title: Generelt - imports_label: Importer - logout: Logg ut - merchants_label: Forhandlere - other_section_title: Mer - preferences_label: Preferanser - profile_label: Konto - rules_label: Regler - security_label: Sikkerhet - self_hosting_label: Selvhosting - tags_label: Tagger - transactions_section_title: Transaksjoner - whats_new_label: Hva er nytt - settings_nav_link_large: - next: Neste - previous: Tilbake - user_avatar_field: - accepted_formats: JPG eller PNG. 5MB maks. - choose: Last opp bilde - choose_label: (valgfritt) +--- +nb: + settings: + billings: + show: + page_title: Fakturering + subscription_subtitle: Oppdater abonnementet og faktureringsdetaljene dine + subscription_title: Administrer abonnement + preferences: + show: + country: Land + currency: Valuta + date_format: Datoformat + general_subtitle: Konfigurer preferansene dine + general_title: Generelt + default_period: Standardperiode + language: Språk + page_title: Preferanser + theme_dark: Mørk + theme_light: Lys + theme_subtitle: Velg et foretrukket tema for appen + theme_system: System + theme_title: Tema + timezone: Tidssone + profiles: + destroy: + cannot_remove_self: Du kan ikke fjerne deg selv fra din egen konto. + member_removal_failed: Det oppsto et problem med å fjerne medlemmet. + member_removed: Medlemmet ble fjernet vellykket. + not_authorized: Du er ikke autorisert til å fjerne medlemmer. + show: + confirm_delete: + body: Er du sikker på at du vil permanent slette kontoen din? Denne handlingen er irreversibel. + title: Slett konto? + confirm_reset: + body: Er du sikker på at du vil tilbakestille kontoen din? Dette vil slette alle kontoene dine, kategorier, forhandlere, tagger og andre data. Denne handlingen kan ikke angres. + title: Tilbakestill konto? + confirm_remove_invitation: + body: Er du sikker på at du vil fjerne invitasjonen for %{email}? + title: Fjern invitasjon + confirm_remove_member: + body: Er du sikker på at du vil fjerne %{name} fra kontoen din? + title: Fjern medlem + danger_zone_title: Fareområde + delete_account: Slett konto + delete_account_warning: Sletting av kontoen din vil permanent fjerne alle + dataene dine og kan ikke angres. + reset_account: Tilbakestill konto + reset_account_warning: Tilbakestilling av kontoen din vil slette alle kontoene dine, kategorier, forhandlere, tagger og andre data, men beholde brukerkontoen din intakt. + email: E-post + first_name: Fornavn + household_form_input_placeholder: Angi husholdningsnavn + household_form_label: Husholdningsnavn + household_subtitle: Inviter familiemedlemmer, partnere og andre individer. Inviterte + kan logge inn på husholdningen din og få tilgang til dine delte kontoer. + household_title: Husholdning + invitation_link: Invitasjonslenke + invite_member: Legg til medlem + last_name: Etternavn + page_title: Konto + pending: Venter + profile_subtitle: Tilpass hvordan du vises på Sure + profile_title: Profil + remove_invitation: Fjern invitasjon + remove_member: Fjern medlem + save: Lagre + securities: + show: + page_title: Sikkerhet + settings_nav: + accounts_label: Kontoer + api_key_label: API-nøkkel + advanced_section_title: Avansert + billing_label: Fakturering + categories_label: Kategorier + feedback_label: Tilbakemelding + general_section_title: Generelt + imports_label: Importer + logout: Logg ut + merchants_label: Forhandlere + other_section_title: Mer + preferences_label: Preferanser + profile_label: Konto + rules_label: Regler + security_label: Sikkerhet + self_hosting_label: Selvhosting + tags_label: Tagger + transactions_section_title: Transaksjoner + whats_new_label: Hva er nytt + settings_nav_link_large: + next: Neste + previous: Tilbake + user_avatar_field: + accepted_formats: JPG eller PNG. 5MB maks. + choose: Last opp bilde + choose_label: (valgfritt) change: Endre bilde \ No newline at end of file diff --git a/config/locales/views/settings/tr.yml b/config/locales/views/settings/tr.yml index fa568c33f..603f020ef 100644 --- a/config/locales/views/settings/tr.yml +++ b/config/locales/views/settings/tr.yml @@ -68,6 +68,7 @@ tr: settings_nav: accounts_label: Hesaplar api_key_label: API Anahtarı + advanced_section_title: Gelişmiş billing_label: Faturalandırma categories_label: Kategoriler feedback_label: Geri Bildirim diff --git a/config/routes.rb b/config/routes.rb index d89dcdb47..9e4e80548 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -24,7 +24,7 @@ Rails.application.routes.draw do end end - resources :family_exports, only: %i[new create index] do + resources :family_exports, only: %i[new create index destroy] do member do get :download end @@ -63,6 +63,8 @@ Rails.application.routes.draw do resource :billing, only: :show resource :security, only: :show resource :api_key, only: [ :show, :new, :create, :destroy ] + resource :ai_prompts, only: :show + resource :guides, only: :show resource :bank_sync, only: :show, controller: "bank_sync" end diff --git a/docs/onboarding/guide.md b/docs/onboarding/guide.md new file mode 100644 index 000000000..6b4973de6 --- /dev/null +++ b/docs/onboarding/guide.md @@ -0,0 +1,160 @@ +# Welcome to Sure! + +This guide aims to assist new users through: + +1. Creating a Sure account +2. Adding your first accounts +3. Recording transactions + +This guide also covers the differences between **asset** and **liability** accounts, a key concept for using and understanding balances in Sure! + +> [!IMPORTANT] +> Sure is evolving quickly. If you find something inaccurate while following this guide, please: +> +> - Ask in the [Discord](https://discord.gg/36ZGBsxYEK) +> - Open an [issue](https://github.com/we-promise/sure/issues/new/choose) +> - Or if you know the answer, open a [PR](https://github.com/we-promise/sure/compare)! + + +## 1. Creating your Sure Account + +Once Sure is installed, open a browser and navigate to [localhost:3000](http://localhost:3000/sessions/new).
+You will see the **login page** (pictured below). Since we do not have an account yet, click on **Sign Up** to begin. + +Landing page on a fresh install. +
+
+ +You’ll be guided through a short series of screens to set your **login details**, **personal information**, and **preferences**.
+When you arrive at the main dashboard, showing **No accounts yet**, you’re all set up! + +Blank home screen of Sure, with no accounts yet. +
+
+ +> [!Note] +> The next sections of this guide cover how to **manually add accounts and transactions** in Sure.
+> If you’d like to use an integration with a data provider instead, see: +> +> - **Lunch Flow** (WIP) +> - [**Plaid**](/docs/hosting/plaid.md) +> - **SimpleFin** (WIP) +> +> Even if you use an integration, we still recommend reading through this guide to understand **account types** and how they work in Sure. + + +## 2. Account Types in Sure + +Sure supports several account types, which are grouped into **Assets** (things you own) and **Debts/Liabilities** (things you owe): + +| Assets | Debts/Liabilities | +| ----------- | ----------------- | +| Cash | Credit Card | +| Investment | Loan | +| Crypto | Other Liability | +| Property | | +| Vehicle | | +| Other Asset | | + + +## 3. How Asset Accounts Work + +Cash, checking and savings accounts **increase** when you add money and **decrease** when you spend money. + +Example: + +- Starting balance: $500 +- Add an expense of $20 -> balance is now $480 +- Add an income of $100 -> balance is now $580 + + +## 4. How Debt Accounts Work (Liabilities) + +Liability accounts track how much money you **owe**, so the math can feel *backwards* compared to an asset account. + +**Key rule:** + +- **Positive Balances** = you owe money +- **Negative balances** = the bank owes *you* (e.g. overpayment or refund) + +**Transactions behave like this:** + +- **Expenses** (e.g. purchases) => increase your debt (you owe more) +- **Payments or refunds** => decrease your debt (you owe less) + +Credit Card example: + +1. Balance: **$200 owed** +2. Spend $20 => You now owe $220 (balance goes *up* in red) +3. Pay off $50 => You now owe $170 (balance goes *down* in green) + +Overpayment Example: + +1. Balance: -$44 (bank owes you $44) +2. Spend $1 => Bank now owes you **$43** (balance shown as -$43, moving towards zero) + +> [!TIP] +> Why does it work this way? This matches standard accounting and what your credit card provider shows online. Think of a liability balance as "**Amount Owed**", not "available cash." + + +## 5. Quick Reference: Assets vs. Liability Behavior + +| Action | Asset Account (e.g. Checking) | Liability Account (e.g. Credit Card) | +| ---------------- | ----------------------------- | ------------------------------------ | +| Spend $20 | Balance ↓ $20 | Balance ↑ $20 (more debt) | +| Receive $50 | Balance ↑ $50 | Balance ↓ $50 (less debt) | +| Negative Balance | Overdraft | Bank owes *you* money | + + +## 6. Adding Accounts + +For this example we'll add a **Savings Account**.
+ +>[!TIP] +>If you’re adding a **credit card**, **loan**, or any other **debt**, be sure to select a **Credit Card** or **Liability** account type instead of **Cash**. This will ensure balances update correctly and match what your bank shows. + +Most bank accounts (checking, savings, money market) are **Cash Accounts** +1. Click **+ Add Account** → **Cash** → **Enter Account Balance** +2. Fill in details such as: + - Account name + - Current Balance + - Account Subtype (This is where you specify checking, savings, or other) +3. Click **Create Account** when you are ready to proceed. + +Cash Account creation menu +
+
+ +Once created, you'll return to the **Home** screen.
+You'll now see: +- Your new cash account in the **Accounts** list (left side) +- An overview of your accounts in the center, under the net worth bar. + +To get this bar moving let's add some transactions! + +Home screen of Sure, showing one account and no transactions. + +## 7. Adding Transactions + +To add a transaction: +1. Go to the **Transactions** page (left sidebar, under **Home**, above **Budgets**) +2. Click **+ New Transaction** (top right) +3. Choose the transaction type: + - **Expense** → Spending money + - **Income** → Receiving money + - **Transfer** → Move money between accounts +4. Enter the details, then click **Add transaction** + +You will now see the transaction you added in your **transaction history**, as well as the **net worth chart** updating accordingly. + +Filled-out expense form + +## 8. Next Steps + +Now that you have one account and your first transaction: +- Explore the other account types that Sure offers, adding ones relevant to your finances. +- **Categorize** and **Tag** transactions for better searching and reporting. +- Experiment with **Budgets** to track your spending habits. +- If you have many historical transactions, use **Bulk Import** to load them in. + +More detailed user guides for these features are coming soon™. diff --git a/test/controllers/family_exports_controller_test.rb b/test/controllers/family_exports_controller_test.rb index 63adf7884..017509bba 100644 --- a/test/controllers/family_exports_controller_test.rb +++ b/test/controllers/family_exports_controller_test.rb @@ -33,7 +33,7 @@ class FamilyExportsControllerTest < ActionDispatch::IntegrationTest post family_exports_path end - assert_redirected_to settings_profile_path + assert_redirected_to imports_path assert_equal "Export started. You'll be able to download it shortly.", flash[:notice] export = @family.family_exports.last @@ -67,7 +67,87 @@ class FamilyExportsControllerTest < ActionDispatch::IntegrationTest export = @family.family_exports.create!(status: "processing") get download_family_export_path(export) - assert_redirected_to settings_profile_path + assert_redirected_to imports_path assert_equal "Export not ready for download", flash[:alert] end + + test "admin can delete export" do + export = @family.family_exports.create!(status: "completed") + + assert_difference "@family.family_exports.count", -1 do + delete family_export_path(export) + end + + assert_redirected_to imports_path + assert_equal "Export deleted successfully", flash[:notice] + end + + test "admin can delete export with attached file" do + export = @family.family_exports.create!(status: "completed") + export.export_file.attach( + io: StringIO.new("test zip content"), + filename: "test.zip", + content_type: "application/zip" + ) + + assert export.export_file.attached? + assert_difference "@family.family_exports.count", -1 do + delete family_export_path(export) + end + + assert_redirected_to imports_path + assert_equal "Export deleted successfully", flash[:notice] + end + + test "admin can delete failed export with attached file" do + export = @family.family_exports.create!(status: "failed") + export.export_file.attach( + io: StringIO.new("failed export content"), + filename: "failed.zip", + content_type: "application/zip" + ) + + assert export.export_file.attached? + assert_difference "@family.family_exports.count", -1 do + delete family_export_path(export) + end + + assert_redirected_to imports_path + assert_equal "Export deleted successfully", flash[:notice] + end + + test "export file is purged when export is deleted" do + export = @family.family_exports.create!(status: "completed") + export.export_file.attach( + io: StringIO.new("test zip content"), + filename: "test.zip", + content_type: "application/zip" + ) + + # Verify file is attached + assert export.export_file.attached? + file_id = export.export_file.id + + # Delete the export + delete family_export_path(export) + + # Verify the export record is gone + assert_not FamilyExport.exists?(export.id) + + # Verify the Active Storage attachment is also gone + # Note: Active Storage purges files asynchronously with `dependent: :purge_later` + # In tests, we can check that the attachment record is gone + assert_not ActiveStorage::Attachment.exists?(file_id) + end + + test "non-admin cannot delete export" do + export = @family.family_exports.create!(status: "completed") + sign_in @non_admin + + assert_no_difference "@family.family_exports.count" do + delete family_export_path(export) + end + + assert_redirected_to root_path + end end diff --git a/test/models/family_export_test.rb b/test/models/family_export_test.rb index 45420adf3..3cd3abb3d 100644 --- a/test/models/family_export_test.rb +++ b/test/models/family_export_test.rb @@ -1,7 +1,131 @@ require "test_helper" class FamilyExportTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end + setup do + @family = families(:dylan_family) + @export = @family.family_exports.create! + end + + test "belongs to family" do + assert_equal @family, @export.family + end + + test "has default status of pending" do + assert_equal "pending", @export.status + end + + test "can have export file attached" do + @export.export_file.attach( + io: StringIO.new("test content"), + filename: "test.zip", + content_type: "application/zip" + ) + + assert @export.export_file.attached? + assert_equal "test.zip", @export.export_file.filename.to_s + assert_equal "application/zip", @export.export_file.content_type + end + + test "filename is generated correctly" do + travel_to Time.zone.local(2024, 1, 15, 14, 30, 0) do + export = @family.family_exports.create! + expected_filename = "maybe_export_20240115_143000.zip" + assert_equal expected_filename, export.filename + end + end + + test "downloadable? returns true for completed export with file" do + @export.update!(status: "completed") + @export.export_file.attach( + io: StringIO.new("test content"), + filename: "test.zip", + content_type: "application/zip" + ) + + assert @export.downloadable? + end + + test "downloadable? returns false for pending export" do + @export.update!(status: "pending") + @export.export_file.attach( + io: StringIO.new("test content"), + filename: "test.zip", + content_type: "application/zip" + ) + + assert_not @export.downloadable? + end + + test "downloadable? returns false for completed export without file" do + @export.update!(status: "completed") + + assert_not @export.downloadable? + end + + test "downloadable? returns false for failed export with file" do + @export.update!(status: "failed") + @export.export_file.attach( + io: StringIO.new("test content"), + filename: "test.zip", + content_type: "application/zip" + ) + + assert_not @export.downloadable? + end + + test "export file is purged when export is destroyed" do + @export.export_file.attach( + io: StringIO.new("test content"), + filename: "test.zip", + content_type: "application/zip" + ) + + # Verify file is attached + assert @export.export_file.attached? + file_id = @export.export_file.id + blob_id = @export.export_file.blob.id + + # Destroy the export + @export.destroy! + + # Verify the export record is gone + assert_not FamilyExport.exists?(@export.id) + + # Verify the Active Storage attachment is gone + assert_not ActiveStorage::Attachment.exists?(file_id) + + # Note: Active Storage purges blobs asynchronously with dependent: :purge_later + # In tests, we can verify the attachment is gone, which is the immediate effect + # The blob will be purged in the background + end + + test "can transition through statuses" do + assert_equal "pending", @export.status + + @export.processing! + assert_equal "processing", @export.status + + @export.completed! + assert_equal "completed", @export.status + + @export.failed! + assert_equal "failed", @export.status + end + + test "ordered scope returns exports in descending order" do + # Clear existing exports to avoid interference + @family.family_exports.destroy_all + + # Create exports with specific timestamps + old_export = @family.family_exports.create! + old_export.update_column(:created_at, 2.days.ago) + + new_export = @family.family_exports.create! + new_export.update_column(:created_at, 1.day.ago) + + ordered_exports = @family.family_exports.ordered.to_a + assert_equal 2, ordered_exports.length + assert_equal new_export.id, ordered_exports.first.id + assert_equal old_export.id, ordered_exports.last.id + end end diff --git a/test/system/settings/api_keys_test.rb b/test/system/settings/api_keys_test.rb index d6beeeee7..e558cb945 100644 --- a/test/system/settings/api_keys_test.rb +++ b/test/system/settings/api_keys_test.rb @@ -10,10 +10,9 @@ class Settings::ApiKeysTest < ApplicationSystemTestCase test "should show no API key state when user has no active keys" do visit settings_api_key_path - assert_text "Create Your API Key" - assert_text "Get programmatic access to your Maybe data" - assert_text "Access your account data programmatically" + assert_text "API Key" assert_link "Create API Key", href: new_settings_api_key_path + assert_text "Access your account data programmatically" end test "should navigate to create new API key form" do @@ -33,7 +32,7 @@ class Settings::ApiKeysTest < ApplicationSystemTestCase fill_in "API Key Name", with: "Test Integration Key" choose "Read/Write" - click_button "Create API Key" + click_button "Save API Key" # Should redirect to show page with the API key details assert_current_path settings_api_key_path @@ -100,7 +99,7 @@ class Settings::ApiKeysTest < ApplicationSystemTestCase fill_in "API Key Name", with: "New API Key" choose "Read Only" - click_button "Create API Key" + click_button "Save API Key" # Should redirect to show page with new key assert_text "New API Key" @@ -133,8 +132,8 @@ class Settings::ApiKeysTest < ApplicationSystemTestCase # Wait for redirect after revoke assert_no_selector "#confirm-dialog" - assert_text "Create Your API Key" - assert_text "Get programmatic access to your Maybe data" + assert_text "API Key" + assert_text "Access your account data programmatically" # Key should be revoked in the database api_key.reload @@ -167,7 +166,7 @@ class Settings::ApiKeysTest < ApplicationSystemTestCase # Try to submit without name choose "Read Only" - click_button "Create API Key" + click_button "Save API Key" # Should stay on form with validation error assert_current_path new_settings_api_key_path diff --git a/test/system/settings_test.rb b/test/system/settings_test.rb index c7d5efe44..0271f129d 100644 --- a/test/system/settings_test.rb +++ b/test/system/settings_test.rb @@ -5,13 +5,18 @@ class SettingsTest < ApplicationSystemTestCase sign_in @user = users(:family_admin) @settings_links = [ - [ "Account", settings_profile_path ], - [ "Preferences", settings_preferences_path ], [ "Accounts", accounts_path ], - [ "Tags", tags_path ], + [ "Bank Sync", settings_bank_sync_path ], + [ "Preferences", settings_preferences_path ], + [ "Profile Info", settings_profile_path ], + [ "Security", settings_security_path ], [ "Categories", categories_path ], + [ "Tags", tags_path ], + [ "Rules", rules_path ], [ "Merchants", family_merchants_path ], - [ "Imports", imports_path ], + [ "AI Prompts", settings_ai_prompts_path ], + [ "API Key", settings_api_key_path ], + [ "Guides", settings_guides_path ], [ "What's new", changelog_path ], [ "Feedback", feedback_path ] ] @@ -20,8 +25,8 @@ class SettingsTest < ApplicationSystemTestCase test "can access settings from sidebar" do VCR.use_cassette("git_repository_provider/fetch_latest_release_notes") do open_settings_from_sidebar - assert_selector "h1", text: "Account" - assert_current_path settings_profile_path, ignore_query: true + assert_selector "h1", text: "Accounts" + assert_current_path accounts_path, ignore_query: true @settings_links.each do |name, path| click_link name