Merge remote-tracking branch 'upstream/main' into sso-upgrades

# Conflicts:
#	app/views/simplefin_items/_simplefin_item.html.erb
#	db/schema.rb
This commit is contained in:
Josh Waldrep
2026-01-10 11:57:23 -05:00
301 changed files with 20707 additions and 967 deletions

182
.github/workflows/flutter-build.yml vendored Normal file
View File

@@ -0,0 +1,182 @@
name: Flutter Mobile Build
on:
push:
branches:
- main
tags:
- 'v*'
paths:
- 'mobile/lib/**'
- 'mobile/android/**'
- 'mobile/ios/**'
- 'mobile/pubspec.yaml'
- '.github/workflows/flutter-build.yml'
pull_request:
paths:
- 'mobile/lib/**'
- 'mobile/android/**'
- 'mobile/ios/**'
- 'mobile/pubspec.yaml'
- '.github/workflows/flutter-build.yml'
workflow_call:
workflow_dispatch:
permissions:
contents: read
jobs:
build-android:
name: Build Android APK
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.32.4'
channel: 'stable'
cache: true
- name: Get dependencies
working-directory: mobile
run: flutter pub get
- name: Generate app icons
working-directory: mobile
run: flutter pub run flutter_launcher_icons
- name: Analyze code
working-directory: mobile
run: flutter analyze --no-fatal-infos
- name: Run tests
working-directory: mobile
run: flutter test
- name: Check if keystore secrets exist
id: check_secrets
run: |
if [ -n "${{ secrets.KEYSTORE_BASE64 }}" ]; then
echo "has_keystore=true" >> $GITHUB_OUTPUT
echo "✓ Keystore secrets found, will build signed APK"
else
echo "has_keystore=false" >> $GITHUB_OUTPUT
echo "⚠ No keystore secrets, will build unsigned debug APK"
fi
- name: Decode and setup keystore
if: steps.check_secrets.outputs.has_keystore == 'true'
env:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
KEY_STORE_PASSWORD: ${{ secrets.KEY_STORE_PASSWORD }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
run: |
echo "$KEYSTORE_BASE64" | base64 -d > mobile/android/app/upload-keystore.jks
echo "storePassword=$KEY_STORE_PASSWORD" > mobile/android/key.properties
echo "keyPassword=$KEY_PASSWORD" >> mobile/android/key.properties
echo "keyAlias=$KEY_ALIAS" >> mobile/android/key.properties
echo "storeFile=upload-keystore.jks" >> mobile/android/key.properties
echo "✓ Keystore configured successfully"
- name: Build APK (Release)
working-directory: mobile
run: |
if [ "${{ steps.check_secrets.outputs.has_keystore }}" == "true" ]; then
echo "Building signed release APK..."
flutter build apk --release
else
echo "Building debug-signed APK..."
flutter build apk --debug
fi
- name: Upload APK artifact
uses: actions/upload-artifact@v4
with:
name: app-release-apk
path: |
mobile/build/app/outputs/flutter-apk/app-release.apk
mobile/build/app/outputs/flutter-apk/app-debug.apk
retention-days: 30
if-no-files-found: ignore
- name: Build App Bundle (Release)
if: steps.check_secrets.outputs.has_keystore == 'true'
working-directory: mobile
run: flutter build appbundle --release
- name: Upload AAB artifact
if: steps.check_secrets.outputs.has_keystore == 'true'
uses: actions/upload-artifact@v4
with:
name: app-release-aab
path: mobile/build/app/outputs/bundle/release/app-release.aab
retention-days: 30
build-ios:
name: Build iOS IPA
runs-on: macos-latest
timeout-minutes: 45
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.32.4'
channel: 'stable'
cache: true
- name: Get dependencies
working-directory: mobile
run: flutter pub get
- name: Generate app icons
working-directory: mobile
run: flutter pub run flutter_launcher_icons
- name: Install CocoaPods dependencies
working-directory: mobile/ios
run: pod install
- name: Analyze code
working-directory: mobile
run: flutter analyze --no-fatal-infos
- name: Run tests
working-directory: mobile
run: flutter test
- name: Build iOS (No Code Signing)
working-directory: mobile
run: flutter build ios --release --no-codesign
- name: Create IPA archive info
working-directory: mobile
run: |
echo "iOS build completed successfully" > build/ios-build-info.txt
echo "Build date: $(date)" >> build/ios-build-info.txt
echo "Note: This build is not code-signed and cannot be installed on physical devices" >> build/ios-build-info.txt
echo "For distribution, you need to configure code signing with Apple certificates" >> build/ios-build-info.txt
- name: Upload iOS build artifact
uses: actions/upload-artifact@v4
with:
name: ios-build-unsigned
path: |
mobile/build/ios/iphoneos/Runner.app
mobile/build/ios-build-info.txt
retention-days: 30

View File

@@ -22,9 +22,12 @@ jobs:
fetch-depth: 0
- name: Configure Git
env:
GIT_USER_NAME: ${{ github.actor }}
GIT_USER_EMAIL: ${{ github.actor }}@users.noreply.github.com
run: |
git config user.name "$GITHUB_ACTOR"
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
git config user.name "$GIT_USER_NAME"
git config user.email "$GIT_USER_EMAIL"
- name: Install Helm
uses: azure/setup-helm@v3
@@ -64,18 +67,21 @@ jobs:
path: gh-pages
- name: Update index and push
env:
GIT_USER_NAME: ${{ github.actor }}
GIT_USER_EMAIL: ${{ github.actor }}@users.noreply.github.com
run: |
# Copy packaged chart
cp .cr-release-packages/*.tgz gh-pages/
# Update index
helm repo index gh-pages --url https://we-promise.github.io/sure --merge gh-pages/index.yaml
# Push to gh-pages
git config --global user.email "sure-admin@sure.am"
git config --global user.name "sure-admin"
git config --global credential.helper cache
cd gh-pages
git config user.name "$GIT_USER_NAME"
git config user.email "$GIT_USER_EMAIL"
git add .
git commit -m "Release nightly: ${{ steps.version.outputs.version }}"
git push

View File

@@ -39,7 +39,8 @@ env:
IMAGE_NAME: ${{ github.repository }}
permissions:
contents: read
contents: write
packages: write
jobs:
ci:
@@ -96,6 +97,7 @@ jobs:
BASE_CONFIG+=$'\n'"type=raw,value=latest"
else
BASE_CONFIG+=$'\n'"type=raw,value=stable"
BASE_CONFIG+=$'\n'"type=raw,value=latest"
fi
fi
fi
@@ -238,3 +240,177 @@ jobs:
echo "Push failed (attempt $attempts). Retrying in ${delay} seconds..."
sleep ${delay}
done
mobile:
name: Build Mobile Apps
if: startsWith(github.ref, 'refs/tags/v')
uses: ./.github/workflows/flutter-build.yml
secrets: inherit
release:
name: Create GitHub Release
if: startsWith(github.ref, 'refs/tags/v')
needs: [merge, mobile]
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- name: Download Android APK artifact
uses: actions/download-artifact@v4.3.0
with:
name: app-release-apk
path: ${{ runner.temp }}/mobile-artifacts
- name: Download iOS build artifact
uses: actions/download-artifact@v4.3.0
with:
name: ios-build-unsigned
path: ${{ runner.temp }}/ios-build
- name: Prepare release assets
run: |
mkdir -p ${{ runner.temp }}/release-assets
echo "=== Debugging: List downloaded artifacts ==="
echo "Mobile artifacts:"
ls -laR "${{ runner.temp }}/mobile-artifacts" || echo "No mobile-artifacts directory"
echo "iOS build:"
ls -laR "${{ runner.temp }}/ios-build" || echo "No ios-build directory"
echo "==========================================="
# Copy debug APK if it exists
if [ -f "${{ runner.temp }}/mobile-artifacts/app-debug.apk" ]; then
cp "${{ runner.temp }}/mobile-artifacts/app-debug.apk" "${{ runner.temp }}/release-assets/sure-${{ github.ref_name }}-debug.apk"
echo "✓ Debug APK prepared"
fi
# Copy release APK if it exists
if [ -f "${{ runner.temp }}/mobile-artifacts/app-release.apk" ]; then
cp "${{ runner.temp }}/mobile-artifacts/app-release.apk" "${{ runner.temp }}/release-assets/sure-${{ github.ref_name }}.apk"
echo "✓ Release APK prepared"
fi
# Create iOS app archive (zip the .app bundle)
# Path preserves directory structure from artifact upload
if [ -d "${{ runner.temp }}/ios-build/ios/iphoneos/Runner.app" ]; then
cd "${{ runner.temp }}/ios-build/ios/iphoneos"
zip -r "${{ runner.temp }}/release-assets/sure-${{ github.ref_name }}-ios-unsigned.zip" Runner.app
echo "✓ iOS build archive prepared"
fi
# Copy iOS build info
if [ -f "${{ runner.temp }}/ios-build/ios-build-info.txt" ]; then
cp "${{ runner.temp }}/ios-build/ios-build-info.txt" "${{ runner.temp }}/release-assets/"
fi
echo "Release assets:"
ls -la "${{ runner.temp }}/release-assets/"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
draft: false
prerelease: ${{ contains(github.ref_name, 'alpha') || contains(github.ref_name, 'beta') || contains(github.ref_name, 'rc') }}
generate_release_notes: true
files: |
${{ runner.temp }}/release-assets/*
body: |
## Mobile Debug Builds
This release includes debug builds of the mobile applications:
- **Android APK**: Debug build for testing on Android devices
- **iOS Build**: Unsigned iOS build (requires code signing for installation)
> **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')
needs: [merge]
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- name: Check out main branch
uses: actions/checkout@v4.2.0
with:
ref: main
token: ${{ secrets.GITHUB_TOKEN }}
- name: Bump alpha version
run: |
VERSION_FILE="config/initializers/version.rb"
# Ensure version file exists
if [ ! -f "$VERSION_FILE" ]; then
echo "ERROR: Version file not found: $VERSION_FILE"
exit 1
fi
# Extract current version
CURRENT_VERSION=$(grep -oP '"\K[0-9]+\.[0-9]+\.[0-9]+-alpha\.[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"
exit 1
fi
NEW_ALPHA_NUM=$((ALPHA_NUM + 1))
# Create new version string
BASE_VERSION=$(echo "$CURRENT_VERSION" | grep -oP '^[0-9]+\.[0-9]+\.[0-9]+')
if [ -z "$BASE_VERSION" ]; then
echo "ERROR: Could not extract base version from $CURRENT_VERSION"
exit 1
fi
NEW_VERSION="${BASE_VERSION}-alpha.${NEW_ALPHA_NUM}"
echo "New version: $NEW_VERSION"
# Update the version file
sed -i "s/\"$CURRENT_VERSION\"/\"$NEW_VERSION\"/" "$VERSION_FILE"
# Verify the change
echo "Updated version.rb:"
- name: Commit and push version bump
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add config/initializers/version.rb
# Check if there are changes to commit
if git diff --cached --quiet; then
echo "No changes to commit - version may have already been bumped"
exit 0
git commit -m "Bump version to next alpha after ${{ github.ref_name }} release"
# Push with retry logic
attempts=0
until git push origin main; do
attempts=$((attempts + 1))
if [[ $attempts -ge 4 ]]; then
echo "ERROR: Failed to push after 4 attempts." >&2
exit 1
fi
delay=$((2 ** attempts))
echo "Push failed (attempt $attempts). Retrying in ${delay} seconds..."
sleep ${delay}
git pull --rebase origin main
done

View File

@@ -17,7 +17,7 @@ gem "bootsnap", require: false
gem "importmap-rails"
gem "propshaft"
gem "tailwindcss-rails"
gem "lucide-rails", github: "maybe-finance/lucide-rails"
gem "lucide-rails"
# Hotwire + UI
gem "stimulus-rails"

View File

@@ -1,10 +1,3 @@
GIT
remote: https://github.com/maybe-finance/lucide-rails.git
revision: 272e5fb8418ea458da3995d6abe0ba0ceee9c9f0
specs:
lucide-rails (0.2.0)
railties (>= 4.1.0)
GEM
remote: https://rubygems.org/
specs:
@@ -121,7 +114,7 @@ GEM
erubi (~> 1.4)
parser (>= 2.4)
smart_properties
bigdecimal (3.2.2)
bigdecimal (3.3.1)
bindata (2.5.1)
bindex (0.8.1)
bootsnap (1.18.6)
@@ -254,7 +247,7 @@ GEM
turbo-rails (>= 1.2)
htmlbeautifier (1.4.3)
htmlentities (4.3.4)
httparty (0.23.1)
httparty (0.24.0)
csv
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
@@ -346,6 +339,8 @@ GEM
view_component (>= 2.0)
yard (~> 0.9)
zeitwerk (~> 2.5)
lucide-rails (0.7.3)
railties (>= 4.1.0)
mail (2.8.1)
mini_mime (>= 0.1.1)
net-imap
@@ -364,8 +359,8 @@ GEM
mocha (2.7.1)
ruby2_keywords (>= 0.0.5)
msgpack (1.8.0)
multi_xml (0.7.2)
bigdecimal (~> 3.1)
multi_xml (0.8.0)
bigdecimal (>= 3.1, < 5)
multipart-post (2.4.1)
mutex_m (0.3.0)
net-http (0.6.0)
@@ -719,7 +714,7 @@ GEM
unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
uri (1.0.3)
uri (1.0.4)
useragent (0.16.11)
validate_url (1.0.15)
activemodel (>= 3.0.0)
@@ -803,7 +798,7 @@ DEPENDENCIES
letter_opener
logtail-rails
lookbook (= 2.3.11)
lucide-rails!
lucide-rails
mocha
octokit
omniauth (~> 2.1)

View File

@@ -80,7 +80,7 @@ export default class extends Controller {
const firstFocusableElement =
this.contentTarget.querySelectorAll(focusableElements)[0];
if (firstFocusableElement) {
firstFocusableElement.focus();
firstFocusableElement.focus({ preventScroll: true });
}
}

View File

@@ -0,0 +1,105 @@
<details class="group bg-surface rounded-lg border border-surface-inset/50">
<summary class="flex items-center justify-between gap-2 p-3 cursor-pointer">
<div class="flex items-center gap-2">
<%= helpers.icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
<span class="text-sm text-primary font-medium"><%= t("provider_sync_summary.title") %></span>
</div>
<div class="flex items-center gap-2 text-xs text-secondary">
<% if last_synced_at %>
<span><%= t("provider_sync_summary.last_sync", time_ago: last_synced_ago) %></span>
<% end %>
</div>
</summary>
<div class="p-3 text-sm text-secondary grid grid-cols-1 md:grid-cols-2 gap-3">
<%# Accounts section - always shown if we have account stats %>
<% if total_accounts > 0 || stats.key?("total_accounts") %>
<div>
<h4 class="text-primary font-medium mb-1"><%= t("provider_sync_summary.accounts.title") %></h4>
<div class="flex items-center gap-3 flex-wrap">
<span><%= t("provider_sync_summary.accounts.total", count: total_accounts) %></span>
<span><%= t("provider_sync_summary.accounts.linked", count: linked_accounts) %></span>
<span><%= t("provider_sync_summary.accounts.unlinked", count: unlinked_accounts) %></span>
<% if institutions_count.present? %>
<span><%= t("provider_sync_summary.accounts.institutions", count: institutions_count) %></span>
<% end %>
</div>
</div>
<% end %>
<%# Transactions section - shown if provider collects transaction stats %>
<% if has_transaction_stats? %>
<div>
<h4 class="text-primary font-medium mb-1"><%= t("provider_sync_summary.transactions.title") %></h4>
<div class="flex items-center gap-3 flex-wrap">
<span><%= t("provider_sync_summary.transactions.seen", count: tx_seen) %></span>
<span><%= t("provider_sync_summary.transactions.imported", count: tx_imported) %></span>
<span><%= t("provider_sync_summary.transactions.updated", count: tx_updated) %></span>
<span><%= t("provider_sync_summary.transactions.skipped", count: tx_skipped) %></span>
</div>
</div>
<% end %>
<%# Holdings section - shown if provider collects holdings stats %>
<% if has_holdings_stats? %>
<div>
<h4 class="text-primary font-medium mb-1"><%= t("provider_sync_summary.holdings.title") %></h4>
<div class="flex items-center gap-3">
<span><%= t("provider_sync_summary.holdings.#{holdings_label_key}", count: holdings_count) %></span>
</div>
</div>
<% end %>
<%# Health section - always shown %>
<div>
<h4 class="text-primary font-medium mb-1"><%= t("provider_sync_summary.health.title") %></h4>
<div class="flex flex-col gap-1">
<div class="flex items-center gap-3 flex-wrap">
<% if rate_limited? %>
<span class="text-warning">
<%= t("provider_sync_summary.health.rate_limited", time_ago: rate_limited_ago || t("provider_sync_summary.health.recently")) %>
</span>
<% end %>
<% if has_errors? %>
<span class="text-destructive"><%= t("provider_sync_summary.health.errors", count: total_errors) %></span>
<% elsif import_started? %>
<span class="text-success"><%= t("provider_sync_summary.health.errors", count: 0) %></span>
<% else %>
<span><%= t("provider_sync_summary.health.errors", count: 0) %></span>
<% end %>
</div>
<%# Data quality warnings %>
<% if has_data_quality_issues? %>
<div class="flex items-center gap-3 mt-1">
<% if data_warnings > 0 %>
<div class="flex items-center gap-1">
<%= helpers.icon "alert-triangle", size: "sm", color: "warning" %>
<span class="text-warning"><%= t("provider_sync_summary.health.data_warnings", count: data_warnings) %></span>
</div>
<% end %>
<% if notices > 0 %>
<div class="flex items-center gap-1">
<%= helpers.icon "info", size: "sm" %>
<span><%= t("provider_sync_summary.health.notices", count: notices) %></span>
</div>
<% end %>
</div>
<% if data_quality_details.any? %>
<details class="mt-2">
<summary class="text-xs cursor-pointer text-secondary hover:text-primary">
<%= t("provider_sync_summary.health.view_data_quality") %>
</summary>
<div class="mt-1 pl-2 border-l-2 border-surface-inset space-y-1">
<% data_quality_details.each do |detail| %>
<p class="text-xs <%= severity_color_class(detail["severity"]) %>"><%= detail["message"] %></p>
<% end %>
</div>
</details>
<% end %>
<% end %>
</div>
</div>
</div>
</details>

View File

@@ -0,0 +1,157 @@
# frozen_string_literal: true
# Reusable sync summary component for provider items.
#
# This component displays sync statistics in a collapsible panel that can be used
# by any provider (SimpleFIN, Plaid, Lunchflow, etc.) to show their sync results.
#
# @example Basic usage
# <%= render ProviderSyncSummary.new(
# stats: @sync_stats,
# provider_item: @plaid_item
# ) %>
#
# @example With custom institution count
# <%= render ProviderSyncSummary.new(
# stats: @sync_stats,
# provider_item: @simplefin_item,
# institutions_count: @simplefin_item.connected_institutions.size
# ) %>
#
class ProviderSyncSummary < ViewComponent::Base
attr_reader :stats, :provider_item, :institutions_count
# @param stats [Hash] The sync statistics hash from sync.sync_stats
# @param provider_item [Object] The provider item (must respond to last_synced_at)
# @param institutions_count [Integer, nil] Optional count of connected institutions
def initialize(stats:, provider_item:, institutions_count: nil)
@stats = stats || {}
@provider_item = provider_item
@institutions_count = institutions_count
end
def render?
stats.present?
end
# Account statistics
def total_accounts
stats["total_accounts"].to_i
end
def linked_accounts
stats["linked_accounts"].to_i
end
def unlinked_accounts
stats["unlinked_accounts"].to_i
end
# Transaction statistics
def tx_seen
stats["tx_seen"].to_i
end
def tx_imported
stats["tx_imported"].to_i
end
def tx_updated
stats["tx_updated"].to_i
end
def tx_skipped
stats["tx_skipped"].to_i
end
def has_transaction_stats?
stats.key?("tx_seen") || stats.key?("tx_imported") || stats.key?("tx_updated")
end
# Holdings statistics
def holdings_found
stats["holdings_found"].to_i
end
def holdings_processed
stats["holdings_processed"].to_i
end
def has_holdings_stats?
stats.key?("holdings_found") || stats.key?("holdings_processed")
end
def holdings_label_key
stats.key?("holdings_processed") ? "processed" : "found"
end
def holdings_count
stats.key?("holdings_processed") ? holdings_processed : holdings_found
end
# Returns the CSS color class for a data quality detail severity
# @param severity [String] The severity level ("warning", "error", or other)
# @return [String] The Tailwind CSS class for the color
def severity_color_class(severity)
case severity
when "warning" then "text-warning"
when "error" then "text-destructive"
else "text-secondary"
end
end
# Health statistics
def rate_limited?
stats["rate_limited"].present? || stats["rate_limited_at"].present?
end
def rate_limited_ago
return nil unless stats["rate_limited_at"].present?
begin
helpers.time_ago_in_words(Time.parse(stats["rate_limited_at"]))
rescue StandardError
nil
end
end
def total_errors
stats["total_errors"].to_i
end
def import_started?
stats["import_started"].present?
end
def has_errors?
total_errors > 0
end
# Data quality / warnings
def data_warnings
stats["data_warnings"].to_i
end
def notices
stats["notices"].to_i
end
def data_quality_details
stats["data_quality_details"] || []
end
def has_data_quality_issues?
data_warnings > 0 || notices > 0 || data_quality_details.any?
end
# Last sync time
def last_synced_at
provider_item.last_synced_at
end
def last_synced_ago
return nil unless last_synced_at
helpers.time_ago_in_words(last_synced_at)
end
end

View File

@@ -6,50 +6,14 @@ class AccountsController < ApplicationController
@manual_accounts = family.accounts
.listable_manual
.order(:name)
@plaid_items = family.plaid_items.ordered
@plaid_items = family.plaid_items.ordered.includes(:syncs, :plaid_accounts)
@simplefin_items = family.simplefin_items.ordered.includes(:syncs)
@lunchflow_items = family.lunchflow_items.ordered
@lunchflow_items = family.lunchflow_items.ordered.includes(:syncs, :lunchflow_accounts)
@enable_banking_items = family.enable_banking_items.ordered.includes(:syncs)
@coinstats_items = family.coinstats_items.ordered.includes(:coinstats_accounts, :accounts, :syncs)
# Precompute per-item maps to avoid queries in the view
@simplefin_sync_stats_map = {}
@simplefin_has_unlinked_map = {}
@simplefin_items.each do |item|
latest_sync = item.syncs.ordered.first
@simplefin_sync_stats_map[item.id] = (latest_sync&.sync_stats || {})
@simplefin_has_unlinked_map[item.id] = item.family.accounts
.listable_manual
.exists?
end
# Count of SimpleFin accounts that are not linked (no legacy account and no AccountProvider)
@simplefin_unlinked_count_map = {}
@simplefin_items.each do |item|
count = item.simplefin_accounts
.left_joins(:account, :account_provider)
.where(accounts: { id: nil }, account_providers: { id: nil })
.count
@simplefin_unlinked_count_map[item.id] = count
end
# Compute CTA visibility map used by the simplefin_item partial
@simplefin_show_relink_map = {}
@simplefin_items.each do |item|
begin
unlinked_count = @simplefin_unlinked_count_map[item.id] || 0
manuals_exist = @simplefin_has_unlinked_map[item.id]
sfa_any = if item.simplefin_accounts.loaded?
item.simplefin_accounts.any?
else
item.simplefin_accounts.exists?
end
@simplefin_show_relink_map[item.id] = (unlinked_count.to_i == 0 && manuals_exist && sfa_any)
rescue => e
Rails.logger.warn("SimpleFin card: CTA computation failed for item #{item.id}: #{e.class} - #{e.message}")
@simplefin_show_relink_map[item.id] = false
end
end
# Build sync stats maps for all providers
build_sync_stats_maps
# Prevent Turbo Drive from caching this page to ensure fresh account lists
expires_now
@@ -140,11 +104,27 @@ class AccountsController < ApplicationController
begin
Account.transaction do
# Detach holdings from provider links before destroying them
provider_link_ids = @account.account_providers.pluck(:id)
if provider_link_ids.any?
Holding.where(account_provider_id: provider_link_ids).update_all(account_provider_id: nil)
end
# Capture SimplefinAccount before clearing FK (so we can destroy it)
simplefin_account_to_destroy = @account.simplefin_account
# Remove new system links (account_providers join table)
@account.account_providers.destroy_all
# Remove legacy system links (foreign keys)
@account.update!(plaid_account_id: nil, simplefin_account_id: nil)
# Destroy the SimplefinAccount record so it doesn't cause stale account issues
# This is safe because:
# - Account data (transactions, holdings, balances) lives on the Account, not SimplefinAccount
# - SimplefinAccount only caches API data which is regenerated on reconnect
# - If user reconnects SimpleFin later, a new SimplefinAccount will be created
simplefin_account_to_destroy&.destroy!
end
redirect_to accounts_path, notice: t("accounts.unlink.success")
@@ -193,4 +173,70 @@ class AccountsController < ApplicationController
def set_account
@account = family.accounts.find(params[:id])
end
# Builds sync stats maps for all provider types to avoid N+1 queries in views
def build_sync_stats_maps
# SimpleFIN sync stats
@simplefin_sync_stats_map = {}
@simplefin_has_unlinked_map = {}
@simplefin_unlinked_count_map = {}
@simplefin_show_relink_map = {}
@simplefin_duplicate_only_map = {}
@simplefin_items.each do |item|
latest_sync = item.syncs.ordered.first
stats = latest_sync&.sync_stats || {}
@simplefin_sync_stats_map[item.id] = stats
@simplefin_has_unlinked_map[item.id] = item.family.accounts.listable_manual.exists?
# Count unlinked accounts
count = item.simplefin_accounts
.left_joins(:account, :account_provider)
.where(accounts: { id: nil }, account_providers: { id: nil })
.count
@simplefin_unlinked_count_map[item.id] = count
# CTA visibility
manuals_exist = @simplefin_has_unlinked_map[item.id]
sfa_any = item.simplefin_accounts.loaded? ? item.simplefin_accounts.any? : item.simplefin_accounts.exists?
@simplefin_show_relink_map[item.id] = (count.to_i == 0 && manuals_exist && sfa_any)
# Check if all errors are duplicate-skips
errors = Array(stats["errors"]).map { |e| e.is_a?(Hash) ? e["message"] || e[:message] : e.to_s }
@simplefin_duplicate_only_map[item.id] = errors.present? && errors.all? { |m| m.to_s.downcase.include?("duplicate upstream account detected") }
rescue => e
Rails.logger.warn("SimpleFin stats map build failed for item #{item.id}: #{e.class} - #{e.message}")
@simplefin_sync_stats_map[item.id] = {}
@simplefin_show_relink_map[item.id] = false
@simplefin_duplicate_only_map[item.id] = false
end
# Plaid sync stats
@plaid_sync_stats_map = {}
@plaid_items.each do |item|
latest_sync = item.syncs.ordered.first
@plaid_sync_stats_map[item.id] = latest_sync&.sync_stats || {}
end
# Lunchflow sync stats
@lunchflow_sync_stats_map = {}
@lunchflow_items.each do |item|
latest_sync = item.syncs.ordered.first
@lunchflow_sync_stats_map[item.id] = latest_sync&.sync_stats || {}
end
# Enable Banking sync stats
@enable_banking_sync_stats_map = {}
@enable_banking_items.each do |item|
latest_sync = item.syncs.ordered.first
@enable_banking_sync_stats_map[item.id] = latest_sync&.sync_stats || {}
end
# CoinStats sync stats
@coinstats_sync_stats_map = {}
@coinstats_items.each do |item|
latest_sync = item.syncs.ordered.first
@coinstats_sync_stats_map[item.id] = latest_sync&.sync_stats || {}
end
end
end

View File

@@ -0,0 +1,171 @@
# frozen_string_literal: true
class Api::V1::ImportsController < Api::V1::BaseController
include Pagy::Backend
# Ensure proper scope authorization
before_action :ensure_read_scope, only: [ :index, :show ]
before_action :ensure_write_scope, only: [ :create ]
before_action :set_import, only: [ :show ]
def index
family = current_resource_owner.family
imports_query = family.imports.ordered
# Apply filters
if params[:status].present?
imports_query = imports_query.where(status: params[:status])
end
if params[:type].present?
imports_query = imports_query.where(type: params[:type])
end
# Pagination
@pagy, @imports = pagy(
imports_query,
page: safe_page_param,
limit: safe_per_page_param
)
@per_page = safe_per_page_param
render :index
rescue StandardError => e
Rails.logger.error "ImportsController#index error: #{e.message}"
render json: { error: "internal_server_error", message: e.message }, status: :internal_server_error
end
def show
render :show
rescue StandardError => e
Rails.logger.error "ImportsController#show error: #{e.message}"
render json: { error: "internal_server_error", message: e.message }, status: :internal_server_error
end
def create
family = current_resource_owner.family
# 1. Determine type and validate
type = params[:type].to_s
type = "TransactionImport" unless Import::TYPES.include?(type)
# 2. Build the import object with permitted config attributes
@import = family.imports.build(import_config_params)
@import.type = type
@import.account_id = params[:account_id] if params[:account_id].present?
# 3. Attach the uploaded file if present (with validation)
if params[:file].present?
file = params[:file]
if file.size > Import::MAX_CSV_SIZE
return render json: {
error: "file_too_large",
message: "File is too large. Maximum size is #{Import::MAX_CSV_SIZE / 1.megabyte}MB."
}, status: :unprocessable_entity
end
unless Import::ALLOWED_MIME_TYPES.include?(file.content_type)
return render json: {
error: "invalid_file_type",
message: "Invalid file type. Please upload a CSV file."
}, status: :unprocessable_entity
end
@import.raw_file_str = file.read
elsif params[:raw_file_content].present?
if params[:raw_file_content].bytesize > Import::MAX_CSV_SIZE
return render json: {
error: "content_too_large",
message: "Content is too large. Maximum size is #{Import::MAX_CSV_SIZE / 1.megabyte}MB."
}, status: :unprocessable_entity
end
@import.raw_file_str = params[:raw_file_content]
end
# 4. Save and Process
if @import.save
# Generate rows if file content was provided
if @import.uploaded?
begin
@import.generate_rows_from_csv
@import.reload
rescue StandardError => e
Rails.logger.error "Row generation failed for import #{@import.id}: #{e.message}"
end
end
# If the import is configured (has rows), we can try to auto-publish or just leave it as pending
# For API simplicity, if enough info is provided, we might want to trigger processing
if @import.configured? && params[:publish] == "true"
@import.publish_later
end
render :show, status: :created
else
render json: {
error: "validation_failed",
message: "Import could not be created",
errors: @import.errors.full_messages
}, status: :unprocessable_entity
end
rescue StandardError => e
Rails.logger.error "ImportsController#create error: #{e.message}"
render json: { error: "internal_server_error", message: e.message }, status: :internal_server_error
end
private
def set_import
@import = current_resource_owner.family.imports.includes(:rows).find(params[:id])
rescue ActiveRecord::RecordNotFound
render json: { error: "not_found", message: "Import not found" }, status: :not_found
end
def ensure_read_scope
authorize_scope!(:read)
end
def ensure_write_scope
authorize_scope!(:write)
end
def import_config_params
params.permit(
:date_col_label,
:amount_col_label,
:name_col_label,
:category_col_label,
:tags_col_label,
:notes_col_label,
:account_col_label,
:qty_col_label,
:ticker_col_label,
:price_col_label,
:entity_type_col_label,
:currency_col_label,
:exchange_operating_mic_col_label,
:date_format,
:number_format,
:signage_convention,
:col_sep,
:amount_type_strategy,
:amount_type_inflow_value
)
end
def safe_page_param
page = params[:page].to_i
page > 0 ? page : 1
end
def safe_per_page_param
per_page = params[:per_page].to_i
(1..100).include?(per_page) ? per_page : 25
end
end

View File

@@ -0,0 +1,169 @@
class CoinstatsItemsController < ApplicationController
before_action :set_coinstats_item, only: [ :show, :edit, :update, :destroy, :sync ]
def index
@coinstats_items = Current.family.coinstats_items.ordered
end
def show
end
def new
@coinstats_item = Current.family.coinstats_items.build
@coinstats_items = Current.family.coinstats_items.where.not(api_key: nil)
@blockchains = fetch_blockchain_options(@coinstats_items.first)
end
def create
@coinstats_item = Current.family.coinstats_items.build(coinstats_item_params)
@coinstats_item.name ||= t(".default_name")
# Validate API key before saving
unless validate_api_key(@coinstats_item.api_key)
return render_error_response(@coinstats_item.errors.full_messages.join(", "))
end
if @coinstats_item.save
render_success_response(".success")
else
render_error_response(@coinstats_item.errors.full_messages.join(", "))
end
end
def edit
end
def update
# Validate API key if it's being changed
unless validate_api_key(coinstats_item_params[:api_key])
return render_error_response(@coinstats_item.errors.full_messages.join(", "))
end
if @coinstats_item.update(coinstats_item_params)
render_success_response(".success")
else
render_error_response(@coinstats_item.errors.full_messages.join(", "))
end
end
def destroy
@coinstats_item.destroy_later
redirect_to settings_providers_path, notice: t(".success"), status: :see_other
end
def sync
unless @coinstats_item.syncing?
@coinstats_item.sync_later
end
respond_to do |format|
format.html { redirect_back_or_to accounts_path }
format.json { head :ok }
end
end
def link_wallet
coinstats_item_id = params[:coinstats_item_id].presence
@address = params[:address]&.to_s&.strip.presence
@blockchain = params[:blockchain]&.to_s&.strip.presence
unless coinstats_item_id && @address && @blockchain
return render_link_wallet_error(t(".missing_params"))
end
@coinstats_item = Current.family.coinstats_items.find(coinstats_item_id)
result = CoinstatsItem::WalletLinker.new(@coinstats_item, address: @address, blockchain: @blockchain).link
if result.success?
redirect_to accounts_path, notice: t(".success", count: result.created_count), status: :see_other
else
error_msg = result.errors.join("; ").presence || t(".failed")
render_link_wallet_error(error_msg)
end
rescue Provider::Coinstats::Error => e
render_link_wallet_error(t(".error", message: e.message))
rescue => e
Rails.logger.error("CoinStats link wallet error: #{e.class} - #{e.message}")
render_link_wallet_error(t(".error", message: e.message))
end
private
def set_coinstats_item
@coinstats_item = Current.family.coinstats_items.find(params[:id])
end
def coinstats_item_params
params.require(:coinstats_item).permit(
:name,
:sync_start_date,
:api_key
)
end
def validate_api_key(api_key)
return true if api_key.blank?
response = Provider::Coinstats.new(api_key).get_blockchains
if response.success?
true
else
@coinstats_item.errors.add(:api_key, t("coinstats_items.create.errors.validation_failed", message: response.error&.message))
false
end
rescue => e
@coinstats_item.errors.add(:api_key, t("coinstats_items.create.errors.validation_failed", message: e.message))
false
end
def render_error_response(error_message)
if turbo_frame_request?
render turbo_stream: turbo_stream.replace(
"coinstats-providers-panel",
partial: "settings/providers/coinstats_panel",
locals: { error_message: error_message }
), status: :unprocessable_entity
else
redirect_to settings_providers_path, alert: error_message, status: :unprocessable_entity
end
end
def render_success_response(notice_key)
if turbo_frame_request?
flash.now[:notice] = t(notice_key, default: notice_key.to_s.humanize)
@coinstats_items = Current.family.coinstats_items.ordered
render turbo_stream: [
turbo_stream.replace(
"coinstats-providers-panel",
partial: "settings/providers/coinstats_panel",
locals: { coinstats_items: @coinstats_items }
),
*flash_notification_stream_items
]
else
redirect_to settings_providers_path, notice: t(notice_key), status: :see_other
end
end
def render_link_wallet_error(error_message)
@error_message = error_message
@coinstats_items = Current.family.coinstats_items.where.not(api_key: nil)
@blockchains = fetch_blockchain_options(@coinstats_items.first)
render :new, status: :unprocessable_entity
end
def fetch_blockchain_options(coinstats_item)
return [] unless coinstats_item&.api_key.present?
Provider::Coinstats.new(coinstats_item.api_key).blockchain_options
rescue Provider::Coinstats::Error => e
Rails.logger.error("CoinStats blockchain fetch failed: item_id=#{coinstats_item.id} error=#{e.class} message=#{e.message}")
flash.now[:alert] = t("coinstats_items.new.blockchain_fetch_error")
[]
rescue StandardError => e
Rails.logger.error("CoinStats blockchain fetch failed: item_id=#{coinstats_item.id} error=#{e.class} message=#{e.message}")
flash.now[:alert] = t("coinstats_items.new.blockchain_fetch_error")
[]
end
end

View File

@@ -269,13 +269,17 @@ class EnableBankingItemsController < ApplicationController
end
# Create the internal Account (uses save! internally, will raise on failure)
# Skip initial sync - provider sync will handle balance creation with correct currency
account = Account.create_and_sync(
family: Current.family,
name: enable_banking_account.name,
balance: enable_banking_account.current_balance || 0,
currency: enable_banking_account.currency || "EUR",
accountable_type: accountable_type,
accountable_attributes: {}
{
family: Current.family,
name: enable_banking_account.name,
balance: enable_banking_account.current_balance || 0,
currency: enable_banking_account.currency || "EUR",
accountable_type: accountable_type,
accountable_attributes: {}
},
skip_initial_sync: true
)
# Link account to enable_banking_account via account_providers

View File

@@ -8,6 +8,15 @@ class FamilyMerchantsController < ApplicationController
@family_merchants = Current.family.merchants.alphabetically
@provider_merchants = Current.family.assigned_merchants.where(type: "ProviderMerchant").alphabetically
# Show recently unlinked ProviderMerchants (within last 30 days)
# Exclude merchants that are already assigned to transactions (they appear in provider_merchants)
recently_unlinked_ids = FamilyMerchantAssociation
.where(family: Current.family)
.recently_unlinked
.pluck(:merchant_id)
assigned_ids = @provider_merchants.pluck(:id)
@unlinked_merchants = ProviderMerchant.where(id: recently_unlinked_ids - assigned_ids).alphabetically
render layout: "settings"
end
@@ -32,24 +41,90 @@ class FamilyMerchantsController < ApplicationController
end
def update
@family_merchant.update!(merchant_params)
respond_to do |format|
format.html { redirect_to family_merchants_path, notice: t(".success") }
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, family_merchants_path) }
if @merchant.is_a?(ProviderMerchant)
# Convert ProviderMerchant to FamilyMerchant for this family only
@family_merchant = @merchant.convert_to_family_merchant_for(Current.family, merchant_params)
respond_to do |format|
format.html { redirect_to family_merchants_path, notice: t(".converted_success") }
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, family_merchants_path) }
end
elsif @merchant.update(merchant_params)
respond_to do |format|
format.html { redirect_to family_merchants_path, notice: t(".success") }
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, family_merchants_path) }
end
else
render :edit, status: :unprocessable_entity
end
rescue ActiveRecord::RecordInvalid => e
@family_merchant = e.record
render :edit, status: :unprocessable_entity
end
def destroy
@family_merchant.destroy!
redirect_to family_merchants_path, notice: t(".success")
if @merchant.is_a?(ProviderMerchant)
# Unlink from family's transactions only (don't delete the global merchant)
@merchant.unlink_from_family(Current.family)
redirect_to family_merchants_path, notice: t(".unlinked_success")
else
@merchant.destroy!
redirect_to family_merchants_path, notice: t(".success")
end
end
def merge
@merchants = all_family_merchants
end
def perform_merge
# Scope lookups to merchants valid for this family (FamilyMerchants + assigned ProviderMerchants)
valid_merchants = all_family_merchants
target = valid_merchants.find_by(id: params[:target_id])
unless target
return redirect_to merge_family_merchants_path, alert: t(".target_not_found")
end
sources = valid_merchants.where(id: params[:source_ids])
unless sources.any?
return redirect_to merge_family_merchants_path, alert: t(".invalid_merchants")
end
merger = Merchant::Merger.new(
family: Current.family,
target_merchant: target,
source_merchants: sources
)
if merger.merge!
redirect_to family_merchants_path, notice: t(".success", count: merger.merged_count)
else
redirect_to merge_family_merchants_path, alert: t(".no_merchants_selected")
end
rescue Merchant::Merger::UnauthorizedMerchantError => e
redirect_to merge_family_merchants_path, alert: e.message
end
private
def set_merchant
@family_merchant = Current.family.merchants.find(params[:id])
# Find merchant that either belongs to family OR is assigned to family's transactions
@merchant = Current.family.merchants.find_by(id: params[:id]) ||
Current.family.assigned_merchants.find(params[:id])
@family_merchant = @merchant # For backwards compatibility with views
end
def merchant_params
params.require(:family_merchant).permit(:name, :color)
# Handle both family_merchant and provider_merchant param keys
key = params.key?(:family_merchant) ? :family_merchant : :provider_merchant
params.require(key).permit(:name, :color)
end
def all_family_merchants
family_merchant_ids = Current.family.merchants.pluck(:id)
provider_merchant_ids = Current.family.assigned_merchants.where(type: "ProviderMerchant").pluck(:id)
combined_ids = (family_merchant_ids + provider_merchant_ids).uniq
Merchant.where(id: combined_ids)
.order(Arel.sql("LOWER(COALESCE(name, ''))"))
end
end

View File

@@ -26,14 +26,38 @@ class ImportsController < ApplicationController
end
def create
type = params.dig(:import, :type).to_s
type = "TransactionImport" unless Import::TYPES.include?(type)
account = Current.family.accounts.find_by(id: params.dig(:import, :account_id))
import = Current.family.imports.create!(
type: import_params[:type],
type: type,
account: account,
date_format: Current.family.date_format,
)
redirect_to import_upload_path(import)
if import_params[:csv_file].present?
file = import_params[:csv_file]
if file.size > Import::MAX_CSV_SIZE
import.destroy
redirect_to new_import_path, alert: "File is too large. Maximum size is #{Import::MAX_CSV_SIZE / 1.megabyte}MB."
return
end
unless Import::ALLOWED_MIME_TYPES.include?(file.content_type)
import.destroy
redirect_to new_import_path, alert: "Invalid file type. Please upload a CSV file."
return
end
# Stream reading is not fully applicable here as we store the raw string in the DB,
# but we have validated size beforehand to prevent memory exhaustion from massive files.
import.update!(raw_file_str: file.read)
redirect_to import_configuration_path(import), notice: "CSV uploaded successfully."
else
redirect_to import_upload_path(import)
end
end
def show
@@ -70,6 +94,6 @@ class ImportsController < ApplicationController
end
def import_params
params.require(:import).permit(:type)
params.require(:import).permit(:csv_file)
end
end

View File

@@ -182,13 +182,18 @@ class LunchflowItemsController < ApplicationController
end
# Create the internal Account with proper balance initialization
# Use lunchflow_account.currency (already parsed) and skip initial sync
# because the provider sync will set the correct currency from the balance API
account = Account.create_and_sync(
family: Current.family,
name: account_data[:name],
balance: 0, # Initial balance will be set during sync
currency: account_data[:currency] || "USD",
accountable_type: accountable_type,
accountable_attributes: {}
{
family: Current.family,
name: account_data[:name],
balance: 0, # Initial balance will be set during sync
currency: lunchflow_account.currency || "USD",
accountable_type: accountable_type,
accountable_attributes: {}
},
skip_initial_sync: true
)
# Link account to lunchflow_account via account_providers join table
@@ -605,13 +610,17 @@ class LunchflowItemsController < ApplicationController
selected_subtype = "credit_card" if selected_type == "CreditCard" && selected_subtype.blank?
# Create account with user-selected type and subtype (raises on failure)
# Skip initial sync - provider sync will handle balance creation with correct currency
account = Account.create_and_sync(
family: Current.family,
name: lunchflow_account.name,
balance: lunchflow_account.current_balance || 0,
currency: lunchflow_account.currency || "USD",
accountable_type: selected_type,
accountable_attributes: selected_subtype.present? ? { subtype: selected_subtype } : {}
{
family: Current.family,
name: lunchflow_account.name,
balance: lunchflow_account.current_balance || 0,
currency: lunchflow_account.currency || "USD",
accountable_type: selected_type,
accountable_attributes: selected_subtype.present? ? { subtype: selected_subtype } : {}
},
skip_initial_sync: true
)
# Link account to lunchflow_account via account_providers join table (raises on failure)

View File

@@ -98,14 +98,15 @@ class OidcAccountsController < ApplicationController
return
end
# Create user with a secure random password since they're using SSO
secure_password = SecureRandom.base58(32)
# Create SSO-only user without local password.
# Security: JIT users should NOT have password_digest set to prevent
# chained authentication attacks where SSO users gain local login access
# via password reset.
@user = User.new(
email: email,
first_name: @pending_auth["first_name"],
last_name: @pending_auth["last_name"],
password: secure_password,
password_confirmation: secure_password
skip_password_validation: true
)
# Create new family for this user

View File

@@ -5,16 +5,17 @@ class PagesController < ApplicationController
def dashboard
@balance_sheet = Current.family.balance_sheet
@investment_statement = Current.family.investment_statement
@accounts = Current.family.accounts.visible.with_attached_logo
family_currency = Current.family.currency
# Use the same period for all widgets (set by Periodable concern)
# Use IncomeStatement for all cashflow data (now includes categorized trades)
income_totals = Current.family.income_statement.income_totals(period: @period)
expense_totals = Current.family.income_statement.expense_totals(period: @period)
@cashflow_sankey_data = build_cashflow_sankey_data(income_totals, expense_totals, family_currency)
@outflows_data = build_outflows_donut_data(expense_totals, family_currency)
@outflows_data = build_outflows_donut_data(expense_totals)
@dashboard_sections = build_dashboard_sections
@@ -81,6 +82,14 @@ class PagesController < ApplicationController
visible: Current.family.accounts.any? && @outflows_data[:categories].present?,
collapsible: true
},
{
key: "investment_summary",
title: "pages.dashboard.investment_summary.title",
partial: "pages/dashboard/investment_summary",
locals: { investment_statement: @investment_statement, period: @period },
visible: Current.family.accounts.any? && @investment_statement.investment_accounts.any?,
collapsible: true
},
{
key: "net_worth_chart",
title: "pages.dashboard.net_worth_chart.title",
@@ -117,12 +126,11 @@ class PagesController < ApplicationController
Provider::Registry.get_provider(:github)
end
def build_cashflow_sankey_data(income_totals, expense_totals, currency_symbol)
def build_cashflow_sankey_data(income_totals, expense_totals, currency)
nodes = []
links = []
node_indices = {} # Memoize node indices by a unique key: "type_categoryid"
node_indices = {}
# Helper to add/find node and return its index
add_node = ->(unique_key, display_name, value, percentage, color) {
node_indices[unique_key] ||= begin
nodes << { name: display_name, value: value.to_f.round(2), percentage: percentage.to_f.round(1), color: color }
@@ -130,93 +138,59 @@ class PagesController < ApplicationController
end
}
total_income_val = income_totals.total.to_f.round(2)
total_expense_val = expense_totals.total.to_f.round(2)
total_income = income_totals.total.to_f.round(2)
total_expense = expense_totals.total.to_f.round(2)
# --- Create Central Cash Flow Node ---
cash_flow_idx = add_node.call("cash_flow_node", "Cash Flow", total_income_val, 0, "var(--color-success)")
# Central Cash Flow node
cash_flow_idx = add_node.call("cash_flow_node", "Cash Flow", total_income, 100.0, "var(--color-success)")
# --- Process Income Side (Top-level categories only) ---
# Income side (top-level categories only)
income_totals.category_totals.each do |ct|
# Skip subcategories only include root income categories
next if ct.category.parent_id.present?
val = ct.total.to_f.round(2)
next if val.zero?
percentage_of_total_income = total_income_val.zero? ? 0 : (val / total_income_val * 100).round(1)
percentage = total_income.zero? ? 0 : (val / total_income * 100).round(1)
color = ct.category.color.presence || Category::COLORS.sample
node_display_name = ct.category.name
node_color = ct.category.color.presence || Category::COLORS.sample
current_cat_idx = add_node.call(
"income_#{ct.category.id}",
node_display_name,
val,
percentage_of_total_income,
node_color
)
links << {
source: current_cat_idx,
target: cash_flow_idx,
value: val,
color: node_color,
percentage: percentage_of_total_income
}
# Use name as fallback key for synthetic categories (no id)
node_key = "income_#{ct.category.id || ct.category.name}"
idx = add_node.call(node_key, ct.category.name, val, percentage, color)
links << { source: idx, target: cash_flow_idx, value: val, color: color, percentage: percentage }
end
# --- Process Expense Side (Top-level categories only) ---
# Expense side (top-level categories only)
expense_totals.category_totals.each do |ct|
# Skip subcategories only include root expense categories to keep Sankey shallow
next if ct.category.parent_id.present?
val = ct.total.to_f.round(2)
next if val.zero?
percentage_of_total_expense = total_expense_val.zero? ? 0 : (val / total_expense_val * 100).round(1)
percentage = total_expense.zero? ? 0 : (val / total_expense * 100).round(1)
color = ct.category.color.presence || Category::UNCATEGORIZED_COLOR
node_display_name = ct.category.name
node_color = ct.category.color.presence || Category::UNCATEGORIZED_COLOR
current_cat_idx = add_node.call(
"expense_#{ct.category.id}",
node_display_name,
val,
percentage_of_total_expense,
node_color
)
links << {
source: cash_flow_idx,
target: current_cat_idx,
value: val,
color: node_color,
percentage: percentage_of_total_expense
}
# Use name as fallback key for synthetic categories (no id)
node_key = "expense_#{ct.category.id || ct.category.name}"
idx = add_node.call(node_key, ct.category.name, val, percentage, color)
links << { source: cash_flow_idx, target: idx, value: val, color: color, percentage: percentage }
end
# --- Process Surplus ---
leftover = (total_income_val - total_expense_val).round(2)
if leftover.positive?
percentage_of_total_income_for_surplus = total_income_val.zero? ? 0 : (leftover / total_income_val * 100).round(1)
surplus_idx = add_node.call("surplus_node", "Surplus", leftover, percentage_of_total_income_for_surplus, "var(--color-success)")
links << { source: cash_flow_idx, target: surplus_idx, value: leftover, color: "var(--color-success)", percentage: percentage_of_total_income_for_surplus }
# Surplus/Deficit
net = (total_income - total_expense).round(2)
if net.positive?
percentage = total_income.zero? ? 0 : (net / total_income * 100).round(1)
idx = add_node.call("surplus_node", "Surplus", net, percentage, "var(--color-success)")
links << { source: cash_flow_idx, target: idx, value: net, color: "var(--color-success)", percentage: percentage }
end
# Update Cash Flow and Income node percentages (relative to total income)
if node_indices["cash_flow_node"]
nodes[node_indices["cash_flow_node"]][:percentage] = 100.0
end
# No primary income node anymore, percentages are on individual income cats relative to total_income_val
{ nodes: nodes, links: links, currency_symbol: Money::Currency.new(currency_symbol).symbol }
{ nodes: nodes, links: links, currency_symbol: Money::Currency.new(currency).symbol }
end
def build_outflows_donut_data(expense_totals, family_currency)
def build_outflows_donut_data(expense_totals)
currency_symbol = Money::Currency.new(expense_totals.currency).symbol
total = expense_totals.total
# Only include top-level categories with non-zero amounts
categories = expense_totals.category_totals
.reject { |ct| ct.category.parent_id.present? || ct.total.zero? }
.sort_by { |ct| -ct.total }
@@ -228,10 +202,11 @@ class PagesController < ApplicationController
currency: ct.currency,
percentage: ct.weight.round(1),
color: ct.category.color.presence || Category::UNCATEGORIZED_COLOR,
icon: ct.category.lucide_icon
icon: ct.category.lucide_icon,
clickable: !ct.category.other_investments?
}
end
{ categories: categories, total: total.to_f.round(2), currency: family_currency, currency_symbol: Money::Currency.new(family_currency).symbol }
{ categories: categories, total: total.to_f.round(2), currency: expense_totals.currency, currency_symbol: currency_symbol }
end
end

View File

@@ -11,12 +11,17 @@ class PasswordResetsController < ApplicationController
def create
if (user = User.find_by(email: params[:email]))
PasswordMailer.with(
user: user,
token: user.generate_token_for(:password_reset)
).password_reset.deliver_later
# Security: Block password reset for SSO-only users.
# These users have no local password and must authenticate via SSO.
unless user.sso_only?
PasswordMailer.with(
user: user,
token: user.generate_token_for(:password_reset)
).password_reset.deliver_later
end
end
# Always redirect to pending step to prevent email enumeration
redirect_to new_password_reset_path(step: "pending")
end
@@ -25,6 +30,13 @@ class PasswordResetsController < ApplicationController
end
def update
# Security: Block password setting for SSO-only users.
# Defense-in-depth: even if they somehow get a reset token, block the update.
if @user.sso_only?
redirect_to new_session_path, alert: t("password_resets.sso_only_user")
return
end
if @user.update(password_params)
redirect_to new_session_path, notice: t(".success")
else

View File

@@ -31,12 +31,15 @@ class ReportsController < ApplicationController
# Build trend data (last 6 months)
@trends_data = build_trends_data
# Spending patterns (weekday vs weekend)
@spending_patterns = build_spending_patterns
# Net worth metrics
@net_worth_metrics = build_net_worth_metrics
# Transactions breakdown
@transactions = build_transactions_breakdown
# Investment metrics (must be before build_reports_sections)
@investment_metrics = build_investment_metrics
# Build reports sections for collapsible/reorderable UI
@reports_sections = build_reports_sections
@@ -121,14 +124,30 @@ class ReportsController < ApplicationController
def build_reports_sections
all_sections = [
{
key: "net_worth",
title: "reports.net_worth.title",
partial: "reports/net_worth",
locals: { net_worth_metrics: @net_worth_metrics },
visible: Current.family.accounts.any?,
collapsible: true
},
{
key: "trends_insights",
title: "reports.trends.title",
partial: "reports/trends_insights",
locals: { trends_data: @trends_data, spending_patterns: @spending_patterns },
locals: { trends_data: @trends_data },
visible: Current.family.transactions.any?,
collapsible: true
},
{
key: "investment_performance",
title: "reports.investment_performance.title",
partial: "reports/investment_performance",
locals: { investment_metrics: @investment_metrics },
visible: @investment_metrics[:has_investments],
collapsible: true
},
{
key: "transactions_breakdown",
title: "reports.transactions_breakdown.title",
@@ -299,61 +318,6 @@ class ReportsController < ApplicationController
trends
end
def build_spending_patterns
# Analyze weekday vs weekend spending
weekday_total = 0
weekend_total = 0
weekday_count = 0
weekend_count = 0
# Build query matching income_statement logic:
# Expenses are transactions with positive amounts, regardless of category
expense_transactions = Transaction
.joins(:entry)
.joins(entry: :account)
.where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] })
.where(entries: { entryable_type: "Transaction", excluded: false, date: @period.date_range })
.where(kind: [ "standard", "loan_payment" ])
.where("entries.amount > 0") # Positive amount = expense (matching income_statement logic)
# Sum up amounts by weekday vs weekend
expense_transactions.each do |transaction|
entry = transaction.entry
amount = entry.amount.abs
if entry.date.wday.in?([ 0, 6 ]) # Sunday or Saturday
weekend_total += amount
weekend_count += 1
else
weekday_total += amount
weekday_count += 1
end
end
weekday_avg = weekday_count.positive? ? (weekday_total / weekday_count) : 0
weekend_avg = weekend_count.positive? ? (weekend_total / weekend_count) : 0
{
weekday_total: weekday_total,
weekend_total: weekend_total,
weekday_avg: weekday_avg,
weekend_avg: weekend_avg,
weekday_count: weekday_count,
weekend_count: weekend_count
}
end
def default_spending_patterns
{
weekday_total: 0,
weekend_total: 0,
weekday_avg: 0,
weekend_avg: 0,
weekday_count: 0,
weekend_count: 0
}
end
def build_transactions_breakdown
# Base query: all transactions in the period
# Exclude transfers, one-time, and CC payments (matching income_statement logic)
@@ -368,25 +332,55 @@ class ReportsController < ApplicationController
# Apply filters
transactions = apply_transaction_filters(transactions)
# Get trades in the period (matching income_statement logic)
trades = Trade
.joins(:entry)
.joins(entry: :account)
.where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] })
.where(entries: { entryable_type: "Trade", excluded: false, date: @period.date_range })
.includes(entry: :account, category: [])
# Get sort parameters
sort_by = params[:sort_by] || "amount"
sort_direction = params[:sort_direction] || "desc"
# Group by category and type
all_transactions = transactions.to_a
grouped_data = {}
family_currency = Current.family.currency
all_transactions.each do |transaction|
# Process transactions
transactions.each do |transaction|
entry = transaction.entry
is_expense = entry.amount > 0
type = is_expense ? "expense" : "income"
category_name = transaction.category&.name || "Uncategorized"
category_color = transaction.category&.color || "#9CA3AF"
category_color = transaction.category&.color || Category::UNCATEGORIZED_COLOR
# Convert to family currency
converted_amount = Money.new(entry.amount.abs, entry.currency).exchange_to(family_currency, fallback_rate: 1).amount
key = [ category_name, type, category_color ]
grouped_data[key] ||= { total: 0, count: 0 }
grouped_data[key][:count] += 1
grouped_data[key][:total] += entry.amount.abs
grouped_data[key][:total] += converted_amount
end
# Process trades
trades.each do |trade|
entry = trade.entry
is_expense = entry.amount > 0
type = is_expense ? "expense" : "income"
# Use "Other Investments" for trades without category
category_name = trade.category&.name || Category.other_investments_name
category_color = trade.category&.color || Category::OTHER_INVESTMENTS_COLOR
# Convert to family currency
converted_amount = Money.new(entry.amount.abs, entry.currency).exchange_to(family_currency, fallback_rate: 1).amount
key = [ category_name, type, category_color ]
grouped_data[key] ||= { total: 0, count: 0 }
grouped_data[key][:count] += 1
grouped_data[key][:total] += converted_amount
end
# Convert to array
@@ -408,6 +402,58 @@ class ReportsController < ApplicationController
end
end
def build_investment_metrics
investment_statement = Current.family.investment_statement
investment_accounts = investment_statement.investment_accounts
return { has_investments: false } unless investment_accounts.any?
period_totals = investment_statement.totals(period: @period)
{
has_investments: true,
portfolio_value: investment_statement.portfolio_value_money,
unrealized_trend: investment_statement.unrealized_gains_trend,
period_contributions: period_totals.contributions,
period_withdrawals: period_totals.withdrawals,
top_holdings: investment_statement.top_holdings(limit: 5),
accounts: investment_accounts.to_a
}
end
def build_net_worth_metrics
balance_sheet = Current.family.balance_sheet
currency = Current.family.currency
# Current net worth
current_net_worth = balance_sheet.net_worth
total_assets = balance_sheet.assets.total
total_liabilities = balance_sheet.liabilities.total
# Get net worth series for the period to calculate change
# The series.trend gives us the change from first to last value in the period
net_worth_series = balance_sheet.net_worth_series(period: @period)
trend = net_worth_series&.trend
# Get asset and liability groups for breakdown
asset_groups = balance_sheet.assets.account_groups.map do |group|
{ name: group.name, total: Money.new(group.total, currency) }
end.reject { |g| g[:total].zero? }
liability_groups = balance_sheet.liabilities.account_groups.map do |group|
{ name: group.name, total: Money.new(group.total, currency) }
end.reject { |g| g[:total].zero? }
{
current_net_worth: Money.new(current_net_worth, currency),
total_assets: Money.new(total_assets, currency),
total_liabilities: Money.new(total_liabilities, currency),
trend: trend,
asset_groups: asset_groups,
liability_groups: liability_groups
}
end
def apply_transaction_filters(transactions)
# Filter by category (including subcategories)
if params[:filter_category_id].present?
@@ -503,9 +549,19 @@ class ReportsController < ApplicationController
transactions = apply_transaction_filters(transactions)
# Group transactions by category, type, and month
breakdown = {}
# Get trades in the period (matching income_statement logic)
trades = Trade
.joins(:entry)
.joins(entry: :account)
.where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] })
.where(entries: { entryable_type: "Trade", excluded: false, date: @period.date_range })
.includes(entry: :account, category: [])
# Group by category, type, and month
breakdown = {}
family_currency = Current.family.currency
# Process transactions
transactions.each do |transaction|
entry = transaction.entry
is_expense = entry.amount > 0
@@ -513,11 +569,33 @@ class ReportsController < ApplicationController
category_name = transaction.category&.name || "Uncategorized"
month_key = entry.date.beginning_of_month
# Convert to family currency
converted_amount = Money.new(entry.amount.abs, entry.currency).exchange_to(family_currency, fallback_rate: 1).amount
key = [ category_name, type ]
breakdown[key] ||= { category: category_name, type: type, months: {}, total: 0 }
breakdown[key][:months][month_key] ||= 0
breakdown[key][:months][month_key] += entry.amount.abs
breakdown[key][:total] += entry.amount.abs
breakdown[key][:months][month_key] += converted_amount
breakdown[key][:total] += converted_amount
end
# Process trades
trades.each do |trade|
entry = trade.entry
is_expense = entry.amount > 0
type = is_expense ? "expense" : "income"
# Use "Other Investments" for trades without category
category_name = trade.category&.name || Category.other_investments_name
month_key = entry.date.beginning_of_month
# Convert to family currency
converted_amount = Money.new(entry.amount.abs, entry.currency).exchange_to(family_currency, fallback_rate: 1).amount
key = [ category_name, type ]
breakdown[key] ||= { category: category_name, type: type, months: {}, total: 0 }
breakdown[key][:months][month_key] ||= 0
breakdown[key][:months][month_key] += converted_amount
breakdown[key][:total] += converted_amount
end
# Convert to array and sort by type and total (descending)

View File

@@ -104,6 +104,30 @@ class RulesController < ApplicationController
redirect_to rules_path, notice: "All rules deleted"
end
def confirm_all
@rules = Current.family.rules
@total_affected_count = Rule.total_affected_resource_count(@rules)
# Compute AI cost estimation if any rule has auto_categorize action
if @rules.any? { |r| r.actions.any? { |a| a.action_type == "auto_categorize" } }
llm_provider = Provider::Registry.get_provider(:openai)
if llm_provider
@selected_model = Provider::Openai.effective_model
@estimated_cost = LlmUsage.estimate_auto_categorize_cost(
transaction_count: @total_affected_count,
category_count: Current.family.categories.count,
model: @selected_model
)
end
end
end
def apply_all
ApplyAllRulesJob.perform_later(Current.family)
redirect_back_or_to rules_path, notice: t("rules.apply_all.success")
end
private
def set_rule
@rule = Current.family.rules.find(params[:id])

View File

@@ -8,7 +8,7 @@ class Settings::ProvidersController < ApplicationController
def show
@breadcrumbs = [
[ "Home", root_path ],
[ "Bank Sync Providers", nil ]
[ "Sync Providers", nil ]
]
prepare_show_context
@@ -124,13 +124,14 @@ class Settings::ProvidersController < ApplicationController
Provider::Factory.ensure_adapters_loaded
@provider_configurations = Provider::ConfigurationRegistry.all.reject do |config|
config.provider_key.to_s.casecmp("simplefin").zero? || config.provider_key.to_s.casecmp("lunchflow").zero? || \
config.provider_key.to_s.casecmp("enable_banking").zero?
config.provider_key.to_s.casecmp("enable_banking").zero? || \
config.provider_key.to_s.casecmp("coinstats").zero?
end
# Providers page only needs to know whether any SimpleFin/Lunchflow connections exist with valid credentials
@simplefin_items = Current.family.simplefin_items.where.not(access_url: [ nil, "" ]).ordered.select(:id)
@lunchflow_items = Current.family.lunchflow_items.where.not(api_key: [ nil, "" ]).ordered.select(:id)
# Enable Banking panel needs session info for status display
@enable_banking_items = Current.family.enable_banking_items.ordered
@enable_banking_items = Current.family.enable_banking_items.ordered # Enable Banking panel needs session info for status display
@coinstats_items = Current.family.coinstats_items.ordered # CoinStats panel needs account info for status display
end
end

View File

@@ -21,43 +21,21 @@ class SimplefinItemsController < ApplicationController
return render_error(t(".errors.blank_token"), context: :edit) if setup_token.blank?
begin
# Create new SimpleFin item data with updated token
updated_item = Current.family.create_simplefin_item!(
setup_token: setup_token,
item_name: @simplefin_item.name
# Validate token shape early so the user gets immediate feedback.
claim_url = Base64.decode64(setup_token)
URI.parse(claim_url)
# Updating a SimpleFin connection can involve network retries/backoff and account import.
# Do it asynchronously so web requests aren't blocked by retry sleeps.
SimplefinConnectionUpdateJob.perform_later(
family_id: Current.family.id,
old_simplefin_item_id: @simplefin_item.id,
setup_token: setup_token
)
# Ensure new simplefin_accounts are created & have account_id set
updated_item.import_latest_simplefin_data
# Transfer accounts from old item to new item
ActiveRecord::Base.transaction do
@simplefin_item.simplefin_accounts.each do |old_account|
if old_account.account.present?
# Find matching account in new item by account_id
new_account = updated_item.simplefin_accounts.find_by(account_id: old_account.account_id)
if new_account
# Transfer the account directly to the new SimpleFin account
# This will automatically break the old association
old_account.account.update!(simplefin_account_id: new_account.id)
end
end
end
# Mark old item for deletion
@simplefin_item.destroy_later
end
# Clear any requires_update status on new item
updated_item.update!(status: :good)
if turbo_frame_request?
@simplefin_items = Current.family.simplefin_items.ordered
render turbo_stream: turbo_stream.replace(
"simplefin-providers-panel",
partial: "settings/providers/simplefin_panel",
locals: { simplefin_items: @simplefin_items }
)
flash.now[:notice] = t(".success")
render turbo_stream: Array(flash_notification_stream_items)
else
redirect_to accounts_path, notice: t(".success"), status: :see_other
end
@@ -157,12 +135,16 @@ class SimplefinItemsController < ApplicationController
end
def setup_accounts
@simplefin_accounts = @simplefin_item.simplefin_accounts.includes(:account).where(accounts: { id: nil })
# Only show unlinked accounts - check both legacy FK and AccountProvider
@simplefin_accounts = @simplefin_item.simplefin_accounts
.left_joins(:account, :account_provider)
.where(accounts: { id: nil }, account_providers: { id: nil })
@account_type_options = [
[ "Skip this account", "skip" ],
[ "Checking or Savings Account", "Depository" ],
[ "Credit Card", "CreditCard" ],
[ "Investment Account", "Investment" ],
[ "Crypto Account", "Crypto" ],
[ "Loan or Mortgage", "Loan" ],
[ "Other Asset", "OtherAsset" ]
]
@@ -208,25 +190,46 @@ class SimplefinItemsController < ApplicationController
label: "Loan Type:",
options: Loan::SUBTYPES.map { |k, v| [ v[:long], k ] }
},
"Crypto" => {
label: nil,
options: [],
message: "Crypto accounts track cryptocurrency holdings."
},
"OtherAsset" => {
label: nil,
options: [],
message: "No additional options needed for Other Assets."
}
}
# Detect stale accounts: linked in DB but no longer in upstream SimpleFin API
@stale_simplefin_accounts = detect_stale_simplefin_accounts
if @stale_simplefin_accounts.any?
# Build list of target accounts for "move transactions to" dropdown
# Only show accounts from this SimpleFin connection (excluding stale ones)
stale_account_ids = @stale_simplefin_accounts.map { |sfa| sfa.current_account&.id }.compact
@target_accounts = @simplefin_item.accounts
.reject { |acct| stale_account_ids.include?(acct.id) }
.sort_by(&:name)
end
end
def complete_account_setup
account_types = params[:account_types] || {}
account_subtypes = params[:account_subtypes] || {}
stale_account_actions = permitted_stale_account_actions
# Update sync start date from form
if params[:sync_start_date].present?
@simplefin_item.update!(sync_start_date: params[:sync_start_date])
end
# Valid account types for this provider (plus OtherAsset which SimpleFIN UI allows)
valid_types = Provider::SimplefinAdapter.supported_account_types + [ "OtherAsset" ]
# Process stale account actions first
stale_results = process_stale_account_actions(stale_account_actions)
stale_action_errors = stale_results[:errors] || []
# Valid account types for this provider (plus Crypto and OtherAsset which SimpleFIN UI allows)
valid_types = Provider::SimplefinAdapter.supported_account_types + [ "Crypto", "OtherAsset" ]
created_accounts = []
skipped_count = 0
@@ -269,6 +272,8 @@ class SimplefinItemsController < ApplicationController
selected_subtype
)
simplefin_account.update!(account: account)
# Also create AccountProvider for consistency with the new linking system
simplefin_account.ensure_account_provider!
created_accounts << account
end
@@ -286,6 +291,17 @@ class SimplefinItemsController < ApplicationController
else
flash[:notice] = t(".no_accounts")
end
# Add stale account results to flash
if stale_results[:deleted] > 0 || stale_results[:moved] > 0
stale_message = t(".stale_accounts_processed", deleted: stale_results[:deleted], moved: stale_results[:moved])
flash[:notice] = [ flash[:notice], stale_message ].compact.join(" ")
end
# Warn about any stale account action failures
if stale_action_errors.any?
flash[:alert] = t(".stale_accounts_errors", count: stale_action_errors.size)
end
if turbo_frame_request?
# Recompute data needed by Accounts#index partials
@manual_accounts = Account.uncached {
@@ -462,6 +478,24 @@ class SimplefinItemsController < ApplicationController
params.require(:simplefin_item).permit(:setup_token, :sync_start_date)
end
def permitted_stale_account_actions
return {} unless params[:stale_account_actions].is_a?(ActionController::Parameters)
# Permit the nested structure: stale_account_actions[simplefin_account_id][action|target_account_id]
params[:stale_account_actions].to_unsafe_h.each_with_object({}) do |(simplefin_account_id, action_params), result|
next unless simplefin_account_id.present? && action_params.is_a?(Hash)
# Validate simplefin_account_id is a valid UUID format to prevent injection
next unless simplefin_account_id.to_s.match?(/\A[0-9a-f-]+\z/i)
permitted = {}
permitted[:action] = action_params[:action] if %w[delete move skip].include?(action_params[:action])
permitted[:target_account_id] = action_params[:target_account_id] if action_params[:target_account_id].present?
result[simplefin_account_id] = permitted if permitted[:action].present?
end
end
def render_error(message, setup_token = nil, context: :new)
if context == :edit
# Keep the persisted record and assign the token for re-render
@@ -483,4 +517,106 @@ class SimplefinItemsController < ApplicationController
render context, status: :unprocessable_entity
end
end
# Detect stale SimpleFin accounts: linked in DB but no longer in upstream API
def detect_stale_simplefin_accounts
# Get upstream account IDs from the last sync's raw_payload
raw_payload = @simplefin_item.raw_payload
return [] if raw_payload.blank?
upstream_ids = raw_payload.with_indifferent_access[:accounts]&.map { |a| a[:id].to_s } || []
return [] if upstream_ids.empty?
# Find SimplefinAccounts that are linked but not in upstream
@simplefin_item.simplefin_accounts
.includes(:account, account_provider: :account)
.select { |sfa| sfa.current_account.present? && !upstream_ids.include?(sfa.account_id) }
end
# Process user-selected actions for stale accounts
def process_stale_account_actions(stale_actions)
results = { deleted: 0, moved: 0, skipped: 0, errors: [] }
return results if stale_actions.blank?
stale_actions.each do |simplefin_account_id, action_params|
action = action_params[:action]
next if action.blank? || action == "skip"
sfa = @simplefin_item.simplefin_accounts.find_by(id: simplefin_account_id)
next unless sfa
account = sfa.current_account
next unless account
case action
when "delete"
if handle_stale_account_delete(sfa, account)
results[:deleted] += 1
else
results[:errors] << { account: account.name, action: "delete" }
end
when "move"
target_account_id = action_params[:target_account_id]
if target_account_id.present? && handle_stale_account_move(sfa, account, target_account_id)
results[:moved] += 1
else
results[:errors] << { account: account.name, action: "move" }
end
else
results[:skipped] += 1
end
end
results
end
def handle_stale_account_delete(simplefin_account, account)
ActiveRecord::Base.transaction do
# Destroy the Account (cascades to entries/holdings)
account.destroy!
# Destroy the SimplefinAccount
simplefin_account.destroy!
end
true
rescue => e
Rails.logger.error("Failed to delete stale account: #{e.class} - #{e.message}")
false
end
def handle_stale_account_move(simplefin_account, source_account, target_account_id)
target_account = @simplefin_item.accounts.find { |acct| acct.id.to_s == target_account_id.to_s }
return false unless target_account
ActiveRecord::Base.transaction do
# Handle transfers that would become invalid after moving entries.
# Transfers linking source entries to target entries would end up with both
# entries in the same account, violating transfer_has_different_accounts validation.
source_entry_ids = source_account.entries.pluck(:id)
target_entry_ids = target_account.entries.pluck(:id)
if source_entry_ids.any? && target_entry_ids.any?
# Find and destroy transfers between source and target accounts
# Use find_each + destroy! to invoke Transfer's custom destroy! callbacks
# which reset transaction kinds to "standard"
Transfer.where(inflow_transaction_id: source_entry_ids, outflow_transaction_id: target_entry_ids)
.or(Transfer.where(inflow_transaction_id: target_entry_ids, outflow_transaction_id: source_entry_ids))
.find_each(&:destroy!)
end
# Move all entries to target account
source_account.entries.update_all(account_id: target_account.id)
# Destroy the now-empty source account
source_account.destroy!
# Destroy the SimplefinAccount
simplefin_account.destroy!
end
# Trigger sync on target account to recalculate balances (after commit)
target_account.sync_later
true
rescue => e
Rails.logger.error("Failed to move transactions from stale account: #{e.class} - #{e.message}")
false
end
end

View File

@@ -54,7 +54,7 @@ class TradesController < ApplicationController
def entry_params
params.require(:entry).permit(
:name, :date, :amount, :currency, :excluded, :notes, :nature,
entryable_attributes: [ :id, :qty, :price ]
entryable_attributes: [ :id, :qty, :price, :category_id ]
)
end

View File

@@ -42,4 +42,27 @@ export default class extends Controller {
}
}
}
clearWarning(event) {
// When user selects a subtype value, clear all warning styling
const select = event.target
if (select.value) {
// Clear the subtype dropdown warning
const warningContainer = select.closest('.ring-2')
if (warningContainer) {
warningContainer.classList.remove('ring-2', 'ring-warning/50', 'rounded-md', 'p-2', '-m-2')
const warningText = warningContainer.querySelector('.text-warning')
if (warningText) {
warningText.remove()
}
}
// Clear the parent card's warning border
const card = this.element.closest('.border-2.border-warning')
if (card) {
card.classList.remove('border-2', 'border-warning', 'bg-warning/5')
card.classList.add('border', 'border-primary')
}
}
}
}

View File

@@ -0,0 +1,65 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "form", "overlay"]
dragDepth = 0
connect() {
this.boundDragOver = this.dragOver.bind(this)
this.boundDragEnter = this.dragEnter.bind(this)
this.boundDragLeave = this.dragLeave.bind(this)
this.boundDrop = this.drop.bind(this)
// Listen on the document to catch drags anywhere
document.addEventListener("dragover", this.boundDragOver)
document.addEventListener("dragenter", this.boundDragEnter)
document.addEventListener("dragleave", this.boundDragLeave)
document.addEventListener("drop", this.boundDrop)
}
disconnect() {
document.removeEventListener("dragover", this.boundDragOver)
document.removeEventListener("dragenter", this.boundDragEnter)
document.removeEventListener("dragleave", this.boundDragLeave)
document.removeEventListener("drop", this.boundDrop)
}
dragEnter(event) {
event.preventDefault()
this.dragDepth++
if (this.dragDepth === 1) {
this.overlayTarget.classList.remove("hidden")
}
}
dragOver(event) {
event.preventDefault()
}
dragLeave(event) {
event.preventDefault()
this.dragDepth--
if (this.dragDepth <= 0) {
this.dragDepth = 0
this.overlayTarget.classList.add("hidden")
}
}
drop(event) {
event.preventDefault()
this.dragDepth = 0
this.overlayTarget.classList.add("hidden")
if (event.dataTransfer.files.length > 0) {
const file = event.dataTransfer.files[0]
// Simple validation
if (file.type === "text/csv" || file.name.toLowerCase().endsWith(".csv")) {
this.inputTarget.files = event.dataTransfer.files
this.formTarget.requestSubmit()
} else {
alert("Please upload a valid CSV file.")
}
}
}
}

View File

@@ -0,0 +1,25 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["moveRadio", "targetSelect"]
static values = { accountId: String }
connect() {
this.updateTargetVisibility()
}
updateTargetVisibility() {
if (!this.hasTargetSelectTarget || !this.hasMoveRadioTarget) return
const moveRadio = this.moveRadioTarget
const targetSelect = this.targetSelectTarget
if (moveRadio?.checked) {
targetSelect.disabled = false
targetSelect.classList.remove("opacity-50", "cursor-not-allowed")
} else {
targetSelect.disabled = true
targetSelect.classList.add("opacity-50", "cursor-not-allowed")
}
}
}

View File

@@ -0,0 +1,9 @@
class ApplyAllRulesJob < ApplicationJob
queue_as :medium_priority
def perform(family, execution_type: "manual")
family.rules.find_each do |rule|
RuleJob.perform_now(rule, ignore_attribute_locks: true, execution_type: execution_type)
end
end
end

View File

@@ -0,0 +1,17 @@
class DataCleanerJob < ApplicationJob
queue_as :scheduled
def perform
clean_old_merchant_associations
end
private
def clean_old_merchant_associations
# Delete FamilyMerchantAssociation records older than 30 days
deleted_count = FamilyMerchantAssociation
.where(unlinked_at: ...30.days.ago)
.delete_all
Rails.logger.info("DataCleanerJob: Deleted #{deleted_count} old merchant associations") if deleted_count > 0
end
end

View File

@@ -0,0 +1,167 @@
class SimplefinConnectionUpdateJob < ApplicationJob
queue_as :high_priority
# Disable automatic retries for this job since the setup token is single-use.
# If the token claim succeeds but import fails, retrying would fail at claim.
discard_on Provider::Simplefin::SimplefinError do |job, error|
Rails.logger.error(
"SimplefinConnectionUpdateJob discarded: #{error.class} - #{error.message} " \
"(family_id=#{job.arguments.first[:family_id]}, old_item_id=#{job.arguments.first[:old_simplefin_item_id]})"
)
end
def perform(family_id:, old_simplefin_item_id:, setup_token:)
family = Family.find(family_id)
old_item = family.simplefin_items.find(old_simplefin_item_id)
# Step 1: Claim the token and create the new item.
# This is the critical step - if it fails, we can safely retry.
# If it succeeds, the token is consumed and we must not retry the claim.
updated_item = family.create_simplefin_item!(
setup_token: setup_token,
item_name: old_item.name
)
# Step 2: Import accounts from SimpleFin.
# If this fails, we have an orphaned item but the token is already consumed.
# We handle this gracefully by marking the item and continuing.
begin
updated_item.import_latest_simplefin_data
rescue => e
Rails.logger.error(
"SimplefinConnectionUpdateJob: import failed for new item #{updated_item.id}: " \
"#{e.class} - #{e.message}. Item created but may need manual sync."
)
# Mark the item as needing attention but don't fail the job entirely.
# The item exists and can be synced manually later.
updated_item.update!(status: :requires_update)
# Still proceed to transfer accounts and schedule old item deletion
end
# Step 3: Transfer account links from old to new item.
# This is idempotent and safe to retry.
# Check for linked accounts via BOTH legacy FK and AccountProvider.
ActiveRecord::Base.transaction do
old_item.simplefin_accounts.includes(:account, account_provider: :account).each do |old_account|
# Get the linked account via either system
linked_account = old_account.current_account
next unless linked_account.present?
new_simplefin_account = find_matching_simplefin_account(old_account, updated_item.simplefin_accounts)
next unless new_simplefin_account
# Update legacy FK
linked_account.update!(simplefin_account_id: new_simplefin_account.id)
# Also migrate AccountProvider if it exists
if old_account.account_provider.present?
old_account.account_provider.update!(
provider_type: "SimplefinAccount",
provider_id: new_simplefin_account.id
)
else
# Create AccountProvider for consistency
new_simplefin_account.ensure_account_provider!
end
end
end
# Schedule deletion outside transaction to avoid race condition where
# the job is enqueued even if the transaction rolls back
old_item.destroy_later
# Only mark as good if import succeeded (status wasn't set to requires_update above)
updated_item.update!(status: :good) unless updated_item.requires_update?
end
private
# Find a matching SimpleFin account in the new item's accounts.
# Uses a multi-tier matching strategy:
# 1. Exact account_id match (preferred)
# 2. Fingerprint match (name + institution + account_type)
# 3. Fuzzy name match with same institution (fallback)
def find_matching_simplefin_account(old_account, new_accounts)
exact_match = new_accounts.find_by(account_id: old_account.account_id)
return exact_match if exact_match
old_fingerprint = account_fingerprint(old_account)
fingerprint_match = new_accounts.find { |new_account| account_fingerprint(new_account) == old_fingerprint }
return fingerprint_match if fingerprint_match
old_institution = extract_institution_id(old_account)
old_name_normalized = normalize_account_name(old_account.name)
new_accounts.find do |new_account|
new_institution = extract_institution_id(new_account)
new_name_normalized = normalize_account_name(new_account.name)
next false unless old_institution.present? && old_institution == new_institution
names_similar?(old_name_normalized, new_name_normalized)
end
end
def account_fingerprint(simplefin_account)
institution_id = extract_institution_id(simplefin_account)
name_normalized = normalize_account_name(simplefin_account.name)
account_type = simplefin_account.account_type.to_s.downcase
"#{institution_id}:#{name_normalized}:#{account_type}"
end
def extract_institution_id(simplefin_account)
org_data = simplefin_account.org_data
return nil unless org_data.is_a?(Hash)
org_data["id"] || org_data["domain"] || org_data["name"]&.downcase&.gsub(/\s+/, "_")
end
def normalize_account_name(name)
return "" if name.blank?
name.to_s
.downcase
.gsub(/[^a-z0-9]/, "")
end
def names_similar?(name1, name2)
return false if name1.blank? || name2.blank?
return true if name1 == name2
return true if name1.include?(name2) || name2.include?(name1)
longer = [ name1.length, name2.length ].max
return false if longer == 0
# Use Levenshtein distance for more accurate similarity
distance = levenshtein_distance(name1, name2)
similarity = 1.0 - (distance.to_f / longer)
similarity >= 0.8
end
# Compute Levenshtein edit distance between two strings
def levenshtein_distance(s1, s2)
m, n = s1.length, s2.length
return n if m.zero?
return m if n.zero?
# Use a single array and update in place for memory efficiency
prev_row = (0..n).to_a
curr_row = []
(1..m).each do |i|
curr_row[0] = i
(1..n).each do |j|
cost = s1[i - 1] == s2[j - 1] ? 0 : 1
curr_row[j] = [
prev_row[j] + 1, # deletion
curr_row[j - 1] + 1, # insertion
prev_row[j - 1] + cost # substitution
].min
end
prev_row, curr_row = curr_row, prev_row
end
prev_row[n]
end
end

View File

@@ -0,0 +1,27 @@
class SyncHourlyJob < ApplicationJob
queue_as :scheduled
sidekiq_options lock: :until_executed, on_conflict: :log
# Provider item classes that opt-in to hourly syncing
HOURLY_SYNCABLES = [
CoinstatsItem # https://coinstats.app/api-docs/rate-limits#plan-limits
].freeze
def perform
Rails.logger.info("Starting hourly sync")
HOURLY_SYNCABLES.each do |syncable_class|
sync_items(syncable_class)
end
Rails.logger.info("Completed hourly sync")
end
private
def sync_items(syncable_class)
syncable_class.active.find_each do |item|
item.sync_later
rescue => e
Rails.logger.error("Failed to sync #{syncable_class.name} #{item.id}: #{e.message}")
end
end
end

View File

@@ -68,7 +68,7 @@ class Account < ApplicationRecord
end
class << self
def create_and_sync(attributes)
def create_and_sync(attributes, skip_initial_sync: false)
attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty
account = new(attributes.merge(cash_balance: attributes[:balance]))
initial_balance = attributes.dig(:accountable_attributes, :initial_balance)&.to_d
@@ -81,7 +81,9 @@ class Account < ApplicationRecord
raise result.error if result.error
end
account.sync_later
# Skip initial sync for linked accounts - the provider sync will handle balance creation
# after the correct currency is known
account.sync_later unless skip_initial_sync
account
end
@@ -130,7 +132,8 @@ class Account < ApplicationRecord
simplefin_account_id: simplefin_account.id
}
create_and_sync(attributes)
# Skip initial sync - provider sync will handle balance creation with correct currency
create_and_sync(attributes, skip_initial_sync: true)
end
def create_from_enable_banking_account(enable_banking_account, account_type, subtype = nil)
@@ -156,11 +159,13 @@ class Account < ApplicationRecord
accountable_attributes = {}
accountable_attributes[:subtype] = subtype if subtype.present?
# Skip initial sync - provider sync will handle balance creation with correct currency
create_and_sync(
attributes.merge(
accountable_type: account_type,
accountable_attributes: accountable_attributes
)
),
skip_initial_sync: true
)
end
@@ -196,6 +201,10 @@ class Account < ApplicationRecord
read_attribute(:institution_domain).presence || provider&.institution_domain
end
def logo_url
provider&.logo_url
end
def destroy_later
mark_for_deletion!
DestroyJob.perform_later(self)

View File

@@ -75,6 +75,7 @@ class Account::ProviderImportAdapter
existing = entry.transaction.extra || {}
incoming = extra.is_a?(Hash) ? extra.deep_stringify_keys : {}
entry.transaction.extra = existing.deep_merge(incoming)
entry.transaction.save!
end
entry.save!
entry
@@ -92,14 +93,42 @@ class Account::ProviderImportAdapter
def find_or_create_merchant(provider_merchant_id:, name:, source:, website_url: nil, logo_url: nil)
return nil unless provider_merchant_id.present? && name.present?
ProviderMerchant.find_or_create_by!(
provider_merchant_id: provider_merchant_id,
source: source
) do |m|
m.name = name
m.website_url = website_url
m.logo_url = logo_url
# First try to find by provider_merchant_id (stable identifier derived from normalized name)
# This handles case variations in merchant names (e.g., "ACME Corp" vs "Acme Corp")
merchant = ProviderMerchant.find_by(provider_merchant_id: provider_merchant_id, source: source)
# If not found by provider_merchant_id, try by exact name match (backwards compatibility)
merchant ||= ProviderMerchant.find_by(source: source, name: name)
if merchant
# Update logo if provided and merchant doesn't have one (or has a different one)
# Best-effort: don't fail transaction import if logo update fails
if logo_url.present? && merchant.logo_url != logo_url
begin
merchant.update!(logo_url: logo_url)
rescue StandardError => e
Rails.logger.warn("Failed to update merchant logo: merchant_id=#{merchant.id} logo_url=#{logo_url} error=#{e.message}")
end
end
return merchant
end
# Create new merchant
begin
merchant = ProviderMerchant.create!(
source: source,
name: name,
provider_merchant_id: provider_merchant_id,
website_url: website_url,
logo_url: logo_url
)
rescue ActiveRecord::RecordNotUnique
# Race condition - another process created the record
merchant = ProviderMerchant.find_by(provider_merchant_id: provider_merchant_id, source: source) ||
ProviderMerchant.find_by(source: source, name: name)
end
merchant
end
# Updates account balance from provider data

View File

@@ -42,7 +42,7 @@ class AccountImport < Import
def dry_run
{
accounts: rows.count
accounts: rows_count
}
end

View File

@@ -2,9 +2,16 @@ class AccountProvider < ApplicationRecord
belongs_to :account
belongs_to :provider, polymorphic: true
has_many :holdings, dependent: :nullify
validates :account_id, uniqueness: { scope: :provider_type }
validates :provider_id, uniqueness: { scope: :provider_type }
# When unlinking a CoinStats account, also destroy the CoinstatsAccount record
# so it doesn't remain orphaned and count as "needs setup".
# Other providers may legitimately enter a "needs setup" state.
after_destroy :destroy_coinstats_provider_account, if: :coinstats_provider?
# Returns the provider adapter for this connection
def adapter
Provider::Factory.create_adapter(provider, account: account)
@@ -15,4 +22,14 @@ class AccountProvider < ApplicationRecord
def provider_name
adapter&.provider_name || provider_type.underscore
end
private
def coinstats_provider?
provider_type == "CoinstatsAccount"
end
def destroy_coinstats_provider_account
provider&.destroy
end
end

View File

@@ -17,6 +17,7 @@ module Assistant::Configurable
[
Assistant::Function::GetTransactions,
Assistant::Function::GetAccounts,
Assistant::Function::GetHoldings,
Assistant::Function::GetBalanceSheet,
Assistant::Function::GetIncomeStatement
]

View File

@@ -0,0 +1,167 @@
class Assistant::Function::GetHoldings < Assistant::Function
include Pagy::Backend
SUPPORTED_ACCOUNT_TYPES = %w[Investment Crypto].freeze
class << self
def default_page_size
50
end
def name
"get_holdings"
end
def description
<<~INSTRUCTIONS
Use this to search user's investment holdings by using various optional filters.
This function is great for things like:
- Finding specific holdings or securities
- Getting portfolio composition and allocation
- Viewing investment performance and cost basis
Note: This function only returns holdings from Investment and Crypto accounts.
Note on pagination:
This function can be paginated. You can expect the following properties in the response:
- `total_pages`: The total number of pages of results
- `page`: The current page of results
- `page_size`: The number of results per page (this will always be #{default_page_size})
- `total_results`: The total number of results for the given filters
- `total_value`: The total value of all holdings for the given filters
Simple example (all current holdings):
```
get_holdings({
page: 1
})
```
More complex example (various filters):
```
get_holdings({
page: 1,
accounts: ["Brokerage Account"],
securities: ["AAPL", "GOOGL"]
})
```
INSTRUCTIONS
end
end
def strict_mode?
false
end
def params_schema
build_schema(
required: [ "page" ],
properties: {
page: {
type: "integer",
description: "Page number"
},
accounts: {
type: "array",
description: "Filter holdings by account name (only Investment and Crypto accounts are supported)",
items: { enum: investment_account_names },
minItems: 1,
uniqueItems: true
},
securities: {
type: "array",
description: "Filter holdings by security ticker symbol",
items: { enum: family_security_tickers },
minItems: 1,
uniqueItems: true
}
}
)
end
def call(params = {})
holdings_query = build_holdings_query(params)
pagy, paginated_holdings = pagy(
holdings_query.includes(:security, :account).order(amount: :desc),
page: params["page"] || 1,
limit: default_page_size
)
total_value = holdings_query.sum(:amount)
normalized_holdings = paginated_holdings.map do |holding|
{
ticker: holding.ticker,
name: holding.name,
quantity: holding.qty.to_f,
price: holding.price.to_f,
currency: holding.currency,
amount: holding.amount.to_f,
formatted_amount: holding.amount_money.format,
weight: holding.weight&.round(2),
average_cost: holding.avg_cost.to_f,
formatted_average_cost: holding.avg_cost.format,
account: holding.account.name,
date: holding.date
}
end
{
holdings: normalized_holdings,
total_results: pagy.count,
page: pagy.page,
page_size: default_page_size,
total_pages: pagy.pages,
total_value: Money.new(total_value, family.currency).format
}
end
private
def default_page_size
self.class.default_page_size
end
def build_holdings_query(params)
accounts = investment_accounts
if params["accounts"].present?
accounts = accounts.where(name: params["accounts"])
end
holdings = Holding.where(account: accounts)
.where(
id: Holding.where(account: accounts)
.select("DISTINCT ON (account_id, security_id) id")
.where.not(qty: 0)
.order(:account_id, :security_id, date: :desc)
)
if params["securities"].present?
security_ids = family.securities.where(ticker: params["securities"]).pluck(:id)
holdings = holdings.where(security_id: security_ids)
end
holdings
end
def investment_accounts
family.accounts.visible.where(accountable_type: SUPPORTED_ACCOUNT_TYPES)
end
def investment_account_names
@investment_account_names ||= investment_accounts.pluck(:name)
end
def family_security_tickers
@family_security_tickers ||= Security
.where(id: Holding.where(account_id: investment_accounts.select(:id)).select(:security_id))
.distinct
.pluck(:ticker)
end
end

View File

@@ -130,6 +130,7 @@ class Balance::ChartSeriesBuilder
b.flows_factor
FROM balances b
WHERE b.account_id = accounts.id
AND b.currency = accounts.currency
AND b.date <= d.date
ORDER BY b.date DESC
LIMIT 1

View File

@@ -1,5 +1,6 @@
class Category < ApplicationRecord
has_many :transactions, dependent: :nullify, class_name: "Transaction"
has_many :trades, dependent: :nullify
has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping"
belongs_to :family
@@ -30,10 +31,15 @@ class Category < ApplicationRecord
COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]
UNCATEGORIZED_COLOR = "#737373"
OTHER_INVESTMENTS_COLOR = "#e99537"
TRANSFER_COLOR = "#444CE7"
PAYMENT_COLOR = "#db5a54"
TRADE_COLOR = "#e99537"
# Synthetic category name keys for i18n
UNCATEGORIZED_NAME_KEY = "models.category.uncategorized"
OTHER_INVESTMENTS_NAME_KEY = "models.category.other_investments"
class Group
attr_reader :category, :subcategories
@@ -81,12 +87,30 @@ class Category < ApplicationRecord
def uncategorized
new(
name: "Uncategorized",
name: I18n.t(UNCATEGORIZED_NAME_KEY),
color: UNCATEGORIZED_COLOR,
lucide_icon: "circle-dashed"
)
end
def other_investments
new(
name: I18n.t(OTHER_INVESTMENTS_NAME_KEY),
color: OTHER_INVESTMENTS_COLOR,
lucide_icon: "trending-up"
)
end
# Helper to get the localized name for uncategorized
def uncategorized_name
I18n.t(UNCATEGORIZED_NAME_KEY)
end
# Helper to get the localized name for other investments
def other_investments_name
I18n.t(OTHER_INVESTMENTS_NAME_KEY)
end
private
def default_categories
[
@@ -110,7 +134,8 @@ class Category < ApplicationRecord
[ "Loan Payments", "#e11d48", "credit-card", "expense" ],
[ "Services", "#7c3aed", "briefcase", "expense" ],
[ "Fees", "#6b7280", "receipt", "expense" ],
[ "Savings & Investments", "#059669", "piggy-bank", "expense" ]
[ "Savings & Investments", "#059669", "piggy-bank", "expense" ],
[ "Investment Contributions", "#0d9488", "trending-up", "expense" ]
]
end
end
@@ -140,6 +165,21 @@ class Category < ApplicationRecord
subcategory? ? "#{parent.name} > #{name}" : name
end
# Predicate: is this the synthetic "Uncategorized" category?
def uncategorized?
!persisted? && name == I18n.t(UNCATEGORIZED_NAME_KEY)
end
# Predicate: is this the synthetic "Other Investments" category?
def other_investments?
!persisted? && name == I18n.t(OTHER_INVESTMENTS_NAME_KEY)
end
# Predicate: is this any synthetic (non-persisted) category?
def synthetic?
uncategorized? || other_investments?
end
private
def category_level_limit
if (subcategory? && parent.subcategory?) || (parent? && subcategory?)

View File

@@ -42,7 +42,7 @@ class CategoryImport < Import
end
def dry_run
{ categories: rows.count }
{ categories: rows_count }
end
def csv_template

View File

@@ -0,0 +1,71 @@
# Represents a single crypto token/coin within a CoinStats wallet.
# Each wallet address may have multiple CoinstatsAccounts (one per token).
class CoinstatsAccount < ApplicationRecord
include CurrencyNormalizable
belongs_to :coinstats_item
# Association through account_providers (standard pattern for all providers)
has_one :account_provider, as: :provider, dependent: :destroy
has_one :account, through: :account_provider, source: :account
validates :name, :currency, presence: true
validates :account_id, uniqueness: { scope: :coinstats_item_id, allow_nil: true }
# Alias for compatibility with provider adapter pattern
alias_method :current_account, :account
# Updates account with latest balance data from CoinStats API.
# @param account_snapshot [Hash] Normalized balance data from API
def upsert_coinstats_snapshot!(account_snapshot)
# Convert to symbol keys or handle both string and symbol keys
snapshot = account_snapshot.with_indifferent_access
# Build attributes to update
attrs = {
current_balance: snapshot[:balance] || snapshot[:current_balance],
currency: parse_currency(snapshot[:currency]) || "USD",
name: snapshot[:name],
account_status: snapshot[:status],
provider: snapshot[:provider],
institution_metadata: {
logo: snapshot[:institution_logo]
}.compact,
raw_payload: account_snapshot
}
# Only set account_id if provided and not already set (preserves ID from initial creation)
if snapshot[:id].present? && account_id.blank?
attrs[:account_id] = snapshot[:id].to_s
end
update!(attrs)
end
# Stores transaction data from CoinStats API for later processing.
# @param transactions_snapshot [Hash, Array] Raw transactions response or array
def upsert_coinstats_transactions_snapshot!(transactions_snapshot)
# CoinStats API returns: { meta: { page, limit }, result: [...] }
# Extract just the result array for storage, or use directly if already an array
transactions_array = if transactions_snapshot.is_a?(Hash)
snapshot = transactions_snapshot.with_indifferent_access
snapshot[:result] || []
elsif transactions_snapshot.is_a?(Array)
transactions_snapshot
else
[]
end
assign_attributes(
raw_transactions_payload: transactions_array
)
save!
end
private
def log_invalid_currency(currency_value)
Rails.logger.warn("Invalid currency code '#{currency_value}' for CoinstatsAccount #{id}, defaulting to USD")
end
end

View File

@@ -0,0 +1,68 @@
# Processes a CoinStats account to update balance and import transactions.
# Updates the linked Account balance and delegates to transaction processor.
class CoinstatsAccount::Processor
include CurrencyNormalizable
attr_reader :coinstats_account
# @param coinstats_account [CoinstatsAccount] Account to process
def initialize(coinstats_account)
@coinstats_account = coinstats_account
end
# Updates account balance and processes transactions.
# Skips processing if no linked account exists.
def process
unless coinstats_account.current_account.present?
Rails.logger.info "CoinstatsAccount::Processor - No linked account for coinstats_account #{coinstats_account.id}, skipping processing"
return
end
Rails.logger.info "CoinstatsAccount::Processor - Processing coinstats_account #{coinstats_account.id}"
begin
process_account!
rescue StandardError => e
Rails.logger.error "CoinstatsAccount::Processor - Failed to process account #{coinstats_account.id}: #{e.message}"
Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
report_exception(e, "account")
raise
end
process_transactions
end
private
# Updates the linked Account with current balance from CoinStats.
def process_account!
account = coinstats_account.current_account
balance = coinstats_account.current_balance || 0
currency = parse_currency(coinstats_account.currency) || account.currency || "USD"
account.update!(
balance: balance,
cash_balance: balance,
currency: currency
)
end
# Delegates transaction processing to the specialized processor.
def process_transactions
CoinstatsAccount::Transactions::Processor.new(coinstats_account).process
rescue StandardError => e
report_exception(e, "transactions")
end
# Reports errors to Sentry with context tags.
# @param error [Exception] The error to report
# @param context [String] Processing context (e.g., "account", "transactions")
def report_exception(error, context)
Sentry.capture_exception(error) do |scope|
scope.set_tags(
coinstats_account_id: coinstats_account.id,
context: context
)
end
end
end

View File

@@ -0,0 +1,138 @@
# Processes stored transactions for a CoinStats account.
# Filters transactions by token and delegates to entry processor.
class CoinstatsAccount::Transactions::Processor
include CoinstatsTransactionIdentifiable
attr_reader :coinstats_account
# @param coinstats_account [CoinstatsAccount] Account with transactions to process
def initialize(coinstats_account)
@coinstats_account = coinstats_account
end
# Processes all stored transactions for this account.
# Filters to relevant token and imports each transaction.
# @return [Hash] Result with :success, :total, :imported, :failed, :errors
def process
unless coinstats_account.raw_transactions_payload.present?
Rails.logger.info "CoinstatsAccount::Transactions::Processor - No transactions in raw_transactions_payload for coinstats_account #{coinstats_account.id}"
return { success: true, total: 0, imported: 0, failed: 0, errors: [] }
end
# Filter transactions to only include ones for this specific token
# Multiple coinstats_accounts can share the same wallet address (one per token)
# but we only want to process transactions relevant to this token
relevant_transactions = filter_transactions_for_account(coinstats_account.raw_transactions_payload)
total_count = relevant_transactions.count
Rails.logger.info "CoinstatsAccount::Transactions::Processor - Processing #{total_count} transactions for coinstats_account #{coinstats_account.id} (#{coinstats_account.name})"
imported_count = 0
failed_count = 0
errors = []
relevant_transactions.each_with_index do |transaction_data, index|
begin
result = CoinstatsEntry::Processor.new(
transaction_data,
coinstats_account: coinstats_account
).process
if result.nil?
failed_count += 1
transaction_id = extract_coinstats_transaction_id(transaction_data)
errors << { index: index, transaction_id: transaction_id, error: "No linked account" }
else
imported_count += 1
end
rescue ArgumentError => e
failed_count += 1
transaction_id = extract_coinstats_transaction_id(transaction_data)
error_message = "Validation error: #{e.message}"
Rails.logger.error "CoinstatsAccount::Transactions::Processor - #{error_message} (transaction #{transaction_id})"
errors << { index: index, transaction_id: transaction_id, error: error_message }
rescue => e
failed_count += 1
transaction_id = extract_coinstats_transaction_id(transaction_data)
error_message = "#{e.class}: #{e.message}"
Rails.logger.error "CoinstatsAccount::Transactions::Processor - Error processing transaction #{transaction_id}: #{error_message}"
Rails.logger.error e.backtrace.join("\n")
errors << { index: index, transaction_id: transaction_id, error: error_message }
end
end
result = {
success: failed_count == 0,
total: total_count,
imported: imported_count,
failed: failed_count,
errors: errors
}
if failed_count > 0
Rails.logger.warn "CoinstatsAccount::Transactions::Processor - Completed with #{failed_count} failures out of #{total_count} transactions"
else
Rails.logger.info "CoinstatsAccount::Transactions::Processor - Successfully processed #{imported_count} transactions"
end
result
end
private
# Filters transactions to only include ones for this specific token.
# CoinStats returns all wallet transactions, but each CoinstatsAccount
# represents a single token, so we filter by matching coin ID or symbol.
# @param transactions [Array<Hash>] Raw transactions from storage
# @return [Array<Hash>] Transactions matching this account's token
def filter_transactions_for_account(transactions)
return [] unless transactions.present?
return transactions unless coinstats_account.account_id.present?
account_id = coinstats_account.account_id.to_s.downcase
transactions.select do |tx|
tx = tx.with_indifferent_access
# Check coin ID in transactions[0].items[0].coin.id (most common location)
coin_id = tx.dig(:transactions, 0, :items, 0, :coin, :id)&.to_s&.downcase
# Also check coinData for symbol match as fallback
coin_symbol = tx.dig(:coinData, :symbol)&.to_s&.downcase
# Match if coin ID equals account_id, or if symbol matches account name precisely.
# We use strict matching to avoid false positives (e.g., "ETH" should not match
# "Ethereum Classic" which has symbol "ETC"). The symbol must appear as:
# - A whole word (bounded by word boundaries), OR
# - Inside parentheses like "(ETH)" which is common in wallet naming conventions
coin_id == account_id ||
(coin_symbol.present? && symbol_matches_name?(coin_symbol, coinstats_account.name))
end
end
# Checks if a coin symbol matches the account name using strict matching.
# Avoids false positives from partial substring matches (e.g., "ETH" matching
# "Ethereum Classic (0x123...)" which should only match "ETC").
#
# @param symbol [String] The coin symbol to match (already downcased)
# @param name [String, nil] The account name to match against
# @return [Boolean] true if symbol matches name precisely
def symbol_matches_name?(symbol, name)
return false if name.blank?
normalized_name = name.to_s.downcase
# Match symbol as a whole word using word boundaries, or within parentheses.
# Examples that SHOULD match:
# - "ETH" matches "ETH Wallet", "My ETH", "Ethereum (ETH)"
# - "BTC" matches "BTC", "(BTC) Savings", "Bitcoin (BTC)"
# Examples that should NOT match:
# - "ETH" should NOT match "Ethereum Classic" (symbol is "ETC")
# - "ETH" should NOT match "WETH Wrapped" (different token)
# - "BTC" should NOT match "BTCB" (different token)
word_boundary_pattern = /\b#{Regexp.escape(symbol)}\b/
parenthesized_pattern = /\(#{Regexp.escape(symbol)}\)/
word_boundary_pattern.match?(normalized_name) || parenthesized_pattern.match?(normalized_name)
end
end

View File

@@ -0,0 +1,270 @@
# Processes a single CoinStats transaction into a local Transaction record.
# Extracts amount, date, and metadata from the CoinStats API format.
#
# CoinStats API transaction structure (from /wallet/transactions endpoint):
# {
# type: "Sent" | "Received" | "Swap" | ...,
# date: "2025-06-07T11:58:11.000Z",
# coinData: { count: -0.00636637, symbol: "ETH", currentValue: 29.21 },
# profitLoss: { profit: -13.41, profitPercent: -84.44, currentValue: 29.21 },
# hash: { id: "0x...", explorerUrl: "https://etherscan.io/tx/0x..." },
# fee: { coin: { id, name, symbol, icon }, count: 0.00003, totalWorth: 0.08 },
# transactions: [{ action: "Sent", items: [{ id, count, totalWorth, coin: {...} }] }]
# }
class CoinstatsEntry::Processor
include CoinstatsTransactionIdentifiable
# @param coinstats_transaction [Hash] Raw transaction data from API
# @param coinstats_account [CoinstatsAccount] Parent account for context
def initialize(coinstats_transaction, coinstats_account:)
@coinstats_transaction = coinstats_transaction
@coinstats_account = coinstats_account
end
# Imports the transaction into the linked account.
# @return [Transaction, nil] Created transaction or nil if no linked account
# @raise [ArgumentError] If transaction data is invalid
# @raise [StandardError] If import fails
def process
unless account.present?
Rails.logger.warn "CoinstatsEntry::Processor - No linked account for coinstats_account #{coinstats_account.id}, skipping transaction #{external_id}"
return nil
end
import_adapter.import_transaction(
external_id: external_id,
amount: amount,
currency: currency,
date: date,
name: name,
source: "coinstats",
merchant: merchant,
notes: notes,
extra: extra_metadata
)
rescue ArgumentError => e
Rails.logger.error "CoinstatsEntry::Processor - Validation error for transaction #{external_id rescue 'unknown'}: #{e.message}"
raise
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e
Rails.logger.error "CoinstatsEntry::Processor - Failed to save transaction #{external_id rescue 'unknown'}: #{e.message}"
raise StandardError.new("Failed to import transaction: #{e.message}")
rescue => e
Rails.logger.error "CoinstatsEntry::Processor - Unexpected error processing transaction #{external_id rescue 'unknown'}: #{e.class} - #{e.message}"
Rails.logger.error e.backtrace.join("\n")
raise StandardError.new("Unexpected error importing transaction: #{e.message}")
end
private
attr_reader :coinstats_transaction, :coinstats_account
def extra_metadata
cs = {}
# Store transaction hash and explorer URL
if hash_data.present?
cs["transaction_hash"] = hash_data[:id] if hash_data[:id].present?
cs["explorer_url"] = hash_data[:explorerUrl] if hash_data[:explorerUrl].present?
end
# Store transaction type
cs["transaction_type"] = transaction_type if transaction_type.present?
# Store coin/token info
if coin_data.present?
cs["symbol"] = coin_data[:symbol] if coin_data[:symbol].present?
cs["count"] = coin_data[:count] if coin_data[:count].present?
end
# Store profit/loss info
if profit_loss.present?
cs["profit"] = profit_loss[:profit] if profit_loss[:profit].present?
cs["profit_percent"] = profit_loss[:profitPercent] if profit_loss[:profitPercent].present?
end
# Store fee info
if fee_data.present?
cs["fee_amount"] = fee_data[:count] if fee_data[:count].present?
cs["fee_symbol"] = fee_data.dig(:coin, :symbol) if fee_data.dig(:coin, :symbol).present?
cs["fee_usd"] = fee_data[:totalWorth] if fee_data[:totalWorth].present?
end
return nil if cs.empty?
{ "coinstats" => cs }
end
def import_adapter
@import_adapter ||= Account::ProviderImportAdapter.new(account)
end
def account
coinstats_account.current_account
end
def data
@data ||= coinstats_transaction.with_indifferent_access
end
# Helper accessors for nested data structures
def hash_data
@hash_data ||= (data[:hash] || {}).with_indifferent_access
end
def coin_data
@coin_data ||= (data[:coinData] || {}).with_indifferent_access
end
def profit_loss
@profit_loss ||= (data[:profitLoss] || {}).with_indifferent_access
end
def fee_data
@fee_data ||= (data[:fee] || {}).with_indifferent_access
end
def transactions_data
@transactions_data ||= data[:transactions] || []
end
def transaction_type
data[:type]
end
def external_id
tx_id = extract_coinstats_transaction_id(data)
raise ArgumentError, "CoinStats transaction missing unique identifier: #{data.inspect}" unless tx_id.present?
"coinstats_#{tx_id}"
end
def name
tx_type = transaction_type || "Transaction"
symbol = coin_data[:symbol]
# Get coin name from nested transaction items if available (used as fallback)
coin_name = transactions_data.dig(0, :items, 0, :coin, :name)
if symbol.present?
"#{tx_type} #{symbol}"
elsif coin_name.present?
"#{tx_type} #{coin_name}"
else
tx_type.to_s
end
end
def amount
# Use currentValue from coinData (USD value) or profitLoss
usd_value = coin_data[:currentValue] || profit_loss[:currentValue] || 0
parsed_amount = case usd_value
when String
BigDecimal(usd_value)
when Numeric
BigDecimal(usd_value.to_s)
else
BigDecimal("0")
end
absolute_amount = parsed_amount.abs
# App convention: negative amount = income (inflow), positive amount = expense (outflow)
# coinData.count is negative for outgoing transactions
coin_count = coin_data[:count] || 0
if coin_count.to_f < 0 || outgoing_transaction_type?
# Outgoing transaction = expense = positive
absolute_amount
else
# Incoming transaction = income = negative
-absolute_amount
end
rescue ArgumentError => e
Rails.logger.error "Failed to parse CoinStats transaction amount: #{usd_value.inspect} - #{e.message}"
raise
end
def outgoing_transaction_type?
tx_type = (transaction_type || "").to_s.downcase
%w[sent send sell withdraw transfer_out swap_out].include?(tx_type)
end
def currency
# CoinStats values are always in USD
"USD"
end
def date
# CoinStats returns date as ISO 8601 string (e.g., "2025-06-07T11:58:11.000Z")
timestamp = data[:date]
raise ArgumentError, "CoinStats transaction missing date" unless timestamp.present?
case timestamp
when Integer, Float
Time.at(timestamp).to_date
when String
Time.parse(timestamp).to_date
when Time, DateTime
timestamp.to_date
when Date
timestamp
else
Rails.logger.error("CoinStats transaction has invalid date format: #{timestamp.inspect}")
raise ArgumentError, "Invalid date format: #{timestamp.inspect}"
end
rescue ArgumentError, TypeError => e
Rails.logger.error("CoinStats transaction date parsing failed: #{e.message}")
raise ArgumentError, "Invalid date format: #{timestamp.inspect}"
end
def merchant
# Use the coinstats_account as the merchant source for consistency
# All transactions from the same account will have the same merchant and logo
merchant_name = coinstats_account.name
return nil unless merchant_name.present?
# Use the account's logo (token icon) for the merchant
logo = coinstats_account.institution_metadata&.dig("logo")
# Use the coinstats_account ID to ensure consistent merchant per account
@merchant ||= import_adapter.find_or_create_merchant(
provider_merchant_id: "coinstats_account_#{coinstats_account.id}",
name: merchant_name,
source: "coinstats",
logo_url: logo
)
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error "CoinstatsEntry::Processor - Failed to create merchant '#{merchant_name}': #{e.message}"
nil
end
def notes
parts = []
# Include coin/token details with count
symbol = coin_data[:symbol]
count = coin_data[:count]
if count.present? && symbol.present?
parts << "#{count} #{symbol}"
end
# Include fee info
if fee_data[:count].present? && fee_data.dig(:coin, :symbol).present?
parts << "Fee: #{fee_data[:count]} #{fee_data.dig(:coin, :symbol)}"
end
# Include profit/loss info
if profit_loss[:profit].present?
profit_formatted = profit_loss[:profit].to_f.round(2)
percent_formatted = profit_loss[:profitPercent].to_f.round(2)
parts << "P/L: $#{profit_formatted} (#{percent_formatted}%)"
end
# Include explorer URL for reference
if hash_data[:explorerUrl].present?
parts << "Explorer: #{hash_data[:explorerUrl]}"
end
parts.presence&.join(" | ")
end
end

View File

@@ -0,0 +1,150 @@
# Represents a CoinStats API connection for a family.
# Stores credentials and manages associated crypto wallet accounts.
class CoinstatsItem < ApplicationRecord
include Syncable, Provided, Unlinking
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
# Checks if ActiveRecord Encryption is properly configured.
# @return [Boolean] true if encryption keys are available
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
encrypts :api_key, deterministic: true if encryption_ready?
validates :name, presence: true
validates :api_key, presence: true
belongs_to :family
has_one_attached :logo
has_many :coinstats_accounts, dependent: :destroy
has_many :accounts, through: :coinstats_accounts
scope :active, -> { where(scheduled_for_deletion: false) }
scope :ordered, -> { order(created_at: :desc) }
scope :needs_update, -> { where(status: :requires_update) }
# Schedules this item for async deletion.
def destroy_later
update!(scheduled_for_deletion: true)
DestroyJob.perform_later(self)
end
# Fetches latest wallet data from CoinStats API and updates local records.
# @raise [StandardError] if provider is not configured or import fails
def import_latest_coinstats_data
provider = coinstats_provider
unless provider
Rails.logger.error "CoinstatsItem #{id} - Cannot import: CoinStats provider is not configured"
raise StandardError.new("CoinStats provider is not configured")
end
CoinstatsItem::Importer.new(self, coinstats_provider: provider).import
rescue => e
Rails.logger.error "CoinstatsItem #{id} - Failed to import data: #{e.message}"
raise
end
# Processes holdings for all linked visible accounts.
# @return [Array<Hash>] Results with success status per account
def process_accounts
return [] if coinstats_accounts.empty?
results = []
coinstats_accounts.includes(:account).joins(:account).merge(Account.visible).each do |coinstats_account|
begin
result = CoinstatsAccount::Processor.new(coinstats_account).process
results << { coinstats_account_id: coinstats_account.id, success: true, result: result }
rescue => e
Rails.logger.error "CoinstatsItem #{id} - Failed to process account #{coinstats_account.id}: #{e.message}"
results << { coinstats_account_id: coinstats_account.id, success: false, error: e.message }
end
end
results
end
# Queues balance sync jobs for all visible accounts.
# @param parent_sync [Sync, nil] Parent sync for tracking
# @param window_start_date [Date, nil] Start of sync window
# @param window_end_date [Date, nil] End of sync window
# @return [Array<Hash>] Results with success status per account
def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil)
return [] if accounts.empty?
results = []
accounts.visible.each do |account|
begin
account.sync_later(
parent_sync: parent_sync,
window_start_date: window_start_date,
window_end_date: window_end_date
)
results << { account_id: account.id, success: true }
rescue => e
Rails.logger.error "CoinstatsItem #{id} - Failed to schedule sync for wallet #{account.id}: #{e.message}"
results << { account_id: account.id, success: false, error: e.message }
end
end
results
end
# Persists raw API response for debugging and reprocessing.
# @param accounts_snapshot [Hash] Raw API response data
def upsert_coinstats_snapshot!(accounts_snapshot)
assign_attributes(raw_payload: accounts_snapshot)
save!
end
# @return [Boolean] true if at least one account has been linked
def has_completed_initial_setup?
accounts.any?
end
# @return [String] Human-readable summary of sync status
def sync_status_summary
total_accounts = total_accounts_count
linked_count = linked_accounts_count
unlinked_count = unlinked_accounts_count
if total_accounts == 0
I18n.t("coinstats_items.coinstats_item.sync_status.no_accounts")
elsif unlinked_count == 0
I18n.t("coinstats_items.coinstats_item.sync_status.all_synced", count: linked_count)
else
I18n.t("coinstats_items.coinstats_item.sync_status.partial_sync", linked_count: linked_count, unlinked_count: unlinked_count)
end
end
# @return [Integer] Number of accounts with provider links
def linked_accounts_count
coinstats_accounts.joins(:account_provider).count
end
# @return [Integer] Number of accounts without provider links
def unlinked_accounts_count
coinstats_accounts.left_joins(:account_provider).where(account_providers: { id: nil }).count
end
# @return [Integer] Total number of coinstats accounts
def total_accounts_count
coinstats_accounts.count
end
# @return [String] Display name for the CoinStats connection
def institution_display_name
name.presence || "CoinStats"
end
# @return [Boolean] true if API key is set
def credentials_configured?
api_key.present?
end
end

View File

@@ -0,0 +1,315 @@
# Imports wallet data from CoinStats API for linked accounts.
# Fetches balances and transactions, then updates local records.
class CoinstatsItem::Importer
include CoinstatsTransactionIdentifiable
attr_reader :coinstats_item, :coinstats_provider
# @param coinstats_item [CoinstatsItem] Item containing accounts to import
# @param coinstats_provider [Provider::Coinstats] API client instance
def initialize(coinstats_item, coinstats_provider:)
@coinstats_item = coinstats_item
@coinstats_provider = coinstats_provider
end
# Imports balance and transaction data for all linked accounts.
# @return [Hash] Result with :success, :accounts_updated, :transactions_imported
def import
Rails.logger.info "CoinstatsItem::Importer - Starting import for item #{coinstats_item.id}"
# CoinStats works differently from bank providers - wallets are added manually
# via the setup_accounts flow. During sync, we just update existing linked accounts.
# Get all linked coinstats accounts (ones with account_provider associations)
linked_accounts = coinstats_item.coinstats_accounts
.joins(:account_provider)
.includes(:account)
if linked_accounts.empty?
Rails.logger.info "CoinstatsItem::Importer - No linked accounts to sync for item #{coinstats_item.id}"
return { success: true, accounts_updated: 0, transactions_imported: 0 }
end
accounts_updated = 0
accounts_failed = 0
transactions_imported = 0
# Fetch balance data using bulk endpoint
bulk_balance_data = fetch_balances_for_accounts(linked_accounts)
# Fetch transaction data using bulk endpoint
bulk_transactions_data = fetch_transactions_for_accounts(linked_accounts)
linked_accounts.each do |coinstats_account|
begin
result = update_account(coinstats_account, bulk_balance_data: bulk_balance_data, bulk_transactions_data: bulk_transactions_data)
accounts_updated += 1 if result[:success]
transactions_imported += result[:transactions_count] || 0
rescue => e
accounts_failed += 1
Rails.logger.error "CoinstatsItem::Importer - Failed to update account #{coinstats_account.id}: #{e.message}"
end
end
Rails.logger.info "CoinstatsItem::Importer - Updated #{accounts_updated} accounts (#{accounts_failed} failed), #{transactions_imported} transactions"
{
success: accounts_failed == 0,
accounts_updated: accounts_updated,
accounts_failed: accounts_failed,
transactions_imported: transactions_imported
}
end
private
# Fetch balance data for all linked accounts using the bulk endpoint
# @param linked_accounts [Array<CoinstatsAccount>] Accounts to fetch balances for
# @return [Array<Hash>, nil] Bulk balance data, or nil on error
def fetch_balances_for_accounts(linked_accounts)
# Extract unique wallet addresses and blockchains
wallets = linked_accounts.filter_map do |account|
raw = account.raw_payload || {}
address = raw["address"] || raw[:address]
blockchain = raw["blockchain"] || raw[:blockchain]
next unless address.present? && blockchain.present?
{ address: address, blockchain: blockchain }
end.uniq { |w| [ w[:address].downcase, w[:blockchain].downcase ] }
return nil if wallets.empty?
Rails.logger.info "CoinstatsItem::Importer - Fetching balances for #{wallets.size} wallet(s) via bulk endpoint"
# Build comma-separated string in format "blockchain:address"
wallets_param = wallets.map { |w| "#{w[:blockchain]}:#{w[:address]}" }.join(",")
response = coinstats_provider.get_wallet_balances(wallets_param)
response.success? ? response.data : nil
rescue => e
Rails.logger.warn "CoinstatsItem::Importer - Bulk balance fetch failed: #{e.message}"
nil
end
# Fetch transaction data for all linked accounts using the bulk endpoint
# @param linked_accounts [Array<CoinstatsAccount>] Accounts to fetch transactions for
# @return [Array<Hash>, nil] Bulk transaction data, or nil on error
def fetch_transactions_for_accounts(linked_accounts)
# Extract unique wallet addresses and blockchains
wallets = linked_accounts.filter_map do |account|
raw = account.raw_payload || {}
address = raw["address"] || raw[:address]
blockchain = raw["blockchain"] || raw[:blockchain]
next unless address.present? && blockchain.present?
{ address: address, blockchain: blockchain }
end.uniq { |w| [ w[:address].downcase, w[:blockchain].downcase ] }
return nil if wallets.empty?
Rails.logger.info "CoinstatsItem::Importer - Fetching transactions for #{wallets.size} wallet(s) via bulk endpoint"
# Build comma-separated string in format "blockchain:address"
wallets_param = wallets.map { |w| "#{w[:blockchain]}:#{w[:address]}" }.join(",")
response = coinstats_provider.get_wallet_transactions(wallets_param)
response.success? ? response.data : nil
rescue => e
Rails.logger.warn "CoinstatsItem::Importer - Bulk transaction fetch failed: #{e.message}"
nil
end
# Updates a single account with balance and transaction data.
# @param coinstats_account [CoinstatsAccount] Account to update
# @param bulk_balance_data [Array, nil] Pre-fetched balance data
# @param bulk_transactions_data [Array, nil] Pre-fetched transaction data
# @return [Hash] Result with :success and :transactions_count
def update_account(coinstats_account, bulk_balance_data:, bulk_transactions_data:)
# Get the wallet address and blockchain from the raw payload
raw = coinstats_account.raw_payload || {}
address = raw["address"] || raw[:address]
blockchain = raw["blockchain"] || raw[:blockchain]
unless address.present? && blockchain.present?
Rails.logger.warn "CoinstatsItem::Importer - Missing address or blockchain for account #{coinstats_account.id}. Address: #{address.inspect}, Blockchain: #{blockchain.inspect}"
return { success: false, error: "Missing address or blockchain" }
end
# Extract balance data for this specific wallet from the bulk response
balance_data = if bulk_balance_data.present?
coinstats_provider.extract_wallet_balance(bulk_balance_data, address, blockchain)
else
[]
end
# Update the coinstats account with new balance data
coinstats_account.upsert_coinstats_snapshot!(normalize_balance_data(balance_data, coinstats_account))
# Extract and merge transactions from bulk response
transactions_count = fetch_and_merge_transactions(coinstats_account, address, blockchain, bulk_transactions_data)
{ success: true, transactions_count: transactions_count }
end
# Extracts and merges new transactions for an account.
# Deduplicates by transaction ID to avoid duplicate imports.
# @param coinstats_account [CoinstatsAccount] Account to update
# @param address [String] Wallet address
# @param blockchain [String] Blockchain identifier
# @param bulk_transactions_data [Array, nil] Pre-fetched transaction data
# @return [Integer] Number of relevant transactions found
def fetch_and_merge_transactions(coinstats_account, address, blockchain, bulk_transactions_data)
# Extract transactions for this specific wallet from the bulk response
transactions_data = if bulk_transactions_data.present?
coinstats_provider.extract_wallet_transactions(bulk_transactions_data, address, blockchain)
else
[]
end
new_transactions = transactions_data.is_a?(Array) ? transactions_data : (transactions_data[:result] || [])
return 0 if new_transactions.empty?
# Filter transactions to only include those relevant to this coin/token
coin_id = coinstats_account.account_id
relevant_transactions = filter_transactions_by_coin(new_transactions, coin_id)
return 0 if relevant_transactions.empty?
# Get existing transactions (already extracted as array)
existing_transactions = coinstats_account.raw_transactions_payload.to_a
# Build a set of existing transaction IDs to avoid duplicates
existing_ids = existing_transactions.map { |tx| extract_coinstats_transaction_id(tx) }.compact.to_set
# Filter to only new transactions
transactions_to_add = relevant_transactions.select do |tx|
tx_id = extract_coinstats_transaction_id(tx)
tx_id.present? && !existing_ids.include?(tx_id)
end
if transactions_to_add.any?
# Merge new transactions with existing ones
merged_transactions = existing_transactions + transactions_to_add
coinstats_account.upsert_coinstats_transactions_snapshot!(merged_transactions)
Rails.logger.info "CoinstatsItem::Importer - Added #{transactions_to_add.count} new transactions for account #{coinstats_account.id}"
end
relevant_transactions.count
end
# Filter transactions to only include those relevant to a specific coin
# Transactions can be matched by:
# - coinData.symbol matching the coin (case-insensitive)
# - transactions[].items[].coin.id matching the coin_id
# @param transactions [Array<Hash>] Array of transaction objects
# @param coin_id [String] The coin ID to filter by (e.g., "chainlink", "ethereum")
# @return [Array<Hash>] Filtered transactions
def filter_transactions_by_coin(transactions, coin_id)
return [] if coin_id.blank?
coin_id_downcase = coin_id.to_s.downcase
transactions.select do |tx|
tx = tx.with_indifferent_access
# Check nested transactions items for coin match
inner_transactions = tx[:transactions] || []
inner_transactions.any? do |inner_tx|
inner_tx = inner_tx.with_indifferent_access
items = inner_tx[:items] || []
items.any? do |item|
item = item.with_indifferent_access
coin = item[:coin]
next false unless coin.present?
coin = coin.with_indifferent_access
coin[:id]&.downcase == coin_id_downcase
end
end
end
end
# Normalizes API balance data to a consistent schema for storage.
# @param balance_data [Array<Hash>] Raw token balances from API
# @param coinstats_account [CoinstatsAccount] Account for context
# @return [Hash] Normalized snapshot with id, balance, address, etc.
def normalize_balance_data(balance_data, coinstats_account)
# CoinStats get_wallet_balance returns an array of token balances directly
# Normalize it to match our expected schema
# Preserve existing address/blockchain from raw_payload
existing_raw = coinstats_account.raw_payload || {}
# Find the matching token for this account to extract id, logo, and balance
matching_token = find_matching_token(balance_data, coinstats_account)
# Calculate balance from the matching token only, not all tokens
# Each coinstats_account represents a single token/coin in the wallet
token_balance = calculate_token_balance(matching_token)
{
# Use existing account_id if set, otherwise extract from matching token
id: coinstats_account.account_id.presence || matching_token&.dig(:coinId) || matching_token&.dig(:id),
name: coinstats_account.name,
balance: token_balance,
currency: "USD", # CoinStats returns values in USD
address: existing_raw["address"] || existing_raw[:address],
blockchain: existing_raw["blockchain"] || existing_raw[:blockchain],
# Extract logo from the matching token
institution_logo: matching_token&.dig(:imgUrl),
# Preserve original data
raw_balance_data: balance_data
}
end
# Finds the token in balance_data that matches this account.
# Matches by account_id (coinId) first, then falls back to name.
# @param balance_data [Array<Hash>] Token balances from API
# @param coinstats_account [CoinstatsAccount] Account to match
# @return [Hash, nil] Matching token data or nil
def find_matching_token(balance_data, coinstats_account)
tokens = normalize_tokens(balance_data).map(&:with_indifferent_access)
return nil if tokens.empty?
# First try to match by account_id (coinId) if available
if coinstats_account.account_id.present?
account_id = coinstats_account.account_id.to_s
matching = tokens.find do |token|
token_id = (token[:coinId] || token[:id])&.to_s
token_id == account_id
end
return matching if matching
end
# Fall back to matching by name (handles legacy accounts without account_id)
account_name = coinstats_account.name&.downcase
return nil if account_name.blank?
tokens.find do |token|
token_name = token[:name]&.to_s&.downcase
token_symbol = token[:symbol]&.to_s&.downcase
# Match if account name contains the token name or symbol, or vice versa
account_name.include?(token_name) || token_name.include?(account_name) ||
(token_symbol.present? && (account_name.include?(token_symbol) || token_symbol == account_name))
end
end
# Normalizes various response formats to an array of tokens.
# @param balance_data [Array, Hash, nil] Raw balance response
# @return [Array<Hash>] Array of token hashes
def normalize_tokens(balance_data)
if balance_data.is_a?(Array)
balance_data
elsif balance_data.is_a?(Hash)
balance_data[:result] || balance_data[:tokens] || []
else
[]
end
end
# Calculates USD balance from token amount and price.
# @param token [Hash, nil] Token with :amount/:balance and :price/:priceUsd
# @return [Float] Balance in USD (0 if token is nil)
def calculate_token_balance(token)
return 0 if token.blank?
amount = token[:amount] || token[:balance] || 0
price = token[:price] || token[:priceUsd] || 0
(amount.to_f * price.to_f)
end
end

View File

@@ -0,0 +1,9 @@
module CoinstatsItem::Provided
extend ActiveSupport::Concern
def coinstats_provider
return nil unless credentials_configured?
Provider::Coinstats.new(api_key)
end
end

View File

@@ -0,0 +1,29 @@
# Broadcasts Turbo Stream updates when a CoinStats sync completes.
# Updates account views and notifies the family of sync completion.
class CoinstatsItem::SyncCompleteEvent
attr_reader :coinstats_item
# @param coinstats_item [CoinstatsItem] The item that completed syncing
def initialize(coinstats_item)
@coinstats_item = coinstats_item
end
# Broadcasts sync completion to update UI components.
def broadcast
# Update UI with latest account data
coinstats_item.accounts.each do |account|
account.broadcast_sync_complete
end
# Update the CoinStats item view
coinstats_item.broadcast_replace_to(
coinstats_item.family,
target: "coinstats_item_#{coinstats_item.id}",
partial: "coinstats_items/coinstats_item",
locals: { coinstats_item: coinstats_item }
)
# Let family handle sync notifications
coinstats_item.family.broadcast_sync_complete
end
end

View File

@@ -0,0 +1,61 @@
# Orchestrates the sync process for a CoinStats connection.
# Imports data, processes holdings, and schedules account syncs.
class CoinstatsItem::Syncer
attr_reader :coinstats_item
# @param coinstats_item [CoinstatsItem] Item to sync
def initialize(coinstats_item)
@coinstats_item = coinstats_item
end
# Runs the full sync workflow: import, process, and schedule.
# @param sync [Sync] Sync record for status tracking
def perform_sync(sync)
# Phase 1: Import data from CoinStats API
sync.update!(status_text: "Importing wallets from CoinStats...") if sync.respond_to?(:status_text)
coinstats_item.import_latest_coinstats_data
# Phase 2: Check account setup status and collect sync statistics
sync.update!(status_text: "Checking wallet configuration...") if sync.respond_to?(:status_text)
total_accounts = coinstats_item.coinstats_accounts.count
linked_accounts = coinstats_item.coinstats_accounts.joins(:account_provider).joins(:account).merge(Account.visible)
unlinked_accounts = coinstats_item.coinstats_accounts.left_joins(:account_provider).where(account_providers: { id: nil })
sync_stats = {
total_accounts: total_accounts,
linked_accounts: linked_accounts.count,
unlinked_accounts: unlinked_accounts.count
}
if unlinked_accounts.any?
coinstats_item.update!(pending_account_setup: true)
sync.update!(status_text: "#{unlinked_accounts.count} wallets need setup...") if sync.respond_to?(:status_text)
else
coinstats_item.update!(pending_account_setup: false)
end
# Phase 3: Process holdings for linked accounts only
if linked_accounts.any?
sync.update!(status_text: "Processing holdings...") if sync.respond_to?(:status_text)
coinstats_item.process_accounts
# Phase 4: Schedule balance calculations for linked accounts
sync.update!(status_text: "Calculating balances...") if sync.respond_to?(:status_text)
coinstats_item.schedule_account_syncs(
parent_sync: sync,
window_start_date: sync.window_start_date,
window_end_date: sync.window_end_date
)
end
if sync.respond_to?(:sync_stats)
sync.update!(sync_stats: sync_stats)
end
end
# Hook called after sync completion. Currently a no-op.
def perform_post_sync
# no-op
end
end

View File

@@ -0,0 +1,50 @@
# frozen_string_literal: true
# Provides unlinking functionality for CoinStats items.
# Allows disconnecting provider accounts while preserving account data.
module CoinstatsItem::Unlinking
extend ActiveSupport::Concern
# Removes all connections between this item and local accounts.
# Detaches AccountProvider links and nullifies associated Holdings.
# @param dry_run [Boolean] If true, returns results without making changes
# @return [Array<Hash>] Results per account with :provider_account_id, :name, :provider_link_ids
def unlink_all!(dry_run: false)
results = []
coinstats_accounts.find_each do |provider_account|
links = AccountProvider.where(provider_type: CoinstatsAccount.name, provider_id: provider_account.id).to_a
link_ids = links.map(&:id)
result = {
provider_account_id: provider_account.id,
name: provider_account.name,
provider_link_ids: link_ids
}
results << result
next if dry_run
begin
ActiveRecord::Base.transaction do
# Detach holdings for any provider links found
if link_ids.any?
Holding.where(account_provider_id: link_ids).update_all(account_provider_id: nil)
end
# Destroy all provider links
links.each do |ap|
ap.destroy!
end
end
rescue StandardError => e
Rails.logger.warn(
"CoinstatsItem Unlinker: failed to fully unlink provider account ##{provider_account.id} (links=#{link_ids.inspect}): #{e.class} - #{e.message}"
)
# Record error for observability; continue with other accounts
result[:error] = e.message
end
end
results
end
end

View File

@@ -0,0 +1,157 @@
# frozen_string_literal: true
# Links a cryptocurrency wallet to CoinStats by fetching token balances
# and creating corresponding accounts for each token found.
class CoinstatsItem::WalletLinker
attr_reader :coinstats_item, :address, :blockchain
Result = Struct.new(:success?, :created_count, :errors, keyword_init: true)
# @param coinstats_item [CoinstatsItem] Parent item with API credentials
# @param address [String] Wallet address to link
# @param blockchain [String] Blockchain network identifier
def initialize(coinstats_item, address:, blockchain:)
@coinstats_item = coinstats_item
@address = address
@blockchain = blockchain
end
# Fetches wallet balances and creates accounts for each token.
# @return [Result] Success status, created count, and any errors
def link
balance_data = fetch_balance_data
tokens = normalize_tokens(balance_data)
return Result.new(success?: false, created_count: 0, errors: [ "No tokens found for wallet" ]) if tokens.empty?
created_count = 0
errors = []
tokens.each do |token_data|
result = create_account_from_token(token_data)
if result[:success]
created_count += 1
else
errors << result[:error]
end
end
# Trigger a sync if we created any accounts
coinstats_item.sync_later if created_count > 0
Result.new(success?: created_count > 0, created_count: created_count, errors: errors)
end
private
# Fetches balance data for this wallet from CoinStats API.
# @return [Array<Hash>] Token balances for the wallet
def fetch_balance_data
provider = Provider::Coinstats.new(coinstats_item.api_key)
wallets_param = "#{blockchain}:#{address}"
response = provider.get_wallet_balances(wallets_param)
return [] unless response.success?
provider.extract_wallet_balance(response.data, address, blockchain)
end
# Normalizes various balance data formats to an array of tokens.
# @param balance_data [Array, Hash, Object] Raw balance response
# @return [Array<Hash>] Normalized array of token data
def normalize_tokens(balance_data)
if balance_data.is_a?(Array)
balance_data
elsif balance_data.is_a?(Hash)
balance_data[:result] || balance_data[:tokens] || [ balance_data ]
elsif balance_data.present?
[ balance_data ]
else
[]
end
end
# Creates a CoinstatsAccount and linked Account for a token.
# @param token_data [Hash] Token balance data from API
# @return [Hash] Result with :success and optional :error
def create_account_from_token(token_data)
token = token_data.with_indifferent_access
account_name = build_account_name(token)
current_balance = calculate_balance(token)
token_id = (token[:coinId] || token[:id])&.to_s
ActiveRecord::Base.transaction do
coinstats_account = coinstats_item.coinstats_accounts.create!(
name: account_name,
currency: "USD",
current_balance: current_balance,
account_id: token_id
)
# Store wallet metadata for future syncs
snapshot = build_snapshot(token, current_balance)
coinstats_account.upsert_coinstats_snapshot!(snapshot)
account = coinstats_item.family.accounts.create!(
accountable: Crypto.new,
name: account_name,
balance: current_balance,
cash_balance: current_balance,
currency: coinstats_account.currency,
status: "active"
)
AccountProvider.create!(account: account, provider: coinstats_account)
{ success: true }
end
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e
Rails.logger.error("CoinstatsItem::WalletLinker - Failed to create account: #{e.message}")
{ success: false, error: "Failed to create #{account_name || 'account'}: #{e.message}" }
rescue => e
Rails.logger.error("CoinstatsItem::WalletLinker - Unexpected error: #{e.class} - #{e.message}")
{ success: false, error: "Unexpected error: #{e.message}" }
end
# Builds a display name for the account from token and address.
# @param token [Hash] Token data with :name
# @return [String] Human-readable account name
def build_account_name(token)
token_name = token[:name].to_s.strip
truncated_address = address.present? ? "#{address.first(4)}...#{address.last(4)}" : nil
if token_name.present? && truncated_address.present?
"#{token_name} (#{truncated_address})"
elsif token_name.present?
token_name
elsif truncated_address.present?
"#{blockchain.capitalize} (#{truncated_address})"
else
"Crypto Wallet"
end
end
# Calculates USD balance from token amount and price.
# @param token [Hash] Token data with :amount/:balance and :price
# @return [Float] Balance in USD
def calculate_balance(token)
amount = token[:amount] || token[:balance] || token[:current_balance] || 0
price = token[:price] || 0
(amount.to_f * price.to_f)
end
# Builds snapshot hash for storing in CoinstatsAccount.
# @param token [Hash] Token data from API
# @param current_balance [Float] Calculated USD balance
# @return [Hash] Snapshot with balance, address, and metadata
def build_snapshot(token, current_balance)
token.to_h.merge(
id: (token[:coinId] || token[:id])&.to_s,
balance: current_balance,
currency: "USD",
address: address,
blockchain: blockchain,
institution_logo: token[:imgUrl]
)
end
end

View File

@@ -0,0 +1,68 @@
# frozen_string_literal: true
# Shared logic for extracting unique transaction IDs from CoinStats API responses.
# Different blockchains return transaction IDs in different locations:
# - Ethereum/EVM: hash.id (transaction hash)
# - Bitcoin/UTXO: transactions[0].items[0].id
module CoinstatsTransactionIdentifiable
extend ActiveSupport::Concern
private
# Extracts a unique transaction ID from CoinStats transaction data.
# Handles different blockchain formats and generates fallback IDs.
# @param transaction_data [Hash] Raw transaction data from API
# @return [String, nil] Unique transaction identifier or nil
def extract_coinstats_transaction_id(transaction_data)
tx = transaction_data.is_a?(Hash) ? transaction_data.with_indifferent_access : {}
# Try hash.id first (Ethereum/EVM chains)
hash_id = tx.dig(:hash, :id)
return hash_id if hash_id.present?
# Try transactions[0].items[0].id (Bitcoin/UTXO chains)
item_id = tx.dig(:transactions, 0, :items, 0, :id)
return item_id if item_id.present?
# Fallback: generate ID from multiple fields to reduce collision risk.
# Include as many distinguishing fields as possible since transactions
# with same date/type/amount are common (DCA, recurring purchases, batch trades).
fallback_id = build_fallback_transaction_id(tx)
return fallback_id if fallback_id.present?
nil
end
# Builds a fallback transaction ID from available fields.
# Uses a hash digest of combined fields to handle varying field availability
# while maintaining uniqueness across similar transactions.
# @param tx [HashWithIndifferentAccess] Transaction data
# @return [String, nil] Generated fallback ID or nil if insufficient data
def build_fallback_transaction_id(tx)
date = tx[:date]
type = tx[:type]
amount = tx.dig(:coinData, :count)
# Require minimum fields for a valid fallback
return nil unless date.present? && type.present? && amount.present?
# Collect additional distinguishing fields.
# Only use stable transaction data—avoid market-dependent values
# (currentValue, totalWorth, profit) that can change between API calls.
components = [
date,
type,
amount,
tx.dig(:coinData, :symbol),
tx.dig(:fee, :count),
tx.dig(:fee, :coin, :symbol),
tx.dig(:transactions, 0, :action),
tx.dig(:transactions, 0, :items, 0, :coin, :id),
tx.dig(:transactions, 0, :items, 0, :count)
].compact
# Generate a hash digest for a fixed-length, collision-resistant ID
content = components.join("|")
"fallback_#{Digest::SHA256.hexdigest(content)[0, 16]}"
end
end

View File

@@ -1,10 +1,10 @@
# Provides currency normalization and validation for provider data imports
#
# This concern provides a shared method to parse and normalize currency codes
# from external providers (Plaid, SimpleFIN, LunchFlow), ensuring:
# from external providers (Plaid, SimpleFIN, LunchFlow, Enable Banking), ensuring:
# - Consistent uppercase formatting (e.g., "eur" -> "EUR")
# - Validation of 3-letter ISO currency codes
# - Proper handling of nil, empty, and invalid values
# - Validation against Money gem's known currencies (not just 3-letter format)
# - Proper handling of nil, empty, and invalid values (e.g., "XXX")
#
# Usage:
# include CurrencyNormalizable
@@ -23,6 +23,7 @@ module CurrencyNormalizable
# parse_currency("usd") # => "USD"
# parse_currency("EUR") # => "EUR"
# parse_currency(" gbp ") # => "GBP"
# parse_currency("XXX") # => nil (not a valid Money currency)
# parse_currency("invalid") # => nil (logs warning)
# parse_currency(nil) # => nil
# parse_currency("") # => nil
@@ -33,8 +34,15 @@ module CurrencyNormalizable
# Normalize to uppercase 3-letter code
normalized = currency_value.to_s.strip.upcase
# Validate it's a reasonable currency code (3 letters)
if normalized.match?(/\A[A-Z]{3}\z/)
# Validate it's a 3-letter format first
unless normalized.match?(/\A[A-Z]{3}\z/)
log_invalid_currency(currency_value)
return nil
end
# Validate against Money gem's known currencies
# This catches codes like "XXX" which are 3 letters but not valid for monetary operations
if valid_money_currency?(normalized)
normalized
else
log_invalid_currency(currency_value)
@@ -42,6 +50,17 @@ module CurrencyNormalizable
end
end
# Check if a currency code is valid in the Money gem
#
# @param code [String] Uppercase 3-letter currency code
# @return [Boolean] true if the Money gem recognizes this currency
def valid_money_currency?(code)
Money::Currency.new(code)
true
rescue Money::Currency::UnknownCurrencyError
false
end
# Log warning for invalid currency codes
# Override this method in including classes to provide context-specific logging
def log_invalid_currency(currency_value)

View File

@@ -0,0 +1,22 @@
# frozen_string_literal: true
module SimplefinNumericHelpers
extend ActiveSupport::Concern
private
def to_decimal(value)
return BigDecimal("0") if value.nil?
case value
when BigDecimal then value
when String then BigDecimal(value) rescue BigDecimal("0")
when Numeric then BigDecimal(value.to_s)
else
BigDecimal("0")
end
end
def same_sign?(a, b)
(a.positive? && b.positive?) || (a.negative? && b.negative?)
end
end

View File

@@ -0,0 +1,198 @@
# frozen_string_literal: true
# SyncStats::Collector provides shared methods for collecting sync statistics
# across different provider syncers.
#
# This concern standardizes the stat collection interface so all providers
# can report consistent sync summaries.
#
# @example Include in a syncer class
# class PlaidItem::Syncer
# include SyncStats::Collector
#
# def perform_sync(sync)
# # ... sync logic ...
# collect_setup_stats(sync, provider_accounts: plaid_item.plaid_accounts)
# collect_transaction_stats(sync, account_ids: account_ids, source: "plaid")
# # ...
# end
# end
#
module SyncStats
module Collector
extend ActiveSupport::Concern
# Collects account setup statistics (total, linked, unlinked counts).
#
# @param sync [Sync] The sync record to update
# @param provider_accounts [ActiveRecord::Relation] The provider accounts (e.g., SimplefinAccount, PlaidAccount)
# @param linked_check [Proc, nil] Optional proc to check if an account is linked. If nil, uses default logic.
# @return [Hash] The setup stats that were collected
def collect_setup_stats(sync, provider_accounts:, linked_check: nil)
return {} unless sync.respond_to?(:sync_stats)
total_accounts = provider_accounts.count
# Count linked accounts - either via custom check or default association check
linked_count = if linked_check
provider_accounts.count { |pa| linked_check.call(pa) }
else
# Default: check for current_account method or account association
provider_accounts.count do |pa|
(pa.respond_to?(:current_account) && pa.current_account.present?) ||
(pa.respond_to?(:account) && pa.account.present?)
end
end
unlinked_count = total_accounts - linked_count
setup_stats = {
"total_accounts" => total_accounts,
"linked_accounts" => linked_count,
"unlinked_accounts" => unlinked_count
}
merge_sync_stats(sync, setup_stats)
setup_stats
end
# Collects transaction statistics (imported, updated, seen, skipped).
#
# @param sync [Sync] The sync record to update
# @param account_ids [Array<String>] The account IDs to count transactions for
# @param source [String] The transaction source (e.g., "simplefin", "plaid")
# @param window_start [Time, nil] Start of the sync window (defaults to sync.created_at or 30 minutes ago)
# @param window_end [Time, nil] End of the sync window (defaults to Time.current)
# @return [Hash] The transaction stats that were collected
def collect_transaction_stats(sync, account_ids:, source:, window_start: nil, window_end: nil)
return {} unless sync.respond_to?(:sync_stats)
return {} if account_ids.empty?
window_start ||= sync.created_at || 30.minutes.ago
window_end ||= Time.current
tx_scope = Entry.where(account_id: account_ids, source: source, entryable_type: "Transaction")
tx_imported = tx_scope.where(created_at: window_start..window_end).count
tx_updated = tx_scope.where(updated_at: window_start..window_end)
.where.not(created_at: window_start..window_end).count
tx_seen = tx_imported + tx_updated
tx_stats = {
"tx_imported" => tx_imported,
"tx_updated" => tx_updated,
"tx_seen" => tx_seen,
"window_start" => window_start.iso8601,
"window_end" => window_end.iso8601
}
merge_sync_stats(sync, tx_stats)
tx_stats
end
# Collects holdings statistics.
#
# @param sync [Sync] The sync record to update
# @param holdings_count [Integer] The number of holdings found/processed
# @param label [String] The label for the stat ("found" or "processed")
# @return [Hash] The holdings stats that were collected
def collect_holdings_stats(sync, holdings_count:, label: "found")
return {} unless sync.respond_to?(:sync_stats)
key = label == "processed" ? "holdings_processed" : "holdings_found"
holdings_stats = { key => holdings_count }
merge_sync_stats(sync, holdings_stats)
holdings_stats
end
# Collects health/error statistics.
#
# @param sync [Sync] The sync record to update
# @param errors [Array<Hash>, nil] Array of error objects with :message and optional :category
# @param rate_limited [Boolean] Whether the sync was rate limited
# @param rate_limited_at [Time, nil] When rate limiting occurred
# @return [Hash] The health stats that were collected
def collect_health_stats(sync, errors: nil, rate_limited: false, rate_limited_at: nil)
return {} unless sync.respond_to?(:sync_stats)
health_stats = {
"import_started" => true
}
if errors.present?
health_stats["errors"] = errors.map do |e|
e.is_a?(Hash) ? e.stringify_keys : { "message" => e.to_s }
end
health_stats["total_errors"] = errors.size
else
health_stats["total_errors"] = 0
end
if rate_limited
health_stats["rate_limited"] = true
health_stats["rate_limited_at"] = rate_limited_at&.iso8601
end
merge_sync_stats(sync, health_stats)
health_stats
end
# Collects data quality warnings and notices.
#
# @param sync [Sync] The sync record to update
# @param warnings [Integer] Number of data warnings
# @param notices [Integer] Number of notices
# @param details [Array<Hash>] Array of detail objects with :message and optional :severity
# @return [Hash] The data quality stats that were collected
def collect_data_quality_stats(sync, warnings: 0, notices: 0, details: [])
return {} unless sync.respond_to?(:sync_stats)
quality_stats = {
"data_warnings" => warnings,
"notices" => notices
}
if details.present?
quality_stats["data_quality_details"] = details.map do |d|
d.is_a?(Hash) ? d.stringify_keys : { "message" => d.to_s, "severity" => "info" }
end
end
merge_sync_stats(sync, quality_stats)
quality_stats
end
# Marks the sync as having started import (used for health indicator).
#
# @param sync [Sync] The sync record to update
def mark_import_started(sync)
return unless sync.respond_to?(:sync_stats)
merge_sync_stats(sync, { "import_started" => true })
end
# Clears previous sync stats (useful at start of sync).
#
# @param sync [Sync] The sync record to update
def clear_sync_stats(sync)
return unless sync.respond_to?(:sync_stats)
sync.update!(sync_stats: { "cleared_at" => Time.current.iso8601 })
end
private
# Merges new stats into the existing sync_stats hash.
#
# @param sync [Sync] The sync record to update
# @param new_stats [Hash] The new stats to merge
def merge_sync_stats(sync, new_stats)
return unless sync.respond_to?(:sync_stats)
existing = sync.sync_stats || {}
sync.update!(sync_stats: existing.merge(new_stats))
rescue => e
Rails.logger.warn("SyncStats::Collector#merge_sync_stats failed: #{e.class} - #{e.message}")
end
end
end

View File

@@ -1,5 +1,5 @@
class DataEnrichment < ApplicationRecord
belongs_to :enrichable, polymorphic: true
enum :source, { rule: "rule", plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking" }
enum :source, { rule: "rule", plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats" }
end

View File

@@ -1,5 +1,5 @@
class Family < ApplicationRecord
include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable, Syncable, AutoTransferMatchable, Subscribeable
include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable, Syncable, AutoTransferMatchable, Subscribeable, CoinstatsConnectable
DATE_FORMATS = [
[ "MM-DD-YYYY", "%m-%d-%Y" ],
@@ -45,6 +45,15 @@ class Family < ApplicationRecord
Merchant.where(id: merchant_ids)
end
def available_merchants
assigned_ids = transactions.where.not(merchant_id: nil).pluck(:merchant_id).uniq
recently_unlinked_ids = FamilyMerchantAssociation
.where(family: self)
.recently_unlinked
.pluck(:merchant_id)
Merchant.where(id: (assigned_ids + recently_unlinked_ids).uniq)
end
def auto_categorize_transactions_later(transactions, rule_run_id: nil)
AutoCategorizeJob.perform_later(self, transaction_ids: transactions.pluck(:id), rule_run_id: rule_run_id)
end
@@ -69,6 +78,10 @@ class Family < ApplicationRecord
@income_statement ||= IncomeStatement.new(self)
end
def investment_statement
@investment_statement ||= InvestmentStatement.new(self)
end
def eu?
country != "US" && country != "CA"
end

View File

@@ -102,21 +102,33 @@ class Family::AutoMerchantDetector
end
def find_or_create_ai_merchant(auto_detection)
# Only use (source, name) for find_or_create since that's the uniqueness constraint
ProviderMerchant.find_or_create_by!(
source: "ai",
name: auto_detection.business_name
) do |pm|
pm.website_url = auto_detection.business_url
if Setting.brand_fetch_client_id.present?
pm.logo_url = "#{default_logo_provider_url}/#{auto_detection.business_url}/icon/fallback/lettermark/w/40/h/40?c=#{Setting.brand_fetch_client_id}"
end
# Strategy 1: Find existing merchant by website_url (most reliable for deduplication)
if auto_detection.business_url.present?
existing = ProviderMerchant.find_by(website_url: auto_detection.business_url)
return existing if existing
end
# Strategy 2: Find by exact name match
existing = ProviderMerchant.find_by(source: "ai", name: auto_detection.business_name)
return existing if existing
# Strategy 3: Create new merchant
ProviderMerchant.create!(
source: "ai",
name: auto_detection.business_name,
website_url: auto_detection.business_url,
logo_url: build_logo_url(auto_detection.business_url)
)
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
# Race condition: another process created the merchant between our find and create
ProviderMerchant.find_by(source: "ai", name: auto_detection.business_name)
end
def build_logo_url(business_url)
return nil unless Setting.brand_fetch_client_id.present? && business_url.present?
"#{default_logo_provider_url}/#{business_url}/icon/fallback/lettermark/w/40/h/40?c=#{Setting.brand_fetch_client_id}"
end
def enhance_provider_merchant(merchant, auto_detection)
updates = {}

View File

@@ -1,5 +1,5 @@
module Family::AutoTransferMatchable
def transfer_match_candidates
def transfer_match_candidates(date_window: 4)
Entry.select([
"inflow_candidates.entryable_id as inflow_transaction_id",
"outflow_candidates.entryable_id as outflow_transaction_id",
@@ -10,7 +10,7 @@ module Family::AutoTransferMatchable
inflow_candidates.amount < 0 AND
outflow_candidates.amount > 0 AND
inflow_candidates.account_id <> outflow_candidates.account_id AND
inflow_candidates.date BETWEEN outflow_candidates.date - 4 AND outflow_candidates.date + 4
inflow_candidates.date BETWEEN outflow_candidates.date - #{date_window.to_i} AND outflow_candidates.date + #{date_window.to_i}
)
").joins("
LEFT JOIN transfers existing_transfers ON (

View File

@@ -0,0 +1,35 @@
# Adds CoinStats connection capabilities to Family.
# Allows families to create and manage CoinStats API connections.
module Family::CoinstatsConnectable
extend ActiveSupport::Concern
included do
has_many :coinstats_items, dependent: :destroy
end
# @return [Boolean] Whether the family can create CoinStats connections
def can_connect_coinstats?
# Families can configure their own Coinstats credentials
true
end
# Creates a new CoinStats connection and triggers initial sync.
# @param api_key [String] CoinStats API key
# @param item_name [String, nil] Optional display name for the connection
# @return [CoinstatsItem] The created connection
def create_coinstats_item!(api_key:, item_name: nil)
coinstats_item = coinstats_items.create!(
name: item_name || "CoinStats Connection",
api_key: api_key
)
coinstats_item.sync_later
coinstats_item
end
# @return [Boolean] Whether the family has any configured CoinStats connections
def has_coinstats_credentials?
coinstats_items.where.not(api_key: nil).exists?
end
end

View File

@@ -10,7 +10,7 @@ class Family::Syncer
family.sync_trial_status!
Rails.logger.info("Applying rules for family #{family.id}")
family.rules.each do |rule|
family.rules.where(active: true).each do |rule|
rule.apply_later
end

View File

@@ -0,0 +1,6 @@
class FamilyMerchantAssociation < ApplicationRecord
belongs_to :family
belongs_to :merchant
scope :recently_unlinked, -> { where(unlinked_at: 30.days.ago..).where.not(unlinked_at: nil) }
end

View File

@@ -28,32 +28,14 @@ class Holding < ApplicationRecord
end
# Basic approximation of cost-basis
# Uses pre-computed cost_basis if available (set during materialization),
# otherwise falls back to calculating from trades
def avg_cost
trades = account.trades
.with_entry
.joins(ActiveRecord::Base.sanitize_sql_array([
"LEFT JOIN exchange_rates ON (
exchange_rates.date = entries.date AND
exchange_rates.from_currency = trades.currency AND
exchange_rates.to_currency = ?
)", account.currency
]))
.where(security_id: security.id)
.where("trades.qty > 0 AND entries.date <= ?", date)
# Use stored cost_basis if available (eliminates N+1 queries)
return Money.new(cost_basis, currency) if cost_basis.present?
total_cost, total_qty = trades.pick(
Arel.sql("SUM(trades.price * trades.qty * COALESCE(exchange_rates.rate, 1))"),
Arel.sql("SUM(trades.qty)")
)
weighted_avg =
if total_qty && total_qty > 0
total_cost / total_qty
else
price
end
Money.new(weighted_avg || price, currency)
# Fallback to calculation for holdings without pre-computed cost_basis
calculate_avg_cost
end
def trend
@@ -100,4 +82,32 @@ class Holding < ApplicationRecord
current: amount_money,
previous: start_amount
end
def calculate_avg_cost
trades = account.trades
.with_entry
.joins(ActiveRecord::Base.sanitize_sql_array([
"LEFT JOIN exchange_rates ON (
exchange_rates.date = entries.date AND
exchange_rates.from_currency = trades.currency AND
exchange_rates.to_currency = ?
)", account.currency
]))
.where(security_id: security.id)
.where("trades.qty > 0 AND entries.date <= ?", date)
total_cost, total_qty = trades.pick(
Arel.sql("SUM(trades.price * trades.qty * COALESCE(exchange_rates.rate, 1))"),
Arel.sql("SUM(trades.qty)")
)
weighted_avg =
if total_qty && total_qty > 0
total_cost / total_qty
else
price
end
Money.new(weighted_avg || price, currency)
end
end

View File

@@ -3,6 +3,8 @@ class Holding::ForwardCalculator
def initialize(account)
@account = account
# Track cost basis per security: { security_id => { total_cost: BigDecimal, total_qty: BigDecimal } }
@cost_basis_tracker = Hash.new { |h, k| h[k] = { total_cost: BigDecimal("0"), total_qty: BigDecimal("0") } }
end
def calculate
@@ -13,6 +15,7 @@ class Holding::ForwardCalculator
account.start_date.upto(Date.current).each do |date|
trades = portfolio_cache.get_trades(date: date)
update_cost_basis_tracker(trades)
next_portfolio = transform_portfolio(current_portfolio, trades, direction: :forward)
holdings += build_holdings(next_portfolio, date)
current_portfolio = next_portfolio
@@ -65,8 +68,36 @@ class Holding::ForwardCalculator
qty: qty,
price: price.price,
currency: price.currency,
amount: qty * price.price
amount: qty * price.price,
cost_basis: cost_basis_for(security_id, price.currency)
)
end.compact
end
# Updates cost basis tracker with buy trades (qty > 0)
# Uses weighted average cost method
def update_cost_basis_tracker(trade_entries)
trade_entries.each do |trade_entry|
trade = trade_entry.entryable
next unless trade.qty > 0 # Only track buys
security_id = trade.security_id
tracker = @cost_basis_tracker[security_id]
# Convert trade price to account currency if needed
trade_price = Money.new(trade.price, trade.currency)
converted_price = trade_price.exchange_to(account.currency, fallback_rate: 1).amount
tracker[:total_cost] += converted_price * trade.qty
tracker[:total_qty] += trade.qty
end
end
# Returns the current cost basis for a security, or nil if no buys recorded
def cost_basis_for(security_id, currency)
tracker = @cost_basis_tracker[security_id]
return nil if tracker[:total_qty].zero?
tracker[:total_cost] / tracker[:total_qty]
end
end

View File

@@ -31,7 +31,7 @@ class Holding::Materializer
account.holdings.upsert_all(
@holdings.map { |h| h.attributes
.slice("date", "currency", "qty", "price", "amount", "security_id")
.slice("date", "currency", "qty", "price", "amount", "security_id", "cost_basis")
.merge("account_id" => account.id, "updated_at" => current_time) },
unique_by: %i[account_id security_id date currency]
)

View File

@@ -8,6 +8,7 @@ class Holding::ReverseCalculator
def calculate
Rails.logger.tagged("Holding::ReverseCalculator") do
precompute_cost_basis
holdings = calculate_holdings
Holding.gapfill(holdings)
end
@@ -69,8 +70,47 @@ class Holding::ReverseCalculator
qty: qty,
price: price.price,
currency: price.currency,
amount: qty * price.price
amount: qty * price.price,
cost_basis: cost_basis_for(security_id, date)
)
end.compact
end
# Pre-compute cost basis for all securities at all dates using forward pass through trades
# Stores: { security_id => { date => cost_basis } }
def precompute_cost_basis
@cost_basis_by_date = Hash.new { |h, k| h[k] = {} }
tracker = Hash.new { |h, k| h[k] = { total_cost: BigDecimal("0"), total_qty: BigDecimal("0") } }
trades = portfolio_cache.get_trades.sort_by(&:date)
trade_index = 0
account.start_date.upto(Date.current).each do |date|
# Process all trades up to and including this date
while trade_index < trades.size && trades[trade_index].date <= date
trade_entry = trades[trade_index]
trade = trade_entry.entryable
if trade.qty > 0 # Only track buys
security_id = trade.security_id
trade_price = Money.new(trade.price, trade.currency)
converted_price = trade_price.exchange_to(account.currency, fallback_rate: 1).amount
tracker[security_id][:total_cost] += converted_price * trade.qty
tracker[security_id][:total_qty] += trade.qty
end
trade_index += 1
end
# Store current cost basis snapshot for each security at this date
tracker.each do |security_id, data|
next if data[:total_qty].zero?
@cost_basis_by_date[security_id][date] = data[:total_cost] / data[:total_qty]
end
end
end
def cost_basis_for(security_id, date)
@cost_basis_by_date.dig(security_id, date)
end
end

View File

@@ -2,6 +2,9 @@ class Import < ApplicationRecord
MaxRowCountExceededError = Class.new(StandardError)
MappingError = Class.new(StandardError)
MAX_CSV_SIZE = 10.megabytes
ALLOWED_MIME_TYPES = %w[text/csv text/plain application/vnd.ms-excel application/csv].freeze
TYPES = %w[TransactionImport TradeImport AccountImport MintImport CategoryImport RuleImport].freeze
SIGNAGE_CONVENTIONS = %w[inflows_positive inflows_negative]
SEPARATORS = [ [ "Comma (,)", "," ], [ "Semicolon (;)", ";" ] ].freeze
@@ -36,6 +39,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 :account_belongs_to_family
has_many :rows, dependent: :destroy
has_many :mappings, dependent: :destroy
@@ -110,7 +114,7 @@ class Import < ApplicationRecord
def dry_run
mappings = {
transactions: rows.count,
transactions: rows_count,
categories: Import::CategoryMapping.for_import(self).creational.count,
tags: Import::TagMapping.for_import(self).creational.count
}
@@ -152,6 +156,7 @@ class Import < ApplicationRecord
end
rows.insert_all!(mapped_rows)
update_column(:rows_count, rows.count)
end
def sync_mappings
@@ -181,7 +186,7 @@ class Import < ApplicationRecord
end
def configured?
uploaded? && rows.any?
uploaded? && rows_count > 0
end
def cleaned?
@@ -232,7 +237,7 @@ class Import < ApplicationRecord
private
def row_count_exceeded?
rows.count > max_row_count
rows_count > max_row_count
end
def import!
@@ -288,4 +293,11 @@ class Import < ApplicationRecord
def set_default_number_format
self.number_format ||= "1,234.56" # Default to US/UK format
end
def account_belongs_to_family
return if account.nil?
return if account.family_id == family_id
errors.add(:account, "must belong to your family")
end
end

View File

@@ -1,5 +1,5 @@
class Import::Row < ApplicationRecord
belongs_to :import
belongs_to :import, counter_cache: true
validates :amount, numericality: true, allow_blank: true
validates :currency, presence: true

View File

@@ -11,10 +11,10 @@ class IncomeStatement
@family = family
end
def totals(transactions_scope: nil)
def totals(transactions_scope: nil, date_range:)
transactions_scope ||= family.transactions.visible
result = totals_query(transactions_scope: transactions_scope)
result = totals_query(transactions_scope: transactions_scope, date_range: date_range)
total_income = result.select { |t| t.classification == "income" }.sum(&:total)
total_expense = result.select { |t| t.classification == "expense" }.sum(&:total)
@@ -64,17 +64,26 @@ class IncomeStatement
end
def build_period_total(classification:, period:)
totals = totals_query(transactions_scope: family.transactions.visible.in_period(period)).select { |t| t.classification == classification }
totals = totals_query(transactions_scope: family.transactions.visible.in_period(period), date_range: period.date_range).select { |t| t.classification == classification }
classification_total = totals.sum(&:total)
uncategorized_category = family.categories.uncategorized
other_investments_category = family.categories.other_investments
category_totals = [ *categories, uncategorized_category ].map do |category|
category_totals = [ *categories, uncategorized_category, other_investments_category ].map do |category|
subcategory = categories.find { |c| c.id == category.parent_id }
parent_category_total = totals.select { |t| t.category_id == category.id }&.sum(&:total) || 0
parent_category_total = if category.uncategorized?
# Regular uncategorized: NULL category_id and NOT uncategorized investment
totals.select { |t| t.category_id.nil? && !t.is_uncategorized_investment }&.sum(&:total) || 0
elsif category.other_investments?
# Other investments: NULL category_id AND is_uncategorized_investment
totals.select { |t| t.category_id.nil? && t.is_uncategorized_investment }&.sum(&:total) || 0
else
totals.select { |t| t.category_id == category.id }&.sum(&:total) || 0
end
children_totals = if category == uncategorized_category
children_totals = if category.synthetic?
0
else
totals.select { |t| t.parent_category_id == category.id }&.sum(&:total) || 0
@@ -114,12 +123,12 @@ class IncomeStatement
]) { CategoryStats.new(family, interval:).call }
end
def totals_query(transactions_scope:)
def totals_query(transactions_scope:, date_range:)
sql_hash = Digest::MD5.hexdigest(transactions_scope.to_sql)
Rails.cache.fetch([
"income_statement", "totals_query", family.id, sql_hash, family.entries_cache_version
]) { Totals.new(family, transactions_scope: transactions_scope).call }
]) { Totals.new(family, transactions_scope: transactions_scope, date_range: date_range).call }
end
def monetizable_currency

View File

@@ -1,7 +1,11 @@
class IncomeStatement::Totals
def initialize(family, transactions_scope:)
def initialize(family, transactions_scope:, date_range:, include_trades: true)
@family = family
@transactions_scope = transactions_scope
@date_range = date_range
@include_trades = include_trades
validate_date_range!
end
def call
@@ -11,33 +15,54 @@ class IncomeStatement::Totals
category_id: row["category_id"],
classification: row["classification"],
total: row["total"],
transactions_count: row["transactions_count"]
transactions_count: row["transactions_count"],
is_uncategorized_investment: row["is_uncategorized_investment"]
)
end
end
private
TotalsRow = Data.define(:parent_category_id, :category_id, :classification, :total, :transactions_count)
TotalsRow = Data.define(:parent_category_id, :category_id, :classification, :total, :transactions_count, :is_uncategorized_investment)
def query_sql
ActiveRecord::Base.sanitize_sql_array([
optimized_query_sql,
@include_trades ? combined_query_sql : transactions_only_query_sql,
sql_params
])
end
# OPTIMIZED: Direct SUM aggregation without unnecessary time bucketing
# Eliminates CTE and intermediate date grouping for maximum performance
def optimized_query_sql
# Combined query that includes both transactions and trades
def combined_query_sql
<<~SQL
SELECT
category_id,
parent_category_id,
classification,
is_uncategorized_investment,
SUM(total) as total,
SUM(entry_count) as transactions_count
FROM (
#{transactions_subquery_sql}
UNION ALL
#{trades_subquery_sql}
) combined
GROUP BY category_id, parent_category_id, classification, is_uncategorized_investment;
SQL
end
# Original transactions-only query (for backwards compatibility)
def transactions_only_query_sql
<<~SQL
SELECT
c.id as category_id,
c.parent_id as parent_category_id,
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
ABS(SUM(ae.amount * COALESCE(er.rate, 1))) as total,
COUNT(ae.id) as transactions_count
COUNT(ae.id) as transactions_count,
false as is_uncategorized_investment
FROM (#{@transactions_scope.to_sql}) at
JOIN entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Transaction'
JOIN accounts a ON a.id = ae.account_id
LEFT JOIN categories c ON c.id = at.category_id
LEFT JOIN exchange_rates er ON (
er.date = ae.date AND
@@ -46,13 +71,82 @@ class IncomeStatement::Totals
)
WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
AND ae.excluded = false
AND a.family_id = :family_id
AND a.status IN ('draft', 'active')
GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END;
SQL
end
def transactions_subquery_sql
<<~SQL
SELECT
c.id as category_id,
c.parent_id as parent_category_id,
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
ABS(SUM(ae.amount * COALESCE(er.rate, 1))) as total,
COUNT(ae.id) as entry_count,
false as is_uncategorized_investment
FROM (#{@transactions_scope.to_sql}) at
JOIN entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Transaction'
JOIN accounts a ON a.id = ae.account_id
LEFT JOIN categories c ON c.id = at.category_id
LEFT JOIN exchange_rates er ON (
er.date = ae.date AND
er.from_currency = ae.currency AND
er.to_currency = :target_currency
)
WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
AND ae.excluded = false
AND a.family_id = :family_id
AND a.status IN ('draft', 'active')
GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END
SQL
end
def trades_subquery_sql
# Get trades for the same family and date range as transactions
# Trades without categories appear as "Uncategorized Investments" (separate from regular uncategorized)
<<~SQL
SELECT
c.id as category_id,
c.parent_id as parent_category_id,
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
ABS(SUM(ae.amount * COALESCE(er.rate, 1))) as total,
COUNT(ae.id) as entry_count,
CASE WHEN t.category_id IS NULL THEN true ELSE false END as is_uncategorized_investment
FROM trades t
JOIN entries ae ON ae.entryable_id = t.id AND ae.entryable_type = 'Trade'
JOIN accounts a ON a.id = ae.account_id
LEFT JOIN categories c ON c.id = t.category_id
LEFT JOIN exchange_rates er ON (
er.date = ae.date AND
er.from_currency = ae.currency AND
er.to_currency = :target_currency
)
WHERE a.family_id = :family_id
AND a.status IN ('draft', 'active')
AND ae.excluded = false
AND ae.date BETWEEN :start_date AND :end_date
GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END, CASE WHEN t.category_id IS NULL THEN true ELSE false END
SQL
end
def sql_params
{
target_currency: @family.currency
target_currency: @family.currency,
family_id: @family.id,
start_date: @date_range.begin,
end_date: @date_range.end
}
end
def validate_date_range!
unless @date_range.is_a?(Range)
raise ArgumentError, "date_range must be a Range, got #{@date_range.class}"
end
unless @date_range.begin.respond_to?(:to_date) && @date_range.end.respond_to?(:to_date)
raise ArgumentError, "date_range must contain date-like objects"
end
end
end

View File

@@ -0,0 +1,191 @@
require "digest/md5"
class InvestmentStatement
include Monetizable
monetize :total_contributions, :total_dividends, :total_interest, :unrealized_gains
attr_reader :family
def initialize(family)
@family = family
end
# Get totals for a specific period
def totals(period: Period.current_month)
trades_in_period = family.trades
.joins(:entry)
.where(entries: { date: period.date_range })
result = totals_query(trades_scope: trades_in_period)
PeriodTotals.new(
contributions: Money.new(result[:contributions], family.currency),
withdrawals: Money.new(result[:withdrawals], family.currency),
dividends: Money.new(result[:dividends], family.currency),
interest: Money.new(result[:interest], family.currency),
trades_count: result[:trades_count],
currency: family.currency
)
end
# Net contributions (contributions - withdrawals)
def net_contributions(period: Period.current_month)
t = totals(period: period)
t.contributions - t.withdrawals
end
# Total portfolio value across all investment accounts
def portfolio_value
investment_accounts.sum(&:balance)
end
def portfolio_value_money
Money.new(portfolio_value, family.currency)
end
# Total cash in investment accounts
def cash_balance
investment_accounts.sum(&:cash_balance)
end
def cash_balance_money
Money.new(cash_balance, family.currency)
end
# Total holdings value
def holdings_value
portfolio_value - cash_balance
end
def holdings_value_money
Money.new(holdings_value, family.currency)
end
# All current holdings across investment accounts
def current_holdings
return Holding.none unless investment_accounts.any?
account_ids = investment_accounts.pluck(:id)
# Get the latest holding for each security per account
Holding
.where(account_id: account_ids)
.where(currency: family.currency)
.where.not(qty: 0)
.where(
id: Holding
.where(account_id: account_ids)
.where(currency: family.currency)
.select("DISTINCT ON (holdings.account_id, holdings.security_id) holdings.id")
.order(Arel.sql("holdings.account_id, holdings.security_id, holdings.date DESC"))
)
.includes(:security, :account)
.order(amount: :desc)
end
# Top holdings by value
def top_holdings(limit: 5)
current_holdings.limit(limit)
end
# Portfolio allocation by security type/sector (simplified for now)
def allocation
holdings = current_holdings.to_a
total = holdings.sum(&:amount)
return [] if total.zero?
holdings.map do |holding|
HoldingAllocation.new(
security: holding.security,
amount: holding.amount_money,
weight: (holding.amount / total * 100).round(2),
trend: holding.trend
)
end
end
# Unrealized gains across all holdings
def unrealized_gains
current_holdings.sum do |holding|
trend = holding.trend
trend ? trend.value : 0
end
end
# Total contributions (all time) - returns numeric for monetize
def total_contributions
all_time_totals.contributions&.amount || 0
end
# Total dividends (all time) - returns numeric for monetize
def total_dividends
all_time_totals.dividends&.amount || 0
end
# Total interest (all time) - returns numeric for monetize
def total_interest
all_time_totals.interest&.amount || 0
end
def unrealized_gains_trend
holdings = current_holdings.to_a
return nil if holdings.empty?
current = holdings.sum(&:amount)
previous = holdings.sum { |h| h.qty * h.avg_cost.amount }
Trend.new(current: current, previous: previous)
end
# Day change across portfolio
def day_change
holdings = current_holdings.to_a
changes = holdings.map(&:day_change).compact
return nil if changes.empty?
current = changes.sum { |t| t.current.is_a?(Money) ? t.current.amount : t.current }
previous = changes.sum { |t| t.previous.is_a?(Money) ? t.previous.amount : t.previous }
Trend.new(
current: Money.new(current, family.currency),
previous: Money.new(previous, family.currency)
)
end
# Investment accounts
def investment_accounts
@investment_accounts ||= family.accounts.visible.where(accountable_type: %w[Investment Crypto])
end
private
def all_time_totals
@all_time_totals ||= totals(period: Period.all_time)
end
PeriodTotals = Data.define(:contributions, :withdrawals, :dividends, :interest, :trades_count, :currency) do
def net_flow
contributions - withdrawals
end
def total_income
dividends + interest
end
end
HoldingAllocation = Data.define(:security, :amount, :weight, :trend)
def totals_query(trades_scope:)
sql_hash = Digest::MD5.hexdigest(trades_scope.to_sql)
Rails.cache.fetch([
"investment_statement", "totals_query", family.id, sql_hash, family.entries_cache_version
]) { Totals.new(family, trades_scope: trades_scope).call }
end
def monetizable_currency
family.currency
end
end

View File

@@ -0,0 +1,56 @@
class InvestmentStatement::Totals
def initialize(family, trades_scope:)
@family = family
@trades_scope = trades_scope
end
def call
result = ActiveRecord::Base.connection.select_one(query_sql)
{
contributions: result["contributions"]&.to_d || 0,
withdrawals: result["withdrawals"]&.to_d || 0,
dividends: 0, # Dividends come through as transactions, not trades
interest: 0, # Interest comes through as transactions, not trades
trades_count: result["trades_count"]&.to_i || 0
}
end
private
def query_sql
ActiveRecord::Base.sanitize_sql_array([
aggregation_sql,
sql_params
])
end
# Aggregate trades by direction (buy vs sell)
# Buys (qty > 0) = contributions (cash going out to buy securities)
# Sells (qty < 0) = withdrawals (cash coming in from selling securities)
def aggregation_sql
<<~SQL
SELECT
COALESCE(SUM(CASE WHEN t.qty > 0 THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as contributions,
COALESCE(SUM(CASE WHEN t.qty < 0 THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as withdrawals,
COUNT(t.id) as trades_count
FROM (#{@trades_scope.to_sql}) t
JOIN entries ae ON ae.entryable_id = t.id AND ae.entryable_type = 'Trade'
JOIN accounts a ON a.id = ae.account_id
LEFT JOIN exchange_rates er ON (
er.date = ae.date AND
er.from_currency = ae.currency AND
er.to_currency = :target_currency
)
WHERE a.family_id = :family_id
AND a.status IN ('draft', 'active')
AND ae.excluded = false
SQL
end
def sql_params
{
family_id: @family.id,
target_currency: @family.currency
}
end
end

View File

@@ -0,0 +1,184 @@
class LunchflowAccount::Investments::HoldingsProcessor
def initialize(lunchflow_account)
@lunchflow_account = lunchflow_account
end
def process
return if holdings_data.empty?
return unless [ "Investment", "Crypto" ].include?(account&.accountable_type)
holdings_data.each do |lunchflow_holding|
begin
process_holding(lunchflow_holding)
rescue => e
symbol = lunchflow_holding.dig(:security, :tickerSymbol) rescue nil
ctx = symbol.present? ? " #{symbol}" : ""
Rails.logger.error "Error processing Lunchflow holding#{ctx}: #{e.message}"
end
end
end
private
attr_reader :lunchflow_account
def process_holding(lunchflow_holding)
# Support both symbol and string keys (JSONB returns string keys)
holding = lunchflow_holding.is_a?(Hash) ? lunchflow_holding.with_indifferent_access : {}
security_data = (holding[:security] || {}).with_indifferent_access
raw_data = holding[:raw] || {}
symbol = security_data[:tickerSymbol].presence
security_name = security_data[:name].to_s.strip
# Extract holding ID from nested raw data (e.g., raw.quiltt.id)
holding_id = extract_holding_id(raw_data) || generate_holding_id(holding)
Rails.logger.debug({
event: "lunchflow.holding.start",
lfa_id: lunchflow_account.id,
account_id: account&.id,
id: holding_id,
symbol: symbol,
name: security_name
}.to_json)
# If symbol is missing but we have a name, create a synthetic ticker
if symbol.blank? && security_name.present?
normalized = security_name.gsub(/[^a-zA-Z0-9]/, "_").upcase.truncate(24, omission: "")
hash_suffix = Digest::MD5.hexdigest(security_name)[0..4].upcase
symbol = "CUSTOM:#{normalized}_#{hash_suffix}"
Rails.logger.info("Lunchflow: using synthetic ticker #{symbol} for holding #{holding_id} (#{security_name})")
end
unless symbol.present?
Rails.logger.debug({ event: "lunchflow.holding.skip", reason: "no_symbol_or_name", id: holding_id }.to_json)
return
end
security = resolve_security(symbol, security_name, security_data)
unless security.present?
Rails.logger.debug({ event: "lunchflow.holding.skip", reason: "unresolved_security", id: holding_id, symbol: symbol }.to_json)
return
end
# Parse holding data from API response
qty = parse_decimal(holding[:quantity])
price = parse_decimal(holding[:price])
amount = parse_decimal(holding[:value])
cost_basis = parse_decimal(holding[:costBasis])
currency = holding[:currency].presence || security_data[:currency].presence || "USD"
# Skip zero positions with no value
if qty.to_d.zero? && amount.to_d.zero?
Rails.logger.debug({ event: "lunchflow.holding.skip", reason: "zero_position", id: holding_id }.to_json)
return
end
saved = import_adapter.import_holding(
security: security,
quantity: qty,
amount: amount,
currency: currency,
date: Date.current,
price: price,
cost_basis: cost_basis,
external_id: "lunchflow_#{holding_id}",
account_provider_id: lunchflow_account.account_provider&.id,
source: "lunchflow",
delete_future_holdings: false
)
Rails.logger.debug({
event: "lunchflow.holding.saved",
account_id: account&.id,
holding_id: saved.id,
security_id: saved.security_id,
qty: saved.qty.to_s,
amount: saved.amount.to_s,
currency: saved.currency,
date: saved.date,
external_id: saved.external_id
}.to_json)
end
def import_adapter
@import_adapter ||= Account::ProviderImportAdapter.new(account)
end
def account
lunchflow_account.current_account
end
def holdings_data
lunchflow_account.raw_holdings_payload || []
end
def extract_holding_id(raw_data)
# Try to find ID in nested provider data (e.g., raw.quiltt.id, raw.plaid.id, etc.)
return nil unless raw_data.is_a?(Hash)
raw_data.each_value do |provider_data|
next unless provider_data.is_a?(Hash)
id = provider_data[:id] || provider_data["id"]
return id.to_s if id.present?
end
nil
end
def generate_holding_id(holding)
# Generate a stable ID based on holding content
# holding should already be with_indifferent_access from process_holding
security = holding[:security] || {}
content = [
security[:tickerSymbol] || security["tickerSymbol"],
security[:name] || security["name"],
holding[:quantity],
holding[:value]
].compact.join("-")
Digest::MD5.hexdigest(content)[0..11]
end
def resolve_security(symbol, description, security_data)
# Normalize crypto tickers to a distinct namespace
sym = symbol.to_s.upcase
is_crypto_account = account&.accountable_type == "Crypto"
is_crypto_symbol = %w[BTC ETH SOL DOGE LTC BCH XRP ADA DOT AVAX].include?(sym)
if !sym.include?(":") && (is_crypto_account || is_crypto_symbol)
sym = "CRYPTO:#{sym}"
end
is_custom = sym.start_with?("CUSTOM:")
begin
if is_custom
raise "Custom ticker - skipping resolver"
end
Security::Resolver.new(sym).resolve
rescue => e
Rails.logger.warn "Lunchflow: resolver failed for symbol=#{sym}: #{e.class} - #{e.message}; falling back to offline security" unless is_custom
Security.find_or_initialize_by(ticker: sym).tap do |sec|
sec.offline = true if sec.respond_to?(:offline) && sec.offline != true
sec.name = description.presence if sec.name.blank? && description.present?
sec.save! if sec.changed?
end
end
end
def parse_decimal(value)
return BigDecimal("0") unless value.present?
case value
when String
BigDecimal(value)
when Numeric
BigDecimal(value.to_s)
else
BigDecimal("0")
end
rescue ArgumentError => e
Rails.logger.error "Failed to parse Lunchflow decimal value #{value}: #{e.message}"
BigDecimal("0")
end
end

View File

@@ -25,6 +25,7 @@ class LunchflowAccount::Processor
end
process_transactions
process_investments
end
private
@@ -67,6 +68,16 @@ class LunchflowAccount::Processor
report_exception(e, "transactions")
end
def process_investments
# Only process holdings for investment/crypto accounts with holdings support
return unless lunchflow_account.holdings_supported?
return unless [ "Investment", "Crypto" ].include?(lunchflow_account.current_account&.accountable_type)
LunchflowAccount::Investments::HoldingsProcessor.new(lunchflow_account).process
rescue => e
report_exception(e, "holdings")
end
def report_exception(error, context)
Sentry.capture_exception(error) do |scope|
scope.set_tags(

View File

@@ -242,6 +242,14 @@ class LunchflowItem::Importer
Rails.logger.warn "LunchflowItem::Importer - Failed to update balance for account #{lunchflow_account.account_id}: #{e.message}"
end
# Fetch holdings for investment/crypto accounts
begin
fetch_and_store_holdings(lunchflow_account)
rescue => e
# Log but don't fail sync if holdings fetch fails
Rails.logger.warn "LunchflowItem::Importer - Failed to fetch holdings for account #{lunchflow_account.account_id}: #{e.message}"
end
{ success: true, transactions_count: transactions_count }
rescue Provider::Lunchflow::LunchflowError => e
Rails.logger.error "LunchflowItem::Importer - Lunchflow API error for account #{lunchflow_account.id}: #{e.message}"
@@ -299,6 +307,53 @@ class LunchflowItem::Importer
end
end
def fetch_and_store_holdings(lunchflow_account)
# Only fetch holdings for investment/crypto accounts
account = lunchflow_account.current_account
return unless account.present?
return unless [ "Investment", "Crypto" ].include?(account.accountable_type)
# Skip if holdings are not supported for this account
unless lunchflow_account.holdings_supported?
Rails.logger.debug "LunchflowItem::Importer - Skipping holdings fetch for account #{lunchflow_account.account_id} (holdings not supported)"
return
end
Rails.logger.info "LunchflowItem::Importer - Fetching holdings for account #{lunchflow_account.account_id}"
begin
holdings_data = lunchflow_provider.get_account_holdings(lunchflow_account.account_id)
# Validate response structure
unless holdings_data.is_a?(Hash)
Rails.logger.error "LunchflowItem::Importer - Invalid holdings_data format for account #{lunchflow_account.account_id}"
return
end
# Check if holdings are not supported (501 response)
if holdings_data[:holdings_not_supported]
Rails.logger.info "LunchflowItem::Importer - Holdings not supported for account #{lunchflow_account.account_id}, disabling future requests"
lunchflow_account.update!(holdings_supported: false)
return
end
# Store holdings payload for processing
holdings_array = holdings_data[:holdings] || []
Rails.logger.info "LunchflowItem::Importer - Fetched #{holdings_array.count} holdings for account #{lunchflow_account.account_id}"
lunchflow_account.update!(raw_holdings_payload: holdings_array)
rescue Provider::Lunchflow::LunchflowError => e
Rails.logger.error "LunchflowItem::Importer - Lunchflow API error fetching holdings for account #{lunchflow_account.id}: #{e.message}"
# Don't fail if holdings fetch fails
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error "LunchflowItem::Importer - Failed to save holdings for account #{lunchflow_account.id}: #{e.message}"
# Don't fail if holdings save fails
rescue => e
Rails.logger.error "LunchflowItem::Importer - Unexpected error fetching holdings for account #{lunchflow_account.id}: #{e.class} - #{e.message}"
# Don't fail if holdings fetch fails
end
end
def determine_sync_start_date(lunchflow_account)
# Check if this account has any stored transactions
# If not, treat it as a first sync for this account even if the item has been synced before

View File

@@ -1,4 +1,6 @@
class LunchflowItem::Syncer
include SyncStats::Collector
attr_reader :lunchflow_item
def initialize(lunchflow_item)
@@ -10,18 +12,13 @@ class LunchflowItem::Syncer
sync.update!(status_text: "Importing accounts from Lunchflow...") if sync.respond_to?(:status_text)
lunchflow_item.import_latest_lunchflow_data
# Phase 2: Check account setup status and collect sync statistics
# Phase 2: Collect setup statistics using shared concern
sync.update!(status_text: "Checking account configuration...") if sync.respond_to?(:status_text)
total_accounts = lunchflow_item.lunchflow_accounts.count
linked_accounts = lunchflow_item.lunchflow_accounts.joins(:account).merge(Account.visible)
unlinked_accounts = lunchflow_item.lunchflow_accounts.includes(:account).where(accounts: { id: nil })
collect_setup_stats(sync, provider_accounts: lunchflow_item.lunchflow_accounts)
# Store sync statistics for display
sync_stats = {
total_accounts: total_accounts,
linked_accounts: linked_accounts.count,
unlinked_accounts: unlinked_accounts.count
}
# Check for unlinked accounts
linked_accounts = lunchflow_item.lunchflow_accounts.joins(:account_provider)
unlinked_accounts = lunchflow_item.lunchflow_accounts.left_joins(:account_provider).where(account_providers: { id: nil })
# Set pending_account_setup if there are unlinked accounts
if unlinked_accounts.any?
@@ -31,9 +28,10 @@ class LunchflowItem::Syncer
lunchflow_item.update!(pending_account_setup: false)
end
# Phase 3: Process transactions for linked accounts only
# Phase 3: Process transactions and holdings for linked accounts only
if linked_accounts.any?
sync.update!(status_text: "Processing transactions...") if sync.respond_to?(:status_text)
sync.update!(status_text: "Processing transactions and holdings...") if sync.respond_to?(:status_text)
mark_import_started(sync)
Rails.logger.info "LunchflowItem::Syncer - Processing #{linked_accounts.count} linked accounts"
lunchflow_item.process_accounts
Rails.logger.info "LunchflowItem::Syncer - Finished processing accounts"
@@ -45,14 +43,19 @@ class LunchflowItem::Syncer
window_start_date: sync.window_start_date,
window_end_date: sync.window_end_date
)
# Phase 5: Collect transaction statistics
account_ids = linked_accounts.includes(:account_provider).filter_map { |la| la.current_account&.id }
collect_transaction_stats(sync, account_ids: account_ids, source: "lunchflow")
else
Rails.logger.info "LunchflowItem::Syncer - No linked accounts to process"
end
# Store sync statistics in the sync record for status display
if sync.respond_to?(:sync_stats)
sync.update!(sync_stats: sync_stats)
end
# Mark sync health
collect_health_stats(sync, errors: nil)
rescue => e
collect_health_stats(sync, errors: [ { message: e.message, category: "sync_error" } ])
raise
end
def perform_post_sync

View File

@@ -0,0 +1,54 @@
class Merchant::Merger
class UnauthorizedMerchantError < StandardError; end
attr_reader :family, :target_merchant, :source_merchants, :merged_count
def initialize(family:, target_merchant:, source_merchants:)
@family = family
@target_merchant = target_merchant
@merged_count = 0
validate_merchant_belongs_to_family!(target_merchant, "Target merchant")
sources = Array(source_merchants)
sources.each { |m| validate_merchant_belongs_to_family!(m, "Source merchant '#{m.name}'") }
@source_merchants = sources.reject { |m| m.id == target_merchant.id }
end
private
def validate_merchant_belongs_to_family!(merchant, label)
return if family_merchant_ids.include?(merchant.id)
raise UnauthorizedMerchantError, "#{label} does not belong to this family"
end
def family_merchant_ids
@family_merchant_ids ||= begin
family_ids = family.merchants.pluck(:id)
assigned_ids = family.assigned_merchants.pluck(:id)
(family_ids + assigned_ids).uniq
end
end
public
def merge!
return false if source_merchants.empty?
Merchant.transaction do
source_merchants.each do |source|
# Reassign family's transactions to target
family.transactions.where(merchant_id: source.id).update_all(merchant_id: target_merchant.id)
# Delete FamilyMerchant, keep ProviderMerchant (it may be used by other families)
source.destroy! if source.is_a?(FamilyMerchant)
@merged_count += 1
end
end
true
end
end

View File

@@ -18,6 +18,7 @@ class MintImport < Import
end
rows.insert_all!(mapped_rows)
update_column(:rows_count, rows.count)
end
def import!

View File

@@ -1,4 +1,6 @@
class PlaidItem::Syncer
include SyncStats::Collector
attr_reader :plaid_item
def initialize(plaid_item)
@@ -6,21 +8,60 @@ class PlaidItem::Syncer
end
def perform_sync(sync)
# Loads item metadata, accounts, transactions, and other data to our DB
# Phase 1: Import data from Plaid API
sync.update!(status_text: "Importing accounts from Plaid...") if sync.respond_to?(:status_text)
plaid_item.import_latest_plaid_data
# Processes the raw Plaid data and updates internal domain objects
plaid_item.process_accounts
# Phase 2: Collect setup statistics
sync.update!(status_text: "Checking account configuration...") if sync.respond_to?(:status_text)
collect_setup_stats(sync, provider_accounts: plaid_item.plaid_accounts)
# All data is synced, so we can now run an account sync to calculate historical balances and more
plaid_item.schedule_account_syncs(
parent_sync: sync,
window_start_date: sync.window_start_date,
window_end_date: sync.window_end_date
)
# Check for unlinked accounts and update pending_account_setup flag
unlinked_count = plaid_item.plaid_accounts.count { |pa| pa.current_account.nil? }
if unlinked_count > 0
plaid_item.update!(pending_account_setup: true) if plaid_item.respond_to?(:pending_account_setup=)
sync.update!(status_text: "#{unlinked_count} accounts need setup...") if sync.respond_to?(:status_text)
else
plaid_item.update!(pending_account_setup: false) if plaid_item.respond_to?(:pending_account_setup=)
end
# Phase 3: Process the raw Plaid data and updates internal domain objects
linked_accounts = plaid_item.plaid_accounts.select { |pa| pa.current_account.present? }
if linked_accounts.any?
sync.update!(status_text: "Processing transactions...") if sync.respond_to?(:status_text)
mark_import_started(sync)
plaid_item.process_accounts
# Phase 4: Schedule balance calculations
sync.update!(status_text: "Calculating balances...") if sync.respond_to?(:status_text)
plaid_item.schedule_account_syncs(
parent_sync: sync,
window_start_date: sync.window_start_date,
window_end_date: sync.window_end_date
)
# Phase 5: Collect transaction and holdings statistics
account_ids = linked_accounts.filter_map { |pa| pa.current_account&.id }
collect_transaction_stats(sync, account_ids: account_ids, source: "plaid")
collect_holdings_stats(sync, holdings_count: count_holdings(linked_accounts), label: "processed")
end
# Mark sync health
collect_health_stats(sync, errors: nil)
rescue => e
collect_health_stats(sync, errors: [ { message: e.message, category: "sync_error" } ])
raise
end
def perform_post_sync
# no-op
end
private
def count_holdings(plaid_accounts)
plaid_accounts.sum do |pa|
Array(pa.raw_investments_payload).size
end
end
end

View File

@@ -0,0 +1,184 @@
# API client for CoinStats cryptocurrency data provider.
# Handles authentication and requests to the CoinStats OpenAPI.
class Provider::Coinstats < Provider
include HTTParty
# Subclass so errors caught in this provider are raised as Provider::Coinstats::Error
Error = Class.new(Provider::Error)
BASE_URL = "https://openapiv1.coinstats.app"
headers "User-Agent" => "Sure Finance CoinStats Client (https://github.com/we-promise/sure)"
default_options.merge!(verify: true, ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER, timeout: 120)
attr_reader :api_key
# @param api_key [String] CoinStats API key for authentication
def initialize(api_key)
@api_key = api_key
end
# Get the list of blockchains supported by CoinStats
# https://coinstats.app/api-docs/openapi/get-blockchains
def get_blockchains
with_provider_response do
res = self.class.get("#{BASE_URL}/wallet/blockchains", headers: auth_headers)
handle_response(res)
end
end
# Returns blockchain options formatted for select dropdowns
# @return [Array<Array>] Array of [label, value] pairs sorted alphabetically
def blockchain_options
response = get_blockchains
unless response.success?
Rails.logger.warn("CoinStats: failed to fetch blockchains: #{response.error&.message}")
return []
end
raw_blockchains = response.data
items = if raw_blockchains.is_a?(Array)
raw_blockchains
elsif raw_blockchains.respond_to?(:dig) && raw_blockchains[:data].is_a?(Array)
raw_blockchains[:data]
else
[]
end
items.filter_map do |b|
b = b.with_indifferent_access
value = b[:connectionId] || b[:id] || b[:name]
next unless value.present?
label = b[:name].presence || value.to_s
[ label, value ]
end.uniq { |_label, value| value }.sort_by { |label, _| label.to_s.downcase }
rescue StandardError => e
Rails.logger.warn("CoinStats: failed to fetch blockchains: #{e.class} - #{e.message}")
[]
end
# Get cryptocurrency balances for multiple wallets in a single request
# https://coinstats.app/api-docs/openapi/get-wallet-balances
# @param wallets [String] Comma-separated list of wallet addresses in format "blockchain:address"
# Example: "ethereum:0x123abc,bitcoin:bc1qxyz"
# @return [Provider::Response] Response with wallet balance data
def get_wallet_balances(wallets)
return with_provider_response { [] } if wallets.blank?
with_provider_response do
res = self.class.get(
"#{BASE_URL}/wallet/balances",
headers: auth_headers,
query: { wallets: wallets }
)
handle_response(res)
end
end
# Extract balance data for a specific wallet from bulk response
# @param bulk_data [Array<Hash>] Response from get_wallet_balances
# @param address [String] Wallet address to find
# @param blockchain [String] Blockchain/connectionId to find
# @return [Array<Hash>] Token balances for the wallet, or empty array if not found
def extract_wallet_balance(bulk_data, address, blockchain)
return [] unless bulk_data.is_a?(Array)
wallet_data = bulk_data.find do |entry|
entry = entry.with_indifferent_access
entry[:address]&.downcase == address&.downcase &&
(entry[:connectionId]&.downcase == blockchain&.downcase ||
entry[:blockchain]&.downcase == blockchain&.downcase)
end
return [] unless wallet_data
wallet_data = wallet_data.with_indifferent_access
wallet_data[:balances] || []
end
# Get transaction data for multiple wallet addresses in a single request
# https://coinstats.app/api-docs/openapi/get-wallet-transactions
# @param wallets [String] Comma-separated list of wallet addresses in format "blockchain:address"
# Example: "ethereum:0x123abc,bitcoin:bc1qxyz"
# @return [Provider::Response] Response with wallet transaction data
def get_wallet_transactions(wallets)
return with_provider_response { [] } if wallets.blank?
with_provider_response do
res = self.class.get(
"#{BASE_URL}/wallet/transactions",
headers: auth_headers,
query: { wallets: wallets }
)
handle_response(res)
end
end
# Extract transaction data for a specific wallet from bulk response
# The transactions API returns {result: Array<transactions>, meta: {...}}
# All transactions in the response belong to the requested wallets
# @param bulk_data [Hash, Array] Response from get_wallet_transactions
# @param address [String] Wallet address to filter by (currently unused as API returns flat list)
# @param blockchain [String] Blockchain/connectionId to filter by (currently unused)
# @return [Array<Hash>] Transactions for the wallet, or empty array if not found
def extract_wallet_transactions(bulk_data, address, blockchain)
# Handle Hash response with :result key (current API format)
if bulk_data.is_a?(Hash)
bulk_data = bulk_data.with_indifferent_access
return bulk_data[:result] || []
end
# Handle legacy Array format (per-wallet structure)
return [] unless bulk_data.is_a?(Array)
wallet_data = bulk_data.find do |entry|
entry = entry.with_indifferent_access
entry[:address]&.downcase == address&.downcase &&
(entry[:connectionId]&.downcase == blockchain&.downcase ||
entry[:blockchain]&.downcase == blockchain&.downcase)
end
return [] unless wallet_data
wallet_data = wallet_data.with_indifferent_access
wallet_data[:transactions] || []
end
private
def auth_headers
{
"X-API-KEY" => api_key,
"Accept" => "application/json"
}
end
# The CoinStats API uses standard HTTP status codes to indicate the success or failure of requests.
# https://coinstats.app/api-docs/errors
def handle_response(response)
case response.code
when 200
JSON.parse(response.body, symbolize_names: true)
when 400
raise Error, "CoinStats: #{response.code} Bad Request - Invalid parameters or request format #{response.body}"
when 401
raise Error, "CoinStats: #{response.code} Unauthorized - Invalid or missing API key #{response.body}"
when 403
raise Error, "CoinStats: #{response.code} Forbidden - #{response.body}"
when 404
raise Error, "CoinStats: #{response.code} Not Found - Resource not found #{response.body}"
when 409
raise Error, "CoinStats: #{response.code} Conflict - Resource conflict #{response.body}"
when 429
raise Error, "CoinStats: #{response.code} Too Many Requests - Rate limit exceeded #{response.body}"
when 500
raise Error, "CoinStats: #{response.code} Internal Server Error - Server error #{response.body}"
when 503
raise Error, "CoinStats: #{response.code} Service Unavailable - #{response.body}"
else
raise Error, "CoinStats: #{response.code} Unexpected Error - #{response.body}"
end
end
end

View File

@@ -0,0 +1,119 @@
# Provider adapter for CoinStats cryptocurrency wallet integration.
# Handles sync operations and institution metadata for crypto accounts.
class Provider::CoinstatsAdapter < Provider::Base
include Provider::Syncable
include Provider::InstitutionMetadata
# Register this adapter with the factory
Provider::Factory.register("CoinstatsAccount", self)
# @return [Array<String>] Account types supported by this provider
def self.supported_account_types
%w[Crypto]
end
# Returns connection configurations for this provider
# @param family [Family] The family to check connection eligibility
# @return [Array<Hash>] Connection config with name, description, and paths
def self.connection_configs(family:)
return [] unless family.can_connect_coinstats?
[ {
key: "coinstats",
name: "CoinStats",
description: "Connect to your crypto wallet via CoinStats",
can_connect: true,
new_account_path: ->(accountable_type, return_to) {
Rails.application.routes.url_helpers.new_coinstats_item_path(
accountable_type: accountable_type,
return_to: return_to
)
},
# CoinStats wallets are linked via the link_wallet action, not via existing account selection
existing_account_path: nil
} ]
end
# @return [String] Unique identifier for this provider
def provider_name
"coinstats"
end
# Build a Coinstats provider instance with family-specific credentials
# @param family [Family] The family to get credentials for (required)
# @return [Provider::Coinstats, nil] Returns nil if credentials are not configured
def self.build_provider(family: nil)
return nil unless family.present?
# Get family-specific credentials
coinstats_item = family.coinstats_items.where.not(api_key: nil).first
return nil unless coinstats_item&.credentials_configured?
Provider::Coinstats.new(coinstats_item.api_key)
end
# @return [String] URL path for triggering a sync
def sync_path
Rails.application.routes.url_helpers.sync_coinstats_item_path(item)
end
# @return [CoinstatsItem] The parent item containing API credentials
def item
provider_account.coinstats_item
end
# @return [Boolean] Whether holdings can be manually deleted
def can_delete_holdings?
false
end
# Extracts institution domain from metadata, deriving from URL if needed.
# @return [String, nil] Domain name or nil if unavailable
def institution_domain
metadata = provider_account.institution_metadata
return nil unless metadata.present?
domain = metadata["domain"]
url = metadata["url"]
# Derive domain from URL if missing
if domain.blank? && url.present?
begin
domain = URI.parse(url).host&.gsub(/^www\./, "")
rescue URI::InvalidURIError
Rails.logger.warn("Invalid institution URL for Coinstats account #{provider_account.id}: #{url}")
end
end
domain
end
# @return [String, nil] Institution display name
def institution_name
metadata = provider_account.institution_metadata
return nil unless metadata.present?
metadata["name"]
end
# @return [String, nil] Institution website URL
def institution_url
metadata = provider_account.institution_metadata
return nil unless metadata.present?
metadata["url"]
end
# @return [nil] CoinStats doesn't provide institution colors
def institution_color
nil # CoinStats doesn't provide institution colors
end
# @return [String, nil] URL for institution/token logo
def logo_url
metadata = provider_account.institution_metadata
return nil unless metadata.present?
metadata["logo"]
end
end

View File

@@ -27,6 +27,12 @@ module Provider::InstitutionMetadata
nil
end
# Returns the institution/account logo URL (direct image URL)
# @return [String, nil] The logo URL or nil if not available
def logo_url
nil
end
# Returns a hash of all institution metadata
# @return [Hash] Hash containing institution metadata
def institution_metadata
@@ -34,7 +40,8 @@ module Provider::InstitutionMetadata
domain: institution_domain,
name: institution_name,
url: institution_url,
color: institution_color
color: institution_color,
logo_url: logo_url
}.compact
end
end

View File

@@ -78,6 +78,31 @@ class Provider::Lunchflow
raise LunchflowError.new("Exception during GET request: #{e.message}", :request_failed)
end
# Get holdings for a specific account (investment accounts only)
# Returns: { holdings: [...], totalValue: N, currency: "USD" }
# Returns { holdings_not_supported: true } if API returns 501
def get_account_holdings(account_id)
path = "/accounts/#{ERB::Util.url_encode(account_id.to_s)}/holdings"
response = self.class.get(
"#{@base_url}#{path}",
headers: auth_headers
)
# Handle 501 specially - indicates holdings not supported for this account
if response.code == 501
return { holdings_not_supported: true }
end
handle_response(response)
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
Rails.logger.error "Lunch Flow API: GET #{path} failed: #{e.class}: #{e.message}"
raise LunchflowError.new("Exception during GET request: #{e.message}", :request_failed)
rescue => e
Rails.logger.error "Lunch Flow API: Unexpected error during GET #{path}: #{e.class}: #{e.message}"
raise LunchflowError.new("Exception during GET request: #{e.message}", :request_failed)
end
private
def auth_headers

View File

@@ -9,6 +9,22 @@ class Provider::Simplefin
headers "User-Agent" => "Sure Finance SimpleFin Client"
default_options.merge!(verify: true, ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER, timeout: 120)
# Retry configuration for transient network failures
MAX_RETRIES = 3
INITIAL_RETRY_DELAY = 2 # seconds
MAX_RETRY_DELAY = 30 # seconds
# Errors that are safe to retry (transient network issues)
RETRYABLE_ERRORS = [
SocketError,
Net::OpenTimeout,
Net::ReadTimeout,
Errno::ECONNRESET,
Errno::ECONNREFUSED,
Errno::ETIMEDOUT,
EOFError
].freeze
def initialize
end
@@ -16,7 +32,11 @@ class Provider::Simplefin
# Decode the base64 setup token to get the claim URL
claim_url = Base64.decode64(setup_token)
response = HTTParty.post(claim_url)
# Use retry logic for transient network failures during token claim
# Claim should be fast; keep request-path latency bounded.
response = with_retries("POST /claim", max_retries: 1, sleep: false) do
HTTParty.post(claim_url, timeout: 15)
end
case response.code
when 200
@@ -49,19 +69,12 @@ class Provider::Simplefin
accounts_url = "#{access_url}/accounts"
accounts_url += "?#{URI.encode_www_form(query_params)}" unless query_params.empty?
# The access URL already contains HTTP Basic Auth credentials
begin
response = HTTParty.get(accounts_url)
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
Rails.logger.error "SimpleFin API: GET /accounts failed: #{e.class}: #{e.message}"
raise SimplefinError.new("Exception during GET request: #{e.message}", :request_failed)
rescue => e
Rails.logger.error "SimpleFin API: Unexpected error during GET /accounts: #{e.class}: #{e.message}"
raise SimplefinError.new("Exception during GET request: #{e.message}", :request_failed)
# Use retry logic with exponential backoff for transient network failures
response = with_retries("GET /accounts") do
HTTParty.get(accounts_url)
end
case response.code
when 200
JSON.parse(response.body, symbolize_names: true)
@@ -72,6 +85,12 @@ class Provider::Simplefin
raise SimplefinError.new("Access URL is no longer valid", :access_forbidden)
when 402
raise SimplefinError.new("Payment required to access this account", :payment_required)
when 429
Rails.logger.warn "SimpleFin API: Rate limited - #{response.body}"
raise SimplefinError.new("SimpleFin rate limit exceeded. Please try again later.", :rate_limited)
when 500..599
Rails.logger.error "SimpleFin API: Server error - Code: #{response.code}, Body: #{response.body}"
raise SimplefinError.new("SimpleFin server error (#{response.code}). Please try again later.", :server_error)
else
Rails.logger.error "SimpleFin API: Unexpected response - Code: #{response.code}, Body: #{response.body}"
raise SimplefinError.new("Failed to fetch accounts: #{response.code} #{response.message} - #{response.body}", :fetch_failed)
@@ -97,4 +116,55 @@ class Provider::Simplefin
@error_type = error_type
end
end
private
# Execute a block with retry logic and exponential backoff for transient network errors.
# This helps handle temporary network issues that cause autosync failures while
# manual sync (with user retry) succeeds.
def with_retries(operation_name, max_retries: MAX_RETRIES, sleep: true)
retries = 0
begin
yield
rescue *RETRYABLE_ERRORS => e
retries += 1
if retries <= max_retries
delay = calculate_retry_delay(retries)
Rails.logger.warn(
"SimpleFin API: #{operation_name} failed (attempt #{retries}/#{max_retries}): " \
"#{e.class}: #{e.message}. Retrying in #{delay}s..."
)
Kernel.sleep(delay) if sleep && delay.to_f.positive?
retry
else
Rails.logger.error(
"SimpleFin API: #{operation_name} failed after #{max_retries} retries: " \
"#{e.class}: #{e.message}"
)
raise SimplefinError.new(
"Network error after #{max_retries} retries: #{e.message}",
:network_error
)
end
rescue SimplefinError => e
# Preserve original error type and message.
raise
rescue => e
# Non-retryable errors are logged and re-raised immediately
Rails.logger.error "SimpleFin API: #{operation_name} failed with non-retryable error: #{e.class}: #{e.message}"
raise SimplefinError.new("Exception during #{operation_name}: #{e.message}", :request_failed)
end
end
# Calculate delay with exponential backoff and jitter
def calculate_retry_delay(retry_count)
# Exponential backoff: 2^retry * initial_delay
base_delay = INITIAL_RETRY_DELAY * (2 ** (retry_count - 1))
# Add jitter (0-25% of base delay) to prevent thundering herd
jitter = base_delay * rand * 0.25
# Cap at max delay
[ base_delay + jitter, MAX_RETRY_DELAY ].min
end
end

View File

@@ -260,7 +260,7 @@ class Provider::YahooFinance < Provider
prices << Price.new(
symbol: symbol,
date: Time.at(timestamp).to_date,
date: Time.at(timestamp).utc.to_date,
price: normalized_price,
currency: normalized_currency,
exchange_operating_mic: exchange_operating_mic
@@ -390,7 +390,7 @@ class Provider::YahooFinance < Provider
symbol = "#{from}#{to}=X"
fetch_chart_data(symbol, start_date, end_date) do |timestamp, close_rate|
Rate.new(
date: Time.at(timestamp).to_date,
date: Time.at(timestamp).utc.to_date,
from: from,
to: to,
rate: close_rate.to_f
@@ -402,7 +402,7 @@ class Provider::YahooFinance < Provider
symbol = "#{to}#{from}=X"
rates = fetch_chart_data(symbol, start_date, end_date) do |timestamp, close_rate|
Rate.new(
date: Time.at(timestamp).to_date,
date: Time.at(timestamp).utc.to_date,
from: from,
to: to,
rate: (1.0 / close_rate.to_f).round(8)

View File

@@ -1,6 +1,36 @@
class ProviderMerchant < Merchant
enum :source, { plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking" }
enum :source, { plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats" }
validates :name, uniqueness: { scope: [ :source ] }
validates :source, presence: true
# Convert this ProviderMerchant to a FamilyMerchant for a specific family.
# Only affects transactions belonging to that family.
# Returns the newly created FamilyMerchant.
def convert_to_family_merchant_for(family, attributes = {})
transaction do
family_merchant = family.merchants.create!(
name: attributes[:name].presence || name,
color: attributes[:color].presence || FamilyMerchant::COLORS.sample,
logo_url: logo_url,
website_url: website_url
)
# Update only this family's transactions to point to new merchant
family.transactions.where(merchant_id: id).update_all(merchant_id: family_merchant.id)
family_merchant
end
end
# Unlink from family's transactions (set merchant_id to null).
# Does NOT delete the ProviderMerchant since it may be used by other families.
# Tracks the unlink in FamilyMerchantAssociation so it shows as "recently unlinked".
def unlink_from_family(family)
family.transactions.where(merchant_id: id).update_all(merchant_id: nil)
# Track that this merchant was unlinked from this family
association = FamilyMerchantAssociation.find_or_initialize_by(family: family, merchant: self)
association.update!(unlinked_at: Time.current)
end
end

View File

@@ -40,6 +40,20 @@ class Rule < ApplicationRecord
matching_resources_scope.count
end
# Calculates total unique resources affected across multiple rules
# This handles overlapping rules by deduplicating transaction IDs
def self.total_affected_resource_count(rules)
return 0 if rules.empty?
# Collect all unique transaction IDs matched by any rule
transaction_ids = Set.new
rules.each do |rule|
transaction_ids.merge(rule.send(:matching_resources_scope).pluck(:id))
end
transaction_ids.size
end
def apply(ignore_attribute_locks: false, rule_run: nil)
total_modified = 0
total_async_jobs = 0

View File

@@ -20,7 +20,7 @@ class RuleImport < Import
end
def dry_run
{ rules: rows.count }
{ rules: rows_count }
end
def csv_template

View File

@@ -27,7 +27,25 @@ class Security < ApplicationRecord
)
end
def brandfetch_icon_url(width: 40, height: 40)
return nil unless Setting.brand_fetch_client_id.present? && website_url.present?
domain = extract_domain(website_url)
return nil unless domain.present?
"https://cdn.brandfetch.io/#{domain}/icon/fallback/lettermark/w/#{width}/h/#{height}?c=#{Setting.brand_fetch_client_id}"
end
private
def extract_domain(url)
uri = URI.parse(url)
host = uri.host || url
host.sub(/\Awww\./, "")
rescue URI::InvalidURIError
nil
end
def upcase_symbols
self.ticker = ticker.upcase
self.exchange_operating_mic = exchange_operating_mic.upcase if exchange_operating_mic.present?

View File

@@ -3,4 +3,13 @@ class Security::Price < ApplicationRecord
validates :date, :price, :currency, presence: true
validates :date, uniqueness: { scope: %i[security_id currency] }
# Provisional prices from recent days that should be re-fetched
# - Must be provisional (gap-filled)
# - Must be from the last few days (configurable, default 7)
# - Includes weekends: they get fixed via cascade when weekday prices are fetched
scope :refetchable_provisional, ->(lookback_days: 7) {
where(provisional: true)
.where(date: lookback_days.days.ago.to_date..Date.current)
}
end

View File

@@ -2,6 +2,8 @@ class Security::Price::Importer
MissingSecurityPriceError = Class.new(StandardError)
MissingStartPriceError = Class.new(StandardError)
PROVISIONAL_LOOKBACK_DAYS = 7
def initialize(security:, security_provider:, start_date:, end_date:, clear_cache: false)
@security = security
@security_provider = security_provider
@@ -24,6 +26,7 @@ class Security::Price::Importer
end
prev_price_value = start_price_value
prev_currency = prev_price_currency || db_price_currency || "USD"
unless prev_price_value.present?
Rails.logger.error("Could not find a start price for #{security.ticker} on or before #{start_date}")
@@ -40,28 +43,53 @@ class Security::Price::Importer
end
gapfilled_prices = effective_start_date.upto(end_date).map do |date|
db_price_value = db_prices[date]&.price
provider_price_value = provider_prices[date]&.price
provider_currency = provider_prices[date]&.currency
db_price = db_prices[date]
db_price_value = db_price&.price
provider_price = provider_prices[date]
provider_price_value = provider_price&.price
provider_currency = provider_price&.currency
chosen_price = if clear_cache
provider_price_value || db_price_value # overwrite when possible
has_provider_price = provider_price_value.present? && provider_price_value.to_f > 0
has_db_price = db_price_value.present? && db_price_value.to_f > 0
is_provisional = db_price&.provisional
# Choose price and currency from the same source to avoid mismatches
chosen_price, chosen_currency = if clear_cache || is_provisional
# For provisional/cache clear: only use provider price, let gap-fill handle missing
# This ensures stale DB values don't persist when provider has no weekend data
[ provider_price_value, provider_currency ]
elsif has_db_price
# For non-provisional with valid DB price: preserve existing value (user edits)
[ db_price_value, db_price&.currency ]
else
db_price_value || provider_price_value # fill gaps
# Fill gaps with provider data
[ provider_price_value, provider_currency ]
end
# Gap-fill using LOCF (last observation carried forward)
# Treat nil or zero prices as invalid and use previous price
# Treat nil or zero prices as invalid and use previous price/currency
used_locf = false
if chosen_price.nil? || chosen_price.to_f <= 0
chosen_price = prev_price_value
chosen_currency = prev_currency
used_locf = true
end
prev_price_value = chosen_price
prev_currency = chosen_currency || prev_currency
provisional = determine_provisional_status(
date: date,
has_provider_price: has_provider_price,
used_locf: used_locf,
existing_provisional: db_price&.provisional
)
{
security_id: security.id,
date: date,
price: chosen_price,
currency: provider_currency || prev_price_currency || db_price_currency || "USD"
currency: chosen_currency || "USD",
provisional: provisional
}
end
@@ -73,7 +101,7 @@ class Security::Price::Importer
def provider_prices
@provider_prices ||= begin
provider_fetch_start_date = effective_start_date - 5.days
provider_fetch_start_date = effective_start_date - PROVISIONAL_LOOKBACK_DAYS.days
response = security_provider.fetch_security_prices(
symbol: security.ticker,
@@ -104,33 +132,97 @@ class Security::Price::Importer
end
def all_prices_exist?
return false if has_refetchable_provisional_prices?
db_prices.count == expected_count
end
def has_refetchable_provisional_prices?
Security::Price.where(security_id: security.id, date: start_date..end_date)
.refetchable_provisional(lookback_days: PROVISIONAL_LOOKBACK_DAYS)
.exists?
end
def expected_count
(start_date..end_date).count
end
# Skip over ranges that already exist unless clearing cache
# Also includes dates with refetchable provisional prices
def effective_start_date
return start_date if clear_cache
(start_date..end_date).detect { |d| !db_prices.key?(d) } || end_date
refetchable_dates = Security::Price.where(security_id: security.id, date: start_date..end_date)
.refetchable_provisional(lookback_days: PROVISIONAL_LOOKBACK_DAYS)
.pluck(:date)
.to_set
(start_date..end_date).detect do |d|
!db_prices.key?(d) || refetchable_dates.include?(d)
end || end_date
end
def start_price_value
provider_price_value = provider_prices.select { |date, _| date <= start_date }
.max_by { |date, _| date }
&.last&.price
db_price_value = db_prices[start_date]&.price
provider_price_value || db_price_value
# When processing full range (first sync), use original behavior
if effective_start_date == start_date
provider_price_value = provider_prices.select { |date, _| date <= start_date }
.max_by { |date, _| date }
&.last&.price
db_price_value = db_prices[start_date]&.price
return provider_price_value if provider_price_value.present? && provider_price_value.to_f > 0
return db_price_value if db_price_value.present? && db_price_value.to_f > 0
return nil
end
# For partial range (effective_start_date > start_date), use recent data
# This prevents stale prices from old trade dates propagating to current gap-fills
cutoff_date = effective_start_date
# First try provider prices (most recent before cutoff)
provider_price_value = provider_prices
.select { |date, _| date < cutoff_date }
.max_by { |date, _| date }
&.last&.price
return provider_price_value if provider_price_value.present? && provider_price_value.to_f > 0
# Fall back to most recent DB price before cutoff
currency = prev_price_currency || db_price_currency
Security::Price
.where(security_id: security.id)
.where("date < ?", cutoff_date)
.where("price > 0")
.where(provisional: false)
.then { |q| currency.present? ? q.where(currency: currency) : q }
.order(date: :desc)
.limit(1)
.pick(:price)
end
def determine_provisional_status(date:, has_provider_price:, used_locf:, existing_provisional:)
# Provider returned real price => NOT provisional
return false if has_provider_price
# Gap-filled (LOCF) => provisional if recent (including weekends)
# Weekend prices inherit uncertainty from Friday and get fixed via cascade
# when the next weekday sync fetches correct Friday price
if used_locf
is_recent = date >= PROVISIONAL_LOOKBACK_DAYS.days.ago.to_date
return is_recent
end
# Otherwise preserve existing status
existing_provisional || false
end
def upsert_rows(rows)
batch_size = 200
total_upsert_count = 0
now = Time.current
rows.each_slice(batch_size) do |batch|
rows_with_timestamps = rows.map { |row| row.merge(updated_at: now) }
rows_with_timestamps.each_slice(batch_size) do |batch|
ids = Security::Price.upsert_all(
batch,
unique_by: %i[security_id date currency],

View File

@@ -68,7 +68,7 @@ module Security::Provided
return
end
if self.name.present? && self.logo_url.present? && !clear_cache
if self.name.present? && (self.logo_url.present? || self.website_url.present?) && !clear_cache
return
end
@@ -81,6 +81,7 @@ module Security::Provided
update(
name: response.data.name,
logo_url: response.data.logo_url,
website_url: response.data.links
)
else
Rails.logger.warn("Failed to fetch security info for #{ticker} from #{provider.class.name}: #{response.error.message}")

View File

@@ -12,6 +12,11 @@ module Simplefin
CREDIT_NAME_KEYWORDS = /\b(credit|card)\b/i.freeze
CREDIT_BRAND_KEYWORDS = /\b(visa|mastercard|amex|american express|discover|apple card|freedom unlimited|quicksilver)\b/i.freeze
LOAN_KEYWORDS = /\b(loan|mortgage|heloc|line of credit|loc)\b/i.freeze
CHECKING_KEYWORDS = /\b(checking|chequing|dda|demand deposit)\b/i.freeze
SAVINGS_KEYWORDS = /\b(savings|sav|money market|mma|high.yield)\b/i.freeze
CRYPTO_KEYWORDS = /\b(bitcoin|btc|ethereum|eth|crypto|cryptocurrency|litecoin|dogecoin|solana)\b/i.freeze
# "Cash" as a standalone name (not "cash back", "cash rewards", etc.)
CASH_ACCOUNT_PATTERN = /\A\s*cash\s*\z/i.freeze
# Explicit investment subtype tokens mapped to known SUBTYPES keys
EXPLICIT_INVESTMENT_TOKENS = {
@@ -53,18 +58,23 @@ module Simplefin
end
end
# 1) Holdings present => Investment (high confidence)
# 1) Crypto keywords → Crypto account (check before holdings since crypto accounts may have holdings)
if CRYPTO_KEYWORDS.match?(nm)
return Inference.new(accountable_type: "Crypto", confidence: :high)
end
# 2) Holdings present => Investment (high confidence)
if holdings_present
# Do not guess generic retirement; explicit tokens handled above
return Inference.new(accountable_type: "Investment", subtype: nil, confidence: :high)
end
# 2) Name suggests LOAN (high confidence)
# 3) Name suggests LOAN (high confidence)
if LOAN_KEYWORDS.match?(nm)
return Inference.new(accountable_type: "Loan", confidence: :high)
end
# 3) Credit card signals
# 4) Credit card signals
# - Name contains credit/card (medium to high)
# - Card brands (Visa/Mastercard/Amex/Discover/Apple Card) → high
# - Or negative balance with available-balance present (medium)
@@ -76,14 +86,26 @@ module Simplefin
return Inference.new(accountable_type: "CreditCard", confidence: :high)
end
# 4) Retirement keywords without holdings still point to Investment (retirement)
# 5) Retirement keywords without holdings still point to Investment (retirement)
if RETIREMENT_KEYWORDS.match?(nm)
# If the name contains 'brokerage', avoid forcing retirement subtype
subtype = BROKERAGE_KEYWORD.match?(nm) ? nil : "retirement"
return Inference.new(accountable_type: "Investment", subtype: subtype, confidence: :high)
end
# 5) Default
# 6) Checking/Savings/Cash accounts (high confidence when name explicitly says so)
if CHECKING_KEYWORDS.match?(nm)
return Inference.new(accountable_type: "Depository", subtype: "checking", confidence: :high)
end
if SAVINGS_KEYWORDS.match?(nm)
return Inference.new(accountable_type: "Depository", subtype: "savings", confidence: :high)
end
# "Cash" as a standalone account name (like Cash App's "Cash" account) → checking
if CASH_ACCOUNT_PATTERN.match?(nm)
return Inference.new(accountable_type: "Depository", subtype: "checking", confidence: :high)
end
# 7) Default - unknown account type, let user decide
Inference.new(accountable_type: "Depository", confidence: :low)
end
end

View File

@@ -80,7 +80,7 @@ class SimplefinAccount < ApplicationRecord
end
def parse_currency(currency_value)
return "USD" if currency_value.nil?
return "USD" if currency_value.blank?
# SimpleFin currency can be a 3-letter code or a URL for custom currencies
if currency_value.start_with?("http")

View File

@@ -9,13 +9,29 @@ class SimplefinAccount::Investments::HoldingsProcessor
holdings_data.each do |simplefin_holding|
begin
symbol = simplefin_holding["symbol"]
symbol = simplefin_holding["symbol"].presence
holding_id = simplefin_holding["id"]
description = simplefin_holding["description"].to_s.strip
Rails.logger.debug({ event: "simplefin.holding.start", sfa_id: simplefin_account.id, account_id: account&.id, id: holding_id, symbol: symbol, raw: simplefin_holding }.to_json)
unless symbol.present? && holding_id.present?
Rails.logger.debug({ event: "simplefin.holding.skip", reason: "missing_symbol_or_id", id: holding_id, symbol: symbol }.to_json)
unless holding_id.present?
Rails.logger.debug({ event: "simplefin.holding.skip", reason: "missing_id", id: holding_id, symbol: symbol }.to_json)
next
end
# If symbol is missing but we have a description, create a synthetic ticker
# This allows tracking holdings like 401k funds that don't have standard symbols
# Append a hash suffix to ensure uniqueness for similar descriptions
if symbol.blank? && description.present?
normalized = description.gsub(/[^a-zA-Z0-9]/, "_").upcase.truncate(24, omission: "")
hash_suffix = Digest::MD5.hexdigest(description)[0..4].upcase
symbol = "CUSTOM:#{normalized}_#{hash_suffix}"
Rails.logger.info("SimpleFin: using synthetic ticker #{symbol} for holding #{holding_id} (#{description})")
end
unless symbol.present?
Rails.logger.debug({ event: "simplefin.holding.skip", reason: "no_symbol_or_description", id: holding_id }.to_json)
next
end
@@ -57,7 +73,7 @@ class SimplefinAccount::Investments::HoldingsProcessor
security: security,
quantity: qty,
amount: computed_amount,
currency: simplefin_holding["currency"] || "USD",
currency: simplefin_holding["currency"].presence || "USD",
date: holding_date,
price: price,
cost_basis: cost_basis,
@@ -101,14 +117,23 @@ class SimplefinAccount::Investments::HoldingsProcessor
if !sym.include?(":") && (is_crypto_account || is_crypto_symbol || mentions_crypto)
sym = "CRYPTO:#{sym}"
end
# Custom tickers (from holdings without symbols) should always be offline
is_custom = sym.start_with?("CUSTOM:")
# Use Security::Resolver to find or create the security, but be resilient
begin
if is_custom
# Skip resolver for custom tickers - create offline security directly
raise "Custom ticker - skipping resolver"
end
Security::Resolver.new(sym).resolve
rescue => e
# If provider search fails or any unexpected error occurs, fall back to an offline security
Rails.logger.warn "SimpleFin: resolver failed for symbol=#{sym}: #{e.class} - #{e.message}; falling back to offline security"
Rails.logger.warn "SimpleFin: resolver failed for symbol=#{sym}: #{e.class} - #{e.message}; falling back to offline security" unless is_custom
Security.find_or_initialize_by(ticker: sym).tap do |sec|
sec.offline = true if sec.respond_to?(:offline) && sec.offline != true
sec.name = description.presence if sec.name.blank? && description.present?
sec.save! if sec.changed?
end
end

View File

@@ -1,85 +1,33 @@
# SimpleFin Investment transactions processor
# Processes investment-specific transactions like trades, dividends, etc.
#
# NOTE: SimpleFIN transactions (dividends, contributions, etc.) for investment accounts
# are already processed by SimplefinAccount::Transactions::Processor, which handles ALL
# account types including investments. That processor uses SimplefinEntry::Processor
# which captures full metadata (merchant, notes, extra data).
#
# This processor is intentionally a no-op for transactions to avoid:
# 1. Duplicate processing of the same transactions
# 2. Overwriting richer data with less complete data
#
# Unlike Plaid (which has a separate investment_transactions endpoint), SimpleFIN returns
# all transactions in a single `transactions` array regardless of account type.
#
# Holdings are processed separately by SimplefinAccount::Investments::HoldingsProcessor.
class SimplefinAccount::Investments::TransactionsProcessor
def initialize(simplefin_account)
@simplefin_account = simplefin_account
end
def process
return unless simplefin_account.current_account&.accountable_type == "Investment"
return unless simplefin_account.raw_transactions_payload.present?
transactions_data = simplefin_account.raw_transactions_payload
transactions_data.each do |transaction_data|
process_investment_transaction(transaction_data)
end
# Intentionally a no-op for transactions.
# SimpleFIN investment transactions are already processed by the regular
# SimplefinAccount::Transactions::Processor which handles all account types.
#
# This avoids duplicate processing and ensures the richer metadata from
# SimplefinEntry::Processor (merchant, notes, extra) is preserved.
Rails.logger.debug "SimplefinAccount::Investments::TransactionsProcessor - Skipping (transactions handled by SimplefinAccount::Transactions::Processor)"
end
private
attr_reader :simplefin_account
def account
simplefin_account.current_account
end
def process_investment_transaction(transaction_data)
data = transaction_data.with_indifferent_access
amount = parse_amount(data[:amount])
posted_date = parse_date(data[:posted])
external_id = "simplefin_#{data[:id]}"
# Use the unified import adapter for consistent handling
import_adapter.import_transaction(
external_id: external_id,
amount: amount,
currency: account.currency,
date: posted_date,
name: data[:description] || "Investment transaction",
source: "simplefin"
)
rescue => e
Rails.logger.error("Failed to process SimpleFin investment transaction #{data[:id]}: #{e.message}")
end
def import_adapter
@import_adapter ||= Account::ProviderImportAdapter.new(account)
end
def parse_amount(amount_value)
parsed_amount = case amount_value
when String
BigDecimal(amount_value)
when Numeric
BigDecimal(amount_value.to_s)
else
BigDecimal("0")
end
# SimpleFin uses banking convention, Maybe expects opposite
-parsed_amount
rescue ArgumentError => e
Rails.logger.error "Failed to parse SimpleFin investment transaction amount: #{amount_value.inspect} - #{e.message}"
BigDecimal("0")
end
def parse_date(date_value)
case date_value
when String
Date.parse(date_value)
when Integer, Float
Time.at(date_value).to_date
when Time, DateTime
date_value.to_date
when Date
date_value
else
Rails.logger.error("SimpleFin investment transaction has invalid date value: #{date_value.inspect}")
raise ArgumentError, "Invalid date format: #{date_value.inspect}"
end
rescue ArgumentError, TypeError => e
Rails.logger.error("Failed to parse SimpleFin investment transaction date '#{date_value}': #{e.message}")
raise ArgumentError, "Unable to parse transaction date: #{date_value.inspect}"
end
end

View File

@@ -0,0 +1,225 @@
# frozen_string_literal: true
# Classifies a SimpleFIN liability balance as :debt (owe, show positive)
# or :credit (overpaid, show negative) using recent transaction history.
#
# Notes:
# - Preferred signal: already-imported Entry records for the linked Account
# (they are in Maybe's convention: expenses/charges > 0, payments < 0).
# - Fallback signal: provider raw transactions payload with amounts converted
# to Maybe convention by negating SimpleFIN's banking convention.
# - Returns :unknown when evidence is insufficient; callers should fallback
# to existing sign-only normalization.
class SimplefinAccount::Liabilities::OverpaymentAnalyzer
include SimplefinNumericHelpers
Result = Struct.new(:classification, :reason, :metrics, keyword_init: true)
DEFAULTS = {
window_days: 120,
min_txns: 10,
min_payments: 2,
epsilon_base: BigDecimal("0.50"),
statement_guard_days: 5,
sticky_days: 7
}.freeze
def initialize(simplefin_account, observed_balance:, now: Time.current)
@sfa = simplefin_account
@observed = to_decimal(observed_balance)
@now = now
end
def call
return unknown("flag disabled") unless enabled?
return unknown("no-account") unless (account = @sfa.current_account)
# Only applicable for liabilities
return unknown("not-liability") unless %w[CreditCard Loan].include?(account.accountable_type)
# Near-zero observed balances are too noisy to infer
return unknown("near-zero-balance") if @observed.abs <= epsilon_base
# Sticky cache via Rails.cache to avoid DB schema changes
sticky = read_sticky
if sticky && sticky[:expires_at] > @now
return Result.new(classification: sticky[:value].to_sym, reason: "sticky_hint", metrics: {})
end
txns = gather_transactions(account)
return unknown("insufficient-txns") if txns.size < min_txns
metrics = compute_metrics(txns)
cls, reason = classify(metrics)
if %i[credit debt].include?(cls)
write_sticky(cls)
end
Result.new(classification: cls, reason: reason, metrics: metrics)
end
private
def enabled?
# Setting override takes precedence, then ENV, then default enabled
setting_val = Setting["simplefin_cc_overpayment_detection"]
return parse_bool(setting_val) unless setting_val.nil?
env_val = ENV["SIMPLEFIN_CC_OVERPAYMENT_HEURISTIC"]
return parse_bool(env_val) if env_val.present?
true # Default enabled
end
def parse_bool(value)
case value
when true, false then value
when String then %w[1 true yes on].include?(value.downcase)
else false
end
end
def window_days
val = Setting["simplefin_cc_overpayment_window_days"]
v = (val.presence || DEFAULTS[:window_days]).to_i
v > 0 ? v : DEFAULTS[:window_days]
end
def min_txns
val = Setting["simplefin_cc_overpayment_min_txns"]
v = (val.presence || DEFAULTS[:min_txns]).to_i
v > 0 ? v : DEFAULTS[:min_txns]
end
def min_payments
val = Setting["simplefin_cc_overpayment_min_payments"]
v = (val.presence || DEFAULTS[:min_payments]).to_i
v > 0 ? v : DEFAULTS[:min_payments]
end
def epsilon_base
val = Setting["simplefin_cc_overpayment_epsilon_base"]
d = to_decimal(val.presence || DEFAULTS[:epsilon_base])
d > 0 ? d : DEFAULTS[:epsilon_base]
end
def statement_guard_days
val = Setting["simplefin_cc_overpayment_statement_guard_days"]
v = (val.presence || DEFAULTS[:statement_guard_days]).to_i
v >= 0 ? v : DEFAULTS[:statement_guard_days]
end
def sticky_days
val = Setting["simplefin_cc_overpayment_sticky_days"]
v = (val.presence || DEFAULTS[:sticky_days]).to_i
v > 0 ? v : DEFAULTS[:sticky_days]
end
def gather_transactions(account)
start_date = (@now.to_date - window_days.days)
# Prefer materialized entries
entries = account.entries.where("date >= ?", start_date).select(:amount, :date)
txns = entries.map { |e| { amount: to_decimal(e.amount), date: e.date } }
return txns if txns.size >= min_txns
# Fallback: provider raw payload
raw = Array(@sfa.raw_transactions_payload)
raw_txns = raw.filter_map do |tx|
h = tx.with_indifferent_access
amt = convert_provider_amount(h[:amount])
d = (
Simplefin::DateUtils.parse_provider_date(h[:posted]) ||
Simplefin::DateUtils.parse_provider_date(h[:transacted_at])
)
next nil unless d
next nil if d < start_date
{ amount: amt, date: d }
end
raw_txns
rescue => e
Rails.logger.debug("SimpleFIN transaction gathering failed for sfa=#{@sfa.id}: #{e.class} - #{e.message}")
[]
end
def compute_metrics(txns)
charges = BigDecimal("0")
payments = BigDecimal("0")
payments_count = 0
recent_payment = false
guard_since = (@now.to_date - statement_guard_days.days)
txns.each do |t|
amt = to_decimal(t[:amount])
if amt.positive?
charges += amt
elsif amt.negative?
payments += -amt
payments_count += 1
recent_payment ||= (t[:date] >= guard_since)
end
end
net = charges - payments
{
charges_total: charges,
payments_total: payments,
payments_count: payments_count,
tx_count: txns.size,
net: net,
observed: @observed,
window_days: window_days,
recent_payment: recent_payment
}
end
def classify(m)
# Boundary guard: a single very recent payment may create temporary credit before charges post
if m[:recent_payment] && m[:payments_count] <= 2
return [ :unknown, "statement-guard" ]
end
eps = [ epsilon_base, (@observed.abs * BigDecimal("0.005")) ].max
# Overpayment (credit): payments exceed charges by at least the observed balance (within eps)
if (m[:payments_total] - m[:charges_total]) >= (@observed.abs - eps)
return [ :credit, "payments>=charges+observed-eps" ]
end
# Debt: charges exceed payments beyond epsilon
if (m[:charges_total] - m[:payments_total]) > eps && m[:payments_count] >= min_payments
return [ :debt, "charges>payments+eps" ]
end
[ :unknown, "ambiguous" ]
end
def convert_provider_amount(val)
amt = case val
when String then BigDecimal(val) rescue BigDecimal("0")
when Numeric then BigDecimal(val.to_s)
else BigDecimal("0")
end
# Negate to convert banking convention (expenses negative) -> Maybe convention
-amt
end
def read_sticky
Rails.cache.read(sticky_key)
end
def write_sticky(value)
Rails.cache.write(sticky_key, { value: value.to_s, expires_at: @now + sticky_days.days }, expires_in: sticky_days.days)
end
def sticky_key
id = @sfa.id || "tmp:#{@sfa.object_id}"
"simplefin:sfa:#{id}:liability_sign_hint"
end
# numeric coercion handled by SimplefinNumericHelpers#to_decimal
def unknown(reason)
Result.new(classification: :unknown, reason: reason, metrics: {})
end
end

View File

@@ -1,4 +1,5 @@
class SimplefinAccount::Processor
include SimplefinNumericHelpers
attr_reader :simplefin_account
def initialize(simplefin_account)
@@ -39,15 +40,90 @@ class SimplefinAccount::Processor
# Update account balance and cash balance from latest SimpleFin data
account = simplefin_account.current_account
balance = simplefin_account.current_balance || simplefin_account.available_balance || 0
# Normalize balances for liabilities (SimpleFIN typically uses opposite sign)
# App convention:
# - Liabilities: positive => you owe; negative => provider owes you (overpayment/credit)
# Since providers often send the opposite sign, ALWAYS invert for liabilities so
# that both debt and overpayment cases are represented correctly.
if [ "CreditCard", "Loan" ].include?(account.accountable_type)
balance = -balance
# Extract raw values from SimpleFIN snapshot
bal = to_decimal(simplefin_account.current_balance)
avail = to_decimal(simplefin_account.available_balance)
# Choose an observed value prioritizing posted balance first
observed = bal.nonzero? ? bal : avail
# Determine if this should be treated as a liability for normalization
is_linked_liability = [ "CreditCard", "Loan" ].include?(account.accountable_type)
raw = (simplefin_account.raw_payload || {}).with_indifferent_access
org = (simplefin_account.org_data || {}).with_indifferent_access
inferred = Simplefin::AccountTypeMapper.infer(
name: simplefin_account.name,
holdings: raw[:holdings],
extra: simplefin_account.extra,
balance: bal,
available_balance: avail,
institution: org[:name]
) rescue nil
is_mapper_liability = inferred && [ "CreditCard", "Loan" ].include?(inferred.accountable_type)
is_liability = is_linked_liability || is_mapper_liability
if is_mapper_liability && !is_linked_liability
Rails.logger.warn(
"SimpleFIN liability normalization: linked account #{account.id} type=#{account.accountable_type} " \
"appears to be liability via mapper (#{inferred.accountable_type}). Normalizing as liability; consider relinking."
)
end
balance = observed
if is_liability
# 1) Try transaction-history heuristic when enabled
begin
result = SimplefinAccount::Liabilities::OverpaymentAnalyzer
.new(simplefin_account, observed_balance: observed)
.call
case result.classification
when :credit
balance = -observed.abs
Rails.logger.info(
"SimpleFIN overpayment heuristic: classified as credit for sfa=#{simplefin_account.id}, " \
"observed=#{observed.to_s('F')} metrics=#{result.metrics.slice(:charges_total, :payments_total, :tx_count).inspect}"
)
Sentry.add_breadcrumb(Sentry::Breadcrumb.new(
category: "simplefin",
message: "liability_sign=credit",
data: { sfa_id: simplefin_account.id, observed: observed.to_s("F") }
)) rescue nil
when :debt
balance = observed.abs
Rails.logger.info(
"SimpleFIN overpayment heuristic: classified as debt for sfa=#{simplefin_account.id}, " \
"observed=#{observed.to_s('F')} metrics=#{result.metrics.slice(:charges_total, :payments_total, :tx_count).inspect}"
)
Sentry.add_breadcrumb(Sentry::Breadcrumb.new(
category: "simplefin",
message: "liability_sign=debt",
data: { sfa_id: simplefin_account.id, observed: observed.to_s("F") }
)) rescue nil
else
# 2) Fall back to existing sign-only logic (log unknown for observability)
begin
obs = {
reason: result.reason,
tx_count: result.metrics[:tx_count],
charges_total: result.metrics[:charges_total],
payments_total: result.metrics[:payments_total],
observed: observed.to_s("F")
}.compact
Rails.logger.info("SimpleFIN overpayment heuristic: unknown; falling back #{obs.inspect}")
rescue
# no-op
end
balance = normalize_liability_balance(observed, bal, avail)
end
rescue NameError
# Analyzer not loaded; keep legacy behavior
balance = normalize_liability_balance(observed, bal, avail)
rescue => e
Rails.logger.warn("SimpleFIN overpayment heuristic error for sfa=#{simplefin_account.id}: #{e.class} - #{e.message}")
balance = normalize_liability_balance(observed, bal, avail)
end
end
# Calculate cash balance correctly for investment accounts
@@ -98,4 +174,19 @@ class SimplefinAccount::Processor
)
end
end
# Helpers
# to_decimal and same_sign? provided by SimplefinNumericHelpers concern
def normalize_liability_balance(observed, bal, avail)
both_present = bal.nonzero? && avail.nonzero?
if both_present && same_sign?(bal, avail)
if bal.positive? && avail.positive?
return -observed.abs
elsif bal.negative? && avail.negative?
return observed.abs
end
end
-observed
end
end

View File

@@ -6,18 +6,40 @@ class SimplefinAccount::Transactions::Processor
end
def process
return unless simplefin_account.raw_transactions_payload.present?
transactions = simplefin_account.raw_transactions_payload.to_a
acct = simplefin_account.current_account
acct_info = acct ? "Account id=#{acct.id} name='#{acct.name}' type=#{acct.accountable_type}" : "NO LINKED ACCOUNT"
if transactions.empty?
Rails.logger.info "SimplefinAccount::Transactions::Processor - No transactions in raw_transactions_payload for simplefin_account #{simplefin_account.id} (#{simplefin_account.name}) - #{acct_info}"
return
end
Rails.logger.info "SimplefinAccount::Transactions::Processor - Processing #{transactions.count} transactions for simplefin_account #{simplefin_account.id} (#{simplefin_account.name}) - #{acct_info}"
# Log first few transaction IDs for debugging
sample_ids = transactions.first(3).map { |t| t.is_a?(Hash) ? (t[:id] || t["id"]) : nil }.compact
Rails.logger.info "SimplefinAccount::Transactions::Processor - Sample transaction IDs: #{sample_ids.inspect}"
processed_count = 0
error_count = 0
# Each entry is processed inside a transaction, but to avoid locking up the DB when
# there are hundreds or thousands of transactions, we process them individually.
simplefin_account.raw_transactions_payload.each do |transaction_data|
transactions.each do |transaction_data|
SimplefinEntry::Processor.new(
transaction_data,
simplefin_account: simplefin_account
).process
processed_count += 1
rescue => e
Rails.logger.error "Error processing SimpleFin transaction: #{e.message}"
error_count += 1
tx_id = transaction_data.is_a?(Hash) ? (transaction_data[:id] || transaction_data["id"]) : nil
Rails.logger.error "SimplefinAccount::Transactions::Processor - Error processing transaction #{tx_id}: #{e.class} - #{e.message}"
Rails.logger.error e.backtrace.first(5).join("\n") if e.backtrace
end
Rails.logger.info "SimplefinAccount::Transactions::Processor - Completed for simplefin_account #{simplefin_account.id}: #{processed_count} processed, #{error_count} errors"
end
private

View File

@@ -34,12 +34,13 @@ class SimplefinEntry::Processor
# Include provider-supplied extra hash if present
sf["extra"] = data[:extra] if data[:extra].is_a?(Hash)
# Pending detection: honor provider flag or infer from missing/zero posted with present transacted_at
posted_val = data[:posted]
posted_missing = posted_val.blank? || posted_val == 0 || posted_val == "0"
if ActiveModel::Type::Boolean.new.cast(data[:pending]) || (posted_missing && data[:transacted_at].present?)
# Pending detection: only use explicit provider flag
# We always set the key (true or false) to ensure deep_merge overwrites any stale value
if ActiveModel::Type::Boolean.new.cast(data[:pending])
sf["pending"] = true
Rails.logger.debug("SimpleFIN: flagged pending transaction #{external_id}")
else
sf["pending"] = false
end
# FX metadata: when tx currency differs from account currency
@@ -87,7 +88,7 @@ class SimplefinEntry::Processor
elsif description.present?
description
else
data[:memo] || "Unknown transaction"
data[:memo] || I18n.t("transactions.unknown_name")
end
end
@@ -142,7 +143,7 @@ class SimplefinEntry::Processor
def posted_date
val = data[:posted]
# Treat 0 / "0" as missing to avoid Unix epoch 1970-01-01 for pendings
# Treat 0 / "0" as missing to avoid Unix epoch 1970-01-01
return nil if val == 0 || val == "0"
Simplefin::DateUtils.parse_provider_date(val)
end

View File

@@ -56,9 +56,164 @@ class SimplefinItem < ApplicationRecord
end
def process_accounts
simplefin_accounts.joins(:account).each do |simplefin_account|
# Process accounts linked via BOTH legacy FK and AccountProvider
# Use direct query to ensure fresh data from DB, bypassing any association cache
all_accounts = SimplefinAccount.where(simplefin_item_id: id).includes(:account, :linked_account, account_provider: :account).to_a
Rails.logger.info "=" * 60
Rails.logger.info "SimplefinItem#process_accounts START - Item #{id} (#{name})"
Rails.logger.info " Total SimplefinAccounts: #{all_accounts.count}"
# Log all accounts for debugging
all_accounts.each do |sfa|
acct = sfa.current_account
Rails.logger.info " - SimplefinAccount id=#{sfa.id} sf_account_id=#{sfa.account_id} name='#{sfa.name}'"
Rails.logger.info " linked_account: #{sfa.linked_account&.id || 'nil'}, account: #{sfa.account&.id || 'nil'}, current_account: #{acct&.id || 'nil'}"
Rails.logger.info " raw_transactions_payload count: #{sfa.raw_transactions_payload.to_a.count}"
end
# First, try to repair stale linkages (old SimplefinAccount linked but new one has data)
repair_stale_linkages(all_accounts)
# Re-fetch after repairs - use direct query for fresh data
all_accounts = SimplefinAccount.where(simplefin_item_id: id).includes(:account, :linked_account, account_provider: :account).to_a
linked = all_accounts.select { |sfa| sfa.current_account.present? }
unlinked = all_accounts.reject { |sfa| sfa.current_account.present? }
Rails.logger.info "SimplefinItem#process_accounts - After repair: #{linked.count} linked, #{unlinked.count} unlinked"
# Log unlinked accounts with transactions for debugging
unlinked_with_txns = unlinked.select { |sfa| sfa.raw_transactions_payload.to_a.any? }
if unlinked_with_txns.any?
Rails.logger.warn "SimplefinItem#process_accounts - #{unlinked_with_txns.count} UNLINKED account(s) have transactions that won't be processed:"
unlinked_with_txns.each do |sfa|
Rails.logger.warn " - SimplefinAccount id=#{sfa.id} name='#{sfa.name}' sf_account_id=#{sfa.account_id} txn_count=#{sfa.raw_transactions_payload.to_a.count}"
end
end
linked.each do |simplefin_account|
acct = simplefin_account.current_account
Rails.logger.info "SimplefinItem#process_accounts - Processing: SimplefinAccount id=#{simplefin_account.id} name='#{simplefin_account.name}' -> Account id=#{acct.id} name='#{acct.name}' type=#{acct.accountable_type}"
SimplefinAccount::Processor.new(simplefin_account).process
end
Rails.logger.info "SimplefinItem#process_accounts END"
Rails.logger.info "=" * 60
end
# Repairs stale linkages when user re-adds institution in SimpleFIN.
# When a user deletes and re-adds an institution in SimpleFIN, new account IDs are generated.
# This causes old SimplefinAccounts to remain "linked" but stale (no new data),
# while new SimplefinAccounts have data but are unlinked.
# This method detects such cases and transfers the linkage from old to new.
def repair_stale_linkages(all_accounts)
linked = all_accounts.select { |sfa| sfa.current_account.present? }
unlinked = all_accounts.reject { |sfa| sfa.current_account.present? }
Rails.logger.info "SimplefinItem#repair_stale_linkages - #{linked.count} linked, #{unlinked.count} unlinked SimplefinAccounts"
# Find unlinked accounts that have transactions
unlinked_with_data = unlinked.select { |sfa| sfa.raw_transactions_payload.to_a.any? }
if unlinked_with_data.any?
Rails.logger.info "SimplefinItem#repair_stale_linkages - Found #{unlinked_with_data.count} unlinked SimplefinAccount(s) with transactions:"
unlinked_with_data.each do |sfa|
Rails.logger.info " - id=#{sfa.id} name='#{sfa.name}' account_id=#{sfa.account_id} txn_count=#{sfa.raw_transactions_payload.to_a.count}"
end
end
return if unlinked_with_data.empty?
# For each unlinked account with data, try to find a matching linked account
unlinked_with_data.each do |new_sfa|
# Find linked SimplefinAccount with same name (case-insensitive).
stale_matches = linked.select do |old_sfa|
old_sfa.name.to_s.downcase.strip == new_sfa.name.to_s.downcase.strip
end
if stale_matches.size > 1
Rails.logger.warn "SimplefinItem#repair_stale_linkages - Multiple linked accounts match '#{new_sfa.name}': #{stale_matches.map(&:id).join(', ')}. Using first match."
end
stale_match = stale_matches.first
next unless stale_match
account = stale_match.current_account
Rails.logger.info "SimplefinItem#repair_stale_linkages - Found matching accounts:"
Rails.logger.info " - OLD: SimplefinAccount id=#{stale_match.id} account_id=#{stale_match.account_id} txn_count=#{stale_match.raw_transactions_payload.to_a.count}"
Rails.logger.info " - NEW: SimplefinAccount id=#{new_sfa.id} account_id=#{new_sfa.account_id} txn_count=#{new_sfa.raw_transactions_payload.to_a.count}"
Rails.logger.info " - Linked to Account: '#{account.name}' (id=#{account.id})"
# Transfer the linkage from old to new
begin
# Merge transactions from old to new before transferring
old_transactions = stale_match.raw_transactions_payload.to_a
new_transactions = new_sfa.raw_transactions_payload.to_a
if old_transactions.any?
Rails.logger.info "SimplefinItem#repair_stale_linkages - Merging #{old_transactions.count} transactions from old SimplefinAccount"
merged = merge_transactions(old_transactions, new_transactions)
new_sfa.update!(raw_transactions_payload: merged)
end
# Check if linked via legacy FK (use to_s for UUID comparison safety)
if account.simplefin_account_id.to_s == stale_match.id.to_s
account.simplefin_account_id = new_sfa.id
account.save!
end
# Check if linked via AccountProvider
if stale_match.account_provider.present?
Rails.logger.info "SimplefinItem#repair_stale_linkages - Transferring AccountProvider linkage from SimplefinAccount #{stale_match.id} to #{new_sfa.id}"
stale_match.account_provider.update!(provider: new_sfa)
end
# If the new one doesn't have an AccountProvider yet, create one
new_sfa.ensure_account_provider!
Rails.logger.info "SimplefinItem#repair_stale_linkages - Successfully transferred linkage for Account '#{account.name}' to SimplefinAccount id=#{new_sfa.id}"
# Clear transactions from stale SimplefinAccount and leave it orphaned
# We don't destroy it because has_one :account, dependent: :nullify would nullify the FK we just set
# IMPORTANT: Use update_all to bypass AR associations - stale_match.update! would
# trigger autosave on the preloaded account association, reverting the FK we just set!
SimplefinAccount.where(id: stale_match.id).update_all(raw_transactions_payload: [], raw_holdings_payload: [])
Rails.logger.info "SimplefinItem#repair_stale_linkages - Cleared data from stale SimplefinAccount id=#{stale_match.id} (leaving orphaned)"
rescue => e
Rails.logger.error "SimplefinItem#repair_stale_linkages - Failed to transfer linkage: #{e.class} - #{e.message}"
Rails.logger.error e.backtrace.first(5).join("\n") if e.backtrace
end
end
end
# Merge two arrays of transactions, deduplicating by ID.
# Fallback: uses composite key [posted, amount, description] when ID/fitid missing.
#
# Known edge cases with composite key fallback:
# 1. False positives: Two distinct transactions with identical posted/amount/description
# will be incorrectly merged (rare but possible).
# 2. Type inconsistency: If posted varies in type (String vs Integer), keys won't match.
# 3. Description variations: Minor differences (whitespace, case) prevent matching.
#
# SimpleFIN typically provides transaction IDs, so this fallback is rarely needed.
def merge_transactions(old_txns, new_txns)
by_id = {}
# Add old transactions first
old_txns.each do |tx|
t = tx.with_indifferent_access
key = t[:id] || t[:fitid] || [ t[:posted], t[:amount], t[:description] ]
by_id[key] = tx
end
# Add new transactions (overwrite old with same ID)
new_txns.each do |tx|
t = tx.with_indifferent_access
key = t[:id] || t[:fitid] || [ t[:posted], t[:amount], t[:description] ]
by_id[key] = tx
end
by_id.values
end
def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil)
@@ -192,6 +347,58 @@ class SimplefinItem < ApplicationRecord
end
end
# Detect if sync data appears stale (no new transactions for extended period)
# Returns a hash with :stale (boolean) and :message (string) if stale
def stale_sync_status
return { stale: false } unless last_synced_at.present?
# Check if last sync was more than 3 days ago
days_since_sync = (Date.current - last_synced_at.to_date).to_i
if days_since_sync > 3
return {
stale: true,
days_since_sync: days_since_sync,
message: "Last successful sync was #{days_since_sync} days ago. Your SimpleFin connection may need attention."
}
end
# Check if linked accounts have recent transactions
linked_accounts = accounts
return { stale: false } if linked_accounts.empty?
# Find the most recent transaction date across all linked accounts
latest_transaction_date = Entry.where(account_id: linked_accounts.map(&:id))
.where(entryable_type: "Transaction")
.maximum(:date)
if latest_transaction_date.present?
days_since_transaction = (Date.current - latest_transaction_date).to_i
if days_since_transaction > 14
return {
stale: true,
days_since_transaction: days_since_transaction,
message: "No new transactions in #{days_since_transaction} days. Check your SimpleFin dashboard to ensure your bank connections are active."
}
end
end
{ stale: false }
end
# Check if the SimpleFin connection needs user attention
def needs_attention?
requires_update? || stale_sync_status[:stale] || pending_account_setup?
end
# Get a summary of issues requiring attention
def attention_summary
issues = []
issues << "Connection needs update" if requires_update?
issues << stale_sync_status[:message] if stale_sync_status[:stale]
issues << "Accounts need setup" if pending_account_setup?
issues
end
private
def remove_simplefin_item
# SimpleFin doesn't require server-side cleanup like Plaid

Some files were not shown because too many files have changed in this diff Show More