mirror of
https://github.com/we-promise/sure.git
synced 2026-04-18 03:24:09 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
12
app/controllers/settings/ai_prompts_controller.rb
Normal file
12
app/controllers/settings/ai_prompts_controller.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
18
app/controllers/settings/guides_controller.rb
Normal file
18
app/controllers/settings/guides_controller.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
|
||||
95
app/views/settings/ai_prompts/show.html.erb
Normal file
95
app/views/settings/ai_prompts/show.html.erb
Normal 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>
|
||||
@@ -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"
|
||||
) %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
5
app/views/settings/guides/show.html.erb
Normal file
5
app/views/settings/guides/show.html.erb
Normal 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>
|
||||
@@ -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? %>
|
||||
|
||||
@@ -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? %>
|
||||
|
||||
7
config/locales/views/family_exports/en.yml
Normal file
7
config/locales/views/family_exports/en.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
en:
|
||||
family_exports:
|
||||
list:
|
||||
in_progress: In progress
|
||||
complete: Complete
|
||||
failed: Failed
|
||||
7
config/locales/views/family_exports/nb.yml
Normal file
7
config/locales/views/family_exports/nb.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
nb:
|
||||
family_exports:
|
||||
list:
|
||||
in_progress: Pågår
|
||||
complete: Fullført
|
||||
failed: Mislykket
|
||||
7
config/locales/views/family_exports/tr.yml
Normal file
7
config/locales/views/family_exports/tr.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
tr:
|
||||
family_exports:
|
||||
list:
|
||||
in_progress: Devam ediyor
|
||||
complete: Tamamlandı
|
||||
failed: Başarısız
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
160
docs/onboarding/guide.md
Normal 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 />
|
||||
|
||||
You’ll 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**, you’re 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 you’d like to use an integration with a data provider instead, see:
|
||||
>
|
||||
> - **Lunch Flow** (WIP)
|
||||
> - [**Plaid**](/docs/hosting/plaid.md)
|
||||
> - **SimpleFin** (WIP)
|
||||
>
|
||||
> Even if you use an integration, we still recommend reading through this guide to understand **account types** and how they work in Sure.
|
||||
|
||||
|
||||
## 2. Account Types in Sure
|
||||
|
||||
Sure supports several account types, which are grouped into **Assets** (things you own) and **Debts/Liabilities** (things you owe):
|
||||
|
||||
| Assets | Debts/Liabilities |
|
||||
| ----------- | ----------------- |
|
||||
| Cash | Credit Card |
|
||||
| Investment | Loan |
|
||||
| Crypto | Other Liability |
|
||||
| Property | |
|
||||
| Vehicle | |
|
||||
| Other Asset | |
|
||||
|
||||
|
||||
## 3. How Asset Accounts Work
|
||||
|
||||
Cash, checking and savings accounts **increase** when you add money and **decrease** when you spend money.
|
||||
|
||||
Example:
|
||||
|
||||
- Starting balance: $500
|
||||
- Add an expense of $20 -> balance is now $480
|
||||
- Add an income of $100 -> balance is now $580
|
||||
|
||||
|
||||
## 4. How Debt Accounts Work (Liabilities)
|
||||
|
||||
Liability accounts track how much money you **owe**, so the math can feel *backwards* compared to an asset account.
|
||||
|
||||
**Key rule:**
|
||||
|
||||
- **Positive Balances** = you owe money
|
||||
- **Negative balances** = the bank owes *you* (e.g. overpayment or refund)
|
||||
|
||||
**Transactions behave like this:**
|
||||
|
||||
- **Expenses** (e.g. purchases) => increase your debt (you owe more)
|
||||
- **Payments or refunds** => decrease your debt (you owe less)
|
||||
|
||||
Credit Card example:
|
||||
|
||||
1. Balance: **$200 owed**
|
||||
2. Spend $20 => You now owe $220 (balance goes *up* in red)
|
||||
3. Pay off $50 => You now owe $170 (balance goes *down* in green)
|
||||
|
||||
Overpayment Example:
|
||||
|
||||
1. Balance: -$44 (bank owes you $44)
|
||||
2. Spend $1 => Bank now owes you **$43** (balance shown as -$43, moving towards zero)
|
||||
|
||||
> [!TIP]
|
||||
> Why does it work this way? This matches standard accounting and what your credit card provider shows online. Think of a liability balance as "**Amount Owed**", not "available cash."
|
||||
|
||||
|
||||
## 5. Quick Reference: Assets vs. Liability Behavior
|
||||
|
||||
| Action | Asset Account (e.g. Checking) | Liability Account (e.g. Credit Card) |
|
||||
| ---------------- | ----------------------------- | ------------------------------------ |
|
||||
| Spend $20 | Balance ↓ $20 | Balance ↑ $20 (more debt) |
|
||||
| Receive $50 | Balance ↑ $50 | Balance ↓ $50 (less debt) |
|
||||
| Negative Balance | Overdraft | Bank owes *you* money |
|
||||
|
||||
|
||||
## 6. Adding Accounts
|
||||
|
||||
For this example we'll add a **Savings Account**.<br />
|
||||
|
||||
>[!TIP]
|
||||
>If you’re adding a **credit card**, **loan**, or any other **debt**, be sure to select a **Credit Card** or **Liability** account type instead of **Cash**. This will ensure balances update correctly and match what your bank shows.
|
||||
|
||||
Most bank accounts (checking, savings, money market) are **Cash Accounts**
|
||||
1. Click **+ Add Account** → **Cash** → **Enter Account Balance**
|
||||
2. Fill in details such as:
|
||||
- Account name
|
||||
- Current Balance
|
||||
- Account Subtype (This is where you specify checking, savings, or other)
|
||||
3. Click **Create Account** when you are ready to proceed.
|
||||
|
||||
<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™.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user