Compare commits
16 Commits
zachgoll/m
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77b5469832 | ||
|
|
a90899668f | ||
|
|
fd9ba8c1b9 | ||
|
|
a2cfa0356f | ||
|
|
224f21354a | ||
|
|
3fb379d140 | ||
|
|
d90d35d97b | ||
|
|
5baf258a32 | ||
|
|
bacab94a1b | ||
|
|
7698ec03b9 | ||
|
|
0329a5f211 | ||
|
|
b7c56e2fb7 | ||
|
|
764164cf57 | ||
|
|
ef49268278 | ||
|
|
527a6128b6 | ||
|
|
32ec57146e |
1
Gemfile
1
Gemfile
@@ -72,6 +72,7 @@ gem "plaid"
|
||||
gem "rotp", "~> 6.3"
|
||||
gem "rqrcode", "~> 3.0"
|
||||
gem "activerecord-import"
|
||||
gem "rubyzip", "~> 2.3"
|
||||
|
||||
# State machines
|
||||
gem "aasm"
|
||||
|
||||
@@ -672,6 +672,7 @@ DEPENDENCIES
|
||||
rubocop-rails-omakase
|
||||
ruby-lsp-rails
|
||||
ruby-openai
|
||||
rubyzip (~> 2.3)
|
||||
selenium-webdriver
|
||||
sentry-rails
|
||||
sentry-ruby
|
||||
|
||||
57
README.md
57
README.md
@@ -1,50 +1,21 @@
|
||||
|
||||
<img width="1190" alt="maybe_hero" src="https://github.com/user-attachments/assets/13fc5ef4-ce0f-4073-a163-9dbc3eb4c8e5" />
|
||||
<img width="1190" alt="maybe_hero" src="https://github.com/user-attachments/assets/5ed08763-a9ee-42b2-a436-e05038fcf573" />
|
||||
|
||||
# Maybe: The personal finance app for everyone
|
||||
|
||||
<b>Get
|
||||
involved: [Discord](https://link.maybe.co/discord) • [Website](https://maybefinance.com) • [Issues](https://github.com/maybe-finance/maybe/issues)</b>
|
||||
|
||||
## Backstory
|
||||
|
||||
We spent the better part of 2021/2022 building a personal finance + wealth
|
||||
management app called, Maybe. Very full-featured, including an "Ask an Advisor"
|
||||
feature which connected users with an actual CFP/CFA to help them with their
|
||||
finances (all included in your subscription).
|
||||
|
||||
The business end of things didn't work out, and so we shut things down mid-2023.
|
||||
|
||||
We spent the better part of $1,000,000 building the app (employees +
|
||||
contractors, data providers/services, infrastructure, etc.).
|
||||
|
||||
We're now reviving the product as a fully open-source project. The goal is to
|
||||
let you run the app yourself, for free, and use it to manage your own finances
|
||||
and eventually offer a hosted version of the app for a small monthly fee.
|
||||
> [!IMPORTANT]
|
||||
> This repository is no longer actively maintained. You can read more about this in our [final release](https://github.com/maybe-finance/maybe/releases/tag/v0.6.0).
|
||||
|
||||
## Maybe Hosting
|
||||
|
||||
There are 2 primary ways to use the Maybe app:
|
||||
Maybe is a fully working personal finance app that can be [self hosted with Docker](docs/hosting/docker.md).
|
||||
|
||||
1. Managed (easiest) - we're in alpha and release invites in our Discord
|
||||
2. [Self-host with Docker](docs/hosting/docker.md)
|
||||
## Forking and Attribution
|
||||
|
||||
## Contributing
|
||||
This repo is no longer maintained. You’re free to fork it under the AGPLv3. To stay compliant and avoid trademark issues:
|
||||
|
||||
Before contributing, you'll likely find it helpful
|
||||
to [understand context and general vision/direction](https://github.com/maybe-finance/maybe/wiki).
|
||||
|
||||
Once you've done that, please visit
|
||||
our [contributing guide](https://github.com/maybe-finance/maybe/blob/main/CONTRIBUTING.md)
|
||||
to get started!
|
||||
|
||||
### Performance Issues
|
||||
|
||||
With data-heavy apps, inevitably, there are performance issues. We've set up a public dashboard showing the problematic requests, along with the stacktraces to help debug them.
|
||||
|
||||
Any contributions that help improve performance are very much welcome.
|
||||
|
||||
https://oss.skylight.io/app/applications/XDpPIXEX52oi/recent/6h/endpoints
|
||||
- Be sure to include the original [AGPLv3 license](https://github.com/maybe-finance/maybe/blob/main/LICENSE) and clearly state in your README that your fork is based on Maybe Finance but is **not affiliated with or endorsed by** Maybe Finance Inc.
|
||||
- "Maybe" is a trademark of Maybe Finance Inc. and therefore, use of it is NOT allowed in forked repositories (or the logo)
|
||||
|
||||
## Local Development Setup
|
||||
|
||||
@@ -78,14 +49,6 @@ credentials to log in (generated by DB seed):
|
||||
|
||||
For further instructions, see guides below.
|
||||
|
||||
### Multi-currency support
|
||||
|
||||
If you'd like multi-currency support, there are a few extra steps to follow.
|
||||
|
||||
1. Sign up for an API key at [Synth](https://synthfinance.com). It's a Maybe
|
||||
product and the free plan is sufficient for basic multi-currency support.
|
||||
2. Add your API key to your `.env` file.
|
||||
|
||||
### Setup Guides
|
||||
|
||||
- [Mac dev setup guide](https://github.com/maybe-finance/maybe/wiki/Mac-Dev-Setup-Guide)
|
||||
@@ -93,10 +56,6 @@ If you'd like multi-currency support, there are a few extra steps to follow.
|
||||
- [Windows dev setup guide](https://github.com/maybe-finance/maybe/wiki/Windows-Dev-Setup-Guide)
|
||||
- Dev containers - visit [this guide](https://code.visualstudio.com/docs/devcontainers/containers) to learn more
|
||||
|
||||
## Repo Activity
|
||||
|
||||

|
||||
|
||||
## Copyright & license
|
||||
|
||||
Maybe is distributed under
|
||||
|
||||
@@ -9,6 +9,11 @@ class AccountsController < ApplicationController
|
||||
render layout: "settings"
|
||||
end
|
||||
|
||||
def sync_all
|
||||
family.sync_later
|
||||
redirect_to accounts_path, notice: "Syncing accounts..."
|
||||
end
|
||||
|
||||
def show
|
||||
@chart_view = params[:chart_view] || "balance"
|
||||
@tab = params[:tab]
|
||||
|
||||
47
app/controllers/family_exports_controller.rb
Normal file
47
app/controllers/family_exports_controller.rb
Normal file
@@ -0,0 +1,47 @@
|
||||
class FamilyExportsController < ApplicationController
|
||||
include StreamExtensions
|
||||
|
||||
before_action :require_admin
|
||||
before_action :set_export, only: [ :download ]
|
||||
|
||||
def new
|
||||
# Modal view for initiating export
|
||||
end
|
||||
|
||||
def create
|
||||
@export = Current.family.family_exports.create!
|
||||
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.turbo_stream {
|
||||
stream_redirect_to settings_profile_path, notice: "Export started. You'll be able to download it shortly."
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def index
|
||||
@exports = Current.family.family_exports.ordered.limit(10)
|
||||
render layout: false # For turbo frame
|
||||
end
|
||||
|
||||
def download
|
||||
if @export.downloadable?
|
||||
redirect_to @export.export_file, allow_other_host: true
|
||||
else
|
||||
redirect_to settings_profile_path, alert: "Export not ready for download"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_export
|
||||
@export = Current.family.family_exports.find(params[:id])
|
||||
end
|
||||
|
||||
def require_admin
|
||||
unless Current.user.admin?
|
||||
redirect_to root_path, alert: "Access denied"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -110,7 +110,7 @@ class TransactionsController < ApplicationController
|
||||
|
||||
private
|
||||
def per_page
|
||||
params[:per_page].to_i.positive? ? params[:per_page].to_i : 50
|
||||
params[:per_page].to_i.positive? ? params[:per_page].to_i : 20
|
||||
end
|
||||
|
||||
def needs_rule_notification?(transaction)
|
||||
@@ -154,10 +154,6 @@ class TransactionsController < ApplicationController
|
||||
|
||||
cleaned_params.delete(:amount_operator) unless cleaned_params[:amount].present?
|
||||
|
||||
# Only add default start_date if params are blank AND filters weren't explicitly cleared
|
||||
if cleaned_params.blank? && params[:filter_cleared].blank?
|
||||
cleaned_params[:start_date] = 30.days.ago.to_date
|
||||
end
|
||||
|
||||
cleaned_params
|
||||
end
|
||||
|
||||
@@ -10,16 +10,14 @@ export default class extends Controller {
|
||||
|
||||
connect() {
|
||||
this.autoTargets.forEach((element) => {
|
||||
const event =
|
||||
element.dataset.autosubmitTriggerEvent || this.triggerEventValue;
|
||||
const event = this.#getTriggerEvent(element);
|
||||
element.addEventListener(event, this.handleInput);
|
||||
});
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.autoTargets.forEach((element) => {
|
||||
const event =
|
||||
element.dataset.autosubmitTriggerEvent || this.triggerEventValue;
|
||||
const event = this.#getTriggerEvent(element);
|
||||
element.removeEventListener(event, this.handleInput);
|
||||
});
|
||||
}
|
||||
@@ -33,6 +31,50 @@ export default class extends Controller {
|
||||
}, this.#debounceTimeout(target));
|
||||
};
|
||||
|
||||
#getTriggerEvent(element) {
|
||||
// Check if element has explicit trigger event set
|
||||
if (element.dataset.autosubmitTriggerEvent) {
|
||||
return element.dataset.autosubmitTriggerEvent;
|
||||
}
|
||||
|
||||
// Check if form has explicit trigger event set
|
||||
if (this.triggerEventValue !== "input") {
|
||||
return this.triggerEventValue;
|
||||
}
|
||||
|
||||
// Otherwise, choose trigger event based on element type
|
||||
const type = element.type || element.tagName;
|
||||
|
||||
switch (type.toLowerCase()) {
|
||||
case "text":
|
||||
case "email":
|
||||
case "password":
|
||||
case "search":
|
||||
case "tel":
|
||||
case "url":
|
||||
case "textarea":
|
||||
return "blur";
|
||||
case "number":
|
||||
case "date":
|
||||
case "datetime-local":
|
||||
case "month":
|
||||
case "time":
|
||||
case "week":
|
||||
case "color":
|
||||
return "change";
|
||||
case "checkbox":
|
||||
case "radio":
|
||||
case "select":
|
||||
case "select-one":
|
||||
case "select-multiple":
|
||||
return "change";
|
||||
case "range":
|
||||
return "input";
|
||||
default:
|
||||
return "blur";
|
||||
}
|
||||
}
|
||||
|
||||
#debounceTimeout(element) {
|
||||
if (element.dataset.autosubmitDebounceTimeout) {
|
||||
return Number.parseInt(element.dataset.autosubmitDebounceTimeout);
|
||||
|
||||
22
app/jobs/family_data_export_job.rb
Normal file
22
app/jobs/family_data_export_job.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
class FamilyDataExportJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(family_export)
|
||||
family_export.update!(status: :processing)
|
||||
|
||||
exporter = Family::DataExporter.new(family_export.family)
|
||||
zip_file = exporter.generate_export
|
||||
|
||||
family_export.export_file.attach(
|
||||
io: zip_file,
|
||||
filename: family_export.filename,
|
||||
content_type: "application/zip"
|
||||
)
|
||||
|
||||
family_export.update!(status: :completed)
|
||||
rescue => e
|
||||
Rails.logger.error "Family export failed: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
family_export.update!(status: :failed)
|
||||
end
|
||||
end
|
||||
@@ -134,7 +134,8 @@ class Assistant::Function::GetTransactions < Assistant::Function
|
||||
def call(params = {})
|
||||
search_params = params.except("order", "page")
|
||||
|
||||
transactions_query = family.transactions.visible.search(search_params)
|
||||
search = Transaction::Search.new(family, filters: search_params)
|
||||
transactions_query = search.transactions_scope
|
||||
pagy_query = params["order"] == "asc" ? transactions_query.chronological : transactions_query.reverse_chronological
|
||||
|
||||
# By default, we give a small page size to force the AI to use filters effectively and save on tokens
|
||||
@@ -149,7 +150,7 @@ class Assistant::Function::GetTransactions < Assistant::Function
|
||||
limit: default_page_size
|
||||
)
|
||||
|
||||
totals = family.income_statement.totals(transactions_scope: transactions_query)
|
||||
totals = search.totals
|
||||
|
||||
normalized_transactions = paginated_transactions.map do |txn|
|
||||
entry = txn.entry
|
||||
|
||||
@@ -49,7 +49,10 @@ class Budget < ApplicationRecord
|
||||
|
||||
private
|
||||
def oldest_valid_budget_date(family)
|
||||
@oldest_valid_budget_date ||= family.oldest_entry_date.beginning_of_month
|
||||
# Allow going back to either the earliest entry date OR 2 years ago, whichever is earlier
|
||||
two_years_ago = 2.years.ago.beginning_of_month
|
||||
oldest_entry_date = family.oldest_entry_date.beginning_of_month
|
||||
[ two_years_ago, oldest_entry_date ].min
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ class Family < ApplicationRecord
|
||||
has_many :invitations, dependent: :destroy
|
||||
|
||||
has_many :imports, dependent: :destroy
|
||||
has_many :family_exports, dependent: :destroy
|
||||
|
||||
has_many :entries, through: :accounts
|
||||
has_many :transactions, through: :accounts
|
||||
|
||||
238
app/models/family/data_exporter.rb
Normal file
238
app/models/family/data_exporter.rb
Normal file
@@ -0,0 +1,238 @@
|
||||
require "zip"
|
||||
require "csv"
|
||||
|
||||
class Family::DataExporter
|
||||
def initialize(family)
|
||||
@family = family
|
||||
end
|
||||
|
||||
def generate_export
|
||||
# Create a StringIO to hold the zip data in memory
|
||||
zip_data = Zip::OutputStream.write_buffer do |zipfile|
|
||||
# Add accounts.csv
|
||||
zipfile.put_next_entry("accounts.csv")
|
||||
zipfile.write generate_accounts_csv
|
||||
|
||||
# Add transactions.csv
|
||||
zipfile.put_next_entry("transactions.csv")
|
||||
zipfile.write generate_transactions_csv
|
||||
|
||||
# Add trades.csv
|
||||
zipfile.put_next_entry("trades.csv")
|
||||
zipfile.write generate_trades_csv
|
||||
|
||||
# Add categories.csv
|
||||
zipfile.put_next_entry("categories.csv")
|
||||
zipfile.write generate_categories_csv
|
||||
|
||||
# Add all.ndjson
|
||||
zipfile.put_next_entry("all.ndjson")
|
||||
zipfile.write generate_ndjson
|
||||
end
|
||||
|
||||
# Rewind and return the StringIO
|
||||
zip_data.rewind
|
||||
zip_data
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_accounts_csv
|
||||
CSV.generate do |csv|
|
||||
csv << [ "id", "name", "type", "subtype", "balance", "currency", "created_at" ]
|
||||
|
||||
# Only export accounts belonging to this family
|
||||
@family.accounts.includes(:accountable).find_each do |account|
|
||||
csv << [
|
||||
account.id,
|
||||
account.name,
|
||||
account.accountable_type,
|
||||
account.subtype,
|
||||
account.balance.to_s,
|
||||
account.currency,
|
||||
account.created_at.iso8601
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def generate_transactions_csv
|
||||
CSV.generate do |csv|
|
||||
csv << [ "date", "account_name", "amount", "name", "category", "tags", "notes", "currency" ]
|
||||
|
||||
# Only export transactions from accounts belonging to this family
|
||||
@family.transactions
|
||||
.includes(:category, :tags, entry: :account)
|
||||
.find_each do |transaction|
|
||||
csv << [
|
||||
transaction.entry.date.iso8601,
|
||||
transaction.entry.account.name,
|
||||
transaction.entry.amount.to_s,
|
||||
transaction.entry.name,
|
||||
transaction.category&.name,
|
||||
transaction.tags.pluck(:name).join(","),
|
||||
transaction.entry.notes,
|
||||
transaction.entry.currency
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def generate_trades_csv
|
||||
CSV.generate do |csv|
|
||||
csv << [ "date", "account_name", "ticker", "quantity", "price", "amount", "currency" ]
|
||||
|
||||
# Only export trades from accounts belonging to this family
|
||||
@family.trades
|
||||
.includes(:security, entry: :account)
|
||||
.find_each do |trade|
|
||||
csv << [
|
||||
trade.entry.date.iso8601,
|
||||
trade.entry.account.name,
|
||||
trade.security.ticker,
|
||||
trade.qty.to_s,
|
||||
trade.price.to_s,
|
||||
trade.entry.amount.to_s,
|
||||
trade.currency
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def generate_categories_csv
|
||||
CSV.generate do |csv|
|
||||
csv << [ "name", "color", "parent_category", "classification" ]
|
||||
|
||||
# Only export categories belonging to this family
|
||||
@family.categories.includes(:parent).find_each do |category|
|
||||
csv << [
|
||||
category.name,
|
||||
category.color,
|
||||
category.parent&.name,
|
||||
category.classification
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def generate_ndjson
|
||||
lines = []
|
||||
|
||||
# Export accounts with full accountable data
|
||||
@family.accounts.includes(:accountable).find_each do |account|
|
||||
lines << {
|
||||
type: "Account",
|
||||
data: account.as_json(
|
||||
include: {
|
||||
accountable: {}
|
||||
}
|
||||
)
|
||||
}.to_json
|
||||
end
|
||||
|
||||
# Export categories
|
||||
@family.categories.find_each do |category|
|
||||
lines << {
|
||||
type: "Category",
|
||||
data: category.as_json
|
||||
}.to_json
|
||||
end
|
||||
|
||||
# Export tags
|
||||
@family.tags.find_each do |tag|
|
||||
lines << {
|
||||
type: "Tag",
|
||||
data: tag.as_json
|
||||
}.to_json
|
||||
end
|
||||
|
||||
# Export merchants (only family merchants)
|
||||
@family.merchants.find_each do |merchant|
|
||||
lines << {
|
||||
type: "Merchant",
|
||||
data: merchant.as_json
|
||||
}.to_json
|
||||
end
|
||||
|
||||
# Export transactions with full data
|
||||
@family.transactions.includes(:category, :merchant, :tags, entry: :account).find_each do |transaction|
|
||||
lines << {
|
||||
type: "Transaction",
|
||||
data: {
|
||||
id: transaction.id,
|
||||
entry_id: transaction.entry.id,
|
||||
account_id: transaction.entry.account_id,
|
||||
date: transaction.entry.date,
|
||||
amount: transaction.entry.amount,
|
||||
currency: transaction.entry.currency,
|
||||
name: transaction.entry.name,
|
||||
notes: transaction.entry.notes,
|
||||
excluded: transaction.entry.excluded,
|
||||
category_id: transaction.category_id,
|
||||
merchant_id: transaction.merchant_id,
|
||||
tag_ids: transaction.tag_ids,
|
||||
kind: transaction.kind,
|
||||
created_at: transaction.created_at,
|
||||
updated_at: transaction.updated_at
|
||||
}
|
||||
}.to_json
|
||||
end
|
||||
|
||||
# Export trades with full data
|
||||
@family.trades.includes(:security, entry: :account).find_each do |trade|
|
||||
lines << {
|
||||
type: "Trade",
|
||||
data: {
|
||||
id: trade.id,
|
||||
entry_id: trade.entry.id,
|
||||
account_id: trade.entry.account_id,
|
||||
security_id: trade.security_id,
|
||||
ticker: trade.security.ticker,
|
||||
date: trade.entry.date,
|
||||
qty: trade.qty,
|
||||
price: trade.price,
|
||||
amount: trade.entry.amount,
|
||||
currency: trade.currency,
|
||||
created_at: trade.created_at,
|
||||
updated_at: trade.updated_at
|
||||
}
|
||||
}.to_json
|
||||
end
|
||||
|
||||
# Export valuations
|
||||
@family.entries.valuations.includes(:account, :entryable).find_each do |entry|
|
||||
lines << {
|
||||
type: "Valuation",
|
||||
data: {
|
||||
id: entry.entryable.id,
|
||||
entry_id: entry.id,
|
||||
account_id: entry.account_id,
|
||||
date: entry.date,
|
||||
amount: entry.amount,
|
||||
currency: entry.currency,
|
||||
name: entry.name,
|
||||
created_at: entry.created_at,
|
||||
updated_at: entry.updated_at
|
||||
}
|
||||
}.to_json
|
||||
end
|
||||
|
||||
# Export budgets
|
||||
@family.budgets.find_each do |budget|
|
||||
lines << {
|
||||
type: "Budget",
|
||||
data: budget.as_json
|
||||
}.to_json
|
||||
end
|
||||
|
||||
# Export budget categories
|
||||
@family.budget_categories.includes(:budget, :category).find_each do |budget_category|
|
||||
lines << {
|
||||
type: "BudgetCategory",
|
||||
data: budget_category.as_json
|
||||
}.to_json
|
||||
end
|
||||
|
||||
lines.join("\n")
|
||||
end
|
||||
end
|
||||
22
app/models/family_export.rb
Normal file
22
app/models/family_export.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
class FamilyExport < ApplicationRecord
|
||||
belongs_to :family
|
||||
|
||||
has_one_attached :export_file
|
||||
|
||||
enum :status, {
|
||||
pending: "pending",
|
||||
processing: "processing",
|
||||
completed: "completed",
|
||||
failed: "failed"
|
||||
}, default: :pending, validate: true
|
||||
|
||||
scope :ordered, -> { order(created_at: :desc) }
|
||||
|
||||
def filename
|
||||
"maybe_export_#{created_at.strftime('%Y%m%d_%H%M%S')}.zip"
|
||||
end
|
||||
|
||||
def downloadable?
|
||||
completed? && export_file.attached?
|
||||
end
|
||||
end
|
||||
@@ -88,7 +88,7 @@ class Import < ApplicationRecord
|
||||
entries.destroy_all
|
||||
end
|
||||
|
||||
family.sync
|
||||
family.sync_later
|
||||
|
||||
update! status: :pending
|
||||
rescue => error
|
||||
|
||||
@@ -47,8 +47,8 @@ class Transaction::Search
|
||||
Rails.cache.fetch("transaction_search_totals/#{cache_key_base}") do
|
||||
result = transactions_scope
|
||||
.select(
|
||||
"COALESCE(SUM(CASE WHEN entries.amount >= 0 THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as expense_total",
|
||||
"COALESCE(SUM(CASE WHEN entries.amount < 0 THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as income_total",
|
||||
"COALESCE(SUM(CASE WHEN entries.amount >= 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment') THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as expense_total",
|
||||
"COALESCE(SUM(CASE WHEN entries.amount < 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment') THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as income_total",
|
||||
"COUNT(entries.id) as transactions_count"
|
||||
)
|
||||
.joins(
|
||||
@@ -61,8 +61,8 @@ class Transaction::Search
|
||||
|
||||
Totals.new(
|
||||
count: result.transactions_count.to_i,
|
||||
income_money: Money.new(result.income_total.to_i, family.currency),
|
||||
expense_money: Money.new(result.expense_total.to_i, family.currency)
|
||||
income_money: Money.new(result.income_total.round, family.currency),
|
||||
expense_money: Money.new(result.expense_total.round, family.currency)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
<h1 class="text-xl"><%= t(".accounts") %></h1>
|
||||
<div class="flex items-center gap-5">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= icon(
|
||||
"refresh-cw",
|
||||
as_button: true,
|
||||
size: "sm",
|
||||
href: sync_all_accounts_path,
|
||||
disabled: Current.family.syncing?,
|
||||
frame: :_top
|
||||
) %>
|
||||
<%= render DS::Link.new(
|
||||
text: "New account",
|
||||
href: new_account_path(return_to: accounts_path),
|
||||
|
||||
39
app/views/family_exports/_list.html.erb
Normal file
39
app/views/family_exports/_list.html.erb
Normal file
@@ -0,0 +1,39 @@
|
||||
<%= turbo_frame_tag "family_exports",
|
||||
data: exports.any? { |e| e.pending? || e.processing? } ? {
|
||||
turbo_refresh_url: family_exports_path,
|
||||
turbo_refresh_interval: 3000
|
||||
} : {} do %>
|
||||
<div class="mt-4 space-y-3 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>
|
||||
|
||||
<% if export.processing? || export.pending? %>
|
||||
<div class="flex items-center gap-2 text-secondary">
|
||||
<div class="animate-spin h-4 w-4 border-2 border-secondary border-t-transparent rounded-full"></div>
|
||||
<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 %>
|
||||
<% 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>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<p class="text-sm text-secondary text-center py-4">No exports yet</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
1
app/views/family_exports/index.html.erb
Normal file
1
app/views/family_exports/index.html.erb
Normal file
@@ -0,0 +1 @@
|
||||
<%= render "list", exports: @exports %>
|
||||
42
app/views/family_exports/new.html.erb
Normal file
42
app/views/family_exports/new.html.erb
Normal file
@@ -0,0 +1,42 @@
|
||||
<%= render DS::Dialog.new do |dialog| %>
|
||||
<% dialog.with_header(title: "Export your data", subtitle: "Download all your financial data") %>
|
||||
|
||||
<% dialog.with_body do %>
|
||||
<div class="space-y-4">
|
||||
<div class="bg-container-inset rounded-lg p-4 space-y-3">
|
||||
<h3 class="font-medium text-primary">What's included:</h3>
|
||||
<ul class="space-y-2 text-sm text-secondary">
|
||||
<li class="flex items-start gap-2">
|
||||
<%= icon "check", class: "shrink-0 mt-0.5 text-positive" %>
|
||||
<span>All accounts and balances</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<%= icon "check", class: "shrink-0 mt-0.5 text-positive" %>
|
||||
<span>Transaction history</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<%= icon "check", class: "shrink-0 mt-0.5 text-positive" %>
|
||||
<span>Investment trades</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<%= icon "check", class: "shrink-0 mt-0.5 text-positive" %>
|
||||
<span>Categories and tags</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
<p class="text-sm text-amber-800">
|
||||
<strong>Note:</strong> This export includes all of your data, but only some of the data can be imported back into Maybe via the CSV import feature. We support account, transaction (with category and tags), and trade imports. Other account data cannot be imported and is for your records only.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<%= form_with url: family_exports_path, method: :post, class: "space-y-4" do |form| %>
|
||||
<div class="flex gap-3">
|
||||
<%= link_to "Cancel", "#", class: "flex-1 text-center px-4 py-2 border border-primary rounded-lg hover:bg-surface-hover", data: { action: "click->modal#close" } %>
|
||||
<%= form.submit "Export data", class: "flex-1 bg-inverse fg-inverse rounded-lg px-4 py-2 cursor-pointer" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -1,5 +1,20 @@
|
||||
<% content_for :page_header do %>
|
||||
<% unless Current.family.self_hoster? %>
|
||||
<div class="bg-gray-100 mb-4 rounded-xl p-4 flex gap-2 items-start">
|
||||
<%= icon "triangle-alert", color: "warning" %>
|
||||
<div class="text-sm space-y-2">
|
||||
<p class="font-medium">We've made a tough decision to shut down the hosted version of Maybe. Here's what's happening next:</p>
|
||||
<ul class="list-disc list-inside space-y-1 ml-2">
|
||||
<li><%= link_to "Read why we're doing this here", "https://x.com/Shpigford/status/1947725345244709240", class: "underline" %></li>
|
||||
<li>You will be refunded in full.</li>
|
||||
<li>You have until July 31, 2025 to export your data. You can do that <%= link_to "here", settings_profile_path, class: "underline" %>.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="space-y-1 mb-6 flex gap-4 justify-between items-center lg:items-start">
|
||||
|
||||
<div class="space-y-1">
|
||||
<h1 class="text-xl lg:text-3xl font-medium text-primary">Welcome back, <%= Current.user.first_name %></h1>
|
||||
<p class="text-sm lg:text-base text-secondary">Here's what's happening with your finances</p>
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<%= styled_form_with model: rule, data: { controller: "auto-submit-form" } do |f| %>
|
||||
<%= styled_form_with model: rule, namespace: "rule_#{rule.id}", data: { controller: "auto-submit-form" } do |f| %>
|
||||
<%= f.toggle :active, { data: { auto_submit_form_target: "auto" } } %>
|
||||
<% end %>
|
||||
<%= render DS::Menu.new do |menu| %>
|
||||
|
||||
@@ -122,6 +122,29 @@
|
||||
</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? %>
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
{
|
||||
"ignored_warnings": [
|
||||
{
|
||||
"warning_type": "Redirect",
|
||||
"warning_code": 18,
|
||||
"fingerprint": "723b1970ca6bf16ea0c2c1afa0c00d3c54854a16568d6cb933e497947565d9ab",
|
||||
"check_name": "Redirect",
|
||||
"message": "Possible unprotected redirect",
|
||||
"file": "app/controllers/family_exports_controller.rb",
|
||||
"line": 30,
|
||||
"link": "https://brakemanscanner.org/docs/warning_types/redirect/",
|
||||
"code": "redirect_to(Current.family.family_exports.find(params[:id]).export_file, :allow_other_host => true)",
|
||||
"render_path": null,
|
||||
"location": {
|
||||
"type": "method",
|
||||
"class": "FamilyExportsController",
|
||||
"method": "download"
|
||||
},
|
||||
"user_input": "Current.family.family_exports.find(params[:id]).export_file",
|
||||
"confidence": "Weak",
|
||||
"cwe_id": [
|
||||
601
|
||||
],
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"warning_type": "Mass Assignment",
|
||||
"warning_code": 105,
|
||||
@@ -105,5 +128,5 @@
|
||||
"note": ""
|
||||
}
|
||||
],
|
||||
"brakeman_version": "7.0.2"
|
||||
"brakeman_version": "7.1.0"
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ module Maybe
|
||||
|
||||
private
|
||||
def semver
|
||||
"0.5.0"
|
||||
"0.6.0"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -24,6 +24,12 @@ Rails.application.routes.draw do
|
||||
end
|
||||
end
|
||||
|
||||
resources :family_exports, only: %i[new create index] do
|
||||
member do
|
||||
get :download
|
||||
end
|
||||
end
|
||||
|
||||
get "changelog", to: "pages#changelog"
|
||||
get "feedback", to: "pages#feedback"
|
||||
|
||||
@@ -156,6 +162,10 @@ Rails.application.routes.draw do
|
||||
get :sparkline
|
||||
patch :toggle_active
|
||||
end
|
||||
|
||||
collection do
|
||||
post :sync_all
|
||||
end
|
||||
end
|
||||
|
||||
# Convenience routes for polymorphic paths
|
||||
|
||||
10
db/migrate/20250724115507_create_family_exports.rb
Normal file
10
db/migrate/20250724115507_create_family_exports.rb
Normal file
@@ -0,0 +1,10 @@
|
||||
class CreateFamilyExports < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
create_table :family_exports, id: :uuid do |t|
|
||||
t.references :family, null: false, foreign_key: true, type: :uuid
|
||||
t.string :status, default: "pending", null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
11
db/schema.rb
generated
11
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 2025_07_19_121103) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2025_07_24_115507) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
@@ -270,6 +270,14 @@ ActiveRecord::Schema[7.2].define(version: 2025_07_19_121103) do
|
||||
t.datetime "latest_sync_completed_at", default: -> { "CURRENT_TIMESTAMP" }
|
||||
end
|
||||
|
||||
create_table "family_exports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "family_id", null: false
|
||||
t.string "status", default: "pending", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["family_id"], name: "index_family_exports_on_family_id"
|
||||
end
|
||||
|
||||
create_table "holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "account_id", null: false
|
||||
t.uuid "security_id", null: false
|
||||
@@ -830,6 +838,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_07_19_121103) do
|
||||
add_foreign_key "chats", "users"
|
||||
add_foreign_key "entries", "accounts"
|
||||
add_foreign_key "entries", "imports"
|
||||
add_foreign_key "family_exports", "families"
|
||||
add_foreign_key "holdings", "accounts"
|
||||
add_foreign_key "holdings", "securities"
|
||||
add_foreign_key "impersonation_session_logs", "impersonation_sessions"
|
||||
|
||||
73
test/controllers/family_exports_controller_test.rb
Normal file
73
test/controllers/family_exports_controller_test.rb
Normal file
@@ -0,0 +1,73 @@
|
||||
require "test_helper"
|
||||
|
||||
class FamilyExportsControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@admin = users(:family_admin)
|
||||
@non_admin = users(:family_member)
|
||||
@family = @admin.family
|
||||
|
||||
sign_in @admin
|
||||
end
|
||||
|
||||
test "non-admin cannot access exports" do
|
||||
sign_in @non_admin
|
||||
|
||||
get new_family_export_path
|
||||
assert_redirected_to root_path
|
||||
|
||||
post family_exports_path
|
||||
assert_redirected_to root_path
|
||||
|
||||
get family_exports_path
|
||||
assert_redirected_to root_path
|
||||
end
|
||||
|
||||
test "admin can view export modal" do
|
||||
get new_family_export_path
|
||||
assert_response :success
|
||||
assert_select "h2", text: "Export your data"
|
||||
end
|
||||
|
||||
test "admin can create export" do
|
||||
assert_enqueued_with(job: FamilyDataExportJob) do
|
||||
post family_exports_path
|
||||
end
|
||||
|
||||
assert_redirected_to settings_profile_path
|
||||
assert_equal "Export started. You'll be able to download it shortly.", flash[:notice]
|
||||
|
||||
export = @family.family_exports.last
|
||||
assert_equal "pending", export.status
|
||||
end
|
||||
|
||||
test "admin can view export list" do
|
||||
export1 = @family.family_exports.create!(status: "completed")
|
||||
export2 = @family.family_exports.create!(status: "processing")
|
||||
|
||||
get family_exports_path
|
||||
assert_response :success
|
||||
|
||||
assert_match export1.filename, response.body
|
||||
assert_match "Exporting...", response.body
|
||||
end
|
||||
|
||||
test "admin can download completed export" 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"
|
||||
)
|
||||
|
||||
get download_family_export_path(export)
|
||||
assert_redirected_to(/rails\/active_storage/)
|
||||
end
|
||||
|
||||
test "cannot download incomplete export" do
|
||||
export = @family.family_exports.create!(status: "processing")
|
||||
|
||||
get download_family_export_path(export)
|
||||
assert_redirected_to settings_profile_path
|
||||
assert_equal "Export not ready for download", flash[:alert]
|
||||
end
|
||||
end
|
||||
@@ -162,8 +162,7 @@ end
|
||||
income_money: Money.new(0, "USD")
|
||||
)
|
||||
|
||||
expected_filters = { "start_date" => 30.days.ago.to_date }
|
||||
Transaction::Search.expects(:new).with(family, filters: expected_filters).returns(search)
|
||||
Transaction::Search.expects(:new).with(family, filters: {}).returns(search)
|
||||
search.expects(:totals).once.returns(totals)
|
||||
|
||||
get transactions_url
|
||||
|
||||
3
test/fixtures/family_exports.yml
vendored
Normal file
3
test/fixtures/family_exports.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
# Empty file - no fixtures needed, tests create them dynamically
|
||||
32
test/jobs/family_data_export_job_test.rb
Normal file
32
test/jobs/family_data_export_job_test.rb
Normal file
@@ -0,0 +1,32 @@
|
||||
require "test_helper"
|
||||
|
||||
class FamilyDataExportJobTest < ActiveJob::TestCase
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@export = @family.family_exports.create!
|
||||
end
|
||||
|
||||
test "marks export as processing then completed" do
|
||||
assert_equal "pending", @export.status
|
||||
|
||||
perform_enqueued_jobs do
|
||||
FamilyDataExportJob.perform_later(@export)
|
||||
end
|
||||
|
||||
@export.reload
|
||||
assert_equal "completed", @export.status
|
||||
assert @export.export_file.attached?
|
||||
end
|
||||
|
||||
test "marks export as failed on error" do
|
||||
# Mock the exporter to raise an error
|
||||
Family::DataExporter.any_instance.stubs(:generate_export).raises(StandardError, "Export failed")
|
||||
|
||||
perform_enqueued_jobs do
|
||||
FamilyDataExportJob.perform_later(@export)
|
||||
end
|
||||
|
||||
@export.reload
|
||||
assert_equal "failed", @export.status
|
||||
end
|
||||
end
|
||||
88
test/models/budget_test.rb
Normal file
88
test/models/budget_test.rb
Normal file
@@ -0,0 +1,88 @@
|
||||
require "test_helper"
|
||||
|
||||
class BudgetTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:empty)
|
||||
end
|
||||
|
||||
test "budget_date_valid? allows going back 2 years even without entries" do
|
||||
two_years_ago = 2.years.ago.beginning_of_month
|
||||
assert Budget.budget_date_valid?(two_years_ago, family: @family)
|
||||
end
|
||||
|
||||
test "budget_date_valid? allows going back to earliest entry date if more than 2 years ago" do
|
||||
# Create an entry 3 years ago
|
||||
old_account = Account.create!(
|
||||
family: @family,
|
||||
accountable: Depository.new,
|
||||
name: "Old Account",
|
||||
status: "active",
|
||||
currency: "USD",
|
||||
balance: 1000
|
||||
)
|
||||
|
||||
old_entry = Entry.create!(
|
||||
account: old_account,
|
||||
entryable: Transaction.new(category: categories(:income)),
|
||||
date: 3.years.ago,
|
||||
name: "Old Transaction",
|
||||
amount: 100,
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
# Should allow going back to the old entry date
|
||||
assert Budget.budget_date_valid?(3.years.ago.beginning_of_month, family: @family)
|
||||
end
|
||||
|
||||
test "budget_date_valid? does not allow dates before earliest entry or 2 years ago" do
|
||||
# Create an entry 1 year ago
|
||||
account = Account.create!(
|
||||
family: @family,
|
||||
accountable: Depository.new,
|
||||
name: "Test Account",
|
||||
status: "active",
|
||||
currency: "USD",
|
||||
balance: 500
|
||||
)
|
||||
|
||||
Entry.create!(
|
||||
account: account,
|
||||
entryable: Transaction.new(category: categories(:income)),
|
||||
date: 1.year.ago,
|
||||
name: "Recent Transaction",
|
||||
amount: 100,
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
# Should not allow going back more than 2 years
|
||||
refute Budget.budget_date_valid?(3.years.ago.beginning_of_month, family: @family)
|
||||
end
|
||||
|
||||
test "budget_date_valid? does not allow future dates beyond current month" do
|
||||
refute Budget.budget_date_valid?(2.months.from_now, family: @family)
|
||||
end
|
||||
|
||||
test "previous_budget_param returns nil when date is too old" do
|
||||
# Create a budget at the oldest allowed date
|
||||
two_years_ago = 2.years.ago.beginning_of_month
|
||||
budget = Budget.create!(
|
||||
family: @family,
|
||||
start_date: two_years_ago,
|
||||
end_date: two_years_ago.end_of_month,
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
assert_nil budget.previous_budget_param
|
||||
end
|
||||
|
||||
test "previous_budget_param returns param when date is valid" do
|
||||
budget = Budget.create!(
|
||||
family: @family,
|
||||
start_date: Date.current.beginning_of_month,
|
||||
end_date: Date.current.end_of_month,
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
assert_not_nil budget.previous_budget_param
|
||||
end
|
||||
end
|
||||
115
test/models/family/data_exporter_test.rb
Normal file
115
test/models/family/data_exporter_test.rb
Normal file
@@ -0,0 +1,115 @@
|
||||
require "test_helper"
|
||||
|
||||
class Family::DataExporterTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@other_family = families(:empty)
|
||||
@exporter = Family::DataExporter.new(@family)
|
||||
|
||||
# Create some test data for the family
|
||||
@account = @family.accounts.create!(
|
||||
name: "Test Account",
|
||||
accountable: Depository.new,
|
||||
balance: 1000,
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
@category = @family.categories.create!(
|
||||
name: "Test Category",
|
||||
color: "#FF0000"
|
||||
)
|
||||
|
||||
@tag = @family.tags.create!(
|
||||
name: "Test Tag",
|
||||
color: "#00FF00"
|
||||
)
|
||||
end
|
||||
|
||||
test "generates a zip file with all required files" do
|
||||
zip_data = @exporter.generate_export
|
||||
|
||||
assert zip_data.is_a?(StringIO)
|
||||
|
||||
# Check that the zip contains all expected files
|
||||
expected_files = [ "accounts.csv", "transactions.csv", "trades.csv", "categories.csv", "all.ndjson" ]
|
||||
|
||||
Zip::File.open_buffer(zip_data) do |zip|
|
||||
actual_files = zip.entries.map(&:name)
|
||||
assert_equal expected_files.sort, actual_files.sort
|
||||
end
|
||||
end
|
||||
|
||||
test "generates valid CSV files" do
|
||||
zip_data = @exporter.generate_export
|
||||
|
||||
Zip::File.open_buffer(zip_data) do |zip|
|
||||
# Check accounts.csv
|
||||
accounts_csv = zip.read("accounts.csv")
|
||||
assert accounts_csv.include?("id,name,type,subtype,balance,currency,created_at")
|
||||
|
||||
# Check transactions.csv
|
||||
transactions_csv = zip.read("transactions.csv")
|
||||
assert transactions_csv.include?("date,account_name,amount,name,category,tags,notes,currency")
|
||||
|
||||
# Check trades.csv
|
||||
trades_csv = zip.read("trades.csv")
|
||||
assert trades_csv.include?("date,account_name,ticker,quantity,price,amount,currency")
|
||||
|
||||
# Check categories.csv
|
||||
categories_csv = zip.read("categories.csv")
|
||||
assert categories_csv.include?("name,color,parent_category,classification")
|
||||
end
|
||||
end
|
||||
|
||||
test "generates valid NDJSON file" do
|
||||
zip_data = @exporter.generate_export
|
||||
|
||||
Zip::File.open_buffer(zip_data) do |zip|
|
||||
ndjson_content = zip.read("all.ndjson")
|
||||
lines = ndjson_content.split("\n")
|
||||
|
||||
lines.each do |line|
|
||||
assert_nothing_raised { JSON.parse(line) }
|
||||
end
|
||||
|
||||
# Check that each line has expected structure
|
||||
first_line = JSON.parse(lines.first)
|
||||
assert first_line.key?("type")
|
||||
assert first_line.key?("data")
|
||||
end
|
||||
end
|
||||
|
||||
test "only exports data from the specified family" do
|
||||
# Create data for another family that should NOT be exported
|
||||
other_account = @other_family.accounts.create!(
|
||||
name: "Other Family Account",
|
||||
accountable: Depository.new,
|
||||
balance: 5000,
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
other_category = @other_family.categories.create!(
|
||||
name: "Other Family Category",
|
||||
color: "#0000FF"
|
||||
)
|
||||
|
||||
zip_data = @exporter.generate_export
|
||||
|
||||
Zip::File.open_buffer(zip_data) do |zip|
|
||||
# Check accounts.csv doesn't contain other family's data
|
||||
accounts_csv = zip.read("accounts.csv")
|
||||
assert accounts_csv.include?(@account.name)
|
||||
refute accounts_csv.include?(other_account.name)
|
||||
|
||||
# Check categories.csv doesn't contain other family's data
|
||||
categories_csv = zip.read("categories.csv")
|
||||
assert categories_csv.include?(@category.name)
|
||||
refute categories_csv.include?(other_category.name)
|
||||
|
||||
# Check NDJSON doesn't contain other family's data
|
||||
ndjson_content = zip.read("all.ndjson")
|
||||
refute ndjson_content.include?(other_account.id)
|
||||
refute ndjson_content.include?(other_category.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
7
test/models/family_export_test.rb
Normal file
7
test/models/family_export_test.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
require "test_helper"
|
||||
|
||||
class FamilyExportTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
||||
@@ -34,6 +34,7 @@ class TransactionsTest < ApplicationSystemTestCase
|
||||
|
||||
within "form#transactions-search" do
|
||||
fill_in "Search transactions ...", with: @transaction.name
|
||||
find("#q_search").send_keys(:tab) # Trigger blur to submit form
|
||||
end
|
||||
|
||||
assert_selector "#" + dom_id(@transaction), count: 1
|
||||
|
||||
Reference in New Issue
Block a user