- <%= content_for :page_title %> -
- <% end %> ++ <%= content_for :page_title %> +
+ <% else %> + + <% end %> + <% if content_for?(:page_actions) %> +Verify Your Identity
+<%= t("oidc_accounts.link.verify_heading") %>
- To link your <%= @pending_auth["provider"] %> account<% if @pending_auth["email"].present? %> (<%= @pending_auth["email"] %>)<% end %>, - please verify your identity by entering your password. + <% email_suffix = @pending_auth["email"].present? ? t("oidc_accounts.link.email_suffix_html", email: @pending_auth["email"]) : "" %> + <%= t("oidc_accounts.link.verify_description_html", provider: @pending_auth["provider"], email_suffix: email_suffix).html_safe %>
This helps ensure that only you can link external accounts to your profile.
+<%= t("oidc_accounts.link.verify_hint") %>
Create New Account
+<%= t("oidc_accounts.link.create_heading") %>
- No account found with the email <%= @pending_auth["email"] %>. - Click below to create a new account using your <%= @pending_auth["provider"] %> identity. + <%= t("oidc_accounts.link.create_description_html", email: @pending_auth["email"], provider: @pending_auth["provider"]).html_safe %>
- Email: <%= @pending_auth["email"] %> + <%= t("oidc_accounts.link.info_email") %> <%= @pending_auth["email"] %>
<% if @pending_auth["name"].present? %>- Name: <%= @pending_auth["name"] %> + <%= t("oidc_accounts.link.info_name") %> <%= @pending_auth["name"] %>
<% end %>What brings you here?
-Select one or more goals that you have with using <%= product_name %> as your personal finance tool.
+<%= t("onboardings.goals.title") %>
+<%= t("onboardings.goals.subtitle", product_name: product_name) %>
Let's set up your account
-First things first, let's get your profile set up.
+<%= t(".title") %>
+<%= t(".subtitle") %>
<%= t(".moniker_prompt", product_name: product_name) %>
+ + + + + + <%= family_form.text_field :name, placeholder: t(".household_name_placeholder"), label: t(".household_name"), data: { onboarding_target: "nameField" } %> +- Try Sure for 45 days + <%= t(".title") %>
- Data will be deleted then + <%= t(".data_deletion") %>
- Starting today you can give the product a good look.
If you like it, self-host or contribute to continue using it here.
+ <%= t(".description_html").html_safe %>
How things work here
+<%= t(".how_it_works") %>
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") %>
<%= t("pages.dashboard.cashflow_sankey.no_data_title") %>
<%= t("pages.dashboard.cashflow_sankey.no_data_description") %>
<%= render DS::Link.new( 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) %>Welcome!
++
Intro experience coming soon
++ We're building a richer onboarding journey to learn about your goals, milestones, and day-to-day needs. For now, head over to the chat sidebar to start a conversation with Sure and let us know where you are in your financial journey. +
+<%= 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") %> +
<%= t(".title") %>
+<%= t(".description") %>
+<%= t("recurring_transactions.title") %>
-<%= t(".invitation_message", inviter: @invitation.inviter.display_name, - role: t(".role_#{@invitation.role}")) %> + role: t(".role_#{@invitation.role}", default: @invitation.role.to_s.humanize.downcase)) %>
<%= t("reports.net_worth.assets_vs_liabilities") %>
-<%= rule.name %>
<% end %> <% if rule.conditions.any? %> + <% displayed_condition = rule.displayed_condition %> + <% additional_condition_count = rule.additional_displayable_conditions_count %>- <% if rule.conditions.first.compound? %> - <%= rule.conditions.first.sub_conditions.first.filter.label %> <%= rule.conditions.first.sub_conditions.first.operator %> <%= rule.conditions.first.sub_conditions.first.value_display %> + <% if displayed_condition.present? %> + <%= displayed_condition.filter.label %> <%= displayed_condition.operator %> <%= displayed_condition.value_display %> <% else %> - <%= rule.conditions.first.filter.label %> <%= rule.conditions.first.operator %> <%= rule.conditions.first.value_display %> + <%= t("rules.no_condition") %> <% end %> - <% if rule.conditions.count > 1 %> - and <%= rule.conditions.count - 1 %> more <%= rule.conditions.count - 1 == 1 ? "condition" : "conditions" %> + <% if additional_condition_count.positive? %> + and <%= additional_condition_count %> more <%= additional_condition_count == 1 ? "condition" : "conditions" %> <% end %>
Rules
--
+
- <%= render DS::Link.new( text: t("settings.settings_nav_link_large.previous"), @@ -102,10 +102,12 @@ nav_sections = [ <% end %> <% end %> - <%= button_to session_path(Current.session), method: :delete, class: "flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-destructive hover:bg-surface-hover w-full" do %> - <%= icon("log-out", color: "current") %> - <%= t(".logout") %> - <% end %> +
- + <%= button_to session_path(Current.session), method: :delete, class: "flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-destructive hover:bg-surface-hover w-full" do %> + <%= icon("log-out", color: "current") %> + <%= t(".logout") %> + <% end %> +
API Key Created Successfully
-Your API Key
+ <%= content_for :page_title, "Your API Key" %> + <%= content_for :page_actions do %> <%= render DS::Link.new( text: "Create New Key", href: new_settings_api_key_path(regenerate: true), variant: "secondary" ) %> -<%= t(".no_api_key.title") %>
+ <%= content_for :page_title, t(".no_api_key.title") %> + <%= content_for :page_actions do %> <%= render DS::Link.new( text: t(".no_api_key.create_api_key"), href: new_settings_api_key_path, variant: "primary" ) %> -<%= 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 %> +
LLM Usage & Costs
-Track your AI usage and estimated costs
+Track your AI usage and estimated costs
Please note, we are still working on translations for various languages.
<% end %> <% end %> diff --git a/app/views/settings/profiles/show.html.erb b/app/views/settings/profiles/show.html.erb index ccdeefb2b..c2f2dc158 100644 --- a/app/views/settings/profiles/show.html.erb +++ b/app/views/settings/profiles/show.html.erb @@ -25,101 +25,105 @@ <% end %> <% end %> -<%= settings_section title: t(".household_title"), subtitle: t(".household_subtitle") do %> -<%= Current.family.name %> · <%= Current.family.users.size %>
-<%= user.display_name %>
-<%= user.role %>
-<%= Current.family.name %> · <%= Current.family.users.size %>
<%= invitation.email %>
-<%= t(".pending") %>
-<%= t(".invitation_link") %>
- <%= accept_invitation_url(invitation.token) %> - - -<%= user.display_name %>
+<%= user.role %>
+<%= invitation.email %>
+<%= t(".pending") %>
+<%= t(".invitation_link") %>
+ <%= accept_invitation_url(invitation.token) %> + + +<%= t("indexa_capital_items.panel.setup_instructions") %>
+-
+
- <%= t("indexa_capital_items.panel.step_1") %> +
- <%= t("indexa_capital_items.panel.step_2") %> +
- <%= t("indexa_capital_items.panel.step_3") %> +
<%= error_msg %>
+<%= t("indexa_capital_items.panel.fields.api_token.label") %>
+<%= t("indexa_capital_items.panel.fields.api_token.description") %>
+ <%= form.text_field :api_token, + label: t("indexa_capital_items.panel.fields.api_token.label"), + placeholder: is_new_record ? t("indexa_capital_items.panel.fields.api_token.placeholder_new") : t("indexa_capital_items.panel.fields.api_token.placeholder_update"), + type: :password %> ++ <%= t("indexa_capital_items.panel.alternative_auth") %> +
+<%= t("indexa_capital_items.panel.status_configured_html", accounts_path: accounts_path).html_safe %>
+ <% else %> + +<%= t("indexa_capital_items.panel.status_not_configured") %>
+ <% end %> +<%= identity.provider_config&.dig(:label) || identity.provider.titleize %>
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 @@ 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) %> -<%= t("snaptrade_items.setup_accounts.linked_accounts", default: "Already Linked") %>
- <% @linked_accounts.each do |snaptrade_account| %> -<%= snaptrade_account.name %>
-- <%= t("snaptrade_items.setup_accounts.linked_to", default: "Linked to:") %> - <%= link_to snaptrade_account.current_account.name, account_path(snaptrade_account.current_account), class: "link" %> -
-<%= t(".tags") %>
- -<%= t(".exclude_title") %>
<%= t(".exclude_subtitle") %>
<%= t(".delete_title") %>
<%= t(".delete_subtitle") %>
+
+
+
<%= format_money -entry.amount_money %>
-
<%= 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 %>
+
diff --git a/app/views/transactions/_list.html.erb b/app/views/transactions/_list.html.erb
new file mode 100644
index 000000000..87eff6c4d
--- /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", turbo: false } do |f| %>
+ <%= f.hidden_field "import[type]", value: "TransactionImport" %>
+ <%= 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") %>
+
+ <%= 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/_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/_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/bulk_updates/new.html.erb b/app/views/transactions/bulk_updates/new.html.erb
index 11e3bf3ff..a22b30e9f 100644
--- a/app/views/transactions/bulk_updates/new.html.erb
+++ b/app/views/transactions/bulk_updates/new.html.erb
@@ -5,16 +5,14 @@
<%= 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 %>
<%= form.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: "Select a category", label: "Category", class: "text-subdued" } %>
<%= form.collection_select :merchant_id, Current.family.available_merchants.alphabetically, :id, :name, { prompt: "Select a merchant", label: "Merchant", class: "text-subdued" } %>
- <%= form.select :tag_ids, Current.family.tags.alphabetically.pluck(:name, :id), { include_blank: "None", multiple: true, label: "Tags" } %>
+ <%= form.select :tag_ids, Current.family.tags.alphabetically.pluck(:name, :id), { include_blank: "None", multiple: true, label: "Tags", include_hidden: false } %>
<%= form.text_area :notes, label: "Notes", placeholder: "Enter a note that will be applied to selected transactions", rows: 5 %>
<% end %>
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/app/views/transactions/show.html.erb b/app/views/transactions/show.html.erb
index 5ec582353..3dc25b148 100644
--- a/app/views/transactions/show.html.erb
+++ b/app/views/transactions/show.html.erb
@@ -1,8 +1,10 @@
-<%= render DS::Dialog.new(variant: "drawer") do |dialog| %>
- <% dialog.with_header do %>
- <%= render "transactions/header", entry: @entry %>
+<%= render DS::Dialog.new(frame: "drawer", responsive: true) do |dialog| %>
+ <% dialog.with_header(custom_header: true) do %>
+
+ <%= render "transactions/header", entry: @entry %>
+ <%= dialog.close_button %>
+
<% end %>
-
<% dialog.with_body do %>
<%# Potential duplicate alert %>
<% if @entry.transaction.has_potential_duplicate? %>
@@ -14,7 +16,6 @@
<%= t("transactions.show.potential_duplicate_title") %>
<%= t("transactions.show.potential_duplicate_description") %>
-
@@ -26,7 +27,6 @@
-
<%= button_to t("transactions.show.merge_duplicate"),
merge_duplicate_transaction_path(@entry.transaction),
@@ -44,33 +44,27 @@
<% end %>
<% end %>
-
<%= 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",
data: { controller: "auto-submit-form" } do |f| %>
-
<%= f.text_field :name,
label: t(".name_label"),
"data-auto-submit-form-target": "auto" %>
-
<%= f.date_field :date,
label: t(".date_label"),
max: Date.current,
disabled: @entry.linked?,
"data-auto-submit-form-target": "auto" %>
-
<% unless @entry.transaction.transfer? %>
<%= f.select :nature,
[["Expense", "outflow"], ["Income", "inflow"]],
{ container_class: "w-1/3", label: t(".nature"), selected: @entry.amount.negative? ? "inflow" : "outflow" },
{ data: { "auto-submit-form-target": "auto" }, disabled: @entry.linked? } %>
-
<%= f.money_field :amount, label: t(".amount"),
container_class: "w-2/3",
auto_submit: true,
@@ -79,7 +73,6 @@
disabled: @entry.linked?,
disable_currency: @entry.linked? %>
-
<%= f.fields_for :entryable do |ef| %>
<%= ef.collection_select :category_id,
Current.family.categories.alphabetically,
@@ -89,57 +82,47 @@
"data-auto-submit-form-target": "auto" %>
<% end %>
<% end %>
-
<% end %>
<% 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 } %>
-
- <%= 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,
+ <%= 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| %>
+ <%= 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)) %>
<% dialog.with_section(title: "Additional details", open: false) do %>
@@ -167,7 +150,6 @@
<% end %>
<% end %>
-
<% if details[:provider_extras].present? %>
Provider extras
@@ -187,7 +169,6 @@
<% end %>
<% end %>
-
<% dialog.with_section(title: t(".settings")) do %>
<%= styled_form_with model: @entry,
@@ -199,12 +180,10 @@
<%= t(".exclude") %>
<%= t(".exclude_description") %>
-
<%= f.toggle :excluded, { data: { auto_submit_form_target: "auto" } } %>
<% end %>
-
<% if @entry.account.investment? || @entry.account.crypto? %>
<%= styled_form_with model: @entry,
@@ -217,7 +196,6 @@
<%= t(".activity_type") %>
<%= t(".activity_type_description") %>
-
<%= ef.select :investment_activity_label,
options_for_select(
[["—", nil]] + Transaction::ACTIVITY_LABELS.map { |l| [t("transactions.activity_labels.#{l.parameterize(separator: '_')}"), l] },
@@ -231,7 +209,6 @@
<% end %>
<% end %>
-
<%= styled_form_with model: @entry,
url: transaction_path(@entry),
@@ -243,7 +220,6 @@
<%= t(".one_time_title", type: @entry.amount.negative? ? t("transactions.form.income") : t("transactions.form.expense")) %>
<%= t(".one_time_description") %>
-
<%= ef.toggle :kind, {
checked: @entry.transaction.one_time?,
data: { auto_submit_form_target: "auto" }
@@ -251,13 +227,11 @@
<% end %>
<% end %>
-
Transfer or Debt Payment?
Transfers and payments are special types of transactions that indicate money movement between 2 accounts.
-
<%= render DS::Link.new(
text: "Open matcher",
icon: "arrow-left-right",
@@ -266,7 +240,6 @@
frame: :modal
) %>
-
<% if @entry.account.investment? && @entry.entryable.is_a?(Transaction) && !@entry.excluded? %>
@@ -274,7 +247,6 @@
Convert to Security Trade
Convert this transaction into a security trade (buy/sell) by providing ticker, shares, and price.
-
<%= render DS::Button.new(
text: "Convert",
variant: "outline",
@@ -285,14 +257,12 @@
) %>
<% end %>
-
<%= t(".mark_recurring_title") %>
<%= t(".mark_recurring_subtitle") %>
-
<%= render DS::Button.new(
text: t(".mark_recurring"),
variant: "outline",
@@ -302,14 +272,12 @@
frame: "_top"
) %>
-
<%= t(".delete_title") %>
<%= t(".delete_subtitle") %>
-
<%= render DS::Button.new(
text: t(".delete"),
variant: "outline-destructive",
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..5a369a2c6 100644
--- a/app/views/transfers/show.html.erb
+++ b/app/views/transfers/show.html.erb
@@ -1,26 +1,23 @@
-<%= render DS::Dialog.new(variant: "drawer") do |dialog| %>
- <% dialog.with_header do %>
-
-
-
+<%= render DS::Dialog.new(frame: "drawer", responsive: true) do |dialog| %>
+ <% dialog.with_header(custom_header: true) do %>
+
+
+
<%= format_money @transfer.amount_abs %>
-
<%= @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 %>
-
+ <%= dialog.close_button %>
<% end %>
-
<% dialog.with_body do %>
<% dialog.with_section(title: t(".overview"), open: true) do %>
@@ -32,20 +29,16 @@
<%= link_to @transfer.from_account.name, account_path(@transfer.from_account), data: { turbo_frame: "_top" } %>
-
- Date
- <%= l(@transfer.outflow_transaction.entry.date, format: :long) %>
-
- Amount
- <%= format_money @transfer.outflow_transaction.entry.amount_money * -1 %>
-
<%= render "shared/ruler", classes: "my-2" %>
-
- To
@@ -54,12 +47,10 @@
<%= link_to @transfer.to_account.name, account_path(@transfer.to_account), data: { turbo_frame: "_top" } %>
-
- Date
- <%= l(@transfer.inflow_transaction.entry.date, format: :long) %>
-
- Amount
- +<%= format_money @transfer.inflow_transaction.entry.amount_money * -1 %>
@@ -67,14 +58,12 @@
<% end %>
-
<% dialog.with_section(title: t(".details")) do %>
<%= styled_form_with model: @transfer,
data: { controller: "auto-submit-form" }, class: "space-y-2" do |f| %>
<% if @transfer.categorizable? %>
<%= f.collection_select :category_id, @categories.alphabetically, :id, :name, { label: "Category", include_blank: "Uncategorized", selected: @transfer.outflow_transaction.category&.id }, "data-auto-submit-form-target": "auto" %>
<% end %>
-
<%= f.text_area :notes,
label: t(".note_label"),
placeholder: t(".note_placeholder"),
@@ -82,7 +71,6 @@
"data-auto-submit-form-target": "auto" %>
<% end %>
<% end %>
-
<% dialog.with_section(title: t(".settings")) do %>
@@ -90,7 +78,6 @@
<%= t(".delete_title") %>
<%= t(".delete_subtitle") %>
-
<%= button_to t(".delete"),
transfer_path(@transfer),
method: :delete,
diff --git a/app/views/users/_user_menu.html.erb b/app/views/users/_user_menu.html.erb
index a37d0f274..b8889c8c6 100644
--- a/app/views/users/_user_menu.html.erb
+++ b/app/views/users/_user_menu.html.erb
@@ -1,7 +1,20 @@
-<%# locals: (user:, placement: "right-start", offset: 16) %>
+<%# locals: (user:, placement: "right-start", offset: 16, intro_mode: false) %>
+
+<% intro_mode = local_assigns.fetch(:intro_mode, false) %>
- <%= render DS::Menu.new(variant: "avatar", avatar_url: user.profile_image&.variant(:small)&.url, initials: user.initials, placement: placement, offset: offset) do |menu| %>
+ <%= render DS::Menu.new(
+ variant: "avatar",
+ avatar_url: user.profile_image&.variant(:small)&.url,
+ initials: user.initials,
+ placement: placement,
+ offset: offset
+ ) do |menu| %>
+ <% if intro_mode %>
+ <% menu.with_button do %>
+ <%= render DS::Button.new(variant: "icon", icon: "settings", data: { DS__menu_target: "button" }) %>
+ <% end %>
+ <% end %>
<%= menu.with_header do %>
@@ -30,10 +43,15 @@
<% end %>
<% end %>
- <% menu.with_item(variant: "link", text: "Settings", icon: "settings", href: accounts_path(return_to: request.fullpath)) %>
+ <% menu.with_item(
+ variant: "link",
+ text: "Settings",
+ icon: "settings",
+ href: intro_mode ? settings_profile_path : accounts_path(return_to: request.fullpath)
+ ) %>
<% menu.with_item(variant: "link", text: "Changelog", icon: "box", href: changelog_path) %>
- <% if self_hosted? %>
+ <% if self_hosted? && !intro_mode %>
<% menu.with_item(variant: "link", text: "Feedback", icon: "megaphone", href: feedback_path) %>
<% end %>
<% menu.with_item(variant: "link", text: "Contact", icon: "message-square-more", href: "https://discord.gg/36ZGBsxYEK") %>
diff --git a/app/views/valuations/show.html.erb b/app/views/valuations/show.html.erb
index 089d98a43..1cfaa09f9 100644
--- a/app/views/valuations/show.html.erb
+++ b/app/views/valuations/show.html.erb
@@ -1,17 +1,17 @@
<% entry, account = @entry, @entry.account %>
-
-<%= render DS::Dialog.new(variant: "drawer") do |dialog| %>
- <% dialog.with_header do %>
- <%= render "valuations/header", entry: @entry %>
+<%= render DS::Dialog.new(frame: "drawer", responsive: true) do |dialog| %>
+ <% dialog.with_header(custom_header: true) do %>
+
+ <%= render "valuations/header", entry: @entry %>
+ <%= dialog.close_button %>
+
<% end %>
-
<% dialog.with_body do %>
<% if @error_message.present? %>
<%= render DS::Alert.new(message: @error_message, variant: :error) %>
<% end %>
-
<% dialog.with_section(title: t(".overview"), open: true) do %>
<%= styled_form_with model: entry,
@@ -22,11 +22,9 @@
<%= f.date_field :date,
label: t(".date_label"),
max: Date.current %>
-
<%= f.money_field :amount,
label: "Account value on date",
disable_currency: true %>
-
<%= render DS::Button.new(
text: "Update value",
@@ -37,7 +35,6 @@
<% end %>
<% end %>
-
<% dialog.with_section(title: t(".details")) do %>
<%= styled_form_with model: entry,
@@ -53,7 +50,6 @@
<% end %>
<% end %>
-
<% dialog.with_section(title: t(".settings")) do %>
@@ -62,7 +58,6 @@
<%= t(".delete_title") %>
<%= t(".delete_subtitle") %>
-
<%= button_to t(".delete"),
entry_path(entry),
method: :delete,
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")
diff --git a/charts/sure/Chart.yaml b/charts/sure/Chart.yaml
index 216c26ab8..77364b422 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.10
+appVersion: "0.6.8-alpha.10"
kubeVersion: ">=1.25.0-0"
diff --git a/config/application.rb b/config/application.rb
index d0ef1361f..3a63072b2 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -42,6 +42,9 @@ module Sure
# Enable Rack::Attack middleware for API rate limiting
config.middleware.use Rack::Attack
+ config.x.ui = ActiveSupport::OrderedOptions.new
+ default_layout = ENV.fetch("DEFAULT_UI_LAYOUT", "dashboard")
+ config.x.ui.default_layout = default_layout.in?(%w[dashboard intro]) ? default_layout : "dashboard"
# Handle OmniAuth/OIDC errors gracefully (must be before OmniAuth middleware)
require_relative "../app/middleware/omniauth_error_handler"
config.middleware.use OmniauthErrorHandler
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/brakeman.ignore b/config/brakeman.ignore
index a03fb993b..ca044eb6c 100644
--- a/config/brakeman.ignore
+++ b/config/brakeman.ignore
@@ -69,6 +69,29 @@
],
"note": "account_id is properly validated in create action - line 79 ensures account belongs to user's family: family.accounts.find(transaction_params[:account_id])"
},
+ {
+ "warning_type": "Mass Assignment",
+ "warning_code": 105,
+ "fingerprint": "d770e95392c6c69b364dcc0c99faa1c8f4f0cceb085bcc55630213d0b7b8b87f",
+ "check_name": "PermitAttributes",
+ "message": "Potentially dangerous key allowed for mass assignment",
+ "file": "app/controllers/api/v1/trades_controller.rb",
+ "line": 165,
+ "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/",
+ "code": "params.require(:trade).permit(:account_id, :date, :qty, :price, :currency, :security_id, :ticker, :manual_ticker, :investment_activity_label, :category_id)",
+ "render_path": null,
+ "location": {
+ "type": "method",
+ "class": "Api::V1::TradesController",
+ "method": "trade_params"
+ },
+ "user_input": ":account_id",
+ "confidence": "High",
+ "cwe_id": [
+ 915
+ ],
+ "note": "account_id and security_id validated in create/update: account via family.accounts.find and supports_trades?, security via resolve_security"
+ },
{
"warning_type": "Mass Assignment",
"warning_code": 105,
diff --git a/config/initializers/00_ssl.rb b/config/initializers/00_ssl.rb
new file mode 100644
index 000000000..acebcfec7
--- /dev/null
+++ b/config/initializers/00_ssl.rb
@@ -0,0 +1,267 @@
+# frozen_string_literal: true
+
+require "openssl"
+require "fileutils"
+
+# Centralized SSL/TLS configuration for outbound HTTPS connections.
+#
+# This enables support for self-signed certificates in self-hosted environments
+# where servers use internal CAs or self-signed certificates.
+#
+# Environment Variables:
+# SSL_CA_FILE - Path to custom CA certificate file (PEM format)
+# SSL_VERIFY - Set to "false" to disable SSL verification (NOT RECOMMENDED for production)
+# SSL_DEBUG - Set to "true" to enable verbose SSL logging
+#
+# Example usage in docker-compose.yml:
+# environment:
+# SSL_CA_FILE: /certs/my-ca.crt
+# volumes:
+# - ./my-ca.crt:/certs/my-ca.crt:ro
+#
+# Security Warning:
+# Disabling SSL verification (SSL_VERIFY=false) removes protection against
+# man-in-the-middle attacks. Only use this for development/testing environments.
+#
+# IMPORTANT: When a valid SSL_CA_FILE is provided, this initializer sets the
+# SSL_CERT_FILE environment variable to a combined CA bundle (system CAs + custom CA).
+# This is a *global* side-effect that affects ALL SSL connections in the Ruby process,
+# including gems that do not go through SslConfigurable (e.g. openid_connect).
+# This is intentional — it ensures OIDC discovery, webhook callbacks, and any other
+# outbound HTTPS connection trusts both public CAs and the user's custom CA.
+
+# Path for the combined CA bundle file (predictable location for debugging)
+COMBINED_CA_BUNDLE_PATH = Rails.root.join("tmp", "ssl_ca_bundle.pem").freeze
+
+# Boot-time helper for SSL certificate validation and bundle creation.
+#
+# This is intentionally a standalone module (not nested under SslConfigurable)
+# because SslConfigurable is autoloaded by Zeitwerk from app/models/concerns/.
+# Reopening that module here at boot time would register the constant before
+# Zeitwerk, preventing the real concern (with httparty_ssl_options, etc.) from
+# ever being loaded — causing NameError at class load time in providers.
+module SslInitializerHelper
+ module_function
+
+ # PEM certificate format markers (X.509 standard)
+ PEM_CERT_BEGIN = "-----BEGIN CERTIFICATE-----"
+ PEM_CERT_END = "-----END CERTIFICATE-----"
+
+ # Validates a CA certificate file.
+ # Supports single certs and multi-cert PEM bundles (CA chains).
+ #
+ # @param path [String] Path to the CA certificate file
+ # @return [Hash] Validation result with :path, :valid, and :error keys
+ def validate_ca_certificate_file(path)
+ result = { path: nil, valid: false, error: nil }
+
+ unless File.exist?(path)
+ result[:error] = "File not found: #{path}"
+ Rails.logger.warn("[SSL] SSL_CA_FILE specified but file not found: #{path}")
+ return result
+ end
+
+ unless File.readable?(path)
+ result[:error] = "File not readable: #{path}"
+ Rails.logger.warn("[SSL] SSL_CA_FILE specified but file not readable: #{path}")
+ return result
+ end
+
+ unless File.file?(path)
+ result[:error] = "Path is not a file: #{path}"
+ Rails.logger.warn("[SSL] SSL_CA_FILE specified but is not a file: #{path}")
+ return result
+ end
+
+ # Sanity check file size (CA certs should be < 1MB)
+ file_size = File.size(path)
+ if file_size > 1_000_000
+ result[:error] = "File too large (#{file_size} bytes) - expected a PEM certificate"
+ Rails.logger.warn("[SSL] SSL_CA_FILE is unexpectedly large: #{path} (#{file_size} bytes)")
+ return result
+ end
+
+ # Validate PEM format using standard X.509 markers
+ content = File.read(path)
+ unless content.include?(PEM_CERT_BEGIN)
+ result[:error] = "Invalid PEM format - missing BEGIN CERTIFICATE marker"
+ Rails.logger.warn("[SSL] SSL_CA_FILE does not appear to be a valid PEM certificate: #{path}")
+ return result
+ end
+
+ unless content.include?(PEM_CERT_END)
+ result[:error] = "Invalid PEM format - missing END CERTIFICATE marker"
+ Rails.logger.warn("[SSL] SSL_CA_FILE has incomplete PEM format: #{path}")
+ return result
+ end
+
+ # Parse and validate every certificate in the PEM file.
+ # OpenSSL::X509::Certificate.new only parses the first PEM block,
+ # so multi-cert bundles (CA chains) need per-block validation.
+ begin
+ pem_blocks = content.scan(/#{PEM_CERT_BEGIN}[\s\S]+?#{PEM_CERT_END}/)
+ raise OpenSSL::X509::CertificateError, "No certificates found in PEM file" if pem_blocks.empty?
+
+ pem_blocks.each_with_index do |pem, index|
+ OpenSSL::X509::Certificate.new(pem)
+ rescue OpenSSL::X509::CertificateError => e
+ raise OpenSSL::X509::CertificateError, "Certificate #{index + 1} of #{pem_blocks.size} is invalid: #{e.message}"
+ end
+
+ result[:path] = path
+ result[:valid] = true
+ rescue OpenSSL::X509::CertificateError => e
+ result[:error] = "Invalid certificate: #{e.message}"
+ Rails.logger.warn("[SSL] SSL_CA_FILE contains invalid certificate: #{e.message}")
+ end
+
+ result
+ end
+
+ # Finds the system CA certificate bundle path using OpenSSL's detection
+ #
+ # @return [String, nil] Path to system CA bundle or nil if not found
+ def find_system_ca_bundle
+ # First, check if SSL_CERT_FILE is already set (user may have their own bundle)
+ existing_cert_file = ENV["SSL_CERT_FILE"]
+ if existing_cert_file.present? && File.exist?(existing_cert_file) && File.readable?(existing_cert_file)
+ return existing_cert_file
+ end
+
+ # Use OpenSSL's built-in CA file detection
+ openssl_ca_file = OpenSSL::X509::DEFAULT_CERT_FILE
+ if openssl_ca_file.present? && File.exist?(openssl_ca_file) && File.readable?(openssl_ca_file)
+ return openssl_ca_file
+ end
+
+ # Use OpenSSL's default certificate directory as fallback
+ openssl_ca_dir = OpenSSL::X509::DEFAULT_CERT_DIR
+ if openssl_ca_dir.present? && Dir.exist?(openssl_ca_dir)
+ # Look for common bundle file names in the certificate directory
+ %w[ca-certificates.crt ca-bundle.crt cert.pem].each do |bundle_name|
+ bundle_path = File.join(openssl_ca_dir, bundle_name)
+ return bundle_path if File.exist?(bundle_path) && File.readable?(bundle_path)
+ end
+ end
+
+ nil
+ end
+
+ # Creates a combined CA bundle with system CAs and custom CA.
+ # Writes to a predictable path (tmp/ssl_ca_bundle.pem) for easy debugging
+ # and to avoid Tempfile GC lifecycle issues.
+ #
+ # @param custom_ca_path [String] Path to the custom CA certificate
+ # @param output_path [String] Where to write the combined bundle
+ # @return [String, nil] Path to the combined bundle, or nil on failure
+ def create_combined_ca_bundle(custom_ca_path, output_path: COMBINED_CA_BUNDLE_PATH)
+ system_ca = find_system_ca_bundle
+ unless system_ca
+ Rails.logger.warn("[SSL] Could not find system CA bundle - using custom CA only")
+ return nil
+ end
+
+ begin
+ system_content = File.read(system_ca)
+ custom_content = File.read(custom_ca_path)
+
+ # Ensure the parent directory exists
+ FileUtils.mkdir_p(File.dirname(output_path))
+
+ File.write(output_path, system_content + "\n# Custom CA Certificate\n" + custom_content)
+
+ Rails.logger.info("[SSL] Created combined CA bundle: #{output_path}")
+ Rails.logger.info("[SSL] - System CA source: #{system_ca}")
+ Rails.logger.info("[SSL] - Custom CA source: #{custom_ca_path}")
+
+ output_path.to_s
+ rescue StandardError => e
+ Rails.logger.error("[SSL] Failed to create combined CA bundle: #{e.message}")
+ nil
+ end
+ end
+
+ # Logs SSL configuration summary at startup
+ #
+ # @param ssl_config [ActiveSupport::OrderedOptions] SSL configuration
+ def log_ssl_configuration(ssl_config)
+ if ssl_config.debug
+ Rails.logger.info("[SSL] Debug mode enabled - verbose SSL logging active")
+ end
+
+ if ssl_config.ca_file.present?
+ if ssl_config.ca_file_valid
+ Rails.logger.info("[SSL] Custom CA certificate configured and validated: #{ssl_config.ca_file}")
+ else
+ Rails.logger.error("[SSL] Custom CA certificate configured but invalid: #{ssl_config.ca_file_error}")
+ end
+ end
+
+ unless ssl_config.verify
+ Rails.logger.warn("[SSL] " + "=" * 60)
+ Rails.logger.warn("[SSL] WARNING: SSL verification is DISABLED")
+ Rails.logger.warn("[SSL] This is insecure and should only be used for development/testing")
+ Rails.logger.warn("[SSL] Set SSL_VERIFY=true or remove the variable for production")
+ Rails.logger.warn("[SSL] " + "=" * 60)
+ end
+
+ if ssl_config.debug
+ Rails.logger.info("[SSL] Configuration summary:")
+ Rails.logger.info("[SSL] - SSL verification: #{ssl_config.verify ? 'ENABLED' : 'DISABLED'}")
+ Rails.logger.info("[SSL] - Custom CA file: #{ssl_config.ca_file || 'not configured'}")
+ Rails.logger.info("[SSL] - CA file valid: #{ssl_config.ca_file_valid}")
+ Rails.logger.info("[SSL] - Combined CA bundle: #{ssl_config.combined_ca_bundle || 'not created'}")
+ Rails.logger.info("[SSL] - SSL_CERT_FILE: #{ENV['SSL_CERT_FILE'] || 'not set'}")
+ end
+ end
+end
+
+# Configure SSL settings
+Rails.application.configure do
+ config.x.ssl ||= ActiveSupport::OrderedOptions.new
+
+ truthy_values = %w[1 true yes on].freeze
+ falsy_values = %w[0 false no off].freeze
+
+ # Debug mode for verbose SSL logging
+ debug_env = ENV["SSL_DEBUG"].to_s.strip.downcase
+ config.x.ssl.debug = truthy_values.include?(debug_env)
+
+ # SSL verification (default: true)
+ verify_env = ENV["SSL_VERIFY"].to_s.strip.downcase
+ config.x.ssl.verify = !falsy_values.include?(verify_env)
+
+ # Custom CA certificate file for trusting self-signed certificates
+ ca_file = ENV["SSL_CA_FILE"].presence
+ config.x.ssl.ca_file = nil
+ config.x.ssl.ca_file_valid = false
+
+ if ca_file
+ ca_file_status = SslInitializerHelper.validate_ca_certificate_file(ca_file)
+ config.x.ssl.ca_file = ca_file_status[:path]
+ config.x.ssl.ca_file_valid = ca_file_status[:valid]
+ config.x.ssl.ca_file_error = ca_file_status[:error]
+
+ # Create combined CA bundle and set SSL_CERT_FILE for global SSL configuration.
+ #
+ # This sets ENV["SSL_CERT_FILE"] globally so that ALL Ruby SSL connections
+ # (including gems like openid_connect that bypass SslConfigurable) will trust
+ # both system CAs (for public services) and the custom CA (for self-signed services).
+ if ca_file_status[:valid]
+ combined_path = SslInitializerHelper.create_combined_ca_bundle(ca_file_status[:path])
+ if combined_path
+ config.x.ssl.combined_ca_bundle = combined_path
+ ENV["SSL_CERT_FILE"] = combined_path
+ Rails.logger.info("[SSL] Set SSL_CERT_FILE=#{combined_path} for global SSL configuration")
+ else
+ # Fallback: just use the custom CA (may break connections to public services)
+ Rails.logger.warn("[SSL] Could not create combined CA bundle, using custom CA only. " \
+ "Connections to public services (not using your custom CA) may fail.")
+ ENV["SSL_CERT_FILE"] = ca_file_status[:path]
+ end
+ end
+ end
+
+ # Log configuration summary at startup
+ SslInitializerHelper.log_ssl_configuration(config.x.ssl)
+end
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/config/initializers/flipper.rb b/config/initializers/flipper.rb
deleted file mode 100644
index 6ed3abe4a..000000000
--- a/config/initializers/flipper.rb
+++ /dev/null
@@ -1,45 +0,0 @@
-# frozen_string_literal: true
-
-require "flipper"
-require "flipper/adapters/active_record"
-require "flipper/adapters/memory"
-
-# Configure Flipper with ActiveRecord adapter for database-backed feature flags
-# Falls back to memory adapter if tables don't exist yet (during migrations)
-Flipper.configure do |config|
- config.adapter do
- begin
- Flipper::Adapters::ActiveRecord.new
- rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid, NameError
- # Tables don't exist yet, use memory adapter as fallback
- Flipper::Adapters::Memory.new
- end
- end
-end
-
-# Initialize feature flags IMMEDIATELY (not in after_initialize)
-# This must happen before OmniAuth initializer runs
-unless Rails.env.test?
- begin
- # Feature flag to control SSO provider source (YAML vs DB)
- # ENV: AUTH_PROVIDERS_SOURCE=db|yaml
- # Default: "db" for self-hosted, "yaml" for managed
- auth_source = ENV.fetch("AUTH_PROVIDERS_SOURCE") do
- Rails.configuration.app_mode.self_hosted? ? "db" : "yaml"
- end.downcase
-
- # Ensure feature exists before enabling/disabling
- Flipper.add(:db_sso_providers) unless Flipper.exist?(:db_sso_providers)
-
- if auth_source == "db"
- Flipper.enable(:db_sso_providers)
- else
- Flipper.disable(:db_sso_providers)
- end
- rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid
- # Database not ready yet (e.g., during initial setup or migrations)
- # This is expected during db:create or initial setup
- rescue StandardError => e
- Rails.logger.warn("[Flipper] Error initializing feature flags: #{e.message}")
- end
-end
diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb
index 1b4d301b3..b72785933 100644
--- a/config/initializers/omniauth.rb
+++ b/config/initializers/omniauth.rb
@@ -77,7 +77,14 @@ Rails.application.config.middleware.use OmniAuth::Builder do
client_options: {
identifier: client_id,
secret: client_secret,
- redirect_uri: redirect_uri
+ redirect_uri: redirect_uri,
+ ssl: begin
+ ssl_config = Rails.configuration.x.ssl
+ ssl_opts = {}
+ ssl_opts[:ca_file] = ssl_config.ca_file if ssl_config&.ca_file.present?
+ ssl_opts[:verify] = false if ssl_config&.verify == false
+ ssl_opts
+ end
}
}
@@ -179,8 +186,8 @@ Rails.application.config.middleware.use OmniAuth::Builder do
Rails.configuration.x.auth.sso_providers << cfg.merge(name: name, strategy: "saml")
end
end
-end
-if Rails.configuration.x.auth.sso_providers.empty?
- Rails.logger.warn("No SSO providers enabled; check auth.yml / ENV configuration or database providers")
+ if Rails.configuration.x.auth.sso_providers.empty?
+ Rails.logger.warn("No SSO providers enabled; check auth.yml / ENV configuration or database providers")
+ end
end
diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb
index c7b3ac0c2..4a16a69bb 100644
--- a/config/initializers/rack_attack.rb
+++ b/config/initializers/rack_attack.rb
@@ -1,8 +1,9 @@
# frozen_string_literal: true
class Rack::Attack
- # Enable Rack::Attack
+ # Enable Rack::Attack only in production and staging (disable in test/development to avoid rate-limit flakiness)
enabled = Rails.env.production? || Rails.env.staging?
+ self.enabled = enabled
# Throttle requests to the OAuth token endpoint
throttle("oauth/token", limit: 10, period: 1.minute) do |request|
diff --git a/config/initializers/version.rb b/config/initializers/version.rb
index 9e1bbf18a..1a1714760 100644
--- a/config/initializers/version.rb
+++ b/config/initializers/version.rb
@@ -10,11 +10,13 @@ module Sure
else
`git rev-parse HEAD`.chomp
end
+ rescue Errno::ENOENT
+ nil
end
private
def semver
- "0.6.8-alpha.1"
+ "0.6.8-alpha.10"
end
end
end
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..401b03efc
--- /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_name}"
diff --git a/config/locales/models/account/en.yml b/config/locales/models/account/en.yml
index 4b199653c..e4c882b9c 100644
--- a/config/locales/models/account/en.yml
+++ b/config/locales/models/account/en.yml
@@ -5,8 +5,8 @@ en:
account:
balance: Balance
currency: Currency
- family: Family
- family_id: Family
+ family: "%{moniker}"
+ family_id: "%{moniker}"
name: Name
subtype: Subtype
models:
diff --git a/config/locales/models/user/en.yml b/config/locales/models/user/en.yml
index 9b66de68d..c94bbad77 100644
--- a/config/locales/models/user/en.yml
+++ b/config/locales/models/user/en.yml
@@ -4,8 +4,8 @@ en:
attributes:
user:
email: Email
- family: Family
- family_id: Family
+ family: "%{moniker}"
+ family_id: "%{moniker}"
first_name: First Name
last_name: Last Name
password: Password
diff --git a/config/locales/views/admin/sso_providers/en.yml b/config/locales/views/admin/sso_providers/en.yml
index ff26989aa..9957e56b8 100644
--- a/config/locales/views/admin/sso_providers/en.yml
+++ b/config/locales/views/admin/sso_providers/en.yml
@@ -76,6 +76,7 @@ en:
provisioning_title: "User Provisioning"
default_role_label: "Default Role for New Users"
default_role_help: "Role assigned to users created via just-in-time (JIT) SSO account provisioning. Defaults to Member."
+ role_guest: "Guest"
role_member: "Member"
role_admin: "Admin"
role_super_admin: "Super Admin"
@@ -83,6 +84,7 @@ en:
role_mapping_help: "Map IdP groups/claims to application roles. Users are assigned the highest matching role. Leave blank to use the default role above."
super_admin_groups: "Super Admin Groups"
admin_groups: "Admin Groups"
+ guest_groups: "Guest Groups"
member_groups: "Member Groups"
groups_help: "Comma-separated list of IdP group names. Use * to match all groups."
advanced_title: "Advanced OIDC Settings"
diff --git a/config/locales/views/admin/users/en.yml b/config/locales/views/admin/users/en.yml
index 6e77b7011..13af72c16 100644
--- a/config/locales/views/admin/users/en.yml
+++ b/config/locales/views/admin/users/en.yml
@@ -10,10 +10,12 @@ en:
no_users: "No users found."
role_descriptions_title: "Role Descriptions"
roles:
+ guest: "Guest"
member: "Member"
admin: "Admin"
super_admin: "Super Admin"
role_descriptions:
+ guest: "Assistant-first experience with intentionally restricted permissions for intro workflows."
member: "Basic user access. Can manage their own accounts, transactions, and settings."
admin: "Family administrator. Can access advanced settings like API keys, imports, and AI prompts."
super_admin: "Instance administrator. Can manage SSO providers, user roles, and impersonate users for support."
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/imports/en.yml b/config/locales/views/imports/en.yml
index cd8fb8bd6..6be06ff26 100644
--- a/config/locales/views/imports/en.yml
+++ b/config/locales/views/imports/en.yml
@@ -102,12 +102,57 @@ en:
import_portfolio: Import investments
import_rules: Import rules
import_transactions: Import transactions
+ import_file: Import document
+ import_file_description: AI-powered analysis for PDFs and searchable upload for other supported files
+ requires_account: Import accounts first to unlock this option.
resume: Resume %{type}
sources: Sources
- title: New CSV Import
+ title: New 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.
+ document_too_large: Document file is too large. Maximum size is %{max_size}MB.
+ invalid_document_file_type: Invalid document file type for the active vector store.
+ document_uploaded: Document uploaded successfully.
+ document_upload_failed: We couldn't upload the document to the vector store. Please try again.
+ document_provider_not_configured: No vector store is configured for document uploads.
+ 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/indexa_capital_items/en.yml b/config/locales/views/indexa_capital_items/en.yml
new file mode 100644
index 000000000..a3a448b7c
--- /dev/null
+++ b/config/locales/views/indexa_capital_items/en.yml
@@ -0,0 +1,254 @@
+---
+en:
+ indexa_capital_items:
+ # Model method strings (i18n for item_model.rb)
+ sync_status:
+ no_accounts: "No accounts found"
+ synced:
+ one: "%{count} account synced"
+ other: "%{count} accounts synced"
+ synced_with_setup: "%{linked} synced, %{unlinked} need setup"
+ institution_summary:
+ none: "No institutions connected"
+ count:
+ one: "%{count} institution"
+ other: "%{count} institutions"
+ errors:
+ provider_not_configured: "IndexaCapital provider is not configured"
+
+ # Syncer status messages
+ sync:
+ status:
+ importing: "Importing accounts from IndexaCapital..."
+ processing: "Processing holdings and activities..."
+ calculating: "Calculating balances..."
+ importing_data: "Importing account data..."
+ checking_setup: "Checking account configuration..."
+ needs_setup: "%{count} accounts need setup..."
+ success: "Sync started"
+
+ # Panel (settings view)
+ panel:
+ setup_instructions: "Setup instructions:"
+ step_1: "Visit your Indexa Capital dashboard to generate a read-only API token"
+ step_2: "Paste your API token below and click Save"
+ step_3: "After a successful connection, go to the Accounts tab to set up new accounts"
+ field_descriptions: "Field descriptions:"
+ optional: "(Optional)"
+ required: "(required)"
+ optional_with_default: "(optional, defaults to %{default_value})"
+ alternative_auth: "Or use username/password authentication instead..."
+ save_button: "Save Configuration"
+ update_button: "Update Configuration"
+ status_configured_html: "Configured and ready to use. Visit the Accounts tab to manage and set up accounts."
+ status_not_configured: "Not configured"
+ fields:
+ api_token:
+ label: "API Token"
+ description: "Your read-only API token from Indexa Capital dashboard"
+ placeholder_new: "Paste your API token here"
+ placeholder_update: "Enter new API token to update"
+ username:
+ label: "Username"
+ description: "Your Indexa Capital username/email"
+ placeholder_new: "Paste username here"
+ placeholder_update: "Enter new username to update"
+ document:
+ label: "Document ID"
+ description: "Your Indexa Capital document/ID"
+ placeholder_new: "Paste document ID here"
+ placeholder_update: "Enter new document ID to update"
+ password:
+ label: "Password"
+ description: "Your Indexa Capital password"
+ placeholder_new: "Paste password here"
+ placeholder_update: "Enter new password to update"
+
+ # CRUD success messages
+ create:
+ success: "IndexaCapital connection created successfully"
+ update:
+ success: "IndexaCapital connection updated"
+ destroy:
+ success: "IndexaCapital connection removed"
+ index:
+ title: "IndexaCapital Connections"
+
+ # Loading states
+ loading:
+ loading_message: "Loading IndexaCapital accounts..."
+ loading_title: "Loading"
+
+ # Account linking
+ link_accounts:
+ all_already_linked:
+ one: "The selected account (%{names}) is already linked"
+ other: "All %{count} selected accounts are already linked: %{names}"
+ api_error: "API error: %{message}"
+ invalid_account_names:
+ one: "Cannot link account with blank name"
+ other: "Cannot link %{count} accounts with blank names"
+ link_failed: "Failed to link accounts"
+ no_accounts_selected: "Please select at least one account"
+ no_api_key: "IndexaCapital credentials not found. Please configure them in Provider Settings."
+ partial_invalid: "Successfully linked %{created_count} account(s), %{already_linked_count} were already linked, %{invalid_count} account(s) had invalid names"
+ partial_success: "Successfully linked %{created_count} account(s). %{already_linked_count} account(s) were already linked: %{already_linked_names}"
+ success:
+ one: "Successfully linked %{count} account"
+ other: "Successfully linked %{count} accounts"
+
+ # Provider item display (used in _item partial)
+ indexa_capital_item:
+ accounts_need_setup: "Accounts need setup"
+ delete: "Delete connection"
+ deletion_in_progress: "deletion in progress..."
+ error: "Error"
+ more_accounts_available:
+ one: "%{count} more account available"
+ other: "%{count} more accounts available"
+ no_accounts_description: "This connection has no linked accounts yet."
+ no_accounts_title: "No accounts"
+ provider_name: "IndexaCapital"
+ requires_update: "Connection needs update"
+ setup_action: "Set Up New Accounts"
+ setup_description: "%{linked} of %{total} accounts linked. Choose account types for your newly imported IndexaCapital accounts."
+ setup_needed: "New accounts ready to set up"
+ status: "Synced %{timestamp} ago — %{summary}"
+ status_never: "Never synced"
+ syncing: "Syncing..."
+ total: "Total"
+ unlinked: "Unlinked"
+ update_credentials: "Update credentials"
+
+ # Select accounts view
+ select_accounts:
+ accounts_selected: "accounts selected"
+ api_error: "API error: %{message}"
+ cancel: "Cancel"
+ configure_name_in_provider: "Cannot import - please configure account name in IndexaCapital"
+ description: "Select the accounts you want to link to your %{product_name} account."
+ link_accounts: "Link selected accounts"
+ no_accounts_found: "No accounts found. Please check your IndexaCapital credentials."
+ no_api_key: "IndexaCapital credentials are not configured. Please configure them in Settings."
+ no_credentials_configured: "Please configure your IndexaCapital credentials first in Provider Settings."
+ no_name_placeholder: "(No name)"
+ title: "Select IndexaCapital Accounts"
+
+ # Select existing account view
+ select_existing_account:
+ account_already_linked: "This account is already linked to a provider"
+ all_accounts_already_linked: "All IndexaCapital accounts are already linked"
+ api_error: "API error: %{message}"
+ balance_label: "Balance:"
+ cancel: "Cancel"
+ cancel_button: "Cancel"
+ configure_name_in_provider: "Cannot import - please configure account name in IndexaCapital"
+ connect_hint: "Connect a IndexaCapital account to enable automatic syncing."
+ description: "Select a IndexaCapital account to link with this account. Transactions will be synced and deduplicated automatically."
+ header: "Link with IndexaCapital"
+ link_account: "Link account"
+ link_button: "Link this account"
+ linking_to: "Linking to:"
+ no_account_specified: "No account specified"
+ no_accounts: "No unlinked IndexaCapital accounts found."
+ no_accounts_found: "No IndexaCapital accounts found. Please check your credentials."
+ no_api_key: "IndexaCapital credentials are not configured. Please configure them in Settings."
+ no_credentials_configured: "Please configure your IndexaCapital credentials first in Provider Settings."
+ no_name_placeholder: "(No name)"
+ settings_link: "Go to Provider Settings"
+ subtitle: "Choose a IndexaCapital account"
+ title: "Link %{account_name} with IndexaCapital"
+
+ # Link existing account
+ link_existing_account:
+ account_already_linked: "This account is already linked to a provider"
+ api_error: "API error: %{message}"
+ invalid_account_name: "Cannot link account with blank name"
+ provider_account_already_linked: "This IndexaCapital account is already linked to another account"
+ provider_account_not_found: "IndexaCapital account not found"
+ missing_parameters: "Missing required parameters"
+ no_api_key: "IndexaCapital credentials not found. Please configure them in Provider Settings."
+ success: "Successfully linked %{account_name} with IndexaCapital"
+
+ # Setup accounts wizard
+ setup_accounts:
+ account_type_label: "Account Type:"
+ accounts_count:
+ one: "%{count} account available"
+ other: "%{count} accounts available"
+ all_accounts_linked: "All your IndexaCapital accounts have already been set up."
+ api_error: "API error: %{message}"
+ creating: "Creating accounts..."
+ fetch_failed: "Failed to Fetch Accounts"
+ import_selected: "Import selected accounts"
+ instructions: "Select the accounts you want to import from IndexaCapital. You can choose multiple accounts."
+ no_accounts: "No unlinked accounts found from this IndexaCapital connection."
+ no_accounts_to_setup: "No Accounts to Set Up"
+ no_api_key: "IndexaCapital credentials are not configured. Please check your connection settings."
+ select_all: "Select all"
+ account_types:
+ skip: "Skip this account"
+ depository: "Checking or Savings Account"
+ credit_card: "Credit Card"
+ investment: "Investment Account"
+ crypto: "Cryptocurrency Account"
+ loan: "Loan or Mortgage"
+ other_asset: "Other Asset"
+ subtype_labels:
+ depository: "Account Subtype:"
+ credit_card: ""
+ investment: "Investment Type:"
+ crypto: ""
+ loan: "Loan Type:"
+ other_asset: ""
+ subtype_messages:
+ credit_card: "Credit cards will be automatically set up as credit card accounts."
+ other_asset: "No additional options needed for Other Assets."
+ crypto: "Cryptocurrency accounts will be set up to track holdings and transactions."
+ subtypes:
+ depository:
+ checking: "Checking"
+ savings: "Savings"
+ hsa: "Health Savings Account"
+ cd: "Certificate of Deposit"
+ money_market: "Money Market"
+ investment:
+ brokerage: "Brokerage"
+ pension: "Pension"
+ retirement: "Retirement"
+ "401k": "401(k)"
+ roth_401k: "Roth 401(k)"
+ "403b": "403(b)"
+ tsp: "Thrift Savings Plan"
+ "529_plan": "529 Plan"
+ hsa: "Health Savings Account"
+ mutual_fund: "Mutual Fund"
+ ira: "Traditional IRA"
+ roth_ira: "Roth IRA"
+ angel: "Angel"
+ loan:
+ mortgage: "Mortgage"
+ student: "Student Loan"
+ auto: "Auto Loan"
+ other: "Other Loan"
+ balance: "Balance"
+ cancel: "Cancel"
+ choose_account_type: "Choose the correct account type for each IndexaCapital account:"
+ create_accounts: "Create Accounts"
+ creating_accounts: "Creating Accounts..."
+ historical_data_range: "Historical Data Range:"
+ subtitle: "Choose the correct account types for your imported accounts"
+ sync_start_date_help: "Select how far back you want to sync transaction history."
+ sync_start_date_label: "Start syncing transactions from:"
+ title: "Set Up Your IndexaCapital Accounts"
+
+ # Complete account setup
+ complete_account_setup:
+ all_skipped: "All accounts were skipped. No accounts were created."
+ creation_failed: "Failed to create accounts: %{error}"
+ no_accounts: "No accounts to set up."
+ success: "Successfully created %{count} account(s)."
+
+ # Preload accounts
+ preload_accounts:
+ no_credentials_configured: "Please configure your IndexaCapital credentials first in Provider Settings."
diff --git a/config/locales/views/invitation_mailer/en.yml b/config/locales/views/invitation_mailer/en.yml
index abd6d22e5..02e7036d3 100644
--- a/config/locales/views/invitation_mailer/en.yml
+++ b/config/locales/views/invitation_mailer/en.yml
@@ -3,6 +3,6 @@ en:
invitation_mailer:
invite_email:
accept_button: Accept Invitation
- body: "%{inviter} has invited you to join the %{family} family on %{product_name}!"
+ body: "%{inviter} has invited you to join the %{family} %{moniker} on %{product_name}!"
expiry_notice: This invitation will expire in %{days} days
greeting: Welcome to %{product_name}!
diff --git a/config/locales/views/invitations/en.yml b/config/locales/views/invitations/en.yml
index 54ea455f3..45c2d51a5 100644
--- a/config/locales/views/invitations/en.yml
+++ b/config/locales/views/invitations/en.yml
@@ -1,7 +1,14 @@
---
en:
invitations:
+ accept_choice:
+ create_account: Create new account
+ joined_household: You have joined the household.
+ message: "%{inviter} has invited you to join as %{role}."
+ sign_in_existing: I already have an account
+ title: Join %{family}
create:
+ existing_user_added: User has been added to your household.
failure: Could not send invitation
success: Invitation sent successfully
destroy:
@@ -12,8 +19,9 @@ en:
email_label: Email Address
email_placeholder: Enter email address
role_admin: Administrator
+ role_guest: Guest
role_label: Role
role_member: Member
submit: Send Invitation
- subtitle: Send an invitation to join your family account on %{product_name}
+ subtitle: Send an invitation to join your %{moniker} account on %{product_name}
title: Invite Someone
diff --git a/config/locales/views/merchants/en.yml b/config/locales/views/merchants/en.yml
index aeb0fead5..efd51ba76 100644
--- a/config/locales/views/merchants/en.yml
+++ b/config/locales/views/merchants/en.yml
@@ -18,10 +18,10 @@ en:
new: New merchant
merge: Merge merchants
title: Merchants
- family_title: Family merchants
- family_empty: No family merchants yet
+ family_title: "%{moniker} merchants"
+ family_empty: "No %{moniker} merchants yet"
provider_title: Provider merchants
- provider_empty: No provider merchants linked to this family yet
+ provider_empty: "No provider merchants linked to this %{moniker} yet"
provider_read_only: Provider merchants are synced from your connected institutions. They cannot be edited here.
provider_info: These merchants were automatically detected by your bank connections or AI. You can edit them to create your own copy, or remove them to unlink from your transactions.
unlinked_title: Recently unlinked
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..c265f8c64 100644
--- a/config/locales/views/onboardings/en.yml
+++ b/config/locales/views/onboardings/en.yml
@@ -2,27 +2,65 @@
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
+ group_name: Group name
+ group_name_placeholder: Group name
+ household_name: Household name
+ household_name_placeholder: Household name
+ moniker_prompt: "Will be using %{product_name} with ..."
+ moniker_family: Family members (just yourself or with partner, teens, etc.)
+ moniker_group: Group of people (company, club, association, any other type)
+ 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!
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/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/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/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..7f61905f9 100644
--- a/config/locales/views/registrations/en.yml
+++ b/config/locales/views/registrations/en.yml
@@ -15,8 +15,9 @@ en:
success: You have signed up successfully.
new:
invitation_message: "%{inviter} has invited you to join as a %{role}"
- join_family_title: Join %{family}
+ join_family_title: Join %{family} %{moniker}
role_admin: administrator
+ role_guest: guest
role_member: member
submit: Create account
title: Create your account
@@ -24,3 +25,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/rules/ca.yml b/config/locales/views/rules/ca.yml
index c4b40ca90..b1cacb21f 100644
--- a/config/locales/views/rules/ca.yml
+++ b/config/locales/views/rules/ca.yml
@@ -19,6 +19,7 @@ ca:
success: Totes les regles s'han posat a cua per a execució
view_usage: Veure l'historial d'ús
no_action: Sense acció
+ no_condition: Sense condició
recent_runs:
columns:
date_time: Data/Hora
diff --git a/config/locales/views/rules/de.yml b/config/locales/views/rules/de.yml
index a8a80bdff..cc57171b0 100644
--- a/config/locales/views/rules/de.yml
+++ b/config/locales/views/rules/de.yml
@@ -2,6 +2,7 @@
de:
rules:
no_action: Keine Aktion
+ no_condition: Keine Bedingung
recent_runs:
title: Letzte Ausführungen
description: Zeige die Ausführungsgeschichte deiner Regeln einschließlich Erfolgs-/Fehlerstatus und Transaktionsanzahlen.
@@ -22,4 +23,3 @@ de:
pending: Ausstehend
success: Erfolgreich
failed: Fehlgeschlagen
-
diff --git a/config/locales/views/rules/en.yml b/config/locales/views/rules/en.yml
index 5cbd252d5..81eccc7e8 100644
--- a/config/locales/views/rules/en.yml
+++ b/config/locales/views/rules/en.yml
@@ -2,6 +2,7 @@
en:
rules:
no_action: No Action
+ no_condition: No condition
actions:
value_placeholder: Enter a value
apply_all:
@@ -37,3 +38,15 @@ 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.
+ condition_filters:
+ transaction_type:
+ income: Income
+ expense: Expense
+ transfer: Transfer
+ equal_to: Equal to
diff --git a/config/locales/views/rules/es.yml b/config/locales/views/rules/es.yml
index 472ed0240..3c494e8fb 100644
--- a/config/locales/views/rules/es.yml
+++ b/config/locales/views/rules/es.yml
@@ -2,6 +2,7 @@
es:
rules:
no_action: Sin acción
+ no_condition: Sin condición
recent_runs:
title: Ejecuciones Recientes
description: Ver el historial de ejecución de tus reglas incluyendo el estado de éxito/fallo y los conteos de transacciones.
@@ -22,4 +23,3 @@ es:
pending: Pendiente
success: Éxito
failed: Fallido
-
diff --git a/config/locales/views/rules/fr.yml b/config/locales/views/rules/fr.yml
index daa5c42cc..dfa7b9755 100644
--- a/config/locales/views/rules/fr.yml
+++ b/config/locales/views/rules/fr.yml
@@ -2,6 +2,7 @@
fr:
rules:
no_action: Aucune action
+ no_condition: Aucune condition
actions:
value_placeholder: Entrez une valeur
apply_all:
diff --git a/config/locales/views/rules/nb.yml b/config/locales/views/rules/nb.yml
index 1787cf18e..c4a333b40 100644
--- a/config/locales/views/rules/nb.yml
+++ b/config/locales/views/rules/nb.yml
@@ -2,6 +2,7 @@
nb:
rules:
no_action: Ingen handling
+ no_condition: Ingen betingelse
recent_runs:
title: Siste Kjøringer
description: Se kjøringsloggen for reglene dine inkludert suksess/feil-status og transaksjonsantall.
@@ -22,4 +23,3 @@ nb:
pending: Ventende
success: Vellykket
failed: Mislyktes
-
diff --git a/config/locales/views/rules/nl.yml b/config/locales/views/rules/nl.yml
index 9ef32b833..a789d20c9 100644
--- a/config/locales/views/rules/nl.yml
+++ b/config/locales/views/rules/nl.yml
@@ -2,6 +2,7 @@
nl:
rules:
no_action: Geen actie
+ no_condition: Geen voorwaarde
actions:
value_placeholder: Voer een waarde in
apply_all:
diff --git a/config/locales/views/rules/ro.yml b/config/locales/views/rules/ro.yml
index 3f3ccb8c5..c5e877aab 100644
--- a/config/locales/views/rules/ro.yml
+++ b/config/locales/views/rules/ro.yml
@@ -2,6 +2,7 @@
ro:
rules:
no_action: Nicio acțiune
+ no_condition: Nicio condiție
recent_runs:
title: Rulări Recente
description: Vezi istoricul de execuție al regulilor tale incluzând statusul de succes/eșec și numărul de tranzacții.
@@ -22,4 +23,3 @@ ro:
pending: În Așteptare
success: Succes
failed: Eșuat
-
diff --git a/config/locales/views/rules/tr.yml b/config/locales/views/rules/tr.yml
index c2d50c11d..b1418415e 100644
--- a/config/locales/views/rules/tr.yml
+++ b/config/locales/views/rules/tr.yml
@@ -2,6 +2,7 @@
tr:
rules:
no_action: İşlem yok
+ no_condition: Koşul yok
recent_runs:
title: Son Çalıştırmalar
description: Başarı/başarısızlık durumu ve işlem sayıları dahil olmak üzere kurallarınızın yürütme geçmişini görüntüleyin.
@@ -22,4 +23,3 @@ tr:
pending: Beklemede
success: Başarılı
failed: Başarısız
-
diff --git a/config/locales/views/rules/zh-CN.yml b/config/locales/views/rules/zh-CN.yml
index 5044d8b00..6f44adc5f 100644
--- a/config/locales/views/rules/zh-CN.yml
+++ b/config/locales/views/rules/zh-CN.yml
@@ -2,6 +2,7 @@
zh-CN:
rules:
no_action: 无操作
+ no_condition: 无条件
recent_runs:
columns:
date_time: 日期/时间
diff --git a/config/locales/views/rules/zh-TW.yml b/config/locales/views/rules/zh-TW.yml
index 5a8bdf3bd..84846ce24 100644
--- a/config/locales/views/rules/zh-TW.yml
+++ b/config/locales/views/rules/zh-TW.yml
@@ -2,6 +2,7 @@
zh-TW:
rules:
no_action: 無動作
+ no_condition: 無條件
recent_runs:
title: 最近執行紀錄
description: 查看規則的執行歷史,包括成功/失敗狀態以及交易處理筆數。
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..ea49a80a0 100644
--- a/config/locales/views/sessions/en.yml
+++ b/config/locales/views/sessions/en.yml
@@ -9,6 +9,7 @@ en:
post_logout:
logout_successful: You have signed out successfully.
openid_connect:
+ account_linked: "Account successfully linked to %{provider}"
failed: Could not authenticate via OpenID Connect.
failure:
failed: Could not authenticate.
@@ -24,6 +25,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/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml
index ca407957b..55730d2b6 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.
@@ -77,10 +80,12 @@ en:
reset_account_with_sample_data_warning: Delete all your existing data and then load fresh sample data so you can explore with a pre-filled environment.
email: Email
first_name: First Name
+ group_form_input_placeholder: Enter group name
+ group_form_label: Group name
+ group_title: Group Members
household_form_input_placeholder: Enter household name
household_form_label: Household name
- household_subtitle: Invite family members, partners and other inviduals. Invitees
- can login to your household and access your shared accounts.
+ household_subtitle: Invitees can login to your %{moniker} account and access shared resources.
household_title: Household
invitation_link: Invitation link
invite_member: Add member
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/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
diff --git a/config/locales/views/simplefin_items/en.yml b/config/locales/views/simplefin_items/en.yml
index 216f9cc2d..9929eb64c 100644
--- a/config/locales/views/simplefin_items/en.yml
+++ b/config/locales/views/simplefin_items/en.yml
@@ -87,7 +87,7 @@ en:
description: Select a SimpleFIN account to link to your existing account
cancel: Cancel
link_account: Link account
- no_accounts_found: No SimpleFIN accounts found for this family.
+ no_accounts_found: "No SimpleFIN accounts found for this %{moniker}."
wait_for_sync: If you just connected or synced, try again after the sync completes.
unlink_to_move: To move a link, first unlink it from the account’s actions menu.
all_accounts_already_linked: All SimpleFIN accounts appear to be linked already.
diff --git a/config/locales/views/snaptrade_items/en.yml b/config/locales/views/snaptrade_items/en.yml
index 779c8a93c..63b70a28c 100644
--- a/config/locales/views/snaptrade_items/en.yml
+++ b/config/locales/views/snaptrade_items/en.yml
@@ -8,8 +8,8 @@ en:
destroy:
success: "Scheduled SnapTrade connection for deletion."
connect:
- registration_failed: "Failed to register with SnapTrade: %{message}"
- portal_error: "Failed to connect to SnapTrade: %{message}"
+ decryption_failed: "Unable to read SnapTrade credentials. Please delete and recreate this connection."
+ connection_failed: "Failed to connect to SnapTrade: %{message}"
callback:
success: "Brokerage connected! Please select which accounts to link."
no_item: "SnapTrade configuration not found."
@@ -75,6 +75,9 @@ en:
cancel_button: "Cancel"
creating: "Creating Accounts..."
done_button: "Done"
+ or_link_existing: "Or link to an existing account instead of creating a new one:"
+ select_account: "Select an account..."
+ link_button: "Link"
linked_accounts: "Already Linked"
linked_to: "Linked to:"
snaptrade_item:
diff --git a/config/locales/views/transactions/en.yml b/config/locales/views/transactions/en.yml
index 00078d609..02287d644 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
@@ -74,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
@@ -95,6 +98,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:
diff --git a/config/routes.rb b/config/routes.rb
index 428c589dc..5badb50fa 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -2,6 +2,21 @@ require "sidekiq/web"
require "sidekiq/cron/web"
Rails.application.routes.draw do
+ resources :indexa_capital_items, only: [ :index, :new, :create, :show, :edit, :update, :destroy ] do
+ collection do
+ get :preload_accounts
+ get :select_accounts
+ post :link_accounts
+ get :select_existing_account
+ post :link_existing_account
+ end
+
+ member do
+ post :sync
+ get :setup_accounts
+ post :complete_account_setup
+ end
+ end
resources :mercury_items, only: %i[index new create show edit update destroy] do
collection do
get :preload_accounts
@@ -118,6 +133,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"
@@ -208,7 +224,7 @@ Rails.application.routes.draw do
resources :transfers, only: %i[new create destroy show update]
- resources :imports, only: %i[index new show create destroy] do
+ resources :imports, only: %i[index new show create update destroy] do
member do
post :publish
put :revert
@@ -297,6 +313,7 @@ Rails.application.routes.draw do
delete :destroy_all
get :confirm_all
post :apply_all
+ post :clear_ai_cache
end
end
@@ -354,6 +371,8 @@ 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"
+ patch "auth/enable_ai", to: "auth#enable_ai"
# Production API endpoints
resources :accounts, only: [ :index, :show ]
@@ -362,6 +381,9 @@ Rails.application.routes.draw do
resources :tags, only: %i[index show create update destroy]
resources :transactions, only: [ :index, :show, :create, :update, :destroy ]
+ resources :trades, only: [ :index, :show, :create, :update, :destroy ]
+ resources :holdings, only: [ :index, :show ]
+ resources :valuations, only: [ :create, :update, :show ]
resources :imports, only: [ :index, :show, :create ]
resource :usage, only: [ :show ], controller: :usage
post :sync, to: "sync#create"
@@ -461,6 +483,7 @@ Rails.application.routes.draw do
terms_url = ENV["LEGAL_TERMS_URL"].presence
get "privacy", to: privacy_url ? redirect(privacy_url) : "pages#privacy"
get "terms", to: terms_url ? redirect(terms_url) : "pages#terms"
+ get "intro", to: "pages#intro"
# Admin namespace for super admin functionality
namespace :admin do
diff --git a/db/migrate/20240701000000_add_category_fields_to_import_rows.rb b/db/migrate/20240701000000_add_category_fields_to_import_rows.rb
deleted file mode 100644
index 8a4210223..000000000
--- a/db/migrate/20240701000000_add_category_fields_to_import_rows.rb
+++ /dev/null
@@ -1,7 +0,0 @@
-class AddCategoryFieldsToImportRows < ActiveRecord::Migration[7.1]
- def change
- add_column :import_rows, :category_parent, :string
- add_column :import_rows, :category_color, :string
- add_column :import_rows, :category_classification, :string
- end
-end
diff --git a/db/migrate/20240701000001_add_category_icon_to_import_rows.rb b/db/migrate/20240701000001_add_category_icon_to_import_rows.rb
deleted file mode 100644
index 66f389233..000000000
--- a/db/migrate/20240701000001_add_category_icon_to_import_rows.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-class AddCategoryIconToImportRows < ActiveRecord::Migration[7.1]
- def change
- add_column :import_rows, :category_icon, :string
- end
-end
diff --git a/db/migrate/20240925112219_ensure_category_fields_on_import_rows.rb b/db/migrate/20240925112219_ensure_category_fields_on_import_rows.rb
new file mode 100644
index 000000000..ad6f5e72f
--- /dev/null
+++ b/db/migrate/20240925112219_ensure_category_fields_on_import_rows.rb
@@ -0,0 +1,7 @@
+class EnsureCategoryFieldsOnImportRows < ActiveRecord::Migration[7.2]
+ def change
+ add_column :import_rows, :category_parent, :string unless column_exists?(:import_rows, :category_parent)
+ add_column :import_rows, :category_color, :string unless column_exists?(:import_rows, :category_color)
+ add_column :import_rows, :category_classification, :string unless column_exists?(:import_rows, :category_classification)
+ end
+end
diff --git a/db/migrate/20240925112220_ensure_category_icon_on_import_rows.rb b/db/migrate/20240925112220_ensure_category_icon_on_import_rows.rb
new file mode 100644
index 000000000..aaacb2275
--- /dev/null
+++ b/db/migrate/20240925112220_ensure_category_icon_on_import_rows.rb
@@ -0,0 +1,5 @@
+class EnsureCategoryIconOnImportRows < ActiveRecord::Migration[7.2]
+ def change
+ add_column :import_rows, :category_icon, :string unless column_exists?(:import_rows, :category_icon)
+ end
+end
diff --git a/db/migrate/20251030140000_add_ui_layout_to_users.rb b/db/migrate/20251030140000_add_ui_layout_to_users.rb
new file mode 100644
index 000000000..236498a62
--- /dev/null
+++ b/db/migrate/20251030140000_add_ui_layout_to_users.rb
@@ -0,0 +1,16 @@
+class AddUiLayoutToUsers < ActiveRecord::Migration[7.2]
+ class MigrationUser < ApplicationRecord
+ self.table_name = "users"
+ end
+
+ def up
+ add_column :users, :ui_layout, :string, if_not_exists: true
+
+ MigrationUser.reset_column_information
+ MigrationUser.where(ui_layout: [ nil, "" ]).update_all(ui_layout: "dashboard")
+ end
+
+ def down
+ remove_column :users, :ui_layout
+ end
+end
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/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/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/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/migrate/20260205110328_create_indexa_capital_items_and_accounts.rb b/db/migrate/20260205110328_create_indexa_capital_items_and_accounts.rb
new file mode 100644
index 000000000..804594f82
--- /dev/null
+++ b/db/migrate/20260205110328_create_indexa_capital_items_and_accounts.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+class CreateIndexaCapitalItemsAndAccounts < ActiveRecord::Migration[7.2]
+ def change
+ # Create provider items table (stores per-family connection credentials)
+ create_table :indexa_capital_items, id: :uuid do |t|
+ t.references :family, null: false, foreign_key: true, type: :uuid
+ t.string :name
+
+ # Institution metadata
+ t.string :institution_id
+ t.string :institution_name
+ t.string :institution_domain
+ t.string :institution_url
+ t.string :institution_color
+
+ # Status and lifecycle
+ t.string :status, default: "good"
+ t.boolean :scheduled_for_deletion, default: false
+ t.boolean :pending_account_setup, default: false
+
+ # Sync settings
+ t.datetime :sync_start_date
+
+ # Raw data storage
+ t.jsonb :raw_payload
+ t.jsonb :raw_institution_payload
+
+ # Provider-specific credential fields
+ t.string :username
+ t.string :document
+ t.text :password
+
+ t.timestamps
+ end
+
+ add_index :indexa_capital_items, :status
+
+ # Create provider accounts table (stores individual account data from provider)
+ create_table :indexa_capital_accounts, id: :uuid do |t|
+ t.references :indexa_capital_item, null: false, foreign_key: true, type: :uuid
+
+ # Account identification
+ t.string :name
+ t.string :indexa_capital_account_id
+ t.string :account_number
+
+ # Account details
+ t.string :currency
+ t.decimal :current_balance, precision: 19, scale: 4
+ t.string :account_status
+ t.string :account_type
+ t.string :provider
+
+ # Metadata and raw data
+ t.jsonb :institution_metadata
+ t.jsonb :raw_payload
+
+ # Investment-specific columns
+ t.string :indexa_capital_authorization_id
+ t.decimal :cash_balance, precision: 19, scale: 4, default: 0.0
+ t.jsonb :raw_holdings_payload, default: []
+ t.jsonb :raw_activities_payload, default: []
+ t.datetime :last_holdings_sync
+ t.datetime :last_activities_sync
+ t.boolean :activities_fetch_pending, default: false
+
+ # Sync settings
+ t.date :sync_start_date
+
+ t.timestamps
+ end
+
+ add_index :indexa_capital_accounts, :indexa_capital_account_id, unique: true
+ add_index :indexa_capital_accounts, :indexa_capital_authorization_id
+ end
+end
diff --git a/db/migrate/20260207231945_add_api_token_to_indexa_capital_items.rb b/db/migrate/20260207231945_add_api_token_to_indexa_capital_items.rb
new file mode 100644
index 000000000..5ae9d5975
--- /dev/null
+++ b/db/migrate/20260207231945_add_api_token_to_indexa_capital_items.rb
@@ -0,0 +1,5 @@
+class AddApiTokenToIndexaCapitalItems < ActiveRecord::Migration[7.2]
+ def change
+ add_column :indexa_capital_items, :api_token, :text
+ end
+end
diff --git a/db/migrate/20260210120000_remove_flipper_tables.rb b/db/migrate/20260210120000_remove_flipper_tables.rb
new file mode 100644
index 000000000..c9e64a869
--- /dev/null
+++ b/db/migrate/20260210120000_remove_flipper_tables.rb
@@ -0,0 +1,22 @@
+class RemoveFlipperTables < ActiveRecord::Migration[7.2]
+ def up
+ drop_table :flipper_gates, if_exists: true
+ drop_table :flipper_features, if_exists: true
+ end
+
+ def down
+ create_table :flipper_features do |t|
+ t.string :key, null: false
+ t.timestamps null: false
+ end
+ add_index :flipper_features, :key, unique: true
+
+ create_table :flipper_gates do |t|
+ t.string :feature_key, null: false
+ t.string :key, null: false
+ t.text :value
+ t.timestamps null: false
+ end
+ add_index :flipper_gates, [ :feature_key, :key, :value ], unique: true, length: { value: 255 }
+ end
+end
diff --git a/db/migrate/20260211101500_add_moniker_to_families.rb b/db/migrate/20260211101500_add_moniker_to_families.rb
new file mode 100644
index 000000000..fa79636a4
--- /dev/null
+++ b/db/migrate/20260211101500_add_moniker_to_families.rb
@@ -0,0 +1,5 @@
+class AddMonikerToFamilies < ActiveRecord::Migration[7.2]
+ def change
+ add_column :families, :moniker, :string, null: false, default: "Family"
+ end
+end
diff --git a/db/migrate/20260211120001_add_vector_store_support.rb b/db/migrate/20260211120001_add_vector_store_support.rb
new file mode 100644
index 000000000..b4a8355f0
--- /dev/null
+++ b/db/migrate/20260211120001_add_vector_store_support.rb
@@ -0,0 +1,19 @@
+class AddVectorStoreSupport < ActiveRecord::Migration[7.2]
+ def change
+ add_column :families, :vector_store_id, :string
+
+ create_table :family_documents, id: :uuid, default: -> { "gen_random_uuid()" } do |t|
+ t.references :family, null: false, foreign_key: true, type: :uuid
+ t.string :filename, null: false
+ t.string :content_type
+ t.integer :file_size
+ t.string :provider_file_id
+ t.string :status, null: false, default: "pending"
+ t.jsonb :metadata, default: {}
+ t.timestamps
+ end
+
+ add_index :family_documents, :status
+ add_index :family_documents, :provider_file_id
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 9b7bcb296..fe8f6523b 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_02_11_120001) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -499,6 +499,25 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_24_180211) 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.string "vector_store_id"
+ t.string "moniker", default: "Family", null: false
+ t.check_constraint "month_start_day >= 1 AND month_start_day <= 28", name: "month_start_day_range"
+ end
+
+ create_table "family_documents", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.uuid "family_id", null: false
+ t.string "filename", null: false
+ t.string "content_type"
+ t.integer "file_size"
+ t.string "provider_file_id"
+ t.string "status", default: "pending", null: false
+ t.jsonb "metadata", default: {}
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["family_id"], name: "index_family_documents_on_family_id"
+ t.index ["provider_file_id"], name: "index_family_documents_on_provider_file_id"
+ t.index ["status"], name: "index_family_documents_on_status"
end
create_table "family_exports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -520,22 +539,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_24_180211) do
t.index ["merchant_id"], name: "index_family_merchant_associations_on_merchant_id"
end
- create_table "flipper_features", force: :cascade do |t|
- t.string "key", null: false
- t.datetime "created_at", null: false
- t.datetime "updated_at", null: false
- t.index ["key"], name: "index_flipper_features_on_key", unique: true
- end
-
- create_table "flipper_gates", force: :cascade do |t|
- t.string "feature_key", null: false
- t.string "key", null: false
- t.text "value"
- t.datetime "created_at", null: false
- t.datetime "updated_at", null: false
- t.index ["feature_key", "key", "value"], name: "index_flipper_gates_on_feature_key_and_key_and_value", unique: true
- end
-
create_table "holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "account_id", null: false
t.uuid "security_id", null: false
@@ -660,9 +663,63 @@ 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
+ create_table "indexa_capital_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.uuid "indexa_capital_item_id", null: false
+ t.string "name"
+ t.string "indexa_capital_account_id"
+ t.string "account_number"
+ t.string "currency"
+ t.decimal "current_balance", precision: 19, scale: 4
+ t.string "account_status"
+ t.string "account_type"
+ t.string "provider"
+ t.jsonb "institution_metadata"
+ t.jsonb "raw_payload"
+ t.string "indexa_capital_authorization_id"
+ t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0"
+ t.jsonb "raw_holdings_payload", default: []
+ t.jsonb "raw_activities_payload", default: []
+ t.datetime "last_holdings_sync"
+ t.datetime "last_activities_sync"
+ t.boolean "activities_fetch_pending", default: false
+ t.date "sync_start_date"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["indexa_capital_account_id"], name: "index_indexa_capital_accounts_on_indexa_capital_account_id", unique: true
+ t.index ["indexa_capital_authorization_id"], name: "idx_on_indexa_capital_authorization_id_58db208d52"
+ t.index ["indexa_capital_item_id"], name: "index_indexa_capital_accounts_on_indexa_capital_item_id"
+ end
+
+ create_table "indexa_capital_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.uuid "family_id", null: false
+ t.string "name"
+ t.string "institution_id"
+ t.string "institution_name"
+ t.string "institution_domain"
+ t.string "institution_url"
+ t.string "institution_color"
+ t.string "status", default: "good"
+ t.boolean "scheduled_for_deletion", default: false
+ t.boolean "pending_account_setup", default: false
+ t.datetime "sync_start_date"
+ t.jsonb "raw_payload"
+ t.jsonb "raw_institution_payload"
+ t.string "username"
+ t.string "document"
+ t.text "password"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.text "api_token"
+ t.index ["family_id"], name: "index_indexa_capital_items_on_family_id"
+ t.index ["status"], name: "index_indexa_capital_items_on_status"
+ end
+
create_table "investments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
@@ -850,8 +907,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_24_180211) 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
@@ -880,7 +935,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_24_180211) 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
@@ -1403,6 +1460,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_24_180211) do
t.string "default_account_order", default: "name_asc"
t.jsonb "preferences", default: {}, null: false
t.string "locale"
+ t.string "ui_layout"
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["family_id"], name: "index_users_on_family_id"
t.index ["last_viewed_chat_id"], name: "index_users_on_last_viewed_chat_id"
@@ -1456,6 +1514,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_24_180211) do
add_foreign_key "eval_results", "eval_samples"
add_foreign_key "eval_runs", "eval_datasets"
add_foreign_key "eval_samples", "eval_datasets"
+ add_foreign_key "family_documents", "families"
add_foreign_key "family_exports", "families"
add_foreign_key "family_merchant_associations", "families"
add_foreign_key "family_merchant_associations", "merchants"
@@ -1468,6 +1527,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_24_180211) do
add_foreign_key "impersonation_sessions", "users", column: "impersonator_id"
add_foreign_key "import_rows", "imports"
add_foreign_key "imports", "families"
+ add_foreign_key "indexa_capital_accounts", "indexa_capital_items"
+ add_foreign_key "indexa_capital_items", "families"
add_foreign_key "invitations", "families"
add_foreign_key "invitations", "users", column: "inviter_id"
add_foreign_key "llm_usages", "families"
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/docs/api/openapi.yaml b/docs/api/openapi.yaml
index b9ba48301..4a1515f90 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -54,6 +54,13 @@ components:
type: string
- type: object
nullable: true
+ errors:
+ type: array
+ items:
+ type: string
+ nullable: true
+ description: Validation error messages (alternative to details used by trades,
+ valuations, etc.)
ToolCall:
type: object
required:
@@ -350,6 +357,31 @@ components:
format: uuid
name:
type: string
+ MerchantDetail:
+ type: object
+ required:
+ - id
+ - name
+ - type
+ - created_at
+ - updated_at
+ properties:
+ id:
+ type: string
+ format: uuid
+ name:
+ type: string
+ type:
+ type: string
+ enum:
+ - FamilyMerchant
+ - ProviderMerchant
+ created_at:
+ type: string
+ format: date-time
+ updated_at:
+ type: string
+ format: date-time
Tag:
type: object
required:
@@ -471,6 +503,41 @@ 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
+ 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:
@@ -657,7 +724,198 @@ components:
properties:
data:
"$ref": "#/components/schemas/ImportDetail"
+ Trade:
+ type: object
+ required:
+ - id
+ - date
+ - amount
+ - currency
+ - name
+ - qty
+ - price
+ - account
+ - created_at
+ - updated_at
+ properties:
+ id:
+ type: string
+ format: uuid
+ date:
+ type: string
+ format: date
+ amount:
+ type: string
+ currency:
+ type: string
+ name:
+ type: string
+ notes:
+ type: string
+ nullable: true
+ qty:
+ type: string
+ price:
+ type: string
+ investment_activity_label:
+ type: string
+ nullable: true
+ account:
+ "$ref": "#/components/schemas/Account"
+ security:
+ type: object
+ nullable: true
+ properties:
+ id:
+ type: string
+ format: uuid
+ ticker:
+ type: string
+ name:
+ type: string
+ nullable: true
+ category:
+ type: object
+ nullable: true
+ properties:
+ id:
+ type: string
+ format: uuid
+ name:
+ type: string
+ created_at:
+ type: string
+ format: date-time
+ updated_at:
+ type: string
+ format: date-time
+ TradeCollection:
+ type: object
+ required:
+ - trades
+ - pagination
+ properties:
+ trades:
+ type: array
+ items:
+ "$ref": "#/components/schemas/Trade"
+ pagination:
+ "$ref": "#/components/schemas/Pagination"
+ Holding:
+ type: object
+ required:
+ - id
+ - date
+ - qty
+ - price
+ - amount
+ - currency
+ - account
+ - security
+ - created_at
+ - updated_at
+ properties:
+ id:
+ type: string
+ format: uuid
+ date:
+ type: string
+ format: date
+ qty:
+ type: string
+ description: Quantity of shares held
+ price:
+ type: string
+ description: Formatted price per share
+ amount:
+ type: string
+ currency:
+ type: string
+ cost_basis_source:
+ type: string
+ nullable: true
+ account:
+ "$ref": "#/components/schemas/Account"
+ security:
+ type: object
+ required:
+ - id
+ - ticker
+ - name
+ properties:
+ id:
+ type: string
+ format: uuid
+ ticker:
+ type: string
+ name:
+ type: string
+ nullable: true
+ avg_cost:
+ type: string
+ nullable: true
+ created_at:
+ type: string
+ format: date-time
+ updated_at:
+ type: string
+ format: date-time
+ HoldingCollection:
+ type: object
+ required:
+ - holdings
+ - pagination
+ properties:
+ holdings:
+ type: array
+ items:
+ "$ref": "#/components/schemas/Holding"
+ pagination:
+ "$ref": "#/components/schemas/Pagination"
paths:
+ "/api/v1/merchants":
+ get:
+ summary: List merchants
+ tags:
+ - Merchants
+ security:
+ - apiKeyAuth: []
+ responses:
+ '200':
+ description: merchants listed
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ "$ref": "#/components/schemas/MerchantDetail"
+ "/api/v1/merchants/{id}":
+ parameters:
+ - name: id
+ in: path
+ required: true
+ description: Merchant ID
+ schema:
+ type: string
+ get:
+ summary: Retrieve a merchant
+ tags:
+ - Merchants
+ security:
+ - apiKeyAuth: []
+ responses:
+ '200':
+ description: merchant retrieved
+ content:
+ application/json:
+ schema:
+ "$ref": "#/components/schemas/MerchantDetail"
+ '404':
+ description: merchant not found
+ content:
+ application/json:
+ schema:
+ "$ref": "#/components/schemas/ErrorResponse"
"/api/v1/accounts":
get:
summary: List accounts
@@ -685,6 +943,365 @@ paths:
application/json:
schema:
"$ref": "#/components/schemas/AccountCollection"
+ "/api/v1/auth/signup":
+ post:
+ summary: Sign up a new user
+ tags:
+ - Auth
+ parameters: []
+ responses:
+ '201':
+ description: user created
+ content:
+ application/json:
+ 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
+ ui_layout:
+ type: string
+ enum:
+ - dashboard
+ - intro
+ ai_enabled:
+ type: boolean
+ '422':
+ description: validation error
+ content:
+ application/json:
+ schema:
+ "$ref": "#/components/schemas/ErrorResponse"
+ '403':
+ description: invite code required or invalid
+ content:
+ application/json:
+ schema:
+ "$ref": "#/components/schemas/ErrorResponse"
+ requestBody:
+ content:
+ application/json:
+ 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:
+ - 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:
+ - 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:
+ - user
+ - device
+ required: true
+ "/api/v1/auth/login":
+ post:
+ summary: Log in with email and password
+ tags:
+ - Auth
+ parameters: []
+ responses:
+ '200':
+ description: login successful
+ content:
+ application/json:
+ 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
+ ui_layout:
+ type: string
+ enum:
+ - dashboard
+ - intro
+ ai_enabled:
+ type: boolean
+ '401':
+ description: invalid credentials or MFA required
+ content:
+ application/json:
+ schema:
+ "$ref": "#/components/schemas/ErrorResponse"
+ requestBody:
+ content:
+ application/json:
+ 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:
+ - device_id
+ - device_name
+ - device_type
+ - os_version
+ - app_version
+ required:
+ - email
+ - password
+ - device
+ required: true
+ "/api/v1/auth/sso_exchange":
+ post:
+ summary: Exchange mobile SSO authorization code for tokens
+ tags:
+ - Auth
+ 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.
+ parameters: []
+ responses:
+ '200':
+ description: tokens issued
+ content:
+ application/json:
+ 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
+ ui_layout:
+ type: string
+ enum:
+ - dashboard
+ - intro
+ ai_enabled:
+ type: boolean
+ '401':
+ description: invalid or expired code
+ content:
+ application/json:
+ schema:
+ "$ref": "#/components/schemas/ErrorResponse"
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ code:
+ type: string
+ description: One-time authorization code from mobile SSO callback
+ required:
+ - code
+ required: true
+ "/api/v1/auth/refresh":
+ post:
+ summary: Refresh an access token
+ tags:
+ - Auth
+ parameters: []
+ responses:
+ '200':
+ description: token refreshed
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ access_token:
+ type: string
+ refresh_token:
+ type: string
+ token_type:
+ type: string
+ expires_in:
+ type: integer
+ created_at:
+ type: integer
+ '401':
+ description: invalid refresh token
+ content:
+ application/json:
+ schema:
+ "$ref": "#/components/schemas/ErrorResponse"
+ '400':
+ description: missing refresh token
+ content:
+ application/json:
+ schema:
+ "$ref": "#/components/schemas/ErrorResponse"
+ requestBody:
+ content:
+ application/json:
+ 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:
+ - device_id
+ required:
+ - refresh_token
+ - device
+ required: true
+ "/api/v1/auth/enable_ai":
+ patch:
+ summary: Enable AI features for the authenticated user
+ tags:
+ - Auth
+ security:
+ - apiKeyAuth: []
+ responses:
+ '200':
+ description: ai enabled
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ user:
+ type: object
+ properties:
+ id:
+ type: string
+ format: uuid
+ email:
+ type: string
+ first_name:
+ type: string
+ nullable: true
+ last_name:
+ type: string
+ nullable: true
+ ui_layout:
+ type: string
+ enum:
+ - dashboard
+ - intro
+ ai_enabled:
+ type: boolean
+ '401':
+ description: unauthorized
+ content:
+ application/json:
+ schema:
+ "$ref": "#/components/schemas/ErrorResponse"
"/api/v1/categories":
get:
summary: List categories
@@ -973,6 +1590,119 @@ paths:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
+ "/api/v1/holdings":
+ get:
+ summary: List holdings
+ tags:
+ - Holdings
+ security:
+ - apiKeyAuth: []
+ parameters:
+ - name: page
+ in: query
+ required: false
+ description: 'Page number (default: 1)'
+ schema:
+ type: integer
+ - name: per_page
+ in: query
+ required: false
+ description: 'Items per page (default: 25, max: 100)'
+ schema:
+ type: integer
+ - name: account_id
+ in: query
+ required: false
+ description: Filter by account ID
+ schema:
+ type: string
+ - name: account_ids
+ in: query
+ required: false
+ description: Filter by multiple account IDs
+ schema:
+ type: array
+ items:
+ type: string
+ - name: date
+ in: query
+ required: false
+ description: Filter by exact date
+ schema:
+ type: string
+ format: date
+ - name: start_date
+ in: query
+ required: false
+ description: Filter holdings from this date (inclusive)
+ schema:
+ type: string
+ format: date
+ - name: end_date
+ in: query
+ required: false
+ description: Filter holdings until this date (inclusive)
+ schema:
+ type: string
+ format: date
+ - name: security_id
+ in: query
+ required: false
+ description: Filter by security ID
+ schema:
+ type: string
+ responses:
+ '200':
+ description: holdings paginated
+ content:
+ application/json:
+ schema:
+ "$ref": "#/components/schemas/HoldingCollection"
+ '401':
+ description: unauthorized
+ content:
+ application/json:
+ schema:
+ "$ref": "#/components/schemas/ErrorResponse"
+ '422':
+ description: invalid date filter
+ content:
+ application/json:
+ schema:
+ "$ref": "#/components/schemas/ErrorResponse"
+ "/api/v1/holdings/{id}":
+ parameters:
+ - name: id
+ in: path
+ required: true
+ description: Holding ID
+ schema:
+ type: string
+ get:
+ summary: Retrieve holding
+ tags:
+ - Holdings
+ security:
+ - apiKeyAuth: []
+ responses:
+ '200':
+ description: holding retrieved
+ content:
+ application/json:
+ schema:
+ "$ref": "#/components/schemas/Holding"
+ '401':
+ description: unauthorized
+ content:
+ application/json:
+ schema:
+ "$ref": "#/components/schemas/ErrorResponse"
+ '404':
+ description: holding not found
+ content:
+ application/json:
+ schema:
+ "$ref": "#/components/schemas/ErrorResponse"
"/api/v1/imports":
get:
summary: List imports
@@ -1273,6 +2003,268 @@ paths:
description: tag deleted
'404':
description: tag not found
+ "/api/v1/trades":
+ get:
+ summary: List trades
+ tags:
+ - Trades
+ security:
+ - apiKeyAuth: []
+ parameters:
+ - name: page
+ in: query
+ required: false
+ description: 'Page number (default: 1)'
+ schema:
+ type: integer
+ - name: per_page
+ in: query
+ required: false
+ description: 'Items per page (default: 25, max: 100)'
+ schema:
+ type: integer
+ - name: account_id
+ in: query
+ required: false
+ description: Filter by account ID
+ schema:
+ type: string
+ - name: account_ids
+ in: query
+ required: false
+ description: Filter by multiple account IDs
+ schema:
+ type: array
+ items:
+ type: string
+ - name: start_date
+ in: query
+ required: false
+ description: Filter trades from this date (inclusive)
+ schema:
+ type: string
+ format: date
+ - name: end_date
+ in: query
+ required: false
+ description: Filter trades until this date (inclusive)
+ schema:
+ type: string
+ format: date
+ responses:
+ '200':
+ description: trades paginated
+ content:
+ application/json:
+ schema:
+ "$ref": "#/components/schemas/TradeCollection"
+ '401':
+ description: unauthorized
+ content:
+ application/json:
+ schema:
+ "$ref": "#/components/schemas/ErrorResponse"
+ '422':
+ description: invalid date filter
+ content:
+ application/json:
+ schema:
+ "$ref": "#/components/schemas/ErrorResponse"
+ post:
+ summary: Create trade
+ tags:
+ - Trades
+ security:
+ - apiKeyAuth: []
+ parameters: []
+ responses:
+ '201':
+ description: trade created
+ content:
+ application/json:
+ schema:
+ "$ref": "#/components/schemas/Trade"
+ '422':
+ description: validation error - missing security identifier
+ 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:
+ trade:
+ type: object
+ properties:
+ account_id:
+ type: string
+ format: uuid
+ description: Account ID (required)
+ date:
+ type: string
+ format: date
+ description: Trade date (required)
+ qty:
+ type: number
+ description: Quantity (required)
+ price:
+ type: number
+ description: Price (required)
+ type:
+ type: string
+ enum:
+ - buy
+ - sell
+ description: Trade type (required)
+ security_id:
+ type: string
+ format: uuid
+ description: Security ID (one of security_id, ticker, manual_ticker
+ required)
+ ticker:
+ type: string
+ description: Ticker symbol
+ manual_ticker:
+ type: string
+ description: Manual ticker for offline securities
+ currency:
+ type: string
+ description: Currency (defaults to account currency)
+ investment_activity_label:
+ type: string
+ description: Activity label (e.g. Buy, Sell)
+ category_id:
+ type: string
+ format: uuid
+ description: Category ID
+ required:
+ - account_id
+ - date
+ - qty
+ - price
+ - type
+ required:
+ - trade
+ required: true
+ "/api/v1/trades/{id}":
+ parameters:
+ - name: id
+ in: path
+ required: true
+ description: Trade ID
+ schema:
+ type: string
+ get:
+ summary: Retrieve trade
+ tags:
+ - Trades
+ security:
+ - apiKeyAuth: []
+ responses:
+ '200':
+ description: trade retrieved
+ content:
+ application/json:
+ schema:
+ "$ref": "#/components/schemas/Trade"
+ '401':
+ description: unauthorized
+ content:
+ application/json:
+ schema:
+ "$ref": "#/components/schemas/ErrorResponse"
+ '404':
+ description: trade not found
+ content:
+ application/json:
+ schema:
+ "$ref": "#/components/schemas/ErrorResponse"
+ patch:
+ summary: Update trade
+ tags:
+ - Trades
+ security:
+ - apiKeyAuth: []
+ parameters: []
+ responses:
+ '200':
+ description: trade updated
+ content:
+ application/json:
+ schema:
+ "$ref": "#/components/schemas/Trade"
+ '404':
+ description: trade not found
+ content:
+ application/json:
+ schema:
+ "$ref": "#/components/schemas/ErrorResponse"
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ trade:
+ type: object
+ properties:
+ date:
+ type: string
+ format: date
+ qty:
+ type: number
+ price:
+ type: number
+ type:
+ type: string
+ enum:
+ - buy
+ - sell
+ nature:
+ type: string
+ enum:
+ - inflow
+ - outflow
+ name:
+ type: string
+ notes:
+ type: string
+ currency:
+ type: string
+ investment_activity_label:
+ type: string
+ category_id:
+ type: string
+ format: uuid
+ required: true
+ delete:
+ summary: Delete trade
+ tags:
+ - Trades
+ security:
+ - apiKeyAuth: []
+ responses:
+ '200':
+ description: trade deleted
+ content:
+ application/json:
+ schema:
+ "$ref": "#/components/schemas/DeleteResponse"
+ '404':
+ description: trade not found
+ content:
+ application/json:
+ schema:
+ "$ref": "#/components/schemas/ErrorResponse"
"/api/v1/transactions":
get:
summary: List transactions
@@ -1562,6 +2554,8 @@ paths:
items:
type: string
format: uuid
+ description: Array of tag IDs to assign. Omit to preserve existing
+ tags; use [] to clear all tags.
required: true
delete:
summary: Delete a transaction
@@ -1582,3 +2576,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/docs/hosting/ai.md b/docs/hosting/ai.md
index 7273ab1ab..0e6d56d1f 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:**
@@ -287,6 +290,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.
@@ -567,6 +633,106 @@ The assistant uses OpenAI's function calling (tool use) to access user data:
These are defined in `app/models/assistant/function/`.
+### Vector Store (Document Search)
+
+Sure's AI assistant can search documents that have been uploaded to a family's vault. Under the hood, documents are indexed in a **vector store** so the assistant can retrieve relevant passages when answering questions (Retrieval-Augmented Generation).
+
+#### How It Works
+
+1. When a user uploads a document to their family vault, it is automatically pushed to the configured vector store.
+2. When the assistant needs financial context from uploaded files, it calls the `search_family_files` function.
+3. The vector store returns the most relevant passages, which the assistant uses to answer the question.
+
+#### Supported Backends
+
+| Backend | Status | Best For | Requirements |
+|---------|--------|----------|--------------|
+| **OpenAI** (default) | ready | Cloud deployments, zero setup | `OPENAI_ACCESS_TOKEN` |
+| **Pgvector** | scaffolded | Self-hosted, full data privacy | PostgreSQL with `pgvector` extension |
+| **Qdrant** | scaffolded | Self-hosted, dedicated vector DB | Running Qdrant instance |
+
+#### Configuration
+
+##### OpenAI (Default)
+
+No extra configuration is needed. If you already have `OPENAI_ACCESS_TOKEN` set for the AI assistant, document search works automatically. OpenAI manages chunking, embedding, and retrieval.
+
+```bash
+# Already set for AI chat — document search uses the same token
+OPENAI_ACCESS_TOKEN=sk-proj-...
+```
+
+##### Pgvector (Self-Hosted)
+
+> [!CAUTION]
+> Only `OpenAI` has been implemented!
+
+Use PostgreSQL's pgvector extension for fully local document search:
+
+```bash
+VECTOR_STORE_PROVIDER=pgvector
+```
+
+> **Note:** The pgvector adapter is currently a skeleton. A future release will add full support including embedding model configuration.
+
+##### Qdrant (Self-Hosted)
+
+> [!CAUTION]
+> Only `OpenAI` has been implemented!
+
+Use a dedicated Qdrant vector database:
+
+```bash
+VECTOR_STORE_PROVIDER=qdrant
+QDRANT_URL=http://localhost:6333 # Default if not set
+QDRANT_API_KEY=your-api-key # Optional, for authenticated instances
+```
+
+Docker Compose example:
+
+```yaml
+services:
+ sure:
+ environment:
+ - VECTOR_STORE_PROVIDER=qdrant
+ - QDRANT_URL=http://qdrant:6333
+ depends_on:
+ - qdrant
+
+ qdrant:
+ image: qdrant/qdrant:latest
+ ports:
+ - "6333:6333"
+ volumes:
+ - qdrant_data:/qdrant/storage
+
+volumes:
+ qdrant_data:
+```
+
+> **Note:** The Qdrant adapter is currently a skeleton. A future release will add full support including collection management and embedding configuration.
+
+#### Verifying the Configuration
+
+You can check whether a vector store is properly configured from the Rails console:
+
+```ruby
+VectorStore.configured? # => true / false
+VectorStore.adapter # => #
+VectorStore.adapter.class.name # => "VectorStore::Openai"
+```
+
+#### Supported File Types
+
+The following file extensions are supported for document upload and search:
+
+`.pdf`, `.txt`, `.md`, `.csv`, `.json`, `.xml`, `.html`, `.css`, `.js`, `.rb`, `.py`, `.docx`, `.pptx`, `.xlsx`, `.yaml`, `.yml`, `.log`, `.sh`
+
+#### Privacy Notes
+
+- **OpenAI backend:** Document content is sent to OpenAI's API for indexing and search. The same privacy considerations as the AI chat apply.
+- **Pgvector / Qdrant backends:** All data stays on your infrastructure. No external API calls are made for document search.
+
### Multi-Model Setup
Currently not supported out of the box, but you could:
diff --git a/docs/hosting/oidc.md b/docs/hosting/oidc.md
index 3aa676b8a..9100bb5f9 100644
--- a/docs/hosting/oidc.md
+++ b/docs/hosting/oidc.md
@@ -471,7 +471,68 @@ When adding an OIDC provider, Sure validates the `.well-known/openid-configurati
- Ensure the issuer URL is correct and accessible
- Check firewall rules allow outbound HTTPS to the issuer
- Verify the issuer returns valid JSON with an `issuer` field
-- For self-signed certificates, you may need to configure SSL verification
+- For self-signed certificates, configure SSL verification (see below)
+
+### Self-signed certificate support
+
+If your identity provider uses self-signed certificates or certificates from an internal CA, configure the following environment variables:
+
+| Variable | Description | Default |
+|----------|-------------|---------|
+| `SSL_CA_FILE` | Path to custom CA certificate (PEM format) | Not set |
+| `SSL_VERIFY` | Enable/disable SSL verification | `true` |
+| `SSL_DEBUG` | Enable verbose SSL logging | `false` |
+
+**Option 1: Custom CA certificate (recommended)**
+
+Mount your CA certificate into the container and set `SSL_CA_FILE`:
+
+```yaml
+# docker-compose.yml
+services:
+ app:
+ environment:
+ SSL_CA_FILE: /certs/my-ca.crt
+ volumes:
+ - ./my-ca.crt:/certs/my-ca.crt:ro
+```
+
+The certificate file must:
+- Be in PEM format (starts with `-----BEGIN CERTIFICATE-----`)
+- Be readable by the application
+- Be the CA certificate that signed your server's SSL certificate
+
+**Option 2: Disable SSL verification (NOT recommended for production)**
+
+For testing only, you can disable SSL verification:
+
+```bash
+SSL_VERIFY=false
+```
+
+**Warning:** Disabling SSL verification removes protection against man-in-the-middle attacks. Only use this for development or testing environments.
+
+**Troubleshooting SSL issues**
+
+Enable debug logging to diagnose SSL certificate problems:
+
+```bash
+SSL_DEBUG=true
+```
+
+This will log detailed information about SSL connections, including:
+- Which CA file is being used
+- SSL verification mode
+- Detailed error messages with resolution hints
+
+Common error messages and solutions:
+
+| Error | Solution |
+|-------|----------|
+| `self-signed certificate` | Set `SSL_CA_FILE` to your CA certificate |
+| `certificate verify failed` | Ensure `SSL_CA_FILE` points to the correct CA |
+| `certificate has expired` | Renew the server's SSL certificate |
+| `unknown CA` | Add the issuing CA to `SSL_CA_FILE` |
### Rate limiting errors (429)
diff --git a/lib/feature_flags.rb b/lib/feature_flags.rb
new file mode 100644
index 000000000..e20472e81
--- /dev/null
+++ b/lib/feature_flags.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module FeatureFlags
+ class << self
+ def db_sso_providers?
+ auth_source = ENV.fetch("AUTH_PROVIDERS_SOURCE") do
+ Rails.configuration.app_mode.self_hosted? ? "db" : "yaml"
+ end
+
+ auth_source.to_s.downcase == "db"
+ end
+
+ def intro_ui?
+ Rails.configuration.x.ui.default_layout.to_s.in?(%w[intro dashboard])
+ end
+ end
+end
diff --git a/lib/generators/provider/family/templates/activities_fetch_job.rb.tt b/lib/generators/provider/family/templates/activities_fetch_job.rb.tt
index a9b9f96c7..163263400 100644
--- a/lib/generators/provider/family/templates/activities_fetch_job.rb.tt
+++ b/lib/generators/provider/family/templates/activities_fetch_job.rb.tt
@@ -2,7 +2,6 @@
class <%= class_name %>ActivitiesFetchJob < ApplicationJob
include <%= class_name %>Account::DataHelpers
- include Sidekiq::Throttled::Job
queue_as :default
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/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
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,
diff --git a/mobile/.gitignore b/mobile/.gitignore
index 39d59563b..c7e1da6e8 100644
--- a/mobile/.gitignore
+++ b/mobile/.gitignore
@@ -25,6 +25,7 @@ ios/Pods/
ios/.symlinks/
ios/Flutter/Flutter.framework
ios/Flutter/Flutter.podspec
+ios/Flutter/ephemeral/
ios/Flutter/Generated.xcconfig
ios/Runner.xcworkspace/
ios/Podfile.lock
diff --git a/mobile/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/mobile/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java
new file mode 100644
index 000000000..ba9a79390
--- /dev/null
+++ b/mobile/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java
@@ -0,0 +1,54 @@
+package io.flutter.plugins;
+
+import androidx.annotation.Keep;
+import androidx.annotation.NonNull;
+import io.flutter.Log;
+
+import io.flutter.embedding.engine.FlutterEngine;
+
+/**
+ * Generated file. Do not edit.
+ * This file is generated by the Flutter tool based on the
+ * plugins that support the Android platform.
+ */
+@Keep
+public final class GeneratedPluginRegistrant {
+ private static final String TAG = "GeneratedPluginRegistrant";
+ public static void registerWith(@NonNull FlutterEngine flutterEngine) {
+ try {
+ flutterEngine.getPlugins().add(new com.llfbandit.app_links.AppLinksPlugin());
+ } catch (Exception e) {
+ Log.e(TAG, "Error registering plugin app_links, com.llfbandit.app_links.AppLinksPlugin", e);
+ }
+ try {
+ flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.connectivity.ConnectivityPlugin());
+ } catch (Exception e) {
+ Log.e(TAG, "Error registering plugin connectivity_plus, dev.fluttercommunity.plus.connectivity.ConnectivityPlugin", e);
+ }
+ try {
+ flutterEngine.getPlugins().add(new com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin());
+ } catch (Exception e) {
+ Log.e(TAG, "Error registering plugin flutter_secure_storage, com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin", e);
+ }
+ try {
+ flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
+ } catch (Exception e) {
+ Log.e(TAG, "Error registering plugin path_provider_android, io.flutter.plugins.pathprovider.PathProviderPlugin", e);
+ }
+ try {
+ flutterEngine.getPlugins().add(new io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin());
+ } catch (Exception e) {
+ Log.e(TAG, "Error registering plugin shared_preferences_android, io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin", e);
+ }
+ try {
+ flutterEngine.getPlugins().add(new com.tekartik.sqflite.SqflitePlugin());
+ } catch (Exception e) {
+ Log.e(TAG, "Error registering plugin sqflite_android, com.tekartik.sqflite.SqflitePlugin", e);
+ }
+ try {
+ flutterEngine.getPlugins().add(new io.flutter.plugins.urllauncher.UrlLauncherPlugin());
+ } catch (Exception e) {
+ Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e);
+ }
+ }
+}
diff --git a/mobile/assets/images/google_g_logo.svg b/mobile/assets/images/google_g_logo.svg
new file mode 100644
index 000000000..d733a534d
--- /dev/null
+++ b/mobile/assets/images/google_g_logo.svg
@@ -0,0 +1,7 @@
+
diff --git a/mobile/assets/images/logomark.svg b/mobile/assets/images/logomark.svg
new file mode 100644
index 000000000..80e043546
--- /dev/null
+++ b/mobile/assets/images/logomark.svg
@@ -0,0 +1,6 @@
+
diff --git a/mobile/ios/Flutter/AppFrameworkInfo.plist b/mobile/ios/Flutter/AppFrameworkInfo.plist
index 7c5696400..1dc6cf765 100644
--- a/mobile/ios/Flutter/AppFrameworkInfo.plist
+++ b/mobile/ios/Flutter/AppFrameworkInfo.plist
@@ -21,6 +21,6 @@
CFBundleVersion
1.0
MinimumOSVersion
- 12.0
+ 13.0
diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj
index 030d8a5b5..649b91cd7 100644
--- a/mobile/ios/Runner.xcodeproj/project.pbxproj
+++ b/mobile/ios/Runner.xcodeproj/project.pbxproj
@@ -10,6 +10,7 @@
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
+ 8DDD6AE026E53835686F984E /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = ECA0200469BC2067F6002294 /* Pods_Runner.framework */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
@@ -31,6 +32,7 @@
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; };
+ 1DC166D4710E23F2EDEDA52D /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
@@ -42,6 +44,9 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ BEEF4E3D580B721E5A0852CA /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; };
+ ECA0200469BC2067F6002294 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+ FE30D351EF409EF61606DE07 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -49,12 +54,32 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
+ 8DDD6AE026E53835686F984E /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
+ 10A9A853ACC5019AE46A5DDA /* Pods */ = {
+ isa = PBXGroup;
+ children = (
+ BEEF4E3D580B721E5A0852CA /* Pods-Runner.debug.xcconfig */,
+ FE30D351EF409EF61606DE07 /* Pods-Runner.release.xcconfig */,
+ 1DC166D4710E23F2EDEDA52D /* Pods-Runner.profile.xcconfig */,
+ );
+ name = Pods;
+ path = Pods;
+ sourceTree = "";
+ };
+ 4D2624EB4309F79988F7AC61 /* Frameworks */ = {
+ isa = PBXGroup;
+ children = (
+ ECA0200469BC2067F6002294 /* Pods_Runner.framework */,
+ );
+ name = Frameworks;
+ sourceTree = "";
+ };
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
@@ -72,6 +97,8 @@
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
+ 10A9A853ACC5019AE46A5DDA /* Pods */,
+ 4D2624EB4309F79988F7AC61 /* Frameworks */,
);
sourceTree = "";
};
@@ -105,12 +132,14 @@
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
+ 17EEDD344E8596DDA68B7B10 /* [CP] Check Pods Manifest.lock */,
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
+ 0C5310326ADD4D85F74E6637 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
@@ -170,6 +199,45 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
+ 0C5310326ADD4D85F74E6637 /* [CP] Embed Pods Frameworks */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
+ );
+ name = "[CP] Embed Pods Frameworks";
+ outputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
+ showEnvVarsInLog = 0;
+ };
+ 17EEDD344E8596DDA68B7B10 /* [CP] Check Pods Manifest.lock */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+ "${PODS_ROOT}/Manifest.lock",
+ );
+ name = "[CP] Check Pods Manifest.lock";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+ showEnvVarsInLog = 0;
+ };
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
@@ -278,7 +346,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 12.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -357,7 +425,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 12.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -408,7 +476,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 12.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
diff --git a/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
new file mode 100644
index 000000000..9c12df59c
--- /dev/null
+++ b/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mobile/ios/Runner/GeneratedPluginRegistrant.h b/mobile/ios/Runner/GeneratedPluginRegistrant.h
index 3ab55947f..7a8909271 100644
--- a/mobile/ios/Runner/GeneratedPluginRegistrant.h
+++ b/mobile/ios/Runner/GeneratedPluginRegistrant.h
@@ -1,5 +1,19 @@
+//
+// Generated file. Do not edit.
+//
+
+// clang-format off
+
+#ifndef GeneratedPluginRegistrant_h
+#define GeneratedPluginRegistrant_h
+
#import
+NS_ASSUME_NONNULL_BEGIN
+
@interface GeneratedPluginRegistrant : NSObject
+ (void)registerWithRegistry:(NSObject*)registry;
@end
+
+NS_ASSUME_NONNULL_END
+#endif /* GeneratedPluginRegistrant_h */
diff --git a/mobile/ios/Runner/GeneratedPluginRegistrant.m b/mobile/ios/Runner/GeneratedPluginRegistrant.m
index efe65eccc..5705b470d 100644
--- a/mobile/ios/Runner/GeneratedPluginRegistrant.m
+++ b/mobile/ios/Runner/GeneratedPluginRegistrant.m
@@ -6,9 +6,58 @@
#import "GeneratedPluginRegistrant.h"
+#if __has_include()
+#import
+#else
+@import app_links;
+#endif
+
+#if __has_include()
+#import
+#else
+@import connectivity_plus;
+#endif
+
+#if __has_include()
+#import
+#else
+@import flutter_secure_storage_darwin;
+#endif
+
+#if __has_include()
+#import
+#else
+@import path_provider_foundation;
+#endif
+
+#if __has_include()
+#import
+#else
+@import shared_preferences_foundation;
+#endif
+
+#if __has_include()
+#import
+#else
+@import sqflite_darwin;
+#endif
+
+#if __has_include()
+#import
+#else
+@import url_launcher_ios;
+#endif
+
@implementation GeneratedPluginRegistrant
+ (void)registerWithRegistry:(NSObject*)registry {
+ [AppLinksIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"AppLinksIosPlugin"]];
+ [ConnectivityPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"ConnectivityPlusPlugin"]];
+ [FlutterSecureStorageDarwinPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterSecureStorageDarwinPlugin"]];
+ [PathProviderPlugin registerWithRegistrar:[registry registrarForPlugin:@"PathProviderPlugin"]];
+ [SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]];
+ [SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]];
+ [URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]];
}
@end
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/models/user.dart b/mobile/lib/models/user.dart
index e932bcda3..1172bdfe8 100644
--- a/mobile/lib/models/user.dart
+++ b/mobile/lib/models/user.dart
@@ -3,23 +3,64 @@ class User {
final String email;
final String? firstName;
final String? lastName;
+ final String uiLayout;
+ final bool aiEnabled;
User({
required this.id,
required this.email,
this.firstName,
this.lastName,
+ required this.uiLayout,
+ required this.aiEnabled,
});
+ bool get isIntroLayout => uiLayout == 'intro';
+
factory User.fromJson(Map json) {
return User(
id: json['id'].toString(),
email: json['email'] as String,
firstName: json['first_name'] as String?,
lastName: json['last_name'] as String?,
+ uiLayout: (json['ui_layout'] as String?) ?? 'dashboard',
+ // Default to true when key is absent (legacy payloads from older app versions).
+ // Avoids regressing existing users who would otherwise be incorrectly gated.
+ aiEnabled: json.containsKey('ai_enabled')
+ ? (json['ai_enabled'] == true)
+ : true,
);
}
+ User copyWith({
+ String? id,
+ String? email,
+ String? firstName,
+ String? lastName,
+ String? uiLayout,
+ bool? aiEnabled,
+ }) {
+ return User(
+ id: id ?? this.id,
+ email: email ?? this.email,
+ firstName: firstName ?? this.firstName,
+ lastName: lastName ?? this.lastName,
+ uiLayout: uiLayout ?? this.uiLayout,
+ aiEnabled: aiEnabled ?? this.aiEnabled,
+ );
+ }
+
+ Map toJson() {
+ return {
+ 'id': id,
+ 'email': email,
+ 'first_name': firstName,
+ 'last_name': lastName,
+ 'ui_layout': uiLayout,
+ 'ai_enabled': aiEnabled,
+ };
+ }
+
String get displayName {
if (firstName != null && lastName != null) {
return '$firstName $lastName';
diff --git a/mobile/lib/providers/auth_provider.dart b/mobile/lib/providers/auth_provider.dart
index 8e58589e0..a75826b1c 100644
--- a/mobile/lib/providers/auth_provider.dart
+++ b/mobile/lib/providers/auth_provider.dart
@@ -1,8 +1,10 @@
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';
import '../services/device_service.dart';
+import '../services/api_config.dart';
import '../services/log_service.dart';
class AuthProvider with ChangeNotifier {
@@ -11,6 +13,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;
@@ -18,10 +22,15 @@ class AuthProvider with ChangeNotifier {
bool _showMfaInput = false; // Track if we should show MFA input field
User? get user => _user;
+ bool get isIntroLayout => _user?.isIntroLayout ?? false;
+ bool get aiEnabled => _user?.aiEnabled ?? false;
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 +45,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 +142,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,
@@ -163,12 +218,69 @@ 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;
_user = null;
+ _apiKey = null;
+ _isApiKeyAuth = false;
_errorMessage = null;
_mfaRequired = false;
+ ApiConfig.clearApiKeyAuth();
notifyListeners();
}
@@ -197,6 +309,10 @@ class AuthProvider with ChangeNotifier {
}
Future getValidAccessToken() async {
+ if (_isApiKeyAuth && _apiKey != null) {
+ return _apiKey;
+ }
+
if (_tokens == null) return null;
if (_tokens!.isExpired) {
@@ -207,6 +323,27 @@ class AuthProvider with ChangeNotifier {
return _tokens?.accessToken;
}
+ Future enableAi() async {
+ final accessToken = await getValidAccessToken();
+ if (accessToken == null) {
+ _errorMessage = 'Session expired. Please login again.';
+ notifyListeners();
+ return false;
+ }
+
+ final result = await _authService.enableAi(accessToken: accessToken);
+ if (result['success'] == true) {
+ _user = result['user'] as User?;
+ _errorMessage = null;
+ notifyListeners();
+ return true;
+ }
+
+ _errorMessage = result['error'] as String?;
+ notifyListeners();
+ return false;
+ }
+
void clearError() {
_errorMessage = null;
notifyListeners();
diff --git a/mobile/lib/screens/backend_config_screen.dart b/mobile/lib/screens/backend_config_screen.dart
index 48bf3dc66..edc50d774 100644
--- a/mobile/lib/screens/backend_config_screen.dart
+++ b/mobile/lib/screens/backend_config_screen.dart
@@ -35,9 +35,13 @@ class _BackendConfigScreenState extends State {
Future _loadSavedUrl() async {
final prefs = await SharedPreferences.getInstance();
final savedUrl = prefs.getString('backend_url');
- if (mounted && savedUrl != null && savedUrl.isNotEmpty) {
+ final urlToShow = (savedUrl != null && savedUrl.isNotEmpty)
+ ? savedUrl
+ : ApiConfig.baseUrl;
+
+ if (mounted) {
setState(() {
- _urlController.text = savedUrl;
+ _urlController.text = urlToShow;
});
}
}
@@ -53,30 +57,37 @@ class _BackendConfigScreenState extends State {
try {
// Normalize base URL by removing trailing slashes
- final normalizedUrl = _urlController.text.trim().replaceAll(RegExp(r'/+$'), '');
+ final normalizedUrl = _urlController.text.trim().replaceAll(
+ RegExp(r'/+$'),
+ '',
+ );
// Check /sessions/new page to verify it's a Sure backend
final sessionsUrl = Uri.parse('$normalizedUrl/sessions/new');
- final sessionsResponse = await http.get(
- sessionsUrl,
- headers: {'Accept': 'text/html'},
- ).timeout(
- const Duration(seconds: 10),
- onTimeout: () {
- throw Exception('Connection timeout. Please check the URL and try again.');
- },
- );
+ final sessionsResponse = await http
+ .get(sessionsUrl, headers: {'Accept': 'text/html'})
+ .timeout(
+ const Duration(seconds: 10),
+ onTimeout: () {
+ throw Exception(
+ 'Connection timeout. Please check the URL and try again.',
+ );
+ },
+ );
- if (sessionsResponse.statusCode >= 200 && sessionsResponse.statusCode < 400) {
+ if (sessionsResponse.statusCode >= 200 &&
+ sessionsResponse.statusCode < 400) {
if (mounted) {
setState(() {
- _successMessage = 'Connection successful! Sure backend is reachable.';
+ _successMessage =
+ 'Connection successful!';
});
}
} else {
if (mounted) {
setState(() {
- _errorMessage = 'Server responded with status ${sessionsResponse.statusCode}. Please check if this is a Sure backend server.';
+ _errorMessage =
+ 'Server responded with status ${sessionsResponse.statusCode}. Please check if this is a Sure backend server.';
});
}
}
@@ -105,7 +116,10 @@ class _BackendConfigScreenState extends State {
try {
// Normalize base URL by removing trailing slashes
- final normalizedUrl = _urlController.text.trim().replaceAll(RegExp(r'/+$'), '');
+ final normalizedUrl = _urlController.text.trim().replaceAll(
+ RegExp(r'/+$'),
+ '',
+ );
// Save URL to SharedPreferences
final prefs = await SharedPreferences.getInstance();
@@ -141,7 +155,8 @@ class _BackendConfigScreenState extends State {
final trimmedValue = value.trim();
// Check if it starts with http:// or https://
- if (!trimmedValue.startsWith('http://') && !trimmedValue.startsWith('https://')) {
+ if (!trimmedValue.startsWith('http://') &&
+ !trimmedValue.startsWith('https://')) {
return 'URL must start with http:// or https://';
}
@@ -180,7 +195,7 @@ class _BackendConfigScreenState extends State {
),
const SizedBox(height: 16),
Text(
- 'Backend Configuration',
+ 'Configuration',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.primary,
@@ -189,7 +204,7 @@ class _BackendConfigScreenState extends State {
),
const SizedBox(height: 8),
Text(
- 'Enter your Sure Finance backend URL',
+ 'Update your Sure server URL',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -209,10 +224,7 @@ class _BackendConfigScreenState extends State {
children: [
Row(
children: [
- Icon(
- Icons.info_outline,
- color: colorScheme.primary,
- ),
+ Icon(Icons.info_outline, color: colorScheme.primary),
const SizedBox(width: 12),
Text(
'Example URLs',
@@ -225,7 +237,7 @@ class _BackendConfigScreenState extends State {
),
const SizedBox(height: 12),
Text(
- '• https://sure.lazyrhythm.com\n'
+ '• https://demo.sure.am\n'
'• https://your-domain.com\n'
'• http://localhost:3000',
style: TextStyle(
@@ -249,15 +261,14 @@ class _BackendConfigScreenState extends State {
),
child: Row(
children: [
- Icon(
- Icons.error_outline,
- color: colorScheme.error,
- ),
+ Icon(Icons.error_outline, color: colorScheme.error),
const SizedBox(width: 12),
Expanded(
child: Text(
_errorMessage!,
- style: TextStyle(color: colorScheme.onErrorContainer),
+ style: TextStyle(
+ color: colorScheme.onErrorContainer,
+ ),
),
),
IconButton(
@@ -316,9 +327,9 @@ class _BackendConfigScreenState extends State {
autocorrect: false,
textInputAction: TextInputAction.done,
decoration: const InputDecoration(
- labelText: 'Backend URL',
+ labelText: 'Sure server URL',
prefixIcon: Icon(Icons.cloud_outlined),
- hintText: 'https://sure.lazyrhythm.com',
+ hintText: 'https://app.sure.am',
),
validator: _validateUrl,
onFieldSubmitted: (_) => _saveAndContinue(),
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,
+ ),
+ ),
+ ),
+ ],
],
- ],
+ ),
),
),
);
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/login_screen.dart b/mobile/lib/screens/login_screen.dart
index c524bada7..4e2879048 100644
--- a/mobile/lib/screens/login_screen.dart
+++ b/mobile/lib/screens/login_screen.dart
@@ -1,5 +1,8 @@
import 'package:flutter/material.dart';
+import 'package:flutter/gestures.dart';
import 'package:provider/provider.dart';
+import 'package:url_launcher/url_launcher.dart';
+import 'package:flutter_svg/flutter_svg.dart';
import '../providers/auth_provider.dart';
import '../services/api_config.dart';
@@ -18,20 +21,143 @@ class _LoginScreenState extends State {
final _passwordController = TextEditingController();
final _otpController = TextEditingController();
bool _obscurePassword = true;
+ late final TapGestureRecognizer _signUpTapRecognizer;
+
+ @override
+ void initState() {
+ super.initState();
+ _signUpTapRecognizer = TapGestureRecognizer()..onTap = _openSignUpPage;
+ }
@override
void dispose() {
+ _signUpTapRecognizer.dispose();
_emailController.dispose();
_passwordController.dispose();
_otpController.dispose();
super.dispose();
}
+ Future _openSignUpPage() async {
+ final signUpUrl = Uri.parse('${ApiConfig.defaultBaseUrl}/registration/new');
+ final launched = await launchUrl(
+ signUpUrl,
+ mode: LaunchMode.externalApplication,
+ );
+
+ if (!launched && mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Unable to open sign up page')),
+ );
+ }
+ }
+
+ 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;
final authProvider = Provider.of(context, listen: false);
- final hadOtpCode = authProvider.showMfaInput && _otpController.text.isNotEmpty;
+ final hadOtpCode =
+ authProvider.showMfaInput && _otpController.text.isNotEmpty;
final success = await authProvider.login(
email: _emailController.text.trim(),
@@ -54,256 +180,321 @@ class _LoginScreenState extends State {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
- appBar: AppBar(
- title: const Text(''),
- actions: [
- IconButton(
- icon: const Icon(Icons.settings_outlined),
- tooltip: 'Backend Settings',
- onPressed: widget.onGoToSettings,
- ),
- ],
- ),
body: SafeArea(
- child: SingleChildScrollView(
- padding: const EdgeInsets.all(24),
- child: Form(
- key: _formKey,
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- const SizedBox(height: 48),
- // Logo/Title
- Icon(
- Icons.account_balance_wallet,
- size: 80,
- color: colorScheme.primary,
- ),
- const SizedBox(height: 16),
- Text(
- 'Sure Finance',
- style: Theme.of(context).textTheme.headlineMedium?.copyWith(
- fontWeight: FontWeight.bold,
- color: colorScheme.primary,
- ),
- textAlign: TextAlign.center,
- ),
- const SizedBox(height: 8),
- Text(
- 'Sign in to manage your finances',
- style: Theme.of(context).textTheme.bodyLarge?.copyWith(
- color: colorScheme.onSurfaceVariant,
- ),
- textAlign: TextAlign.center,
- ),
- const SizedBox(height: 48),
-
- // Error Message
- Consumer(
- builder: (context, authProvider, _) {
- if (authProvider.errorMessage != null) {
- return Container(
- padding: const EdgeInsets.all(12),
- margin: const EdgeInsets.only(bottom: 16),
- decoration: BoxDecoration(
- color: colorScheme.errorContainer,
- borderRadius: BorderRadius.circular(12),
- ),
- child: Row(
- children: [
- Icon(
- Icons.error_outline,
- color: colorScheme.error,
+ child: Stack(
+ children: [
+ SingleChildScrollView(
+ padding: const EdgeInsets.all(24),
+ child: Form(
+ key: _formKey,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ const SizedBox(height: 48),
+ // Logo/Title
+ SvgPicture.asset(
+ 'assets/images/logomark.svg',
+ width: 80,
+ height: 80,
+ ),
+ const SizedBox(height: 8),
+ Text.rich(
+ TextSpan(
+ style: Theme.of(context).textTheme.bodyLarge?.copyWith(
+ color: colorScheme.onSurfaceVariant,
),
- const SizedBox(width: 12),
- Expanded(
- child: Text(
- authProvider.errorMessage!,
- style: TextStyle(color: colorScheme.onErrorContainer),
- ),
- ),
- IconButton(
- icon: const Icon(Icons.close),
- onPressed: () => authProvider.clearError(),
- iconSize: 20,
- ),
- ],
- ),
- );
- }
- return const SizedBox.shrink();
- },
- ),
-
- // Email Field
- TextFormField(
- controller: _emailController,
- keyboardType: TextInputType.emailAddress,
- autocorrect: false,
- textInputAction: TextInputAction.next,
- decoration: const InputDecoration(
- labelText: 'Email',
- prefixIcon: Icon(Icons.email_outlined),
- ),
- validator: (value) {
- if (value == null || value.isEmpty) {
- return 'Please enter your email';
- }
- if (!value.contains('@')) {
- return 'Please enter a valid email';
- }
- return null;
- },
- ),
- const SizedBox(height: 16),
-
- // Password and OTP Fields with Consumer
- Consumer(
- builder: (context, authProvider, _) {
- final showOtp = authProvider.showMfaInput;
-
- return Column(
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- // Password Field
- TextFormField(
- controller: _passwordController,
- obscureText: _obscurePassword,
- textInputAction: showOtp
- ? TextInputAction.next
- : TextInputAction.done,
- decoration: InputDecoration(
- labelText: 'Password',
- prefixIcon: const Icon(Icons.lock_outlined),
- suffixIcon: IconButton(
- icon: Icon(
- _obscurePassword
- ? Icons.visibility_outlined
- : Icons.visibility_off_outlined,
- ),
- onPressed: () {
- setState(() {
- _obscurePassword = !_obscurePassword;
- });
- },
+ children: [
+ const TextSpan(text: 'Please '),
+ TextSpan(
+ text: 'Sign Up',
+ style: TextStyle(
+ color: colorScheme.primary,
+ decoration: TextDecoration.underline,
+ fontWeight: FontWeight.w600,
),
+ recognizer: _signUpTapRecognizer,
),
- validator: (value) {
- if (value == null || value.isEmpty) {
- return 'Please enter your password';
- }
- return null;
- },
- onFieldSubmitted: showOtp ? null : (_) => _handleLogin(),
- ),
+ const TextSpan(text: ' first!'),
+ ],
+ ),
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 48),
- // OTP Field (shown when MFA is required)
- if (showOtp) ...[
- const SizedBox(height: 16),
- Container(
+ // Error Message
+ Consumer(
+ builder: (context, authProvider, _) {
+ if (authProvider.errorMessage != null) {
+ return Container(
padding: const EdgeInsets.all(12),
+ margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
- color: colorScheme.primaryContainer.withValues(alpha: 0.3),
+ color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
- Icons.security,
- color: colorScheme.primary,
+ Icons.error_outline,
+ color: colorScheme.error,
),
const SizedBox(width: 12),
Expanded(
child: Text(
- 'Two-factor authentication is enabled. Enter your code.',
- style: TextStyle(color: colorScheme.onSurface),
+ authProvider.errorMessage!,
+ style: TextStyle(
+ color: colorScheme.onErrorContainer),
),
),
+ IconButton(
+ icon: const Icon(Icons.close),
+ onPressed: () => authProvider.clearError(),
+ iconSize: 20,
+ ),
],
),
- ),
- const SizedBox(height: 16),
- TextFormField(
- controller: _otpController,
- keyboardType: TextInputType.number,
- textInputAction: TextInputAction.done,
- decoration: const InputDecoration(
- labelText: 'Authentication Code',
- prefixIcon: Icon(Icons.pin_outlined),
+ );
+ }
+ return const SizedBox.shrink();
+ },
+ ),
+
+ // Email Field
+ TextFormField(
+ controller: _emailController,
+ keyboardType: TextInputType.emailAddress,
+ autocorrect: false,
+ textInputAction: TextInputAction.next,
+ decoration: const InputDecoration(
+ labelText: 'Email',
+ prefixIcon: Icon(Icons.email_outlined),
+ ),
+ validator: (value) {
+ if (value == null || value.isEmpty) {
+ return 'Please enter your email';
+ }
+ if (!value.contains('@')) {
+ return 'Please enter a valid email';
+ }
+ return null;
+ },
+ ),
+ const SizedBox(height: 16),
+
+ // Password and OTP Fields with Consumer
+ Consumer(
+ builder: (context, authProvider, _) {
+ final showOtp = authProvider.showMfaInput;
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ // Password Field
+ TextFormField(
+ controller: _passwordController,
+ obscureText: _obscurePassword,
+ textInputAction: showOtp
+ ? TextInputAction.next
+ : TextInputAction.done,
+ decoration: InputDecoration(
+ labelText: 'Password',
+ prefixIcon: const Icon(Icons.lock_outlined),
+ suffixIcon: IconButton(
+ icon: Icon(
+ _obscurePassword
+ ? Icons.visibility_outlined
+ : Icons.visibility_off_outlined,
+ ),
+ onPressed: () {
+ setState(() {
+ _obscurePassword = !_obscurePassword;
+ });
+ },
+ ),
+ ),
+ validator: (value) {
+ if (value == null || value.isEmpty) {
+ return 'Please enter your password';
+ }
+ return null;
+ },
+ onFieldSubmitted:
+ showOtp ? null : (_) => _handleLogin(),
),
- validator: (value) {
- if (showOtp && (value == null || value.isEmpty)) {
- return 'Please enter your authentication code';
- }
- return null;
- },
- onFieldSubmitted: (_) => _handleLogin(),
+
+ // OTP Field (shown when MFA is required)
+ if (showOtp) ...[
+ const SizedBox(height: 16),
+ Container(
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: colorScheme.primaryContainer
+ .withValues(alpha: 0.3),
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Row(
+ children: [
+ Icon(
+ Icons.security,
+ color: colorScheme.primary,
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Text(
+ 'Two-factor authentication is enabled. Enter your code.',
+ style: TextStyle(
+ color: colorScheme.onSurface),
+ ),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 16),
+ TextFormField(
+ controller: _otpController,
+ keyboardType: TextInputType.number,
+ textInputAction: TextInputAction.done,
+ decoration: const InputDecoration(
+ labelText: 'Authentication Code',
+ prefixIcon: Icon(Icons.pin_outlined),
+ ),
+ validator: (value) {
+ if (showOtp &&
+ (value == null || value.isEmpty)) {
+ return 'Please enter your authentication code';
+ }
+ return null;
+ },
+ onFieldSubmitted: (_) => _handleLogin(),
+ ),
+ ],
+ ],
+ );
+ },
+ ),
+
+ const SizedBox(height: 24),
+
+ // Login Button
+ Consumer(
+ builder: (context, authProvider, _) {
+ return ElevatedButton(
+ onPressed:
+ authProvider.isLoading ? null : _handleLogin,
+ child: authProvider.isLoading
+ ? const SizedBox(
+ height: 20,
+ width: 20,
+ child:
+ CircularProgressIndicator(strokeWidth: 2),
+ )
+ : const Text('Sign In'),
+ );
+ },
+ ),
+
+ const SizedBox(height: 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: SvgPicture.asset(
+ 'assets/images/google_g_logo.svg',
+ width: 18,
+ height: 18,
+ ),
+ 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),
+ decoration: BoxDecoration(
+ color: colorScheme.surfaceContainerHighest
+ .withValues(alpha: 0.3),
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: Column(
+ children: [
+ Text(
+ 'Sure server URL:',
+ style:
+ Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ ApiConfig.baseUrl,
+ style:
+ Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: colorScheme.primary,
+ fontFamily: 'monospace',
+ ),
+ textAlign: TextAlign.center,
),
],
- ],
- );
- },
- ),
-
- const SizedBox(height: 24),
-
- // Login Button
- Consumer(
- builder: (context, authProvider, _) {
- return ElevatedButton(
- onPressed: authProvider.isLoading ? null : _handleLogin,
- child: authProvider.isLoading
- ? const SizedBox(
- height: 20,
- width: 20,
- child: CircularProgressIndicator(strokeWidth: 2),
- )
- : const Text('Sign In'),
- );
- },
- ),
-
- const SizedBox(height: 24),
-
- // Backend URL info
- Container(
- padding: const EdgeInsets.all(12),
- decoration: BoxDecoration(
- color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
- borderRadius: BorderRadius.circular(8),
- ),
- child: Column(
- children: [
- Text(
- 'Backend URL:',
- style: Theme.of(context).textTheme.bodySmall?.copyWith(
- color: colorScheme.onSurfaceVariant,
- fontWeight: FontWeight.bold,
- ),
),
- const SizedBox(height: 4),
- Text(
- ApiConfig.baseUrl,
- style: Theme.of(context).textTheme.bodySmall?.copyWith(
- color: colorScheme.primary,
- fontFamily: 'monospace',
- ),
- textAlign: TextAlign.center,
- ),
- ],
- ),
+ ),
+
+ const SizedBox(height: 12),
+
+ // API Key Login Button
+ TextButton.icon(
+ onPressed: _showApiKeyDialog,
+ icon: const Icon(Icons.vpn_key_outlined, size: 18),
+ label: const Text('API-Key Login'),
+ ),
+ ],
),
- const SizedBox(height: 8),
- Text(
- 'Connect to your Sure Finance server to manage your accounts.',
- style: Theme.of(context).textTheme.bodySmall?.copyWith(
- color: colorScheme.onSurfaceVariant,
- ),
- textAlign: TextAlign.center,
- ),
- ],
+ ),
),
- ),
+ Positioned(
+ right: 8,
+ top: 8,
+ child: IconButton(
+ icon: const Icon(Icons.settings_outlined),
+ tooltip: 'Backend Settings',
+ onPressed: widget.onGoToSettings,
+ ),
+ ),
+ ],
),
),
);
diff --git a/mobile/lib/screens/main_navigation_screen.dart b/mobile/lib/screens/main_navigation_screen.dart
index a253bf614..7b537667e 100644
--- a/mobile/lib/screens/main_navigation_screen.dart
+++ b/mobile/lib/screens/main_navigation_screen.dart
@@ -1,4 +1,6 @@
import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+import '../providers/auth_provider.dart';
import 'dashboard_screen.dart';
import 'chat_list_screen.dart';
import 'more_screen.dart';
@@ -13,51 +15,149 @@ class MainNavigationScreen extends StatefulWidget {
class _MainNavigationScreenState extends State {
int _currentIndex = 0;
+ final _dashboardKey = GlobalKey();
- final List _screens = [
- const DashboardScreen(),
- const ChatListScreen(),
- const MoreScreen(),
- const SettingsScreen(),
- ];
+ List _buildScreens(bool introLayout) {
+ final screens = [];
- @override
- Widget build(BuildContext context) {
- return Scaffold(
- body: IndexedStack(
- index: _currentIndex,
- children: _screens,
+ if (!introLayout) {
+ screens.add(DashboardScreen(key: _dashboardKey));
+ }
+
+ screens.add(const ChatListScreen());
+
+ if (!introLayout) {
+ screens.add(const MoreScreen());
+ }
+
+ screens.add(const SettingsScreen());
+
+ return screens;
+ }
+
+ List _buildDestinations(bool introLayout) {
+ final destinations = [];
+
+ if (!introLayout) {
+ destinations.add(
+ const NavigationDestination(
+ icon: Icon(Icons.home_outlined),
+ selectedIcon: Icon(Icons.home),
+ label: 'Home',
+ ),
+ );
+ }
+
+ destinations.add(
+ const NavigationDestination(
+ icon: Icon(Icons.chat_bubble_outline),
+ selectedIcon: Icon(Icons.chat_bubble),
+ label: 'AI Chat',
),
- bottomNavigationBar: NavigationBar(
- selectedIndex: _currentIndex,
- onDestinationSelected: (index) {
- setState(() {
- _currentIndex = index;
- });
- },
- destinations: const [
- NavigationDestination(
- icon: Icon(Icons.home_outlined),
- selectedIcon: Icon(Icons.home),
- label: 'Home',
+ );
+
+ if (!introLayout) {
+ destinations.add(
+ const NavigationDestination(
+ icon: Icon(Icons.more_horiz),
+ selectedIcon: Icon(Icons.more_horiz),
+ label: 'More',
+ ),
+ );
+ }
+
+ destinations.add(
+ const NavigationDestination(
+ icon: Icon(Icons.settings_outlined),
+ selectedIcon: Icon(Icons.settings),
+ label: 'Settings',
+ ),
+ );
+
+ return destinations;
+ }
+
+ Future _showEnableAiPrompt() async {
+ final authProvider = Provider.of(context, listen: false);
+
+ final shouldEnable = await showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('Turn on AI Chat?'),
+ content: const Text('AI Chat is currently disabled in your account settings. Would you like to turn it on now?'),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(false),
+ child: const Text('Not now'),
),
- NavigationDestination(
- icon: Icon(Icons.chat_bubble_outline),
- selectedIcon: Icon(Icons.chat_bubble),
- label: 'AI Chat',
- ),
- NavigationDestination(
- icon: Icon(Icons.more_horiz),
- selectedIcon: Icon(Icons.more_horiz),
- label: 'More',
- ),
- NavigationDestination(
- icon: Icon(Icons.settings_outlined),
- selectedIcon: Icon(Icons.settings),
- label: 'Settings',
+ FilledButton(
+ onPressed: () => Navigator.of(context).pop(true),
+ child: const Text('Turn on AI'),
),
],
),
);
+
+ if (shouldEnable != true) {
+ return false;
+ }
+
+ final enabled = await authProvider.enableAi();
+
+ if (!enabled && mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(authProvider.errorMessage ?? 'Unable to enable AI right now.'),
+ backgroundColor: Colors.red,
+ ),
+ );
+ }
+
+ return enabled;
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Consumer(
+ builder: (context, authProvider, _) {
+ final introLayout = authProvider.isIntroLayout;
+ final screens = _buildScreens(introLayout);
+ final destinations = _buildDestinations(introLayout);
+
+ if (_currentIndex >= screens.length) {
+ _currentIndex = 0;
+ }
+
+ final chatIndex = introLayout ? 0 : 1;
+ final homeIndex = 0;
+
+ return Scaffold(
+ body: IndexedStack(
+ index: _currentIndex,
+ children: screens,
+ ),
+ bottomNavigationBar: NavigationBar(
+ selectedIndex: _currentIndex,
+ onDestinationSelected: (index) async {
+ if (index == chatIndex && !authProvider.aiEnabled) {
+ final enabled = await _showEnableAiPrompt();
+ if (!enabled) {
+ return;
+ }
+ }
+
+ setState(() {
+ _currentIndex = index;
+ });
+
+ if (!introLayout && index == homeIndex) {
+ _dashboardKey.currentState?.reloadPreferences();
+ }
+ },
+ destinations: destinations,
+ ),
+ );
+ },
+ );
}
}
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/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..441618a7c 100644
--- a/mobile/lib/services/api_config.dart
+++ b/mobile/lib/services/api_config.dart
@@ -5,29 +5,64 @@ class ApiConfig {
// For local development, use: http://10.0.2.2:3000 (Android emulator)
// For iOS simulator, use: http://localhost:3000
// For production, use your actual server URL
- static String _baseUrl = 'http://10.0.2.2:3000';
+ static const String _defaultBaseUrl = 'https://demo.sure.am';
+ static const String _backendUrlKey = 'backend_url';
+ static String _baseUrl = _defaultBaseUrl;
static String get baseUrl => _baseUrl;
+ static String get defaultBaseUrl => _defaultBaseUrl;
static void setBaseUrl(String url) {
_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
+ /// Returns true when a backend URL is configured (stored or default)
static Future initialize() async {
try {
final prefs = await SharedPreferences.getInstance();
- final savedUrl = prefs.getString('backend_url');
+ final savedUrl = prefs.getString(_backendUrlKey);
if (savedUrl != null && savedUrl.isNotEmpty) {
_baseUrl = savedUrl;
return true;
}
- return false;
+
+ // Seed first launch with the active development backend so the app can
+ // go straight to login while still letting users override it later.
+ _baseUrl = _defaultBaseUrl;
+ await prefs.setString(_backendUrlKey, _defaultBaseUrl);
+ return true;
} catch (e) {
// If initialization fails, keep the default URL
- return false;
+ _baseUrl = _defaultBaseUrl;
+ return true;
}
}
diff --git a/mobile/lib/services/auth_service.dart b/mobile/lib/services/auth_service.dart
index 789030abe..874b63068 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
<%= format_money -entry.amount_money %> - <%= 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") %> +transaction
+<%= t("transactions.show.amount") %>
+<%= t("transactions.list.transaction") %>
<%= t("transactions.show.amount") %>
++ <%= tag.span I18n.l(date, format: :long) %> + · + <%= tag.span transactions.size %> +
+transaction
-amount
-<%= t("recurring_transactions.upcoming") %>
-<%= t("transactions.show.potential_duplicate_title") %>
<%= t("transactions.show.potential_duplicate_description") %>
-Provider extras
@@ -187,7 +169,6 @@<%= t(".exclude") %>
<%= t(".exclude_description") %>
<%= t(".activity_type") %>
<%= t(".activity_type_description") %>
<%= t(".one_time_title", type: @entry.amount.negative? ? t("transactions.form.income") : t("transactions.form.expense")) %>
<%= t(".one_time_description") %>
Transfer or Debt Payment?
Transfers and payments are special types of transactions that indicate money movement between 2 accounts.
Convert to Security Trade
Convert this transaction into a security trade (buy/sell) by providing ticker, shares, and price.
<%= t(".mark_recurring_title") %>
<%= t(".mark_recurring_subtitle") %>
<%= t(".delete_title") %>
<%= t(".delete_subtitle") %>
+<%= render DS::Dialog.new(frame: "drawer", responsive: true) do |dialog| %>
+ <% dialog.with_header(custom_header: true) do %>
+
+
+
<%= format_money @transfer.amount_abs %>
-
<%= @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 %>
-
+ <%= dialog.close_button %>
<% end %>
-
<% dialog.with_body do %>
<% dialog.with_section(title: t(".overview"), open: true) do %>
@@ -32,20 +29,16 @@
<%= link_to @transfer.from_account.name, account_path(@transfer.from_account), data: { turbo_frame: "_top" } %>
-
- Date
- <%= l(@transfer.outflow_transaction.entry.date, format: :long) %>
-
- Amount
- <%= format_money @transfer.outflow_transaction.entry.amount_money * -1 %>
-
<%= render "shared/ruler", classes: "my-2" %>
-
- To
@@ -54,12 +47,10 @@
<%= link_to @transfer.to_account.name, account_path(@transfer.to_account), data: { turbo_frame: "_top" } %>
-
- Date
- <%= l(@transfer.inflow_transaction.entry.date, format: :long) %>
-
- Amount
- +<%= format_money @transfer.inflow_transaction.entry.amount_money * -1 %>
@@ -67,14 +58,12 @@
<%= format_money @transfer.amount_abs %> - <%= @transfer.amount_abs.currency.iso_code %> + <%= icon "arrow-left-right", size: "sm", class: "text-secondary" %>
- - <%= icon "arrow-left-right", size: "sm" %> + + <%= @transfer.name %> +- Date
- <%= l(@transfer.outflow_transaction.entry.date, format: :long) %>
- Amount
- <%= format_money @transfer.outflow_transaction.entry.amount_money * -1 %>
- To @@ -54,12 +47,10 @@ <%= link_to @transfer.to_account.name, account_path(@transfer.to_account), data: { turbo_frame: "_top" } %>
- Date
- <%= l(@transfer.inflow_transaction.entry.date, format: :long) %>
- Amount
- +<%= format_money @transfer.inflow_transaction.entry.amount_money * -1 %> @@ -67,14 +58,12 @@
<%= t(".delete_title") %>
<%= t(".delete_subtitle") %>
<%= t(".delete_title") %>
<%= t(".delete_subtitle") %>
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..c265f8c64 100644 --- a/config/locales/views/onboardings/en.yml +++ b/config/locales/views/onboardings/en.yml @@ -2,27 +2,65 @@ 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 + group_name: Group name + group_name_placeholder: Group name + household_name: Household name + household_name_placeholder: Household name + moniker_prompt: "Will be using %{product_name} with ..." + moniker_family: Family members (just yourself or with partner, teens, etc.) + moniker_group: Group of people (company, club, association, any other type) + 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! 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/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/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/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..7f61905f9 100644 --- a/config/locales/views/registrations/en.yml +++ b/config/locales/views/registrations/en.yml @@ -15,8 +15,9 @@ en: success: You have signed up successfully. new: invitation_message: "%{inviter} has invited you to join as a %{role}" - join_family_title: Join %{family} + join_family_title: Join %{family} %{moniker} role_admin: administrator + role_guest: guest role_member: member submit: Create account title: Create your account @@ -24,3 +25,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/rules/ca.yml b/config/locales/views/rules/ca.yml index c4b40ca90..b1cacb21f 100644 --- a/config/locales/views/rules/ca.yml +++ b/config/locales/views/rules/ca.yml @@ -19,6 +19,7 @@ ca: success: Totes les regles s'han posat a cua per a execució view_usage: Veure l'historial d'ús no_action: Sense acció + no_condition: Sense condició recent_runs: columns: date_time: Data/Hora diff --git a/config/locales/views/rules/de.yml b/config/locales/views/rules/de.yml index a8a80bdff..cc57171b0 100644 --- a/config/locales/views/rules/de.yml +++ b/config/locales/views/rules/de.yml @@ -2,6 +2,7 @@ de: rules: no_action: Keine Aktion + no_condition: Keine Bedingung recent_runs: title: Letzte Ausführungen description: Zeige die Ausführungsgeschichte deiner Regeln einschließlich Erfolgs-/Fehlerstatus und Transaktionsanzahlen. @@ -22,4 +23,3 @@ de: pending: Ausstehend success: Erfolgreich failed: Fehlgeschlagen - diff --git a/config/locales/views/rules/en.yml b/config/locales/views/rules/en.yml index 5cbd252d5..81eccc7e8 100644 --- a/config/locales/views/rules/en.yml +++ b/config/locales/views/rules/en.yml @@ -2,6 +2,7 @@ en: rules: no_action: No Action + no_condition: No condition actions: value_placeholder: Enter a value apply_all: @@ -37,3 +38,15 @@ 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. + condition_filters: + transaction_type: + income: Income + expense: Expense + transfer: Transfer + equal_to: Equal to diff --git a/config/locales/views/rules/es.yml b/config/locales/views/rules/es.yml index 472ed0240..3c494e8fb 100644 --- a/config/locales/views/rules/es.yml +++ b/config/locales/views/rules/es.yml @@ -2,6 +2,7 @@ es: rules: no_action: Sin acción + no_condition: Sin condición recent_runs: title: Ejecuciones Recientes description: Ver el historial de ejecución de tus reglas incluyendo el estado de éxito/fallo y los conteos de transacciones. @@ -22,4 +23,3 @@ es: pending: Pendiente success: Éxito failed: Fallido - diff --git a/config/locales/views/rules/fr.yml b/config/locales/views/rules/fr.yml index daa5c42cc..dfa7b9755 100644 --- a/config/locales/views/rules/fr.yml +++ b/config/locales/views/rules/fr.yml @@ -2,6 +2,7 @@ fr: rules: no_action: Aucune action + no_condition: Aucune condition actions: value_placeholder: Entrez une valeur apply_all: diff --git a/config/locales/views/rules/nb.yml b/config/locales/views/rules/nb.yml index 1787cf18e..c4a333b40 100644 --- a/config/locales/views/rules/nb.yml +++ b/config/locales/views/rules/nb.yml @@ -2,6 +2,7 @@ nb: rules: no_action: Ingen handling + no_condition: Ingen betingelse recent_runs: title: Siste Kjøringer description: Se kjøringsloggen for reglene dine inkludert suksess/feil-status og transaksjonsantall. @@ -22,4 +23,3 @@ nb: pending: Ventende success: Vellykket failed: Mislyktes - diff --git a/config/locales/views/rules/nl.yml b/config/locales/views/rules/nl.yml index 9ef32b833..a789d20c9 100644 --- a/config/locales/views/rules/nl.yml +++ b/config/locales/views/rules/nl.yml @@ -2,6 +2,7 @@ nl: rules: no_action: Geen actie + no_condition: Geen voorwaarde actions: value_placeholder: Voer een waarde in apply_all: diff --git a/config/locales/views/rules/ro.yml b/config/locales/views/rules/ro.yml index 3f3ccb8c5..c5e877aab 100644 --- a/config/locales/views/rules/ro.yml +++ b/config/locales/views/rules/ro.yml @@ -2,6 +2,7 @@ ro: rules: no_action: Nicio acțiune + no_condition: Nicio condiție recent_runs: title: Rulări Recente description: Vezi istoricul de execuție al regulilor tale incluzând statusul de succes/eșec și numărul de tranzacții. @@ -22,4 +23,3 @@ ro: pending: În Așteptare success: Succes failed: Eșuat - diff --git a/config/locales/views/rules/tr.yml b/config/locales/views/rules/tr.yml index c2d50c11d..b1418415e 100644 --- a/config/locales/views/rules/tr.yml +++ b/config/locales/views/rules/tr.yml @@ -2,6 +2,7 @@ tr: rules: no_action: İşlem yok + no_condition: Koşul yok recent_runs: title: Son Çalıştırmalar description: Başarı/başarısızlık durumu ve işlem sayıları dahil olmak üzere kurallarınızın yürütme geçmişini görüntüleyin. @@ -22,4 +23,3 @@ tr: pending: Beklemede success: Başarılı failed: Başarısız - diff --git a/config/locales/views/rules/zh-CN.yml b/config/locales/views/rules/zh-CN.yml index 5044d8b00..6f44adc5f 100644 --- a/config/locales/views/rules/zh-CN.yml +++ b/config/locales/views/rules/zh-CN.yml @@ -2,6 +2,7 @@ zh-CN: rules: no_action: 无操作 + no_condition: 无条件 recent_runs: columns: date_time: 日期/时间 diff --git a/config/locales/views/rules/zh-TW.yml b/config/locales/views/rules/zh-TW.yml index 5a8bdf3bd..84846ce24 100644 --- a/config/locales/views/rules/zh-TW.yml +++ b/config/locales/views/rules/zh-TW.yml @@ -2,6 +2,7 @@ zh-TW: rules: no_action: 無動作 + no_condition: 無條件 recent_runs: title: 最近執行紀錄 description: 查看規則的執行歷史,包括成功/失敗狀態以及交易處理筆數。 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..ea49a80a0 100644 --- a/config/locales/views/sessions/en.yml +++ b/config/locales/views/sessions/en.yml @@ -9,6 +9,7 @@ en: post_logout: logout_successful: You have signed out successfully. openid_connect: + account_linked: "Account successfully linked to %{provider}" failed: Could not authenticate via OpenID Connect. failure: failed: Could not authenticate. @@ -24,6 +25,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/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index ca407957b..55730d2b6 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. @@ -77,10 +80,12 @@ en: reset_account_with_sample_data_warning: Delete all your existing data and then load fresh sample data so you can explore with a pre-filled environment. email: Email first_name: First Name + group_form_input_placeholder: Enter group name + group_form_label: Group name + group_title: Group Members household_form_input_placeholder: Enter household name household_form_label: Household name - household_subtitle: Invite family members, partners and other inviduals. Invitees - can login to your household and access your shared accounts. + household_subtitle: Invitees can login to your %{moniker} account and access shared resources. household_title: Household invitation_link: Invitation link invite_member: Add member 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/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 diff --git a/config/locales/views/simplefin_items/en.yml b/config/locales/views/simplefin_items/en.yml index 216f9cc2d..9929eb64c 100644 --- a/config/locales/views/simplefin_items/en.yml +++ b/config/locales/views/simplefin_items/en.yml @@ -87,7 +87,7 @@ en: description: Select a SimpleFIN account to link to your existing account cancel: Cancel link_account: Link account - no_accounts_found: No SimpleFIN accounts found for this family. + no_accounts_found: "No SimpleFIN accounts found for this %{moniker}." wait_for_sync: If you just connected or synced, try again after the sync completes. unlink_to_move: To move a link, first unlink it from the account’s actions menu. all_accounts_already_linked: All SimpleFIN accounts appear to be linked already. diff --git a/config/locales/views/snaptrade_items/en.yml b/config/locales/views/snaptrade_items/en.yml index 779c8a93c..63b70a28c 100644 --- a/config/locales/views/snaptrade_items/en.yml +++ b/config/locales/views/snaptrade_items/en.yml @@ -8,8 +8,8 @@ en: destroy: success: "Scheduled SnapTrade connection for deletion." connect: - registration_failed: "Failed to register with SnapTrade: %{message}" - portal_error: "Failed to connect to SnapTrade: %{message}" + decryption_failed: "Unable to read SnapTrade credentials. Please delete and recreate this connection." + connection_failed: "Failed to connect to SnapTrade: %{message}" callback: success: "Brokerage connected! Please select which accounts to link." no_item: "SnapTrade configuration not found." @@ -75,6 +75,9 @@ en: cancel_button: "Cancel" creating: "Creating Accounts..." done_button: "Done" + or_link_existing: "Or link to an existing account instead of creating a new one:" + select_account: "Select an account..." + link_button: "Link" linked_accounts: "Already Linked" linked_to: "Linked to:" snaptrade_item: diff --git a/config/locales/views/transactions/en.yml b/config/locales/views/transactions/en.yml index 00078d609..02287d644 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 @@ -74,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 @@ -95,6 +98,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: diff --git a/config/routes.rb b/config/routes.rb index 428c589dc..5badb50fa 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,6 +2,21 @@ require "sidekiq/web" require "sidekiq/cron/web" Rails.application.routes.draw do + resources :indexa_capital_items, only: [ :index, :new, :create, :show, :edit, :update, :destroy ] do + collection do + get :preload_accounts + get :select_accounts + post :link_accounts + get :select_existing_account + post :link_existing_account + end + + member do + post :sync + get :setup_accounts + post :complete_account_setup + end + end resources :mercury_items, only: %i[index new create show edit update destroy] do collection do get :preload_accounts @@ -118,6 +133,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" @@ -208,7 +224,7 @@ Rails.application.routes.draw do resources :transfers, only: %i[new create destroy show update] - resources :imports, only: %i[index new show create destroy] do + resources :imports, only: %i[index new show create update destroy] do member do post :publish put :revert @@ -297,6 +313,7 @@ Rails.application.routes.draw do delete :destroy_all get :confirm_all post :apply_all + post :clear_ai_cache end end @@ -354,6 +371,8 @@ 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" + patch "auth/enable_ai", to: "auth#enable_ai" # Production API endpoints resources :accounts, only: [ :index, :show ] @@ -362,6 +381,9 @@ Rails.application.routes.draw do resources :tags, only: %i[index show create update destroy] resources :transactions, only: [ :index, :show, :create, :update, :destroy ] + resources :trades, only: [ :index, :show, :create, :update, :destroy ] + resources :holdings, only: [ :index, :show ] + resources :valuations, only: [ :create, :update, :show ] resources :imports, only: [ :index, :show, :create ] resource :usage, only: [ :show ], controller: :usage post :sync, to: "sync#create" @@ -461,6 +483,7 @@ Rails.application.routes.draw do terms_url = ENV["LEGAL_TERMS_URL"].presence get "privacy", to: privacy_url ? redirect(privacy_url) : "pages#privacy" get "terms", to: terms_url ? redirect(terms_url) : "pages#terms" + get "intro", to: "pages#intro" # Admin namespace for super admin functionality namespace :admin do diff --git a/db/migrate/20240701000000_add_category_fields_to_import_rows.rb b/db/migrate/20240701000000_add_category_fields_to_import_rows.rb deleted file mode 100644 index 8a4210223..000000000 --- a/db/migrate/20240701000000_add_category_fields_to_import_rows.rb +++ /dev/null @@ -1,7 +0,0 @@ -class AddCategoryFieldsToImportRows < ActiveRecord::Migration[7.1] - def change - add_column :import_rows, :category_parent, :string - add_column :import_rows, :category_color, :string - add_column :import_rows, :category_classification, :string - end -end diff --git a/db/migrate/20240701000001_add_category_icon_to_import_rows.rb b/db/migrate/20240701000001_add_category_icon_to_import_rows.rb deleted file mode 100644 index 66f389233..000000000 --- a/db/migrate/20240701000001_add_category_icon_to_import_rows.rb +++ /dev/null @@ -1,5 +0,0 @@ -class AddCategoryIconToImportRows < ActiveRecord::Migration[7.1] - def change - add_column :import_rows, :category_icon, :string - end -end diff --git a/db/migrate/20240925112219_ensure_category_fields_on_import_rows.rb b/db/migrate/20240925112219_ensure_category_fields_on_import_rows.rb new file mode 100644 index 000000000..ad6f5e72f --- /dev/null +++ b/db/migrate/20240925112219_ensure_category_fields_on_import_rows.rb @@ -0,0 +1,7 @@ +class EnsureCategoryFieldsOnImportRows < ActiveRecord::Migration[7.2] + def change + add_column :import_rows, :category_parent, :string unless column_exists?(:import_rows, :category_parent) + add_column :import_rows, :category_color, :string unless column_exists?(:import_rows, :category_color) + add_column :import_rows, :category_classification, :string unless column_exists?(:import_rows, :category_classification) + end +end diff --git a/db/migrate/20240925112220_ensure_category_icon_on_import_rows.rb b/db/migrate/20240925112220_ensure_category_icon_on_import_rows.rb new file mode 100644 index 000000000..aaacb2275 --- /dev/null +++ b/db/migrate/20240925112220_ensure_category_icon_on_import_rows.rb @@ -0,0 +1,5 @@ +class EnsureCategoryIconOnImportRows < ActiveRecord::Migration[7.2] + def change + add_column :import_rows, :category_icon, :string unless column_exists?(:import_rows, :category_icon) + end +end diff --git a/db/migrate/20251030140000_add_ui_layout_to_users.rb b/db/migrate/20251030140000_add_ui_layout_to_users.rb new file mode 100644 index 000000000..236498a62 --- /dev/null +++ b/db/migrate/20251030140000_add_ui_layout_to_users.rb @@ -0,0 +1,16 @@ +class AddUiLayoutToUsers < ActiveRecord::Migration[7.2] + class MigrationUser < ApplicationRecord + self.table_name = "users" + end + + def up + add_column :users, :ui_layout, :string, if_not_exists: true + + MigrationUser.reset_column_information + MigrationUser.where(ui_layout: [ nil, "" ]).update_all(ui_layout: "dashboard") + end + + def down + remove_column :users, :ui_layout + end +end 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/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/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/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/migrate/20260205110328_create_indexa_capital_items_and_accounts.rb b/db/migrate/20260205110328_create_indexa_capital_items_and_accounts.rb new file mode 100644 index 000000000..804594f82 --- /dev/null +++ b/db/migrate/20260205110328_create_indexa_capital_items_and_accounts.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +class CreateIndexaCapitalItemsAndAccounts < ActiveRecord::Migration[7.2] + def change + # Create provider items table (stores per-family connection credentials) + create_table :indexa_capital_items, id: :uuid do |t| + t.references :family, null: false, foreign_key: true, type: :uuid + t.string :name + + # Institution metadata + t.string :institution_id + t.string :institution_name + t.string :institution_domain + t.string :institution_url + t.string :institution_color + + # Status and lifecycle + t.string :status, default: "good" + t.boolean :scheduled_for_deletion, default: false + t.boolean :pending_account_setup, default: false + + # Sync settings + t.datetime :sync_start_date + + # Raw data storage + t.jsonb :raw_payload + t.jsonb :raw_institution_payload + + # Provider-specific credential fields + t.string :username + t.string :document + t.text :password + + t.timestamps + end + + add_index :indexa_capital_items, :status + + # Create provider accounts table (stores individual account data from provider) + create_table :indexa_capital_accounts, id: :uuid do |t| + t.references :indexa_capital_item, null: false, foreign_key: true, type: :uuid + + # Account identification + t.string :name + t.string :indexa_capital_account_id + t.string :account_number + + # Account details + t.string :currency + t.decimal :current_balance, precision: 19, scale: 4 + t.string :account_status + t.string :account_type + t.string :provider + + # Metadata and raw data + t.jsonb :institution_metadata + t.jsonb :raw_payload + + # Investment-specific columns + t.string :indexa_capital_authorization_id + t.decimal :cash_balance, precision: 19, scale: 4, default: 0.0 + t.jsonb :raw_holdings_payload, default: [] + t.jsonb :raw_activities_payload, default: [] + t.datetime :last_holdings_sync + t.datetime :last_activities_sync + t.boolean :activities_fetch_pending, default: false + + # Sync settings + t.date :sync_start_date + + t.timestamps + end + + add_index :indexa_capital_accounts, :indexa_capital_account_id, unique: true + add_index :indexa_capital_accounts, :indexa_capital_authorization_id + end +end diff --git a/db/migrate/20260207231945_add_api_token_to_indexa_capital_items.rb b/db/migrate/20260207231945_add_api_token_to_indexa_capital_items.rb new file mode 100644 index 000000000..5ae9d5975 --- /dev/null +++ b/db/migrate/20260207231945_add_api_token_to_indexa_capital_items.rb @@ -0,0 +1,5 @@ +class AddApiTokenToIndexaCapitalItems < ActiveRecord::Migration[7.2] + def change + add_column :indexa_capital_items, :api_token, :text + end +end diff --git a/db/migrate/20260210120000_remove_flipper_tables.rb b/db/migrate/20260210120000_remove_flipper_tables.rb new file mode 100644 index 000000000..c9e64a869 --- /dev/null +++ b/db/migrate/20260210120000_remove_flipper_tables.rb @@ -0,0 +1,22 @@ +class RemoveFlipperTables < ActiveRecord::Migration[7.2] + def up + drop_table :flipper_gates, if_exists: true + drop_table :flipper_features, if_exists: true + end + + def down + create_table :flipper_features do |t| + t.string :key, null: false + t.timestamps null: false + end + add_index :flipper_features, :key, unique: true + + create_table :flipper_gates do |t| + t.string :feature_key, null: false + t.string :key, null: false + t.text :value + t.timestamps null: false + end + add_index :flipper_gates, [ :feature_key, :key, :value ], unique: true, length: { value: 255 } + end +end diff --git a/db/migrate/20260211101500_add_moniker_to_families.rb b/db/migrate/20260211101500_add_moniker_to_families.rb new file mode 100644 index 000000000..fa79636a4 --- /dev/null +++ b/db/migrate/20260211101500_add_moniker_to_families.rb @@ -0,0 +1,5 @@ +class AddMonikerToFamilies < ActiveRecord::Migration[7.2] + def change + add_column :families, :moniker, :string, null: false, default: "Family" + end +end diff --git a/db/migrate/20260211120001_add_vector_store_support.rb b/db/migrate/20260211120001_add_vector_store_support.rb new file mode 100644 index 000000000..b4a8355f0 --- /dev/null +++ b/db/migrate/20260211120001_add_vector_store_support.rb @@ -0,0 +1,19 @@ +class AddVectorStoreSupport < ActiveRecord::Migration[7.2] + def change + add_column :families, :vector_store_id, :string + + create_table :family_documents, id: :uuid, default: -> { "gen_random_uuid()" } do |t| + t.references :family, null: false, foreign_key: true, type: :uuid + t.string :filename, null: false + t.string :content_type + t.integer :file_size + t.string :provider_file_id + t.string :status, null: false, default: "pending" + t.jsonb :metadata, default: {} + t.timestamps + end + + add_index :family_documents, :status + add_index :family_documents, :provider_file_id + end +end diff --git a/db/schema.rb b/db/schema.rb index 9b7bcb296..fe8f6523b 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_02_11_120001) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -499,6 +499,25 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_24_180211) 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.string "vector_store_id" + t.string "moniker", default: "Family", null: false + t.check_constraint "month_start_day >= 1 AND month_start_day <= 28", name: "month_start_day_range" + end + + create_table "family_documents", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "family_id", null: false + t.string "filename", null: false + t.string "content_type" + t.integer "file_size" + t.string "provider_file_id" + t.string "status", default: "pending", null: false + t.jsonb "metadata", default: {} + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["family_id"], name: "index_family_documents_on_family_id" + t.index ["provider_file_id"], name: "index_family_documents_on_provider_file_id" + t.index ["status"], name: "index_family_documents_on_status" end create_table "family_exports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -520,22 +539,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_24_180211) do t.index ["merchant_id"], name: "index_family_merchant_associations_on_merchant_id" end - create_table "flipper_features", force: :cascade do |t| - t.string "key", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["key"], name: "index_flipper_features_on_key", unique: true - end - - create_table "flipper_gates", force: :cascade do |t| - t.string "feature_key", null: false - t.string "key", null: false - t.text "value" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["feature_key", "key", "value"], name: "index_flipper_gates_on_feature_key_and_key_and_value", unique: true - end - create_table "holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "account_id", null: false t.uuid "security_id", null: false @@ -660,9 +663,63 @@ 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 + create_table "indexa_capital_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "indexa_capital_item_id", null: false + t.string "name" + t.string "indexa_capital_account_id" + t.string "account_number" + t.string "currency" + t.decimal "current_balance", precision: 19, scale: 4 + t.string "account_status" + t.string "account_type" + t.string "provider" + t.jsonb "institution_metadata" + t.jsonb "raw_payload" + t.string "indexa_capital_authorization_id" + t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" + t.jsonb "raw_holdings_payload", default: [] + t.jsonb "raw_activities_payload", default: [] + t.datetime "last_holdings_sync" + t.datetime "last_activities_sync" + t.boolean "activities_fetch_pending", default: false + t.date "sync_start_date" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["indexa_capital_account_id"], name: "index_indexa_capital_accounts_on_indexa_capital_account_id", unique: true + t.index ["indexa_capital_authorization_id"], name: "idx_on_indexa_capital_authorization_id_58db208d52" + t.index ["indexa_capital_item_id"], name: "index_indexa_capital_accounts_on_indexa_capital_item_id" + end + + create_table "indexa_capital_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "family_id", null: false + t.string "name" + t.string "institution_id" + t.string "institution_name" + t.string "institution_domain" + t.string "institution_url" + t.string "institution_color" + t.string "status", default: "good" + t.boolean "scheduled_for_deletion", default: false + t.boolean "pending_account_setup", default: false + t.datetime "sync_start_date" + t.jsonb "raw_payload" + t.jsonb "raw_institution_payload" + t.string "username" + t.string "document" + t.text "password" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.text "api_token" + t.index ["family_id"], name: "index_indexa_capital_items_on_family_id" + t.index ["status"], name: "index_indexa_capital_items_on_status" + end + create_table "investments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -850,8 +907,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_24_180211) 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 @@ -880,7 +935,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_24_180211) 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 @@ -1403,6 +1460,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_24_180211) do t.string "default_account_order", default: "name_asc" t.jsonb "preferences", default: {}, null: false t.string "locale" + t.string "ui_layout" t.index ["email"], name: "index_users_on_email", unique: true t.index ["family_id"], name: "index_users_on_family_id" t.index ["last_viewed_chat_id"], name: "index_users_on_last_viewed_chat_id" @@ -1456,6 +1514,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_24_180211) do add_foreign_key "eval_results", "eval_samples" add_foreign_key "eval_runs", "eval_datasets" add_foreign_key "eval_samples", "eval_datasets" + add_foreign_key "family_documents", "families" add_foreign_key "family_exports", "families" add_foreign_key "family_merchant_associations", "families" add_foreign_key "family_merchant_associations", "merchants" @@ -1468,6 +1527,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_24_180211) do add_foreign_key "impersonation_sessions", "users", column: "impersonator_id" add_foreign_key "import_rows", "imports" add_foreign_key "imports", "families" + add_foreign_key "indexa_capital_accounts", "indexa_capital_items" + add_foreign_key "indexa_capital_items", "families" add_foreign_key "invitations", "families" add_foreign_key "invitations", "users", column: "inviter_id" add_foreign_key "llm_usages", "families" 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/docs/api/openapi.yaml b/docs/api/openapi.yaml index b9ba48301..4a1515f90 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -54,6 +54,13 @@ components: type: string - type: object nullable: true + errors: + type: array + items: + type: string + nullable: true + description: Validation error messages (alternative to details used by trades, + valuations, etc.) ToolCall: type: object required: @@ -350,6 +357,31 @@ components: format: uuid name: type: string + MerchantDetail: + type: object + required: + - id + - name + - type + - created_at + - updated_at + properties: + id: + type: string + format: uuid + name: + type: string + type: + type: string + enum: + - FamilyMerchant + - ProviderMerchant + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time Tag: type: object required: @@ -471,6 +503,41 @@ 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 + 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: @@ -657,7 +724,198 @@ components: properties: data: "$ref": "#/components/schemas/ImportDetail" + Trade: + type: object + required: + - id + - date + - amount + - currency + - name + - qty + - price + - account + - created_at + - updated_at + properties: + id: + type: string + format: uuid + date: + type: string + format: date + amount: + type: string + currency: + type: string + name: + type: string + notes: + type: string + nullable: true + qty: + type: string + price: + type: string + investment_activity_label: + type: string + nullable: true + account: + "$ref": "#/components/schemas/Account" + security: + type: object + nullable: true + properties: + id: + type: string + format: uuid + ticker: + type: string + name: + type: string + nullable: true + category: + type: object + nullable: true + properties: + id: + type: string + format: uuid + name: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + TradeCollection: + type: object + required: + - trades + - pagination + properties: + trades: + type: array + items: + "$ref": "#/components/schemas/Trade" + pagination: + "$ref": "#/components/schemas/Pagination" + Holding: + type: object + required: + - id + - date + - qty + - price + - amount + - currency + - account + - security + - created_at + - updated_at + properties: + id: + type: string + format: uuid + date: + type: string + format: date + qty: + type: string + description: Quantity of shares held + price: + type: string + description: Formatted price per share + amount: + type: string + currency: + type: string + cost_basis_source: + type: string + nullable: true + account: + "$ref": "#/components/schemas/Account" + security: + type: object + required: + - id + - ticker + - name + properties: + id: + type: string + format: uuid + ticker: + type: string + name: + type: string + nullable: true + avg_cost: + type: string + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + HoldingCollection: + type: object + required: + - holdings + - pagination + properties: + holdings: + type: array + items: + "$ref": "#/components/schemas/Holding" + pagination: + "$ref": "#/components/schemas/Pagination" paths: + "/api/v1/merchants": + get: + summary: List merchants + tags: + - Merchants + security: + - apiKeyAuth: [] + responses: + '200': + description: merchants listed + content: + application/json: + schema: + type: array + items: + "$ref": "#/components/schemas/MerchantDetail" + "/api/v1/merchants/{id}": + parameters: + - name: id + in: path + required: true + description: Merchant ID + schema: + type: string + get: + summary: Retrieve a merchant + tags: + - Merchants + security: + - apiKeyAuth: [] + responses: + '200': + description: merchant retrieved + content: + application/json: + schema: + "$ref": "#/components/schemas/MerchantDetail" + '404': + description: merchant not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" "/api/v1/accounts": get: summary: List accounts @@ -685,6 +943,365 @@ paths: application/json: schema: "$ref": "#/components/schemas/AccountCollection" + "/api/v1/auth/signup": + post: + summary: Sign up a new user + tags: + - Auth + parameters: [] + responses: + '201': + description: user created + content: + application/json: + 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 + ui_layout: + type: string + enum: + - dashboard + - intro + ai_enabled: + type: boolean + '422': + description: validation error + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: invite code required or invalid + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + requestBody: + content: + application/json: + 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: + - 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: + - 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: + - user + - device + required: true + "/api/v1/auth/login": + post: + summary: Log in with email and password + tags: + - Auth + parameters: [] + responses: + '200': + description: login successful + content: + application/json: + 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 + ui_layout: + type: string + enum: + - dashboard + - intro + ai_enabled: + type: boolean + '401': + description: invalid credentials or MFA required + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + requestBody: + content: + application/json: + 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: + - device_id + - device_name + - device_type + - os_version + - app_version + required: + - email + - password + - device + required: true + "/api/v1/auth/sso_exchange": + post: + summary: Exchange mobile SSO authorization code for tokens + tags: + - Auth + 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. + parameters: [] + responses: + '200': + description: tokens issued + content: + application/json: + 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 + ui_layout: + type: string + enum: + - dashboard + - intro + ai_enabled: + type: boolean + '401': + description: invalid or expired code + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + requestBody: + content: + application/json: + schema: + type: object + properties: + code: + type: string + description: One-time authorization code from mobile SSO callback + required: + - code + required: true + "/api/v1/auth/refresh": + post: + summary: Refresh an access token + tags: + - Auth + parameters: [] + responses: + '200': + description: token refreshed + content: + application/json: + schema: + type: object + properties: + access_token: + type: string + refresh_token: + type: string + token_type: + type: string + expires_in: + type: integer + created_at: + type: integer + '401': + description: invalid refresh token + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '400': + description: missing refresh token + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + requestBody: + content: + application/json: + 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: + - device_id + required: + - refresh_token + - device + required: true + "/api/v1/auth/enable_ai": + patch: + summary: Enable AI features for the authenticated user + tags: + - Auth + security: + - apiKeyAuth: [] + responses: + '200': + description: ai enabled + content: + application/json: + schema: + type: object + properties: + user: + type: object + properties: + id: + type: string + format: uuid + email: + type: string + first_name: + type: string + nullable: true + last_name: + type: string + nullable: true + ui_layout: + type: string + enum: + - dashboard + - intro + ai_enabled: + type: boolean + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" "/api/v1/categories": get: summary: List categories @@ -973,6 +1590,119 @@ paths: application/json: schema: "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/holdings": + get: + summary: List holdings + tags: + - Holdings + security: + - apiKeyAuth: [] + parameters: + - name: page + in: query + required: false + description: 'Page number (default: 1)' + schema: + type: integer + - name: per_page + in: query + required: false + description: 'Items per page (default: 25, max: 100)' + schema: + type: integer + - name: account_id + in: query + required: false + description: Filter by account ID + schema: + type: string + - name: account_ids + in: query + required: false + description: Filter by multiple account IDs + schema: + type: array + items: + type: string + - name: date + in: query + required: false + description: Filter by exact date + schema: + type: string + format: date + - name: start_date + in: query + required: false + description: Filter holdings from this date (inclusive) + schema: + type: string + format: date + - name: end_date + in: query + required: false + description: Filter holdings until this date (inclusive) + schema: + type: string + format: date + - name: security_id + in: query + required: false + description: Filter by security ID + schema: + type: string + responses: + '200': + description: holdings paginated + content: + application/json: + schema: + "$ref": "#/components/schemas/HoldingCollection" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '422': + description: invalid date filter + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/holdings/{id}": + parameters: + - name: id + in: path + required: true + description: Holding ID + schema: + type: string + get: + summary: Retrieve holding + tags: + - Holdings + security: + - apiKeyAuth: [] + responses: + '200': + description: holding retrieved + content: + application/json: + schema: + "$ref": "#/components/schemas/Holding" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '404': + description: holding not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" "/api/v1/imports": get: summary: List imports @@ -1273,6 +2003,268 @@ paths: description: tag deleted '404': description: tag not found + "/api/v1/trades": + get: + summary: List trades + tags: + - Trades + security: + - apiKeyAuth: [] + parameters: + - name: page + in: query + required: false + description: 'Page number (default: 1)' + schema: + type: integer + - name: per_page + in: query + required: false + description: 'Items per page (default: 25, max: 100)' + schema: + type: integer + - name: account_id + in: query + required: false + description: Filter by account ID + schema: + type: string + - name: account_ids + in: query + required: false + description: Filter by multiple account IDs + schema: + type: array + items: + type: string + - name: start_date + in: query + required: false + description: Filter trades from this date (inclusive) + schema: + type: string + format: date + - name: end_date + in: query + required: false + description: Filter trades until this date (inclusive) + schema: + type: string + format: date + responses: + '200': + description: trades paginated + content: + application/json: + schema: + "$ref": "#/components/schemas/TradeCollection" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '422': + description: invalid date filter + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + post: + summary: Create trade + tags: + - Trades + security: + - apiKeyAuth: [] + parameters: [] + responses: + '201': + description: trade created + content: + application/json: + schema: + "$ref": "#/components/schemas/Trade" + '422': + description: validation error - missing security identifier + 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: + trade: + type: object + properties: + account_id: + type: string + format: uuid + description: Account ID (required) + date: + type: string + format: date + description: Trade date (required) + qty: + type: number + description: Quantity (required) + price: + type: number + description: Price (required) + type: + type: string + enum: + - buy + - sell + description: Trade type (required) + security_id: + type: string + format: uuid + description: Security ID (one of security_id, ticker, manual_ticker + required) + ticker: + type: string + description: Ticker symbol + manual_ticker: + type: string + description: Manual ticker for offline securities + currency: + type: string + description: Currency (defaults to account currency) + investment_activity_label: + type: string + description: Activity label (e.g. Buy, Sell) + category_id: + type: string + format: uuid + description: Category ID + required: + - account_id + - date + - qty + - price + - type + required: + - trade + required: true + "/api/v1/trades/{id}": + parameters: + - name: id + in: path + required: true + description: Trade ID + schema: + type: string + get: + summary: Retrieve trade + tags: + - Trades + security: + - apiKeyAuth: [] + responses: + '200': + description: trade retrieved + content: + application/json: + schema: + "$ref": "#/components/schemas/Trade" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '404': + description: trade not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + patch: + summary: Update trade + tags: + - Trades + security: + - apiKeyAuth: [] + parameters: [] + responses: + '200': + description: trade updated + content: + application/json: + schema: + "$ref": "#/components/schemas/Trade" + '404': + description: trade not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + requestBody: + content: + application/json: + schema: + type: object + properties: + trade: + type: object + properties: + date: + type: string + format: date + qty: + type: number + price: + type: number + type: + type: string + enum: + - buy + - sell + nature: + type: string + enum: + - inflow + - outflow + name: + type: string + notes: + type: string + currency: + type: string + investment_activity_label: + type: string + category_id: + type: string + format: uuid + required: true + delete: + summary: Delete trade + tags: + - Trades + security: + - apiKeyAuth: [] + responses: + '200': + description: trade deleted + content: + application/json: + schema: + "$ref": "#/components/schemas/DeleteResponse" + '404': + description: trade not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" "/api/v1/transactions": get: summary: List transactions @@ -1562,6 +2554,8 @@ paths: items: type: string format: uuid + description: Array of tag IDs to assign. Omit to preserve existing + tags; use [] to clear all tags. required: true delete: summary: Delete a transaction @@ -1582,3 +2576,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/docs/hosting/ai.md b/docs/hosting/ai.md index 7273ab1ab..0e6d56d1f 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:** @@ -287,6 +290,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. @@ -567,6 +633,106 @@ The assistant uses OpenAI's function calling (tool use) to access user data: These are defined in `app/models/assistant/function/`. +### Vector Store (Document Search) + +Sure's AI assistant can search documents that have been uploaded to a family's vault. Under the hood, documents are indexed in a **vector store** so the assistant can retrieve relevant passages when answering questions (Retrieval-Augmented Generation). + +#### How It Works + +1. When a user uploads a document to their family vault, it is automatically pushed to the configured vector store. +2. When the assistant needs financial context from uploaded files, it calls the `search_family_files` function. +3. The vector store returns the most relevant passages, which the assistant uses to answer the question. + +#### Supported Backends + +| Backend | Status | Best For | Requirements | +|---------|--------|----------|--------------| +| **OpenAI** (default) | ready | Cloud deployments, zero setup | `OPENAI_ACCESS_TOKEN` | +| **Pgvector** | scaffolded | Self-hosted, full data privacy | PostgreSQL with `pgvector` extension | +| **Qdrant** | scaffolded | Self-hosted, dedicated vector DB | Running Qdrant instance | + +#### Configuration + +##### OpenAI (Default) + +No extra configuration is needed. If you already have `OPENAI_ACCESS_TOKEN` set for the AI assistant, document search works automatically. OpenAI manages chunking, embedding, and retrieval. + +```bash +# Already set for AI chat — document search uses the same token +OPENAI_ACCESS_TOKEN=sk-proj-... +``` + +##### Pgvector (Self-Hosted) + +> [!CAUTION] +> Only `OpenAI` has been implemented! + +Use PostgreSQL's pgvector extension for fully local document search: + +```bash +VECTOR_STORE_PROVIDER=pgvector +``` + +> **Note:** The pgvector adapter is currently a skeleton. A future release will add full support including embedding model configuration. + +##### Qdrant (Self-Hosted) + +> [!CAUTION] +> Only `OpenAI` has been implemented! + +Use a dedicated Qdrant vector database: + +```bash +VECTOR_STORE_PROVIDER=qdrant +QDRANT_URL=http://localhost:6333 # Default if not set +QDRANT_API_KEY=your-api-key # Optional, for authenticated instances +``` + +Docker Compose example: + +```yaml +services: + sure: + environment: + - VECTOR_STORE_PROVIDER=qdrant + - QDRANT_URL=http://qdrant:6333 + depends_on: + - qdrant + + qdrant: + image: qdrant/qdrant:latest + ports: + - "6333:6333" + volumes: + - qdrant_data:/qdrant/storage + +volumes: + qdrant_data: +``` + +> **Note:** The Qdrant adapter is currently a skeleton. A future release will add full support including collection management and embedding configuration. + +#### Verifying the Configuration + +You can check whether a vector store is properly configured from the Rails console: + +```ruby +VectorStore.configured? # => true / false +VectorStore.adapter # => #