mirror of
https://github.com/we-promise/sure
synced 2026-04-25 17:15:07 +02:00
Merge branch 'main' into feature/llm-cache-reset
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
@@ -21,7 +21,7 @@ services:
|
||||
- ..:/workspace:cached
|
||||
- bundle_cache:/bundle
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- ${PORT:-3000}:3000
|
||||
command: sleep infinity
|
||||
environment:
|
||||
<<: *rails_env
|
||||
|
||||
@@ -103,7 +103,8 @@ POSTHOG_HOST=
|
||||
|
||||
# Active Record Encryption Keys (Optional)
|
||||
# These keys are used to encrypt sensitive data like API keys in the database.
|
||||
# If not provided, they will be automatically generated based on your SECRET_KEY_BASE.
|
||||
# For managed mode: Set these environment variables to provide encryption keys.
|
||||
# For self-hosted mode: If not provided, they will be automatically generated based on your SECRET_KEY_BASE.
|
||||
# You can generate your own keys by running: rails db:encryption:init
|
||||
# ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=
|
||||
# ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
# To enable / disable self-hosting features.
|
||||
SELF_HOSTED = true
|
||||
|
||||
# Custom port config
|
||||
# For users who have other applications listening at 3000, this allows them to set a value puma will listen to.
|
||||
PORT=3000
|
||||
|
||||
# SimpleFIN runtime flags (default-off)
|
||||
# Accepted truthy values: 1, true, yes, on
|
||||
# SIMPLEFIN_DEBUG_RAW: when truthy, logs the raw payload returned by SimpleFIN (debug-only; can be noisy)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
SELF_HOSTED=false
|
||||
|
||||
# Custom port config
|
||||
# For users who have other applications listening at 3000, this allows them to set a value puma will listen to.
|
||||
PORT=3000
|
||||
|
||||
# SimpleFIN runtime flags (default-off)
|
||||
# Accepted truthy values: 1, true, yes, on
|
||||
# SIMPLEFIN_DEBUG_RAW: when truthy, logs the raw payload returned by SimpleFIN (debug-only; can be noisy)
|
||||
|
||||
32
.github/workflows/publish.yml
vendored
32
.github/workflows/publish.yml
vendored
@@ -329,9 +329,9 @@ jobs:
|
||||
|
||||
> **Note**: These are debug builds intended for testing purposes. For production use, please build from source with proper signing credentials.
|
||||
|
||||
bump-alpha-version:
|
||||
name: Bump Alpha Version
|
||||
if: startsWith(github.ref, 'refs/tags/v') && contains(github.ref_name, 'alpha')
|
||||
bump-pre_release-version:
|
||||
name: Bump Pre-release Version
|
||||
if: startsWith(github.ref, 'refs/tags/v') && (contains(github.ref_name, 'alpha') || contains(github.ref_name, 'beta') || contains(github.ref_name, 'rc'))
|
||||
needs: [merge]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
@@ -346,7 +346,7 @@ jobs:
|
||||
ref: main
|
||||
token: ${{ secrets.GH_PAT }}
|
||||
|
||||
- name: Bump alpha version
|
||||
- name: Bump pre-release version
|
||||
run: |
|
||||
VERSION_FILE="config/initializers/version.rb"
|
||||
|
||||
@@ -357,20 +357,26 @@ jobs:
|
||||
fi
|
||||
|
||||
# Extract current version
|
||||
CURRENT_VERSION=$(grep -oP '"\K[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+' "$VERSION_FILE")
|
||||
CURRENT_VERSION=$(grep -oP '"\K[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc)\.[0-9]+' "$VERSION_FILE")
|
||||
if [ -z "$CURRENT_VERSION" ]; then
|
||||
echo "ERROR: Could not extract version from $VERSION_FILE"
|
||||
exit 1
|
||||
fi
|
||||
echo "Current version: $CURRENT_VERSION"
|
||||
|
||||
# Extract the alpha number and increment it
|
||||
ALPHA_NUM=$(echo "$CURRENT_VERSION" | grep -oP 'alpha\.\K[0-9]+')
|
||||
if [ -z "$ALPHA_NUM" ]; then
|
||||
echo "ERROR: Could not extract alpha number from $CURRENT_VERSION"
|
||||
|
||||
# Extract the pre-release tag and number, then increment it
|
||||
PRE_RELEASE_TAG=$(echo "$CURRENT_VERSION" | grep -oP '(alpha|beta|rc)')
|
||||
if [ -z "$PRE_RELEASE_TAG" ]; then
|
||||
echo "ERROR: Could not extract pre-release tag from $CURRENT_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
NEW_ALPHA_NUM=$((ALPHA_NUM + 1))
|
||||
|
||||
PRE_RELEASE_NUM=$(echo "$CURRENT_VERSION" | grep -oP '(alpha|beta|rc)\.\K[0-9]+')
|
||||
if [ -z "$PRE_RELEASE_NUM" ]; then
|
||||
echo "ERROR: Could not extract pre-release number from $CURRENT_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
NEW_PRE_RELEASE_NUM=$((PRE_RELEASE_NUM + 1))
|
||||
|
||||
# Create new version string
|
||||
BASE_VERSION=$(echo "$CURRENT_VERSION" | grep -oP '^[0-9]+\.[0-9]+\.[0-9]+')
|
||||
@@ -378,7 +384,7 @@ jobs:
|
||||
echo "ERROR: Could not extract base version from $CURRENT_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
NEW_VERSION="${BASE_VERSION}-alpha.${NEW_ALPHA_NUM}"
|
||||
NEW_VERSION="${BASE_VERSION}-${PRE_RELEASE_TAG}.${NEW_PRE_RELEASE_NUM}"
|
||||
echo "New version: $NEW_VERSION"
|
||||
|
||||
# Update the version file
|
||||
@@ -401,7 +407,7 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git commit -m "Bump version to next alpha after ${{ github.ref_name }} release"
|
||||
git commit -m "Bump version to next iteration after ${{ github.ref_name }} release"
|
||||
|
||||
# Push with retry logic
|
||||
attempts=0
|
||||
|
||||
@@ -104,7 +104,8 @@ For further instructions, see guides below.
|
||||
|
||||
[](https://www.pikapods.com/pods?run=sure)
|
||||
|
||||
[](https://railway.com/deploy/sure?referralCode=CW_fPQ)
|
||||
[](https://railway.com/deploy/T_draF?referralCode=CW_fPQ)
|
||||
|
||||
|
||||
## License and Trademarks
|
||||
|
||||
|
||||
@@ -8,12 +8,104 @@ module Localize
|
||||
|
||||
private
|
||||
def switch_locale(&action)
|
||||
locale = locale_from_param || Current.family.try(:locale) || I18n.default_locale
|
||||
locale = locale_from_param || locale_from_user || locale_from_accept_language || locale_from_family || I18n.default_locale
|
||||
I18n.with_locale(locale, &action)
|
||||
end
|
||||
|
||||
def locale_from_user
|
||||
locale = Current.user&.locale
|
||||
return if locale.blank?
|
||||
|
||||
locale_sym = locale.to_sym
|
||||
locale_sym if I18n.available_locales.include?(locale_sym)
|
||||
end
|
||||
|
||||
def locale_from_family
|
||||
locale = Current.family&.locale
|
||||
return if locale.blank?
|
||||
|
||||
locale_sym = locale.to_sym
|
||||
locale_sym if I18n.available_locales.include?(locale_sym)
|
||||
end
|
||||
|
||||
def locale_from_accept_language
|
||||
locale = accept_language_top_locale
|
||||
return if locale.blank?
|
||||
|
||||
locale_sym = locale.to_sym
|
||||
return unless I18n.available_locales.include?(locale_sym)
|
||||
|
||||
# Auto-save detected locale to user profile (once per user, not per session)
|
||||
if Current.user.present? && Current.user.locale.blank?
|
||||
Current.user.update_column(:locale, locale_sym.to_s)
|
||||
end
|
||||
|
||||
locale_sym
|
||||
end
|
||||
|
||||
def accept_language_top_locale
|
||||
header = request.get_header("HTTP_ACCEPT_LANGUAGE")
|
||||
return if header.blank?
|
||||
|
||||
# Parse language;q pairs and sort by q-value (descending), preserving header order for ties
|
||||
parsed_languages = parse_accept_language(header)
|
||||
return if parsed_languages.empty?
|
||||
|
||||
# Find first supported locale by q-value priority
|
||||
parsed_languages.each do |lang, _q|
|
||||
normalized = normalize_locale(lang)
|
||||
canonical = supported_locales[normalized.downcase]
|
||||
return canonical if canonical.present?
|
||||
|
||||
primary_language = normalized.split("-").first
|
||||
primary_match = supported_locales[primary_language.downcase]
|
||||
return primary_match if primary_match.present?
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def parse_accept_language(header)
|
||||
entries = []
|
||||
|
||||
header.split(",").each_with_index do |entry, index|
|
||||
parts = entry.split(";")
|
||||
language = parts.first.to_s.strip
|
||||
next if language.blank?
|
||||
|
||||
# Extract q-value, default to 1.0
|
||||
q_value = 1.0
|
||||
parts[1..].each do |param|
|
||||
param = param.strip
|
||||
if param.start_with?("q=")
|
||||
q_str = param[2..]
|
||||
q_value = Float(q_str) rescue 1.0
|
||||
q_value = q_value.clamp(0.0, 1.0)
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
entries << [ language, q_value, index ]
|
||||
end
|
||||
|
||||
# Sort by q-value descending, then by original header order ascending
|
||||
entries.sort_by { |_lang, q, idx| [ -q, idx ] }.map { |lang, q, _idx| [ lang, q ] }
|
||||
end
|
||||
|
||||
def supported_locales
|
||||
@supported_locales ||= LanguagesHelper::SUPPORTED_LOCALES.each_with_object({}) do |locale, locales|
|
||||
normalized = normalize_locale(locale)
|
||||
locales[normalized.downcase] = normalized
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_locale(locale)
|
||||
locale.to_s.strip.gsub("_", "-")
|
||||
end
|
||||
|
||||
def locale_from_param
|
||||
return unless params[:locale].is_a?(String) && params[:locale].present?
|
||||
|
||||
locale = params[:locale].to_sym
|
||||
locale if I18n.available_locales.include?(locale)
|
||||
end
|
||||
|
||||
@@ -46,6 +46,7 @@ class Import::ConfigurationsController < ApplicationController
|
||||
:number_format,
|
||||
:signage_convention,
|
||||
:amount_type_strategy,
|
||||
:amount_type_identifier_value,
|
||||
:amount_type_inflow_value,
|
||||
:rows_to_skip
|
||||
)
|
||||
|
||||
@@ -10,6 +10,9 @@ class Settings::ProvidersController < ApplicationController
|
||||
]
|
||||
|
||||
prepare_show_context
|
||||
rescue ActiveRecord::Encryption::Errors::Configuration => e
|
||||
Rails.logger.error("Active Record Encryption not configured: #{e.message}")
|
||||
@encryption_error = true
|
||||
end
|
||||
|
||||
def update
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
class TradesController < ApplicationController
|
||||
include EntryableResource
|
||||
|
||||
before_action :set_entry_for_unlock, only: :unlock
|
||||
|
||||
# Defaults to a buy trade
|
||||
def new
|
||||
@account = Current.family.accounts.find_by(id: params[:account_id])
|
||||
@@ -17,6 +19,12 @@ class TradesController < ApplicationController
|
||||
@model = Trade::CreateForm.new(create_params.merge(account: @account)).create
|
||||
|
||||
if @model.persisted?
|
||||
# Mark manually created entries as user-modified to protect from sync
|
||||
if @model.is_a?(Entry)
|
||||
@model.lock_saved_attributes!
|
||||
@model.mark_user_modified!
|
||||
end
|
||||
|
||||
flash[:notice] = t("entries.create.success")
|
||||
|
||||
respond_to do |format|
|
||||
@@ -30,19 +38,28 @@ class TradesController < ApplicationController
|
||||
|
||||
def update
|
||||
if @entry.update(update_entry_params)
|
||||
@entry.lock_saved_attributes!
|
||||
@entry.mark_user_modified!
|
||||
@entry.sync_account_later
|
||||
|
||||
# Reload to ensure fresh state for turbo stream rendering
|
||||
@entry.reload
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(@entry.account), notice: t("entries.update.success") }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: [
|
||||
turbo_stream.replace(
|
||||
"header_entry_#{@entry.id}",
|
||||
dom_id(@entry, :header),
|
||||
partial: "trades/header",
|
||||
locals: { entry: @entry }
|
||||
),
|
||||
turbo_stream.replace("entry_#{@entry.id}", partial: "entries/entry", locals: { entry: @entry })
|
||||
turbo_stream.replace(
|
||||
dom_id(@entry, :protection),
|
||||
partial: "entries/protection_indicator",
|
||||
locals: { entry: @entry, unlock_path: unlock_trade_path(@entry.trade) }
|
||||
),
|
||||
turbo_stream.replace(@entry)
|
||||
]
|
||||
end
|
||||
end
|
||||
@@ -51,7 +68,19 @@ class TradesController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def unlock
|
||||
@entry.unlock_for_sync!
|
||||
flash[:notice] = t("entries.unlock.success")
|
||||
|
||||
redirect_back_or_to account_path(@entry.account)
|
||||
end
|
||||
|
||||
private
|
||||
def set_entry_for_unlock
|
||||
trade = Current.family.trades.find(params[:id])
|
||||
@entry = trade.entry
|
||||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:entry).permit(
|
||||
:name, :date, :amount, :currency, :excluded, :notes, :nature,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class TransactionsController < ApplicationController
|
||||
include EntryableResource
|
||||
|
||||
before_action :set_entry_for_unlock, only: :unlock
|
||||
before_action :store_params!, only: :index
|
||||
|
||||
def new
|
||||
@@ -68,6 +69,7 @@ class TransactionsController < ApplicationController
|
||||
if @entry.save
|
||||
@entry.sync_account_later
|
||||
@entry.lock_saved_attributes!
|
||||
@entry.mark_user_modified!
|
||||
@entry.transaction.lock_attr!(:tag_ids) if @entry.transaction.tags.any?
|
||||
|
||||
flash[:notice] = "Transaction created"
|
||||
@@ -98,6 +100,9 @@ class TransactionsController < ApplicationController
|
||||
@entry.transaction.lock_attr!(:tag_ids) if @entry.transaction.tags.any?
|
||||
@entry.sync_account_later
|
||||
|
||||
# Reload to ensure fresh state for turbo stream rendering
|
||||
@entry.reload
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(@entry.account), notice: "Transaction updated" }
|
||||
format.turbo_stream do
|
||||
@@ -107,6 +112,11 @@ class TransactionsController < ApplicationController
|
||||
partial: "transactions/header",
|
||||
locals: { entry: @entry }
|
||||
),
|
||||
turbo_stream.replace(
|
||||
dom_id(@entry, :protection),
|
||||
partial: "entries/protection_indicator",
|
||||
locals: { entry: @entry, unlock_path: unlock_transaction_path(@entry.transaction) }
|
||||
),
|
||||
turbo_stream.replace(@entry),
|
||||
*flash_notification_stream_items
|
||||
]
|
||||
@@ -206,7 +216,7 @@ class TransactionsController < ApplicationController
|
||||
original_name: @entry.name,
|
||||
original_date: I18n.l(@entry.date, format: :long))
|
||||
|
||||
@entry.account.entries.create!(
|
||||
new_entry = @entry.account.entries.create!(
|
||||
name: params[:trade_name] || Trade.build_name(is_sell ? "sell" : "buy", qty, security.ticker),
|
||||
date: @entry.date,
|
||||
amount: signed_amount,
|
||||
@@ -221,6 +231,10 @@ class TransactionsController < ApplicationController
|
||||
)
|
||||
)
|
||||
|
||||
# Mark the new trade as user-modified to protect from sync
|
||||
new_entry.lock_saved_attributes!
|
||||
new_entry.mark_user_modified!
|
||||
|
||||
# Mark original transaction as excluded (soft delete)
|
||||
@entry.update!(excluded: true)
|
||||
end
|
||||
@@ -235,6 +249,13 @@ class TransactionsController < ApplicationController
|
||||
redirect_back_or_to transactions_path, status: :see_other
|
||||
end
|
||||
|
||||
def unlock
|
||||
@entry.unlock_for_sync!
|
||||
flash[:notice] = t("entries.unlock.success")
|
||||
|
||||
redirect_back_or_to transactions_path
|
||||
end
|
||||
|
||||
def mark_as_recurring
|
||||
transaction = Current.family.transactions.includes(entry: :account).find(params[:id])
|
||||
|
||||
@@ -286,6 +307,11 @@ class TransactionsController < ApplicationController
|
||||
end
|
||||
|
||||
private
|
||||
def set_entry_for_unlock
|
||||
transaction = Current.family.transactions.find(params[:id])
|
||||
@entry = transaction.entry
|
||||
end
|
||||
|
||||
def needs_rule_notification?(transaction)
|
||||
return false if Current.user.rule_prompts_disabled
|
||||
|
||||
|
||||
@@ -105,8 +105,8 @@ class UsersController < ApplicationController
|
||||
def user_params
|
||||
params.require(:user).permit(
|
||||
:first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at,
|
||||
:show_sidebar, :default_period, :default_account_order, :show_ai_sidebar, :ai_enabled, :theme, :set_onboarding_preferences_at, :set_onboarding_goals_at,
|
||||
family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id ],
|
||||
:show_sidebar, :default_period, :default_account_order, :show_ai_sidebar, :ai_enabled, :theme, :set_onboarding_preferences_at, :set_onboarding_goals_at, :locale,
|
||||
family_attributes: [ :name, :currency, :country, :date_format, :timezone, :locale, :id ],
|
||||
goals: []
|
||||
)
|
||||
end
|
||||
|
||||
@@ -44,9 +44,9 @@ module SettingsHelper
|
||||
}
|
||||
end
|
||||
|
||||
def settings_section(title:, subtitle: nil, collapsible: false, open: true, &block)
|
||||
def settings_section(title:, subtitle: nil, collapsible: false, open: true, auto_open_param: nil, &block)
|
||||
content = capture(&block)
|
||||
render partial: "settings/section", locals: { title: title, subtitle: subtitle, content: content, collapsible: collapsible, open: open }
|
||||
render partial: "settings/section", locals: { title: title, subtitle: subtitle, content: content, collapsible: collapsible, open: open, auto_open_param: auto_open_param }
|
||||
end
|
||||
|
||||
def settings_nav_footer
|
||||
|
||||
29
app/javascript/controllers/auto_open_controller.js
Normal file
29
app/javascript/controllers/auto_open_controller.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// Connects to data-controller="auto-open"
|
||||
// Auto-opens a <details> element based on URL param
|
||||
// Use data-auto-open-param-value="paramName" to open when ?paramName=1 is in URL
|
||||
export default class extends Controller {
|
||||
static values = { param: String };
|
||||
|
||||
connect() {
|
||||
if (!this.hasParamValue || !this.paramValue) return;
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get(this.paramValue) === "1") {
|
||||
this.element.open = true;
|
||||
|
||||
// Clean up the URL param after opening
|
||||
params.delete(this.paramValue);
|
||||
const newUrl = params.toString()
|
||||
? `${window.location.pathname}?${params.toString()}${window.location.hash}`
|
||||
: `${window.location.pathname}${window.location.hash}`;
|
||||
window.history.replaceState({}, "", newUrl);
|
||||
|
||||
// Scroll into view after opening
|
||||
requestAnimationFrame(() => {
|
||||
this.element.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export default class extends Controller {
|
||||
"signedAmountFieldset",
|
||||
"customColumnFieldset",
|
||||
"amountTypeValue",
|
||||
"amountTypeInflowValue",
|
||||
"amountTypeStrategySelect",
|
||||
];
|
||||
|
||||
@@ -20,6 +21,9 @@ export default class extends Controller {
|
||||
this.amountTypeColumnKeyValue
|
||||
) {
|
||||
this.#showAmountTypeValueTargets(this.amountTypeColumnKeyValue);
|
||||
if (this.amountTypeValueTarget.querySelector("select")?.value) {
|
||||
this.#showAmountTypeInflowValueTargets();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +35,9 @@ export default class extends Controller {
|
||||
|
||||
if (this.amountTypeColumnKeyValue) {
|
||||
this.#showAmountTypeValueTargets(this.amountTypeColumnKeyValue);
|
||||
if (this.amountTypeValueTarget.querySelector("select")?.value) {
|
||||
this.#showAmountTypeInflowValueTargets();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +50,11 @@ export default class extends Controller {
|
||||
const amountTypeColumnKey = event.target.value;
|
||||
|
||||
this.#showAmountTypeValueTargets(amountTypeColumnKey);
|
||||
this.#showAmountTypeInflowValueTargets();
|
||||
}
|
||||
|
||||
handleAmountTypeIdentifierChange(event) {
|
||||
this.#showAmountTypeInflowValueTargets();
|
||||
}
|
||||
|
||||
refreshForm(event) {
|
||||
@@ -91,6 +103,29 @@ export default class extends Controller {
|
||||
select.appendChild(fragment);
|
||||
}
|
||||
|
||||
#showAmountTypeInflowValueTargets() {
|
||||
// Called when amount_type_identifier_value changes
|
||||
// Updates the displayed identifier value in the UI text and shows/hides the inflow value dropdown
|
||||
const identifierValueSelect = this.amountTypeValueTarget.querySelector("select");
|
||||
const selectedValue = identifierValueSelect.value;
|
||||
|
||||
if (!selectedValue) {
|
||||
this.amountTypeInflowValueTarget.classList.add("hidden");
|
||||
this.amountTypeInflowValueTarget.classList.remove("flex");
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the inflow value dropdown
|
||||
this.amountTypeInflowValueTarget.classList.remove("hidden");
|
||||
this.amountTypeInflowValueTarget.classList.add("flex");
|
||||
|
||||
// Update the displayed identifier value in the text
|
||||
const identifierSpan = this.amountTypeInflowValueTarget.querySelector("span.font-medium");
|
||||
if (identifierSpan) {
|
||||
identifierSpan.textContent = selectedValue;
|
||||
}
|
||||
}
|
||||
|
||||
#uniqueValuesForColumn(column) {
|
||||
const colIdx = this.csvValue[0].indexOf(column);
|
||||
const values = this.csvValue.slice(1).map((row) => row[colIdx]);
|
||||
@@ -120,6 +155,11 @@ export default class extends Controller {
|
||||
this.customColumnFieldsetTarget.classList.add("hidden");
|
||||
this.signedAmountFieldsetTarget.classList.remove("hidden");
|
||||
|
||||
// Hide the inflow value targets when using signed amount strategy
|
||||
this.amountTypeValueTarget.classList.add("hidden");
|
||||
this.amountTypeValueTarget.classList.remove("flex");
|
||||
this.amountTypeInflowValueTarget.classList.add("hidden");
|
||||
this.amountTypeInflowValueTarget.classList.remove("flex");
|
||||
// Remove required from custom column fields
|
||||
this.customColumnFieldsetTarget
|
||||
.querySelectorAll("select, input")
|
||||
|
||||
@@ -3,11 +3,26 @@ import { Controller } from "@hotwired/stimulus";
|
||||
// Connects to data-controller="lazy-load"
|
||||
// Used with <details> elements to lazy-load content when expanded
|
||||
// Use data-action="toggle->lazy-load#toggled" on the <details> element
|
||||
// Optional: data-lazy-load-auto-open-param-value="paramName" to auto-open when ?paramName=1 is in URL
|
||||
export default class extends Controller {
|
||||
static targets = ["content", "loading", "frame"];
|
||||
static values = { url: String, loaded: Boolean };
|
||||
static values = { url: String, loaded: Boolean, autoOpenParam: String };
|
||||
|
||||
connect() {
|
||||
// Check if we should auto-open based on URL param
|
||||
if (this.hasAutoOpenParamValue && this.autoOpenParamValue) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get(this.autoOpenParamValue) === "1") {
|
||||
this.element.open = true;
|
||||
// Clean up the URL param after opening
|
||||
params.delete(this.autoOpenParamValue);
|
||||
const newUrl = params.toString()
|
||||
? `${window.location.pathname}?${params.toString()}${window.location.hash}`
|
||||
: `${window.location.pathname}${window.location.hash}`;
|
||||
window.history.replaceState({}, "", newUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// If already open on connect (browser restored state), load immediately
|
||||
if (this.element.open && !this.loadedValue) {
|
||||
this.load();
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
class ApiKey < ApplicationRecord
|
||||
include Encryptable
|
||||
|
||||
belongs_to :user
|
||||
|
||||
# Use Rails built-in encryption for secure storage
|
||||
encrypts :display_key, deterministic: true
|
||||
# Encrypt display_key if ActiveRecord encryption is configured
|
||||
if encryption_ready?
|
||||
encrypts :display_key, deterministic: true
|
||||
end
|
||||
|
||||
# Constants
|
||||
SOURCES = [ "web", "mobile" ].freeze
|
||||
|
||||
16
app/models/concerns/encryptable.rb
Normal file
16
app/models/concerns/encryptable.rb
Normal file
@@ -0,0 +1,16 @@
|
||||
module Encryptable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
# Helper to detect if ActiveRecord Encryption is configured for this app.
|
||||
# This allows encryption to be optional - if not configured, sensitive fields
|
||||
# are stored in plaintext (useful for development or legacy deployments).
|
||||
def encryption_ready?
|
||||
creds_ready = Rails.application.credentials.active_record_encryption.present?
|
||||
env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? &&
|
||||
ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? &&
|
||||
ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present?
|
||||
creds_ready || env_ready
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,11 @@
|
||||
class EnableBankingAccount < ApplicationRecord
|
||||
include CurrencyNormalizable
|
||||
include CurrencyNormalizable, Encryptable
|
||||
|
||||
# Encrypt raw payloads if ActiveRecord encryption is configured
|
||||
if encryption_ready?
|
||||
encrypts :raw_payload
|
||||
encrypts :raw_transactions_payload
|
||||
end
|
||||
|
||||
belongs_to :enable_banking_item
|
||||
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
class EnableBankingItem < ApplicationRecord
|
||||
include Syncable, Provided, Unlinking
|
||||
include Syncable, Provided, Unlinking, Encryptable
|
||||
|
||||
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
|
||||
|
||||
# Helper to detect if ActiveRecord Encryption is configured for this app
|
||||
def self.encryption_ready?
|
||||
creds_ready = Rails.application.credentials.active_record_encryption.present?
|
||||
env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? &&
|
||||
ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? &&
|
||||
ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present?
|
||||
creds_ready || env_ready
|
||||
end
|
||||
|
||||
# Encrypt sensitive credentials if ActiveRecord encryption is configured
|
||||
# Encrypt sensitive credentials and raw payloads if ActiveRecord encryption is configured
|
||||
if encryption_ready?
|
||||
encrypts :client_certificate, deterministic: true
|
||||
encrypts :session_id, deterministic: true
|
||||
encrypts :raw_payload
|
||||
encrypts :raw_institution_payload
|
||||
end
|
||||
|
||||
validates :name, presence: true
|
||||
|
||||
@@ -265,6 +265,50 @@ class Entry < ApplicationRecord
|
||||
update!(user_modified: true)
|
||||
end
|
||||
|
||||
# Returns the reason this entry is protected from sync, or nil if not protected.
|
||||
# Priority: excluded > user_modified > import_locked
|
||||
#
|
||||
# @return [Symbol, nil] :excluded, :user_modified, :import_locked, or nil
|
||||
def protection_reason
|
||||
return :excluded if excluded?
|
||||
return :user_modified if user_modified?
|
||||
return :import_locked if import_locked?
|
||||
nil
|
||||
end
|
||||
|
||||
# Returns array of field names that are locked on entry and entryable.
|
||||
#
|
||||
# @return [Array<String>] locked field names
|
||||
def locked_field_names
|
||||
entry_keys = locked_attributes&.keys || []
|
||||
entryable_keys = entryable&.locked_attributes&.keys || []
|
||||
(entry_keys + entryable_keys).uniq
|
||||
end
|
||||
|
||||
# Returns hash of locked field names to their lock timestamps.
|
||||
# Combines locked_attributes from both entry and entryable.
|
||||
# Parses ISO8601 timestamps stored in locked_attributes.
|
||||
#
|
||||
# @return [Hash{String => Time}] field name to lock timestamp
|
||||
def locked_fields_with_timestamps
|
||||
combined = (locked_attributes || {}).merge(entryable&.locked_attributes || {})
|
||||
combined.transform_values do |timestamp|
|
||||
Time.zone.parse(timestamp.to_s) rescue timestamp
|
||||
end
|
||||
end
|
||||
|
||||
# Clears protection flags so provider sync can update this entry again.
|
||||
# Clears user_modified, import_locked flags, and all locked_attributes
|
||||
# on both the entry and its entryable.
|
||||
#
|
||||
# @return [void]
|
||||
def unlock_for_sync!
|
||||
self.class.transaction do
|
||||
update!(user_modified: false, import_locked: false, locked_attributes: {})
|
||||
entryable&.update!(locked_attributes: {})
|
||||
end
|
||||
end
|
||||
|
||||
class << self
|
||||
def search(params)
|
||||
EntrySearch.new(params).build_query(all)
|
||||
|
||||
@@ -53,6 +53,10 @@ module Family::Subscribeable
|
||||
subscription&.current_period_ends_at
|
||||
end
|
||||
|
||||
def subscription_pending_cancellation?
|
||||
subscription&.pending_cancellation?
|
||||
end
|
||||
|
||||
def start_subscription!(stripe_subscription_id)
|
||||
if subscription.present?
|
||||
subscription.update!(status: "active", stripe_id: stripe_subscription_id)
|
||||
|
||||
@@ -40,6 +40,7 @@ class Import < ApplicationRecord
|
||||
validates :col_sep, inclusion: { in: SEPARATORS.map(&:last) }
|
||||
validates :signage_convention, inclusion: { in: SIGNAGE_CONVENTIONS }, allow_nil: true
|
||||
validates :number_format, presence: true, inclusion: { in: NUMBER_FORMATS.keys }
|
||||
validate :custom_column_import_requires_identifier
|
||||
validates :rows_to_skip, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
||||
validate :account_belongs_to_family
|
||||
validate :rows_to_skip_within_file_bounds
|
||||
@@ -305,6 +306,14 @@ class Import < ApplicationRecord
|
||||
self.number_format ||= "1,234.56" # Default to US/UK format
|
||||
end
|
||||
|
||||
def custom_column_import_requires_identifier
|
||||
return unless amount_type_strategy == "custom_column"
|
||||
|
||||
if amount_type_inflow_value.blank?
|
||||
errors.add(:base, I18n.t("imports.errors.custom_column_requires_inflow"))
|
||||
end
|
||||
end
|
||||
|
||||
# Common encodings to try when UTF-8 detection fails
|
||||
# Windows-1250 is prioritized for Central/Eastern European languages
|
||||
COMMON_ENCODINGS = [ "Windows-1250", "Windows-1252", "ISO-8859-1", "ISO-8859-2" ].freeze
|
||||
|
||||
@@ -47,12 +47,27 @@ class Import::Row < ApplicationRecord
|
||||
if import.amount_type_strategy == "signed_amount"
|
||||
value * (import.signage_convention == "inflows_positive" ? -1 : 1)
|
||||
elsif import.amount_type_strategy == "custom_column"
|
||||
inflow_value = import.amount_type_inflow_value
|
||||
legacy_identifier = import.amount_type_inflow_value
|
||||
selected_identifier =
|
||||
if import.amount_type_identifier_value.present?
|
||||
import.amount_type_identifier_value
|
||||
else
|
||||
legacy_identifier
|
||||
end
|
||||
|
||||
if entity_type == inflow_value
|
||||
value * -1
|
||||
inflow_treatment =
|
||||
if import.amount_type_inflow_value.in?(%w[inflows_positive inflows_negative])
|
||||
import.amount_type_inflow_value
|
||||
elsif import.signage_convention.in?(%w[inflows_positive inflows_negative])
|
||||
import.signage_convention
|
||||
else
|
||||
"inflows_positive"
|
||||
end
|
||||
|
||||
if entity_type == selected_identifier
|
||||
value * (inflow_treatment == "inflows_positive" ? -1 : 1)
|
||||
else
|
||||
value
|
||||
value * (inflow_treatment == "inflows_positive" ? 1 : -1)
|
||||
end
|
||||
else
|
||||
raise "Unknown amount type strategy for import: #{import.amount_type_strategy}"
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
class Invitation < ApplicationRecord
|
||||
include Encryptable
|
||||
|
||||
belongs_to :family
|
||||
belongs_to :inviter, class_name: "User"
|
||||
|
||||
# Encrypt sensitive fields if ActiveRecord encryption is configured
|
||||
if encryption_ready?
|
||||
encrypts :token, deterministic: true
|
||||
encrypts :email, deterministic: true, downcase: true
|
||||
end
|
||||
|
||||
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
||||
validates :role, presence: true, inclusion: { in: %w[admin member] }
|
||||
validates :token, presence: true, uniqueness: true
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
class InviteCode < ApplicationRecord
|
||||
include Encryptable
|
||||
|
||||
# Encrypt token if ActiveRecord encryption is configured
|
||||
if encryption_ready?
|
||||
encrypts :token, deterministic: true, downcase: true
|
||||
end
|
||||
|
||||
before_validation :generate_token, on: :create
|
||||
|
||||
class << self
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
class LunchflowAccount < ApplicationRecord
|
||||
include CurrencyNormalizable
|
||||
include CurrencyNormalizable, Encryptable
|
||||
|
||||
# Encrypt raw payloads if ActiveRecord encryption is configured
|
||||
if encryption_ready?
|
||||
encrypts :raw_payload
|
||||
encrypts :raw_transactions_payload
|
||||
end
|
||||
|
||||
belongs_to :lunchflow_item
|
||||
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
class LunchflowItem < ApplicationRecord
|
||||
include Syncable, Provided, Unlinking
|
||||
include Syncable, Provided, Unlinking, Encryptable
|
||||
|
||||
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
|
||||
|
||||
# Helper to detect if ActiveRecord Encryption is configured for this app
|
||||
def self.encryption_ready?
|
||||
creds_ready = Rails.application.credentials.active_record_encryption.present?
|
||||
env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? &&
|
||||
ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? &&
|
||||
ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present?
|
||||
creds_ready || env_ready
|
||||
end
|
||||
|
||||
# Encrypt sensitive credentials if ActiveRecord encryption is configured (credentials OR env vars)
|
||||
# Encrypt sensitive credentials and raw payloads if ActiveRecord encryption is configured
|
||||
if encryption_ready?
|
||||
encrypts :api_key, deterministic: true
|
||||
encrypts :raw_payload
|
||||
encrypts :raw_institution_payload
|
||||
end
|
||||
|
||||
validates :name, presence: true
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
class MobileDevice < ApplicationRecord
|
||||
include Encryptable
|
||||
|
||||
# Encrypt device_id if ActiveRecord encryption is configured
|
||||
if encryption_ready?
|
||||
encrypts :device_id, deterministic: true
|
||||
end
|
||||
|
||||
belongs_to :user
|
||||
belongs_to :oauth_application, class_name: "Doorkeeper::Application", optional: true
|
||||
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
class PlaidAccount < ApplicationRecord
|
||||
include Encryptable
|
||||
|
||||
# Encrypt raw payloads if ActiveRecord encryption is configured
|
||||
if encryption_ready?
|
||||
encrypts :raw_payload
|
||||
encrypts :raw_transactions_payload
|
||||
# Support reading data encrypted under the old column name after rename
|
||||
encrypts :raw_holdings_payload, previous: { attribute: :raw_investments_payload }
|
||||
encrypts :raw_liabilities_payload
|
||||
end
|
||||
|
||||
belongs_to :plaid_item
|
||||
|
||||
# Legacy association via foreign key (will be removed after migration)
|
||||
@@ -38,9 +49,9 @@ class PlaidAccount < ApplicationRecord
|
||||
save!
|
||||
end
|
||||
|
||||
def upsert_plaid_investments_snapshot!(investments_snapshot)
|
||||
def upsert_plaid_holdings_snapshot!(holdings_snapshot)
|
||||
assign_attributes(
|
||||
raw_investments_payload: investments_snapshot
|
||||
raw_holdings_payload: holdings_snapshot
|
||||
)
|
||||
|
||||
save!
|
||||
|
||||
@@ -23,7 +23,7 @@ class PlaidAccount::Importer
|
||||
end
|
||||
|
||||
def import_investments
|
||||
plaid_account.upsert_plaid_investments_snapshot!(account_snapshot.investments_data)
|
||||
plaid_account.upsert_plaid_holdings_snapshot!(account_snapshot.investments_data)
|
||||
end
|
||||
|
||||
def import_liabilities
|
||||
|
||||
@@ -44,7 +44,7 @@ class PlaidAccount::Investments::BalanceCalculator
|
||||
attr_reader :plaid_account, :security_resolver
|
||||
|
||||
def holdings
|
||||
plaid_account.raw_investments_payload["holdings"] || []
|
||||
plaid_account.raw_holdings_payload&.dig("holdings") || []
|
||||
end
|
||||
|
||||
def calculate_investment_brokerage_cash
|
||||
|
||||
@@ -51,7 +51,7 @@ class PlaidAccount::Investments::HoldingsProcessor
|
||||
end
|
||||
|
||||
def holdings
|
||||
plaid_account.raw_investments_payload&.[]("holdings") || []
|
||||
plaid_account.raw_holdings_payload&.[]("holdings") || []
|
||||
end
|
||||
|
||||
def parse_decimal(value)
|
||||
|
||||
@@ -43,7 +43,7 @@ class PlaidAccount::Investments::SecurityResolver
|
||||
Response = Struct.new(:security, :cash_equivalent?, :brokerage_cash?, keyword_init: true)
|
||||
|
||||
def securities
|
||||
plaid_account.raw_investments_payload["securities"] || []
|
||||
plaid_account.raw_holdings_payload&.dig("securities") || []
|
||||
end
|
||||
|
||||
# Tries to find security, or returns the "proxy security" (common with options contracts that have underlying securities)
|
||||
|
||||
@@ -98,7 +98,7 @@ class PlaidAccount::Investments::TransactionsProcessor
|
||||
end
|
||||
|
||||
def transactions
|
||||
plaid_account.raw_investments_payload["transactions"] || []
|
||||
plaid_account.raw_holdings_payload&.dig("transactions") || []
|
||||
end
|
||||
|
||||
# Plaid unfortunately returns incorrect signage on some `quantity` values. They claim all "sell" transactions
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
class PlaidItem < ApplicationRecord
|
||||
include Syncable, Provided
|
||||
include Syncable, Provided, Encryptable
|
||||
|
||||
enum :plaid_region, { us: "us", eu: "eu" }
|
||||
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
|
||||
|
||||
# Helper to detect if ActiveRecord Encryption is configured for this app
|
||||
def self.encryption_ready?
|
||||
creds_ready = Rails.application.credentials.active_record_encryption.present?
|
||||
env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? &&
|
||||
ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? &&
|
||||
ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present?
|
||||
creds_ready || env_ready
|
||||
end
|
||||
|
||||
# Encrypt sensitive credentials if ActiveRecord encryption is configured (credentials OR env vars)
|
||||
# Encrypt sensitive credentials and raw payloads if ActiveRecord encryption is configured
|
||||
if encryption_ready?
|
||||
encrypts :access_token, deterministic: true
|
||||
encrypts :raw_payload
|
||||
encrypts :raw_institution_payload
|
||||
end
|
||||
|
||||
validates :name, presence: true
|
||||
|
||||
@@ -61,7 +61,7 @@ class PlaidItem::Syncer
|
||||
|
||||
def count_holdings(plaid_accounts)
|
||||
plaid_accounts.sum do |pa|
|
||||
Array(pa.raw_investments_payload).size
|
||||
pa.raw_holdings_payload&.dig("holdings")&.size || 0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,7 +10,8 @@ class Provider::Stripe::SubscriptionEventProcessor < Provider::Stripe::EventProc
|
||||
interval: subscription_details.plan.interval,
|
||||
amount: subscription_details.plan.amount / 100.0, # Stripe returns cents, we report dollars
|
||||
currency: subscription_details.plan.currency.upcase,
|
||||
current_period_ends_at: Time.at(subscription_details.current_period_end)
|
||||
current_period_ends_at: Time.at(subscription_details.current_period_end),
|
||||
cancel_at_period_end: subscription.cancel_at_period_end
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
42
app/models/rule/condition_filter/transaction_type.rb
Normal file
42
app/models/rule/condition_filter/transaction_type.rb
Normal file
@@ -0,0 +1,42 @@
|
||||
class Rule::ConditionFilter::TransactionType < Rule::ConditionFilter
|
||||
# Transfer kinds matching Transaction#transfer? method
|
||||
TRANSFER_KINDS = %w[funds_movement cc_payment loan_payment].freeze
|
||||
|
||||
def type
|
||||
"select"
|
||||
end
|
||||
|
||||
def options
|
||||
[
|
||||
[ I18n.t("rules.condition_filters.transaction_type.income"), "income" ],
|
||||
[ I18n.t("rules.condition_filters.transaction_type.expense"), "expense" ],
|
||||
[ I18n.t("rules.condition_filters.transaction_type.transfer"), "transfer" ]
|
||||
]
|
||||
end
|
||||
|
||||
def operators
|
||||
[ [ I18n.t("rules.condition_filters.transaction_type.equal_to"), "=" ] ]
|
||||
end
|
||||
|
||||
def prepare(scope)
|
||||
scope.with_entry
|
||||
end
|
||||
|
||||
def apply(scope, operator, value)
|
||||
# Logic matches Transaction::Search#apply_type_filter for consistency
|
||||
case value
|
||||
when "income"
|
||||
# Negative amounts, excluding transfers and investment_contribution
|
||||
scope.where("entries.amount < 0")
|
||||
.where.not(kind: TRANSFER_KINDS + %w[investment_contribution])
|
||||
when "expense"
|
||||
# Positive amounts OR investment_contribution (regardless of sign), excluding transfers
|
||||
scope.where("entries.amount >= 0 OR transactions.kind = 'investment_contribution'")
|
||||
.where.not(kind: TRANSFER_KINDS)
|
||||
when "transfer"
|
||||
scope.where(kind: TRANSFER_KINDS)
|
||||
else
|
||||
scope
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -7,6 +7,7 @@ class Rule::Registry::TransactionResource < Rule::Registry
|
||||
[
|
||||
Rule::ConditionFilter::TransactionName.new(rule),
|
||||
Rule::ConditionFilter::TransactionAmount.new(rule),
|
||||
Rule::ConditionFilter::TransactionType.new(rule),
|
||||
Rule::ConditionFilter::TransactionMerchant.new(rule),
|
||||
Rule::ConditionFilter::TransactionCategory.new(rule),
|
||||
Rule::ConditionFilter::TransactionDetails.new(rule),
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
class Session < ApplicationRecord
|
||||
include Encryptable
|
||||
|
||||
# Encrypt user_agent if ActiveRecord encryption is configured
|
||||
if encryption_ready?
|
||||
encrypts :user_agent
|
||||
end
|
||||
|
||||
belongs_to :user
|
||||
belongs_to :active_impersonator_session,
|
||||
-> { where(status: :in_progress) },
|
||||
class_name: "ImpersonationSession",
|
||||
optional: true
|
||||
|
||||
before_create do
|
||||
self.user_agent = Current.user_agent
|
||||
self.ip_address = Current.ip_address
|
||||
end
|
||||
before_create :capture_session_info
|
||||
|
||||
def get_preferred_tab(tab_key)
|
||||
data.dig("tab_preferences", tab_key)
|
||||
@@ -19,4 +23,13 @@ class Session < ApplicationRecord
|
||||
data["tab_preferences"][tab_key] = tab_value
|
||||
save!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def capture_session_info
|
||||
self.user_agent = Current.user_agent
|
||||
raw_ip = Current.ip_address
|
||||
self.ip_address = raw_ip
|
||||
self.ip_address_digest = Digest::SHA256.hexdigest(raw_ip.to_s) if raw_ip.present?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
class SimplefinAccount < ApplicationRecord
|
||||
include Encryptable
|
||||
|
||||
# Encrypt raw payloads if ActiveRecord encryption is configured
|
||||
if encryption_ready?
|
||||
encrypts :raw_payload
|
||||
encrypts :raw_transactions_payload
|
||||
encrypts :raw_holdings_payload
|
||||
end
|
||||
|
||||
belongs_to :simplefin_item
|
||||
|
||||
# Legacy association via foreign key (will be removed after migration)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class SimplefinItem < ApplicationRecord
|
||||
include Syncable, Provided
|
||||
include Syncable, Provided, Encryptable
|
||||
include SimplefinItem::Unlinking
|
||||
|
||||
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
|
||||
@@ -7,18 +7,11 @@ class SimplefinItem < ApplicationRecord
|
||||
# Virtual attribute for the setup token form field
|
||||
attr_accessor :setup_token
|
||||
|
||||
# Helper to detect if ActiveRecord Encryption is configured for this app
|
||||
def self.encryption_ready?
|
||||
creds_ready = Rails.application.credentials.active_record_encryption.present?
|
||||
env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? &&
|
||||
ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? &&
|
||||
ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present?
|
||||
creds_ready || env_ready
|
||||
end
|
||||
|
||||
# Encrypt sensitive credentials if ActiveRecord encryption is configured (credentials OR env vars)
|
||||
# Encrypt sensitive credentials and raw payloads if ActiveRecord encryption is configured
|
||||
if encryption_ready?
|
||||
encrypts :access_url, deterministic: true
|
||||
encrypts :raw_payload
|
||||
encrypts :raw_institution_payload
|
||||
end
|
||||
|
||||
validates :name, presence: true
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SsoProvider < ApplicationRecord
|
||||
# Encrypt sensitive credentials using Rails 7.2 built-in encryption
|
||||
encrypts :client_secret, deterministic: false
|
||||
include Encryptable
|
||||
|
||||
# Encrypt sensitive credentials if ActiveRecord encryption is configured
|
||||
if encryption_ready?
|
||||
encrypts :client_secret, deterministic: false
|
||||
end
|
||||
|
||||
# Default enabled to true for new providers
|
||||
attribute :enabled, :boolean, default: true
|
||||
|
||||
@@ -35,4 +35,8 @@ class Subscription < ApplicationRecord
|
||||
"Open demo"
|
||||
end
|
||||
end
|
||||
|
||||
def pending_cancellation?
|
||||
active? && cancel_at_period_end?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
class User < ApplicationRecord
|
||||
include Encryptable
|
||||
|
||||
# Allow nil password for SSO-only users (JIT provisioning).
|
||||
# Custom validation ensures password is present for non-SSO registration.
|
||||
has_secure_password validations: false
|
||||
|
||||
# Encrypt sensitive fields if ActiveRecord encryption is configured
|
||||
if encryption_ready?
|
||||
# MFA secrets
|
||||
encrypts :otp_secret, deterministic: true
|
||||
# Note: otp_backup_codes is a PostgreSQL array column which doesn't support
|
||||
# AR encryption. To encrypt it, a migration would be needed to change the
|
||||
# column type from array to text/jsonb.
|
||||
|
||||
# PII - emails (deterministic for lookups, downcase for case-insensitive)
|
||||
encrypts :email, deterministic: true, downcase: true
|
||||
encrypts :unconfirmed_email, deterministic: true, downcase: true
|
||||
|
||||
# PII - names (non-deterministic for maximum security)
|
||||
encrypts :first_name
|
||||
encrypts :last_name
|
||||
end
|
||||
|
||||
belongs_to :family
|
||||
belongs_to :last_viewed_chat, class_name: "Chat", optional: true
|
||||
has_many :sessions, dependent: :destroy
|
||||
@@ -20,6 +39,7 @@ class User < ApplicationRecord
|
||||
validate :ensure_valid_profile_image
|
||||
validates :default_period, inclusion: { in: Period::PERIODS.keys }
|
||||
validates :default_account_order, inclusion: { in: AccountOrder::ORDERS.keys }
|
||||
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }, allow_nil: true
|
||||
|
||||
# Password is required on create unless the user is being created via SSO JIT.
|
||||
# SSO JIT users have password_digest = nil and authenticate via OIDC only.
|
||||
@@ -332,7 +352,7 @@ class User < ApplicationRecord
|
||||
if (index = otp_backup_codes.index(code))
|
||||
remaining_codes = otp_backup_codes.dup
|
||||
remaining_codes.delete_at(index)
|
||||
update_column(:otp_backup_codes, remaining_codes)
|
||||
update!(otp_backup_codes: remaining_codes)
|
||||
true
|
||||
else
|
||||
false
|
||||
|
||||
@@ -61,14 +61,14 @@
|
||||
<%= format_money(budget_category.actual_spending_money) %>
|
||||
</span>
|
||||
</div>
|
||||
<div class="whitespace-nowrap w-full sm:w-auto">
|
||||
<div class="whitespace-nowrap w-full sm:w-auto inline-flex items-center gap-1">
|
||||
<span class="text-sm text-secondary"><%= t("reports.budget_performance.budgeted") %>:</span>
|
||||
<span class="font-medium text-primary">
|
||||
<%= format_money(budget_category.budgeted_spending_money) %>
|
||||
<% if budget_category.inherits_parent_budget? %>
|
||||
<span class="text-xs text-subdued">(<%= t("reports.budget_performance.shared") %>)</span>
|
||||
<% end %>
|
||||
</span>
|
||||
<% if budget_category.inherits_parent_budget? %>
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-gray-500/5 text-secondary"><%= t("reports.budget_performance.shared") %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="whitespace-nowrap w-full sm:w-auto lg:ml-auto">
|
||||
<% if budget_category.available_to_spend >= 0 %>
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
<%# locals: (coinbase_item:) %>
|
||||
|
||||
<%= tag.div id: dom_id(coinbase_item) do %>
|
||||
<%# Compute unlinked count early so it's available for both menu and bottom section %>
|
||||
<% unlinked_count = if defined?(@coinbase_unlinked_count_map) && @coinbase_unlinked_count_map
|
||||
@coinbase_unlinked_count_map[coinbase_item.id] || 0
|
||||
else
|
||||
begin
|
||||
coinbase_item.coinbase_accounts
|
||||
.left_joins(:account_provider)
|
||||
.where(account_providers: { id: nil })
|
||||
.count
|
||||
rescue => e
|
||||
Rails.logger.warn("Coinbase card: unlinked_count fallback failed: #{e.class} - #{e.message}")
|
||||
0
|
||||
end
|
||||
end %>
|
||||
|
||||
<details open class="group bg-container p-4 shadow-border-xs rounded-xl">
|
||||
<summary class="flex items-center justify-between gap-2 focus-visible:outline-hidden">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -55,15 +70,25 @@
|
||||
href: settings_providers_path,
|
||||
frame: "_top"
|
||||
) %>
|
||||
<% elsif Rails.env.development? %>
|
||||
<% else %>
|
||||
<%= icon(
|
||||
"refresh-cw",
|
||||
as_button: true,
|
||||
href: sync_coinbase_item_path(coinbase_item)
|
||||
href: sync_coinbase_item_path(coinbase_item),
|
||||
disabled: coinbase_item.syncing?
|
||||
) %>
|
||||
<% end %>
|
||||
|
||||
<%= render DS::Menu.new do |menu| %>
|
||||
<% if unlinked_count.to_i > 0 %>
|
||||
<% menu.with_item(
|
||||
variant: "link",
|
||||
text: t(".import_wallets_menu"),
|
||||
icon: "plus",
|
||||
href: setup_accounts_coinbase_item_path(coinbase_item),
|
||||
frame: :modal
|
||||
) %>
|
||||
<% end %>
|
||||
<% menu.with_item(
|
||||
variant: "button",
|
||||
text: t(".delete"),
|
||||
@@ -93,20 +118,6 @@
|
||||
provider_item: coinbase_item
|
||||
) %>
|
||||
|
||||
<%# Compute unlinked Coinbase accounts (no AccountProvider link) %>
|
||||
<% unlinked_count = if defined?(@coinbase_unlinked_count_map) && @coinbase_unlinked_count_map
|
||||
@coinbase_unlinked_count_map[coinbase_item.id] || 0
|
||||
else
|
||||
begin
|
||||
coinbase_item.coinbase_accounts
|
||||
.left_joins(:account_provider)
|
||||
.where(account_providers: { id: nil })
|
||||
.count
|
||||
rescue => e
|
||||
0
|
||||
end
|
||||
end %>
|
||||
|
||||
<% if unlinked_count.to_i > 0 && coinbase_item.accounts.empty? %>
|
||||
<%# No accounts imported yet - show prominent setup prompt %>
|
||||
<div class="p-4 flex flex-col gap-3 items-center justify-center">
|
||||
@@ -120,16 +131,6 @@
|
||||
frame: :modal
|
||||
) %>
|
||||
</div>
|
||||
<% elsif unlinked_count.to_i > 0 %>
|
||||
<%# Some accounts imported, more available - show subtle link %>
|
||||
<div class="pt-2 border-t border-primary">
|
||||
<%= link_to setup_accounts_coinbase_item_path(coinbase_item),
|
||||
data: { turbo_frame: :modal },
|
||||
class: "flex items-center gap-2 text-sm text-secondary hover:text-primary transition-colors" do %>
|
||||
<%= icon "plus", size: "sm" %>
|
||||
<span><%= t(".more_wallets_available", count: unlinked_count) %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% elsif coinbase_item.accounts.empty? && coinbase_item.coinbase_accounts.none? %>
|
||||
<%# No coinbase_accounts at all - waiting for sync %>
|
||||
<div class="p-4 flex flex-col gap-3 items-center justify-center">
|
||||
|
||||
42
app/views/entries/_protection_indicator.html.erb
Normal file
42
app/views/entries/_protection_indicator.html.erb
Normal file
@@ -0,0 +1,42 @@
|
||||
<%# locals: (entry:, unlock_path:) %>
|
||||
|
||||
<%# Protection indicator - shows when entry is protected from sync overwrites %>
|
||||
<%= turbo_frame_tag dom_id(entry, :protection) do %>
|
||||
<% if entry.protected_from_sync? && !entry.excluded? %>
|
||||
<details class="mx-4 my-3 border border-primary rounded-lg overflow-hidden">
|
||||
<summary class="flex items-center gap-2 cursor-pointer p-3 bg-container hover:bg-surface-hover list-none [&::-webkit-details-marker]:hidden">
|
||||
<%= icon "lock", size: "sm", class: "text-secondary" %>
|
||||
<span class="text-sm font-medium text-primary flex-1"><%= t("entries.protection.title") %></span>
|
||||
<%= icon "chevron-down", size: "sm", class: "text-secondary transition-transform [[open]>&]:rotate-180" %>
|
||||
</summary>
|
||||
<div class="p-4 border-t border-primary bg-surface-inset space-y-4">
|
||||
<p class="text-sm text-secondary">
|
||||
<%= t("entries.protection.description") %>
|
||||
</p>
|
||||
|
||||
<% if entry.locked_field_names.any? %>
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-medium text-secondary"><%= t("entries.protection.locked_fields_label") %></p>
|
||||
<% entry.locked_fields_with_timestamps.each do |field, timestamp| %>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-primary"><%= field.humanize %></span>
|
||||
<span class="text-secondary"><%= timestamp.respond_to?(:strftime) ? l(timestamp.to_date, format: :long) : timestamp %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= link_to unlock_path,
|
||||
class: "w-full flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium rounded-lg border border-secondary text-primary hover:bg-surface-hover transition-colors",
|
||||
data: {
|
||||
turbo_method: :post,
|
||||
turbo_confirm: t("entries.protection.unlock_confirm"),
|
||||
turbo_frame: "_top"
|
||||
} do %>
|
||||
<%= icon "unlock", size: "sm" %>
|
||||
<span><%= t("entries.protection.unlock_button") %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
</details>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -82,11 +82,21 @@
|
||||
<div class="items-center gap-2 text-sm <%= @import.entity_type_col_label.nil? ? "hidden" : "flex" %>" data-import-target="amountTypeValue">
|
||||
<span class="shrink-0 text-secondary">↪</span>
|
||||
<span class="text-secondary">Set</span>
|
||||
<%= form.select :amount_type_inflow_value,
|
||||
<%= form.select :amount_type_identifier_value,
|
||||
@import.selectable_amount_type_values,
|
||||
{ prompt: "Select column", container_class: "w-48 px-3 py-1.5 border border-secondary rounded-md" },
|
||||
{ prompt: "Select value", container_class: "w-48 px-3 py-1.5 border border-secondary rounded-md" },
|
||||
required: @import.amount_type_strategy == "custom_column",
|
||||
data: { action: "import#handleAmountTypeIdentifierChange" } %>
|
||||
<span class="text-secondary">as identifier value</span>
|
||||
</div>
|
||||
|
||||
<div class="items-center gap-2 text-sm <%= @import.amount_type_identifier_value.nil? ? "hidden" : "flex" %>" data-import-target="amountTypeInflowValue">
|
||||
<span class="shrink-0 text-secondary">↪</span>
|
||||
<span class="text-secondary">Treat "<span class="font-medium"><%= @import.amount_type_identifier_value %></span>" as</span>
|
||||
<%= form.select :amount_type_inflow_value,
|
||||
[["Income (inflow)", "inflows_positive"], ["Expense (outflow)", "inflows_negative"]],
|
||||
{ prompt: "Select type", container_class: "w-48 px-3 py-1.5 border border-secondary rounded-md" },
|
||||
required: @import.amount_type_strategy == "custom_column" %>
|
||||
<span class="text-secondary">as "income" (inflow) value</span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<%= render "layouts/shared/htmldoc" do %>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex flex-col h-full px-6 py-12 bg-surface">
|
||||
<div class="flex flex-col h-full overflow-y-auto">
|
||||
<div class="flex flex-col min-h-full px-6 py-12 bg-surface">
|
||||
<div class="grow flex flex-col justify-center">
|
||||
<div class="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div class="flex justify-center mt-2 md:mb-6">
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
|
||||
<%= family_form.select :locale,
|
||||
language_options,
|
||||
{ label: t(".locale"), required: true, selected: params[:locale] || @user.family.locale },
|
||||
{ label: t(".locale"), required: true, selected: params[:locale] || @user.locale || I18n.locale },
|
||||
{ data: { action: "onboarding#setLocale" } } %>
|
||||
|
||||
<%= family_form.select :currency,
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<%= property_form.number_field :year_built,
|
||||
label: t("properties.form.year_built"),
|
||||
placeholder: t("properties.form.year_built_placeholder"),
|
||||
min: 1800,
|
||||
min: 1500,
|
||||
max: Time.current.year %>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<%= property_form.number_field :year_built,
|
||||
label: "Year Built (optional)",
|
||||
placeholder: "1990",
|
||||
min: 1800,
|
||||
min: 1500,
|
||||
max: Time.current.year %>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<%# locals: (title:, subtitle: nil, content:, collapsible: false, open: true) %>
|
||||
<%# locals: (title:, subtitle: nil, content:, collapsible: false, open: true, auto_open_param: nil) %>
|
||||
<% if collapsible %>
|
||||
<details <%= "open" if open %> class="group bg-container shadow-border-xs rounded-xl p-4">
|
||||
<details <%= "open" if open %>
|
||||
class="group bg-container shadow-border-xs rounded-xl p-4"
|
||||
<%= "data-controller=\"auto-open\" data-auto-open-param-value=\"#{h(auto_open_param)}\"".html_safe if auto_open_param.present? %>>
|
||||
<summary class="flex items-center justify-between gap-2 cursor-pointer rounded-lg list-none [&::-webkit-details-marker]:hidden">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= icon "chevron-right", class: "text-secondary group-open:transform group-open:rotate-90 transition-transform" %>
|
||||
|
||||
@@ -16,7 +16,11 @@
|
||||
<span>Currently on the <span class="font-medium"><%= @family.subscription.name %></span>.</span> <br />
|
||||
|
||||
<% if @family.next_payment_date %>
|
||||
<span><%= t("views.settings.payments.renewal", date: l(@family.next_payment_date, format: :long)) %></span>
|
||||
<% if @family.subscription_pending_cancellation? %>
|
||||
<span><%= t("views.settings.payments.cancellation", date: l(@family.next_payment_date, format: :long)) %></span>
|
||||
<% else %>
|
||||
<span><%= t("views.settings.payments.renewal", date: l(@family.next_payment_date, format: :long)) %></span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</p>
|
||||
<% elsif @family.trialing? %>
|
||||
|
||||
@@ -5,16 +5,16 @@
|
||||
<%= styled_form_with model: @user, class: "space-y-4", data: { controller: "auto-submit-form" } do |form| %>
|
||||
<%= form.hidden_field :redirect_to, value: "preferences" %>
|
||||
|
||||
<%= form.select :locale,
|
||||
language_options,
|
||||
{ label: t(".language"), include_blank: t(".language_auto") },
|
||||
{ data: { auto_submit_form_target: "auto" } } %>
|
||||
|
||||
<%= form.fields_for :family do |family_form| %>
|
||||
<%= family_form.select :currency,
|
||||
Money::Currency.as_options.map { |currency| [ "#{currency.name} (#{currency.iso_code})", currency.iso_code ] },
|
||||
{ label: t(".currency") }, disabled: true %>
|
||||
|
||||
<%= family_form.select :locale,
|
||||
language_options,
|
||||
{ label: t(".language") },
|
||||
{ data: { auto_submit_form_target: "auto" } } %>
|
||||
|
||||
<%= family_form.select :timezone,
|
||||
timezone_options,
|
||||
{ label: t(".timezone") },
|
||||
|
||||
@@ -54,31 +54,26 @@
|
||||
<div class="border-t border-primary pt-4 mt-4">
|
||||
<% if items&.any? %>
|
||||
<% item = items.first %>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<% if item.user_registered? %>
|
||||
<div class="w-2 h-2 bg-success rounded-full"></div>
|
||||
<p class="text-sm text-secondary">
|
||||
<%= t("providers.snaptrade.status_connected", count: item.snaptrade_accounts.count) %>
|
||||
<% if item.unlinked_accounts_count > 0 %>
|
||||
<span class="text-warning">(<%= t("providers.snaptrade.needs_setup", count: item.unlinked_accounts_count) %>)</span>
|
||||
<% end %>
|
||||
</p>
|
||||
<% else %>
|
||||
<div class="w-2 h-2 bg-warning rounded-full"></div>
|
||||
<p class="text-sm text-secondary"><%= t("providers.snaptrade.status_needs_registration") %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if item.user_registered? %>
|
||||
<details class="group mt-4"
|
||||
<details class="group"
|
||||
data-controller="lazy-load"
|
||||
data-action="toggle->lazy-load#toggled"
|
||||
data-lazy-load-url-value="<%= connections_snaptrade_item_path(item) %>">
|
||||
<summary class="flex items-center justify-end gap-1 cursor-pointer text-sm text-secondary hover:text-primary list-none [&::-webkit-details-marker]:hidden">
|
||||
<%= t("providers.snaptrade.manage_connections") %>
|
||||
<%= icon "chevron-right", class: "w-3 h-3 transition-transform group-open:rotate-90" %>
|
||||
data-lazy-load-url-value="<%= connections_snaptrade_item_path(item) %>"
|
||||
data-lazy-load-auto-open-param-value="manage">
|
||||
<summary class="flex items-center justify-between cursor-pointer list-none [&::-webkit-details-marker]:hidden">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-2 bg-success rounded-full"></div>
|
||||
<p class="text-sm text-secondary">
|
||||
<%= t("providers.snaptrade.status_connected", count: item.snaptrade_accounts.count) %>
|
||||
<% if item.unlinked_accounts_count > 0 %>
|
||||
<span class="text-warning">(<%= t("providers.snaptrade.needs_setup", count: item.unlinked_accounts_count) %>)</span>
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
<span class="flex items-center gap-1 text-sm text-secondary hover:text-primary">
|
||||
<%= t("providers.snaptrade.manage_connections") %>
|
||||
<%= icon "chevron-right", class: "w-3 h-3 transition-transform group-open:rotate-90" %>
|
||||
</span>
|
||||
</summary>
|
||||
|
||||
<div class="mt-3 space-y-3" data-lazy-load-target="content">
|
||||
@@ -86,7 +81,6 @@
|
||||
<%= t("providers.snaptrade.connection_limit_info") %>
|
||||
</p>
|
||||
|
||||
<%# Loading state - replaced by fetched content %>
|
||||
<div data-lazy-load-target="loading" class="flex items-center gap-2 text-sm text-secondary py-2">
|
||||
<%= icon "loader-2", class: "w-4 h-4 animate-spin" %>
|
||||
<%= t("providers.snaptrade.loading_connections") %>
|
||||
@@ -96,6 +90,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<% else %>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-2 bg-warning rounded-full"></div>
|
||||
<p class="text-sm text-secondary"><%= t("providers.snaptrade.status_needs_registration") %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@@ -1,62 +1,76 @@
|
||||
<%= content_for :page_title, "Sync Providers" %>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<p class="text-secondary mb-4">
|
||||
Configure credentials for third-party sync providers. Settings configured here will override environment variables.
|
||||
</p>
|
||||
</div>
|
||||
<% if @encryption_error %>
|
||||
<div class="p-4 rounded-lg bg-destructive/10 border border-destructive/20">
|
||||
<div class="flex items-start gap-3">
|
||||
<%= icon("triangle-alert", class: "w-5 h-5 text-destructive shrink-0 mt-0.5") %>
|
||||
<div>
|
||||
<h3 class="font-medium text-primary"><%= t("settings.providers.encryption_error.title") %></h3>
|
||||
<p class="text-secondary text-sm mt-1"><%= t("settings.providers.encryption_error.message") %></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div>
|
||||
<p class="text-secondary mb-4">
|
||||
Configure credentials for third-party sync providers. Settings configured here will override environment variables.
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% @provider_configurations.each do |config| %>
|
||||
<%= settings_section title: config.provider_key.titleize, collapsible: true, open: false do %>
|
||||
<%= render "settings/providers/provider_form", configuration: config %>
|
||||
<% unless @encryption_error %>
|
||||
<% @provider_configurations.each do |config| %>
|
||||
<%= settings_section title: config.provider_key.titleize, collapsible: true, open: false do %>
|
||||
<%= render "settings/providers/provider_form", configuration: config %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%# Providers below are hardcoded because they manage Family-scoped connections %>
|
||||
<%# (via their own models like SimplefinItem, LunchflowItem, etc.) rather than global settings. %>
|
||||
<%# They require custom UI for connection management, status display, and sync actions. %>
|
||||
<%# The controller excludes them from @provider_configurations (see prepare_show_context). %>
|
||||
|
||||
<%= settings_section title: "Lunch Flow", collapsible: true, open: false do %>
|
||||
<turbo-frame id="lunchflow-providers-panel">
|
||||
<%= render "settings/providers/lunchflow_panel" %>
|
||||
</turbo-frame>
|
||||
<% end %>
|
||||
|
||||
<%= settings_section title: "SimpleFIN", collapsible: true, open: false do %>
|
||||
<turbo-frame id="simplefin-providers-panel">
|
||||
<%= render "settings/providers/simplefin_panel" %>
|
||||
</turbo-frame>
|
||||
<% end %>
|
||||
|
||||
<%= settings_section title: "Enable Banking (beta)", collapsible: true, open: false do %>
|
||||
<turbo-frame id="enable_banking-providers-panel">
|
||||
<%= render "settings/providers/enable_banking_panel" %>
|
||||
</turbo-frame>
|
||||
<% end %>
|
||||
|
||||
<%= settings_section title: "CoinStats (beta)", collapsible: true, open: false do %>
|
||||
<turbo-frame id="coinstats-providers-panel">
|
||||
<%= render "settings/providers/coinstats_panel" %>
|
||||
</turbo-frame>
|
||||
<% end %>
|
||||
|
||||
<%= settings_section title: "Mercury (beta)", collapsible: true, open: false do %>
|
||||
<turbo-frame id="mercury-providers-panel">
|
||||
<%= render "settings/providers/mercury_panel" %>
|
||||
</turbo-frame>
|
||||
<% end %>
|
||||
|
||||
<%= settings_section title: "Coinbase (beta)", collapsible: true, open: false do %>
|
||||
<turbo-frame id="coinbase-providers-panel">
|
||||
<%= render "settings/providers/coinbase_panel" %>
|
||||
</turbo-frame>
|
||||
<% end %>
|
||||
|
||||
<%= settings_section title: "SnapTrade (beta)", collapsible: true, open: false, auto_open_param: "manage" do %>
|
||||
<turbo-frame id="snaptrade-providers-panel">
|
||||
<%= render "settings/providers/snaptrade_panel" %>
|
||||
</turbo-frame>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%# Providers below are hardcoded because they manage Family-scoped connections %>
|
||||
<%# (via their own models like SimplefinItem, LunchflowItem, etc.) rather than global settings. %>
|
||||
<%# They require custom UI for connection management, status display, and sync actions. %>
|
||||
<%# The controller excludes them from @provider_configurations (see prepare_show_context). %>
|
||||
|
||||
<%= settings_section title: "Lunch Flow", collapsible: true, open: false do %>
|
||||
<turbo-frame id="lunchflow-providers-panel">
|
||||
<%= render "settings/providers/lunchflow_panel" %>
|
||||
</turbo-frame>
|
||||
<% end %>
|
||||
|
||||
<%= settings_section title: "SimpleFIN", collapsible: true, open: false do %>
|
||||
<turbo-frame id="simplefin-providers-panel">
|
||||
<%= render "settings/providers/simplefin_panel" %>
|
||||
</turbo-frame>
|
||||
<% end %>
|
||||
|
||||
<%= settings_section title: "Enable Banking (beta)", collapsible: true, open: false do %>
|
||||
<turbo-frame id="enable_banking-providers-panel">
|
||||
<%= render "settings/providers/enable_banking_panel" %>
|
||||
</turbo-frame>
|
||||
<% end %>
|
||||
|
||||
<%= settings_section title: "CoinStats (beta)", collapsible: true, open: false do %>
|
||||
<turbo-frame id="coinstats-providers-panel">
|
||||
<%= render "settings/providers/coinstats_panel" %>
|
||||
</turbo-frame>
|
||||
<% end %>
|
||||
|
||||
<%= settings_section title: "Mercury (beta)", collapsible: true, open: false do %>
|
||||
<turbo-frame id="mercury-providers-panel">
|
||||
<%= render "settings/providers/mercury_panel" %>
|
||||
</turbo-frame>
|
||||
<% end %>
|
||||
|
||||
<%= settings_section title: "Coinbase (beta)", collapsible: true, open: false do %>
|
||||
<turbo-frame id="coinbase-providers-panel">
|
||||
<%= render "settings/providers/coinbase_panel" %>
|
||||
</turbo-frame>
|
||||
<% end %>
|
||||
|
||||
<%= settings_section title: "SnapTrade (beta)", collapsible: true, open: false do %>
|
||||
<turbo-frame id="snaptrade-providers-panel">
|
||||
<%= render "settings/providers/snaptrade_panel" %>
|
||||
</turbo-frame>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
<%# locals: (simplefin_item:) %>
|
||||
|
||||
<%= tag.div id: dom_id(simplefin_item) do %>
|
||||
<%# Compute unlinked count early so it's available for both menu and bottom section %>
|
||||
<% unlinked_count = if defined?(@simplefin_unlinked_count_map) && @simplefin_unlinked_count_map
|
||||
@simplefin_unlinked_count_map[simplefin_item.id] || 0
|
||||
else
|
||||
begin
|
||||
simplefin_item.simplefin_accounts
|
||||
.left_joins(:account, :account_provider)
|
||||
.where(accounts: { id: nil }, account_providers: { id: nil })
|
||||
.count
|
||||
rescue => e
|
||||
Rails.logger.warn("SimpleFin card: unlinked_count fallback failed: #{e.class} - #{e.message}")
|
||||
0
|
||||
end
|
||||
end %>
|
||||
|
||||
<details open class="group bg-container p-4 shadow-border-xs rounded-xl">
|
||||
<summary class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -16,31 +31,9 @@
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%# Compute unlinked count early for badge display %>
|
||||
<% header_unlinked_count = if defined?(@simplefin_unlinked_count_map) && @simplefin_unlinked_count_map
|
||||
@simplefin_unlinked_count_map[simplefin_item.id] || 0
|
||||
else
|
||||
begin
|
||||
simplefin_item.simplefin_accounts
|
||||
.left_joins(:account, :account_provider)
|
||||
.where(accounts: { id: nil }, account_providers: { id: nil })
|
||||
.count
|
||||
rescue => e
|
||||
0
|
||||
end
|
||||
end %>
|
||||
|
||||
<div class="pl-1 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= tag.p simplefin_item.name, class: "font-medium text-primary" %>
|
||||
<% if header_unlinked_count.to_i > 0 %>
|
||||
<%= link_to setup_accounts_simplefin_item_path(simplefin_item),
|
||||
data: { turbo_frame: :modal },
|
||||
class: "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-warning/10 text-warning hover:bg-warning/20 transition-colors" do %>
|
||||
<%= icon "alert-circle", size: "xs" %>
|
||||
<span><%= header_unlinked_count %> <%= header_unlinked_count == 1 ? "account" : "accounts" %> need setup</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% if simplefin_item.scheduled_for_deletion? %>
|
||||
<p class="text-destructive text-sm animate-pulse"><%= t(".deletion_in_progress") %></p>
|
||||
<% end %>
|
||||
@@ -49,25 +42,25 @@
|
||||
<p class="text-xs text-secondary">
|
||||
<%= simplefin_item.institution_summary %>
|
||||
</p>
|
||||
<%# Extra inline badges from latest sync stats %>
|
||||
<%# Extra inline badges from latest sync stats - only show warnings %>
|
||||
<% stats = (@simplefin_sync_stats_map || {})[simplefin_item.id] || {} %>
|
||||
<% if stats.present? %>
|
||||
<% has_warnings = stats["accounts_skipped"].to_i > 0 ||
|
||||
stats["rate_limited"].present? ||
|
||||
stats["rate_limited_at"].present? ||
|
||||
stats["total_errors"].to_i > 0 ||
|
||||
(stats["errors"].is_a?(Array) && stats["errors"].any?) %>
|
||||
<% if has_warnings %>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<% if stats["unlinked_accounts"].to_i > 0 %>
|
||||
<%= render DS::Tooltip.new(text: "Accounts need setup", icon: "link-2", size: "sm") %>
|
||||
<span class="text-xs text-secondary">Unlinked: <%= stats["unlinked_accounts"].to_i %></span>
|
||||
<% end %>
|
||||
|
||||
<% if stats["accounts_skipped"].to_i > 0 %>
|
||||
<%= render DS::Tooltip.new(text: "Some accounts were skipped due to errors during sync", icon: "alert-triangle", size: "sm", color: "warning") %>
|
||||
<span class="text-xs text-warning">Skipped: <%= stats["accounts_skipped"].to_i %></span>
|
||||
<%= render DS::Tooltip.new(text: t(".accounts_skipped_tooltip"), icon: "alert-triangle", size: "sm", color: "warning") %>
|
||||
<span class="text-xs text-warning"><%= t(".accounts_skipped_label", count: stats["accounts_skipped"].to_i) %></span>
|
||||
<% end %>
|
||||
|
||||
<% if stats["rate_limited"].present? || stats["rate_limited_at"].present? %>
|
||||
<% ts = stats["rate_limited_at"] %>
|
||||
<% ago = (ts.present? ? (begin; time_ago_in_words(Time.parse(ts)); rescue StandardError; nil; end) : nil) %>
|
||||
<%= render DS::Tooltip.new(
|
||||
text: (ago ? "Rate limited (" + ago + " ago)" : "Rate limited recently"),
|
||||
text: (ago ? t(".rate_limited_ago", time: ago) : t(".rate_limited_recently")),
|
||||
icon: "clock",
|
||||
size: "sm",
|
||||
color: "warning"
|
||||
@@ -80,10 +73,6 @@
|
||||
<%= render DS::Tooltip.new(text: tooltip_text, icon: "alert-octagon", size: "sm", color: "warning") %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% if stats["total_accounts"].to_i > 0 %>
|
||||
<span class="text-xs text-secondary">Total: <%= stats["total_accounts"].to_i %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -163,15 +152,25 @@
|
||||
href: edit_simplefin_item_path(simplefin_item),
|
||||
frame: "modal"
|
||||
) %>
|
||||
<% elsif Rails.env.development? %>
|
||||
<% else %>
|
||||
<%= icon(
|
||||
"refresh-cw",
|
||||
as_button: true,
|
||||
href: sync_simplefin_item_path(simplefin_item)
|
||||
href: sync_simplefin_item_path(simplefin_item),
|
||||
disabled: simplefin_item.syncing?
|
||||
) %>
|
||||
<% end %>
|
||||
|
||||
<%= render DS::Menu.new do |menu| %>
|
||||
<% if unlinked_count.to_i > 0 %>
|
||||
<% menu.with_item(
|
||||
variant: "link",
|
||||
text: t(".setup_accounts_menu"),
|
||||
icon: "settings",
|
||||
href: setup_accounts_simplefin_item_path(simplefin_item),
|
||||
frame: :modal
|
||||
) %>
|
||||
<% end %>
|
||||
<% menu.with_item(
|
||||
variant: "button",
|
||||
text: t(".delete"),
|
||||
@@ -205,23 +204,8 @@
|
||||
institutions_count: simplefin_item.connected_institutions.size
|
||||
) %>
|
||||
|
||||
<%# Compute unlinked SimpleFin accounts (no legacy account and no AccountProvider link)
|
||||
# Prefer controller-provided map; fallback to a local query so the card stays accurate after Turbo broadcasts %>
|
||||
<% unlinked_count = if defined?(@simplefin_unlinked_count_map) && @simplefin_unlinked_count_map
|
||||
@simplefin_unlinked_count_map[simplefin_item.id] || 0
|
||||
else
|
||||
begin
|
||||
simplefin_item.simplefin_accounts
|
||||
.left_joins(:account, :account_provider)
|
||||
.where(accounts: { id: nil }, account_providers: { id: nil })
|
||||
.count
|
||||
rescue => e
|
||||
Rails.logger.warn("SimpleFin card: unlinked_count fallback failed: #{e.class} - #{e.message}")
|
||||
0
|
||||
end
|
||||
end %>
|
||||
|
||||
<% if unlinked_count.to_i > 0 %>
|
||||
<% if unlinked_count.to_i > 0 && simplefin_item.accounts.empty? %>
|
||||
<%# No accounts imported yet - show prominent setup prompt %>
|
||||
<div class="p-4 flex flex-col gap-3 items-center justify-center">
|
||||
<p class="text-primary font-medium text-sm"><%= t(".setup_needed") %></p>
|
||||
<p class="text-secondary text-sm"><%= t(".setup_description") %></p>
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
|
||||
|
||||
<div class="flex items-center justify-center h-8 w-8 bg-primary/10 rounded-full">
|
||||
<div class="flex items-center justify-center h-8 w-8 bg-success/10 rounded-full">
|
||||
<% if snaptrade_item.logo.attached? %>
|
||||
<%= image_tag snaptrade_item.logo, class: "rounded-full h-full w-full", loading: "lazy" %>
|
||||
<% else %>
|
||||
<div class="flex items-center justify-center">
|
||||
<%= tag.p snaptrade_item.name.first.upcase, class: "text-primary text-xs font-medium" %>
|
||||
<%= tag.p snaptrade_item.name.first.upcase, class: "text-success text-xs font-medium" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -21,14 +21,6 @@
|
||||
<div class="pl-1 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= tag.p snaptrade_item.name, class: "font-medium text-primary" %>
|
||||
<% if unlinked_count > 0 %>
|
||||
<%= link_to setup_accounts_snaptrade_item_path(snaptrade_item),
|
||||
data: { turbo_frame: :modal },
|
||||
class: "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-warning/10 text-warning hover:bg-warning/20 transition-colors" do %>
|
||||
<%= icon "alert-circle", size: "xs" %>
|
||||
<span><%= t(".accounts_need_setup", count: unlinked_count) %></span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% if snaptrade_item.scheduled_for_deletion? %>
|
||||
<p class="text-destructive text-sm animate-pulse"><%= t(".deletion_in_progress") %></p>
|
||||
<% end %>
|
||||
@@ -89,6 +81,21 @@
|
||||
icon: "plus",
|
||||
href: connect_snaptrade_item_path(snaptrade_item)
|
||||
) %>
|
||||
<% if unlinked_count > 0 %>
|
||||
<% menu.with_item(
|
||||
variant: "link",
|
||||
text: t(".setup_accounts_menu"),
|
||||
icon: "settings",
|
||||
href: setup_accounts_snaptrade_item_path(snaptrade_item),
|
||||
frame: :modal
|
||||
) %>
|
||||
<% end %>
|
||||
<% menu.with_item(
|
||||
variant: "link",
|
||||
text: t(".manage_connections"),
|
||||
icon: "cable",
|
||||
href: settings_providers_path(manage: "1")
|
||||
) %>
|
||||
<% menu.with_item(
|
||||
variant: "button",
|
||||
text: t(".delete"),
|
||||
@@ -105,13 +112,6 @@
|
||||
<div class="space-y-4 mt-4">
|
||||
<% if snaptrade_item.accounts.any? %>
|
||||
<%= render "accounts/index/account_groups", accounts: snaptrade_item.accounts %>
|
||||
<div class="flex justify-end pt-2">
|
||||
<%= link_to connect_snaptrade_item_path(snaptrade_item),
|
||||
class: "text-sm text-secondary hover:text-primary flex items-center gap-1 transition-colors" do %>
|
||||
<%= icon "plus", size: "sm" %>
|
||||
<span><%= t(".add_another_brokerage") %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%# Sync summary (collapsible) - using shared ProviderSyncSummary component %>
|
||||
@@ -124,7 +124,8 @@
|
||||
activities_pending: activities_pending
|
||||
) %>
|
||||
|
||||
<% if unlinked_count > 0 %>
|
||||
<% if unlinked_count > 0 && snaptrade_item.accounts.empty? %>
|
||||
<%# No accounts imported yet - show prominent setup prompt %>
|
||||
<div class="p-4 flex flex-col gap-3 items-center justify-center">
|
||||
<p class="text-primary font-medium text-sm"><%= t(".setup_needed") %></p>
|
||||
<p class="text-secondary text-sm"><%= t(".setup_description") %></p>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
<%= turbo_frame_tag dom_id(entry) do %>
|
||||
<%= turbo_frame_tag dom_id(trade) do %>
|
||||
<div class="grid grid-cols-12 items-center <%= entry.excluded ? "text-gray-400 bg-gray-25" : "text-primary" %> text-sm font-medium p-4">
|
||||
<div class="group grid grid-cols-12 items-center <%= entry.excluded ? "text-secondary bg-surface-inset" : "text-primary" %> text-sm font-medium p-4">
|
||||
<div class="col-span-8 flex items-center gap-4">
|
||||
<%= check_box_tag dom_id(entry, "selection"),
|
||||
class: "checkbox checkbox--light hidden lg:block",
|
||||
@@ -44,7 +44,16 @@
|
||||
<%= render "investment_activity/quick_edit_badge", entry: entry, entryable: trade %>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 col-span-4 lg:col-span-2 ml-auto text-right">
|
||||
<div class="shrink-0 col-span-4 lg:col-span-2 ml-auto flex items-center justify-end gap-2">
|
||||
<%# Protection indicator - shows on hover when entry is protected from sync %>
|
||||
<% if entry.protected_from_sync? && !entry.excluded? %>
|
||||
<%= link_to entry_path(entry),
|
||||
data: { turbo_frame: "drawer", turbo_prefetch: false },
|
||||
class: "invisible group-hover:visible transition-opacity",
|
||||
title: t("entries.protection.tooltip") do %>
|
||||
<%= icon "lock", size: "sm", class: "text-secondary" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= content_tag :p,
|
||||
format_money(-entry.amount_money),
|
||||
class: ["text-green-600": entry.amount.negative?] %>
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
<% trade = @entry.trade %>
|
||||
|
||||
<% dialog.with_body do %>
|
||||
<%= render "entries/protection_indicator", entry: @entry, unlock_path: unlock_trade_path(trade) %>
|
||||
|
||||
<% dialog.with_section(title: t(".details"), open: true) do %>
|
||||
<div class="pb-4">
|
||||
<%= styled_form_with model: @entry,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
<%= turbo_frame_tag dom_id(entry) do %>
|
||||
<%= turbo_frame_tag dom_id(transaction) do %>
|
||||
<div class="flex lg:grid lg:grid-cols-12 items-center text-primary text-sm font-medium p-3 lg:p-4 <%= entry.excluded ? "opacity-50 text-gray-400" : "" %>">
|
||||
<div class="group flex lg:grid lg:grid-cols-12 items-center text-primary text-sm font-medium p-3 lg:p-4 <%= entry.excluded ? "opacity-50 text-secondary" : "" %>">
|
||||
|
||||
<div class="pr-4 lg:pr-10 flex items-center gap-3 lg:gap-4 col-span-8 min-w-0">
|
||||
<%= check_box_tag dom_id(entry, "selection"),
|
||||
@@ -22,7 +22,7 @@
|
||||
<div class="hidden lg:flex">
|
||||
<% if transaction.merchant&.logo_url.present? %>
|
||||
<%= image_tag Setting.transform_brand_fetch_url(transaction.merchant.logo_url),
|
||||
class: "w-9 h-9 rounded-full",
|
||||
class: "w-9 h-9 rounded-full border border-secondary",
|
||||
loading: "lazy" %>
|
||||
<% else %>
|
||||
<div class="hidden lg:flex">
|
||||
@@ -145,7 +145,16 @@
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 col-span-4 lg:col-span-2 ml-auto text-right">
|
||||
<div class="shrink-0 col-span-4 lg:col-span-2 ml-auto flex items-center justify-end gap-2">
|
||||
<%# Protection indicator - shows on hover when entry is protected from sync %>
|
||||
<% if entry.protected_from_sync? && !entry.excluded? %>
|
||||
<%= link_to entry_path(entry),
|
||||
data: { turbo_frame: "drawer", turbo_prefetch: false },
|
||||
class: "invisible group-hover:visible transition-opacity",
|
||||
title: t("entries.protection.tooltip") do %>
|
||||
<%= icon "lock", size: "sm", class: "text-secondary" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= content_tag :p,
|
||||
transaction.transfer? && view_ctx == "global" ? "+/- #{format_money(entry.amount_money.abs)}" : format_money(-entry.amount_money),
|
||||
class: ["text-green-600": entry.amount.negative?] %>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<div class="space-y-2">
|
||||
<%= form.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: "Select a category", label: "Category", class: "text-subdued" } %>
|
||||
<%= form.collection_select :merchant_id, Current.family.available_merchants.alphabetically, :id, :name, { prompt: "Select a merchant", label: "Merchant", class: "text-subdued" } %>
|
||||
<%= form.select :tag_ids, Current.family.tags.alphabetically.pluck(:name, :id), { include_blank: "None", multiple: true, label: "Tags", container_class: "h-40" } %>
|
||||
<%= form.select :tag_ids, Current.family.tags.alphabetically.pluck(:name, :id), { include_blank: "None", multiple: true, label: "Tags" } %>
|
||||
<%= form.text_area :notes, label: "Notes", placeholder: "Enter a note that will be applied to selected transactions", rows: 5 %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -45,6 +45,8 @@
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= render "entries/protection_indicator", entry: @entry, unlock_path: unlock_transaction_path(@entry.transaction) %>
|
||||
|
||||
<% dialog.with_section(title: t(".overview"), open: true) do %>
|
||||
<div class="pb-4">
|
||||
<%= styled_form_with model: @entry,
|
||||
|
||||
@@ -533,9 +533,9 @@ ingress:
|
||||
secretName: finance-tls
|
||||
```
|
||||
|
||||
## Boot-required secrets (self-hosted)
|
||||
## Boot-required secrets
|
||||
|
||||
In self-hosted mode the Rails initializer for Active Record Encryption loads on boot. To prevent boot crashes, ensure the following environment variables are present for ALL workloads (web, worker, migrate job/initContainer, CronJobs, and the SimpleFin backfill job):
|
||||
The Rails initializer for Active Record Encryption loads on boot. To prevent boot crashes, ensure the following environment variables are present for ALL workloads (web, worker, migrate job/initContainer, CronJobs, and the SimpleFin backfill job):
|
||||
|
||||
- `SECRET_KEY_BASE`
|
||||
- `ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY`
|
||||
@@ -552,7 +552,7 @@ rails:
|
||||
enabled: true # set to false to skip injecting the three AR encryption env vars
|
||||
```
|
||||
|
||||
Note: Even if `simplefin.encryption.enabled=false`, the app initializer expects these env vars to exist in self-hosted mode.
|
||||
Note: In self-hosted mode, if these env vars are not provided, they will be automatically generated from `SECRET_KEY_BASE`. In managed mode, these env vars must be explicitly provided via environment variables or Rails credentials.
|
||||
|
||||
## Advanced environment variable injection
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ services:
|
||||
volumes:
|
||||
- app-storage:/rails/storage
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- ${PORT:-3000}:3000
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
<<: *rails_env
|
||||
|
||||
@@ -50,7 +50,7 @@ services:
|
||||
volumes:
|
||||
- app-storage:/rails/storage
|
||||
ports:
|
||||
- 3000:3000
|
||||
- ${PORT:-3000}:3000
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
<<: *rails_env
|
||||
|
||||
@@ -1,25 +1,33 @@
|
||||
# Auto-generate Active Record encryption keys for self-hosted instances
|
||||
# This ensures encryption works out of the box without manual setup
|
||||
if Rails.application.config.app_mode.self_hosted? && !Rails.application.credentials.active_record_encryption.present?
|
||||
# Check if keys are provided via environment variables
|
||||
primary_key = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"]
|
||||
deterministic_key = ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"]
|
||||
key_derivation_salt = ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"]
|
||||
# Configure Active Record encryption keys
|
||||
# Priority order:
|
||||
# 1. Environment variables (works for both managed and self-hosted modes)
|
||||
# 2. Auto-generation from SECRET_KEY_BASE (self-hosted only, if credentials not present)
|
||||
# 3. Rails credentials (fallback, handled in application.rb)
|
||||
|
||||
# If any key is missing, generate all of them based on SECRET_KEY_BASE
|
||||
if primary_key.blank? || deterministic_key.blank? || key_derivation_salt.blank?
|
||||
# Use SECRET_KEY_BASE as the seed for deterministic key generation
|
||||
# This ensures keys are consistent across container restarts
|
||||
secret_base = Rails.application.secret_key_base
|
||||
# Check if keys are provided via environment variables
|
||||
primary_key = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"]
|
||||
deterministic_key = ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"]
|
||||
key_derivation_salt = ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"]
|
||||
|
||||
# Generate deterministic keys from the secret base
|
||||
primary_key = Digest::SHA256.hexdigest("#{secret_base}:primary_key")[0..63]
|
||||
deterministic_key = Digest::SHA256.hexdigest("#{secret_base}:deterministic_key")[0..63]
|
||||
key_derivation_salt = Digest::SHA256.hexdigest("#{secret_base}:key_derivation_salt")[0..63]
|
||||
end
|
||||
# If all environment variables are present, use them (works for both managed and self-hosted)
|
||||
if primary_key.present? && deterministic_key.present? && key_derivation_salt.present?
|
||||
Rails.application.config.active_record.encryption.primary_key = primary_key
|
||||
Rails.application.config.active_record.encryption.deterministic_key = deterministic_key
|
||||
Rails.application.config.active_record.encryption.key_derivation_salt = key_derivation_salt
|
||||
elsif Rails.application.config.app_mode.self_hosted? && !Rails.application.credentials.active_record_encryption.present?
|
||||
# For self-hosted instances without credentials or env vars, auto-generate keys
|
||||
# Use SECRET_KEY_BASE as the seed for deterministic key generation
|
||||
# This ensures keys are consistent across container restarts
|
||||
secret_base = Rails.application.secret_key_base
|
||||
|
||||
# Generate deterministic keys from the secret base
|
||||
primary_key = Digest::SHA256.hexdigest("#{secret_base}:primary_key")[0..63]
|
||||
deterministic_key = Digest::SHA256.hexdigest("#{secret_base}:deterministic_key")[0..63]
|
||||
key_derivation_salt = Digest::SHA256.hexdigest("#{secret_base}:key_derivation_salt")[0..63]
|
||||
|
||||
# Configure Active Record encryption
|
||||
Rails.application.config.active_record.encryption.primary_key = primary_key
|
||||
Rails.application.config.active_record.encryption.deterministic_key = deterministic_key
|
||||
Rails.application.config.active_record.encryption.key_derivation_salt = key_derivation_salt
|
||||
end
|
||||
# If none of the above conditions are met, credentials from application.rb will be used
|
||||
|
||||
@@ -58,6 +58,14 @@ end
|
||||
|
||||
Sidekiq.configure_server do |config|
|
||||
config.redis = redis_config
|
||||
|
||||
# Initialize auto-sync scheduler when Sidekiq server starts
|
||||
config.on(:startup) do
|
||||
AutoSyncScheduler.sync!
|
||||
Rails.logger.info("[AutoSyncScheduler] Initialized sync_all_accounts cron job")
|
||||
rescue => e
|
||||
Rails.logger.error("[AutoSyncScheduler] Failed to initialize: #{e.message}")
|
||||
end
|
||||
end
|
||||
|
||||
Sidekiq.configure_client do |config|
|
||||
|
||||
@@ -14,7 +14,7 @@ module Sure
|
||||
|
||||
private
|
||||
def semver
|
||||
"0.6.7-alpha.16"
|
||||
"0.6.8-alpha.1"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -47,6 +47,7 @@ en:
|
||||
setup_needed: Wallets ready to import
|
||||
setup_description: Select which Coinbase wallets you want to track.
|
||||
setup_action: Import Wallets
|
||||
import_wallets_menu: Import Wallets
|
||||
more_wallets_available:
|
||||
one: "%{count} more wallet available to import"
|
||||
other: "%{count} more wallets available to import"
|
||||
|
||||
@@ -12,3 +12,12 @@ en:
|
||||
loading: Loading entries...
|
||||
update:
|
||||
success: Entry updated
|
||||
unlock:
|
||||
success: Entry unlocked. It may be updated on next sync.
|
||||
protection:
|
||||
tooltip: Protected from sync
|
||||
title: Protected from sync
|
||||
description: Your edits to this entry won't be overwritten by provider sync.
|
||||
locked_fields_label: "Locked fields:"
|
||||
unlock_button: Allow sync to update
|
||||
unlock_confirm: Allow sync to update this entry? Your changes may be overwritten on the next sync.
|
||||
|
||||
@@ -109,3 +109,5 @@ en:
|
||||
description: Here's a summary of the new items that will be added to your account
|
||||
once you publish this import.
|
||||
title: Confirm your import data
|
||||
errors:
|
||||
custom_column_requires_inflow: "Custom column imports require an inflow column to be selected"
|
||||
|
||||
@@ -43,3 +43,9 @@ en:
|
||||
confirm_body: Are you sure you want to reset the AI cache? This will allow AI rules to re-process all transactions. This may incur additional API costs.
|
||||
confirm_button: Reset Cache
|
||||
success: AI cache is being cleared. This may take a few moments.
|
||||
condition_filters:
|
||||
transaction_type:
|
||||
income: Income
|
||||
expense: Expense
|
||||
transfer: Transfer
|
||||
equal_to: Equal to
|
||||
|
||||
@@ -41,6 +41,7 @@ ca:
|
||||
general_subtitle: Configura les teves preferències
|
||||
general_title: General
|
||||
language: Idioma
|
||||
language_auto: Idioma del navegador
|
||||
page_title: Preferències
|
||||
theme_dark: Fosc
|
||||
theme_light: Clar
|
||||
|
||||
@@ -34,6 +34,7 @@ de:
|
||||
default_period: Standardzeitraum
|
||||
default_account_order: Standardreihenfolge der Konten
|
||||
language: Sprache
|
||||
language_auto: Browsersprache
|
||||
page_title: Einstellungen
|
||||
theme_dark: Dunkel
|
||||
theme_light: Hell
|
||||
|
||||
@@ -4,6 +4,7 @@ en:
|
||||
settings:
|
||||
payments:
|
||||
renewal: "Your contribution continues on %{date}."
|
||||
cancellation: "Your contribution ends on %{date}."
|
||||
settings:
|
||||
ai_prompts:
|
||||
show:
|
||||
@@ -35,6 +36,7 @@ en:
|
||||
default_period: Default Period
|
||||
default_account_order: Default Account Order
|
||||
language: Language
|
||||
language_auto: Browser language
|
||||
page_title: Preferences
|
||||
theme_dark: Dark
|
||||
theme_light: Light
|
||||
@@ -147,6 +149,9 @@ en:
|
||||
providers:
|
||||
show:
|
||||
coinbase_title: Coinbase
|
||||
encryption_error:
|
||||
title: Encryption Configuration Required
|
||||
message: Active Record encryption keys are not configured. Please ensure the encryption credentials (active_record_encryption.primary_key, active_record_encryption.deterministic_key, and active_record_encryption.key_derivation_salt) are properly set up in your Rails credentials or environment variables before using sync providers.
|
||||
coinbase_panel:
|
||||
setup_instructions: "To connect Coinbase:"
|
||||
step1_html: Go to <a href="https://portal.cdp.coinbase.com/projects/api-keys" target="_blank" class="text-primary underline">Coinbase API Settings</a>
|
||||
|
||||
@@ -35,6 +35,7 @@ es:
|
||||
default_period: Periodo Predeterminado
|
||||
default_account_order: Orden Predeterminado de Cuentas
|
||||
language: Idioma
|
||||
language_auto: Idioma del navegador
|
||||
page_title: Preferencias
|
||||
theme_dark: Oscuro
|
||||
theme_light: Claro
|
||||
|
||||
@@ -35,6 +35,7 @@ fr:
|
||||
default_period: Période par défaut
|
||||
default_account_order: Ordre d'affichage des comptes par défaut
|
||||
language: Langue
|
||||
language_auto: Langue du navigateur
|
||||
page_title: Préférences
|
||||
theme_dark: Sombre
|
||||
theme_light: Clair
|
||||
|
||||
@@ -19,6 +19,7 @@ nb:
|
||||
general_title: Generelt
|
||||
default_period: Standardperiode
|
||||
language: Språk
|
||||
language_auto: Nettleserens språk
|
||||
page_title: Preferanser
|
||||
theme_dark: Mørk
|
||||
theme_light: Lys
|
||||
|
||||
@@ -35,6 +35,7 @@ nl:
|
||||
default_period: Standaard Periode
|
||||
default_account_order: Standaard Account Volgorde
|
||||
language: Taal
|
||||
language_auto: Browsertaal
|
||||
page_title: Voorkeuren
|
||||
theme_dark: Donker
|
||||
theme_light: Licht
|
||||
|
||||
@@ -34,6 +34,7 @@ pt-BR:
|
||||
general_title: Geral
|
||||
default_period: Período Padrão
|
||||
language: Idioma
|
||||
language_auto: Idioma do navegador
|
||||
page_title: Preferências
|
||||
theme_dark: Escuro
|
||||
theme_light: Claro
|
||||
|
||||
@@ -35,6 +35,7 @@ ro:
|
||||
default_period: Perioadă implicită
|
||||
default_account_order: Ordine implicită conturi
|
||||
language: Limbă
|
||||
language_auto: Limba browserului
|
||||
page_title: Preferințe
|
||||
theme_dark: Întunecat
|
||||
theme_light: Luminos
|
||||
|
||||
@@ -19,6 +19,7 @@ tr:
|
||||
general_title: Genel
|
||||
default_period: Varsayılan Dönem
|
||||
language: Dil
|
||||
language_auto: Tarayıcı dili
|
||||
page_title: Tercihler
|
||||
theme_dark: Koyu
|
||||
theme_light: Açık
|
||||
|
||||
@@ -38,6 +38,7 @@ zh-CN:
|
||||
general_subtitle: 配置个人偏好设置
|
||||
general_title: 通用设置
|
||||
language: 语言
|
||||
language_auto: 浏览器语言
|
||||
page_title: 偏好设置
|
||||
theme_dark: 深色模式
|
||||
theme_light: 浅色模式
|
||||
|
||||
@@ -35,6 +35,7 @@ zh-TW:
|
||||
default_period: 預設時段
|
||||
default_account_order: 預設帳戶排序
|
||||
language: 語言
|
||||
language_auto: 瀏覽器語言
|
||||
page_title: 偏好設定
|
||||
theme_dark: 深色
|
||||
theme_light: 淺色
|
||||
|
||||
@@ -65,6 +65,14 @@ en:
|
||||
setup_needed: New accounts ready to set up
|
||||
setup_description: Choose account types for your newly imported SimpleFIN accounts.
|
||||
setup_action: Set Up New Accounts
|
||||
setup_accounts_menu: Set Up Accounts
|
||||
more_accounts_available:
|
||||
one: "%{count} more account available to set up"
|
||||
other: "%{count} more accounts available to set up"
|
||||
accounts_skipped_tooltip: "Some accounts were skipped due to errors during sync"
|
||||
accounts_skipped_label: "Skipped: %{count}"
|
||||
rate_limited_ago: "Rate limited (%{time} ago)"
|
||||
rate_limited_recently: "Rate limited recently"
|
||||
status: Last synced %{timestamp} ago
|
||||
status_never: Never synced
|
||||
status_with_summary: "Last synced %{timestamp} ago • %{summary}"
|
||||
|
||||
@@ -94,6 +94,11 @@ en:
|
||||
setup_needed: "Accounts need setup"
|
||||
setup_description: "Some accounts from SnapTrade need to be linked to Sure accounts."
|
||||
setup_action: "Setup Accounts"
|
||||
setup_accounts_menu: "Set Up Accounts"
|
||||
manage_connections: "Manage Connections"
|
||||
more_accounts_available:
|
||||
one: "%{count} more account available to set up"
|
||||
other: "%{count} more accounts available to set up"
|
||||
no_accounts_title: "No accounts discovered"
|
||||
no_accounts_description: "Connect a brokerage to import your investment accounts."
|
||||
|
||||
|
||||
@@ -40,6 +40,16 @@ en:
|
||||
convert_to_trade_title: Convert to Security Trade
|
||||
convert_to_trade_description: Convert this transaction into a Buy or Sell trade with security details for portfolio tracking.
|
||||
convert_to_trade_button: Convert to Trade
|
||||
merchant_label: Merchant
|
||||
name_label: Name
|
||||
nature: Type
|
||||
none: "(none)"
|
||||
note_label: Notes
|
||||
note_placeholder: Enter a note
|
||||
overview: Overview
|
||||
settings: Settings
|
||||
tags_label: Tags
|
||||
uncategorized: "(uncategorized)"
|
||||
activity_labels:
|
||||
buy: Buy
|
||||
sell: Sell
|
||||
@@ -57,16 +67,6 @@ en:
|
||||
mark_recurring: Mark as Recurring
|
||||
mark_recurring_subtitle: Track this as a recurring transaction. Amount variance is automatically calculated from past 6 months of similar transactions.
|
||||
mark_recurring_title: Recurring Transaction
|
||||
merchant_label: Merchant
|
||||
name_label: Name
|
||||
nature: Type
|
||||
none: "(none)"
|
||||
note_label: Notes
|
||||
note_placeholder: Enter a note
|
||||
overview: Overview
|
||||
settings: Settings
|
||||
tags_label: Tags
|
||||
uncategorized: "(uncategorized)"
|
||||
potential_duplicate_title: Possible duplicate detected
|
||||
potential_duplicate_description: This pending transaction may be the same as the posted transaction below. If so, merge them to avoid double-counting.
|
||||
merge_duplicate: Yes, merge them
|
||||
|
||||
@@ -40,6 +40,16 @@ nl:
|
||||
convert_to_trade_title: Converteer naar Effectentransactie
|
||||
convert_to_trade_description: Converteer deze transactie naar een Koop- of Verkooptransactie met effectendetails voor portfoliobeheer.
|
||||
convert_to_trade_button: Converteer naar Trade
|
||||
merchant_label: Handelaar
|
||||
name_label: Naam
|
||||
nature: Type
|
||||
none: "(geen)"
|
||||
note_label: Notities
|
||||
note_placeholder: Voer een notitie in
|
||||
overview: Overzicht
|
||||
settings: Instellingen
|
||||
tags_label: Tags
|
||||
uncategorized: "(ongecategoriseerd)"
|
||||
activity_labels:
|
||||
buy: Kopen
|
||||
sell: Verkopen
|
||||
@@ -57,16 +67,6 @@ nl:
|
||||
mark_recurring: Markeer als Terugkerend
|
||||
mark_recurring_subtitle: Volg dit als een terugkerende transactie. Bedragvariatie wordt automatisch berekend uit de afgelopen 6 maanden van vergelijkbare transacties.
|
||||
mark_recurring_title: Terugkerende Transactie
|
||||
merchant_label: Handelaar
|
||||
name_label: Naam
|
||||
nature: Type
|
||||
none: "(geen)"
|
||||
note_label: Notities
|
||||
note_placeholder: Voer een notitie in
|
||||
overview: Overzicht
|
||||
settings: Instellingen
|
||||
tags_label: Tags
|
||||
uncategorized: "(ongecategoriseerd)"
|
||||
potential_duplicate_title: Mogelijk duplicaat gedetecteerd
|
||||
potential_duplicate_description: Deze wachtende transactie kan hetzelfde zijn als de geposte transactie hieronder. Indien ja, voeg ze samen om dubbeltelling te voorkomen.
|
||||
merge_duplicate: Ja, voeg ze samen
|
||||
|
||||
@@ -231,7 +231,11 @@ Rails.application.routes.draw do
|
||||
post :reset_security
|
||||
end
|
||||
end
|
||||
resources :trades, only: %i[show new create update destroy]
|
||||
resources :trades, only: %i[show new create update destroy] do
|
||||
member do
|
||||
post :unlock
|
||||
end
|
||||
end
|
||||
resources :valuations, only: %i[show new create update destroy] do
|
||||
post :confirm_create, on: :collection
|
||||
post :confirm_update, on: :member
|
||||
@@ -257,6 +261,7 @@ Rails.application.routes.draw do
|
||||
post :mark_as_recurring
|
||||
post :merge_duplicate
|
||||
post :dismiss_duplicate
|
||||
post :unlock
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
15
db/migrate/20251217141218_add_hash_columns_for_security.rb
Normal file
15
db/migrate/20251217141218_add_hash_columns_for_security.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
class AddHashColumnsForSecurity < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
# Invitations - for token hashing
|
||||
add_column :invitations, :token_digest, :string
|
||||
add_index :invitations, :token_digest, unique: true, where: "token_digest IS NOT NULL"
|
||||
|
||||
# InviteCodes - for token hashing
|
||||
add_column :invite_codes, :token_digest, :string
|
||||
add_index :invite_codes, :token_digest, unique: true, where: "token_digest IS NOT NULL"
|
||||
|
||||
# Sessions - for IP hashing
|
||||
add_column :sessions, :ip_address_digest, :string
|
||||
add_index :sessions, :ip_address_digest
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
class AddAmountTypeIdentifierValueToImports < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
unless column_exists?(:imports, :amount_type_identifier_value)
|
||||
add_column :imports, :amount_type_identifier_value, :string
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddCancelAtPeriodEndToSubscriptions < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
add_column :subscriptions, :cancel_at_period_end, :boolean, default: false, null: false
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class RenameRawInvestmentsPayloadToRawHoldingsPayload < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
rename_column :plaid_accounts, :raw_investments_payload, :raw_holdings_payload
|
||||
end
|
||||
end
|
||||
6
db/migrate/20260124180211_add_locale_to_users.rb
Normal file
6
db/migrate/20260124180211_add_locale_to_users.rb
Normal file
@@ -0,0 +1,6 @@
|
||||
class AddLocaleToUsers < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
add_column :users, :locale, :string, null: true, default: nil
|
||||
add_index :users, :locale
|
||||
end
|
||||
end
|
||||
14
db/schema.rb
generated
14
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: 2026_01_22_160000) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2026_01_24_180211) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
@@ -659,6 +659,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_22_160000) do
|
||||
t.string "amount_type_inflow_value"
|
||||
t.integer "rows_to_skip", default: 0, null: false
|
||||
t.integer "rows_count", default: 0, null: false
|
||||
t.string "amount_type_identifier_value"
|
||||
t.index ["family_id"], name: "index_imports_on_family_id"
|
||||
end
|
||||
|
||||
@@ -679,18 +680,22 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_22_160000) do
|
||||
t.datetime "expires_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "token_digest"
|
||||
t.index ["email", "family_id"], name: "index_invitations_on_email_and_family_id", unique: true
|
||||
t.index ["email"], name: "index_invitations_on_email"
|
||||
t.index ["family_id"], name: "index_invitations_on_family_id"
|
||||
t.index ["inviter_id"], name: "index_invitations_on_inviter_id"
|
||||
t.index ["token"], name: "index_invitations_on_token", unique: true
|
||||
t.index ["token_digest"], name: "index_invitations_on_token_digest", unique: true, where: "(token_digest IS NOT NULL)"
|
||||
end
|
||||
|
||||
create_table "invite_codes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.string "token", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "token_digest"
|
||||
t.index ["token"], name: "index_invite_codes_on_token", unique: true
|
||||
t.index ["token_digest"], name: "index_invite_codes_on_token_digest", unique: true, where: "(token_digest IS NOT NULL)"
|
||||
end
|
||||
|
||||
create_table "llm_usages", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
@@ -938,7 +943,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_22_160000) do
|
||||
t.datetime "updated_at", null: false
|
||||
t.jsonb "raw_payload", default: {}
|
||||
t.jsonb "raw_transactions_payload", default: {}
|
||||
t.jsonb "raw_investments_payload", default: {}
|
||||
t.jsonb "raw_holdings_payload", default: {}
|
||||
t.jsonb "raw_liabilities_payload", default: {}
|
||||
t.index ["plaid_id"], name: "index_plaid_accounts_on_plaid_id", unique: true
|
||||
t.index ["plaid_item_id"], name: "index_plaid_accounts_on_plaid_item_id"
|
||||
@@ -1103,7 +1108,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_22_160000) do
|
||||
t.datetime "subscribed_at"
|
||||
t.jsonb "prev_transaction_page_params", default: {}
|
||||
t.jsonb "data", default: {}
|
||||
t.string "ip_address_digest"
|
||||
t.index ["active_impersonator_session_id"], name: "index_sessions_on_active_impersonator_session_id"
|
||||
t.index ["ip_address_digest"], name: "index_sessions_on_ip_address_digest"
|
||||
t.index ["user_id"], name: "index_sessions_on_user_id"
|
||||
end
|
||||
|
||||
@@ -1259,6 +1266,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_22_160000) do
|
||||
t.datetime "trial_ends_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.boolean "cancel_at_period_end", default: false, null: false
|
||||
t.index ["family_id"], name: "index_subscriptions_on_family_id", unique: true
|
||||
end
|
||||
|
||||
@@ -1394,9 +1402,11 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_22_160000) do
|
||||
t.datetime "set_onboarding_goals_at"
|
||||
t.string "default_account_order", default: "name_asc"
|
||||
t.jsonb "preferences", default: {}, null: false
|
||||
t.string "locale"
|
||||
t.index ["email"], name: "index_users_on_email", unique: true
|
||||
t.index ["family_id"], name: "index_users_on_family_id"
|
||||
t.index ["last_viewed_chat_id"], name: "index_users_on_last_viewed_chat_id"
|
||||
t.index ["locale"], name: "index_users_on_locale"
|
||||
t.index ["otp_secret"], name: "index_users_on_otp_secret", unique: true, where: "(otp_secret IS NOT NULL)"
|
||||
t.index ["preferences"], name: "index_users_on_preferences", using: :gin
|
||||
end
|
||||
|
||||
@@ -40,7 +40,7 @@ When you arrive at the main dashboard, showing **No accounts yet**, you're all s
|
||||
> - [**Plaid**](/docs/hosting/plaid.md)
|
||||
> - [**SimpleFIN**](https://beta-bridge.simplefin.org/)
|
||||
> - [**Enable Banking**](https://enablebanking.com/) (beta)
|
||||
> - [**CoinStats**](https://coinstats.app//) (beta)
|
||||
> - [**CoinStats**](https://coinstats.app/) (beta)
|
||||
>
|
||||
> Even if you use an integration, we still recommend reading through this guide to understand **account types** and how they work in Sure.
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user