Reorganize Settings sections + add LLM model/prompt configs (#116)

* Reshuffle/organize settings UI
* Settings: AI prompt display/minor touch-ups
* API key settings tests
* Moved import/export together
* Collapsible LLM prompt DIVs
* Add export tests
This commit is contained in:
Juan José Mata
2025-08-22 11:43:24 -07:00
committed by GitHub
parent fb6e094f78
commit d054cd0bb2
38 changed files with 1036 additions and 420 deletions

View File

@@ -2,7 +2,7 @@ class FamilyExportsController < ApplicationController
include StreamExtensions include StreamExtensions
before_action :require_admin before_action :require_admin
before_action :set_export, only: [ :download ] before_action :set_export, only: [ :download, :destroy ]
def new def new
# Modal view for initiating export # Modal view for initiating export
@@ -13,9 +13,9 @@ class FamilyExportsController < ApplicationController
FamilyDataExportJob.perform_later(@export) FamilyDataExportJob.perform_later(@export)
respond_to do |format| respond_to do |format|
format.html { redirect_to settings_profile_path, notice: "Export started. You'll be able to download it shortly." } format.html { redirect_to imports_path, notice: "Export started. You'll be able to download it shortly." }
format.turbo_stream { format.turbo_stream {
stream_redirect_to settings_profile_path, notice: "Export started. You'll be able to download it shortly." stream_redirect_to imports_path, notice: "Export started. You'll be able to download it shortly."
} }
end end
end end
@@ -29,10 +29,15 @@ class FamilyExportsController < ApplicationController
if @export.downloadable? if @export.downloadable?
redirect_to @export.export_file, allow_other_host: true redirect_to @export.export_file, allow_other_host: true
else else
redirect_to settings_profile_path, alert: "Export not ready for download" redirect_to imports_path, alert: "Export not ready for download"
end end
end end
def destroy
@export.destroy
redirect_to imports_path, notice: "Export deleted successfully"
end
private private
def set_export def set_export

View File

@@ -1,4 +1,6 @@
class ImportsController < ApplicationController class ImportsController < ApplicationController
include SettingsHelper
before_action :set_import, only: %i[show publish destroy revert apply_template] before_action :set_import, only: %i[show publish destroy revert apply_template]
def publish def publish
@@ -11,7 +13,11 @@ class ImportsController < ApplicationController
def index def index
@imports = Current.family.imports @imports = Current.family.imports
@exports = Current.user.admin? ? Current.family.family_exports.ordered.limit(10) : nil
@breadcrumbs = [
[ "Home", root_path ],
[ "Import/Export", imports_path ]
]
render layout: "settings" render layout: "settings"
end end

View File

@@ -0,0 +1,12 @@
class Settings::AiPromptsController < ApplicationController
layout "settings"
def show
@breadcrumbs = [
[ "Home", root_path ],
[ "AI Prompts", nil ]
]
@family = Current.family
@assistant_config = Assistant.config_for(OpenStruct.new(user: Current.user))
end
end

View File

@@ -8,7 +8,7 @@ class Settings::ApiKeysController < ApplicationController
def show def show
@breadcrumbs = [ @breadcrumbs = [
[ "Home", root_path ], [ "Home", root_path ],
[ "API Keys", nil ] [ "API Key", nil ]
] ]
@current_api_key = @api_key @current_api_key = @api_key
end end

View File

@@ -0,0 +1,18 @@
class Settings::GuidesController < ApplicationController
layout "settings"
def show
@breadcrumbs = [
[ "Home", root_path ],
[ "Guides", nil ]
]
markdown = Redcarpet::Markdown.new(Redcarpet::Render::HTML,
autolink: true,
tables: true,
fenced_code_blocks: true,
strikethrough: true,
superscript: true
)
@guide_content = markdown.render(File.read(Rails.root.join("docs/onboarding/guide.md")))
end
end

View File

@@ -5,6 +5,10 @@ class Settings::ProfilesController < ApplicationController
@user = Current.user @user = Current.user
@users = Current.family.users.order(:created_at) @users = Current.family.users.order(:created_at)
@pending_invitations = Current.family.invitations.pending @pending_invitations = Current.family.invitations.pending
@breadcrumbs = [
[ "Home", root_path ],
[ "Profile Info", nil ]
]
end end
def destroy def destroy

View File

@@ -1,17 +1,25 @@
module SettingsHelper module SettingsHelper
SETTINGS_ORDER = [ SETTINGS_ORDER = [
{ name: "Account", path: :settings_profile_path }, # General section
{ name: "Preferences", path: :settings_preferences_path },
{ name: "Security", path: :settings_security_path },
{ name: "Self-Hosting", path: :settings_hosting_path, condition: :self_hosted? },
{ name: "API Key", path: :settings_api_key_path },
{ name: "Billing", path: :settings_billing_path, condition: :not_self_hosted? },
{ name: "Accounts", path: :accounts_path }, { name: "Accounts", path: :accounts_path },
{ name: "Imports", path: :imports_path }, { name: "Bank Sync", path: :settings_bank_sync_path },
{ name: "Tags", path: :tags_path }, { name: "Preferences", path: :settings_preferences_path },
{ name: "Profile Info", path: :settings_profile_path },
{ name: "Security", path: :settings_security_path },
{ name: "Billing", path: :settings_billing_path, condition: :not_self_hosted? },
# Transactions section
{ name: "Categories", path: :categories_path }, { name: "Categories", path: :categories_path },
{ name: "Tags", path: :tags_path },
{ name: "Rules", path: :rules_path }, { name: "Rules", path: :rules_path },
{ name: "Merchants", path: :family_merchants_path }, { name: "Merchants", path: :family_merchants_path },
# Advanced section
{ name: "AI Prompts", path: :settings_ai_prompts_path },
{ name: "API Key", path: :settings_api_key_path },
{ name: "Self-Hosting", path: :settings_hosting_path, condition: :self_hosted? },
{ name: "Imports", path: :imports_path },
{ name: "SimpleFin", path: :simplefin_items_path },
# More section
{ name: "Guides", path: :settings_guides_path },
{ name: "What's new", path: :changelog_path }, { name: "What's new", path: :changelog_path },
{ name: "Feedback", path: :feedback_path } { name: "Feedback", path: :feedback_path }
] ]

View File

@@ -1,7 +1,7 @@
class FamilyExport < ApplicationRecord class FamilyExport < ApplicationRecord
belongs_to :family belongs_to :family
has_one_attached :export_file has_one_attached :export_file, dependent: :purge_later
enum :status, { enum :status, {
pending: "pending", pending: "pending",

View File

@@ -14,7 +14,7 @@ class Provider::Openai < Provider
MODELS.include?(model) MODELS.include?(model)
end end
def auto_categorize(transactions: [], user_categories: [], model: "gpt-4.1-mini") def auto_categorize(transactions: [], user_categories: [], model: "")
with_provider_response do with_provider_response do
raise Error, "Too many transactions to auto-categorize. Max is 25 per request." if transactions.size > 25 raise Error, "Too many transactions to auto-categorize. Max is 25 per request." if transactions.size > 25
@@ -36,7 +36,7 @@ class Provider::Openai < Provider
end end
end end
def auto_detect_merchants(transactions: [], user_merchants: [], model: "gpt-4.1-mini") def auto_detect_merchants(transactions: [], user_merchants: [], model: "")
with_provider_response do with_provider_response do
raise Error, "Too many transactions to auto-detect merchants. Max is 25 per request." if transactions.size > 25 raise Error, "Too many transactions to auto-detect merchants. Max is 25 per request." if transactions.size > 25

View File

@@ -1,4 +1,6 @@
class Provider::Openai::AutoCategorizer class Provider::Openai::AutoCategorizer
DEFAULT_MODEL = "gpt-4.1-mini"
def initialize(client, model: "", transactions: [], user_categories: []) def initialize(client, model: "", transactions: [], user_categories: [])
@client = client @client = client
@model = model @model = model
@@ -8,7 +10,7 @@ class Provider::Openai::AutoCategorizer
def auto_categorize def auto_categorize
response = client.responses.create(parameters: { response = client.responses.create(parameters: {
model: model, model: model.presence || DEFAULT_MODEL,
input: [ { role: "developer", content: developer_message } ], input: [ { role: "developer", content: developer_message } ],
text: { text: {
format: { format: {
@@ -26,6 +28,27 @@ class Provider::Openai::AutoCategorizer
build_response(extract_categorizations(response)) build_response(extract_categorizations(response))
end end
def instructions
<<~INSTRUCTIONS.strip_heredoc
You are an assistant to a consumer personal finance app. You will be provided a list
of the user's transactions and a list of the user's categories. Your job is to auto-categorize
each transaction.
Closely follow ALL the rules below while auto-categorizing:
- Return 1 result per transaction
- Correlate each transaction by ID (transaction_id)
- Attempt to match the most specific category possible (i.e. subcategory over parent category)
- Category and transaction classifications should match (i.e. if transaction is an "expense", the category must have classification of "expense")
- If you don't know the category, return "null"
- You should always favor "null" over false positives
- Be slightly pessimistic. Only match a category if you're 60%+ confident it is the correct one.
- Each transaction has varying metadata that can be used to determine the category
- Note: "hint" comes from 3rd party aggregators and typically represents a category name that
may or may not match any of the user-supplied categories
INSTRUCTIONS
end
private private
attr_reader :client, :model, :transactions, :user_categories attr_reader :client, :model, :transactions, :user_categories
@@ -97,25 +120,4 @@ class Provider::Openai::AutoCategorizer
``` ```
MESSAGE MESSAGE
end end
def instructions
<<~INSTRUCTIONS.strip_heredoc
You are an assistant to a consumer personal finance app. You will be provided a list
of the user's transactions and a list of the user's categories. Your job is to auto-categorize
each transaction.
Closely follow ALL the rules below while auto-categorizing:
- Return 1 result per transaction
- Correlate each transaction by ID (transaction_id)
- Attempt to match the most specific category possible (i.e. subcategory over parent category)
- Category and transaction classifications should match (i.e. if transaction is an "expense", the category must have classification of "expense")
- If you don't know the category, return "null"
- You should always favor "null" over false positives
- Be slightly pessimistic. Only match a category if you're 60%+ confident it is the correct one.
- Each transaction has varying metadata that can be used to determine the category
- Note: "hint" comes from 3rd party aggregators and typically represents a category name that
may or may not match any of the user-supplied categories
INSTRUCTIONS
end
end end

View File

@@ -1,4 +1,6 @@
class Provider::Openai::AutoMerchantDetector class Provider::Openai::AutoMerchantDetector
DEFAULT_MODEL = "gpt-4.1-mini"
def initialize(client, model: "", transactions:, user_merchants:) def initialize(client, model: "", transactions:, user_merchants:)
@client = client @client = client
@model = model @model = model
@@ -8,7 +10,7 @@ class Provider::Openai::AutoMerchantDetector
def auto_detect_merchants def auto_detect_merchants
response = client.responses.create(parameters: { response = client.responses.create(parameters: {
model: model, model: model.presence || DEFAULT_MODEL,
input: [ { role: "developer", content: developer_message } ], input: [ { role: "developer", content: developer_message } ],
text: { text: {
format: { format: {
@@ -26,6 +28,47 @@ class Provider::Openai::AutoMerchantDetector
build_response(extract_categorizations(response)) build_response(extract_categorizations(response))
end end
def instructions
<<~INSTRUCTIONS.strip_heredoc
You are an assistant to a consumer personal finance app.
Closely follow ALL the rules below while auto-detecting business names and website URLs:
- Return 1 result per transaction
- Correlate each transaction by ID (transaction_id)
- Do not include the subdomain in the business_url (i.e. "amazon.com" not "www.amazon.com")
- User merchants are considered "manual" user-generated merchants and should only be used in 100% clear cases
- Be slightly pessimistic. We favor returning "null" over returning a false positive.
- NEVER return a name or URL for generic transaction names (e.g. "Paycheck", "Laundromat", "Grocery store", "Local diner")
Determining a value:
- First attempt to determine the name + URL from your knowledge of global businesses
- If no certain match, attempt to match one of the user-provided merchants
- If no match, return "null"
Example 1 (known business):
```
Transaction name: "Some Amazon purchases"
Result:
- business_name: "Amazon"
- business_url: "amazon.com"
```
Example 2 (generic business):
```
Transaction name: "local diner"
Result:
- business_name: null
- business_url: null
```
INSTRUCTIONS
end
private private
attr_reader :client, :model, :transactions, :user_merchants attr_reader :client, :model, :transactions, :user_merchants
@@ -103,45 +146,4 @@ class Provider::Openai::AutoMerchantDetector
Return "null" if you are not 80%+ confident in your answer. Return "null" if you are not 80%+ confident in your answer.
MESSAGE MESSAGE
end end
def instructions
<<~INSTRUCTIONS.strip_heredoc
You are an assistant to a consumer personal finance app.
Closely follow ALL the rules below while auto-detecting business names and website URLs:
- Return 1 result per transaction
- Correlate each transaction by ID (transaction_id)
- Do not include the subdomain in the business_url (i.e. "amazon.com" not "www.amazon.com")
- User merchants are considered "manual" user-generated merchants and should only be used in 100% clear cases
- Be slightly pessimistic. We favor returning "null" over returning a false positive.
- NEVER return a name or URL for generic transaction names (e.g. "Paycheck", "Laundromat", "Grocery store", "Local diner")
Determining a value:
- First attempt to determine the name + URL from your knowledge of global businesses
- If no certain match, attempt to match one of the user-provided merchants
- If no match, return "null"
Example 1 (known business):
```
Transaction name: "Some Amazon purchases"
Result:
- business_name: "Amazon"
- business_url: "amazon.com"
```
Example 2 (generic business):
```
Transaction name: "local diner"
Result:
- business_name: null
- business_url: null
```
INSTRUCTIONS
end
end end

View File

@@ -3,13 +3,29 @@
turbo_refresh_url: family_exports_path, turbo_refresh_url: family_exports_path,
turbo_refresh_interval: 3000 turbo_refresh_interval: 3000
} : {} do %> } : {} do %>
<div class="mt-4 space-y-3 max-h-96 overflow-y-auto"> <div class="space-y-0 max-h-96 overflow-y-auto">
<% if exports.any? %> <% if exports.any? %>
<% exports.each do |export| %> <% exports.each do |export| %>
<div class="flex items-center justify-between bg-container p-4 rounded-lg border border-primary"> <div class="flex items-center justify-between mx-4 py-4">
<div> <div class="flex items-center gap-2 mb-1">
<p class="text-sm font-medium text-primary">Export from <%= export.created_at.strftime("%B %d, %Y at %I:%M %p") %></p> <div>
<p class="text-xs text-secondary"><%= export.filename %></p> <p class="text-sm font-medium text-primary">Export from <%= export.created_at.strftime("%B %d, %Y at %I:%M %p") %></p>
<p class="text-xs text-secondary"><%= export.filename %></p>
</div>
<% if export.processing? || export.pending? %>
<span class="px-1 py text-xs rounded-full bg-gray-500/5 text-secondary border border-alpha-black-50">
<%= t(".in_progress") %>
</span>
<% elsif export.completed? %>
<span class="px-1 py text-xs rounded-full bg-green-500/5 text-green-500 border border-alpha-black-50">
<%= t(".complete") %>
</span>
<% elsif export.failed? %>
<span class="px-1 py text-xs rounded-full bg-red-500/5 text-red-500 border border-alpha-black-50">
<%= t(".failed") %>
</span>
<% end %>
</div> </div>
<% if export.processing? || export.pending? %> <% if export.processing? || export.pending? %>
@@ -18,22 +34,44 @@
<span class="text-sm">Exporting...</span> <span class="text-sm">Exporting...</span>
</div> </div>
<% elsif export.completed? %> <% elsif export.completed? %>
<%= link_to download_family_export_path(export), <div class="flex items-center gap-2">
class: "flex items-center gap-2 text-primary hover:text-primary-hover", <%= button_to family_export_path(export),
data: { turbo_frame: "_top" } do %> method: :delete,
<%= icon "download", class: "w-5 h-5" %> class: "flex items-center gap-2 text-destructive hover:text-destructive-hover",
<span class="text-sm font-medium">Download</span> data: {
<% end %> turbo_confirm: "Are you sure you want to delete this export? This action cannot be undone.",
turbo_frame: "_top"
} do %>
<%= icon "trash-2", class: "w-5 h-5 text-destructive" %>
<% end %>
<%= link_to download_family_export_path(export),
class: "flex items-center gap-2 text-primary hover:text-primary-hover",
data: { turbo_frame: "_top" } do %>
<%= icon "download", class: "w-5 h-5" %>
<% end %>
</div>
<% elsif export.failed? %> <% elsif export.failed? %>
<div class="flex items-center gap-2 text-destructive"> <div class="flex items-center gap-2">
<%= icon "alert-circle", class: "w-4 h-4" %> <div class="flex items-center gap-2 text-destructive">
<span class="text-sm">Failed</span> <%= icon "alert-circle", class: "w-4 h-4" %>
</div>
<%= button_to family_export_path(export),
method: :delete,
class: "flex items-center gap-2 text-destructive hover:text-destructive-hover",
data: {
turbo_confirm: "Are you sure you want to delete this failed export?",
turbo_frame: "_top"
} do %>
<%= icon "trash-2", class: "w-5 h-5 text-destructive" %>
<% end %>
</div> </div>
<% end %> <% end %>
</div> </div>
<% end %> <% end %>
<% else %> <% else %>
<p class="text-sm text-secondary text-center py-4">No exports yet</p> <p class="text-sm text-primary text-center py-4 mb-1 font-medium">No exports yet.</p>
<% end %> <% end %>
</div> </div>
<% end %> <% end %>

View File

@@ -1,13 +1,5 @@
<div class="flex justify-center items-center py-20"> <div class="flex justify-center items-center py-20">
<div class="text-center flex flex-col items-center max-w-[300px] gap-4"> <div class="text-center flex flex-col items-center max-w-[300px] gap-4">
<p class="text-primary mb-1 font-medium text-sm"><%= t(".message") %></p> <p class="text-primary mb-1 font-medium text-sm"><%= t(".message") %></p>
<%= render DS::Link.new(
text: t(".new"),
variant: "primary",
href: new_import_path,
icon: "plus",
frame: :modal
) %>
</div> </div>
</div> </div>

View File

@@ -36,30 +36,36 @@
<% end %> <% end %>
</div> </div>
<%= render DS::Menu.new do |menu| %> <div class="flex items-center gap-2">
<% menu.with_item(variant: "link", text: t(".view"), href: import_path(import), icon: "eye") %>
<% if import.complete? || import.revert_failed? %> <% if import.complete? || import.revert_failed? %>
<% menu.with_item( <%= button_to revert_import_path(import),
variant: "button", method: :put,
text: t(".revert"), class: "flex items-center gap-2 text-orange-500 hover:text-orange-600",
href: revert_import_path(import), data: {
icon: "rotate-ccw", turbo_confirm: "This will delete transactions that were imported, but you will still be able to review and re-import your data at any time."
method: :put, } do %>
confirm: CustomConfirm.new( <%= icon "rotate-ccw", class: "w-5 h-5 text-destructive" %>
title: "Revert import?", <% end %>
body: "This will delete transactions that were imported, but you will still be able to review and re-import your data at any time.",
btn_text: "Revert"
)) %>
<% else %> <% else %>
<% menu.with_item( <%= button_to import_path(import),
variant: "button", method: :delete,
text: t(".delete"), class: "flex items-center gap-2 text-destructive hover:text-destructive-hover",
href: import_path(import), data: {
icon: "trash-2", turbo_confirm: CustomConfirm.for_resource_deletion("import")
method: :delete, } do %>
confirm: CustomConfirm.for_resource_deletion("import")) %> <%= icon "trash-2", class: "w-5 h-5 text-destructive" %>
<% end %>
<% end %> <% end %>
<% end %>
<%= link_to import_path(import),
class: "flex items-center gap-2 text-primary hover:text-primary-hover" do %>
<%= icon "eye", class: "w-5 h-5" %>
<% end %>
</div>
</div> </div>

View File

@@ -1,25 +1,39 @@
<div class="flex items-center justify-between"> <%= settings_section title: t(".imports") do %>
<h1 class="text-xl font-medium text-primary"><%= t(".title") %></h1> <div class="space-y-4">
<% if @imports.empty? %>
<%= render DS::Link.new( <%= render partial: "imports/empty" %>
text: "New import", <% else %>
href: new_import_path, <div class="bg-container rounded-lg shadow-border-xs">
icon: "plus", <%= render partial: "imports/import", collection: @imports.ordered %>
variant: "primary",
frame: :modal
) %>
</div>
<div class="bg-container shadow-border-xs rounded-xl p-4">
<% if @imports.empty? %>
<%= render partial: "imports/empty" %>
<% else %>
<div class="rounded-xl bg-container-inset p-1">
<h2 class="uppercase px-4 py-2 text-secondary text-xs"><%= t(".imports") %> · <%= @imports.size %></h2>
<div class="border border-alpha-black-100 rounded-lg bg-container shadow-xs">
<%= render partial: "imports/import", collection: @imports.ordered, spacer_template: "shared/ruler" %>
</div> </div>
<% end %>
<%= link_to new_import_path,
class: "bg-container-inset flex items-center justify-center gap-2 text-secondary mt-1 hover:bg-container-inset-hover rounded-lg px-4 py-2 w-full text-center",
data: { turbo_frame: :modal } do %>
<%= icon("plus") %>
<%= t(".new") %>
<% end %>
</div>
<% end %>
<% if Current.user.admin? %>
<%= settings_section title: t(".exports") do %>
<div class="space-y-4">
<div class="bg-container rounded-lg shadow-border-xs">
<%= turbo_frame_tag "family_exports", src: family_exports_path, loading: :lazy do %>
<div class="mt-4 text-center text-secondary py-8">
<div class="animate-spin inline-block h-4 w-4 border-2 border-secondary border-t-transparent rounded-full"></div>
</div>
<% end %>
</div>
<%= link_to new_family_export_path,
class: "bg-container-inset flex items-center justify-center gap-2 text-secondary mt-1 hover:bg-container-inset-hover rounded-lg px-4 py-2 w-full text-center",
data: { turbo_frame: :modal } do %>
<%= icon("plus") %>
<%= t(".new_export") %>
<% end %>
</div> </div>
<% end %> <% end %>
</div> <% end %>

View File

@@ -3,30 +3,37 @@ nav_sections = [
{ {
header: t(".general_section_title"), header: t(".general_section_title"),
items: [ items: [
{ label: t(".profile_label"), path: settings_profile_path, icon: "circle-user" },
{ label: t(".preferences_label"), path: settings_preferences_path, icon: "bolt" },
{ label: t(".security_label"), path: settings_security_path, icon: "shield-check" },
{ label: t(".api_keys_label"), path: settings_api_key_path, icon: "key" },
{ label: t(".self_hosting_label"), path: settings_hosting_path, icon: "database", if: self_hosted? },
{ label: t(".billing_label"), path: settings_billing_path, icon: "circle-dollar-sign", if: !self_hosted? },
{ label: t(".accounts_label"), path: accounts_path, icon: "layers" }, { label: t(".accounts_label"), path: accounts_path, icon: "layers" },
{ label: t(".bank_sync_label"), path: settings_bank_sync_path, icon: "banknote" }, { label: t(".bank_sync_label"), path: settings_bank_sync_path, icon: "banknote" },
{ label: "SimpleFin", path: simplefin_items_path, icon: "building-2" }, { label: t(".preferences_label"), path: settings_preferences_path, icon: "bolt" },
{ label: t(".imports_label"), path: imports_path, icon: "download" } { label: t(".profile_label"), path: settings_profile_path, icon: "circle-user" },
{ label: t(".security_label"), path: settings_security_path, icon: "shield-check" },
{ label: t(".billing_label"), path: settings_billing_path, icon: "circle-dollar-sign", if: !self_hosted? }
] ]
}, },
{ {
header: t(".transactions_section_title"), header: t(".transactions_section_title"),
items: [ items: [
{ label: t(".tags_label"), path: tags_path, icon: "tags" },
{ label: t(".categories_label"), path: categories_path, icon: "shapes" }, { label: t(".categories_label"), path: categories_path, icon: "shapes" },
{ label: t(".tags_label"), path: tags_path, icon: "tags" },
{ label: t(".rules_label"), path: rules_path, icon: "git-branch" }, { label: t(".rules_label"), path: rules_path, icon: "git-branch" },
{ label: t(".merchants_label"), path: family_merchants_path, icon: "store" } { label: t(".merchants_label"), path: family_merchants_path, icon: "store" }
] ]
}, },
{
header: t(".advanced_section_title"),
items: [
{ label: t(".ai_prompts_label"), path: settings_ai_prompts_path, icon: "bot" },
{ label: t(".api_keys_label"), path: settings_api_key_path, icon: "key" },
{ label: t(".self_hosting_label"), path: settings_hosting_path, icon: "database", if: self_hosted? },
{ label: t(".imports_label"), path: imports_path, icon: "download" },
{ label: "SimpleFin", path: simplefin_items_path, icon: "building-2" }
]
},
{ {
header: t(".other_section_title"), header: t(".other_section_title"),
items: [ items: [
{ label: t(".guides_label"), path: settings_guides_path, icon: "book-open" },
{ label: t(".whats_new_label"), path: changelog_path, icon: "box" }, { label: t(".whats_new_label"), path: changelog_path, icon: "box" },
{ label: t(".feedback_label"), path: feedback_path, icon: "megaphone" } { label: t(".feedback_label"), path: feedback_path, icon: "megaphone" }
] ]

View File

@@ -0,0 +1,95 @@
<%= content_for :page_title, t(".page_title") %>
<div class="bg-container rounded-xl shadow-border-xs p-4">
<div class="rounded-xl bg-container-inset space-y-1 p-1">
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-secondary">
<p><%= t(".openai_label") %></p>
</div>
<div class="bg-container rounded-lg shadow-border-xs">
<div class="space-y-4 p-4">
<!-- Main System Prompt Section -->
<div class="space-y-3">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-full bg-gray-25 flex justify-center items-center">
<%= icon "message-circle" %>
</div>
<div>
<p class="text-sm font-medium text-primary"><%= t(".main_system_prompt.title") %></p>
<p class="text-xs text-secondary"><%= t(".main_system_prompt.subtitle") %></p>
</div>
</div>
<div class="pl-12 space-y-2">
<details class="group">
<summary class="flex items-center gap-2 cursor-pointer">
<span class="text-xs font-medium text-primary uppercase"><%= t(".prompt_instructions") %></span>
<%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
</summary>
<pre class="whitespace-pre-wrap text-xs font-mono text-primary">[<%= Provider::Openai::MODELS.join(", ") %>]</pre>
<div class="mt-2 px-3 py-2 bg-surface-default border border-primary rounded-lg">
<pre class="whitespace-pre-wrap text-xs font-mono text-primary"><%= @assistant_config[:instructions] %></pre>
</div>
</details>
</div>
</div>
<div class="border-t border-primary"></div>
<!-- Auto-Categorization Section -->
<div class="space-y-3">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-full bg-gray-25 flex justify-center items-center">
<%= icon "brain" %>
</div>
<div>
<p class="text-sm font-medium text-primary"><%= t(".transaction_categorizer.title") %></p>
<p class="text-xs text-secondary"><%= t(".transaction_categorizer.subtitle") %></p>
</div>
</div>
<div class="pl-12 space-y-2">
<details class="group">
<summary class="flex items-center gap-2 cursor-pointer">
<span class="text-xs font-medium text-primary uppercase"><%= t(".prompt_instructions") %></span>
<%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
</summary>
<pre class="whitespace-pre-wrap text-xs font-mono text-primary">[<%= Provider::Openai::AutoCategorizer::DEFAULT_MODEL %>]</pre>
<div class="mt-2 px-3 py-2 bg-surface-default border border-primary rounded-lg">
<pre class="whitespace-pre-wrap text-xs font-mono text-primary"><%= @assistant_config[:auto_categorizer]&.instructions || Provider::Openai::AutoCategorizer.new(nil).instructions %></pre>
</div>
</details>
</div>
</div>
<div class="border-t border-primary"></div>
<!-- Merchant Detection Section -->
<div class="space-y-3">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-full bg-gray-25 flex justify-center items-center">
<%= icon "store" %>
</div>
<div>
<p class="text-sm font-medium text-primary"><%= t(".merchant_detector.title") %></p>
<p class="text-xs text-secondary"><%= t(".merchant_detector.subtitle") %></p>
</div>
</div>
<div class="pl-12 space-y-2">
<details class="group">
<summary class="flex items-center gap-2 cursor-pointer">
<span class="text-xs font-medium text-primary uppercase"><%= t(".prompt_instructions") %></span>
<%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
</summary>
<pre class="whitespace-pre-wrap text-xs font-mono text-primary">[<%= Provider::Openai::AutoMerchantDetector::DEFAULT_MODEL %>]</pre>
<div class="mt-2 px-3 py-2 bg-surface-default border border-primary rounded-lg">
<pre class="whitespace-pre-wrap text-xs font-mono text-primary"><%= @assistant_config[:auto_merchant]&.instructions || Provider::Openai::AutoMerchantDetector.new(nil, model: "", transactions: [], user_merchants: []).instructions %></pre>
</div>
</details>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
<%= content_for :page_title, "Create New API Key" %> <%= content_for :page_title, "Create New API Key" %>
<%= settings_section title: "Create New API Key", subtitle: "Generate a new API key to access your Maybe data programmatically." do %> <%= settings_section title: nil, subtitle: "Generate a new API key to access your Maybe data programmatically." do %>
<%= styled_form_with model: @api_key, url: settings_api_key_path, class: "space-y-4" do |form| %> <%= styled_form_with model: @api_key, url: settings_api_key_path, class: "space-y-4" do |form| %>
<%= form.text_field :name, <%= form.text_field :name,
placeholder: "e.g., My Budget App, Portfolio Tracker", placeholder: "e.g., My Budget App, Portfolio Tracker",
@@ -51,7 +51,7 @@
) %> ) %>
<%= render DS::Button.new( <%= render DS::Button.new(
text: "Create API Key", text: "Save API Key",
variant: "primary", variant: "primary",
type: "submit" type: "submit"
) %> ) %>

View File

@@ -1,7 +1,9 @@
<%= content_for :page_title, "API Key" %>
<% if @newly_created && @plain_key %> <% if @newly_created && @plain_key %>
<%= settings_section title: "API Key Created Successfully", subtitle: "Your new API key has been generated successfully." do %> <header class="flex items-center justify-between">
<h1 class="text-primary text-xl font-medium">API Key Created Successfully</h1>
</header>
<div class="bg-container rounded-xl shadow-border-xs p-4">
<div class="space-y-4"> <div class="space-y-4">
<div class="p-3 shadow-border-xs bg-container rounded-lg"> <div class="p-3 shadow-border-xs bg-container rounded-lg">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
@@ -51,9 +53,18 @@
) %> ) %>
</div> </div>
</div> </div>
<% end %> </div>
<% elsif @current_api_key %> <% elsif @current_api_key %>
<%= settings_section title: "Your API Key", subtitle: "Manage your API key for programmatic access to your Maybe data." do %> <header class="flex items-center justify-between">
<h1 class="text-primary text-xl font-medium">Your API Key</h1>
<%= render DS::Link.new(
text: "Create New Key",
href: new_settings_api_key_path(regenerate: true),
variant: "secondary"
) %>
</header>
<div class="bg-container rounded-xl shadow-border-xs p-4">
<div class="space-y-4"> <div class="space-y-4">
<div class="p-3 shadow-border-xs bg-container rounded-lg flex justify-between items-center"> <div class="p-3 shadow-border-xs bg-container rounded-lg flex justify-between items-center">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@@ -122,13 +133,7 @@
</div> </div>
</div> </div>
<div class="flex flex-col sm:flex-row gap-3 pt-4 border-t border-primary"> <div class="flex justify-end pt-4 border-t border-primary">
<%= render DS::Link.new(
text: "Create New Key",
href: new_settings_api_key_path(regenerate: true),
variant: "secondary"
) %>
<%= render DS::Button.new( <%= render DS::Button.new(
text: "Revoke Key", text: "Revoke Key",
href: settings_api_key_path, href: settings_api_key_path,
@@ -140,9 +145,18 @@
) %> ) %>
</div> </div>
</div> </div>
<% end %> </div>
<% else %> <% else %>
<%= settings_section title: "Create Your API Key", subtitle: "Get programmatic access to your Maybe data" do %> <header class="flex items-center justify-between">
<h1 class="text-primary text-xl font-medium">API Key</h1>
<%= render DS::Link.new(
text: "Create API Key",
href: new_settings_api_key_path,
variant: "primary"
) %>
</header>
<div class="bg-container rounded-xl shadow-border-xs p-4">
<div class="space-y-4"> <div class="space-y-4">
<div class="p-3 shadow-border-xs bg-container rounded-lg"> <div class="p-3 shadow-border-xs bg-container rounded-lg">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
@@ -179,14 +193,6 @@
</li> </li>
</ul> </ul>
</div> </div>
<div class="flex justify-start">
<%= render DS::Link.new(
text: "Create API Key",
href: new_settings_api_key_path,
variant: "primary"
) %>
</div>
</div> </div>
<% end %> </div>
<% end %> <% end %>

View File

@@ -0,0 +1,5 @@
<%= content_for :page_title, "Guides" %>
<div class="bg-container rounded-xl shadow-border-xs p-4 prose prose-sm max-w-none">
<%= @guide_content.html_safe %>
</div>

View File

@@ -122,29 +122,6 @@
</div> </div>
<% end %> <% end %>
<% if Current.user.admin? %>
<%= settings_section title: "Data Import/Export" do %>
<div class="space-y-4">
<div class="flex gap-4 items-center">
<%= render DS::Link.new(
text: "Export data",
icon: "database",
href: new_family_export_path,
variant: "secondary",
full_width: true,
data: { turbo_frame: :modal }
) %>
</div>
<%= turbo_frame_tag "family_exports", src: family_exports_path, loading: :lazy do %>
<div class="mt-4 text-center text-secondary">
<div class="animate-spin inline-block h-4 w-4 border-2 border-secondary border-t-transparent rounded-full"></div>
</div>
<% end %>
</div>
<% end %>
<% end %>
<%= settings_section title: t(".danger_zone_title") do %> <%= settings_section title: t(".danger_zone_title") do %>
<div class="space-y-4"> <div class="space-y-4">
<% if Current.user.admin? %> <% if Current.user.admin? %>

View File

@@ -30,7 +30,7 @@
<% end %> <% end %>
<% end %> <% end %>
<% menu.with_item(variant: "link", text: "Settings", icon: "settings", href: settings_profile_path(return_to: request.fullpath)) %> <% menu.with_item(variant: "link", text: "Settings", icon: "settings", href: accounts_path(return_to: request.fullpath)) %>
<% menu.with_item(variant: "link", text: "Changelog", icon: "box", href: changelog_path) %> <% menu.with_item(variant: "link", text: "Changelog", icon: "box", href: changelog_path) %>
<% if self_hosted? %> <% if self_hosted? %>

View File

@@ -0,0 +1,7 @@
---
en:
family_exports:
list:
in_progress: In progress
complete: Complete
failed: Failed

View File

@@ -0,0 +1,7 @@
---
nb:
family_exports:
list:
in_progress: Pågår
complete: Fullført
failed: Mislykket

View File

@@ -0,0 +1,7 @@
---
tr:
family_exports:
list:
in_progress: Devam ediyor
complete: Tamamlandı
failed: Başarısız

View File

@@ -58,7 +58,7 @@ en:
imports: imports:
empty: empty:
message: No imports yet. message: No imports yet.
new: New import new: New Import
import: import:
complete: Complete complete: Complete
delete: Delete delete: Delete
@@ -71,8 +71,11 @@ en:
view: View view: View
index: index:
imports: Imports imports: Imports
new: New import new: New Import
title: Imports title: Import/Export
exports: Exports
new_export: New Export
no_exports: No exports yet.
new: new:
description: You can manually import various types of data via CSV or use one description: You can manually import various types of data via CSV or use one
of our import templates like Mint. of our import templates like Mint.

View File

@@ -1,90 +1,93 @@
--- ---
nb: nb:
import: import:
cleans: cleans:
show: show:
description: Rediger dataene dine i tabellen nedenfor. Røde celler er ugyldige. description: Rediger dataene dine i tabellen nedenfor. Røde celler er ugyldige.
errors_notice: Du har feil i dataene dine. Hold musepekeren over feilen for å se errors_notice: Du har feil i dataene dine. Hold musepekeren over feilen for å se
detaljer. detaljer.
errors_notice_mobile: Du har feil i dataene dine. Trykk på feilverktøytipset for å se errors_notice_mobile: Du har feil i dataene dine. Trykk på feilverktøytipset for å se
detaljer. detaljer.
title: Rengjør dataene dine title: Rengjør dataene dine
configurations: configurations:
mint_import: mint_import:
date_format_label: Datoformat date_format_label: Datoformat
show: show:
description: Velg kolonnene som tilsvarer hvert felt i CSV-filen din. description: Velg kolonnene som tilsvarer hvert felt i CSV-filen din.
title: Konfigurer importen din title: Konfigurer importen din
trade_import: trade_import:
date_format_label: Datoformat date_format_label: Datoformat
transaction_import: transaction_import:
date_format_label: Datoformat date_format_label: Datoformat
confirms: confirms:
mappings: mappings:
create_account: Opprett konto create_account: Opprett konto
csv_mapping_label: "%{mapping} i CSV" csv_mapping_label: "%{mapping} i CSV"
maybe_mapping_label: "%{mapping} i Sure" maybe_mapping_label: "%{mapping} i Sure"
no_accounts: Du har ingen kontoer ennå. Vennligst opprett en konto som no_accounts: Du har ingen kontoer ennå. Vennligst opprett en konto som
vi kan bruke for (utilordnede) rader i CSV-filen din eller gå tilbake til Rengjør-trinnet vi kan bruke for (utilordnede) rader i CSV-filen din eller gå tilbake til Rengjør-trinnet
og oppgi et kontonavn vi kan bruke. og oppgi et kontonavn vi kan bruke.
rows_label: Rader rows_label: Rader
unassigned_account: Trenger å opprette en ny konto for utilordnede rader? unassigned_account: Trenger å opprette en ny konto for utilordnede rader?
show: show:
account_mapping_description: Tilordne alle kontoene i den importerte filen din til account_mapping_description: Tilordne alle kontoene i den importerte filen din til
Maybes eksisterende kontoer. Du kan også legge til nye kontoer eller la dem Maybes eksisterende kontoer. Du kan også legge til nye kontoer eller la dem
være ukategorisert. være ukategorisert.
account_mapping_title: Tilordne kontoene dine account_mapping_title: Tilordne kontoene dine
account_type_mapping_description: Tilordne alle kontotypene i den importerte filen din til account_type_mapping_description: Tilordne alle kontotypene i den importerte filen din til
Maybes Maybes
account_type_mapping_title: Tilordne kontotypene dine account_type_mapping_title: Tilordne kontotypene dine
category_mapping_description: Tilordne alle kategoriene i den importerte filen din til category_mapping_description: Tilordne alle kategoriene i den importerte filen din til
Maybes eksisterende kategorier. Du kan også legge til nye kategorier eller la dem Maybes eksisterende kategorier. Du kan også legge til nye kategorier eller la dem
være ukategorisert. være ukategorisert.
category_mapping_title: Tilordne kategoriene dine category_mapping_title: Tilordne kategoriene dine
tag_mapping_description: Tilordne alle tagene i den importerte filen din til tag_mapping_description: Tilordne alle tagene i den importerte filen din til
Maybes eksisterende tagger. Du kan også legge til nye tagger eller la dem Maybes eksisterende tagger. Du kan også legge til nye tagger eller la dem
være ukategorisert. være ukategorisert.
tag_mapping_title: Tilordne tagene dine tag_mapping_title: Tilordne tagene dine
uploads: uploads:
show: show:
description: Lim inn eller last opp CSV-filen din nedenfor. Vennligst gjennomgå instruksjonene description: Lim inn eller last opp CSV-filen din nedenfor. Vennligst gjennomgå instruksjonene
i tabellen nedenfor før du begynner. i tabellen nedenfor før du begynner.
instructions_1: Nedenfor er et eksempel på CSV med kolonner tilgjengelig for import. instructions_1: Nedenfor er et eksempel på CSV med kolonner tilgjengelig for import.
instructions_2: CSV-filen din må ha en overskriftsrad instructions_2: CSV-filen din må ha en overskriftsrad
instructions_3: Du kan navngi kolonnene dine hva du vil. Du vil tilordne instructions_3: Du kan navngi kolonnene dine hva du vil. Du vil tilordne
dem på et senere trinn. dem på et senere trinn.
instructions_4: Kolonner merket med en stjerne (*) er obligatoriske data. instructions_4: Kolonner merket med en stjerne (*) er obligatoriske data.
instructions_5: Ingen komma, ingen valutasymboler og ingen parenteser i tall. instructions_5: Ingen komma, ingen valutasymboler og ingen parenteser i tall.
title: Importer dataene dine title: Importer dataene dine
imports: imports:
empty: empty:
message: Ingen importer ennå. message: Ingen importer ennå.
new: Ny import new: Ny Import
import: import:
complete: Fullført complete: Fullført
delete: Slett delete: Slett
failed: Mislykket failed: Mislykket
in_progress: Pågår in_progress: Pågår
label: "%{type}: %{datetime}" label: "%{type}: %{datetime}"
revert_failed: Tilbakestilling mislykket revert_failed: Tilbakestilling mislykket
reverting: Tilbakestiller reverting: Tilbakestiller
uploading: Behandler rader uploading: Behandler rader
view: Vis view: Vis
index: index:
imports: Importer imports: Importer
new: Ny import new: Ny Import
title: Importer title: Importer
new: exports: Eksporter
description: Du kan manuelt importere ulike typer data via CSV eller bruke en av new_export: Ny Eksport
våre importmaler som Mint. no_exports: Ingen eksporter ennå.
import_accounts: Importer kontoer new:
import_mint: Importer fra Mint description: Du kan manuelt importere ulike typer data via CSV eller bruke en av
import_portfolio: Importer investeringer våre importmaler som Mint.
import_transactions: Importer transaksjoner import_accounts: Importer kontoer
resume: Fortsett %{type} import_mint: Importer fra Mint
sources: Kilder import_portfolio: Importer investeringer
title: Ny CSV-import import_transactions: Importer transaksjoner
ready: resume: Fortsett %{type}
description: Her er en oppsummering av de nye elementene som vil bli lagt til kontoen din sources: Kilder
når du publiserer denne importen. title: Ny CSV-import
ready:
description: Her er en oppsummering av de nye elementene som vil bli lagt til kontoen din
når du publiserer denne importen.
title: Bekreft importdataene dine title: Bekreft importdataene dine

View File

@@ -46,7 +46,7 @@ tr:
imports: imports:
empty: empty:
message: Henüz hiç içe aktarma yok. message: Henüz hiç içe aktarma yok.
new: Yeni içe aktarma new: Yeni İçe Aktarma
import: import:
complete: Tamamlandı complete: Tamamlandı
delete: Sil delete: Sil
@@ -59,8 +59,11 @@ tr:
view: Görüntüle view: Görüntüle
index: index:
imports: İçe aktarmalar imports: İçe aktarmalar
new: Yeni içe aktarma new: Yeni İçe Aktarma
title: İçe aktarmalar title: İçe aktarmalar
exports: Dışa aktarmalar
new_export: Yeni Dışa Aktarma
no_exports: Henüz hiç dışa aktarma yok.
new: new:
description: Farklı veri türlerini CSV ile manuel olarak içe aktarabilir veya Mint gibi içe aktarma şablonlarımızı kullanabilirsiniz. description: Farklı veri türlerini CSV ile manuel olarak içe aktarabilir veya Mint gibi içe aktarma şablonlarımızı kullanabilirsiniz.
import_accounts: Hesapları içe aktar import_accounts: Hesapları içe aktar

View File

@@ -14,7 +14,7 @@ en:
show: show:
title: "API Key Management" title: "API Key Management"
no_api_key: no_api_key:
title: "Create Your API Key" title: "API Key"
description: "Get programmatic access to your Maybe data with a secure API key." description: "Get programmatic access to your Maybe data with a secure API key."
what_you_can_do: "What you can do with the API:" what_you_can_do: "What you can do with the API:"
feature_1: "Access your account data programmatically" feature_1: "Access your account data programmatically"

View File

@@ -1,6 +1,20 @@
--- ---
en: en:
settings: settings:
ai_prompts:
show:
page_title: AI Prompts
openai_label: OpenAI
prompt_instructions: Prompt Instructions
main_system_prompt:
title: Main System Prompt
subtitle: Core instructions that define how the AI assistant behaves in all chat conversations
transaction_categorizer:
title: Transaction Categorizer
subtitle: AI automatically categorizes your transactions based on your defined categories
merchant_detector:
title: Merchant Detector
subtitle: AI identifies and enriches transaction data with merchant information
billings: billings:
show: show:
page_title: Billing page_title: Billing
@@ -59,10 +73,10 @@ en:
invitation_link: Invitation link invitation_link: Invitation link
invite_member: Add member invite_member: Add member
last_name: Last Name last_name: Last Name
page_title: Account page_title: Profile Info
pending: Pending pending: Pending
profile_subtitle: Customize how you appear on Sure profile_subtitle: Customize how you appear on Sure
profile_title: Profile profile_title: Personal
remove_invitation: Remove Invitation remove_invitation: Remove Invitation
remove_member: Remove Member remove_member: Remove Member
save: Save save: Save
@@ -71,24 +85,27 @@ en:
page_title: Security page_title: Security
settings_nav: settings_nav:
accounts_label: Accounts accounts_label: Accounts
advanced_section_title: Advanced
ai_prompts_label: AI Prompts
api_key_label: API Key api_key_label: API Key
billing_label: Billing billing_label: Billing
categories_label: Categories categories_label: Categories
feedback_label: Feedback feedback_label: Feedback
general_section_title: General general_section_title: General
imports_label: Imports imports_label: Import/Export
logout: Logout logout: Logout
merchants_label: Merchants merchants_label: Merchants
guides_label: Guides
other_section_title: More other_section_title: More
preferences_label: Preferences preferences_label: Preferences
profile_label: Account profile_label: Profile Info
rules_label: Rules rules_label: Rules
security_label: Security security_label: Security
self_hosting_label: Self-Hosting self_hosting_label: Self-Hosting
tags_label: Tags tags_label: Tags
transactions_section_title: Transactions transactions_section_title: Transactions
whats_new_label: What's new whats_new_label: What's new
api_keys_label: API Keys api_keys_label: API Key
bank_sync_label: Bank Sync bank_sync_label: Bank Sync
settings_nav_link_large: settings_nav_link_large:
next: Next next: Next

View File

@@ -1,96 +1,97 @@
--- ---
nb: nb:
settings: settings:
billings: billings:
show: show:
page_title: Fakturering page_title: Fakturering
subscription_subtitle: Oppdater abonnementet og faktureringsdetaljene dine subscription_subtitle: Oppdater abonnementet og faktureringsdetaljene dine
subscription_title: Administrer abonnement subscription_title: Administrer abonnement
preferences: preferences:
show: show:
country: Land country: Land
currency: Valuta currency: Valuta
date_format: Datoformat date_format: Datoformat
general_subtitle: Konfigurer preferansene dine general_subtitle: Konfigurer preferansene dine
general_title: Generelt general_title: Generelt
default_period: Standardperiode default_period: Standardperiode
language: Språk language: Språk
page_title: Preferanser page_title: Preferanser
theme_dark: Mørk theme_dark: Mørk
theme_light: Lys theme_light: Lys
theme_subtitle: Velg et foretrukket tema for appen theme_subtitle: Velg et foretrukket tema for appen
theme_system: System theme_system: System
theme_title: Tema theme_title: Tema
timezone: Tidssone timezone: Tidssone
profiles: profiles:
destroy: destroy:
cannot_remove_self: Du kan ikke fjerne deg selv fra din egen konto. cannot_remove_self: Du kan ikke fjerne deg selv fra din egen konto.
member_removal_failed: Det oppsto et problem med å fjerne medlemmet. member_removal_failed: Det oppsto et problem med å fjerne medlemmet.
member_removed: Medlemmet ble fjernet vellykket. member_removed: Medlemmet ble fjernet vellykket.
not_authorized: Du er ikke autorisert til å fjerne medlemmer. not_authorized: Du er ikke autorisert til å fjerne medlemmer.
show: show:
confirm_delete: confirm_delete:
body: Er du sikker på at du vil permanent slette kontoen din? Denne handlingen er irreversibel. body: Er du sikker på at du vil permanent slette kontoen din? Denne handlingen er irreversibel.
title: Slett konto? title: Slett konto?
confirm_reset: confirm_reset:
body: Er du sikker på at du vil tilbakestille kontoen din? Dette vil slette alle kontoene dine, kategorier, forhandlere, tagger og andre data. Denne handlingen kan ikke angres. body: Er du sikker på at du vil tilbakestille kontoen din? Dette vil slette alle kontoene dine, kategorier, forhandlere, tagger og andre data. Denne handlingen kan ikke angres.
title: Tilbakestill konto? title: Tilbakestill konto?
confirm_remove_invitation: confirm_remove_invitation:
body: Er du sikker på at du vil fjerne invitasjonen for %{email}? body: Er du sikker på at du vil fjerne invitasjonen for %{email}?
title: Fjern invitasjon title: Fjern invitasjon
confirm_remove_member: confirm_remove_member:
body: Er du sikker på at du vil fjerne %{name} fra kontoen din? body: Er du sikker på at du vil fjerne %{name} fra kontoen din?
title: Fjern medlem title: Fjern medlem
danger_zone_title: Fareområde danger_zone_title: Fareområde
delete_account: Slett konto delete_account: Slett konto
delete_account_warning: Sletting av kontoen din vil permanent fjerne alle delete_account_warning: Sletting av kontoen din vil permanent fjerne alle
dataene dine og kan ikke angres. dataene dine og kan ikke angres.
reset_account: Tilbakestill konto reset_account: Tilbakestill konto
reset_account_warning: Tilbakestilling av kontoen din vil slette alle kontoene dine, kategorier, forhandlere, tagger og andre data, men beholde brukerkontoen din intakt. reset_account_warning: Tilbakestilling av kontoen din vil slette alle kontoene dine, kategorier, forhandlere, tagger og andre data, men beholde brukerkontoen din intakt.
email: E-post email: E-post
first_name: Fornavn first_name: Fornavn
household_form_input_placeholder: Angi husholdningsnavn household_form_input_placeholder: Angi husholdningsnavn
household_form_label: Husholdningsnavn household_form_label: Husholdningsnavn
household_subtitle: Inviter familiemedlemmer, partnere og andre individer. Inviterte household_subtitle: Inviter familiemedlemmer, partnere og andre individer. Inviterte
kan logge inn på husholdningen din og få tilgang til dine delte kontoer. kan logge inn på husholdningen din og få tilgang til dine delte kontoer.
household_title: Husholdning household_title: Husholdning
invitation_link: Invitasjonslenke invitation_link: Invitasjonslenke
invite_member: Legg til medlem invite_member: Legg til medlem
last_name: Etternavn last_name: Etternavn
page_title: Konto page_title: Konto
pending: Venter pending: Venter
profile_subtitle: Tilpass hvordan du vises på Sure profile_subtitle: Tilpass hvordan du vises på Sure
profile_title: Profil profile_title: Profil
remove_invitation: Fjern invitasjon remove_invitation: Fjern invitasjon
remove_member: Fjern medlem remove_member: Fjern medlem
save: Lagre save: Lagre
securities: securities:
show: show:
page_title: Sikkerhet page_title: Sikkerhet
settings_nav: settings_nav:
accounts_label: Kontoer accounts_label: Kontoer
api_key_label: API-nøkkel api_key_label: API-nøkkel
billing_label: Fakturering advanced_section_title: Avansert
categories_label: Kategorier billing_label: Fakturering
feedback_label: Tilbakemelding categories_label: Kategorier
general_section_title: Generelt feedback_label: Tilbakemelding
imports_label: Importer general_section_title: Generelt
logout: Logg ut imports_label: Importer
merchants_label: Forhandlere logout: Logg ut
other_section_title: Mer merchants_label: Forhandlere
preferences_label: Preferanser other_section_title: Mer
profile_label: Konto preferences_label: Preferanser
rules_label: Regler profile_label: Konto
security_label: Sikkerhet rules_label: Regler
self_hosting_label: Selvhosting security_label: Sikkerhet
tags_label: Tagger self_hosting_label: Selvhosting
transactions_section_title: Transaksjoner tags_label: Tagger
whats_new_label: Hva er nytt transactions_section_title: Transaksjoner
settings_nav_link_large: whats_new_label: Hva er nytt
next: Neste settings_nav_link_large:
previous: Tilbake next: Neste
user_avatar_field: previous: Tilbake
accepted_formats: JPG eller PNG. 5MB maks. user_avatar_field:
choose: Last opp bilde accepted_formats: JPG eller PNG. 5MB maks.
choose_label: (valgfritt) choose: Last opp bilde
choose_label: (valgfritt)
change: Endre bilde change: Endre bilde

View File

@@ -68,6 +68,7 @@ tr:
settings_nav: settings_nav:
accounts_label: Hesaplar accounts_label: Hesaplar
api_key_label: API Anahtarı api_key_label: API Anahtarı
advanced_section_title: Gelişmiş
billing_label: Faturalandırma billing_label: Faturalandırma
categories_label: Kategoriler categories_label: Kategoriler
feedback_label: Geri Bildirim feedback_label: Geri Bildirim

View File

@@ -24,7 +24,7 @@ Rails.application.routes.draw do
end end
end end
resources :family_exports, only: %i[new create index] do resources :family_exports, only: %i[new create index destroy] do
member do member do
get :download get :download
end end
@@ -63,6 +63,8 @@ Rails.application.routes.draw do
resource :billing, only: :show resource :billing, only: :show
resource :security, only: :show resource :security, only: :show
resource :api_key, only: [ :show, :new, :create, :destroy ] resource :api_key, only: [ :show, :new, :create, :destroy ]
resource :ai_prompts, only: :show
resource :guides, only: :show
resource :bank_sync, only: :show, controller: "bank_sync" resource :bank_sync, only: :show, controller: "bank_sync"
end end

160
docs/onboarding/guide.md Normal file
View File

@@ -0,0 +1,160 @@
# Welcome to Sure!
This guide aims to assist new users through:
1. Creating a Sure account
2. Adding your first accounts
3. Recording transactions
This guide also covers the differences between **asset** and **liability** accounts, a key concept for using and understanding balances in Sure!
> [!IMPORTANT]
> Sure is evolving quickly. If you find something inaccurate while following this guide, please:
>
> - Ask in the [Discord](https://discord.gg/36ZGBsxYEK)
> - Open an [issue](https://github.com/we-promise/sure/issues/new/choose)
> - Or if you know the answer, open a [PR](https://github.com/we-promise/sure/compare)!
## 1. Creating your Sure Account
Once Sure is installed, open a browser and navigate to [localhost:3000](http://localhost:3000/sessions/new).<br />
You will see the **login page** (pictured below). Since we do not have an account yet, click on **Sign Up** to begin.
<img width="2508" height="1314" alt="Landing page on a fresh install." src="https://github.com/user-attachments/assets/2319dc87-5615-4473-bebc-8360dd983367" />
<br />
<br />
Youll be guided through a short series of screens to set your **login details**, **personal information**, and **preferences**.<br />
When you arrive at the main dashboard, showing **No accounts yet**, youre all set up!
<img width="2508" height="1314" alt="Blank home screen of Sure, with no accounts yet." src="https://github.com/user-attachments/assets/f06ba8e2-f188-4bf9-98a7-fdef724e9b5a" />
<br />
<br />
> [!Note]
> The next sections of this guide cover how to **manually add accounts and transactions** in Sure.<br />
> If youd like to use an integration with a data provider instead, see:
>
> - **Lunch Flow** (WIP)
> - [**Plaid**](/docs/hosting/plaid.md)
> - **SimpleFin** (WIP)
>
> Even if you use an integration, we still recommend reading through this guide to understand **account types** and how they work in Sure.
## 2. Account Types in Sure
Sure supports several account types, which are grouped into **Assets** (things you own) and **Debts/Liabilities** (things you owe):
| Assets | Debts/Liabilities |
| ----------- | ----------------- |
| Cash | Credit Card |
| Investment | Loan |
| Crypto | Other Liability |
| Property | |
| Vehicle | |
| Other Asset | |
## 3. How Asset Accounts Work
Cash, checking and savings accounts **increase** when you add money and **decrease** when you spend money.
Example:
- Starting balance: $500
- Add an expense of $20 -> balance is now $480
- Add an income of $100 -> balance is now $580
## 4. How Debt Accounts Work (Liabilities)
Liability accounts track how much money you **owe**, so the math can feel *backwards* compared to an asset account.
**Key rule:**
- **Positive Balances** = you owe money
- **Negative balances** = the bank owes *you* (e.g. overpayment or refund)
**Transactions behave like this:**
- **Expenses** (e.g. purchases) => increase your debt (you owe more)
- **Payments or refunds** => decrease your debt (you owe less)
Credit Card example:
1. Balance: **$200 owed**
2. Spend $20 => You now owe $220 (balance goes *up* in red)
3. Pay off $50 => You now owe $170 (balance goes *down* in green)
Overpayment Example:
1. Balance: -$44 (bank owes you $44)
2. Spend $1 => Bank now owes you **$43** (balance shown as -$43, moving towards zero)
> [!TIP]
> Why does it work this way? This matches standard accounting and what your credit card provider shows online. Think of a liability balance as "**Amount Owed**", not "available cash."
## 5. Quick Reference: Assets vs. Liability Behavior
| Action | Asset Account (e.g. Checking) | Liability Account (e.g. Credit Card) |
| ---------------- | ----------------------------- | ------------------------------------ |
| Spend $20 | Balance ↓ $20 | Balance ↑ $20 (more debt) |
| Receive $50 | Balance ↑ $50 | Balance ↓ $50 (less debt) |
| Negative Balance | Overdraft | Bank owes *you* money |
## 6. Adding Accounts
For this example we'll add a **Savings Account**.<br />
>[!TIP]
>If youre adding a **credit card**, **loan**, or any other **debt**, be sure to select a **Credit Card** or **Liability** account type instead of **Cash**. This will ensure balances update correctly and match what your bank shows.
Most bank accounts (checking, savings, money market) are **Cash Accounts**
1. Click **+ Add Account** → **Cash****Enter Account Balance**
2. Fill in details such as:
- Account name
- Current Balance
- Account Subtype (This is where you specify checking, savings, or other)
3. Click **Create Account** when you are ready to proceed.
<img width="500" height="303" alt="Cash Account creation menu" src="https://github.com/user-attachments/assets/e564a447-c85e-403e-979b-efe770ea2a61" />
<br />
<br />
Once created, you'll return to the **Home** screen.<br />
You'll now see:
- Your new cash account in the **Accounts** list (left side)
- An overview of your accounts in the center, under the net worth bar.
To get this bar moving let's add some transactions!
<img width="2508" height="1314" alt="Home screen of Sure, showing one account and no transactions." src="https://github.com/user-attachments/assets/7766a0cd-6b20-48f0-9ba2-87dfddd77236" />
## 7. Adding Transactions
To add a transaction:
1. Go to the **Transactions** page (left sidebar, under **Home**, above **Budgets**)
2. Click **+ New Transaction** (top right)
3. Choose the transaction type:
- **Expense** → Spending money
- **Income** → Receiving money
- **Transfer** → Move money between accounts
4. Enter the details, then click **Add transaction**
You will now see the transaction you added in your **transaction history**, as well as the **net worth chart** updating accordingly.
<img width="500" height="512" alt="Filled-out expense form" src="https://github.com/user-attachments/assets/7c1d38d1-edb8-4d12-8b3e-bbef4836cc92" />
## 8. Next Steps
Now that you have one account and your first transaction:
- Explore the other account types that Sure offers, adding ones relevant to your finances.
- **Categorize** and **Tag** transactions for better searching and reporting.
- Experiment with **Budgets** to track your spending habits.
- If you have many historical transactions, use **Bulk Import** to load them in.
More detailed user guides for these features are coming soon™.

View File

@@ -33,7 +33,7 @@ class FamilyExportsControllerTest < ActionDispatch::IntegrationTest
post family_exports_path post family_exports_path
end end
assert_redirected_to settings_profile_path assert_redirected_to imports_path
assert_equal "Export started. You'll be able to download it shortly.", flash[:notice] assert_equal "Export started. You'll be able to download it shortly.", flash[:notice]
export = @family.family_exports.last export = @family.family_exports.last
@@ -67,7 +67,87 @@ class FamilyExportsControllerTest < ActionDispatch::IntegrationTest
export = @family.family_exports.create!(status: "processing") export = @family.family_exports.create!(status: "processing")
get download_family_export_path(export) get download_family_export_path(export)
assert_redirected_to settings_profile_path assert_redirected_to imports_path
assert_equal "Export not ready for download", flash[:alert] assert_equal "Export not ready for download", flash[:alert]
end end
test "admin can delete export" do
export = @family.family_exports.create!(status: "completed")
assert_difference "@family.family_exports.count", -1 do
delete family_export_path(export)
end
assert_redirected_to imports_path
assert_equal "Export deleted successfully", flash[:notice]
end
test "admin can delete export with attached file" do
export = @family.family_exports.create!(status: "completed")
export.export_file.attach(
io: StringIO.new("test zip content"),
filename: "test.zip",
content_type: "application/zip"
)
assert export.export_file.attached?
assert_difference "@family.family_exports.count", -1 do
delete family_export_path(export)
end
assert_redirected_to imports_path
assert_equal "Export deleted successfully", flash[:notice]
end
test "admin can delete failed export with attached file" do
export = @family.family_exports.create!(status: "failed")
export.export_file.attach(
io: StringIO.new("failed export content"),
filename: "failed.zip",
content_type: "application/zip"
)
assert export.export_file.attached?
assert_difference "@family.family_exports.count", -1 do
delete family_export_path(export)
end
assert_redirected_to imports_path
assert_equal "Export deleted successfully", flash[:notice]
end
test "export file is purged when export is deleted" do
export = @family.family_exports.create!(status: "completed")
export.export_file.attach(
io: StringIO.new("test zip content"),
filename: "test.zip",
content_type: "application/zip"
)
# Verify file is attached
assert export.export_file.attached?
file_id = export.export_file.id
# Delete the export
delete family_export_path(export)
# Verify the export record is gone
assert_not FamilyExport.exists?(export.id)
# Verify the Active Storage attachment is also gone
# Note: Active Storage purges files asynchronously with `dependent: :purge_later`
# In tests, we can check that the attachment record is gone
assert_not ActiveStorage::Attachment.exists?(file_id)
end
test "non-admin cannot delete export" do
export = @family.family_exports.create!(status: "completed")
sign_in @non_admin
assert_no_difference "@family.family_exports.count" do
delete family_export_path(export)
end
assert_redirected_to root_path
end
end end

View File

@@ -1,7 +1,131 @@
require "test_helper" require "test_helper"
class FamilyExportTest < ActiveSupport::TestCase class FamilyExportTest < ActiveSupport::TestCase
# test "the truth" do setup do
# assert true @family = families(:dylan_family)
# end @export = @family.family_exports.create!
end
test "belongs to family" do
assert_equal @family, @export.family
end
test "has default status of pending" do
assert_equal "pending", @export.status
end
test "can have export file attached" do
@export.export_file.attach(
io: StringIO.new("test content"),
filename: "test.zip",
content_type: "application/zip"
)
assert @export.export_file.attached?
assert_equal "test.zip", @export.export_file.filename.to_s
assert_equal "application/zip", @export.export_file.content_type
end
test "filename is generated correctly" do
travel_to Time.zone.local(2024, 1, 15, 14, 30, 0) do
export = @family.family_exports.create!
expected_filename = "maybe_export_20240115_143000.zip"
assert_equal expected_filename, export.filename
end
end
test "downloadable? returns true for completed export with file" do
@export.update!(status: "completed")
@export.export_file.attach(
io: StringIO.new("test content"),
filename: "test.zip",
content_type: "application/zip"
)
assert @export.downloadable?
end
test "downloadable? returns false for pending export" do
@export.update!(status: "pending")
@export.export_file.attach(
io: StringIO.new("test content"),
filename: "test.zip",
content_type: "application/zip"
)
assert_not @export.downloadable?
end
test "downloadable? returns false for completed export without file" do
@export.update!(status: "completed")
assert_not @export.downloadable?
end
test "downloadable? returns false for failed export with file" do
@export.update!(status: "failed")
@export.export_file.attach(
io: StringIO.new("test content"),
filename: "test.zip",
content_type: "application/zip"
)
assert_not @export.downloadable?
end
test "export file is purged when export is destroyed" do
@export.export_file.attach(
io: StringIO.new("test content"),
filename: "test.zip",
content_type: "application/zip"
)
# Verify file is attached
assert @export.export_file.attached?
file_id = @export.export_file.id
blob_id = @export.export_file.blob.id
# Destroy the export
@export.destroy!
# Verify the export record is gone
assert_not FamilyExport.exists?(@export.id)
# Verify the Active Storage attachment is gone
assert_not ActiveStorage::Attachment.exists?(file_id)
# Note: Active Storage purges blobs asynchronously with dependent: :purge_later
# In tests, we can verify the attachment is gone, which is the immediate effect
# The blob will be purged in the background
end
test "can transition through statuses" do
assert_equal "pending", @export.status
@export.processing!
assert_equal "processing", @export.status
@export.completed!
assert_equal "completed", @export.status
@export.failed!
assert_equal "failed", @export.status
end
test "ordered scope returns exports in descending order" do
# Clear existing exports to avoid interference
@family.family_exports.destroy_all
# Create exports with specific timestamps
old_export = @family.family_exports.create!
old_export.update_column(:created_at, 2.days.ago)
new_export = @family.family_exports.create!
new_export.update_column(:created_at, 1.day.ago)
ordered_exports = @family.family_exports.ordered.to_a
assert_equal 2, ordered_exports.length
assert_equal new_export.id, ordered_exports.first.id
assert_equal old_export.id, ordered_exports.last.id
end
end end

View File

@@ -10,10 +10,9 @@ class Settings::ApiKeysTest < ApplicationSystemTestCase
test "should show no API key state when user has no active keys" do test "should show no API key state when user has no active keys" do
visit settings_api_key_path visit settings_api_key_path
assert_text "Create Your API Key" assert_text "API Key"
assert_text "Get programmatic access to your Maybe data"
assert_text "Access your account data programmatically"
assert_link "Create API Key", href: new_settings_api_key_path assert_link "Create API Key", href: new_settings_api_key_path
assert_text "Access your account data programmatically"
end end
test "should navigate to create new API key form" do test "should navigate to create new API key form" do
@@ -33,7 +32,7 @@ class Settings::ApiKeysTest < ApplicationSystemTestCase
fill_in "API Key Name", with: "Test Integration Key" fill_in "API Key Name", with: "Test Integration Key"
choose "Read/Write" choose "Read/Write"
click_button "Create API Key" click_button "Save API Key"
# Should redirect to show page with the API key details # Should redirect to show page with the API key details
assert_current_path settings_api_key_path assert_current_path settings_api_key_path
@@ -100,7 +99,7 @@ class Settings::ApiKeysTest < ApplicationSystemTestCase
fill_in "API Key Name", with: "New API Key" fill_in "API Key Name", with: "New API Key"
choose "Read Only" choose "Read Only"
click_button "Create API Key" click_button "Save API Key"
# Should redirect to show page with new key # Should redirect to show page with new key
assert_text "New API Key" assert_text "New API Key"
@@ -133,8 +132,8 @@ class Settings::ApiKeysTest < ApplicationSystemTestCase
# Wait for redirect after revoke # Wait for redirect after revoke
assert_no_selector "#confirm-dialog" assert_no_selector "#confirm-dialog"
assert_text "Create Your API Key" assert_text "API Key"
assert_text "Get programmatic access to your Maybe data" assert_text "Access your account data programmatically"
# Key should be revoked in the database # Key should be revoked in the database
api_key.reload api_key.reload
@@ -167,7 +166,7 @@ class Settings::ApiKeysTest < ApplicationSystemTestCase
# Try to submit without name # Try to submit without name
choose "Read Only" choose "Read Only"
click_button "Create API Key" click_button "Save API Key"
# Should stay on form with validation error # Should stay on form with validation error
assert_current_path new_settings_api_key_path assert_current_path new_settings_api_key_path

View File

@@ -5,13 +5,18 @@ class SettingsTest < ApplicationSystemTestCase
sign_in @user = users(:family_admin) sign_in @user = users(:family_admin)
@settings_links = [ @settings_links = [
[ "Account", settings_profile_path ],
[ "Preferences", settings_preferences_path ],
[ "Accounts", accounts_path ], [ "Accounts", accounts_path ],
[ "Tags", tags_path ], [ "Bank Sync", settings_bank_sync_path ],
[ "Preferences", settings_preferences_path ],
[ "Profile Info", settings_profile_path ],
[ "Security", settings_security_path ],
[ "Categories", categories_path ], [ "Categories", categories_path ],
[ "Tags", tags_path ],
[ "Rules", rules_path ],
[ "Merchants", family_merchants_path ], [ "Merchants", family_merchants_path ],
[ "Imports", imports_path ], [ "AI Prompts", settings_ai_prompts_path ],
[ "API Key", settings_api_key_path ],
[ "Guides", settings_guides_path ],
[ "What's new", changelog_path ], [ "What's new", changelog_path ],
[ "Feedback", feedback_path ] [ "Feedback", feedback_path ]
] ]
@@ -20,8 +25,8 @@ class SettingsTest < ApplicationSystemTestCase
test "can access settings from sidebar" do test "can access settings from sidebar" do
VCR.use_cassette("git_repository_provider/fetch_latest_release_notes") do VCR.use_cassette("git_repository_provider/fetch_latest_release_notes") do
open_settings_from_sidebar open_settings_from_sidebar
assert_selector "h1", text: "Account" assert_selector "h1", text: "Accounts"
assert_current_path settings_profile_path, ignore_query: true assert_current_path accounts_path, ignore_query: true
@settings_links.each do |name, path| @settings_links.each do |name, path|
click_link name click_link name