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

View File

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

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
@breadcrumbs = [
[ "Home", root_path ],
[ "API Keys", nil ]
[ "API Key", nil ]
]
@current_api_key = @api_key
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
@users = Current.family.users.order(:created_at)
@pending_invitations = Current.family.invitations.pending
@breadcrumbs = [
[ "Home", root_path ],
[ "Profile Info", nil ]
]
end
def destroy

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,13 +3,29 @@
turbo_refresh_url: family_exports_path,
turbo_refresh_interval: 3000
} : {} 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? %>
<% exports.each do |export| %>
<div class="flex items-center justify-between bg-container p-4 rounded-lg border border-primary">
<div>
<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 class="flex items-center justify-between mx-4 py-4">
<div class="flex items-center gap-2 mb-1">
<div>
<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>
<% if export.processing? || export.pending? %>
@@ -18,22 +34,44 @@
<span class="text-sm">Exporting...</span>
</div>
<% elsif export.completed? %>
<%= link_to download_family_export_path(export),
class: "flex items-center gap-2 text-primary hover:text-primary-hover",
data: { turbo_frame: "_top" } do %>
<%= icon "download", class: "w-5 h-5" %>
<span class="text-sm font-medium">Download</span>
<% end %>
<div class="flex items-center gap-2">
<%= button_to family_export_path(export),
method: :delete,
class: "flex items-center gap-2 text-destructive hover:text-destructive-hover",
data: {
turbo_confirm: "Are you sure you want to delete this export? This action cannot be undone.",
turbo_frame: "_top"
} do %>
<%= icon "trash-2", class: "w-5 h-5 text-destructive" %>
<% end %>
<%= link_to download_family_export_path(export),
class: "flex items-center gap-2 text-primary hover:text-primary-hover",
data: { turbo_frame: "_top" } do %>
<%= icon "download", class: "w-5 h-5" %>
<% end %>
</div>
<% elsif export.failed? %>
<div class="flex items-center gap-2 text-destructive">
<%= icon "alert-circle", class: "w-4 h-4" %>
<span class="text-sm">Failed</span>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 text-destructive">
<%= 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>
<% end %>
</div>
<% end %>
<% 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 %>
</div>
<% end %>

View File

@@ -1,13 +1,5 @@
<div class="flex justify-center items-center py-20">
<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>
<%= render DS::Link.new(
text: t(".new"),
variant: "primary",
href: new_import_path,
icon: "plus",
frame: :modal
) %>
</div>
</div>

View File

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

View File

@@ -1,25 +1,39 @@
<div class="flex items-center justify-between">
<h1 class="text-xl font-medium text-primary"><%= t(".title") %></h1>
<%= render DS::Link.new(
text: "New import",
href: new_import_path,
icon: "plus",
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" %>
<%= settings_section title: t(".imports") do %>
<div class="space-y-4">
<% if @imports.empty? %>
<%= render partial: "imports/empty" %>
<% else %>
<div class="bg-container rounded-lg shadow-border-xs">
<%= render partial: "imports/import", collection: @imports.ordered %>
</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>
<% end %>
</div>
<% end %>

View File

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

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

View File

@@ -1,7 +1,9 @@
<%= content_for :page_title, "API Key" %>
<% if @newly_created && @plain_key %>
<%= settings_section title: "API Key Created Successfully", subtitle: "Your new API key has been generated successfully." do %>
<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="p-3 shadow-border-xs bg-container rounded-lg">
<div class="flex items-start gap-3">
@@ -51,9 +53,18 @@
) %>
</div>
</div>
<% end %>
</div>
<% 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="p-3 shadow-border-xs bg-container rounded-lg flex justify-between items-center">
<div class="flex items-center gap-3">
@@ -122,13 +133,7 @@
</div>
</div>
<div class="flex flex-col sm:flex-row gap-3 pt-4 border-t border-primary">
<%= render DS::Link.new(
text: "Create New Key",
href: new_settings_api_key_path(regenerate: true),
variant: "secondary"
) %>
<div class="flex justify-end pt-4 border-t border-primary">
<%= render DS::Button.new(
text: "Revoke Key",
href: settings_api_key_path,
@@ -140,9 +145,18 @@
) %>
</div>
</div>
<% end %>
</div>
<% 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="p-3 shadow-border-xs bg-container rounded-lg">
<div class="flex items-start gap-3">
@@ -179,14 +193,6 @@
</li>
</ul>
</div>
<div class="flex justify-start">
<%= render DS::Link.new(
text: "Create API Key",
href: new_settings_api_key_path,
variant: "primary"
) %>
</div>
</div>
<% end %>
</div>
<% 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>
<% 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 %>
<div class="space-y-4">
<% if Current.user.admin? %>

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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