From ad3087f1dd6de5b41e155b7a7b5d57bb8f22c21b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Sun, 22 Feb 2026 21:22:32 -0500 Subject: [PATCH 01/75] Improvements to Flutter client (#1042) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Chat improvements * Delete/reset account via API for Flutter app * Fix tests. * Add "contact us" to settings * Update mobile/lib/screens/chat_conversation_screen.dart Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Juan José Mata * Improve LLM special token detection * Deactivated user shouldn't have API working * Fix tests * API-Key usage * Flutter app launch failure on no network * Handle deletion/reset delays * Local cached data may become stale * Use X-Api-Key correctly! --------- Signed-off-by: Juan José Mata Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- app/controllers/api/v1/base_controller.rb | 10 + app/controllers/api/v1/users_controller.rb | 27 +++ config/routes.rb | 3 + mobile/lib/models/chat.dart | 11 + mobile/lib/models/message.dart | 33 ++- mobile/lib/providers/auth_provider.dart | 16 +- mobile/lib/providers/chat_provider.dart | 70 +++++-- .../lib/screens/chat_conversation_screen.dart | 83 +++++--- mobile/lib/screens/chat_list_screen.dart | 3 +- mobile/lib/screens/settings_screen.dart | 191 ++++++++++++++++++ mobile/lib/services/chat_service.dart | 5 +- mobile/lib/services/user_service.dart | 71 +++++++ spec/requests/api/v1/users_spec.rb | 123 +++++++++++ spec/swagger_helper.rb | 7 + .../api/v1/users_controller_test.rb | 116 +++++++++++ 15 files changed, 716 insertions(+), 53 deletions(-) create mode 100644 app/controllers/api/v1/users_controller.rb create mode 100644 mobile/lib/services/user_service.dart create mode 100644 spec/requests/api/v1/users_spec.rb create mode 100644 test/controllers/api/v1/users_controller_test.rb diff --git a/app/controllers/api/v1/base_controller.rb b/app/controllers/api/v1/base_controller.rb index f176fff4b..d768bdf3f 100644 --- a/app/controllers/api/v1/base_controller.rb +++ b/app/controllers/api/v1/base_controller.rb @@ -73,6 +73,11 @@ class Api::V1::BaseController < ApplicationController render_json({ error: "unauthorized", message: "Access token is invalid - user not found" }, status: :unauthorized) return false end + + unless @current_user.active? + render_json({ error: "unauthorized", message: "Account has been deactivated" }, status: :unauthorized) + return false + end else Rails.logger.warn "API OAuth Token Invalid: Access token missing resource_owner_id" render_json({ error: "unauthorized", message: "Access token is invalid - missing resource owner" }, status: :unauthorized) @@ -96,6 +101,11 @@ class Api::V1::BaseController < ApplicationController return false unless @api_key && @api_key.active? @current_user = @api_key.user + unless @current_user.active? + render_json({ error: "unauthorized", message: "Account has been deactivated" }, status: :unauthorized) + return false + end + @api_key.update_last_used! @authentication_method = :api_key @rate_limiter = ApiRateLimiter.limit(@api_key) diff --git a/app/controllers/api/v1/users_controller.rb b/app/controllers/api/v1/users_controller.rb new file mode 100644 index 000000000..be01669f4 --- /dev/null +++ b/app/controllers/api/v1/users_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class Api::V1::UsersController < Api::V1::BaseController + before_action :ensure_write_scope + + def reset + FamilyResetJob.perform_later(Current.family) + render json: { message: "Account reset has been initiated" } + end + + def destroy + user = current_resource_owner + + if user.deactivate + Current.session&.destroy + render json: { message: "Account has been deleted" } + else + render json: { error: "Failed to delete account", details: user.errors.full_messages }, status: :unprocessable_entity + end + end + + private + + def ensure_write_scope + authorize_scope!(:write) + end +end diff --git a/config/routes.rb b/config/routes.rb index 5badb50fa..b72530f40 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -394,6 +394,9 @@ Rails.application.routes.draw do end end + delete "users/reset", to: "users#reset" + delete "users/me", to: "users#destroy" + # Test routes for API controller testing (only available in test environment) if Rails.env.test? get "test", to: "test#index" diff --git a/mobile/lib/models/chat.dart b/mobile/lib/models/chat.dart index 480398f94..9082b2cc8 100644 --- a/mobile/lib/models/chat.dart +++ b/mobile/lib/models/chat.dart @@ -53,6 +53,17 @@ class Chat { }; } + static const String defaultTitle = 'New Chat'; + static const int maxTitleLength = 80; + + static String generateTitle(String prompt) { + final trimmed = prompt.trim(); + if (trimmed.length <= maxTitleLength) return trimmed; + return trimmed.substring(0, maxTitleLength); + } + + bool get hasDefaultTitle => title == defaultTitle; + Chat copyWith({ String? id, String? title, diff --git a/mobile/lib/models/message.dart b/mobile/lib/models/message.dart index d3b71949e..264f4e940 100644 --- a/mobile/lib/models/message.dart +++ b/mobile/lib/models/message.dart @@ -1,6 +1,32 @@ import 'tool_call.dart'; class Message { + /// Known LLM special tokens that may leak into responses (strip from display). + /// Includes ASCII ChatML (<|...|>) and DeepSeek full-width variants (<|...|>). + static const _llmTokenPatterns = [ + '<|start_of_sentence|>', + '<|im_start|>', + '<|im_end|>', + '<|endoftext|>', + '', + // DeepSeek full-width pipe variants (U+FF5C |) + '<\uFF5Cstart_of_sentence\uFF5C>', + '<\uFF5Cim_start\uFF5C>', + '<\uFF5Cim_end\uFF5C>', + '<\uFF5Cendoftext\uFF5C>', + ]; + + /// Removes LLM tokens and trims trailing whitespace from assistant content. + static String sanitizeContent(String content) { + var out = content; + for (final token in _llmTokenPatterns) { + out = out.replaceAll(token, ''); + } + out = out.replaceAll(RegExp(r'<\|[^|]*\|>'), ''); + out = out.replaceAll(RegExp('<\u{FF5C}[^\u{FF5C}]*\u{FF5C}>'), ''); + return out.trim(); + } + final String id; final String type; final String role; @@ -22,11 +48,14 @@ class Message { }); factory Message.fromJson(Map json) { + final rawContent = json['content'] as String; + final role = json['role'] as String; + final content = role == 'assistant' ? sanitizeContent(rawContent) : rawContent; return Message( id: json['id'].toString(), type: json['type'] as String, - role: json['role'] as String, - content: json['content'] as String, + role: role, + content: content, model: json['model'] as String?, createdAt: DateTime.parse(json['created_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String), diff --git a/mobile/lib/providers/auth_provider.dart b/mobile/lib/providers/auth_provider.dart index a75826b1c..3b6a6510e 100644 --- a/mobile/lib/providers/auth_provider.dart +++ b/mobile/lib/providers/auth_provider.dart @@ -1,3 +1,4 @@ +import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/foundation.dart'; import 'package:url_launcher/url_launcher.dart'; import '../models/user.dart'; @@ -57,9 +58,20 @@ class AuthProvider with ChangeNotifier { _tokens = await _authService.getStoredTokens(); _user = await _authService.getStoredUser(); - // If tokens exist but are expired, try to refresh + // If tokens exist but are expired, try to refresh only when online if (_tokens != null && _tokens!.isExpired) { - await _refreshToken(); + final results = await Connectivity().checkConnectivity(); + final isOnline = results.any((r) => + r == ConnectivityResult.mobile || + r == ConnectivityResult.wifi || + r == ConnectivityResult.ethernet || + r == ConnectivityResult.vpn || + r == ConnectivityResult.bluetooth); + if (isOnline) { + await _refreshToken(); + } else { + await logout(); + } } } } catch (e) { diff --git a/mobile/lib/providers/chat_provider.dart b/mobile/lib/providers/chat_provider.dart index 2760aec23..5016c934f 100644 --- a/mobile/lib/providers/chat_provider.dart +++ b/mobile/lib/providers/chat_provider.dart @@ -14,6 +14,10 @@ class ChatProvider with ChangeNotifier { String? _errorMessage; Timer? _pollingTimer; + /// Content length of the last assistant message from the previous poll. + /// Used to detect when the LLM has finished writing (no growth between polls). + int? _lastAssistantContentLength; + List get chats => _chats; Chat? get currentChat => _currentChat; bool get isLoading => _isLoading; @@ -85,7 +89,6 @@ class ChatProvider with ChangeNotifier { required String accessToken, String? title, String? initialMessage, - String model = 'gpt-4', }) async { _isLoading = true; _errorMessage = null; @@ -96,7 +99,6 @@ class ChatProvider with ChangeNotifier { accessToken: accessToken, title: title, initialMessage: initialMessage, - model: model, ); if (result['success'] == true) { @@ -127,8 +129,9 @@ class ChatProvider with ChangeNotifier { } } - /// Send a message to the current chat - Future sendMessage({ + /// Send a message to the current chat. + /// Returns true if delivery succeeded, false otherwise. + Future sendMessage({ required String accessToken, required String chatId, required String content, @@ -158,11 +161,14 @@ class ChatProvider with ChangeNotifier { // Start polling for AI response _startPolling(accessToken, chatId); + return true; } else { _errorMessage = result['error'] ?? 'Failed to send message'; + return false; } } catch (e) { _errorMessage = 'Error: ${e.toString()}'; + return false; } finally { _isSendingMessage = false; notifyListeners(); @@ -239,6 +245,7 @@ class ChatProvider with ChangeNotifier { /// Start polling for new messages (AI responses) void _startPolling(String accessToken, String chatId) { _stopPolling(); + _lastAssistantContentLength = null; _pollingTimer = Timer.periodic(const Duration(seconds: 2), (timer) async { await _pollForUpdates(accessToken, chatId); @@ -262,26 +269,55 @@ class ChatProvider with ChangeNotifier { if (result['success'] == true) { final updatedChat = result['chat'] as Chat; - // Check if we have new messages - if (_currentChat != null && _currentChat!.id == chatId) { - final oldMessageCount = _currentChat!.messages.length; - final newMessageCount = updatedChat.messages.length; + if (_currentChat == null || _currentChat!.id != chatId) return; - if (newMessageCount > oldMessageCount) { - _currentChat = updatedChat; - notifyListeners(); + final oldMessages = _currentChat!.messages; + final newMessages = updatedChat.messages; + final oldMessageCount = oldMessages.length; + final newMessageCount = newMessages.length; - // Check if the last message is from assistant and complete - final lastMessage = updatedChat.messages.lastOrNull; - if (lastMessage != null && lastMessage.isAssistant) { - // Stop polling after getting assistant response - _stopPolling(); + final oldContentLengthById = {}; + for (final m in oldMessages) { + if (m.isAssistant) oldContentLengthById[m.id] = m.content.length; + } + + bool shouldUpdate = false; + + // New messages added + if (newMessageCount > oldMessageCount) { + shouldUpdate = true; + _lastAssistantContentLength = null; + } else if (newMessageCount == oldMessageCount) { + // Same count: check if any assistant message has more content + for (final m in newMessages) { + if (m.isAssistant) { + final oldLen = oldContentLengthById[m.id] ?? 0; + if (m.content.length > oldLen) { + shouldUpdate = true; + break; + } } } } + + if (shouldUpdate) { + _currentChat = updatedChat; + notifyListeners(); + } + + final lastMessage = updatedChat.messages.lastOrNull; + if (lastMessage != null && lastMessage.isAssistant) { + final newLen = lastMessage.content.length; + if (newLen > (_lastAssistantContentLength ?? 0)) { + _lastAssistantContentLength = newLen; + } else { + // Content stable: no growth since last poll + _stopPolling(); + _lastAssistantContentLength = null; + } + } } } catch (e) { - // Silently fail polling errors to avoid interrupting user experience debugPrint('Polling error: ${e.toString()}'); } } diff --git a/mobile/lib/screens/chat_conversation_screen.dart b/mobile/lib/screens/chat_conversation_screen.dart index 0de3fee61..66b6d20c4 100644 --- a/mobile/lib/screens/chat_conversation_screen.dart +++ b/mobile/lib/screens/chat_conversation_screen.dart @@ -1,9 +1,15 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import '../models/chat.dart'; import '../providers/auth_provider.dart'; import '../providers/chat_provider.dart'; import '../models/message.dart'; +class _SendMessageIntent extends Intent { + const _SendMessageIntent(); +} + class ChatConversationScreen extends StatefulWidget { final String chatId; @@ -69,15 +75,24 @@ class _ChatConversationScreenState extends State { return; } - // Clear input field immediately + final shouldUpdateTitle = chatProvider.currentChat?.hasDefaultTitle == true; + _messageController.clear(); - await chatProvider.sendMessage( + final delivered = await chatProvider.sendMessage( accessToken: accessToken, chatId: widget.chatId, content: content, ); + if (delivered && shouldUpdateTitle) { + await chatProvider.updateChatTitle( + accessToken: accessToken, + chatId: widget.chatId, + title: Chat.generateTitle(content), + ); + } + // Scroll to bottom after sending WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { @@ -298,34 +313,48 @@ class _ChatConversationScreenState extends State { ), ], ), - child: Row( - children: [ - Expanded( - child: TextField( - controller: _messageController, - decoration: InputDecoration( - hintText: 'Type a message...', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, + child: Shortcuts( + shortcuts: const { + SingleActivator(LogicalKeyboardKey.enter): _SendMessageIntent(), + }, + child: Actions( + actions: >{ + _SendMessageIntent: CallbackAction<_SendMessageIntent>( + onInvoke: (_) { + if (!chatProvider.isSendingMessage) _sendMessage(); + return null; + }, + ), + }, + child: Row( + children: [ + Expanded( + child: TextField( + controller: _messageController, + decoration: InputDecoration( + hintText: 'Type a message...', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + maxLines: null, + textCapitalization: TextCapitalization.sentences, ), ), - maxLines: null, - textCapitalization: TextCapitalization.sentences, - onSubmitted: (_) => _sendMessage(), - ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.send), + onPressed: chatProvider.isSendingMessage ? null : _sendMessage, + color: colorScheme.primary, + iconSize: 28, + ), + ], ), - const SizedBox(width: 8), - IconButton( - icon: const Icon(Icons.send), - onPressed: chatProvider.isSendingMessage ? null : _sendMessage, - color: colorScheme.primary, - iconSize: 28, - ), - ], + ), ), ), ], diff --git a/mobile/lib/screens/chat_list_screen.dart b/mobile/lib/screens/chat_list_screen.dart index eece17448..d49bbd3fd 100644 --- a/mobile/lib/screens/chat_list_screen.dart +++ b/mobile/lib/screens/chat_list_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../models/chat.dart'; import '../providers/auth_provider.dart'; import '../providers/chat_provider.dart'; import 'chat_conversation_screen.dart'; @@ -58,7 +59,7 @@ class _ChatListScreenState extends State { final chat = await chatProvider.createChat( accessToken: accessToken, - title: 'New Chat', + title: Chat.defaultTitle, ); // Close loading dialog diff --git a/mobile/lib/screens/settings_screen.dart b/mobile/lib/screens/settings_screen.dart index 16a51fc77..b8ddc0a4e 100644 --- a/mobile/lib/screens/settings_screen.dart +++ b/mobile/lib/screens/settings_screen.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../providers/auth_provider.dart'; import '../services/offline_storage_service.dart'; import '../services/log_service.dart'; import '../services/preferences_service.dart'; +import '../services/user_service.dart'; class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @@ -16,6 +18,8 @@ class SettingsScreen extends StatefulWidget { class _SettingsScreenState extends State { bool _groupByType = false; String? _appVersion; + bool _isResettingAccount = false; + bool _isDeletingAccount = false; @override void initState() { @@ -101,6 +105,139 @@ class _SettingsScreenState extends State { } } + Future _launchContactUrl(BuildContext context) async { + final uri = Uri.parse('https://discord.com/invite/36ZGBsxYEK'); + final launched = await launchUrl(uri, mode: LaunchMode.externalApplication); + if (!launched && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Unable to open link')), + ); + } + } + + Future _handleResetAccount(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Reset Account'), + content: const Text( + 'Resetting your account will delete all your accounts, categories, ' + 'merchants, tags, and other data, but keep your user account intact.\n\n' + 'This action cannot be undone. Are you sure?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + child: const Text('Reset Account'), + ), + ], + ), + ); + + if (confirmed != true || !context.mounted) return; + + setState(() => _isResettingAccount = true); + try { + final authProvider = Provider.of(context, listen: false); + final accessToken = await authProvider.getValidAccessToken(); + if (accessToken == null) { + await authProvider.logout(); + return; + } + + final result = await UserService().resetAccount(accessToken: accessToken); + + if (!context.mounted) return; + + if (result['success'] == true) { + await OfflineStorageService().clearAllData(); + + if (!context.mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Account reset has been initiated. This may take a moment.'), + backgroundColor: Colors.green, + ), + ); + + await authProvider.logout(); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(result['error'] ?? 'Failed to reset account'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) setState(() => _isResettingAccount = false); + } + } + + Future _handleDeleteAccount(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Account'), + content: const Text( + 'Deleting your account will permanently remove all your data ' + 'and cannot be undone.\n\n' + 'Are you sure you want to delete your account?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + child: const Text('Delete Account'), + ), + ], + ), + ); + + if (confirmed != true || !context.mounted) return; + + setState(() => _isDeletingAccount = true); + try { + final authProvider = Provider.of(context, listen: false); + final accessToken = await authProvider.getValidAccessToken(); + if (accessToken == null) { + await authProvider.logout(); + return; + } + + final result = await UserService().deleteAccount(accessToken: accessToken); + + if (!context.mounted) return; + + if (result['success'] == true) { + await authProvider.logout(); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(result['error'] ?? 'Failed to delete account'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) setState(() => _isDeletingAccount = false); + } + } + Future _handleLogout(BuildContext context) async { final confirmed = await showDialog( context: context, @@ -200,6 +337,19 @@ class _SettingsScreenState extends State { ), ), + ListTile( + leading: const Icon(Icons.chat_bubble_outline), + title: const Text('Contact us'), + subtitle: Text( + 'https://discord.com/invite/36ZGBsxYEK', + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + ), + ), + onTap: () => _launchContactUrl(context), + ), + const Divider(), // Display Settings Section @@ -253,6 +403,47 @@ class _SettingsScreenState extends State { const Divider(), + // Danger Zone Section + const Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + 'Danger Zone', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.red, + ), + ), + ), + + ListTile( + leading: const Icon(Icons.restart_alt, color: Colors.red), + title: const Text('Reset Account'), + subtitle: const Text( + 'Delete all accounts, categories, merchants, and tags but keep your user account', + ), + trailing: _isResettingAccount + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) + : null, + enabled: !_isResettingAccount && !_isDeletingAccount, + onTap: _isResettingAccount || _isDeletingAccount ? null : () => _handleResetAccount(context), + ), + + ListTile( + leading: const Icon(Icons.delete_forever, color: Colors.red), + title: const Text('Delete Account'), + subtitle: const Text( + 'Permanently remove all your data. This cannot be undone.', + ), + trailing: _isDeletingAccount + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) + : null, + enabled: !_isDeletingAccount && !_isResettingAccount, + onTap: _isDeletingAccount || _isResettingAccount ? null : () => _handleDeleteAccount(context), + ), + + const Divider(), + // Sign out button Padding( padding: const EdgeInsets.all(16), diff --git a/mobile/lib/services/chat_service.dart b/mobile/lib/services/chat_service.dart index 080378c51..1e55e57da 100644 --- a/mobile/lib/services/chat_service.dart +++ b/mobile/lib/services/chat_service.dart @@ -118,14 +118,11 @@ class ChatService { required String accessToken, String? title, String? initialMessage, - String model = 'gpt-4', }) async { try { final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/chats'); - final body = { - 'model': model, - }; + final body = {}; if (title != null) { body['title'] = title; diff --git a/mobile/lib/services/user_service.dart b/mobile/lib/services/user_service.dart new file mode 100644 index 000000000..f410bcf7f --- /dev/null +++ b/mobile/lib/services/user_service.dart @@ -0,0 +1,71 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'api_config.dart'; + +class UserService { + Future> resetAccount({ + required String accessToken, + }) async { + try { + final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/users/reset'); + + final response = await http.delete( + url, + headers: ApiConfig.getAuthHeaders(accessToken), + ).timeout(const Duration(seconds: 30)); + + if (response.statusCode == 200) { + return {'success': true}; + } else if (response.statusCode == 401) { + return { + 'success': false, + 'error': 'Session expired. Please login again.', + }; + } else { + final responseData = jsonDecode(response.body); + return { + 'success': false, + 'error': responseData['error'] ?? 'Failed to reset account', + }; + } + } catch (e) { + return { + 'success': false, + 'error': 'Network error: ${e.toString()}', + }; + } + } + + Future> deleteAccount({ + required String accessToken, + }) async { + try { + final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/users/me'); + + final response = await http.delete( + url, + headers: ApiConfig.getAuthHeaders(accessToken), + ).timeout(const Duration(seconds: 30)); + + if (response.statusCode == 200) { + return {'success': true}; + } else if (response.statusCode == 401) { + return { + 'success': false, + 'error': 'Session expired. Please login again.', + }; + } else { + final responseData = jsonDecode(response.body); + return { + 'success': false, + 'error': responseData['error'] ?? 'Failed to delete account', + }; + } + } catch (e) { + return { + 'success': false, + 'error': 'Network error: ${e.toString()}', + }; + } + } +} diff --git a/spec/requests/api/v1/users_spec.rb b/spec/requests/api/v1/users_spec.rb new file mode 100644 index 000000000..65c745ede --- /dev/null +++ b/spec/requests/api/v1/users_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'API V1 Users', type: :request do + let(:family) do + Family.create!( + name: 'API Family', + currency: 'USD', + locale: 'en', + date_format: '%m-%d-%Y' + ) + end + + let(:user) do + family.users.create!( + email: 'api-user@example.com', + password: 'password123', + password_confirmation: 'password123' + ) + end + + let(:api_key) do + key = ApiKey.generate_secure_key + ApiKey.create!( + user: user, + name: 'API Docs Key', + key: key, + scopes: %w[read_write], + source: 'web' + ) + end + + let(:'X-Api-Key') { api_key.plain_key } + + path '/api/v1/users/reset' do + delete 'Reset account' do + tags 'Users' + description 'Resets all financial data (accounts, categories, merchants, tags, etc.) ' \ + 'for the current user\'s family while keeping the user account intact. ' \ + 'The reset runs asynchronously in the background.' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + + response '200', 'account reset initiated' do + schema '$ref' => '#/components/schemas/SuccessMessage' + + run_test! + end + + response '401', 'unauthorized' do + let(:'X-Api-Key') { 'invalid-key' } + + run_test! + end + + response '403', 'insufficient scope' do + let(:api_key) do + key = ApiKey.generate_secure_key + ApiKey.create!( + user: user, + name: 'Read Only Key', + key: key, + scopes: %w[read], + source: 'web' + ) + end + + run_test! + end + end + end + + path '/api/v1/users/me' do + delete 'Delete account' do + tags 'Users' + description 'Permanently deactivates the current user account and all associated data. ' \ + 'This action cannot be undone.' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + + response '200', 'account deleted' do + schema '$ref' => '#/components/schemas/SuccessMessage' + + run_test! + end + + response '401', 'unauthorized' do + let(:'X-Api-Key') { 'invalid-key' } + + run_test! + end + + response '403', 'insufficient scope' do + let(:api_key) do + key = ApiKey.generate_secure_key + ApiKey.create!( + user: user, + name: 'Read Only Key', + key: key, + scopes: %w[read], + source: 'web' + ) + end + + run_test! + end + + response '422', 'deactivation failed' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + before do + allow_any_instance_of(User).to receive(:deactivate).and_return(false) + allow_any_instance_of(User).to receive(:errors).and_return( + double(full_messages: [ 'Cannot deactivate admin with other users' ]) + ) + end + + run_test! + end + end + end +end diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb index 8a990075b..c02b0831c 100644 --- a/spec/swagger_helper.rb +++ b/spec/swagger_helper.rb @@ -517,6 +517,13 @@ RSpec.configure do |config| }, pagination: { '$ref' => '#/components/schemas/Pagination' } } + }, + SuccessMessage: { + type: :object, + required: %w[message], + properties: { + message: { type: :string } + } } } } diff --git a/test/controllers/api/v1/users_controller_test.rb b/test/controllers/api/v1/users_controller_test.rb new file mode 100644 index 000000000..9ea8b89cb --- /dev/null +++ b/test/controllers/api/v1/users_controller_test.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require "test_helper" + +class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:family_admin) + + @user.api_keys.active.destroy_all + + @api_key = ApiKey.create!( + user: @user, + name: "Test Read-Write Key", + scopes: [ "read_write" ], + display_key: "test_rw_#{SecureRandom.hex(8)}" + ) + + @read_only_api_key = ApiKey.create!( + user: @user, + name: "Test Read-Only Key", + scopes: [ "read" ], + display_key: "test_ro_#{SecureRandom.hex(8)}", + source: "mobile" + ) + end + + # -- Authentication -------------------------------------------------------- + + test "reset requires authentication" do + delete "/api/v1/users/reset" + assert_response :unauthorized + end + + test "destroy requires authentication" do + delete "/api/v1/users/me" + assert_response :unauthorized + end + + # -- Scope enforcement ----------------------------------------------------- + + test "reset requires write scope" do + delete "/api/v1/users/reset", headers: api_headers(@read_only_api_key) + assert_response :forbidden + end + + test "destroy requires write scope" do + delete "/api/v1/users/me", headers: api_headers(@read_only_api_key) + assert_response :forbidden + end + + # -- Reset ----------------------------------------------------------------- + + test "reset enqueues FamilyResetJob and returns 200" do + assert_enqueued_with(job: FamilyResetJob) do + delete "/api/v1/users/reset", headers: api_headers(@api_key) + end + + assert_response :ok + body = JSON.parse(response.body) + assert_equal "Account reset has been initiated", body["message"] + end + + # -- Delete account -------------------------------------------------------- + + test "destroy deactivates user and returns 200" do + solo_family = Family.create!(name: "Solo Family", currency: "USD", locale: "en", date_format: "%m-%d-%Y") + solo_user = solo_family.users.create!( + email: "solo@example.com", + password: "password123", + password_confirmation: "password123", + role: :admin + ) + solo_api_key = ApiKey.create!( + user: solo_user, + name: "Solo Key", + scopes: [ "read_write" ], + display_key: "test_solo_#{SecureRandom.hex(8)}" + ) + + delete "/api/v1/users/me", headers: api_headers(solo_api_key) + assert_response :ok + + body = JSON.parse(response.body) + assert_equal "Account has been deleted", body["message"] + + solo_user.reload + assert_not solo_user.active? + assert_not_equal "solo@example.com", solo_user.email + end + + test "destroy returns 422 when admin has other family members" do + delete "/api/v1/users/me", headers: api_headers(@api_key) + assert_response :unprocessable_entity + + body = JSON.parse(response.body) + assert_equal "Failed to delete account", body["error"] + end + + # -- Deactivated user ------------------------------------------------------ + + test "rejects deactivated user with 401" do + @user.update_column(:active, false) + + delete "/api/v1/users/reset", headers: api_headers(@api_key) + assert_response :unauthorized + + body = JSON.parse(response.body) + assert_equal "Account has been deactivated", body["message"] + end + + private + + def api_headers(api_key) + { "X-Api-Key" => api_key.display_key } + end +end From 0ddca461fc86d0c910d42c3303b6c8b90415e918 Mon Sep 17 00:00:00 2001 From: LPW Date: Mon, 23 Feb 2026 07:33:36 -0500 Subject: [PATCH 02/75] Add Pipelock agent security scan to CI (#1049) * Add Pipelock agent security scan to CI Scans PR diffs for leaked secrets and agent security risks. Zero config, runs on every PR to main. * Retrigger CI (v1 action tag now available) * Harden checkout: persist-credentials false Pipelock only reads local git history for diff scanning, no auth token needed in .git/config. --- .github/workflows/pipelock.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/workflows/pipelock.yml diff --git a/.github/workflows/pipelock.yml b/.github/workflows/pipelock.yml new file mode 100644 index 000000000..741a344ff --- /dev/null +++ b/.github/workflows/pipelock.yml @@ -0,0 +1,24 @@ +name: Pipelock Security Scan + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + security-scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Pipelock Scan + uses: luckyPipewrench/pipelock@v1 + with: + scan-diff: 'true' + fail-on-findings: 'true' + test-vectors: 'false' From 111d6839e076483e3a7d7aa7a5ead48e9a12dfed Mon Sep 17 00:00:00 2001 From: MkDev11 <94194147+MkDev11@users.noreply.github.com> Date: Mon, 23 Feb 2026 04:38:58 -0800 Subject: [PATCH 03/75] Feat/Abstract Assistant into module with registry (#1020) * Abstract Assistant into module with registry (fixes #1016) - Add Assistant module with registry/factory (builtin, external) - Assistant.for_chat(chat) routes by family.assistant_type - Assistant.config_for(chat) delegates to Builtin for backward compat - Assistant.available_types returns registered types - Add Assistant::Base (Broadcastable, respond_to contract) - Move current behavior to Assistant::Builtin (Provided + Configurable) - Add Assistant::External stub for future OpenClaw/WebSocket - Migration: add families.assistant_type (default builtin) - Family: validate assistant_type inclusion - Tests: for_chat routing, available_types, External stub, blank chat guard * Fix RuboCop layout: indentation in Assistant module and tests * Move new test methods above private so Minitest discovers them * Clear thinking indicator in External#respond_to to avoid stuck UI * Rebase onto upstream main: fix schema to avoid spurious diffs - Rebase feature/abstract-assistant-1016 onto we-promise/main - Rename migration to 20260218120001 to avoid duplicate version with backfill_crypto_subtype - Regenerate schema from upstream + assistant_type only (keeps vector_store_id, realized_gain, etc.) - PR schema diff now shows only assistant_type addition and version bump --------- Co-authored-by: mkdev11 --- app/models/assistant.rb | 118 ++++-------------- app/models/assistant/base.rb | 13 ++ app/models/assistant/builtin.rb | 95 ++++++++++++++ app/models/assistant/external.rb | 14 +++ app/models/family.rb | 2 + ...18120001_add_assistant_type_to_families.rb | 5 + db/schema.rb | 3 +- test/models/assistant_test.rb | 25 ++++ 8 files changed, 180 insertions(+), 95 deletions(-) create mode 100644 app/models/assistant/base.rb create mode 100644 app/models/assistant/builtin.rb create mode 100644 app/models/assistant/external.rb create mode 100644 db/migrate/20260218120001_add_assistant_type_to_families.rb diff --git a/app/models/assistant.rb b/app/models/assistant.rb index 4e9fbb340..ca649ceac 100644 --- a/app/models/assistant.rb +++ b/app/models/assistant.rb @@ -1,101 +1,31 @@ -class Assistant - include Provided, Configurable, Broadcastable +module Assistant + Error = Class.new(StandardError) - attr_reader :chat, :instructions + REGISTRY = { + "builtin" => Assistant::Builtin, + "external" => Assistant::External + }.freeze class << self def for_chat(chat) - config = config_for(chat) - new(chat, instructions: config[:instructions], functions: config[:functions]) + implementation_for(chat).for_chat(chat) end + + def config_for(chat) + raise Error, "chat is required" if chat.blank? + Assistant::Builtin.config_for(chat) + end + + def available_types + REGISTRY.keys + end + + private + + def implementation_for(chat) + raise Error, "chat is required" if chat.blank? + type = chat.user&.family&.assistant_type.presence || "builtin" + REGISTRY.fetch(type) { REGISTRY["builtin"] } + end end - - def initialize(chat, instructions: nil, functions: []) - @chat = chat - @instructions = instructions - @functions = functions - end - - def respond_to(message) - assistant_message = AssistantMessage.new( - chat: chat, - content: "", - ai_model: message.ai_model - ) - - llm_provider = get_model_provider(message.ai_model) - - unless llm_provider - error_message = build_no_provider_error_message(message.ai_model) - raise StandardError, error_message - end - - responder = Assistant::Responder.new( - message: message, - instructions: instructions, - function_tool_caller: function_tool_caller, - llm: llm_provider - ) - - latest_response_id = chat.latest_assistant_response_id - - responder.on(:output_text) do |text| - if assistant_message.content.blank? - stop_thinking - - Chat.transaction do - assistant_message.append_text!(text) - chat.update_latest_response!(latest_response_id) - end - else - assistant_message.append_text!(text) - end - end - - responder.on(:response) do |data| - update_thinking("Analyzing your data...") - - if data[:function_tool_calls].present? - assistant_message.tool_calls = data[:function_tool_calls] - latest_response_id = data[:id] - else - chat.update_latest_response!(data[:id]) - end - end - - responder.respond(previous_response_id: latest_response_id) - rescue => e - stop_thinking - chat.add_error(e) - end - - private - attr_reader :functions - - def function_tool_caller - function_instances = functions.map do |fn| - fn.new(chat.user) - end - - @function_tool_caller ||= FunctionToolCaller.new(function_instances) - end - - def build_no_provider_error_message(requested_model) - available_providers = registry.providers - - if available_providers.empty? - "No LLM provider configured that supports model '#{requested_model}'. " \ - "Please configure an LLM provider (e.g., OpenAI) in settings." - else - provider_details = available_providers.map do |provider| - " - #{provider.provider_name}: #{provider.supported_models_description}" - end.join("\n") - - "No LLM provider configured that supports model '#{requested_model}'.\n\n" \ - "Available providers:\n#{provider_details}\n\n" \ - "Please either:\n" \ - " 1. Use a supported model from the list above, or\n" \ - " 2. Configure a provider that supports '#{requested_model}' in settings." - end - end end diff --git a/app/models/assistant/base.rb b/app/models/assistant/base.rb new file mode 100644 index 000000000..2b77671af --- /dev/null +++ b/app/models/assistant/base.rb @@ -0,0 +1,13 @@ +class Assistant::Base + include Assistant::Broadcastable + + attr_reader :chat + + def initialize(chat) + @chat = chat + end + + def respond_to(message) + raise NotImplementedError, "#{self.class}#respond_to must be implemented" + end +end diff --git a/app/models/assistant/builtin.rb b/app/models/assistant/builtin.rb new file mode 100644 index 000000000..1d615eb5a --- /dev/null +++ b/app/models/assistant/builtin.rb @@ -0,0 +1,95 @@ +class Assistant::Builtin < Assistant::Base + include Assistant::Provided + include Assistant::Configurable + + attr_reader :instructions + + class << self + def for_chat(chat) + config = config_for(chat) + new(chat, instructions: config[:instructions], functions: config[:functions]) + end + end + + def initialize(chat, instructions: nil, functions: []) + super(chat) + @instructions = instructions + @functions = functions + end + + def respond_to(message) + assistant_message = AssistantMessage.new( + chat: chat, + content: "", + ai_model: message.ai_model + ) + + llm_provider = get_model_provider(message.ai_model) + unless llm_provider + raise StandardError, build_no_provider_error_message(message.ai_model) + end + + responder = Assistant::Responder.new( + message: message, + instructions: instructions, + function_tool_caller: function_tool_caller, + llm: llm_provider + ) + + latest_response_id = chat.latest_assistant_response_id + + responder.on(:output_text) do |text| + if assistant_message.content.blank? + stop_thinking + Chat.transaction do + assistant_message.append_text!(text) + chat.update_latest_response!(latest_response_id) + end + else + assistant_message.append_text!(text) + end + end + + responder.on(:response) do |data| + update_thinking("Analyzing your data...") + if data[:function_tool_calls].present? + assistant_message.tool_calls = data[:function_tool_calls] + latest_response_id = data[:id] + else + chat.update_latest_response!(data[:id]) + end + end + + responder.respond(previous_response_id: latest_response_id) + rescue => e + stop_thinking + chat.add_error(e) + end + + private + + attr_reader :functions + + def function_tool_caller + @function_tool_caller ||= Assistant::FunctionToolCaller.new( + functions.map { |fn| fn.new(chat.user) } + ) + end + + def build_no_provider_error_message(requested_model) + available_providers = registry.providers + if available_providers.empty? + "No LLM provider configured that supports model '#{requested_model}'. " \ + "Please configure an LLM provider (e.g., OpenAI) in settings." + else + provider_details = available_providers.map do |provider| + " - #{provider.provider_name}: #{provider.supported_models_description}" + end.join("\n") + "No LLM provider configured that supports model '#{requested_model}'.\n\n" \ + "Available providers:\n#{provider_details}\n\n" \ + "Please either:\n" \ + " 1. Use a supported model from the list above, or\n" \ + " 2. Configure a provider that supports '#{requested_model}' in settings." + end + end +end diff --git a/app/models/assistant/external.rb b/app/models/assistant/external.rb new file mode 100644 index 000000000..276595dad --- /dev/null +++ b/app/models/assistant/external.rb @@ -0,0 +1,14 @@ +class Assistant::External < Assistant::Base + class << self + def for_chat(chat) + new(chat) + end + end + + def respond_to(message) + stop_thinking + chat.add_error( + StandardError.new("External assistant (OpenClaw/WebSocket) is not yet implemented.") + ) + end +end diff --git a/app/models/family.rb b/app/models/family.rb index 7296fa205..75cae5bf0 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -19,6 +19,7 @@ class Family < ApplicationRecord MONIKERS = [ "Family", "Group" ].freeze + ASSISTANT_TYPES = %w[builtin external].freeze has_many :users, dependent: :destroy has_many :accounts, dependent: :destroy @@ -47,6 +48,7 @@ class Family < ApplicationRecord validates :date_format, inclusion: { in: DATE_FORMATS.map(&:last) } validates :month_start_day, inclusion: { in: 1..28 } validates :moniker, inclusion: { in: MONIKERS } + validates :assistant_type, inclusion: { in: ASSISTANT_TYPES } def moniker_label diff --git a/db/migrate/20260218120001_add_assistant_type_to_families.rb b/db/migrate/20260218120001_add_assistant_type_to_families.rb new file mode 100644 index 000000000..44313cf9b --- /dev/null +++ b/db/migrate/20260218120001_add_assistant_type_to_families.rb @@ -0,0 +1,5 @@ +class AddAssistantTypeToFamilies < ActiveRecord::Migration[7.2] + def change + add_column :families, :assistant_type, :string, null: false, default: "builtin" + end +end diff --git a/db/schema.rb b/db/schema.rb index d87e5720a..82bfd1e0f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_02_18_120000) do +ActiveRecord::Schema[7.2].define(version: 2026_02_18_120001) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -503,6 +503,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_18_120000) do t.integer "month_start_day", default: 1, null: false t.string "vector_store_id" t.string "moniker", default: "Family", null: false + t.string "assistant_type", default: "builtin", null: false t.check_constraint "month_start_day >= 1 AND month_start_day <= 28", name: "month_start_day_range" end diff --git a/test/models/assistant_test.rb b/test/models/assistant_test.rb index bbb476c8f..7ced43542 100644 --- a/test/models/assistant_test.rb +++ b/test/models/assistant_test.rb @@ -176,6 +176,31 @@ class AssistantTest < ActiveSupport::TestCase end end + test "for_chat returns Builtin by default" do + assert_instance_of Assistant::Builtin, Assistant.for_chat(@chat) + end + + test "available_types includes builtin and external" do + assert_includes Assistant.available_types, "builtin" + assert_includes Assistant.available_types, "external" + end + + test "for_chat returns External when family assistant_type is external" do + @chat.user.family.update!(assistant_type: "external") + assistant = Assistant.for_chat(@chat) + assert_instance_of Assistant::External, assistant + assert_no_difference "AssistantMessage.count" do + assistant.respond_to(@message) + end + @chat.reload + assert @chat.error.present? + assert_includes @chat.error, "not yet implemented" + end + + test "for_chat raises when chat is blank" do + assert_raises(Assistant::Error) { Assistant.for_chat(nil) } + end + private def provider_function_request(id:, call_id:, function_name:, function_args:) Provider::LlmConcept::ChatFunctionRequest.new( From 17e9bb8fbfbb92553d2926bf564c9f60c396ce62 Mon Sep 17 00:00:00 2001 From: LPW Date: Mon, 23 Feb 2026 09:13:15 -0500 Subject: [PATCH 04/75] Add MCP server endpoint for external AI assistants (#1051) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add MCP server endpoint for external AI assistants Expose Sure's Assistant::Function tools via JSON-RPC 2.0 at POST /mcp, enabling external AI clients (Claude, GPT, etc.) to query financial data through the Model Context Protocol. - Bearer token auth via MCP_API_TOKEN / MCP_USER_EMAIL env vars - JSON-RPC 2.0 with proper id threading, notification handling (204) - Transient session (sessions.build) to prevent impersonation leaks - Centralize function_classes in Assistant module - Docker Compose example with Pipelock forward proxy - 18 integration tests with scoped env (ClimateControl) * Update compose for full Pipelock MCP reverse proxy integration Use Pipelock's --mcp-listen/--mcp-upstream flags (PR #127) to run bidirectional MCP scanning in the same container as the forward proxy. External AI clients connect to port 8889, Pipelock scans requests (DLP, injection, tool policy) and responses (injection, tool poisoning) before forwarding to Sure's /mcp endpoint. This supersedes the standalone compose in PR #1050. * Fix compose --preset→--mode, add port 3000 trust comment, notification test Review fixes: - pipelock run uses --mode not --preset (would prevent stack startup) - Document port 3000 exposes /mcp directly (auth still required) - Add version requirement note for Pipelock MCP listener support - Add test: tools/call sent as notification does not execute --- app/controllers/mcp_controller.rb | 150 ++++++++++++ app/models/assistant.rb | 12 + app/models/assistant/configurable.rb | 10 +- compose.example.pipelock.yml | 275 ++++++++++++++++++++++ config/routes.rb | 3 + test/controllers/mcp_controller_test.rb | 293 ++++++++++++++++++++++++ 6 files changed, 734 insertions(+), 9 deletions(-) create mode 100644 app/controllers/mcp_controller.rb create mode 100644 compose.example.pipelock.yml create mode 100644 test/controllers/mcp_controller_test.rb diff --git a/app/controllers/mcp_controller.rb b/app/controllers/mcp_controller.rb new file mode 100644 index 000000000..1a7308b86 --- /dev/null +++ b/app/controllers/mcp_controller.rb @@ -0,0 +1,150 @@ +class McpController < ApplicationController + PROTOCOL_VERSION = "2025-03-26" + + # Skip session-based auth and CSRF — this is a token-authenticated API + skip_authentication + skip_before_action :verify_authenticity_token + skip_before_action :require_onboarding_and_upgrade + skip_before_action :set_default_chat + skip_before_action :detect_os + + before_action :authenticate_mcp_token! + + def handle + body = parse_request_body + return if performed? + + unless valid_jsonrpc?(body) + render_jsonrpc_error(body&.dig("id"), -32600, "Invalid Request") + return + end + + request_id = body["id"] + + # JSON-RPC notifications omit the id field — server must not respond + unless body.key?("id") + return head(:no_content) + end + + result = dispatch_jsonrpc(request_id, body["method"], body["params"]) + return if performed? + + render json: { jsonrpc: "2.0", id: request_id, result: result } + end + + private + + def parse_request_body + JSON.parse(request.raw_post) + rescue JSON::ParserError + render_jsonrpc_error(nil, -32700, "Parse error") + nil + end + + def valid_jsonrpc?(body) + body.is_a?(Hash) && body["jsonrpc"] == "2.0" && body["method"].present? + end + + def dispatch_jsonrpc(request_id, method, params) + case method + when "initialize" + handle_initialize + when "tools/list" + handle_tools_list + when "tools/call" + handle_tools_call(request_id, params) + else + render_jsonrpc_error(request_id, -32601, "Method not found: #{method}") + nil + end + end + + def handle_initialize + { + protocolVersion: PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: { name: "sure", version: "1.0" } + } + end + + def handle_tools_list + tools = Assistant.function_classes.map do |fn_class| + fn_instance = fn_class.new(mcp_user) + { + name: fn_instance.name, + description: fn_instance.description, + inputSchema: fn_instance.params_schema + } + end + + { tools: tools } + end + + def handle_tools_call(request_id, params) + name = params&.dig("name") + arguments = params&.dig("arguments") || {} + + fn_class = Assistant.function_classes.find { |fc| fc.name == name } + + unless fn_class + render_jsonrpc_error(request_id, -32602, "Unknown tool: #{name}") + return nil + end + + fn = fn_class.new(mcp_user) + result = fn.call(arguments) + + { content: [ { type: "text", text: result.to_json } ] } + rescue => e + Rails.logger.error "MCP tools/call error: #{e.message}" + { content: [ { type: "text", text: { error: e.message }.to_json } ], isError: true } + end + + def authenticate_mcp_token! + expected = ENV["MCP_API_TOKEN"] + + unless expected.present? + render json: { error: "MCP endpoint not configured" }, status: :service_unavailable + return + end + + token = request.headers["Authorization"]&.delete_prefix("Bearer ")&.strip + + unless ActiveSupport::SecurityUtils.secure_compare(token.to_s, expected) + render json: { error: "unauthorized" }, status: :unauthorized + return + end + + setup_mcp_user + end + + def setup_mcp_user + email = ENV["MCP_USER_EMAIL"] + @mcp_user = User.find_by(email: email) if email.present? + + unless @mcp_user + render json: { error: "MCP user not configured" }, status: :service_unavailable + return + end + + # Build a fresh session to avoid inheriting impersonation state from + # existing sessions (Current.user resolves via active_impersonator_session + # first, which could leak another user's data into MCP tool calls). + Current.session = @mcp_user.sessions.build( + user_agent: request.user_agent, + ip_address: request.ip + ) + end + + def mcp_user + @mcp_user + end + + def render_jsonrpc_error(id, code, message) + render json: { + jsonrpc: "2.0", + id: id, + error: { code: code, message: message } + } + end +end diff --git a/app/models/assistant.rb b/app/models/assistant.rb index ca649ceac..582af9b0b 100644 --- a/app/models/assistant.rb +++ b/app/models/assistant.rb @@ -20,6 +20,18 @@ module Assistant REGISTRY.keys end + def function_classes + [ + Function::GetTransactions, + Function::GetAccounts, + Function::GetHoldings, + Function::GetBalanceSheet, + Function::GetIncomeStatement, + Function::ImportBankStatement, + Function::SearchFamilyFiles + ] + end + private def implementation_for(chat) diff --git a/app/models/assistant/configurable.rb b/app/models/assistant/configurable.rb index 94d8755f1..8c68ffb4f 100644 --- a/app/models/assistant/configurable.rb +++ b/app/models/assistant/configurable.rb @@ -52,15 +52,7 @@ module Assistant::Configurable end def default_functions - [ - Assistant::Function::GetTransactions, - Assistant::Function::GetAccounts, - Assistant::Function::GetHoldings, - Assistant::Function::GetBalanceSheet, - Assistant::Function::GetIncomeStatement, - Assistant::Function::ImportBankStatement, - Assistant::Function::SearchFamilyFiles - ] + Assistant.function_classes end def default_instructions(preferred_currency, preferred_date_format) diff --git a/compose.example.pipelock.yml b/compose.example.pipelock.yml new file mode 100644 index 000000000..b70bbb916 --- /dev/null +++ b/compose.example.pipelock.yml @@ -0,0 +1,275 @@ +# =========================================================================== +# Example Docker Compose file with Pipelock agent security proxy +# =========================================================================== +# +# Purpose: +# -------- +# +# This file adds Pipelock (https://github.com/luckyPipewrench/pipelock) +# as a security proxy for Sure, providing two layers of protection: +# +# 1. Forward proxy (port 8888) — routes outbound HTTPS through Pipelock +# for clients that respect the HTTPS_PROXY environment variable. +# +# 2. MCP reverse proxy (port 8889) — scans inbound MCP traffic from +# external AI assistants bidirectionally (DLP, prompt injection, +# tool poisoning, tool call policy). +# +# Forward proxy coverage: +# ----------------------- +# +# Covered (Faraday-based clients respect HTTPS_PROXY automatically): +# - OpenAI API calls (ruby-openai gem) +# - Market data providers using Faraday +# +# NOT covered (these clients ignore HTTPS_PROXY): +# - SimpleFin (HTTParty / Net::HTTP) +# - Coinbase (HTTParty / Net::HTTP) +# - Any code using Net::HTTP or HTTParty directly +# +# For covered traffic, Pipelock provides: +# - Domain allowlisting (only known-good external APIs can be reached) +# - SSRF protection (blocks connections to private/internal IPs) +# - DLP scanning on connection targets (detects exfiltration patterns) +# - Rate limiting per domain +# - Structured JSON audit logging of all outbound connections +# +# MCP reverse proxy coverage: +# --------------------------- +# +# External AI assistants connect to Pipelock on port 8889 instead of +# directly to Sure's /mcp endpoint. Pipelock scans all traffic: +# +# Request scanning (client → Sure): +# - DLP detection (blocks credential/secret leakage in tool arguments) +# - Prompt injection detection in tool call parameters +# - Tool call policy enforcement (blocks dangerous operations) +# +# Response scanning (Sure → client): +# - Prompt injection detection in tool response content +# - Tool poisoning / drift detection (tool definitions changing) +# +# The MCP endpoint on Sure (port 3000/mcp) should NOT be exposed directly +# to the internet. Route all external MCP traffic through Pipelock. +# +# Limitations: +# ------------ +# +# HTTPS_PROXY is cooperative. Docker Compose has no egress network policy, +# so any code path that doesn't check the env var can connect directly. +# For hard enforcement, deploy with network-level controls that deny all +# egress except through the proxy. Example for Kubernetes: +# +# # NetworkPolicy: deny all egress, allow only proxy + DNS +# egress: +# - to: +# - podSelector: +# matchLabels: +# app: pipelock +# ports: +# - port: 8888 +# - ports: +# - port: 53 +# protocol: UDP +# +# Monitoring: +# ----------- +# +# Pipelock logs every connection and MCP request as structured JSON to stdout. +# View logs with: docker compose logs pipelock +# +# Forward proxy endpoints (port 8888): +# http://localhost:8888/health - liveness check +# http://localhost:8888/metrics - Prometheus metrics +# http://localhost:8888/stats - JSON summary +# +# More info: https://github.com/luckyPipewrench/pipelock +# +# Setup: +# ------ +# +# 1. Copy this file to compose.yml (or use -f flag) +# 2. Set your environment variables (OPENAI_ACCESS_TOKEN, MCP_API_TOKEN, etc.) +# 3. docker compose up +# +# Pipelock runs both proxies in a single container: +# - Port 8888: forward proxy for outbound HTTPS (internal only) +# - Port 8889: MCP reverse proxy for external AI assistants +# +# External AI clients connect to http://:8889 as their MCP endpoint. +# Pipelock scans the traffic and forwards clean requests to Sure's /mcp. +# +# Customization: +# -------------- +# +# Requires Pipelock with MCP HTTP listener support (--mcp-listen flag). +# See: https://github.com/luckyPipewrench/pipelock/releases +# +# Edit the pipelock command to change the mode: +# --mode strict Block unknown domains (recommended for production) +# --mode balanced Warn on unknown domains, block known-bad (default) +# --mode audit Log everything, block nothing (for evaluation) +# +# For a custom config, mount a file and use --config instead of --mode: +# volumes: +# - ./config/pipelock.yml:/etc/pipelock/config.yml:ro +# command: ["run", "--config", "/etc/pipelock/config.yml", +# "--mcp-listen", "0.0.0.0:8889", "--mcp-upstream", "http://web:3000/mcp"] +# + +x-db-env: &db_env + POSTGRES_USER: ${POSTGRES_USER:-sure_user} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-sure_password} + POSTGRES_DB: ${POSTGRES_DB:-sure_production} + +x-rails-env: &rails_env + <<: *db_env + SECRET_KEY_BASE: ${SECRET_KEY_BASE:-a7523c3d0ae56415046ad8abae168d71074a79534a7062258f8d1d51ac2f76d3c3bc86d86b6b0b307df30d9a6a90a2066a3fa9e67c5e6f374dbd7dd4e0778e13} + SELF_HOSTED: "true" + RAILS_FORCE_SSL: "false" + RAILS_ASSUME_SSL: "false" + DB_HOST: db + DB_PORT: 5432 + REDIS_URL: redis://redis:6379/1 + # NOTE: enabling OpenAI will incur costs when you use AI-related features in the app (chat, rules). Make sure you have set appropriate spend limits on your account before adding this. + OPENAI_ACCESS_TOKEN: ${OPENAI_ACCESS_TOKEN} + # MCP server endpoint — enables /mcp for external AI assistants (e.g. Claude, GPT). + # Set both values to activate. MCP_USER_EMAIL must match an existing user's email. + # External AI clients connect via Pipelock (port 8889), not directly to /mcp. + MCP_API_TOKEN: ${MCP_API_TOKEN:-} + MCP_USER_EMAIL: ${MCP_USER_EMAIL:-} + # Route outbound HTTPS through Pipelock for clients that respect HTTPS_PROXY. + # See "Forward proxy coverage" section above for which clients are covered. + HTTPS_PROXY: "http://pipelock:8888" + HTTP_PROXY: "http://pipelock:8888" + # Skip proxy for internal Docker network services + NO_PROXY: "db,redis,pipelock,localhost,127.0.0.1" + +services: + pipelock: + image: ghcr.io/luckypipewrench/pipelock:latest + container_name: pipelock + hostname: pipelock + restart: unless-stopped + command: + - "run" + - "--listen" + - "0.0.0.0:8888" + - "--mode" + - "balanced" + - "--mcp-listen" + - "0.0.0.0:8889" + - "--mcp-upstream" + - "http://web:3000/mcp" + ports: + # MCP reverse proxy — external AI assistants connect here + - "${MCP_PROXY_PORT:-8889}:8889" + # Uncomment to expose forward proxy endpoints (/health, /metrics, /stats): + # - "8888:8888" + healthcheck: + test: ["CMD", "/pipelock", "healthcheck", "--addr", "127.0.0.1:8888"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 30s + networks: + - sure_net + + web: + image: ghcr.io/we-promise/sure:stable + volumes: + - app-storage:/rails/storage + ports: + # Web UI for browser access. Note: /mcp is also reachable on this port, + # bypassing Pipelock's MCP scanning (auth token is still required). + # For hardened deployments, use `expose: [3000]` instead and front + # the web UI with a separate reverse proxy. + - ${PORT:-3000}:3000 + restart: unless-stopped + environment: + <<: *rails_env + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + pipelock: + condition: service_healthy + networks: + - sure_net + + worker: + image: ghcr.io/we-promise/sure:stable + command: bundle exec sidekiq + volumes: + - app-storage:/rails/storage + restart: unless-stopped + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + pipelock: + condition: service_healthy + environment: + <<: *rails_env + networks: + - sure_net + + db: + image: postgres:16 + restart: unless-stopped + volumes: + - postgres-data:/var/lib/postgresql/data + environment: + <<: *db_env + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" ] + interval: 5s + timeout: 5s + retries: 5 + networks: + - sure_net + + backup: + profiles: + - backup + image: prodrigestivill/postgres-backup-local + restart: unless-stopped + volumes: + - /opt/sure-data/backups:/backups # Change this path to your desired backup location on the host machine + environment: + - POSTGRES_HOST=db + - POSTGRES_DB=${POSTGRES_DB:-sure_production} + - POSTGRES_USER=${POSTGRES_USER:-sure_user} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-sure_password} + - SCHEDULE=@daily # Runs once a day at midnight + - BACKUP_KEEP_DAYS=7 # Keeps the last 7 days of backups + - BACKUP_KEEP_WEEKS=4 # Keeps 4 weekly backups + - BACKUP_KEEP_MONTHS=6 # Keeps 6 monthly backups + depends_on: + - db + networks: + - sure_net + + redis: + image: redis:latest + restart: unless-stopped + volumes: + - redis-data:/data + healthcheck: + test: [ "CMD", "redis-cli", "ping" ] + interval: 5s + timeout: 5s + retries: 5 + networks: + - sure_net + +volumes: + app-storage: + postgres-data: + redis-data: + +networks: + sure_net: + driver: bridge diff --git a/config/routes.rb b/config/routes.rb index b72530f40..90e2c8f9a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -472,6 +472,9 @@ Rails.application.routes.draw do get "redis-configuration-error", to: "pages#redis_configuration_error" + # MCP server endpoint for external AI assistants (JSON-RPC 2.0) + post "mcp", to: "mcp#handle" + # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. # Can be used by load balancers and uptime monitors to verify that the app is live. get "up" => "rails/health#show", as: :rails_health_check diff --git a/test/controllers/mcp_controller_test.rb b/test/controllers/mcp_controller_test.rb new file mode 100644 index 000000000..2b23791be --- /dev/null +++ b/test/controllers/mcp_controller_test.rb @@ -0,0 +1,293 @@ +require "test_helper" + +class McpControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:family_admin) + @token = "test-mcp-token-#{SecureRandom.hex(8)}" + end + + # -- Authentication -- + + test "returns 401 without authorization header" do + with_mcp_env do + post "/mcp", params: jsonrpc_request("initialize").to_json, + headers: { "Content-Type" => "application/json" } + + assert_response :unauthorized + assert_equal "unauthorized", JSON.parse(response.body)["error"] + end + end + + test "returns 401 with wrong token" do + with_mcp_env do + post "/mcp", params: jsonrpc_request("initialize").to_json, + headers: mcp_headers("wrong-token") + + assert_response :unauthorized + end + end + + test "returns 503 when MCP_API_TOKEN is not set" do + with_env_overrides("MCP_USER_EMAIL" => @user.email) do + post "/mcp", params: jsonrpc_request("initialize").to_json, + headers: mcp_headers(@token) + + assert_response :service_unavailable + assert_includes JSON.parse(response.body)["error"], "not configured" + end + end + + test "returns 503 when MCP_USER_EMAIL is not set" do + with_env_overrides("MCP_API_TOKEN" => @token) do + post "/mcp", params: jsonrpc_request("initialize").to_json, + headers: mcp_headers(@token) + + assert_response :service_unavailable + assert_includes JSON.parse(response.body)["error"], "user not configured" + end + end + + test "returns 503 when MCP_USER_EMAIL does not match any user" do + with_env_overrides("MCP_API_TOKEN" => @token, "MCP_USER_EMAIL" => "nonexistent@example.com") do + post "/mcp", params: jsonrpc_request("initialize").to_json, + headers: mcp_headers(@token) + + assert_response :service_unavailable + end + end + + # -- JSON-RPC protocol -- + + test "returns parse error for invalid JSON" do + with_mcp_env do + # Send with text/plain to bypass Rails JSON middleware parsing + post "/mcp", params: "not valid json", + headers: mcp_headers(@token).merge("Content-Type" => "text/plain") + + assert_response :ok + body = JSON.parse(response.body) + assert_equal(-32700, body["error"]["code"]) + assert_includes body["error"]["message"], "Parse error" + end + end + + test "returns invalid request for missing jsonrpc version" do + with_mcp_env do + post "/mcp", params: { method: "initialize" }.to_json, + headers: mcp_headers(@token) + + assert_response :ok + body = JSON.parse(response.body) + assert_equal(-32600, body["error"]["code"]) + end + end + + test "returns method not found for unknown method with request id preserved" do + with_mcp_env do + post "/mcp", params: jsonrpc_request("unknown/method", {}, id: 77).to_json, + headers: mcp_headers(@token) + + assert_response :ok + body = JSON.parse(response.body) + assert_equal(-32601, body["error"]["code"]) + assert_includes body["error"]["message"], "unknown/method" + assert_equal 77, body["id"], "Error response must echo the request id" + end + end + + # -- Notifications (requests without id) -- + + test "notifications receive no response body" do + with_mcp_env do + post "/mcp", params: jsonrpc_notification("notifications/initialized").to_json, + headers: mcp_headers(@token) + + assert_response :no_content + assert response.body.blank?, "Notification must not produce a response body" + end + end + + test "tools/call sent as notification does not execute" do + with_mcp_env do + post "/mcp", params: jsonrpc_notification("tools/call", { name: "get_balance_sheet", arguments: {} }).to_json, + headers: mcp_headers(@token) + + assert_response :no_content + assert response.body.blank?, "Notification-style tools/call must not execute or respond" + end + end + + test "unknown notification method still returns no content" do + with_mcp_env do + post "/mcp", params: jsonrpc_notification("notifications/unknown").to_json, + headers: mcp_headers(@token) + + assert_response :no_content + assert response.body.blank? + end + end + + # -- initialize -- + + test "initialize returns server info and capabilities" do + with_mcp_env do + post "/mcp", params: jsonrpc_request("initialize", { protocolVersion: "2025-03-26" }).to_json, + headers: mcp_headers(@token) + + assert_response :ok + body = JSON.parse(response.body) + result = body["result"] + + assert_equal "2.0", body["jsonrpc"] + assert_equal 1, body["id"] + assert_equal "2025-03-26", result["protocolVersion"] + assert_equal "sure", result["serverInfo"]["name"] + assert result["capabilities"].key?("tools") + end + end + + # -- tools/list -- + + test "tools/list returns all assistant function tools" do + with_mcp_env do + post "/mcp", params: jsonrpc_request("tools/list").to_json, + headers: mcp_headers(@token) + + assert_response :ok + body = JSON.parse(response.body) + tools = body["result"]["tools"] + + assert_kind_of Array, tools + assert_equal Assistant.function_classes.size, tools.size + + tool_names = tools.map { |t| t["name"] } + assert_includes tool_names, "get_transactions" + assert_includes tool_names, "get_accounts" + assert_includes tool_names, "get_holdings" + assert_includes tool_names, "get_balance_sheet" + assert_includes tool_names, "get_income_statement" + + # Each tool has required fields + tools.each do |tool| + assert tool["name"].present?, "Tool missing name" + assert tool["description"].present?, "Tool #{tool['name']} missing description" + assert tool["inputSchema"].present?, "Tool #{tool['name']} missing inputSchema" + assert_equal "object", tool["inputSchema"]["type"] + end + end + end + + # -- tools/call -- + + test "tools/call returns error for unknown tool with request id preserved" do + with_mcp_env do + post "/mcp", params: jsonrpc_request("tools/call", { name: "nonexistent_tool", arguments: {} }, id: 99).to_json, + headers: mcp_headers(@token) + + assert_response :ok + body = JSON.parse(response.body) + assert_equal(-32602, body["error"]["code"]) + assert_includes body["error"]["message"], "nonexistent_tool" + assert_equal 99, body["id"], "Error response must echo the request id" + end + end + + test "tools/call executes get_balance_sheet" do + with_mcp_env do + post "/mcp", params: jsonrpc_request("tools/call", { + name: "get_balance_sheet", + arguments: {} + }).to_json, headers: mcp_headers(@token) + + assert_response :ok + body = JSON.parse(response.body) + result = body["result"] + + assert_kind_of Array, result["content"] + assert_equal "text", result["content"][0]["type"] + + # The text field should be valid JSON + inner = JSON.parse(result["content"][0]["text"]) + assert inner.key?("net_worth") || inner.key?("error"), + "Expected balance sheet data or error, got: #{inner.keys}" + end + end + + test "tools/call wraps function errors as isError response" do + with_mcp_env do + # Force a function error by stubbing + Assistant::Function::GetBalanceSheet.any_instance.stubs(:call).raises(StandardError, "test error") + + post "/mcp", params: jsonrpc_request("tools/call", { + name: "get_balance_sheet", + arguments: {} + }).to_json, headers: mcp_headers(@token) + + assert_response :ok + body = JSON.parse(response.body) + result = body["result"] + + assert result["isError"], "Expected isError to be true" + inner = JSON.parse(result["content"][0]["text"]) + assert_equal "test error", inner["error"] + end + end + + # -- Session isolation -- + + test "does not persist sessions or inherit impersonation state" do + with_mcp_env do + assert_no_difference "Session.count" do + post "/mcp", params: jsonrpc_request("initialize").to_json, + headers: mcp_headers(@token) + end + + assert_response :ok + end + end + + # -- JSON-RPC id preservation -- + + test "preserves request id in successful response" do + with_mcp_env do + post "/mcp", params: jsonrpc_request("initialize", {}, id: 42).to_json, + headers: mcp_headers(@token) + + assert_response :ok + body = JSON.parse(response.body) + assert_equal 42, body["id"] + end + end + + test "preserves string request id" do + with_mcp_env do + post "/mcp", params: jsonrpc_request("initialize", {}, id: "req-abc-123").to_json, + headers: mcp_headers(@token) + + assert_response :ok + body = JSON.parse(response.body) + assert_equal "req-abc-123", body["id"] + end + end + + private + + def with_mcp_env(&block) + with_env_overrides("MCP_API_TOKEN" => @token, "MCP_USER_EMAIL" => @user.email, &block) + end + + def mcp_headers(token) + { + "Content-Type" => "application/json", + "Authorization" => "Bearer #{token}" + } + end + + def jsonrpc_request(method, params = {}, id: 1) + { jsonrpc: "2.0", id: id, method: method, params: params } + end + + def jsonrpc_notification(method, params = {}) + { jsonrpc: "2.0", method: method, params: params } + end +end From 1f9a934c5972b8dc905a78d5d7b0aa505b687b1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Mon, 23 Feb 2026 14:22:44 +0000 Subject: [PATCH 05/75] Add build ID to Flutter app version display --- mobile/lib/screens/settings_screen.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mobile/lib/screens/settings_screen.dart b/mobile/lib/screens/settings_screen.dart index b8ddc0a4e..04b8e2649 100644 --- a/mobile/lib/screens/settings_screen.dart +++ b/mobile/lib/screens/settings_screen.dart @@ -31,7 +31,11 @@ class _SettingsScreenState extends State { Future _loadAppVersion() async { final packageInfo = await PackageInfo.fromPlatform(); if (mounted) { - setState(() => _appVersion = packageInfo.version); + final build = packageInfo.buildNumber; + final display = build.isNotEmpty + ? '${packageInfo.version} (${build})' + : packageInfo.version; + setState(() => _appVersion = display); } } From 90e94f0ad15892b4cf9959dbc7a8824526ff7b84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Mon, 23 Feb 2026 09:29:21 -0500 Subject: [PATCH 06/75] Use Langfuse client trace upsert API (#1041) Replace direct trace.update calls with client trace upserts so OpenAI provider is compatible with langfuse-ruby 0.1.6 behavior. Add richer warning logs that include full exception details for trace creation, trace upserts, and generation logging failures. Add tests for client-based trace upserts and detailed error logging. --- app/models/provider/openai.rb | 32 +++++++++++++++------ test/models/provider/openai_test.rb | 44 +++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/app/models/provider/openai.rb b/app/models/provider/openai.rb index 6ec10333d..154b2d02e 100644 --- a/app/models/provider/openai.rb +++ b/app/models/provider/openai.rb @@ -82,7 +82,7 @@ class Provider::Openai < Provider json_mode: json_mode ).auto_categorize - trace&.update(output: result.map(&:to_h)) + upsert_langfuse_trace(trace: trace, output: result.map(&:to_h)) result end @@ -110,7 +110,7 @@ class Provider::Openai < Provider json_mode: json_mode ).auto_detect_merchants - trace&.update(output: result.map(&:to_h)) + upsert_langfuse_trace(trace: trace, output: result.map(&:to_h)) result end @@ -147,7 +147,7 @@ class Provider::Openai < Provider family: family ).process - trace&.update(output: result.to_h) + upsert_langfuse_trace(trace: trace, output: result.to_h) result end @@ -168,7 +168,7 @@ class Provider::Openai < Provider model: effective_model ).extract - trace&.update(output: { transaction_count: result[:transactions].size }) + upsert_langfuse_trace(trace: trace, output: { transaction_count: result[:transactions].size }) result end @@ -480,7 +480,7 @@ class Provider::Openai < Provider environment: Rails.env ) rescue => e - Rails.logger.warn("Langfuse trace creation failed: #{e.message}") + Rails.logger.warn("Langfuse trace creation failed: #{e.message}\n#{e.full_message}") nil end @@ -505,16 +505,32 @@ class Provider::Openai < Provider output: { error: error.message, details: error.respond_to?(:details) ? error.details : nil }, level: "ERROR" ) - trace&.update( + upsert_langfuse_trace( + trace: trace, output: { error: error.message }, level: "ERROR" ) else generation&.end(output: output, usage: usage) - trace&.update(output: output) + upsert_langfuse_trace(trace: trace, output: output) end rescue => e - Rails.logger.warn("Langfuse logging failed: #{e.message}") + Rails.logger.warn("Langfuse logging failed: #{e.message}\n#{e.full_message}") + end + + def upsert_langfuse_trace(trace:, output:, level: nil) + return unless langfuse_client && trace&.id + + payload = { + id: trace.id, + output: output + } + payload[:level] = level if level.present? + + langfuse_client.trace(**payload) + rescue => e + Rails.logger.warn("Langfuse trace upsert failed for trace_id=#{trace&.id}: #{e.message}\n#{e.full_message}") + nil end def record_llm_usage(family:, model:, operation:, usage: nil, error: nil) diff --git a/test/models/provider/openai_test.rb b/test/models/provider/openai_test.rb index 83b2b787e..879fe20e7 100644 --- a/test/models/provider/openai_test.rb +++ b/test/models/provider/openai_test.rb @@ -286,4 +286,48 @@ class Provider::OpenaiTest < ActiveSupport::TestCase assert_equal "configured model: custom-model", custom_provider.supported_models_description end + + test "upsert_langfuse_trace uses client trace upsert" do + trace = Struct.new(:id).new("trace_123") + fake_client = mock + + fake_client.expects(:trace).with(id: "trace_123", output: { ok: true }, level: "ERROR") + @subject.stubs(:langfuse_client).returns(fake_client) + + @subject.send(:upsert_langfuse_trace, trace: trace, output: { ok: true }, level: "ERROR") + end + + test "log_langfuse_generation upserts trace through client" do + trace = Struct.new(:id).new("trace_456") + generation = mock + fake_client = mock + + @subject.stubs(:langfuse_client).returns(fake_client) + @subject.stubs(:create_langfuse_trace).returns(trace) + + fake_client.expects(:trace).with(id: "trace_456", output: "hello") + trace.expects(:generation).returns(generation) + generation.expects(:end).with(output: "hello", usage: { "total_tokens" => 10 }) + + @subject.send( + :log_langfuse_generation, + name: "chat", + model: "gpt-4.1", + input: { prompt: "Hi" }, + output: "hello", + usage: { "total_tokens" => 10 } + ) + end + + test "create_langfuse_trace logs full error details" do + fake_client = mock + error = StandardError.new("boom") + + @subject.stubs(:langfuse_client).returns(fake_client) + fake_client.expects(:trace).raises(error) + + Rails.logger.expects(:warn).with(regexp_matches(/Langfuse trace creation failed: boom.*test\/models\/provider\/openai_test\.rb/m)) + + @subject.send(:create_langfuse_trace, name: "openai.test", input: { foo: "bar" }) + end end From 4dd5ed4379322e77481ed3a9c841c828de582970 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 23 Feb 2026 14:39:33 +0000 Subject: [PATCH 07/75] Bump version to next iteration after v0.6.8-alpha.13 release --- charts/sure/Chart.yaml | 4 ++-- config/initializers/version.rb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/charts/sure/Chart.yaml b/charts/sure/Chart.yaml index 260c9fdcc..6c2cd02eb 100644 --- a/charts/sure/Chart.yaml +++ b/charts/sure/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: sure description: Official Helm chart for deploying the Sure Rails app (web + Sidekiq) on Kubernetes with optional HA PostgreSQL (CloudNativePG) and Redis. type: application -version: 0.6.8-alpha.13 -appVersion: "0.6.8-alpha.13" +version: 0.6.8-alpha.14 +appVersion: "0.6.8-alpha.14" kubeVersion: ">=1.25.0-0" diff --git a/config/initializers/version.rb b/config/initializers/version.rb index 1d5103c1b..60ce0779c 100644 --- a/config/initializers/version.rb +++ b/config/initializers/version.rb @@ -16,7 +16,7 @@ module Sure private def semver - "0.6.8-alpha.13" + "0.6.8-alpha.14" end end end From 4ba90e0e8a9df5824be9d8c6d921f6be30e92d11 Mon Sep 17 00:00:00 2001 From: 0xRozier Date: Mon, 23 Feb 2026 23:05:46 +0100 Subject: [PATCH 08/75] fix: Update PWA icons to use current logo (#1052) * fix: Update PWA icons to use current logo (#997) Replace outdated android-chrome-192x192.png and logo-pwa.png with the current logo. The old icons showed the previous branding (cyan border / old logomark) which appeared when creating web shortcuts on smartphones. Also add the 192x192 icon entry to the PWA manifest for better Android home screen icon support. Co-Authored-By: Claude Opus 4.6 * fix: Replace transparent background with solid #F9F9F9 in 192x192 PWA icon The android-chrome-192x192.png had an RGBA transparent background which can cause display issues on Android home-screen shortcuts. Regenerated with a solid #F9F9F9 background to match theme_color/background_color in the PWA manifest and the 512x512 icon. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- app/views/pwa/manifest.json.erb | 5 +++++ public/android-chrome-192x192.png | Bin 2842 -> 10757 bytes public/logo-pwa.png | Bin 11654 -> 7550 bytes 3 files changed, 5 insertions(+) diff --git a/app/views/pwa/manifest.json.erb b/app/views/pwa/manifest.json.erb index e7960c255..820c221a8 100644 --- a/app/views/pwa/manifest.json.erb +++ b/app/views/pwa/manifest.json.erb @@ -2,6 +2,11 @@ "name": "<%= j product_name %>", "short_name": "<%= j product_name %>", "icons": [ + { + "src": "/android-chrome-192x192.png", + "type": "image/png", + "sizes": "192x192" + }, { "src": "/logo-pwa.png", "type": "image/png", diff --git a/public/android-chrome-192x192.png b/public/android-chrome-192x192.png index cadde8cd2dd6ccc8de0881752896e5287b6e6fc9..b456e5ca8698a2bd8fc16f5bac965f119d945220 100644 GIT binary patch literal 10757 zcmZvicRZEvAI6X4aE^WK&9V31`;fg!vRC%V$W}Nu*|M@LBxGgp5F(U46N!*0E5z^a z`}gm8y?W}E<9W{W-1q(YT-W=$6Akq>i16v~ArJ_WmZqvP_`C1l9|RPfS9>lEArPK% zEmdWc;KCo)K@Uytoc4Nazn{?!<#>UPgx!LXk)N}~*qO;9_@lT{BigG0cLH&iqdG3I zsH}aJ?I^7&u#61T5v+D9cv`3zMo>H+e+5yOkMgB4L;G z#b^Z*m~w)u6|MB^<7f>H^z<7vid#@H{A@L7$U$U4nApRDn?h1bO31pyf1%!*z~}OqP$n1@O9k;JFB%V~_xY^* z-qUUSTM#$ROjTlbWMqX~lXo!G9IG-%h5>GX;#;2)jo)0ioqiPQG*PaTE9tqgzPr1t ziKC2E_3+qxpUnp+VIo}hF`Hh+GAO--B=ZOetagXB8gl#Rv9ckeP>81$TzXH%gAeWC zmXfQOO~hbcq*(>?PFS@Cvn@9Bp@gu+4|ndiwl>u8Q#Ldzx*tXLm~r}9t(_25gz(n1 zI8zu6A$=;mM`l16ro51Ir+yBl^~vv}J=c2fW<8Y`jwKl)3wI}~DEqLoqM{-$E^cXQ zsX)qmZfpQ&J@$vDwxw$61M0j{d zCnwpXj>c6_9z{TEJTV3e9s<)3?6ILA#r3A&(7J&qzQ5Au`}g9c`fi=Gi_7PZfNF5J zmX*CQcey1i_m#e z!mD^uZ2^08?GLs^#l#-?`3c%|EzHa~x)GA`uM5>KB|Edi9OVXt1^zp>wzka8&3$MG zgn6@H8I&k~{P^)!JTarJzpJWhj0rE!Zlske;>cl?X$K#37>s~HGui8`+Wn4qK0en- zE0%en{`_NK!rN?APJKg^lE3owW<%=>lvXAY5mEksM@htWyBYuK*%`B}|IXo;f!oH$ ztZZxzRxx)dC)w$rJ%Byq#<#4=&dzpobF+PTq-JVLWpehbeu&(u>HWCHgWz6`{7c6M4SBGL{@pTnZ0h;PgA@$C(eC|zyk+WN~f ziV8HQYrT;UX2gufj3%YHMxm0`?}dklD=YVNl|1|e-loxW^%O=qNeKxsT&yv!Pg{O0 z9g+@4 zEAMEGnSD|0@^y$;k!*%6#a+L&jt)yqtdy+VuMifrs zboOeT6!@_!_F7t_!^{zdPo6x<%rv&JND3`Ii?^E3}y`uYIp8X9^$_1D`R|v zjpm8QS>0?QZo}mxL%is=sk80y|302C_H(73jRL=aDkRUDSDv7*s{`)=9_;8-KtMo~ z^9R|0-6xvt8&6Zlqi?sRq5F#Ef(~@^MLD>*H2A5q*#8J?F)~RGKPu2bg;f}o6lZ0z zktKHYdJk=u4O`PAlHcd#+Y! zz1%kv(rEFN=fKmfdXegWkIfbM7RNrmq4?mho9fD=TB2hZU}JFe%^#wQ{z2 zEq~iDKHSMbqGBeBP1?)>a5%LFokk#!;b8?J`zRl==S6cCtoZ{9D!E?M9?)(?3X&%_$5!ji2G{xg~$_Y?+)|670? zgNgR`_CIV%5~U**A6?Yn3<~R-n=hX1%}f`U=7^i?v0R}nc03+h`FKwpFl>T?#QFGAGPuIw zE|bLqf`V~Y0?KeyBjmbv3O1sSGm!d>o0>Y8lufJl9H2SGL?P=MWPS}1_m+ds;oxb6i`gx{JfN~ zu&}iB!q}Kjl}Ttw2&O$cA0wuP-eu?Hd zbBGHQV?1JQwb5xT@W+yqFbxAwOuB$}XhAkXR0L1btEwt39UVEpuL*|S3}VjXVw3V> zz8k7lCiqL_BcS*|rM~QS`O^m7Z4nU>c}y*&UO`S1inP-r`9`c}WGBZK)7MCq2)=ZA zdAY*P)$eFH$y$q7flXa}ALU`>Z{m0NH*9>TTh~H8`XaH^pEnHqju7S<*HdA`F^1e- zA-}(Yua%jV)zs9~-#n7byQg?SmQdl=Zq)5NZQH=|1&+W4{DDrDXWGj*9bm{&M%Ykic4PNz4R z_74sY4h_A~5$cBFO{~X;<<0RRp+F`C?JqR=ZmPeO*xT1%E!Wr4dE6xTNoHj%MT2vg zP;Xkh+;6%yw!k9|nWcxTc~~ zF$8*CJ}C4#0+v8C1cP=4WQ1OcqJoHsQMqyAki-5Uwkf6*DqMtVN^Lpct*%3=Oi-$< zv-Upx^9zFwM^!*34fc7Q%qq+MU}skf>pblb|M)pLY((J@%+*?MfBAxmr&>TGV{|zm zKYkpIK*kt?Zu98T-xPYOfAR;^NpSa-_6L=qjkdSbChbXzi=%E|vBZeA&QeDAuLsjY z1e~3mwzjv22M2xZ*Mw}k%*u_+U%coe(5U!9H`VUsXz$rih z<7rScKy9FBVoE(Bc%6&*1Z1P_zvFZG-;4ZJ_yYC|j5kf3o2#pT%&Nls+5|9Yin`dNi{q`aTp*YfxVcBBr*GHI1I@-KCl@wZ ztiWYZ{P1+qg@Y{7oUm=+Lu!25-PymU-~)V6{_6Tl+9OwA%(~ zC?i{oTGPsDw`PkJJlP`s&m6AJ7Tq>C$I}LE!}mv(rTb>8Zl8p9Thz|&oqUeCQSe(M z=9}AO%@`RT<^mH9jC;v1QU->r4^p4|Tr{*%G^7i zc^>&QLXik!ljGCVK(o#tkZ-~rw)k)5^2OcADwCcop>Ovk71=pOxi>G>{C5cToU5>t zg@lHxYiIy*sKm|9EiOJYIVmJ9ZB}LS-cb~^`oN7LFtqOOuWD-rz*tR8Twh=Qth4Tn zQ^;?Ln1YuzH)r#i;t-tQK_6G>a?&e=9%u92EnMc4ly!8k4`OKWfU~Gh`-6!Z5Fjy# z6}81jf#2QL^-t%ouC4;Dzp_FTcXV`wwQcy|L6b#og&W<%fFg|CrD|zgiy2a+zkU9- zEfsrBS4AaCr3`fZiGv9R|0PoiauQEP`yib9SJ6_AB}UtWYDle2+~|Ju+8|^Wr=H40 zVDQtj&c}?W1NB8z&YVoX5lN_P$a<6uVWvXiD#2c)0ClX&5g0(0iM&Y&J@cj4X&Jo# z!v{Uq4CzklIjjpl9v-|o+rp&eb;G-bIC)dXRVHPIKKU0}Tn2%G)HRV@Q`S%%I2`Da zsSze7rUbDtoYLN}et+^r?iH?5^R46g=b}N8LUY17r7fgcQ_%KAO!Cs~>@fuIp3yP| zK-gGiBZCvU zPzf|q#MLHq#%Q$K^czj~q@*Mj1{&cVNZ#a14fc^HIf7M#TF8cho}R#{Ys675jX~zq zXpz`=Aq-kL{3>`5OVV=axejXN8y*Qs#L4VkQjg&EfqMIa7!?*%cYQ2zMtb_og?ni% z`gG`>3=ZU)XUNUpv%gnY3#u4jI%ywN-mE@5@)*`4)K=9y^;`?p^`jSbGazrx-evT= z)|xdyWzx{4y+{?SIolmlcBJzpocUqB&li3gUzqS}WjfApteNx}nt|QYD+oJ=->lwluErcxDRj-s&}#`S+yFbf^|D*>E#9B6FZSv@ z#Ob(KM*TZ=MJ|;W)MV6?X|POtomw%Shus5lkSA3f1$bi&q=kq$T+?c&G^ff5PO&g#;k!{`-8!4@ZyyTIHXaLIoS)~ZsXi~B02Bdq$D8Z3UhoPjsHvND zlXt>8UNNJxM)DCO?W7M0WXTPyq)9ZVBdwg@gbtM@%Qq&CgGm%JcSYP~vnsBB_O!SQ?V^Xb*-FrlDFg zBLDE=13)BnDY%LzckW0?Nde`RqCPz}WoJ5+QsEY>0&q{639o{@{5nb>co$G0trla5 zJjKmE-1Db7G$WfM9^XSOsPTu!jELO2i*Up{?I4VsZlR;geg8oA-V$c9>#ehBPS3fm zTsKE$pCqgif2`bmIF~D-S^o>_t|vjY$3n{*<@{P2<5^JQwdYDqzPQuf${o>#146jY zPW-kZrQ~6udPUXv98c;`LMH2%zV}Y=nz_%+%)EK?W@dKQO`I;e-_X{Uq@xZz|G)q? z!vqN*G{3U4vYcaAnEqob?|6otnTu3wZ(LB`M|`4uui`f?EjhWl zww-~sKx2Rz9vd4A#z#+25BP?j9;QpOfE50<*ws}^d^H~*pPTEymnVDF6ciM+ zw2PfV-ly*e+zmWqi}g@lkx~T_?#&l~2f&oUrK1LJmmA~2V4CH|r`et*owX1h-xd4; zVKDgMMxa&52ke>v?*_n^CGGnhVll^OI+jG{-CE!-y1L__tC5kB;Sw_>SVstd@Vp#3 zL&Uh$-<~qh6*>K8zrFvn_og^MAN;bpsi~=zm6fF>K?t?5g+l|79Jnyp`Q;^$A`yU) z;o;$dDfz_u%=TrzWkSXG!oor}jno&Fl`c+BRkQA=of6f*aBUlB?`!nUjRe;Y2a@(n z;m5KnDk^etIDH+-05%Mn;V>(+B5^E_2m6UY%+JjD|N1)m_wtNcF0clyx6K3XC{$N> z_x5;xlU;ulpa|8MxQwu65xq>u`}Yr8KZl0_5A*C9t9*{xBY*ea8iKxu4&Dv@B`8Zj zaG}Rk0>Gf7beAVrUyzo~?|nJxK6*z#oW`6xwv{@L!VE`FcLG4bES(HCeBU>#Q2-7}(tB-XW(H;et7~m)YfX-46dWc;yl}lFgxK?uCF%nS?=e>c zj0eQq&M&Ceo!CpZ1#6>4RuFTj?_z2?cX6^e{pl0x^(}TXk&1!v&MD?FY#k?Linv~G zPL3cz^@rqbstc3b~^vmi{M|t(GbfRKq-M^<-o1q|1+#~%_3ZcWtSBIlivqc z>9``AN|rSkKoMZvY=ghxv}sKevADpW0{l>3S=m(55WRyHCSoTFgz72K^e~y^dtRN` z$Z)d67v7(HUSbogdme$IWdm{oI7&1l5~FX`7f9=)LM^Qf9;qlZvoY|8Mg5vAi>izu z5JFvGU`GO@;E+~2VY@zTE^`kLJ_e!8l9H0VJVftSY4)@eB;Ic4-o1PF{0m&Kj!p1j z-#!ASKlj?20PC$}ax@mSzu)BtzkGiUX`(7HS@enz&-2Ax*3~TIC{C>+6Sbz_nDI{l z{i&q|s2!y15U$7;ga>4}fXm^sF~v!4@0An4LZA*B{*H}q z#_uGFvrkrk4oCs0!GKP-dM-jqmajdw1u>QoltX{FajF{MMek%Huck%Bwz9ag)z>U`){{8^i4o0vkl!F=%c1Z^4I%w2t zr#z{I$yk4#0we4U9z6K{gy%M}??PmWhZvJIibMUyS9huv0ZeI;9#n&VtjKx-I5*Cz zd^a*_!JJY6U^4jPfL?3ky~v_@pt#)a?aS_1zf*%MSAIdtbq=EQUui_b9Hg)~zb-X< zfd2J&R8QHF6t?diaBbd$%Z2|~k|^jb3YA5{mhFVQ zPUb9~BQ_W?c3**nN0%)`qkRklX2k<7BfsVK74rQ6{ z904SGZ0y$Q$2`WLG_Pmy7T+LmW2y!7AEhBf?RXy-L25yd1gZiL2EBi08;O9OoScB> zI#N79OG}GaOz64+Vwm;Xp!YpInv7PzSorz(LBNrglmrSxq%J2pek_otmDA0j&(F_O zQ&CY-QUc%tf~?JFkW>RJ3Ir@AB?SmEAd%YIogl_&!M%@40Lqzw%{(`y?Ev{Fh z5SH^81|?Y1Kd@`t-b_;>z5(mfqPC^0i$NwDjS2>0z&2zn8@TiLTYl@}R4LAJ#Liv7 zvIDYUWwnyYq4ST00Rq0~FWdR9I}eKv>Wu_bNKjSoPbqEO(ed#hmAJ}FAlYha2xSV< zG2go`Kr7{9XI~dLFSMB^_Ms%MGU;&s@Kog9JKX}wS`feWD;|T-&&`?Mr~Qavxm;{2 z6$0@Upst6xQr>IgbScC(GkuzT+3$fUP-J1jlAR*mT`$=j6o39PFFpMb#86QrZ$(I; z&jBL8ZDiC>2~{q6Rb376SBAoXnevsLL`F$Tnl2aZQrA!mMAiI_tI@-UUHx{*$3xci z!BHrbO&AaoV0x;hz9)z!!hZSq10@ve9?<&+)7sD?aOMHG*jagRR%qJu*E9? zNtVctLiM$r&Fq~3;xIc)$=hUTExXYajl_=yS{gq#iVp_TnR-0$<_=OpMfW?>O?+D| zv#{kT=g+EThN$?FhlIktQBhG7#R_{H8&FHT)!te|?n1>!q4(}>f`kHiv(U2xBHWyQ zyU6lT zG=$vXRPuJx;XXM5vI`8$!NGxNCh!DzO-uj;3|y6a>mMoLB|A>QGE-^5oT#d@8j&n; z*(Eg$8T8W883VHTZjBjM!R~513xjJ(MuriHSg9VnJ-zSd_UG@}=PxgQFD#hZ8G}++ zY-cj8-?E1QHwR#>v9U2J34bt?8Gvlyo)QxiLHJ8bN~)}^3|=|tbmNQe;&ij_0RV=o z{dWLKIhw^)XWN_Fno}Rmd}{b%Ft4|_cPsZ4cPjMvw+~>YpPrtAA^os&WZCaMUuT=9 zTkdk?m&`s2v@dXfL2DsgK(PSU3-Toc`6F14)~X8`7vk&2hTqBV9Wx_xm{LaqKxHM9P07!mc#b-)Rn{-BOw8S zBBhAy{%H77;!@EkCzR&;-Je~*CqT;OgTT%aSvY3V`UT8euAueB;Q-04Tem=iRtWj^ z6^Ky&NZ;V#$`c;yBu^mW$BPx{S;WJy&OvmAw$`y)HLjjjx$`ry6l8D~0#?a;N|KV2 z;lDK8J!%jaKP-euZ9WEth5@=s>{O7=aDLmxeQ zq~PiBM(MD0`opUyxNx|TRhti(6~LIZ;k}ezJJRlrv8jXa>J`bJfFez-pBxoZXIuq5 zb8*-~d++s0owRA0oRgt4-rKLYt7kz%@P2$;{=qlGurWGCnv{2dTYzdUBU+4$y%wmc zIRX-%CP3hUdlX9?Gn2E0zXmD$B8cGtgLpqSCMV&3XmMBsO5^X}I+~h!qE7EeGT5S{ zqSi(EvzA&ri*!+r8&m(t{f7dV<$*O3Qn#mq+}t#vPb?Pop94^cyF*&}f?^}jq}-U6 znOVvHZ43YkQ&WbZ3^Yxt#@V}jc!1y`0PJfRh$mrI3h!!cB-0oSG`)ce_=pQUw=yL8l+OwVh{QqF{ax9&`pDIRJWbHvcB4)V%V09+q)P%l*|3har5I zAyu}l0Vu2PdTd)~=jES2JlWbHo6Q|7a~D&2`Rv&<;303)yFcwa!x7Vqx6!KYRv@KG zi`E6Y!{Jf`LB8HvDEzG9qYDW_7tU;MOx{DQ_^=HW<$gOKA5~`D)R*vDJtK;kr8fZz zR&miZUZ1eeBZrbLY8An30V@CpstkM|(4Ii^!-?o%W2ABR^|O=N!Kkq1deah$p5)Zj zI0ez|9-=(8{n@N3sPGBP&@dAfePFNs^8))kuoBKdY&XhPqN}MHyjR-_QlOJig_hxV z6_g7WuIG53$OtWgh>kF4WTXi8b#?b8q&8X~&iPTisHEg(GUEF8&GpZlIAX>Q5WIjn ziYi8-3|&pB)6~^1lnrpVBnW4MEKp{Q zI@UixBVh)mVZmUdPM>&S{k*zbG zK|eWka!^_G(SSQgKK2|+q6GHX=d`9NY)}Sr4=}jR?d|v!wPN%cgx-bu(Q$@K8RXCY zK9euP$8+S3+w`}IqA{O#?P_u?or|_MX{(>}iwYa~>kH{%^^O_8kjETg?{k3c`vxFV zIl0SiBizBxkgzZi;|>FyC~8jjdKeL)iH;k+pqUw zaz3`oV`?DI|DYQ95caHC;VRoUgf~4jH1zsk$%rRz?r2%ZbHtJ6b^#IWq|!~-N7pw$ zGEP?D%19&%^v(bPchAp*6N(zUevyXq6hVDPhK9D9CKW9k9TUB7amWxah;wL(N=lxB zXa#3+I!9)N{G}fHkVq@@=3>5gE#l@;`ZkGFJo*q7qnY_HMgps2u{Yx8l$u-~vxKe5 zn8|J%{$~sLWI#9|@l4uu%8@KunXg~JwrufSY`6z^v}wJmm&Y(cJc$N}hi%(@zI?AN zsn8vMCD-cEMTF&a$a3s5^U?_H&H=IDPrFxi2BpMUD!)LW(F2GlIhVewnws)MROQ>K zPKYkJ8>j~$348!}1lVT~xwJcSywgwe({6qJYH)B6C=;)xH>1PDaMu~~Y&x^|Xhx7q zEP2#fxB1wEweo|#!K^pUI_yt6mcuGfk{^iszL)3X6A%FPv;rWGp}TN_%+kryN9^10!<{2b^IUTvEW%NXs(-J(#&|_+)KVQi;-3LNHm~)lA!C5Dg zUc?-*u&@ApIx;$H-W}T9+pF|i?c<_cfzYIj(K_Rz}7V z*n0G@p8-K+QJbS6W1qK3`RfSwE=ck@wWU6J#nXs$TTO4L&n5PG2?+^cD-;(O*T3jv za*~9cpaH)l;Oq$0l!u>O#nac8{}u#d0fls1{Z?+j;Bs_t&C8b~z-odn{NKi^>YAFF z@BTkN4>z)E$W^^G8~O9+Pi%ZV34^q-tSs2R)DM&aNN)C111i5Io>Yinf)}XFEc1V0 z@0?r!eY~-;VcB}0lA7AKJM_-;k3evWi;J)GYM6Q>_(}0&jjI4Lyu+FG=lnbdSOgp# zKziYD`0%@Tpw|Lpep=^g_F*ElP8d_@k#H<6*Rg45<~nR8L6-uEAn?%u5|l1lT38%1 zmg>UwrU7UZ%muMkxv`tX1Z-I$<`HkxQ%o*86<4m8@tAnm-5M?_k)bs%9nAZSr65&Z z?w)50?EdEj$?EA%ey}?d*@jM-*swP{KlEV@h92!u{lC*SvweQe-=F+svtWBCL`zLy K^|gv!)c*jvmv!_2 literal 2842 zcmds3`8U*U8^6B8EMc0FOvu)19Z~eCl*#fcJtSl+OHYrCCW#h|?TbW3sU*o(_8ugL zXwa0Dh{QBWX%u4YjqKYyo%fvgPk4WLf4J}K+@Je%-RC;@`JC(eT(+^cAWA4m0043S zJ~LZU2y$M(Y#6557D=uMB*M_?jk10_%G%FW_P0wJ>zl3U8NnFSz4SQ`uxN<_kydo6zE9w zO>Kqln9?rXv}~Q|?~7l)Nn~uCIiYehEVYj>0?Lk$kIy~}TTF4QND3FD(com6vcR{T z=g*VE!Jqx^TAXQBL#v;mbH4MbEixJsa??*`xar3RbUGg_G78zH0!o#^Wd`bf52Bsh) zS*K(W+1OF;d!pPj&%yObsz}e7^=->7N3!wmwITAg$J(XIqH?Uj$to)Mx?_vxojh|Y zAKyQ=RfMKMiDguZ=HV*CW$T#%$&z0IxaR>Ml*5)&%4l!?ysA%UWjh+jB>Gc_k5lM% z&YAXoX?IMs)4vU~JwUE&LEqSSM49&XlRTzi*;!Hwnvq>|mnM;MDFouK_5bJ}K~P5m zWbW{=;FoNNtupr3yWB#5>bn$3ztiIHast)4{Z$q*_24BxS=^Rv>_~pNSVO!`0nJbIXKs+5J$X?+&`C|tO#{f%Wwhkru zAqJ&{;`@)d^mg+43g(S9_mqbu1;_mmG_*r?O-<-9%Yrn{TzoH|W#?OVro~=NljM#? z?MagH0%T{_+ZQ|14iDA($bzkn3tbA*zWwzrwdLV?enq1ktxaWzwmuGRIrI38wbzHI zzD-fzH0FhF4G&gAtGo`SMNsH6?K2yU<_(u!BE!zQz5);wj^=!}9XxhBjH~HMTn9%b z;`v*IIGQ}+Aa$}r#DdJHiq8l8j_8ET$5G;?M1Ab_mA9SvNeYtOTfJc{ch<$2(Qx^RaJF)|mKS4ae2dJKohw;?QPL z7kjM`%WZ2~rp{kHS+q>=@IpTwulqhdd<)|$D;#EbmywKx9!aF1?eE!oqaWrfjyjeV zEm%c;vZ>$(L@^eTl86C&KP4=9D7o9*!mkDtZ}x{qY0_w4xRzZ`W`PVOoAtaj5&Auk zRZSPAP?^@*9KF*e*YCVyL5nauha-T*!o1(-))|BGfNB}R0I~Dy%{B?uSB`SbB={T%- zn1}WQM;fxC4oSux+TDz1RxT2nd7rUy!2pVgF0-dI3mI?Gc;~Em8iN2Ak{|0V)4@oA zA} zliUMA$Q0Wwz*hh8_>|-TVoTCh05|kQmNY1+r=?xbeYo%8R%S+8Uf3#w4B@GSx$rwz zC>zr4w4QlNrhJ|I{R_aPLeu283Am z|845Nqb1BB??Y{N&cd@5QjqWpHU#+Ykx*>X&sPhy;Fs-v2ap8&oRhp;2hiGyYV%_N z3`n<(8WkVQ4_*_zV)F?EHGubRnTqLOKNTDThjcOE!M>nF!4d2&g3TzvK_EX~<5V)K zhP9+N%BXm=s?Vjg3h(amf8UKq)sWbm5>&(S=5K0P=6Y=7?(QI?lECqHF{3WAY+;Q_ zNq6RxKNkm(^ikxdg;D&9QEg3qrPOidZuhDTIq{E*BS>U4zS`i80CB+321~WX*Ot#o zMK-#N-&)1!|CnqBNfu`IyH(RxEG1X@>ruV!F@Qy#2mZTEZ3 z8jgwV)tM2uxc1eeQTTtDx(8<3%P-5tVAMpmQ&FAlAaavKOsO^hahV+B^oQ&wWv~>f P17N?owOO%=N94Z&+AiH2 diff --git a/public/logo-pwa.png b/public/logo-pwa.png index e4669cd4cb8f099b8d214171cfda7c24b80eb70a..d387ae48e9c701be7daed17e7b0ddbbd02986492 100644 GIT binary patch literal 7550 zcmbtZc|4Ts+rOVN#)ynHBui1MBc_zHPa!$kDy4;n5=xdROJSZ;kxHa2WjnMTLdlXW zGjeDlL$YLvj$$lXGIlfb-p|N+&*$^LfBZi0`^S7{?)$#(>-t{Hb1lzYv$Zx~x@7qh z0Kigt5K*YW-EH}x9U&21S4*3BfB87bra4$&?07bHe>2|xoq#vD*aT$7f zXu}G5)#ENHiCx35%nEMR(5~IEUHhPnddt3Ocf>T=MUR#PEc?}GmjZDU0M@JqAXXBf z7Q%q>01uq@;sALa0ut^3VEOz1=h*S3gQoF{1Npr;aCSS$Tzl9L0k2fRe(CID035~9 z^^wgE0M#T?WXwD?0Yw&{7MZ?97Nm*bY|R?(iGX@RxK=8l0r2wsB)=bg*rHd z!6zFb+pIwzrjk=I&L;`DVE7o*`0j1UTMYBR7*nj`Y1wZK=5rl2<5@K7fq%%KwQvqn zxiY>9-nlR@L+7^RS$IFj2(DY(;u-#W@O0M-kU`V& zRFY}6!)&`wz=>@${AuQxha!ZcCW1CFKik~!e1-|yjEugR&oAEMa-V-jWW360$ok9l z)^$O?c?ZGEuO@T-$7`_Dv&%t7>J!AJhrf`83& zbI4m0*Zn&66`w?61>8>B4vs-MF^JDsrp3bcGVoX)TJ|7}3kq1FTepUPu^I7cb*J!& ztpvIs8G8~b8qg_@z>;=vfj9#saPt?WEJJ*DHY9+*BvB1(66!%~lfu|l6(eMh@Jc?r z^etK!3i@rN`2eM}gDSGDXgFa$OZN{`&r^-5)(g1YKhT2CvFUBxl=-6Ig6xSdf?HIdU7- z|9h3uWl*|c$Mh|{z9uoyxrmE5erk%wT2yvE)n4D`-z#&vU*K-O)y+J(U0K;ihI~`P zCzdyLiUe4RzpUf{-Y$5OPnOZ86TIq|on?XVKL!dZc=*`wmjZr>iqIm!F8d2O%f3Zj za1^osNmC4>!~PdpQ%!Z^dg_a!&|!^$X2m#lW7S3VL?{XE5%hVcg?iI>-(s688|!~q zyZ425P%}EiojodTx)xrcc+}>@Onj5+JkNg3J<81ob5|2-z z`vtLR$FfiKh(W)3johNMLKNJs_F3_&S1&3rLa}Njq-_^nl*CjKt?pTGTSu5*nLg5d;9subo=HKH0s?mZ+R_AfJ*ksEuRm{m z#F1YV${QbG1sL6#8~It9G@^H&uqd2Y+7`4aq0c)q)cxZYn}2U33H$6l1LKiJ*Ysq; z*x?lZaY80Vdl3;WxM1HE4z0DjjV!Pjj&JPs3DU(OMlVx3z~bHO_WDP*Nrnvyi|JIy z8SxYLI1F34Z8@`s>8d=B#Z(L<{<8CfuBgJ)Tl`!c zyznt0e=TPGk@E$F==1OYS%wr4>Z|iKn&__9zv@qEchhpp$1|$GD$~))Q|glP>QNyV z7SjERTz}1&GpyEpEV=d(7-cFSKPd9SZ8HSRDLD-b7dPP25VdV#| zrIm58PnbovO|<@oxKX2*P&VM|=`V2g-7OVW=qmlGyWqh~5jC(sNeU)C$!_0Tfp$to3a|8SSmMw0xKzgx}~zJ0u2Z?LE)txq*CCkxm~EQ98i ze%`OpAt;IJ6#CH=#-{Y=b=bx$hzl^pc{0%(v2LxVhlIDh4}u7a(l&hn{>7EGJmg-e){fn zR+09l3W3q$B}{sR+%aaZ(cT!KHsRB3N>7%kl|usxCFbj|-XC-(QO8{WHkUw=sC%%- z+gw2)F=UU1dgQ-LnGL8|QDP*_kjJC_M9NMQhF%;zQUwAX1yCe^R}beSi8$%uqU*+; zCHZ^P>(Y>@cwn~@SegT033QwY8YNJNqe`&h+W``yfT+(iEY_U#%9n1IK(^flWJP4x zx6WVJi43rHaN#^Ppct@C0IdF&edC%%iF0FL$uOF642Mw8zK=S7k*ce~ej@OLEf_ux zL7X({!@l>6rBX?;nDkmRK$w1Qggh|=32$?!XqIsr=I@;|A%qCv@|9cw@qHW5MH4j+ z4fD2H77&5d=aL5D?_r0vSC3PHT*%Y;PS)i%zZ)>tJ1cq2ps`_NBH*;s8$uLJ`=)64 zG>!#iab`vftc$t>?4I);hy&NptVzE<(tx1Kw^DIXO`qTDa7=gS2AO189OZW$`AdxN zjm))rT6ol1dA-r7Tw33pG0t{(17vi=VBfGXC4wWg3r2|EIy_q8%}vlcBo-%NXU0D- zr7IWdE7A@?!@sYK2;gl8a{WJPJ({-1&CJa~TQ2N~YuH6u3gfYdSMpnBKR1PqU-2rY zSpV?Cf$QBNc{f&f#K(9sSad?q zSg5hPo81*CXIm4K@oRb^cY3C}?X)48H}2t2C}@z@{K)4~_~?l?l-Kw~C!be~MUm*UoWZ~hUbUF_Xr&|EqyVos%%Dy8S8C04JujnX79h|n z&p^k^Et|vs+{wFnlk>E>4JPt2bFc1@v;wD0&nbo+&%Gro>rr^nZ?8=eQ zZGV}R9(L%pe?IG@0xlaEvs*OjO0)ajRA^Rn?ehYpzCq{VH?Zrg)vT(QpBAn4B~Hta`I>??ch9gy z{bx@Y;OB3g)BULf;%io)PY(acJpORVSUZK&;g{tm=kYUpE$|a`a%@Rm$$pkVB~OLt zyK*TiU|;Q1=bgMYoijSEv)a|LfR71~zwLKRw*fnOrQ2ovmwz$1c3wQaq4uJ zgTD5=JU@repgk`taesh-&GD%R2J5t(}5p?&C}oG z4!g`26`n$Y%MwOy*q@(s(c&MK1`w+yGM#uZY`h}Ht?;@MvP%k0)Z!`Noqm2lQD;uY zMfa}d)_#azw=uiswkX_=yWZ;1i zSQhsVVim@TbH2P0Mf+{^n&7fbh>@h%gP{9Y|M?1v-HGpx5YX?qz3E=T7olSa%Mdhp z2j}lrYD`6*-i20j6k_COUMM?mC|`ZJrxc@CnJ;oWeUjj(%N=}J7nefX&n_8${an#& zqkOaP^zakFRsi8ne%TcP!J8gTwBA%fSX;p`@6)S>ldI)hzrP)kE1M(X#6zPU@(&23 z{mjvvE=e?0K%5g-(!HDAT?(7U`Unu*SK{eO(!M{D#e9#eKM5RODe+bcdAzmLq&l1^ zhYW1v!RAolgd~308(N_Tk_il{?l2@GbR^oTj!4NBB6Ot*sndw!sFGV^RhI+(*sS)8 zf^U8=09y{I4%OQfR&?ad*~DSBY5+D@B%q=Aw1laMur`7eK$8WH%8`U9NQ|(SL%gcLY!8dk??;ZhWV11;WwCsLXo)y!HLvZ0;OSn&avgFcTmyOp!%= zEJ}F7&k7>XDU8J%`|HdFm3Fj=b4d4QHKee>vR49Ujo5UhDSc9wYC8)4nhyT?QmjoG zWfkehc(PLUrl^u(`#63&W%>kMJ$vM5H$t(gEC14EL8$Ygxd^7kkRHcA{4$hCY|hN0 zS2tc2O$w0R1^yOrqFn&>VPfVy!|?KD2vG&33RBXoy{6{}e2W7QAE@%Q!0xIi7?$yN zQ?i>*%W|0x_!R+1ED=`s$fh1)pJ><@WFQ6AF#s>b(31@jc4zA={{FF_-his&)8awv zn>qCu!$W%p`srtkN2eQPF?9rNPz432S?|r=-ELk8Q4Ccqh1!EpzKT~iPQb`HQEf)g z)f_TtugZ&b*5qyVuL>w^l0x5tgDy5`dr4H4#aS2VAQ#ZlIO?XNpfChmBohXFuelBh zGzW*5Zvt1FWbrCS@CnQmnPZN#0`x9PQ)g`7KbxK1%a4J8$<)4vk+b-g?Pj?iMmX^m z;5P?0zSVdC8AUtxD_6q_yov@;0@9tg3s)ZL)lp<#+$~Jmha0^OC`1UD!qy*nUDxkz zvcnNqe+R4(MM;*nF~F3-*!CMI@$7E|89p_vG11_%AcM5w4tFoS>WCB98A|W6mYuYL z5jcQPgM;I=c0UfB6Q)>#+B~iDyS3@tATc*8@u#h?m4 z_q;W6i#2uo8o<|kw z#9a}PK>1vWosNr4%l03|*{^a2qiLi*BI0fehC>4OEaT*ulLzn`=W`z6x8myGdR7ME z<|od%vrCjJq603Lmp?iDnC7klvSEtZ`D*mL>%*&qK2fyHqi(RoZC=XwGwb}^*p~v^ z9@1(m^>Sfwtbl^|$0o<5b1x@mDBvC;?Y#ineuqB4C&0u(GyaH-^^(dK^@I|)0tX@A zk0u2tqLh^-Q9SaJ({}VzY}OH(OpPnxvVv>=7P);jJk7!!_^M}T;ZnQJuaAXu`{Y(6 zR#AD>IV3Z(+MUwlap~MkNg_qHz5KKBRIYsR)bs8^p8kcr9F6v;l_HnU-7y!08u@Q9 zuI@1QU9Y6;IrMHI=j6GcXMC)V;t+S262DDfSXvy4&**90zHZA6JzoZ0W=Xa4EE1{YJ)+> zAIv@bIYo^{yo%?!Qze))J~rK?e;TT_?x0N!c}CD{D=TSbA@|>YlcI-Xa&WfSli#Vu zjgKgZV2haF%pc_?O@=+dQZIacYw3}F7;-lS>+^J-v&WwBpO{y{M6?IKuWJ>{xT_F|%a+cdUrSPKyJ5L!K!=srFqnDuizYFT)vgI>@%QXQo2}%OM7n z>*oi)dtc_WUaySnei5%8g-Lvz=lC@&fJ$5mZW0-vA?g6UN&-mw5OpQqC|EfOBUw6~ z3<4*=-oY59E!}#lJ0&-4_!7u>?s{Fu?7NPUe1H({Q+b*R*kXg3vS&n5U+#`2P>=fC zog(py+U8g(sqP>ibImed(j|kq*AiS^TSD+@5{Nr9SCIU;X85Ekgg_xKbC3}^)aOB31pG{C znL9CYN`Wf1fz95J=^$SfB-ewTc}fXfEL)s?UF|A2_joLjqevW zzhM#VlKpG6CJQt^Hjy(|_pvZ! zK}AFvM@`w)awmtWw^Li-`Q*(Vj-jVF=dK=K&4ajH5)@4wH$_)%s_(!=R=4ZfeG}MT z6t~iAq7=IR#;c1@YD_L!YtTe#aJEye98t?0f1Xx$f;Gr-gW1U7NaVnsZo8TlvU6I{ zpnhIjbuYK85%z1uIu6CdKjS)Zz7ytkO literal 11654 zcmeHtc|4SD`}cWWMrKMi_N|x_T5Lt7Y?BmaFK#KJRLU+YH8fY*N|x>xBwJ~b7Nmw` zncIES9kOQ|nL$c21~Jx|`JL4B{O^cii-IuifD^0d=E{wV21^NScr@NPxxR?;0gF;iHQO(bAM5T2t+9DNIG?@ zPD@L`XU0BxKClYKv4}7JP!K#8OplJ13F=s>YvZAV8eF9t3!18{%lOqd&-xZj;PB+e z6bPrM`h^i(Hi3wF-akB!C(D%f{DI4m%{q-I5qDw0v|H%DOxMzjJbC6+fgiJnvZ9fM$X?FL0)5hbvQ)G;FCDgdJ=kFQUo#I zyHiU-oCLb>iD6k7p1f}bjKp&s!XY4aHK0f}blsdyMNaqW9W$EX09S^U~ zx!_!vvf<&3DtM?zxh96WT)y2)Agg?cdhL5{nmggMqs+BmN%~Zqo5ZDp#$4Wv_qM|M zeLRy++}VjI+iT7rKwCG~C!kD&>!CiTA@bw5WwZ+K=HBcjeGc(n%?5fUnuy-YDG&WM z^9ncW`d*vmwhup9!p{^^RKfi`MEsZcy2fhoP&TfS>=Pk)@=fz6&()80y4b)m$|zsDRjf@h{XJcMwlrkw^pzTi`0vE<&#}RwU_2hDy^Ol46Bo_`*}6ICsBTT{(X}+O-X%;l&V{+!MZ)Ouj4a4ww#Xt?B;=E zk#h6yN?>^*@eXQ$Gdh|rXwxn7u>u{1sT-p~%8qs;MKKbH$EQvOqQf#BF5?9@WVF84 zcg!5sPR^aC;E9;wt=f)AFl*Y;!`wQ8Co7fxo=Kpa7kP42$bfreySqL*takhwRS>QW zc%o(wB#`-cHYFS&dL~|DG#pFh$TjK*Q)RGug_-@TIrlw(3y$se{>;VEdtCL(@Z?oB zJ^SXfxD|9_3EGY@JiVGzCNRPeT{uo4`+mWZw%)zHT^VM7Elps}Yqj2u+E(Yklt;22 zJo#+{g4XZ#{i`rS7_Me~4^K91%8tfmL_gSW2v=KPGLVPMNbYZhWWjg#9}B627ra&* zQ`ENZRrYEmYxNWB<#3^$ITr@+qpUINxaqW+A*vnewaWpu-C-B&g5oj~-TEeX3A0*V zsiEyg9A;($K(hLroMM4}qy5v{U_ORB=VlA8y#Fjpm(AYnKp-zQbC`xUG`jdYlR(y} zt9ZIi=(;`v53^;%ncXmQU2Fkuzks0v;xE80(oQ+-LTz{LOI3s)qFlxgpte>)-!8y& zUqr9p{jfI`I_N6`d7pgLg(-Fc^I``dhSg&#l{XFlrUx`BBYKkEPZT7u{Q-=25vYG- z^BfxrX{)BS2}Y~6j4xg+8&)#JtZ-db^a&0#K4H?Ig4#yDEG8mr&9dEupbdae+jU`K zH}`GTZvkx=b*ARBj-p>hhQne!U2e~7b{KdSpp!kXd%I&S7#mU0o35!4l>Avp_~Y!l zBb+`VRNLPqC_xpZf~8uk$Y8Iww3L%QI5A1e@MD&qNgvWmRxz&f_E@d>9vX0s%b{za zdDQ-SEHq&6mJ^bQ?4PeQ2F^aGzRlx2vjCJsrKxhkoJ|PQW-&6$#?z;2*dsY}nms#U z4}P0c#|pNJc9mJM*mVc})Qbhy!szv}&-^zs|7$N9DNN(ClP}J|tEjV9&wGZ!pSdF0 zX^MfA+bttJBGy|Ilch!m19xbS&IiDW!sQu;ZOOkE6ete85zX`Y(S9~RM5wnRVl?}m zgH9NlJ^RDBqzh@#WhYJiSRdm;q&? z-H#w`M`47XYrJ(Tl+vUAEEP`l5iTKO=#?`nc=F0>{@!a#=!@zfVzg61_E0~Cui18H z6^}g2Y;tyjG{s9gz)r0A6pTY`2il?5-&MDP1lTbfc8g<+qdmvO{TI#oXD~V(_Gku~ zR{f|*Ac4>rikSoi)k;Sq2}nsk^(12H=Cv=)9V%R%zW$D@Dd+ajp>}qPg3a?jar*ud zCMRV+q3~~1s+ZN@eALiV&3%L1#f6L|MQlLK?>ePIb6I1r|D1E>Qcs-QwD40uv^kD% ziQ>r`-!nhPMSp0R&SOYmStsWp5NedUJg5DB^6~DycYaeU(RY)Mi`P8f8JN~rh$mO+ z9Ag_nF+LRI2GHZJ4>Nm;LaRPeTrc00JY}NA(b$r&eygi1Njq#Y1?opUL0yN|*X6!I zk;$9Kt2dPS3}@#2{d|d6+Fnjx$}YfN+jjCq+2cW8JvYX^K`A zqqVQ=BJDn@xZq0dYEaP0#djx+(kB#x<3lOTzz>U+Yfb+<175Z@_Q~D0${{F*d zMO{2}`(5B|Ks)a*_;96Qh3^spHxRWoq%8ja24{=UFOx+2Jq|ct0wGl|CzkjRT%LZ@ zDC&AVL>LLS;AKW!ysNB<#3X+cK0_cQe-(-2GUAR?RnXkKN1{b*M)ywh)vMG)IcY>{ z%BnV+8ZgUBE!IHvVNG^dnuGV^~g3j&WsI*UEbPepbdEqnDF>BW%NB`q>2c z1dB~Wh~DI}{3#-ebHle(-p6H#b-G5C+`h~%D|pA|q@@-gb~{nn5~ANCg>yS(?;)mu z*8fn~rA5QoTkT{Q1?+7@4)nUv7n?fykCVlBo`pFs*!_x)dNB0F)z5<26Y9uXTpW%2Kn z1f6jk=0G($8!{L5U(BU|B?sQ^3VXIkDTp9smsP2e#t8di5X z$-%pL>F_?AoKwpq!J1w0_8Q^ACYCHif_;l|tT(f4p3)plSk3{U^P|LxQp<1aQU9{* zML437&tdd`$y1@FmQv{59#Pk$L7&G2Gz|owPWuOu>fKZl5>#{t7-$_$1ZnTO(ggES zzZB->Sc|dIhNl8H>mqNwRmn9?=X-C-u4XxGSU2?_q0ynK~AlX{+i)?K?qZRMMV=nV;sRl~#V|D&>9;3Ref(pmdS1SF-iQTJQ zhKxu2>UPwemBzr}p3OlDa9z=Vt$SEY<={&RA&3oU2*lZ%uewZQ1a zc{6%uwE`~V%%T+CIFN$7!1Tq_jjX?V?*g^mA@@oQ4ie*642WL)n%SG$ElacH7Vqpe zKilLDZ~6Wl#Z=b>`Lmi{rkVP2W1@;0Dc^X^B;)OI;oG)65J9zZUS4)TGkAqC zD>7(CrPQ#LDlob{+Pv9JW7hB91I_!4Jim!;AzX7duhaw89&Z}n^g#l9u$Dk~ZA|Ku zneIOoR9T*`iNsqA{CIVnT=DdNkC=Zjmk{#{GZp;t@2yXa-}3u)i#|2CQo@)GyhqXW zv|TsB*n`mW1Bt*IE2hfe+>D=&91_P4$M_}UU#!P@lyy*Xp_C{(_p4muvACCzDK&6sR62AX#IZAAO6t0QU?i)=Q2pP z_3-39@&%614&}Yd$NAZOuPdcHTxpGcr;gfh+To~a0`51e%}4#cQ{G2&4SlJ8r~N6N zd{;jqKi9l2>DTQt(9ynG?a3NCT^6}pM`#E`LD3)UK%Bxn^mZ*1G6~sw)n5> zz9)ktW~q`v_SLf=mBHc3Xb}nQXhzj4j4TPoq+qEqPwoF7Vjp(1O73pt2$<|IAK0qZ z6N}m86OSU@~+BqwHK^@4*beQ?7%NKSD(*GWPizLykl|4E>z02hRkq%+P;2sJ<~wd zYGTMGM0ZlY)Qq`9+8qurHx7E_9NU}xMj-_FT>FETOEaoHFzHtkdqG&=;T*|c%$qrulM&|umZQ`Y#^$gz{wlHv2H+-iu@j_fh!7<@rLWZv=v<* zUt!kKu7dRA4(O*q+BEodH_%iAbq8k)uD0c`b(&oy;tWk zU7}J8er`vA+r1X}?B#@hyi@PhxZmGiYYl7pOPpJ?q*t~DNX}gH+I!%}alppO6v_k7+eCcFV z`!#r={zB;!NNQwa9<@O#iH1wa;Qp8O^S4FBYNsaK%X^xsYMnjZ7ITv;^0RVQApO0L z`pLjj1H9h~E{wa**gwyreRcw1~w7tdrW0Er75f+L+@{B_y` z)*8(#en#LaVbathat{Ay+Gpd5mz{7jaWkyrVQ;|gAY$%xjQJMP?rt<~Y*K%o_^=iw zVp-o{T7~^oT!y>Rb{-SqT-`~aOLS|MdZE{K5j_1Bdfh*kXNZ_@)A@Z&*W&x}C)J=9 zm|7XnTa*M={un87yx}O0`P?fuba8c^>wQf3rZadl`NrIk^G$>ACNrtfQI+Raw;ZXS z_DE^mY{@F9efUfnE_8ftu#w@4X zg80fTPcu*nAi=s)*1Tz z7GSlbd_lf7_3@7bJ-f*sKt8?OJyHzY=*-9#$G+i6eYg84*UZZATs5-^&zBZFj~}C~ z-kmOd{#M00*lu|2*7a5b%}s2UV~7E?Uc1g{d#EMgFV?w3Ku#vjMh3yc{3l++rMh2@ z)}({rw^KQjcgBvq(a33r$ZhqdC=_xdZvs6C+93M$3B>%uwBcJBba)Wg!Z1e6t5N_F zi=3KlDF2y51Ta|2qv-#1izhhf7IdG3FsG_b^t#~b?z?GOu(tO+ z&vj4+I2KEEdXU$vgQvF$xNrC#hO~HsQ6SYU&dWV-!aY8|Ikuxk4$FecZO)OP7ndUb z2|5skCrbA;a6Ks$Z;t>2>osIx9nK4O#IZJJ{-BY-ege6C!(g4Yg4)fWGe2_A`I?CqlzrtY~zgYhA;Y)XDV*K@3) zp#AU;P+K_le0)Ff+vdXW2pP$Fxx88o%QxqAEJxZRzCDPY^Kb3bVl(E86xo4LBS!xB zhP*KYp+ho)nb0D?47CX3O7VZxZ#Xx3q^-P5I^p*vt!5sJd_9B}*>63&zo)m&tT))6 z7V2=Z%TrQlQ~iy~>gpxPN!->GvUA1m^Yuv5Za|CZ8`N*}yXmQ~ocl>2- z%YxU1pPkYp4|H-zPUBd5`InUObaG*og#)`<+tEZGIEQ>G58{3z|Cy;y9<+jHBf^ZUe=kej5*pRSZ zxL1E=wFV#O)*s@z&Kexdij|++I_u52*C4Q+ zwm6*~Q3GKo>?uyL3HRdS8iWMK^9TNn9um>Kk zNpe(b4l<@+&2@EgNpPJ_MfA>+VA6@vkzSIj^?HO+DzEK%o@pHh4_`Ko9^l4T*bvzp1t(vhGDkZuw@LHZx63L7LKkbnE30QaGVyd$uGMh@><1<5 z^)3>-6zZ)pJEJ$Xg`S#X-4QcagtYoXrP?;z)iST(Luxu-^M!si$YDacm416crA}9w z2s}2oF5$8F9vpw^4BNh}i6`=CHKGT+W*_0)40?GKnA6+@-scArHeACTXIJira6x~n zw0FJtv#Tl{bRhdGoXF1(lJn9 zHnYFzJ$*~h_tu<%%U>P%e02-j8lWQkG@6L5zae!-S_WGj?pd>gC3kfAY#wv%`3OPy z8%VVPulMlJZu>s@3)}9{RfYYOCIqECZ~dG8f;MYLS!nU$PK_}&k*W3}JlR$j@}i$D z`$!ieIU^bN0R91$(U;ao0n`o_&GvJ@7R+__7sM6}vfv>Lv0w@Q8^9q>FjaP=s9_0w zjKtQ4Wz3J7PF}wSl4PqkQIokR-pFpgunRgg8#MO>2?--hz+W@MKak)69Q^`&AYW>2 uCg1=Z)m@mjV3GwdSqPj3Q1~y#Nr6(9?>3F`o<>msUwd|0o4zn{yZk@IFC}gO From 98df0d301a2a095632bfbd93c57467daf0f252ee Mon Sep 17 00:00:00 2001 From: Michel Roegl-Brunner <73236783+michelroegl-brunner@users.noreply.github.com> Date: Mon, 23 Feb 2026 23:18:15 +0100 Subject: [PATCH 09/75] fix/qol: Add Callback URL the Enable Banking Instructions (#1060) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix/qol: Add wich Callback URL to use to the Enable Banking Instructions * CodeRabbit suggestion * CodeRabbit suggestion * Skip CI failure on findings --------- Co-authored-by: Juan José Mata --- .github/workflows/pipelock.yml | 2 +- app/controllers/enable_banking_items_controller.rb | 7 +------ app/helpers/application_helper.rb | 9 +++++++++ .../settings/providers/_enable_banking_panel.html.erb | 1 + config/locales/views/settings/en.yml | 1 + 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/workflows/pipelock.yml b/.github/workflows/pipelock.yml index 741a344ff..dfcb866ec 100644 --- a/.github/workflows/pipelock.yml +++ b/.github/workflows/pipelock.yml @@ -20,5 +20,5 @@ jobs: uses: luckyPipewrench/pipelock@v1 with: scan-diff: 'true' - fail-on-findings: 'true' + fail-on-findings: 'false' test-vectors: 'false' diff --git a/app/controllers/enable_banking_items_controller.rb b/app/controllers/enable_banking_items_controller.rb index bd165d7dd..63c23601a 100644 --- a/app/controllers/enable_banking_items_controller.rb +++ b/app/controllers/enable_banking_items_controller.rb @@ -540,13 +540,8 @@ class EnableBankingItemsController < ApplicationController ) end - # Generate the callback URL for Enable Banking OAuth - # In production, uses the standard Rails route - # In development, uses DEV_WEBHOOKS_URL if set (e.g., ngrok URL) def enable_banking_callback_url - return callback_enable_banking_items_url if Rails.env.production? - - ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/")) + "/enable_banking_items/callback" + helpers.enable_banking_callback_url end # Validate redirect URLs from Enable Banking API to prevent open redirect attacks diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 15777b223..b116e3aa4 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -139,6 +139,15 @@ module ApplicationHelper markdown.render(text).html_safe end + # Generate the callback URL for Enable Banking OAuth (used in views and controller). + # In production, uses the standard Rails route. + # In development, uses DEV_WEBHOOKS_URL if set (e.g., ngrok URL). + def enable_banking_callback_url + return callback_enable_banking_items_url if Rails.env.production? + + ENV.fetch("DEV_WEBHOOKS_URL", root_url).chomp("/") + "/enable_banking_items/callback" + end + # Formats quantity with adaptive precision based on the value size. # Shows more decimal places for small quantities (common with crypto). # diff --git a/app/views/settings/providers/_enable_banking_panel.html.erb b/app/views/settings/providers/_enable_banking_panel.html.erb index e1568a8f4..33e4dca22 100644 --- a/app/views/settings/providers/_enable_banking_panel.html.erb +++ b/app/views/settings/providers/_enable_banking_panel.html.erb @@ -6,6 +6,7 @@
  • Select your country code from the dropdown below
  • Enter your Application ID and paste your Client Certificate (including the private key)
  • Click Save Configuration, then use "Add Connection" to link your bank
  • +
  • <%= t("settings.providers.enable_banking_panel.callback_url_instruction", callback_url: enable_banking_callback_url) %>
  • Field descriptions:

    diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index e0030605a..a4db0706a 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -173,4 +173,5 @@ en: status_connected: Coinbase is connected and syncing your crypto holdings. status_not_connected: Not connected. Enter your API credentials above to get started. enable_banking_panel: + callback_url_instruction: "For the callback URL, use %{callback_url}." connection_error: Connection Error From 430c95e27830b9e568a1e00349fc225fb69c74fc Mon Sep 17 00:00:00 2001 From: Alessio Cappa <104093777+alessiocappa@users.noreply.github.com> Date: Mon, 23 Feb 2026 23:43:00 +0100 Subject: [PATCH 10/75] feat: Add tag badge in filter window (#1038) * feat: Add tag badge in filter window * fix: validate Tag color attribute as hex format and increase transparency mix in border color * fix: use fallback for tag color --- app/models/tag.rb | 1 + .../searches/filters/_tag_filter.html.erb | 18 +++++++++--------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/app/models/tag.rb b/app/models/tag.rb index c5bdc0bc2..108e1c89c 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -5,6 +5,7 @@ class Tag < ApplicationRecord has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping" validates :name, presence: true, uniqueness: { scope: :family } + validates :color, format: { with: /\A#[0-9A-Fa-f]{6}\z/ }, allow_nil: true scope :alphabetically, -> { order(:name) } diff --git a/app/views/transactions/searches/filters/_tag_filter.html.erb b/app/views/transactions/searches/filters/_tag_filter.html.erb index 76b198119..0c6b93873 100644 --- a/app/views/transactions/searches/filters/_tag_filter.html.erb +++ b/app/views/transactions/searches/filters/_tag_filter.html.erb @@ -16,15 +16,15 @@ tag.name, nil %> <%= form.label :tags, value: tag.name, class: "text-sm text-primary flex items-center gap-2" do %> - <%= render DS::FilledIcon.new( - variant: :text, - hex_color: tag.color || Tag::UNCATEGORIZED_COLOR, - text: tag.name, - size: "sm", - rounded: true - ) %> - - <%= tag.name %> + <% tag_color = tag.color.presence || Tag::UNCATEGORIZED_COLOR %> + + + <%= tag.name %> + <% end %> <% end %> From 7e912c1c934bc00c16c9a1d397bb74577bd651b1 Mon Sep 17 00:00:00 2001 From: Duc-Thomas <43140281+Duckiduc@users.noreply.github.com> Date: Mon, 23 Feb 2026 23:47:49 +0100 Subject: [PATCH 11/75] Fix Investment account subtype not saving on creation (#1039) * Add permitted_accountable_attributes for investments * Include fields_for for accountable subtype selection --- app/controllers/investments_controller.rb | 2 ++ app/views/investments/_form.html.erb | 8 +++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/controllers/investments_controller.rb b/app/controllers/investments_controller.rb index 1ef7d144b..5fa25f123 100644 --- a/app/controllers/investments_controller.rb +++ b/app/controllers/investments_controller.rb @@ -1,3 +1,5 @@ class InvestmentsController < ApplicationController include AccountableResource + + permitted_accountable_attributes :id, :subtype end diff --git a/app/views/investments/_form.html.erb b/app/views/investments/_form.html.erb index 4fc706e33..be4004d27 100644 --- a/app/views/investments/_form.html.erb +++ b/app/views/investments/_form.html.erb @@ -1,7 +1,9 @@ <%# locals: (account:, url:) %> <%= render "accounts/form", account: account, url: url do |form| %> - <%= form.select :subtype, - grouped_options_for_select(Investment.subtypes_grouped_for_select(currency: Current.family.currency), account.subtype), - { label: true, prompt: t("investments.form.subtype_prompt"), include_blank: t("investments.form.none") } %> + <%= form.fields_for :accountable do |investment_form| %> + <%= investment_form.select :subtype, + grouped_options_for_select(Investment.subtypes_grouped_for_select(currency: Current.family.currency), account.accountable.subtype), + { label: true, prompt: t("investments.form.subtype_prompt"), include_blank: t("investments.form.none") } %> + <% end %> <% end %> From 91b79053fd33a3dc465491abc08bdba82e54778f Mon Sep 17 00:00:00 2001 From: Dream <42954461+eureka928@users.noreply.github.com> Date: Wed, 25 Feb 2026 17:09:51 -0500 Subject: [PATCH 12/75] Fix export polling missing template error (#796) (#721) The polling controller was requesting turbo_stream format but only an HTML template exists. Fix the Accept header to request text/html and handle both formats in the controller. --- app/controllers/family_exports_controller.rb | 6 +++++- app/javascript/controllers/polling_controller.js | 2 +- test/controllers/family_exports_controller_test.rb | 11 +++++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/app/controllers/family_exports_controller.rb b/app/controllers/family_exports_controller.rb index d3a9163f5..cc9226a54 100644 --- a/app/controllers/family_exports_controller.rb +++ b/app/controllers/family_exports_controller.rb @@ -26,7 +26,11 @@ class FamilyExportsController < ApplicationController [ t("breadcrumbs.home"), root_path ], [ t("breadcrumbs.exports"), family_exports_path ] ] - render layout: "settings" + + respond_to do |format| + format.html { render layout: "settings" } + format.turbo_stream { redirect_to family_exports_path } + end end def download diff --git a/app/javascript/controllers/polling_controller.js b/app/javascript/controllers/polling_controller.js index 97cc252c1..0e9e743f8 100644 --- a/app/javascript/controllers/polling_controller.js +++ b/app/javascript/controllers/polling_controller.js @@ -35,7 +35,7 @@ export default class extends Controller { try { const response = await fetch(this.urlValue, { headers: { - Accept: "text/vnd.turbo-stream.html", + Accept: "text/html", "Turbo-Frame": this.element.id, }, }); diff --git a/test/controllers/family_exports_controller_test.rb b/test/controllers/family_exports_controller_test.rb index a7a820ae3..217a6c170 100644 --- a/test/controllers/family_exports_controller_test.rb +++ b/test/controllers/family_exports_controller_test.rb @@ -140,6 +140,17 @@ class FamilyExportsControllerTest < ActionDispatch::IntegrationTest assert_not ActiveStorage::Attachment.exists?(file_id) end + test "index responds to html with settings layout" do + get family_exports_path + assert_response :success + assert_select "title" # rendered with layout + end + + test "index responds to turbo_stream without raising MissingTemplate" do + get family_exports_path, headers: { "Accept" => "text/vnd.turbo-stream.html" } + assert_redirected_to family_exports_path + end + test "non-admin cannot delete export" do export = @family.family_exports.create!(status: "completed") sign_in @non_admin From bf27809024f7f3c924dd6bb1c621ea5eeb94dd72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Sun, 1 Mar 2026 13:07:45 -0500 Subject: [PATCH 13/75] Bump version numbers --- charts/sure/Chart.yaml | 4 ++-- config/initializers/version.rb | 2 +- mobile/pubspec.yaml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/charts/sure/Chart.yaml b/charts/sure/Chart.yaml index 6c2cd02eb..471fa1006 100644 --- a/charts/sure/Chart.yaml +++ b/charts/sure/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: sure description: Official Helm chart for deploying the Sure Rails app (web + Sidekiq) on Kubernetes with optional HA PostgreSQL (CloudNativePG) and Redis. type: application -version: 0.6.8-alpha.14 -appVersion: "0.6.8-alpha.14" +version: 0.6.9-alpha.1 +appVersion: "0.6.9-alpha.1" kubeVersion: ">=1.25.0-0" diff --git a/config/initializers/version.rb b/config/initializers/version.rb index 60ce0779c..e4d07100d 100644 --- a/config/initializers/version.rb +++ b/config/initializers/version.rb @@ -16,7 +16,7 @@ module Sure private def semver - "0.6.8-alpha.14" + "0.6.9-alpha.1" end end end diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 00d82483b..1b33e18f2 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -1,7 +1,7 @@ name: sure_mobile description: A mobile app for Sure personal finance management publish_to: 'none' -version: 0.6.8+11 +version: 0.6.9+1 environment: sdk: '>=3.0.0 <4.0.0' From f13da4809b5093e2cd3b510d1061a046ea41cfa1 Mon Sep 17 00:00:00 2001 From: lolimmlost Date: Sun, 1 Mar 2026 13:42:53 -0800 Subject: [PATCH 14/75] Fix PWA: back/X buttons untappable in wizard layout (budget edit) (#1076) The wizard layout header lacked safe-area-inset-top padding, causing the back arrow and X button to sit under the system status bar in PWA standalone mode. All other layouts already account for this. Co-authored-by: Claude --- app/views/layouts/wizard.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/layouts/wizard.html.erb b/app/views/layouts/wizard.html.erb index 3e82583e1..2a9896fb0 100644 --- a/app/views/layouts/wizard.html.erb +++ b/app/views/layouts/wizard.html.erb @@ -1,5 +1,5 @@ <%= render "layouts/shared/htmldoc" do %> -
    +
    <% if content_for?(:prev_nav) %> <%= yield :prev_nav %> From 15cfcf585db65488f5457da30dcc5920add78c56 Mon Sep 17 00:00:00 2001 From: Serge L Date: Sun, 1 Mar 2026 16:48:48 -0500 Subject: [PATCH 15/75] Fix: Yahoo Finance provider Cookie/Crumb Auth (#1082) * Fix: use cookie/crumb auth in healthy? chart endpoint check The health check was calling /v8/finance/chart/AAPL via the plain unauthenticated client. Yahoo Finance requires cookie + crumb authentication on the chart endpoint, so the health check would fail even when credentials are valid. Updated healthy? to use fetch_cookie_and_crumb + authenticated_client, consistent with fetch_security_prices and fetch_chart_data. Co-Authored-By: Claude Sonnet 4.6 * Fix: add cookie/crumb auth to all /v8/finance/chart/ calls fetch_security_prices and fetch_chart_data (used for exchange rates) were calling the chart endpoint without cookie/crumb authentication, inconsistent with healthy? and fetch_security_info. Added auth to both, including the same retry-on-Unauthorized pattern already used in fetch_security_info. Co-Authored-By: Claude Sonnet 4.6 * Update user-agent strings in yahoo_finance.rb Updated user-agent strings to reflect current browser versions Signed-off-by: Serge L * Fix: Add stale-crumb retry to healthy? and fetch_chart_data Yahoo Finance returns 200 OK with {"chart":{"error":{"code":"Unauthorized"}}} when a cached crumb expires server-side. Both healthy? and fetch_chart_data now mirror the retry pattern already in fetch_security_prices: detect the Unauthorized body, clear the crumb cache, fetch fresh credentials, and retry the request once. Adds a test for the healthy? retry path. Co-Authored-By: Claude Sonnet 4.6 * Refactor: Extract fetch_authenticated_chart helper to DRY crumb retry logic The cookie/crumb fetch + stale-crumb retry pattern was duplicated across healthy?, fetch_security_prices, and fetch_chart_data. Extract it into a single private fetch_authenticated_chart(symbol, params) helper that centralizes the retry logic; all three call sites now delegate to it. Co-Authored-By: Claude Sonnet 4.6 * Fix: Catch JSON::ParserError in fetch_chart_data rescue clause After moving JSON.parse inside fetch_authenticated_chart, a malformed Yahoo response would throw JSON::ParserError through fetch_chart_data's rescue Faraday::Error, breaking the inverse currency pair fallback. Co-Authored-By: Claude Sonnet 4.6 * Fix: Raise AuthenticationError if retry still returns Unauthorized After refreshing the crumb and retrying, if Yahoo still returns an Unauthorized error body the helper now raises AuthenticationError instead of silently returning the error payload. This prevents callers from misinterpreting a persistent auth failure as missing chart data. Co-Authored-By: Claude Sonnet 4.6 * Fix: Raise AuthenticationError after failed retry in fetch_security_info Mirrors the same post-retry Unauthorized check added to fetch_authenticated_chart. Without this, a persistent auth failure on the quoteSummary endpoint would surface as a generic "No security info found" error instead of an AuthenticationError. Co-Authored-By: Claude Sonnet 4.6 --------- Signed-off-by: Serge L Co-authored-by: Claude Sonnet 4.6 --- app/models/provider/yahoo_finance.rb | 91 +++++++++++++--------- test/models/provider/yahoo_finance_test.rb | 28 +++++-- 2 files changed, 77 insertions(+), 42 deletions(-) diff --git a/app/models/provider/yahoo_finance.rb b/app/models/provider/yahoo_finance.rb index 75b82520c..d4a992184 100644 --- a/app/models/provider/yahoo_finance.rb +++ b/app/models/provider/yahoo_finance.rb @@ -25,12 +25,13 @@ class Provider::YahooFinance < Provider # Pool of modern browser user-agents to rotate through # Based on https://github.com/ranaroussi/yfinance/pull/2277 + # UPDATED user-agents string on 2026-02-27 with current versions of browsers (Chrome 145, Firefox 148, Safari 26) USER_AGENTS = [ - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0" + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_7_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:148.0) Gecko/20100101 Firefox/148.0" ].freeze def initialize @@ -39,21 +40,11 @@ class Provider::YahooFinance < Provider end def healthy? - begin - # Test with a known stable ticker (Apple) - response = client.get("#{base_url}/v8/finance/chart/AAPL") do |req| - req.params["interval"] = "1d" - req.params["range"] = "1d" - end - - data = JSON.parse(response.body) - result = data.dig("chart", "result") - health_status = result.present? && result.any? - - health_status - rescue => e - false - end + data = fetch_authenticated_chart("AAPL", { "interval" => "1d", "range" => "1d" }) + result = data.dig("chart", "result") + result.present? && result.any? + rescue => e + false end def usage @@ -201,6 +192,9 @@ class Provider::YahooFinance < Provider req.params["crumb"] = crumb end data = JSON.parse(response.body) + if data.dig("quoteSummary", "error", "code") == "Unauthorized" + raise AuthenticationError, "Yahoo Finance authentication failed after crumb refresh" + end end result = data.dig("quoteSummary", "result", 0) @@ -271,14 +265,13 @@ class Provider::YahooFinance < Provider period2 = end_date.end_of_day.to_time.utc.to_i throttle_request - response = client.get("#{base_url}/v8/finance/chart/#{symbol}") do |req| - req.params["period1"] = period1 - req.params["period2"] = period2 - req.params["interval"] = "1d" - req.params["includeAdjustedClose"] = true - end + data = fetch_authenticated_chart(symbol, { + "period1" => period1, + "period2" => period2, + "interval" => "1d", + "includeAdjustedClose" => true + }) - data = JSON.parse(response.body) chart_data = data.dig("chart", "result", 0) raise Error, "No chart data found for #{symbol}" unless chart_data @@ -452,24 +445,48 @@ class Provider::YahooFinance < Provider rates end + # Makes a single authenticated GET to /v8/finance/chart/:symbol. + # If Yahoo returns a stale-crumb error (200 OK with Unauthorized body), + # clears the crumb cache and retries once with fresh credentials. + def fetch_authenticated_chart(symbol, params) + cookie, crumb = fetch_cookie_and_crumb + response = authenticated_client(cookie).get("#{base_url}/v8/finance/chart/#{symbol}") do |req| + params.each { |k, v| req.params[k] = v } + req.params["crumb"] = crumb + end + data = JSON.parse(response.body) + + if data.dig("chart", "error", "code") == "Unauthorized" + clear_crumb_cache + cookie, crumb = fetch_cookie_and_crumb + response = authenticated_client(cookie).get("#{base_url}/v8/finance/chart/#{symbol}") do |req| + params.each { |k, v| req.params[k] = v } + req.params["crumb"] = crumb + end + data = JSON.parse(response.body) + if data.dig("chart", "error", "code") == "Unauthorized" + raise AuthenticationError, "Yahoo Finance authentication failed after crumb refresh" + end + end + + data + end + def fetch_chart_data(symbol, start_date, end_date, &block) period1 = start_date.to_time.utc.to_i period2 = end_date.end_of_day.to_time.utc.to_i begin throttle_request - response = client.get("#{base_url}/v8/finance/chart/#{symbol}") do |req| - req.params["period1"] = period1 - req.params["period2"] = period2 - req.params["interval"] = "1d" - req.params["includeAdjustedClose"] = true - end - - data = JSON.parse(response.body) + data = fetch_authenticated_chart(symbol, { + "period1" => period1, + "period2" => period2, + "interval" => "1d", + "includeAdjustedClose" => true + }) # Check for Yahoo Finance errors if data.dig("chart", "error") - error_msg = data.dig("chart", "error", "description") || "Unknown Yahoo Finance error" return nil end @@ -489,7 +506,7 @@ class Provider::YahooFinance < Provider end results.sort_by(&:date) - rescue Faraday::Error => e + rescue Faraday::Error, JSON::ParserError => e nil end end diff --git a/test/models/provider/yahoo_finance_test.rb b/test/models/provider/yahoo_finance_test.rb index e7a569b65..6dd0d30a2 100644 --- a/test/models/provider/yahoo_finance_test.rb +++ b/test/models/provider/yahoo_finance_test.rb @@ -10,24 +10,42 @@ class Provider::YahooFinanceTest < ActiveSupport::TestCase # ================================ test "healthy? returns true when API is working" do - # Mock successful response mock_response = mock mock_response.stubs(:body).returns('{"chart":{"result":[{"meta":{"symbol":"AAPL"}}]}}') - @provider.stubs(:client).returns(mock_client = mock) + @provider.stubs(:fetch_cookie_and_crumb).returns([ "test_cookie", "test_crumb" ]) + @provider.stubs(:authenticated_client).returns(mock_client = mock) mock_client.stubs(:get).returns(mock_response) assert @provider.healthy? end test "healthy? returns false when API fails" do - # Mock failed response - @provider.stubs(:client).returns(mock_client = mock) - mock_client.stubs(:get).raises(Faraday::Error.new("Connection failed")) + @provider.stubs(:fetch_cookie_and_crumb).raises(Provider::YahooFinance::AuthenticationError.new("auth failed")) assert_not @provider.healthy? end + test "healthy? retries with fresh crumb on Unauthorized body response" do + unauthorized_body = '{"chart":{"error":{"code":"Unauthorized","description":"No crumb"}}}' + success_body = '{"chart":{"result":[{"meta":{"symbol":"AAPL"}}]}}' + + unauthorized_response = mock + unauthorized_response.stubs(:body).returns(unauthorized_body) + + success_response = mock + success_response.stubs(:body).returns(success_body) + + mock_client = mock + mock_client.stubs(:get).returns(unauthorized_response, success_response) + + @provider.stubs(:fetch_cookie_and_crumb).returns([ "cookie1", "crumb1" ], [ "cookie2", "crumb2" ]) + @provider.stubs(:authenticated_client).returns(mock_client) + @provider.expects(:clear_crumb_cache).once + + assert @provider.healthy? + end + # ================================ # Exchange Rate Tests # ================================ From a914e35fca50ba280d26aa50873d78d0a48f30cc Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Sun, 1 Mar 2026 23:23:25 +0100 Subject: [PATCH 16/75] refactor: Improve enable banking panel rendering context (#1073) Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> --- app/models/enable_banking_item/sync_complete_event.rb | 2 +- .../settings/providers/_enable_banking_panel.html.erb | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/models/enable_banking_item/sync_complete_event.rb b/app/models/enable_banking_item/sync_complete_event.rb index 455ebccff..586241e9e 100644 --- a/app/models/enable_banking_item/sync_complete_event.rb +++ b/app/models/enable_banking_item/sync_complete_event.rb @@ -30,7 +30,7 @@ class EnableBankingItem::SyncCompleteEvent family, target: "enable_banking-providers-panel", partial: "settings/providers/enable_banking_panel", - locals: { enable_banking_items: enable_banking_items } + locals: { enable_banking_items: enable_banking_items, family: family } ) # Let family handle sync notifications diff --git a/app/views/settings/providers/_enable_banking_panel.html.erb b/app/views/settings/providers/_enable_banking_panel.html.erb index 33e4dca22..1ca388cea 100644 --- a/app/views/settings/providers/_enable_banking_panel.html.erb +++ b/app/views/settings/providers/_enable_banking_panel.html.erb @@ -25,10 +25,12 @@ <% end %> <% - enable_banking_item = Current.family.enable_banking_items.first_or_initialize(name: "Enable Banking Connection") + # Use local family variable if available (e.g., from Sidekiq broadcast), otherwise fall back to Current.family (HTTP requests) + family = local_assigns[:family] || Current.family + enable_banking_item = family.enable_banking_items.first_or_initialize(name: "Enable Banking Connection") is_new_record = enable_banking_item.new_record? # Check if there are any authenticated connections (have session_id) - has_authenticated_connections = Current.family.enable_banking_items.where.not(session_id: nil).exists? + has_authenticated_connections = family.enable_banking_items.where.not(session_id: nil).exists? %> <%= styled_form_with model: enable_banking_item, @@ -101,7 +103,7 @@
    <% end %> - <% items = local_assigns[:enable_banking_items] || @enable_banking_items || Current.family.enable_banking_items.where.not(client_certificate: nil) %> + <% items = local_assigns[:enable_banking_items] || @enable_banking_items || family.enable_banking_items.where.not(client_certificate: nil) %> <% if items&.any? %> <% # Find the first item with valid session to use for "Add Connection" button From 4db5737c9c9782b71323e7a2f869a8863db8a24e Mon Sep 17 00:00:00 2001 From: "Ang Wei Feng (Ted)" Date: Tue, 3 Mar 2026 05:44:24 +0800 Subject: [PATCH 17/75] fix: maintain activity tab during pagination from holdings tab (#1096) --- app/controllers/accounts_controller.rb | 6 +++++- test/controllers/accounts_controller_test.rb | 21 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 4258e86ad..6b9ba590c 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -42,7 +42,11 @@ class AccountsController < ApplicationController @q = params.fetch(:q, {}).permit(:search, status: []) entries = @account.entries.where(excluded: false).search(@q).reverse_chronological - @pagy, @entries = pagy(entries, limit: safe_per_page) + @pagy, @entries = pagy( + entries, + limit: safe_per_page, + params: request.query_parameters.except("tab").merge("tab" => "activity") + ) @activity_feed_data = Account::ActivityFeedData.new(@account, @entries) end diff --git a/test/controllers/accounts_controller_test.rb b/test/controllers/accounts_controller_test.rb index c192c278b..39e4dc2e1 100644 --- a/test/controllers/accounts_controller_test.rb +++ b/test/controllers/accounts_controller_test.rb @@ -16,6 +16,27 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest assert_response :success end + test "activity pagination keeps activity tab when loaded from holdings tab" do + investment = accounts(:investment) + + 11.times do |i| + Entry.create!( + account: investment, + name: "Test investment activity #{i}", + date: Date.current - i.days, + amount: 10 + i, + currency: investment.currency, + entryable: Transaction.new + ) + end + + get account_url(investment, tab: "holdings") + + assert_response :success + assert_select "a[href*='page=2'][href*='tab=activity']" + assert_select "a[href*='page=2'][href*='tab=holdings']", count: 0 + end + test "should sync account" do post sync_account_url(@account) assert_redirected_to account_url(@account) From 59bf72dc49641906c85a9eddd1d7a49d9061d97f Mon Sep 17 00:00:00 2001 From: LPW Date: Mon, 2 Mar 2026 17:26:01 -0500 Subject: [PATCH 18/75] feat(helm): add Pipelock ConfigMap, scanning config, and consolidate compose (#1064) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(helm): add Pipelock ConfigMap, scanning config, and consolidate compose - Add ConfigMap template rendering DLP, response scanning, MCP input/tool scanning, and forward proxy settings from values - Mount ConfigMap as /etc/pipelock/pipelock.yaml volume in deployment - Add checksum/config annotation for automatic pod restart on config change - Gate HTTPS_PROXY/HTTP_PROXY env injection on forwardProxy.enabled (skip in MCP-only mode) - Use hasKey for all boolean values to prevent Helm default swallowing false - Single source of truth for ports (forwardProxy.port/mcpProxy.port) - Pipelock-specific imagePullSecrets with fallback to app secrets - Merge standalone compose.example.pipelock.yml into compose.example.ai.yml - Add pipelock.example.yaml for Docker Compose users - Add exclude-paths to CI workflow for locale file false positives * Add CHANGELOG entry for Pipelock security proxy integration * Missed v0.6.8 release --------- Co-authored-by: Juan José Mata --- .github/workflows/pipelock.yml | 4 +- charts/sure/CHANGELOG.md | 46 ++- charts/sure/templates/_env.tpl | 13 + charts/sure/templates/_helpers.tpl | 24 ++ charts/sure/templates/pipelock-configmap.yaml | 76 +++++ .../sure/templates/pipelock-deployment.yaml | 101 +++++++ charts/sure/templates/pipelock-service.yaml | 30 ++ charts/sure/values.yaml | 50 ++++ compose.example.ai.yml | 78 ++++- compose.example.pipelock.yml | 275 ------------------ pipelock.example.yaml | 36 +++ 11 files changed, 437 insertions(+), 296 deletions(-) create mode 100644 charts/sure/templates/pipelock-configmap.yaml create mode 100644 charts/sure/templates/pipelock-deployment.yaml create mode 100644 charts/sure/templates/pipelock-service.yaml delete mode 100644 compose.example.pipelock.yml create mode 100644 pipelock.example.yaml diff --git a/.github/workflows/pipelock.yml b/.github/workflows/pipelock.yml index dfcb866ec..3668c0a49 100644 --- a/.github/workflows/pipelock.yml +++ b/.github/workflows/pipelock.yml @@ -20,5 +20,7 @@ jobs: uses: luckyPipewrench/pipelock@v1 with: scan-diff: 'true' - fail-on-findings: 'false' + fail-on-findings: 'true' test-vectors: 'false' + exclude-paths: | + config/locales/views/reports/ diff --git a/charts/sure/CHANGELOG.md b/charts/sure/CHANGELOG.md index f0d636ba5..b2d44fe72 100644 --- a/charts/sure/CHANGELOG.md +++ b/charts/sure/CHANGELOG.md @@ -5,22 +5,25 @@ All notable changes to the Sure Helm chart will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -### [0.0.0], [0.6.5] +## [0.6.9-alpha] - 2026-03-01 ### Added +- **Pipelock security proxy** (`pipelock.enabled=true`): Separate Deployment + Service that provides two scanning layers + - **Forward proxy** (port 8888): Scans outbound HTTPS from Faraday-based clients (e.g. ruby-openai). Auto-injects `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` env vars into app pods + - **MCP reverse proxy** (port 8889): Scans inbound MCP traffic for DLP, prompt injection, and tool poisoning. Auto-computes upstream URL via `sure.pipelockUpstream` helper + - **WebSocket proxy** configuration support (disabled by default, requires Pipelock >= 0.2.9) + - ConfigMap with scanning config (DLP, prompt injection detection, MCP input/tool scanning, response scanning) + - ConfigMap checksum annotation for automatic pod restart on config changes + - Helm helpers: `sure.pipelockImage`, `sure.pipelockUpstream` + - Health and readiness probes on the Pipelock deployment + - `imagePullSecrets` with fallback to app-level secrets + - Boolean safety: uses `hasKey` to prevent Helm's `default` from swallowing explicit `false` + - Configurable ports via `forwardProxy.port` and `mcpProxy.port` (single source of truth across Service, Deployment, and env vars) +- `pipelock.example.yaml` reference config for Docker Compose deployments -- First (nightly/test) releases via - -### [0.6.6] - 2025-12-31 - -### Added - -- First version/release that aligns versions with monorepo -- CNPG: render `Cluster.spec.backup` from `cnpg.cluster.backup`. - - If `backup.method` is omitted and `backup.volumeSnapshot` is present, the chart will infer `method: volumeSnapshot`. - - For snapshot backups, `backup.volumeSnapshot.className` is required (template fails early if missing). - - Example-only keys like `backup.ttl` and `backup.volumeSnapshot.enabled` are stripped to avoid CRD warnings. -- CNPG: render `Cluster.spec.plugins` from `cnpg.cluster.plugins` (enables barman-cloud plugin / WAL archiver configuration). +### Changed +- Consolidated `compose.example.pipelock.yml` into `compose.example.ai.yml` — Pipelock now runs alongside Ollama in one compose file with health checks, config volume mount, and MCP env vars (`MCP_API_TOKEN`, `MCP_USER_EMAIL`) +- CI: Pipelock scan `fail-on-findings` changed from `false` to `true`; added `exclude-paths` for locale help text false positives ## [0.6.7-alpha] - 2026-01-10 @@ -33,6 +36,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Production-ready HA timeouts: 200ms connect, 1s read/write, 3 reconnection attempts - Backward compatible with existing `REDIS_URL` deployments +### [0.6.6] - 2025-12-31 + +### Added + +- First version/release that aligns versions with monorepo +- CNPG: render `Cluster.spec.backup` from `cnpg.cluster.backup`. + - If `backup.method` is omitted and `backup.volumeSnapshot` is present, the chart will infer `method: volumeSnapshot`. + - For snapshot backups, `backup.volumeSnapshot.className` is required (template fails early if missing). + - Example-only keys like `backup.ttl` and `backup.volumeSnapshot.enabled` are stripped to avoid CRD warnings. +- CNPG: render `Cluster.spec.plugins` from `cnpg.cluster.plugins` (enables barman-cloud plugin / WAL archiver configuration). + +### [0.0.0], [0.6.5] + +### Added + +- First (nightly/test) releases via + ## Notes - Chart version and application version are kept in sync - Requires Kubernetes >= 1.25.0 diff --git a/charts/sure/templates/_env.tpl b/charts/sure/templates/_env.tpl index ccf0c1b69..a0b230ac1 100644 --- a/charts/sure/templates/_env.tpl +++ b/charts/sure/templates/_env.tpl @@ -11,6 +11,7 @@ The helper always injects: - optional Active Record Encryption keys (controlled by rails.encryptionEnv.enabled) - optional DATABASE_URL + DB_PASSWORD (includeDatabase=true and helper can compute a DB URL) - optional REDIS_URL + REDIS_PASSWORD (includeRedis=true and helper can compute a Redis URL) +- optional HTTPS_PROXY / HTTP_PROXY / NO_PROXY (pipelock.enabled=true) - rails.settings / rails.extraEnv / rails.extraEnvVars - optional additional per-workload env / envFrom blocks via extraEnv / extraEnvFrom. */}} @@ -77,6 +78,18 @@ The helper always injects: {{- end }} {{- end }} {{- end }} +{{- if and $ctx.Values.pipelock.enabled (ne (toString (dig "forwardProxy" "enabled" true $ctx.Values.pipelock)) "false") }} +{{- $proxyPort := 8888 -}} +{{- if $ctx.Values.pipelock.forwardProxy -}} +{{- $proxyPort = int ($ctx.Values.pipelock.forwardProxy.port | default 8888) -}} +{{- end }} +- name: HTTPS_PROXY + value: {{ printf "http://%s-pipelock.%s.svc.cluster.local:%d" (include "sure.fullname" $ctx) $ctx.Release.Namespace $proxyPort | quote }} +- name: HTTP_PROXY + value: {{ printf "http://%s-pipelock.%s.svc.cluster.local:%d" (include "sure.fullname" $ctx) $ctx.Release.Namespace $proxyPort | quote }} +- name: NO_PROXY + value: "localhost,127.0.0.1,.svc.cluster.local,.cluster.local" +{{- end }} {{- range $k, $v := $ctx.Values.rails.settings }} - name: {{ $k }} value: {{ $v | quote }} diff --git a/charts/sure/templates/_helpers.tpl b/charts/sure/templates/_helpers.tpl index 436127959..d36105db9 100644 --- a/charts/sure/templates/_helpers.tpl +++ b/charts/sure/templates/_helpers.tpl @@ -157,3 +157,27 @@ true {{- default "redis-password" .Values.redis.passwordKey -}} {{- end -}} {{- end -}} + +{{/* Pipelock image string */}} +{{- define "sure.pipelockImage" -}} +{{- $repo := "ghcr.io/luckypipewrench/pipelock" -}} +{{- $tag := "latest" -}} +{{- if .Values.pipelock.image -}} +{{- $repo = .Values.pipelock.image.repository | default $repo -}} +{{- $tag = .Values.pipelock.image.tag | default $tag -}} +{{- end -}} +{{- printf "%s:%s" $repo $tag -}} +{{- end -}} + +{{/* Pipelock MCP upstream URL (auto-compute or explicit override) */}} +{{- define "sure.pipelockUpstream" -}} +{{- $upstream := "" -}} +{{- if .Values.pipelock.mcpProxy -}} +{{- $upstream = .Values.pipelock.mcpProxy.upstream | default "" -}} +{{- end -}} +{{- if $upstream -}} +{{- $upstream -}} +{{- else -}} +{{- printf "http://%s:%d/mcp" (include "sure.fullname" .) (int (.Values.service.port | default 80)) -}} +{{- end -}} +{{- end -}} diff --git a/charts/sure/templates/pipelock-configmap.yaml b/charts/sure/templates/pipelock-configmap.yaml new file mode 100644 index 000000000..f840961e2 --- /dev/null +++ b/charts/sure/templates/pipelock-configmap.yaml @@ -0,0 +1,76 @@ +{{- if .Values.pipelock.enabled }} +{{- $fwdEnabled := true -}} +{{- $fwdMaxTunnel := 300 -}} +{{- $fwdIdleTimeout := 60 -}} +{{- if .Values.pipelock.forwardProxy -}} +{{- if hasKey .Values.pipelock.forwardProxy "enabled" -}} +{{- $fwdEnabled = .Values.pipelock.forwardProxy.enabled -}} +{{- end -}} +{{- $fwdMaxTunnel = int (.Values.pipelock.forwardProxy.maxTunnelSeconds | default 300) -}} +{{- $fwdIdleTimeout = int (.Values.pipelock.forwardProxy.idleTimeoutSeconds | default 60) -}} +{{- end -}} +{{- $wsEnabled := false -}} +{{- $wsMaxMsg := 1048576 -}} +{{- $wsMaxConns := 128 -}} +{{- $wsScanText := true -}} +{{- $wsAllowBinary := false -}} +{{- $wsForwardCookies := false -}} +{{- $wsMaxConnSec := 3600 -}} +{{- $wsIdleTimeout := 300 -}} +{{- $wsOriginPolicy := "rewrite" -}} +{{- if .Values.pipelock.websocketProxy -}} +{{- if hasKey .Values.pipelock.websocketProxy "enabled" -}} +{{- $wsEnabled = .Values.pipelock.websocketProxy.enabled -}} +{{- end -}} +{{- $wsMaxMsg = int (.Values.pipelock.websocketProxy.maxMessageBytes | default 1048576) -}} +{{- $wsMaxConns = int (.Values.pipelock.websocketProxy.maxConcurrentConnections | default 128) -}} +{{- if hasKey .Values.pipelock.websocketProxy "scanTextFrames" -}} +{{- $wsScanText = .Values.pipelock.websocketProxy.scanTextFrames -}} +{{- end -}} +{{- if hasKey .Values.pipelock.websocketProxy "allowBinaryFrames" -}} +{{- $wsAllowBinary = .Values.pipelock.websocketProxy.allowBinaryFrames -}} +{{- end -}} +{{- if hasKey .Values.pipelock.websocketProxy "forwardCookies" -}} +{{- $wsForwardCookies = .Values.pipelock.websocketProxy.forwardCookies -}} +{{- end -}} +{{- $wsMaxConnSec = int (.Values.pipelock.websocketProxy.maxConnectionSeconds | default 3600) -}} +{{- $wsIdleTimeout = int (.Values.pipelock.websocketProxy.idleTimeoutSeconds | default 300) -}} +{{- $wsOriginPolicy = .Values.pipelock.websocketProxy.originPolicy | default "rewrite" -}} +{{- end }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "sure.fullname" . }}-pipelock + labels: + {{- include "sure.labels" . | nindent 4 }} +data: + pipelock.yaml: | + forward_proxy: + enabled: {{ $fwdEnabled }} + max_tunnel_seconds: {{ $fwdMaxTunnel }} + idle_timeout_seconds: {{ $fwdIdleTimeout }} + websocket_proxy: + enabled: {{ $wsEnabled }} + max_message_bytes: {{ $wsMaxMsg }} + max_concurrent_connections: {{ $wsMaxConns }} + scan_text_frames: {{ $wsScanText }} + allow_binary_frames: {{ $wsAllowBinary }} + forward_cookies: {{ $wsForwardCookies }} + strip_compression: true + max_connection_seconds: {{ $wsMaxConnSec }} + idle_timeout_seconds: {{ $wsIdleTimeout }} + origin_policy: {{ $wsOriginPolicy }} + dlp: + scan_env: true + response_scanning: + enabled: true + action: warn + mcp_input_scanning: + enabled: true + action: block + on_parse_error: block + mcp_tool_scanning: + enabled: true + action: warn + detect_drift: true +{{- end }} diff --git a/charts/sure/templates/pipelock-deployment.yaml b/charts/sure/templates/pipelock-deployment.yaml new file mode 100644 index 000000000..f35db3e49 --- /dev/null +++ b/charts/sure/templates/pipelock-deployment.yaml @@ -0,0 +1,101 @@ +{{- if .Values.pipelock.enabled }} +{{- $fwdPort := 8888 -}} +{{- $mcpPort := 8889 -}} +{{- $pullPolicy := "IfNotPresent" -}} +{{- if .Values.pipelock.forwardProxy -}} +{{- $fwdPort = int (.Values.pipelock.forwardProxy.port | default 8888) -}} +{{- end -}} +{{- if .Values.pipelock.mcpProxy -}} +{{- $mcpPort = int (.Values.pipelock.mcpProxy.port | default 8889) -}} +{{- end -}} +{{- if .Values.pipelock.image -}} +{{- $pullPolicy = .Values.pipelock.image.pullPolicy | default "IfNotPresent" -}} +{{- end }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "sure.fullname" . }}-pipelock + labels: + {{- include "sure.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.pipelock.replicas | default 1 }} + selector: + matchLabels: + app.kubernetes.io/component: pipelock + {{- include "sure.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + app.kubernetes.io/component: pipelock + {{- include "sure.selectorLabels" . | nindent 8 }} + annotations: + checksum/config: {{ include (print $.Template.BasePath "/pipelock-configmap.yaml") . | sha256sum }} + {{- with .Values.pipelock.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + {{- $plSecrets := coalesce .Values.pipelock.image.imagePullSecrets .Values.image.imagePullSecrets }} + {{- if $plSecrets }} + imagePullSecrets: + {{- toYaml $plSecrets | nindent 8 }} + {{- end }} + volumes: + - name: config + configMap: + name: {{ include "sure.fullname" . }}-pipelock + containers: + - name: pipelock + image: {{ include "sure.pipelockImage" . }} + imagePullPolicy: {{ $pullPolicy }} + args: + - "run" + - "--config" + - "/etc/pipelock/pipelock.yaml" + - "--listen" + - "0.0.0.0:{{ $fwdPort }}" + - "--mode" + - {{ .Values.pipelock.mode | default "balanced" | quote }} + - "--mcp-listen" + - "0.0.0.0:{{ $mcpPort }}" + - "--mcp-upstream" + - {{ include "sure.pipelockUpstream" . | quote }} + volumeMounts: + - name: config + mountPath: /etc/pipelock + readOnly: true + ports: + - name: proxy + containerPort: {{ $fwdPort }} + protocol: TCP + - name: mcp + containerPort: {{ $mcpPort }} + protocol: TCP + livenessProbe: + httpGet: + path: /health + port: proxy + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health + port: proxy + initialDelaySeconds: 3 + periodSeconds: 5 + timeoutSeconds: 2 + failureThreshold: 3 + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + resources: + {{- toYaml (.Values.pipelock.resources | default dict) | nindent 12 }} + nodeSelector: + {{- toYaml (.Values.pipelock.nodeSelector | default dict) | nindent 8 }} + affinity: + {{- toYaml (.Values.pipelock.affinity | default dict) | nindent 8 }} + tolerations: + {{- toYaml (.Values.pipelock.tolerations | default list) | nindent 8 }} +{{- end }} diff --git a/charts/sure/templates/pipelock-service.yaml b/charts/sure/templates/pipelock-service.yaml new file mode 100644 index 000000000..01be758c7 --- /dev/null +++ b/charts/sure/templates/pipelock-service.yaml @@ -0,0 +1,30 @@ +{{- if .Values.pipelock.enabled }} +{{- $fwdPort := 8888 -}} +{{- $mcpPort := 8889 -}} +{{- if .Values.pipelock.forwardProxy -}} +{{- $fwdPort = int (.Values.pipelock.forwardProxy.port | default 8888) -}} +{{- end -}} +{{- if .Values.pipelock.mcpProxy -}} +{{- $mcpPort = int (.Values.pipelock.mcpProxy.port | default 8889) -}} +{{- end }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "sure.fullname" . }}-pipelock + labels: + {{- include "sure.labels" . | nindent 4 }} +spec: + type: {{ (.Values.pipelock.service).type | default "ClusterIP" }} + selector: + app.kubernetes.io/component: pipelock + {{- include "sure.selectorLabels" . | nindent 4 }} + ports: + - name: proxy + port: {{ $fwdPort }} + targetPort: proxy + protocol: TCP + - name: mcp + port: {{ $mcpPort }} + targetPort: mcp + protocol: TCP +{{- end }} diff --git a/charts/sure/values.yaml b/charts/sure/values.yaml index 3ecd95f94..349e88a23 100644 --- a/charts/sure/values.yaml +++ b/charts/sure/values.yaml @@ -465,3 +465,53 @@ hpa: minReplicas: 2 maxReplicas: 10 targetCPUUtilizationPercentage: 70 + +# Pipelock: AI agent security proxy (optional) +# Provides forward proxy (outbound HTTPS scanning) and MCP reverse proxy +# (inbound MCP traffic scanning for prompt injection, DLP, tool poisoning). +# More info: https://github.com/luckyPipewrench/pipelock +pipelock: + enabled: false + image: + repository: ghcr.io/luckypipewrench/pipelock + tag: "0.2.7" + pullPolicy: IfNotPresent + imagePullSecrets: [] + replicas: 1 + # Pipelock run mode: strict, balanced, audit + mode: balanced + forwardProxy: + enabled: true + port: 8888 + maxTunnelSeconds: 300 + idleTimeoutSeconds: 60 + mcpProxy: + port: 8889 + # Auto-computed when empty: http://:/mcp + upstream: "" + # WebSocket proxy: bidirectional frame scanning for ws/wss connections. + # Runs on the same listener as the forward proxy at /ws?url=. + # Requires Pipelock >= 0.2.9 (or current dev build). + websocketProxy: + # Requires image.tag >= 0.2.9. Update pipelock.image.tag before enabling. + enabled: false + maxMessageBytes: 1048576 # 1MB per message + maxConcurrentConnections: 128 + scanTextFrames: true # DLP + injection scanning on text frames + allowBinaryFrames: false # block binary frames by default + forwardCookies: false + maxConnectionSeconds: 3600 # 1 hour max connection lifetime + idleTimeoutSeconds: 300 # 5 min idle timeout + originPolicy: rewrite # rewrite, forward, or strip + service: + type: ClusterIP + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + memory: 128Mi + podAnnotations: {} + nodeSelector: {} + tolerations: [] + affinity: {} diff --git a/compose.example.ai.yml b/compose.example.ai.yml index e711fc8f4..fb85a354a 100644 --- a/compose.example.ai.yml +++ b/compose.example.ai.yml @@ -1,21 +1,33 @@ # =========================================================================== -# Example Docker Compose file with additional Ollama service for LLM tools +# Example Docker Compose file with Ollama (local LLM) and Pipelock (agent +# security proxy) # =========================================================================== # # Purpose: # -------- # -# This file is an example Docker Compose configuration for self hosting -# Sure with Ollama on your local machine or on a cloud VPS. +# This file extends the standard Sure setup with two optional capabilities: # -# The configuration below is a "standard" setup that works out of the box, -# but if you're running this outside of a local network, it is recommended -# to set the environment variables for extra security. +# Pipelock — agent security proxy (always runs) +# - Forward proxy (port 8888): scans outbound HTTPS from Faraday-based +# clients (e.g. ruby-openai). NOT covered: SimpleFin, Coinbase, or +# anything using Net::HTTP/HTTParty directly. HTTPS_PROXY is +# cooperative; Docker Compose has no egress network policy. +# - MCP reverse proxy (port 8889): scans inbound AI traffic (DLP, +# prompt injection, tool poisoning, tool call policy). External AI +# clients should connect to Pipelock on port 8889 rather than +# directly to Sure's /mcp endpoint. Note: /mcp is still reachable +# on web port 3000 (auth token required); Pipelock adds scanning +# but Docker Compose cannot enforce network-level routing. +# +# Ollama + Open WebUI — local LLM inference (optional, --profile ai) +# - Only starts when you run: docker compose --profile ai up # # Setup: # ------ # -# To run this, you should read the setup guide: +# 1. Copy pipelock.example.yaml alongside this file (or customize it). +# 2. Read the full setup guide: # # https://github.com/we-promise/sure/blob/main/docs/hosting/docker.md # @@ -41,6 +53,17 @@ x-rails-env: &rails_env DB_HOST: db DB_PORT: 5432 REDIS_URL: redis://redis:6379/1 + # MCP server endpoint — enables /mcp for external AI assistants (e.g. Claude, GPT). + # Set both values to activate. MCP_USER_EMAIL must match an existing user's email. + # External AI clients should connect via Pipelock (port 8889) for scanning. + MCP_API_TOKEN: ${MCP_API_TOKEN:-} + MCP_USER_EMAIL: ${MCP_USER_EMAIL:-} + # Route outbound HTTPS through Pipelock for clients that respect HTTPS_PROXY. + # Covered: OpenAI API (ruby-openai/Faraday). NOT covered: SimpleFin, Coinbase (Net::HTTP). + HTTPS_PROXY: "http://pipelock:8888" + HTTP_PROXY: "http://pipelock:8888" + # Skip proxy for internal Docker network services (including ollama for local LLM calls) + NO_PROXY: "db,redis,pipelock,ollama,localhost,127.0.0.1" AI_DEBUG_MODE: "true" # Useful for debugging, set to "false" in production # Ollama using OpenAI API compatible endpoints OPENAI_ACCESS_TOKEN: token-can-be-any-value-for-ollama @@ -50,6 +73,39 @@ x-rails-env: &rails_env # OPENAI_ACCESS_TOKEN: ${OPENAI_ACCESS_TOKEN} services: + pipelock: + image: ghcr.io/luckypipewrench/pipelock:latest # pin to a specific version (e.g., :0.2.7) for production + container_name: pipelock + hostname: pipelock + restart: unless-stopped + volumes: + - ./pipelock.example.yaml:/etc/pipelock/pipelock.yaml:ro + command: + - "run" + - "--config" + - "/etc/pipelock/pipelock.yaml" + - "--listen" + - "0.0.0.0:8888" + - "--mode" + - "balanced" + - "--mcp-listen" + - "0.0.0.0:8889" + - "--mcp-upstream" + - "http://web:3000/mcp" + ports: + # MCP reverse proxy — external AI assistants connect here + - "${MCP_PROXY_PORT:-8889}:8889" + # Uncomment to expose forward proxy endpoints (/health, /metrics, /stats): + # - "8888:8888" + healthcheck: + test: ["CMD", "/pipelock", "healthcheck", "--addr", "127.0.0.1:8888"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 30s + networks: + - sure_net + # Note: You still have to download models manually using the ollama CLI or via Open WebUI ollama: profiles: @@ -106,6 +162,10 @@ services: volumes: - app-storage:/rails/storage ports: + # Web UI for browser access. Note: /mcp is also reachable on this port, + # bypassing Pipelock's MCP scanning (auth token is still required). + # For hardened deployments, use `expose: [3000]` instead and front + # the web UI with a separate reverse proxy. - ${PORT:-3000}:3000 restart: unless-stopped environment: @@ -115,6 +175,8 @@ services: condition: service_healthy redis: condition: service_healthy + pipelock: # Remove this block and unset HTTPS_PROXY/HTTP_PROXY to run without Pipelock + condition: service_healthy dns: - 8.8.8.8 - 1.1.1.1 @@ -132,6 +194,8 @@ services: condition: service_healthy redis: condition: service_healthy + pipelock: # Remove this block and unset HTTPS_PROXY/HTTP_PROXY to run without Pipelock + condition: service_healthy dns: - 8.8.8.8 - 1.1.1.1 diff --git a/compose.example.pipelock.yml b/compose.example.pipelock.yml deleted file mode 100644 index b70bbb916..000000000 --- a/compose.example.pipelock.yml +++ /dev/null @@ -1,275 +0,0 @@ -# =========================================================================== -# Example Docker Compose file with Pipelock agent security proxy -# =========================================================================== -# -# Purpose: -# -------- -# -# This file adds Pipelock (https://github.com/luckyPipewrench/pipelock) -# as a security proxy for Sure, providing two layers of protection: -# -# 1. Forward proxy (port 8888) — routes outbound HTTPS through Pipelock -# for clients that respect the HTTPS_PROXY environment variable. -# -# 2. MCP reverse proxy (port 8889) — scans inbound MCP traffic from -# external AI assistants bidirectionally (DLP, prompt injection, -# tool poisoning, tool call policy). -# -# Forward proxy coverage: -# ----------------------- -# -# Covered (Faraday-based clients respect HTTPS_PROXY automatically): -# - OpenAI API calls (ruby-openai gem) -# - Market data providers using Faraday -# -# NOT covered (these clients ignore HTTPS_PROXY): -# - SimpleFin (HTTParty / Net::HTTP) -# - Coinbase (HTTParty / Net::HTTP) -# - Any code using Net::HTTP or HTTParty directly -# -# For covered traffic, Pipelock provides: -# - Domain allowlisting (only known-good external APIs can be reached) -# - SSRF protection (blocks connections to private/internal IPs) -# - DLP scanning on connection targets (detects exfiltration patterns) -# - Rate limiting per domain -# - Structured JSON audit logging of all outbound connections -# -# MCP reverse proxy coverage: -# --------------------------- -# -# External AI assistants connect to Pipelock on port 8889 instead of -# directly to Sure's /mcp endpoint. Pipelock scans all traffic: -# -# Request scanning (client → Sure): -# - DLP detection (blocks credential/secret leakage in tool arguments) -# - Prompt injection detection in tool call parameters -# - Tool call policy enforcement (blocks dangerous operations) -# -# Response scanning (Sure → client): -# - Prompt injection detection in tool response content -# - Tool poisoning / drift detection (tool definitions changing) -# -# The MCP endpoint on Sure (port 3000/mcp) should NOT be exposed directly -# to the internet. Route all external MCP traffic through Pipelock. -# -# Limitations: -# ------------ -# -# HTTPS_PROXY is cooperative. Docker Compose has no egress network policy, -# so any code path that doesn't check the env var can connect directly. -# For hard enforcement, deploy with network-level controls that deny all -# egress except through the proxy. Example for Kubernetes: -# -# # NetworkPolicy: deny all egress, allow only proxy + DNS -# egress: -# - to: -# - podSelector: -# matchLabels: -# app: pipelock -# ports: -# - port: 8888 -# - ports: -# - port: 53 -# protocol: UDP -# -# Monitoring: -# ----------- -# -# Pipelock logs every connection and MCP request as structured JSON to stdout. -# View logs with: docker compose logs pipelock -# -# Forward proxy endpoints (port 8888): -# http://localhost:8888/health - liveness check -# http://localhost:8888/metrics - Prometheus metrics -# http://localhost:8888/stats - JSON summary -# -# More info: https://github.com/luckyPipewrench/pipelock -# -# Setup: -# ------ -# -# 1. Copy this file to compose.yml (or use -f flag) -# 2. Set your environment variables (OPENAI_ACCESS_TOKEN, MCP_API_TOKEN, etc.) -# 3. docker compose up -# -# Pipelock runs both proxies in a single container: -# - Port 8888: forward proxy for outbound HTTPS (internal only) -# - Port 8889: MCP reverse proxy for external AI assistants -# -# External AI clients connect to http://:8889 as their MCP endpoint. -# Pipelock scans the traffic and forwards clean requests to Sure's /mcp. -# -# Customization: -# -------------- -# -# Requires Pipelock with MCP HTTP listener support (--mcp-listen flag). -# See: https://github.com/luckyPipewrench/pipelock/releases -# -# Edit the pipelock command to change the mode: -# --mode strict Block unknown domains (recommended for production) -# --mode balanced Warn on unknown domains, block known-bad (default) -# --mode audit Log everything, block nothing (for evaluation) -# -# For a custom config, mount a file and use --config instead of --mode: -# volumes: -# - ./config/pipelock.yml:/etc/pipelock/config.yml:ro -# command: ["run", "--config", "/etc/pipelock/config.yml", -# "--mcp-listen", "0.0.0.0:8889", "--mcp-upstream", "http://web:3000/mcp"] -# - -x-db-env: &db_env - POSTGRES_USER: ${POSTGRES_USER:-sure_user} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-sure_password} - POSTGRES_DB: ${POSTGRES_DB:-sure_production} - -x-rails-env: &rails_env - <<: *db_env - SECRET_KEY_BASE: ${SECRET_KEY_BASE:-a7523c3d0ae56415046ad8abae168d71074a79534a7062258f8d1d51ac2f76d3c3bc86d86b6b0b307df30d9a6a90a2066a3fa9e67c5e6f374dbd7dd4e0778e13} - SELF_HOSTED: "true" - RAILS_FORCE_SSL: "false" - RAILS_ASSUME_SSL: "false" - DB_HOST: db - DB_PORT: 5432 - REDIS_URL: redis://redis:6379/1 - # NOTE: enabling OpenAI will incur costs when you use AI-related features in the app (chat, rules). Make sure you have set appropriate spend limits on your account before adding this. - OPENAI_ACCESS_TOKEN: ${OPENAI_ACCESS_TOKEN} - # MCP server endpoint — enables /mcp for external AI assistants (e.g. Claude, GPT). - # Set both values to activate. MCP_USER_EMAIL must match an existing user's email. - # External AI clients connect via Pipelock (port 8889), not directly to /mcp. - MCP_API_TOKEN: ${MCP_API_TOKEN:-} - MCP_USER_EMAIL: ${MCP_USER_EMAIL:-} - # Route outbound HTTPS through Pipelock for clients that respect HTTPS_PROXY. - # See "Forward proxy coverage" section above for which clients are covered. - HTTPS_PROXY: "http://pipelock:8888" - HTTP_PROXY: "http://pipelock:8888" - # Skip proxy for internal Docker network services - NO_PROXY: "db,redis,pipelock,localhost,127.0.0.1" - -services: - pipelock: - image: ghcr.io/luckypipewrench/pipelock:latest - container_name: pipelock - hostname: pipelock - restart: unless-stopped - command: - - "run" - - "--listen" - - "0.0.0.0:8888" - - "--mode" - - "balanced" - - "--mcp-listen" - - "0.0.0.0:8889" - - "--mcp-upstream" - - "http://web:3000/mcp" - ports: - # MCP reverse proxy — external AI assistants connect here - - "${MCP_PROXY_PORT:-8889}:8889" - # Uncomment to expose forward proxy endpoints (/health, /metrics, /stats): - # - "8888:8888" - healthcheck: - test: ["CMD", "/pipelock", "healthcheck", "--addr", "127.0.0.1:8888"] - interval: 10s - timeout: 5s - retries: 3 - start_period: 30s - networks: - - sure_net - - web: - image: ghcr.io/we-promise/sure:stable - volumes: - - app-storage:/rails/storage - ports: - # Web UI for browser access. Note: /mcp is also reachable on this port, - # bypassing Pipelock's MCP scanning (auth token is still required). - # For hardened deployments, use `expose: [3000]` instead and front - # the web UI with a separate reverse proxy. - - ${PORT:-3000}:3000 - restart: unless-stopped - environment: - <<: *rails_env - depends_on: - db: - condition: service_healthy - redis: - condition: service_healthy - pipelock: - condition: service_healthy - networks: - - sure_net - - worker: - image: ghcr.io/we-promise/sure:stable - command: bundle exec sidekiq - volumes: - - app-storage:/rails/storage - restart: unless-stopped - depends_on: - db: - condition: service_healthy - redis: - condition: service_healthy - pipelock: - condition: service_healthy - environment: - <<: *rails_env - networks: - - sure_net - - db: - image: postgres:16 - restart: unless-stopped - volumes: - - postgres-data:/var/lib/postgresql/data - environment: - <<: *db_env - healthcheck: - test: [ "CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" ] - interval: 5s - timeout: 5s - retries: 5 - networks: - - sure_net - - backup: - profiles: - - backup - image: prodrigestivill/postgres-backup-local - restart: unless-stopped - volumes: - - /opt/sure-data/backups:/backups # Change this path to your desired backup location on the host machine - environment: - - POSTGRES_HOST=db - - POSTGRES_DB=${POSTGRES_DB:-sure_production} - - POSTGRES_USER=${POSTGRES_USER:-sure_user} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-sure_password} - - SCHEDULE=@daily # Runs once a day at midnight - - BACKUP_KEEP_DAYS=7 # Keeps the last 7 days of backups - - BACKUP_KEEP_WEEKS=4 # Keeps 4 weekly backups - - BACKUP_KEEP_MONTHS=6 # Keeps 6 monthly backups - depends_on: - - db - networks: - - sure_net - - redis: - image: redis:latest - restart: unless-stopped - volumes: - - redis-data:/data - healthcheck: - test: [ "CMD", "redis-cli", "ping" ] - interval: 5s - timeout: 5s - retries: 5 - networks: - - sure_net - -volumes: - app-storage: - postgres-data: - redis-data: - -networks: - sure_net: - driver: bridge diff --git a/pipelock.example.yaml b/pipelock.example.yaml new file mode 100644 index 000000000..d53f11a13 --- /dev/null +++ b/pipelock.example.yaml @@ -0,0 +1,36 @@ +# Pipelock configuration for Docker Compose +# See https://github.com/luckyPipewrench/pipelock for full options. + +forward_proxy: + enabled: true + max_tunnel_seconds: 300 + idle_timeout_seconds: 60 + +websocket_proxy: + enabled: false + max_message_bytes: 1048576 + max_concurrent_connections: 128 + scan_text_frames: true + allow_binary_frames: false + forward_cookies: false + strip_compression: true + max_connection_seconds: 3600 + idle_timeout_seconds: 300 + origin_policy: rewrite + +dlp: + scan_env: true + +response_scanning: + enabled: true + action: warn + +mcp_input_scanning: + enabled: true + action: block + on_parse_error: block + +mcp_tool_scanning: + enabled: true + action: warn + detect_drift: true From ad24c3aba5c8c580d98fc3ef72b3bc1d8da3b4da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Mon, 2 Mar 2026 23:30:46 +0100 Subject: [PATCH 19/75] Update Skylight dashboard link in README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Juan José Mata --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 81d509be7..fe45b8bbb 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ To stay compliant and avoid trademark issues: With data-heavy apps, inevitably, there are performance issues. We've set up a public dashboard showing the problematic requests seen on the demo site, along with the stacktraces to help debug them. -https://www.skylight.io/app/applications/s6PEZSKwcklL/recent/6h/endpoints +[https://www.skylight.io/app/applications/s6PEZSKwcklL/recent/6h/endpoints](https://oss.skylight.io/app/applications/s6PEZSKwcklL/recent/6h/endpoints) Any contributions that help improve performance are very much welcome. From 84bfe5b7ab47aa10f8ad089fc552b9d2d0b36b77 Mon Sep 17 00:00:00 2001 From: LPW Date: Tue, 3 Mar 2026 09:47:51 -0500 Subject: [PATCH 20/75] Add external AI assistant with Pipelock security proxy (#1069) * feat(helm): add Pipelock ConfigMap, scanning config, and consolidate compose - Add ConfigMap template rendering DLP, response scanning, MCP input/tool scanning, and forward proxy settings from values - Mount ConfigMap as /etc/pipelock/pipelock.yaml volume in deployment - Add checksum/config annotation for automatic pod restart on config change - Gate HTTPS_PROXY/HTTP_PROXY env injection on forwardProxy.enabled (skip in MCP-only mode) - Use hasKey for all boolean values to prevent Helm default swallowing false - Single source of truth for ports (forwardProxy.port/mcpProxy.port) - Pipelock-specific imagePullSecrets with fallback to app secrets - Merge standalone compose.example.pipelock.yml into compose.example.ai.yml - Add pipelock.example.yaml for Docker Compose users - Add exclude-paths to CI workflow for locale file false positives * Add external assistant support (OpenAI-compatible SSE proxy) Allow self-hosted instances to delegate chat to an external AI agent via an OpenAI-compatible streaming endpoint. Configurable per-family through Settings UI or ASSISTANT_TYPE env override. - Assistant::External::Client: SSE streaming HTTP client (no new gems) - Settings UI with type selector, env lock indicator, config status - Helm chart and Docker Compose env var support - 45 tests covering client, config, routing, controller, integration * Add session key routing, email allowlist, and config plumbing Route to the actual OpenClaw session via x-openclaw-session-key header instead of creating isolated sessions. Gate external assistant access behind an email allowlist (EXTERNAL_ASSISTANT_ALLOWED_EMAILS env var). Plumb session_key and allowedEmails through Helm chart, compose, and env template. * Add HTTPS_PROXY support to External::Client for Pipelock integration Net::HTTP does not auto-read HTTPS_PROXY/HTTP_PROXY env vars (unlike Faraday). Explicitly resolve proxy from environment in build_http so outbound traffic to the external assistant routes through Pipelock's forward proxy when enabled. Respects NO_PROXY for internal hosts. * Add UI fields for external assistant config (Setting-backed with env fallback) Follow the same pattern as OpenAI settings: database-backed Setting fields with env var defaults. Self-hosters can now configure the external assistant URL, token, and agent ID from the browser (Settings > Self-Hosting > AI Assistant) instead of requiring env vars. Fields disable when the corresponding env var is set. * Improve external assistant UI labels and add help text Change placeholder to generic OpenAI-compatible URL pattern. Add help text under each field explaining where the values come from: URL from agent provider, token for authentication, agent ID for multi-agent routing. * Add external assistant docs and fix URL help text Add External AI Assistant section to docs/hosting/ai.md covering setup (UI and env vars), how it works, Pipelock security scanning, access control, and Docker Compose example. Drop "chat completions" jargon from URL help text. * Harden external assistant: retry logic, disconnect UI, error handling, and test coverage - Add retry with backoff for transient network errors (no retry after streaming starts) - Add disconnect button with confirmation modal in self-hosting settings - Narrow rescue scope with fallback logging for unexpected errors - Safe cleanup of partial responses on stream interruption - Gate ai_available? on family assistant_type instead of OR-ing all providers - Truncate conversation history to last 20 messages - Proxy-aware HTTP client with NO_PROXY support - Sanitize protocol to use generic headers (X-Agent-Id, X-Session-Key) - Full test coverage for streaming, retries, proxy routing, config, and disconnect * Exclude external assistant client from Pipelock scan-diff False positive: `@token` instance variable flagged as "Credential in URL". Temporary workaround until Pipelock supports inline suppression. * Address review feedback: NO_PROXY boundary fix, SSE done flag, design tokens - Fix NO_PROXY matching to require domain boundary (exact match or .suffix), case-insensitive. Prevents badexample.com matching example.com. - Add done flag to SSE streaming so read_body stops after [DONE] - Move MAX_CONVERSATION_MESSAGES to class level - Use bg-success/bg-destructive design tokens for status indicators - Add rationale comment for pipelock scan exclusion - Update docs last-updated date * Address second round of review feedback - Allowlist email comparison is now case-insensitive and nil-safe - Cap SSE buffer at 1 MB to prevent memory blowup from malformed streams - Don't expose upstream HTTP response body in user-facing errors (log it instead) - Fix frozen string warning on buffer initialization - Fix "builtin" typo in docs (should be "built-in") * Protect completed responses from cleanup, sanitize error messages - Don't destroy a fully streamed assistant message if post-stream metadata update fails (only cleanup partial responses) - Log raw connection/HTTP errors internally, show generic messages to users to avoid leaking network/proxy details - Update test assertions for new error message wording * Fix SSE content guard and NO_PROXY test correctness Use nil check instead of present? for SSE delta content to preserve whitespace-only chunks (newlines, spaces) that can occur in code output. Fix NO_PROXY test to use HTTP_PROXY matching the http:// client URL so the proxy resolution and NO_PROXY bypass logic are actually exercised. * Forward proxy credentials to Net::HTTP Pass proxy_uri.user and proxy_uri.password to Net::HTTP.new so authenticated proxies (http://user:pass@host:port) work correctly. Without this, credentials parsed from the proxy URL were silently dropped. Nil values are safe as positional args when no creds exist. * Update pipelock integration to v0.3.1 with full scanning config Bump Helm image tag from 0.2.7 to 0.3.1. Add missing security sections to both the Helm ConfigMap and compose example config: mcp_tool_policy, mcp_session_binding, and tool_chain_detection. These protect the /mcp endpoint against tool injection, session hijacking, and multi-step exfiltration chains. Add version and mode fields to config files. Enable include_defaults for DLP and response scanning to merge user patterns with the 35 built-in patterns. Remove redundant --mode CLI flag from the Helm deployment template since mode is now in the config file. --- .github/workflows/pipelock.yml | 2 + .../settings/hostings_controller.rb | 41 ++- app/models/assistant.rb | 2 +- app/models/assistant/external.rb | 102 ++++++- app/models/assistant/external/client.rb | 175 +++++++++++ app/models/setting.rb | 3 + app/models/user.rb | 11 +- .../hostings/_assistant_settings.html.erb | 112 +++++++ app/views/settings/hostings/show.html.erb | 3 + charts/sure/CHANGELOG.md | 2 +- charts/sure/templates/_env.tpl | 22 ++ charts/sure/templates/pipelock-configmap.yaml | 43 +++ .../sure/templates/pipelock-deployment.yaml | 2 - charts/sure/values.yaml | 32 +- compose.example.ai.yml | 12 +- config/locales/views/settings/hostings/en.yml | 29 ++ config/routes.rb | 1 + docs/hosting/ai.md | 66 +++- pipelock.example.yaml | 19 ++ .../settings/hostings_controller_test.rb | 92 ++++++ test/models/assistant/external/client_test.rb | 283 ++++++++++++++++++ test/models/assistant/external_config_test.rb | 93 ++++++ test/models/assistant_test.rb | 239 ++++++++++++++- test/models/user_test.rb | 39 ++- 24 files changed, 1401 insertions(+), 24 deletions(-) create mode 100644 app/models/assistant/external/client.rb create mode 100644 app/views/settings/hostings/_assistant_settings.html.erb create mode 100644 test/models/assistant/external/client_test.rb create mode 100644 test/models/assistant/external_config_test.rb diff --git a/.github/workflows/pipelock.yml b/.github/workflows/pipelock.yml index 3668c0a49..ad538b51d 100644 --- a/.github/workflows/pipelock.yml +++ b/.github/workflows/pipelock.yml @@ -24,3 +24,5 @@ jobs: test-vectors: 'false' exclude-paths: | config/locales/views/reports/ + # False positive: client.rb stores Bearer token and sends Authorization header by design + app/models/assistant/external/client.rb diff --git a/app/controllers/settings/hostings_controller.rb b/app/controllers/settings/hostings_controller.rb index 22936cfb4..e63a65c71 100644 --- a/app/controllers/settings/hostings_controller.rb +++ b/app/controllers/settings/hostings_controller.rb @@ -3,7 +3,7 @@ class Settings::HostingsController < ApplicationController guard_feature unless: -> { self_hosted? } - before_action :ensure_admin, only: [ :update, :clear_cache ] + before_action :ensure_admin, only: [ :update, :clear_cache, :disconnect_external_assistant ] def show @breadcrumbs = [ @@ -118,6 +118,23 @@ class Settings::HostingsController < ApplicationController Setting.openai_json_mode = hosting_params[:openai_json_mode].presence end + if hosting_params.key?(:external_assistant_url) + Setting.external_assistant_url = hosting_params[:external_assistant_url] + end + + if hosting_params.key?(:external_assistant_token) + token_param = hosting_params[:external_assistant_token].to_s.strip + unless token_param.blank? || token_param == "********" + Setting.external_assistant_token = token_param + end + end + + if hosting_params.key?(:external_assistant_agent_id) + Setting.external_assistant_agent_id = hosting_params[:external_assistant_agent_id] + end + + update_assistant_type + redirect_to settings_hosting_path, notice: t(".success") rescue Setting::ValidationError => error flash.now[:alert] = error.message @@ -129,9 +146,29 @@ class Settings::HostingsController < ApplicationController redirect_to settings_hosting_path, notice: t(".cache_cleared") end + def disconnect_external_assistant + Setting.external_assistant_url = nil + Setting.external_assistant_token = nil + Setting.external_assistant_agent_id = nil + Current.family.update!(assistant_type: "builtin") unless ENV["ASSISTANT_TYPE"].present? + redirect_to settings_hosting_path, notice: t(".external_assistant_disconnected") + rescue => e + Rails.logger.error("[External Assistant] Disconnect failed: #{e.message}") + redirect_to settings_hosting_path, alert: t("settings.hostings.update.failure") + end + private def hosting_params - params.require(:setting).permit(:onboarding_state, :require_email_confirmation, :brand_fetch_client_id, :brand_fetch_high_res_logos, :twelve_data_api_key, :openai_access_token, :openai_uri_base, :openai_model, :openai_json_mode, :exchange_rate_provider, :securities_provider, :syncs_include_pending, :auto_sync_enabled, :auto_sync_time) + return ActionController::Parameters.new unless params.key?(:setting) + params.require(:setting).permit(:onboarding_state, :require_email_confirmation, :brand_fetch_client_id, :brand_fetch_high_res_logos, :twelve_data_api_key, :openai_access_token, :openai_uri_base, :openai_model, :openai_json_mode, :exchange_rate_provider, :securities_provider, :syncs_include_pending, :auto_sync_enabled, :auto_sync_time, :external_assistant_url, :external_assistant_token, :external_assistant_agent_id) + end + + def update_assistant_type + return unless params[:family].present? && params[:family][:assistant_type].present? + return if ENV["ASSISTANT_TYPE"].present? + + assistant_type = params[:family][:assistant_type] + Current.family.update!(assistant_type: assistant_type) if Family::ASSISTANT_TYPES.include?(assistant_type) end def ensure_admin diff --git a/app/models/assistant.rb b/app/models/assistant.rb index 582af9b0b..b07009396 100644 --- a/app/models/assistant.rb +++ b/app/models/assistant.rb @@ -36,7 +36,7 @@ module Assistant def implementation_for(chat) raise Error, "chat is required" if chat.blank? - type = chat.user&.family&.assistant_type.presence || "builtin" + type = ENV["ASSISTANT_TYPE"].presence || chat.user&.family&.assistant_type.presence || "builtin" REGISTRY.fetch(type) { REGISTRY["builtin"] } end end diff --git a/app/models/assistant/external.rb b/app/models/assistant/external.rb index 276595dad..530d25503 100644 --- a/app/models/assistant/external.rb +++ b/app/models/assistant/external.rb @@ -1,14 +1,110 @@ class Assistant::External < Assistant::Base + Config = Struct.new(:url, :token, :agent_id, :session_key, keyword_init: true) + MAX_CONVERSATION_MESSAGES = 20 + class << self def for_chat(chat) new(chat) end + + def configured? + config.url.present? && config.token.present? + end + + def available_for?(user) + configured? && allowed_user?(user) + end + + def allowed_user?(user) + allowed = ENV["EXTERNAL_ASSISTANT_ALLOWED_EMAILS"] + return true if allowed.blank? + return false if user&.email.blank? + + allowed.split(",").map { |e| e.strip.downcase }.include?(user.email.downcase) + end + + def config + Config.new( + url: ENV["EXTERNAL_ASSISTANT_URL"].presence || Setting.external_assistant_url, + token: ENV["EXTERNAL_ASSISTANT_TOKEN"].presence || Setting.external_assistant_token, + agent_id: ENV["EXTERNAL_ASSISTANT_AGENT_ID"].presence || Setting.external_assistant_agent_id.presence || "main", + session_key: ENV.fetch("EXTERNAL_ASSISTANT_SESSION_KEY", "agent:main:main") + ) + end end def respond_to(message) - stop_thinking - chat.add_error( - StandardError.new("External assistant (OpenClaw/WebSocket) is not yet implemented.") + response_completed = false + + unless self.class.configured? + raise Assistant::Error, + "External assistant is not configured. Set the URL and token in Settings > Self-Hosting or via environment variables." + end + + unless self.class.allowed_user?(chat.user) + raise Assistant::Error, "Your account is not authorized to use the external assistant." + end + + assistant_message = AssistantMessage.new( + chat: chat, + content: "", + ai_model: "external-agent" ) + + client = build_client + messages = build_conversation_messages + + model = client.chat( + messages: messages, + user: "sure-family-#{chat.user.family_id}" + ) do |text| + if assistant_message.content.blank? + stop_thinking + assistant_message.content = text + assistant_message.save! + else + assistant_message.append_text!(text) + end + end + + if assistant_message.new_record? + stop_thinking + raise Assistant::Error, "External assistant returned an empty response." + end + + response_completed = true + assistant_message.update!(ai_model: model) if model.present? + rescue Assistant::Error, ActiveRecord::ActiveRecordError => e + cleanup_partial_response(assistant_message) unless response_completed + stop_thinking + chat.add_error(e) + rescue => e + Rails.logger.error("[Assistant::External] Unexpected error: #{e.class} - #{e.message}") + cleanup_partial_response(assistant_message) unless response_completed + stop_thinking + chat.add_error(Assistant::Error.new("Something went wrong with the external assistant. Check server logs for details.")) end + + private + + def cleanup_partial_response(assistant_message) + assistant_message&.destroy! if assistant_message&.persisted? + rescue ActiveRecord::ActiveRecordError => e + Rails.logger.warn("[Assistant::External] Failed to clean up partial response: #{e.message}") + end + + def build_client + Assistant::External::Client.new( + url: self.class.config.url, + token: self.class.config.token, + agent_id: self.class.config.agent_id, + session_key: self.class.config.session_key + ) + end + + def build_conversation_messages + chat.conversation_messages.ordered.last(MAX_CONVERSATION_MESSAGES).map do |msg| + { role: msg.role, content: msg.content } + end + end end diff --git a/app/models/assistant/external/client.rb b/app/models/assistant/external/client.rb new file mode 100644 index 000000000..c6d680e8e --- /dev/null +++ b/app/models/assistant/external/client.rb @@ -0,0 +1,175 @@ +require "net/http" +require "uri" +require "json" + +class Assistant::External::Client + TIMEOUT_CONNECT = 10 # seconds + TIMEOUT_READ = 120 # seconds (agent may take time to reason + call tools) + MAX_RETRIES = 2 + RETRY_DELAY = 1 # seconds (doubles each retry) + MAX_SSE_BUFFER = 1_048_576 # 1 MB safety cap on SSE buffer + + TRANSIENT_ERRORS = [ + Net::OpenTimeout, + Net::ReadTimeout, + Errno::ECONNREFUSED, + Errno::ECONNRESET, + Errno::EHOSTUNREACH, + SocketError + ].freeze + + def initialize(url:, token:, agent_id: "main", session_key: "agent:main:main") + @url = url + @token = token + @agent_id = agent_id + @session_key = session_key + end + + # Streams text chunks from an OpenAI-compatible chat endpoint via SSE. + # + # messages - Array of {role:, content:} hashes (conversation history) + # user - Optional user identifier for session persistence + # block - Called with each text chunk as it arrives + # + # Returns the model identifier string from the response. + def chat(messages:, user: nil, &block) + uri = URI(@url) + request = build_request(uri, messages, user) + retries = 0 + streaming_started = false + + begin + http = build_http(uri) + model = stream_response(http, request) do |content| + streaming_started = true + block.call(content) + end + model + rescue *TRANSIENT_ERRORS => e + if streaming_started + Rails.logger.warn("[External::Client] Stream interrupted: #{e.class} - #{e.message}") + raise Assistant::Error, "External assistant connection was interrupted." + end + + retries += 1 + if retries <= MAX_RETRIES + Rails.logger.warn("[External::Client] Transient error (attempt #{retries}/#{MAX_RETRIES}): #{e.class} - #{e.message}") + sleep(RETRY_DELAY * retries) + retry + end + Rails.logger.error("[External::Client] Unreachable after #{MAX_RETRIES + 1} attempts: #{e.class} - #{e.message}") + raise Assistant::Error, "External assistant is temporarily unavailable." + end + end + + private + + def stream_response(http, request, &block) + model = nil + buffer = +"" + done = false + + http.request(request) do |response| + unless response.is_a?(Net::HTTPSuccess) + Rails.logger.warn("[External::Client] Upstream HTTP #{response.code}: #{response.body.to_s.truncate(500)}") + raise Assistant::Error, "External assistant returned HTTP #{response.code}." + end + + response.read_body do |chunk| + break if done + buffer << chunk + + if buffer.bytesize > MAX_SSE_BUFFER + raise Assistant::Error, "External assistant stream exceeded maximum buffer size." + end + + while (line_end = buffer.index("\n")) + line = buffer.slice!(0..line_end).strip + next if line.empty? + next unless line.start_with?("data:") + + data = line.delete_prefix("data:") + data = data.delete_prefix(" ") # SSE spec: strip one optional leading space + + if data == "[DONE]" + done = true + break + end + + parsed = parse_sse_data(data) + next unless parsed + + model ||= parsed["model"] + content = parsed.dig("choices", 0, "delta", "content") + block.call(content) unless content.nil? + end + end + end + + model + end + + def build_http(uri) + proxy_uri = resolve_proxy(uri) + + if proxy_uri + http = Net::HTTP.new(uri.host, uri.port, proxy_uri.host, proxy_uri.port, proxy_uri.user, proxy_uri.password) + else + http = Net::HTTP.new(uri.host, uri.port) + end + + http.use_ssl = (uri.scheme == "https") + http.open_timeout = TIMEOUT_CONNECT + http.read_timeout = TIMEOUT_READ + http + end + + def resolve_proxy(uri) + proxy_env = (uri.scheme == "https") ? "HTTPS_PROXY" : "HTTP_PROXY" + proxy_url = ENV[proxy_env] || ENV[proxy_env.downcase] + return nil if proxy_url.blank? + + no_proxy = ENV["NO_PROXY"] || ENV["no_proxy"] + return nil if host_bypasses_proxy?(uri.host, no_proxy) + + URI(proxy_url) + rescue URI::InvalidURIError => e + Rails.logger.warn("[External::Client] Invalid proxy URL ignored: #{e.message}") + nil + end + + def host_bypasses_proxy?(host, no_proxy) + return false if no_proxy.blank? + host_down = host.downcase + no_proxy.split(",").any? do |pattern| + pattern = pattern.strip.downcase.delete_prefix(".") + host_down == pattern || host_down.end_with?(".#{pattern}") + end + end + + def build_request(uri, messages, user) + request = Net::HTTP::Post.new(uri.request_uri) + request["Content-Type"] = "application/json" + request["Authorization"] = "Bearer #{@token}" + request["Accept"] = "text/event-stream" + request["X-Agent-Id"] = @agent_id + request["X-Session-Key"] = @session_key + + payload = { + model: @agent_id, + messages: messages, + stream: true + } + payload[:user] = user if user.present? + + request.body = payload.to_json + request + end + + def parse_sse_data(data) + JSON.parse(data) + rescue JSON::ParserError => e + Rails.logger.warn("[External::Client] Unparseable SSE data: #{e.message}") + nil + end +end diff --git a/app/models/setting.rb b/app/models/setting.rb index 9a9facfb8..376dedc27 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -10,6 +10,9 @@ class Setting < RailsSettings::Base field :openai_uri_base, type: :string, default: ENV["OPENAI_URI_BASE"] field :openai_model, type: :string, default: ENV["OPENAI_MODEL"] field :openai_json_mode, type: :string, default: ENV["LLM_JSON_MODE"] + field :external_assistant_url, type: :string, default: ENV["EXTERNAL_ASSISTANT_URL"] + field :external_assistant_token, type: :string, default: ENV["EXTERNAL_ASSISTANT_TOKEN"] + field :external_assistant_agent_id, type: :string, default: ENV.fetch("EXTERNAL_ASSISTANT_AGENT_ID", "main") field :brand_fetch_client_id, type: :string, default: ENV["BRAND_FETCH_CLIENT_ID"] field :brand_fetch_high_res_logos, type: :boolean, default: ENV.fetch("BRAND_FETCH_HIGH_RES_LOGOS", "false") == "true" diff --git a/app/models/user.rb b/app/models/user.rb index 5aef7afeb..24ce74a0b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -136,7 +136,16 @@ class User < ApplicationRecord end def ai_available? - !Rails.application.config.app_mode.self_hosted? || ENV["OPENAI_ACCESS_TOKEN"].present? || Setting.openai_access_token.present? + return true unless Rails.application.config.app_mode.self_hosted? + + effective_type = ENV["ASSISTANT_TYPE"].presence || family&.assistant_type.presence || "builtin" + + case effective_type + when "external" + Assistant::External.available_for?(self) + else + ENV["OPENAI_ACCESS_TOKEN"].present? || Setting.openai_access_token.present? + end end def ai_enabled? diff --git a/app/views/settings/hostings/_assistant_settings.html.erb b/app/views/settings/hostings/_assistant_settings.html.erb new file mode 100644 index 000000000..5522706ad --- /dev/null +++ b/app/views/settings/hostings/_assistant_settings.html.erb @@ -0,0 +1,112 @@ +
    +
    +

    <%= t(".title") %>

    + <% if ENV["ASSISTANT_TYPE"].present? %> +

    <%= t(".env_notice", type: ENV["ASSISTANT_TYPE"]) %>

    + <% else %> +

    <%= t(".description") %>

    + <% end %> +
    + + <% effective_type = ENV["ASSISTANT_TYPE"].presence || Current.family.assistant_type %> + + <%= styled_form_with model: Current.family, + url: settings_hosting_path, + method: :patch, + class: "space-y-4", + data: { + controller: "auto-submit-form", + "auto-submit-form-trigger-event-value": "change" + } do |form| %> + <%= form.select :assistant_type, + options_for_select( + [ + [t(".type_builtin"), "builtin"], + [t(".type_external"), "external"] + ], + effective_type + ), + { label: t(".type_label") }, + { disabled: ENV["ASSISTANT_TYPE"].present?, + data: { "auto-submit-form-target": "auto" } } %> + <% end %> + <% if effective_type == "external" %> +
    + <% if Assistant::External.configured? %> + + <%= t(".external_configured") %> + <% else %> + + <%= t(".external_not_configured") %> + <% end %> +
    + + <% if ENV["EXTERNAL_ASSISTANT_URL"].present? && ENV["EXTERNAL_ASSISTANT_TOKEN"].present? %> +

    <%= t(".env_configured_external") %>

    + <% end %> + + <% if Assistant::External.configured? && !ENV["EXTERNAL_ASSISTANT_URL"].present? %> +
    +
    +

    <%= t(".disconnect_title") %>

    +

    <%= t(".disconnect_description") %>

    +
    + <%= button_to t(".disconnect_button"), + disconnect_external_assistant_settings_hosting_path, + method: :delete, + class: "bg-red-600 fg-inverse text-sm font-medium rounded-lg px-4 py-2 whitespace-nowrap", + data: { turbo_confirm: { + title: t(".confirm_disconnect.title"), + body: t(".confirm_disconnect.body"), + accept: t(".disconnect_button"), + acceptClass: "w-full bg-red-600 fg-inverse rounded-xl text-center p-[10px] border mb-2" + }} %> +
    + <% end %> + + <%= styled_form_with model: Setting.new, + url: settings_hosting_path, + method: :patch, + class: "space-y-4", + data: { + controller: "auto-submit-form", + "auto-submit-form-trigger-event-value": "blur" + } do |form| %> + <%= form.text_field :external_assistant_url, + label: t(".url_label"), + placeholder: t(".url_placeholder"), + value: Setting.external_assistant_url, + autocomplete: "off", + autocapitalize: "none", + spellcheck: "false", + inputmode: "url", + disabled: ENV["EXTERNAL_ASSISTANT_URL"].present?, + data: { "auto-submit-form-target": "auto" } %> +

    <%= t(".url_help") %>

    + + <%= form.password_field :external_assistant_token, + label: t(".token_label"), + placeholder: t(".token_placeholder"), + value: (Setting.external_assistant_token.present? ? "********" : nil), + autocomplete: "off", + autocapitalize: "none", + spellcheck: "false", + inputmode: "text", + disabled: ENV["EXTERNAL_ASSISTANT_TOKEN"].present?, + data: { "auto-submit-form-target": "auto" } %> +

    <%= t(".token_help") %>

    + + <%= form.text_field :external_assistant_agent_id, + label: t(".agent_id_label"), + placeholder: t(".agent_id_placeholder"), + value: Setting.external_assistant_agent_id, + autocomplete: "off", + autocapitalize: "none", + spellcheck: "false", + inputmode: "text", + disabled: ENV["EXTERNAL_ASSISTANT_AGENT_ID"].present?, + data: { "auto-submit-form-target": "auto" } %> +

    <%= t(".agent_id_help") %>

    + <% end %> + <% end %> +
    diff --git a/app/views/settings/hostings/show.html.erb b/app/views/settings/hostings/show.html.erb index 00b60c823..adb78ec51 100644 --- a/app/views/settings/hostings/show.html.erb +++ b/app/views/settings/hostings/show.html.erb @@ -1,4 +1,7 @@ <%= content_for :page_title, t(".title") %> +<%= settings_section title: t(".ai_assistant") do %> + <%= render "settings/hostings/assistant_settings" %> +<% end %> <%= settings_section title: t(".general") do %>
    <%= render "settings/hostings/openai_settings" %> diff --git a/charts/sure/CHANGELOG.md b/charts/sure/CHANGELOG.md index b2d44fe72..7aa613010 100644 --- a/charts/sure/CHANGELOG.md +++ b/charts/sure/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Pipelock security proxy** (`pipelock.enabled=true`): Separate Deployment + Service that provides two scanning layers - **Forward proxy** (port 8888): Scans outbound HTTPS from Faraday-based clients (e.g. ruby-openai). Auto-injects `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` env vars into app pods - **MCP reverse proxy** (port 8889): Scans inbound MCP traffic for DLP, prompt injection, and tool poisoning. Auto-computes upstream URL via `sure.pipelockUpstream` helper - - **WebSocket proxy** configuration support (disabled by default, requires Pipelock >= 0.2.9) + - **WebSocket proxy** configuration support (disabled by default) - ConfigMap with scanning config (DLP, prompt injection detection, MCP input/tool scanning, response scanning) - ConfigMap checksum annotation for automatic pod restart on config changes - Helm helpers: `sure.pipelockImage`, `sure.pipelockUpstream` diff --git a/charts/sure/templates/_env.tpl b/charts/sure/templates/_env.tpl index a0b230ac1..e50ab7ce8 100644 --- a/charts/sure/templates/_env.tpl +++ b/charts/sure/templates/_env.tpl @@ -94,6 +94,28 @@ The helper always injects: - name: {{ $k }} value: {{ $v | quote }} {{- end }} +{{- if $ctx.Values.rails.externalAssistant.enabled }} +- name: EXTERNAL_ASSISTANT_URL + value: {{ $ctx.Values.rails.externalAssistant.url | quote }} +{{- if $ctx.Values.rails.externalAssistant.tokenSecretRef }} +- name: EXTERNAL_ASSISTANT_TOKEN + valueFrom: + secretKeyRef: + name: {{ $ctx.Values.rails.externalAssistant.tokenSecretRef.name }} + key: {{ $ctx.Values.rails.externalAssistant.tokenSecretRef.key }} +{{- else }} +- name: EXTERNAL_ASSISTANT_TOKEN + value: {{ $ctx.Values.rails.externalAssistant.token | quote }} +{{- end }} +- name: EXTERNAL_ASSISTANT_AGENT_ID + value: {{ $ctx.Values.rails.externalAssistant.agentId | quote }} +- name: EXTERNAL_ASSISTANT_SESSION_KEY + value: {{ $ctx.Values.rails.externalAssistant.sessionKey | quote }} +{{- if $ctx.Values.rails.externalAssistant.allowedEmails }} +- name: EXTERNAL_ASSISTANT_ALLOWED_EMAILS + value: {{ $ctx.Values.rails.externalAssistant.allowedEmails | quote }} +{{- end }} +{{- end }} {{- range $k, $v := $ctx.Values.rails.extraEnv }} - name: {{ $k }} value: {{ $v | quote }} diff --git a/charts/sure/templates/pipelock-configmap.yaml b/charts/sure/templates/pipelock-configmap.yaml index f840961e2..7b39a6726 100644 --- a/charts/sure/templates/pipelock-configmap.yaml +++ b/charts/sure/templates/pipelock-configmap.yaml @@ -36,6 +36,34 @@ {{- $wsMaxConnSec = int (.Values.pipelock.websocketProxy.maxConnectionSeconds | default 3600) -}} {{- $wsIdleTimeout = int (.Values.pipelock.websocketProxy.idleTimeoutSeconds | default 300) -}} {{- $wsOriginPolicy = .Values.pipelock.websocketProxy.originPolicy | default "rewrite" -}} +{{- end -}} +{{- $mcpPolicyEnabled := true -}} +{{- $mcpPolicyAction := "warn" -}} +{{- if .Values.pipelock.mcpToolPolicy -}} +{{- if hasKey .Values.pipelock.mcpToolPolicy "enabled" -}} +{{- $mcpPolicyEnabled = .Values.pipelock.mcpToolPolicy.enabled -}} +{{- end -}} +{{- $mcpPolicyAction = .Values.pipelock.mcpToolPolicy.action | default "warn" -}} +{{- end -}} +{{- $mcpBindingEnabled := true -}} +{{- $mcpBindingAction := "warn" -}} +{{- if .Values.pipelock.mcpSessionBinding -}} +{{- if hasKey .Values.pipelock.mcpSessionBinding "enabled" -}} +{{- $mcpBindingEnabled = .Values.pipelock.mcpSessionBinding.enabled -}} +{{- end -}} +{{- $mcpBindingAction = .Values.pipelock.mcpSessionBinding.unknownToolAction | default "warn" -}} +{{- end -}} +{{- $chainEnabled := true -}} +{{- $chainAction := "warn" -}} +{{- $chainWindow := 20 -}} +{{- $chainGap := 3 -}} +{{- if .Values.pipelock.toolChainDetection -}} +{{- if hasKey .Values.pipelock.toolChainDetection "enabled" -}} +{{- $chainEnabled = .Values.pipelock.toolChainDetection.enabled -}} +{{- end -}} +{{- $chainAction = .Values.pipelock.toolChainDetection.action | default "warn" -}} +{{- $chainWindow = int (.Values.pipelock.toolChainDetection.windowSize | default 20) -}} +{{- $chainGap = int (.Values.pipelock.toolChainDetection.maxGap | default 3) -}} {{- end }} apiVersion: v1 kind: ConfigMap @@ -45,6 +73,8 @@ metadata: {{- include "sure.labels" . | nindent 4 }} data: pipelock.yaml: | + version: 1 + mode: {{ .Values.pipelock.mode | default "balanced" }} forward_proxy: enabled: {{ $fwdEnabled }} max_tunnel_seconds: {{ $fwdMaxTunnel }} @@ -62,9 +92,11 @@ data: origin_policy: {{ $wsOriginPolicy }} dlp: scan_env: true + include_defaults: true response_scanning: enabled: true action: warn + include_defaults: true mcp_input_scanning: enabled: true action: block @@ -73,4 +105,15 @@ data: enabled: true action: warn detect_drift: true + mcp_tool_policy: + enabled: {{ $mcpPolicyEnabled }} + action: {{ $mcpPolicyAction }} + mcp_session_binding: + enabled: {{ $mcpBindingEnabled }} + unknown_tool_action: {{ $mcpBindingAction }} + tool_chain_detection: + enabled: {{ $chainEnabled }} + action: {{ $chainAction }} + window_size: {{ $chainWindow }} + max_gap: {{ $chainGap }} {{- end }} diff --git a/charts/sure/templates/pipelock-deployment.yaml b/charts/sure/templates/pipelock-deployment.yaml index f35db3e49..99732fb0c 100644 --- a/charts/sure/templates/pipelock-deployment.yaml +++ b/charts/sure/templates/pipelock-deployment.yaml @@ -55,8 +55,6 @@ spec: - "/etc/pipelock/pipelock.yaml" - "--listen" - "0.0.0.0:{{ $fwdPort }}" - - "--mode" - - {{ .Values.pipelock.mode | default "balanced" | quote }} - "--mcp-listen" - "0.0.0.0:{{ $mcpPort }}" - "--mcp-upstream" diff --git a/charts/sure/values.yaml b/charts/sure/values.yaml index 349e88a23..ae3b34b3d 100644 --- a/charts/sure/values.yaml +++ b/charts/sure/values.yaml @@ -54,6 +54,20 @@ rails: ONBOARDING_STATE: "open" AI_DEBUG_MODE: "false" + # External AI Assistant (optional) + # Delegates chat to a remote AI agent that calls back via MCP. + externalAssistant: + enabled: false + url: "" # e.g., https://your-agent-host/v1/chat + token: "" # Bearer token for the external AI gateway + agentId: "main" # Agent routing identifier + sessionKey: "agent:main:main" # Session key for persistent agent sessions + allowedEmails: "" # Comma-separated emails allowed to use external assistant (empty = all) + # For production, use a Secret reference instead of plaintext: + # tokenSecretRef: + # name: external-assistant-secret + # key: token + # Database: CloudNativePG (operator chart dependency) and a Cluster CR (optional) cloudnative-pg: config: @@ -474,7 +488,7 @@ pipelock: enabled: false image: repository: ghcr.io/luckypipewrench/pipelock - tag: "0.2.7" + tag: "0.3.1" pullPolicy: IfNotPresent imagePullSecrets: [] replicas: 1 @@ -491,9 +505,7 @@ pipelock: upstream: "" # WebSocket proxy: bidirectional frame scanning for ws/wss connections. # Runs on the same listener as the forward proxy at /ws?url=. - # Requires Pipelock >= 0.2.9 (or current dev build). websocketProxy: - # Requires image.tag >= 0.2.9. Update pipelock.image.tag before enabling. enabled: false maxMessageBytes: 1048576 # 1MB per message maxConcurrentConnections: 128 @@ -503,6 +515,20 @@ pipelock: maxConnectionSeconds: 3600 # 1 hour max connection lifetime idleTimeoutSeconds: 300 # 5 min idle timeout originPolicy: rewrite # rewrite, forward, or strip + # MCP tool policy: pre-execution rules for tool calls (shell obfuscation, etc.) + mcpToolPolicy: + enabled: true + action: warn + # MCP session binding: pins tool inventory on first tools/list, detects injection + mcpSessionBinding: + enabled: true + unknownToolAction: warn + # Tool call chain detection: detects multi-step attack patterns (recon, exfil, etc.) + toolChainDetection: + enabled: true + action: warn + windowSize: 20 + maxGap: 3 service: type: ClusterIP resources: diff --git a/compose.example.ai.yml b/compose.example.ai.yml index fb85a354a..7532e27a8 100644 --- a/compose.example.ai.yml +++ b/compose.example.ai.yml @@ -71,6 +71,16 @@ x-rails-env: &rails_env OPENAI_URI_BASE: http://ollama:11434/v1 # NOTE: enabling OpenAI will incur costs when you use AI-related features in the app (chat, rules). Make sure you have set appropriate spend limits on your account before adding this. # OPENAI_ACCESS_TOKEN: ${OPENAI_ACCESS_TOKEN} + # External AI Assistant — delegates chat to a remote AI agent (e.g., OpenClaw). + # The agent calls back to Sure's /mcp endpoint for financial data. + # Set EXTERNAL_ASSISTANT_URL + TOKEN to activate, then either set ASSISTANT_TYPE=external + # here (forces all families) or choose "External" in Settings > Self-Hosting > AI Assistant. + ASSISTANT_TYPE: ${ASSISTANT_TYPE:-} + EXTERNAL_ASSISTANT_URL: ${EXTERNAL_ASSISTANT_URL:-} + EXTERNAL_ASSISTANT_TOKEN: ${EXTERNAL_ASSISTANT_TOKEN:-} + EXTERNAL_ASSISTANT_AGENT_ID: ${EXTERNAL_ASSISTANT_AGENT_ID:-main} + EXTERNAL_ASSISTANT_SESSION_KEY: ${EXTERNAL_ASSISTANT_SESSION_KEY:-agent:main:main} + EXTERNAL_ASSISTANT_ALLOWED_EMAILS: ${EXTERNAL_ASSISTANT_ALLOWED_EMAILS:-} services: pipelock: @@ -86,8 +96,6 @@ services: - "/etc/pipelock/pipelock.yaml" - "--listen" - "0.0.0.0:8888" - - "--mode" - - "balanced" - "--mcp-listen" - "0.0.0.0:8889" - "--mcp-upstream" diff --git a/config/locales/views/settings/hostings/en.yml b/config/locales/views/settings/hostings/en.yml index 8f3fcec32..cfe44a8ad 100644 --- a/config/locales/views/settings/hostings/en.yml +++ b/config/locales/views/settings/hostings/en.yml @@ -16,6 +16,7 @@ en: invite_only: Invite-only show: general: General Settings + ai_assistant: AI Assistant financial_data_providers: Financial Data Providers sync_settings: Sync Settings invites: Invite Codes @@ -35,6 +36,32 @@ en: providers: twelve_data: Twelve Data yahoo_finance: Yahoo Finance + assistant_settings: + title: AI Assistant + description: Choose how the chat assistant responds. Builtin uses your configured LLM provider directly. External delegates to a remote AI agent that can call back to Sure's financial tools via MCP. + type_label: Assistant type + type_builtin: Builtin (direct LLM) + type_external: External (remote agent) + external_status: External assistant endpoint + external_configured: Configured + external_not_configured: Not configured. Enter the URL and token below, or set EXTERNAL_ASSISTANT_URL and EXTERNAL_ASSISTANT_TOKEN environment variables. + env_notice: "Assistant type is locked to '%{type}' via ASSISTANT_TYPE environment variable." + env_configured_external: Successfully configured through environment variables. + url_label: Endpoint URL + url_placeholder: "https://your-agent-host/v1/chat" + url_help: The full URL to your agent's API endpoint. Your agent provider will give you this. + token_label: API Token + token_placeholder: Enter the token from your agent provider + token_help: The authentication token provided by your external agent. This is sent as a Bearer token with each request. + agent_id_label: Agent ID (Optional) + agent_id_placeholder: "main (default)" + agent_id_help: Routes to a specific agent when the provider hosts multiple. Leave blank for the default. + disconnect_title: External connection + disconnect_description: Remove the external assistant connection and switch back to the builtin assistant. + disconnect_button: Disconnect + confirm_disconnect: + title: Disconnect external assistant? + body: This will remove the saved URL, token, and agent ID, and switch to the builtin assistant. You can reconnect later by entering new credentials. brand_fetch_settings: description: Enter the Client ID provided by Brand Fetch label: Client ID @@ -83,6 +110,8 @@ en: invalid_onboarding_state: Invalid onboarding state invalid_sync_time: Invalid sync time format. Please use HH:MM format (e.g., 02:30). scheduler_sync_failed: Settings saved, but failed to update the sync schedule. Please try again or check the server logs. + disconnect_external_assistant: + external_assistant_disconnected: External assistant disconnected clear_cache: cache_cleared: Data cache has been cleared. This may take a few moments to complete. not_authorized: You are not authorized to perform this action diff --git a/config/routes.rb b/config/routes.rb index 90e2c8f9a..1e5097fd2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -167,6 +167,7 @@ Rails.application.routes.draw do resource :preferences, only: :show resource :hosting, only: %i[show update] do delete :clear_cache, on: :collection + delete :disconnect_external_assistant, on: :collection end resource :payment, only: :show resource :security, only: :show diff --git a/docs/hosting/ai.md b/docs/hosting/ai.md index 0e6d56d1f..a1f8829c6 100644 --- a/docs/hosting/ai.md +++ b/docs/hosting/ai.md @@ -290,6 +290,70 @@ For self-hosted deployments, you can configure AI settings through the web inter **Note:** Settings in the UI override environment variables. If you change settings in the UI, those values take precedence. +## External AI Assistant + +Instead of using the built-in LLM (which calls OpenAI or a local model directly), you can delegate chat to an **external AI agent**. The agent receives the conversation, can call back to Sure's financial data via MCP, and streams a response. + +This is useful when: +- You have a custom AI agent with domain knowledge, memory, or personality +- You want to use a non-OpenAI-compatible model (the agent translates) +- You want to keep LLM credentials and logic outside Sure entirely + +### How It Works + +1. Sure sends the chat conversation to your agent's API endpoint +2. Your agent processes it (using whatever LLM, tools, or context it needs) +3. Your agent can call Sure's `/mcp` endpoint for financial data (accounts, transactions, balance sheet) +4. Your agent streams the response back to Sure via Server-Sent Events (SSE) + +The agent's API must be **OpenAI chat completions compatible** — accept `POST` with `messages` array, return SSE with `delta.content` chunks. + +### Configuration + +Configure via the UI or environment variables: + +**Settings UI:** +1. Go to **Settings** → **Self-Hosting** +2. Set **Assistant type** to "External (remote agent)" +3. Enter the **Endpoint URL** and **API Token** from your agent provider +4. Optionally set an **Agent ID** if the provider hosts multiple agents + +**Environment variables:** +```bash +ASSISTANT_TYPE=external # Force all families to use external +EXTERNAL_ASSISTANT_URL=https://your-agent/v1/chat/completions +EXTERNAL_ASSISTANT_TOKEN=your-api-token +EXTERNAL_ASSISTANT_AGENT_ID=main # Optional, defaults to "main" +EXTERNAL_ASSISTANT_SESSION_KEY=agent:main:main # Optional, for session persistence +EXTERNAL_ASSISTANT_ALLOWED_EMAILS=user@example.com # Optional, comma-separated allowlist +``` + +When environment variables are set, the corresponding UI fields are disabled (env takes precedence). + +### Security with Pipelock + +When [Pipelock](https://github.com/luckyPipewrench/pipelock) is enabled (`pipelock.enabled=true` in Helm, or the `pipelock` service in Docker Compose), all traffic between Sure and the external agent is scanned: + +- **Outbound** (Sure → agent): routed through Pipelock's forward proxy via `HTTPS_PROXY` +- **Inbound** (agent → Sure /mcp): routed through Pipelock's MCP reverse proxy (port 8889) + +Pipelock scans for prompt injection, DLP violations, and tool poisoning. The external agent does not need Pipelock installed — Sure's Pipelock handles both directions. + +### Access Control + +Use `EXTERNAL_ASSISTANT_ALLOWED_EMAILS` to restrict which users can use the external assistant. When set, only users whose email matches the comma-separated list will see the AI chat. When blank, all users can access it. + +### Docker Compose Example + +```yaml +x-rails-env: &rails_env + ASSISTANT_TYPE: external + EXTERNAL_ASSISTANT_URL: https://your-agent/v1/chat/completions + EXTERNAL_ASSISTANT_TOKEN: your-api-token +``` + +Or configure via the Settings UI after startup (no env vars needed). + ## AI Cache Management Sure caches AI-generated results (like auto-categorization and merchant detection) to avoid redundant API calls and costs. However, there are situations where you may want to clear this cache. @@ -777,4 +841,4 @@ For issues with AI features: --- -**Last Updated:** October 2025 +**Last Updated:** March 2026 diff --git a/pipelock.example.yaml b/pipelock.example.yaml index d53f11a13..2a8f4acdb 100644 --- a/pipelock.example.yaml +++ b/pipelock.example.yaml @@ -1,6 +1,9 @@ # Pipelock configuration for Docker Compose # See https://github.com/luckyPipewrench/pipelock for full options. +version: 1 +mode: balanced + forward_proxy: enabled: true max_tunnel_seconds: 300 @@ -20,10 +23,12 @@ websocket_proxy: dlp: scan_env: true + include_defaults: true response_scanning: enabled: true action: warn + include_defaults: true mcp_input_scanning: enabled: true @@ -34,3 +39,17 @@ mcp_tool_scanning: enabled: true action: warn detect_drift: true + +mcp_tool_policy: + enabled: true + action: warn + +mcp_session_binding: + enabled: true + unknown_tool_action: warn + +tool_chain_detection: + enabled: true + action: warn + window_size: 20 + max_gap: 3 diff --git a/test/controllers/settings/hostings_controller_test.rb b/test/controllers/settings/hostings_controller_test.rb index 5b1edb7cf..bd02b321a 100644 --- a/test/controllers/settings/hostings_controller_test.rb +++ b/test/controllers/settings/hostings_controller_test.rb @@ -136,6 +136,98 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest assert_not Balance.exists?(account_balance.id) end + test "can update assistant type to external" do + with_self_hosting do + assert_equal "builtin", users(:family_admin).family.assistant_type + + patch settings_hosting_url, params: { family: { assistant_type: "external" } } + + assert_redirected_to settings_hosting_url + assert_equal "external", users(:family_admin).family.reload.assistant_type + end + end + + test "ignores invalid assistant type values" do + with_self_hosting do + patch settings_hosting_url, params: { family: { assistant_type: "hacked" } } + + assert_redirected_to settings_hosting_url + assert_equal "builtin", users(:family_admin).family.reload.assistant_type + end + end + + test "ignores assistant type update when ASSISTANT_TYPE env is set" do + with_self_hosting do + with_env_overrides("ASSISTANT_TYPE" => "external") do + patch settings_hosting_url, params: { family: { assistant_type: "external" } } + + assert_redirected_to settings_hosting_url + # DB value should NOT change when env override is active + assert_equal "builtin", users(:family_admin).family.reload.assistant_type + end + end + end + + test "can update external assistant settings" do + with_self_hosting do + patch settings_hosting_url, params: { setting: { + external_assistant_url: "https://agent.example.com/v1/chat", + external_assistant_token: "my-secret-token", + external_assistant_agent_id: "finance-bot" + } } + + assert_redirected_to settings_hosting_url + assert_equal "https://agent.example.com/v1/chat", Setting.external_assistant_url + assert_equal "my-secret-token", Setting.external_assistant_token + assert_equal "finance-bot", Setting.external_assistant_agent_id + end + ensure + Setting.external_assistant_url = nil + Setting.external_assistant_token = nil + Setting.external_assistant_agent_id = nil + end + + test "does not overwrite token with masked placeholder" do + with_self_hosting do + Setting.external_assistant_token = "real-secret" + + patch settings_hosting_url, params: { setting: { external_assistant_token: "********" } } + + assert_equal "real-secret", Setting.external_assistant_token + end + ensure + Setting.external_assistant_token = nil + end + + test "disconnect external assistant clears settings and resets type" do + with_self_hosting do + Setting.external_assistant_url = "https://agent.example.com/v1/chat" + Setting.external_assistant_token = "token" + Setting.external_assistant_agent_id = "finance-bot" + users(:family_admin).family.update!(assistant_type: "external") + + delete disconnect_external_assistant_settings_hosting_url + + assert_redirected_to settings_hosting_url + assert_not Assistant::External.configured? + assert_equal "builtin", users(:family_admin).family.reload.assistant_type + end + ensure + Setting.external_assistant_url = nil + Setting.external_assistant_token = nil + Setting.external_assistant_agent_id = nil + end + + test "disconnect external assistant requires admin" do + with_self_hosting do + sign_in users(:family_member) + delete disconnect_external_assistant_settings_hosting_url + + assert_redirected_to settings_hosting_url + assert_equal I18n.t("settings.hostings.not_authorized"), flash[:alert] + end + end + test "can clear data only when admin" do with_self_hosting do sign_in users(:family_member) diff --git a/test/models/assistant/external/client_test.rb b/test/models/assistant/external/client_test.rb new file mode 100644 index 000000000..74f2258ea --- /dev/null +++ b/test/models/assistant/external/client_test.rb @@ -0,0 +1,283 @@ +require "test_helper" + +class Assistant::External::ClientTest < ActiveSupport::TestCase + setup do + @client = Assistant::External::Client.new( + url: "http://localhost:18789/v1/chat", + token: "test-token", + agent_id: "test-agent" + ) + end + + test "streams text chunks from SSE response" do + sse_body = <<~SSE + data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}],"model":"test-agent"} + + data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"Your net worth"},"finish_reason":null}],"model":"test-agent"} + + data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":" is $124,200."},"finish_reason":null}],"model":"test-agent"} + + data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"model":"test-agent"} + + data: [DONE] + + SSE + + mock_http_streaming_response(sse_body) + + chunks = [] + model = @client.chat(messages: [ { role: "user", content: "test" } ]) do |text| + chunks << text + end + + assert_equal [ "Your net worth", " is $124,200." ], chunks + assert_equal "test-agent", model + end + + test "raises on non-200 response" do + mock_http_error_response(503, "Service Unavailable") + + assert_raises(Assistant::Error) do + @client.chat(messages: [ { role: "user", content: "test" } ]) { |_| } + end + end + + test "retries transient errors then raises Assistant::Error" do + Net::HTTP.any_instance.stubs(:request).raises(Net::OpenTimeout, "connection timed out") + + error = assert_raises(Assistant::Error) do + @client.chat(messages: [ { role: "user", content: "test" } ]) { |_| } + end + + assert_match(/temporarily unavailable/, error.message) + end + + test "does not retry after streaming has started" do + call_count = 0 + + # Custom response that yields one chunk then raises mid-stream + mock_response = Object.new + mock_response.define_singleton_method(:is_a?) { |klass| klass == Net::HTTPSuccess } + mock_response.define_singleton_method(:read_body) do |&blk| + blk.call("data: {\"choices\":[{\"delta\":{\"content\":\"partial\"}}],\"model\":\"m\"}\n\n") + raise Errno::ECONNRESET, "connection reset mid-stream" + end + + mock_http = stub("http") + mock_http.stubs(:use_ssl=) + mock_http.stubs(:open_timeout=) + mock_http.stubs(:read_timeout=) + mock_http.define_singleton_method(:request) do |_req, &blk| + call_count += 1 + blk.call(mock_response) + end + + Net::HTTP.stubs(:new).returns(mock_http) + + chunks = [] + error = assert_raises(Assistant::Error) do + @client.chat(messages: [ { role: "user", content: "test" } ]) { |t| chunks << t } + end + + assert_equal 1, call_count, "Should not retry after streaming started" + assert_equal [ "partial" ], chunks + assert_match(/connection was interrupted/, error.message) + end + + test "builds correct request payload" do + sse_body = "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}],\"model\":\"m\"}\n\ndata: [DONE]\n\n" + capture = mock_http_streaming_response(sse_body) + + @client.chat( + messages: [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi there" }, + { role: "user", content: "What is my balance?" } + ], + user: "sure-family-42" + ) { |_| } + + body = JSON.parse(capture[0].body) + assert_equal "test-agent", body["model"] + assert_equal true, body["stream"] + assert_equal 3, body["messages"].size + assert_equal "sure-family-42", body["user"] + end + + test "sets authorization header and agent_id header" do + sse_body = "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}],\"model\":\"m\"}\n\ndata: [DONE]\n\n" + capture = mock_http_streaming_response(sse_body) + + @client.chat(messages: [ { role: "user", content: "test" } ]) { |_| } + + assert_equal "Bearer test-token", capture[0]["Authorization"] + assert_equal "test-agent", capture[0]["X-Agent-Id"] + assert_equal "agent:main:main", capture[0]["X-Session-Key"] + assert_equal "text/event-stream", capture[0]["Accept"] + assert_equal "application/json", capture[0]["Content-Type"] + end + + test "omits user field when not provided" do + sse_body = "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}],\"model\":\"m\"}\n\ndata: [DONE]\n\n" + capture = mock_http_streaming_response(sse_body) + + @client.chat(messages: [ { role: "user", content: "test" } ]) { |_| } + + body = JSON.parse(capture[0].body) + assert_not body.key?("user") + end + + test "handles malformed JSON in SSE data gracefully" do + sse_body = "data: {not valid json}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"OK\"}}],\"model\":\"m\"}\n\ndata: [DONE]\n\n" + mock_http_streaming_response(sse_body) + + chunks = [] + @client.chat(messages: [ { role: "user", content: "test" } ]) { |t| chunks << t } + + assert_equal [ "OK" ], chunks + end + + test "handles SSE data: field without space after colon (spec-compliant)" do + sse_body = "data:{\"choices\":[{\"delta\":{\"content\":\"no space\"}}],\"model\":\"m\"}\n\ndata:[DONE]\n\n" + mock_http_streaming_response(sse_body) + + chunks = [] + @client.chat(messages: [ { role: "user", content: "test" } ]) { |t| chunks << t } + + assert_equal [ "no space" ], chunks + end + + test "handles chunked SSE data split across read_body calls" do + chunk1 = "data: {\"choices\":[{\"delta\":{\"content\":\"Hel" + chunk2 = "lo\"}}],\"model\":\"m\"}\n\ndata: [DONE]\n\n" + + mock_http_streaming_response_chunked([ chunk1, chunk2 ]) + + chunks = [] + @client.chat(messages: [ { role: "user", content: "test" } ]) { |t| chunks << t } + + assert_equal [ "Hello" ], chunks + end + + test "routes through HTTPS_PROXY when set" do + sse_body = "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}],\"model\":\"m\"}\n\ndata: [DONE]\n\n" + + mock_response = stub("response") + mock_response.stubs(:code).returns("200") + mock_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true) + mock_response.stubs(:read_body).yields(sse_body) + + mock_http = stub("http") + mock_http.stubs(:use_ssl=) + mock_http.stubs(:open_timeout=) + mock_http.stubs(:read_timeout=) + mock_http.stubs(:request).yields(mock_response) + + captured_args = nil + Net::HTTP.stubs(:new).with do |*args| + captured_args = args + true + end.returns(mock_http) + + client = Assistant::External::Client.new( + url: "https://example.com/v1/chat", + token: "test-token" + ) + + ClimateControl.modify(HTTPS_PROXY: "http://proxyuser:proxypass@proxy:8888") do + client.chat(messages: [ { role: "user", content: "test" } ]) { |_| } + end + + assert_equal "example.com", captured_args[0] + assert_equal 443, captured_args[1] + assert_equal "proxy", captured_args[2] + assert_equal 8888, captured_args[3] + assert_equal "proxyuser", captured_args[4] + assert_equal "proxypass", captured_args[5] + end + + test "skips proxy for hosts in NO_PROXY" do + sse_body = "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}],\"model\":\"m\"}\n\ndata: [DONE]\n\n" + + mock_response = stub("response") + mock_response.stubs(:code).returns("200") + mock_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true) + mock_response.stubs(:read_body).yields(sse_body) + + mock_http = stub("http") + mock_http.stubs(:use_ssl=) + mock_http.stubs(:open_timeout=) + mock_http.stubs(:read_timeout=) + mock_http.stubs(:request).yields(mock_response) + + captured_args = nil + Net::HTTP.stubs(:new).with do |*args| + captured_args = args + true + end.returns(mock_http) + + client = Assistant::External::Client.new( + url: "http://agent.internal.example.com:18789/v1/chat", + token: "test-token" + ) + + ClimateControl.modify(HTTP_PROXY: "http://proxy:8888", NO_PROXY: "localhost,.example.com") do + client.chat(messages: [ { role: "user", content: "test" } ]) { |_| } + end + + # Should NOT pass proxy args — only host and port + assert_equal 2, captured_args.length + end + + private + + def mock_http_streaming_response(sse_body) + capture = [] + mock_response = stub("response") + mock_response.stubs(:code).returns("200") + mock_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true) + mock_response.stubs(:read_body).yields(sse_body) + + mock_http = stub("http") + mock_http.stubs(:use_ssl=) + mock_http.stubs(:open_timeout=) + mock_http.stubs(:read_timeout=) + mock_http.stubs(:request).with do |req| + capture[0] = req + true + end.yields(mock_response) + + Net::HTTP.stubs(:new).returns(mock_http) + capture + end + + def mock_http_streaming_response_chunked(chunks) + mock_response = stub("response") + mock_response.stubs(:code).returns("200") + mock_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true) + mock_response.stubs(:read_body).multiple_yields(*chunks.map { |c| [ c ] }) + + mock_http = stub("http") + mock_http.stubs(:use_ssl=) + mock_http.stubs(:open_timeout=) + mock_http.stubs(:read_timeout=) + mock_http.stubs(:request).yields(mock_response) + + Net::HTTP.stubs(:new).returns(mock_http) + end + + def mock_http_error_response(code, message) + mock_response = stub("response") + mock_response.stubs(:code).returns(code.to_s) + mock_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(false) + mock_response.stubs(:body).returns(message) + + mock_http = stub("http") + mock_http.stubs(:use_ssl=) + mock_http.stubs(:open_timeout=) + mock_http.stubs(:read_timeout=) + mock_http.stubs(:request).yields(mock_response) + + Net::HTTP.stubs(:new).returns(mock_http) + end +end diff --git a/test/models/assistant/external_config_test.rb b/test/models/assistant/external_config_test.rb new file mode 100644 index 000000000..77f2a342d --- /dev/null +++ b/test/models/assistant/external_config_test.rb @@ -0,0 +1,93 @@ +require "test_helper" + +class Assistant::ExternalConfigTest < ActiveSupport::TestCase + test "config reads URL from environment with priority over Setting" do + with_env_overrides("EXTERNAL_ASSISTANT_URL" => "http://from-env/v1/chat") do + assert_equal "http://from-env/v1/chat", Assistant::External.config.url + assert_equal "main", Assistant::External.config.agent_id + assert_equal "agent:main:main", Assistant::External.config.session_key + end + end + + test "config falls back to Setting when env var is absent" do + Setting.external_assistant_url = "http://from-setting/v1/chat" + Setting.external_assistant_token = "setting-token" + + with_env_overrides("EXTERNAL_ASSISTANT_URL" => nil, "EXTERNAL_ASSISTANT_TOKEN" => nil) do + assert_equal "http://from-setting/v1/chat", Assistant::External.config.url + assert_equal "setting-token", Assistant::External.config.token + end + ensure + Setting.external_assistant_url = nil + Setting.external_assistant_token = nil + end + + test "config reads agent_id with custom value" do + with_env_overrides( + "EXTERNAL_ASSISTANT_URL" => "http://example.com/v1/chat", + "EXTERNAL_ASSISTANT_TOKEN" => "test-token", + "EXTERNAL_ASSISTANT_AGENT_ID" => "finance-bot" + ) do + assert_equal "finance-bot", Assistant::External.config.agent_id + assert_equal "test-token", Assistant::External.config.token + end + end + + test "config reads session_key with custom value" do + with_env_overrides( + "EXTERNAL_ASSISTANT_URL" => "http://example.com/v1/chat", + "EXTERNAL_ASSISTANT_TOKEN" => "test-token", + "EXTERNAL_ASSISTANT_SESSION_KEY" => "agent:finance-bot:finance" + ) do + assert_equal "agent:finance-bot:finance", Assistant::External.config.session_key + end + end + + test "available_for? allows any user when no allowlist is set" do + user = OpenStruct.new(email: "anyone@example.com") + with_env_overrides("EXTERNAL_ASSISTANT_URL" => "http://x", "EXTERNAL_ASSISTANT_TOKEN" => "t", "EXTERNAL_ASSISTANT_ALLOWED_EMAILS" => nil) do + assert Assistant::External.available_for?(user) + end + end + + test "available_for? restricts to allowlisted emails" do + allowed = OpenStruct.new(email: "josh@example.com") + denied = OpenStruct.new(email: "other@example.com") + with_env_overrides("EXTERNAL_ASSISTANT_URL" => "http://x", "EXTERNAL_ASSISTANT_TOKEN" => "t", "EXTERNAL_ASSISTANT_ALLOWED_EMAILS" => "josh@example.com, admin@example.com") do + assert Assistant::External.available_for?(allowed) + assert_not Assistant::External.available_for?(denied) + end + end + + test "build_conversation_messages truncates to last 20 messages" do + chat = chats(:one) + + # Create enough messages to exceed the 20-message cap + 25.times do |i| + role_class = i.even? ? UserMessage : AssistantMessage + role_class.create!(chat: chat, content: "msg #{i}", ai_model: "test") + end + + with_env_overrides("EXTERNAL_ASSISTANT_URL" => "http://x", "EXTERNAL_ASSISTANT_TOKEN" => "t") do + external = Assistant::External.new(chat) + messages = external.send(:build_conversation_messages) + + assert_equal 20, messages.length + # Last message should be the most recent one we created + assert_equal "msg 24", messages.last[:content] + end + end + + test "configured? returns true only when URL and token are both present" do + Setting.external_assistant_url = nil + Setting.external_assistant_token = nil + + with_env_overrides("EXTERNAL_ASSISTANT_URL" => "http://x", "EXTERNAL_ASSISTANT_TOKEN" => nil) do + assert_not Assistant::External.configured? + end + + with_env_overrides("EXTERNAL_ASSISTANT_URL" => "http://x", "EXTERNAL_ASSISTANT_TOKEN" => "t") do + assert Assistant::External.configured? + end + end +end diff --git a/test/models/assistant_test.rb b/test/models/assistant_test.rb index 7ced43542..c07859090 100644 --- a/test/models/assistant_test.rb +++ b/test/models/assistant_test.rb @@ -187,14 +187,218 @@ class AssistantTest < ActiveSupport::TestCase test "for_chat returns External when family assistant_type is external" do @chat.user.family.update!(assistant_type: "external") - assistant = Assistant.for_chat(@chat) - assert_instance_of Assistant::External, assistant - assert_no_difference "AssistantMessage.count" do - assistant.respond_to(@message) + assert_instance_of Assistant::External, Assistant.for_chat(@chat) + end + + test "ASSISTANT_TYPE env override forces external regardless of DB value" do + assert_equal "builtin", @chat.user.family.assistant_type + + with_env_overrides("ASSISTANT_TYPE" => "external") do + assert_instance_of Assistant::External, Assistant.for_chat(@chat) + end + + assert_instance_of Assistant::Builtin, Assistant.for_chat(@chat) + end + + test "external assistant responds with streamed text" do + @chat.user.family.update!(assistant_type: "external") + assistant = Assistant.for_chat(@chat) + + sse_body = <<~SSE + data: {"choices":[{"delta":{"content":"Your net worth"}}],"model":"ext-agent:main"} + + data: {"choices":[{"delta":{"content":" is $124,200."}}],"model":"ext-agent:main"} + + data: [DONE] + + SSE + + mock_external_sse_response(sse_body) + + with_env_overrides( + "EXTERNAL_ASSISTANT_URL" => "http://localhost:18789/v1/chat", + "EXTERNAL_ASSISTANT_TOKEN" => "test-token" + ) do + assert_difference "AssistantMessage.count", 1 do + assistant.respond_to(@message) + end + + response_msg = @chat.messages.where(type: "AssistantMessage").last + assert_equal "Your net worth is $124,200.", response_msg.content + assert_equal "ext-agent:main", response_msg.ai_model + end + end + + test "external assistant adds error when not configured" do + @chat.user.family.update!(assistant_type: "external") + assistant = Assistant.for_chat(@chat) + + with_env_overrides( + "EXTERNAL_ASSISTANT_URL" => nil, + "EXTERNAL_ASSISTANT_TOKEN" => nil + ) do + assert_no_difference "AssistantMessage.count" do + assistant.respond_to(@message) + end + + @chat.reload + assert @chat.error.present? + assert_includes @chat.error, "not configured" + end + end + + test "external assistant adds error on connection failure" do + @chat.user.family.update!(assistant_type: "external") + assistant = Assistant.for_chat(@chat) + + Net::HTTP.any_instance.stubs(:request).raises(Errno::ECONNREFUSED, "Connection refused") + + with_env_overrides( + "EXTERNAL_ASSISTANT_URL" => "http://localhost:18789/v1/chat", + "EXTERNAL_ASSISTANT_TOKEN" => "test-token" + ) do + assert_no_difference "AssistantMessage.count" do + assistant.respond_to(@message) + end + + @chat.reload + assert @chat.error.present? + end + end + + test "external assistant handles empty response gracefully" do + @chat.user.family.update!(assistant_type: "external") + assistant = Assistant.for_chat(@chat) + + sse_body = <<~SSE + data: {"choices":[{"delta":{"role":"assistant"}}],"model":"ext-agent:main"} + + data: {"choices":[{"delta":{}}],"model":"ext-agent:main"} + + data: [DONE] + + SSE + + mock_external_sse_response(sse_body) + + with_env_overrides( + "EXTERNAL_ASSISTANT_URL" => "http://localhost:18789/v1/chat", + "EXTERNAL_ASSISTANT_TOKEN" => "test-token" + ) do + assert_no_difference "AssistantMessage.count" do + assistant.respond_to(@message) + end + + @chat.reload + assert @chat.error.present? + assert_includes @chat.error, "empty response" + end + end + + test "external assistant sends conversation history" do + @chat.user.family.update!(assistant_type: "external") + assistant = Assistant.for_chat(@chat) + + AssistantMessage.create!(chat: @chat, content: "I can help with that.", ai_model: "external") + + sse_body = "data: {\"choices\":[{\"delta\":{\"content\":\"Sure!\"}}],\"model\":\"m\"}\n\ndata: [DONE]\n\n" + capture = mock_external_sse_response(sse_body) + + with_env_overrides( + "EXTERNAL_ASSISTANT_URL" => "http://localhost:18789/v1/chat", + "EXTERNAL_ASSISTANT_TOKEN" => "test-token" + ) do + assistant.respond_to(@message) + + body = JSON.parse(capture[0].body) + messages = body["messages"] + + assert messages.size >= 2 + assert_equal "user", messages.first["role"] + end + end + + test "full external assistant flow: config check, stream, save, error recovery" do + @chat.user.family.update!(assistant_type: "external") + + # Phase 1: Without config, errors gracefully + with_env_overrides("EXTERNAL_ASSISTANT_URL" => nil, "EXTERNAL_ASSISTANT_TOKEN" => nil) do + assistant = Assistant::External.new(@chat) + assistant.respond_to(@message) + @chat.reload + assert @chat.error.present? + end + + # Phase 2: With config, streams response + @chat.update!(error: nil) + + sse_body = <<~SSE + data: {"choices":[{"delta":{"content":"Based on your accounts, "}}],"model":"ext-agent:main"} + + data: {"choices":[{"delta":{"content":"your net worth is $50,000."}}],"model":"ext-agent:main"} + + data: [DONE] + + SSE + + mock_external_sse_response(sse_body) + + with_env_overrides( + "EXTERNAL_ASSISTANT_URL" => "http://localhost:18789/v1/chat", + "EXTERNAL_ASSISTANT_TOKEN" => "test-token" + ) do + assistant = Assistant::External.new(@chat) + assistant.respond_to(@message) + + @chat.reload + assert_nil @chat.error + + response = @chat.messages.where(type: "AssistantMessage").last + assert_equal "Based on your accounts, your net worth is $50,000.", response.content + assert_equal "ext-agent:main", response.ai_model + end + end + + test "ASSISTANT_TYPE env override with unknown value falls back to builtin" do + with_env_overrides("ASSISTANT_TYPE" => "nonexistent") do + assert_instance_of Assistant::Builtin, Assistant.for_chat(@chat) + end + end + + test "external assistant sets user identifier with family_id" do + @chat.user.family.update!(assistant_type: "external") + assistant = Assistant.for_chat(@chat) + + sse_body = "data: {\"choices\":[{\"delta\":{\"content\":\"OK\"}}],\"model\":\"m\"}\n\ndata: [DONE]\n\n" + capture = mock_external_sse_response(sse_body) + + with_env_overrides( + "EXTERNAL_ASSISTANT_URL" => "http://localhost:18789/v1/chat", + "EXTERNAL_ASSISTANT_TOKEN" => "test-token" + ) do + assistant.respond_to(@message) + + body = JSON.parse(capture[0].body) + assert_equal "sure-family-#{@chat.user.family_id}", body["user"] + end + end + + test "external assistant updates ai_model from SSE response model field" do + @chat.user.family.update!(assistant_type: "external") + assistant = Assistant.for_chat(@chat) + + sse_body = "data: {\"choices\":[{\"delta\":{\"content\":\"Hi\"}}],\"model\":\"ext-agent:custom\"}\n\ndata: [DONE]\n\n" + mock_external_sse_response(sse_body) + + with_env_overrides( + "EXTERNAL_ASSISTANT_URL" => "http://localhost:18789/v1/chat", + "EXTERNAL_ASSISTANT_TOKEN" => "test-token" + ) do + assistant.respond_to(@message) + + response = @chat.messages.where(type: "AssistantMessage").last + assert_equal "ext-agent:custom", response.ai_model end - @chat.reload - assert @chat.error.present? - assert_includes @chat.error, "not yet implemented" end test "for_chat raises when chat is blank" do @@ -202,6 +406,27 @@ class AssistantTest < ActiveSupport::TestCase end private + + def mock_external_sse_response(sse_body) + capture = [] + mock_response = stub("response") + mock_response.stubs(:code).returns("200") + mock_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true) + mock_response.stubs(:read_body).yields(sse_body) + + mock_http = stub("http") + mock_http.stubs(:use_ssl=) + mock_http.stubs(:open_timeout=) + mock_http.stubs(:read_timeout=) + mock_http.stubs(:request).with do |req| + capture[0] = req + true + end.yields(mock_response) + + Net::HTTP.stubs(:new).returns(mock_http) + capture + end + def provider_function_request(id:, call_id:, function_name:, function_args:) Provider::LlmConcept::ChatFunctionRequest.new( id: id, diff --git a/test/models/user_test.rb b/test/models/user_test.rb index 85501c1d4..0d2a09d58 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -149,7 +149,7 @@ class UserTest < ActiveSupport::TestCase test "ai_available? returns true when openai access token set in settings" do Rails.application.config.app_mode.stubs(:self_hosted?).returns(true) previous = Setting.openai_access_token - with_env_overrides OPENAI_ACCESS_TOKEN: nil do + with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: nil, EXTERNAL_ASSISTANT_TOKEN: nil do Setting.openai_access_token = nil assert_not @user.ai_available? @@ -160,6 +160,43 @@ class UserTest < ActiveSupport::TestCase Setting.openai_access_token = previous end + test "ai_available? returns true when external assistant is configured and family type is external" do + Rails.application.config.app_mode.stubs(:self_hosted?).returns(true) + previous = Setting.openai_access_token + @user.family.update!(assistant_type: "external") + with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: "http://localhost:18789/v1/chat", EXTERNAL_ASSISTANT_TOKEN: "test-token" do + Setting.openai_access_token = nil + assert @user.ai_available? + end + ensure + Setting.openai_access_token = previous + @user.family.update!(assistant_type: "builtin") + end + + test "ai_available? returns false when external assistant is configured but family type is builtin" do + Rails.application.config.app_mode.stubs(:self_hosted?).returns(true) + previous = Setting.openai_access_token + with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: "http://localhost:18789/v1/chat", EXTERNAL_ASSISTANT_TOKEN: "test-token" do + Setting.openai_access_token = nil + assert_not @user.ai_available? + end + ensure + Setting.openai_access_token = previous + end + + test "ai_available? returns false when external assistant is configured but user is not in allowlist" do + Rails.application.config.app_mode.stubs(:self_hosted?).returns(true) + previous = Setting.openai_access_token + @user.family.update!(assistant_type: "external") + with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: "http://localhost:18789/v1/chat", EXTERNAL_ASSISTANT_TOKEN: "test-token", EXTERNAL_ASSISTANT_ALLOWED_EMAILS: "other@example.com" do + Setting.openai_access_token = nil + assert_not @user.ai_available? + end + ensure + Setting.openai_access_token = previous + @user.family.update!(assistant_type: "builtin") + end + test "intro layout collapses sidebars and enables ai" do user = User.new( family: families(:empty), From 947eb3fea914ca80b67dd24dfd6a0d2f2df55f65 Mon Sep 17 00:00:00 2001 From: Serge L Date: Tue, 3 Mar 2026 10:07:38 -0500 Subject: [PATCH 21/75] feat: Enable Skylight ActiveJob probe for background worker visibility (#1108) * feat: enable Skylight instrumentation for ActiveJob * Fix: Oops, load only in prod --- config/application.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config/application.rb b/config/application.rb index 3a63072b2..1269f1aad 100644 --- a/config/application.rb +++ b/config/application.rb @@ -39,6 +39,9 @@ module Sure theme: [ "light", "dark" ] # available in view as params[:theme] } + # Enable Skylight instrumentation for ActiveJob (background workers) + config.skylight.probes << "active_job" if defined?(Skylight) + # Enable Rack::Attack middleware for API rate limiting config.middleware.use Rack::Attack From a53a131c466ee6151f6e9411673e6e7883f890db Mon Sep 17 00:00:00 2001 From: LPW Date: Tue, 3 Mar 2026 10:32:35 -0500 Subject: [PATCH 22/75] Add Pipelock operational templates, docs, and config hardening (#1102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(helm): add Pipelock ConfigMap, scanning config, and consolidate compose - Add ConfigMap template rendering DLP, response scanning, MCP input/tool scanning, and forward proxy settings from values - Mount ConfigMap as /etc/pipelock/pipelock.yaml volume in deployment - Add checksum/config annotation for automatic pod restart on config change - Gate HTTPS_PROXY/HTTP_PROXY env injection on forwardProxy.enabled (skip in MCP-only mode) - Use hasKey for all boolean values to prevent Helm default swallowing false - Single source of truth for ports (forwardProxy.port/mcpProxy.port) - Pipelock-specific imagePullSecrets with fallback to app secrets - Merge standalone compose.example.pipelock.yml into compose.example.ai.yml - Add pipelock.example.yaml for Docker Compose users - Add exclude-paths to CI workflow for locale file false positives * Add external assistant support (OpenAI-compatible SSE proxy) Allow self-hosted instances to delegate chat to an external AI agent via an OpenAI-compatible streaming endpoint. Configurable per-family through Settings UI or ASSISTANT_TYPE env override. - Assistant::External::Client: SSE streaming HTTP client (no new gems) - Settings UI with type selector, env lock indicator, config status - Helm chart and Docker Compose env var support - 45 tests covering client, config, routing, controller, integration * Add session key routing, email allowlist, and config plumbing Route to the actual OpenClaw session via x-openclaw-session-key header instead of creating isolated sessions. Gate external assistant access behind an email allowlist (EXTERNAL_ASSISTANT_ALLOWED_EMAILS env var). Plumb session_key and allowedEmails through Helm chart, compose, and env template. * Add HTTPS_PROXY support to External::Client for Pipelock integration Net::HTTP does not auto-read HTTPS_PROXY/HTTP_PROXY env vars (unlike Faraday). Explicitly resolve proxy from environment in build_http so outbound traffic to the external assistant routes through Pipelock's forward proxy when enabled. Respects NO_PROXY for internal hosts. * Add UI fields for external assistant config (Setting-backed with env fallback) Follow the same pattern as OpenAI settings: database-backed Setting fields with env var defaults. Self-hosters can now configure the external assistant URL, token, and agent ID from the browser (Settings > Self-Hosting > AI Assistant) instead of requiring env vars. Fields disable when the corresponding env var is set. * Improve external assistant UI labels and add help text Change placeholder to generic OpenAI-compatible URL pattern. Add help text under each field explaining where the values come from: URL from agent provider, token for authentication, agent ID for multi-agent routing. * Add external assistant docs and fix URL help text Add External AI Assistant section to docs/hosting/ai.md covering setup (UI and env vars), how it works, Pipelock security scanning, access control, and Docker Compose example. Drop "chat completions" jargon from URL help text. * Harden external assistant: retry logic, disconnect UI, error handling, and test coverage - Add retry with backoff for transient network errors (no retry after streaming starts) - Add disconnect button with confirmation modal in self-hosting settings - Narrow rescue scope with fallback logging for unexpected errors - Safe cleanup of partial responses on stream interruption - Gate ai_available? on family assistant_type instead of OR-ing all providers - Truncate conversation history to last 20 messages - Proxy-aware HTTP client with NO_PROXY support - Sanitize protocol to use generic headers (X-Agent-Id, X-Session-Key) - Full test coverage for streaming, retries, proxy routing, config, and disconnect * Exclude external assistant client from Pipelock scan-diff False positive: `@token` instance variable flagged as "Credential in URL". Temporary workaround until Pipelock supports inline suppression. * Address review feedback: NO_PROXY boundary fix, SSE done flag, design tokens - Fix NO_PROXY matching to require domain boundary (exact match or .suffix), case-insensitive. Prevents badexample.com matching example.com. - Add done flag to SSE streaming so read_body stops after [DONE] - Move MAX_CONVERSATION_MESSAGES to class level - Use bg-success/bg-destructive design tokens for status indicators - Add rationale comment for pipelock scan exclusion - Update docs last-updated date * Address second round of review feedback - Allowlist email comparison is now case-insensitive and nil-safe - Cap SSE buffer at 1 MB to prevent memory blowup from malformed streams - Don't expose upstream HTTP response body in user-facing errors (log it instead) - Fix frozen string warning on buffer initialization - Fix "builtin" typo in docs (should be "built-in") * Protect completed responses from cleanup, sanitize error messages - Don't destroy a fully streamed assistant message if post-stream metadata update fails (only cleanup partial responses) - Log raw connection/HTTP errors internally, show generic messages to users to avoid leaking network/proxy details - Update test assertions for new error message wording * Fix SSE content guard and NO_PROXY test correctness Use nil check instead of present? for SSE delta content to preserve whitespace-only chunks (newlines, spaces) that can occur in code output. Fix NO_PROXY test to use HTTP_PROXY matching the http:// client URL so the proxy resolution and NO_PROXY bypass logic are actually exercised. * Forward proxy credentials to Net::HTTP Pass proxy_uri.user and proxy_uri.password to Net::HTTP.new so authenticated proxies (http://user:pass@host:port) work correctly. Without this, credentials parsed from the proxy URL were silently dropped. Nil values are safe as positional args when no creds exist. * Update pipelock integration to v0.3.1 with full scanning config Bump Helm image tag from 0.2.7 to 0.3.1. Add missing security sections to both the Helm ConfigMap and compose example config: mcp_tool_policy, mcp_session_binding, and tool_chain_detection. These protect the /mcp endpoint against tool injection, session hijacking, and multi-step exfiltration chains. Add version and mode fields to config files. Enable include_defaults for DLP and response scanning to merge user patterns with the 35 built-in patterns. Remove redundant --mode CLI flag from the Helm deployment template since mode is now in the config file. * Pipelock Helm hardening + docs for external assistant and pipelock Helm templates: - ServiceMonitor for Prometheus scraping on /metrics (proxy port) - Ingress template for MCP reverse proxy (external AI agent access) - PodDisruptionBudget with minAvailable/maxUnavailable mutual exclusion - topologySpreadConstraints on Deployment - Structured logging config (format, output, include_allowed/blocked) - extraConfig escape hatch for additional pipelock.yaml sections - requireForExternalAssistant guard (fails when assistant enabled without pipelock) - Component label on Service metadata for ServiceMonitor targeting - NOTES.txt pipelock section with health, access, security, metrics info - Bump pipelock image tag 0.3.1 -> 0.3.2 - Fix: rename _asserts.tpl -> asserts.tpl (Helm skipped _ prefixed file) Documentation: - Helm chart README: full Pipelock section - docs/hosting/pipelock.md: dedicated hosting guide (Docker + Kubernetes) - docs/hosting/docker.md: AI features section (external assistant, pipelock) - .env.example: external assistant and MCP env vars Infra: - Chart.lock pinning dependency versions - .gitignore for vendored subchart tarballs * Fix bot comments: quote ingress host, fix sidecar wording, add code block lang * Fail fast when pipelock ingress enabled with empty hosts * Fail fast when pipelock ingress host has empty paths * Messed up the conflict merge --------- Signed-off-by: Juan José Mata Co-authored-by: Juan José Mata Co-authored-by: Juan José Mata --- .env.example | 15 ++ charts/sure/.gitignore | 2 + charts/sure/CHANGELOG.md | 16 +- charts/sure/Chart.lock | 9 + charts/sure/README.md | 108 +++++++++ charts/sure/templates/NOTES.txt | 35 ++- charts/sure/templates/_asserts.tpl | 7 - charts/sure/templates/asserts.tpl | 23 ++ charts/sure/templates/pipelock-configmap.yaml | 22 ++ .../sure/templates/pipelock-deployment.yaml | 2 + charts/sure/templates/pipelock-ingress.yaml | 42 ++++ charts/sure/templates/pipelock-pdb.yaml | 21 ++ charts/sure/templates/pipelock-service.yaml | 1 + .../templates/pipelock-servicemonitor.yaml | 21 ++ charts/sure/values.yaml | 51 +++- docs/hosting/docker.md | 56 +++++ docs/hosting/pipelock.md | 219 ++++++++++++++++++ 17 files changed, 640 insertions(+), 10 deletions(-) create mode 100644 charts/sure/.gitignore create mode 100644 charts/sure/Chart.lock delete mode 100644 charts/sure/templates/_asserts.tpl create mode 100644 charts/sure/templates/asserts.tpl create mode 100644 charts/sure/templates/pipelock-ingress.yaml create mode 100644 charts/sure/templates/pipelock-pdb.yaml create mode 100644 charts/sure/templates/pipelock-servicemonitor.yaml create mode 100644 docs/hosting/pipelock.md diff --git a/.env.example b/.env.example index 548bd1971..2928bcc1c 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,21 @@ OPENAI_ACCESS_TOKEN= OPENAI_MODEL= OPENAI_URI_BASE= +# Optional: External AI Assistant — delegates chat to a remote AI agent +# instead of calling LLMs directly. The agent calls back to Sure's /mcp endpoint. +# See docs/hosting/ai.md for full details. +# ASSISTANT_TYPE=external +# EXTERNAL_ASSISTANT_URL=https://your-agent-host/v1/chat/completions +# EXTERNAL_ASSISTANT_TOKEN=your-api-token +# EXTERNAL_ASSISTANT_AGENT_ID=main +# EXTERNAL_ASSISTANT_SESSION_KEY=agent:main:main +# EXTERNAL_ASSISTANT_ALLOWED_EMAILS=user@example.com + +# Optional: MCP server endpoint — enables /mcp for external AI assistants. +# Both values are required. MCP_USER_EMAIL must match an existing user's email. +# MCP_API_TOKEN=your-random-bearer-token +# MCP_USER_EMAIL=user@example.com + # Optional: Langfuse config LANGFUSE_HOST=https://cloud.langfuse.com LANGFUSE_PUBLIC_KEY= diff --git a/charts/sure/.gitignore b/charts/sure/.gitignore new file mode 100644 index 000000000..58f68018c --- /dev/null +++ b/charts/sure/.gitignore @@ -0,0 +1,2 @@ +# Vendored subchart tarballs (regenerated by `helm dependency build`) +charts/ diff --git a/charts/sure/CHANGELOG.md b/charts/sure/CHANGELOG.md index 7aa613010..0d1842c6b 100644 --- a/charts/sure/CHANGELOG.md +++ b/charts/sure/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to the Sure Helm chart will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.6.9-alpha] - 2026-03-01 +## [0.6.9-alpha] - 2026-03-02 ### Added - **Pipelock security proxy** (`pipelock.enabled=true`): Separate Deployment + Service that provides two scanning layers @@ -20,11 +20,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Boolean safety: uses `hasKey` to prevent Helm's `default` from swallowing explicit `false` - Configurable ports via `forwardProxy.port` and `mcpProxy.port` (single source of truth across Service, Deployment, and env vars) - `pipelock.example.yaml` reference config for Docker Compose deployments +- **Pipelock operational hardening**: + - `pipelock.serviceMonitor`: Prometheus Operator ServiceMonitor for /metrics on the proxy port + - `pipelock.ingress`: Ingress template for MCP reverse proxy (external AI assistant access in k8s) + - `pipelock.pdb`: PodDisruptionBudget with minAvailable/maxUnavailable mutual exclusion guard + - `pipelock.topologySpreadConstraints`: Pod spread across nodes + - `pipelock.logging`: Structured logging config (format, output, include_allowed, include_blocked) + - `pipelock.extraConfig`: Escape hatch for additional pipelock.yaml config sections + - `pipelock.requireForExternalAssistant`: Helm guard that fails when externalAssistant is enabled without pipelock + - Component label (`app.kubernetes.io/component: pipelock`) on Service metadata for selector targeting + - NOTES.txt: Pipelock health check commands, MCP access info, security notes, metrics status ### Changed +- Bumped `pipelock.image.tag` from `0.3.1` to `0.3.2` - Consolidated `compose.example.pipelock.yml` into `compose.example.ai.yml` — Pipelock now runs alongside Ollama in one compose file with health checks, config volume mount, and MCP env vars (`MCP_API_TOKEN`, `MCP_USER_EMAIL`) - CI: Pipelock scan `fail-on-findings` changed from `false` to `true`; added `exclude-paths` for locale help text false positives +### Fixed +- Renamed `_asserts.tpl` to `asserts.tpl` — Helm's `_` prefix convention prevented guards from executing + ## [0.6.7-alpha] - 2026-01-10 ### Added diff --git a/charts/sure/Chart.lock b/charts/sure/Chart.lock new file mode 100644 index 000000000..5cdb2cfcc --- /dev/null +++ b/charts/sure/Chart.lock @@ -0,0 +1,9 @@ +dependencies: +- name: cloudnative-pg + repository: https://cloudnative-pg.github.io/charts + version: 0.27.1 +- name: redis-operator + repository: https://ot-container-kit.github.io/helm-charts + version: 0.23.0 +digest: sha256:5ffa5c535cb5feea62a29665045a79da8a5d058c3ba11c4db37a4afa97563e3e +generated: "2026-03-02T21:16:32.757224371-05:00" diff --git a/charts/sure/README.md b/charts/sure/README.md index 6a004d153..bf30f3071 100644 --- a/charts/sure/README.md +++ b/charts/sure/README.md @@ -12,6 +12,7 @@ Official Helm chart for deploying the Sure Rails application on Kubernetes. It s - Optional subcharts - CloudNativePG (operator) + Cluster CR for PostgreSQL with HA support - OT-CONTAINER-KIT redis-operator for Redis HA (replication by default, optional Sentinel) +- Optional Pipelock AI agent security proxy (forward proxy + MCP reverse proxy with DLP, prompt injection, and tool poisoning detection) - Security best practices: runAsNonRoot, readOnlyRootFilesystem, optional existingSecret, no hardcoded secrets - Scalability - Replicas (web/worker), resources, topology spread constraints @@ -637,6 +638,112 @@ hpa: targetCPUUtilizationPercentage: 70 ``` +## Pipelock (AI agent security proxy) + +Pipelock is an optional sidecar that scans AI agent traffic for secret exfiltration, prompt injection, and tool poisoning. It runs as a separate Deployment with two listeners: + +- **Forward proxy** (port 8888): Scans outbound HTTPS from Faraday-based AI clients. Auto-injected via `HTTPS_PROXY` env vars when enabled. +- **MCP reverse proxy** (port 8889): Scans inbound MCP traffic from external AI assistants. + +### Enabling Pipelock + +```yaml +pipelock: + enabled: true + image: + tag: "0.3.2" + mode: balanced # strict, balanced, or audit +``` + +### Exposing MCP to external AI assistants + +When running in Kubernetes, external AI agents need network access to the MCP reverse proxy port. Enable the Pipelock Ingress: + +```yaml +pipelock: + enabled: true + ingress: + enabled: true + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt + hosts: + - host: pipelock.example.com + paths: + - path: / + pathType: Prefix + tls: + - hosts: [pipelock.example.com] + secretName: pipelock-tls +``` + +Security: The Ingress routes to port `mcp` (8889). Ensure `MCP_API_TOKEN` is set so the MCP endpoint requires authentication. The Ingress itself does not add auth. + +### Metrics (Prometheus) + +Pipelock exposes `/metrics` on the forward proxy port. Enable scraping with a ServiceMonitor: + +```yaml +pipelock: + serviceMonitor: + enabled: true + interval: 30s + portName: proxy # matches Service port name for 8888 + additionalLabels: + release: prometheus # match your Prometheus Operator selector +``` + +### PodDisruptionBudget + +Protect Pipelock from node drains: + +```yaml +pipelock: + pdb: + enabled: true + maxUnavailable: 1 # safe for single-replica; use minAvailable when replicas > 1 +``` + +Note: Setting `minAvailable` with `replicas=1` blocks eviction entirely. Use `maxUnavailable` for single-replica deployments. + +### Structured logging + +```yaml +pipelock: + logging: + format: json # json or text + output: stdout + includeAllowed: false + includeBlocked: true +``` + +### Extra config (escape hatch) + +For Pipelock config sections not covered by structured values (session profiling, data budgets, kill switch, etc.), use `extraConfig`: + +```yaml +pipelock: + extraConfig: + session_profiling: + enabled: true + max_sessions: 1000 + data_budget: + max_bytes_per_session: 10485760 +``` + +These are appended verbatim to `pipelock.yaml`. Do not duplicate keys already rendered by the chart. + +### Requiring Pipelock for external assistants + +To enforce that Pipelock is enabled whenever the external AI assistant feature is active: + +```yaml +pipelock: + requireForExternalAssistant: true +``` + +This causes `helm template` / `helm install` to fail if `rails.externalAssistant.enabled=true` and `pipelock.enabled=false`. Note: this only guards the `externalAssistant` path. Direct MCP access via `MCP_API_TOKEN` is configured through env vars and not detectable from Helm values. + ## Security Notes - Never commit secrets in `values.yaml`. Use `rails.existingSecret` or a tool like Sealed Secrets. @@ -660,6 +767,7 @@ See `values.yaml` for the complete configuration surface, including: - `migrations.*`: strategy job or initContainer - `simplefin.encryption.*`: enable + backfill options - `cronjobs.*`: custom CronJobs +- `pipelock.*`: AI agent security proxy (forward proxy, MCP reverse proxy, DLP, injection scanning, logging, serviceMonitor, ingress, PDB, extraConfig) - `service.*`, `ingress.*`, `serviceMonitor.*`, `hpa.*` ## Helm tests diff --git a/charts/sure/templates/NOTES.txt b/charts/sure/templates/NOTES.txt index f4c340b9d..e9e46850f 100644 --- a/charts/sure/templates/NOTES.txt +++ b/charts/sure/templates/NOTES.txt @@ -41,7 +41,40 @@ Troubleshooting - For CloudNativePG, verify the RW service exists and the primary is Ready. - For redis-operator, verify the RedisSentinel CR reports Ready and that the master service resolves. +{{- if .Values.pipelock.enabled }} + +Pipelock (AI agent security proxy) +----------------------------------- +5) Verify pipelock is running: + kubectl rollout status deploy/{{ include "sure.fullname" . }}-pipelock -n {{ .Release.Namespace }} + kubectl logs deploy/{{ include "sure.fullname" . }}-pipelock -n {{ .Release.Namespace }} --tail=20 + +6) MCP access for external AI assistants: +{{- if .Values.pipelock.ingress.enabled }} +{{- range .Values.pipelock.ingress.hosts }} + - Ingress: http{{ if $.Values.pipelock.ingress.tls }}s{{ end }}://{{ .host }} +{{- end }} +{{- else }} + - No Ingress configured. Port-forward for local access: + kubectl port-forward -n {{ .Release.Namespace }} svc/{{ include "sure.fullname" . }}-pipelock 8889:{{ .Values.pipelock.mcpProxy.port | default 8889 }} +{{- end }} + + Security: Enable TLS on the pipelock Ingress and ensure MCP_API_TOKEN is set. + The MCP endpoint requires authentication but the Ingress does not add it. + +7) Metrics: +{{- if .Values.pipelock.serviceMonitor.enabled }} + - ServiceMonitor enabled — Prometheus will scrape /metrics on port {{ .Values.pipelock.serviceMonitor.portName }}. +{{- else }} + - ServiceMonitor not enabled. Metrics are available at http://:{{ .Values.pipelock.forwardProxy.port | default 8888 }}/metrics + Enable with: pipelock.serviceMonitor.enabled=true +{{- end }} +{{- end }} + Security reminder ----------------- - For production, prefer immutable image tags (for example, image.tag=v1.2.3) instead of 'latest'. -- Provide secrets via an existing Kubernetes Secret or a secret manager (External Secrets, Sealed Secrets). \ No newline at end of file +- Provide secrets via an existing Kubernetes Secret or a secret manager (External Secrets, Sealed Secrets). +{{- if .Values.pipelock.enabled }} +- When exposing MCP to external AI assistants, always enable pipelock to scan inbound traffic. +{{- end }} \ No newline at end of file diff --git a/charts/sure/templates/_asserts.tpl b/charts/sure/templates/_asserts.tpl deleted file mode 100644 index de1cf0bbb..000000000 --- a/charts/sure/templates/_asserts.tpl +++ /dev/null @@ -1,7 +0,0 @@ -{{/* -Mutual exclusivity and configuration guards -*/}} - -{{- if and .Values.redisOperator.managed.enabled .Values.redisSimple.enabled -}} -{{- fail "Invalid configuration: Both redisOperator.managed.enabled and redisSimple.enabled are true. Enable only one in-cluster Redis provider." -}} -{{- end -}} diff --git a/charts/sure/templates/asserts.tpl b/charts/sure/templates/asserts.tpl new file mode 100644 index 000000000..1d481c0e9 --- /dev/null +++ b/charts/sure/templates/asserts.tpl @@ -0,0 +1,23 @@ +{{/* +Mutual exclusivity and configuration guards +*/}} + +{{- if and .Values.redisOperator.managed.enabled .Values.redisSimple.enabled -}} +{{- fail "Invalid configuration: Both redisOperator.managed.enabled and redisSimple.enabled are true. Enable only one in-cluster Redis provider." -}} +{{- end -}} + +{{- $extEnabled := false -}} +{{- if .Values.rails -}}{{- if .Values.rails.externalAssistant -}}{{- if .Values.rails.externalAssistant.enabled -}} +{{- $extEnabled = true -}} +{{- end -}}{{- end -}}{{- end -}} +{{- $plEnabled := false -}} +{{- if .Values.pipelock -}}{{- if .Values.pipelock.enabled -}} +{{- $plEnabled = true -}} +{{- end -}}{{- end -}} +{{- $requirePL := false -}} +{{- if .Values.pipelock -}}{{- if .Values.pipelock.requireForExternalAssistant -}} +{{- $requirePL = true -}} +{{- end -}}{{- end -}} +{{- if and $extEnabled (not $plEnabled) $requirePL -}} +{{- fail "pipelock.requireForExternalAssistant is true but pipelock.enabled is false. Enable pipelock (pipelock.enabled=true) when using rails.externalAssistant, or set pipelock.requireForExternalAssistant=false." -}} +{{- end -}} diff --git a/charts/sure/templates/pipelock-configmap.yaml b/charts/sure/templates/pipelock-configmap.yaml index 7b39a6726..8962d67fb 100644 --- a/charts/sure/templates/pipelock-configmap.yaml +++ b/charts/sure/templates/pipelock-configmap.yaml @@ -64,6 +64,20 @@ {{- $chainAction = .Values.pipelock.toolChainDetection.action | default "warn" -}} {{- $chainWindow = int (.Values.pipelock.toolChainDetection.windowSize | default 20) -}} {{- $chainGap = int (.Values.pipelock.toolChainDetection.maxGap | default 3) -}} +{{- end -}} +{{- $logFormat := "json" -}} +{{- $logOutput := "stdout" -}} +{{- $logIncludeAllowed := false -}} +{{- $logIncludeBlocked := true -}} +{{- if .Values.pipelock.logging -}} +{{- $logFormat = .Values.pipelock.logging.format | default "json" -}} +{{- $logOutput = .Values.pipelock.logging.output | default "stdout" -}} +{{- if hasKey .Values.pipelock.logging "includeAllowed" -}} +{{- $logIncludeAllowed = .Values.pipelock.logging.includeAllowed -}} +{{- end -}} +{{- if hasKey .Values.pipelock.logging "includeBlocked" -}} +{{- $logIncludeBlocked = .Values.pipelock.logging.includeBlocked -}} +{{- end -}} {{- end }} apiVersion: v1 kind: ConfigMap @@ -116,4 +130,12 @@ data: action: {{ $chainAction }} window_size: {{ $chainWindow }} max_gap: {{ $chainGap }} + logging: + format: {{ $logFormat }} + output: {{ $logOutput }} + include_allowed: {{ $logIncludeAllowed }} + include_blocked: {{ $logIncludeBlocked }} +{{- if .Values.pipelock.extraConfig }} + {{- toYaml .Values.pipelock.extraConfig | nindent 4 }} +{{- end }} {{- end }} diff --git a/charts/sure/templates/pipelock-deployment.yaml b/charts/sure/templates/pipelock-deployment.yaml index 99732fb0c..bf57ec8ab 100644 --- a/charts/sure/templates/pipelock-deployment.yaml +++ b/charts/sure/templates/pipelock-deployment.yaml @@ -96,4 +96,6 @@ spec: {{- toYaml (.Values.pipelock.affinity | default dict) | nindent 8 }} tolerations: {{- toYaml (.Values.pipelock.tolerations | default list) | nindent 8 }} + topologySpreadConstraints: + {{- toYaml (.Values.pipelock.topologySpreadConstraints | default (list)) | nindent 8 }} {{- end }} diff --git a/charts/sure/templates/pipelock-ingress.yaml b/charts/sure/templates/pipelock-ingress.yaml new file mode 100644 index 000000000..49c3e7ef8 --- /dev/null +++ b/charts/sure/templates/pipelock-ingress.yaml @@ -0,0 +1,42 @@ +{{- if and .Values.pipelock.enabled .Values.pipelock.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "sure.fullname" . }}-pipelock + labels: + {{- include "sure.labels" . | nindent 4 }} + {{- with .Values.pipelock.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.pipelock.ingress.className }} + ingressClassName: {{ .Values.pipelock.ingress.className }} + {{- end }} + {{- if .Values.pipelock.ingress.hosts }} + rules: + {{- range .Values.pipelock.ingress.hosts }} + {{- if not .paths }} + {{- fail "each entry in pipelock.ingress.hosts must include at least one paths item" }} + {{- end }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "sure.fullname" $ }}-pipelock + port: + name: mcp + {{- end }} + {{- end }} + {{- else }} + {{- fail "pipelock.ingress.enabled=true requires at least one entry in pipelock.ingress.hosts" }} + {{- end }} + {{- if .Values.pipelock.ingress.tls }} + tls: + {{- toYaml .Values.pipelock.ingress.tls | nindent 4 }} + {{- end }} +{{- end }} diff --git a/charts/sure/templates/pipelock-pdb.yaml b/charts/sure/templates/pipelock-pdb.yaml new file mode 100644 index 000000000..59f7da34a --- /dev/null +++ b/charts/sure/templates/pipelock-pdb.yaml @@ -0,0 +1,21 @@ +{{- if and .Values.pipelock.enabled .Values.pipelock.pdb.enabled }} +{{- if and .Values.pipelock.pdb.minAvailable .Values.pipelock.pdb.maxUnavailable }} +{{- fail "pipelock.pdb: set either minAvailable or maxUnavailable, not both." -}} +{{- end }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "sure.fullname" . }}-pipelock + labels: + {{- include "sure.labels" . | nindent 4 }} +spec: + {{- if .Values.pipelock.pdb.minAvailable }} + minAvailable: {{ .Values.pipelock.pdb.minAvailable }} + {{- else if .Values.pipelock.pdb.maxUnavailable }} + maxUnavailable: {{ .Values.pipelock.pdb.maxUnavailable }} + {{- end }} + selector: + matchLabels: + app.kubernetes.io/component: pipelock + {{- include "sure.selectorLabels" . | nindent 6 }} +{{- end }} diff --git a/charts/sure/templates/pipelock-service.yaml b/charts/sure/templates/pipelock-service.yaml index 01be758c7..c20cac0a4 100644 --- a/charts/sure/templates/pipelock-service.yaml +++ b/charts/sure/templates/pipelock-service.yaml @@ -13,6 +13,7 @@ metadata: name: {{ include "sure.fullname" . }}-pipelock labels: {{- include "sure.labels" . | nindent 4 }} + app.kubernetes.io/component: pipelock spec: type: {{ (.Values.pipelock.service).type | default "ClusterIP" }} selector: diff --git a/charts/sure/templates/pipelock-servicemonitor.yaml b/charts/sure/templates/pipelock-servicemonitor.yaml new file mode 100644 index 000000000..dfe2d2c54 --- /dev/null +++ b/charts/sure/templates/pipelock-servicemonitor.yaml @@ -0,0 +1,21 @@ +{{- if and .Values.pipelock.enabled .Values.pipelock.serviceMonitor.enabled }} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "sure.fullname" . }}-pipelock + labels: + {{- include "sure.labels" . | nindent 4 }} + {{- with .Values.pipelock.serviceMonitor.additionalLabels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + selector: + matchLabels: + app.kubernetes.io/component: pipelock + {{- include "sure.selectorLabels" . | nindent 6 }} + endpoints: + - interval: {{ .Values.pipelock.serviceMonitor.interval }} + scrapeTimeout: {{ .Values.pipelock.serviceMonitor.scrapeTimeout }} + path: {{ .Values.pipelock.serviceMonitor.path }} + port: {{ .Values.pipelock.serviceMonitor.portName }} +{{- end }} diff --git a/charts/sure/values.yaml b/charts/sure/values.yaml index ae3b34b3d..f6d473044 100644 --- a/charts/sure/values.yaml +++ b/charts/sure/values.yaml @@ -488,7 +488,7 @@ pipelock: enabled: false image: repository: ghcr.io/luckypipewrench/pipelock - tag: "0.3.1" + tag: "0.3.2" pullPolicy: IfNotPresent imagePullSecrets: [] replicas: 1 @@ -541,3 +541,52 @@ pipelock: nodeSelector: {} tolerations: [] affinity: {} + topologySpreadConstraints: [] + + # Prometheus Operator ServiceMonitor for /metrics on the proxy port + serviceMonitor: + enabled: false + interval: 30s + scrapeTimeout: 10s + path: /metrics + portName: proxy # matches Service port name "proxy" (8888) + additionalLabels: {} + + # Ingress for MCP reverse proxy (port 8889) — external AI assistants need this in k8s + ingress: + enabled: false + className: "" + annotations: {} + hosts: + - host: pipelock.local + paths: + - path: / + pathType: Prefix + tls: [] + + # PodDisruptionBudget — protects pipelock during node drains. + # WARNING: minAvailable with replicas=1 blocks eviction entirely. + # Use maxUnavailable: 1 for single-replica deployments, or increase replicas. + pdb: + enabled: false + minAvailable: "" # set to 1 when replicas > 1 + maxUnavailable: 1 # safe default: allows 1 pod to be evicted + + # Structured logging for k8s log aggregation + logging: + format: json + output: stdout + includeAllowed: false + includeBlocked: true + + # Escape hatch: ADDITIONAL config sections appended to pipelock.yaml. + # Use for sections not covered by structured values above (session_profiling, + # data_budget, adaptive_enforcement, kill_switch, internal CIDRs, etc.) + # Do NOT duplicate keys already rendered above — behavior is parser-dependent. + extraConfig: {} + + # Hard-fail helm template when externalAssistant is enabled without pipelock. + # NOTE: This only guards the rails.externalAssistant path. Direct MCP access + # (/mcp endpoint with MCP_API_TOKEN) is not detectable from Helm values. + # For full coverage, also ensure pipelock is enabled whenever MCP_API_TOKEN is set. + requireForExternalAssistant: false diff --git a/docs/hosting/docker.md b/docs/hosting/docker.md index 8fd6a25cf..09114ed09 100644 --- a/docs/hosting/docker.md +++ b/docs/hosting/docker.md @@ -152,6 +152,62 @@ Your app is now set up. You can visit it at `http://localhost:3000` in your brow If you find bugs or have a feature request, be sure to read through our [contributing guide here](https://github.com/we-promise/sure/wiki/How-to-Contribute-Effectively-to-Sure). +## AI features, external assistant, and Pipelock + +Sure ships with a separate compose file for AI-related features: `compose.example.ai.yml`. It adds: + +- **Pipelock** (always on): AI agent security proxy that scans outbound LLM calls and inbound MCP traffic +- **Ollama + Open WebUI** (optional `--profile ai`): local LLM inference + +### Using the AI compose file + +```bash +# Download both compose files +curl -o compose.yml https://raw.githubusercontent.com/we-promise/sure/main/compose.example.yml +curl -o compose.ai.yml https://raw.githubusercontent.com/we-promise/sure/main/compose.example.ai.yml +curl -o pipelock.example.yaml https://raw.githubusercontent.com/we-promise/sure/main/pipelock.example.yaml + +# Run with Pipelock (no local LLM) +docker compose -f compose.ai.yml up -d + +# Run with Pipelock + Ollama +docker compose -f compose.ai.yml --profile ai up -d +``` + +### Setting up the external AI assistant + +The external assistant delegates chat to a remote AI agent instead of calling LLMs directly. The agent calls back to Sure's `/mcp` endpoint for financial data (accounts, transactions, balance sheet). + +1. Set the MCP endpoint credentials in your `.env`: + ```bash + MCP_API_TOKEN=generate-a-random-token-here + MCP_USER_EMAIL=your@email.com # must match an existing Sure user + ``` + +2. Set the external assistant connection: + ```bash + EXTERNAL_ASSISTANT_URL=https://your-agent/v1/chat/completions + EXTERNAL_ASSISTANT_TOKEN=your-agent-api-token + ``` + +3. Choose how to activate: + - **Per-family (UI):** Go to Settings > Self-Hosting > AI Assistant, select "External" + - **Global (env):** Set `ASSISTANT_TYPE=external` to force all families to use external + +See [docs/hosting/ai.md](ai.md) for full configuration details including agent ID, session keys, and email allowlisting. + +### Pipelock security proxy + +Pipelock sits between Sure and external services, scanning AI traffic for: + +- **Secret exfiltration** (DLP): catches API keys, tokens, or personal data leaking in prompts +- **Prompt injection**: detects attempts to override system instructions +- **Tool poisoning**: validates MCP tool calls against known-safe patterns + +When using `compose.example.ai.yml`, Pipelock is always running. External AI agents should connect to port 8889 (MCP reverse proxy) instead of directly to Sure's `/mcp` on port 3000. + +For full Pipelock configuration, see [docs/hosting/pipelock.md](pipelock.md). + ## How to update your app The mechanism that updates your self-hosted Sure app is the GHCR (Github Container Registry) Docker image that you see in the `compose.yml` file: diff --git a/docs/hosting/pipelock.md b/docs/hosting/pipelock.md new file mode 100644 index 000000000..622253999 --- /dev/null +++ b/docs/hosting/pipelock.md @@ -0,0 +1,219 @@ +# Pipelock: AI Agent Security Proxy + +[Pipelock](https://github.com/luckyPipewrench/pipelock) is an optional security proxy that scans AI agent traffic flowing through Sure. It protects against secret exfiltration, prompt injection, and tool poisoning. + +## What Pipelock does + +Pipelock runs as a separate proxy service alongside Sure with two listeners: + +| Listener | Port | Direction | What it scans | +|----------|------|-----------|---------------| +| Forward proxy | 8888 | Outbound (Sure to LLM) | DLP (secrets in prompts), response injection | +| MCP reverse proxy | 8889 | Inbound (agent to Sure /mcp) | Prompt injection, tool poisoning, DLP | + +### Forward proxy (outbound) + +When `HTTPS_PROXY=http://pipelock:8888` is set, outbound HTTPS requests from Faraday-based clients (like `ruby-openai`) are routed through Pipelock. It scans request bodies for leaked secrets and response bodies for prompt injection. + +**Covered:** OpenAI API calls via ruby-openai (uses Faraday). +**Not covered:** SimpleFIN, Coinbase, Plaid, or anything using Net::HTTP/HTTParty directly. These bypass `HTTPS_PROXY`. + +### MCP reverse proxy (inbound) + +External AI assistants that call Sure's `/mcp` endpoint should connect through Pipelock on port 8889 instead of directly to port 3000. Pipelock scans: + +- Tool call arguments (DLP, shell obfuscation detection) +- Tool responses (injection payloads) +- Session binding (detects tool inventory manipulation) +- Tool call chains (multi-step attack patterns like recon then exfil) + +## Docker Compose setup + +The `compose.example.ai.yml` file includes Pipelock. To use it: + +1. Download the compose file and Pipelock config: + ```bash + curl -o compose.ai.yml https://raw.githubusercontent.com/we-promise/sure/main/compose.example.ai.yml + curl -o pipelock.example.yaml https://raw.githubusercontent.com/we-promise/sure/main/pipelock.example.yaml + ``` + +2. Start the stack: + ```bash + docker compose -f compose.ai.yml up -d + ``` + +3. Verify Pipelock is healthy: + ```bash + docker compose -f compose.ai.yml ps pipelock + # Should show "healthy" + ``` + +### Connecting external AI agents + +External agents should use the MCP reverse proxy port: + +```text +http://your-server:8889 +``` + +The agent must include the `MCP_API_TOKEN` as a Bearer token in requests. Set this in your `.env`: + +```bash +MCP_API_TOKEN=generate-a-random-token +MCP_USER_EMAIL=your@email.com +``` + +### Running without Pipelock + +To use `compose.example.ai.yml` without Pipelock, remove the `pipelock` service and its `depends_on` entries from `web` and `worker`, then unset the proxy env vars (`HTTPS_PROXY`, `HTTP_PROXY`). + +Or use the standard `compose.example.yml` which does not include Pipelock. + +## Helm (Kubernetes) setup + +Enable Pipelock in your Helm values: + +```yaml +pipelock: + enabled: true + image: + tag: "0.3.2" + mode: balanced +``` + +This creates a separate Deployment, Service, and ConfigMap. The chart auto-injects `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` into web and worker pods. + +### Exposing MCP to external agents (Kubernetes) + +In Kubernetes, external agents cannot reach the MCP port by default. Enable the Pipelock Ingress: + +```yaml +pipelock: + enabled: true + ingress: + enabled: true + className: nginx + hosts: + - host: pipelock.example.com + paths: + - path: / + pathType: Prefix + tls: + - hosts: [pipelock.example.com] + secretName: pipelock-tls +``` + +Or port-forward for testing: + +```bash +kubectl port-forward svc/sure-pipelock 8889:8889 -n sure +``` + +### Monitoring + +Enable the ServiceMonitor for Prometheus scraping: + +```yaml +pipelock: + serviceMonitor: + enabled: true + interval: 30s + additionalLabels: + release: prometheus +``` + +Metrics are available at `/metrics` on the forward proxy port (8888). + +### Eviction protection + +For production, enable the PodDisruptionBudget: + +```yaml +pipelock: + pdb: + enabled: true + maxUnavailable: 1 +``` + +See the [Helm chart README](../../charts/sure/README.md#pipelock-ai-agent-security-proxy) for all configuration options. + +## Pipelock configuration file + +The `pipelock.example.yaml` file (Docker Compose) or ConfigMap (Helm) controls scanning behavior. Key sections: + +| Section | What it controls | +|---------|-----------------| +| `mode` | `strict` (block threats), `balanced` (warn + block critical), `audit` (log only) | +| `forward_proxy` | Outbound HTTPS scanning (tunnel timeouts, idle timeouts) | +| `dlp` | Data loss prevention (scan env vars, built-in patterns) | +| `response_scanning` | Scan LLM responses for prompt injection | +| `mcp_input_scanning` | Scan inbound MCP requests | +| `mcp_tool_scanning` | Validate tool calls, detect drift | +| `mcp_tool_policy` | Pre-execution rules (shell obfuscation, etc.) | +| `mcp_session_binding` | Pin tool inventory, detect manipulation | +| `tool_chain_detection` | Multi-step attack patterns | +| `websocket_proxy` | WebSocket frame scanning (disabled by default) | +| `logging` | Output format (json/text), verbosity | + +For the Helm chart, most sections are configurable via `values.yaml`. For additional sections not covered by structured values (session profiling, data budgets, kill switch), use the `extraConfig` escape hatch: + +```yaml +pipelock: + extraConfig: + session_profiling: + enabled: true + max_sessions: 1000 +``` + +## Modes + +| Mode | Behavior | Use case | +|------|----------|----------| +| `strict` | Block all detected threats | Production with sensitive data | +| `balanced` | Warn on low-severity, block on high-severity | Default; good for most deployments | +| `audit` | Log everything, block nothing | Initial rollout, testing | + +Start with `audit` mode to see what Pipelock detects without blocking anything. Review the logs, then switch to `balanced` or `strict`. + +## Limitations + +- Forward proxy only covers Faraday-based HTTP clients. Net::HTTP, HTTParty, and other libraries ignore `HTTPS_PROXY`. +- Docker Compose has no egress network policies. The `/mcp` endpoint on port 3000 is still reachable directly (auth token required). For enforcement, use Kubernetes NetworkPolicies. +- Pipelock scans text content. Binary payloads (images, file uploads) are passed through by default. + +## Troubleshooting + +### Pipelock container not starting + +Check the config file is mounted correctly: +```bash +docker compose -f compose.ai.yml logs pipelock +``` + +Common issues: +- Missing `pipelock.example.yaml` file +- YAML syntax errors in config +- Port conflicts (8888 or 8889 already in use) + +### LLM calls failing with proxy errors + +If AI chat stops working after enabling Pipelock: +```bash +# Check Pipelock logs for blocked requests +docker compose -f compose.ai.yml logs pipelock --tail=50 +``` + +If requests are being incorrectly blocked, switch to `audit` mode in the config file and restart: +```yaml +mode: audit +``` + +### MCP requests not reaching Sure + +Verify the MCP upstream is configured correctly: +```bash +# Test from inside the Pipelock container +docker compose -f compose.ai.yml exec pipelock /pipelock healthcheck --addr 127.0.0.1:8888 +``` + +Check that `MCP_API_TOKEN` and `MCP_USER_EMAIL` are set in your `.env` file and that the email matches an existing Sure user. From 69bb4f6944b331a94f12097251bf4739294e1893 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 3 Mar 2026 15:43:44 +0000 Subject: [PATCH 23/75] Bump version to next iteration after v0.6.9-alpha.1 release --- charts/sure/Chart.yaml | 4 ++-- config/initializers/version.rb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/charts/sure/Chart.yaml b/charts/sure/Chart.yaml index 471fa1006..af56cf0a1 100644 --- a/charts/sure/Chart.yaml +++ b/charts/sure/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: sure description: Official Helm chart for deploying the Sure Rails app (web + Sidekiq) on Kubernetes with optional HA PostgreSQL (CloudNativePG) and Redis. type: application -version: 0.6.9-alpha.1 -appVersion: "0.6.9-alpha.1" +version: 0.6.9-alpha.2 +appVersion: "0.6.9-alpha.2" kubeVersion: ">=1.25.0-0" diff --git a/config/initializers/version.rb b/config/initializers/version.rb index e4d07100d..68783d1b6 100644 --- a/config/initializers/version.rb +++ b/config/initializers/version.rb @@ -16,7 +16,7 @@ module Sure private def semver - "0.6.9-alpha.1" + "0.6.9-alpha.2" end end end From 15eaf13b3efca8154d4297b8d0d15e207079c397 Mon Sep 17 00:00:00 2001 From: LPW Date: Tue, 3 Mar 2026 15:10:53 -0500 Subject: [PATCH 24/75] Backfill category for pre-#924 investment contribution transfers (#1111) Transfers created before PR #924 have kind='investment_contribution' but category_id=NULL because auto-categorization was only added to Transfer::Creator, not the other code paths. PR #924 fixed it going forward. This migration catches the old ones. Only updates transactions where category_id IS NULL so it never overwrites user choices. Skips families without the category. --- ...fill_investment_contribution_categories.rb | 56 +++++++++++++++++++ db/schema.rb | 2 +- 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20260303120000_backfill_investment_contribution_categories.rb diff --git a/db/migrate/20260303120000_backfill_investment_contribution_categories.rb b/db/migrate/20260303120000_backfill_investment_contribution_categories.rb new file mode 100644 index 000000000..8a010d8b4 --- /dev/null +++ b/db/migrate/20260303120000_backfill_investment_contribution_categories.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +class BackfillInvestmentContributionCategories < ActiveRecord::Migration[7.2] + def up + # PR #924 fixed auto-categorization of investment contributions going forward, + # but transfers created before that PR have kind = 'investment_contribution' + # with category_id NULL. This backfill assigns the correct category to those + # transactions using the family's existing "Investment Contributions" category. + # + # Safety: + # - Only updates transactions where category_id IS NULL (never overwrites user choices) + # - Only updates transactions that already have kind = 'investment_contribution' + # - Skips families that don't have an Investment Contributions category yet + # (it will be lazily created on their next new transfer) + # - If a family has duplicate locale-variant categories, picks the oldest one + # (matches Family#investment_contributions_category dedup behavior) + + # Static snapshot of Category.all_investment_contributions_names at migration time. + # Inlined to avoid coupling to app code that may change after this migration ships. + locale_names = [ + "Investment Contributions", + "Contributions aux investissements", + "Contribucions d'inversió", + "Investeringsbijdragen" + ] + + quoted_names = locale_names.map { |n| connection.quote(n) }.join(", ") + + say_with_time "Backfilling category for investment_contribution transactions" do + execute <<-SQL.squish + UPDATE transactions + SET category_id = matched_category.id + FROM entries, accounts, + LATERAL ( + SELECT c.id + FROM categories c + WHERE c.family_id = accounts.family_id + AND c.name IN (#{quoted_names}) + ORDER BY c.created_at ASC + LIMIT 1 + ) AS matched_category + WHERE transactions.kind = 'investment_contribution' + AND transactions.category_id IS NULL + AND entries.entryable_id = transactions.id + AND entries.entryable_type = 'Transaction' + AND accounts.id = entries.account_id + SQL + end + end + + def down + # No-op: we cannot distinguish backfilled records from ones that were + # categorized at creation time, so reverting would incorrectly clear + # legitimately assigned categories. + end +end diff --git a/db/schema.rb b/db/schema.rb index 82bfd1e0f..a1cc7b39f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_02_18_120001) do +ActiveRecord::Schema[7.2].define(version: 2026_03_03_120000) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" From 69f4e47d68dd6b7ed6eaee9589964c90a554c730 Mon Sep 17 00:00:00 2001 From: Alessio Cappa <104093777+alessiocappa@users.noreply.github.com> Date: Tue, 3 Mar 2026 21:12:27 +0100 Subject: [PATCH 25/75] Add safe-area padding for PWA on import page (#1113) --- app/views/layouts/imports.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/layouts/imports.html.erb b/app/views/layouts/imports.html.erb index 09f15cf04..e68034e10 100644 --- a/app/views/layouts/imports.html.erb +++ b/app/views/layouts/imports.html.erb @@ -1,5 +1,5 @@ <%= render "layouts/shared/htmldoc" do %> -
    +
    <%= render DS::Link.new( variant: "icon", From 158e18cd0575199b5858ff272a5f50fd23231f6f Mon Sep 17 00:00:00 2001 From: lolimmlost Date: Tue, 3 Mar 2026 12:13:59 -0800 Subject: [PATCH 26/75] Add budget rollover: copy from previous month (#1100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add budget rollover: copy from previous month When navigating to an uninitialized budget month, show a prompt offering to copy amounts from the most recent initialized budget. Copies budgeted_spending, expected_income, and all matching category allocations. Also fixes over-allocation warning showing on uninitialized budgets. Co-Authored-By: Claude Opus 4.6 * Redirect copy_previous to categories wizard for review Matches the normal budget setup flow (edit → categories → show) so users can review/tweak copied allocations before confirming. Co-Authored-By: Claude Opus 4.6 * Address code review: eager-load categories, guard against overwrite - Add .includes(:budget_categories) to most_recent_initialized_budget to avoid N+1 when copy_from! iterates source categories - Guard copy_previous action against overwriting already-initialized budgets (prevents crafted POST from clobbering existing data) - Add i18n key for already_initialized flash message Co-Authored-By: Claude Opus 4.6 * Add invariant guards to copy_from! for defensive safety Validate that source budget belongs to the same family and precedes the target budget before copying. Protects against misuse from other callers beyond the controller. Co-Authored-By: Claude Opus 4.6 * Fix button overflow on small screens in copy previous prompt Stack buttons vertically on mobile, side-by-side on sm+ breakpoint. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- app/controllers/budgets_controller.rb | 19 +++- app/models/budget.rb | 30 ++++++ .../budgets/_copy_previous_prompt.html.erb | 26 +++++ app/views/budgets/show.html.erb | 4 +- config/locales/views/budgets/en.yml | 9 ++ config/routes.rb | 1 + test/models/budget_test.rb | 95 +++++++++++++++++++ 7 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 app/views/budgets/_copy_previous_prompt.html.erb diff --git a/app/controllers/budgets_controller.rb b/app/controllers/budgets_controller.rb index 1ec8e81b6..837306ce7 100644 --- a/app/controllers/budgets_controller.rb +++ b/app/controllers/budgets_controller.rb @@ -1,11 +1,12 @@ class BudgetsController < ApplicationController - before_action :set_budget, only: %i[show edit update] + before_action :set_budget, only: %i[show edit update copy_previous] def index redirect_to_current_month_budget end def show + @source_budget = @budget.most_recent_initialized_budget unless @budget.initialized? end def edit @@ -17,6 +18,22 @@ class BudgetsController < ApplicationController redirect_to budget_budget_categories_path(@budget) end + def copy_previous + if @budget.initialized? + redirect_to budget_path(@budget), alert: t("budgets.copy_previous.already_initialized") + return + end + + source_budget = @budget.most_recent_initialized_budget + + if source_budget + @budget.copy_from!(source_budget) + redirect_to budget_budget_categories_path(@budget), notice: t("budgets.copy_previous.success", source_name: source_budget.name) + else + redirect_to budget_path(@budget), alert: t("budgets.copy_previous.no_source") + end + end + def picker render partial: "budgets/picker", locals: { family: Current.family, diff --git a/app/models/budget.rb b/app/models/budget.rb index c345801fc..aa8f900b8 100644 --- a/app/models/budget.rb +++ b/app/models/budget.rb @@ -126,6 +126,36 @@ class Budget < ApplicationRecord budgeted_spending.present? end + def most_recent_initialized_budget + family.budgets + .includes(:budget_categories) + .where("start_date < ?", start_date) + .where.not(budgeted_spending: nil) + .order(start_date: :desc) + .first + end + + def copy_from!(source_budget) + raise ArgumentError, "source budget must belong to the same family" unless source_budget.family_id == family_id + raise ArgumentError, "source budget must precede target budget" unless source_budget.start_date < start_date + + Budget.transaction do + update!( + budgeted_spending: source_budget.budgeted_spending, + expected_income: source_budget.expected_income + ) + + target_by_category = budget_categories.index_by(&:category_id) + + source_budget.budget_categories.each do |source_bc| + target_bc = target_by_category[source_bc.category_id] + next unless target_bc + + target_bc.update!(budgeted_spending: source_bc.budgeted_spending) + end + end + end + def income_category_totals income_totals.category_totals.reject { |ct| ct.category.subcategory? || ct.total.zero? }.sort_by(&:weight).reverse end diff --git a/app/views/budgets/_copy_previous_prompt.html.erb b/app/views/budgets/_copy_previous_prompt.html.erb new file mode 100644 index 000000000..8edd87c1d --- /dev/null +++ b/app/views/budgets/_copy_previous_prompt.html.erb @@ -0,0 +1,26 @@ +<%# locals: (budget:, source_budget:) %> + +
    + <%= icon "copy", size: "lg" %> + +
    +

    <%= t("budgets.copy_previous_prompt.title") %>

    +

    <%= t("budgets.copy_previous_prompt.description", source_name: source_budget.name) %>

    +
    + +
    + <%= render DS::Button.new( + text: t("budgets.copy_previous_prompt.copy_button", source_name: source_budget.name), + href: copy_previous_budget_path(budget), + method: :post, + icon: "copy" + ) %> + + <%= render DS::Link.new( + text: t("budgets.copy_previous_prompt.fresh_button"), + variant: "secondary", + icon: "plus", + href: edit_budget_path(budget) + ) %> +
    +
    diff --git a/app/views/budgets/show.html.erb b/app/views/budgets/show.html.erb index 33eea2d50..8b810538e 100644 --- a/app/views/budgets/show.html.erb +++ b/app/views/budgets/show.html.erb @@ -10,7 +10,9 @@
    <%# Budget Donut %>
    - <% if @budget.available_to_allocate.negative? %> + <% if !@budget.initialized? && @source_budget.present? %> + <%= render "budgets/copy_previous_prompt", budget: @budget, source_budget: @source_budget %> + <% elsif @budget.initialized? && @budget.available_to_allocate.negative? %> <%= render "budgets/over_allocation_warning", budget: @budget %> <% else %> <%= render "budgets/budget_donut", budget: @budget %> diff --git a/config/locales/views/budgets/en.yml b/config/locales/views/budgets/en.yml index 6f98a5686..c727dc37c 100644 --- a/config/locales/views/budgets/en.yml +++ b/config/locales/views/budgets/en.yml @@ -8,3 +8,12 @@ en: tabs: actual: Actual budgeted: Budgeted + copy_previous_prompt: + title: "Set up your budget" + description: "You can copy your budget from %{source_name} or start fresh." + copy_button: "Copy from %{source_name}" + fresh_button: "Start fresh" + copy_previous: + success: "Budget copied from %{source_name}" + no_source: "No previous budget found to copy from" + already_initialized: "This budget has already been set up" diff --git a/config/routes.rb b/config/routes.rb index 1e5097fd2..f5e2fb767 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -211,6 +211,7 @@ Rails.application.routes.draw do end resources :budgets, only: %i[index show edit update], param: :month_year do + post :copy_previous, on: :member get :picker, on: :collection resources :budget_categories, only: %i[index show update] diff --git a/test/models/budget_test.rb b/test/models/budget_test.rb index cd3e95307..1896e887a 100644 --- a/test/models/budget_test.rb +++ b/test/models/budget_test.rb @@ -199,6 +199,101 @@ class BudgetTest < ActiveSupport::TestCase assert_equal 150, spending_without_refund - spending_with_refund end + test "most_recent_initialized_budget returns latest initialized budget before this one" do + family = families(:dylan_family) + + # Create an older initialized budget (2 months ago) + older_budget = Budget.create!( + family: family, + start_date: 2.months.ago.beginning_of_month, + end_date: 2.months.ago.end_of_month, + budgeted_spending: 3000, + expected_income: 5000, + currency: "USD" + ) + + # Create a middle uninitialized budget (1 month ago) + Budget.create!( + family: family, + start_date: 1.month.ago.beginning_of_month, + end_date: 1.month.ago.end_of_month, + currency: "USD" + ) + + current_budget = Budget.find_or_bootstrap(family, start_date: Date.current) + + assert_equal older_budget, current_budget.most_recent_initialized_budget + end + + test "most_recent_initialized_budget returns nil when none exist" do + family = families(:empty) + budget = Budget.create!( + family: family, + start_date: Date.current.beginning_of_month, + end_date: Date.current.end_of_month, + currency: "USD" + ) + + assert_nil budget.most_recent_initialized_budget + end + + test "copy_from copies budgeted_spending expected_income and matching category budgets" do + family = families(:dylan_family) + + # Use past months to avoid fixture conflict (fixture :one is at Date.current for dylan_family) + source_budget = Budget.find_or_bootstrap(family, start_date: 2.months.ago) + source_budget.update!(budgeted_spending: 4000, expected_income: 6000) + source_bc = source_budget.budget_categories.find_by(category: categories(:food_and_drink)) + source_bc.update!(budgeted_spending: 500) + + target_budget = Budget.find_or_bootstrap(family, start_date: 1.month.ago) + assert_nil target_budget.budgeted_spending + + target_budget.copy_from!(source_budget) + target_budget.reload + + assert_equal 4000, target_budget.budgeted_spending + assert_equal 6000, target_budget.expected_income + + target_bc = target_budget.budget_categories.find_by(category: categories(:food_and_drink)) + assert_equal 500, target_bc.budgeted_spending + end + + test "copy_from skips categories that dont exist in target" do + family = families(:dylan_family) + + source_budget = Budget.find_or_bootstrap(family, start_date: 2.months.ago) + source_budget.update!(budgeted_spending: 4000, expected_income: 6000) + + # Create a category only in the source budget + temp_category = Category.create!(name: "Temp #{Time.now.to_f}", family: family, color: "#aaa", classification: "expense") + source_budget.budget_categories.create!(category: temp_category, budgeted_spending: 100, currency: "USD") + + target_budget = Budget.find_or_bootstrap(family, start_date: 1.month.ago) + + # Should not raise even though target doesn't have the temp category + assert_nothing_raised { target_budget.copy_from!(source_budget) } + assert_equal 4000, target_budget.reload.budgeted_spending + end + + test "copy_from leaves new categories at zero" do + family = families(:dylan_family) + + source_budget = Budget.find_or_bootstrap(family, start_date: 2.months.ago) + source_budget.update!(budgeted_spending: 4000, expected_income: 6000) + + target_budget = Budget.find_or_bootstrap(family, start_date: 1.month.ago) + + # Add a new category only to the target + new_category = Category.create!(name: "New #{Time.now.to_f}", family: family, color: "#bbb", classification: "expense") + target_budget.budget_categories.create!(category: new_category, budgeted_spending: 0, currency: "USD") + + target_budget.copy_from!(source_budget) + + new_bc = target_budget.budget_categories.find_by(category: new_category) + assert_equal 0, new_bc.budgeted_spending + end + test "previous_budget_param returns param when date is valid" do budget = Budget.create!( family: @family, From 97195d9f13b09b6b7586fd3a3b15d3dcde489cd8 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 21:15:18 +0100 Subject: [PATCH 27/75] fix: Parse transfer date parameter (#1110) Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> --- app/controllers/transfers_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/transfers_controller.rb b/app/controllers/transfers_controller.rb index 248f8d4ad..9579cadd0 100644 --- a/app/controllers/transfers_controller.rb +++ b/app/controllers/transfers_controller.rb @@ -17,7 +17,7 @@ class TransfersController < ApplicationController family: Current.family, source_account_id: transfer_params[:from_account_id], destination_account_id: transfer_params[:to_account_id], - date: transfer_params[:date], + date: Date.parse(transfer_params[:date]), amount: transfer_params[:amount].to_d ).create From e66f9543f29743301440afbf3b627d6aea54702b Mon Sep 17 00:00:00 2001 From: Juan Manuel Reyes Date: Wed, 4 Mar 2026 02:02:19 -0800 Subject: [PATCH 28/75] Fix uncategorized budget category showing incorrect available_to_spend (#1117) The `subcategories` method queries `WHERE parent_id = category.id`, but for the synthetic uncategorized budget category, `category.id` is nil. This caused `WHERE parent_id IS NULL` to match ALL top-level categories, making them appear as subcategories of uncategorized. This inflated actual_spending and produced a large negative available_to_spend. Add a nil guard on category.id to return an empty relation for synthetic categories. Fixes #819 Co-authored-by: Claude Opus 4.6 --- app/models/budget_category.rb | 1 + test/models/budget_category_test.rb | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/app/models/budget_category.rb b/app/models/budget_category.rb index 27d999703..85c355861 100644 --- a/app/models/budget_category.rb +++ b/app/models/budget_category.rb @@ -209,6 +209,7 @@ class BudgetCategory < ApplicationRecord def subcategories return BudgetCategory.none unless category.parent_id.nil? + return BudgetCategory.none if category.id.nil? budget.budget_categories .joins(:category) diff --git a/test/models/budget_category_test.rb b/test/models/budget_category_test.rb index 61335265e..4e16c4f88 100644 --- a/test/models/budget_category_test.rb +++ b/test/models/budget_category_test.rb @@ -162,6 +162,15 @@ class BudgetCategoryTest < ActiveSupport::TestCase assert_equal 40.0, standalone_bc.percent_of_budget_spent end + test "uncategorized budget category returns no subcategories" do + uncategorized_bc = BudgetCategory.uncategorized + uncategorized_bc.budget = @budget + + # Before the fix, this would return all top-level categories because + # category.id is nil, causing WHERE parent_id IS NULL to match all roots + assert_empty uncategorized_bc.subcategories + end + test "parent with only inheriting subcategories shares entire budget" do # Set subcategory_with_limit to also inherit @subcategory_with_limit_bc.update!(budgeted_spending: 0) From 4a3a55d76743ac77b22e5330601a3e26ea6c9709 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreu=20Gordillo=20V=C3=A1zquez?= Date: Wed, 4 Mar 2026 11:12:53 +0100 Subject: [PATCH 29/75] Complete Spanish (es) translations across all locale files (#1112) * Complete Spanish (es) translations across all locale files Add missing translations for 44 locale files covering views, models, mailers, and defaults. Includes 18 new es.yml files and updates to 26 existing ones. * Small fixes * Fix CodeRabbit comments --- config/locales/defaults/es.yml | 5 + .../locales/mailers/pdf_import_mailer/es.yml | 5 + config/locales/models/category/es.yml | 7 + config/locales/models/coinbase_account/es.yml | 5 + config/locales/models/coinstats_item/es.yml | 10 + config/locales/views/accounts/es.yml | 59 ++++- .../locales/views/admin/sso_providers/es.yml | 115 +++++++++ config/locales/views/admin/users/es.yml | 45 ++++ config/locales/views/budgets/es.yml | 10 + config/locales/views/chats/es.yml | 5 + config/locales/views/coinbase_items/es.yml | 78 ++++++ config/locales/views/coinstats_items/es.yml | 63 +++++ config/locales/views/components/es.yml | 66 +++++ config/locales/views/cryptos/es.yml | 13 + .../locales/views/enable_banking_items/es.yml | 49 ++++ config/locales/views/entries/es.yml | 9 + config/locales/views/holdings/es.yml | 53 +++- config/locales/views/imports/es.yml | 80 +++++- .../locales/views/indexa_capital_items/es.yml | 241 ++++++++++++++++++ config/locales/views/investments/es.yml | 111 +++++++- config/locales/views/invitations/es.yml | 10 +- config/locales/views/lunchflow_items/es.yml | 84 +++++- config/locales/views/merchants/es.yml | 34 ++- config/locales/views/mercury_items/es.yml | 147 +++++++++++ config/locales/views/onboardings/es.yml | 5 + config/locales/views/other_assets/es.yml | 2 + config/locales/views/pages/es.yml | 27 ++ config/locales/views/password_resets/es.yml | 9 +- config/locales/views/pdf_import_mailer/es.yml | 17 ++ config/locales/views/plaid_items/es.yml | 11 +- .../views/recurring_transactions/es.yml | 17 +- config/locales/views/registrations/es.yml | 12 +- config/locales/views/reports/es.yml | 21 +- config/locales/views/rules/es.yml | 27 ++ config/locales/views/securities/es.yml | 6 + config/locales/views/sessions/es.yml | 26 +- config/locales/views/settings/es.yml | 56 +++- config/locales/views/settings/hostings/es.yml | 73 +++++- .../views/settings/sso_identities/es.yml | 7 + config/locales/views/simplefin_items/es.yml | 100 ++++++-- config/locales/views/snaptrade_items/es.yml | 188 ++++++++++++++ config/locales/views/trades/es.yml | 5 + config/locales/views/transactions/es.yml | 116 ++++++++- config/locales/views/users/es.yml | 3 + 44 files changed, 1946 insertions(+), 86 deletions(-) create mode 100644 config/locales/mailers/pdf_import_mailer/es.yml create mode 100644 config/locales/models/category/es.yml create mode 100644 config/locales/models/coinbase_account/es.yml create mode 100644 config/locales/models/coinstats_item/es.yml create mode 100644 config/locales/views/admin/sso_providers/es.yml create mode 100644 config/locales/views/admin/users/es.yml create mode 100644 config/locales/views/budgets/es.yml create mode 100644 config/locales/views/chats/es.yml create mode 100644 config/locales/views/coinbase_items/es.yml create mode 100644 config/locales/views/coinstats_items/es.yml create mode 100644 config/locales/views/components/es.yml create mode 100644 config/locales/views/enable_banking_items/es.yml create mode 100644 config/locales/views/indexa_capital_items/es.yml create mode 100644 config/locales/views/mercury_items/es.yml create mode 100644 config/locales/views/pdf_import_mailer/es.yml create mode 100644 config/locales/views/securities/es.yml create mode 100644 config/locales/views/settings/sso_identities/es.yml create mode 100644 config/locales/views/snaptrade_items/es.yml diff --git a/config/locales/defaults/es.yml b/config/locales/defaults/es.yml index 96757c381..82b73c583 100644 --- a/config/locales/defaults/es.yml +++ b/config/locales/defaults/es.yml @@ -1,5 +1,10 @@ --- es: + defaults: + brand_name: "%{brand_name}" + product_name: "%{product_name}" + global: + expand: "Expandir" activerecord: errors: messages: diff --git a/config/locales/mailers/pdf_import_mailer/es.yml b/config/locales/mailers/pdf_import_mailer/es.yml new file mode 100644 index 000000000..d5d423152 --- /dev/null +++ b/config/locales/mailers/pdf_import_mailer/es.yml @@ -0,0 +1,5 @@ +--- +es: + pdf_import_mailer: + next_steps: + subject: "Tu documento PDF ha sido analizado - %{product_name}" \ No newline at end of file diff --git a/config/locales/models/category/es.yml b/config/locales/models/category/es.yml new file mode 100644 index 000000000..90a24cd55 --- /dev/null +++ b/config/locales/models/category/es.yml @@ -0,0 +1,7 @@ +--- +es: + models: + category: + uncategorized: Sin clasificar + other_investments: Otras inversiones + investment_contributions: Aportaciones a inversiones \ No newline at end of file diff --git a/config/locales/models/coinbase_account/es.yml b/config/locales/models/coinbase_account/es.yml new file mode 100644 index 000000000..88904258c --- /dev/null +++ b/config/locales/models/coinbase_account/es.yml @@ -0,0 +1,5 @@ +--- +es: + coinbase: + processor: + paid_via: "Pagado mediante %{method}" \ No newline at end of file diff --git a/config/locales/models/coinstats_item/es.yml b/config/locales/models/coinstats_item/es.yml new file mode 100644 index 000000000..c2bc6d3fc --- /dev/null +++ b/config/locales/models/coinstats_item/es.yml @@ -0,0 +1,10 @@ +--- +es: + models: + coinstats_item: + syncer: + importing_wallets: Importando carteras desde CoinStats... + checking_configuration: Comprobando la configuración de la cartera... + wallets_need_setup: "%{count} carteras necesitan configuración..." + processing_holdings: Procesando activos... + calculating_balances: Calculando saldos... \ No newline at end of file diff --git a/config/locales/views/accounts/es.yml b/config/locales/views/accounts/es.yml index c99a987a1..2639652df 100644 --- a/config/locales/views/accounts/es.yml +++ b/config/locales/views/accounts/es.yml @@ -3,6 +3,8 @@ es: accounts: account: link_lunchflow: Vincular con Lunch Flow + link_provider: Vincular con proveedor + unlink_provider: Desvincular de proveedor troubleshoot: Solucionar problemas chart: data_not_available: Datos no disponibles para el período seleccionado @@ -10,6 +12,7 @@ es: success: "Cuenta %{type} creada" destroy: success: "Cuenta %{type} programada para eliminación" + cannot_delete_linked: "No se puede eliminar una cuenta vinculada. Por favor, desvincúlela primero." empty: empty_message: Añade una cuenta mediante conexión, importación o introducción manual. new_account: Nueva cuenta @@ -18,12 +21,21 @@ es: balance: Saldo actual name_label: Nombre de la cuenta name_placeholder: Ejemplo de nombre de cuenta + additional_details: Detalles adicionales + institution_name_label: Nombre de la institución + institution_name_placeholder: ej. Chase Bank + institution_domain_label: Dominio de la institución + institution_domain_placeholder: ej. chase.com + notes_label: Notas + notes_placeholder: Guarda información adicional como números de cuenta, códigos de sucursal, IBAN, números de ruta, etc. index: accounts: Cuentas manual_accounts: other_accounts: Otras cuentas new_account: Nueva cuenta sync: Sincronizar todo + sync_all: + syncing: "Sincronizando cuentas..." new: import_accounts: Importar cuentas method_selector: @@ -38,15 +50,22 @@ es: activity: amount: Cantidad balance: Saldo + confirmed: Confirmado date: Fecha entries: entradas entry: entrada + filter: Filtrar new: Nuevo + new_activity: Nueva actividad new_balance: Nuevo saldo + new_trade: Nueva operación new_transaction: Nueva transacción + new_transfer: Nueva transferencia no_entries: No se encontraron entradas + pending: Pendiente search: placeholder: Buscar entradas por nombre + status: Estado title: Actividad chart: balance: Saldo @@ -57,6 +76,8 @@ es: confirm_title: ¿Eliminar cuenta? edit: Editar import: Importar transacciones + import_trades: Importar operaciones + import_transactions: Importar transacciones manage: Gestionar cuentas update: success: "Cuenta %{type} actualizada" @@ -82,8 +103,44 @@ es: credit_card: Tarjeta de crédito loan: Préstamo other_liability: Otra deuda + tax_treatments: + taxable: Sujeto a impuestos + tax_deferred: Impuestos diferidos + tax_exempt: Exento de impuestos + tax_advantaged: Ventaja fiscal + tax_treatment_descriptions: + taxable: Ganancias gravadas al realizarse + tax_deferred: Aportaciones deducibles, impuestos al retirar + tax_exempt: Aportaciones después de impuestos, ganancias exentas + tax_advantaged: Beneficios fiscales especiales con condiciones + subtype_regions: + us: Estados Unidos + uk: Reino Unido + ca: Canadá + au: Australia + eu: Europa + generic: General + confirm_unlink: + title: ¿Desvincular cuenta del proveedor? + description_html: "Estás a punto de desvincular %{account_name} de %{provider_name}. Esto la convertirá en una cuenta manual." + warning_title: Qué significa esto + warning_no_sync: La cuenta dejará de sincronizarse automáticamente con tu proveedor + warning_manual_updates: Deberás añadir transacciones y actualizar saldos manualmente + warning_transactions_kept: Se conservarán todas las transacciones y saldos existentes + warning_can_delete: Tras desvincularla, podrás eliminar la cuenta si es necesario + confirm_button: Confirmar y desvincular + unlink: + success: "Cuenta desvinculada correctamente. Ahora es una cuenta manual." + not_linked: "La cuenta no está vinculada a un proveedor" + error: "Error al desvincular la cuenta: %{error}" + generic_error: "Ha ocurrido un error inesperado. Por favor, inténtalo de nuevo." + select_provider: + title: Selecciona un proveedor para vincular + description: "Elige qué proveedor quieres usar para vincular %{account_name}" + already_linked: "La cuenta ya está vinculada a un proveedor" + no_providers: "No hay proveedores configurados actualmente" email_confirmations: new: invalid_token: Enlace de confirmación inválido o caducado. - success_login: Tu correo electrónico ha sido confirmado. Por favor, inicia sesión con tu nueva dirección de correo electrónico. + success_login: Tu correo electrónico ha sido confirmado. Por favor, inicia sesión con tu nueva dirección de correo electrónico. \ No newline at end of file diff --git a/config/locales/views/admin/sso_providers/es.yml b/config/locales/views/admin/sso_providers/es.yml new file mode 100644 index 000000000..ab9567955 --- /dev/null +++ b/config/locales/views/admin/sso_providers/es.yml @@ -0,0 +1,115 @@ +--- +es: + admin: + unauthorized: "No tienes autorización para acceder a esta área." + sso_providers: + index: + title: "Proveedores de SSO" + description: "Gestiona los proveedores de autenticación de inicio de sesión único para tu instancia" + add_provider: "Añadir proveedor" + no_providers_title: "No hay proveedores de SSO" + no_providers_message: "Empieza añadiendo tu primer proveedor de SSO." + note: "Los cambios en los proveedores de SSO requieren un reinicio del servidor para surtir efecto. Alternativamente, activa la función AUTH_PROVIDERS_SOURCE=db para cargar los proveedores desde la base de datos de forma dinámica." + table: + name: "Nombre" + strategy: "Estrategia" + status: "Estado" + issuer: "Emisor (Issuer)" + actions: "Acciones" + enabled: "Activado" + disabled: "Desactivado" + legacy_providers_title: "Proveedores configurados por entorno" + legacy_providers_notice: "Estos proveedores se configuran mediante variables de entorno o YAML y no pueden gestionarse a través de esta interfaz. Para gestionarlos aquí, mígralos a proveedores respaldados por la base de datos activando AUTH_PROVIDERS_SOURCE=db y recreándolos en la interfaz de usuario." + env_configured: "Env/YAML" + new: + title: "Añadir proveedor de SSO" + description: "Configura un nuevo proveedor de autenticación de inicio de sesión único" + edit: + title: "Editar proveedor de SSO" + description: "Actualizar configuración para %{label}" + create: + success: "El proveedor de SSO se ha creado correctamente." + update: + success: "El proveedor de SSO se ha actualizado correctamente." + destroy: + success: "El proveedor de SSO se ha eliminado correctamente." + confirm: "¿Estás seguro de que quieres eliminar este proveedor? Esta acción no se puede deshacer." + toggle: + success_enabled: "El proveedor de SSO se ha activado correctamente." + success_disabled: "El proveedor de SSO se ha desactivado correctamente." + confirm_enable: "¿Estás seguro de que quieres activar este proveedor?" + confirm_disable: "¿Estás seguro de que quieres desactivar este proveedor?" + form: + basic_information: "Información básica" + oauth_configuration: "Configuración OAuth/OIDC" + strategy_label: "Estrategia" + strategy_help: "La estrategia de autenticación a utilizar" + name_label: "Nombre" + name_placeholder: "ej. openid_connect, keycloak, authentik" + name_help: "Identificador único (solo minúsculas, números y guiones bajos)" + label_label: "Etiqueta" + label_placeholder: "ej. Iniciar sesión con Keycloak" + label_help: "Texto del botón mostrado a los usuarios" + icon_label: "Icono" + icon_placeholder: "ej. key, google, github" + icon_help: "Nombre del icono de Lucide (opcional)" + enabled_label: "Activar este proveedor" + enabled_help: "Los usuarios pueden iniciar sesión con este proveedor cuando está activado" + issuer_label: "Emisor (Issuer)" + issuer_placeholder: "https://accounts.google.com" + issuer_help: "URL del emisor OIDC (validará el punto de conexión .well-known/openid-configuration)" + client_id_label: "ID de cliente (Client ID)" + client_id_placeholder: "tu-id-de-cliente" + client_id_help: "ID de cliente OAuth de tu proveedor de identidad" + client_secret_label: "Secreto de cliente (Client Secret)" + client_secret_placeholder_new: "tu-secreto-de-cliente" + client_secret_placeholder_existing: "••••••••••••••••" + client_secret_help: "Secreto de cliente OAuth (encriptado en la base de datos)" + client_secret_help_existing: " - dejar en blanco para mantener el actual" + redirect_uri_label: "URI de redirección" + redirect_uri_placeholder: "https://tudominio.com/auth/openid_connect/callback" + redirect_uri_help: "URL de retorno para configurar en tu proveedor de identidad" + copy_button: "Copiar" + cancel: "Cancelar" + submit: "Guardar proveedor" + errors_title: "%{count} error impidió que se guardara este proveedor:" + provisioning_title: "Aprovisionamiento de usuarios" + default_role_label: "Rol predeterminado para nuevos usuarios" + default_role_help: "Rol asignado a los usuarios creados mediante el aprovisionamiento de cuentas SSO Just-In-Time (JIT). Por defecto es Miembro." + role_guest: "Invitado" + role_member: "Miembro" + role_admin: "Administrador" + role_super_admin: "Superadministrador" + role_mapping_title: "Mapeo de Grupos a Roles (Opcional)" + role_mapping_help: "Mapea grupos/notificaciones del IdP a roles de la aplicación. A los usuarios se les asigna el rol más alto coincidente. Deja en blanco para usar el rol predeterminado de arriba." + super_admin_groups: "Grupos de Superadministrador" + admin_groups: "Grupos de Administrador" + guest_groups: "Grupos de Invitado" + member_groups: "Grupos de Miembro" + groups_help: "Lista de nombres de grupos del IdP separados por comas. Usa * para coincidir con todos los grupos." + advanced_title: "Ajustes avanzados de OIDC" + scopes_label: "Ámbitos (Scopes) personalizados" + scopes_help: "Lista de ámbitos OIDC separados por espacios. Deja en blanco para los predeterminados (openid email profile). Añade 'groups' para recuperar las notificaciones de grupo." + prompt_label: "Solicitud de autenticación (Prompt)" + prompt_default: "Predeterminado (el IdP decide)" + prompt_login: "Forzar inicio de sesión (reautenticar)" + prompt_consent: "Forzar consentimiento (reautorizar)" + prompt_select_account: "Selección de cuenta (elegir cuenta)" + prompt_none: "Sin solicitud (autenticación silenciosa)" + prompt_help: "Controla cómo el IdP solicita información al usuario durante la autenticación." + test_connection: "Probar conexión" + saml_configuration: "Configuración SAML" + idp_metadata_url: "URL de metadatos del IdP" + idp_metadata_url_help: "URL a los metadatos SAML de tu IdP. Si se proporciona, otros ajustes de SAML se configurarán automáticamente." + manual_saml_config: "Configuración manual (si no se usa URL de metadatos)" + manual_saml_help: "Usa estos ajustes solo si tu IdP no proporciona una URL de metadatos." + idp_sso_url: "URL de SSO del IdP" + idp_slo_url: "URL de SLO del IdP (opcional)" + idp_certificate: "Certificado del IdP" + idp_certificate_help: "Certificado X.509 en formato PEM. Obligatorio si no se usa URL de metadatos." + idp_cert_fingerprint: "Huella digital del certificado (alternativa)" + name_id_format: "Formato de NameID" + name_id_email: "Dirección de correo (predeterminado)" + name_id_persistent: "Persistente" + name_id_transient: "Transitorio" + name_id_unspecified: "Sin especificar" \ No newline at end of file diff --git a/config/locales/views/admin/users/es.yml b/config/locales/views/admin/users/es.yml new file mode 100644 index 000000000..d2cad061f --- /dev/null +++ b/config/locales/views/admin/users/es.yml @@ -0,0 +1,45 @@ +--- +es: + admin: + users: + index: + title: "Gestión de usuarios" + description: "Gestiona los roles de usuario para tu instancia. Los superadministradores pueden acceder a la configuración del proveedor de SSO y a la gestión de usuarios." + section_title: "Usuarios" + you: "(Tú)" + trial_ends_at: "La prueba finaliza" + not_available: "n/a" + no_users: "No se han encontrado usuarios." + filters: + role: "Rol" + role_all: "Todos los roles" + trial_status: "Estado de la prueba" + trial_all: "Todos" + trial_expiring_soon: "Caduca en 7 días" + trial_trialing: "En periodo de prueba" + submit: "Filtrar" + summary: + trials_expiring_7_days: "Pruebas que caducan en los próximos 7 días" + table: + user: "Usuario" + trial_ends_at: "La prueba finaliza" + family_accounts: "Cuentas familiares" + family_transactions: "Transacciones familiares" + last_login: "Último inicio de sesión" + session_count: "Número de sesiones" + never: "Nunca" + role: "Rol" + role_descriptions_title: "Descripción de los roles" + roles: + guest: "Invitado" + member: "Miembro" + admin: "Administrador" + super_admin: "Superadministrador" + role_descriptions: + guest: "Experiencia centrada en el asistente con permisos restringidos intencionadamente para flujos de introducción." + member: "Acceso de usuario básico. Pueden gestionar sus propias cuentas, transacciones y ajustes." + admin: "Administrador de la familia. Puede acceder a ajustes avanzados como claves API, importaciones e instrucciones de IA." + super_admin: "Administrador de la instancia. Puede gestionar proveedores de SSO, roles de usuario y suplantar a usuarios para soporte." + update: + success: "Rol de usuario actualizado correctamente." + failure: "Error al actualizar el rol de usuario." \ No newline at end of file diff --git a/config/locales/views/budgets/es.yml b/config/locales/views/budgets/es.yml new file mode 100644 index 000000000..87bc1649b --- /dev/null +++ b/config/locales/views/budgets/es.yml @@ -0,0 +1,10 @@ +--- +es: + budgets: + name: + custom_range: "%{start} - %{end_date}" + month_year: "%{month}" + show: + tabs: + actual: Real + budgeted: Presupuestado \ No newline at end of file diff --git a/config/locales/views/chats/es.yml b/config/locales/views/chats/es.yml new file mode 100644 index 000000000..d1d8ed83f --- /dev/null +++ b/config/locales/views/chats/es.yml @@ -0,0 +1,5 @@ +--- +es: + chats: + demo_banner_title: "Modo de demostración activo" + demo_banner_message: "Estás utilizando un LLM Qwen3 de pesos abiertos con créditos proporcionados por Cloudflare Workers AI. Los resultados pueden variar, ya que la base de código se probó principalmente con `gpt-4.1`, ¡pero tus tokens no se enviarán a ningún otro lugar para ser entrenados! 🤖" \ No newline at end of file diff --git a/config/locales/views/coinbase_items/es.yml b/config/locales/views/coinbase_items/es.yml new file mode 100644 index 000000000..4efeeb0eb --- /dev/null +++ b/config/locales/views/coinbase_items/es.yml @@ -0,0 +1,78 @@ +--- +es: + coinbase_items: + create: + default_name: Coinbase + success: ¡Conexión con Coinbase establecida con éxito! Tus cuentas se están sincronizando. + update: + success: Configuración de Coinbase actualizada correctamente. + destroy: + success: Conexión de Coinbase programada para su eliminación. + setup_accounts: + title: Importar carteras de Coinbase + subtitle: Selecciona qué carteras quieres seguir + instructions: Selecciona las carteras que quieres importar. Las carteras no seleccionadas seguirán estando disponibles por si quieres añadirlas más tarde. + no_accounts: Se han importado todas las carteras. + accounts_count: + one: "%{count} cartera disponible" + other: "%{count} carteras disponibles" + select_all: Seleccionar todas + import_selected: Importar seleccionadas + cancel: Cancelar + creating: Importando... + complete_account_setup: + success: + one: "Se ha importado %{count} cartera" + other: "Se han importado %{count} carteras" + none_selected: No se ha seleccionado ninguna cartera + no_accounts: No hay carteras para importar + coinbase_item: + provider_name: Coinbase + syncing: Sincronizando... + reconnect: Es necesario actualizar las credenciales + deletion_in_progress: Eliminando... + sync_status: + no_accounts: No se han encontrado cuentas + all_synced: + one: "%{count} cuenta sincronizada" + other: "%{count} cuentas sincronizadas" + partial_sync: "%{linked_count} sincronizadas, %{unlinked_count} necesitan configuración" + status: "Sincronizado hace %{timestamp}" + status_with_summary: "Sincronizado hace %{timestamp} - %{summary}" + status_never: Nunca sincronizado + update_credentials: Actualizar credenciales + delete: Eliminar + no_accounts_title: No se han encontrado cuentas + no_accounts_message: Tus carteras de Coinbase aparecerán aquí después de la sincronización. + setup_needed: Carteras listas para importar + setup_description: Selecciona qué carteras de Coinbase quieres seguir. + setup_action: Importar carteras + import_wallets_menu: Importar carteras + more_wallets_available: + one: "%{count} cartera más disponible para importar" + other: "%{count} carteras más disponibles para importar" + select_existing_account: + title: Vincular cuenta de Coinbase + no_accounts_found: No se han encontrado cuentas de Coinbase. + wait_for_sync: Espera a que Coinbase termine de sincronizar + check_provider_health: Comprueba que tus credenciales de la API de Coinbase sean válidas + balance: Saldo + currently_linked_to: "Vinculada actualmente a: %{account_name}" + link: Vincular + cancel: Cancelar + link_existing_account: + success: Vinculado correctamente a la cuenta de Coinbase + errors: + only_manual: Solo las cuentas manuales pueden vincularse a Coinbase + invalid_coinbase_account: Cuenta de Coinbase no válida + coinbase_item: + syncer: + checking_credentials: Comprobando credenciales... + credentials_invalid: Credenciales de API no válidas. Por favor, comprueba tu clave API y el secreto. + importing_accounts: Importando cuentas desde Coinbase... + checking_configuration: Comprobando la configuración de la cuenta... + accounts_need_setup: + one: "%{count} cuenta necesita configuración" + other: "%{count} cuentas necesitan configuración" + processing_accounts: Procesando datos de la cuenta... + calculating_balances: Calculando saldos. \ No newline at end of file diff --git a/config/locales/views/coinstats_items/es.yml b/config/locales/views/coinstats_items/es.yml new file mode 100644 index 000000000..36744d53e --- /dev/null +++ b/config/locales/views/coinstats_items/es.yml @@ -0,0 +1,63 @@ +--- +es: + coinstats_items: + create: + success: Conexión del proveedor CoinStats configurada correctamente. + default_name: Conexión de CoinStats + errors: + validation_failed: "Error de validación: %{message}." + update: + success: Conexión del proveedor CoinStats actualizada correctamente. + errors: + validation_failed: "Error de validación: %{message}." + destroy: + success: Conexión del proveedor CoinStats programada para su eliminación. + link_wallet: + success: "%{count} cartera(s) de criptomonedas vinculada(s) correctamente." + missing_params: "Faltan parámetros requeridos: dirección y blockchain." + failed: Error al vincular la cartera de criptomonedas. + error: "Error al vincular la cartera de criptomonedas: %{message}." + new: + title: Vincular una cartera de criptomonedas con CoinStats + blockchain_fetch_error: Error al cargar las Blockchains. Por favor, inténtalo de nuevo más tarde. + address_label: Dirección + address_placeholder: Obligatorio + blockchain_label: Blockchain + blockchain_placeholder: Obligatorio + blockchain_select_blank: Selecciona una Blockchain + link: Vincular cartera de criptomonedas + not_configured_title: Conexión del proveedor CoinStats no configurada + not_configured_message: Para vincular una cartera de criptomonedas, primero debes configurar la conexión del proveedor CoinStats. + not_configured_step1_html: Ve a Ajustes → Proveedores + not_configured_step2_html: Localiza el proveedor CoinStats + not_configured_step3_html: Sigue las instrucciones de configuración proporcionadas para completar la configuración del proveedor + go_to_settings: Ir a Ajustes de proveedores + setup_instructions: "Instrucciones de configuración:" + step1_html: Visita el Panel de la API pública de CoinStats para obtener una clave API. + step2: Introduce tu clave API a continuación y haz clic en Configurar. + step3_html: Tras una conexión exitosa, visita la pestaña de Cuentas para configurar tus carteras de criptomonedas. + api_key_label: Clave API + api_key_placeholder: Obligatorio + configure: Configurar + update_configuration: Reconfigurar + default_name: Conexión de CoinStats + status_configured_html: Listo para usar + status_not_configured: No configurado + coinstats_item: + deletion_in_progress: Los datos de la cartera de criptomonedas se están eliminando… + provider_name: CoinStats + syncing: Sincronizando… + sync_status: + no_accounts: No se han encontrado carteras de criptomonedas + all_synced: + one: "%{count} cartera de criptomonedas sincronizada" + other: "%{count} carteras de criptomonedas sincronizadas" + partial_sync: "%{linked_count} carteras de criptomonedas sincronizadas, %{unlinked_count} necesitan configuración" + reconnect: Reconectar + status: Sincronizado hace %{timestamp} + status_never: Nunca sincronizado + status_with_summary: "Sincronizado hace %{timestamp} • %{summary}" + update_api_key: Actualizar clave API + delete: Eliminar + no_wallets_title: No hay carteras de criptomonedas conectadas + no_wallets_message: Actualmente no hay carteras de criptomonedas conectadas a CoinStats. \ No newline at end of file diff --git a/config/locales/views/components/es.yml b/config/locales/views/components/es.yml new file mode 100644 index 000000000..4587a0088 --- /dev/null +++ b/config/locales/views/components/es.yml @@ -0,0 +1,66 @@ +es: + provider_sync_summary: + title: Resumen de sincronización + last_sync: "Última sincronización: hace %{time_ago}" + accounts: + title: Cuentas + total: "Total: %{count}" + linked: "Vinculadas: %{count}" + unlinked: "Desvinculadas: %{count}" + institutions: "Instituciones: %{count}" + transactions: + title: Transacciones + seen: "Detectadas: %{count}" + imported: "Importadas: %{count}" + updated: "Actualizadas: %{count}" + skipped: "Omitidas: %{count}" + fetching: "Obteniendo del bróker..." + protected: + one: "%{count} entrada protegida (no sobrescrita)" + other: "%{count} entradas protegidas (no sobrescritas)" + view_protected: Ver entradas protegidas + skip_reasons: + excluded: Excluida + user_modified: Modificada por el usuario + import_locked: Importación CSV + protected: Protegida + holdings: + title: Posiciones + found: "Encontradas: %{count}" + processed: "Procesadas: %{count}" + trades: + title: Operaciones + imported: "Importadas: %{count}" + skipped: "Omitidas: %{count}" + fetching: "Obteniendo actividades del bróker..." + health: + title: Estado de salud + view_error_details: Ver detalles del error + rate_limited: "Límite de frecuencia alcanzado hace %{time_ago}" + recently: recientemente + errors: "Errores: %{count}" + pending_reconciled: + one: "%{count} transacción pendiente duplicada conciliada" + other: "%{count} transacciones pendientes duplicadas conciliadas" + view_reconciled: Ver transacciones conciliadas + duplicate_suggestions: + one: "%{count} posible duplicado necesita revisión" + other: "%{count} posibles duplicados necesitan revisión" + view_duplicate_suggestions: Ver duplicados sugeridos + stale_pending: + one: "%{count} transacción pendiente antigua (excluida de presupuestos)" + other: "%{count} transacciones pendientes antiguas (excluidas de presupuestos)" + view_stale_pending: Ver cuentas afectadas + stale_pending_count: + one: "%{count} transacción" + other: "%{count} transacciones" + stale_unmatched: + one: "%{count} transacción pendiente necesita revisión manual" + other: "%{count} transacciones pendientes necesitan revisión manual" + view_stale_unmatched: Ver transacciones que necesitan revisión + stale_unmatched_count: + one: "%{count} transacción" + other: "%{count} transacciones" + data_warnings: "Avisos de datos: %{count}" + notices: "Avisos: %{count}" + view_data_quality: Ver detalles de calidad de datos \ No newline at end of file diff --git a/config/locales/views/cryptos/es.yml b/config/locales/views/cryptos/es.yml index 15a8125f5..14ea42ded 100644 --- a/config/locales/views/cryptos/es.yml +++ b/config/locales/views/cryptos/es.yml @@ -3,5 +3,18 @@ es: cryptos: edit: edit: Editar %{account} + form: + subtype_label: Tipo de cuenta + subtype_prompt: Seleccionar tipo... + subtype_none: No especificado + tax_treatment_label: Tratamiento fiscal + tax_treatment_hint: La mayoría de las criptomonedas se mantienen en cuentas sujetas a impuestos. Selecciona una opción diferente si se mantienen en una cuenta con ventajas fiscales. new: title: Introducir saldo de la cuenta + subtypes: + wallet: + short: Cartera + long: Cartera de criptomonedas + exchange: + short: Exchange + long: Exchange de criptomonedas \ No newline at end of file diff --git a/config/locales/views/enable_banking_items/es.yml b/config/locales/views/enable_banking_items/es.yml new file mode 100644 index 000000000..62d890f0e --- /dev/null +++ b/config/locales/views/enable_banking_items/es.yml @@ -0,0 +1,49 @@ +--- +es: + enable_banking_items: + authorize: + authorization_failed: Error al iniciar la autorización + bank_required: Por favor, selecciona un banco. + invalid_redirect: La URL de autorización recibida no es válida. Por favor, inténtalo de nuevo. + redirect_uri_not_allowed: Redirección no permitida. Por favor, configura `%{callback_url}` en los ajustes de tu aplicación de Enable Banking. + unexpected_error: Ha ocurrido un error inesperado. Por favor, inténtalo de nuevo. + callback: + authorization_error: Error de autorización + invalid_callback: Parámetros de retorno (callback) no válidos. + item_not_found: Conexión no encontrada. + session_failed: No se ha podido completar la autorización + success: Conectado correctamente con tu banco. Tus cuentas se están sincronizando. + unexpected_error: Ha ocurrido un error inesperado. Por favor, inténtalo de nuevo. + complete_account_setup: + all_skipped: Se han omitido todas las cuentas. Puedes configurarlas más tarde en la página de cuentas. + no_accounts: No hay cuentas disponibles para configurar. + success: ¡Se han creado %{count} cuentas correctamente! + create: + success: Configuración de Enable Banking realizada con éxito. + destroy: + success: La conexión de Enable Banking se ha puesto en cola para su eliminación. + link_accounts: + already_linked: Las cuentas seleccionadas ya están vinculadas. + link_failed: Error al vincular las cuentas + no_accounts_selected: No se ha seleccionado ninguna cuenta. + no_session: No hay ninguna conexión activa de Enable Banking. Por favor, conéctate primero a un banco. + success: Se han vinculado %{count} cuentas correctamente. + link_existing_account: + success: Cuenta vinculada correctamente a Enable Banking + errors: + only_manual: Solo se pueden vincular cuentas manuales + invalid_enable_banking_account: Se ha seleccionado una cuenta de Enable Banking no válida + new: + link_enable_banking_title: Vincular Enable Banking + reauthorize: + invalid_redirect: La URL de autorización recibida no es válida. Por favor, inténtalo de nuevo. + reauthorization_failed: Error en la reautorización + select_bank: + cancel: Cancelar + check_country: Por favor, comprueba los ajustes de tu código de país. + credentials_required: Por favor, configura primero tus credenciales de Enable Banking. + description: Selecciona el banco que quieres conectar a tus cuentas. + no_banks: No hay bancos disponibles para este país/región. + title: Selecciona tu banco + update: + success: Configuración de Enable Banking actualizada. \ No newline at end of file diff --git a/config/locales/views/entries/es.yml b/config/locales/views/entries/es.yml index 54b20e21d..e8dee8098 100644 --- a/config/locales/views/entries/es.yml +++ b/config/locales/views/entries/es.yml @@ -12,3 +12,12 @@ es: loading: Cargando entradas... update: success: Entrada actualizada + unlock: + success: Entrada desbloqueada. Podría actualizarse en la próxima sincronización. + protection: + tooltip: Protegido contra sincronización + title: Protegido contra sincronización + description: Los cambios que realices en esta entrada no serán sobrescritos por la sincronización del proveedor. + locked_fields_label: "Campos bloqueados:" + unlock_button: Permitir que la sincronización actualice + unlock_confirm: ¿Permitir que la sincronización actualice esta entrada? Tus cambios podrían sobrescribirse en la próxima sincronización. diff --git a/config/locales/views/holdings/es.yml b/config/locales/views/holdings/es.yml index 0db0f3010..bc17c9aab 100644 --- a/config/locales/views/holdings/es.yml +++ b/config/locales/views/holdings/es.yml @@ -5,9 +5,37 @@ es: brokerage_cash: Efectivo en la cuenta de corretaje destroy: success: Posición eliminada + update: + success: Base de costes guardada. + error: Valor de base de costes no válido. + unlock_cost_basis: + success: Base de costes desbloqueada. Podría actualizarse en la próxima sincronización. + remap_security: + success: Valor actualizado correctamente. + security_not_found: No se ha podido encontrar el valor seleccionado. + reset_security: + success: Valor restablecido al valor del proveedor. + errors: + security_collision: "No se puede reasignar: ya tienes una posición para %{ticker} en la fecha %{date}." + cost_basis_sources: + manual: Configurado por el usuario + calculated: Desde operaciones + provider: Desde el proveedor + cost_basis_cell: + unknown: "--" + set_cost_basis_header: "Establecer base de costes para %{ticker} (%{qty} acciones)" + total_cost_basis_label: Base de costes total + or_per_share_label: "O introduce por acción:" + per_share: por acción + cancel: Cancelar + save: Guardar + overwrite_confirm_title: ¿Sobrescribir base de costes? + overwrite_confirm_body: "Esto reemplazará la base de costes actual de %{current}." holding: per_share: por acción shares: "%{qty} acciones" + unknown: "--" + no_cost_basis: Sin base de costes index: average_cost: Costo promedio holdings: Posiciones @@ -17,21 +45,38 @@ es: return: Rendimiento total weight: Peso missing_price_tooltip: - description: Esta inversión tiene valores faltantes y no pudimos calcular - su rendimiento o valor. + description: Esta inversión tiene valores faltantes y no pudimos calcular su rendimiento o valor. missing_data: Datos faltantes show: avg_cost_label: Costo promedio current_market_price_label: Precio de mercado actual delete: Eliminar - delete_subtitle: Esto eliminará la posición y todas tus operaciones asociadas - en esta cuenta. Esta acción no se puede deshacer. + delete_subtitle: Esto eliminará la posición y todas tus operaciones asociadas en esta cuenta. Esta acción no se puede deshacer. delete_title: Eliminar posición + edit_security: Editar valor history: Historial + no_trade_history: No hay historial de operaciones disponible para esta posición. overview: Resumen portfolio_weight_label: Peso en el portafolio settings: Configuración + security_label: Valor + originally: "era %{ticker}" + search_security: Buscar valor + search_security_placeholder: Buscar por ticker o nombre + cancel: Cancelar + remap_security: Guardar + no_security_provider: Proveedor de valores no configurado. No se pueden buscar valores. + security_remapped_label: Valor reasignado + provider_sent: "El proveedor envió: %{ticker}" + reset_to_provider: Restablecer al proveedor + reset_confirm_title: ¿Restablecer valor al del proveedor? + reset_confirm_body: "Esto cambiará el valor de %{current} de nuevo a %{original} y moverá todas las operaciones asociadas." ticker_label: Ticker trade_history_entry: "%{qty} acciones de %{security} a %{price}" total_return_label: Rendimiento total unknown: Desconocido + cost_basis_locked_label: La base de costes está bloqueada + cost_basis_locked_description: La base de costes establecida manualmente no cambiará con las sincronizaciones. + unlock_cost_basis: Desbloquear + unlock_confirm_title: ¿Desbloquear base de costes? + unlock_confirm_body: Esto permitirá que la base de costes se actualice mediante las sincronizaciones del proveedor o los cálculos de operaciones. \ No newline at end of file diff --git a/config/locales/views/imports/es.yml b/config/locales/views/imports/es.yml index 8674ebcae..56c71e50e 100644 --- a/config/locales/views/imports/es.yml +++ b/config/locales/views/imports/es.yml @@ -8,8 +8,18 @@ es: errors_notice_mobile: Tienes errores en tus datos. Toca el tooltip del error para ver los detalles. title: Limpia tus datos configurations: + update: + success: Importación configurada correctamente. + category_import: + button_label: Continuar + description: Sube un archivo CSV sencillo (como el que generamos al exportar tus datos). Mapearemos las columnas automáticamente. + instructions: Selecciona continuar para analizar tu CSV y pasar al paso de limpieza. mint_import: date_format_label: Formato de fecha + rule_import: + description: Configura tu importación de reglas. Las reglas se crearán o actualizarán basándose en los datos del CSV. + process_button: Procesar reglas + process_help: Haz clic en el botón de abajo para procesar tu CSV y generar las filas de reglas. show: description: Selecciona las columnas que corresponden a cada campo en tu CSV. title: Configura tu importación @@ -17,26 +27,23 @@ es: date_format_label: Formato de fecha transaction_import: date_format_label: Formato de fecha + rows_to_skip_label: Omitir las primeras n filas confirms: mappings: create_account: Crear cuenta csv_mapping_label: "%{mapping} en CSV" sure_mapping_label: "%{mapping} en %{product_name}" - no_accounts: Aún no tienes cuentas. Por favor, crea una cuenta que podamos usar para las filas - (sin asignar) en tu CSV o vuelve al paso de Limpieza y proporciona un nombre de cuenta que podamos usar. + no_accounts: Aún no tienes cuentas. Por favor, crea una cuenta que podamos usar para las filas (sin asignar) en tu CSV o vuelve al paso de Limpieza y proporciona un nombre de cuenta que podamos usar. rows_label: Filas unassigned_account: ¿Necesitas crear una nueva cuenta para las filas sin asignar? show: - account_mapping_description: Asigna todas las cuentas de tu archivo importado a las cuentas existentes de Sure. - También puedes añadir nuevas cuentas o dejarlas sin categorizar. + account_mapping_description: Asigna todas las cuentas de tu archivo importado a las cuentas existentes de Sure. También puedes añadir nuevas cuentas o dejarlas sin categorizar. account_mapping_title: Asigna tus cuentas account_type_mapping_description: Asigna todos los tipos de cuenta de tu archivo importado a los de Sure. account_type_mapping_title: Asigna tus tipos de cuenta - category_mapping_description: Asigna todas las categorías de tu archivo importado a las categorías existentes de Sure. - También puedes añadir nuevas categorías o dejarlas sin categorizar. + category_mapping_description: Asigna todas las categorías de tu archivo importado a las categorías existentes de Sure. También puedes añadir nuevas categorías o dejarlas sin categorizar. category_mapping_title: Asigna tus categorías - tag_mapping_description: Asigna todas las etiquetas de tu archivo importado a las etiquetas existentes de Sure. - También puedes añadir nuevas etiquetas o dejarlas sin categorizar. + tag_mapping_description: Asigna todas las etiquetas de tu archivo importado a las etiquetas existentes de Sure. También puedes añadir nuevas etiquetas o dejarlas sin categorizar. tag_mapping_title: Asigna tus etiquetas uploads: show: @@ -52,10 +59,10 @@ es: title: Importaciones new: Nueva importación table: - title: Imports + title: Importaciones header: date: Fecha - operation: Operation + operation: Operación status: Estado actions: Acciones row: @@ -68,19 +75,68 @@ es: failed: Fallido actions: revert: Revertir - confirm_revert: Esto eliminará las transacciones que se importaron, pero aún podrá revisar y volver a importar sus datos en cualquier momento. + confirm_revert: Esto eliminará las transacciones que se importaron, pero aún podrás revisar y volver a importar tus datos en cualquier momento. delete: Eliminar view: Ver empty: Aún no hay importaciones. new: description: Puedes importar manualmente varios tipos de datos mediante CSV o usar una de nuestras plantillas de importación como Mint. import_accounts: Importar cuentas + import_categories: Importar categorías import_mint: Importar desde Mint import_portfolio: Importar inversiones + import_rules: Importar reglas import_transactions: Importar transacciones + import_file: Importar documento + import_file_description: Análisis potenciado por IA para PDFs y subida con búsqueda para otros archivos compatibles + requires_account: Importa cuentas primero para desbloquear esta opción. resume: Reanudar %{type} sources: Fuentes - title: Nueva importación CSV + title: Nueva importación + create: + file_too_large: El archivo es demasiado grande. El tamaño máximo es %{max_size}MB. + invalid_file_type: Tipo de archivo no válido. Por favor, sube un archivo CSV. + csv_uploaded: CSV subido correctamente. + pdf_too_large: El archivo PDF es demasiado grande. El tamaño máximo es %{max_size}MB. + pdf_processing: Tu PDF se está procesando. Recibirás un correo electrónico cuando el análisis haya finalizado. + invalid_pdf: El archivo subido no es un PDF válido. + document_too_large: El documento es demasiado grande. El tamaño máximo es %{max_size}MB. + invalid_document_file_type: Tipo de archivo de documento no válido para el almacén de vectores activo. + document_uploaded: Documento subido correctamente. + document_upload_failed: No hemos podido subir el documento al almacén de vectores. Por favor, inténtalo de nuevo. + document_provider_not_configured: No hay ningún almacén de vectores configurado para la subida de documentos. + show: + finalize_upload: Por favor, finaliza la subida de tu archivo. + finalize_mappings: Por favor, finaliza tus mapeos antes de continuar. ready: description: Aquí tienes un resumen de los nuevos elementos que se añadirán a tu cuenta una vez publiques esta importación. title: Confirma tus datos de importación + errors: + custom_column_requires_inflow: "Las importaciones de columnas personalizadas requieren que se seleccione una columna de entrada de fondos (inflow)" + document_types: + bank_statement: Extracto bancario + credit_card_statement: Extracto de tarjeta de crédito + investment_statement: Extracto de inversiones + financial_document: Documento financiero + contract: Contrato + other: Otro documento + unknown: Documento desconocido + pdf_import: + processing_title: Procesando tu PDF + processing_description: Estamos analizando tu documento mediante IA. Esto puede tardar un momento. Recibirás un correo electrónico cuando el análisis finalice. + check_status: Comprobar estado + back_to_dashboard: Volver al panel + failed_title: Error en el procesamiento + failed_description: No hemos podido procesar tu documento PDF. Por favor, inténtalo de nuevo o contacta con soporte. + try_again: Reintentar + delete_import: Eliminar importación + complete_title: Documento analizado + complete_description: Hemos analizado tu PDF y esto es lo que hemos encontrado. + document_type_label: Tipo de documento + summary_label: Resumen + email_sent_notice: Se te ha enviado un correo electrónico con los siguientes pasos. + back_to_imports: Volver a importaciones + unknown_state_title: Estado desconocido + unknown_state_description: Esta importación se encuentra en un estado inesperado. Por favor, vuelve a importaciones. + processing_failed_with_message: "%{message}" + processing_failed_generic: "Error en el procesamiento: %{error}" \ No newline at end of file diff --git a/config/locales/views/indexa_capital_items/es.yml b/config/locales/views/indexa_capital_items/es.yml new file mode 100644 index 000000000..f63db32d8 --- /dev/null +++ b/config/locales/views/indexa_capital_items/es.yml @@ -0,0 +1,241 @@ +--- +es: + indexa_capital_items: + sync_status: + no_accounts: "No se han encontrado cuentas" + synced: + one: "%{count} cuenta sincronizada" + other: "%{count} cuentas sincronizadas" + synced_with_setup: "%{linked} sincronizadas, %{unlinked} necesitan configuración" + institution_summary: + none: "No hay instituciones conectadas" + count: + one: "%{count} institución" + other: "%{count} instituciones" + errors: + provider_not_configured: "El proveedor Indexa Capital no está configurado" + + sync: + status: + importing: "Importando cuentas de Indexa Capital..." + processing: "Procesando posiciones y actividades..." + calculating: "Calculando saldos..." + importing_data: "Importando datos de la cuenta..." + checking_setup: "Comprobando la configuración de la cuenta..." + needs_setup: "%{count} cuentas necesitan configuración..." + success: "Sincronización iniciada" + + panel: + setup_instructions: "Instrucciones de configuración:" + step_1: "Visita tu panel de Indexa Capital para generar un token de API de solo lectura" + step_2: "Pega tu token de API a continuación y haz clic en Guardar" + step_3: "Tras una conexión exitosa, ve a la pestaña Cuentas para configurar las nuevas cuentas" + field_descriptions: "Descripciones de los campos:" + optional: "(Opcional)" + required: "(obligatorio)" + optional_with_default: "(opcional, por defecto %{default_value})" + alternative_auth: "O usa la autenticación por usuario/contraseña en su lugar..." + save_button: "Guardar configuración" + update_button: "Actualizar configuración" + status_configured_html: "Configurado y listo para usar. Visita la pestaña de Cuentas para gestionar y configurar tus cuentas." + status_not_configured: "No configurado" + fields: + api_token: + label: "Token de API" + description: "Tu token de API de solo lectura del panel de Indexa Capital" + placeholder_new: "Pega tu token de API aquí" + placeholder_update: "Introduce el nuevo token de API para actualizar" + username: + label: "Usuario" + description: "Tu usuario/email de Indexa Capital" + placeholder_new: "Pega el usuario aquí" + placeholder_update: "Introduce el nuevo usuario para actualizar" + document: + label: "Documento de identidad" + description: "Tu documento/ID de Indexa Capital" + placeholder_new: "Pega el ID del documento aquí" + placeholder_update: "Introduce el nuevo ID de documento para actualizar" + password: + label: "Contraseña" + description: "Tu contraseña de Indexa Capital" + placeholder_new: "Pega la contraseña aquí" + placeholder_update: "Introduce la nueva contraseña para actualizar" + + create: + success: "Conexión con Indexa Capital creada correctamente" + update: + success: "Conexión con Indexa Capital actualizada" + destroy: + success: "Conexión con Indexa Capital eliminada" + index: + title: "Conexiones de Indexa Capital" + + loading: + loading_message: "Cargando cuentas de Indexa Capital..." + loading_title: "Cargando" + + link_accounts: + all_already_linked: + one: "La cuenta seleccionada (%{names}) ya está vinculada" + other: "Las %{count} cuentas seleccionadas ya están vinculadas: %{names}" + api_error: "Error de la API: %{message}" + invalid_account_names: + one: "No se puede vincular una cuenta sin nombre" + other: "No se pueden vincular %{count} cuentas sin nombre" + link_failed: "Error al vincular las cuentas" + no_accounts_selected: "Por favor, selecciona al menos una cuenta" + no_api_key: "No se han encontrado las credenciales de Indexa Capital. Por favor, configúralas en los ajustes del proveedor." + partial_invalid: "Se han vinculado correctamente %{created_count} cuenta(s), %{already_linked_count} ya estaban vinculadas, %{invalid_count} cuenta(s) tenían nombres no válidos" + partial_success: "Se han vinculado correctamente %{created_count} cuenta(s). %{already_linked_count} cuenta(s) ya estaban vinculadas: %{already_linked_names}" + success: + one: "Cuenta vinculada correctamente" + other: "%{count} cuentas vinculadas correctamente" + + indexa_capital_item: + accounts_need_setup: "Las cuentas necesitan configuración" + delete: "Eliminar conexión" + deletion_in_progress: "eliminación en curso..." + error: "Error" + more_accounts_available: + one: "Hay %{count} cuenta más disponible" + other: "Hay %{count} cuentas más disponibles" + no_accounts_description: "Esta conexión aún no tiene cuentas vinculadas." + no_accounts_title: "Sin cuentas" + provider_name: "Indexa Capital" + requires_update: "La conexión necesita una actualización" + setup_action: "Configurar nuevas cuentas" + setup_description: "%{linked} de %{total} cuentas vinculadas. Elige los tipos de cuenta para tus cuentas de Indexa Capital recién importadas." + setup_needed: "Nuevas cuentas listas para configurar" + status: "Sincronizado hace %{timestamp} — %{summary}" + status_never: "Nunca sincronizado" + syncing: "Sincronizando..." + total: "Total" + unlinked: "Desvinculadas" + update_credentials: "Actualizar credenciales" + + select_accounts: + accounts_selected: "cuentas seleccionadas" + api_error: "Error de la API: %{message}" + cancel: "Cancelar" + configure_name_in_provider: "No se puede importar; por favor, configura el nombre de la cuenta en Indexa Capital" + description: "Selecciona las cuentas que quieres vincular a tu cuenta de %{product_name}." + link_accounts: "Vincular cuentas seleccionadas" + no_accounts_found: "No se han encontrado cuentas. Por favor, comprueba tus credenciales de Indexa Capital." + no_api_key: "Las credenciales de Indexa Capital no están configuradas. Por favor, configúralas en Ajustes." + no_credentials_configured: "Por favor, configura primero tus credenciales de Indexa Capital en los ajustes del proveedor." + no_name_placeholder: "(Sin nombre)" + title: "Seleccionar cuentas de Indexa Capital" + + select_existing_account: + account_already_linked: "Esta cuenta ya está vinculada a un proveedor" + all_accounts_already_linked: "Todas las cuentas de Indexa Capital ya están vinculadas" + api_error: "Error de la API: %{message}" + balance_label: "Saldo:" + cancel: "Cancelar" + cancel_button: "Cancelar" + configure_name_in_provider: "No se puede importar; por favor, configura el nombre de la cuenta en Indexa Capital" + connect_hint: "Conecta una cuenta de Indexa Capital para habilitar la sincronización automática." + description: "Selecciona una cuenta de Indexa Capital para vincularla con esta cuenta. Las transacciones se sincronizarán y se eliminarán los duplicados automáticamente." + header: "Vincular con Indexa Capital" + link_account: "Vincular cuenta" + link_button: "Vincular esta cuenta" + linking_to: "Vinculando a:" + no_account_specified: "No se ha especificado ninguna cuenta" + no_accounts: "No se han encontrado cuentas de Indexa Capital sin vincular." + no_accounts_found: "No se han encontrado cuentas de Indexa Capital. Por favor, comprueba tus credenciales." + no_api_key: "Las credenciales de Indexa Capital no están configuradas. Por favor, configúralas en Ajustes." + no_credentials_configured: "Por favor, configura primero tus credenciales de Indexa Capital en los ajustes del proveedor." + no_name_placeholder: "(Sin nombre)" + settings_link: "Ir a ajustes del proveedor" + subtitle: "Elige una cuenta de Indexa Capital" + title: "Vincular %{account_name} con Indexa Capital" + + link_existing_account: + account_already_linked: "Esta cuenta ya está vinculada a un proveedor" + api_error: "Error de la API: %{message}" + invalid_account_name: "No se puede vincular una cuenta sin nombre" + provider_account_already_linked: "Esta cuenta de Indexa Capital ya está vinculada a otra cuenta" + provider_account_not_found: "Cuenta de Indexa Capital no encontrada" + missing_parameters: "Faltan parámetros obligatorios" + no_api_key: "No se han encontrado las credenciales de Indexa Capital. Por favor, configúralas en los ajustes del proveedor." + success: "Se ha vinculado correctamente %{account_name} con Indexa Capital" + + setup_accounts: + account_type_label: "Tipo de cuenta:" + accounts_count: + one: "%{count} cuenta disponible" + other: "%{count} cuentas disponibles" + all_accounts_linked: "Todas tus cuentas de Indexa Capital ya han sido configuradas." + api_error: "Error de la API: %{message}" + creating: "Creando cuentas..." + fetch_failed: "Error al obtener las cuentas" + import_selected: "Importar cuentas seleccionadas" + instructions: "Selecciona las cuentas que quieres importar de Indexa Capital. Puedes elegir varias cuentas." + no_accounts: "No se han encontrado cuentas sin vincular en esta conexión de Indexa Capital." + no_accounts_to_setup: "No hay cuentas para configurar" + no_api_key: "Las credenciales de Indexa Capital no están configuradas. Por favor, comprueba los ajustes de conexión." + select_all: "Seleccionar todas" + account_types: + skip: "Omitir esta cuenta" + depository: "Cuenta corriente o de ahorro" + credit_card: "Tarjeta de crédito" + investment: "Cuenta de inversión" + crypto: "Cuenta de criptomonedas" + loan: "Préstamo o hipoteca" + other_asset: "Otro activo" + subtype_labels: + depository: "Subtipo de cuenta:" + credit_card: "" + investment: "Tipo de inversión:" + crypto: "" + loan: "Tipo de préstamo:" + other_asset: "" + subtype_messages: + credit_card: "Las tarjetas de crédito se configurarán automáticamente como cuentas de tarjeta de crédito." + other_asset: "No se necesitan opciones adicionales para otros activos." + crypto: "Las cuentas de criptomonedas se configurarán para seguir posiciones y transacciones." + subtypes: + depository: + checking: "Corriente" + savings: "Ahorros" + hsa: "Cuenta de ahorros para la salud (HSA)" + cd: "Certificado de depósito" + money_market: "Mercado monetario" + investment: + brokerage: "Bróker" + pension: "Plan de pensiones" + retirement: "Jubilación" + "401k": "401(k)" + roth_401k: "Roth 401(k)" + "403b": "403(b)" + tsp: "Plan de ahorro TSP" + "529_plan": "Plan 529" + hsa: "Cuenta de ahorros para la salud (HSA)" + mutual_fund: "Fondo de inversión" + ira: "IRA tradicional" + roth_ira: "Roth IRA" + angel: "Capital riesgo / Angel" + loan: + mortgage: "Hipoteca" + student: "Préstamo estudiantil" + auto: "Préstamo de coche" + other: "Otro préstamo" + balance: "Saldo" + cancel: "Cancelar" + choose_account_type: "Elige el tipo de cuenta correcto para cada cuenta de Indexa Capital:" + create_accounts: "Crear cuentas" + creating_accounts: "Creando cuentas..." + historical_data_range: "Rango de datos históricos:" + subtitle: "Elige los tipos de cuenta correctos para tus cuentas importadas" + sync_start_date_help: "Selecciona desde qué fecha quieres sincronizar el historial de transacciones." + sync_start_date_label: "Empezar a sincronizar transacciones desde:" + title: "Configura tus cuentas de Indexa Capital" + + complete_account_setup: + all_skipped: "Se han omitido todas las cuentas. No se ha creado ninguna." + creation_failed: "Error al crear las cuentas: %{error}" + no_accounts: "No hay cuentas para configurar." + success: "Se han creado correctamente %{count} cuenta(s)." + + preload_accounts: + no_credentials_configured: "Por favor, configura primero tus credenciales de Indexa Capital en los ajustes del proveedor." \ No newline at end of file diff --git a/config/locales/views/investments/es.yml b/config/locales/views/investments/es.yml index 14db2128f..c1a3ac0e3 100644 --- a/config/locales/views/investments/es.yml +++ b/config/locales/views/investments/es.yml @@ -10,8 +10,117 @@ es: title: Introduce el saldo de la cuenta show: chart_title: Valor total + subtypes: + # Estados Unidos + brokerage: + short: Corretaje + long: Cuenta de corretaje (Brokerage) + 401k: + short: 401(k) + long: Plan de jubilación 401(k) + roth_401k: + short: Roth 401(k) + long: Plan de jubilación Roth 401(k) + 403b: + short: 403(b) + long: Plan de jubilación 403(b) + 457b: + short: 457(b) + long: Plan de jubilación 457(b) + tsp: + short: TSP + long: Thrift Savings Plan (Plan de ahorro para empleados federales) + ira: + short: IRA + long: Cuenta de jubilación individual (Traditional IRA) + roth_ira: + short: Roth IRA + long: Cuenta de jubilación individual Roth (Roth IRA) + sep_ira: + short: SEP IRA + long: SEP IRA (Plan de pensión simplificado para empleados) + simple_ira: + short: SIMPLE IRA + long: SIMPLE IRA (Plan de incentivos para empleados) + 529_plan: + short: Plan 529 + long: Plan 529 de ahorro para educación + hsa: + short: HSA + long: Cuenta de ahorros para la salud (Health Savings Account) + ugma: + short: UGMA + long: Cuenta de custodia UGMA + utma: + short: UTMA + long: Cuenta de custodia UTMA + # Reino Unido + isa: + short: ISA + long: Cuenta de ahorro individual (ISA) + lisa: + short: LISA + long: ISA vitalicia (Lifetime ISA) + sipp: + short: SIPP + long: Pensión personal con gestión propia (SIPP) + workplace_pension_uk: + short: Pensión + long: Pensión del lugar de trabajo + # Canadá + rrsp: + short: RRSP + long: Plan registrado de ahorro para la jubilación (RRSP) + tfsa: + short: TFSA + long: Cuenta de ahorros libre de impuestos (TFSA) + resp: + short: RESP + long: Plan registrado de ahorros para educación (RESP) + lira: + short: LIRA + long: Cuenta de jubilación inmovilizada (LIRA) + rrif: + short: RRIF + long: Fondo registrado de ingresos para la jubilación (RRIF) + # Australia + super: + short: Super + long: Superannuation (Fondo de pensiones australiano) + smsf: + short: SMSF + long: Fondo de pensiones gestionado por uno mismo (SMSF) + # Europa + pea: + short: PEA + long: Plan de ahorro en acciones (PEA - Francia) + pillar_3a: + short: Pilar 3a + long: Pensión privada (Pilar 3a - Suiza) + riester: + short: Riester + long: Plan de pensiones Riester (Riester-Rente - Alemania) + # Genéricos + pension: + short: Pensión + long: Plan de pensiones + retirement: + short: Jubilación + long: Cuenta de jubilación + mutual_fund: + short: Fondo de inversión + long: Fondo de inversión (Mutual Fund) + angel: + short: Angel + long: Inversión Angel (Capital riesgo) + trust: + short: Fideicomiso + long: Fideicomiso (Trust) + other: + short: Otra + long: Otra inversión value_tooltip: cash: Efectivo holdings: Inversiones total: Saldo de la cartera - total_value_tooltip: El saldo total de la cartera es la suma del efectivo de corretaje (disponible para operar) y el valor de mercado actual de tus inversiones. + total_value_tooltip: El saldo total de la cartera es la suma del efectivo de corretaje (disponible para operar) y el valor de mercado actual de tus inversiones. \ No newline at end of file diff --git a/config/locales/views/invitations/es.yml b/config/locales/views/invitations/es.yml index b17da8342..d47edf372 100644 --- a/config/locales/views/invitations/es.yml +++ b/config/locales/views/invitations/es.yml @@ -1,7 +1,14 @@ --- es: invitations: + accept_choice: + create_account: Crear cuenta nueva + joined_household: Te has unido a la unidad familiar. + message: "%{inviter} te ha invitado a unirte como %{role}." + sign_in_existing: Ya tengo una cuenta + title: Unirse a %{family} create: + existing_user_added: El usuario ha sido añadido a tu unidad familiar. failure: No se pudo enviar la invitación success: Invitación enviada con éxito destroy: @@ -12,8 +19,9 @@ es: email_label: Dirección de correo electrónico email_placeholder: Introduce la dirección de correo electrónico role_admin: Administrador + role_guest: Invitado role_label: Rol role_member: Miembro submit: Enviar invitación - subtitle: Envía una invitación para unirte a tu cuenta familiar en Maybe + subtitle: Envía una invitación para unirte a tu cuenta familiar en %{product_name} title: Invitar a alguien diff --git a/config/locales/views/lunchflow_items/es.yml b/config/locales/views/lunchflow_items/es.yml index a64a308dc..c2889172e 100644 --- a/config/locales/views/lunchflow_items/es.yml +++ b/config/locales/views/lunchflow_items/es.yml @@ -15,49 +15,129 @@ es: one: "La cuenta seleccionada (%{names}) ya está vinculada" other: "Todas las %{count} cuentas seleccionadas ya están vinculadas: %{names}" api_error: "Error de API: %{message}" + invalid_account_names: + one: "No se puede vincular una cuenta con el nombre en blanco" + other: "No se pueden vincular %{count} cuentas con nombres en blanco" link_failed: Error al vincular cuentas no_accounts_selected: Por favor, selecciona al menos una cuenta + partial_invalid: "Se han vinculado %{created_count} cuenta(s) con éxito, %{already_linked_count} ya estaban vinculadas, %{invalid_count} cuenta(s) tenían nombres no válidos" partial_success: "%{created_count} cuenta(s) vinculada(s) con éxito. %{already_linked_count} cuenta(s) ya estaban vinculadas: %{already_linked_names}" success: one: "%{count} cuenta vinculada con éxito" other: "%{count} cuentas vinculadas con éxito" lunchflow_item: + accounts_need_setup: Las cuentas necesitan configuración delete: Eliminar conexión deletion_in_progress: eliminación en progreso... error: Error no_accounts_description: Esta conexión aún no tiene cuentas vinculadas. no_accounts_title: Sin cuentas + setup_action: Configurar nuevas cuentas + setup_description: "%{linked} de %{total} cuentas vinculadas. Elige los tipos de cuenta para tus cuentas de Lunch Flow recién importadas." + setup_needed: Nuevas cuentas listas para configurar status: "Sincronizado hace %{timestamp}" status_never: Nunca sincronizado + status_with_summary: "Sincronizado hace %{timestamp} • %{summary}" syncing: Sincronizando... + total: Total + unlinked: Desvinculadas select_accounts: accounts_selected: cuentas seleccionadas api_error: "Error de API: %{message}" cancel: Cancelar - description: Selecciona las cuentas que deseas vincular a tu cuenta de Sure. + configure_name_in_lunchflow: "No se puede importar: por favor, configura el nombre de la cuenta en Lunch Flow" + description: Selecciona las cuentas que deseas vincular a tu cuenta de %{product_name}. link_accounts: Vincular cuentas seleccionadas no_accounts_found: No se encontraron cuentas. Por favor, verifica la configuración de tu clave API. no_api_key: La clave API de Lunch Flow no está configurada. Por favor, configúrala en Configuración. + no_name_placeholder: "(Sin nombre)" title: Seleccionar cuentas de Lunch Flow select_existing_account: account_already_linked: Esta cuenta ya está vinculada a un proveedor all_accounts_already_linked: Todas las cuentas de Lunch Flow ya están vinculadas api_error: "Error de API: %{message}" cancel: Cancelar + configure_name_in_lunchflow: "No se puede importar: por favor, configura el nombre de la cuenta en Lunch Flow" description: Selecciona una cuenta de Lunch Flow para vincular con esta cuenta. Las transacciones se sincronizarán y desduplicarán automáticamente. link_account: Vincular cuenta no_account_specified: No se especificó ninguna cuenta no_accounts_found: No se encontraron cuentas de Lunch Flow. Por favor, verifica la configuración de tu clave API. no_api_key: La clave API de Lunch Flow no está configurada. Por favor, configúrala en Configuración. + no_name_placeholder: "(Sin nombre)" title: "Vincular %{account_name} con Lunch Flow" link_existing_account: account_already_linked: Esta cuenta ya está vinculada a un proveedor api_error: "Error de API: %{message}" + invalid_account_name: No se puede vincular una cuenta con el nombre en blanco lunchflow_account_already_linked: Esta cuenta de Lunch Flow ya está vinculada a otra cuenta lunchflow_account_not_found: Cuenta de Lunch Flow no encontrada missing_parameters: Faltan parámetros requeridos success: "%{account_name} vinculada con Lunch Flow con éxito" + setup_accounts: + account_type_label: "Tipo de cuenta:" + all_accounts_linked: "Todas tus cuentas de Lunch Flow ya han sido configuradas." + api_error: "Error de API: %{message}" + fetch_failed: "Error al obtener las cuentas" + no_accounts_to_setup: "No hay cuentas para configurar" + no_api_key: "La clave API de Lunch Flow no está configurada. Por favor, comprueba los ajustes de conexión." + account_types: + skip: Omitir esta cuenta + depository: Cuenta corriente o de ahorro + credit_card: Tarjeta de crédito + investment: Cuenta de inversión + loan: Préstamo o hipoteca + other_asset: Otro activo + subtype_labels: + depository: "Subtipo de cuenta:" + credit_card: "" + investment: "Tipo de inversión:" + loan: "Tipo de préstamo:" + other_asset: "" + subtype_messages: + credit_card: "Las tarjetas de crédito se configurarán automáticamente como cuentas de tarjeta de crédito." + other_asset: "No se necesitan opciones adicionales para otros activos." + subtypes: + depository: + checking: Corriente + savings: Ahorros + hsa: Cuenta de ahorros para la salud (HSA) + cd: Certificado de depósito + money_market: Mercado monetario + investment: + brokerage: Bróker + pension: Plan de pensiones + retirement: Jubilación + "401k": "401(k)" + roth_401k: "Roth 401(k)" + "403b": "403(b)" + tsp: Plan de ahorro TSP + "529_plan": Plan 529 + hsa: Cuenta de ahorros para la salud (HSA) + mutual_fund: Fondo de inversión + ira: IRA tradicional + roth_ira: Roth IRA + angel: Inversión Angel + loan: + mortgage: Hipoteca + student: Préstamo estudiantil + auto: Préstamo de coche + other: Otro préstamo + balance: Saldo + cancel: Cancelar + choose_account_type: "Elige el tipo de cuenta correcto para cada cuenta de Lunch Flow:" + create_accounts: Crear cuentas + creating_accounts: Creando cuentas... + historical_data_range: "Rango de datos históricos:" + subtitle: Elige los tipos de cuenta correctos para tus cuentas importadas + sync_start_date_help: Selecciona cuánto tiempo atrás deseas sincronizar el historial de transacciones. Máximo 3 años de historial disponibles. + sync_start_date_label: "Empezar a sincronizar transacciones desde:" + title: Configura tus cuentas de Lunch Flow + complete_account_setup: + all_skipped: "Se omitieron todas las cuentas. No se creó ninguna cuenta." + creation_failed: "Error al crear las cuentas: %{error}" + no_accounts: "No hay cuentas para configurar." + success: "Se han creado %{count} cuenta(s) con éxito." sync: success: Sincronización iniciada update: - success: Conexión con Lunch Flow actualizada + success: Conexión con Lunch Flow actualizada \ No newline at end of file diff --git a/config/locales/views/merchants/es.yml b/config/locales/views/merchants/es.yml index f769dfea1..fb04644f5 100644 --- a/config/locales/views/merchants/es.yml +++ b/config/locales/views/merchants/es.yml @@ -6,31 +6,57 @@ es: success: Nuevo comercio creado con éxito destroy: success: Comercio eliminado con éxito + unlinked_success: Comercio eliminado de tus transacciones edit: title: Editar comercio form: name_placeholder: Nombre del comercio + website_placeholder: Sitio web (ej. starbucks.com) + website_hint: Introduce el sitio web del comercio para mostrar automáticamente su logotipo index: empty: Aún no hay comercios new: Nuevo comercio + merge: Fusionar comercios title: Comercios - family_title: Comercios familiares - family_empty: Aún no hay comercios familiares + family_title: "Comercios de %{moniker}" + family_empty: "Aún no hay comercios de %{moniker}" provider_title: Comercios del proveedor - provider_empty: Ningún comercio del proveedor vinculado a esta familia todavía + provider_empty: "Aún no hay comercios del proveedor vinculados a %{moniker}" provider_read_only: Los comercios del proveedor se sincronizan desde tus instituciones conectadas. No se pueden editar aquí. + provider_info: Estos comercios han sido detectados automáticamente por tus conexiones bancarias o por IA. Puedes editarlos para crear tu propia copia o eliminarlos para desvincularlos de tus transacciones. + unlinked_title: Desvinculados recientemente + unlinked_info: Estos comercios se han eliminado recientemente de tus transacciones. Desaparecerán de esta lista tras 30 días, a menos que se vuelvan a asignar a una transacción. table: merchant: Comercio actions: Acciones source: Origen merchant: confirm_accept: Eliminar comercio - confirm_body: ¿Estás seguro de que deseas eliminar este comercio? Eliminar este comercio + confirm_body: ¿Estás seguro de que deseas eliminar este comercio? Eliminar este comercio desvinculará todas las transacciones asociadas y puede afectar a tus informes. confirm_title: ¿Eliminar comercio? delete: Eliminar comercio edit: Editar comercio + merge: + title: Fusionar comercios + description: Selecciona un comercio de destino y los comercios que deseas fusionar en él. Todas las transacciones de los comercios fusionados se reasignarán al de destino. + target_label: Fusionar en (destino) + select_target: Seleccionar comercio de destino... + sources_label: Comercios a fusionar + sources_hint: Los comercios seleccionados se fusionarán en el de destino. Los comercios familiares se eliminarán y los de proveedores se desvincularán. + submit: Fusionar seleccionados new: title: Nuevo comercio + perform_merge: + success: Se han fusionado %{count} comercios correctamente + no_merchants_selected: No se han seleccionado comercios para fusionar + target_not_found: No se ha encontrado el comercio de destino + invalid_merchants: Se han seleccionado comercios no válidos + provider_merchant: + edit: Editar + remove: Eliminar + remove_confirm_title: ¿Eliminar comercio? + remove_confirm_body: ¿Estás seguro de que quieres eliminar %{name}? Esto desvinculará todas las transacciones asociadas a este comercio, pero no eliminará el comercio en sí. update: success: Comercio actualizado con éxito + converted_success: Comercio convertido y actualizado con éxito diff --git a/config/locales/views/mercury_items/es.yml b/config/locales/views/mercury_items/es.yml new file mode 100644 index 000000000..085607cd7 --- /dev/null +++ b/config/locales/views/mercury_items/es.yml @@ -0,0 +1,147 @@ +--- +es: + mercury_items: + create: + success: Conexión con Mercury creada con éxito + destroy: + success: Conexión con Mercury eliminada + index: + title: Conexiones de Mercury + loading: + loading_message: Cargando cuentas de Mercury... + loading_title: Cargando + link_accounts: + all_already_linked: + one: "La cuenta seleccionada (%{names}) ya está vinculada" + other: "Todas las %{count} cuentas seleccionadas ya están vinculadas: %{names}" + api_error: "Error de API: %{message}" + invalid_account_names: + one: "No se puede vincular una cuenta con el nombre en blanco" + other: "No se pueden vincular %{count} cuentas con nombres en blanco" + link_failed: Error al vincular cuentas + no_accounts_selected: Por favor, selecciona al menos una cuenta + no_api_token: No se encontró el token de API de Mercury. Por favor, configúralo en los Ajustes del Proveedor. + partial_invalid: "Se han vinculado %{created_count} cuenta(s) con éxito, %{already_linked_count} ya estaban vinculadas, %{invalid_count} cuenta(s) tenían nombres no válidos" + partial_success: "%{created_count} cuenta(s) vinculada(s) con éxito. %{already_linked_count} cuenta(s) ya estaban vinculadas: %{already_linked_names}" + success: + one: "%{count} cuenta vinculada con éxito" + other: "%{count} cuentas vinculadas con éxito" + mercury_item: + accounts_need_setup: Las cuentas necesitan configuración + delete: Eliminar conexión + deletion_in_progress: eliminación en curso... + error: Error + no_accounts_description: Esta conexión aún no tiene cuentas vinculadas. + no_accounts_title: Sin cuentas + setup_action: Configurar nuevas cuentas + setup_description: "%{linked} de %{total} cuentas vinculadas. Elige los tipos de cuenta para tus cuentas de Mercury recién importadas." + setup_needed: Nuevas cuentas listas para configurar + status: "Sincronizado hace %{timestamp}" + status_never: Nunca sincronizado + status_with_summary: "Sincronizado hace %{timestamp} - %{summary}" + syncing: Sincronizando... + total: Total + unlinked: Desvinculadas + select_accounts: + accounts_selected: cuentas seleccionadas + api_error: "Error de API: %{message}" + cancel: Cancelar + configure_name_in_mercury: "No se puede importar: por favor, configura el nombre de la cuenta en Mercury" + description: Selecciona las cuentas que deseas vincular a tu cuenta de %{product_name}. + link_accounts: Vincular cuentas seleccionadas + no_accounts_found: No se encontraron cuentas. Por favor, verifica la configuración de tu token de API. + no_api_token: El token de API de Mercury no está configurado. Por favor, configúralo en Ajustes. + no_credentials_configured: Por favor, configura primero tu token de API de Mercury en los Ajustes del Proveedor. + no_name_placeholder: "(Sin nombre)" + title: Seleccionar cuentas de Mercury + select_existing_account: + account_already_linked: Esta cuenta ya está vinculada a un proveedor + all_accounts_already_linked: Todas las cuentas de Mercury ya están vinculadas + api_error: "Error de API: %{message}" + cancel: Cancelar + configure_name_in_mercury: "No se puede importar: por favor, configura el nombre de la cuenta en Mercury" + description: Selecciona una cuenta de Mercury para vincular con esta cuenta. Las transacciones se sincronizarán y desduplicarán automáticamente. + link_account: Vincular cuenta + no_account_specified: No se especificó ninguna cuenta + no_accounts_found: No se encontraron cuentas de Mercury. Por favor, verifica la configuración de tu token de API. + no_api_token: El token de API de Mercury no está configurado. Por favor, configúralo en Ajustes. + no_credentials_configured: Por favor, configura primero tu token de API de Mercury en los Ajustes del Proveedor. + no_name_placeholder: "(Sin nombre)" + title: "Vincular %{account_name} con Mercury" + link_existing_account: + account_already_linked: Esta cuenta ya está vinculada a un proveedor + api_error: "Error de API: %{message}" + invalid_account_name: No se puede vincular una cuenta con el nombre en blanco + mercury_account_already_linked: Esta cuenta de Mercury ya está vinculada a otra cuenta + mercury_account_not_found: Cuenta de Mercury no encontrada + missing_parameters: Faltan parámetros requeridos + no_api_token: No se encontró el token de API de Mercury. Por favor, configúralo en los Ajustes del Proveedor. + success: "%{account_name} vinculada con Mercury con éxito" + setup_accounts: + account_type_label: "Tipo de cuenta:" + all_accounts_linked: "Todas tus cuentas de Mercury ya han sido configuradas." + api_error: "Error de API: %{message}" + fetch_failed: "Error al obtener las cuentas" + no_accounts_to_setup: "No hay cuentas para configurar" + no_api_token: "El token de API de Mercury no está configurado. Por favor, comprueba los ajustes de conexión." + account_types: + skip: Omitir esta cuenta + depository: Cuenta corriente o de ahorro + credit_card: Tarjeta de crédito + investment: Cuenta de inversión + loan: Préstamo o hipoteca + other_asset: Otro activo + subtype_labels: + depository: "Subtipo de cuenta:" + credit_card: "" + investment: "Tipo de inversión:" + loan: "Tipo de préstamo:" + other_asset: "" + subtype_messages: + credit_card: "Las tarjetas de crédito se configurarán automáticamente como cuentas de tarjeta de crédito." + other_asset: "No se necesitan opciones adicionales para otros activos." + subtypes: + depository: + checking: Corriente + savings: Ahorros + hsa: Cuenta de ahorros para la salud (HSA) + cd: Certificado de depósito + money_market: Mercado monetario + investment: + brokerage: Bróker + pension: Plan de pensiones + retirement: Jubilación + "401k": "401(k)" + roth_401k: "Roth 401(k)" + "403b": "403(b)" + tsp: Plan de ahorro TSP + "529_plan": Plan 529 + hsa: Cuenta de ahorros para la salud (HSA) + mutual_fund: Fondo de inversión + ira: IRA tradicional + roth_ira: Roth IRA + angel: Inversión Angel + loan: + mortgage: Hipoteca + student: Préstamo estudiantil + auto: Préstamo de coche + other: Otro préstamo + balance: Saldo + cancel: Cancelar + choose_account_type: "Elige el tipo de cuenta correcto para cada cuenta de Mercury:" + create_accounts: Crear cuentas + creating_accounts: Creando cuentas... + historical_data_range: "Rango de datos históricos:" + subtitle: Elige los tipos de cuenta correctos para tus cuentas importadas + sync_start_date_help: Selecciona cuánto tiempo atrás deseas sincronizar el historial de transacciones. Máximo 3 años de historial disponibles. + sync_start_date_label: "Empezar a sincronizar transacciones desde:" + title: Configura tus cuentas de Mercury + complete_account_setup: + all_skipped: "Se omitieron todas las cuentas. No se creó ninguna cuenta." + creation_failed: "Error al crear las cuentas: %{error}" + no_accounts: "No hay cuentas para configurar." + success: "Se han creado %{count} cuenta(s) con éxito." + sync: + success: Sincronización iniciada + update: + success: Conexión con Mercury actualizada \ No newline at end of file diff --git a/config/locales/views/onboardings/es.yml b/config/locales/views/onboardings/es.yml index 1fc48268a..d32b480cf 100644 --- a/config/locales/views/onboardings/es.yml +++ b/config/locales/views/onboardings/es.yml @@ -16,8 +16,13 @@ es: first_name_placeholder: Nombre last_name: Apellido last_name_placeholder: Apellido + group_name: Nombre del grupo + group_name_placeholder: Nombre del grupo household_name: Nombre del hogar household_name_placeholder: Nombre del hogar + moniker_prompt: "Usaré %{product_name} con..." + moniker_family: Miembros de la familia (solo tú o con pareja, adolescentes, etc.) + moniker_group: Un grupo de personas (empresa, club, asociación o cualquier otro tipo) country: País submit: Continuar preferences: diff --git a/config/locales/views/other_assets/es.yml b/config/locales/views/other_assets/es.yml index d04b956ae..3a48fddcd 100644 --- a/config/locales/views/other_assets/es.yml +++ b/config/locales/views/other_assets/es.yml @@ -3,5 +3,7 @@ es: other_assets: edit: edit: Editar %{account} + balance_tracking_info: "El seguimiento de otros activos se realiza mediante valoraciones manuales usando 'Nuevo saldo', no mediante transacciones. El flujo de caja no afectará al saldo de la cuenta." new: title: Introduce los detalles del activo + balance_tracking_info: "El seguimiento de otros activos se realiza mediante valoraciones manuales usando 'Nuevo saldo', no mediante transacciones. El flujo de caja no afectará al saldo de la cuenta." diff --git a/config/locales/views/pages/es.yml b/config/locales/views/pages/es.yml index 1c3c86115..db7095fb2 100644 --- a/config/locales/views/pages/es.yml +++ b/config/locales/views/pages/es.yml @@ -3,10 +3,20 @@ es: pages: changelog: title: Novedades + privacy: + title: Política de privacidad + heading: Política de privacidad + placeholder: El contenido de la política de privacidad se mostrará aquí. + terms: + title: Condiciones del servicio + heading: Condiciones del servicio + placeholder: El contenido de las condiciones del servicio se mostrará aquí. dashboard: welcome: "Bienvenido de nuevo, %{name}" subtitle: "Esto es lo que está pasando con tus finanzas" new: "Nuevo" + drag_to_reorder: "Arrastra para reordenar la sección" + toggle_section: "Alternar visibilidad de la sección" net_worth_chart: data_not_available: Datos no disponibles para el período seleccionado title: Patrimonio neto @@ -15,6 +25,7 @@ es: no_account_subtitle: Como no se han añadido cuentas, no hay datos para mostrar. Añade tus primeras cuentas para empezar a ver los datos del panel. no_account_title: Aún no hay cuentas balance_sheet: + title: "Balance de situación" no_items: "Aún no hay %{name}" add_accounts: "Añade tus cuentas de %{name} para ver un desglose completo" cashflow_sankey: @@ -29,3 +40,19 @@ es: outflows_donut: title: "Salidas" total_outflows: "Salidas totales" + categories: "Categorías" + value: "Valor" + weight: "Peso" + investment_summary: + title: "Inversiones" + total_return: "Rentabilidad total" + holding: "Activo" + weight: "Peso" + value: "Valor" + return: "Rentabilidad" + period_activity: "Actividad de %{period}" + contributions: "Aportaciones" + withdrawals: "Retiradas" + trades: "Operaciones" + no_investments: "No hay cuentas de inversión" + add_investment: "Añade una cuenta de inversión para realizar el seguimiento de tu cartera" \ No newline at end of file diff --git a/config/locales/views/password_resets/es.yml b/config/locales/views/password_resets/es.yml index 36fe99a79..4fa5c10ea 100644 --- a/config/locales/views/password_resets/es.yml +++ b/config/locales/views/password_resets/es.yml @@ -1,14 +1,15 @@ --- es: password_resets: + disabled: El restablecimiento de contraseña a través de Sure está desactivado. Por favor, restablece tu contraseña a través de tu proveedor de identidad. + sso_only_user: Tu cuenta utiliza SSO para la autenticación. Por favor, contacta con tu administrador para gestionar tus credenciales. edit: title: Restablecer contraseña new: - requested: Por favor, revisa tu correo electrónico para obtener - un enlace para restablecer tu contraseña. + requested: Por favor, revisa tu correo electrónico para obtener un enlace para restablecer tu contraseña. submit: Restablecer contraseña title: Restablecer contraseña back: Volver update: - invalid_token: Token inválido. - success: Tu contraseña ha sido restablecida. + invalid_token: Token no válido. + success: Tu contraseña ha sido restablecida. \ No newline at end of file diff --git a/config/locales/views/pdf_import_mailer/es.yml b/config/locales/views/pdf_import_mailer/es.yml new file mode 100644 index 000000000..d7c2e24af --- /dev/null +++ b/config/locales/views/pdf_import_mailer/es.yml @@ -0,0 +1,17 @@ +--- +es: + pdf_import_mailer: + next_steps: + greeting: "Hola %{name}," + intro: "Hemos terminado de analizar el documento PDF que subiste a %{product}." + document_type_label: Tipo de documento + summary_label: Resumen de la IA + transactions_note: Este documento parece contener transacciones. Ya puedes extraerlas y revisarlas. + document_stored_note: Este documento ha sido guardado para tu referencia. Se puede utilizar para proporcionar contexto en futuras conversaciones con la IA. + next_steps_label: ¿Qué sigue ahora? + next_steps_intro: "Tienes varias opciones:" + option_extract_transactions: Extraer las transacciones de este extracto + option_keep_reference: Guardar este documento como referencia para futuras conversaciones con la IA + option_delete: Eliminar esta importación si ya no la necesitas + view_import_button: Ver detalles de la importación + footer_note: Este es un mensaje automático. Por favor, no respondas directamente a este correo electrónico. \ No newline at end of file diff --git a/config/locales/views/plaid_items/es.yml b/config/locales/views/plaid_items/es.yml index 7931f1591..32c3802fe 100644 --- a/config/locales/views/plaid_items/es.yml +++ b/config/locales/views/plaid_items/es.yml @@ -8,12 +8,10 @@ es: plaid_item: add_new: Añadir nueva conexión confirm_accept: Eliminar institución - confirm_body: Esto eliminará permanentemente todas las cuentas de este grupo y - todos los datos asociados. + confirm_body: Esto eliminará permanentemente todas las cuentas de este grupo y todos los datos asociados. confirm_title: ¿Eliminar institución? connection_lost: Conexión perdida - connection_lost_description: Esta conexión ya no es válida. Necesitarás - eliminar esta conexión y añadirla de nuevo para continuar sincronizando los datos. + connection_lost_description: Esta conexión ya no es válida. Necesitarás eliminar esta conexión y añadirla de nuevo para continuar sincronizando los datos. delete: Eliminar error: Ocurrió un error mientras se sincronizaban los datos no_accounts_description: No pudimos cargar ninguna cuenta de esta institución financiera. @@ -23,3 +21,8 @@ es: status_never: Requiere sincronización de datos syncing: Sincronizando... update: Actualizar conexión + select_existing_account: + title: "Vincular %{account_name} a Plaid" + description: Selecciona una cuenta de Plaid para vincularla a tu cuenta existente + cancel: Cancelar + link_account: Vincular cuenta \ No newline at end of file diff --git a/config/locales/views/recurring_transactions/es.yml b/config/locales/views/recurring_transactions/es.yml index bd42f4268..690b835d8 100644 --- a/config/locales/views/recurring_transactions/es.yml +++ b/config/locales/views/recurring_transactions/es.yml @@ -5,10 +5,18 @@ es: upcoming: Próximas Transacciones Recurrentes projected: Proyectado recurring: Recurrente + expected_today: "Esperado hoy" + expected_in: + one: "Esperado en %{count} día" + other: "Esperado en %{count} días" expected_on: Esperado el %{date} day_of_month: Día %{day} del mes identify_patterns: Identificar Patrones cleanup_stale: Limpiar Obsoletos + settings: + enable_label: Activar transacciones recurrentes + enable_description: Detecta automáticamente patrones de transacciones recurrentes y muestra las próximas transacciones proyectadas. + settings_updated: Configuración de transacciones recurrentes actualizada info: title: Detección Automática de Patrones manual_description: Puedes identificar patrones manualmente o limpiar transacciones recurrentes obsoletas usando los botones de arriba. @@ -22,11 +30,16 @@ es: marked_active: Transacción recurrente marcada como activa deleted: Transacción recurrente eliminada confirm_delete: ¿Estás seguro de que deseas eliminar esta transacción recurrente? + marked_as_recurring: Transacción marcada como recurrente + already_exists: Ya existe una transacción recurrente manual para este patrón + creation_failed: Error al crear la transacción recurrente. Por favor, comprueba los detalles e inténtalo de nuevo. + unexpected_error: Ha ocurrido un error inesperado al crear la transacción recurrente + amount_range: "Rango: %{min} a %{max}" empty: title: No se encontraron transacciones recurrentes description: Haz clic en "Identificar Patrones" para detectar automáticamente transacciones recurrentes de tu historial de transacciones. table: - merchant: Comerciante + merchant: Nombre amount: Importe expected_day: Día Esperado next_date: Próxima Fecha @@ -36,3 +49,5 @@ es: status: active: Activa inactive: Inactiva + badges: + manual: Manual \ No newline at end of file diff --git a/config/locales/views/registrations/es.yml b/config/locales/views/registrations/es.yml index fc4b1169d..fadc9e6d5 100644 --- a/config/locales/views/registrations/es.yml +++ b/config/locales/views/registrations/es.yml @@ -11,21 +11,21 @@ es: closed: Las inscripciones están actualmente cerradas. create: failure: Hubo un problema al registrarse. - invalid_invite_code: Código de invitación inválido, por favor inténtalo de nuevo. + invalid_invite_code: Código de invitación no válido, por favor inténtalo de nuevo. success: Te has registrado con éxito. new: invitation_message: "%{inviter} te ha invitado a unirte como %{role}" - join_family_title: Únete a %{family} + join_family_title: Únete a %{family} %{moniker} role_admin: administrador + role_guest: invitado role_member: miembro submit: Crear cuenta title: Crea tu cuenta - welcome_body: Para comenzar, debes registrarte para obtener una nueva cuenta. Luego podrás - configurar ajustes adicionales dentro de la aplicación. - welcome_title: ¡Bienvenido a Self Hosted %{product_name}! + welcome_body: Para comenzar, debes registrarte para obtener una nueva cuenta. Luego podrás configurar ajustes adicionales dentro de la aplicación. + welcome_title: ¡Bienvenido a %{product_name} (Self Hosted)! password_placeholder: Introduce tu contraseña password_requirements: length: Mínimo 8 caracteres case: Mayúsculas y minúsculas number: Un número (0-9) - special: "Un carácter especial (!, @, #, $, %, etc)" + special: "Un carácter especial (!, @, #, $, %, etc)" \ No newline at end of file diff --git a/config/locales/views/reports/es.yml b/config/locales/views/reports/es.yml index 8feab2eb5..00934b2fb 100644 --- a/config/locales/views/reports/es.yml +++ b/config/locales/views/reports/es.yml @@ -34,6 +34,7 @@ es: budgeted: Presupuestado remaining: Restante over_by: Exceso de + shared: compartido suggested_daily: "%{amount} sugerido por día durante los %{days} días restantes" no_budgets: No hay categorías de presupuesto configuradas para este mes status: @@ -134,6 +135,22 @@ es: value: Valor return: Rentabilidad accounts: Cuentas de inversión + gains_by_tax_treatment: Ganancias por tratamiento fiscal + unrealized_gains: Ganancias no realizadas + realized_gains: Ganancias realizadas + total_gains: Ganancias totales + taxable_realized_note: Estas ganancias pueden estar sujetas a impuestos + no_data: "-" + view_details: Ver detalles + holdings_count: + one: "%{count} activo" + other: "%{count} activos" + sells_count: + one: "%{count} venta" + other: "%{count} ventas" + holdings: Activos + sell_trades: Operaciones de venta + and_more: "+%{count} más" investment_flows: title: Flujos de inversión description: Controla el dinero que entra y sale de tus cuentas de inversión @@ -147,7 +164,7 @@ es: steps: "Para importar en Google Sheets:\n1. Crea una nueva hoja de cálculo\n2. En la celda A1, introduce la fórmula que se muestra abajo\n3. Pulsa Enter" security_warning: "Esta URL incluye tu clave API. ¡Mantenla segura!" need_key: Para importar los datos en Google Sheets necesitas una clave API. - step1: "Ve a ajustes → Clave API" + step1: "Ve a Ajustes → Claves API" step2: "Crea una nueva clave API con permiso de lectura (\"read\")" step3: Copia la clave API step4: "Añádela a esta URL como: ?api_key=TU_CLAVE" @@ -209,4 +226,4 @@ es: category: Categoría amount: Importe percent: "%" - more_categories: "+ %{count} más categorías" + more_categories: "+ %{count} más categorías" \ No newline at end of file diff --git a/config/locales/views/rules/es.yml b/config/locales/views/rules/es.yml index 3c494e8fb..2390c7676 100644 --- a/config/locales/views/rules/es.yml +++ b/config/locales/views/rules/es.yml @@ -3,6 +3,21 @@ es: rules: no_action: Sin acción no_condition: Sin condición + actions: + value_placeholder: Introduce un valor + apply_all: + button: Aplicar todas + confirm_title: Aplicar todas las reglas + confirm_message: Estás a punto de aplicar %{count} reglas que afectan a %{transactions} transacciones únicas. Por favor, confirma si deseas continuar. + confirm_button: Confirmar y aplicar todas + success: Todas las reglas han sido puestas en cola para su ejecución + ai_cost_title: Estimación de costes de IA + ai_cost_message: Esto utilizará IA para categorizar hasta %{transactions} transacciones. + estimated_cost: "Coste estimado: ~$%{cost}" + cost_unavailable_model: Estimación de costes no disponible para el modelo "%{model}". + cost_unavailable_no_provider: Estimación de costes no disponible (no hay proveedor de LLM configurado). + cost_warning: Puedes incurrir en costes, consulta con el proveedor del modelo los precios más actualizados. + view_usage: Ver historial de uso recent_runs: title: Ejecuciones Recientes description: Ver el historial de ejecución de tus reglas incluyendo el estado de éxito/fallo y los conteos de transacciones. @@ -23,3 +38,15 @@ es: pending: Pendiente success: Éxito failed: Fallido + clear_ai_cache: + button: Restablecer caché de IA + confirm_title: ¿Restablecer caché de IA? + confirm_body: ¿Estás seguro de que deseas restablecer la caché de IA? Esto permitirá que las reglas de IA vuelvan a procesar todas las transacciones. Esto puede incurrir en costes adicionales de API. + confirm_button: Restablecer caché + success: Se está limpiando la caché de IA. Esto puede tardar unos momentos. + condition_filters: + transaction_type: + income: Ingreso + expense: Gasto + transfer: Transferencia + equal_to: Igual a diff --git a/config/locales/views/securities/es.yml b/config/locales/views/securities/es.yml new file mode 100644 index 000000000..2ccd9cdd4 --- /dev/null +++ b/config/locales/views/securities/es.yml @@ -0,0 +1,6 @@ +--- +es: + securities: + combobox: + display: "%{symbol} - %{name} (%{exchange})" + exchange_label: "%{symbol} (%{exchange})" \ No newline at end of file diff --git a/config/locales/views/sessions/es.yml b/config/locales/views/sessions/es.yml index d7dbbee6b..1e6cc34d2 100644 --- a/config/locales/views/sessions/es.yml +++ b/config/locales/views/sessions/es.yml @@ -2,24 +2,32 @@ es: sessions: create: - invalid_credentials: Correo electrónico o contraseña inválidos. - local_login_disabled: El inicio de sesión con contraseña local está deshabilitado. Utiliza el inicio de sesión único (SSO). + invalid_credentials: Correo electrónico o contraseña no válidos. + local_login_disabled: El inicio de sesión con contraseña local está desactivado. Por favor, utiliza el inicio de sesión único (SSO). destroy: - logout_successful: Has cerrado sesión con éxito. + logout_successful: Has cerrado sesión correctamente. + post_logout: + logout_successful: Has cerrado sesión correctamente. openid_connect: + account_linked: "Cuenta vinculada correctamente a %{provider}" failed: No se pudo autenticar a través de OpenID Connect. failure: failed: No se pudo autenticar. + sso_provider_unavailable: "El proveedor de SSO no está disponible en este momento. Por favor, inténtalo de nuevo más tarde o contacta con un administrador." + sso_invalid_response: "Se ha recibido una respuesta no válida del proveedor de SSO. Por favor, inténtalo de nuevo." + sso_failed: "Error en la autenticación de inicio de sesión único (SSO). Por favor, inténtalo de nuevo." new: email: Dirección de correo electrónico email_placeholder: tu@ejemplo.com - forgot_password: ¿Olvidaste tu contraseña? + forgot_password: ¿Has olvidado tu contraseña? password: Contraseña submit: Iniciar sesión - title: Inicia sesión en tu cuenta + title: Sure password_placeholder: Introduce tu contraseña - openid_connect: Inicia sesión con OpenID Connect - oidc: Inicia sesión con OpenID Connect - google_auth_connect: Inicia sesión con Google + openid_connect: Iniciar sesión con OpenID Connect + oidc: Iniciar sesión con OpenID Connect + google_auth_connect: Iniciar sesión con Google local_login_admin_only: El inicio de sesión local está restringido a administradores. - no_auth_methods_enabled: No hay métodos de autenticación habilitados actualmente. Ponte en contacto con un administrador. + no_auth_methods_enabled: No hay métodos de autenticación habilitados actualmente. Por favor, contacta con un administrador. + demo_banner_title: "Modo de demostración activo" + demo_banner_message: "Este es un entorno de demostración. Las credenciales de acceso se han rellenado automáticamente para tu comodidad. Por favor, no introduzcas información real o sensible." \ No newline at end of file diff --git a/config/locales/views/settings/es.yml b/config/locales/views/settings/es.yml index 1867ac49e..b9346a547 100644 --- a/config/locales/views/settings/es.yml +++ b/config/locales/views/settings/es.yml @@ -4,6 +4,7 @@ es: settings: payments: renewal: "Tu contribución continúa el %{date}." + cancellation: "Tu contribución finaliza el %{date}." settings: ai_prompts: show: @@ -22,9 +23,9 @@ es: subtitle: La IA identifica y enriquece los datos de transacciones con información de comerciantes payments: show: - page_title: Pago - subscription_subtitle: Actualiza tu suscripción y detalles de pago - subscription_title: Gestionar suscripción + page_title: Pagos + subscription_subtitle: Actualiza los detalles de tu tarjeta de crédito + subscription_title: Gestionar contribuciones preferences: show: country: País @@ -43,6 +44,9 @@ es: theme_system: Sistema theme_title: Tema timezone: Zona horaria + month_start_day: El mes de presupuesto comienza el + month_start_day_hint: Establece cuándo empieza tu mes financiero (ej. el día de cobro) + month_start_day_warning: Tus presupuestos y cálculos del mes en curso utilizarán este día personalizado en lugar del día 1 de cada mes. profiles: destroy: cannot_remove_self: No puedes eliminarte a ti mismo de la cuenta. @@ -74,9 +78,12 @@ es: reset_account_with_sample_data_warning: Elimina todos tus datos existentes y luego carga nuevos datos de ejemplo para que puedas explorar con un entorno prellenado. email: Correo electrónico first_name: Nombre + group_form_input_placeholder: Introduce el nombre del grupo + group_form_label: Nombre del grupo + group_title: Miembros del Grupo household_form_input_placeholder: Introduce el nombre del grupo familiar household_form_label: Nombre del grupo familiar - household_subtitle: Invita a miembros de la familia, socios y otras personas. Los invitados pueden entrar en la cuenta y acceder a las cuentas compartidas. + household_subtitle: Los invitados pueden entrar en tu cuenta de %{moniker} y acceder a los recursos compartidos. household_title: Grupo Familiar invitation_link: Enlace de invitación invite_member: Añadir miembro @@ -91,6 +98,23 @@ es: securities: show: page_title: Seguridad + mfa_title: Autenticación de Dos Factores (2FA) + mfa_description: Añade una capa extra de seguridad a tu cuenta requiriendo un código de tu aplicación de autenticación al iniciar sesión. + enable_mfa: Activar 2FA + disable_mfa: Desactivar 2FA + disable_mfa_confirm: ¿Estás seguro de que deseas desactivar la autenticación de dos factores? + sso_title: Cuentas Conectadas + sso_subtitle: Gestiona tus conexiones de inicio de sesión único (SSO) + sso_disconnect: Desconectar + sso_last_used: Último uso + sso_never: Nunca + sso_no_email: Sin correo + sso_no_identities: No hay cuentas de SSO conectadas + sso_connect_hint: Cierra sesión e iníciala con un proveedor de SSO para conectar una cuenta. + sso_confirm_title: ¿Desconectar cuenta? + sso_confirm_body: ¿Estás seguro de que deseas desconectar tu cuenta de %{provider}? Podrás volver a conectarla más tarde iniciando sesión de nuevo con ese proveedor. + sso_confirm_button: Desconectar + sso_warning_message: Este es tu único método de acceso. Deberías establecer una contraseña en tus ajustes de seguridad antes de desconectarlo, de lo contrario podrías perder el acceso a tu cuenta. settings_nav: accounts_label: Cuentas advanced_section_title: Avanzado @@ -125,3 +149,27 @@ es: choose: Subir foto choose_label: (opcional) change: Cambiar foto + providers: + show: + coinbase_title: Coinbase + encryption_error: + title: Configuración de Cifrado Requerida + message: Las claves de cifrado de Active Record no están configuradas. Por favor, asegúrate de que las credenciales de cifrado (active_record_encryption.primary_key, active_record_encryption.deterministic_key y active_record_encryption.key_derivation_salt) estén correctamente configuradas en tus credenciales de Rails o variables de entorno antes de usar proveedores de sincronización. + coinbase_panel: + setup_instructions: "Para conectar Coinbase:" + step1_html: Ve a los Ajustes de API de Coinbase + step2: Crea una nueva clave API con permisos de solo lectura (ver cuentas, ver transacciones) + step3: Copia tu clave API y tu secreto de API y pégalos a continuación + api_key_label: Clave API + api_key_placeholder: Introduce tu clave API de Coinbase + api_secret_label: Secreto de API + api_secret_placeholder: Introduce tu secreto de API de Coinbase + connect_button: Conectar Coinbase + syncing: Sincronizando... + sync: Sincronizar + disconnect_confirm: ¿Estás seguro de que deseas desconectar esta conexión de Coinbase? Tus cuentas sincronizadas pasarán a ser cuentas manuales. + status_connected: Coinbase está conectado y sincronizando tus activos de criptomonedas. + status_not_connected: No conectado. Introduce tus credenciales de API arriba para comenzar. + enable_banking_panel: + callback_url_instruction: "Para la URL de retorno (callback), utiliza %{callback_url}." + connection_error: Error de conexión \ No newline at end of file diff --git a/config/locales/views/settings/hostings/es.yml b/config/locales/views/settings/hostings/es.yml index df51060cd..aff9d71f8 100644 --- a/config/locales/views/settings/hostings/es.yml +++ b/config/locales/views/settings/hostings/es.yml @@ -4,7 +4,7 @@ es: hostings: invite_code_settings: description: Controla cómo se registran nuevas personas en tu instancia de %{product}. - email_confirmation_description: Cuando está habilitado, los usuarios deben confirmar su dirección de correo electrónico al cambiarla. + email_confirmation_description: Cuando está habilitado, los usuarios deben confirmar su dirección de correo electrónico al cambiarla o registrarse. email_confirmation_title: Requerir confirmación de correo electrónico generate_tokens: Generar nuevo código generated_tokens: Códigos generados @@ -15,22 +15,59 @@ es: invite_only: Solo con invitación show: general: Configuración General + ai_assistant: Asistente de IA financial_data_providers: Proveedores de Datos Financieros + sync_settings: Ajustes de Sincronización invites: Códigos de Invitación title: Autoalojamiento danger_zone: Zona de Peligro clear_cache: Limpiar caché de datos - clear_cache_warning: Limpiar la caché de datos eliminará todos los tipos de cambio, precios de valores, - saldos de cuentas y otros datos. Esto no eliminará cuentas, transacciones, categorías u otros datos propiedad del usuario. + clear_cache_warning: Limpiar la caché de datos eliminará todos los tipos de cambio, precios de valores, saldos de cuentas y otros datos temporales. Esto no eliminará cuentas, transacciones, categorías ni otros datos del usuario. confirm_clear_cache: title: ¿Limpiar caché de datos? - body: ¿Estás seguro de que deseas limpiar la caché de datos? Esto eliminará todos los tipos de cambio, - precios de valores, saldos de cuentas y otros datos. Esta acción no se puede deshacer. + body: ¿Estás seguro de que deseas limpiar la caché de datos? Se eliminarán tipos de cambio, precios y saldos históricos. Esta acción no se puede deshacer. + provider_selection: + title: Selección de Proveedores + description: Elige qué servicio usar para obtener tipos de cambio y precios de acciones. Yahoo Finance es gratuito y no requiere clave API. Twelve Data requiere una clave API (gratuita disponible) pero ofrece mayor cobertura de datos. + exchange_rate_provider_label: Proveedor de tipos de cambio + securities_provider_label: Proveedor de valores (Precios de acciones) + env_configured_message: La selección de proveedor está desactivada porque las variables de entorno (EXCHANGE_RATE_PROVIDER o SECURITIES_PROVIDER) están definidas. Para habilitar la selección aquí, elimina dichas variables de tu configuración. + providers: + twelve_data: Twelve Data + yahoo_finance: Yahoo Finance + assistant_settings: + title: Asistente de IA + description: Elige cómo responde el asistente del chat. "Integrado" utiliza directamente tu proveedor de LLM configurado. "Externo" delega en un agente remoto que puede interactuar con las herramientas financieras de Sure mediante MCP. + type_label: Tipo de asistente + type_builtin: Integrado (LLM directo) + type_external: Externo (agente remoto) + external_status: Punto de conexión del asistente externo + external_configured: Configurado + external_not_configured: No configurado. Introduce la URL y el token a continuación, o define las variables de entorno EXTERNAL_ASSISTANT_URL y EXTERNAL_ASSISTANT_TOKEN. + env_notice: "El tipo de asistente está fijado en '%{type}' mediante la variable de entorno ASSISTANT_TYPE." + env_configured_external: Configurado correctamente mediante variables de entorno. + url_label: URL del punto de conexión (Endpoint) + url_placeholder: "https://tu-agente-host/v1/chat" + url_help: La URL completa del punto de conexión de la API de tu agente. Tu proveedor de agentes te facilitará esta dirección. + token_label: Token de API + token_placeholder: Introduce el token de tu proveedor de agentes + token_help: El token de autenticación proporcionado por tu agente externo. Se envía como un token Bearer en cada solicitud. + agent_id_label: ID del Agente (Opcional) + agent_id_placeholder: "main (por defecto)" + agent_id_help: Dirige las peticiones a un agente específico si el proveedor aloja varios. Déjalo en blanco para el predeterminado. + disconnect_title: Conexión externa + disconnect_description: Elimina la conexión del asistente externo y vuelve al asistente integrado. + disconnect_button: Desconectar + confirm_disconnect: + title: ¿Desconectar asistente externo? + body: Esto eliminará la URL guardada, el token y el ID del agente, y cambiará al asistente integrado. Podrás volver a conectarlo más tarde introduciendo nuevas credenciales. brand_fetch_settings: description: Introduce el ID de Cliente proporcionado por Brand Fetch label: ID de Cliente placeholder: Introduce tu ID de Cliente aquí title: Configuración de Brand Fetch + high_res_label: Activar logotipos de alta resolución + high_res_description: Cuando está habilitado, los logotipos se obtendrán a una resolución de 120x120 en lugar de 40x40. Esto ofrece imágenes más nítidas en pantallas de alta densidad de píxeles (DPI). openai_settings: description: Introduce el token de acceso y, opcionalmente, configura un proveedor compatible con OpenAI personalizado env_configured_message: Configurado con éxito a través de variables de entorno. @@ -40,6 +77,12 @@ es: uri_base_placeholder: "https://api.openai.com/v1 (por defecto)" model_label: Modelo (Opcional) model_placeholder: "gpt-4.1 (por defecto)" + json_mode_label: Modo JSON + json_mode_auto: Automático (recomendado) + json_mode_strict: Estricto (mejor para modelos "thinking") + json_mode_none: Ninguno (mejor para modelos estándar) + json_mode_json_object: Objeto JSON + json_mode_help: "El modo Estricto funciona mejor con modelos de razonamiento (qwen-thinking, deepseek-reasoner). El modo Ninguno funciona mejor con modelos estándar (llama, mistral, gpt-oss)." title: OpenAI yahoo_finance_settings: title: Yahoo Finance @@ -55,11 +98,27 @@ es: label: Clave API placeholder: Introduce tu clave API aquí plan: Plan %{plan} + plan_upgrade_warning_title: Algunos activos requieren un plan de pago + plan_upgrade_warning_description: Los siguientes activos de tu cartera no pueden sincronizar precios con tu plan actual de Twelve Data. + requires_plan: requiere el plan %{plan} + view_pricing: Ver precios de Twelve Data title: Twelve Data update: - failure: Valor de configuración inválido + failure: Valor de configuración no válido success: Configuración actualizada - invalid_onboarding_state: Estado de incorporación inválido + invalid_onboarding_state: Estado de incorporación no válido + invalid_sync_time: Formato de hora de sincronización no válido. Por favor, usa el formato HH:MM (ej. 02:30). + scheduler_sync_failed: Ajustes guardados, pero no se pudo actualizar la programación de sincronización. Inténtalo de nuevo o revisa los registros del servidor. + disconnect_external_assistant: + external_assistant_disconnected: Asistente externo desconectado clear_cache: cache_cleared: La caché de datos ha sido limpiada. Esto puede tardar unos momentos en completarse. not_authorized: No estás autorizado para realizar esta acción + sync_settings: + auto_sync_label: Activar sincronización automática + auto_sync_description: Cuando está habilitado, todas las cuentas se sincronizarán automáticamente cada día a la hora especificada. + auto_sync_time_label: Hora de sincronización (HH:MM) + auto_sync_time_description: Especifica la hora del día en la que debe ocurrir la sincronización automática. + include_pending_label: Incluir transacciones pendientes + include_pending_description: Cuando está habilitado, las transacciones pendientes (no liquidadas) se importarán y se conciliarán automáticamente cuando se confirmen. Desactívalo si tu banco proporciona datos pendientes poco fiables. + env_configured_message: Este ajuste está desactivado porque hay una variable de entorno del proveedor (SIMPLEFIN_INCLUDE_PENDING o PLAID_INCLUDE_PENDING) definida. Elimínala para habilitar este ajuste aquí. \ No newline at end of file diff --git a/config/locales/views/settings/sso_identities/es.yml b/config/locales/views/settings/sso_identities/es.yml new file mode 100644 index 000000000..a1edda19f --- /dev/null +++ b/config/locales/views/settings/sso_identities/es.yml @@ -0,0 +1,7 @@ +--- +es: + settings: + sso_identities: + destroy: + cannot_unlink_last: No se puede desvincular la última identidad + success: Éxito \ No newline at end of file diff --git a/config/locales/views/simplefin_items/es.yml b/config/locales/views/simplefin_items/es.yml index e36d3db59..f1755c82b 100644 --- a/config/locales/views/simplefin_items/es.yml +++ b/config/locales/views/simplefin_items/es.yml @@ -2,52 +2,106 @@ es: simplefin_items: new: - title: Conectar SimpleFin + title: Conectar SimpleFIN setup_token: Token de configuración - setup_token_placeholder: pega tu token de configuración de SimpleFin + setup_token_placeholder: pega tu token de configuración de SimpleFIN connect: Conectar cancel: Cancelar create: - success: ¡Conexión SimpleFin añadida con éxito! Tus cuentas aparecerán en breve mientras se sincronizan en segundo plano. + success: ¡Conexión SimpleFIN añadida con éxito! Tus cuentas aparecerán en breve mientras se sincronizan en segundo plano. errors: - blank_token: Por favor, introduce un token de configuración de SimpleFin. - invalid_token: Token de configuración inválido. Por favor, verifica que has copiado el token completo desde SimpleFin Bridge. - token_compromised: El token de configuración puede estar comprometido, expirado o ya utilizado. Por favor, crea uno nuevo. + blank_token: Por favor, introduce un token de configuración de SimpleFIN. + invalid_token: Token de configuración no válido. Por favor, verifica que has copiado el token completo desde SimpleFIN Bridge. + token_compromised: El token de configuración puede estar comprometido, caducado o ya utilizado. Por favor, crea uno nuevo. create_failed: "No se pudo conectar: %{message}" - unexpected: Ocurrió un error inesperado. Por favor, inténtalo de nuevo o contacta con soporte. + unexpected: Ha ocurrido un error inesperado. Por favor, inténtalo de nuevo. destroy: - success: La conexión SimpleFin será eliminada. + success: La conexión SimpleFIN será eliminada. update: - success: ¡Conexión SimpleFin actualizada con éxito! Tus cuentas están siendo reconectadas. + success: ¡Conexión SimpleFIN actualizada con éxito! Tus cuentas se están reconectando. errors: - blank_token: Por favor, introduce un token de configuración de SimpleFin. - invalid_token: Token de configuración inválido. Por favor, verifica que has copiado el token completo desde SimpleFin Bridge. - token_compromised: El token de configuración puede estar comprometido, expirado o ya utilizado. Por favor, crea uno nuevo. + blank_token: Por favor, introduce un token de configuración de SimpleFIN. + invalid_token: Token de configuración no válido. Por favor, verifica que has copiado el token completo desde SimpleFIN Bridge. + token_compromised: El token de configuración puede estar comprometido, caducado o ya utilizado. Por favor, crea uno nuevo. update_failed: "No se pudo actualizar la conexión: %{message}" - unexpected: Ocurrió un error inesperado. Por favor, inténtalo de nuevo o contacta con soporte. + unexpected: Ha ocurrido un error inesperado. Por favor, inténtalo de nuevo. edit: setup_token: - label: "Token de configuración de SimpleFin:" - placeholder: "Pega aquí tu token de configuración de SimpleFin..." - help_text: "El token debería ser una cadena larga que comienza con letras y números." + label: "Token de configuración de SimpleFIN:" + placeholder: "Pega aquí tu token de configuración de SimpleFIN..." + help_text: "El token debería ser una cadena larga que comienza con letras y números" + setup_accounts: + stale_accounts: + title: "Cuentas que ya no están en SimpleFIN" + description: "Estas cuentas existen en tu base de datos pero SimpleFIN ya no las proporciona. Esto puede ocurrir cuando cambian las configuraciones de origen." + action_prompt: "¿Qué te gustaría hacer?" + action_delete: "Eliminar cuenta y todas las transacciones" + action_move: "Mover transacciones a:" + action_skip: "Omitir por ahora" + transaction_count: + one: "%{count} transacción" + other: "%{count} transacciones" complete_account_setup: - success: ¡Las cuentas de SimpleFin se han configurado con éxito! Tus transacciones y activos se están importando en segundo plano. + all_skipped: "Se han omitido todas las cuentas. No se ha creado ninguna." + no_accounts: "No hay cuentas para configurar." + success: + one: "¡Se ha creado correctamente %{count} cuenta de SimpleFIN! Tus transacciones y posiciones se están importando en segundo plano." + other: "¡Se han creado correctamente %{count} cuentas de SimpleFIN! Tus transacciones y posiciones se están importando en segundo plano." + stale_accounts_processed: "Cuentas obsoletas: %{deleted} eliminadas, %{moved} movidas." + stale_accounts_errors: + one: "Error en la acción de %{count} cuenta obsoleta. Revisa los registros para más detalles." + other: "Error en las acciones de %{count} cuentas obsoletas. Revisa los registros para más detalles." simplefin_item: add_new: Añadir nueva conexión confirm_accept: Eliminar conexión confirm_body: Esto eliminará permanentemente todas las cuentas de este grupo y todos los datos asociados. - confirm_title: ¿Eliminar conexión SimpleFin? + confirm_title: ¿Eliminar conexión SimpleFIN? delete: Eliminar - deletion_in_progress: "(eliminación en progreso...)" - error: Ocurrió un error al sincronizar los datos + deletion_in_progress: "(eliminación en curso...)" + error: Ha ocurrido un error al sincronizar los datos no_accounts_description: Esta conexión aún no tiene cuentas sincronizadas. no_accounts_title: No se encontraron cuentas - requires_update: Requiere reautenticación + requires_update: Reconectar setup_needed: Nuevas cuentas listas para configurar - setup_description: Elige los tipos de cuenta para tus nuevas cuentas importadas de SimpleFin. + setup_description: Elige los tipos de cuenta para tus nuevas cuentas importadas de SimpleFIN. setup_action: Configurar nuevas cuentas + setup_accounts_menu: Configurar cuentas + more_accounts_available: + one: "Hay %{count} cuenta más disponible para configurar" + other: "Hay %{count} cuentas más disponibles para configurar" + accounts_skipped_tooltip: "Se omitieron algunas cuentas debido a errores durante la sincronización" + accounts_skipped_label: "Omitidas: %{count}" + rate_limited_ago: "Límite de frecuencia alcanzado (hace %{time})" + rate_limited_recently: "Límite de frecuencia alcanzado recientemente" status: Última sincronización hace %{timestamp} status_never: Nunca sincronizado status_with_summary: "Última sincronización hace %{timestamp} • %{summary}" syncing: Sincronizando... - update: Actualizar conexión \ No newline at end of file + update: Actualizar + stale_pending_note: "(excluido de presupuestos)" + stale_pending_accounts: "en: %{accounts}" + reconciled_details_note: "(ver resumen de sincronización para detalles)" + duplicate_accounts_skipped: "Se omitieron algunas cuentas por estar duplicadas — usa 'Vincular cuentas existentes' para fusionarlas." + select_existing_account: + title: "Vincular %{account_name} a SimpleFIN" + description: Selecciona una cuenta de SimpleFIN para vincularla a tu cuenta existente + cancel: Cancelar + link_account: Vincular cuenta + no_accounts_found: "No se han encontrado cuentas de SimpleFIN para este %{moniker}." + wait_for_sync: Si acabas de conectar o sincronizar, inténtalo de nuevo cuando finalice la sincronización. + unlink_to_move: Para mover un vínculo, primero desvincúlalo desde el menú de acciones de la cuenta. + all_accounts_already_linked: Todas las cuentas de SimpleFIN parecen estar ya vinculadas. + currently_linked_to: "Vinculada actualmente a: %{account_name}" + link_existing_account: + success: Cuenta vinculada correctamente a SimpleFIN + errors: + only_manual: Solo se pueden vincular cuentas manuales + invalid_simplefin_account: Se ha seleccionado una cuenta de SimpleFIN no válida + reconciled_status: + message: + one: "%{count} transacción pendiente duplicada conciliada" + other: "%{count} transacciones pendientes duplicadas conciliadas" + stale_pending_status: + message: + one: "%{count} transacción pendiente con más de %{days} días" + other: "%{count} transacciones pendientes con más de %{days} días" \ No newline at end of file diff --git a/config/locales/views/snaptrade_items/es.yml b/config/locales/views/snaptrade_items/es.yml new file mode 100644 index 000000000..d7456076e --- /dev/null +++ b/config/locales/views/snaptrade_items/es.yml @@ -0,0 +1,188 @@ +--- +es: + snaptrade_items: + default_name: "Conexión de SnapTrade" + create: + success: "SnapTrade configurado correctamente." + update: + success: "Configuración de SnapTrade actualizada correctamente." + destroy: + success: "Conexión de SnapTrade programada para su eliminación." + connect: + decryption_failed: "No se han podido leer las credenciales de SnapTrade. Por favor, elimina y vuelve a crear esta conexión." + connection_failed: "Error al conectar con SnapTrade: %{message}" + callback: + success: "¡Bróker conectado! Por favor, selecciona qué cuentas quieres vincular." + no_item: "No se ha encontrado la configuración de SnapTrade." + complete_account_setup: + success: + one: "Se ha vinculado %{count} cuenta correctamente." + other: "Se han vinculado %{count} cuentas correctamente." + partial_success: "Se han vinculado %{linked} cuenta(s). %{failed} han fallado." + link_failed: "Error al vincular las cuentas: %{errors}" + no_accounts: "No se ha seleccionado ninguna cuenta para vincular." + preload_accounts: + not_configured: "SnapTrade no está configurado." + select_accounts: + not_configured: "SnapTrade no está configurado." + select_existing_account: + not_found: "No se ha encontrado la cuenta o la configuración de SnapTrade." + title: "Vincular a cuenta de SnapTrade" + header: "Vincular cuenta existente" + subtitle: "Selecciona una cuenta de SnapTrade para realizar la vinculación" + no_accounts: "No hay cuentas de SnapTrade disponibles sin vincular." + connect_hint: "Es posible que primero debas conectar un bróker." + settings_link: "Ir a Ajustes del proveedor" + linking_to: "Vinculando a la cuenta:" + balance_label: "Saldo:" + link_button: "Vincular" + cancel_button: "Cancelar" + link_existing_account: + success: "Vinculado correctamente a la cuenta de SnapTrade." + failed: "Error al vincular la cuenta: %{message}" + not_found: "Cuenta no encontrada." + connections: + unknown_brokerage: "Bróker desconocido" + delete_connection: + success: "Conexión eliminada correctamente. Se ha liberado un espacio." + failed: "Error al eliminar la conexión: %{message}" + missing_authorization_id: "Falta el ID de autorización" + api_deletion_failed: "No se pudo eliminar la conexión de SnapTrade por falta de credenciales. Es posible que la conexión aún exista en tu cuenta de SnapTrade." + delete_orphaned_user: + success: "Registro huérfano eliminado correctamente." + failed: "Error al eliminar el registro huérfano." + setup_accounts: + title: "Configurar cuentas de SnapTrade" + header: "Configura tus cuentas de SnapTrade" + subtitle: "Selecciona qué cuentas de bróker quieres vincular" + syncing: "Obteniendo tus cuentas..." + loading: "Obteniendo cuentas de SnapTrade..." + loading_hint: "Haz clic en Actualizar para buscar cuentas." + refresh: "Actualizar" + info_title: "Datos de inversión de SnapTrade" + info_holdings: "Posiciones con precios y cantidades actuales" + info_cost_basis: "Base de costes por posición (cuando esté disponible)" + info_activities: "Historial de operaciones con etiquetas de actividad (Compra, Venta, Dividendo, etc.)" + info_history: "Hasta 3 años de historial de transacciones" + free_tier_note: "El nivel gratuito de SnapTrade permite 5 conexiones de bróker. Consulta tu panel de SnapTrade para ver el uso actual." + no_accounts_title: "No se han encontrado cuentas" + no_accounts_message: "No se han encontrado cuentas de bróker. Esto puede ocurrir si cancelaste la conexión o si tu bróker no es compatible." + try_again: "Conectar bróker" + back_to_settings: "Volver a Ajustes" + available_accounts: "Cuentas disponibles" + balance_label: "Saldo:" + account_number: "Cuenta:" + create_button: "Crear cuentas seleccionadas" + cancel_button: "Cancelar" + creating: "Creando cuentas..." + done_button: "Listo" + or_link_existing: "O vincula a una cuenta existente en lugar de crear una nueva:" + select_account: "Selecciona una cuenta..." + link_button: "Vincular" + linked_accounts: "Ya vinculadas" + linked_to: "Vinculada a:" + snaptrade_item: + accounts_need_setup: + one: "%{count} cuenta necesita configuración" + other: "%{count} cuentas necesitan configuración" + deletion_in_progress: "Eliminación en curso..." + syncing: "Sincronizando..." + requires_update: "La conexión necesita una actualización" + error: "Error de sincronización" + status: "Sincronizado hace %{timestamp} - %{summary}" + status_never: "Nunca sincronizado" + reconnect: "Reconectar" + connect_brokerage: "Conectar bróker" + add_another_brokerage: "Conectar otro bróker" + delete: "Eliminar" + setup_needed: "Las cuentas necesitan configuración" + setup_description: "Algunas cuentas de SnapTrade deben vincularse a cuentas de Sure." + setup_action: "Configurar cuentas" + setup_accounts_menu: "Configurar cuentas" + manage_connections: "Gestionar conexiones" + more_accounts_available: + one: "Hay %{count} cuenta más disponible para configurar" + other: "Hay %{count} cuentas más disponibles para configurar" + no_accounts_title: "No se han detectado cuentas" + no_accounts_description: "Conecta un bróker para importar tus cuentas de inversión." + + providers: + snaptrade: + name: "SnapTrade" + connection_description: "Conecta con tu bróker a través de SnapTrade (más de 25 brókers compatibles)" + description: "SnapTrade conecta con más de 25 brókers principales (Fidelity, Vanguard, Schwab, Robinhood, etc.) y proporciona un historial completo de operaciones con etiquetas de actividad y base de costes." + setup_title: "Instrucciones de configuración:" + step_1_html: "Crea una cuenta en dashboard.snaptrade.com" + step_2: "Copia tu Client ID y tu Consumer Key desde el panel" + step_3: "Introduce tus credenciales a continuación y haz clic en Guardar" + step_4: "Ve a la página de Cuentas y usa 'Conectar otro bróker' para vincular tus cuentas de inversión" + free_tier_warning: "El nivel gratuito incluye 5 conexiones de bróker. Las conexiones adicionales requieren un plan de pago de SnapTrade." + client_id_label: "Client ID" + client_id_placeholder: "Introduce tu Client ID de SnapTrade" + client_id_update_placeholder: "Introduce el nuevo Client ID para actualizar" + consumer_key_label: "Consumer Key" + consumer_key_placeholder: "Introduce tu Consumer Key de SnapTrade" + consumer_key_update_placeholder: "Introduce la nueva Consumer Key para actualizar" + save_button: "Guardar configuración" + update_button: "Actualizar configuración" + status_connected: + one: "%{count} cuenta de SnapTrade" + other: "%{count} cuentas de SnapTrade" + needs_setup: + one: "%{count} necesita configuración" + other: "%{count} necesitan configuración" + status_ready: "Listo para conectar brókers" + status_needs_registration: "Credenciales guardadas. Ve a la página de Cuentas para conectar brókers." + status_not_configured: "No configurado" + setup_accounts_button: "Configurar cuentas" + connect_button: "Conectar bróker" + connected_brokerages: "Conectados:" + manage_connections: "Gestionar conexiones" + connection_limit_info: "El nivel gratuito de SnapTrade permite 5 conexiones de bróker. Elimina conexiones sin usar para liberar espacios." + loading_connections: "Cargando conexiones..." + connections_error: "Error al cargar las conexiones: %{message}" + accounts_count: + one: "%{count} cuenta" + other: "%{count} cuentas" + orphaned_connection: "Conexión huérfana (no sincronizada localmente)" + needs_linking: "necesita vincularse" + no_connections: "No se han encontrado conexiones de bróker." + delete_connection: "Eliminar" + delete_connection_title: "¿Eliminar conexión del bróker?" + delete_connection_body: "Esto eliminará permanentemente la conexión de %{brokerage} de SnapTrade. Todas las cuentas de este bróker se desvincularán. Deberás volver a conectar para sincronizar estas cuentas de nuevo." + delete_connection_confirm: "Eliminar conexión" + orphaned_users_title: + one: "%{count} registro huérfano" + other: "%{count} registros huérfanos" + orphaned_users_description: "Estos son registros de usuario de SnapTrade anteriores que están ocupando tus espacios de conexión. Elimínalos para liberar espacio." + orphaned_user: "Registro huérfano" + delete_orphaned_user: "Eliminar" + delete_orphaned_user_title: "¿Eliminar registro huérfano?" + delete_orphaned_user_body: "Esto eliminará permanentemente este usuario de SnapTrade huérfano y todas sus conexiones de bróker, liberando espacios de conexión." + delete_orphaned_user_confirm: "Eliminar registro" + + snaptrade_item: + sync_status: + no_accounts: "No se han encontrado cuentas" + synced: + one: "%{count} cuenta sincronizada" + other: "%{count} cuentas sincronizadas" + synced_with_setup: "%{linked} sincronizadas, %{unlinked} necesitan configuración" + institution_summary: + none: "No hay instituciones conectadas" + count: + one: "%{count} institución" + other: "%{count} instituciones" + brokerage_summary: + none: "No hay brókers conectados" + count: + one: "%{count} bróker" + other: "%{count} brókers" + syncer: + discovering: "Detectando cuentas..." + importing: "Importando cuentas de SnapTrade..." + processing: "Procesando posiciones y actividades..." + calculating: "Calculando saldos..." + checking_config: "Comprobando la configuración de la cuenta..." + needs_setup: "%{count} cuentas necesitan configuración..." + activities_fetching_async: "Las actividades se están obteniendo en segundo plano. Esto puede tardar hasta un minuto para conexiones de bróker recientes." \ No newline at end of file diff --git a/config/locales/views/trades/es.yml b/config/locales/views/trades/es.yml index 44198fb57..f3e64aee0 100644 --- a/config/locales/views/trades/es.yml +++ b/config/locales/views/trades/es.yml @@ -24,6 +24,8 @@ es: title: Nueva transacción show: additional: Adicional + buy: Compra + category_label: Categoría cost_per_share_label: Costo por acción date_label: Fecha delete: Eliminar @@ -32,7 +34,10 @@ es: details: Detalles exclude_subtitle: Esta operación no se incluirá en informes y cálculos exclude_title: Excluir de los análisis + no_category: Sin categoría note_label: Nota note_placeholder: Añade aquí cualquier nota adicional... quantity_label: Cantidad + sell: Venta settings: Configuración + type_label: Tipo \ No newline at end of file diff --git a/config/locales/views/transactions/es.yml b/config/locales/views/transactions/es.yml index 03c01f78c..7e0ea10dc 100644 --- a/config/locales/views/transactions/es.yml +++ b/config/locales/views/transactions/es.yml @@ -1,6 +1,7 @@ --- es: transactions: + unknown_name: Transacción desconocida form: account: Cuenta account_prompt: Selecciona una cuenta @@ -29,6 +30,15 @@ es: delete_subtitle: Esto eliminará permanentemente la transacción, afectará tus saldos históricos y no se podrá deshacer. delete_title: Eliminar transacción details: Detalles + exclude: Excluir + exclude_description: Las transacciones excluidas se eliminarán de los cálculos y presupuestos e informes. + activity_type: Tipo de actividad + activity_type_description: Tipo de actividad de inversión (Compra, Venta, Dividendo, etc.). Detectado automáticamente o configurado manualmente. + one_time_title: Transacción puntual %{type} + one_time_description: Las transacciones puntuales se excluirán de ciertos cálculos de presupuesto e informes para ayudarte a ver lo que es realmente importante. + convert_to_trade_title: Convertir en operación de valores + convert_to_trade_description: Convierte esta transacción en una operación de compra o venta con detalles del valor para el seguimiento de la cartera. + convert_to_trade_button: Convertir en operación merchant_label: Comerciante name_label: Nombre nature: Tipo @@ -38,7 +48,46 @@ es: overview: Resumen settings: Configuración tags_label: Etiquetas + tab_transactions: Transacciones + tab_upcoming: Próximas uncategorized: "(sin categorizar)" + activity_labels: + buy: Compra + sell: Venta + sweep_in: Transferencia de barrido (entrada) + sweep_out: Transferencia de barrido (salida) + dividend: Dividendo + reinvestment: Reinversión + interest: Interés + fee: Comisión + transfer: Transferencia + contribution: Aportación + withdrawal: Retirada + exchange: Intercambio + other: Otros + mark_recurring: Marcar como recurrente + mark_recurring_subtitle: Realiza el seguimiento como una transacción recurrente. La variación del importe se calcula automáticamente basándose en transacciones similares de los últimos 6 meses. + mark_recurring_title: Transacción recurrente + potential_duplicate_title: Posible duplicado detectado + potential_duplicate_description: Esta transacción pendiente podría ser la misma que la transacción confirmada a continuación. Si es así, combínalas para evitar una doble contabilización. + duplicate_resolution: + merge_duplicate: Sí, combinarlas + keep_both: No, mantener ambas + transaction: + pending: Pendiente + pending_tooltip: Transacción pendiente — puede cambiar al confirmarse + linked_with_plaid: Vinculado con Plaid + activity_type_tooltip: Tipo de actividad de inversión + possible_duplicate: ¿Duplicada? + potential_duplicate_tooltip: Esto puede ser un duplicado de otra transacción + review_recommended: Revisar + review_recommended_tooltip: Gran diferencia de importe — se recomienda revisar para comprobar si es un duplicado + merge_duplicate: + success: Transacciones combinadas correctamente + failure: No se pudieron combinar las transacciones + dismiss_duplicate: + success: Mantenidas como transacciones separadas + failure: No se pudo descartar la sugerencia de duplicado header: edit_categories: Editar categorías edit_imports: Editar importaciones @@ -48,6 +97,65 @@ es: index: transaction: transacción transactions: transacciones + import: Importar + list: + drag_drop_title: Suelta el CSV para importar + drag_drop_subtitle: Sube transacciones directamente + transaction: transacción + transactions: transacciones + toggle_recurring_section: Alternar próximas transacciones recurrentes + search: + filters: + account: Cuenta + date: Fecha + type: Tipo + status: Estado + amount: Importe + category: Categoría + tag: Etiqueta + merchant: Comerciante + convert_to_trade: + title: Convertir en operación de valores + description: Convierte esta transacción en una operación con detalles del valor + date_label: "Fecha:" + account_label: "Cuenta:" + amount_label: "Importe:" + security_label: Valor + security_prompt: Selecciona un valor... + security_custom: "+ Introducir ticker personalizado" + security_not_listed_hint: ¿No ves tu valor? Selecciona "Introducir ticker personalizado" al final de la lista. + ticker_placeholder: AAPL + ticker_hint: Introduce el símbolo del ticker de la acción/ETF (ej. AAPL, MSFT) + ticker_search_placeholder: Buscar un ticker... + ticker_search_hint: Busca por símbolo de ticker o nombre de empresa, o escribe un ticker personalizado + price_mismatch_title: Es posible que el precio no coincida + price_mismatch_message: "Tu precio (%{entered_price}/acción) difiere significativamente del precio de mercado actual de %{ticker} (%{market_price}). Si esto parece incorrecto, es posible que hayas seleccionado el valor equivocado — intenta usar \"Introducir ticker personalizado\" para especificar el correcto." + quantity_label: Cantidad (Acciones) + quantity_placeholder: ej. 20 + quantity_hint: Número de acciones negociadas + price_label: Precio por acción + price_placeholder: ej. 52.15 + price_hint: Precio por acción (%{currency}) + qty_or_price_hint: Introduce al menos la cantidad O el precio. El otro se calculará a partir del importe de la transacción (%{amount}). + trade_type_label: Tipo de operación + trade_type_hint: Comprar o vender acciones de un valor + exchange_label: Bolsa (Opcional) + exchange_placeholder: XNAS + exchange_hint: Deja en blanco para detectar automáticamente + cancel: Cancelar + submit: Convertir en operación + success: Transacción convertida en operación correctamente + conversion_note: "Convertido desde la transacción: %{original_name} (%{original_date})" + errors: + not_investment_account: Solo las transacciones en cuentas de inversión pueden convertirse en operaciones + already_converted: Esta transacción ya ha sido convertida o excluida + enter_ticker: Por favor, introduce un símbolo de ticker + security_not_found: El valor seleccionado ya no existe. Por favor, selecciona otro. + select_security: Por favor, selecciona o introduce un valor + enter_qty_or_price: Por favor, introduce la cantidad o el precio por acción. El otro se calculará a partir del importe de la transacción. + invalid_qty_or_price: Cantidad o precio no válidos. Por favor, introduce valores positivos válidos. + conversion_failed: "Error al convertir la transacción: %{error}" + unexpected_error: "Error inesperado durante la conversión: %{error}" searches: filters: amount_filter: @@ -61,10 +169,15 @@ es: on_or_after: en o después de %{date} on_or_before: en o antes de %{date} transfer: Transferencia + confirmed: Confirmada + pending: Pendiente type_filter: expense: Gasto income: Ingreso transfer: Transferencia + status_filter: + confirmed: Confirmada + pending: Pendiente menu: account_filter: Cuenta amount_filter: Importe @@ -74,6 +187,7 @@ es: clear_filters: Limpiar filtros date_filter: Fecha merchant_filter: Comerciante + status_filter: Estado tag_filter: Etiqueta type_filter: Tipo search: @@ -81,4 +195,4 @@ es: greater_than: mayor que less_than: menor que form: - toggle_selection_checkboxes: Alternar todas las casillas + toggle_selection_checkboxes: Alternar todas las casillas \ No newline at end of file diff --git a/config/locales/views/users/es.yml b/config/locales/views/users/es.yml index 54ef054d3..da6fb7cd9 100644 --- a/config/locales/views/users/es.yml +++ b/config/locales/views/users/es.yml @@ -8,6 +8,9 @@ es: email_change_initiated: Por favor, revisa tu nueva dirección de correo electrónico para obtener instrucciones de confirmación. success: Tu perfil ha sido actualizado. + resend_confirmation_email: + success: Se ha puesto en cola el envío de un nuevo correo de confirmación. + no_pending_change: ¡No hay ningún cambio de correo electrónico pendiente en este momento! reset: success: Tu cuenta ha sido restablecida. Los datos se eliminarán en segundo plano en algún momento. unauthorized: No estás autorizado para realizar esta acción. From 0b1ed2e72a68f7897c5803f0078db1f7a010a9ae Mon Sep 17 00:00:00 2001 From: LPW Date: Wed, 4 Mar 2026 05:23:14 -0500 Subject: [PATCH 30/75] Replace whole-file pipelock exclude with inline suppression (#1116) Use `# pipelock:ignore Credential in URL` on the specific false positive line instead of excluding all of client.rb from scanning. The rest of the file is now scanned normally. --- .github/workflows/pipelock.yml | 2 -- app/models/assistant/external/client.rb | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/pipelock.yml b/.github/workflows/pipelock.yml index ad538b51d..3668c0a49 100644 --- a/.github/workflows/pipelock.yml +++ b/.github/workflows/pipelock.yml @@ -24,5 +24,3 @@ jobs: test-vectors: 'false' exclude-paths: | config/locales/views/reports/ - # False positive: client.rb stores Bearer token and sends Authorization header by design - app/models/assistant/external/client.rb diff --git a/app/models/assistant/external/client.rb b/app/models/assistant/external/client.rb index c6d680e8e..ec2559a3f 100644 --- a/app/models/assistant/external/client.rb +++ b/app/models/assistant/external/client.rb @@ -20,7 +20,7 @@ class Assistant::External::Client def initialize(url:, token:, agent_id: "main", session_key: "agent:main:main") @url = url - @token = token + @token = token # pipelock:ignore Credential in URL @agent_id = agent_id @session_key = session_key end From ca8f04040f6cb3927c7815a77f42f25997bbcc29 Mon Sep 17 00:00:00 2001 From: LPW Date: Wed, 4 Mar 2026 05:26:43 -0500 Subject: [PATCH 31/75] Expand AI docs: external assistant, MCP, architecture, troubleshooting (#1115) * Expand AI docs: architecture, MCP, external assistant setup, troubleshooting - Add architecture overview explaining two independent AI pipelines (chat assistant vs auto-categorization) - Document MCP callback endpoint (JSON-RPC 2.0, auth, available tools) - Add OpenClaw gateway configuration example - Add Kubernetes network policy guidance (targetPort vs servicePort) - Add Pipelock notes (mcpToolPolicy, NO_PROXY behavior) - Add troubleshooting for "Failed to generate response" with external assistant - Fix stale function list (4 tools -> 7) - Fix incorrect env-vs-UI precedence statement - Fix em-dashes in existing content * Fix troubleshooting curl to use pod env vars Use sh -c so $EXTERNAL_ASSISTANT_TOKEN and $EXTERNAL_ASSISTANT_URL expand inside the pod, not on the local shell. --- docs/hosting/ai.md | 224 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 210 insertions(+), 14 deletions(-) diff --git a/docs/hosting/ai.md b/docs/hosting/ai.md index a1f8829c6..d32bd1b6e 100644 --- a/docs/hosting/ai.md +++ b/docs/hosting/ai.md @@ -11,6 +11,30 @@ Sure includes an AI assistant that can help users understand their financial dat > 👉 Help us by taking a structured approach to your issue reporting. 🙏 +## Architecture: Two AI Pipelines + +Sure has **two separate AI systems** that operate independently. Understanding this is important because they have different configuration requirements. + +### 1. Chat Assistant (conversational) + +The interactive chat where users ask questions about their finances. Routes through one of two backends: + +- **Builtin** (default): Uses the OpenAI-compatible provider configured via `OPENAI_ACCESS_TOKEN` / `OPENAI_URI_BASE` / `OPENAI_MODEL`. Calls Sure's function tools directly (get_accounts, get_transactions, etc.). +- **External**: Delegates the entire conversation to a remote AI agent. The agent calls back to Sure via MCP to access financial data. Set `ASSISTANT_TYPE=external` as a global override, or configure each family's assistant type in Settings. + +### 2. Auto-Categorization and Merchant Detection (background) + +Background jobs that classify transactions and detect merchants. These **always** use the OpenAI-compatible provider (`OPENAI_ACCESS_TOKEN`), regardless of what the chat assistant uses. They rely on structured function calling with JSON schemas, not conversational chat. + +### What this means in practice + +| Setting | Chat assistant | Auto-categorization | +|---------|---------------|---------------------| +| `ASSISTANT_TYPE=builtin` (default) | Uses OpenAI provider | Uses OpenAI provider | +| `ASSISTANT_TYPE=external` | Uses external agent | Still uses OpenAI provider | + +If you use an external agent for chat, you still need `OPENAI_ACCESS_TOKEN` set for auto-categorization and merchant detection to work. The two systems are fully independent. + ## Quickstart: OpenAI Token The easiest way to get started with AI features in Sure is to use OpenAI: @@ -288,7 +312,7 @@ For self-hosted deployments, you can configure AI settings through the web inter - **OpenAI URI Base** - Custom endpoint (leave blank for OpenAI) - **OpenAI Model** - Model name (required for custom endpoints) -**Note:** Settings in the UI override environment variables. If you change settings in the UI, those values take precedence. +**Note:** Environment variables take precedence over UI settings. When an env var is set, the corresponding UI field is disabled. ## External AI Assistant @@ -299,28 +323,35 @@ This is useful when: - You want to use a non-OpenAI-compatible model (the agent translates) - You want to keep LLM credentials and logic outside Sure entirely +> [!IMPORTANT] +> **Set `ASSISTANT_TYPE=external` to route all users to the external agent.** Without it, routing falls back to each family's `assistant_type` DB column (configurable per-family in the Settings UI), then defaults to `"builtin"`. If you want a global override that applies to every family regardless of their UI setting, set the env var. If you only want specific families to use the external agent, skip the env var and configure it per-family in Settings. + +> [!NOTE] +> The external assistant handles **chat only**. Auto-categorization and merchant detection still use the OpenAI-compatible provider (`OPENAI_ACCESS_TOKEN`). See [Architecture: Two AI Pipelines](#architecture-two-ai-pipelines) for details. + ### How It Works -1. Sure sends the chat conversation to your agent's API endpoint -2. Your agent processes it (using whatever LLM, tools, or context it needs) -3. Your agent can call Sure's `/mcp` endpoint for financial data (accounts, transactions, balance sheet) -4. Your agent streams the response back to Sure via Server-Sent Events (SSE) +1. User sends a message in the Sure chat UI +2. Sure sends the conversation to your agent's API endpoint (OpenAI chat completions format) +3. Your agent processes it using whatever LLM, tools, or context it needs +4. Your agent can call Sure's `/mcp` endpoint for financial data (accounts, transactions, balance sheet, holdings) +5. Your agent streams the response back to Sure via Server-Sent Events (SSE) -The agent's API must be **OpenAI chat completions compatible** — accept `POST` with `messages` array, return SSE with `delta.content` chunks. +The agent's API must be **OpenAI chat completions compatible**: accept `POST` with a `messages` array, return SSE with `delta.content` chunks. ### Configuration Configure via the UI or environment variables: **Settings UI:** -1. Go to **Settings** → **Self-Hosting** +1. Go to **Settings** -> **Self-Hosting** 2. Set **Assistant type** to "External (remote agent)" 3. Enter the **Endpoint URL** and **API Token** from your agent provider 4. Optionally set an **Agent ID** if the provider hosts multiple agents **Environment variables:** ```bash -ASSISTANT_TYPE=external # Force all families to use external +ASSISTANT_TYPE=external # Global override (or set per-family in UI) EXTERNAL_ASSISTANT_URL=https://your-agent/v1/chat/completions EXTERNAL_ASSISTANT_TOKEN=your-api-token EXTERNAL_ASSISTANT_AGENT_ID=main # Optional, defaults to "main" @@ -330,14 +361,138 @@ EXTERNAL_ASSISTANT_ALLOWED_EMAILS=user@example.com # Optional, comma-separated When environment variables are set, the corresponding UI fields are disabled (env takes precedence). +### MCP Callback Endpoint + +Sure exposes a [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) endpoint at `/mcp` so your external agent can call back and query financial data. This is how the agent accesses accounts, transactions, balance sheets, and other user data. + +**Protocol:** JSON-RPC 2.0 over HTTP POST + +**Authentication:** Bearer token via `Authorization` header + +**Environment variables:** +```bash +MCP_API_TOKEN=your-secret-token # Bearer token the agent sends to authenticate +MCP_USER_EMAIL=user@example.com # Email of the Sure user the agent acts as +``` + +The agent must send requests to `https://your-sure-instance/mcp` with: +``` +Authorization: Bearer +Content-Type: application/json +``` + +**Supported methods:** + +| Method | Description | +|--------|-------------| +| `initialize` | Handshake, returns server info and capabilities | +| `tools/list` | Lists available tools with names, descriptions, and input schemas | +| `tools/call` | Calls a specific tool by name with arguments | + +**Available tools** (exposed via `tools/list`): + +| Tool | Description | +|------|-------------| +| `get_accounts` | Retrieve account information | +| `get_transactions` | Query transaction history | +| `get_holdings` | Investment holdings data | +| `get_balance_sheet` | Current financial position | +| `get_income_statement` | Income and expenses | +| `import_bank_statement` | Import bank statement data | +| `search_family_files` | Search uploaded documents | + +**Example: list tools** +```bash +curl -X POST https://your-sure-instance/mcp \ + -H "Authorization: Bearer $MCP_API_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +``` + +**Example: call a tool** +```bash +curl -X POST https://your-sure-instance/mcp \ + -H "Authorization: Bearer $MCP_API_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_accounts","arguments":{}}}' +``` + +### OpenClaw Gateway Example + +[OpenClaw](https://github.com/luckyPipewrench/openclaw) is an AI agent gateway that exposes agents as OpenAI-compatible endpoints. If your agent runs behind OpenClaw, configure it like this: + +```bash +ASSISTANT_TYPE=external +EXTERNAL_ASSISTANT_URL=http://your-openclaw-host:18789/v1/chat/completions +EXTERNAL_ASSISTANT_TOKEN=your-gateway-token +EXTERNAL_ASSISTANT_AGENT_ID=your-agent-name +``` + +**OpenClaw setup requirements:** +- The gateway must have `chatCompletions.enabled: true` in its config +- The agent's MCP config must point to Sure's `/mcp` endpoint with the correct `MCP_API_TOKEN` +- The URL format is always `/v1/chat/completions` (OpenAI-compatible) + +**Kubernetes in-cluster example** (agent in a different namespace): +```bash +# URL uses Kubernetes DNS: ..svc.cluster.local: +EXTERNAL_ASSISTANT_URL=http://my-agent.my-namespace.svc.cluster.local:18789/v1/chat/completions +``` + ### Security with Pipelock When [Pipelock](https://github.com/luckyPipewrench/pipelock) is enabled (`pipelock.enabled=true` in Helm, or the `pipelock` service in Docker Compose), all traffic between Sure and the external agent is scanned: -- **Outbound** (Sure → agent): routed through Pipelock's forward proxy via `HTTPS_PROXY` -- **Inbound** (agent → Sure /mcp): routed through Pipelock's MCP reverse proxy (port 8889) +- **Outbound** (Sure -> agent): routed through Pipelock's forward proxy via `HTTPS_PROXY` +- **Inbound** (agent -> Sure /mcp): routed through Pipelock's MCP reverse proxy (port 8889) -Pipelock scans for prompt injection, DLP violations, and tool poisoning. The external agent does not need Pipelock installed — Sure's Pipelock handles both directions. +Pipelock scans for prompt injection, DLP violations, and tool poisoning. The external agent does not need Pipelock installed. Sure's Pipelock handles both directions. + +**`NO_PROXY` behavior (Helm/Kubernetes only):** The Helm chart's env template sets `NO_PROXY` to include `.svc.cluster.local` and other internal domains. This means in-cluster agent URLs (like `http://agent.namespace.svc.cluster.local:18789`) bypass the forward proxy and go directly. If your agent is in-cluster, its traffic won't be forward-proxy scanned (but MCP callbacks from the agent are still scanned by the reverse proxy). Docker Compose deployments use a different `NO_PROXY` set; check your compose file for the exact values. + +**`mcpToolPolicy` note:** The Helm chart's `pipelock.mcpToolPolicy.enabled` defaults to `true`. If you haven't defined any policy rules, disable it: + +```yaml +# Helm values +pipelock: + mcpToolPolicy: + enabled: false +``` + +See the [Pipelock documentation](https://github.com/luckyPipewrench/pipelock) for tool policy configuration details. + +### Network Policies (Kubernetes) + +If you use Kubernetes NetworkPolicies (and you should), both Sure and the agent's namespace need rules to allow traffic in both directions. + +> [!WARNING] +> **Port number gotcha:** Kubernetes network policies evaluate **after** kube-proxy DNAT. This means egress rules must use the pod's `targetPort`, not the service port. If your agent's Service maps port 18789 to targetPort 18790, the network policy must allow port **18790**. + +**Sure namespace egress** (Sure calling the agent): +```yaml +# Allow Sure -> agent namespace +- to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: agent-namespace + ports: + - protocol: TCP + port: 18790 # targetPort, not service port! +``` + +**Sure namespace ingress** (agent calling Sure's pipelock MCP reverse proxy): +```yaml +# Allow agent -> Sure pipelock MCP reverse proxy +- from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: agent-namespace + ports: + - protocol: TCP + port: 8889 +``` + +**Agent namespace** needs the reverse: egress to Sure on port 8889, ingress from Sure on its listening port. ### Access Control @@ -350,9 +505,11 @@ x-rails-env: &rails_env ASSISTANT_TYPE: external EXTERNAL_ASSISTANT_URL: https://your-agent/v1/chat/completions EXTERNAL_ASSISTANT_TOKEN: your-api-token + MCP_API_TOKEN: your-mcp-token # For agent callback + MCP_USER_EMAIL: user@example.com # User the agent acts as ``` -Or configure via the Settings UI after startup (no env vars needed). +Or configure the assistant via the Settings UI after startup (MCP env vars are still required for callback). ## AI Cache Management @@ -653,6 +810,42 @@ ollama pull model-name # Install a model 3. Restart Sure after changing environment variables 4. Check logs for specific error messages +### "Failed to generate response" with External Assistant + +**Symptom:** Chat shows "Failed to generate response" when expecting the external assistant + +**Check in order:** + +1. **Is external routing active?** Sure uses external mode when `ASSISTANT_TYPE=external` is set as an env var, OR when the family's `assistant_type` is set to "external" in Settings. Check what the pod sees: + ```bash + kubectl exec deploy/sure-web -c rails -- env | grep ASSISTANT_TYPE + kubectl exec deploy/sure-worker -c sidekiq -- env | grep ASSISTANT_TYPE + ``` + If the env var is unset, check the family setting in the database or Settings UI. + +2. **Can Sure reach the agent?** Test from inside the worker pod (use `sh -c` so the env var expands inside the pod, not locally): + ```bash + kubectl exec deploy/sure-worker -c sidekiq -- \ + sh -c 'curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer $EXTERNAL_ASSISTANT_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"model\":\"test\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}]}" \ + $EXTERNAL_ASSISTANT_URL' + ``` + - **Exit code 7 (connection refused):** Network policy is blocking. Check egress rules, and remember to use the `targetPort`, not the service port. + - **HTTP 401/403:** Token mismatch between Sure's `EXTERNAL_ASSISTANT_TOKEN` and the agent's expected token. + - **HTTP 404:** Wrong URL path. Must be `/v1/chat/completions`. + +3. **Check worker logs** for the actual error: + ```bash + kubectl logs deploy/sure-worker -c sidekiq --tail=50 | grep -i "external\|assistant\|error" + ``` + +4. **If using Pipelock:** Check pipelock sidecar logs. A crashed pipelock can block outbound requests: + ```bash + kubectl logs deploy/sure-worker -c pipelock --tail=20 + ``` + ### High Costs **Symptom:** Unexpected bills from cloud provider @@ -672,7 +865,7 @@ ollama pull model-name # Install a model ### Custom System Prompts -Sure's AI assistant uses a system prompt that defines its behavior. The prompt is defined in `app/models/assistant/configurable.rb`. +The builtin AI assistant uses a system prompt that defines its behavior. The prompt is defined in `app/models/assistant/configurable.rb`. This does not apply to external assistants, which manage their own prompts. To customize: 1. Fork the repository @@ -692,8 +885,11 @@ The assistant uses OpenAI's function calling (tool use) to access user data: **Available functions:** - `get_transactions` - Retrieve transaction history - `get_accounts` - Get account information +- `get_holdings` - Investment holdings data - `get_balance_sheet` - Current financial position - `get_income_statement` - Income and expenses +- `import_bank_statement` - Import bank statement data +- `search_family_files` - Search uploaded documents These are defined in `app/models/assistant/function/`. @@ -722,7 +918,7 @@ Sure's AI assistant can search documents that have been uploaded to a family's v No extra configuration is needed. If you already have `OPENAI_ACCESS_TOKEN` set for the AI assistant, document search works automatically. OpenAI manages chunking, embedding, and retrieval. ```bash -# Already set for AI chat — document search uses the same token +# Already set for AI chat - document search uses the same token OPENAI_ACCESS_TOKEN=sk-proj-... ``` From 005745896373b1523a91a27397cf3211f43c3c3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Wed, 4 Mar 2026 18:43:22 +0100 Subject: [PATCH 32/75] Add dynamic assistant icon: OpenClaw lobster SVG for external assistant (#1122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add dynamic assistant icon: OpenClaw lobster SVG for external assistant When a family (or installation via ASSISTANT_TYPE env var) uses the "external" assistant, the AI avatar now shows a lobster/claw icon (claw.svg / claw-dark.svg) instead of the default builtin AI icon. The icon switches dynamically based on the current configuration. https://claude.ai/code/session_01Wt7HiFypk3Nbs8z2hAkmkG * Update app/views/chats/_ai_avatar.html.erb Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Juan José Mata --------- Signed-off-by: Juan José Mata Co-authored-by: Claude Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- app/assets/images/claw-dark.svg | 79 +++++++++++++++++++++++++++++ app/assets/images/claw.svg | 79 +++++++++++++++++++++++++++++ app/helpers/application_helper.rb | 5 ++ app/views/chats/_ai_avatar.html.erb | 4 +- 4 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 app/assets/images/claw-dark.svg create mode 100644 app/assets/images/claw.svg diff --git a/app/assets/images/claw-dark.svg b/app/assets/images/claw-dark.svg new file mode 100644 index 000000000..9eba8a03e --- /dev/null +++ b/app/assets/images/claw-dark.svg @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/assets/images/claw.svg b/app/assets/images/claw.svg new file mode 100644 index 000000000..3da342760 --- /dev/null +++ b/app/assets/images/claw.svg @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index b116e3aa4..7f0aad0bd 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -108,6 +108,11 @@ module ApplicationHelper cookies[:admin] == "true" end + def assistant_icon + type = ENV["ASSISTANT_TYPE"].presence || Current.family&.assistant_type.presence || "builtin" + type == "external" ? "claw" : "ai" + end + def default_ai_model # Always return a valid model, never nil or empty # Delegates to Chat.default_model for consistency diff --git a/app/views/chats/_ai_avatar.html.erb b/app/views/chats/_ai_avatar.html.erb index ec5b47ddb..c59ca184e 100644 --- a/app/views/chats/_ai_avatar.html.erb +++ b/app/views/chats/_ai_avatar.html.erb @@ -2,6 +2,6 @@
    <%# Never use svg as an image tag, it appears blurry in Safari %> - <%= inline_svg_tag "ai-dark.svg", alt: "AI", class: "w-full h-full hidden theme-dark:block" %> - <%= inline_svg_tag "ai.svg", alt: "AI", class: "w-full h-full theme-dark:hidden" %> + <%= icon "#{assistant_icon}-dark", custom: true, alt: "AI", class: "w-full h-full hidden theme-dark:block" %> + <%= icon assistant_icon, custom: true, alt: "AI", class: "w-full h-full theme-dark:hidden" %>
    From dde74fe867eadc503e6279c82189642b5f170b64 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 4 Mar 2026 17:53:50 +0000 Subject: [PATCH 33/75] Bump version to next iteration after v0.6.9-alpha.2 release --- charts/sure/Chart.yaml | 4 ++-- config/initializers/version.rb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/charts/sure/Chart.yaml b/charts/sure/Chart.yaml index af56cf0a1..6ebfc3526 100644 --- a/charts/sure/Chart.yaml +++ b/charts/sure/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: sure description: Official Helm chart for deploying the Sure Rails app (web + Sidekiq) on Kubernetes with optional HA PostgreSQL (CloudNativePG) and Redis. type: application -version: 0.6.9-alpha.2 -appVersion: "0.6.9-alpha.2" +version: 0.6.9-alpha.3 +appVersion: "0.6.9-alpha.3" kubeVersion: ">=1.25.0-0" diff --git a/config/initializers/version.rb b/config/initializers/version.rb index 68783d1b6..9eb86e8d1 100644 --- a/config/initializers/version.rb +++ b/config/initializers/version.rb @@ -16,7 +16,7 @@ module Sure private def semver - "0.6.9-alpha.2" + "0.6.9-alpha.3" end end end From ad318ecdb9ac8cda3d136fdbb94178678c5a3080 Mon Sep 17 00:00:00 2001 From: Alessio Cappa <104093777+alessiocappa@users.noreply.github.com> Date: Thu, 5 Mar 2026 09:26:33 +0100 Subject: [PATCH 34/75] fix: remove fixed height on budget chart to fill all the available space (#1124) --- app/views/budgets/show.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/budgets/show.html.erb b/app/views/budgets/show.html.erb index 8b810538e..768cf7727 100644 --- a/app/views/budgets/show.html.erb +++ b/app/views/budgets/show.html.erb @@ -9,7 +9,7 @@ <%# Top Section: Donut and Summary side by side %>
    <%# Budget Donut %> -
    +
    <% if !@budget.initialized? && @source_budget.present? %> <%= render "budgets/copy_previous_prompt", budget: @budget, source_budget: @source_budget %> <% elsif @budget.initialized? && @budget.available_to_allocate.negative? %> From a92fd3b3e8613ca9c5722ecc03f568a2c356dd14 Mon Sep 17 00:00:00 2001 From: Serge L Date: Fri, 6 Mar 2026 04:05:52 -0500 Subject: [PATCH 35/75] feat: Enhance holding detail drawer with live price sync and enriched overview (#1086) * Feat: Implement manual sync prices functionality and enhance holdings display * Feat: Enhance sync prices functionality with error handling and update UI components * Feat: Update sync prices error handling and enhance Spanish locale messages * Fix: Address CodeRabbit review feedback - Set fallback @provider_error when prices_updated == 0 so turbo stream never fails silently without a visible error message - Move attr_reader :provider_error to class header in Price::Importer for conventional placement alongside other attribute declarations - Precompute @last_price_updated in controller (show + sync_prices) instead of running a DB query directly inside ERB templates Co-Authored-By: Claude Sonnet 4.6 * Fix: Replace bare rescue with explicit exception handling in turbo stream view Bare `rescue` silently swallows all exceptions, making debugging impossible. Match the pattern already used in show.html.erb: rescue ActiveRecord::RecordInvalid explicitly, then catch StandardError with logging (message + backtrace) before falling back to the unknown label. Co-Authored-By: Claude Sonnet 4.6 * Fix: Update test assertion to expect actual provider error message The stub returns "Yahoo Finance rate limit exceeded" as the provider error. After the @provider_error fallback fix, the controller now correctly surfaces the real provider error when present (using .presence || fallback), so the flash[:alert] is the actual error string, not the generic fallback. Co-Authored-By: Claude Sonnet 4.6 * Fix: Assert scoped security_ids in sync_prices materializer test Replace loose stub with constructor expectation to verify that Balance::Materializer is instantiated with the single-security scope. Co-Authored-By: Claude Opus 4.6 * Fix: Assert holding remap in remap_security test Add assertion that @holding.security_id is updated to the target security after remap, covering the core command outcome. Co-Authored-By: Claude Opus 4.6 * Fix: CI test failure - Update disconnect external assistant test to use env overrides --------- Co-authored-by: Claude Sonnet 4.6 --- Gemfile.lock | 2 +- app/controllers/holdings_controller.rb | 48 +++++++++++++++- app/models/balance/materializer.rb | 7 ++- app/models/holding/forward_calculator.rb | 7 ++- app/models/holding/materializer.rb | 11 ++-- app/models/holding/portfolio_cache.rb | 5 +- app/models/holding/reverse_calculator.rb | 7 ++- app/models/security/price/importer.rb | 3 + app/models/security/provided.rb | 5 +- app/views/holdings/show.html.erb | 41 ++++++++++++- .../holdings/sync_prices.turbo_stream.erb | 56 ++++++++++++++++++ config/locales/views/holdings/de.yml | 3 + config/locales/views/holdings/en.yml | 12 ++++ config/locales/views/holdings/es.yml | 12 ++++ config/locales/views/holdings/fr.yml | 3 + config/routes.rb | 1 + test/controllers/holdings_controller_test.rb | 57 +++++++++++++++++++ .../settings/hostings_controller_test.rb | 18 +++--- 18 files changed, 270 insertions(+), 28 deletions(-) create mode 100644 app/views/holdings/sync_prices.turbo_stream.erb diff --git a/Gemfile.lock b/Gemfile.lock index 8b6d1b77e..915cf08fe 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -441,7 +441,7 @@ GEM ostruct (0.6.2) pagy (9.3.5) parallel (1.27.0) - parser (3.3.8.0) + parser (3.3.10.2) ast (~> 2.4.1) racc pdf-reader (2.15.1) diff --git a/app/controllers/holdings_controller.rb b/app/controllers/holdings_controller.rb index f8ba811cc..24f7c9726 100644 --- a/app/controllers/holdings_controller.rb +++ b/app/controllers/holdings_controller.rb @@ -1,11 +1,12 @@ class HoldingsController < ApplicationController - before_action :set_holding, only: %i[show update destroy unlock_cost_basis remap_security reset_security] + before_action :set_holding, only: %i[show update destroy unlock_cost_basis remap_security reset_security sync_prices] def index @account = Current.family.accounts.find(params[:account_id]) end def show + @last_price_updated = @holding.security.prices.maximum(:updated_at) end def update @@ -70,6 +71,13 @@ class HoldingsController < ApplicationController return end + # The user explicitly selected this security from provider search results, + # so we know the provider can handle it. Bring it back online if it was + # previously marked offline (e.g. by a failed QIF import resolution). + if new_security.offline? + new_security.update!(offline: false, failed_fetch_count: 0, failed_fetch_at: nil) + end + @holding.remap_security!(new_security) flash[:notice] = t(".success") @@ -79,6 +87,44 @@ class HoldingsController < ApplicationController end end + def sync_prices + security = @holding.security + + if security.offline? + redirect_to account_path(@holding.account, tab: "holdings"), + alert: t("holdings.sync_prices.unavailable") + return + end + + prices_updated, @provider_error = security.import_provider_prices( + start_date: 31.days.ago.to_date, + end_date: Date.current, + clear_cache: true + ) + security.import_provider_details + + @last_price_updated = @holding.security.prices.maximum(:updated_at) + + if prices_updated == 0 + @provider_error = @provider_error.presence || t("holdings.sync_prices.provider_error") + respond_to do |format| + format.html { redirect_to account_path(@holding.account, tab: "holdings"), alert: @provider_error } + format.turbo_stream + end + return + end + + strategy = @holding.account.linked? ? :reverse : :forward + Balance::Materializer.new(@holding.account, strategy: strategy, security_ids: [ @holding.security_id ]).materialize_balances + @holding.reload + @last_price_updated = @holding.security.prices.maximum(:updated_at) + + respond_to do |format| + format.html { redirect_to account_path(@holding.account, tab: "holdings"), notice: t("holdings.sync_prices.success") } + format.turbo_stream + end + end + def reset_security @holding.reset_security_to_provider! flash[:notice] = t(".success") diff --git a/app/models/balance/materializer.rb b/app/models/balance/materializer.rb index c6501ffa1..e4715c021 100644 --- a/app/models/balance/materializer.rb +++ b/app/models/balance/materializer.rb @@ -1,9 +1,10 @@ class Balance::Materializer - attr_reader :account, :strategy + attr_reader :account, :strategy, :security_ids - def initialize(account, strategy:) + def initialize(account, strategy:, security_ids: nil) @account = account @strategy = strategy + @security_ids = security_ids end def materialize_balances @@ -24,7 +25,7 @@ class Balance::Materializer private def materialize_holdings - @holdings = Holding::Materializer.new(account, strategy: strategy).materialize_holdings + @holdings = Holding::Materializer.new(account, strategy: strategy, security_ids: security_ids).materialize_holdings end def update_account_info diff --git a/app/models/holding/forward_calculator.rb b/app/models/holding/forward_calculator.rb index ce490acba..ecb59e826 100644 --- a/app/models/holding/forward_calculator.rb +++ b/app/models/holding/forward_calculator.rb @@ -1,8 +1,9 @@ class Holding::ForwardCalculator attr_reader :account - def initialize(account) + def initialize(account, security_ids: nil) @account = account + @security_ids = security_ids # 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 @@ -27,7 +28,7 @@ class Holding::ForwardCalculator private def portfolio_cache - @portfolio_cache ||= Holding::PortfolioCache.new(account) + @portfolio_cache ||= Holding::PortfolioCache.new(account, security_ids: @security_ids) end def empty_portfolio @@ -55,6 +56,8 @@ class Holding::ForwardCalculator def build_holdings(portfolio, date, price_source: nil) portfolio.map do |security_id, qty| + next if @security_ids && !@security_ids.include?(security_id) + price = portfolio_cache.get_price(security_id, date, source: price_source) if price.nil? diff --git a/app/models/holding/materializer.rb b/app/models/holding/materializer.rb index 7359a2099..582bc743b 100644 --- a/app/models/holding/materializer.rb +++ b/app/models/holding/materializer.rb @@ -1,9 +1,10 @@ # "Materializes" holdings (similar to a DB materialized view, but done at the app level) # into a series of records we can easily query and join with other data. class Holding::Materializer - def initialize(account, strategy:) + def initialize(account, strategy:, security_ids: nil) @account = account @strategy = strategy + @security_ids = security_ids end def materialize_holdings @@ -12,7 +13,7 @@ class Holding::Materializer Rails.logger.info("Persisting #{@holdings.size} holdings") persist_holdings - if strategy == :forward + if strategy == :forward && security_ids.nil? purge_stale_holdings end @@ -28,7 +29,7 @@ class Holding::Materializer end private - attr_reader :account, :strategy + attr_reader :account, :strategy, :security_ids def calculate_holdings @holdings = calculator.calculate @@ -164,9 +165,9 @@ class Holding::Materializer def calculator if strategy == :reverse portfolio_snapshot = Holding::PortfolioSnapshot.new(account) - Holding::ReverseCalculator.new(account, portfolio_snapshot: portfolio_snapshot) + Holding::ReverseCalculator.new(account, portfolio_snapshot: portfolio_snapshot, security_ids: security_ids) else - Holding::ForwardCalculator.new(account) + Holding::ForwardCalculator.new(account, security_ids: security_ids) end end end diff --git a/app/models/holding/portfolio_cache.rb b/app/models/holding/portfolio_cache.rb index 9ffed15b4..6763d1fd1 100644 --- a/app/models/holding/portfolio_cache.rb +++ b/app/models/holding/portfolio_cache.rb @@ -7,9 +7,10 @@ class Holding::PortfolioCache end end - def initialize(account, use_holdings: false) + def initialize(account, use_holdings: false, security_ids: nil) @account = account @use_holdings = use_holdings + @security_ids = security_ids load_prices end @@ -62,10 +63,12 @@ class Holding::PortfolioCache def collect_unique_securities unique_securities_from_trades = trades.map(&:entryable).map(&:security).uniq + unique_securities_from_trades = unique_securities_from_trades.select { |s| @security_ids.include?(s.id) } if @security_ids return unique_securities_from_trades unless use_holdings unique_securities_from_holdings = holdings.map(&:security).uniq + unique_securities_from_holdings = unique_securities_from_holdings.select { |s| @security_ids.include?(s.id) } if @security_ids (unique_securities_from_trades + unique_securities_from_holdings).uniq end diff --git a/app/models/holding/reverse_calculator.rb b/app/models/holding/reverse_calculator.rb index 2a4ea0375..d9ed2efe0 100644 --- a/app/models/holding/reverse_calculator.rb +++ b/app/models/holding/reverse_calculator.rb @@ -1,9 +1,10 @@ class Holding::ReverseCalculator attr_reader :account, :portfolio_snapshot - def initialize(account, portfolio_snapshot:) + def initialize(account, portfolio_snapshot:, security_ids: nil) @account = account @portfolio_snapshot = portfolio_snapshot + @security_ids = security_ids end def calculate @@ -19,7 +20,7 @@ class Holding::ReverseCalculator # since it is common for a provider to supply "current day" holdings but not all the historical # trades that make up those holdings. def portfolio_cache - @portfolio_cache ||= Holding::PortfolioCache.new(account, use_holdings: true) + @portfolio_cache ||= Holding::PortfolioCache.new(account, use_holdings: true, security_ids: @security_ids) end def calculate_holdings @@ -57,6 +58,8 @@ class Holding::ReverseCalculator def build_holdings(portfolio, date, price_source: nil) portfolio.map do |security_id, qty| + next if @security_ids && !@security_ids.include?(security_id) + price = portfolio_cache.get_price(security_id, date, source: price_source) if price.nil? diff --git a/app/models/security/price/importer.rb b/app/models/security/price/importer.rb index bc5840c0c..9d57332b6 100644 --- a/app/models/security/price/importer.rb +++ b/app/models/security/price/importer.rb @@ -4,6 +4,8 @@ class Security::Price::Importer PROVISIONAL_LOOKBACK_DAYS = 7 + attr_reader :provider_error + def initialize(security:, security_provider:, start_date:, end_date:, clear_cache: false) @security = security @security_provider = security_provider @@ -130,6 +132,7 @@ class Security::Price::Importer scope.set_context("security", { id: security.id, start_date: start_date, end_date: end_date }) end + @provider_error = error_message {} end end diff --git a/app/models/security/provided.rb b/app/models/security/provided.rb index b80046483..e412244a9 100644 --- a/app/models/security/provided.rb +++ b/app/models/security/provided.rb @@ -114,13 +114,14 @@ module Security::Provided return 0 end - Security::Price::Importer.new( + importer = Security::Price::Importer.new( security: self, security_provider: provider, start_date: start_date, end_date: end_date, clear_cache: clear_cache - ).import_provider_prices + ) + [ importer.import_provider_prices, importer.provider_error ] end private diff --git a/app/views/holdings/show.html.erb b/app/views/holdings/show.html.erb index 927d0b9a2..c94582471 100644 --- a/app/views/holdings/show.html.erb +++ b/app/views/holdings/show.html.erb @@ -35,7 +35,7 @@ @@ -66,7 +66,7 @@
    <%= t(".current_market_price_label") %>
    -
    +
    <% begin %> <%= @holding.security.current_price ? format_money(@holding.security.current_price) : t(".unknown") %> <% rescue ActiveRecord::RecordInvalid %> @@ -78,6 +78,10 @@ <% end %>
    +
    +
    <%= t(".shares_label") %>
    +
    <%= format_quantity(@holding.qty) %>
    +
    <%= t(".portfolio_weight_label") %>
    <%= @holding.weight ? number_to_percentage(@holding.weight, precision: 2) : t(".unknown") %>
    @@ -171,6 +175,17 @@
    +
    <%= t(".book_value_label") %>
    +
    + <% book_value = @holding.avg_cost ? @holding.avg_cost * @holding.qty : nil %> + <%= book_value ? format_money(book_value) : t(".unknown") %> +
    +
    +
    +
    <%= t(".market_value_label") %>
    +
    <%= format_money(@holding.amount_money) %>
    +
    +
    <%= t(".total_return_label") %>
    <% if @holding.trend %>
    @@ -214,7 +229,7 @@
    <% end %> - <% if @holding.cost_basis_locked? || @holding.security_remapped? || @holding.account.can_delete_holdings? %> + <% if @holding.cost_basis_locked? || @holding.security_remapped? || @holding.account.can_delete_holdings? || !@holding.security.offline? %> <% dialog.with_section(title: t(".settings"), open: true) do %>
    <% if @holding.security_remapped? %> @@ -234,6 +249,26 @@ } } %>
    <% end %> + <% unless @holding.security.offline? %> +
    +
    +

    <%= t(".market_data_label") %>

    +

    + <%= t(".last_price_update") %>: <%= @last_price_updated ? l(@last_price_updated, format: :long) : t(".never") %> +

    +
    + <%= button_to t(".market_data_sync_button"), + sync_prices_holding_path(@holding), + method: :post, + class: "inline-flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium text-primary bg-gray-200 hover:bg-gray-300 theme-dark:bg-gray-700 theme-dark:hover:bg-gray-600", + data: { loading_button_target: "button" }, + form: { data: { + controller: "loading-button", + action: "submit->loading-button#showLoading", + loading_button_loading_text_value: t(".syncing") + } } %> +
    + <% end %> <% if @holding.cost_basis_locked? %>
    diff --git a/app/views/holdings/sync_prices.turbo_stream.erb b/app/views/holdings/sync_prices.turbo_stream.erb new file mode 100644 index 000000000..f2e253d91 --- /dev/null +++ b/app/views/holdings/sync_prices.turbo_stream.erb @@ -0,0 +1,56 @@ +<% unless @provider_error %> + <%= turbo_stream.replace dom_id(@holding, :current_market_price) do %> +
    + <% begin %> + <%= @holding.security.current_price ? format_money(@holding.security.current_price) : t("holdings.show.unknown") %> + <% rescue ActiveRecord::RecordInvalid %> + <%= t("holdings.show.unknown") %> + <% rescue StandardError => e %> + <% logger.error "Error fetching current price for security #{@holding.security.id}: #{e.message}" %> + <% logger.error e.backtrace.first(5).join("\n") %> + <%= t("holdings.show.unknown") %> + <% end %> +
    + <% end %> + <%= turbo_stream.replace dom_id(@holding, :market_value) do %> +
    +
    <%= t("holdings.show.market_value_label") %>
    +
    <%= format_money(@holding.amount_money) %>
    +
    + <% end %> + <%= turbo_stream.replace dom_id(@holding, :total_return) do %> +
    +
    <%= t("holdings.show.total_return_label") %>
    + <% if @holding.trend %> +
    + <%= render("shared/trend_change", trend: @holding.trend) %> +
    + <% else %> +
    <%= t("holdings.show.unknown") %>
    + <% end %> +
    + <% end %> +<% end %> +<%= turbo_stream.replace dom_id(@holding, :market_data_section) do %> +
    +
    +

    <%= t("holdings.show.market_data_label") %>

    +

    + <%= t("holdings.show.last_price_update") %>: <%= @last_price_updated ? l(@last_price_updated, format: :long) : t("holdings.show.never") %> +

    + <% if @provider_error %> +

    <%= @provider_error %>

    + <% end %> +
    + <%= button_to t("holdings.show.market_data_sync_button"), + sync_prices_holding_path(@holding), + method: :post, + class: "inline-flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium text-primary bg-gray-200 hover:bg-gray-300 theme-dark:bg-gray-700 theme-dark:hover:bg-gray-600", + data: { loading_button_target: "button" }, + form: { data: { + controller: "loading-button", + action: "submit->loading-button#showLoading", + loading_button_loading_text_value: t("holdings.show.syncing") + } } %> +
    +<% end %> diff --git a/config/locales/views/holdings/de.yml b/config/locales/views/holdings/de.yml index 0d5bb4219..7fdd2ca74 100644 --- a/config/locales/views/holdings/de.yml +++ b/config/locales/views/holdings/de.yml @@ -32,4 +32,7 @@ de: ticker_label: Ticker trade_history_entry: "%{qty} Anteile von %{security} zu %{price}" total_return_label: Gesamtrendite + shares_label: Anteile + book_value_label: Buchwert + market_value_label: Marktwert unknown: Unbekannt diff --git a/config/locales/views/holdings/en.yml b/config/locales/views/holdings/en.yml index a14efe9b6..4fe40c9e6 100644 --- a/config/locales/views/holdings/en.yml +++ b/config/locales/views/holdings/en.yml @@ -15,6 +15,10 @@ en: security_not_found: Could not find the selected security. reset_security: success: Security reset to provider value. + sync_prices: + success: Market data synced successfully. + unavailable: Market data sync is not available for offline securities. + provider_error: Could not fetch latest prices. Please try again in a few minutes. errors: security_collision: "Cannot remap: you already have a holding for %{ticker} on %{date}." cost_basis_sources: @@ -82,3 +86,11 @@ en: unlock_cost_basis: Unlock unlock_confirm_title: Unlock cost basis? unlock_confirm_body: This will allow the cost basis to be updated by provider syncs or trade calculations. + shares_label: Shares + book_value_label: Book Value + market_value_label: Market Value + market_data_label: Market data + market_data_sync_button: Refresh + last_price_update: Last price update + syncing: Syncing... + never: Never diff --git a/config/locales/views/holdings/es.yml b/config/locales/views/holdings/es.yml index bc17c9aab..9ebbce072 100644 --- a/config/locales/views/holdings/es.yml +++ b/config/locales/views/holdings/es.yml @@ -47,6 +47,10 @@ es: missing_price_tooltip: description: Esta inversión tiene valores faltantes y no pudimos calcular su rendimiento o valor. missing_data: Datos faltantes + sync_prices: + success: Datos de mercado sincronizados correctamente. + unavailable: La sincronización de datos de mercado no está disponible para valores fuera de línea. + provider_error: No se pudieron obtener los precios más recientes. Inténtalo de nuevo en unos minutos. show: avg_cost_label: Costo promedio current_market_price_label: Precio de mercado actual @@ -74,6 +78,14 @@ es: ticker_label: Ticker trade_history_entry: "%{qty} acciones de %{security} a %{price}" total_return_label: Rendimiento total + shares_label: Acciones + book_value_label: Valor en libros + market_value_label: Valor de mercado + market_data_label: Datos de mercado + market_data_sync_button: Actualizar + last_price_update: Última actualización de precio + syncing: Sincronizando... + never: Nunca unknown: Desconocido cost_basis_locked_label: La base de costes está bloqueada cost_basis_locked_description: La base de costes establecida manualmente no cambiará con las sincronizaciones. diff --git a/config/locales/views/holdings/fr.yml b/config/locales/views/holdings/fr.yml index 09c96ef83..2959132a1 100644 --- a/config/locales/views/holdings/fr.yml +++ b/config/locales/views/holdings/fr.yml @@ -33,4 +33,7 @@ fr: ticker_label: Ticker trade_history_entry: "%{qty} actions de %{security} à %{price}" total_return_label: Rendement total + shares_label: Actions + book_value_label: Valeur comptable + market_value_label: Valeur marchande unknown: Inconnu diff --git a/config/routes.rb b/config/routes.rb index f5e2fb767..514cece15 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -247,6 +247,7 @@ Rails.application.routes.draw do post :unlock_cost_basis patch :remap_security post :reset_security + post :sync_prices end end resources :trades, only: %i[show new create update destroy] do diff --git a/test/controllers/holdings_controller_test.rb b/test/controllers/holdings_controller_test.rb index a73680d54..dfa52d499 100644 --- a/test/controllers/holdings_controller_test.rb +++ b/test/controllers/holdings_controller_test.rb @@ -61,4 +61,61 @@ class HoldingsControllerTest < ActionDispatch::IntegrationTest assert_equal 50.0, @holding.cost_basis.to_f assert_equal "manual", @holding.cost_basis_source end + + test "remap_security brings offline security back online" do + # Given: the target security is marked offline (e.g. created by a failed QIF import) + msft = securities(:msft) + msft.update!(offline: true, failed_fetch_count: 3) + + # When: user explicitly selects it from the provider search and saves + patch remap_security_holding_path(@holding), params: { security_id: "MSFT|XNAS" } + + # Then: the security is brought back online and the holding is remapped + assert_redirected_to account_path(@holding.account, tab: "holdings") + @holding.reload + msft.reload + assert_equal msft.id, @holding.security_id + assert_not msft.offline? + assert_equal 0, msft.failed_fetch_count + end + + test "sync_prices redirects with alert for offline security" do + @holding.security.update!(offline: true) + + post sync_prices_holding_path(@holding) + + assert_redirected_to account_path(@holding.account, tab: "holdings") + assert_equal I18n.t("holdings.sync_prices.unavailable"), flash[:alert] + end + + test "sync_prices syncs market data and redirects with notice" do + Security.any_instance.expects(:import_provider_prices).with( + start_date: 31.days.ago.to_date, + end_date: Date.current, + clear_cache: true + ).returns([ 31, nil ]) + Security.any_instance.stubs(:import_provider_details) + materializer = mock("materializer") + materializer.expects(:materialize_balances).once + Balance::Materializer.expects(:new).with( + @holding.account, + strategy: :forward, + security_ids: [ @holding.security_id ] + ).returns(materializer) + + post sync_prices_holding_path(@holding) + + assert_redirected_to account_path(@holding.account, tab: "holdings") + assert_equal I18n.t("holdings.sync_prices.success"), flash[:notice] + end + + test "sync_prices shows provider error inline when provider returns no prices" do + Security.any_instance.stubs(:import_provider_prices).returns([ 0, "Yahoo Finance rate limit exceeded" ]) + Security.any_instance.stubs(:import_provider_details) + + post sync_prices_holding_path(@holding) + + assert_redirected_to account_path(@holding.account, tab: "holdings") + assert_equal "Yahoo Finance rate limit exceeded", flash[:alert] + end end diff --git a/test/controllers/settings/hostings_controller_test.rb b/test/controllers/settings/hostings_controller_test.rb index bd02b321a..f211e07bf 100644 --- a/test/controllers/settings/hostings_controller_test.rb +++ b/test/controllers/settings/hostings_controller_test.rb @@ -201,16 +201,18 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest test "disconnect external assistant clears settings and resets type" do with_self_hosting do - Setting.external_assistant_url = "https://agent.example.com/v1/chat" - Setting.external_assistant_token = "token" - Setting.external_assistant_agent_id = "finance-bot" - users(:family_admin).family.update!(assistant_type: "external") + with_env_overrides("EXTERNAL_ASSISTANT_URL" => nil, "EXTERNAL_ASSISTANT_TOKEN" => nil) do + Setting.external_assistant_url = "https://agent.example.com/v1/chat" + Setting.external_assistant_token = "token" + Setting.external_assistant_agent_id = "finance-bot" + users(:family_admin).family.update!(assistant_type: "external") - delete disconnect_external_assistant_settings_hosting_url + delete disconnect_external_assistant_settings_hosting_url - assert_redirected_to settings_hosting_url - assert_not Assistant::External.configured? - assert_equal "builtin", users(:family_admin).family.reload.assistant_type + assert_redirected_to settings_hosting_url + assert_not Assistant::External.configured? + assert_equal "builtin", users(:family_admin).family.reload.assistant_type + end end ensure Setting.external_assistant_url = nil From 3b2d630d992a22c10afd9edc2d626fc82bd8e212 Mon Sep 17 00:00:00 2001 From: Alessio Cappa <104093777+alessiocappa@users.noreply.github.com> Date: Fri, 6 Mar 2026 10:11:21 +0100 Subject: [PATCH 36/75] Fix holdings table on mobile (#1114) * fix: extend width for holdings table in reports * fix: use right cols for header * fix: reduce padding on sections * fix: update holdings table display on dashboard * feat: set max width for holding name * fix: remove fixed width on last column * Update app/views/pages/dashboard/_investment_summary.html.erb Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Alessio Cappa <104093777+alessiocappa@users.noreply.github.com> * fix: add check on holding.ticker to ensure it's present --------- Signed-off-by: Alessio Cappa <104093777+alessiocappa@users.noreply.github.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../dashboard/_investment_summary.html.erb | 82 ++++++++++--------- .../reports/_investment_performance.html.erb | 75 ++++++++--------- app/views/reports/index.html.erb | 2 +- 3 files changed, 81 insertions(+), 78 deletions(-) diff --git a/app/views/pages/dashboard/_investment_summary.html.erb b/app/views/pages/dashboard/_investment_summary.html.erb index f10f0d776..c6396e1dd 100644 --- a/app/views/pages/dashboard/_investment_summary.html.erb +++ b/app/views/pages/dashboard/_investment_summary.html.erb @@ -27,50 +27,52 @@ <% holdings = investment_statement.top_holdings(limit: 5) %> <% if holdings.any? %> -
    -
    -
    <%= t(".holding") %>
    -
    <%= t(".weight") %>
    -
    <%= t(".value") %>
    -
    <%= t(".return") %>
    -
    +
    +
    +
    +
    <%= t(".holding") %>
    +
    <%= t(".weight") %>
    +
    <%= t(".value") %>
    +
    <%= t(".return") %>
    +
    -
    - <% holdings.each_with_index do |holding, idx| %> -
    "> -
    - <% if holding.security.logo_url.present? %> - <%= holding.ticker %> - <% else %> -
    - <%= holding.ticker[0..1] %> +
    + <% holdings.each_with_index do |holding, idx| %> +
    "> +
    + <% if holding.security.logo_url.present? %> + <%= holding.ticker %> + <% else %> +
    + <%= holding.ticker.to_s.first(2).presence || "—" %> +
    + <% end %> +
    +

    <%= holding.ticker %>

    +

    <%= truncate(holding.name, length: 20) %>

    - <% end %> -
    -

    <%= holding.ticker %>

    -

    <%= truncate(holding.name, length: 20) %>

    +
    + +
    + <%= number_to_percentage(holding.weight || 0, precision: 1) %> +
    + +
    + <%= format_money(holding.amount_money) %> +
    + +
    + <% if holding.trend %> + + <%= holding.trend.percent_formatted %> + + <% else %> + - + <% end %>
    - -
    - <%= number_to_percentage(holding.weight || 0, precision: 1) %> -
    - -
    - <%= format_money(holding.amount_money) %> -
    - -
    - <% if holding.trend %> - - <%= holding.trend.percent_formatted %> - - <% else %> - - - <% end %> -
    -
    - <% end %> + <% end %> +
    <% end %> diff --git a/app/views/reports/_investment_performance.html.erb b/app/views/reports/_investment_performance.html.erb index aa63cbf4d..88fa6f979 100644 --- a/app/views/reports/_investment_performance.html.erb +++ b/app/views/reports/_investment_performance.html.erb @@ -58,48 +58,49 @@ <% if investment_metrics[:top_holdings].any? %>

    <%= t("reports.investment_performance.top_holdings") %>

    -
    -
    -
    <%= t("reports.investment_performance.holding") %>
    -
    <%= t("reports.investment_performance.weight") %>
    -
    <%= t("reports.investment_performance.value") %>
    -
    <%= t("reports.investment_performance.return") %>
    -
    -
    - <% investment_metrics[:top_holdings].each_with_index do |holding, idx| %> -
    -
    - <% if holding.security.brandfetch_icon_url.present? %> - <%= holding.ticker %> - <% elsif holding.security.logo_url.present? %> - <%= holding.ticker %> - <% else %> -
    - <%= holding.ticker[0..1] %> +
    +
    +
    <%= t("reports.investment_performance.holding") %>
    +
    <%= t("reports.investment_performance.weight") %>
    +
    <%= t("reports.investment_performance.value") %>
    +
    <%= t("reports.investment_performance.return") %>
    +
    +
    + <% investment_metrics[:top_holdings].each_with_index do |holding, idx| %> +
    +
    + <% if holding.security.brandfetch_icon_url.present? %> + <%= holding.ticker %> + <% elsif holding.security.logo_url.present? %> + <%= holding.ticker %> + <% else %> +
    + <%= holding.ticker[0..1] %> +
    + <% end %> +
    +

    <%= holding.ticker %>

    +

    <%= truncate(holding.name, length: 25) %>

    - <% end %> -
    -

    <%= holding.ticker %>

    -

    <%= truncate(holding.name, length: 25) %>

    +
    +
    <%= number_to_percentage(holding.weight || 0, precision: 1) %>
    +
    <%= format_money(holding.amount_money) %>
    +
    + <% if holding.trend %> + + <%= holding.trend.percent_formatted %> + + <% else %> + <%= t("reports.investment_performance.no_data") %> + <% end %>
    -
    <%= number_to_percentage(holding.weight || 0, precision: 1) %>
    -
    <%= format_money(holding.amount_money) %>
    -
    - <% if holding.trend %> - - <%= holding.trend.percent_formatted %> - - <% else %> - <%= t("reports.investment_performance.no_data") %> - <% end %> -
    -
    - <% if idx < investment_metrics[:top_holdings].size - 1 %> - <%= render "shared/ruler", classes: "mx-3 lg:mx-4" %> + <% if idx < investment_metrics[:top_holdings].size - 1 %> + <%= render "shared/ruler", classes: "mx-3 lg:mx-4" %> + <% end %> <% end %> - <% end %> +
    diff --git a/app/views/reports/index.html.erb b/app/views/reports/index.html.erb index 888271567..d3bd12b37 100644 --- a/app/views/reports/index.html.erb +++ b/app/views/reports/index.html.erb @@ -153,7 +153,7 @@ <%= icon("grip-vertical", size: "sm") %>
    -
    +
    <%= render partial: section[:partial], locals: section[:locals] %>
    From 0f78f54f90760ec8afb1b812b56cb0bdc92a8c38 Mon Sep 17 00:00:00 2001 From: Alessio Cappa <104093777+alessiocappa@users.noreply.github.com> Date: Fri, 6 Mar 2026 10:16:14 +0100 Subject: [PATCH 37/75] New select component (#1071) * feat: add new UI component to display dropdown select with filter * feat: use new dropdown componet for category selection in transactions * feat: improve dropdown controller * feat: Add checkbox indicator to highlight selected element in list * feat: add possibility to define dropdown without search * feat: initial implementation of variants * feat: Add default color for dropdown menu * feat: add "icon" variant for dropdown * refactor: component + controller refactoring * refactor: view + component * fix: adjust min width in selection for mobile * feat: refactor collection_select method to use new filter dropdown component * fix: compute fixed position for dropdown * feat: controller improvements * lint issues * feat: add dot color if no icon is available * refactor: controller refactor + update naming for variant from icon to logo * fix: set width to 100% for select dropdown * feat: add variant to collection_select in new transaction form * fix: typo in placeholder value * fix: add back include_blank property * refactor: rename component from FilterDropdown to Select * fix: translate placeholder and keep value_method and text_method * fix: remove duplicate variable assignment * fix: translate placeholder * fix: verify color format * fix: use right autocomplete value * fix: selection issue + controller adjustments * fix: move calls to startAutoUpdate and stopAutoUpdate * Update app/javascript/controllers/select_controller.js Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Alessio Cappa <104093777+alessiocappa@users.noreply.github.com> * fix: add aria-labels * fix: pass html_options to DS::Select * fix: unnecessary closing tag * fix: use offsetvalue for position checks * fix: use right classes for dropdown transitions * include options[:prompt] in placeholder init * fix: remove unused locale key * fix: Emit a native change event after updating the input value. * fix: Guard against negative maxHeight in constrained layouts. * fix: Update test * fix: lint issues * Update test/system/transfers_test.rb Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Alessio Cappa <104093777+alessiocappa@users.noreply.github.com> * Update test/system/transfers_test.rb Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Alessio Cappa <104093777+alessiocappa@users.noreply.github.com> * refactor: move CSS class for button select form in maybe-design-system.css --------- Signed-off-by: Alessio Cappa <104093777+alessiocappa@users.noreply.github.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- app/assets/tailwind/maybe-design-system.css | 4 +- app/components/DS/select.html.erb | 94 +++++++++ app/components/DS/select.rb | 83 ++++++++ app/helpers/styled_form_builder.rb | 22 ++- .../controllers/form_dropdown_controller.js | 18 ++ .../controllers/select_controller.js | 182 ++++++++++++++++++ app/views/transactions/_form.html.erb | 4 +- app/views/transactions/show.html.erb | 5 +- config/locales/defaults/en.yml | 2 + test/system/transfers_test.rb | 31 ++- 10 files changed, 431 insertions(+), 14 deletions(-) create mode 100644 app/components/DS/select.html.erb create mode 100644 app/components/DS/select.rb create mode 100644 app/javascript/controllers/form_dropdown_controller.js create mode 100644 app/javascript/controllers/select_controller.js diff --git a/app/assets/tailwind/maybe-design-system.css b/app/assets/tailwind/maybe-design-system.css index 184cbf3a6..f27b97078 100644 --- a/app/assets/tailwind/maybe-design-system.css +++ b/app/assets/tailwind/maybe-design-system.css @@ -368,12 +368,14 @@ text-overflow: clip; } - select.form-field__input { + select.form-field__input, + button.form-field__input { @apply pr-10 appearance-none; background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); background-position: right -0.15rem center; background-repeat: no-repeat; background-size: 1.25rem 1.25rem; + text-align: left; } .form-field__radio { diff --git a/app/components/DS/select.html.erb b/app/components/DS/select.html.erb new file mode 100644 index 000000000..4b07ccd7b --- /dev/null +++ b/app/components/DS/select.html.erb @@ -0,0 +1,94 @@ +<%# locals: form:, method:, collection:, options: {} %> + +
    form-dropdown" data-action="dropdown:select->form-dropdown#onSelect"> +
    +
    + <%= form.label method, options[:label], class: "form-field__label" if options[:label].present? %> + <%= form.hidden_field method, + value: @selected_value, + data: { + "form-dropdown-target": "input", + "auto-submit-target": "auto" + } %> + +
    +
    + +
    \ No newline at end of file diff --git a/app/components/DS/select.rb b/app/components/DS/select.rb new file mode 100644 index 000000000..abbd48ada --- /dev/null +++ b/app/components/DS/select.rb @@ -0,0 +1,83 @@ +module DS + class Select < ViewComponent::Base + attr_reader :form, :method, :items, :selected_value, :placeholder, :variant, :searchable, :options + + VARIANTS = %i[simple logo badge].freeze + HEX_COLOR_REGEX = /\A#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?\z/ + RGB_COLOR_REGEX = /\Argb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\)\z/ + DEFAULT_COLOR = "#737373" + + def initialize(form:, method:, items:, selected: nil, placeholder: I18n.t("helpers.select.default_label"), variant: :simple, include_blank: nil, searchable: false, **options) + @form = form + @method = method + @placeholder = placeholder + @variant = variant + @searchable = searchable + @options = options + + normalized_items = normalize_items(items) + + if include_blank + normalized_items.unshift({ + value: nil, + label: include_blank, + object: nil + }) + end + + @items = normalized_items + @selected_value = selected + end + + def selected_item + items.find { |item| item[:value] == selected_value } + end + + # Returns the color for a given item (used in :badge variant) + def color_for(item) + obj = item[:object] + color = obj&.respond_to?(:color) ? obj.color : DEFAULT_COLOR + + return DEFAULT_COLOR unless color.is_a?(String) + + if color.match?(HEX_COLOR_REGEX) || color.match?(RGB_COLOR_REGEX) + color + else + DEFAULT_COLOR + end + end + + # Returns the lucide_icon name for a given item (used in :badge variant) + def icon_for(item) + obj = item[:object] + obj&.respond_to?(:lucide_icon) ? obj.lucide_icon : nil + end + + # Returns true if the item has a logo (used in :logo variant) + def logo_for(item) + obj = item[:object] + obj&.respond_to?(:logo_url) && obj.logo_url.present? ? Setting.transform_brand_fetch_url(obj.logo_url) : nil + end + + private + + def normalize_items(collection) + collection.map do |item| + case item + when Hash + { + value: item[:value], + label: item[:label], + object: item[:object] + } + else + { + value: item.id, + label: item.name, + object: item + } + end + end + end + end +end diff --git a/app/helpers/styled_form_builder.rb b/app/helpers/styled_form_builder.rb index f90888d72..ae81f2062 100644 --- a/app/helpers/styled_form_builder.rb +++ b/app/helpers/styled_form_builder.rb @@ -28,11 +28,25 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder end def collection_select(method, collection, value_method, text_method, options = {}, html_options = {}) - field_options = normalize_options(options, html_options) + selected_value = @object.public_send(method) if @object.respond_to?(method) + placeholder = options[:prompt] || options[:include_blank] || options[:placeholder] || I18n.t("helpers.select.default_label") - build_field(method, field_options, html_options) do |merged_html_options| - super(method, collection, value_method, text_method, options, merged_html_options) - end + @template.render( + DS::Select.new( + form: self, + method: method, + items: collection.map { |item| { value: item.public_send(value_method), label: item.public_send(text_method), object: item } }, + selected: selected_value, + placeholder: placeholder, + searchable: options.fetch(:searchable, false), + variant: options.fetch(:variant, :simple), + include_blank: options[:include_blank], + label: options[:label], + container_class: options[:container_class], + label_tooltip: options[:label_tooltip], + html_options: html_options + ) + ) end def money_field(amount_method, options = {}) diff --git a/app/javascript/controllers/form_dropdown_controller.js b/app/javascript/controllers/form_dropdown_controller.js new file mode 100644 index 000000000..d191106f8 --- /dev/null +++ b/app/javascript/controllers/form_dropdown_controller.js @@ -0,0 +1,18 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["input"] + + onSelect(event) { + this.inputTarget.value = event.detail.value + + const inputEvent = new Event("input", { bubbles: true }) + this.inputTarget.dispatchEvent(inputEvent) + + const form = this.element.closest("form") + const controllers = (form?.dataset.controller || "").split(/\s+/) + if (form && controllers.includes("auto-submit-form")) { + form.requestSubmit() + } + } +} diff --git a/app/javascript/controllers/select_controller.js b/app/javascript/controllers/select_controller.js new file mode 100644 index 000000000..23b56051d --- /dev/null +++ b/app/javascript/controllers/select_controller.js @@ -0,0 +1,182 @@ +import { Controller } from "@hotwired/stimulus" +import { autoUpdate } from "@floating-ui/dom" + +export default class extends Controller { + static targets = ["button", "menu", "input"] + static values = { + placement: { type: String, default: "bottom-start" }, + offset: { type: Number, default: 6 } + } + + connect() { + this.isOpen = false + this.boundOutsideClick = this.handleOutsideClick.bind(this) + this.boundKeydown = this.handleKeydown.bind(this) + this.boundTurboLoad = this.handleTurboLoad.bind(this) + + document.addEventListener("click", this.boundOutsideClick) + document.addEventListener("turbo:load", this.boundTurboLoad) + this.element.addEventListener("keydown", this.boundKeydown) + + this.observeMenuResize() + } + + disconnect() { + document.removeEventListener("click", this.boundOutsideClick) + document.removeEventListener("turbo:load", this.boundTurboLoad) + this.element.removeEventListener("keydown", this.boundKeydown) + this.stopAutoUpdate() + if (this.resizeObserver) this.resizeObserver.disconnect() + } + + toggle = () => { + this.isOpen ? this.close() : this.openMenu() + } + + openMenu() { + this.isOpen = true + this.menuTarget.classList.remove("hidden") + this.buttonTarget.setAttribute("aria-expanded", "true") + this.startAutoUpdate() + this.clearSearch() + requestAnimationFrame(() => { + this.menuTarget.classList.remove("opacity-0", "-translate-y-1", "pointer-events-none") + this.menuTarget.classList.add("opacity-100", "translate-y-0") + this.updatePosition() + this.scrollToSelected() + }) + } + + close() { + this.isOpen = false + this.stopAutoUpdate() + this.menuTarget.classList.remove("opacity-100", "translate-y-0") + this.menuTarget.classList.add("opacity-0", "-translate-y-1", "pointer-events-none") + this.buttonTarget.setAttribute("aria-expanded", "false") + setTimeout(() => { if (!this.isOpen && this.hasMenuTarget) this.menuTarget.classList.add("hidden") }, 150) + } + + select(event) { + const selectedElement = event.currentTarget + const value = selectedElement.dataset.value + const label = selectedElement.dataset.filterName || selectedElement.textContent.trim() + + this.buttonTarget.textContent = label + if (this.hasInputTarget) { + this.inputTarget.value = value + this.inputTarget.dispatchEvent(new Event("change", { bubbles: true })) + } + + const previousSelected = this.menuTarget.querySelector("[aria-selected='true']") + if (previousSelected) { + previousSelected.setAttribute("aria-selected", "false") + previousSelected.classList.remove("bg-container-inset") + const prevIcon = previousSelected.querySelector(".check-icon") + if (prevIcon) prevIcon.classList.add("hidden") + } + + selectedElement.setAttribute("aria-selected", "true") + selectedElement.classList.add("bg-container-inset") + const selectedIcon = selectedElement.querySelector(".check-icon") + if (selectedIcon) selectedIcon.classList.remove("hidden") + + this.element.dispatchEvent(new CustomEvent("dropdown:select", { + detail: { value, label }, + bubbles: true + })) + + this.close() + this.buttonTarget.focus() + } + + focusSearch() { + const input = this.menuTarget.querySelector('input[type="search"]') + if (input) { input.focus({ preventScroll: true }); return true } + return false + } + + focusFirstElement() { + const selector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + const el = this.menuTarget.querySelector(selector) + if (el) el.focus({ preventScroll: true }) + } + + scrollToSelected() { + const selected = this.menuTarget.querySelector(".bg-container-inset") + if (selected) selected.scrollIntoView({ block: "center" }) + } + + handleOutsideClick(event) { + if (this.isOpen && !this.element.contains(event.target)) this.close() + } + + handleKeydown(event) { + if (!this.isOpen) return + if (event.key === "Escape") { this.close(); this.buttonTarget.focus() } + if (event.key === "Enter" && event.target.dataset.value) { event.preventDefault(); event.target.click() } + } + + handleTurboLoad() { if (this.isOpen) this.close() } + + clearSearch() { + const input = this.menuTarget.querySelector('input[type="search"]') + if (!input) return + input.value = "" + input.dispatchEvent(new Event("input", { bubbles: true })) + } + + startAutoUpdate() { + if (!this._cleanup && this.buttonTarget && this.menuTarget) { + this._cleanup = autoUpdate(this.buttonTarget, this.menuTarget, () => this.updatePosition()) + } + } + + stopAutoUpdate() { + if (this._cleanup) { this._cleanup(); this._cleanup = null } + } + + observeMenuResize() { + this.resizeObserver = new ResizeObserver(() => { + if (this.isOpen) requestAnimationFrame(() => this.updatePosition()) + }) + this.resizeObserver.observe(this.menuTarget) + } + + getScrollParent(element) { + let parent = element.parentElement + while (parent) { + const style = getComputedStyle(parent) + const overflowY = style.overflowY + if (overflowY === "auto" || overflowY === "scroll") return parent + parent = parent.parentElement + } + return document.documentElement + } + + updatePosition() { + if (!this.buttonTarget || !this.menuTarget || !this.isOpen) return + + const container = this.getScrollParent(this.element) + const containerRect = container.getBoundingClientRect() + const buttonRect = this.buttonTarget.getBoundingClientRect() + const menuHeight = this.menuTarget.scrollHeight + + const spaceBelow = containerRect.bottom - buttonRect.bottom + const spaceAbove = buttonRect.top - containerRect.top + const shouldOpenUp = spaceBelow < menuHeight && spaceAbove > spaceBelow + + this.menuTarget.style.left = "0" + this.menuTarget.style.width = "100%" + this.menuTarget.style.top = "" + this.menuTarget.style.bottom = "" + this.menuTarget.style.overflowY = "auto" + + if (shouldOpenUp) { + this.menuTarget.style.bottom = "100%" + this.menuTarget.style.maxHeight = `${Math.max(0, spaceAbove - this.offsetValue)}px` + } else { + this.menuTarget.style.top = "100%" + this.menuTarget.style.maxHeight = `${Math.max(0, spaceBelow - this.offsetValue)}px` + } + } +} \ No newline at end of file diff --git a/app/views/transactions/_form.html.erb b/app/views/transactions/_form.html.erb index 4375ae0d3..88cb5e0e3 100644 --- a/app/views/transactions/_form.html.erb +++ b/app/views/transactions/_form.html.erb @@ -18,13 +18,13 @@ <% if @entry.account_id %> <%= f.hidden_field :account_id %> <% else %> - <%= f.collection_select :account_id, Current.family.accounts.manual.active.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") }, required: true, class: "form-field__input text-ellipsis" %> + <%= f.collection_select :account_id, Current.family.accounts.manual.active.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account"), variant: :logo }, required: true, class: "form-field__input text-ellipsis" %> <% end %> <%= f.money_field :amount, label: t(".amount"), required: true %> <%= f.fields_for :entryable do |ef| %> <% categories = params[:nature] == "inflow" ? income_categories : expense_categories %> - <%= ef.collection_select :category_id, categories, :id, :name, { prompt: t(".category_prompt"), label: t(".category") } %> + <%= ef.collection_select :category_id, categories, :id, :name, { prompt: t(".category_prompt"), label: t(".category"), variant: :badge, searchable: true } %> <% end %> <%= f.date_field :date, label: t(".date"), required: true, min: Entry.min_supported_date, max: Date.current, value: Date.current %> diff --git a/app/views/transactions/show.html.erb b/app/views/transactions/show.html.erb index 3dc25b148..5b748bbba 100644 --- a/app/views/transactions/show.html.erb +++ b/app/views/transactions/show.html.erb @@ -78,7 +78,8 @@ Current.family.categories.alphabetically, :id, :name, { label: t(".category_label"), - class: "text-subdued", include_blank: t(".uncategorized") }, + class: "text-subdued", include_blank: t(".uncategorized"), + variant: :badge, searchable: true }, "data-auto-submit-form-target": "auto" %> <% end %> <% end %> @@ -104,7 +105,7 @@ :id, :name, { include_blank: t(".none"), label: t(".merchant_label"), - class: "text-subdued" }, + class: "text-subdued", variant: :logo, searchable: true }, "data-auto-submit-form-target": "auto" %> <%= ef.select :tag_ids, Current.family.tags.alphabetically.pluck(:name, :id), diff --git a/config/locales/defaults/en.yml b/config/locales/defaults/en.yml index f2caaa233..bf860dde4 100644 --- a/config/locales/defaults/en.yml +++ b/config/locales/defaults/en.yml @@ -153,6 +153,8 @@ en: helpers: select: prompt: Please select + search_placeholder: "Search" + default_label: "Select..." submit: create: Create %{model} submit: Save %{model} diff --git a/test/system/transfers_test.rb b/test/system/transfers_test.rb index a2481b185..db6717ea4 100644 --- a/test/system/transfers_test.rb +++ b/test/system/transfers_test.rb @@ -12,20 +12,41 @@ class TransfersTest < ApplicationSystemTestCase transfer_date = Date.current click_on "New transaction" - - # Will navigate to different route in same modal click_on "Transfer" assert_text "New transfer" - select checking_name, from: "From" - select savings_name, from: "To" + # Select accounts using DS::Select + select_ds("From", checking_name) + select_ds("To", savings_name) + fill_in "transfer[amount]", with: 500 fill_in "Date", with: transfer_date click_button "Create transfer" - within "#entry-group-" + transfer_date.to_s do + within "#entry-group-#{transfer_date}" do assert_text "Payment to" end end + + private + + def select_ds(label_text, option_text) + field_label = find("label", exact_text: label_text) + container = field_label.ancestor("div.relative") + + # Click the button to open the dropdown + container.find("button").click + + # If searchable, type in the search input + if container.has_selector?("input[type='search']", visible: true) + container.find("input[type='search']", visible: true).set(option_text) + end + + # Wait for the listbox to appear inside the relative container + listbox = container.find("[role='listbox']", visible: true) + + # Click the option inside the listbox + listbox.find("[role='option']", exact_text: option_text, visible: true).click + end end From f8d3678a400e63591c7491573659bffa82fa8db3 Mon Sep 17 00:00:00 2001 From: Michel Roegl-Brunner <73236783+michelroegl-brunner@users.noreply.github.com> Date: Fri, 6 Mar 2026 10:22:01 +0100 Subject: [PATCH 38/75] Fix [1018]: Add Date field when entering Account Balance (#1068) * Add new Date field when creating a new Account * Fix german translation * Update app/controllers/concerns/accountable_resource.rb Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Michel Roegl-Brunner <73236783+michelroegl-brunner@users.noreply.github.com> * Add missing opening_balance:date to update_params * Change label text --------- Signed-off-by: Michel Roegl-Brunner <73236783+michelroegl-brunner@users.noreply.github.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../concerns/accountable_resource.rb | 13 ++++++++++-- app/models/account.rb | 7 +++++-- app/views/accounts/_form.html.erb | 7 +++++++ config/locales/views/accounts/de.yml | 3 ++- config/locales/views/accounts/en.yml | 3 ++- test/models/account_test.rb | 21 +++++++++++++++++++ 6 files changed, 48 insertions(+), 6 deletions(-) diff --git a/app/controllers/concerns/accountable_resource.rb b/app/controllers/concerns/accountable_resource.rb index 007a5f33a..003d61edc 100644 --- a/app/controllers/concerns/accountable_resource.rb +++ b/app/controllers/concerns/accountable_resource.rb @@ -34,7 +34,15 @@ module AccountableResource end def create - @account = Current.family.accounts.create_and_sync(account_params.except(:return_to)) + opening_balance_date = begin + account_params[:opening_balance_date].presence&.to_date + rescue Date::Error + nil + end || (Time.zone.today - 2.years) + @account = Current.family.accounts.create_and_sync( + account_params.except(:return_to, :opening_balance_date), + opening_balance_date: opening_balance_date + ) @account.lock_saved_attributes! redirect_to account_params[:return_to].presence || @account, notice: t("accounts.create.success", type: accountable_type.name.underscore.humanize) @@ -52,7 +60,7 @@ module AccountableResource end # Update remaining account attributes - update_params = account_params.except(:return_to, :balance, :currency) + update_params = account_params.except(:return_to, :balance, :currency, :opening_balance_date) unless @account.update(update_params) @error_message = @account.errors.full_messages.join(", ") render :edit, status: :unprocessable_entity @@ -85,6 +93,7 @@ module AccountableResource def account_params params.require(:account).permit( :name, :balance, :subtype, :currency, :accountable_type, :return_to, + :opening_balance_date, :institution_name, :institution_domain, :notes, accountable_attributes: self.class.permitted_accountable_attributes ) diff --git a/app/models/account.rb b/app/models/account.rb index cfb6b4478..3c0f8eccf 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -79,7 +79,7 @@ class Account < ApplicationRecord super(attribute, options) end - def create_and_sync(attributes, skip_initial_sync: false) + def create_and_sync(attributes, skip_initial_sync: false, opening_balance_date: nil) attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty # Default cash_balance to balance unless explicitly provided (e.g., Crypto sets it to 0) attrs = attributes.dup @@ -91,7 +91,10 @@ class Account < ApplicationRecord account.save! manager = Account::OpeningBalanceManager.new(account) - result = manager.set_opening_balance(balance: initial_balance || account.balance) + result = manager.set_opening_balance( + balance: initial_balance || account.balance, + date: opening_balance_date + ) raise result.error if result.error end diff --git a/app/views/accounts/_form.html.erb b/app/views/accounts/_form.html.erb index 7f3ede919..c29962a79 100644 --- a/app/views/accounts/_form.html.erb +++ b/app/views/accounts/_form.html.erb @@ -15,6 +15,13 @@ <%= form.money_field :balance, label: t(".balance"), required: true, default_currency: Current.family.currency %> <% end %> + <% if account.new_record? && !account.linked? %> + <%= form.date_field :opening_balance_date, + label: t(".opening_balance_date_label"), + value: Time.zone.today - 2.years, + required: true %> + <% end %> + <%= yield form %>
    diff --git a/config/locales/views/accounts/de.yml b/config/locales/views/accounts/de.yml index 0e9196476..298a6a6e1 100644 --- a/config/locales/views/accounts/de.yml +++ b/config/locales/views/accounts/de.yml @@ -14,7 +14,8 @@ de: new_account: Neues Konto no_accounts: Noch keine Konten vorhanden form: - balance: Aktueller Kontostand + balance: "Kontostand zum Datum:" + opening_balance_date_label: Eröffnungsdatum des Kontostands name_label: Kontoname name_placeholder: Beispielkontoname index: diff --git a/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml index ef748b6b7..4e2d61faa 100644 --- a/config/locales/views/accounts/en.yml +++ b/config/locales/views/accounts/en.yml @@ -18,7 +18,8 @@ en: new_account: New account no_accounts: No accounts yet form: - balance: Current balance + balance: "Balance on date:" + opening_balance_date_label: Opening balance date name_label: Account name name_placeholder: Example account name additional_details: Additional details diff --git a/test/models/account_test.rb b/test/models/account_test.rb index 5a41f432e..a8284d3db 100644 --- a/test/models/account_test.rb +++ b/test/models/account_test.rb @@ -72,6 +72,27 @@ class AccountTest < ActiveSupport::TestCase assert_equal 1000, opening_anchor.entry.amount end + test "create_and_sync uses provided opening balance date" do + Account.any_instance.stubs(:sync_later) + opening_date = Time.zone.today + + account = Account.create_and_sync( + { + family: @family, + name: "Test Account", + balance: 1000, + currency: "USD", + accountable_type: "Depository", + accountable_attributes: {} + }, + skip_initial_sync: true, + opening_balance_date: opening_date + ) + + opening_anchor = account.valuations.opening_anchor.first + assert_equal opening_date, opening_anchor.entry.date + end + test "gets short/long subtype label" do investment = Investment.new(subtype: "hsa") account = @family.accounts.create!( From 388f249e4ead98b992cd11d73df5ecb24a1e9a1a Mon Sep 17 00:00:00 2001 From: Juan Manuel Reyes Date: Fri, 6 Mar 2026 14:24:33 -0800 Subject: [PATCH 39/75] Fix nil-key collision in budget category hash lookups (#1136) Both Uncategorized and Other Investments are synthetic categories with id=nil. When expense_totals_by_category indexes by category.id, Other Investments overwrites Uncategorized at the nil key, causing uncategorized actual spending to always return 0. Use category.name as fallback key (id || name) to differentiate the two synthetic categories in all hash builders and lookup sites. Co-authored-by: Claude Opus 4.6 --- app/models/budget.rb | 10 +++++----- test/models/budget_test.rb | 26 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/app/models/budget.rb b/app/models/budget.rb index aa8f900b8..0396fc30e 100644 --- a/app/models/budget.rb +++ b/app/models/budget.rb @@ -218,9 +218,9 @@ class Budget < ApplicationRecord end def budget_category_actual_spending(budget_category) - cat_id = budget_category.category_id - expense = expense_totals_by_category[cat_id]&.total || 0 - refund = income_totals_by_category[cat_id]&.total || 0 + key = budget_category.category_id || budget_category.category.name + expense = expense_totals_by_category[key]&.total || 0 + refund = income_totals_by_category[key]&.total || 0 [ expense - refund, 0 ].max end @@ -318,10 +318,10 @@ class Budget < ApplicationRecord end def expense_totals_by_category - @expense_totals_by_category ||= expense_totals.category_totals.index_by { |ct| ct.category.id } + @expense_totals_by_category ||= expense_totals.category_totals.index_by { |ct| ct.category.id || ct.category.name } end def income_totals_by_category - @income_totals_by_category ||= income_totals.category_totals.index_by { |ct| ct.category.id } + @income_totals_by_category ||= income_totals.category_totals.index_by { |ct| ct.category.id || ct.category.name } end end diff --git a/test/models/budget_test.rb b/test/models/budget_test.rb index 1896e887a..e12b51d49 100644 --- a/test/models/budget_test.rb +++ b/test/models/budget_test.rb @@ -304,4 +304,30 @@ class BudgetTest < ActiveSupport::TestCase assert_not_nil budget.previous_budget_param end + + test "uncategorized budget category actual spending reflects uncategorized transactions" do + family = families(:dylan_family) + budget = Budget.find_or_bootstrap(family, start_date: Date.current.beginning_of_month) + account = accounts(:depository) + + # Create an uncategorized expense + Entry.create!( + account: account, + entryable: Transaction.create!(category: nil), + date: Date.current, + name: "Uncategorized lunch", + amount: 75, + currency: "USD" + ) + + budget = Budget.find(budget.id) + budget.sync_budget_categories + + uncategorized_bc = budget.uncategorized_budget_category + spending = budget.budget_category_actual_spending(uncategorized_bc) + + # Must be > 0 — the nil-key collision between Uncategorized and + # Other Investments synthetic categories previously caused this to return 0 + assert spending >= 75, "Uncategorized actual spending should include the $75 transaction, got #{spending}" + end end From 7fce804c89ad907af82b66fcbd5c14838e3f2941 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Fri, 6 Mar 2026 23:38:25 +0100 Subject: [PATCH 40/75] Group users by family in `/admin/users` (#1139) * Display user admins grouped * Start family/groups collapsed * Sort by number of transactions * Display subscription status * Fix tests * Use Stimulus --- app/controllers/admin/users_controller.rb | 10 +- app/views/admin/users/index.html.erb | 168 +++++++++++------- config/locales/views/admin/users/en.yml | 5 +- .../admin/users_controller_test.rb | 56 +++--- 4 files changed, 139 insertions(+), 100 deletions(-) diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index d460b1ac5..f5a3ae953 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -13,7 +13,7 @@ module Admin scope = scope.where(role: params[:role]) if params[:role].present? scope = apply_trial_filter(scope) if params[:trial_status].present? - @users = scope.order( + users = scope.order( Arel.sql( "CASE " \ "WHEN subscriptions.status = 'trialing' THEN 0 " \ @@ -23,14 +23,18 @@ module Admin ) ) - family_ids = @users.map(&:family_id).uniq + family_ids = users.map(&:family_id).uniq @accounts_count_by_family = Account.where(family_id: family_ids).group(:family_id).count @entries_count_by_family = Entry.joins(:account).where(accounts: { family_id: family_ids }).group("accounts.family_id").count - user_ids = @users.map(&:id).uniq + user_ids = users.map(&:id).uniq @last_login_by_user = Session.where(user_id: user_ids).group(:user_id).maximum(:created_at) @sessions_count_by_user = Session.where(user_id: user_ids).group(:user_id).count + @families_with_users = users.group_by(&:family).sort_by do |family, _users| + -(@entries_count_by_family[family.id] || 0) + end + @trials_expiring_in_7_days = Subscription .where(status: :trialing) .where(trial_ends_at: Time.current..7.days.from_now) diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb index abc09a652..0c21d2060 100644 --- a/app/views/admin/users/index.html.erb +++ b/app/views/admin/users/index.html.erb @@ -43,80 +43,110 @@
    - +

    <%= t(".section_title") %>

    -
    - <% if @users.any? %> - - - - - - - - - - - - <% @users.each do |user| %> - - - - - - +
    <%= t(".table.user") %><%= t(".table.trial_ends_at") %><%= t(".table.family_accounts") %><%= t(".table.family_transactions") %><%= t(".table.role") %>
    -
    -
    - <%= user.initials %> -
    -
    -

    <%= user.display_name %>

    -

    <%= user.email %>

    -

    - <%= t(".table.last_login") %>: <%= @last_login_by_user[user.id]&.to_fs(:long) || t(".table.never") %> - <%= t(".table.session_count") %>: <%= number_with_delimiter(@sessions_count_by_user[user.id] || 0) %> -

    -
    -
    -
    - <%= user.family.subscription&.trial_ends_at&.to_fs(:long) || t(".not_available") %> - - <%= number_with_delimiter(@accounts_count_by_family[user.family_id] || 0) %> - - <%= number_with_delimiter(@entries_count_by_family[user.family_id] || 0) %> - - <% if user.id == Current.user.id %> - <%= t(".you") %> - <% else %> - <%= form_with model: [:admin, user], method: :patch, class: "flex items-center justify-end gap-2" do |form| %> - <%= form.select :role, - options_for_select([ - [t(".roles.guest"), "guest"], - [t(".roles.member", default: "Member"), "member"], - [t(".roles.admin"), "admin"], - [t(".roles.super_admin"), "super_admin"] - ], user.role), - {}, - class: "text-sm rounded-lg border border-primary bg-container text-primary px-2 py-1", - onchange: "this.form.requestSubmit()" %> - <% end %> + + <% if @families_with_users.any? %> +
    + <% @families_with_users.each do |family, users| %> +
    + +
    + <%= icon "users", class: "w-5 h-5 text-secondary shrink-0" %> +
    +

    <%= family.name.presence || t(".unnamed_family") %>

    +

    + <%= t(".family_summary", + members: users.size, + accounts: number_with_delimiter(@accounts_count_by_family[family.id] || 0), + transactions: number_with_delimiter(@entries_count_by_family[family.id] || 0)) %> +

    +
    +
    +
    + <% sub = family.subscription %> + <% if sub&.trialing? %> + + <%= t(".table.trial_ends_at") %>: <%= sub.trial_ends_at&.to_fs(:long) || t(".not_available") %> + + <% elsif sub %> + + <%= sub.status.humanize %> + + <% else %> + <%= t(".no_subscription") %> + <% end %> + <%= icon "chevron-down", class: "w-4 h-4 text-secondary transition-transform group-open:rotate-180" %> +
    +
    + +
    + + + + + + + + + + + <% users.each do |user| %> + + + + + + <% end %> - - - <% end %> - -
    <%= t(".table.user") %><%= t(".table.last_login") %><%= t(".table.session_count") %><%= t(".table.role") %>
    +
    +
    + <%= user.initials %> +
    +
    +

    <%= user.display_name %>

    +

    <%= user.email %>

    +
    +
    +
    + <%= @last_login_by_user[user.id]&.to_fs(:long) || t(".table.never") %> + + <%= number_with_delimiter(@sessions_count_by_user[user.id] || 0) %> + + <% if user.id == Current.user.id %> + <%= t(".you") %> + <% else %> + <%= form_with model: [:admin, user], method: :patch, class: "flex items-center justify-end gap-2", data: { controller: "auto-submit-form" } do |form| %> + <%= form.select :role, + options_for_select([ + [t(".roles.guest"), "guest"], + [t(".roles.member", default: "Member"), "member"], + [t(".roles.admin"), "admin"], + [t(".roles.super_admin"), "super_admin"] + ], user.role), + {}, + class: "text-sm rounded-lg border border-primary bg-container text-primary px-2 py-1", + data: { auto_submit_form_target: "auto" } %> + <% end %> + <% end %> +
    - <% else %> -
    - <%= icon "users", class: "w-12 h-12 mx-auto text-secondary mb-3" %> -

    <%= t(".no_users") %>

    -
    - <% end %> -
    +
    +
    + + <% end %> +
    + <% else %> +
    + <%= icon "users", class: "w-12 h-12 mx-auto text-secondary mb-3" %> +

    <%= t(".no_users") %>

    +
    + <% end %>
    - <%= settings_section title: t(".role_descriptions_title"), collapsible: true, open: false do %> + <%= settings_section title: t(".role_descriptions_title"), collapsible: true, open: true do %>
    diff --git a/config/locales/views/admin/users/en.yml b/config/locales/views/admin/users/en.yml index 7eb7102a7..b14feb785 100644 --- a/config/locales/views/admin/users/en.yml +++ b/config/locales/views/admin/users/en.yml @@ -5,11 +5,14 @@ en: index: title: "User Management" description: "Manage user roles for your instance. Super admins can access SSO provider settings and user management." - section_title: "Users" + section_title: "Families / Groups" you: "(You)" trial_ends_at: "Trial ends" not_available: "n/a" no_users: "No users found." + unnamed_family: "Unnamed Family/Group" + no_subscription: "No subscription" + family_summary: "%{members} members · %{accounts} accounts · %{transactions} transactions" filters: role: "Role" role_all: "All roles" diff --git a/test/controllers/admin/users_controller_test.rb b/test/controllers/admin/users_controller_test.rb index e23180e2c..1273c4b18 100644 --- a/test/controllers/admin/users_controller_test.rb +++ b/test/controllers/admin/users_controller_test.rb @@ -5,43 +5,45 @@ class Admin::UsersControllerTest < ActionDispatch::IntegrationTest sign_in users(:sure_support_staff) end - test "index sorts users by subscription trial end date with nils last" do - user_with_trial = User.find_by!(email: "user1@example.com") - user_without_trial = User.find_by!(email: "bob@bobdylan.com") + test "index groups users by family sorted by transaction count" do + family_with_more = users(:family_admin).family + family_with_fewer = users(:empty).family - user_with_trial.family.subscription&.destroy - Subscription.create!( - family_id: user_with_trial.family_id, - status: :trialing, - trial_ends_at: 2.days.from_now - ) - - user_without_trial.family.subscription&.destroy - Subscription.create!( - family_id: user_without_trial.family_id, - status: :active, - trial_ends_at: nil, - stripe_id: "cus_test_#{user_without_trial.family_id}" - ) + account = Account.create!(family: family_with_more, name: "Test", balance: 0, currency: "USD", accountable: Depository.new) + 3.times { |i| account.entries.create!(name: "Txn #{i}", date: Date.current, amount: 10, currency: "USD", entryable: Transaction.new) } get admin_users_url - assert_response :success body = response.body - trial_user_index = body.index("user1@example.com") - no_trial_user_index = body.index("bob@bobdylan.com") + more_idx = body.index(family_with_more.name) + fewer_idx = body.index(family_with_fewer.name) - assert_not_nil trial_user_index - assert_not_nil no_trial_user_index - assert_operator trial_user_index, :<, no_trial_user_index, - "User with trialing subscription (user1@example.com) should appear before user with non-trial subscription (bob@bobdylan.com)" + assert_not_nil more_idx + assert_not_nil fewer_idx + assert_operator more_idx, :<, fewer_idx, + "Family with more transactions should appear before family with fewer" end - test "index shows n/a when trial end date is unavailable" do - get admin_users_url + test "index shows subscription status for families" do + family = users(:family_admin).family + family.subscription&.destroy + Subscription.create!( + family_id: family.id, + status: :active, + stripe_id: "cus_test_#{family.id}" + ) + get admin_users_url assert_response :success - assert_match(/n\/a/, response.body, "Page should show n/a for users without trial end date") + assert_match(/Active/, response.body, "Page should show subscription status for families with active subscriptions") + end + + test "index shows no subscription label for families without subscription" do + users(:family_admin).family.subscription&.destroy + + get admin_users_url + assert_response :success + assert_match(/No subscription/, response.body, "Page should show 'No subscription' for families without one") end end From a0628d26add5c7eacdc40def0a4e3a7eabacbc6e Mon Sep 17 00:00:00 2001 From: Michel Roegl-Brunner <73236783+michelroegl-brunner@users.noreply.github.com> Date: Sat, 7 Mar 2026 00:45:45 +0100 Subject: [PATCH 41/75] Feat: add missing German locals (#1065) * Feat: add missing German locals * CodeRabbit suggestions * CodeRabbit suggestions * Update config/locales/views/lunchflow_items/de.yml Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Michel Roegl-Brunner <73236783+michelroegl-brunner@users.noreply.github.com> * Update config/locales/views/lunchflow_items/de.yml Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Michel Roegl-Brunner <73236783+michelroegl-brunner@users.noreply.github.com> --------- Signed-off-by: Michel Roegl-Brunner <73236783+michelroegl-brunner@users.noreply.github.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- config/locales/defaults/de.yml | 2 + .../locales/mailers/pdf_import_mailer/de.yml | 5 + config/locales/models/category/de.yml | 7 + config/locales/models/coinbase_account/de.yml | 5 + config/locales/models/coinstats_item/de.yml | 12 + config/locales/views/accounts/de.yml | 61 +++++ .../locales/views/admin/sso_providers/de.yml | 115 ++++++++ config/locales/views/admin/users/de.yml | 45 ++++ config/locales/views/budgets/de.yml | 10 + config/locales/views/chats/de.yml | 5 + config/locales/views/coinbase_items/de.yml | 78 ++++++ config/locales/views/coinstats_items/de.yml | 63 +++++ config/locales/views/components/de.yml | 67 +++++ config/locales/views/cryptos/de.yml | 13 + .../locales/views/enable_banking_items/de.yml | 49 ++++ config/locales/views/entries/de.yml | 9 + config/locales/views/holdings/de.yml | 49 +++- config/locales/views/imports/de.yml | 60 +++++ .../locales/views/indexa_capital_items/de.yml | 247 ++++++++++++++++++ config/locales/views/investments/de.yml | 103 ++++++++ config/locales/views/invitations/de.yml | 8 + config/locales/views/lunchflow_items/de.yml | 113 ++++++-- config/locales/views/merchants/de.yml | 26 ++ config/locales/views/mercury_items/de.yml | 147 +++++++++++ config/locales/views/onboardings/de.yml | 5 + config/locales/views/other_assets/de.yml | 2 + config/locales/views/pages/de.yml | 27 ++ config/locales/views/password_resets/de.yml | 2 + config/locales/views/pdf_import_mailer/de.yml | 17 ++ config/locales/views/plaid_items/de.yml | 5 + .../views/recurring_transactions/de.yml | 15 ++ config/locales/views/registrations/de.yml | 1 + config/locales/views/reports/de.yml | 104 +++++++- config/locales/views/rules/de.yml | 27 ++ config/locales/views/sessions/de.yml | 11 + config/locales/views/settings/de.yml | 48 ++++ config/locales/views/settings/hostings/de.yml | 32 +++ .../views/settings/sso_identities/de.yml | 7 + config/locales/views/simplefin_items/de.yml | 56 +++- config/locales/views/snaptrade_items/de.yml | 188 +++++++++++++ config/locales/views/trades/de.yml | 5 + config/locales/views/transactions/de.yml | 113 ++++++++ 42 files changed, 1945 insertions(+), 19 deletions(-) create mode 100644 config/locales/mailers/pdf_import_mailer/de.yml create mode 100644 config/locales/models/category/de.yml create mode 100644 config/locales/models/coinbase_account/de.yml create mode 100644 config/locales/models/coinstats_item/de.yml create mode 100644 config/locales/views/admin/sso_providers/de.yml create mode 100644 config/locales/views/admin/users/de.yml create mode 100644 config/locales/views/budgets/de.yml create mode 100644 config/locales/views/chats/de.yml create mode 100644 config/locales/views/coinbase_items/de.yml create mode 100644 config/locales/views/coinstats_items/de.yml create mode 100644 config/locales/views/components/de.yml create mode 100644 config/locales/views/enable_banking_items/de.yml create mode 100644 config/locales/views/indexa_capital_items/de.yml create mode 100644 config/locales/views/mercury_items/de.yml create mode 100644 config/locales/views/pdf_import_mailer/de.yml create mode 100644 config/locales/views/settings/sso_identities/de.yml create mode 100644 config/locales/views/snaptrade_items/de.yml diff --git a/config/locales/defaults/de.yml b/config/locales/defaults/de.yml index 1d0f1af64..27bdd0dd2 100644 --- a/config/locales/defaults/de.yml +++ b/config/locales/defaults/de.yml @@ -3,6 +3,8 @@ de: defaults: brand_name: "%{brand_name}" product_name: "%{product_name}" + global: + expand: "Aufklappen" activerecord: errors: messages: diff --git a/config/locales/mailers/pdf_import_mailer/de.yml b/config/locales/mailers/pdf_import_mailer/de.yml new file mode 100644 index 000000000..072a80c2e --- /dev/null +++ b/config/locales/mailers/pdf_import_mailer/de.yml @@ -0,0 +1,5 @@ +--- +de: + pdf_import_mailer: + next_steps: + subject: "Ihr PDF-Dokument wurde analysiert - %{product_name}" diff --git a/config/locales/models/category/de.yml b/config/locales/models/category/de.yml new file mode 100644 index 000000000..58fa84e3e --- /dev/null +++ b/config/locales/models/category/de.yml @@ -0,0 +1,7 @@ +--- +de: + models: + category: + uncategorized: Nicht kategorisiert + other_investments: Sonstige Anlagen + investment_contributions: Anlagebeiträge diff --git a/config/locales/models/coinbase_account/de.yml b/config/locales/models/coinbase_account/de.yml new file mode 100644 index 000000000..9afe7a778 --- /dev/null +++ b/config/locales/models/coinbase_account/de.yml @@ -0,0 +1,5 @@ +--- +de: + coinbase: + processor: + paid_via: "Bezahlt über %{method}" diff --git a/config/locales/models/coinstats_item/de.yml b/config/locales/models/coinstats_item/de.yml new file mode 100644 index 000000000..d56bc04aa --- /dev/null +++ b/config/locales/models/coinstats_item/de.yml @@ -0,0 +1,12 @@ +--- +de: + models: + coinstats_item: + syncer: + importing_wallets: Wallets werden von CoinStats importiert... + checking_configuration: Wallet-Konfiguration wird geprüft... + wallets_need_setup: + one: "1 Wallet muss eingerichtet werden..." + other: "%{count} Wallets müssen eingerichtet werden..." + processing_holdings: Bestände werden verarbeitet... + calculating_balances: Salden werden berechnet... diff --git a/config/locales/views/accounts/de.yml b/config/locales/views/accounts/de.yml index 298a6a6e1..1b3e4d27b 100644 --- a/config/locales/views/accounts/de.yml +++ b/config/locales/views/accounts/de.yml @@ -1,7 +1,10 @@ +--- de: accounts: account: link_lunchflow: Mit Lunch Flow verknüpfen + link_provider: Mit Provider verknüpfen + unlink_provider: Von Provider trennen troubleshoot: Fehlerbehebung chart: data_not_available: Für den ausgewählten Zeitraum sind keine Daten verfügbar @@ -9,6 +12,7 @@ de: success: "%{type}-Konto erstellt" destroy: success: "%{type}-Konto zur Löschung vorgemerkt" + cannot_delete_linked: "Ein verknüpftes Konto kann nicht gelöscht werden. Bitte trennen Sie es zuerst." empty: empty_message: Füge ein Konto über eine Verbindung, einen Import oder manuell hinzu new_account: Neues Konto @@ -18,18 +22,28 @@ de: opening_balance_date_label: Eröffnungsdatum des Kontostands name_label: Kontoname name_placeholder: Beispielkontoname + additional_details: Weitere Angaben + institution_name_label: Name der Institution + institution_name_placeholder: "z. B. Chase Bank" + institution_domain_label: Domain der Institution + institution_domain_placeholder: "z. B. chase.com" + notes_label: Notizen + notes_placeholder: Zusätzliche Informationen wie Kontonummern, Sort codes, IBAN, Routing-Nummern usw. index: accounts: Konten manual_accounts: other_accounts: Andere Konten new_account: Neues Konto sync: Alle synchronisieren + sync_all: + syncing: "Konten werden synchronisiert..." new: import_accounts: Konten importieren method_selector: connected_entry: Konto verknüpfen connected_entry_eu: EU-Konto verknüpfen link_with_provider: "Mit %{provider} verknüpfen" + lunchflow_entry: Lunch-Flow-Konto verknüpfen manual_entry: Kontostand manuell eingeben title: Wie möchtest du es hinzufügen title: Was möchtest du hinzufügen @@ -37,13 +51,22 @@ de: activity: amount: Betrag balance: Kontostand + confirmed: Bestätigt date: Datum entries: Buchungen entry: Buchung + filter: Filtern new: Neu + new_activity: Neue Aktivität new_balance: Neuer Kontostand + new_trade: Neuer Trade new_transaction: Neue Transaktion + new_transfer: Neue Überweisung no_entries: Keine Buchungen gefunden + pending: Ausstehend + search: + placeholder: Buchungen nach Name suchen + status: Status title: Aktivität chart: balance: Kontostand @@ -54,6 +77,8 @@ de: confirm_title: Konto löschen edit: Bearbeiten import: Transaktionen importieren + import_trades: Trades importieren + import_transactions: Transaktionen importieren manage: Konten verwalten update: success: "%{type}-Konto aktualisiert" @@ -79,6 +104,42 @@ de: credit_card: Kreditkarte loan: Darlehen other_liability: Sonstige Verbindlichkeit + subtype_regions: + us: Vereinigte Staaten + uk: Vereinigtes Königreich + ca: Kanada + au: Australien + eu: Europa + generic: Generisch + tax_treatments: + taxable: Versteuerbar + tax_deferred: Steuerlich aufgeschoben + tax_exempt: Steuerfrei + tax_advantaged: Steuerbegünstigt + tax_treatment_descriptions: + taxable: Gewinne werden bei Realisierung besteuert + tax_deferred: Beiträge abzugsfähig, Besteuerung bei Auszahlung + tax_exempt: Beiträge nach Steuer, Gewinne nicht besteuert + tax_advantaged: Besondere Steuervorteile unter Bedingungen + confirm_unlink: + title: Konto vom Provider trennen? + description_html: "Sie sind dabei, %{account_name} von %{provider_name} zu trennen. Das Konto wird zu einem manuellen Konto." + warning_title: Was das bedeutet + warning_no_sync: Das Konto wird nicht mehr automatisch mit dem Provider synchronisiert + warning_manual_updates: Sie müssen Buchungen und Salden manuell pflegen + warning_transactions_kept: Bestehende Buchungen und Salden bleiben erhalten + warning_can_delete: Nach dem Trennen können Sie das Konto bei Bedarf löschen + confirm_button: Bestätigen und trennen + unlink: + success: "Konto erfolgreich getrennt. Es ist jetzt ein manuelles Konto." + not_linked: "Konto ist mit keinem Provider verknüpft" + error: "Konto konnte nicht getrennt werden: %{error}" + generic_error: "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut." + select_provider: + title: Provider zum Verknüpfen auswählen + description: "Wählen Sie den Provider, mit dem %{account_name} verknüpft werden soll" + already_linked: "Konto ist bereits mit einem Provider verknüpft" + no_providers: "Derzeit sind keine Provider konfiguriert" email_confirmations: new: diff --git a/config/locales/views/admin/sso_providers/de.yml b/config/locales/views/admin/sso_providers/de.yml new file mode 100644 index 000000000..4f46a9b61 --- /dev/null +++ b/config/locales/views/admin/sso_providers/de.yml @@ -0,0 +1,115 @@ +--- +de: + admin: + unauthorized: "Sie sind nicht berechtigt, auf diesen Bereich zuzugreifen." + sso_providers: + index: + title: "SSO-Provider" + description: "Single-Sign-On-Authentifizierungsprovider für Ihre Instanz verwalten" + add_provider: "Provider hinzufügen" + no_providers_title: "Keine SSO-Provider" + no_providers_message: "Fügen Sie Ihren ersten SSO-Provider hinzu." + note: "Änderungen an SSO-Providern erfordern einen Neustart des Servers. Alternativ aktivieren Sie das Feature AUTH_PROVIDERS_SOURCE=db, um Provider dynamisch aus der Datenbank zu laden." + table: + name: "Name" + strategy: "Strategie" + status: "Status" + issuer: "Issuer" + actions: "Aktionen" + enabled: "Aktiviert" + disabled: "Deaktiviert" + legacy_providers_title: "Umgebungskonfigurierte Provider" + legacy_providers_notice: "Diese Provider werden über Umgebungsvariablen oder YAML konfiguriert und können nicht über diese Oberfläche verwaltet werden. Zur Verwaltung hier migrieren Sie sie zu datenbankgestützten Providern (AUTH_PROVIDERS_SOURCE=db) und legen Sie sie in der Oberfläche neu an." + env_configured: "Env/YAML" + new: + title: "SSO-Provider hinzufügen" + description: "Neuen Single-Sign-On-Authentifizierungsprovider konfigurieren" + edit: + title: "SSO-Provider bearbeiten" + description: "Konfiguration für %{label} aktualisieren" + create: + success: "SSO-Provider wurde erfolgreich erstellt." + update: + success: "SSO-Provider wurde erfolgreich aktualisiert." + destroy: + success: "SSO-Provider wurde erfolgreich gelöscht." + confirm: "Möchten Sie diesen Provider wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden." + toggle: + success_enabled: "SSO-Provider wurde erfolgreich aktiviert." + success_disabled: "SSO-Provider wurde erfolgreich deaktiviert." + confirm_enable: "Möchten Sie diesen Provider aktivieren?" + confirm_disable: "Möchten Sie diesen Provider deaktivieren?" + form: + basic_information: "Grundinformationen" + oauth_configuration: "OAuth/OIDC-Konfiguration" + strategy_label: "Strategie" + strategy_help: "Die zu verwendende Authentifizierungsstrategie" + name_label: "Name" + name_placeholder: "z. B. openid_connect, keycloak, authentik" + name_help: "Eindeutige Kennung (nur Kleinbuchstaben, Zahlen, Unterstriche)" + label_label: "Bezeichnung" + label_placeholder: "z. B. Anmelden mit Keycloak" + label_help: "Button-Text für Benutzer" + icon_label: "Icon" + icon_placeholder: "z. B. key, google, github" + icon_help: "Lucide-Icon-Name (optional)" + enabled_label: "Diesen Provider aktivieren" + enabled_help: "Benutzer können sich bei Aktivierung mit diesem Provider anmelden" + issuer_label: "Issuer" + issuer_placeholder: "https://accounts.google.com" + issuer_help: "OIDC-Issuer-URL (validiert .well-known/openid-configuration)" + client_id_label: "Client ID" + client_id_placeholder: "your-client-id" + client_id_help: "OAuth-Client-ID Ihres Identitätsanbieters" + client_secret_label: "Client Secret" + client_secret_placeholder_new: "your-client-secret" + client_secret_placeholder_existing: "••••••••••••••••" + client_secret_help: "OAuth-Client-Secret (verschlüsselt in der Datenbank)" + client_secret_help_existing: " – leer lassen, um bestehendes beizubehalten" + redirect_uri_label: "Redirect URI" + redirect_uri_placeholder: "https://yourdomain.com/auth/openid_connect/callback" + redirect_uri_help: "Callback-URL, die beim Identitätsanbieter eingetragen werden muss" + copy_button: "Kopieren" + cancel: "Abbrechen" + submit: "Provider speichern" + errors_title: "%{count} Fehler verhinderte das Speichern dieses Providers:" + provisioning_title: "Benutzer-Bereitstellung" + default_role_label: "Standardrolle für neue Benutzer" + default_role_help: "Rolle für per JIT-SSO bereitgestellte Benutzer. Standard: Mitglied." + role_guest: "Gast" + role_member: "Mitglied" + role_admin: "Admin" + role_super_admin: "Super-Admin" + role_mapping_title: "Gruppen-Rollen-Zuordnung (optional)" + role_mapping_help: "IdP-Gruppen/Claims auf Anwendungsrollen abbilden. Höchste passende Rolle wird zugewiesen. Leer = Standardrolle oben." + super_admin_groups: "Super-Admin-Gruppen" + admin_groups: "Admin-Gruppen" + guest_groups: "Gast-Gruppen" + member_groups: "Mitglieder-Gruppen" + groups_help: "Kommagetrennte IdP-Gruppennamen. * = alle Gruppen." + advanced_title: "Erweiterte OIDC-Einstellungen" + scopes_label: "Benutzerdefinierte Scopes" + scopes_help: "Leerzeichengetrennte OIDC-Scopes. Leer = Standard (openid email profile). 'groups' für Gruppen-Claims." + prompt_label: "Authentifizierungs-Prompt" + prompt_default: "Standard (IdP entscheidet)" + prompt_login: "Login erzwingen (erneut anmelden)" + prompt_consent: "Zustimmung erzwingen (erneut autorisieren)" + prompt_select_account: "Kontoauswahl" + prompt_none: "Kein Prompt (stille Anmeldung)" + prompt_help: "Steuert, wie der IdP den Benutzer während der Anmeldung auffordert." + test_connection: "Verbindung testen" + saml_configuration: "SAML-Konfiguration" + idp_metadata_url: "IdP-Metadaten-URL" + idp_metadata_url_help: "URL zu den SAML-Metadaten Ihres IdP. Andere SAML-Einstellungen werden dann automatisch gesetzt." + manual_saml_config: "Manuelle Konfiguration (ohne Metadaten-URL)" + manual_saml_help: "Nur verwenden, wenn Ihr IdP keine Metadaten-URL bereitstellt." + idp_sso_url: "IdP-SSO-URL" + idp_slo_url: "IdP-SLO-URL (optional)" + idp_certificate: "IdP-Zertifikat" + idp_certificate_help: "X.509-Zertifikat im PEM-Format. Erforderlich ohne Metadaten-URL." + idp_cert_fingerprint: "Zertifikats-Fingerabdruck (Alternative)" + name_id_format: "NameID-Format" + name_id_email: "E-Mail-Adresse (Standard)" + name_id_persistent: "Persistent" + name_id_transient: "Transient" + name_id_unspecified: "Nicht angegeben" diff --git a/config/locales/views/admin/users/de.yml b/config/locales/views/admin/users/de.yml new file mode 100644 index 000000000..2eda1c6b9 --- /dev/null +++ b/config/locales/views/admin/users/de.yml @@ -0,0 +1,45 @@ +--- +de: + admin: + users: + index: + title: "Benutzerverwaltung" + description: "Benutzerrollen für Ihre Instanz verwalten. Super-Admins haben Zugriff auf SSO-Provider und Benutzerverwaltung." + section_title: "Benutzer" + you: "(Sie)" + trial_ends_at: "Testversion endet" + not_available: "k. A." + no_users: "Keine Benutzer gefunden." + filters: + role: "Rolle" + role_all: "Alle Rollen" + trial_status: "Teststatus" + trial_all: "Alle" + trial_expiring_soon: "Läuft in 7 Tagen ab" + trial_trialing: "In Testphase" + submit: "Filtern" + summary: + trials_expiring_7_days: "Testversionen laufen in den nächsten 7 Tagen ab" + table: + user: "Benutzer" + trial_ends_at: "Testversion endet" + family_accounts: "Familienkonten" + family_transactions: "Familienbuchungen" + last_login: "Letzte Anmeldung" + session_count: "Anzahl Sitzungen" + never: "Nie" + role: "Rolle" + role_descriptions_title: "Rollenbeschreibungen" + roles: + guest: "Gast" + member: "Mitglied" + admin: "Admin" + super_admin: "Super-Admin" + role_descriptions: + guest: "Assistenten-orientierte Nutzung mit eingeschränkten Rechten für Einführungsabläufe." + member: "Standard-Zugriff. Kann eigene Konten, Buchungen und Einstellungen verwalten." + admin: "Familien-Administrator. Zugriff auf erweiterte Einstellungen wie API-Schlüssel, Importe und KI-Prompts." + super_admin: "Instanz-Administrator. Kann SSO-Provider, Benutzerrollen verwalten und Benutzer für Support vertreten." + update: + success: "Benutzerrolle wurde erfolgreich aktualisiert." + failure: "Benutzerrolle konnte nicht aktualisiert werden." diff --git a/config/locales/views/budgets/de.yml b/config/locales/views/budgets/de.yml new file mode 100644 index 000000000..00ac0252b --- /dev/null +++ b/config/locales/views/budgets/de.yml @@ -0,0 +1,10 @@ +--- +de: + budgets: + name: + custom_range: "%{start} - %{end_date}" + month_year: "%{month}" + show: + tabs: + actual: Ist + budgeted: Budgetiert diff --git a/config/locales/views/chats/de.yml b/config/locales/views/chats/de.yml new file mode 100644 index 000000000..2d38fb9aa --- /dev/null +++ b/config/locales/views/chats/de.yml @@ -0,0 +1,5 @@ +--- +de: + chats: + demo_banner_title: "Demo-Modus aktiv" + demo_banner_message: "Sie nutzen ein Open-Weight Qwen3-LLM mit Credits von Cloudflare Workers AI. Die Ergebnisse können variieren, da die Codebasis hauptsächlich mit `gpt-4.1` getestet wurde – Ihre Tokens werden jedoch nicht anderswo zum Training verwendet! 🤖" diff --git a/config/locales/views/coinbase_items/de.yml b/config/locales/views/coinbase_items/de.yml new file mode 100644 index 000000000..a7300e8d8 --- /dev/null +++ b/config/locales/views/coinbase_items/de.yml @@ -0,0 +1,78 @@ +--- +de: + coinbase_items: + create: + default_name: Coinbase + success: Mit Coinbase verbunden! Ihre Konten werden synchronisiert. + update: + success: Coinbase-Konfiguration wurde erfolgreich aktualisiert. + destroy: + success: Coinbase-Verbindung wurde zur Löschung vorgemerkt. + setup_accounts: + title: Coinbase-Wallets importieren + subtitle: Wählen Sie die zu verfolgenden Wallets + instructions: Wählen Sie die Wallets zum Import. Nicht ausgewählte Wallets bleiben verfügbar für einen späteren Import. + no_accounts: Alle Wallets wurden bereits importiert. + accounts_count: + one: "%{count} Wallet verfügbar" + other: "%{count} Wallets verfügbar" + select_all: Alle auswählen + import_selected: Ausgewählte importieren + cancel: Abbrechen + creating: Importiere... + complete_account_setup: + success: + one: "%{count} Wallet importiert" + other: "%{count} Wallets importiert" + none_selected: Keine Wallets ausgewählt + no_accounts: Keine Wallets zum Import + coinbase_item: + provider_name: Coinbase + syncing: Synchronisiere... + reconnect: Zugangsdaten müssen aktualisiert werden + deletion_in_progress: Wird gelöscht... + sync_status: + no_accounts: Keine Konten gefunden + all_synced: + one: "%{count} Konto synchronisiert" + other: "%{count} Konten synchronisiert" + partial_sync: "%{linked_count} synchronisiert, %{unlinked_count} müssen eingerichtet werden" + status: "Zuletzt synchronisiert vor %{timestamp}" + status_with_summary: "Zuletzt synchronisiert vor %{timestamp} – %{summary}" + status_never: Noch nie synchronisiert + update_credentials: Zugangsdaten aktualisieren + delete: Löschen + no_accounts_title: Keine Konten gefunden + no_accounts_message: Ihre Coinbase-Wallets erscheinen hier nach dem Sync. + setup_needed: Wallets bereit zum Import + setup_description: Wählen Sie die Coinbase-Wallets, die Sie verfolgen möchten. + setup_action: Wallets importieren + import_wallets_menu: Wallets importieren + more_wallets_available: + one: "%{count} weiteres Wallet zum Import verfügbar" + other: "%{count} weitere Wallets zum Import verfügbar" + select_existing_account: + title: Coinbase-Konto verknüpfen + no_accounts_found: Keine Coinbase-Konten gefunden. + wait_for_sync: Warten Sie, bis Coinbase die Synchronisation abgeschlossen hat + check_provider_health: Prüfen Sie, ob Ihre Coinbase-API-Zugangsdaten gültig sind + balance: Saldo + currently_linked_to: "Aktuell verknüpft mit: %{account_name}" + link: Verknüpfen + cancel: Abbrechen + link_existing_account: + success: Erfolgreich mit Coinbase-Konto verknüpft + errors: + only_manual: Nur manuelle Konten können mit Coinbase verknüpft werden + invalid_coinbase_account: Ungültiges Coinbase-Konto + coinbase_item: + syncer: + checking_credentials: Zugangsdaten werden geprüft... + credentials_invalid: Ungültige API-Zugangsdaten. Bitte API-Key und Secret prüfen. + importing_accounts: Konten werden von Coinbase importiert... + checking_configuration: Kontokonfiguration wird geprüft... + accounts_need_setup: + one: "%{count} Konto muss eingerichtet werden" + other: "%{count} Konten müssen eingerichtet werden" + processing_accounts: Kontodaten werden verarbeitet... + calculating_balances: Salden werden berechnet... diff --git a/config/locales/views/coinstats_items/de.yml b/config/locales/views/coinstats_items/de.yml new file mode 100644 index 000000000..56ce87bd8 --- /dev/null +++ b/config/locales/views/coinstats_items/de.yml @@ -0,0 +1,63 @@ +--- +de: + coinstats_items: + create: + success: CoinStats-Provider-Verbindung wurde erfolgreich eingerichtet. + default_name: CoinStats-Verbindung + errors: + validation_failed: "Validierung fehlgeschlagen: %{message}." + update: + success: CoinStats-Provider-Verbindung wurde erfolgreich aktualisiert. + errors: + validation_failed: "Validierung fehlgeschlagen: %{message}." + destroy: + success: CoinStats-Provider-Verbindung wurde zur Löschung vorgemerkt. + link_wallet: + success: "%{count} Krypto-Wallet(s) erfolgreich verknüpft." + missing_params: "Fehlende erforderliche Parameter: Adresse und Blockchain." + failed: Verknüpfung des Krypto-Wallets fehlgeschlagen. + error: "Krypto-Wallet-Verknüpfung fehlgeschlagen: %{message}." + new: + title: Krypto-Wallet mit CoinStats verknüpfen + blockchain_fetch_error: Blockchains konnten nicht geladen werden. Bitte später erneut versuchen. + address_label: Adresse + address_placeholder: Erforderlich + blockchain_label: Blockchain + blockchain_placeholder: Erforderlich + blockchain_select_blank: Blockchain auswählen + link: Krypto-Wallet verknüpfen + not_configured_title: CoinStats-Provider-Verbindung nicht konfiguriert + not_configured_message: Zum Verknüpfen eines Krypto-Wallets müssen Sie zuerst die CoinStats-Provider-Verbindung konfigurieren. + not_configured_step1_html: Gehen Sie zu Einstellungen → Provider + not_configured_step2_html: Suchen Sie den CoinStats-Provider + not_configured_step3_html: Folgen Sie den Einrichtungsanweisungen zur Konfiguration + go_to_settings: Zu den Provider-Einstellungen + setup_instructions: "Einrichtungsanleitung:" + step1_html: Besuchen Sie das CoinStats Public API Dashboard, um einen API-Key zu erhalten. + step2: Tragen Sie Ihren API-Key unten ein und klicken Sie auf Konfigurieren. + step3_html: Nach erfolgreicher Verbindung gehen Sie zum Konten-Tab, um Krypto-Wallets einzurichten. + api_key_label: API-Key + api_key_placeholder: Erforderlich + configure: Konfigurieren + update_configuration: Neu konfigurieren + default_name: CoinStats-Verbindung + status_configured_html: Bereit zur Nutzung + status_not_configured: Nicht konfiguriert + coinstats_item: + deletion_in_progress: Krypto-Wallet-Daten werden gelöscht… + provider_name: CoinStats + syncing: Synchronisiere… + sync_status: + no_accounts: Keine Krypto-Wallets gefunden + all_synced: + one: "%{count} Krypto-Wallet synchronisiert" + other: "%{count} Krypto-Wallets synchronisiert" + partial_sync: "%{linked_count} Krypto-Wallets synchronisiert, %{unlinked_count} müssen eingerichtet werden" + reconnect: Erneut verbinden + status: Zuletzt synchronisiert vor %{timestamp} + status_never: Noch nie synchronisiert + status_with_summary: "Zuletzt synchronisiert vor %{timestamp} • %{summary}" + update_api_key: API-Key aktualisieren + delete: Löschen + no_wallets_title: Keine Krypto-Wallets verbunden + no_wallets_message: Derzeit sind keine Krypto-Wallets mit CoinStats verbunden. diff --git a/config/locales/views/components/de.yml b/config/locales/views/components/de.yml new file mode 100644 index 000000000..ec3e70fed --- /dev/null +++ b/config/locales/views/components/de.yml @@ -0,0 +1,67 @@ +--- +de: + provider_sync_summary: + title: Sync-Zusammenfassung + last_sync: "Letzter Sync: vor %{time_ago}" + accounts: + title: Konten + total: "Gesamt: %{count}" + linked: "Verknüpft: %{count}" + unlinked: "Nicht verknüpft: %{count}" + institutions: "Institute: %{count}" + transactions: + title: Buchungen + seen: "Erfasst: %{count}" + imported: "Importiert: %{count}" + updated: "Aktualisiert: %{count}" + skipped: "Übersprungen: %{count}" + fetching: "Wird vom Broker abgerufen..." + protected: + one: "%{count} Buchung geschützt (nicht überschrieben)" + other: "%{count} Buchungen geschützt (nicht überschrieben)" + view_protected: Geschützte Buchungen anzeigen + skip_reasons: + excluded: Ausgeschlossen + user_modified: Vom Benutzer geändert + import_locked: CSV-Import + protected: Geschützt + holdings: + title: Bestände + found: "Gefunden: %{count}" + processed: "Verarbeitet: %{count}" + trades: + title: Trades + imported: "Importiert: %{count}" + skipped: "Übersprungen: %{count}" + fetching: "Aktivitäten werden vom Broker abgerufen..." + health: + title: Status + view_error_details: Fehlerdetails anzeigen + rate_limited: "Rate-Limit vor %{time_ago}" + recently: kürzlich + errors: "Fehler: %{count}" + pending_reconciled: + one: "%{count} ausstehende Doppelbuchung abgeglichen" + other: "%{count} ausstehende Doppelbuchungen abgeglichen" + view_reconciled: Abgeglichene Buchungen anzeigen + duplicate_suggestions: + one: "%{count} mögliche Doppelbuchung zur Prüfung" + other: "%{count} mögliche Doppelbuchungen zur Prüfung" + view_duplicate_suggestions: Vorgeschlagene Duplikate anzeigen + stale_pending: + one: "%{count} veraltete ausstehende Buchung (von Budgets ausgeschlossen)" + other: "%{count} veraltete ausstehende Buchungen (von Budgets ausgeschlossen)" + view_stale_pending: Betroffene Konten anzeigen + stale_pending_count: + one: "%{count} Buchung" + other: "%{count} Buchungen" + stale_unmatched: + one: "%{count} ausstehende Buchung benötigt manuelle Prüfung" + other: "%{count} ausstehende Buchungen benötigen manuelle Prüfung" + view_stale_unmatched: Zu prüfende Buchungen anzeigen + stale_unmatched_count: + one: "%{count} Buchung" + other: "%{count} Buchungen" + data_warnings: "Datenwarnungen: %{count}" + notices: "Hinweise: %{count}" + view_data_quality: Datenqualitätsdetails anzeigen diff --git a/config/locales/views/cryptos/de.yml b/config/locales/views/cryptos/de.yml index 06039fa62..250562848 100644 --- a/config/locales/views/cryptos/de.yml +++ b/config/locales/views/cryptos/de.yml @@ -3,5 +3,18 @@ de: cryptos: edit: edit: "%{account} bearbeiten" + form: + subtype_label: Kontotyp + subtype_prompt: Typ auswählen... + subtype_none: Nicht angegeben + tax_treatment_label: Steuerbehandlung + tax_treatment_hint: Die meisten Kryptowährungen werden in versteuerten Konten gehalten. Wählen Sie eine andere Option bei steuerbegünstigten Konten (z. B. selbst verwaltetes IRA). new: title: Kontostand eingeben + subtypes: + wallet: + short: Wallet + long: Krypto-Wallet + exchange: + short: Börse + long: Krypto-Börse diff --git a/config/locales/views/enable_banking_items/de.yml b/config/locales/views/enable_banking_items/de.yml new file mode 100644 index 000000000..c7ead9255 --- /dev/null +++ b/config/locales/views/enable_banking_items/de.yml @@ -0,0 +1,49 @@ +--- +de: + enable_banking_items: + authorize: + authorization_failed: Autorisierung konnte nicht gestartet werden + bank_required: Bitte wählen Sie eine Bank. + invalid_redirect: Die erhaltene Autorisierungs-URL ist ungültig. Bitte versuchen Sie es erneut. + redirect_uri_not_allowed: Weiterleitung nicht erlaubt. Konfigurieren Sie %{callback_url} in den Enable-Banking-App-Einstellungen. + unexpected_error: Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut. + callback: + authorization_error: Autorisierung fehlgeschlagen + invalid_callback: Ungültige Callback-Parameter. + item_not_found: Verbindung nicht gefunden. + session_failed: Autorisierung konnte nicht abgeschlossen werden + success: Erfolgreich mit Ihrer Bank verbunden. Ihre Konten werden synchronisiert. + unexpected_error: Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut. + complete_account_setup: + all_skipped: Alle Konten wurden übersprungen. Sie können sie später auf der Konten-Seite einrichten. + no_accounts: Keine Konten zum Einrichten verfügbar. + success: "%{count} Konten wurden erfolgreich angelegt!" + create: + success: Enable-Banking-Konfiguration erfolgreich. + destroy: + success: Die Enable-Banking-Verbindung wurde zur Löschung vorgemerkt. + link_accounts: + already_linked: Die ausgewählten Konten sind bereits verknüpft. + link_failed: Konten konnten nicht verknüpft werden + no_accounts_selected: Keine Konten ausgewählt. + no_session: Keine aktive Enable-Banking-Verbindung. Bitte verbinden Sie zuerst eine Bank. + success: "%{count} Konten wurden erfolgreich verknüpft." + link_existing_account: + success: Konto erfolgreich mit Enable-Banking verknüpft + errors: + only_manual: Nur manuelle Konten können verknüpft werden + invalid_enable_banking_account: Ungültiges Enable-Banking-Konto ausgewählt + new: + link_enable_banking_title: Enable-Banking verknüpfen + reauthorize: + invalid_redirect: Die erhaltene Autorisierungs-URL ist ungültig. Bitte versuchen Sie es erneut. + reauthorization_failed: Erneute Autorisierung fehlgeschlagen + select_bank: + cancel: Abbrechen + check_country: Bitte prüfen Sie Ihre Ländereinstellungen. + credentials_required: Bitte konfigurieren Sie zuerst Ihre Enable-Banking-Zugangsdaten. + description: Wählen Sie die Bank, die Sie mit Ihren Konten verbinden möchten. + no_banks: Für diese Region/Land sind keine Banken verfügbar. + title: Wählen Sie Ihre Bank + update: + success: Enable-Banking-Konfiguration aktualisiert. diff --git a/config/locales/views/entries/de.yml b/config/locales/views/entries/de.yml index 4b08c0138..ca6893024 100644 --- a/config/locales/views/entries/de.yml +++ b/config/locales/views/entries/de.yml @@ -12,3 +12,12 @@ de: loading: Buchungen werden geladen... update: success: Buchung aktualisiert + unlock: + success: Buchung freigegeben. Sie kann beim nächsten Sync aktualisiert werden. + protection: + tooltip: Vor Sync geschützt + title: Vor Sync geschützt + description: Ihre Änderungen an dieser Buchung werden nicht durch den Provider-Sync überschrieben. + locked_fields_label: "Gesperrte Felder:" + unlock_button: Sync-Updates zulassen + unlock_confirm: Soll der Sync diese Buchung aktualisieren dürfen? Ihre Änderungen können beim nächsten Sync überschrieben werden. diff --git a/config/locales/views/holdings/de.yml b/config/locales/views/holdings/de.yml index 7fdd2ca74..3954a23ad 100644 --- a/config/locales/views/holdings/de.yml +++ b/config/locales/views/holdings/de.yml @@ -5,9 +5,37 @@ de: brokerage_cash: Depotguthaben destroy: success: Position gelöscht + update: + success: Einstandspreis gespeichert. + error: Ungültiger Einstandspreis. + unlock_cost_basis: + success: Einstandspreis freigegeben. Er kann beim nächsten Sync aktualisiert werden. + remap_security: + success: Wertpapier erfolgreich aktualisiert. + security_not_found: Das ausgewählte Wertpapier konnte nicht gefunden werden. + reset_security: + success: Wertpapier auf Provider-Wert zurückgesetzt. + errors: + security_collision: "Umbildung nicht möglich: Sie haben bereits eine Position für %{ticker} am %{date}." + cost_basis_sources: + manual: Vom Benutzer gesetzt + calculated: Aus Trades + provider: Vom Provider + cost_basis_cell: + unknown: "--" + set_cost_basis_header: "Einstandspreis für %{ticker} setzen (%{qty} Anteile)" + total_cost_basis_label: Gesamter Einstandspreis + or_per_share_label: "Oder pro Anteil eingeben:" + per_share: pro Anteil + cancel: Abbrechen + save: Speichern + overwrite_confirm_title: Einstandspreis überschreiben? + overwrite_confirm_body: "Dies ersetzt den aktuellen Einstandspreis von %{current}." holding: per_share: pro Anteil shares: "%{qty} Anteile" + unknown: "--" + no_cost_basis: Kein Einstandspreis index: average_cost: Durchschnittlicher Einstandspreis holdings: Positionen @@ -23,12 +51,26 @@ de: avg_cost_label: Durchschnittlicher Einstandspreis current_market_price_label: Aktueller Marktpreis delete: Löschen - delete_subtitle: Dadurch wird die Position und alle zugehörigen Trades auf diesem Konto gelöscht Diese Aktion kann nicht rückgängig gemacht werden + delete_subtitle: Dadurch wird die Position und alle zugehörigen Trades auf diesem Konto gelöscht. Diese Aktion kann nicht rückgängig gemacht werden. delete_title: Position löschen + edit_security: Wertpapier bearbeiten history: Verlauf + no_trade_history: Für diese Position ist keine Trade-Historie verfügbar. overview: Übersicht portfolio_weight_label: Portfolio-Gewichtung settings: Einstellungen + security_label: Wertpapier + originally: "war %{ticker}" + search_security: Wertpapier suchen + search_security_placeholder: Nach Ticker oder Name suchen + cancel: Abbrechen + remap_security: Speichern + no_security_provider: Wertpapier-Provider nicht konfiguriert. Suche nach Wertpapieren nicht möglich. + security_remapped_label: Wertpapier umgebildet + provider_sent: "Provider: %{ticker}" + reset_to_provider: Auf Provider zurücksetzen + reset_confirm_title: Wertpapier auf Provider zurücksetzen? + reset_confirm_body: "Das Wertpapier wird von %{current} zurück auf %{original} geändert; alle zugehörigen Trades werden verschoben." ticker_label: Ticker trade_history_entry: "%{qty} Anteile von %{security} zu %{price}" total_return_label: Gesamtrendite @@ -36,3 +78,8 @@ de: book_value_label: Buchwert market_value_label: Marktwert unknown: Unbekannt + cost_basis_locked_label: Einstandspreis ist gesperrt + cost_basis_locked_description: Ihr manuell gesetzter Einstandspreis wird durch Syncs nicht geändert. + unlock_cost_basis: Freigeben + unlock_confirm_title: Einstandspreis freigeben? + unlock_confirm_body: Der Einstandspreis kann dann durch Provider-Syncs oder Trade-Berechnungen aktualisiert werden. diff --git a/config/locales/views/imports/de.yml b/config/locales/views/imports/de.yml index ce94b1a0b..d9f5d992c 100644 --- a/config/locales/views/imports/de.yml +++ b/config/locales/views/imports/de.yml @@ -8,8 +8,18 @@ de: errors_notice_mobile: Deine Daten enthalten Fehler. Tippe auf den Fehler-Tooltip, um Details zu sehen. title: Daten bereinigen configurations: + update: + success: Import wurde erfolgreich konfiguriert. + category_import: + button_label: Weiter + description: Lade eine einfache CSV-Datei hoch (z. B. wie bei einem Export). Die Spalten werden automatisch zugeordnet. + instructions: Wähle Weiter, um die CSV zu parsen und zum Bereinigungsschritt zu gelangen. mint_import: date_format_label: Datumsformat + rule_import: + description: Konfigurieren Sie den Regel-Import. Regeln werden basierend auf den CSV-Daten erstellt oder aktualisiert. + process_button: Regeln verarbeiten + process_help: Klicken Sie unten, um Ihre CSV zu verarbeiten und Regelzeilen zu erzeugen. show: description: Wähle die Spalten aus, die den jeweiligen Feldern in deiner CSV entsprechen. title: Import konfigurieren @@ -17,6 +27,7 @@ de: date_format_label: Datumsformat transaction_import: date_format_label: Datumsformat + rows_to_skip_label: Erste n Zeilen überspringen confirms: mappings: create_account: Konto erstellen @@ -71,12 +82,61 @@ de: new: description: Du kannst verschiedene Datentypen manuell über CSV importieren oder eine unserer Importvorlagen wie Mint verwenden. import_accounts: Konten importieren + import_categories: Kategorien importieren + import_file: Dokument importieren + import_file_description: KI-gestützte Analyse für PDFs und durchsuchbarer Upload für weitere Formate import_mint: Von Mint importieren import_portfolio: Investitionen importieren + import_rules: Regeln importieren import_transactions: Transaktionen importieren + requires_account: Importiere zuerst Konten, um diese Option zu nutzen. resume: "%{type} fortsetzen" sources: Quellen title: Neuer CSV-Import + create: + file_too_large: Datei ist zu groß. Maximale Größe %{max_size} MB. + invalid_file_type: Ungültiger Dateityp. Bitte laden Sie eine CSV-Datei hoch. + csv_uploaded: CSV wurde erfolgreich hochgeladen. + pdf_too_large: PDF ist zu groß. Maximale Größe %{max_size} MB. + pdf_processing: Ihre PDF wird verarbeitet. Sie erhalten eine E-Mail, wenn die Analyse abgeschlossen ist. + invalid_pdf: Die hochgeladene Datei ist keine gültige PDF. + document_too_large: Dokument ist zu groß. Maximale Größe %{max_size} MB. + invalid_document_file_type: Ungültiger Dokumenttyp für den aktiven Vektorspeicher. + document_uploaded: Dokument wurde erfolgreich hochgeladen. + document_upload_failed: Das Dokument konnte nicht in den Vektorspeicher hochgeladen werden. Bitte versuchen Sie es erneut. + document_provider_not_configured: Kein Vektorspeicher für Dokument-Uploads konfiguriert. + show: + finalize_upload: Bitte schließen Sie den Datei-Upload ab. + finalize_mappings: Bitte schließen Sie die Zuordnungen ab, bevor Sie fortfahren. + errors: + custom_column_requires_inflow: "Bei benutzerdefinierten Spalten muss eine Einnahmen-Spalte ausgewählt werden." + document_types: + bank_statement: Kontoauszug + credit_card_statement: Kreditkartenabrechnung + investment_statement: Wertpapierabrechnung + financial_document: Finanzdokument + contract: Vertrag + other: Sonstiges Dokument + unknown: Unbekanntes Dokument + pdf_import: + processing_title: Ihre PDF wird verarbeitet + processing_description: Wir analysieren Ihr Dokument mit KI. Das kann einen Moment dauern. Sie erhalten eine E-Mail, wenn die Analyse abgeschlossen ist. + check_status: Status prüfen + back_to_dashboard: Zurück zur Übersicht + failed_title: Verarbeitung fehlgeschlagen + failed_description: Ihre PDF konnte nicht verarbeitet werden. Bitte versuchen Sie es erneut oder kontaktieren Sie den Support. + try_again: Erneut versuchen + delete_import: Import löschen + complete_title: Dokument analysiert + complete_description: Wir haben Ihre PDF analysiert – hier ist das Ergebnis. + document_type_label: Dokumenttyp + summary_label: Zusammenfassung + email_sent_notice: Sie haben eine E-Mail mit den nächsten Schritten erhalten. + back_to_imports: Zurück zu Importen + unknown_state_title: Unbekannter Status + unknown_state_description: Dieser Import befindet sich in einem unerwarteten Zustand. Bitte kehren Sie zu den Importen zurück. + processing_failed_with_message: "%{message}" + processing_failed_generic: "Verarbeitung fehlgeschlagen: %{error}" ready: description: Hier ist eine Zusammenfassung der neuen Elemente, die deinem Konto hinzugefügt werden, sobald du diesen Import veröffentlichst. title: Importdaten bestätigen diff --git a/config/locales/views/indexa_capital_items/de.yml b/config/locales/views/indexa_capital_items/de.yml new file mode 100644 index 000000000..09d0e2a22 --- /dev/null +++ b/config/locales/views/indexa_capital_items/de.yml @@ -0,0 +1,247 @@ +--- +de: + indexa_capital_items: + sync_status: + no_accounts: "Keine Konten gefunden" + synced: + one: "%{count} Konto synchronisiert" + other: "%{count} Konten synchronisiert" + synced_with_setup: "%{linked} synchronisiert, %{unlinked} benötigen Einrichtung" + institution_summary: + none: "Keine Institute verbunden" + count: + one: "%{count} Institut" + other: "%{count} Institute" + errors: + provider_not_configured: "IndexaCapital-Anbieter ist nicht konfiguriert" + + + sync: + status: + importing: "Konten werden von IndexaCapital importiert..." + processing: "Depots und Aktivitäten werden verarbeitet..." + calculating: "Salden werden berechnet..." + importing_data: "Kontodaten werden importiert..." + checking_setup: "Kontokonfiguration wird geprüft..." + needs_setup: "%{count} Konten benötigen Einrichtung..." + success: "Synchronisation gestartet" + + + panel: + setup_instructions: "Einrichtungsanleitung:" + step_1: "Besuchen Sie Ihr IndexaCapital Dashboard, um einen schreibgeschützten API-Token zu erstellen" + step_2: "Fügen Sie Ihren API-Token unten ein und klicken Sie auf Speichern" + step_3: "Nach erfolgreicher Verbindung gehen Sie zur Registerkarte Konten, um neue Konten einzurichten" + field_descriptions: "Feldbeschreibungen:" + optional: "(Optional)" + required: "(Pflichtfeld)" + optional_with_default: "(optional, Standard: %{default_value})" + alternative_auth: "Oder nutzen Sie Benutzername/Passwort-Anmeldung..." + save_button: "Konfiguration speichern" + update_button: "Konfiguration aktualisieren" + status_configured_html: "Konfiguriert und einsatzbereit. Besuchen Sie die Registerkarte Konten, um Konten zu verwalten und einzurichten." + status_not_configured: "Nicht konfiguriert" + fields: + api_token: + label: "API-Token" + description: "Ihr schreibgeschützter API-Token aus dem IndexaCapital Dashboard" + placeholder_new: "API-Token hier einfügen" + placeholder_update: "Neuen API-Token zum Aktualisieren eingeben" + username: + label: "Benutzername" + description: "Ihr IndexaCapital Benutzername/E-Mail" + placeholder_new: "Benutzername hier einfügen" + placeholder_update: "Neuen Benutzernamen zum Aktualisieren eingeben" + document: + label: "Dokument-ID" + description: "Ihre IndexaCapital Dokument-/ID-Nummer" + placeholder_new: "Dokument-ID hier einfügen" + placeholder_update: "Neue Dokument-ID zum Aktualisieren eingeben" + password: + label: "Passwort" + description: "Ihr IndexaCapital Passwort" + placeholder_new: "Passwort hier einfügen" + placeholder_update: "Neues Passwort zum Aktualisieren eingeben" + + + create: + success: "IndexaCapital-Verbindung erfolgreich erstellt" + update: + success: "IndexaCapital-Verbindung aktualisiert" + destroy: + success: "IndexaCapital-Verbindung entfernt" + index: + title: "IndexaCapital-Verbindungen" + + + loading: + loading_message: "IndexaCapital-Konten werden geladen..." + loading_title: "Laden" + + link_accounts: + all_already_linked: + one: "Das ausgewählte Konto (%{names}) ist bereits verknüpft" + other: "Alle %{count} ausgewählten Konten sind bereits verknüpft: %{names}" + api_error: "API-Fehler: %{message}" + invalid_account_names: + one: "Konto mit leerem Namen kann nicht verknüpft werden" + other: "%{count} Konten mit leeren Namen können nicht verknüpft werden" + link_failed: "Konten konnten nicht verknüpft werden" + no_accounts_selected: "Bitte wählen Sie mindestens ein Konto aus" + no_api_key: "IndexaCapital-Zugangsdaten nicht gefunden. Bitte in den Anbieter-Einstellungen konfigurieren." + partial_invalid: "Erfolgreich %{created_count} Konto/Konten verknüpft, %{already_linked_count} waren bereits verknüpft, %{invalid_count} Konto/Konten hatten ungültige Namen" + partial_success: "Erfolgreich %{created_count} Konto/Konten verknüpft. %{already_linked_count} Konto/Konten waren bereits verknüpft: %{already_linked_names}" + success: + one: "Erfolgreich %{count} Konto verknüpft" + other: "Erfolgreich %{count} Konten verknüpft" + + + indexa_capital_item: + accounts_need_setup: "Konten benötigen Einrichtung" + delete: "Verbindung löschen" + deletion_in_progress: "Löschung läuft..." + error: "Fehler" + more_accounts_available: + one: "%{count} weiteres Konto verfügbar" + other: "%{count} weitere Konten verfügbar" + no_accounts_description: "Diese Verbindung hat noch keine verknüpften Konten." + no_accounts_title: "Keine Konten" + provider_name: "IndexaCapital" + requires_update: "Verbindung muss aktualisiert werden" + setup_action: "Neue Konten einrichten" + setup_description: "%{linked} von %{total} Konten verknüpft. Wählen Sie Kontotypen für Ihre neu importierten IndexaCapital-Konten." + setup_needed: "Neue Konten bereit zur Einrichtung" + status: "Vor %{timestamp} synchronisiert — %{summary}" + status_never: "Noch nie synchronisiert" + syncing: "Synchronisiere..." + total: "Gesamt" + unlinked: "Nicht verknüpft" + update_credentials: "Zugangsdaten aktualisieren" + + + select_accounts: + accounts_selected: "Konten ausgewählt" + api_error: "API-Fehler: %{message}" + cancel: "Abbrechen" + configure_name_in_provider: "Import nicht möglich – bitte Kontoname in IndexaCapital konfigurieren" + description: "Wählen Sie die Konten aus, die Sie mit Ihrem %{product_name}-Konto verknüpfen möchten." + link_accounts: "Ausgewählte Konten verknüpfen" + no_accounts_found: "Keine Konten gefunden. Bitte überprüfen Sie Ihre IndexaCapital-Zugangsdaten." + no_api_key: "IndexaCapital-Zugangsdaten sind nicht konfiguriert. Bitte in den Einstellungen konfigurieren." + no_credentials_configured: "Bitte konfigurieren Sie zuerst Ihre IndexaCapital-Zugangsdaten in den Anbieter-Einstellungen." + no_name_placeholder: "(Kein Name)" + title: "IndexaCapital-Konten auswählen" + + select_existing_account: + account_already_linked: "Dieses Konto ist bereits mit einem Anbieter verknüpft" + all_accounts_already_linked: "Alle IndexaCapital-Konten sind bereits verknüpft" + api_error: "API-Fehler: %{message}" + balance_label: "Saldo:" + cancel: "Abbrechen" + cancel_button: "Abbrechen" + configure_name_in_provider: "Import nicht möglich – bitte Kontoname in IndexaCapital konfigurieren" + connect_hint: "Verbinden Sie ein IndexaCapital-Konto für automatische Synchronisation." + description: "Wählen Sie ein IndexaCapital-Konto zur Verknüpfung mit diesem Konto. Transaktionen werden automatisch synchronisiert und dedupliziert." + header: "Mit IndexaCapital verknüpfen" + link_account: "Konto verknüpfen" + link_button: "Dieses Konto verknüpfen" + linking_to: "Verknüpfe mit:" + no_account_specified: "Kein Konto angegeben" + no_accounts: "Keine unverknüpften IndexaCapital-Konten gefunden." + no_accounts_found: "Keine IndexaCapital-Konten gefunden. Bitte überprüfen Sie Ihre Zugangsdaten." + no_api_key: "IndexaCapital-Zugangsdaten sind nicht konfiguriert. Bitte in den Einstellungen konfigurieren." + no_credentials_configured: "Bitte konfigurieren Sie zuerst Ihre IndexaCapital-Zugangsdaten in den Anbieter-Einstellungen." + no_name_placeholder: "(Kein Name)" + settings_link: "Zu den Anbieter-Einstellungen" + subtitle: "IndexaCapital-Konto auswählen" + title: "%{account_name} mit IndexaCapital verknüpfen" + + link_existing_account: + account_already_linked: "Dieses Konto ist bereits mit einem Anbieter verknüpft" + api_error: "API-Fehler: %{message}" + invalid_account_name: "Konto mit leerem Namen kann nicht verknüpft werden" + provider_account_already_linked: "Dieses IndexaCapital-Konto ist bereits mit einem anderen Konto verknüpft" + provider_account_not_found: "IndexaCapital-Konto nicht gefunden" + missing_parameters: "Erforderliche Parameter fehlen" + no_api_key: "IndexaCapital-Zugangsdaten nicht gefunden. Bitte in den Anbieter-Einstellungen konfigurieren." + success: "%{account_name} erfolgreich mit IndexaCapital verknüpft" + + setup_accounts: + account_type_label: "Kontotyp:" + accounts_count: + one: "%{count} Konto verfügbar" + other: "%{count} Konten verfügbar" + all_accounts_linked: "Alle Ihre IndexaCapital-Konten sind bereits eingerichtet." + api_error: "API-Fehler: %{message}" + creating: "Konten werden erstellt..." + fetch_failed: "Konten konnten nicht geladen werden" + import_selected: "Ausgewählte Konten importieren" + instructions: "Wählen Sie die Konten aus, die Sie von IndexaCapital importieren möchten. Sie können mehrere Konten auswählen." + no_accounts: "Keine unverknüpften Konten von dieser IndexaCapital-Verbindung gefunden." + no_accounts_to_setup: "Keine Konten zum Einrichten" + no_api_key: "IndexaCapital-Zugangsdaten sind nicht konfiguriert. Bitte überprüfen Sie Ihre Verbindungseinstellungen." + select_all: "Alle auswählen" + account_types: + skip: "Dieses Konto überspringen" + depository: "Giro- oder Sparkonto" + credit_card: "Kreditkarte" + investment: "Depot/Anlagekonto" + crypto: "Kryptowährungs-Konto" + loan: "Darlehen oder Hypothek" + other_asset: "Sonstiges Vermögen" + subtype_labels: + depository: "Konto-Untertyp:" + credit_card: "" + investment: "Anlagetyp:" + crypto: "" + loan: "Darlehenstyp:" + other_asset: "" + subtype_messages: + credit_card: "Kreditkarten werden automatisch als Kreditkartenkonten eingerichtet." + other_asset: "Für sonstiges Vermögen sind keine weiteren Optionen nötig." + crypto: "Kryptowährungs-Konten werden zur Verwaltung von Beständen und Transaktionen eingerichtet." + subtypes: + depository: + checking: "Girokonto" + savings: "Sparkonto" + hsa: "Gesundheits-Sparkonto" + cd: "Festgeld" + money_market: "Geldmarkt" + investment: + brokerage: "Brokerage" + pension: "Rente" + retirement: "Altersvorsorge" + "401k": "401(k)" + roth_401k: "Roth 401(k)" + "403b": "403(b)" + tsp: "Thrift Savings Plan" + "529_plan": "529 Plan" + hsa: "Gesundheits-Sparkonto" + mutual_fund: "Investmentfonds" + ira: "Traditioneller IRA" + roth_ira: "Roth IRA" + angel: "Angel" + loan: + mortgage: "Hypothek" + student: "Studienkredit" + auto: "Autokredit" + other: "Sonstiges Darlehen" + balance: "Saldo" + cancel: "Abbrechen" + choose_account_type: "Wählen Sie den passenden Kontotyp für jedes IndexaCapital-Konto:" + create_accounts: "Konten erstellen" + creating_accounts: "Konten werden erstellt..." + historical_data_range: "Zeitraum für Verlauf:" + subtitle: "Wählen Sie die passenden Kontotypen für Ihre importierten Konten" + sync_start_date_help: "Wählen Sie, wie weit die Transaktionshistorie synchronisiert werden soll." + sync_start_date_label: "Transaktionen synchronisieren ab:" + title: "Ihre IndexaCapital-Konten einrichten" + + complete_account_setup: + all_skipped: "Alle Konten wurden übersprungen. Es wurden keine Konten erstellt." + creation_failed: "Konten konnten nicht erstellt werden: %{error}" + no_accounts: "Keine Konten zum Einrichten." + success: "Erfolgreich %{count} Konto/Konten erstellt." + + preload_accounts: + no_credentials_configured: "Bitte konfigurieren Sie zuerst Ihre IndexaCapital-Zugangsdaten in den Anbieter-Einstellungen." diff --git a/config/locales/views/investments/de.yml b/config/locales/views/investments/de.yml index b7ba4e403..ba6066fdb 100644 --- a/config/locales/views/investments/de.yml +++ b/config/locales/views/investments/de.yml @@ -10,6 +10,109 @@ de: title: Kontostand eingeben show: chart_title: Gesamtwert + subtypes: + brokerage: + short: Brokerage + long: Brokerage + "401k": + short: "401(k)" + long: "401(k)" + roth_401k: + short: Roth 401(k) + long: Roth 401(k) + "403b": + short: "403(b)" + long: "403(b)" + "457b": + short: "457(b)" + long: "457(b)" + tsp: + short: TSP + long: Thrift Savings Plan + ira: + short: IRA + long: Traditionelles IRA + roth_ira: + short: Roth IRA + long: Roth IRA + sep_ira: + short: SEP IRA + long: SEP IRA + simple_ira: + short: SIMPLE IRA + long: SIMPLE IRA + "529_plan": + short: "529 Plan" + long: "529 Bildungssparplan" + hsa: + short: HSA + long: Health Savings Account + ugma: + short: UGMA + long: UGMA-Treuhandkonto + utma: + short: UTMA + long: UTMA-Treuhandkonto + isa: + short: ISA + long: Individual Savings Account + lisa: + short: LISA + long: Lifetime ISA + sipp: + short: SIPP + long: Self-Invested Personal Pension + workplace_pension_uk: + short: Pension + long: Betriebliche Altersvorsorge + rrsp: + short: RRSP + long: Registered Retirement Savings Plan + tfsa: + short: TFSA + long: Tax-Free Savings Account + resp: + short: RESP + long: Registered Education Savings Plan + lira: + short: LIRA + long: Locked-In Retirement Account + rrif: + short: RRIF + long: Registered Retirement Income Fund + super: + short: Super + long: Superannuation + smsf: + short: SMSF + long: Self-Managed Super Fund + pea: + short: PEA + long: Plan d'Épargne en Actions + pillar_3a: + short: Säule 3a + long: Private Vorsorge (Säule 3a) + riester: + short: Riester + long: Riester-Rente + pension: + short: Pension + long: Pension + retirement: + short: Ruhestand + long: Ruhestandskonto + mutual_fund: + short: Fonds + long: Investmentfonds + angel: + short: Angel + long: Angel-Investment + trust: + short: Trust + long: Trust + other: + short: Sonstige + long: Sonstige Anlage value_tooltip: cash: Bargeld holdings: Positionen diff --git a/config/locales/views/invitations/de.yml b/config/locales/views/invitations/de.yml index 759899268..8aec53344 100644 --- a/config/locales/views/invitations/de.yml +++ b/config/locales/views/invitations/de.yml @@ -1,7 +1,14 @@ --- de: invitations: + accept_choice: + create_account: Neues Konto erstellen + joined_household: Sie sind dem Haushalt beigetreten. + message: "%{inviter} hat Sie eingeladen, als %{role} beizutreten." + sign_in_existing: Ich habe bereits ein Konto + title: "%{family} beitreten" create: + existing_user_added: Der Benutzer wurde Ihrem Haushalt hinzugefügt. failure: Einladung konnte nicht gesendet werden. success: Einladung erfolgreich gesendet. destroy: @@ -12,6 +19,7 @@ de: email_label: E-Mail-Adresse email_placeholder: E-Mail-Adresse eingeben role_admin: Administrator + role_guest: Gast role_label: Rolle role_member: Mitglied submit: Einladung senden diff --git a/config/locales/views/lunchflow_items/de.yml b/config/locales/views/lunchflow_items/de.yml index e25330471..5aa783ebc 100644 --- a/config/locales/views/lunchflow_items/de.yml +++ b/config/locales/views/lunchflow_items/de.yml @@ -1,62 +1,145 @@ +--- de: lunchflow_items: create: - success: Lunch-Flow-Verbindung erfolgreich erstellt + success: Lunch‑Flow-Verbindung erfolgreich erstellt destroy: - success: Lunch-Flow-Verbindung entfernt + success: Lunch‑Flow-Verbindung entfernt index: - title: Lunch-Flow-Verbindungen + title: Lunch‑Flow-Verbindungen loading: - loading_message: Lunch-Flow-Konten werden geladen... + loading_message: Lunch‑Flow-Konten werden geladen... loading_title: Wird geladen link_accounts: all_already_linked: one: "Das ausgewählte Konto (%{names}) ist bereits verknüpft" other: "Alle %{count} ausgewählten Konten sind bereits verknüpft: %{names}" api_error: "API-Fehler: %{message}" + invalid_account_names: + one: "Konto mit leerem Namen kann nicht verknüpft werden" + other: "%{count} Konten mit leerem Namen können nicht verknüpft werden" link_failed: Konten konnten nicht verknüpft werden no_accounts_selected: Bitte wähle mindestens ein Konto aus + partial_invalid: "%{created_count} Konto/Konten verknüpft, %{already_linked_count} waren bereits verknüpft, %{invalid_count} hatten ungültige Namen" partial_success: "%{created_count} Konto/Konten erfolgreich verknüpft. %{already_linked_count} Konto/Konten waren bereits verknüpft: %{already_linked_names}" success: one: "%{count} Konto erfolgreich verknüpft" other: "%{count} Konten erfolgreich verknüpft" lunchflow_item: + accounts_need_setup: Konten müssen eingerichtet werden delete: Verbindung löschen deletion_in_progress: Löschung wird durchgeführt... error: Fehler no_accounts_description: Diese Verbindung enthält derzeit keine verknüpften Konten. no_accounts_title: Keine Konten + setup_action: Neue Konten einrichten + setup_description: "%{linked} von %{total} Konten verknüpft. Wähle die richtigen Kontotypen für deine neu importierten Lunch‑Flow-Konten." + setup_needed: Neue Konten bereit zur Einrichtung status: "Vor %{timestamp} synchronisiert" status_never: Noch nie synchronisiert + status_with_summary: "Zuletzt vor %{timestamp} synchronisiert • %{summary}" syncing: Wird synchronisiert... + total: Gesamt + unlinked: Nicht verknüpft select_accounts: accounts_selected: Konten ausgewählt api_error: "API-Fehler: %{message}" cancel: Abbrechen + configure_name_in_lunchflow: Import nicht möglich – bitte Kontoname in Lunch‑Flow konfigurieren description: Wähle die Konten aus, die du mit deinem %{product_name}-Konto verknüpfen möchtest. link_accounts: Ausgewählte Konten verknüpfen no_accounts_found: Keine Konten gefunden. Bitte überprüfe deine API-Key-Konfiguration. - no_api_key: Lunch-Flow-API-Schlüssel ist nicht konfiguriert. Bitte konfiguriere ihn in den Einstellungen. - title: Lunch-Flow-Konten auswählen + no_api_key: Lunch‑Flow-API-Schlüssel ist nicht konfiguriert. Bitte konfiguriere ihn in den Einstellungen. + no_name_placeholder: "(Kein Name)" + title: Lunch‑Flow-Konten auswählen select_existing_account: account_already_linked: Dieses Konto ist bereits mit einem Anbieter verknüpft - all_accounts_already_linked: Alle Lunch-Flow-Konten sind bereits verknüpft + all_accounts_already_linked: Alle Lunch‑Flow-Konten sind bereits verknüpft api_error: "API-Fehler: %{message}" cancel: Abbrechen - description: Wähle ein Lunch-Flow-Konto aus, um es mit diesem Konto zu verknüpfen. Transaktionen werden automatisch synchronisiert und doppelte Einträge entfernt. + configure_name_in_lunchflow: Import nicht möglich – bitte Kontoname in Lunch‑Flow konfigurieren + description: Wähle ein Lunch‑Flow-Konto aus, um es mit diesem Konto zu verknüpfen. Transaktionen werden automatisch synchronisiert und doppelte Einträge entfernt. link_account: Konto verknüpfen no_account_specified: Kein Konto angegeben - no_accounts_found: Keine Lunch-Flow-Konten gefunden. Bitte überprüfe deine API-Key-Konfiguration. - no_api_key: Lunch-Flow-API-Schlüssel ist nicht konfiguriert. Bitte konfiguriere ihn in den Einstellungen. - title: "%{account_name} mit Lunch Flow verknüpfen" + no_accounts_found: Keine Lunch‑Flow-Konten gefunden. Bitte überprüfe deine API-Key-Konfiguration. + no_api_key: Lunch‑Flow-API-Schlüssel ist nicht konfiguriert. Bitte konfiguriere ihn in den Einstellungen. + no_name_placeholder: "(Kein Name)" + title: "%{account_name} mit Lunch‑Flow verknüpfen" link_existing_account: account_already_linked: Dieses Konto ist bereits mit einem Anbieter verknüpft api_error: "API-Fehler: %{message}" - lunchflow_account_already_linked: Dieses Lunch-Flow-Konto ist bereits mit einem anderen Konto verknüpft - lunchflow_account_not_found: Lunch-Flow-Konto nicht gefunden + invalid_account_name: Konto mit leerem Namen kann nicht verknüpft werden + lunchflow_account_already_linked: Dieses Lunch‑Flow-Konto ist bereits mit einem anderen Konto verknüpft + lunchflow_account_not_found: Lunch‑Flow-Konto nicht gefunden missing_parameters: Erforderliche Parameter fehlen - success: "%{account_name} erfolgreich mit Lunch Flow verknüpft" + success: "%{account_name} erfolgreich mit Lunch‑Flow verknüpft" + setup_accounts: + account_type_label: "Kontotyp:" + all_accounts_linked: "Alle deine Lunch‑Flow-Konten sind bereits eingerichtet." + api_error: "API-Fehler: %{message}" + fetch_failed: "Konten konnten nicht geladen werden" + no_accounts_to_setup: "Keine Konten zum Einrichten" + no_api_key: "Der Lunch‑Flow-API-Schlüssel ist nicht konfiguriert. Bitte prüfe deine Verbindungseinstellungen." + account_types: + skip: Dieses Konto überspringen + depository: Giro- oder Sparkonto + credit_card: Kreditkarte + investment: Anlagekonto + loan: Darlehen oder Hypothek + other_asset: Sonstiges Vermögen + subtype_labels: + depository: "Konto-Untertyp:" + credit_card: "" + investment: "Anlagetyp:" + loan: "Darlehenstyp:" + other_asset: "" + subtype_messages: + credit_card: "Kreditkarten werden automatisch als Kreditkartenkonten eingerichtet." + other_asset: "Für sonstiges Vermögen sind keine weiteren Optionen nötig." + subtypes: + depository: + checking: Girokonto + savings: Sparkonto + hsa: Health Savings Account + cd: Festgeld + money_market: Geldmarkt + investment: + brokerage: Brokerage + pension: Pension + retirement: Ruhestand + "401k": "401(k)" + roth_401k: "Roth 401(k)" + "403b": "403(b)" + tsp: Thrift Savings Plan + "529_plan": "529 Plan" + hsa: Health Savings Account + mutual_fund: Fonds + ira: Traditionelles IRA + roth_ira: Roth IRA + angel: Business Angel + loan: + mortgage: Hypothek + student: Studienkredit + auto: Autokredit + other: Sonstiges Darlehen + balance: Saldo + cancel: Abbrechen + choose_account_type: "Wähle den richtigen Kontotyp für jedes Lunch‑Flow-Konto:" + create_accounts: Konten anlegen + creating_accounts: Konten werden angelegt... + historical_data_range: "Historischer Datenbereich:" + subtitle: Wähle die richtigen Kontotypen für deine importierten Konten + sync_start_date_help: Wähle, wie weit die Buchungshistorie zurück synchronisiert werden soll. Maximal sind 3 Jahre verfügbar. + sync_start_date_label: "Buchungen synchronisieren ab:" + title: Lunch‑Flow-Konten einrichten + complete_account_setup: + all_skipped: "Alle Konten wurden übersprungen. Es wurden keine Konten angelegt." + creation_failed: "Konten konnten nicht angelegt werden: %{error}" + no_accounts: "Keine Konten zum Einrichten." + success: + one: "%{count} Konto erfolgreich angelegt." + other: "%{count} Konten erfolgreich angelegt." sync: success: Synchronisierung gestartet update: - success: Lunch-Flow-Verbindung aktualisiert + success: Lunch‑Flow-Verbindung aktualisiert diff --git a/config/locales/views/merchants/de.yml b/config/locales/views/merchants/de.yml index 2efbc7dc5..f0da0c60e 100644 --- a/config/locales/views/merchants/de.yml +++ b/config/locales/views/merchants/de.yml @@ -6,19 +6,26 @@ de: success: Neuer Händler erfolgreich erstellt destroy: success: Händler erfolgreich gelöscht + unlinked_success: Händler von deinen Transaktionen entfernt edit: title: Händler bearbeiten form: name_placeholder: Händlername + website_placeholder: Website (z. B. starbucks.com) + website_hint: Gib die Website des Händlers ein, um dessen Logo automatisch anzuzeigen index: empty: Noch keine Händler vorhanden new: Neuer Händler + merge: Händler zusammenführen title: Händler family_title: Händler der Familie family_empty: Noch keine Händler der Familie vorhanden provider_title: Anbieter-Händler provider_empty: Noch keine Anbieter-Händler mit dieser Familie verbunden provider_read_only: Anbieter-Händler werden von deinen verbundenen Institutionen synchronisiert. Sie können hier nicht bearbeitet werden. + provider_info: Diese Händler wurden automatisch von deinen Bankverbindungen oder der KI erkannt. Du kannst sie bearbeiten, um deine eigene Kopie zu erstellen, oder sie entfernen, um sie von deinen Transaktionen zu trennen. + unlinked_title: Kürzlich getrennt + unlinked_info: Diese Händler wurden kürzlich von deinen Transaktionen entfernt. Sie verschwinden nach 30 Tagen aus dieser Liste, sofern sie nicht erneut einer Transaktion zugewiesen werden. table: merchant: Händler actions: Aktionen @@ -30,7 +37,26 @@ de: confirm_title: Händler löschen delete: Händler löschen edit: Händler bearbeiten + merge: + title: Händler zusammenführen + description: Wähle einen Zielhändler und die Händler, die darin zusammengeführt werden sollen. Alle Transaktionen der zusammengeführten Händler werden dem Ziel zugewiesen. + target_label: Zusammenführen in (Ziel) + select_target: Zielhändler auswählen … + sources_label: Händler zum Zusammenführen + sources_hint: Die ausgewählten Händler werden in den Zielhändler zusammengeführt. Familienhändler werden gelöscht, Anbieter-Händler werden getrennt. + submit: Ausgewählte zusammenführen new: title: Neuer Händler + perform_merge: + success: "%{count} Händler erfolgreich zusammengeführt" + no_merchants_selected: Keine Händler zum Zusammenführen ausgewählt + target_not_found: Zielhändler nicht gefunden + invalid_merchants: Ungültige Händler ausgewählt + provider_merchant: + edit: Bearbeiten + remove: Entfernen + remove_confirm_title: Händler entfernen? + remove_confirm_body: Bist du sicher, dass du %{name} entfernen möchtest? Dadurch werden alle zugehörigen Transaktionen von diesem Händler getrennt, der Händler selbst wird nicht gelöscht. update: success: Händler erfolgreich aktualisiert + converted_success: Händler umgewandelt und erfolgreich aktualisiert diff --git a/config/locales/views/mercury_items/de.yml b/config/locales/views/mercury_items/de.yml new file mode 100644 index 000000000..85c1a15d9 --- /dev/null +++ b/config/locales/views/mercury_items/de.yml @@ -0,0 +1,147 @@ +--- +de: + mercury_items: + create: + success: "Mercury-Verbindung erfolgreich erstellt" + destroy: + success: "Mercury-Verbindung entfernt" + index: + title: "Mercury-Verbindungen" + loading: + loading_message: "Mercury-Konten werden geladen..." + loading_title: "Laden" + link_accounts: + all_already_linked: + one: "Das ausgewählte Konto (%{names}) ist bereits verknüpft" + other: "Alle %{count} ausgewählten Konten sind bereits verknüpft: %{names}" + api_error: "API-Fehler: %{message}" + invalid_account_names: + one: "Konto mit leerem Namen kann nicht verknüpft werden" + other: "%{count} Konten mit leeren Namen können nicht verknüpft werden" + link_failed: "Konten konnten nicht verknüpft werden" + no_accounts_selected: "Bitte wählen Sie mindestens ein Konto aus" + no_api_token: "Mercury API-Token nicht gefunden. Bitte in den Anbieter-Einstellungen konfigurieren." + partial_invalid: "Erfolgreich %{created_count} Konto/Konten verknüpft, %{already_linked_count} waren bereits verknüpft, %{invalid_count} Konto/Konten hatten ungültige Namen" + partial_success: "Erfolgreich %{created_count} Konto/Konten verknüpft. %{already_linked_count} Konto/Konten waren bereits verknüpft: %{already_linked_names}" + success: + one: "Erfolgreich %{count} Konto verknüpft" + other: "Erfolgreich %{count} Konten verknüpft" + mercury_item: + accounts_need_setup: "Konten benötigen Einrichtung" + delete: "Verbindung löschen" + deletion_in_progress: "Löschung läuft..." + error: "Fehler" + no_accounts_description: "Diese Verbindung hat noch keine verknüpften Konten." + no_accounts_title: "Keine Konten" + setup_action: "Neue Konten einrichten" + setup_description: "%{linked} von %{total} Konten verknüpft. Wählen Sie Kontotypen für Ihre neu importierten Mercury-Konten." + setup_needed: "Neue Konten bereit zur Einrichtung" + status: "Vor %{timestamp} synchronisiert" + status_never: "Noch nie synchronisiert" + status_with_summary: "Zuletzt vor %{timestamp} synchronisiert – %{summary}" + syncing: "Synchronisiere..." + total: "Gesamt" + unlinked: "Nicht verknüpft" + select_accounts: + accounts_selected: "Konten ausgewählt" + api_error: "API-Fehler: %{message}" + cancel: "Abbrechen" + configure_name_in_mercury: "Import nicht möglich – bitte Kontoname in Mercury konfigurieren" + description: "Wählen Sie die Konten aus, die Sie mit Ihrem %{product_name}-Konto verknüpfen möchten." + link_accounts: "Ausgewählte Konten verknüpfen" + no_accounts_found: "Keine Konten gefunden. Bitte überprüfen Sie Ihre API-Token-Konfiguration." + no_api_token: "Mercury API-Token ist nicht konfiguriert. Bitte in den Einstellungen konfigurieren." + no_credentials_configured: "Bitte konfigurieren Sie zuerst Ihren Mercury API-Token in den Anbieter-Einstellungen." + no_name_placeholder: "(Kein Name)" + title: "Mercury-Konten auswählen" + select_existing_account: + account_already_linked: "Dieses Konto ist bereits mit einem Anbieter verknüpft" + all_accounts_already_linked: "Alle Mercury-Konten sind bereits verknüpft" + api_error: "API-Fehler: %{message}" + cancel: "Abbrechen" + configure_name_in_mercury: "Import nicht möglich – bitte Kontoname in Mercury konfigurieren" + description: "Wählen Sie ein Mercury-Konto zur Verknüpfung mit diesem Konto. Transaktionen werden automatisch synchronisiert und dedupliziert." + link_account: "Konto verknüpfen" + no_account_specified: "Kein Konto angegeben" + no_accounts_found: "Keine Mercury-Konten gefunden. Bitte überprüfen Sie Ihre API-Token-Konfiguration." + no_api_token: "Mercury API-Token ist nicht konfiguriert. Bitte in den Einstellungen konfigurieren." + no_credentials_configured: "Bitte konfigurieren Sie zuerst Ihren Mercury API-Token in den Anbieter-Einstellungen." + no_name_placeholder: "(Kein Name)" + title: "%{account_name} mit Mercury verknüpfen" + link_existing_account: + account_already_linked: "Dieses Konto ist bereits mit einem Anbieter verknüpft" + api_error: "API-Fehler: %{message}" + invalid_account_name: "Konto mit leerem Namen kann nicht verknüpft werden" + mercury_account_already_linked: "Dieses Mercury-Konto ist bereits mit einem anderen Konto verknüpft" + mercury_account_not_found: "Mercury-Konto nicht gefunden" + missing_parameters: "Erforderliche Parameter fehlen" + no_api_token: "Mercury API-Token nicht gefunden. Bitte in den Anbieter-Einstellungen konfigurieren." + success: "%{account_name} erfolgreich mit Mercury verknüpft" + setup_accounts: + account_type_label: "Kontotyp:" + all_accounts_linked: "Alle Ihre Mercury-Konten sind bereits eingerichtet." + api_error: "API-Fehler: %{message}" + fetch_failed: "Konten konnten nicht geladen werden" + no_accounts_to_setup: "Keine Konten zum Einrichten" + no_api_token: "Mercury API-Token ist nicht konfiguriert. Bitte überprüfen Sie Ihre Verbindungseinstellungen." + account_types: + skip: "Dieses Konto überspringen" + depository: "Giro- oder Sparkonto" + credit_card: "Kreditkarte" + investment: "Depot/Anlagekonto" + loan: "Darlehen oder Hypothek" + other_asset: "Sonstiges Vermögen" + subtype_labels: + depository: "Konto-Untertyp:" + credit_card: "" + investment: "Anlagetyp:" + loan: "Darlehenstyp:" + other_asset: "" + subtype_messages: + credit_card: "Kreditkarten werden automatisch als Kreditkartenkonten eingerichtet." + other_asset: "Für sonstiges Vermögen sind keine weiteren Optionen nötig." + subtypes: + depository: + checking: "Girokonto" + savings: "Sparkonto" + hsa: "Gesundheits-Sparkonto" + cd: "Festgeld" + money_market: "Geldmarkt" + investment: + brokerage: "Brokerage" + pension: "Rente" + retirement: "Altersvorsorge" + "401k": "401(k)" + roth_401k: "Roth 401(k)" + "403b": "403(b)" + tsp: "Thrift Savings Plan" + "529_plan": "529 Plan" + hsa: "Gesundheits-Sparkonto" + mutual_fund: "Investmentfonds" + ira: "Traditioneller IRA" + roth_ira: "Roth IRA" + angel: "Angel" + loan: + mortgage: "Hypothek" + student: "Studienkredit" + auto: "Autokredit" + other: "Sonstiges Darlehen" + balance: "Saldo" + cancel: "Abbrechen" + choose_account_type: "Wählen Sie den passenden Kontotyp für jedes Mercury-Konto:" + create_accounts: "Konten erstellen" + creating_accounts: "Konten werden erstellt..." + historical_data_range: "Zeitraum für Verlauf:" + subtitle: "Wählen Sie die passenden Kontotypen für Ihre importierten Konten" + sync_start_date_help: "Wählen Sie, wie weit die Transaktionshistorie synchronisiert werden soll. Maximal 3 Jahre Verlauf verfügbar." + sync_start_date_label: "Transaktionen synchronisieren ab:" + title: "Ihre Mercury-Konten einrichten" + complete_account_setup: + all_skipped: "Alle Konten wurden übersprungen. Es wurden keine Konten erstellt." + creation_failed: "Konten konnten nicht erstellt werden: %{error}" + no_accounts: "Keine Konten zum Einrichten." + success: "Erfolgreich %{count} Konto/Konten erstellt." + sync: + success: "Synchronisation gestartet" + update: + success: "Mercury-Verbindung aktualisiert" diff --git a/config/locales/views/onboardings/de.yml b/config/locales/views/onboardings/de.yml index 845e9a3fb..741805a63 100644 --- a/config/locales/views/onboardings/de.yml +++ b/config/locales/views/onboardings/de.yml @@ -16,8 +16,13 @@ de: first_name_placeholder: Vorname last_name: Nachname last_name_placeholder: Nachname + group_name: Gruppenname + group_name_placeholder: Gruppenname household_name: Haushaltsname household_name_placeholder: Haushaltsname + moniker_prompt: "%{product_name} wird genutzt mit …" + moniker_family: Familienmitglieder (nur Sie selbst oder mit Partner, Kindern usw.) + moniker_group: Personengruppe (Firma, Verein, Verband o. Ä.) country: Land submit: Weiter preferences: diff --git a/config/locales/views/other_assets/de.yml b/config/locales/views/other_assets/de.yml index 969e44f52..a6bc88674 100644 --- a/config/locales/views/other_assets/de.yml +++ b/config/locales/views/other_assets/de.yml @@ -3,5 +3,7 @@ de: other_assets: edit: edit: "%{account} bearbeiten" + balance_tracking_info: "Sonstige Vermögenswerte werden über manuelle Bewertungen („Neuer Saldo“) erfasst, nicht über Buchungen. Cashflow ändert den Kontostand nicht." new: title: Vermögenswertdetails eingeben + balance_tracking_info: "Sonstige Vermögenswerte werden über manuelle Bewertungen („Neuer Saldo“) erfasst, nicht über Buchungen. Cashflow ändert den Kontostand nicht." diff --git a/config/locales/views/pages/de.yml b/config/locales/views/pages/de.yml index 4df171506..a381eb2d6 100644 --- a/config/locales/views/pages/de.yml +++ b/config/locales/views/pages/de.yml @@ -3,10 +3,20 @@ de: pages: changelog: title: Was ist neu + privacy: + title: Datenschutzrichtlinie + heading: Datenschutzrichtlinie + placeholder: Der Inhalt der Datenschutzrichtlinie wird hier angezeigt. + terms: + title: Nutzungsbedingungen + heading: Nutzungsbedingungen + placeholder: Der Inhalt der Nutzungsbedingungen wird hier angezeigt. dashboard: welcome: "Willkommen zurück, %{name}" subtitle: "Hier siehst du, was in deinen Finanzen passiert." new: "Neu" + drag_to_reorder: "Bereich per Drag & Drop neu anordnen" + toggle_section: "Sichtbarkeit des Bereichs umschalten" net_worth_chart: data_not_available: Für den ausgewählten Zeitraum sind keine Daten verfügbar. title: Nettovermögen @@ -15,6 +25,7 @@ de: no_account_subtitle: Da noch keine Konten hinzugefügt wurden, gibt es keine Daten anzuzeigen. Füge dein erstes Konto hinzu, um Dashboard-Daten zu sehen. no_account_title: Noch keine Konten vorhanden balance_sheet: + title: "Bilanz" no_items: "Noch keine %{name}" add_accounts: "Füge deine %{name}-Konten hinzu, um eine vollständige Übersicht zu erhalten." cashflow_sankey: @@ -29,3 +40,19 @@ de: outflows_donut: title: "Ausgaben" total_outflows: "Gesamtausgaben" + categories: "Kategorien" + value: "Wert" + weight: "Gewicht" + investment_summary: + title: "Investitionen" + total_return: "Gesamtrendite" + holding: "Position" + weight: "Gewicht" + value: "Wert" + return: "Rendite" + period_activity: "%{period} Aktivität" + contributions: "Einlagen" + withdrawals: "Entnahmen" + trades: "Trades" + no_investments: "Keine Anlagekonten" + add_investment: "Füge ein Anlagekonto hinzu, um dein Portfolio zu verfolgen" diff --git a/config/locales/views/password_resets/de.yml b/config/locales/views/password_resets/de.yml index 01d03288d..4f7536949 100644 --- a/config/locales/views/password_resets/de.yml +++ b/config/locales/views/password_resets/de.yml @@ -1,6 +1,8 @@ --- de: password_resets: + disabled: Passwort-Zurücksetzen über Sure ist deaktiviert. Bitte setzen Sie Ihr Passwort über Ihren Identitätsanbieter zurück. + sso_only_user: Ihr Konto nutzt SSO zur Anmeldung. Bitte wenden Sie sich an Ihren Administrator, um Ihre Zugangsdaten zu verwalten. edit: title: Passwort zurücksetzen new: diff --git a/config/locales/views/pdf_import_mailer/de.yml b/config/locales/views/pdf_import_mailer/de.yml new file mode 100644 index 000000000..8df811a38 --- /dev/null +++ b/config/locales/views/pdf_import_mailer/de.yml @@ -0,0 +1,17 @@ +--- +de: + pdf_import_mailer: + next_steps: + greeting: "Hallo %{name}," + intro: "Wir haben die PDF-Datei analysiert, die Sie an %{product} hochgeladen haben." + document_type_label: Dokumenttyp + summary_label: KI-Zusammenfassung + transactions_note: Dieses Dokument scheint Buchungen zu enthalten. Sie können diese jetzt extrahieren und prüfen. + document_stored_note: Dieses Dokument wurde zu Ihrer Referenz gespeichert. Es kann für Kontext in zukünftigen KI-Gesprächen verwendet werden. + next_steps_label: Was als Nächstes? + next_steps_intro: "Sie haben mehrere Möglichkeiten:" + option_extract_transactions: Buchungen aus diesem Kontoauszug extrahieren + option_keep_reference: Dokument für Referenz in zukünftigen KI-Gesprächen behalten + option_delete: Import löschen, wenn Sie ihn nicht mehr benötigen + view_import_button: Importdetails anzeigen + footer_note: Dies ist eine automatische Nachricht. Bitte antworten Sie nicht direkt auf diese E-Mail. diff --git a/config/locales/views/plaid_items/de.yml b/config/locales/views/plaid_items/de.yml index 01371319d..033449fd2 100644 --- a/config/locales/views/plaid_items/de.yml +++ b/config/locales/views/plaid_items/de.yml @@ -21,3 +21,8 @@ de: status_never: Synchronisierung erforderlich syncing: Wird synchronisiert... update: Verbindung aktualisieren + select_existing_account: + title: "%{account_name} mit Plaid verknüpfen" + description: Wählen Sie ein Plaid-Konto zur Verknüpfung mit Ihrem bestehenden Konto + cancel: Abbrechen + link_account: Konto verknüpfen diff --git a/config/locales/views/recurring_transactions/de.yml b/config/locales/views/recurring_transactions/de.yml index 305e2461f..ddb4bc938 100644 --- a/config/locales/views/recurring_transactions/de.yml +++ b/config/locales/views/recurring_transactions/de.yml @@ -4,10 +4,18 @@ de: upcoming: Anstehende wiederkehrende Transaktionen projected: Prognostiziert recurring: Wiederkehrend + expected_today: "Erwartet heute" + expected_in: + one: "Erwartet in %{count} Tag" + other: "Erwartet in %{count} Tagen" expected_on: Erwartet am %{date} day_of_month: Tag %{day} des Monats identify_patterns: Muster erkennen cleanup_stale: Alte Einträge bereinigen + settings: + enable_label: Wiederkehrende Transaktionen aktivieren + enable_description: Erkenne automatisch wiederkehrende Transaktionsmuster und zeige anstehende prognostizierte Transaktionen an. + settings_updated: Einstellungen für wiederkehrende Transaktionen aktualisiert info: title: Automatische Mustererkennung manual_description: Du kannst Muster manuell erkennen oder alte wiederkehrende Transaktionen mit den obigen Schaltflächen bereinigen. @@ -21,6 +29,11 @@ de: marked_active: Wiederkehrende Transaktion als aktiv markiert deleted: Wiederkehrende Transaktion gelöscht confirm_delete: Bist du sicher, dass du diese wiederkehrende Transaktion löschen möchtest? + marked_as_recurring: Transaktion als wiederkehrend markiert + already_exists: Für dieses Muster existiert bereits eine manuelle wiederkehrende Transaktion + creation_failed: Wiederkehrende Transaktion konnte nicht erstellt werden. Bitte überprüfe die Transaktionsdetails und versuche es erneut. + unexpected_error: Beim Erstellen der wiederkehrenden Transaktion ist ein unerwarteter Fehler aufgetreten + amount_range: "Bereich: %{min} bis %{max}" empty: title: Keine wiederkehrenden Transaktionen gefunden description: Klicke auf „Muster erkennen“, um automatisch wiederkehrende Transaktionen aus deinem Verlauf zu erkennen. @@ -35,3 +48,5 @@ de: status: active: Aktiv inactive: Inaktiv + badges: + manual: Manuell diff --git a/config/locales/views/registrations/de.yml b/config/locales/views/registrations/de.yml index f0747f360..f60f7d6ba 100644 --- a/config/locales/views/registrations/de.yml +++ b/config/locales/views/registrations/de.yml @@ -17,6 +17,7 @@ de: invitation_message: "%{inviter} hat dich eingeladen als %{role} beizutreten" join_family_title: "%{family} beitreten" role_admin: Administrator + role_guest: Gast role_member: Mitglied submit: Konto erstellen title: Erstelle dein Konto diff --git a/config/locales/views/reports/de.yml b/config/locales/views/reports/de.yml index 66f68d5a2..9baf148c1 100644 --- a/config/locales/views/reports/de.yml +++ b/config/locales/views/reports/de.yml @@ -5,6 +5,9 @@ de: title: Berichte subtitle: Umfassende Einblicke in deine finanzielle Situation export: CSV exportieren + print_report: Bericht drucken + drag_to_reorder: "Bereich per Drag & Drop neu anordnen" + toggle_section: "Sichtbarkeit des Bereichs umschalten" periods: monthly: Monatlich quarterly: Vierteljährlich @@ -45,6 +48,7 @@ de: budgeted: Budgetiert remaining: Verbleibend over_by: Überschritten um + shared: geteilt suggested_daily: "%{amount} pro Tag empfohlen für %{days} verbleibende Tage" no_budgets: Keine Budgetkategorien für diesen Monat eingerichtet status: @@ -80,6 +84,49 @@ de: description: Erfasse deine Finanzen, indem du Transaktionen hinzufügst oder deine Konten verbindest, um umfassende Berichte zu sehen add_transaction: Transaktion hinzufügen add_account: Konto hinzufügen + net_worth: + title: Nettovermögen + current_net_worth: Aktuelles Nettovermögen + period_change: Änderung im Zeitraum + assets_vs_liabilities: Vermögen vs. Verbindlichkeiten + total_assets: Vermögen + total_liabilities: Verbindlichkeiten + no_assets: Keine Vermögenswerte + no_liabilities: Keine Verbindlichkeiten + investment_performance: + title: Anlageperformance + portfolio_value: Portfoliowert + total_return: Gesamtrendite + contributions: Einlagen im Zeitraum + withdrawals: Entnahmen im Zeitraum + top_holdings: Top-Positionen + holding: Position + weight: Gewicht + value: Wert + return: Rendite + accounts: Anlagekonten + gains_by_tax_treatment: Gewinne nach Steuerbehandlung + unrealized_gains: Nicht realisierte Gewinne + realized_gains: Realisierte Gewinne + total_gains: Gesamtgewinne + taxable_realized_note: Diese Gewinne können steuerpflichtig sein + no_data: "-" + view_details: Details anzeigen + holdings_count: + one: "%{count} Position" + other: "%{count} Positionen" + sells_count: + one: "%{count} Verkauf" + other: "%{count} Verkäufe" + holdings: Positionen + sell_trades: Verkaufstrades + and_more: "+%{count} weitere" + investment_flows: + title: Anlageflüsse + description: Verfolge Geldflüsse in und aus deinen Anlagekonten + contributions: Einlagen + withdrawals: Entnahmen + net_flow: Nettozufluss transactions_breakdown: title: Transaktionsübersicht no_transactions: Keine Transaktionen für den ausgewählten Zeitraum und Filter gefunden @@ -114,10 +161,14 @@ de: expense: Ausgaben income: Einnahmen uncategorized: Ohne Kategorie - transactions: Transaktionen + entries: + one: "%{count} Eintrag" + other: "%{count} Einträge" percentage: "% des Gesamtbetrags" pagination: - showing: Zeige %{count} Transaktionen + showing: + one: Zeige %{count} Eintrag + other: Zeige %{count} Einträge previous: Zurück next: Weiter google_sheets_instructions: @@ -136,3 +187,52 @@ de: open_sheets: Google Sheets öffnen go_to_api_keys: Zu den API-Schlüsseln close: Verstanden + print: + document_title: Finanzbericht + title: Finanzbericht + generated_on: "Erstellt am %{date}" + summary: + title: Zusammenfassung + income: Einnahmen + expenses: Ausgaben + net_savings: Nettoersparnis + budget: Budget + vs_prior: "%{percent}% vs. Vorperiode" + of_income: "%{percent}% der Einnahmen" + used: genutzt + net_worth: + title: Nettovermögen + current_balance: Aktueller Kontostand + this_period: dieser Zeitraum + assets: Vermögen + liabilities: Verbindlichkeiten + no_liabilities: Keine Verbindlichkeiten + trends: + title: Monatliche Trends + month: Monat + income: Einnahmen + expenses: Ausgaben + net: Netto + savings_rate: Sparquote + average: Durchschnitt + current_month_note: "* Aktueller Monat (Teildaten)" + investments: + title: Anlagen + portfolio_value: Portfoliowert + total_return: Gesamtrendite + contributions: Einlagen + withdrawals: Entnahmen + this_period: dieser Zeitraum + top_holdings: Top-Positionen + holding: Position + weight: Gewicht + value: Wert + return: Rendite + spending: + title: Ausgaben nach Kategorie + income: Einnahmen + expenses: Ausgaben + category: Kategorie + amount: Betrag + percent: "%" + more_categories: "+ %{count} weitere Kategorien" diff --git a/config/locales/views/rules/de.yml b/config/locales/views/rules/de.yml index cc57171b0..c6da99379 100644 --- a/config/locales/views/rules/de.yml +++ b/config/locales/views/rules/de.yml @@ -3,6 +3,21 @@ de: rules: no_action: Keine Aktion no_condition: Keine Bedingung + actions: + value_placeholder: Wert eingeben + apply_all: + button: Alle anwenden + confirm_title: Alle Regeln anwenden + confirm_message: Du bist dabei, %{count} Regeln anzuwenden, die %{transactions} eindeutige Transaktionen betreffen. Bitte bestätige, wenn du fortfahren möchtest. + confirm_button: Bestätigen und alle anwenden + success: Alle Regeln wurden zur Ausführung in die Warteschlange gestellt + ai_cost_title: KI-Kostenschätzung + ai_cost_message: Dies verwendet KI, um bis zu %{transactions} Transaktionen zu kategorisieren. + estimated_cost: "Geschätzte Kosten: ca. %{cost} $" + cost_unavailable_model: Kostenschätzung für Modell „%{model}“ nicht verfügbar. + cost_unavailable_no_provider: Kostenschätzung nicht verfügbar (kein LLM-Anbieter konfiguriert). + cost_warning: Es können Kosten entstehen. Bitte informiere dich beim Modellanbieter über die aktuellen Preise. + view_usage: Nutzungshistorie anzeigen recent_runs: title: Letzte Ausführungen description: Zeige die Ausführungsgeschichte deiner Regeln einschließlich Erfolgs-/Fehlerstatus und Transaktionsanzahlen. @@ -23,3 +38,15 @@ de: pending: Ausstehend success: Erfolgreich failed: Fehlgeschlagen + clear_ai_cache: + button: KI-Cache zurücksetzen + confirm_title: KI-Cache zurücksetzen? + confirm_body: Bist du sicher, dass du den KI-Cache zurücksetzen möchtest? Dadurch können KI-Regeln alle Transaktionen erneut verarbeiten. Dies kann zusätzliche API-Kosten verursachen. + confirm_button: Cache zurücksetzen + success: Der KI-Cache wird geleert. Das kann einen Moment dauern. + condition_filters: + transaction_type: + income: Einnahme + expense: Ausgabe + transfer: Überweisung + equal_to: Gleich diff --git a/config/locales/views/sessions/de.yml b/config/locales/views/sessions/de.yml index 1d307a7de..f472825b0 100644 --- a/config/locales/views/sessions/de.yml +++ b/config/locales/views/sessions/de.yml @@ -3,12 +3,19 @@ de: sessions: create: invalid_credentials: Ungültige E-Mail-Adresse oder falsches Passwort. + local_login_disabled: Anmeldung mit Passwort ist deaktiviert. Bitte nutze Single Sign-On. destroy: logout_successful: Du hast dich erfolgreich abgemeldet. + post_logout: + logout_successful: Du hast dich erfolgreich abgemeldet. openid_connect: + account_linked: "Konto erfolgreich mit %{provider} verknüpft" failed: Anmeldung über OpenID Connect fehlgeschlagen. failure: failed: Anmeldung fehlgeschlagen. + sso_provider_unavailable: "Der SSO-Anbieter ist derzeit nicht verfügbar. Bitte versuche es später erneut oder wende dich an einen Administrator." + sso_invalid_response: "Vom SSO-Anbieter wurde eine ungültige Antwort erhalten. Bitte versuche es erneut." + sso_failed: "Single Sign-On-Anmeldung fehlgeschlagen. Bitte versuche es erneut." new: email: E-Mail-Adresse email_placeholder: du@beispiel.de @@ -20,3 +27,7 @@ de: openid_connect: Mit OpenID Connect anmelden oidc: Mit OpenID Connect anmelden google_auth_connect: Mit Google anmelden + local_login_admin_only: Lokale Anmeldung ist auf Administratoren beschränkt. + no_auth_methods_enabled: Derzeit sind keine Anmeldemethoden aktiviert. Bitte wende dich an einen Administrator. + demo_banner_title: "Demo-Modus aktiv" + demo_banner_message: "Dies ist eine Demo-Umgebung. Anmeldedaten wurden zur Vereinfachung vorausgefüllt. Bitte gib keine echten oder sensiblen Daten ein." diff --git a/config/locales/views/settings/de.yml b/config/locales/views/settings/de.yml index 708145ff9..e8a64d97e 100644 --- a/config/locales/views/settings/de.yml +++ b/config/locales/views/settings/de.yml @@ -3,6 +3,7 @@ de: settings: payments: renewal: "Ihr Beitrag wird fortgesetzt am %{date}." + cancellation: "Ihr Beitrag endet am %{date}." settings: ai_prompts: show: @@ -42,6 +43,9 @@ de: theme_system: System theme_title: Design timezone: Zeitzone + month_start_day: Budgetmonat beginnt am + month_start_day_hint: Lege fest, wann dein Budgetmonat beginnt (z. B. Gehaltstag) + month_start_day_warning: Deine Budgets und MTD-Berechnungen verwenden diesen benutzerdefinierten Starttag anstelle des 1. jedes Monats. profiles: destroy: cannot_remove_self: Du kannst dich nicht selbst aus dem Konto entfernen. @@ -73,6 +77,9 @@ de: reset_account_with_sample_data_warning: Löscht alle vorhandenen Daten und lädt anschließend neue Beispieldaten, um eine vorbefüllte Umgebung zu erkunden. email: E-Mail first_name: Vorname + group_form_input_placeholder: Gruppennamen eingeben + group_form_label: Gruppenname + group_title: Gruppenmitglieder household_form_input_placeholder: Haushaltsnamen eingeben household_form_label: Haushaltsname household_subtitle: Lade Familienmitglieder, Partner oder andere Personen ein. Eingeladene können sich in deinen Haushalt einloggen und auf gemeinsame Konten zugreifen. @@ -90,6 +97,23 @@ de: securities: show: page_title: Sicherheit + mfa_title: Zwei-Faktor-Authentifizierung + mfa_description: Erhöhe die Sicherheit deines Kontos, indem du bei der Anmeldung einen Code von deiner Authenticator-App verlangst + enable_mfa: 2FA aktivieren + disable_mfa: 2FA deaktivieren + disable_mfa_confirm: Bist du sicher, dass du die Zwei-Faktor-Authentifizierung deaktivieren möchtest? + sso_title: Verbundene Konten + sso_subtitle: Verwalte deine Single Sign-On-Kontoverbindungen + sso_disconnect: Trennen + sso_last_used: Zuletzt verwendet + sso_never: Nie + sso_no_email: Keine E-Mail + sso_no_identities: Keine SSO-Konten verbunden + sso_connect_hint: Melde dich ab und mit einem SSO-Anbieter an, um ein Konto zu verbinden. + sso_confirm_title: Konto trennen? + sso_confirm_body: Bist du sicher, dass du dein %{provider}-Konto trennen möchtest? Du kannst es später erneut verbinden, indem du dich erneut mit diesem Anbieter anmeldest. + sso_confirm_button: Trennen + sso_warning_message: Dies ist deine einzige Anmeldemethode. Du solltest vor dem Trennen ein Passwort in den Sicherheitseinstellungen festlegen, sonst könntest du dich aus deinem Konto ausschließen. settings_nav: accounts_label: Konten advanced_section_title: Erweitert @@ -124,3 +148,27 @@ de: choose: Foto hochladen choose_label: (optional) change: Foto ändern + providers: + show: + coinbase_title: Coinbase + encryption_error: + title: Verschlüsselungskonfiguration erforderlich + message: Die Active-Record-Verschlüsselungsschlüssel sind nicht konfiguriert. Bitte stelle die Verschlüsselungszugangsdaten (active_record_encryption.primary_key, active_record_encryption.deterministic_key und active_record_encryption.key_derivation_salt) in deinen Rails-Zugangsdaten oder Umgebungsvariablen ein, bevor du Sync-Anbieter verwendest. + coinbase_panel: + setup_instructions: "So verbindest du Coinbase:" + step1_html: Gehe zu Coinbase API-Einstellungen + step2: Erstelle einen neuen API-Schlüssel mit Leseberechtigung (Konten anzeigen, Transaktionen anzeigen) + step3: Kopiere deinen API-Schlüssel und dein API-Geheimnis und füge sie unten ein + api_key_label: API-Schlüssel + api_key_placeholder: Gib deinen Coinbase-API-Schlüssel ein + api_secret_label: API-Geheimnis + api_secret_placeholder: Gib dein Coinbase-API-Geheimnis ein + connect_button: Coinbase verbinden + syncing: Wird synchronisiert… + sync: Synchronisieren + disconnect_confirm: Bist du sicher, dass du diese Coinbase-Verbindung trennen möchtest? Deine synchronisierten Konten werden zu manuellen Konten. + status_connected: Coinbase ist verbunden und synchronisiert deine Krypto-Bestände. + status_not_connected: Nicht verbunden. Gib deine API-Zugangsdaten oben ein, um zu starten. + enable_banking_panel: + callback_url_instruction: "Für die Callback-URL, verwende %{callback_url}." + connection_error: Verbindungsfehler diff --git a/config/locales/views/settings/hostings/de.yml b/config/locales/views/settings/hostings/de.yml index cef56500f..322560e6c 100644 --- a/config/locales/views/settings/hostings/de.yml +++ b/config/locales/views/settings/hostings/de.yml @@ -16,6 +16,7 @@ de: show: general: Allgemeine Einstellungen financial_data_providers: Finanzdatenanbieter + sync_settings: Synchronisierungseinstellungen invites: Einladungscodes title: Self-Hosting danger_zone: Gefahrenbereich @@ -24,11 +25,22 @@ de: confirm_clear_cache: title: Daten-Cache leeren? body: Bist du sicher, dass du den Daten-Cache leeren möchtest? Dadurch werden alle Wechselkurse, Wertpapierpreise, Kontostände und andere Daten entfernt. Diese Aktion kann nicht rückgängig gemacht werden. + provider_selection: + title: Anbieterauswahl + description: Wähle, welcher Dienst für Wechselkurse und Wertpapierpreise verwendet werden soll. Yahoo Finance ist kostenlos und benötigt keinen API-Schlüssel. Twelve Data erfordert einen kostenlosen API-Schlüssel, bietet aber möglicherweise eine bessere Datenabdeckung. + exchange_rate_provider_label: Wechselkursanbieter + securities_provider_label: Wertpapiere (Aktienkurse) Anbieter + env_configured_message: Die Anbieterauswahl ist deaktiviert, weil Umgebungsvariablen (EXCHANGE_RATE_PROVIDER oder SECURITIES_PROVIDER) gesetzt sind. Um die Auswahl hier zu aktivieren, entferne diese Umgebungsvariablen aus deiner Konfiguration. + providers: + twelve_data: Twelve Data + yahoo_finance: Yahoo Finance brand_fetch_settings: description: Gib die von Brand Fetch bereitgestellte Client-ID ein. label: Client-ID placeholder: Gib hier deine Client-ID ein title: Brand Fetch Einstellungen + high_res_label: Hochauflösende Logos aktivieren + high_res_description: Wenn aktiviert, werden Logos in 120x120 statt 40x40 abgerufen. Das liefert schärfere Bilder auf hochauflösenden Displays. openai_settings: description: Gib dein Zugriffstoken ein und konfiguriere optional einen benutzerdefinierten, OpenAI-kompatiblen Anbieter. env_configured_message: Erfolgreich über Umgebungsvariablen konfiguriert. @@ -38,6 +50,12 @@ de: uri_base_placeholder: "https://api.openai.com/v1 (Standard)" model_label: Modell (optional) model_placeholder: "gpt-4.1 (Standard)" + json_mode_label: JSON-Modus + json_mode_auto: Auto (empfohlen) + json_mode_strict: Streng (am besten für Denk-Modelle) + json_mode_none: Keiner (am besten für Standard-Modelle) + json_mode_json_object: JSON-Objekt + json_mode_help: "Der strenge Modus funktioniert am besten mit Denk-Modellen (qwen-thinking, deepseek-reasoner). Der Modus Keiner funktioniert am besten mit Standard-Modellen (llama, mistral, gpt-oss)." title: OpenAI yahoo_finance_settings: title: Yahoo Finance @@ -53,11 +71,25 @@ de: label: API-Schlüssel placeholder: Gib hier deinen API-Schlüssel ein plan: "%{plan}-Tarif" + plan_upgrade_warning_title: Einige Ticker erfordern einen kostenpflichtigen Tarif + plan_upgrade_warning_description: Die folgenden Ticker in deinem Portfolio können mit deinem aktuellen Twelve-Data-Tarif keine Kurse synchronisieren. + requires_plan: erfordert %{plan}-Tarif + view_pricing: Twelve-Data-Preise anzeigen title: Twelve Data update: failure: Ungültiger Einstellungswert success: Einstellungen aktualisiert invalid_onboarding_state: Ungültiger Onboarding-Status + invalid_sync_time: Ungültiges Synchronisierungszeitformat. Bitte verwende das Format HH:MM (z. B. 02:30). + scheduler_sync_failed: Einstellungen gespeichert, aber die Synchronisierungsplanung konnte nicht aktualisiert werden. Bitte versuche es erneut oder prüfe die Server-Logs. clear_cache: cache_cleared: Daten-Cache wurde geleert. Dies kann einige Augenblicke dauern. not_authorized: Du bist nicht berechtigt, diese Aktion auszuführen. + sync_settings: + auto_sync_label: Automatische Synchronisierung aktivieren + auto_sync_description: Wenn aktiviert, werden alle Konten täglich zur angegebenen Zeit automatisch synchronisiert. + auto_sync_time_label: Synchronisierungszeit (HH:MM) + auto_sync_time_description: Lege die Tageszeit fest, zu der die automatische Synchronisierung erfolgen soll. + include_pending_label: Ausstehende Transaktionen einbeziehen + include_pending_description: Wenn aktiviert, werden ausstehende (noch nicht gebuchte) Transaktionen importiert und bei Buchung automatisch abgeglichen. Deaktivieren, wenn deine Bank unzuverlässige Ausstehend-Daten liefert. + env_configured_message: Diese Einstellung ist deaktiviert, weil eine Anbieter-Umgebungsvariable (SIMPLEFIN_INCLUDE_PENDING oder PLAID_INCLUDE_PENDING) gesetzt ist. Entferne sie, um diese Einstellung zu aktivieren. diff --git a/config/locales/views/settings/sso_identities/de.yml b/config/locales/views/settings/sso_identities/de.yml new file mode 100644 index 000000000..2df58d70a --- /dev/null +++ b/config/locales/views/settings/sso_identities/de.yml @@ -0,0 +1,7 @@ +--- +de: + settings: + sso_identities: + destroy: + cannot_unlink_last: Die letzte Identität kann nicht getrennt werden + success: Erfolg diff --git a/config/locales/views/simplefin_items/de.yml b/config/locales/views/simplefin_items/de.yml index 9e6201489..105640fb8 100644 --- a/config/locales/views/simplefin_items/de.yml +++ b/config/locales/views/simplefin_items/de.yml @@ -30,8 +30,27 @@ de: label: "SimpleFin-Setup-Token:" placeholder: "Füge hier dein SimpleFin-Setup-Token ein..." help_text: "Das Token sollte eine lange Zeichenfolge aus Buchstaben und Zahlen sein." + setup_accounts: + stale_accounts: + title: "Konten nicht mehr in SimpleFIN" + description: "Diese Konten existieren in deiner Datenbank, werden aber nicht mehr von SimpleFIN bereitgestellt. Das kann passieren, wenn sich Kontokonfigurationen beim Anbieter ändern." + action_prompt: "Was möchtest du tun?" + action_delete: "Konto und alle Transaktionen löschen" + action_move: "Transaktionen verschieben nach:" + action_skip: "Vorerst überspringen" + transaction_count: + one: "%{count} Transaktion" + other: "%{count} Transaktionen" complete_account_setup: - success: SimpleFin-Konten wurden erfolgreich eingerichtet! Deine Transaktionen und Positionen werden im Hintergrund importiert. + all_skipped: "Alle Konten wurden übersprungen. Es wurden keine Konten erstellt." + no_accounts: "Keine Konten zum Einrichten." + success: + one: "Ein SimpleFIN-Konto erfolgreich erstellt! Deine Transaktionen und Positionen werden im Hintergrund importiert." + other: "%{count} SimpleFIN-Konten erfolgreich erstellt! Deine Transaktionen und Positionen werden im Hintergrund importiert." + stale_accounts_processed: "Veraltete Konten: %{deleted} gelöscht, %{moved} verschoben." + stale_accounts_errors: + one: "%{count} Aktion für veraltetes Konto fehlgeschlagen. Details in den Logs prüfen." + other: "%{count} Aktionen für veraltete Konten fehlgeschlagen. Details in den Logs prüfen." simplefin_item: add_new: Neue Verbindung hinzufügen confirm_accept: Verbindung löschen @@ -46,8 +65,43 @@ de: setup_needed: Neue Konten bereit zur Einrichtung setup_description: Wähle die Kontotypen für deine neu importierten SimpleFin-Konten aus. setup_action: Neue Konten einrichten + setup_accounts_menu: Konten einrichten + more_accounts_available: + one: "%{count} weiteres Konto kann eingerichtet werden" + other: "%{count} weitere Konten können eingerichtet werden" + accounts_skipped_tooltip: "Einige Konten wurden aufgrund von Fehlern bei der Synchronisierung übersprungen" + accounts_skipped_label: "Übersprungen: %{count}" + rate_limited_ago: "Ratenbegrenzung (vor %{time})" + rate_limited_recently: "Kürzlich ratenbegrenzt" status: Zuletzt vor %{timestamp} synchronisiert status_never: Noch nie synchronisiert status_with_summary: "Zuletzt vor %{timestamp} synchronisiert • %{summary}" syncing: Wird synchronisiert... update: Verbindung aktualisieren + stale_pending_note: "(von Budgets ausgeschlossen)" + stale_pending_accounts: "in: %{accounts}" + reconciled_details_note: "(Details siehe Synchronisierungszusammenfassung)" + duplicate_accounts_skipped: "Einige Konten wurden als Duplikate übersprungen — nutze „Bestehendes Konto verknüpfen“, um sie zusammenzuführen." + select_existing_account: + title: "%{account_name} mit SimpleFIN verknüpfen" + description: Wähle ein SimpleFIN-Konto aus, das mit deinem bestehenden Konto verknüpft werden soll + cancel: Abbrechen + link_account: Konto verknüpfen + no_accounts_found: "Keine SimpleFIN-Konten für diesen %{moniker} gefunden." + wait_for_sync: Wenn du gerade verbunden oder synchronisiert hast, versuche es nach Abschluss der Synchronisierung erneut. + unlink_to_move: Um eine Verknüpfung zu verschieben, trenne sie zuerst im Aktionsmenü des Kontos. + all_accounts_already_linked: Alle SimpleFIN-Konten scheinen bereits verknüpft zu sein. + currently_linked_to: "Aktuell verknüpft mit: %{account_name}" + link_existing_account: + success: Konto erfolgreich mit SimpleFIN verknüpft + errors: + only_manual: Nur manuelle Konten können verknüpft werden + invalid_simplefin_account: Ungültiges SimpleFIN-Konto ausgewählt + reconciled_status: + message: + one: "%{count} doppelte ausstehende Transaktion abgeglichen" + other: "%{count} doppelte ausstehende Transaktionen abgeglichen" + stale_pending_status: + message: + one: "%{count} ausstehende Transaktion älter als %{days} Tage" + other: "%{count} ausstehende Transaktionen älter als %{days} Tage" diff --git a/config/locales/views/snaptrade_items/de.yml b/config/locales/views/snaptrade_items/de.yml new file mode 100644 index 000000000..d50ae7bb6 --- /dev/null +++ b/config/locales/views/snaptrade_items/de.yml @@ -0,0 +1,188 @@ +--- +de: + snaptrade_items: + default_name: "SnapTrade-Verbindung" + create: + success: "SnapTrade wurde erfolgreich eingerichtet." + update: + success: "SnapTrade-Konfiguration wurde erfolgreich aktualisiert." + destroy: + success: "SnapTrade-Verbindung wurde zur Löschung vorgemerkt." + connect: + decryption_failed: "SnapTrade-Zugangsdaten konnten nicht gelesen werden. Bitte löschen Sie die Verbindung und legen Sie sie neu an." + connection_failed: "Verbindung zu SnapTrade fehlgeschlagen: %{message}" + callback: + success: "Broker verbunden! Bitte wählen Sie die zu verknüpfenden Konten." + no_item: "SnapTrade-Konfiguration nicht gefunden." + complete_account_setup: + success: + one: "%{count} Konto erfolgreich verknüpft." + other: "%{count} Konten erfolgreich verknüpft." + partial_success: "%{linked} Konto/Konten verknüpft. %{failed} Verknüpfung(en) fehlgeschlagen." + link_failed: "Konten konnten nicht verknüpft werden: %{errors}" + no_accounts: "Es wurden keine Konten zur Verknüpfung ausgewählt." + preload_accounts: + not_configured: "SnapTrade ist nicht konfiguriert." + select_accounts: + not_configured: "SnapTrade ist nicht konfiguriert." + select_existing_account: + not_found: "Konto oder SnapTrade-Konfiguration nicht gefunden." + title: "Mit SnapTrade-Konto verknüpfen" + header: "Bestehendes Konto verknüpfen" + subtitle: "Wählen Sie ein SnapTrade-Konto zur Verknüpfung" + no_accounts: "Keine unverknüpften SnapTrade-Konten verfügbar." + connect_hint: "Möglicherweise müssen Sie zuerst einen Broker verbinden." + settings_link: "Zu den Provider-Einstellungen" + linking_to: "Verknüpfe mit Konto:" + balance_label: "Saldo:" + link_button: "Verknüpfen" + cancel_button: "Abbrechen" + link_existing_account: + success: "Erfolgreich mit SnapTrade-Konto verknüpft." + failed: "Verknüpfung fehlgeschlagen: %{message}" + not_found: "Konto nicht gefunden." + connections: + unknown_brokerage: "Unbekannter Broker" + delete_connection: + success: "Verbindung erfolgreich gelöscht. Ein Platz ist frei." + failed: "Löschen der Verbindung fehlgeschlagen: %{message}" + missing_authorization_id: "Autorisierungs-ID fehlt" + api_deletion_failed: "Verbindung konnte bei SnapTrade nicht gelöscht werden – Zugangsdaten fehlen. Die Verbindung kann in Ihrem SnapTrade-Konto noch existieren." + delete_orphaned_user: + success: "Verwaiste Registrierung wurde erfolgreich gelöscht." + failed: "Löschen der verwaisten Registrierung fehlgeschlagen." + setup_accounts: + title: "SnapTrade-Konten einrichten" + header: "SnapTrade-Konten einrichten" + subtitle: "Wählen Sie die zu verknüpfenden Broker-Konten" + syncing: "Ihre Konten werden abgerufen..." + loading: "Konten werden von SnapTrade geladen..." + loading_hint: "Klicken Sie auf Aktualisieren, um nach Konten zu suchen." + refresh: "Aktualisieren" + info_title: "SnapTrade-Anlagedaten" + info_holdings: "Bestände mit aktuellen Preisen und Mengen" + info_cost_basis: "Einstandskosten pro Position (falls verfügbar)" + info_activities: "Handelshistorie mit Aktivitätslabels (Kaufen, Verkaufen, Dividende usw.)" + info_history: "Bis zu 3 Jahre Buchungshistorie" + free_tier_note: "SnapTrade Free Tier erlaubt 5 Broker-Verbindungen. Nutzung im SnapTrade-Dashboard prüfen." + no_accounts_title: "Keine Konten gefunden" + no_accounts_message: "Es wurden keine Broker-Konten gefunden. Das kann passieren, wenn Sie die Verbindung abgebrochen haben oder Ihr Broker nicht unterstützt wird." + try_again: "Broker verbinden" + back_to_settings: "Zurück zu Einstellungen" + available_accounts: "Verfügbare Konten" + balance_label: "Saldo:" + account_number: "Konto:" + create_button: "Ausgewählte Konten anlegen" + cancel_button: "Abbrechen" + creating: "Konten werden angelegt..." + done_button: "Fertig" + or_link_existing: "Oder mit einem bestehenden Konto verknüpfen statt neu anlegen:" + select_account: "Konto auswählen..." + link_button: "Verknüpfen" + linked_accounts: "Bereits verknüpft" + linked_to: "Verknüpft mit:" + snaptrade_item: + accounts_need_setup: + one: "%{count} Konto muss eingerichtet werden" + other: "%{count} Konten müssen eingerichtet werden" + deletion_in_progress: "Löschung läuft..." + syncing: "Synchronisiere..." + requires_update: "Verbindung muss aktualisiert werden" + error: "Sync-Fehler" + status: "Zuletzt synchronisiert vor %{timestamp} – %{summary}" + status_never: "Noch nie synchronisiert" + reconnect: "Erneut verbinden" + connect_brokerage: "Broker verbinden" + add_another_brokerage: "Weiteren Broker verbinden" + delete: "Löschen" + setup_needed: "Konten müssen eingerichtet werden" + setup_description: "Einige Konten von SnapTrade müssen Sure-Konten zugeordnet werden." + setup_action: "Konten einrichten" + setup_accounts_menu: "Konten einrichten" + manage_connections: "Verbindungen verwalten" + more_accounts_available: + one: "%{count} weiteres Konto kann eingerichtet werden" + other: "%{count} weitere Konten können eingerichtet werden" + no_accounts_title: "Keine Konten gefunden" + no_accounts_description: "Verbinden Sie einen Broker, um Ihre Anlagekonten zu importieren." + + providers: + snaptrade: + name: "SnapTrade" + connection_description: "Verbinden Sie Ihren Broker über SnapTrade (25+ Broker unterstützt)" + description: "SnapTrade verbindet mit 25+ großen Brokern (Fidelity, Vanguard, Schwab, Robinhood usw.) und liefert vollständige Handelshistorie mit Aktivitätslabels und Einstandskosten." + setup_title: "Einrichtungsanleitung:" + step_1_html: "Konto erstellen unter dashboard.snaptrade.com" + step_2: "Client ID und Consumer Key aus dem Dashboard kopieren" + step_3: "Zugangsdaten unten eintragen und auf Speichern klicken" + step_4: "Auf die Konten-Seite gehen und „Weiteren Broker verbinden“ nutzen, um Ihre Anlagekonten zu verknüpfen" + free_tier_warning: "Free Tier enthält 5 Broker-Verbindungen. Weitere erfordern einen kostenpflichtigen SnapTrade-Plan." + client_id_label: "Client ID" + client_id_placeholder: "SnapTrade Client ID eingeben" + client_id_update_placeholder: "Neue Client ID zum Aktualisieren eingeben" + consumer_key_label: "Consumer Key" + consumer_key_placeholder: "SnapTrade Consumer Key eingeben" + consumer_key_update_placeholder: "Neuen Consumer Key zum Aktualisieren eingeben" + save_button: "Konfiguration speichern" + update_button: "Konfiguration aktualisieren" + status_connected: + one: "%{count} Konto von SnapTrade" + other: "%{count} Konten von SnapTrade" + needs_setup: + one: "%{count} muss eingerichtet werden" + other: "%{count} müssen eingerichtet werden" + status_ready: "Bereit zum Verbinden von Brokern" + status_needs_registration: "Zugangsdaten gespeichert. Gehen Sie zur Konten-Seite, um Broker zu verbinden." + status_not_configured: "Nicht konfiguriert" + setup_accounts_button: "Konten einrichten" + connect_button: "Broker verbinden" + connected_brokerages: "Verbunden:" + manage_connections: "Verbindungen verwalten" + connection_limit_info: "SnapTrade Free Tier erlaubt 5 Broker-Verbindungen. Löschen Sie ungenutzte Verbindungen, um Plätze freizugeben." + loading_connections: "Verbindungen werden geladen..." + connections_error: "Verbindungen konnten nicht geladen werden: %{message}" + accounts_count: + one: "%{count} Konto" + other: "%{count} Konten" + orphaned_connection: "Verwaiste Verbindung (lokal nicht synchronisiert)" + needs_linking: "muss verknüpft werden" + no_connections: "Keine Broker-Verbindungen gefunden." + delete_connection: "Löschen" + delete_connection_title: "Broker-Verbindung löschen?" + delete_connection_body: "Die Verbindung zu %{brokerage} wird dauerhaft von SnapTrade entfernt. Alle Konten dieses Brokers werden getrennt. Zum erneuten Sync müssen Sie sich wieder verbinden." + delete_connection_confirm: "Verbindung löschen" + orphaned_users_title: + one: "%{count} verwaiste Registrierung" + other: "%{count} verwaiste Registrierungen" + orphaned_users_description: "Das sind frühere SnapTrade-Registrierungen, die Ihre Verbindungsplätze belegen. Löschen Sie sie, um Plätze freizugeben." + orphaned_user: "Verwaiste Registrierung" + delete_orphaned_user: "Löschen" + delete_orphaned_user_title: "Verwaiste Registrierung löschen?" + delete_orphaned_user_body: "Diese verwaiste SnapTrade-Registrierung und alle zugehörigen Broker-Verbindungen werden dauerhaft gelöscht, Verbindungsplätze werden frei." + delete_orphaned_user_confirm: "Registrierung löschen" + + snaptrade_item: + sync_status: + no_accounts: "Keine Konten gefunden" + synced: + one: "%{count} Konto synchronisiert" + other: "%{count} Konten synchronisiert" + synced_with_setup: "%{linked} synchronisiert, %{unlinked} müssen eingerichtet werden" + institution_summary: + none: "Keine Institute verbunden" + count: + one: "%{count} Institut" + other: "%{count} Institute" + brokerage_summary: + none: "Keine Broker verbunden" + count: + one: "%{count} Broker" + other: "%{count} Broker" + syncer: + discovering: "Konten werden ermittelt..." + importing: "Konten werden von SnapTrade importiert..." + processing: "Bestände und Aktivitäten werden verarbeitet..." + calculating: "Salden werden berechnet..." + checking_config: "Kontokonfiguration wird geprüft..." + needs_setup: "%{count} Konten müssen eingerichtet werden..." + activities_fetching_async: "Aktivitäten werden im Hintergrund geladen. Bei neuen Broker-Verbindungen kann das bis zu einer Minute dauern." diff --git a/config/locales/views/trades/de.yml b/config/locales/views/trades/de.yml index 51417c1f9..8fadd71af 100644 --- a/config/locales/views/trades/de.yml +++ b/config/locales/views/trades/de.yml @@ -24,6 +24,8 @@ de: title: Neue Transaktion show: additional: Zusätzlich + buy: Kaufen + category_label: Kategorie cost_per_share_label: Kosten pro Anteil date_label: Datum delete: Löschen @@ -32,7 +34,10 @@ de: details: Details exclude_subtitle: Dieser Trade wird nicht in Berichten und Berechnungen berücksichtigt exclude_title: Von Analysen ausschließen + no_category: Keine Kategorie note_label: Notiz note_placeholder: Füge hier zusätzliche Notizen hinzu … quantity_label: Menge + sell: Verkaufen settings: Einstellungen + type_label: Typ diff --git a/config/locales/views/transactions/de.yml b/config/locales/views/transactions/de.yml index 2674005b4..a8e280497 100644 --- a/config/locales/views/transactions/de.yml +++ b/config/locales/views/transactions/de.yml @@ -1,6 +1,7 @@ --- de: transactions: + unknown_name: Unbekannte Transaktion form: account: Konto account_prompt: Konto auswählen @@ -29,6 +30,15 @@ de: delete_subtitle: Diese Aktion löscht die Transaktion dauerhaft, beeinflusst deine bisherigen Kontostände und kann nicht rückgängig gemacht werden. delete_title: Transaktion löschen details: Details + exclude: Ausschließen + exclude_description: Ausgeschlossene Transaktionen werden aus Budgetberechnungen und Berichten entfernt. + activity_type: Aktivitätsart + activity_type_description: Art der Anlageaktivität (Kauf, Verkauf, Dividende usw.). Wird automatisch erkannt oder manuell gesetzt. + one_time_title: Einmalige %{type} + one_time_description: Einmalige Transaktionen werden aus bestimmten Budgetberechnungen und Berichten ausgeschlossen, damit du das Wichtige besser erkennst. + convert_to_trade_title: In Wertpapier-Trade umwandeln + convert_to_trade_description: Diese Transaktion in einen Kauf- oder Verkaufstrade mit Wertpapierdetails für die Portfolioverfolgung umwandeln. + convert_to_trade_button: In Trade umwandeln merchant_label: Händler name_label: Name nature: Typ @@ -38,7 +48,45 @@ de: overview: Übersicht settings: Einstellungen tags_label: Tags + tab_transactions: Transaktionen + tab_upcoming: Anstehend uncategorized: (ohne Kategorie) + activity_labels: + buy: Kaufen + sell: Verkaufen + sweep_in: Sweep In + sweep_out: Sweep Out + dividend: Dividende + reinvestment: Reinvestition + interest: Zinsen + fee: Gebühr + transfer: Überweisung + contribution: Einlage + withdrawal: Entnahme + exchange: Umtausch + other: Sonstige + mark_recurring: Als wiederkehrend markieren + mark_recurring_subtitle: Als wiederkehrende Transaktion verfolgen. Die Betragsabweichung wird automatisch aus den letzten 6 Monaten ähnlicher Transaktionen berechnet. + mark_recurring_title: Wiederkehrende Transaktion + potential_duplicate_title: Mögliches Duplikat erkannt + potential_duplicate_description: Diese ausstehende Transaktion könnte mit der unten stehenden gebuchten Transaktion übereinstimmen. Wenn ja, führe sie zusammen, um Doppelzählung zu vermeiden. + merge_duplicate: Ja, zusammenführen + keep_both: Nein, beide behalten + transaction: + pending: Ausstehend + pending_tooltip: Ausstehende Transaktion — kann sich bei Buchung ändern + linked_with_plaid: Mit Plaid verknüpft + activity_type_tooltip: Art der Anlageaktivität + possible_duplicate: Duplikat? + potential_duplicate_tooltip: Dies könnte ein Duplikat einer anderen Transaktion sein + review_recommended: Prüfen + review_recommended_tooltip: Große Betragsabweichung — Prüfung empfohlen, ob es sich um ein Duplikat handelt + merge_duplicate: + success: Transaktionen erfolgreich zusammengeführt + failure: Transaktionen konnten nicht zusammengeführt werden + dismiss_duplicate: + success: Als getrennte Transaktionen beibehalten + failure: Duplikatshinweis konnte nicht verworfen werden header: edit_categories: Kategorien bearbeiten edit_imports: Importe bearbeiten @@ -48,6 +96,65 @@ de: index: transaction: Transaktion transactions: Transaktionen + import: Import + list: + drag_drop_title: CSV zum Importieren ablegen + drag_drop_subtitle: Transaktionen direkt hochladen + transaction: Transaktion + transactions: Transaktionen + toggle_recurring_section: Anstehende wiederkehrende Transaktionen ein-/ausblenden + search: + filters: + account: Konto + date: Datum + type: Typ + status: Status + amount: Betrag + category: Kategorie + tag: Tag + merchant: Händler + convert_to_trade: + title: In Wertpapier-Trade umwandeln + description: Diese Transaktion in einen Trade mit Wertpapierdetails umwandeln + date_label: "Datum:" + account_label: "Konto:" + amount_label: "Betrag:" + security_label: Wertpapier + security_prompt: Wertpapier auswählen… + security_custom: "+ Eigenes Tickersymbol eingeben" + security_not_listed_hint: Dein Wertpapier nicht dabei? Wähle unten in der Liste „Eigenes Tickersymbol eingeben“. + ticker_placeholder: AAPL + ticker_hint: Gib das Aktien-/ETF-Tickersymbol ein (z. B. AAPL, MSFT) + ticker_search_placeholder: Nach Ticker suchen… + ticker_search_hint: Nach Tickersymbol oder Firmenname suchen oder eigenes Tickersymbol eingeben + price_mismatch_title: Preis weicht möglicherweise ab + price_mismatch_message: "Dein Preis (%{entered_price}/Aktie) weicht deutlich vom aktuellen Marktpreis von %{ticker} (%{market_price}) ab. Wenn das falsch erscheint, hast du vielleicht das falsche Wertpapier gewählt — versuche „Eigenes Tickersymbol eingeben“, um das richtige anzugeben." + quantity_label: Menge (Aktien) + quantity_placeholder: z. B. 20 + quantity_hint: Anzahl der gehandelten Aktien + price_label: Preis pro Aktie + price_placeholder: z. B. 52,15 + price_hint: Preis pro Aktie (%{currency}) + qty_or_price_hint: Gib mindestens Menge ODER Preis ein. Der andere Wert wird aus dem Transaktionsbetrag (%{amount}) berechnet. + trade_type_label: Trade-Typ + trade_type_hint: Kauf oder Verkauf von Wertpapieranteilen + exchange_label: Börse (optional) + exchange_placeholder: XNAS + exchange_hint: Leer lassen für automatische Erkennung + cancel: Abbrechen + submit: In Trade umwandeln + success: Transaktion in Trade umgewandelt + conversion_note: "Umgewandelt aus Transaktion: %{original_name} (%{original_date})" + errors: + not_investment_account: Nur Transaktionen in Anlagekonten können in Trades umgewandelt werden + already_converted: Diese Transaktion wurde bereits umgewandelt oder ausgeschlossen + enter_ticker: Bitte gib ein Tickersymbol ein + security_not_found: Das gewählte Wertpapier existiert nicht mehr. Bitte wähle ein anderes. + select_security: Bitte wähle ein Wertpapier aus oder gib eines ein + enter_qty_or_price: Bitte gib entweder Menge oder Preis pro Aktie ein. Der andere Wert wird aus dem Transaktionsbetrag berechnet. + invalid_qty_or_price: Ungültige Menge oder Preis. Bitte gib gültige positive Werte ein. + conversion_failed: "Transaktion konnte nicht umgewandelt werden: %{error}" + unexpected_error: "Unerwarteter Fehler bei der Umwandlung: %{error}" searches: filters: amount_filter: @@ -61,10 +168,15 @@ de: on_or_after: am oder nach %{date} on_or_before: am oder vor %{date} transfer: Überweisung + confirmed: Bestätigt + pending: Ausstehend type_filter: expense: Ausgabe income: Einnahme transfer: Überweisung + status_filter: + confirmed: Bestätigt + pending: Ausstehend menu: account_filter: Konto amount_filter: Betrag @@ -74,6 +186,7 @@ de: clear_filters: Filter löschen date_filter: Datum merchant_filter: Händler + status_filter: Status tag_filter: Tag type_filter: Typ search: From c47edaa51e977f54e4f22b1abc507dc0202c9438 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Fri, 6 Mar 2026 23:58:48 +0000 Subject: [PATCH 42/75] Indexa Capital y very much alpha --- app/views/settings/providers/show.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/settings/providers/show.html.erb b/app/views/settings/providers/show.html.erb index 3e5603e39..9276e5618 100644 --- a/app/views/settings/providers/show.html.erb +++ b/app/views/settings/providers/show.html.erb @@ -73,7 +73,7 @@ <% end %> - <%= settings_section title: "Indexa Capital", collapsible: true, open: false do %> + <%= settings_section title: "Indexa Capital (alpha)", collapsible: true, open: false do %> <%= render "settings/providers/indexa_capital_panel" %> From f96e58b9bca9bf7c53891233febe9ffa70c0ff8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Sat, 7 Mar 2026 01:35:47 +0100 Subject: [PATCH 43/75] Enhance logging in `search_family_files.rb` for vector store debugging (#1033) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Enhance logging in search_family_files.rb Added logging for search parameters and results in SearchFamilyFiles. Signed-off-by: Juan José Mata * Log level should be `debug` not `warn` here * Unguarded `trace&.update` patterns * API concernts from CodeRabbit --------- Signed-off-by: Juan José Mata --- .../assistant/function/search_family_files.rb | 91 +++++++++++++++---- 1 file changed, 71 insertions(+), 20 deletions(-) diff --git a/app/models/assistant/function/search_family_files.rb b/app/models/assistant/function/search_family_files.rb index 2c0e5bf37..c9c917f0a 100644 --- a/app/models/assistant/function/search_family_files.rb +++ b/app/models/assistant/function/search_family_files.rb @@ -53,7 +53,10 @@ class Assistant::Function::SearchFamilyFiles < Assistant::Function query = params["query"] max_results = (params["max_results"] || 10).to_i.clamp(1, 20) + Rails.logger.debug("[SearchFamilyFiles] query=#{query.inspect} max_results=#{max_results} family_id=#{family.id}") + unless family.vector_store_id.present? + Rails.logger.debug("[SearchFamilyFiles] family #{family.id} has no vector_store_id") return { success: false, error: "no_documents", @@ -64,6 +67,7 @@ class Assistant::Function::SearchFamilyFiles < Assistant::Function adapter = VectorStore.adapter unless adapter + Rails.logger.debug("[SearchFamilyFiles] no VectorStore adapter configured") return { success: false, error: "provider_not_configured", @@ -71,48 +75,95 @@ class Assistant::Function::SearchFamilyFiles < Assistant::Function } end + store_id = family.vector_store_id + Rails.logger.debug("[SearchFamilyFiles] searching store_id=#{store_id} via #{adapter.class.name}") + + trace = create_langfuse_trace( + name: "search_family_files", + input: { query: query, max_results: max_results, store_id: store_id } + ) + response = adapter.search( - store_id: family.vector_store_id, + store_id: store_id, query: query, max_results: max_results ) unless response.success? + error_msg = response.error&.message + Rails.logger.debug("[SearchFamilyFiles] search failed: #{error_msg}") + begin + langfuse_client&.trace(id: trace.id, output: { error: error_msg }, level: "ERROR") if trace + rescue => e + Rails.logger.debug("[SearchFamilyFiles] Langfuse trace update failed: #{e.class}: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}") + end return { success: false, error: "search_failed", - message: "Failed to search documents: #{response.error&.message}" + message: "Failed to search documents: #{error_msg}" } end results = response.data - if results.empty? - return { - success: true, - results: [], - message: "No matching documents found for the query." - } + Rails.logger.debug("[SearchFamilyFiles] #{results.size} chunk(s) returned") + + results.each_with_index do |r, i| + Rails.logger.debug( + "[SearchFamilyFiles] chunk[#{i}] score=#{r[:score]} file=#{r[:filename].inspect} " \ + "content_length=#{r[:content]&.length} preview=#{r[:content]&.truncate(10).inspect}" + ) end - { - success: true, - query: query, - result_count: results.size, - results: results.map do |result| - { - content: result[:content], - filename: result[:filename], - score: result[:score] - } + mapped = results.map do |result| + { content: result[:content], filename: result[:filename], score: result[:score] } + end + + output = if mapped.empty? + { success: true, results: [], message: "No matching documents found for the query." } + else + { success: true, query: query, result_count: mapped.size, results: mapped } + end + + begin + if trace + langfuse_client&.trace(id: trace.id, output: { + result_count: mapped.size, + chunks: mapped.map { |r| { filename: r[:filename], score: r[:score], content_length: r[:content]&.length } } + }) end - } + rescue => e + Rails.logger.debug("[SearchFamilyFiles] Langfuse trace update failed: #{e.class}: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}") + end + + output rescue => e - Rails.logger.error("SearchFamilyFiles error: #{e.class.name} - #{e.message}") + Rails.logger.error("[SearchFamilyFiles] error: #{e.class.name} - #{e.message}") { success: false, error: "search_failed", message: "An error occurred while searching documents: #{e.message.truncate(200)}" } end + + private + def langfuse_client + return unless ENV["LANGFUSE_PUBLIC_KEY"].present? && ENV["LANGFUSE_SECRET_KEY"].present? + + @langfuse_client ||= Langfuse.new + end + + def create_langfuse_trace(name:, input:) + return unless langfuse_client + + langfuse_client.trace( + name: name, + input: input, + user_id: user.id&.to_s, + environment: Rails.env + ) + rescue => e + Rails.logger.debug("[SearchFamilyFiles] Langfuse trace creation failed: #{e.class}: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}") + nil + end end From 5230b50c8e28e6910f573c63bf5900741e99ea61 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 7 Mar 2026 00:45:56 +0000 Subject: [PATCH 44/75] Bump version to next iteration after v0.6.9-alpha.3 release --- charts/sure/Chart.yaml | 4 ++-- config/initializers/version.rb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/charts/sure/Chart.yaml b/charts/sure/Chart.yaml index 6ebfc3526..35011dd8a 100644 --- a/charts/sure/Chart.yaml +++ b/charts/sure/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: sure description: Official Helm chart for deploying the Sure Rails app (web + Sidekiq) on Kubernetes with optional HA PostgreSQL (CloudNativePG) and Redis. type: application -version: 0.6.9-alpha.3 -appVersion: "0.6.9-alpha.3" +version: 0.6.9-alpha.4 +appVersion: "0.6.9-alpha.4" kubeVersion: ">=1.25.0-0" diff --git a/config/initializers/version.rb b/config/initializers/version.rb index 9eb86e8d1..5b83f4f81 100644 --- a/config/initializers/version.rb +++ b/config/initializers/version.rb @@ -16,7 +16,7 @@ module Sure private def semver - "0.6.9-alpha.3" + "0.6.9-alpha.4" end end end From df650b0284d7ea4040b458f77e3b531fa61d5217 Mon Sep 17 00:00:00 2001 From: James Ward Date: Sat, 7 Mar 2026 05:02:05 -0500 Subject: [PATCH 45/75] fix(helm): use expected health endpoint (#1142) the liveness / readiness probes were making requests to the root of the web server. this causes the health check to fail in some cases because a redirect may occur in unexpected ways Instead, we can test against the rails "up" health controller Signed-off-by: James Ward --- charts/sure/values.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/charts/sure/values.yaml b/charts/sure/values.yaml index f6d473044..d0635b92f 100644 --- a/charts/sure/values.yaml +++ b/charts/sure/values.yaml @@ -314,7 +314,7 @@ web: # Probes livenessProbe: httpGet: - path: / + path: /up port: http initialDelaySeconds: 20 periodSeconds: 10 @@ -322,7 +322,7 @@ web: failureThreshold: 6 readinessProbe: httpGet: - path: / + path: /up port: http initialDelaySeconds: 10 periodSeconds: 5 @@ -330,7 +330,7 @@ web: failureThreshold: 6 startupProbe: httpGet: - path: / + path: /up port: http failureThreshold: 30 periodSeconds: 5 From a07b1f00c3afc7fb8af70bd06b9588555b04ec77 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 18:49:13 +0100 Subject: [PATCH 46/75] Guard error.message with rescue in LLM failed-usage recording (#1144) * Initial plan * Fix nil references in Recording failed LLM usage code paths Co-authored-by: jjmata <187772+jjmata@users.noreply.github.com> * Replace error&.message with rescue-guarded safe_error_message helper error&.message only guards against nil; it still raises when the error object's .message implementation itself throws (e.g. OpenAI errors that call data on nil). Replace with a safe_error_message helper that wraps error&.message in a rescue block, returning a descriptive fallback string on secondary failures. Apply the helper in both record_usage_error (usage_recorder.rb) and record_llm_usage (openai.rb), including the regex branch of extract_http_status_code in both files. Co-authored-by: jjmata <187772+jjmata@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jjmata <187772+jjmata@users.noreply.github.com> --- app/models/provider/openai.rb | 12 +++++++++--- .../provider/openai/concerns/usage_recorder.rb | 14 ++++++++++---- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/app/models/provider/openai.rb b/app/models/provider/openai.rb index 154b2d02e..993a32a43 100644 --- a/app/models/provider/openai.rb +++ b/app/models/provider/openai.rb @@ -538,7 +538,7 @@ class Provider::Openai < Provider # For error cases, record with zero tokens if error.present? - Rails.logger.info("Recording failed LLM usage - Error: #{error.message}") + Rails.logger.info("Recording failed LLM usage - Error: #{safe_error_message(error)}") # Extract HTTP status code if available from the error http_status_code = extract_http_status_code(error) @@ -553,7 +553,7 @@ class Provider::Openai < Provider total_tokens: 0, estimated_cost: nil, metadata: { - error: error.message, + error: safe_error_message(error), http_status_code: http_status_code } ) @@ -614,11 +614,17 @@ class Provider::Openai < Provider error.status_code elsif error.respond_to?(:response) && error.response.respond_to?(:code) error.response.code.to_i - elsif error.message =~ /(\d{3})/ + elsif safe_error_message(error) =~ /(\d{3})/ # Extract 3-digit HTTP status code from error message $1.to_i else nil end end + + def safe_error_message(error) + error&.message + rescue => e + "(message unavailable: #{e.class})" + end end diff --git a/app/models/provider/openai/concerns/usage_recorder.rb b/app/models/provider/openai/concerns/usage_recorder.rb index 55f94f052..ef552dfd1 100644 --- a/app/models/provider/openai/concerns/usage_recorder.rb +++ b/app/models/provider/openai/concerns/usage_recorder.rb @@ -47,15 +47,15 @@ module Provider::Openai::Concerns::UsageRecorder # Records failed LLM usage for a family with error details def record_usage_error(model_name, operation:, error:, metadata: {}) - return unless family + return unless family && error - Rails.logger.info("Recording failed LLM usage - Operation: #{operation}, Error: #{error.message}") + Rails.logger.info("Recording failed LLM usage - Operation: #{operation}, Error: #{safe_error_message(error)}") # Extract HTTP status code if available from the error http_status_code = extract_http_status_code(error) error_metadata = metadata.merge( - error: error.message, + error: safe_error_message(error), http_status_code: http_status_code ) @@ -86,11 +86,17 @@ module Provider::Openai::Concerns::UsageRecorder error.status_code elsif error.respond_to?(:response) && error.response.respond_to?(:code) error.response.code.to_i - elsif error.message =~ /(\d{3})/ + elsif safe_error_message(error) =~ /(\d{3})/ # Extract 3-digit HTTP status code from error message $1.to_i else nil end end + + def safe_error_message(error) + error&.message + rescue => e + "(message unavailable: #{e.class})" + end end From f6e7234ead124d08920e4251b7fe4e2b24a32b4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Mon, 9 Mar 2026 16:47:32 +0100 Subject: [PATCH 47/75] Enable Google SSO account creation in Flutter app (#1164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Google SSO onboarding flow for Flutter mobile app Previously, mobile users attempting Google SSO without a linked OIDC identity received an error telling them to link from the web app first. This adds the same account linking/creation flow that exists on the PWA. Backend changes: - sessions_controller: Cache pending OIDC auth with a linking code and redirect back to the app instead of returning an error - api/v1/auth_controller: Add sso_link endpoint to link Google identity to an existing account via email/password, and sso_create_account endpoint to create a new SSO-only account (respects JIT config) - routes: Add POST auth/sso_link and auth/sso_create_account Flutter changes: - auth_service: Detect account_not_linked callback status, add ssoLink and ssoCreateAccount API methods - auth_provider: Track SSO onboarding state, expose linking/creation methods and cancelSsoOnboarding - sso_onboarding_screen: New screen with tabs to link existing account or create new account, pre-filled with Google profile data - main.dart: Show SsoOnboardingScreen when ssoOnboardingPending is true https://claude.ai/code/session_011ag1qSfriUg6j7TqFgbS5c * Fix broken SSO tests: use MemoryStore cache and correct redirect param - Sessions test: check `status` param instead of `error` since handle_mobile_sso_onboarding sends linking info with status key - API auth tests: swap null_store for MemoryStore so cache-based linking code validation works in test environment https://claude.ai/code/session_011ag1qSfriUg6j7TqFgbS5c * Delay linking-code consumption until SSO link/create succeeds Split validate_and_consume_linking_code into validate_linking_code (read-only) and consume_linking_code! (delete). The code is now only consumed after password verification (sso_link) or successful user save (sso_create_account), so recoverable errors no longer burn the one-time code and force a full Google SSO roundtrip. https://claude.ai/code/session_011ag1qSfriUg6j7TqFgbS5c * Make linking-code consumption atomic to prevent race conditions Move consume_linking_code! (backed by Rails.cache.delete) to after recoverable checks (bad password, policy rejection) but before side-effecting operations (identity/user creation). Only the first caller to delete the cache key gets true, so concurrent requests with the same code cannot both succeed. - sso_link: consume after password auth, before OidcIdentity creation - sso_create_account: consume after allow_account_creation check, before User creation - Bad password still preserves the code for retry - Add single-use regression tests for both endpoints https://claude.ai/code/session_011ag1qSfriUg6j7TqFgbS5c * Add missing sso_create_account test coverage for blank code and validation failure - Test blank linking_code returns 400 (bad_request) with proper error - Test duplicate email triggers user.save failure → 422 with validation errors https://claude.ai/code/session_011ag1qSfriUg6j7TqFgbS5c * Verify cache payload in mobile SSO onboarding test with MemoryStore The test environment uses :null_store which silently discards cache writes, so handle_mobile_sso_onboarding's Rails.cache.write was never verified. Swap in a MemoryStore for this test and assert the full cached payload (provider, uid, email, name, device_info, allow_account_creation) at the linking_code key from the redirect URL. https://claude.ai/code/session_011ag1qSfriUg6j7TqFgbS5c * Add rswag/OpenAPI specs for sso_link and sso_create_account endpoints POST /api/v1/auth/sso_link: documents linking_code + email/password params, 200 (tokens), 400 (missing code), 401 (invalid creds/expired). POST /api/v1/auth/sso_create_account: documents linking_code + optional first_name/last_name params, 200 (tokens), 400 (missing code), 401 (expired code), 403 (creation disabled), 422 (validation errors). Note: RAILS_ENV=test bundle exec rake rswag:specs:swaggerize should be run to regenerate docs/api/openapi.yaml once the runtime environment matches the Gemfile Ruby version. https://claude.ai/code/session_011ag1qSfriUg6j7TqFgbS5c * Preserve OIDC issuer through mobile SSO onboarding flow handle_mobile_sso_onboarding now caches the issuer from auth.extra.raw_info.iss so it survives the linking-code round trip. build_omniauth_hash populates extra.raw_info.iss from the cached issuer so OidcIdentity.create_from_omniauth stores it correctly. Previously the issuer was always nil for mobile SSO-created identities because build_omniauth_hash passed an empty raw_info OpenStruct. https://claude.ai/code/session_011ag1qSfriUg6j7TqFgbS5c * Block MFA users from bypassing second factor via sso_link sso_link authenticated with email/password but never checked user.otp_required?, allowing MFA users to obtain tokens without a second factor. The mobile SSO callback already rejects MFA users with "mfa_not_supported"; apply the same guard in sso_link before consuming the linking code or creating an identity. Returns 401 with mfa_required: true, consistent with the login action's MFA response shape. https://claude.ai/code/session_011ag1qSfriUg6j7TqFgbS5c * Fix NoMethodError in SSO link MFA test Replace non-existent User.generate_otp_secret class method with ROTP::Base32.random(32), matching the pattern used in User#setup_mfa!. https://claude.ai/code/session_011ag1qSfriUg6j7TqFgbS5c * Assert linking code survives rejected SSO create account Add cache persistence assertion to "should reject SSO create account when not allowed" test, verifying the linking code is not consumed on the 403 path. This mirrors the pattern used in the invalid-password sso_link test. The other rejection tests (expired/missing linking code) don't have a valid cached code to check, so no assertion is needed there. https://claude.ai/code/session_011ag1qSfriUg6j7TqFgbS5c --------- Co-authored-by: Claude --- app/controllers/api/v1/auth_controller.rb | 116 ++++++ app/controllers/sessions_controller.rb | 37 +- config/routes.rb | 2 + mobile/lib/main.dart | 5 + mobile/lib/providers/auth_provider.dart | 128 ++++++ mobile/lib/screens/sso_onboarding_screen.dart | 363 ++++++++++++++++++ mobile/lib/services/auth_service.dart | 123 ++++++ spec/requests/api/v1/auth_spec.rb | 118 ++++++ .../api/v1/auth_controller_test.rb | 313 +++++++++++++++ test/controllers/sessions_controller_test.rb | 43 ++- 10 files changed, 1233 insertions(+), 15 deletions(-) create mode 100644 mobile/lib/screens/sso_onboarding_screen.dart diff --git a/app/controllers/api/v1/auth_controller.rb b/app/controllers/api/v1/auth_controller.rb index ae1744823..25140f3ea 100644 --- a/app/controllers/api/v1/auth_controller.rb +++ b/app/controllers/api/v1/auth_controller.rb @@ -140,6 +140,80 @@ module Api } end + def sso_link + linking_code = params[:linking_code] + cached = validate_linking_code(linking_code) + return unless cached + + user = User.authenticate_by(email: params[:email], password: params[:password]) + + unless user + render json: { error: "Invalid email or password" }, status: :unauthorized + return + end + + if user.otp_required? + render json: { error: "MFA users should sign in with email and password", mfa_required: true }, status: :unauthorized + return + end + + # Atomically claim the code before creating the identity + return render json: { error: "Linking code is invalid or expired" }, status: :unauthorized unless consume_linking_code!(linking_code) + + OidcIdentity.create_from_omniauth(build_omniauth_hash(cached), user) + + SsoAuditLog.log_link!( + user: user, + provider: cached[:provider], + request: request + ) + + issue_mobile_tokens(user, cached[:device_info]) + end + + def sso_create_account + linking_code = params[:linking_code] + cached = validate_linking_code(linking_code) + return unless cached + + email = cached[:email] + + unless cached[:allow_account_creation] + render json: { error: "SSO account creation is disabled. Please contact an administrator." }, status: :forbidden + return + end + + # Atomically claim the code before creating the user + return render json: { error: "Linking code is invalid or expired" }, status: :unauthorized unless consume_linking_code!(linking_code) + + user = User.new( + email: email, + first_name: params[:first_name].presence || cached[:first_name], + last_name: params[:last_name].presence || cached[:last_name], + skip_password_validation: true + ) + + user.family = Family.new + + provider_config = Rails.configuration.x.auth.sso_providers&.find { |p| p[:name] == cached[:provider] } + provider_default_role = provider_config&.dig(:settings, :default_role) + user.role = User.role_for_new_family_creator(fallback_role: provider_default_role || :admin) + + if user.save + OidcIdentity.create_from_omniauth(build_omniauth_hash(cached), user) + + SsoAuditLog.log_jit_account_created!( + user: user, + provider: cached[:provider], + request: request + ) + + issue_mobile_tokens(user, cached[:device_info]) + else + render json: { errors: user.errors.full_messages }, status: :unprocessable_entity + end + end + def enable_ai user = current_resource_owner @@ -248,6 +322,48 @@ module Api } end + def build_omniauth_hash(cached) + OpenStruct.new( + provider: cached[:provider], + uid: cached[:uid], + info: OpenStruct.new(cached.slice(:email, :name, :first_name, :last_name)), + extra: OpenStruct.new(raw_info: OpenStruct.new(iss: cached[:issuer])) + ) + end + + def validate_linking_code(linking_code) + if linking_code.blank? + render json: { error: "Linking code is required" }, status: :bad_request + return nil + end + + cache_key = "mobile_sso_link:#{linking_code}" + cached = Rails.cache.read(cache_key) + + unless cached.present? + render json: { error: "Linking code is invalid or expired" }, status: :unauthorized + return nil + end + + cached + end + + # Atomically deletes the linking code from cache. + # Returns true only for the first caller; subsequent callers get false. + def consume_linking_code!(linking_code) + Rails.cache.delete("mobile_sso_link:#{linking_code}") + end + + def issue_mobile_tokens(user, device_info) + device_info = device_info.symbolize_keys if device_info.respond_to?(:symbolize_keys) + device = MobileDevice.upsert_device!(user, device_info) + token_response = device.issue_token! + + render json: token_response.merge(user: mobile_user_payload(user)) + rescue ActiveRecord::RecordInvalid => e + render json: { error: "Failed to register device: #{e.message}" }, status: :unprocessable_entity + end + def ensure_write_scope authorize_scope!(:write) end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index ec53ef5f6..7d56a96fe 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -188,10 +188,10 @@ class SessionsController < ApplicationController redirect_to root_path end else - # Mobile SSO with no linked identity - redirect back with error + # Mobile SSO with no linked identity - cache pending auth and redirect + # back to the app with a linking code so the user can link or create an account if session[:mobile_sso].present? - session.delete(:mobile_sso) - mobile_sso_redirect(error: "account_not_linked", message: "Please link your Google account from the web app first") + handle_mobile_sso_onboarding(auth) return end @@ -273,6 +273,37 @@ class SessionsController < ApplicationController mobile_sso_redirect(error: "device_error", message: "Unable to register device") end + def handle_mobile_sso_onboarding(auth) + device_info = session.delete(:mobile_sso) + email = auth.info&.email + + linking_code = SecureRandom.urlsafe_base64(32) + Rails.cache.write( + "mobile_sso_link:#{linking_code}", + { + provider: auth.provider, + uid: auth.uid, + email: email, + first_name: auth.info&.first_name, + last_name: auth.info&.last_name, + name: auth.info&.name, + issuer: auth.extra&.raw_info&.iss || auth.extra&.raw_info&.[]("iss"), + device_info: device_info, + allow_account_creation: !AuthConfig.jit_link_only? && AuthConfig.allowed_oidc_domain?(email) + }, + expires_in: 10.minutes + ) + + mobile_sso_redirect( + status: "account_not_linked", + linking_code: linking_code, + email: email, + first_name: auth.info&.first_name, + last_name: auth.info&.last_name, + allow_account_creation: !AuthConfig.jit_link_only? && AuthConfig.allowed_oidc_domain?(email) + ) + end + def mobile_sso_redirect(params = {}) redirect_to "sureapp://oauth/callback?#{params.to_query}", allow_other_host: true end diff --git a/config/routes.rb b/config/routes.rb index 514cece15..d0b6c8827 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -375,6 +375,8 @@ Rails.application.routes.draw do post "auth/login", to: "auth#login" post "auth/refresh", to: "auth#refresh" post "auth/sso_exchange", to: "auth#sso_exchange" + post "auth/sso_link", to: "auth#sso_link" + post "auth/sso_create_account", to: "auth#sso_create_account" patch "auth/enable_ai", to: "auth#enable_ai" # Production API endpoints diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 2f3aca585..55e74ab9d 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -9,6 +9,7 @@ import 'providers/chat_provider.dart'; import 'screens/backend_config_screen.dart'; import 'screens/login_screen.dart'; import 'screens/main_navigation_screen.dart'; +import 'screens/sso_onboarding_screen.dart'; import 'services/api_config.dart'; import 'services/connectivity_service.dart'; import 'services/log_service.dart'; @@ -255,6 +256,10 @@ class _AppWrapperState extends State { return const MainNavigationScreen(); } + if (authProvider.ssoOnboardingPending) { + return const SsoOnboardingScreen(); + } + return LoginScreen( onGoToSettings: _goToBackendConfig, ); diff --git a/mobile/lib/providers/auth_provider.dart b/mobile/lib/providers/auth_provider.dart index 3b6a6510e..c2e8bdcfc 100644 --- a/mobile/lib/providers/auth_provider.dart +++ b/mobile/lib/providers/auth_provider.dart @@ -22,6 +22,14 @@ class AuthProvider with ChangeNotifier { bool _mfaRequired = false; bool _showMfaInput = false; // Track if we should show MFA input field + // SSO onboarding state + bool _ssoOnboardingPending = false; + String? _ssoLinkingCode; + String? _ssoEmail; + String? _ssoFirstName; + String? _ssoLastName; + bool _ssoAllowAccountCreation = false; + User? get user => _user; bool get isIntroLayout => _user?.isIntroLayout ?? false; bool get aiEnabled => _user?.aiEnabled ?? false; @@ -36,6 +44,14 @@ class AuthProvider with ChangeNotifier { bool get mfaRequired => _mfaRequired; bool get showMfaInput => _showMfaInput; // Expose MFA input state + // SSO onboarding getters + bool get ssoOnboardingPending => _ssoOnboardingPending; + String? get ssoLinkingCode => _ssoLinkingCode; + String? get ssoEmail => _ssoEmail; + String? get ssoFirstName => _ssoFirstName; + String? get ssoLastName => _ssoLastName; + bool get ssoAllowAccountCreation => _ssoAllowAccountCreation; + AuthProvider() { _loadStoredAuth(); } @@ -266,9 +282,21 @@ class AuthProvider with ChangeNotifier { if (result['success'] == true) { _tokens = result['tokens'] as AuthTokens?; _user = result['user'] as User?; + _ssoOnboardingPending = false; _isLoading = false; notifyListeners(); return true; + } else if (result['account_not_linked'] == true) { + // SSO onboarding needed - store linking data + _ssoOnboardingPending = true; + _ssoLinkingCode = result['linking_code'] as String?; + _ssoEmail = result['email'] as String?; + _ssoFirstName = result['first_name'] as String?; + _ssoLastName = result['last_name'] as String?; + _ssoAllowAccountCreation = result['allow_account_creation'] == true; + _isLoading = false; + notifyListeners(); + return false; } else { _errorMessage = result['error'] as String?; _isLoading = false; @@ -284,6 +312,106 @@ class AuthProvider with ChangeNotifier { } } + Future ssoLinkAccount({ + required String email, + required String password, + }) async { + if (_ssoLinkingCode == null) { + _errorMessage = 'No pending SSO session. Please try signing in again.'; + notifyListeners(); + return false; + } + + _errorMessage = null; + _isLoading = true; + notifyListeners(); + + try { + final result = await _authService.ssoLink( + linkingCode: _ssoLinkingCode!, + email: email, + password: password, + ); + + if (result['success'] == true) { + _tokens = result['tokens'] as AuthTokens?; + _user = result['user'] as User?; + _clearSsoOnboardingState(); + _isLoading = false; + notifyListeners(); + return true; + } else { + _errorMessage = result['error'] as String?; + _isLoading = false; + notifyListeners(); + return false; + } + } catch (e, stackTrace) { + LogService.instance.error('AuthProvider', 'SSO link error: $e\n$stackTrace'); + _errorMessage = 'Failed to link account. Please try again.'; + _isLoading = false; + notifyListeners(); + return false; + } + } + + Future ssoCreateAccount({ + String? firstName, + String? lastName, + }) async { + if (_ssoLinkingCode == null) { + _errorMessage = 'No pending SSO session. Please try signing in again.'; + notifyListeners(); + return false; + } + + _errorMessage = null; + _isLoading = true; + notifyListeners(); + + try { + final result = await _authService.ssoCreateAccount( + linkingCode: _ssoLinkingCode!, + firstName: firstName, + lastName: lastName, + ); + + if (result['success'] == true) { + _tokens = result['tokens'] as AuthTokens?; + _user = result['user'] as User?; + _clearSsoOnboardingState(); + _isLoading = false; + notifyListeners(); + return true; + } else { + _errorMessage = result['error'] as String?; + _isLoading = false; + notifyListeners(); + return false; + } + } catch (e, stackTrace) { + LogService.instance.error('AuthProvider', 'SSO create account error: $e\n$stackTrace'); + _errorMessage = 'Failed to create account. Please try again.'; + _isLoading = false; + notifyListeners(); + return false; + } + } + + void cancelSsoOnboarding() { + _clearSsoOnboardingState(); + notifyListeners(); + } + + void _clearSsoOnboardingState() { + _ssoOnboardingPending = false; + _ssoLinkingCode = null; + _ssoEmail = null; + _ssoFirstName = null; + _ssoLastName = null; + _ssoAllowAccountCreation = false; + } + Future logout() async { await _authService.logout(); _tokens = null; diff --git a/mobile/lib/screens/sso_onboarding_screen.dart b/mobile/lib/screens/sso_onboarding_screen.dart new file mode 100644 index 000000000..c98a2c3de --- /dev/null +++ b/mobile/lib/screens/sso_onboarding_screen.dart @@ -0,0 +1,363 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import '../providers/auth_provider.dart'; + +class SsoOnboardingScreen extends StatefulWidget { + const SsoOnboardingScreen({super.key}); + + @override + State createState() => _SsoOnboardingScreenState(); +} + +class _SsoOnboardingScreenState extends State { + bool _showLinkForm = true; + final _linkFormKey = GlobalKey(); + final _createFormKey = GlobalKey(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + final _firstNameController = TextEditingController(); + final _lastNameController = TextEditingController(); + bool _obscurePassword = true; + + @override + void initState() { + super.initState(); + final authProvider = Provider.of(context, listen: false); + _emailController.text = authProvider.ssoEmail ?? ''; + _firstNameController.text = authProvider.ssoFirstName ?? ''; + _lastNameController.text = authProvider.ssoLastName ?? ''; + } + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + _firstNameController.dispose(); + _lastNameController.dispose(); + super.dispose(); + } + + Future _handleLinkAccount() async { + if (!_linkFormKey.currentState!.validate()) return; + + final authProvider = Provider.of(context, listen: false); + await authProvider.ssoLinkAccount( + email: _emailController.text.trim(), + password: _passwordController.text, + ); + } + + Future _handleCreateAccount() async { + if (!_createFormKey.currentState!.validate()) return; + + final authProvider = Provider.of(context, listen: false); + await authProvider.ssoCreateAccount( + firstName: _firstNameController.text.trim(), + lastName: _lastNameController.text.trim(), + ); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + Provider.of(context, listen: false) + .cancelSsoOnboarding(); + }, + ), + title: const Text('Link Your Account'), + ), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Consumer( + builder: (context, authProvider, _) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header + SvgPicture.asset( + 'assets/images/google_g_logo.svg', + width: 48, + height: 48, + ), + const SizedBox(height: 16), + Text( + authProvider.ssoEmail != null + ? 'Signed in as ${authProvider.ssoEmail}' + : 'Google account verified', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 24), + + // Error message + if (authProvider.errorMessage != null) + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.error_outline, color: colorScheme.error), + const SizedBox(width: 12), + Expanded( + child: Text( + authProvider.errorMessage!, + style: TextStyle( + color: colorScheme.onErrorContainer), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => authProvider.clearError(), + iconSize: 20, + ), + ], + ), + ), + + // Tab selector + if (authProvider.ssoAllowAccountCreation) ...[ + Container( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest + .withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Expanded( + child: _TabButton( + label: 'Link Existing', + isSelected: _showLinkForm, + onTap: () => + setState(() => _showLinkForm = true), + ), + ), + Expanded( + child: _TabButton( + label: 'Create New', + isSelected: !_showLinkForm, + onTap: () => + setState(() => _showLinkForm = false), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + ], + + // Link existing account form + if (_showLinkForm) _buildLinkForm(authProvider, colorScheme), + + // Create new account form + if (!_showLinkForm) + _buildCreateForm(authProvider, colorScheme), + ], + ); + }, + ), + ), + ), + ); + } + + Widget _buildLinkForm(AuthProvider authProvider, ColorScheme colorScheme) { + return Form( + key: _linkFormKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.link, color: colorScheme.primary), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Enter your existing account credentials to link with Google Sign-In.', + style: TextStyle(color: colorScheme.onSurface), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + autocorrect: false, + textInputAction: TextInputAction.next, + decoration: const InputDecoration( + labelText: 'Email', + prefixIcon: Icon(Icons.email_outlined), + ), + validator: (value) { + if (value == null || value.isEmpty) return 'Please enter your email'; + if (!value.contains('@')) return 'Please enter a valid email'; + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _passwordController, + obscureText: _obscurePassword, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + labelText: 'Password', + prefixIcon: const Icon(Icons.lock_outlined), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + onPressed: () { + setState(() => _obscurePassword = !_obscurePassword); + }, + ), + ), + validator: (value) { + if (value == null || value.isEmpty) return 'Please enter your password'; + return null; + }, + onFieldSubmitted: (_) => _handleLinkAccount(), + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: authProvider.isLoading ? null : _handleLinkAccount, + child: authProvider.isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Link Account'), + ), + ], + ), + ); + } + + Widget _buildCreateForm(AuthProvider authProvider, ColorScheme colorScheme) { + return Form( + key: _createFormKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.person_add, color: colorScheme.primary), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Create a new account using your Google identity.', + style: TextStyle(color: colorScheme.onSurface), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + TextFormField( + controller: _firstNameController, + textInputAction: TextInputAction.next, + decoration: const InputDecoration( + labelText: 'First Name', + prefixIcon: Icon(Icons.person_outlined), + ), + validator: (value) { + if (value == null || value.isEmpty) return 'Please enter your first name'; + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _lastNameController, + textInputAction: TextInputAction.done, + decoration: const InputDecoration( + labelText: 'Last Name', + prefixIcon: Icon(Icons.person_outlined), + ), + validator: (value) { + if (value == null || value.isEmpty) return 'Please enter your last name'; + return null; + }, + onFieldSubmitted: (_) => _handleCreateAccount(), + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: authProvider.isLoading ? null : _handleCreateAccount, + child: authProvider.isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Create Account'), + ), + ], + ), + ); + } +} + +class _TabButton extends StatelessWidget { + final String label; + final bool isSelected; + final VoidCallback onTap; + + const _TabButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: isSelected ? colorScheme.primary : Colors.transparent, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + label, + textAlign: TextAlign.center, + style: TextStyle( + color: isSelected ? colorScheme.onPrimary : colorScheme.onSurface, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + ), + ), + ), + ); + } +} diff --git a/mobile/lib/services/auth_service.dart b/mobile/lib/services/auth_service.dart index 98a98f31e..a89f25c3e 100644 --- a/mobile/lib/services/auth_service.dart +++ b/mobile/lib/services/auth_service.dart @@ -364,6 +364,19 @@ class AuthService { Future> handleSsoCallback(Uri uri) async { final params = uri.queryParameters; + // Handle account not linked - return linking data for onboarding flow + if (params['status'] == 'account_not_linked') { + return { + 'success': false, + 'account_not_linked': true, + 'linking_code': params['linking_code'] ?? '', + 'email': params['email'] ?? '', + 'first_name': params['first_name'] ?? '', + 'last_name': params['last_name'] ?? '', + 'allow_account_creation': params['allow_account_creation'] == 'true', + }; + } + if (params.containsKey('error')) { return { 'success': false, @@ -440,6 +453,116 @@ class AuthService { } } + Future> ssoLink({ + required String linkingCode, + required String email, + required String password, + }) async { + try { + final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/auth/sso_link'); + final response = await http.post( + url, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: jsonEncode({ + 'linking_code': linkingCode, + 'email': email, + 'password': password, + }), + ).timeout(const Duration(seconds: 30)); + + final responseData = jsonDecode(response.body); + + if (response.statusCode == 200) { + final tokens = AuthTokens.fromJson(responseData); + await _saveTokens(tokens); + + User? user; + if (responseData['user'] != null) { + _logRawUserPayload('sso_link', responseData['user']); + user = User.fromJson(responseData['user']); + await _saveUser(user); + } + + return { + 'success': true, + 'tokens': tokens, + 'user': user, + }; + } else { + return { + 'success': false, + 'error': responseData['error'] ?? responseData['errors']?.join(', ') ?? 'Account linking failed', + }; + } + } on SocketException { + return {'success': false, 'error': 'Network unavailable'}; + } on TimeoutException { + return {'success': false, 'error': 'Request timed out'}; + } catch (e, stackTrace) { + LogService.instance.error('AuthService', 'SSO link error: $e\n$stackTrace'); + return {'success': false, 'error': 'Failed to link account'}; + } + } + + Future> ssoCreateAccount({ + required String linkingCode, + String? firstName, + String? lastName, + }) async { + try { + final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/auth/sso_create_account'); + final body = { + 'linking_code': linkingCode, + }; + if (firstName != null) body['first_name'] = firstName; + if (lastName != null) body['last_name'] = lastName; + + final response = await http.post( + url, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: jsonEncode(body), + ).timeout(const Duration(seconds: 30)); + + final responseData = jsonDecode(response.body); + + if (response.statusCode == 200) { + final tokens = AuthTokens.fromJson(responseData); + await _saveTokens(tokens); + + User? user; + if (responseData['user'] != null) { + _logRawUserPayload('sso_create_account', responseData['user']); + user = User.fromJson(responseData['user']); + await _saveUser(user); + } + + return { + 'success': true, + 'tokens': tokens, + 'user': user, + }; + } else { + return { + 'success': false, + 'error': responseData['error'] ?? responseData['errors']?.join(', ') ?? 'Account creation failed', + }; + } + } on SocketException { + return {'success': false, 'error': 'Network unavailable'}; + } on TimeoutException { + return {'success': false, 'error': 'Request timed out'}; + } catch (e, stackTrace) { + LogService.instance.error('AuthService', 'SSO create account error: $e\n$stackTrace'); + return {'success': false, 'error': 'Failed to create account'}; + } + } + Future> enableAi({ required String accessToken, }) async { diff --git a/spec/requests/api/v1/auth_spec.rb b/spec/requests/api/v1/auth_spec.rb index f21ab79f2..278ec7348 100644 --- a/spec/requests/api/v1/auth_spec.rb +++ b/spec/requests/api/v1/auth_spec.rb @@ -216,6 +216,124 @@ RSpec.describe 'API V1 Auth', type: :request do end end + path '/api/v1/auth/sso_link' do + post 'Link an existing account via SSO' do + tags 'Auth' + consumes 'application/json' + produces 'application/json' + description 'Authenticates with email/password and links the SSO identity from a previously issued linking code. Creates an OidcIdentity, logs the link via SsoAuditLog, and issues mobile OAuth tokens.' + parameter name: :body, in: :body, required: true, schema: { + type: :object, + properties: { + linking_code: { type: :string, description: 'One-time linking code from mobile SSO onboarding redirect' }, + email: { type: :string, format: :email, description: 'Email of the existing account to link' }, + password: { type: :string, description: 'Password for the existing account' } + }, + required: %w[linking_code email password] + } + + response '200', 'account linked and tokens issued' do + schema type: :object, + properties: { + access_token: { type: :string }, + refresh_token: { type: :string }, + token_type: { type: :string }, + expires_in: { type: :integer }, + created_at: { type: :integer }, + user: { + type: :object, + properties: { + id: { type: :string, format: :uuid }, + email: { type: :string }, + first_name: { type: :string }, + last_name: { type: :string }, + ui_layout: { type: :string, enum: %w[dashboard intro] }, + ai_enabled: { type: :boolean } + } + } + } + run_test! + end + + response '400', 'missing linking code' do + schema '$ref' => '#/components/schemas/ErrorResponse' + run_test! + end + + response '401', 'invalid credentials or expired linking code' do + schema '$ref' => '#/components/schemas/ErrorResponse' + run_test! + end + end + end + + path '/api/v1/auth/sso_create_account' do + post 'Create a new account via SSO' do + tags 'Auth' + consumes 'application/json' + produces 'application/json' + description 'Creates a new user and family from a previously issued linking code. Links the SSO identity via OidcIdentity, logs the JIT account creation via SsoAuditLog, and issues mobile OAuth tokens. The linking code must have allow_account_creation enabled.' + parameter name: :body, in: :body, required: true, schema: { + type: :object, + properties: { + linking_code: { type: :string, description: 'One-time linking code from mobile SSO onboarding redirect' }, + first_name: { type: :string, description: 'First name (overrides value from SSO provider if provided)' }, + last_name: { type: :string, description: 'Last name (overrides value from SSO provider if provided)' } + }, + required: %w[linking_code] + } + + response '200', 'account created and tokens issued' do + schema type: :object, + properties: { + access_token: { type: :string }, + refresh_token: { type: :string }, + token_type: { type: :string }, + expires_in: { type: :integer }, + created_at: { type: :integer }, + user: { + type: :object, + properties: { + id: { type: :string, format: :uuid }, + email: { type: :string }, + first_name: { type: :string }, + last_name: { type: :string }, + ui_layout: { type: :string, enum: %w[dashboard intro] }, + ai_enabled: { type: :boolean } + } + } + } + run_test! + end + + response '400', 'missing linking code' do + schema '$ref' => '#/components/schemas/ErrorResponse' + run_test! + end + + response '401', 'invalid or expired linking code' do + schema '$ref' => '#/components/schemas/ErrorResponse' + run_test! + end + + response '403', 'account creation disabled' do + schema '$ref' => '#/components/schemas/ErrorResponse' + run_test! + end + + response '422', 'user validation error' do + schema type: :object, + properties: { + errors: { + type: :array, + items: { type: :string } + } + } + run_test! + end + end + end + path '/api/v1/auth/enable_ai' do patch 'Enable AI features for the authenticated user' do tags 'Auth' diff --git a/test/controllers/api/v1/auth_controller_test.rb b/test/controllers/api/v1/auth_controller_test.rb index 06b2ed537..52207df3a 100644 --- a/test/controllers/api/v1/auth_controller_test.rb +++ b/test/controllers/api/v1/auth_controller_test.rb @@ -22,6 +22,14 @@ class Api::V1::AuthControllerTest < ActionDispatch::IntegrationTest # Clear the memoized class variable so it picks up the test record MobileDevice.instance_variable_set(:@shared_oauth_application, nil) + + # Use a real cache store for SSO linking tests (test env uses :null_store by default) + @original_cache = Rails.cache + Rails.cache = ActiveSupport::Cache::MemoryStore.new + end + + teardown do + Rails.cache = @original_cache if @original_cache end test "should signup new user and return OAuth tokens" do @@ -488,6 +496,311 @@ class Api::V1::AuthControllerTest < ActionDispatch::IntegrationTest assert_response :unauthorized end + # SSO Link tests + test "should link existing account via SSO and return tokens" do + user = users(:family_admin) + + linking_code = SecureRandom.urlsafe_base64(32) + Rails.cache.write("mobile_sso_link:#{linking_code}", { + provider: "google_oauth2", + uid: "google-uid-123", + email: "google@example.com", + first_name: "Google", + last_name: "User", + name: "Google User", + device_info: @device_info.stringify_keys, + allow_account_creation: true + }, expires_in: 10.minutes) + + assert_difference("OidcIdentity.count", 1) do + post "/api/v1/auth/sso_link", params: { + linking_code: linking_code, + email: user.email, + password: user_password_test + } + end + + assert_response :success + response_data = JSON.parse(response.body) + assert response_data["access_token"].present? + assert response_data["refresh_token"].present? + assert_equal user.id.to_s, response_data["user"]["id"] + + # Linking code should be consumed + assert_nil Rails.cache.read("mobile_sso_link:#{linking_code}") + end + + test "should reject SSO link with invalid password" do + user = users(:family_admin) + + linking_code = SecureRandom.urlsafe_base64(32) + Rails.cache.write("mobile_sso_link:#{linking_code}", { + provider: "google_oauth2", + uid: "google-uid-123", + email: "google@example.com", + device_info: @device_info.stringify_keys, + allow_account_creation: true + }, expires_in: 10.minutes) + + assert_no_difference("OidcIdentity.count") do + post "/api/v1/auth/sso_link", params: { + linking_code: linking_code, + email: user.email, + password: "wrong_password" + } + end + + assert_response :unauthorized + response_data = JSON.parse(response.body) + assert_equal "Invalid email or password", response_data["error"] + + # Linking code should NOT be consumed on failed password + assert Rails.cache.read("mobile_sso_link:#{linking_code}").present?, "Expected linking code to survive a failed attempt" + end + + test "should reject SSO link when user has MFA enabled" do + user = users(:family_admin) + user.update!(otp_required: true, otp_secret: ROTP::Base32.random(32)) + + linking_code = SecureRandom.urlsafe_base64(32) + Rails.cache.write("mobile_sso_link:#{linking_code}", { + provider: "google_oauth2", + uid: "google-uid-mfa", + email: "mfa@example.com", + first_name: "MFA", + last_name: "User", + name: "MFA User", + device_info: @device_info.stringify_keys, + allow_account_creation: true + }, expires_in: 10.minutes) + + assert_no_difference("OidcIdentity.count") do + post "/api/v1/auth/sso_link", params: { + linking_code: linking_code, + email: user.email, + password: user_password_test + } + end + + assert_response :unauthorized + response_data = JSON.parse(response.body) + assert_equal true, response_data["mfa_required"] + assert_match(/MFA/, response_data["error"]) + + # Linking code should NOT be consumed on MFA rejection + assert Rails.cache.read("mobile_sso_link:#{linking_code}").present?, "Expected linking code to survive MFA rejection" + end + + test "should reject SSO link with expired linking code" do + post "/api/v1/auth/sso_link", params: { + linking_code: "expired-code", + email: "test@example.com", + password: "password" + } + + assert_response :unauthorized + response_data = JSON.parse(response.body) + assert_equal "Linking code is invalid or expired", response_data["error"] + end + + test "should reject SSO link without linking code" do + post "/api/v1/auth/sso_link", params: { + email: "test@example.com", + password: "password" + } + + assert_response :bad_request + response_data = JSON.parse(response.body) + assert_equal "Linking code is required", response_data["error"] + end + + test "linking_code is single-use under race" do + user = users(:family_admin) + + linking_code = SecureRandom.urlsafe_base64(32) + Rails.cache.write("mobile_sso_link:#{linking_code}", { + provider: "google_oauth2", + uid: "google-uid-race-test", + email: "race@example.com", + first_name: "Race", + last_name: "Test", + name: "Race Test", + device_info: @device_info.stringify_keys, + allow_account_creation: true + }, expires_in: 10.minutes) + + # First request succeeds + assert_difference("OidcIdentity.count", 1) do + post "/api/v1/auth/sso_link", params: { + linking_code: linking_code, + email: user.email, + password: user_password_test + } + end + assert_response :success + + # Second request with the same code is rejected + assert_no_difference("OidcIdentity.count") do + post "/api/v1/auth/sso_link", params: { + linking_code: linking_code, + email: user.email, + password: user_password_test + } + end + assert_response :unauthorized + assert_equal "Linking code is invalid or expired", JSON.parse(response.body)["error"] + assert_nil Rails.cache.read("mobile_sso_link:#{linking_code}") + end + + # SSO Create Account tests + test "should create new account via SSO and return tokens" do + linking_code = SecureRandom.urlsafe_base64(32) + Rails.cache.write("mobile_sso_link:#{linking_code}", { + provider: "google_oauth2", + uid: "google-uid-456", + email: "newgoogleuser@example.com", + first_name: "New", + last_name: "GoogleUser", + name: "New GoogleUser", + device_info: @device_info.stringify_keys, + allow_account_creation: true + }, expires_in: 10.minutes) + + assert_difference([ "User.count", "OidcIdentity.count" ], 1) do + post "/api/v1/auth/sso_create_account", params: { + linking_code: linking_code, + first_name: "New", + last_name: "GoogleUser" + } + end + + assert_response :success + response_data = JSON.parse(response.body) + assert response_data["access_token"].present? + assert response_data["refresh_token"].present? + assert_equal "newgoogleuser@example.com", response_data["user"]["email"] + assert_equal "New", response_data["user"]["first_name"] + assert_equal "GoogleUser", response_data["user"]["last_name"] + + # Linking code should be consumed + assert_nil Rails.cache.read("mobile_sso_link:#{linking_code}") + end + + test "should reject SSO create account when not allowed" do + linking_code = SecureRandom.urlsafe_base64(32) + Rails.cache.write("mobile_sso_link:#{linking_code}", { + provider: "google_oauth2", + uid: "google-uid-789", + email: "blocked@example.com", + first_name: "Blocked", + last_name: "User", + device_info: @device_info.stringify_keys, + allow_account_creation: false + }, expires_in: 10.minutes) + + assert_no_difference("User.count") do + post "/api/v1/auth/sso_create_account", params: { + linking_code: linking_code, + first_name: "Blocked", + last_name: "User" + } + end + + assert_response :forbidden + response_data = JSON.parse(response.body) + assert_match(/disabled/, response_data["error"]) + + # Linking code should NOT be consumed on rejection + assert Rails.cache.read("mobile_sso_link:#{linking_code}").present?, "Expected linking code to survive a rejected create account attempt" + end + + test "should reject SSO create account with expired linking code" do + post "/api/v1/auth/sso_create_account", params: { + linking_code: "expired-code", + first_name: "Test", + last_name: "User" + } + + assert_response :unauthorized + response_data = JSON.parse(response.body) + assert_equal "Linking code is invalid or expired", response_data["error"] + end + + test "should reject SSO create account without linking code" do + post "/api/v1/auth/sso_create_account", params: { + first_name: "Test", + last_name: "User" + } + + assert_response :bad_request + response_data = JSON.parse(response.body) + assert_equal "Linking code is required", response_data["error"] + end + + test "should return 422 when SSO create account fails user validation" do + existing_user = users(:family_admin) + + linking_code = SecureRandom.urlsafe_base64(32) + Rails.cache.write("mobile_sso_link:#{linking_code}", { + provider: "google_oauth2", + uid: "google-uid-dup-email", + email: existing_user.email, + first_name: "Duplicate", + last_name: "Email", + name: "Duplicate Email", + device_info: @device_info.stringify_keys, + allow_account_creation: true + }, expires_in: 10.minutes) + + assert_no_difference([ "User.count", "OidcIdentity.count" ]) do + post "/api/v1/auth/sso_create_account", params: { + linking_code: linking_code, + first_name: "Duplicate", + last_name: "Email" + } + end + + assert_response :unprocessable_entity + response_data = JSON.parse(response.body) + assert response_data["errors"].any? { |e| e.match?(/email/i) }, "Expected email validation error in: #{response_data["errors"]}" + end + + test "sso_create_account linking_code single-use under race" do + linking_code = SecureRandom.urlsafe_base64(32) + Rails.cache.write("mobile_sso_link:#{linking_code}", { + provider: "google_oauth2", + uid: "google-uid-race-create", + email: "raceuser@example.com", + first_name: "Race", + last_name: "CreateUser", + name: "Race CreateUser", + device_info: @device_info.stringify_keys, + allow_account_creation: true + }, expires_in: 10.minutes) + + # First request succeeds + assert_difference([ "User.count", "OidcIdentity.count" ], 1) do + post "/api/v1/auth/sso_create_account", params: { + linking_code: linking_code, + first_name: "Race", + last_name: "CreateUser" + } + end + assert_response :success + + # Second request with the same code is rejected + assert_no_difference([ "User.count", "OidcIdentity.count" ]) do + post "/api/v1/auth/sso_create_account", params: { + linking_code: linking_code, + first_name: "Race", + last_name: "CreateUser" + } + end + assert_response :unauthorized + assert_equal "Linking code is invalid or expired", JSON.parse(response.body)["error"] + assert_nil Rails.cache.read("mobile_sso_link:#{linking_code}") + end + test "should return forbidden when ai is not available" do user = users(:family_admin) user.update!(ai_enabled: false) diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index 8bdad7bf0..d3ec232ef 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -543,20 +543,39 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest { name: "openid_connect", strategy: "openid_connect", label: "Google" } ]) - get "/auth/mobile/openid_connect", params: { - device_id: "flutter-device-006", - device_name: "Pixel 8", - device_type: "android" - } - get "/auth/openid_connect/callback" + # Use a real cache store so we can verify the cache entry written by handle_mobile_sso_onboarding + original_cache = Rails.cache + Rails.cache = ActiveSupport::Cache::MemoryStore.new - assert_response :redirect - redirect_url = @response.redirect_url + begin + get "/auth/mobile/openid_connect", params: { + device_id: "flutter-device-006", + device_name: "Pixel 8", + device_type: "android" + } + get "/auth/openid_connect/callback" - assert redirect_url.start_with?("sureapp://oauth/callback?"), "Expected redirect to sureapp://" - params = Rack::Utils.parse_query(URI.parse(redirect_url).query) - assert_equal "account_not_linked", params["error"] - assert_nil session[:mobile_sso], "Expected mobile_sso session to be cleared" + assert_response :redirect + redirect_url = @response.redirect_url + + assert redirect_url.start_with?("sureapp://oauth/callback?"), "Expected redirect to sureapp://" + params = Rack::Utils.parse_query(URI.parse(redirect_url).query) + assert_equal "account_not_linked", params["status"] + assert params["linking_code"].present?, "Expected linking_code in redirect params" + assert_nil session[:mobile_sso], "Expected mobile_sso session to be cleared" + + # Verify the cache entry written by handle_mobile_sso_onboarding + cached = Rails.cache.read("mobile_sso_link:#{params['linking_code']}") + assert cached.present?, "Expected cache entry for mobile_sso_link:#{params['linking_code']}" + assert_equal "openid_connect", cached[:provider] + assert_equal "unlinked-uid-99999", cached[:uid] + assert_equal user_without_oidc.email, cached[:email] + assert_equal "New User", cached[:name] + assert cached.key?(:device_info), "Expected device_info in cached payload" + assert cached.key?(:allow_account_creation), "Expected allow_account_creation in cached payload" + ensure + Rails.cache = original_cache + end end test "mobile SSO does not create a web session" do From c09362b880d4951ed50f5a91436745fe6324d93a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Tue, 10 Mar 2026 13:38:42 +0100 Subject: [PATCH 48/75] Check for pending invitations before creating new Family during SSO log in/sign up (#1171) * Check for pending invitations before creating new Family during SSO account creation When a user signs in via Google SSO and doesn't have an account yet, the system now checks for pending invitations before creating a new Family. If an invitation exists, the user joins the invited family instead. - OidcAccountsController: check Invitation.pending in link/create_user - API AuthController: check pending invitations in sso_create_account - SessionsController: pass has_pending_invitation to mobile SSO callback - Web view: show "Accept Invitation" button when invitation exists - Flutter: show "Accept Invitation" tab/button when invitation pending https://claude.ai/code/session_019Tr6edJa496V1ErGmsbqFU * Fix external assistant tests: clear Settings cache to prevent test pollution The tests relied solely on with_env_overrides to clear configuration, but rails-settings-cached may retain stale Setting values across tests when the cache isn't explicitly invalidated. Ensure both ENV vars AND Setting values are cleared with Setting.clear_cache before assertions. https://claude.ai/code/session_019Tr6edJa496V1ErGmsbqFU --------- Co-authored-by: Claude --- app/controllers/api/v1/auth_controller.rb | 22 ++++++--- app/controllers/oidc_accounts_controller.rb | 45 ++++++++++++++----- app/controllers/sessions_controller.rb | 8 +++- app/views/oidc_accounts/link.html.erb | 2 +- config/locales/views/oidc_accounts/en.yml | 1 + mobile/lib/providers/auth_provider.dart | 4 ++ mobile/lib/screens/sso_onboarding_screen.dart | 18 ++++++-- mobile/lib/services/auth_service.dart | 1 + .../settings/hostings_controller_test.rb | 3 ++ test/models/assistant_test.rb | 13 ++++++ 10 files changed, 93 insertions(+), 24 deletions(-) diff --git a/app/controllers/api/v1/auth_controller.rb b/app/controllers/api/v1/auth_controller.rb index 25140f3ea..e522fb03f 100644 --- a/app/controllers/api/v1/auth_controller.rb +++ b/app/controllers/api/v1/auth_controller.rb @@ -178,7 +178,10 @@ module Api email = cached[:email] - unless cached[:allow_account_creation] + # Check for a pending invitation for this email + invitation = Invitation.pending.find_by(email: email) + + unless invitation.present? || cached[:allow_account_creation] render json: { error: "SSO account creation is disabled. Please contact an administrator." }, status: :forbidden return end @@ -193,13 +196,22 @@ module Api skip_password_validation: true ) - user.family = Family.new + if invitation.present? + # Accept the pending invitation: join the existing family + user.family_id = invitation.family_id + user.role = invitation.role + else + user.family = Family.new - provider_config = Rails.configuration.x.auth.sso_providers&.find { |p| p[:name] == cached[:provider] } - provider_default_role = provider_config&.dig(:settings, :default_role) - user.role = User.role_for_new_family_creator(fallback_role: provider_default_role || :admin) + provider_config = Rails.configuration.x.auth.sso_providers&.find { |p| p[:name] == cached[:provider] } + provider_default_role = provider_config&.dig(:settings, :default_role) + user.role = User.role_for_new_family_creator(fallback_role: provider_default_role || :admin) + end if user.save + # Mark invitation as accepted if one was used + invitation&.update!(accepted_at: Time.current) + OidcIdentity.create_from_omniauth(build_omniauth_hash(cached), user) SsoAuditLog.log_jit_account_created!( diff --git a/app/controllers/oidc_accounts_controller.rb b/app/controllers/oidc_accounts_controller.rb index cd46bf30e..25548995d 100644 --- a/app/controllers/oidc_accounts_controller.rb +++ b/app/controllers/oidc_accounts_controller.rb @@ -14,9 +14,12 @@ class OidcAccountsController < ApplicationController @email = @pending_auth["email"] @user_exists = User.exists?(email: @email) if @email.present? + # Check for a pending invitation for this email + @pending_invitation = Invitation.pending.find_by(email: @email) if @email.present? + # Determine whether we should offer JIT account creation for this # pending auth, based on JIT mode and allowed domains. - @allow_account_creation = !AuthConfig.jit_link_only? && AuthConfig.allowed_oidc_domain?(@email) + @allow_account_creation = @pending_invitation.present? || (!AuthConfig.jit_link_only? && AuthConfig.allowed_oidc_domain?(@email)) end def create_link @@ -94,10 +97,13 @@ class OidcAccountsController < ApplicationController email = @pending_auth["email"] + # Check for a pending invitation for this email + invitation = Invitation.pending.find_by(email: email) + # Respect global JIT configuration: in link_only mode or when the email - # domain is not allowed, block JIT account creation and send the user - # back to the login page with a clear message. - unless !AuthConfig.jit_link_only? && AuthConfig.allowed_oidc_domain?(email) + # domain is not allowed, block JIT account creation—unless there's a + # pending invitation for this user. + unless invitation.present? || (!AuthConfig.jit_link_only? && AuthConfig.allowed_oidc_domain?(email)) redirect_to new_session_path, alert: "SSO account creation is disabled. Please contact an administrator." return end @@ -115,14 +121,20 @@ class OidcAccountsController < ApplicationController skip_password_validation: true ) - # Create new family for this user - @user.family = Family.new + if invitation.present? + # Accept the pending invitation: join the existing family + @user.family_id = invitation.family_id + @user.role = invitation.role + else + # Create new family for this user + @user.family = Family.new - # Use provider-configured default role, or fall back to admin for family creators - # First user of an instance always becomes super_admin regardless of provider config - provider_config = Rails.configuration.x.auth.sso_providers&.find { |p| p[:name] == @pending_auth["provider"] } - provider_default_role = provider_config&.dig(:settings, :default_role) - @user.role = User.role_for_new_family_creator(fallback_role: provider_default_role || :admin) + # Use provider-configured default role, or fall back to admin for family creators + # First user of an instance always becomes super_admin regardless of provider config + provider_config = Rails.configuration.x.auth.sso_providers&.find { |p| p[:name] == @pending_auth["provider"] } + provider_default_role = provider_config&.dig(:settings, :default_role) + @user.role = User.role_for_new_family_creator(fallback_role: provider_default_role || :admin) + end if @user.save # Create the OIDC (or other SSO) identity @@ -140,11 +152,20 @@ class OidcAccountsController < ApplicationController ) end + # Mark invitation as accepted if one was used + invitation&.update!(accepted_at: Time.current) + # Clear pending auth from session session.delete(:pending_oidc_auth) @session = create_session_for(@user) - notice = accept_pending_invitation_for(@user) ? t("invitations.accept_choice.joined_household") : "Welcome! Your account has been created." + notice = if invitation.present? + t("invitations.accept_choice.joined_household") + elsif accept_pending_invitation_for(@user) + t("invitations.accept_choice.joined_household") + else + "Welcome! Your account has been created." + end redirect_to root_path, notice: notice else render :new_user, status: :unprocessable_entity diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 7d56a96fe..ea2f37c08 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -277,6 +277,9 @@ class SessionsController < ApplicationController device_info = session.delete(:mobile_sso) email = auth.info&.email + has_pending_invitation = email.present? && Invitation.pending.exists?(email: email) + allow_creation = has_pending_invitation || (!AuthConfig.jit_link_only? && AuthConfig.allowed_oidc_domain?(email)) + linking_code = SecureRandom.urlsafe_base64(32) Rails.cache.write( "mobile_sso_link:#{linking_code}", @@ -289,7 +292,7 @@ class SessionsController < ApplicationController name: auth.info&.name, issuer: auth.extra&.raw_info&.iss || auth.extra&.raw_info&.[]("iss"), device_info: device_info, - allow_account_creation: !AuthConfig.jit_link_only? && AuthConfig.allowed_oidc_domain?(email) + allow_account_creation: allow_creation }, expires_in: 10.minutes ) @@ -300,7 +303,8 @@ class SessionsController < ApplicationController email: email, first_name: auth.info&.first_name, last_name: auth.info&.last_name, - allow_account_creation: !AuthConfig.jit_link_only? && AuthConfig.allowed_oidc_domain?(email) + allow_account_creation: allow_creation, + has_pending_invitation: has_pending_invitation ) end diff --git a/app/views/oidc_accounts/link.html.erb b/app/views/oidc_accounts/link.html.erb index c97c3cd3f..d01b7c2db 100644 --- a/app/views/oidc_accounts/link.html.erb +++ b/app/views/oidc_accounts/link.html.erb @@ -54,7 +54,7 @@ <% if @allow_account_creation %> <%= render DS::Button.new( - text: t("oidc_accounts.link.submit_create"), + text: @pending_invitation ? t("oidc_accounts.link.submit_accept_invitation") : t("oidc_accounts.link.submit_create"), href: create_user_oidc_account_path, full_width: true, variant: :primary, diff --git a/config/locales/views/oidc_accounts/en.yml b/config/locales/views/oidc_accounts/en.yml index 4ed91677c..e04729307 100644 --- a/config/locales/views/oidc_accounts/en.yml +++ b/config/locales/views/oidc_accounts/en.yml @@ -18,6 +18,7 @@ en: info_email: "Email:" info_name: "Name:" submit_create: Create Account + submit_accept_invitation: Accept Invitation account_creation_disabled: New account creation via single sign-on is disabled. Please contact an administrator to create your account. cancel: Cancel new_user: diff --git a/mobile/lib/providers/auth_provider.dart b/mobile/lib/providers/auth_provider.dart index c2e8bdcfc..884ef3d53 100644 --- a/mobile/lib/providers/auth_provider.dart +++ b/mobile/lib/providers/auth_provider.dart @@ -29,6 +29,7 @@ class AuthProvider with ChangeNotifier { String? _ssoFirstName; String? _ssoLastName; bool _ssoAllowAccountCreation = false; + bool _ssoHasPendingInvitation = false; User? get user => _user; bool get isIntroLayout => _user?.isIntroLayout ?? false; @@ -51,6 +52,7 @@ class AuthProvider with ChangeNotifier { String? get ssoFirstName => _ssoFirstName; String? get ssoLastName => _ssoLastName; bool get ssoAllowAccountCreation => _ssoAllowAccountCreation; + bool get ssoHasPendingInvitation => _ssoHasPendingInvitation; AuthProvider() { _loadStoredAuth(); @@ -294,6 +296,7 @@ class AuthProvider with ChangeNotifier { _ssoFirstName = result['first_name'] as String?; _ssoLastName = result['last_name'] as String?; _ssoAllowAccountCreation = result['allow_account_creation'] == true; + _ssoHasPendingInvitation = result['has_pending_invitation'] == true; _isLoading = false; notifyListeners(); return false; @@ -410,6 +413,7 @@ class AuthProvider with ChangeNotifier { _ssoFirstName = null; _ssoLastName = null; _ssoAllowAccountCreation = false; + _ssoHasPendingInvitation = false; } Future logout() async { diff --git a/mobile/lib/screens/sso_onboarding_screen.dart b/mobile/lib/screens/sso_onboarding_screen.dart index c98a2c3de..3fee35077 100644 --- a/mobile/lib/screens/sso_onboarding_screen.dart +++ b/mobile/lib/screens/sso_onboarding_screen.dart @@ -148,7 +148,9 @@ class _SsoOnboardingScreenState extends State { ), Expanded( child: _TabButton( - label: 'Create New', + label: authProvider.ssoHasPendingInvitation + ? 'Accept Invitation' + : 'Create New', isSelected: !_showLinkForm, onTap: () => setState(() => _showLinkForm = false), @@ -258,6 +260,7 @@ class _SsoOnboardingScreenState extends State { } Widget _buildCreateForm(AuthProvider authProvider, ColorScheme colorScheme) { + final hasPendingInvitation = authProvider.ssoHasPendingInvitation; return Form( key: _createFormKey, child: Column( @@ -271,11 +274,16 @@ class _SsoOnboardingScreenState extends State { ), child: Row( children: [ - Icon(Icons.person_add, color: colorScheme.primary), + Icon( + hasPendingInvitation ? Icons.mail_outline : Icons.person_add, + color: colorScheme.primary, + ), const SizedBox(width: 12), Expanded( child: Text( - 'Create a new account using your Google identity.', + hasPendingInvitation + ? 'You have a pending invitation. Accept it to join an existing household.' + : 'Create a new account using your Google identity.', style: TextStyle(color: colorScheme.onSurface), ), ), @@ -318,7 +326,9 @@ class _SsoOnboardingScreenState extends State { width: 20, child: CircularProgressIndicator(strokeWidth: 2), ) - : const Text('Create Account'), + : Text(hasPendingInvitation + ? 'Accept Invitation' + : 'Create Account'), ), ], ), diff --git a/mobile/lib/services/auth_service.dart b/mobile/lib/services/auth_service.dart index a89f25c3e..e31c34554 100644 --- a/mobile/lib/services/auth_service.dart +++ b/mobile/lib/services/auth_service.dart @@ -374,6 +374,7 @@ class AuthService { 'first_name': params['first_name'] ?? '', 'last_name': params['last_name'] ?? '', 'allow_account_creation': params['allow_account_creation'] == 'true', + 'has_pending_invitation': params['has_pending_invitation'] == 'true', }; } diff --git a/test/controllers/settings/hostings_controller_test.rb b/test/controllers/settings/hostings_controller_test.rb index f211e07bf..91f8b1f26 100644 --- a/test/controllers/settings/hostings_controller_test.rb +++ b/test/controllers/settings/hostings_controller_test.rb @@ -210,6 +210,9 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest delete disconnect_external_assistant_settings_hosting_url assert_redirected_to settings_hosting_url + # Force cache refresh so configured? reads fresh DB state after + # the disconnect action cleared the settings within its own request. + Setting.clear_cache assert_not Assistant::External.configured? assert_equal "builtin", users(:family_admin).family.reload.assistant_type end diff --git a/test/models/assistant_test.rb b/test/models/assistant_test.rb index c07859090..b396cf7ed 100644 --- a/test/models/assistant_test.rb +++ b/test/models/assistant_test.rb @@ -237,6 +237,12 @@ class AssistantTest < ActiveSupport::TestCase "EXTERNAL_ASSISTANT_URL" => nil, "EXTERNAL_ASSISTANT_TOKEN" => nil ) do + # Ensure Settings are also cleared to avoid test pollution from + # other tests that may have set these values in the same process. + Setting.external_assistant_url = nil + Setting.external_assistant_token = nil + Setting.clear_cache + assert_no_difference "AssistantMessage.count" do assistant.respond_to(@message) end @@ -245,6 +251,9 @@ class AssistantTest < ActiveSupport::TestCase assert @chat.error.present? assert_includes @chat.error, "not configured" end + ensure + Setting.external_assistant_url = nil + Setting.external_assistant_token = nil end test "external assistant adds error on connection failure" do @@ -323,6 +332,10 @@ class AssistantTest < ActiveSupport::TestCase # Phase 1: Without config, errors gracefully with_env_overrides("EXTERNAL_ASSISTANT_URL" => nil, "EXTERNAL_ASSISTANT_TOKEN" => nil) do + Setting.external_assistant_url = nil + Setting.external_assistant_token = nil + Setting.clear_cache + assistant = Assistant::External.new(@chat) assistant.respond_to(@message) @chat.reload From 674799a6e03b563598b8577a0eabb70329362bc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Tue, 10 Mar 2026 13:44:53 +0100 Subject: [PATCH 49/75] Enforce one pending invitation per email across all families (#1173) * Enforce one pending invitation per email across all families Users can only belong to one family, so allowing the same email to have pending invitations from multiple families leads to ambiguous behavior. Add a `no_other_pending_invitation` validation on create to prevent this. Accepted and expired invitations from other families are not blocked. Fixes #1172 https://claude.ai/code/session_016fGqgha18jP48dhznm6k4m * Normalize email before validation and use case-insensitive lookup When ActiveRecord encryption is not configured, the email column stores raw values preserving original casing. The prior validation used a direct equality match which would miss case variants (e.g. Case@Test.com vs case@test.com), leaving a gap in the cross-family uniqueness guarantee. Fix by: 1. Adding a normalize_email callback that downcases/strips email before validation, so all new records store lowercase consistently. 2. Using LOWER() in the SQL query for non-encrypted deployments to catch any pre-existing mixed-case records. https://claude.ai/code/session_016fGqgha18jP48dhznm6k4m --------- Co-authored-by: Claude --- app/models/invitation.rb | 20 ++++++++++++ test/models/invitation_test.rb | 60 ++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/app/models/invitation.rb b/app/models/invitation.rb index afafd7852..e2900c7cf 100644 --- a/app/models/invitation.rb +++ b/app/models/invitation.rb @@ -15,7 +15,9 @@ class Invitation < ApplicationRecord validates :token, presence: true, uniqueness: true validates_uniqueness_of :email, scope: :family_id, message: "has already been invited to this family" validate :inviter_is_admin + validate :no_other_pending_invitation, on: :create + before_validation :normalize_email before_validation :generate_token, on: :create before_create :set_expiration @@ -57,6 +59,24 @@ class Invitation < ApplicationRecord self.expires_at = 3.days.from_now end + def normalize_email + self.email = email.to_s.strip.downcase if email.present? + end + + def no_other_pending_invitation + return if email.blank? + + existing = if self.class.encryption_ready? + self.class.pending.where(email: email).where.not(family_id: family_id).exists? + else + self.class.pending.where("LOWER(email) = ?", email.downcase).where.not(family_id: family_id).exists? + end + + if existing + errors.add(:email, "already has a pending invitation from another family") + end + end + def inviter_is_admin inviter.admin? end diff --git a/test/models/invitation_test.rb b/test/models/invitation_test.rb index 710b4447e..9895538e8 100644 --- a/test/models/invitation_test.rb +++ b/test/models/invitation_test.rb @@ -62,6 +62,66 @@ class InvitationTest < ActiveSupport::TestCase assert_not result end + test "cannot create invitation when email has pending invitation from another family" do + other_family = families(:empty) + other_inviter = users(:empty) + other_inviter.update_columns(family_id: other_family.id, role: "admin") + + email = "cross-family-test@example.com" + + # Create a pending invitation in the first family + @family.invitations.create!(email: email, role: "member", inviter: @inviter) + + # Attempting to create a pending invitation in a different family should fail + invitation = other_family.invitations.build(email: email, role: "member", inviter: other_inviter) + assert_not invitation.valid? + assert_includes invitation.errors[:email], "already has a pending invitation from another family" + end + + test "can create invitation when existing invitation from another family is accepted" do + other_family = families(:empty) + other_inviter = users(:empty) + other_inviter.update_columns(family_id: other_family.id, role: "admin") + + email = "cross-family-accepted@example.com" + + # Create an accepted invitation in the first family + accepted_invitation = @family.invitations.create!(email: email, role: "member", inviter: @inviter) + accepted_invitation.update!(accepted_at: Time.current) + + # Should be able to create a pending invitation in a different family + invitation = other_family.invitations.build(email: email, role: "member", inviter: other_inviter) + assert invitation.valid? + end + + test "can create invitation when existing invitation from another family is expired" do + other_family = families(:empty) + other_inviter = users(:empty) + other_inviter.update_columns(family_id: other_family.id, role: "admin") + + email = "cross-family-expired@example.com" + + # Create an expired invitation in the first family + expired_invitation = @family.invitations.create!(email: email, role: "member", inviter: @inviter) + expired_invitation.update_columns(expires_at: 1.day.ago) + + # Should be able to create a pending invitation in a different family + invitation = other_family.invitations.build(email: email, role: "member", inviter: other_inviter) + assert invitation.valid? + end + + test "can create invitation in same family (uniqueness scoped to family)" do + email = "same-family-test@example.com" + + # Create a pending invitation in the family + @family.invitations.create!(email: email, role: "member", inviter: @inviter) + + # Attempting to create another in the same family should fail due to the existing scope validation + invitation = @family.invitations.build(email: email, role: "admin", inviter: @inviter) + assert_not invitation.valid? + assert_includes invitation.errors[:email], "has already been invited to this family" + end + test "accept_for applies guest role defaults" do user = users(:family_member) user.update!( From 7ae9077935f01bb6cdefdbc65f89199dfbd2d875 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Tue, 10 Mar 2026 18:12:53 +0100 Subject: [PATCH 50/75] Add default family selection for invite-only onboarding mode (#1174) * Add default family selection for invite-only onboarding mode When onboarding is set to invite-only, admins can now choose a default family that new users without an invitation are automatically placed into as members, instead of creating a new family for each signup. https://claude.ai/code/session_01U9KgikKjV6xbyBZ5wMYsYx * Restrict invite codes and onboarding settings to super_admin only The Invite Codes section on /settings/hosting was visible to any authenticated user via the show action, leaking all family names/IDs through the default-family dropdown. This tightens access: - Hide the entire Invite Codes section in the view behind super_admin? - Add before_action :ensure_super_admin to InviteCodesController for all actions (index, create, destroy), replacing the inline admin? check - Add ensure_super_admin_for_onboarding filter on hostings#update that blocks non-super_admin users from changing onboarding_state or invite_only_default_family_id https://claude.ai/code/session_01U9KgikKjV6xbyBZ5wMYsYx * Fix tests for super_admin-only invite codes and onboarding settings - Hostings controller test: sign in as sure_support_staff (super_admin) for the onboarding_state update test, since ensure_super_admin_for_onboarding now requires super_admin role - Invite codes tests: use super_admin fixture for the success case and verify that a regular admin gets redirected instead of raising StandardError https://claude.ai/code/session_01U9KgikKjV6xbyBZ5wMYsYx * Fix system test to use super_admin for self-hosting settings The invite codes section is now only visible to super_admin users, so the system test needs to sign in as sure_support_staff to find the onboarding_state select element. https://claude.ai/code/session_01U9KgikKjV6xbyBZ5wMYsYx * Skip invite code requirement when a default family is configured When onboarding is invite-only but a default family is set, the claim_invite_code before_action was blocking registration before the create action could assign the user to the default family. Now invite_code_required? returns false when invite_only_default_family_id is present, allowing codeless signups to land in the configured default family. https://claude.ai/code/session_01U9KgikKjV6xbyBZ5wMYsYx --------- Co-authored-by: Claude --- app/controllers/concerns/invitable.rb | 2 +- app/controllers/invite_codes_controller.rb | 6 ++++- app/controllers/registrations_controller.rb | 5 ++++ .../settings/hostings_controller.rb | 14 ++++++++++- app/models/setting.rb | 1 + .../hostings/_invite_code_settings.html.erb | 23 +++++++++++++++++++ app/views/settings/hostings/show.html.erb | 6 +++-- config/locales/views/settings/hostings/en.yml | 3 +++ .../invite_codes_controller_test.rb | 14 +++++++---- .../settings/hostings_controller_test.rb | 2 ++ test/system/settings_test.rb | 1 + 11 files changed, 67 insertions(+), 10 deletions(-) diff --git a/app/controllers/concerns/invitable.rb b/app/controllers/concerns/invitable.rb index a295e859f..dc93b30ec 100644 --- a/app/controllers/concerns/invitable.rb +++ b/app/controllers/concerns/invitable.rb @@ -9,7 +9,7 @@ module Invitable def invite_code_required? return false if @invitation.present? if self_hosted? - Setting.onboarding_state == "invite_only" + Setting.onboarding_state == "invite_only" && Setting.invite_only_default_family_id.blank? else ENV["REQUIRE_INVITE_CODE"] == "true" end diff --git a/app/controllers/invite_codes_controller.rb b/app/controllers/invite_codes_controller.rb index e97cb6ec0..f9bcf6760 100644 --- a/app/controllers/invite_codes_controller.rb +++ b/app/controllers/invite_codes_controller.rb @@ -1,12 +1,12 @@ class InviteCodesController < ApplicationController before_action :ensure_self_hosted + before_action :ensure_super_admin def index @invite_codes = InviteCode.all end def create - raise StandardError, "You are not allowed to generate invite codes" unless Current.user.admin? InviteCode.generate! redirect_back_or_to invite_codes_path, notice: "Code generated" end @@ -22,4 +22,8 @@ class InviteCodesController < ApplicationController def ensure_self_hosted redirect_to root_path unless self_hosted? end + + def ensure_super_admin + redirect_to root_path, alert: t("settings.hostings.not_authorized") unless Current.user.super_admin? + end end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 93cc303bd..074f46cbd 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -18,6 +18,11 @@ class RegistrationsController < ApplicationController @user.family = @invitation.family @user.role = @invitation.role @user.email = @invitation.email + elsif (default_family_id = Setting.invite_only_default_family_id).present? && + Setting.onboarding_state == "invite_only" && + (default_family = Family.find_by(id: default_family_id)) + @user.family = default_family + @user.role = :member else family = Family.new @user.family = family diff --git a/app/controllers/settings/hostings_controller.rb b/app/controllers/settings/hostings_controller.rb index e63a65c71..f3a63e9a7 100644 --- a/app/controllers/settings/hostings_controller.rb +++ b/app/controllers/settings/hostings_controller.rb @@ -4,6 +4,7 @@ class Settings::HostingsController < ApplicationController guard_feature unless: -> { self_hosted? } before_action :ensure_admin, only: [ :update, :clear_cache, :disconnect_external_assistant ] + before_action :ensure_super_admin_for_onboarding, only: :update def show @breadcrumbs = [ @@ -43,6 +44,11 @@ class Settings::HostingsController < ApplicationController Setting.require_email_confirmation = hosting_params[:require_email_confirmation] end + if hosting_params.key?(:invite_only_default_family_id) + value = hosting_params[:invite_only_default_family_id].presence + Setting.invite_only_default_family_id = value + end + if hosting_params.key?(:brand_fetch_client_id) Setting.brand_fetch_client_id = hosting_params[:brand_fetch_client_id] end @@ -160,7 +166,7 @@ class Settings::HostingsController < ApplicationController private def hosting_params return ActionController::Parameters.new unless params.key?(:setting) - params.require(:setting).permit(:onboarding_state, :require_email_confirmation, :brand_fetch_client_id, :brand_fetch_high_res_logos, :twelve_data_api_key, :openai_access_token, :openai_uri_base, :openai_model, :openai_json_mode, :exchange_rate_provider, :securities_provider, :syncs_include_pending, :auto_sync_enabled, :auto_sync_time, :external_assistant_url, :external_assistant_token, :external_assistant_agent_id) + params.require(:setting).permit(:onboarding_state, :require_email_confirmation, :invite_only_default_family_id, :brand_fetch_client_id, :brand_fetch_high_res_logos, :twelve_data_api_key, :openai_access_token, :openai_uri_base, :openai_model, :openai_json_mode, :exchange_rate_provider, :securities_provider, :syncs_include_pending, :auto_sync_enabled, :auto_sync_time, :external_assistant_url, :external_assistant_token, :external_assistant_agent_id) end def update_assistant_type @@ -175,6 +181,12 @@ class Settings::HostingsController < ApplicationController redirect_to settings_hosting_path, alert: t(".not_authorized") unless Current.user.admin? end + def ensure_super_admin_for_onboarding + onboarding_params = %i[onboarding_state invite_only_default_family_id] + return unless onboarding_params.any? { |p| hosting_params.key?(p) } + redirect_to settings_hosting_path, alert: t(".not_authorized") unless Current.user.super_admin? + end + def sync_auto_sync_scheduler! AutoSyncScheduler.sync! rescue StandardError => error diff --git a/app/models/setting.rb b/app/models/setting.rb index 376dedc27..a53e70273 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -73,6 +73,7 @@ class Setting < RailsSettings::Base field :onboarding_state, type: :string, default: DEFAULT_ONBOARDING_STATE field :require_invite_for_signup, type: :boolean, default: false field :require_email_confirmation, type: :boolean, default: ENV.fetch("REQUIRE_EMAIL_CONFIRMATION", "true") == "true" + field :invite_only_default_family_id, type: :string, default: nil def self.validate_onboarding_state!(state) return if ONBOARDING_STATES.include?(state) diff --git a/app/views/settings/hostings/_invite_code_settings.html.erb b/app/views/settings/hostings/_invite_code_settings.html.erb index 14e4439e3..cb02f7757 100644 --- a/app/views/settings/hostings/_invite_code_settings.html.erb +++ b/app/views/settings/hostings/_invite_code_settings.html.erb @@ -40,6 +40,29 @@
    <% if Setting.onboarding_state == "invite_only" %> +
    +
    +

    <%= t(".default_family_title") %>

    +

    <%= t(".default_family_description") %>

    +
    + + <%= styled_form_with model: Setting.new, + url: settings_hosting_path, + method: :patch, + data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "change" } do |form| %> +
    + <%= form.select :invite_only_default_family_id, + options_for_select( + [ [ t(".default_family_none"), "" ] ] + + Family.all.map { |f| [ f.name, f.id ] }, + Setting.invite_only_default_family_id + ), + { label: false }, + { data: { auto_submit_form_target: "auto" } } %> +
    + <% end %> +
    +
    <%= t(".generated_tokens") %> diff --git a/app/views/settings/hostings/show.html.erb b/app/views/settings/hostings/show.html.erb index adb78ec51..354cf86a4 100644 --- a/app/views/settings/hostings/show.html.erb +++ b/app/views/settings/hostings/show.html.erb @@ -22,8 +22,10 @@ <%= settings_section title: t(".sync_settings") do %> <%= render "settings/hostings/sync_settings" %> <% end %> -<%= settings_section title: t(".invites") do %> - <%= render "settings/hostings/invite_code_settings" %> +<% if Current.user.super_admin? %> + <%= settings_section title: t(".invites") do %> + <%= render "settings/hostings/invite_code_settings" %> + <% end %> <% end %> <%= settings_section title: t(".danger_zone") do %> <%= render "settings/hostings/danger_zone_settings" %> diff --git a/config/locales/views/settings/hostings/en.yml b/config/locales/views/settings/hostings/en.yml index cfe44a8ad..814d0b13c 100644 --- a/config/locales/views/settings/hostings/en.yml +++ b/config/locales/views/settings/hostings/en.yml @@ -7,6 +7,9 @@ en: email_confirmation_description: When enabled, users must confirm their email address when changing it. email_confirmation_title: Require email confirmation + default_family_title: Default family for new users + default_family_description: "Put new users on this family/group only if they have no invitation." + default_family_none: None (create new family) generate_tokens: Generate new code generated_tokens: Generated codes title: Onboarding diff --git a/test/controllers/invite_codes_controller_test.rb b/test/controllers/invite_codes_controller_test.rb index ea39395fb..2403a3732 100644 --- a/test/controllers/invite_codes_controller_test.rb +++ b/test/controllers/invite_codes_controller_test.rb @@ -4,17 +4,21 @@ class InviteCodesControllerTest < ActionDispatch::IntegrationTest setup do Rails.application.config.app_mode.stubs(:self_hosted?).returns(true) end - test "admin can generate invite codes" do - sign_in users(:family_admin) + test "super admin can generate invite codes" do + sign_in users(:sure_support_staff) assert_difference("InviteCode.count") do post invite_codes_url, params: {} end end - test "non-admin cannot generate invite codes" do - sign_in users(:family_member) + test "non-super-admin cannot generate invite codes" do + sign_in users(:family_admin) - assert_raises(StandardError) { post invite_codes_url, params: {} } + assert_no_difference("InviteCode.count") do + post invite_codes_url, params: {} + end + + assert_redirected_to root_path end end diff --git a/test/controllers/settings/hostings_controller_test.rb b/test/controllers/settings/hostings_controller_test.rb index 91f8b1f26..f4706c07c 100644 --- a/test/controllers/settings/hostings_controller_test.rb +++ b/test/controllers/settings/hostings_controller_test.rb @@ -51,6 +51,8 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest end test "can update onboarding state when self hosting is enabled" do + sign_in users(:sure_support_staff) + with_self_hosting do patch settings_hosting_url, params: { setting: { onboarding_state: "invite_only" } } diff --git a/test/system/settings_test.rb b/test/system/settings_test.rb index 25f2fee70..d994881c5 100644 --- a/test/system/settings_test.rb +++ b/test/system/settings_test.rb @@ -44,6 +44,7 @@ class SettingsTest < ApplicationSystemTestCase end test "can update self hosting settings" do + sign_in users(:sure_support_staff) Rails.application.config.app_mode.stubs(:self_hosted?).returns(true) Provider::Registry.stubs(:get_provider).with(:twelve_data).returns(nil) Provider::Registry.stubs(:get_provider).with(:yahoo_finance).returns(nil) From e1ff6d46ee125e84d3f40fc0e76e797f6f211940 Mon Sep 17 00:00:00 2001 From: soky srm Date: Wed, 11 Mar 2026 15:54:01 +0100 Subject: [PATCH 51/75] Make categories global (#1160) * Make categories global This solves us A LOT of cash flow and budgeting problems. * Update schema.rb * Update auto_categorizer.rb * Update income_statement.rb * FIX budget sub-categories * FIX sub-categories and tests * Add 2 step migration --- .../api/v1/categories_controller.rb | 5 - app/controllers/categories_controller.rb | 2 +- app/controllers/pages_controller.rb | 218 +++++++++++------- app/controllers/transactions_controller.rb | 3 +- app/controllers/transfers_controller.rb | 2 +- app/helpers/imports_helper.rb | 1 - app/models/budget.rb | 36 +-- app/models/category.rb | 59 +++-- app/models/category_import.rb | 14 +- app/models/demo/generator.rb | 46 ++-- app/models/family.rb | 1 - app/models/family/auto_categorizer.rb | 3 +- app/models/family/data_exporter.rb | 3 +- app/models/income_statement.rb | 60 +++++ .../transactions/category_matcher.rb | 1 - .../provider/openai/auto_categorizer.rb | 2 +- app/models/rule_import.rb | 2 - app/views/accounts/index.html.erb | 2 +- .../api/v1/categories/_category.json.jbuilder | 1 - .../transactions/_transaction.json.jbuilder | 1 - app/views/categories/_form.html.erb | 1 - app/views/categories/index.html.erb | 8 +- app/views/oidc_accounts/link.html.erb | 2 +- app/views/onboardings/show.html.erb | 3 +- app/views/pages/dashboard.html.erb | 2 +- app/views/pages/intro.html.erb | 2 +- .../properties/_overview_fields.html.erb | 1 - app/views/reports/_breakdown_table.html.erb | 6 +- app/views/settings/payments/show.html.erb | 4 +- app/views/subscriptions/upgrade.html.erb | 16 +- app/views/transactions/_form.html.erb | 3 +- app/views/transactions/_header.html.erb | 2 +- app/views/transactions/_list.html.erb | 2 +- app/views/transactions/_upcoming.html.erb | 2 +- app/views/transactions/new.html.erb | 2 +- ...6_remove_classification_from_categories.rb | 9 + db/schema.rb | 4 +- spec/requests/api/v1/categories_spec.rb | 14 -- spec/requests/api/v1/trades_spec.rb | 1 - spec/requests/api/v1/transactions_spec.rb | 1 - spec/swagger_helper.rb | 6 +- .../api/v1/categories_controller_test.rb | 16 +- test/controllers/pages_controller_test.rb | 4 +- test/controllers/reports_controller_test.rb | 9 +- test/models/budget_category_test.rb | 18 +- test/models/budget_test.rb | 10 +- test/models/category_import_test.rb | 30 ++- test/models/family/data_exporter_test.rb | 2 +- test/models/family_test.rb | 5 - test/models/income_statement_test.rb | 47 +++- .../transactions/category_matcher_test.rb | 6 +- test/models/rule_import_test.rb | 2 - test/models/transaction/search_test.rb | 3 +- test/test_helper.rb | 1 - 54 files changed, 393 insertions(+), 313 deletions(-) create mode 100644 db/migrate/20260308113006_remove_classification_from_categories.rb diff --git a/app/controllers/api/v1/categories_controller.rb b/app/controllers/api/v1/categories_controller.rb index 571cd93ce..c810ffa25 100644 --- a/app/controllers/api/v1/categories_controller.rb +++ b/app/controllers/api/v1/categories_controller.rb @@ -62,11 +62,6 @@ class Api::V1::CategoriesController < Api::V1::BaseController end def apply_filters(query) - # Filter by classification (income/expense) - if params[:classification].present? - query = query.where(classification: params[:classification]) - end - # Filter for root categories only (no parent) if params[:roots_only].present? && ActiveModel::Type::Boolean.new.cast(params[:roots_only]) query = query.roots diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index b1516f863..6d5e6b9fc 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -87,6 +87,6 @@ class CategoriesController < ApplicationController end def category_params - params.require(:category).permit(:name, :color, :parent_id, :classification, :lucide_icon) + params.require(:category).permit(:name, :color, :parent_id, :lucide_icon) end end diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 7f2aa6785..29a00cdf8 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -16,11 +16,13 @@ class PagesController < ApplicationController family_currency = Current.family.currency # 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) + income_statement = Current.family.income_statement + income_totals = income_statement.income_totals(period: @period) + expense_totals = income_statement.expense_totals(period: @period) + net_totals = income_statement.net_category_totals(period: @period) - @cashflow_sankey_data = build_cashflow_sankey_data(income_totals, expense_totals, family_currency) - @outflows_data = build_outflows_donut_data(expense_totals) + @cashflow_sankey_data = build_cashflow_sankey_data(net_totals, income_totals, expense_totals, family_currency) + @outflows_data = build_outflows_donut_data(net_totals) @dashboard_sections = build_dashboard_sections @@ -143,7 +145,7 @@ class PagesController < ApplicationController Provider::Registry.get_provider(:github) end - def build_cashflow_sankey_data(income_totals, expense_totals, currency) + def build_cashflow_sankey_data(net_totals, income_totals, expense_totals, currency) nodes = [] links = [] node_indices = {} @@ -155,30 +157,33 @@ class PagesController < ApplicationController end } - total_income = income_totals.total.to_f.round(2) - total_expense = expense_totals.total.to_f.round(2) + total_income = net_totals.total_net_income.to_f.round(2) + total_expense = net_totals.total_net_expense.to_f.round(2) # Central Cash Flow node cash_flow_idx = add_node.call("cash_flow_node", "Cash Flow", total_income, 100.0, "var(--color-success)") - # Process income categories (flow: subcategory -> parent -> cash_flow) - process_category_totals( - category_totals: income_totals.category_totals, + # Build netted subcategory data from raw totals + net_subcategories_by_parent = build_net_subcategories(expense_totals, income_totals) + + # Process net income categories (flow: subcategory -> parent -> cash_flow) + process_net_category_nodes( + categories: net_totals.net_income_categories, total: total_income, prefix: "income", - default_color: Category::UNCATEGORIZED_COLOR, + net_subcategories_by_parent: net_subcategories_by_parent, add_node: add_node, links: links, cash_flow_idx: cash_flow_idx, flow_direction: :inbound ) - # Process expense categories (flow: cash_flow -> parent -> subcategory) - process_category_totals( - category_totals: expense_totals.category_totals, + # Process net expense categories (flow: cash_flow -> parent -> subcategory) + process_net_category_nodes( + categories: net_totals.net_expense_categories, total: total_expense, prefix: "expense", - default_color: Category::UNCATEGORIZED_COLOR, + net_subcategories_by_parent: net_subcategories_by_parent, add_node: add_node, links: links, cash_flow_idx: cash_flow_idx, @@ -196,12 +201,124 @@ class PagesController < ApplicationController { nodes: nodes, links: links, currency_symbol: Money::Currency.new(currency).symbol } end - def build_outflows_donut_data(expense_totals) - currency_symbol = Money::Currency.new(expense_totals.currency).symbol - total = expense_totals.total + # Nets subcategory expense and income totals, grouped by parent_id. + # Returns { parent_id => [ { category:, total: net_amount }, ... ] } + # Only includes subcategories with positive net (same direction as parent). + def build_net_subcategories(expense_totals, income_totals) + expense_subs = expense_totals.category_totals + .select { |ct| ct.category.parent_id.present? } + .index_by { |ct| ct.category.id } - categories = expense_totals.category_totals - .reject { |ct| ct.category.parent_id.present? || ct.total.zero? } + income_subs = income_totals.category_totals + .select { |ct| ct.category.parent_id.present? } + .index_by { |ct| ct.category.id } + + all_sub_ids = (expense_subs.keys + income_subs.keys).uniq + result = {} + + all_sub_ids.each do |sub_id| + exp_ct = expense_subs[sub_id] + inc_ct = income_subs[sub_id] + exp_total = exp_ct&.total || 0 + inc_total = inc_ct&.total || 0 + net = exp_total - inc_total + category = exp_ct&.category || inc_ct&.category + + next if net.zero? + + parent_id = category.parent_id + result[parent_id] ||= [] + result[parent_id] << { category: category, total: net.abs, net_direction: net > 0 ? :expense : :income } + end + + result + end + + # Builds sankey nodes/links for net categories with subcategory hierarchy. + # Subcategories matching the parent's flow direction are shown as children. + # Subcategories with opposite net direction appear on the OTHER side of the + # sankey (handled when the other side calls this method). + # + # flow_direction: :inbound (subcategory -> parent -> cash_flow) for income + # :outbound (cash_flow -> parent -> subcategory) for expenses + def process_net_category_nodes(categories:, total:, prefix:, net_subcategories_by_parent:, add_node:, links:, cash_flow_idx:, flow_direction:) + matching_direction = flow_direction == :inbound ? :income : :expense + + categories.each do |ct| + val = ct.total.to_f.round(2) + next if val.zero? + + percentage = total.zero? ? 0 : (val / total * 100).round(1) + color = ct.category.color.presence || Category::UNCATEGORIZED_COLOR + node_key = "#{prefix}_#{ct.category.id || ct.category.name}" + + all_subs = ct.category.id ? (net_subcategories_by_parent[ct.category.id] || []) : [] + same_side_subs = all_subs.select { |s| s[:net_direction] == matching_direction } + + # Also check if any subcategory has opposite direction — those will be + # rendered by the OTHER side's call to this method, linked to cash_flow + # directly (they appear as independent nodes on the opposite side). + opposite_subs = all_subs.select { |s| s[:net_direction] != matching_direction } + + if same_side_subs.any? + parent_idx = add_node.call(node_key, ct.category.name, val, percentage, color) + + if flow_direction == :inbound + links << { source: parent_idx, target: cash_flow_idx, value: val, color: color, percentage: percentage } + else + links << { source: cash_flow_idx, target: parent_idx, value: val, color: color, percentage: percentage } + end + + same_side_subs.each do |sub| + sub_val = sub[:total].to_f.round(2) + sub_pct = val.zero? ? 0 : (sub_val / val * 100).round(1) + sub_color = sub[:category].color.presence || color + sub_key = "#{prefix}_sub_#{sub[:category].id}" + sub_idx = add_node.call(sub_key, sub[:category].name, sub_val, sub_pct, sub_color) + + if flow_direction == :inbound + links << { source: sub_idx, target: parent_idx, value: sub_val, color: sub_color, percentage: sub_pct } + else + links << { source: parent_idx, target: sub_idx, value: sub_val, color: sub_color, percentage: sub_pct } + end + end + else + idx = add_node.call(node_key, ct.category.name, val, percentage, color) + + if flow_direction == :inbound + links << { source: idx, target: cash_flow_idx, value: val, color: color, percentage: percentage } + else + links << { source: cash_flow_idx, target: idx, value: val, color: color, percentage: percentage } + end + end + + # Render opposite-direction subcategories as standalone nodes on this side, + # linked directly to cash_flow. They represent subcategory surplus/deficit + # that goes against the parent's overall direction. + opposite_prefix = flow_direction == :inbound ? "expense" : "income" + opposite_subs.each do |sub| + sub_val = sub[:total].to_f.round(2) + sub_pct = total.zero? ? 0 : (sub_val / total * 100).round(1) + sub_color = sub[:category].color.presence || color + sub_key = "#{opposite_prefix}_sub_#{sub[:category].id}" + sub_idx = add_node.call(sub_key, sub[:category].name, sub_val, sub_pct, sub_color) + + # Opposite direction: if parent is outbound (expense), this sub is inbound (income) + if flow_direction == :inbound + links << { source: cash_flow_idx, target: sub_idx, value: sub_val, color: sub_color, percentage: sub_pct } + else + links << { source: sub_idx, target: cash_flow_idx, value: sub_val, color: sub_color, percentage: sub_pct } + end + end + end + end + + def build_outflows_donut_data(net_totals) + currency_symbol = Money::Currency.new(net_totals.currency).symbol + total = net_totals.total_net_expense + + categories = net_totals.net_expense_categories + .reject { |ct| ct.total.zero? } .sort_by { |ct| -ct.total } .map do |ct| { @@ -216,66 +333,7 @@ class PagesController < ApplicationController } end - { categories: categories, total: total.to_f.round(2), currency: expense_totals.currency, currency_symbol: currency_symbol } - end - - # Processes category totals for sankey diagram, handling parent/subcategory relationships. - # flow_direction: :inbound (subcategory -> parent -> cash_flow) for income - # :outbound (cash_flow -> parent -> subcategory) for expenses - def process_category_totals(category_totals:, total:, prefix:, default_color:, add_node:, links:, cash_flow_idx:, flow_direction:) - # Build lookup of subcategories by parent_id - subcategories_by_parent = category_totals - .select { |ct| ct.category.parent_id.present? && ct.total.to_f > 0 } - .group_by { |ct| ct.category.parent_id } - - category_totals.each do |ct| - next if ct.category.parent_id.present? # Skip subcategories in first pass - - val = ct.total.to_f.round(2) - next if val.zero? - - percentage = total.zero? ? 0 : (val / total * 100).round(1) - color = ct.category.color.presence || default_color - node_key = "#{prefix}_#{ct.category.id || ct.category.name}" - - subs = subcategories_by_parent[ct.category.id] || [] - - if subs.any? - parent_idx = add_node.call(node_key, ct.category.name, val, percentage, color) - - # Link parent to/from cash flow based on direction - if flow_direction == :inbound - links << { source: parent_idx, target: cash_flow_idx, value: val, color: color, percentage: percentage } - else - links << { source: cash_flow_idx, target: parent_idx, value: val, color: color, percentage: percentage } - end - - # Add subcategory nodes - subs.each do |sub_ct| - sub_val = sub_ct.total.to_f.round(2) - sub_pct = val.zero? ? 0 : (sub_val / val * 100).round(1) - sub_color = sub_ct.category.color.presence || color - sub_key = "#{prefix}_sub_#{sub_ct.category.id}" - sub_idx = add_node.call(sub_key, sub_ct.category.name, sub_val, sub_pct, sub_color) - - # Link subcategory to/from parent based on direction - if flow_direction == :inbound - links << { source: sub_idx, target: parent_idx, value: sub_val, color: sub_color, percentage: sub_pct } - else - links << { source: parent_idx, target: sub_idx, value: sub_val, color: sub_color, percentage: sub_pct } - end - end - else - # No subcategories, link directly to/from cash flow - idx = add_node.call(node_key, ct.category.name, val, percentage, color) - - if flow_direction == :inbound - links << { source: idx, target: cash_flow_idx, value: val, color: color, percentage: percentage } - else - links << { source: cash_flow_idx, target: idx, value: val, color: color, percentage: percentage } - end - end - end + { categories: categories, total: total.to_f.round(2), currency: net_totals.currency, currency_symbol: currency_symbol } end def ensure_intro_guest! diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index 0b775826d..076943529 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -6,8 +6,7 @@ class TransactionsController < ApplicationController def new super - @income_categories = Current.family.categories.incomes.alphabetically - @expense_categories = Current.family.categories.expenses.alphabetically + @categories = Current.family.categories.alphabetically end def index diff --git a/app/controllers/transfers_controller.rb b/app/controllers/transfers_controller.rb index 9579cadd0..e1fdac825 100644 --- a/app/controllers/transfers_controller.rb +++ b/app/controllers/transfers_controller.rb @@ -9,7 +9,7 @@ class TransfersController < ApplicationController end def show - @categories = Current.family.categories.expenses + @categories = Current.family.categories.alphabetically end def create diff --git a/app/helpers/imports_helper.rb b/app/helpers/imports_helper.rb index e7ff95e03..65ae6e43c 100644 --- a/app/helpers/imports_helper.rb +++ b/app/helpers/imports_helper.rb @@ -25,7 +25,6 @@ module ImportsHelper entity_type: "Type", category_parent: "Parent category", category_color: "Color", - category_classification: "Classification", category_icon: "Lucide icon" }[key] end diff --git a/app/models/budget.rb b/app/models/budget.rb index 0396fc30e..6c994ee1e 100644 --- a/app/models/budget.rb +++ b/app/models/budget.rb @@ -81,7 +81,7 @@ class Budget < ApplicationRecord end def sync_budget_categories - current_category_ids = family.categories.expenses.pluck(:id).to_set + current_category_ids = family.categories.pluck(:id).to_set existing_budget_category_ids = budget_categories.pluck(:category_id).to_set categories_to_add = current_category_ids - existing_budget_category_ids categories_to_remove = existing_budget_category_ids - current_category_ids @@ -157,11 +157,11 @@ class Budget < ApplicationRecord end def income_category_totals - income_totals.category_totals.reject { |ct| ct.category.subcategory? || ct.total.zero? }.sort_by(&:weight).reverse + net_totals.net_income_categories.reject { |ct| ct.total.zero? }.sort_by(&:weight).reverse end def expense_category_totals - expense_totals.category_totals.reject { |ct| ct.category.subcategory? || ct.total.zero? }.sort_by(&:weight).reverse + net_totals.net_expense_categories.reject { |ct| ct.total.zero? }.sort_by(&:weight).reverse end def current? @@ -214,11 +214,11 @@ class Budget < ApplicationRecord end def actual_spending - [ expense_totals.total - refunds_in_expense_categories, 0 ].max + net_totals.total_net_expense end def budget_category_actual_spending(budget_category) - key = budget_category.category_id || budget_category.category.name + key = budget_category.category_id || stable_synthetic_key(budget_category.category) expense = expense_totals_by_category[key]&.total || 0 refund = income_totals_by_category[key]&.total || 0 [ expense - refund, 0 ].max @@ -297,31 +297,35 @@ class Budget < ApplicationRecord end private - def refunds_in_expense_categories - expense_category_ids = budget_categories.map(&:category_id).to_set - income_totals.category_totals - .reject { |ct| ct.category.subcategory? } - .select { |ct| expense_category_ids.include?(ct.category.id) || ct.category.uncategorized? } - .sum(&:total) - end - def income_statement @income_statement ||= family.income_statement end + def net_totals + @net_totals ||= income_statement.net_category_totals(period: period) + end + def expense_totals @expense_totals ||= income_statement.expense_totals(period: period) end def income_totals - @income_totals ||= family.income_statement.income_totals(period: period) + @income_totals ||= income_statement.income_totals(period: period) end def expense_totals_by_category - @expense_totals_by_category ||= expense_totals.category_totals.index_by { |ct| ct.category.id || ct.category.name } + @expense_totals_by_category ||= expense_totals.category_totals.index_by { |ct| ct.category.id || stable_synthetic_key(ct.category) } end def income_totals_by_category - @income_totals_by_category ||= income_totals.category_totals.index_by { |ct| ct.category.id || ct.category.name } + @income_totals_by_category ||= income_totals.category_totals.index_by { |ct| ct.category.id || stable_synthetic_key(ct.category) } + end + + def stable_synthetic_key(category) + if category.uncategorized? + :uncategorized + elsif category.other_investments? + :other_investments + end end end diff --git a/app/models/category.rb b/app/models/category.rb index 02acee0f6..743397456 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -12,7 +12,6 @@ class Category < ApplicationRecord validates :name, uniqueness: { scope: :family_id } validate :category_level_limit - validate :nested_category_matches_parent_classification before_save :inherit_color_from_parent @@ -24,8 +23,9 @@ class Category < ApplicationRecord .order(:name) } scope :roots, -> { where(parent_id: nil) } - scope :incomes, -> { where(classification: "income") } - scope :expenses, -> { where(classification: "expense") } + # Legacy scopes - classification removed; these now return all categories + scope :incomes, -> { all } + scope :expenses, -> { all } COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a] @@ -79,10 +79,9 @@ class Category < ApplicationRecord end def bootstrap! - default_categories.each do |name, color, icon, classification| + default_categories.each do |name, color, icon| find_or_create_by!(name: name) do |category| category.color = color - category.classification = classification category.lucide_icon = icon end end @@ -138,28 +137,28 @@ class Category < ApplicationRecord private def default_categories [ - [ "Income", "#22c55e", "circle-dollar-sign", "income" ], - [ "Food & Drink", "#f97316", "utensils", "expense" ], - [ "Groceries", "#407706", "shopping-bag", "expense" ], - [ "Shopping", "#3b82f6", "shopping-cart", "expense" ], - [ "Transportation", "#0ea5e9", "bus", "expense" ], - [ "Travel", "#2563eb", "plane", "expense" ], - [ "Entertainment", "#a855f7", "drama", "expense" ], - [ "Healthcare", "#4da568", "pill", "expense" ], - [ "Personal Care", "#14b8a6", "scissors", "expense" ], - [ "Home Improvement", "#d97706", "hammer", "expense" ], - [ "Mortgage / Rent", "#b45309", "home", "expense" ], - [ "Utilities", "#eab308", "lightbulb", "expense" ], - [ "Subscriptions", "#6366f1", "wifi", "expense" ], - [ "Insurance", "#0284c7", "shield", "expense" ], - [ "Sports & Fitness", "#10b981", "dumbbell", "expense" ], - [ "Gifts & Donations", "#61c9ea", "hand-helping", "expense" ], - [ "Taxes", "#dc2626", "landmark", "expense" ], - [ "Loan Payments", "#e11d48", "credit-card", "expense" ], - [ "Services", "#7c3aed", "briefcase", "expense" ], - [ "Fees", "#6b7280", "receipt", "expense" ], - [ "Savings & Investments", "#059669", "piggy-bank", "expense" ], - [ investment_contributions_name, "#0d9488", "trending-up", "expense" ] + [ "Income", "#22c55e", "circle-dollar-sign" ], + [ "Food & Drink", "#f97316", "utensils" ], + [ "Groceries", "#407706", "shopping-bag" ], + [ "Shopping", "#3b82f6", "shopping-cart" ], + [ "Transportation", "#0ea5e9", "bus" ], + [ "Travel", "#2563eb", "plane" ], + [ "Entertainment", "#a855f7", "drama" ], + [ "Healthcare", "#4da568", "pill" ], + [ "Personal Care", "#14b8a6", "scissors" ], + [ "Home Improvement", "#d97706", "hammer" ], + [ "Mortgage / Rent", "#b45309", "home" ], + [ "Utilities", "#eab308", "lightbulb" ], + [ "Subscriptions", "#6366f1", "wifi" ], + [ "Insurance", "#0284c7", "shield" ], + [ "Sports & Fitness", "#10b981", "dumbbell" ], + [ "Gifts & Donations", "#61c9ea", "hand-helping" ], + [ "Taxes", "#dc2626", "landmark" ], + [ "Loan Payments", "#e11d48", "credit-card" ], + [ "Services", "#7c3aed", "briefcase" ], + [ "Fees", "#6b7280", "receipt" ], + [ "Savings & Investments", "#059669", "piggy-bank" ], + [ investment_contributions_name, "#0d9488", "trending-up" ] ] end end @@ -211,12 +210,6 @@ class Category < ApplicationRecord end end - def nested_category_matches_parent_classification - if subcategory? && parent.classification != classification - errors.add(:parent, "must have the same classification as its parent") - end - end - def monetizable_currency family.currency end diff --git a/app/models/category_import.rb b/app/models/category_import.rb index 59f8095f4..f58a50b70 100644 --- a/app/models/category_import.rb +++ b/app/models/category_import.rb @@ -5,7 +5,6 @@ class CategoryImport < Import category_name = row.name.to_s.strip category = family.categories.find_or_initialize_by(name: category_name) category.color = row.category_color.presence || category.color || Category::UNCATEGORIZED_COLOR - category.classification = row.category_classification.presence || category.classification || "expense" category.lucide_icon = row.category_icon.presence || category.lucide_icon || "shapes" category.parent = nil category.save! @@ -30,7 +29,7 @@ class CategoryImport < Import end def column_keys - %i[name category_color category_parent category_classification category_icon] + %i[name category_color category_parent category_icon] end def required_column_keys @@ -47,10 +46,10 @@ class CategoryImport < Import def csv_template template = <<-CSV - name*,color,parent_category,classification,lucide_icon - Food & Drink,#f97316,,expense,carrot - Groceries,#407706,Food & Drink,expense,shopping-basket - Salary,#22c55e,,income,briefcase + name*,color,parent_category,lucide_icon + Food & Drink,#f97316,,carrot + Groceries,#407706,Food & Drink,shopping-basket + Salary,#22c55e,,briefcase CSV CSV.parse(template, headers: true) @@ -64,7 +63,6 @@ class CategoryImport < Import name_header = header_for("name") color_header = header_for("color") parent_header = header_for("parent_category", "parent category") - classification_header = header_for("classification") icon_header = header_for("lucide_icon", "lucide icon", "icon") csv_rows.each do |row| @@ -72,7 +70,6 @@ class CategoryImport < Import name: row[name_header].to_s.strip, category_color: row[color_header].to_s.strip, category_parent: row[parent_header].to_s.strip, - category_classification: row[classification_header].to_s.strip, category_icon: row[icon_header].to_s.strip, currency: default_currency ) @@ -112,7 +109,6 @@ class CategoryImport < Import family.categories.find_or_create_by!(name: trimmed_name) do |placeholder| placeholder.color = Category::UNCATEGORIZED_COLOR - placeholder.classification = "expense" placeholder.lucide_icon = "shapes" end end diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb index d44b16c83..474a1095c 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -213,36 +213,36 @@ class Demo::Generator def create_realistic_categories!(family) # Income categories (3 total) - @salary_cat = family.categories.create!(name: "Salary", color: "#10b981", classification: "income") - @freelance_cat = family.categories.create!(name: "Freelance", color: "#059669", classification: "income") - @investment_income_cat = family.categories.create!(name: "Investment Income", color: "#047857", classification: "income") + @salary_cat = family.categories.create!(name: "Salary", color: "#10b981") + @freelance_cat = family.categories.create!(name: "Freelance", color: "#059669") + @investment_income_cat = family.categories.create!(name: "Investment Income", color: "#047857") # Expense categories with subcategories (12 total) - @housing_cat = family.categories.create!(name: "Housing", color: "#dc2626", classification: "expense") - @rent_cat = family.categories.create!(name: "Rent/Mortgage", parent: @housing_cat, color: "#b91c1c", classification: "expense") - @utilities_cat = family.categories.create!(name: "Utilities", parent: @housing_cat, color: "#991b1b", classification: "expense") + @housing_cat = family.categories.create!(name: "Housing", color: "#dc2626") + @rent_cat = family.categories.create!(name: "Rent/Mortgage", parent: @housing_cat, color: "#b91c1c") + @utilities_cat = family.categories.create!(name: "Utilities", parent: @housing_cat, color: "#991b1b") - @food_cat = family.categories.create!(name: "Food & Dining", color: "#ea580c", classification: "expense") - @groceries_cat = family.categories.create!(name: "Groceries", parent: @food_cat, color: "#c2410c", classification: "expense") - @restaurants_cat = family.categories.create!(name: "Restaurants", parent: @food_cat, color: "#9a3412", classification: "expense") - @coffee_cat = family.categories.create!(name: "Coffee & Takeout", parent: @food_cat, color: "#7c2d12", classification: "expense") + @food_cat = family.categories.create!(name: "Food & Dining", color: "#ea580c") + @groceries_cat = family.categories.create!(name: "Groceries", parent: @food_cat, color: "#c2410c") + @restaurants_cat = family.categories.create!(name: "Restaurants", parent: @food_cat, color: "#9a3412") + @coffee_cat = family.categories.create!(name: "Coffee & Takeout", parent: @food_cat, color: "#7c2d12") - @transportation_cat = family.categories.create!(name: "Transportation", color: "#2563eb", classification: "expense") - @gas_cat = family.categories.create!(name: "Gas", parent: @transportation_cat, color: "#1d4ed8", classification: "expense") - @car_payment_cat = family.categories.create!(name: "Car Payment", parent: @transportation_cat, color: "#1e40af", classification: "expense") + @transportation_cat = family.categories.create!(name: "Transportation", color: "#2563eb") + @gas_cat = family.categories.create!(name: "Gas", parent: @transportation_cat, color: "#1d4ed8") + @car_payment_cat = family.categories.create!(name: "Car Payment", parent: @transportation_cat, color: "#1e40af") - @entertainment_cat = family.categories.create!(name: "Entertainment", color: "#7c3aed", classification: "expense") - @healthcare_cat = family.categories.create!(name: "Healthcare", color: "#db2777", classification: "expense") - @shopping_cat = family.categories.create!(name: "Shopping", color: "#059669", classification: "expense") - @travel_cat = family.categories.create!(name: "Travel", color: "#0891b2", classification: "expense") - @personal_care_cat = family.categories.create!(name: "Personal Care", color: "#be185d", classification: "expense") + @entertainment_cat = family.categories.create!(name: "Entertainment", color: "#7c3aed") + @healthcare_cat = family.categories.create!(name: "Healthcare", color: "#db2777") + @shopping_cat = family.categories.create!(name: "Shopping", color: "#059669") + @travel_cat = family.categories.create!(name: "Travel", color: "#0891b2") + @personal_care_cat = family.categories.create!(name: "Personal Care", color: "#be185d") # Additional high-level expense categories to reach 13 top-level items - @insurance_cat = family.categories.create!(name: "Insurance", color: "#6366f1", classification: "expense") - @misc_cat = family.categories.create!(name: "Miscellaneous", color: "#6b7280", classification: "expense") + @insurance_cat = family.categories.create!(name: "Insurance", color: "#6366f1") + @misc_cat = family.categories.create!(name: "Miscellaneous", color: "#6b7280") # Interest expense bucket - @interest_cat = family.categories.create!(name: "Loan Interest", color: "#475569", classification: "expense") + @interest_cat = family.categories.create!(name: "Loan Interest", color: "#475569") end def create_realistic_accounts!(family) @@ -354,11 +354,11 @@ class Demo::Generator analysis_start = (current_month - 3.months).beginning_of_month analysis_period = analysis_start..(current_month - 1.day) - # Fetch expense transactions in the analysis period + # Fetch expense transactions in the analysis period (positive amounts = expenses) txns = Entry.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id") .joins("INNER JOIN categories ON categories.id = transactions.category_id") .where(entries: { entryable_type: "Transaction", date: analysis_period }) - .where(categories: { classification: "expense" }) + .where("entries.amount > 0") spend_per_cat = txns.group("categories.id").sum("entries.amount") diff --git a/app/models/family.rb b/app/models/family.rb index 75cae5bf0..a43b118a2 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -155,7 +155,6 @@ class Family < ApplicationRecord I18n.with_locale(locale) do categories.find_or_create_by!(name: Category.investment_contributions_name) do |cat| cat.color = "#0d9488" - cat.classification = "expense" cat.lucide_icon = "trending-up" end end diff --git a/app/models/family/auto_categorizer.rb b/app/models/family/auto_categorizer.rb index 1efb76c58..c3b452dbc 100644 --- a/app/models/family/auto_categorizer.rb +++ b/app/models/family/auto_categorizer.rb @@ -69,8 +69,7 @@ class Family::AutoCategorizer id: category.id, name: category.name, is_subcategory: category.subcategory?, - parent_id: category.parent_id, - classification: category.classification + parent_id: category.parent_id } end end diff --git a/app/models/family/data_exporter.rb b/app/models/family/data_exporter.rb index 6b3be40fd..3eacd9fd2 100644 --- a/app/models/family/data_exporter.rb +++ b/app/models/family/data_exporter.rb @@ -105,7 +105,7 @@ class Family::DataExporter def generate_categories_csv CSV.generate do |csv| - csv << [ "name", "color", "parent_category", "classification", "lucide_icon" ] + csv << [ "name", "color", "parent_category", "lucide_icon" ] # Only export categories belonging to this family @family.categories.includes(:parent).find_each do |category| @@ -113,7 +113,6 @@ class Family::DataExporter category.name, category.color, category.parent&.name, - category.classification, category.lucide_icon ] end diff --git a/app/models/income_statement.rb b/app/models/income_statement.rb index 83aa2c9fd..8f1b8f0a9 100644 --- a/app/models/income_statement.rb +++ b/app/models/income_statement.rb @@ -36,6 +36,65 @@ class IncomeStatement build_period_total(classification: "income", period: period) end + def net_category_totals(period: Period.current_month) + expense = expense_totals(period: period) + income = income_totals(period: period) + + # Use a stable key for each category: id for persisted, invariant token for synthetic + cat_key = ->(ct) { + if ct.category.uncategorized? + :uncategorized + elsif ct.category.other_investments? + :other_investments + else + ct.category.id + end + } + + expense_by_cat = expense.category_totals.reject { |ct| ct.category.subcategory? }.index_by { |ct| cat_key.call(ct) } + income_by_cat = income.category_totals.reject { |ct| ct.category.subcategory? }.index_by { |ct| cat_key.call(ct) } + + all_keys = (expense_by_cat.keys + income_by_cat.keys).uniq + raw_expense_categories = [] + raw_income_categories = [] + + all_keys.each do |key| + exp_ct = expense_by_cat[key] + inc_ct = income_by_cat[key] + exp_total = exp_ct&.total || 0 + inc_total = inc_ct&.total || 0 + net = exp_total - inc_total + category = exp_ct&.category || inc_ct&.category + + if net > 0 + raw_expense_categories << { category: category, total: net } + elsif net < 0 + raw_income_categories << { category: category, total: net.abs } + end + end + + total_net_expense = raw_expense_categories.sum { |r| r[:total] } + total_net_income = raw_income_categories.sum { |r| r[:total] } + + net_expense_categories = raw_expense_categories.map do |r| + weight = total_net_expense.zero? ? 0 : (r[:total].to_f / total_net_expense) * 100 + CategoryTotal.new(category: r[:category], total: r[:total], currency: family.currency, weight: weight) + end + + net_income_categories = raw_income_categories.map do |r| + weight = total_net_income.zero? ? 0 : (r[:total].to_f / total_net_income) * 100 + CategoryTotal.new(category: r[:category], total: r[:total], currency: family.currency, weight: weight) + end + + NetCategoryTotals.new( + net_expense_categories: net_expense_categories, + net_income_categories: net_income_categories, + total_net_expense: total_net_expense, + total_net_income: total_net_income, + currency: family.currency + ) + end + def median_expense(interval: "month", category: nil) if category.present? category_stats(interval: interval).find { |stat| stat.classification == "expense" && stat.category_id == category.id }&.median || 0 @@ -60,6 +119,7 @@ class IncomeStatement ScopeTotals = Data.define(:transactions_count, :income_money, :expense_money) PeriodTotal = Data.define(:classification, :total, :currency, :category_totals) CategoryTotal = Data.define(:category, :total, :currency, :weight) + NetCategoryTotals = Data.define(:net_expense_categories, :net_income_categories, :total_net_expense, :total_net_income, :currency) def categories @categories ||= family.categories.all.to_a diff --git a/app/models/plaid_account/transactions/category_matcher.rb b/app/models/plaid_account/transactions/category_matcher.rb index 87652109f..263ec0445 100644 --- a/app/models/plaid_account/transactions/category_matcher.rb +++ b/app/models/plaid_account/transactions/category_matcher.rb @@ -97,7 +97,6 @@ class PlaidAccount::Transactions::CategoryMatcher user_categories.map do |user_category| { id: user_category.id, - classification: user_category.classification, name: normalize_user_category_name(user_category.name) } end diff --git a/app/models/provider/openai/auto_categorizer.rb b/app/models/provider/openai/auto_categorizer.rb index 36cdf80bf..3c31c9d5b 100644 --- a/app/models/provider/openai/auto_categorizer.rb +++ b/app/models/provider/openai/auto_categorizer.rb @@ -105,7 +105,7 @@ class Provider::Openai::AutoCategorizer - Return 1 result per transaction - Correlate each transaction by ID (transaction_id) - Attempt to match the most specific category possible (i.e. subcategory over parent category) - - Category and transaction classifications should match (i.e. if transaction is an "expense", the category must have classification of "expense") + - Any category can be used for any transaction regardless of whether the transaction is income or expense - If you don't know the category, return "null" - You should always favor "null" over false positives - Be slightly pessimistic. Only match a category if you're 60%+ confident it is the correct one. diff --git a/app/models/rule_import.rb b/app/models/rule_import.rb index d2a2d07ca..bae5820d7 100644 --- a/app/models/rule_import.rb +++ b/app/models/rule_import.rb @@ -212,7 +212,6 @@ class RuleImport < Import category = family.categories.create!( name: value, color: Category::UNCATEGORIZED_COLOR, - classification: "expense", lucide_icon: "shapes" ) end @@ -245,7 +244,6 @@ class RuleImport < Import category = family.categories.create!( name: value, color: Category::UNCATEGORIZED_COLOR, - classification: "expense", lucide_icon: "shapes" ) end diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index 86e726e4c..ec2fce04e 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -44,7 +44,7 @@ <% if @mercury_items.any? %> <%= render @mercury_items.sort_by(&:created_at) %> <% end %> - + <% if @coinbase_items.any? %> <%= render @coinbase_items.sort_by(&:created_at) %> <% end %> diff --git a/app/views/api/v1/categories/_category.json.jbuilder b/app/views/api/v1/categories/_category.json.jbuilder index f0ebfe0cf..926df6584 100644 --- a/app/views/api/v1/categories/_category.json.jbuilder +++ b/app/views/api/v1/categories/_category.json.jbuilder @@ -2,7 +2,6 @@ json.id category.id json.name category.name -json.classification category.classification json.color category.color json.icon category.lucide_icon diff --git a/app/views/api/v1/transactions/_transaction.json.jbuilder b/app/views/api/v1/transactions/_transaction.json.jbuilder index 617f47505..9f3a47a98 100644 --- a/app/views/api/v1/transactions/_transaction.json.jbuilder +++ b/app/views/api/v1/transactions/_transaction.json.jbuilder @@ -31,7 +31,6 @@ if transaction.category.present? json.category do json.id transaction.category.id json.name transaction.category.name - json.classification transaction.category.classification json.color transaction.category.color json.icon transaction.category.lucide_icon end diff --git a/app/views/categories/_form.html.erb b/app/views/categories/_form.html.erb index d699d3c4d..3b9e6f1b4 100644 --- a/app/views/categories/_form.html.erb +++ b/app/views/categories/_form.html.erb @@ -62,7 +62,6 @@ <% end %>
    - <%= f.select :classification, [["Income", "income"], ["Expense", "expense"]], { label: "Classification" }, required: true %> <%= f.text_field :name, placeholder: t(".placeholder"), required: true, autofocus: true, label: "Name", data: { color_avatar_target: "name" } %> <% unless category.parent? %> <%= f.select :parent_id, categories.pluck(:name, :id), { include_blank: "(unassigned)", label: "Parent category (optional)" }, disabled: category.parent?, data: { action: "change->category#handleParentChange" } %> diff --git a/app/views/categories/index.html.erb b/app/views/categories/index.html.erb index c9c094396..8c5391831 100644 --- a/app/views/categories/index.html.erb +++ b/app/views/categories/index.html.erb @@ -22,13 +22,7 @@
    <% if @categories.any? %>
    - <% if @categories.incomes.any? %> - <%= render "categories/category_list_group", title: t(".categories_incomes"), categories: @categories.incomes %> - <% end %> - - <% if @categories.expenses.any? %> - <%= render "categories/category_list_group", title: t(".categories_expenses"), categories: @categories.expenses %> - <% end %> + <%= render "categories/category_list_group", title: t(".categories"), categories: @categories %>
    <% else %>
    diff --git a/app/views/oidc_accounts/link.html.erb b/app/views/oidc_accounts/link.html.erb index d01b7c2db..c4bf1aaf3 100644 --- a/app/views/oidc_accounts/link.html.erb +++ b/app/views/oidc_accounts/link.html.erb @@ -76,4 +76,4 @@ variant: :default, class: "font-medium text-sm text-primary hover:underline transition" ) %> -
    \ No newline at end of file +
    diff --git a/app/views/onboardings/show.html.erb b/app/views/onboardings/show.html.erb index 694bc97e8..38d90b378 100644 --- a/app/views/onboardings/show.html.erb +++ b/app/views/onboardings/show.html.erb @@ -39,8 +39,7 @@ data-onboarding-household-name-label-value="<%= t(".household_name") %>" data-onboarding-household-name-placeholder-value="<%= t(".household_name_placeholder") %>" data-onboarding-group-name-label-value="<%= t(".group_name") %>" - data-onboarding-group-name-placeholder-value="<%= t(".group_name_placeholder") %>" - > + data-onboarding-group-name-placeholder-value="<%= t(".group_name_placeholder") %>">

    <%= t(".moniker_prompt", product_name: product_name) %>

    <% else %> <%= render "recurring_transactions/empty" %> -<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/transactions/new.html.erb b/app/views/transactions/new.html.erb index ea9d27300..f3fddcfdf 100644 --- a/app/views/transactions/new.html.erb +++ b/app/views/transactions/new.html.erb @@ -1,6 +1,6 @@ <%= render DS::Dialog.new do |dialog| %> <% dialog.with_header(title: "New transaction") %> <% dialog.with_body do %> - <%= render "form", entry: @entry, income_categories: @income_categories, expense_categories: @expense_categories %> + <%= render "form", entry: @entry, categories: @categories %> <% end %> <% end %> diff --git a/db/migrate/20260308113006_remove_classification_from_categories.rb b/db/migrate/20260308113006_remove_classification_from_categories.rb new file mode 100644 index 000000000..b2862fc29 --- /dev/null +++ b/db/migrate/20260308113006_remove_classification_from_categories.rb @@ -0,0 +1,9 @@ +class RemoveClassificationFromCategories < ActiveRecord::Migration[7.2] + def up + rename_column :categories, :classification, :classification_unused + end + + def down + rename_column :categories, :classification_unused, :classification + end +end diff --git a/db/schema.rb b/db/schema.rb index a1cc7b39f..9c6837f21 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_03_03_120000) do +ActiveRecord::Schema[7.2].define(version: 2026_03_08_113006) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -184,8 +184,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_03_03_120000) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.uuid "parent_id" - t.string "classification", default: "expense", null: false t.string "lucide_icon", default: "shapes", null: false + t.string "classification_unused", default: "expense", null: false t.index ["family_id"], name: "index_categories_on_family_id" end diff --git a/spec/requests/api/v1/categories_spec.rb b/spec/requests/api/v1/categories_spec.rb index 6868e3979..90e06d5c2 100644 --- a/spec/requests/api/v1/categories_spec.rb +++ b/spec/requests/api/v1/categories_spec.rb @@ -36,7 +36,6 @@ RSpec.describe 'API V1 Categories', type: :request do let!(:parent_category) do family.categories.create!( name: 'Food & Drink', - classification: 'expense', color: '#f97316', lucide_icon: 'utensils' ) @@ -45,7 +44,6 @@ RSpec.describe 'API V1 Categories', type: :request do let!(:subcategory) do family.categories.create!( name: 'Restaurants', - classification: 'expense', color: '#f97316', lucide_icon: 'utensils', parent: parent_category @@ -55,7 +53,6 @@ RSpec.describe 'API V1 Categories', type: :request do let!(:income_category) do family.categories.create!( name: 'Salary', - classification: 'income', color: '#22c55e', lucide_icon: 'circle-dollar-sign' ) @@ -70,9 +67,6 @@ RSpec.describe 'API V1 Categories', type: :request do description: 'Page number (default: 1)' parameter name: :per_page, in: :query, type: :integer, required: false, description: 'Items per page (default: 25, max: 100)' - parameter name: :classification, in: :query, required: false, - description: 'Filter by classification (income or expense)', - schema: { type: :string, enum: %w[income expense] } parameter name: :roots_only, in: :query, required: false, description: 'Return only root categories (no parent)', schema: { type: :boolean } @@ -86,14 +80,6 @@ RSpec.describe 'API V1 Categories', type: :request do run_test! end - response '200', 'categories filtered by classification' do - schema '$ref' => '#/components/schemas/CategoryCollection' - - let(:classification) { 'expense' } - - run_test! - end - response '200', 'root categories only' do schema '$ref' => '#/components/schemas/CategoryCollection' diff --git a/spec/requests/api/v1/trades_spec.rb b/spec/requests/api/v1/trades_spec.rb index 1eac1032d..97a40a03a 100644 --- a/spec/requests/api/v1/trades_spec.rb +++ b/spec/requests/api/v1/trades_spec.rb @@ -54,7 +54,6 @@ RSpec.describe 'API V1 Trades', type: :request do let(:category) do family.categories.create!( name: 'Investments', - classification: 'expense', color: '#2196F3', lucide_icon: 'trending-up' ) diff --git a/spec/requests/api/v1/transactions_spec.rb b/spec/requests/api/v1/transactions_spec.rb index 5e114c705..a4eab7590 100644 --- a/spec/requests/api/v1/transactions_spec.rb +++ b/spec/requests/api/v1/transactions_spec.rb @@ -46,7 +46,6 @@ RSpec.describe 'API V1 Transactions', type: :request do let(:category) do family.categories.create!( name: 'Groceries', - classification: 'expense', color: '#4CAF50', lucide_icon: 'shopping-cart' ) diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb index c02b0831c..1a4c6d835 100644 --- a/spec/swagger_helper.rb +++ b/spec/swagger_helper.rb @@ -203,11 +203,10 @@ RSpec.configure do |config| }, Category: { type: :object, - required: %w[id name classification color icon], + required: %w[id name color icon], properties: { id: { type: :string, format: :uuid }, name: { type: :string }, - classification: { type: :string }, color: { type: :string }, icon: { type: :string } } @@ -222,11 +221,10 @@ RSpec.configure do |config| }, CategoryDetail: { type: :object, - required: %w[id name classification color icon subcategories_count created_at updated_at], + required: %w[id name color icon subcategories_count created_at updated_at], properties: { id: { type: :string, format: :uuid }, name: { type: :string }, - classification: { type: :string, enum: %w[income expense] }, color: { type: :string }, icon: { type: :string }, parent: { '$ref' => '#/components/schemas/CategoryParent', nullable: true }, diff --git a/test/controllers/api/v1/categories_controller_test.rb b/test/controllers/api/v1/categories_controller_test.rb index 9f4e87630..d3a26fbbc 100644 --- a/test/controllers/api/v1/categories_controller_test.rb +++ b/test/controllers/api/v1/categories_controller_test.rb @@ -84,7 +84,7 @@ class Api::V1::CategoriesControllerTest < ActionDispatch::IntegrationTest category = response_body["categories"].find { |c| c["name"] == @category.name } assert category.present?, "Should find the food_and_drink category" - required_fields = %w[id name classification color icon subcategories_count created_at updated_at] + required_fields = %w[id name color icon subcategories_count created_at updated_at] required_fields.each do |field| assert category.key?(field), "Category should have #{field} field" end @@ -124,19 +124,6 @@ class Api::V1::CategoriesControllerTest < ActionDispatch::IntegrationTest assert_equal 2, response_body["pagination"]["per_page"] end - test "should filter by classification" do - get "/api/v1/categories", params: { classification: "expense" }, headers: { - "Authorization" => "Bearer #{@access_token.token}" - } - - assert_response :success - response_body = JSON.parse(response.body) - - response_body["categories"].each do |category| - assert_equal "expense", category["classification"] - end - end - test "should filter for roots only" do get "/api/v1/categories", params: { roots_only: true }, headers: { "Authorization" => "Bearer #{@access_token.token}" @@ -174,7 +161,6 @@ class Api::V1::CategoriesControllerTest < ActionDispatch::IntegrationTest assert_equal @category.id, response_body["id"] assert_equal @category.name, response_body["name"] - assert_equal @category.classification, response_body["classification"] assert_equal @category.color, response_body["color"] assert_equal @category.lucide_icon, response_body["icon"] end diff --git a/test/controllers/pages_controller_test.rb b/test/controllers/pages_controller_test.rb index 2c636ffd9..73c39f5a1 100644 --- a/test/controllers/pages_controller_test.rb +++ b/test/controllers/pages_controller_test.rb @@ -31,8 +31,8 @@ class PagesControllerTest < ActionDispatch::IntegrationTest test "dashboard renders sankey chart with subcategories" do # Create parent category with subcategory - parent_category = @family.categories.create!(name: "Shopping", classification: "expense", color: "#FF5733") - subcategory = @family.categories.create!(name: "Groceries", classification: "expense", parent: parent_category, color: "#33FF57") + parent_category = @family.categories.create!(name: "Shopping", color: "#FF5733") + subcategory = @family.categories.create!(name: "Groceries", parent: parent_category, color: "#33FF57") # Create transactions using helper create_transaction(account: @family.accounts.first, name: "General shopping", amount: 100, category: parent_category) diff --git a/test/controllers/reports_controller_test.rb b/test/controllers/reports_controller_test.rb index 1c7e7bbd2..6f041c0da 100644 --- a/test/controllers/reports_controller_test.rb +++ b/test/controllers/reports_controller_test.rb @@ -118,8 +118,7 @@ class ReportsControllerTest < ActionDispatch::IntegrationTest test "spending patterns returns data when expense transactions exist" do # Create expense category expense_category = @family.categories.create!( - name: "Test Groceries", - classification: "expense" + name: "Test Groceries" ) # Create account @@ -228,9 +227,9 @@ class ReportsControllerTest < ActionDispatch::IntegrationTest test "index groups transactions by parent and subcategories" do # Create parent category with subcategories - parent_category = @family.categories.create!(name: "Entertainment", classification: "expense", color: "#FF5733") - subcategory_movies = @family.categories.create!(name: "Movies", classification: "expense", parent: parent_category, color: "#33FF57") - subcategory_games = @family.categories.create!(name: "Games", classification: "expense", parent: parent_category, color: "#5733FF") + parent_category = @family.categories.create!(name: "Entertainment", color: "#FF5733") + subcategory_movies = @family.categories.create!(name: "Movies", parent: parent_category, color: "#33FF57") + subcategory_games = @family.categories.create!(name: "Games", parent: parent_category, color: "#5733FF") # Create transactions using helper create_transaction(account: @family.accounts.first, name: "Cinema ticket", amount: 15, category: subcategory_movies) diff --git a/test/models/budget_category_test.rb b/test/models/budget_category_test.rb index 4e16c4f88..5814fe9d8 100644 --- a/test/models/budget_category_test.rb +++ b/test/models/budget_category_test.rb @@ -10,23 +10,20 @@ class BudgetCategoryTest < ActiveSupport::TestCase name: "Test Food & Groceries #{Time.now.to_f}", family: @family, color: "#4da568", - lucide_icon: "utensils", - classification: "expense" + lucide_icon: "utensils" ) # Create subcategories with unique names @subcategory_with_limit = Category.create!( name: "Test Restaurants #{Time.now.to_f}", parent: @parent_category, - family: @family, - classification: "expense" + family: @family ) @subcategory_inheriting = Category.create!( name: "Test Groceries #{Time.now.to_f}", parent: @parent_category, - family: @family, - classification: "expense" + family: @family ) # Create budget categories @@ -95,8 +92,7 @@ class BudgetCategoryTest < ActiveSupport::TestCase another_inheriting = Category.create!( name: "Test Coffee #{Time.now.to_f}", parent: @parent_category, - family: @family, - classification: "expense" + family: @family ) another_inheriting_bc = BudgetCategory.create!( @@ -114,8 +110,7 @@ class BudgetCategoryTest < ActiveSupport::TestCase new_subcategory_cat = Category.create!( name: "Test Fast Food #{Time.now.to_f}", parent: @parent_category, - family: @family, - classification: "expense" + family: @family ) new_subcategory_bc = BudgetCategory.create!( @@ -143,8 +138,7 @@ class BudgetCategoryTest < ActiveSupport::TestCase name: "Test Entertainment #{Time.now.to_f}", family: @family, color: "#a855f7", - lucide_icon: "drama", - classification: "expense" + lucide_icon: "drama" ) standalone_bc = BudgetCategory.create!( diff --git a/test/models/budget_test.rb b/test/models/budget_test.rb index e12b51d49..f681bebc2 100644 --- a/test/models/budget_test.rb +++ b/test/models/budget_test.rb @@ -82,8 +82,7 @@ class BudgetTest < ActiveSupport::TestCase healthcare = Category.create!( name: "Healthcare #{Time.now.to_f}", family: family, - color: "#e74c3c", - classification: "expense" + color: "#e74c3c" ) budget.sync_budget_categories @@ -129,8 +128,7 @@ class BudgetTest < ActiveSupport::TestCase category = Category.create!( name: "Returns Only #{Time.now.to_f}", family: family, - color: "#3498db", - classification: "expense" + color: "#3498db" ) budget.sync_budget_categories @@ -266,7 +264,7 @@ class BudgetTest < ActiveSupport::TestCase source_budget.update!(budgeted_spending: 4000, expected_income: 6000) # Create a category only in the source budget - temp_category = Category.create!(name: "Temp #{Time.now.to_f}", family: family, color: "#aaa", classification: "expense") + temp_category = Category.create!(name: "Temp #{Time.now.to_f}", family: family, color: "#aaa") source_budget.budget_categories.create!(category: temp_category, budgeted_spending: 100, currency: "USD") target_budget = Budget.find_or_bootstrap(family, start_date: 1.month.ago) @@ -285,7 +283,7 @@ class BudgetTest < ActiveSupport::TestCase target_budget = Budget.find_or_bootstrap(family, start_date: 1.month.ago) # Add a new category only to the target - new_category = Category.create!(name: "New #{Time.now.to_f}", family: family, color: "#bbb", classification: "expense") + new_category = Category.create!(name: "New #{Time.now.to_f}", family: family, color: "#bbb") target_budget.budget_categories.create!(category: new_category, budgeted_spending: 0, currency: "USD") target_budget.copy_from!(source_budget) diff --git a/test/models/category_import_test.rb b/test/models/category_import_test.rb index 99e645c33..92128bd22 100644 --- a/test/models/category_import_test.rb +++ b/test/models/category_import_test.rb @@ -4,10 +4,10 @@ class CategoryImportTest < ActiveSupport::TestCase setup do @family = families(:dylan_family) @csv = <<~CSV - name,color,parent_category,classification,icon - Food & Drink,#f97316,,expense,carrot - Groceries,#407706,Food & Drink,expense,shopping-basket - Salary,#22c55e,,income,briefcase + name,color,parent_category,icon + Food & Drink,#f97316,,carrot + Groceries,#407706,Food & Drink,shopping-basket + Salary,#22c55e,,briefcase CSV end @@ -26,19 +26,17 @@ class CategoryImportTest < ActiveSupport::TestCase groceries = Category.find_by!(family: @family, name: "Groceries") salary = Category.find_by!(family: @family, name: "Salary") - assert_equal "expense", food.classification assert_equal "carrot", food.lucide_icon assert_equal food, groceries.parent assert_equal "shopping-basket", groceries.lucide_icon - assert_equal "income", salary.classification assert_equal "briefcase", salary.lucide_icon end test "imports subcategories even when parent row comes later" do csv = <<~CSV - name,color,parent_category,classification,icon - Utilities,#407706,Household,expense,plug - Household,#f97316,,expense,house + name,color,parent_category,icon + Utilities,#407706,Household,plug + Household,#f97316,,house CSV import = @family.imports.create!(type: "CategoryImport", raw_file_str: csv, col_sep: ",") @@ -55,9 +53,9 @@ class CategoryImportTest < ActiveSupport::TestCase test "updates categories when duplicate rows are provided" do csv = <<~CSV - name,color,parent_category,classification,icon - Snacks,#aaaaaa,,expense,cookie - Snacks,#bbbbbb,,expense,pizza + name,color,parent_category,icon + Snacks,#aaaaaa,,cookie + Snacks,#bbbbbb,,pizza CSV import = @family.imports.create!(type: "CategoryImport", raw_file_str: csv, col_sep: ",") @@ -72,8 +70,8 @@ class CategoryImportTest < ActiveSupport::TestCase test "accepts required headers with an asterisk suffix" do csv = <<~CSV - name*,color,parent_category,classification,icon - Food & Drink,#f97316,,expense,carrot + name*,color,parent_category,icon + Food & Drink,#f97316,,carrot CSV import = @family.imports.create!(type: "CategoryImport", raw_file_str: csv, col_sep: ",") @@ -85,8 +83,8 @@ class CategoryImportTest < ActiveSupport::TestCase test "fails fast when required headers are missing" do csv = <<~CSV - title,color,parent_category,classification,icon - Food & Drink,#f97316,,expense,carrot + title,color,parent_category,icon + Food & Drink,#f97316,,carrot CSV import = @family.imports.create!(type: "CategoryImport", raw_file_str: csv, col_sep: ",") diff --git a/test/models/family/data_exporter_test.rb b/test/models/family/data_exporter_test.rb index 9f36e16bf..e25ed0e5d 100644 --- a/test/models/family/data_exporter_test.rb +++ b/test/models/family/data_exporter_test.rb @@ -73,7 +73,7 @@ class Family::DataExporterTest < ActiveSupport::TestCase # Check categories.csv categories_csv = zip.read("categories.csv") - assert categories_csv.include?("name,color,parent_category,classification,lucide_icon") + assert categories_csv.include?("name,color,parent_category,lucide_icon") # Check rules.csv rules_csv = zip.read("rules.csv") diff --git a/test/models/family_test.rb b/test/models/family_test.rb index bfff617be..69a530316 100644 --- a/test/models/family_test.rb +++ b/test/models/family_test.rb @@ -18,7 +18,6 @@ class FamilyTest < ActiveSupport::TestCase assert category.persisted? assert_equal Category.investment_contributions_name, category.name assert_equal "#0d9488", category.color - assert_equal "expense", category.classification assert_equal "trending-up", category.lucide_icon end @@ -26,7 +25,6 @@ class FamilyTest < ActiveSupport::TestCase family = families(:dylan_family) existing = family.categories.find_or_create_by!(name: Category.investment_contributions_name) do |c| c.color = "#0d9488" - c.classification = "expense" c.lucide_icon = "trending-up" end @@ -89,7 +87,6 @@ class FamilyTest < ActiveSupport::TestCase legacy_category = family.categories.create!( name: "Investment Contributions", color: "#0d9488", - classification: "expense", lucide_icon: "trending-up" ) @@ -110,14 +107,12 @@ class FamilyTest < ActiveSupport::TestCase english_category = family.categories.create!( name: "Investment Contributions", color: "#0d9488", - classification: "expense", lucide_icon: "trending-up" ) french_category = family.categories.create!( name: "Contributions aux investissements", color: "#0d9488", - classification: "expense", lucide_icon: "trending-up" ) diff --git a/test/models/income_statement_test.rb b/test/models/income_statement_test.rb index 14280ec1c..253eea8d2 100644 --- a/test/models/income_statement_test.rb +++ b/test/models/income_statement_test.rb @@ -6,9 +6,9 @@ class IncomeStatementTest < ActiveSupport::TestCase setup do @family = families(:empty) - @income_category = @family.categories.create! name: "Income", classification: "income" - @food_category = @family.categories.create! name: "Food", classification: "expense" - @groceries_category = @family.categories.create! name: "Groceries", classification: "expense", parent: @food_category + @income_category = @family.categories.create! name: "Income" + @food_category = @family.categories.create! name: "Food" + @groceries_category = @family.categories.create! name: "Groceries", parent: @food_category @checking_account = @family.accounts.create! name: "Checking", currency: @family.currency, balance: 5000, accountable: Depository.new @credit_card_account = @family.accounts.create! name: "Credit Card", currency: @family.currency, balance: 1000, accountable: CreditCard.new @@ -114,7 +114,7 @@ class IncomeStatementTest < ActiveSupport::TestCase Entry.joins(:account).where(accounts: { family_id: @family.id }).destroy_all # Create different amounts for groceries vs other food - other_food_category = @family.categories.create! name: "Restaurants", classification: "expense", parent: @food_category + other_food_category = @family.categories.create! name: "Restaurants", parent: @food_category # Groceries: 100, 300, 500 (median = 300) create_transaction(account: @checking_account, amount: 100, category: @groceries_category) @@ -497,6 +497,45 @@ class IncomeStatementTest < ActiveSupport::TestCase refute_includes tax_advantaged_ids, @credit_card_account.id end + # net_category_totals tests + test "net_category_totals nets expense and refund in the same category" do + Entry.joins(:account).where(accounts: { family_id: @family.id }).destroy_all + + # $200 expense and $50 refund both on Food + create_transaction(account: @checking_account, amount: 200, category: @food_category) + create_transaction(account: @checking_account, amount: -50, category: @food_category) + + net = IncomeStatement.new(@family).net_category_totals(period: Period.last_30_days) + + assert_equal 150, net.total_net_expense + assert_equal 0, net.total_net_income + + food_net = net.net_expense_categories.find { |ct| ct.category.id == @food_category.id } + assert_not_nil food_net + assert_equal 150, food_net.total + assert_in_delta 100.0, food_net.weight, 0.1 + end + + test "net_category_totals places category on income side when refunds exceed expenses" do + Entry.joins(:account).where(accounts: { family_id: @family.id }).destroy_all + + # $100 expense but $250 refund on Food => net income of 150 + create_transaction(account: @checking_account, amount: 100, category: @food_category) + create_transaction(account: @checking_account, amount: -250, category: @food_category) + + net = IncomeStatement.new(@family).net_category_totals(period: Period.last_30_days) + + assert_equal 0, net.total_net_expense + assert_equal 150, net.total_net_income + + food_net = net.net_income_categories.find { |ct| ct.category.id == @food_category.id } + assert_not_nil food_net + assert_equal 150, food_net.total + + # Should not appear on expense side + assert_nil net.net_expense_categories.find { |ct| ct.category.id == @food_category.id } + end + test "returns zero totals when family has only tax-advantaged accounts" do # Create a fresh family with ONLY tax-advantaged accounts family_only_retirement = Family.create!( diff --git a/test/models/plaid_account/transactions/category_matcher_test.rb b/test/models/plaid_account/transactions/category_matcher_test.rb index 35bcf8fe2..f01a62889 100644 --- a/test/models/plaid_account/transactions/category_matcher_test.rb +++ b/test/models/plaid_account/transactions/category_matcher_test.rb @@ -5,9 +5,9 @@ class PlaidAccount::Transactions::CategoryMatcherTest < ActiveSupport::TestCase @family = families(:empty) # User income categories - @income = @family.categories.create!(name: "Income", classification: "income") - @dividend_income = @family.categories.create!(name: "Dividend Income", parent: @income, classification: "income") - @interest_income = @family.categories.create!(name: "Interest Income", parent: @income, classification: "income") + @income = @family.categories.create!(name: "Income") + @dividend_income = @family.categories.create!(name: "Dividend Income", parent: @income) + @interest_income = @family.categories.create!(name: "Interest Income", parent: @income) # User expense categories @loan_payments = @family.categories.create!(name: "Loan Payments") diff --git a/test/models/rule_import_test.rb b/test/models/rule_import_test.rb index 017194887..23cc246f8 100644 --- a/test/models/rule_import_test.rb +++ b/test/models/rule_import_test.rb @@ -6,7 +6,6 @@ class RuleImportTest < ActiveSupport::TestCase @category = @family.categories.create!( name: "Groceries", color: "#407706", - classification: "expense", lucide_icon: "shopping-basket" ) @csv = <<~CSV @@ -110,7 +109,6 @@ class RuleImportTest < ActiveSupport::TestCase new_category = Category.find_by!(family: @family, name: "Coffee Shops") assert_equal Category::UNCATEGORIZED_COLOR, new_category.color - assert_equal "expense", new_category.classification rule = Rule.find_by!(family: @family, name: "New category rule") action = rule.actions.first diff --git a/test/models/transaction/search_test.rb b/test/models/transaction/search_test.rb index c6d817ce1..1d7521c0e 100644 --- a/test/models/transaction/search_test.rb +++ b/test/models/transaction/search_test.rb @@ -152,8 +152,7 @@ class Transaction::SearchTest < ActiveSupport::TestCase # Create a travel category for testing travel_category = @family.categories.create!( name: "Travel", - color: "#3b82f6", - classification: "expense" + color: "#3b82f6" ) # Create transactions with different categories diff --git a/test/test_helper.rb b/test/test_helper.rb index afeab9f2e..5a227e964 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -87,7 +87,6 @@ module ActiveSupport family.categories.find_or_create_by!(name: Category.investment_contributions_name) do |c| c.color = "#0d9488" c.lucide_icon = "trending-up" - c.classification = "expense" end end end From d7a3b0cacd613bf6bc7a5d1e4819901cda1b9b57 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:45:42 +0100 Subject: [PATCH 52/75] Fix: Remove blank amount from transaction entry parameters (#1178) Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> --- app/controllers/transactions_controller.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index 076943529..00bc8f0da 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -331,6 +331,8 @@ class TransactionsController < ApplicationController nature = entry_params.delete(:nature) + entry_params.delete(:amount) if entry_params[:amount].blank? + if nature.present? && entry_params[:amount].present? signed_amount = nature == "inflow" ? -entry_params[:amount].to_d : entry_params[:amount].to_d entry_params = entry_params.merge(amount: signed_amount) From 9888b3071ca577b68a9b19244b90b2501a0f426e Mon Sep 17 00:00:00 2001 From: Alessio Cappa <104093777+alessiocappa@users.noreply.github.com> Date: Fri, 13 Mar 2026 07:56:29 +0100 Subject: [PATCH 53/75] feat: move account logo determination in dedicated method (#1190) --- app/models/account.rb | 10 +++++++++- app/views/accounts/_logo.html.erb | 19 +++++++++++-------- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/app/models/account.rb b/app/models/account.rb index 3c0f8eccf..63afce7d7 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -244,7 +244,15 @@ class Account < ApplicationRecord end def logo_url - provider&.logo_url + if institution_domain.present? && Setting.brand_fetch_client_id.present? + logo_size = Setting.brand_fetch_logo_size + + "https://cdn.brandfetch.io/#{institution_domain}/icon/fallback/lettermark/w/#{logo_size}/h/#{logo_size}?c=#{Setting.brand_fetch_client_id}" + elsif provider&.logo_url.present? + provider.logo_url + elsif logo.attached? + Rails.application.routes.url_helpers.rails_blob_path(logo, only_path: true) + end end def destroy_later diff --git a/app/views/accounts/_logo.html.erb b/app/views/accounts/_logo.html.erb index 8c4d9c433..5018f6dab 100644 --- a/app/views/accounts/_logo.html.erb +++ b/app/views/accounts/_logo.html.erb @@ -7,13 +7,16 @@ "full" => "w-full h-full" } %> -<% if account.institution_domain.present? && Setting.brand_fetch_client_id.present? %> - <% logo_size = Setting.brand_fetch_logo_size %> - <%= image_tag "https://cdn.brandfetch.io/#{account.institution_domain}/icon/fallback/lettermark/w/#{logo_size}/h/#{logo_size}?c=#{Setting.brand_fetch_client_id}", class: "shrink-0 rounded-full #{size_classes[size]}" %> -<% elsif account.logo_url.present? %> - <%= image_tag account.logo_url, class: "shrink-0 rounded-full #{size_classes[size]}", loading: "lazy" %> -<% elsif account.logo.attached? %> - <%= image_tag account.logo, class: "shrink-0 rounded-full #{size_classes[size]}" %> +<% if account.logo_url.present? %> + <%= image_tag account.logo_url, + class: "shrink-0 rounded-full #{size_classes[size]}", + loading: "lazy" %> <% else %> - <%= render DS::FilledIcon.new(variant: :text, hex_color: color || account.accountable.color, text: account.name, size: size, rounded: true) %> + <%= render DS::FilledIcon.new( + variant: :text, + hex_color: color || account.accountable.color, + text: account.name, + size: size, + rounded: true + ) %> <% end %> From 80026aeee44d82c46d8dd8a0a034131e74f4c6a3 Mon Sep 17 00:00:00 2001 From: Alessio Cappa <104093777+alessiocappa@users.noreply.github.com> Date: Fri, 13 Mar 2026 07:59:45 +0100 Subject: [PATCH 54/75] Add "Transaction account" as rule condition filter (#1186) * feat: Add transaction account as rule condition filter * Update app/models/rule/condition_filter/transaction_account.rb Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Alessio Cappa <104093777+alessiocappa@users.noreply.github.com> --------- Signed-off-by: Alessio Cappa <104093777+alessiocappa@users.noreply.github.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../condition_filter/transaction_account.rb | 14 +++++ .../rule/registry/transaction_resource.rb | 3 +- test/models/rule_test.rb | 54 +++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 app/models/rule/condition_filter/transaction_account.rb diff --git a/app/models/rule/condition_filter/transaction_account.rb b/app/models/rule/condition_filter/transaction_account.rb new file mode 100644 index 000000000..dcdabdf65 --- /dev/null +++ b/app/models/rule/condition_filter/transaction_account.rb @@ -0,0 +1,14 @@ +class Rule::ConditionFilter::TransactionAccount < Rule::ConditionFilter + def type + "select" + end + + def options + family.accounts.alphabetically.pluck(:name, :id) + end + + def apply(scope, operator, value) + expression = build_sanitized_where_condition("entries.account_id", operator, value) + scope.where(expression) + end +end diff --git a/app/models/rule/registry/transaction_resource.rb b/app/models/rule/registry/transaction_resource.rb index a9497d9c0..fb4847728 100644 --- a/app/models/rule/registry/transaction_resource.rb +++ b/app/models/rule/registry/transaction_resource.rb @@ -11,7 +11,8 @@ class Rule::Registry::TransactionResource < Rule::Registry Rule::ConditionFilter::TransactionMerchant.new(rule), Rule::ConditionFilter::TransactionCategory.new(rule), Rule::ConditionFilter::TransactionDetails.new(rule), - Rule::ConditionFilter::TransactionNotes.new(rule) + Rule::ConditionFilter::TransactionNotes.new(rule), + Rule::ConditionFilter::TransactionAccount.new(rule) ] end diff --git a/test/models/rule_test.rb b/test/models/rule_test.rb index 6f50221be..cc4256436 100644 --- a/test/models/rule_test.rb +++ b/test/models/rule_test.rb @@ -294,4 +294,58 @@ class RuleTest < ActiveSupport::TestCase test "total_affected_resource_count returns zero for empty rules" do assert_equal 0, Rule.total_affected_resource_count([]) end + + test "rule matching on transaction account" do + # Create a second account + other_account = @family.accounts.create!( + name: "Other account", + balance: 500, + currency: "USD", + accountable: Depository.new + ) + + # Transaction on the target account + transaction_entry1 = create_transaction( + date: Date.current, + account: @account, + amount: 50 + ) + + # Transaction on another account + transaction_entry2 = create_transaction( + date: Date.current, + account: other_account, + amount: 75 + ) + + rule = Rule.create!( + family: @family, + resource_type: "transaction", + effective_date: 1.day.ago.to_date, + conditions: [ + Rule::Condition.new( + condition_type: "transaction_account", + operator: "=", + value: @account.id + ) + ], + actions: [ + Rule::Action.new( + action_type: "set_transaction_category", + value: @groceries_category.id + ) + ] + ) + + rule.apply + + transaction_entry1.reload + transaction_entry2.reload + + assert_equal @groceries_category, transaction_entry1.transaction.category, + "Transaction on selected account should be categorized" + + assert_nil transaction_entry2.transaction.category, + "Transaction on other account should not be categorized" + end end From 3adc011df04005ddad06ad373e05ba92c9b3c092 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Fri, 13 Mar 2026 08:07:30 +0100 Subject: [PATCH 55/75] Require admin role for API family reset (#1189) Prevent non-admin users with read_write API access from triggering family-wide reset jobs via /api/v1/users/reset. --- app/controllers/api/v1/users_controller.rb | 8 ++++++++ .../api/v1/users_controller_test.rb | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/app/controllers/api/v1/users_controller.rb b/app/controllers/api/v1/users_controller.rb index be01669f4..0a87bf43e 100644 --- a/app/controllers/api/v1/users_controller.rb +++ b/app/controllers/api/v1/users_controller.rb @@ -2,6 +2,7 @@ class Api::V1::UsersController < Api::V1::BaseController before_action :ensure_write_scope + before_action :ensure_admin, only: :reset def reset FamilyResetJob.perform_later(Current.family) @@ -24,4 +25,11 @@ class Api::V1::UsersController < Api::V1::BaseController def ensure_write_scope authorize_scope!(:write) end + + def ensure_admin + return true if current_resource_owner&.admin? + + render_json({ error: "forbidden", message: I18n.t("users.reset.unauthorized") }, status: :forbidden) + false + end end diff --git a/test/controllers/api/v1/users_controller_test.rb b/test/controllers/api/v1/users_controller_test.rb index 9ea8b89cb..9a8be9d85 100644 --- a/test/controllers/api/v1/users_controller_test.rb +++ b/test/controllers/api/v1/users_controller_test.rb @@ -50,6 +50,24 @@ class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest # -- Reset ----------------------------------------------------------------- + + test "reset requires admin role" do + non_admin_api_key = ApiKey.create!( + user: users(:family_member), + name: "Member Read-Write Key", + scopes: [ "read_write" ], + display_key: "test_member_#{SecureRandom.hex(8)}" + ) + + assert_no_enqueued_jobs only: FamilyResetJob do + delete "/api/v1/users/reset", headers: api_headers(non_admin_api_key) + end + + assert_response :forbidden + body = JSON.parse(response.body) + assert_equal "You are not authorized to perform this action", body["message"] + end + test "reset enqueues FamilyResetJob and returns 200" do assert_enqueued_with(job: FamilyResetJob) do delete "/api/v1/users/reset", headers: api_headers(@api_key) From 50f3a5c03042b53245013594be5d92c843bf62bb Mon Sep 17 00:00:00 2001 From: Chase Martin <8998243+chasestech@users.noreply.github.com> Date: Fri, 13 Mar 2026 03:11:51 -0400 Subject: [PATCH 56/75] Fix Plaid link script loading and first-sync account linking (#1165) * fix: Handle conditional loading of Plaid Link script * fix: Plaid accounts not linking on first sync * fix: Handle Plaid script loading edge cases * fix: Use connection token for disconnect safety and retry failed script loads * fix: Destroy Plaid Link handler on controller disconnect * fix: Add timeout to Plaid CDN script loader to prevent deadlocks --- .../controllers/plaid_controller.js | 72 +++++++++++++++++-- app/models/plaid_item/syncer.rb | 17 +++-- app/views/layouts/shared/_head.html.erb | 1 - .../plaid_items/_auto_link_opener.html.erb | 4 -- 4 files changed, 78 insertions(+), 16 deletions(-) diff --git a/app/javascript/controllers/plaid_controller.js b/app/javascript/controllers/plaid_controller.js index a12660f0a..e24031f77 100644 --- a/app/javascript/controllers/plaid_controller.js +++ b/app/javascript/controllers/plaid_controller.js @@ -10,11 +10,75 @@ export default class extends Controller { }; connect() { - this.open(); + this._connectionToken = (this._connectionToken ?? 0) + 1; + const connectionToken = this._connectionToken; + this.open(connectionToken).catch((error) => { + console.error("Failed to initialize Plaid Link", error); + }); } - open() { - const handler = Plaid.create({ + disconnect() { + this._handler?.destroy(); + this._handler = null; + this._connectionToken = (this._connectionToken ?? 0) + 1; + } + + waitForPlaid() { + if (typeof Plaid !== "undefined") { + return Promise.resolve(); + } + + return new Promise((resolve, reject) => { + let plaidScript = document.querySelector( + 'script[src*="link-initialize.js"]' + ); + + // Reject if the CDN request stalls without firing load or error + const timeoutId = window.setTimeout(() => { + if (plaidScript) plaidScript.dataset.plaidState = "error"; + reject(new Error("Timed out loading Plaid script")); + }, 10_000); + + // Remove previously failed script so we can retry with a fresh element + if (plaidScript?.dataset.plaidState === "error") { + plaidScript.remove(); + plaidScript = null; + } + + if (!plaidScript) { + plaidScript = document.createElement("script"); + plaidScript.src = "https://cdn.plaid.com/link/v2/stable/link-initialize.js"; + plaidScript.async = true; + plaidScript.dataset.plaidState = "loading"; + document.head.appendChild(plaidScript); + } + + plaidScript.addEventListener("load", () => { + window.clearTimeout(timeoutId); + plaidScript.dataset.plaidState = "loaded"; + resolve(); + }, { once: true }); + plaidScript.addEventListener("error", () => { + window.clearTimeout(timeoutId); + plaidScript.dataset.plaidState = "error"; + reject(new Error("Failed to load Plaid script")); + }, { once: true }); + + // Re-check after attaching listeners in case the script loaded between + // the initial typeof check and listener attachment (avoids a permanently + // pending promise on retry flows). + if (typeof Plaid !== "undefined") { + window.clearTimeout(timeoutId); + resolve(); + } + }); + } + + async open(connectionToken = this._connectionToken) { + await this.waitForPlaid(); + if (connectionToken !== this._connectionToken) return; + + this._handler = Plaid.create({ token: this.linkTokenValue, onSuccess: this.handleSuccess, onLoad: this.handleLoad, @@ -22,7 +86,7 @@ export default class extends Controller { onEvent: this.handleEvent, }); - handler.open(); + this._handler.open(); } handleSuccess = (public_token, metadata) => { diff --git a/app/models/plaid_item/syncer.rb b/app/models/plaid_item/syncer.rb index 74d66a58b..1f90f68c6 100644 --- a/app/models/plaid_item/syncer.rb +++ b/app/models/plaid_item/syncer.rb @@ -12,8 +12,16 @@ class PlaidItem::Syncer sync.update!(status_text: "Importing accounts from Plaid...") if sync.respond_to?(:status_text) plaid_item.import_latest_plaid_data - # Phase 2: Collect setup statistics + # Phase 2: Process the raw Plaid data and create/update internal domain objects + # This must happen before the linked/unlinked check because process_accounts + # is what creates Account and AccountProvider records for new PlaidAccounts. + sync.update!(status_text: "Processing accounts...") if sync.respond_to?(:status_text) + mark_import_started(sync) + plaid_item.process_accounts + + # Phase 3: Collect setup statistics (now that accounts have been processed) sync.update!(status_text: "Checking account configuration...") if sync.respond_to?(:status_text) + plaid_item.plaid_accounts.reload collect_setup_stats(sync, provider_accounts: plaid_item.plaid_accounts) # Check for unlinked accounts and update pending_account_setup flag @@ -25,14 +33,9 @@ class PlaidItem::Syncer 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 + # Phase 4: Schedule balance calculations for linked accounts 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, diff --git a/app/views/layouts/shared/_head.html.erb b/app/views/layouts/shared/_head.html.erb index b96ce2c61..d908a5d0a 100644 --- a/app/views/layouts/shared/_head.html.erb +++ b/app/views/layouts/shared/_head.html.erb @@ -8,7 +8,6 @@ <%= combobox_style_tag %> - <%= yield :plaid_link %> <%= javascript_importmap_tags %> <%= render "layouts/dark_mode_check" %> <%= turbo_refreshes_with method: :morph, scroll: :preserve %> diff --git a/app/views/plaid_items/_auto_link_opener.html.erb b/app/views/plaid_items/_auto_link_opener.html.erb index b25884954..7e9c76950 100644 --- a/app/views/plaid_items/_auto_link_opener.html.erb +++ b/app/views/plaid_items/_auto_link_opener.html.erb @@ -1,9 +1,5 @@ <%# locals: (link_token:, region:, item_id:, is_update: false) %> -<% content_for :plaid_link, flush: true do %> - <%= javascript_include_tag "https://cdn.plaid.com/link/v2/stable/link-initialize.js" %> -<% end %> - <%= tag.div data: { controller: "plaid", plaid_link_token_value: link_token, From 02af8463f6ee4adfb05ec6cca91d29558720a327 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Sat, 14 Mar 2026 11:32:33 +0100 Subject: [PATCH 57/75] Administer invitations in `/admin/users` (#1185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add invited users with delete button to admin users page Shows pending invitations per family below active users in /admin/users/. Each invitation row has a red Delete button aligned with the role column. Alt/option-clicking any Delete button changes all invitation button labels to "Delete All" and destroys all pending invitations for that family. - Add admin routes: DELETE /admin/invitations/:id and DELETE /admin/families/:id/invitations - Add Admin::InvitationsController with destroy and destroy_all actions - Load pending invitations grouped by family in users controller index - Render invitation rows in a dashed-border tbody below active user rows - Add admin-invitation-delete Stimulus controller for alt-click behavior - Add i18n strings for invitation UI and flash messages https://claude.ai/code/session_01F8WaH5TmtdUWwhHnVoQ6Gm * Fix destroy_all using params[:id] from member route The member route /admin/families/:id/invitations sets params[:id], not params[:family_id], so Family.find was always receiving nil. https://claude.ai/code/session_01F8WaH5TmtdUWwhHnVoQ6Gm * Fix translation key in destroy_all to match locale t(".success_all") looked up a nonexistent key; the locale defines admin.invitations.destroy_all.success, so t(".success") is correct. https://claude.ai/code/session_01F8WaH5TmtdUWwhHnVoQ6Gm * Scope bulk delete to pending invitations and allow re-inviting emails - destroy_all now uses family.invitations.pending.destroy_all so accepted and expired invitation history is preserved - Replace blanket email uniqueness validation with a custom check scoped to pending invitations only, so the same email can be invited again after an invitation is deleted or expires https://claude.ai/code/session_01F8WaH5TmtdUWwhHnVoQ6Gm * Drop unconditional unique DB index on invitations(email, family_id) The model-level uniqueness check was already scoped to pending invitations, but the blanket unique index on (email, family_id) still caused ActiveRecord::RecordNotUnique when re-inviting an email that had any historical invitation record in the same family (e.g. after an accepted invite or after an account deletion). Replace it with no DB-level unique constraint — the no_duplicate_pending_invitation_in_family model validation is the sole enforcer and correctly scopes uniqueness to pending rows only. https://claude.ai/code/session_01F8WaH5TmtdUWwhHnVoQ6Gm * Replace blanket unique index with partial unique index on pending invitations Instead of dropping the DB-level uniqueness constraint entirely, replace the unconditional unique index on (email, family_id) with a partial unique index scoped to WHERE accepted_at IS NULL. This enforces the invariant at the DB layer (no two non-accepted invitations for the same email in a family) while allowing re-invites once a prior invitation has been accepted. https://claude.ai/code/session_01F8WaH5TmtdUWwhHnVoQ6Gm * Fix migration version and make remove_index reversible - Change Migration[8.0] to Migration[7.2] to match the rest of the codebase - Pass column names to remove_index so Rails can reconstruct the old index on rollback https://claude.ai/code/session_01F8WaH5TmtdUWwhHnVoQ6Gm --------- Signed-off-by: Juan José Mata Co-authored-by: Claude --- .../admin/invitations_controller.rb | 17 +++++++ app/controllers/admin/users_controller.rb | 4 ++ .../admin_invitation_delete_controller.js | 22 ++++++++++ app/models/invitation.rb | 17 ++++++- app/views/admin/users/index.html.erb | 44 ++++++++++++++++++- config/locales/views/admin/invitations/en.yml | 8 ++++ config/locales/views/admin/users/en.yml | 5 +++ config/routes.rb | 6 +++ ...que_email_family_index_from_invitations.rb | 9 ++++ db/schema.rb | 4 +- 10 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 app/controllers/admin/invitations_controller.rb create mode 100644 app/javascript/controllers/admin_invitation_delete_controller.js create mode 100644 config/locales/views/admin/invitations/en.yml create mode 100644 db/migrate/20260314120000_remove_unique_email_family_index_from_invitations.rb diff --git a/app/controllers/admin/invitations_controller.rb b/app/controllers/admin/invitations_controller.rb new file mode 100644 index 000000000..50dd7cff7 --- /dev/null +++ b/app/controllers/admin/invitations_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Admin + class InvitationsController < Admin::BaseController + def destroy + invitation = Invitation.find(params[:id]) + invitation.destroy! + redirect_to admin_users_path, notice: t(".success") + end + + def destroy_all + family = Family.find(params[:id]) + family.invitations.pending.destroy_all + redirect_to admin_users_path, notice: t(".success") + end + end +end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index f5a3ae953..a86fda917 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -35,6 +35,10 @@ module Admin -(@entries_count_by_family[family.id] || 0) end + @invitations_by_family = Invitation.pending + .where(family_id: family_ids) + .group_by(&:family_id) + @trials_expiring_in_7_days = Subscription .where(status: :trialing) .where(trial_ends_at: Time.current..7.days.from_now) diff --git a/app/javascript/controllers/admin_invitation_delete_controller.js b/app/javascript/controllers/admin_invitation_delete_controller.js new file mode 100644 index 000000000..e819d4200 --- /dev/null +++ b/app/javascript/controllers/admin_invitation_delete_controller.js @@ -0,0 +1,22 @@ +import { Controller } from "@hotwired/stimulus" + +// Connects to data-controller="admin-invitation-delete" +// Handles individual invitation deletion and alt-click to delete all family invitations +export default class extends Controller { + static targets = [ "button", "destroyAllForm" ] + static values = { deleteAllLabel: String } + + handleClick(event) { + if (event.altKey) { + event.preventDefault() + + this.buttonTargets.forEach(btn => { + btn.textContent = this.deleteAllLabelValue + }) + + if (this.hasDestroyAllFormTarget) { + this.destroyAllFormTarget.requestSubmit() + } + } + } +} diff --git a/app/models/invitation.rb b/app/models/invitation.rb index e2900c7cf..42bee7e9d 100644 --- a/app/models/invitation.rb +++ b/app/models/invitation.rb @@ -13,7 +13,7 @@ class Invitation < ApplicationRecord validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } validates :role, presence: true, inclusion: { in: %w[admin member guest] } validates :token, presence: true, uniqueness: true - validates_uniqueness_of :email, scope: :family_id, message: "has already been invited to this family" + validate :no_duplicate_pending_invitation_in_family validate :inviter_is_admin validate :no_other_pending_invitation, on: :create @@ -77,6 +77,21 @@ class Invitation < ApplicationRecord end end + def no_duplicate_pending_invitation_in_family + return if email.blank? + + scope = self.class.pending.where(family_id: family_id) + scope = scope.where.not(id: id) if persisted? + + exists = if self.class.encryption_ready? + scope.where(email: email).exists? + else + scope.where("LOWER(email) = ?", email.to_s.strip.downcase).exists? + end + + errors.add(:email, "has already been invited to this family") if exists + end + def inviter_is_admin inviter.admin? end diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb index 0c21d2060..ac30a0062 100644 --- a/app/views/admin/users/index.html.erb +++ b/app/views/admin/users/index.html.erb @@ -50,7 +50,10 @@ <% if @families_with_users.any? %>
    <% @families_with_users.each do |family, users| %> -
    + <% pending_invitations = @invitations_by_family[family.id] || [] %> +
    <%= icon "users", class: "w-5 h-5 text-secondary shrink-0" %> @@ -133,7 +136,46 @@ <% end %> + <% if pending_invitations.any? %> + + <% pending_invitations.each do |invitation| %> + + +
    + <%= icon "mail", class: "w-5 h-5 text-secondary shrink-0" %> +
    +

    <%= invitation.email %>

    +

    <%= t(".invitations.pending_label") %>

    +
    +
    + + + <%= t(".invitations.expires", date: invitation.expires_at.to_fs(:long)) %> + + + — + + + <%= form_with url: admin_invitation_path(invitation), method: :delete, class: "inline" do |f| %> + + <% end %> + + + <% end %> + + <% end %> + <% if pending_invitations.any? %> + <%= form_with url: invitations_admin_family_path(family), method: :delete, + data: { admin_invitation_delete_target: "destroyAllForm" }, + class: "hidden" do |f| %> + <% end %> + <% end %>
    <% end %> diff --git a/config/locales/views/admin/invitations/en.yml b/config/locales/views/admin/invitations/en.yml new file mode 100644 index 000000000..389566e19 --- /dev/null +++ b/config/locales/views/admin/invitations/en.yml @@ -0,0 +1,8 @@ +--- +en: + admin: + invitations: + destroy: + success: "Invitation deleted." + destroy_all: + success: "All invitations for this family have been deleted." diff --git a/config/locales/views/admin/users/en.yml b/config/locales/views/admin/users/en.yml index b14feb785..c14a243d5 100644 --- a/config/locales/views/admin/users/en.yml +++ b/config/locales/views/admin/users/en.yml @@ -43,6 +43,11 @@ en: member: "Basic user access. Can manage their own accounts, transactions, and settings." admin: "Family administrator. Can access advanced settings like API keys, imports, and AI prompts." super_admin: "Instance administrator. Can manage SSO providers, user roles, and impersonate users for support." + invitations: + pending_label: "Invited (pending)" + expires: "Expires %{date}" + delete: "Delete" + delete_all: "Delete All" update: success: "User role updated successfully." failure: "Failed to update user role." diff --git a/config/routes.rb b/config/routes.rb index d0b6c8827..3cf33b146 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -505,6 +505,12 @@ Rails.application.routes.draw do end end resources :users, only: [ :index, :update ] + resources :invitations, only: [ :destroy ] + resources :families, only: [] do + member do + delete :invitations, to: "invitations#destroy_all" + end + end end # Defines the root path route ("/") diff --git a/db/migrate/20260314120000_remove_unique_email_family_index_from_invitations.rb b/db/migrate/20260314120000_remove_unique_email_family_index_from_invitations.rb new file mode 100644 index 000000000..d714a4143 --- /dev/null +++ b/db/migrate/20260314120000_remove_unique_email_family_index_from_invitations.rb @@ -0,0 +1,9 @@ +class RemoveUniqueEmailFamilyIndexFromInvitations < ActiveRecord::Migration[7.2] + def change + remove_index :invitations, [ :email, :family_id ], name: "index_invitations_on_email_and_family_id" + add_index :invitations, [ :email, :family_id ], + name: "index_invitations_on_email_and_family_id_pending", + unique: true, + where: "accepted_at IS NULL" + end +end diff --git a/db/schema.rb b/db/schema.rb index 9c6837f21..b56fb9f41 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_03_08_113006) do +ActiveRecord::Schema[7.2].define(version: 2026_03_14_120000) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -740,7 +740,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_03_08_113006) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "token_digest" - t.index ["email", "family_id"], name: "index_invitations_on_email_and_family_id", unique: true + t.index ["email", "family_id"], name: "index_invitations_on_email_and_family_id_pending", unique: true, where: "(accepted_at IS NULL)" t.index ["email"], name: "index_invitations_on_email" t.index ["family_id"], name: "index_invitations_on_family_id" t.index ["inviter_id"], name: "index_invitations_on_inviter_id" From f0902aa8e40f86a0102a826b085fbc1f14610fa7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 14 Mar 2026 10:45:08 +0000 Subject: [PATCH 58/75] Bump version to next iteration after v0.6.9-alpha.4 release --- charts/sure/Chart.yaml | 4 ++-- config/initializers/version.rb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/charts/sure/Chart.yaml b/charts/sure/Chart.yaml index 35011dd8a..64eccc0f5 100644 --- a/charts/sure/Chart.yaml +++ b/charts/sure/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: sure description: Official Helm chart for deploying the Sure Rails app (web + Sidekiq) on Kubernetes with optional HA PostgreSQL (CloudNativePG) and Redis. type: application -version: 0.6.9-alpha.4 -appVersion: "0.6.9-alpha.4" +version: 0.6.9-alpha.5 +appVersion: "0.6.9-alpha.5" kubeVersion: ">=1.25.0-0" diff --git a/config/initializers/version.rb b/config/initializers/version.rb index 5b83f4f81..dfb1ed6b1 100644 --- a/config/initializers/version.rb +++ b/config/initializers/version.rb @@ -16,7 +16,7 @@ module Sure private def semver - "0.6.9-alpha.4" + "0.6.9-alpha.5" end end end From 5b0ddd06a4ead74d35f69bb7e468650e9be63326 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Sat, 14 Mar 2026 20:14:18 +0100 Subject: [PATCH 59/75] Add post-trial inactive `Family` cleanup with data archival (#1199) * Add post-trial inactive family cleanup with data archival Families that expire their trial without subscribing now get cleaned up daily. Empty families (no accounts) are destroyed immediately after a 14-day grace period. Families with meaningful data (12+ transactions, some recent) get their data exported as NDJSON/ZIP to an ArchivedExport record before deletion, downloadable via a token-based URL for 90 days. - Add InactiveFamilyCleanerJob (scheduled daily at 4 AM, managed mode only) - Add ArchivedExport model with token-based downloads - Add inactive_trial_for_cleanup scope and requires_data_archive? to Family - Extend DataCleanerJob to purge expired archived exports - Add ArchivedExportsController for unauthenticated token downloads https://claude.ai/code/session_01LR3Vo83R5s5SczYe6T33dQ * Fix Brakeman redirect warning in ArchivedExportsController Use rails_blob_path instead of redirecting directly to the ActiveStorage attachment, which avoids the allow_other_host: true open redirect. https://claude.ai/code/session_01LR3Vo83R5s5SczYe6T33dQ * Update schema.rb with archived_exports table Add the archived_exports table definition to schema.rb to match the pending migration, unblocking CI tests. https://claude.ai/code/session_01LR3Vo83R5s5SczYe6T33dQ * Fix broken CI tests for ArchivedExports and InactiveFamilyCleaner - ArchivedExportsController 404 test: use assert_response :not_found instead of assert_raises since Rails rescues RecordNotFound in integration tests and returns a 404 response. - InactiveFamilyCleanerJob test: remove assert_no_difference on Family.count since the inactive_trial fixture gets cleaned up by the job. The test intent is to verify the active family survives, which is checked by assert Family.exists?. https://claude.ai/code/session_01LR3Vo83R5s5SczYe6T33dQ * Wrap ArchivedExport creation in a transaction Ensure the ArchivedExport record and its file attachment succeed atomically. If the attach fails, the transaction rolls back so no orphaned record is left without an export file. https://claude.ai/code/session_01LR3Vo83R5s5SczYe6T33dQ * Store only a digest of the download token for ArchivedExport Replace plaintext download_token column with download_token_digest (SHA-256 hex). The raw token is generated via SecureRandom on create, exposed transiently via attr_reader for use in emails/logs, and only its digest is persisted. Lookup uses find_by_download_token! which digests the incoming token before querying. https://claude.ai/code/session_01LR3Vo83R5s5SczYe6T33dQ * Remove raw download token from cleanup job logs Log a truncated digest prefix instead of the raw token, which is the sole credential for the unauthenticated download endpoint. https://claude.ai/code/session_01LR3Vo83R5s5SczYe6T33dQ * Fix empty assert_no_difference block in cleaner job test Wrap the perform_now call with both assertions so the ArchivedExport.count check actually exercises the job. https://claude.ai/code/session_01LR3Vo83R5s5SczYe6T33dQ --------- Co-authored-by: Claude --- .../archived_exports_controller.rb | 13 ++ app/jobs/data_cleaner_job.rb | 7 + app/jobs/inactive_family_cleaner_job.rb | 64 ++++++++ app/models/archived_export.rb | 29 ++++ app/models/family/subscribeable.rb | 28 ++++ config/routes.rb | 2 + config/schedule.yml | 8 +- .../20260314131357_create_archived_exports.rb | 15 ++ db/schema.rb | 13 +- .../archived_exports_controller_test.rb | 57 +++++++ test/fixtures/families.yml | 4 + test/fixtures/subscriptions.yml | 9 +- test/fixtures/users.yml | 13 ++ test/jobs/inactive_family_cleaner_job_test.rb | 149 ++++++++++++++++++ test/models/archived_export_test.rb | 70 ++++++++ test/models/family/subscribeable_test.rb | 82 ++++++++++ 16 files changed, 559 insertions(+), 4 deletions(-) create mode 100644 app/controllers/archived_exports_controller.rb create mode 100644 app/jobs/inactive_family_cleaner_job.rb create mode 100644 app/models/archived_export.rb create mode 100644 db/migrate/20260314131357_create_archived_exports.rb create mode 100644 test/controllers/archived_exports_controller_test.rb create mode 100644 test/jobs/inactive_family_cleaner_job_test.rb create mode 100644 test/models/archived_export_test.rb diff --git a/app/controllers/archived_exports_controller.rb b/app/controllers/archived_exports_controller.rb new file mode 100644 index 000000000..f626141a6 --- /dev/null +++ b/app/controllers/archived_exports_controller.rb @@ -0,0 +1,13 @@ +class ArchivedExportsController < ApplicationController + skip_authentication + + def show + export = ArchivedExport.find_by_download_token!(params[:token]) + + if export.downloadable? + redirect_to rails_blob_path(export.export_file, disposition: "attachment") + else + head :gone + end + end +end diff --git a/app/jobs/data_cleaner_job.rb b/app/jobs/data_cleaner_job.rb index 8cb22f283..becf0fba5 100644 --- a/app/jobs/data_cleaner_job.rb +++ b/app/jobs/data_cleaner_job.rb @@ -3,6 +3,7 @@ class DataCleanerJob < ApplicationJob def perform clean_old_merchant_associations + clean_expired_archived_exports end private @@ -14,4 +15,10 @@ class DataCleanerJob < ApplicationJob Rails.logger.info("DataCleanerJob: Deleted #{deleted_count} old merchant associations") if deleted_count > 0 end + + def clean_expired_archived_exports + deleted_count = ArchivedExport.expired.destroy_all.count + + Rails.logger.info("DataCleanerJob: Deleted #{deleted_count} expired archived exports") if deleted_count > 0 + end end diff --git a/app/jobs/inactive_family_cleaner_job.rb b/app/jobs/inactive_family_cleaner_job.rb new file mode 100644 index 000000000..118d1c4c1 --- /dev/null +++ b/app/jobs/inactive_family_cleaner_job.rb @@ -0,0 +1,64 @@ +class InactiveFamilyCleanerJob < ApplicationJob + queue_as :scheduled + + BATCH_SIZE = 500 + ARCHIVE_EXPIRY = 90.days + + def perform(dry_run: false) + return unless Rails.application.config.app_mode.managed? + + families = Family.inactive_trial_for_cleanup.limit(BATCH_SIZE) + count = families.count + + if count == 0 + Rails.logger.info("InactiveFamilyCleanerJob: No inactive families to clean up") + return + end + + Rails.logger.info("InactiveFamilyCleanerJob: Found #{count} inactive families to clean up#{' (dry run)' if dry_run}") + + families.find_each do |family| + if family.requires_data_archive? + if dry_run + Rails.logger.info("InactiveFamilyCleanerJob: Would archive data for family #{family.id}") + else + archive_family_data(family) + end + end + + if dry_run + Rails.logger.info("InactiveFamilyCleanerJob: Would destroy family #{family.id} (created: #{family.created_at})") + else + Rails.logger.info("InactiveFamilyCleanerJob: Destroying family #{family.id} (created: #{family.created_at})") + family.destroy + end + end + + Rails.logger.info("InactiveFamilyCleanerJob: Completed cleanup of #{count} families#{' (dry run)' if dry_run}") + end + + private + + def archive_family_data(family) + export_data = Family::DataExporter.new(family).generate_export + email = family.users.order(:created_at).first&.email + + ActiveRecord::Base.transaction do + archive = ArchivedExport.create!( + email: email || "unknown", + family_name: family.name, + expires_at: ARCHIVE_EXPIRY.from_now + ) + + archive.export_file.attach( + io: export_data, + filename: "sure_archive_#{family.id}.zip", + content_type: "application/zip" + ) + + raise ActiveRecord::Rollback, "File attach failed" unless archive.export_file.attached? + + Rails.logger.info("InactiveFamilyCleanerJob: Archived data for family #{family.id} (email: #{email}, token_digest: #{archive.download_token_digest.first(8)}...)") + end + end +end diff --git a/app/models/archived_export.rb b/app/models/archived_export.rb new file mode 100644 index 000000000..fb0f48181 --- /dev/null +++ b/app/models/archived_export.rb @@ -0,0 +1,29 @@ +class ArchivedExport < ApplicationRecord + has_one_attached :export_file, dependent: :purge_later + + scope :expired, -> { where(expires_at: ...Time.current) } + + attr_reader :download_token + + before_create :set_download_token_digest + + def downloadable? + expires_at > Time.current && export_file.attached? + end + + def self.find_by_download_token!(token) + find_by!(download_token_digest: digest_token(token)) + end + + def self.digest_token(token) + OpenSSL::Digest::SHA256.hexdigest(token) + end + + private + + def set_download_token_digest + raw_token = SecureRandom.urlsafe_base64(24) + @download_token = raw_token + self.download_token_digest = self.class.digest_token(raw_token) + end +end diff --git a/app/models/family/subscribeable.rb b/app/models/family/subscribeable.rb index de75bbe0c..9ac267f6c 100644 --- a/app/models/family/subscribeable.rb +++ b/app/models/family/subscribeable.rb @@ -1,8 +1,27 @@ module Family::Subscribeable extend ActiveSupport::Concern + CLEANUP_GRACE_PERIOD = 14.days + ARCHIVE_TRANSACTION_THRESHOLD = 12 + ARCHIVE_RECENT_ACTIVITY_WINDOW = 14.days + included do has_one :subscription, dependent: :destroy + + scope :inactive_trial_for_cleanup, -> { + cutoff_with_sub = CLEANUP_GRACE_PERIOD.ago + cutoff_without_sub = (Subscription::TRIAL_DAYS.days + CLEANUP_GRACE_PERIOD).ago + + expired_trial = left_joins(:subscription) + .where(subscriptions: { status: [ "paused", "trialing" ] }) + .where(subscriptions: { trial_ends_at: ...cutoff_with_sub }) + + no_subscription = left_joins(:subscription) + .where(subscriptions: { id: nil }) + .where(families: { created_at: ...cutoff_without_sub }) + + where(id: expired_trial).or(where(id: no_subscription)) + } end def payment_email @@ -85,4 +104,13 @@ module Family::Subscribeable subscription.update!(status: "paused") end end + + def requires_data_archive? + return false unless transactions.count > ARCHIVE_TRANSACTION_THRESHOLD + + trial_end = subscription&.trial_ends_at || (created_at + Subscription::TRIAL_DAYS.days) + recent_window_start = trial_end - ARCHIVE_RECENT_ACTIVITY_WINDOW + + entries.where(date: recent_window_start..trial_end).exists? + end end diff --git a/config/routes.rb b/config/routes.rb index 3cf33b146..8fede6930 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -125,6 +125,8 @@ Rails.application.routes.draw do end end + get "exports/archive/:token", to: "archived_exports#show", as: :archived_export + get "changelog", to: "pages#changelog" get "feedback", to: "pages#feedback" patch "dashboard/preferences", to: "pages#update_preferences" diff --git a/config/schedule.yml b/config/schedule.yml index c0d324408..74ac99122 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -29,4 +29,10 @@ clean_data: cron: "0 3 * * *" # daily at 3:00 AM class: "DataCleanerJob" queue: "scheduled" - description: "Cleans up old data (e.g., expired merchant associations)" + description: "Cleans up old data (e.g., expired merchant associations, expired archived exports)" + +clean_inactive_families: + cron: "0 4 * * *" # daily at 4:00 AM + class: "InactiveFamilyCleanerJob" + queue: "scheduled" + description: "Archives and destroys families that expired their trial without subscribing (managed mode only)" diff --git a/db/migrate/20260314131357_create_archived_exports.rb b/db/migrate/20260314131357_create_archived_exports.rb new file mode 100644 index 000000000..1fecb099e --- /dev/null +++ b/db/migrate/20260314131357_create_archived_exports.rb @@ -0,0 +1,15 @@ +class CreateArchivedExports < ActiveRecord::Migration[7.2] + def change + create_table :archived_exports, id: :uuid do |t| + t.string :email, null: false + t.string :family_name + t.string :download_token_digest, null: false + t.datetime :expires_at, null: false + + t.timestamps + end + + add_index :archived_exports, :download_token_digest, unique: true + add_index :archived_exports, :expires_at + end +end diff --git a/db/schema.rb b/db/schema.rb index b56fb9f41..ae425569d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_03_14_120000) do +ActiveRecord::Schema[7.2].define(version: 2026_03_14_131357) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -125,6 +125,17 @@ ActiveRecord::Schema[7.2].define(version: 2026_03_14_120000) do t.index ["user_id"], name: "index_api_keys_on_user_id" end + create_table "archived_exports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "email", null: false + t.string "family_name" + t.string "download_token_digest", null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["download_token_digest"], name: "index_archived_exports_on_download_token_digest", unique: true + t.index ["expires_at"], name: "index_archived_exports_on_expires_at" + end + create_table "balances", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "account_id", null: false t.date "date", null: false diff --git a/test/controllers/archived_exports_controller_test.rb b/test/controllers/archived_exports_controller_test.rb new file mode 100644 index 000000000..0e23cb286 --- /dev/null +++ b/test/controllers/archived_exports_controller_test.rb @@ -0,0 +1,57 @@ +require "test_helper" + +class ArchivedExportsControllerTest < ActionDispatch::IntegrationTest + test "redirects to file with valid token" do + archive = ArchivedExport.create!( + email: "test@example.com", + family_name: "Test", + expires_at: 30.days.from_now + ) + archive.export_file.attach( + io: StringIO.new("test zip content"), + filename: "test.zip", + content_type: "application/zip" + ) + + get archived_export_path(token: archive.download_token) + assert_response :redirect + end + + test "returns 410 gone for expired token" do + archive = ArchivedExport.create!( + email: "test@example.com", + family_name: "Test", + expires_at: 1.day.ago + ) + archive.export_file.attach( + io: StringIO.new("test zip content"), + filename: "test.zip", + content_type: "application/zip" + ) + + get archived_export_path(token: archive.download_token) + assert_response :gone + end + + test "returns 404 for invalid token" do + get archived_export_path(token: "nonexistent-token") + assert_response :not_found + end + + test "does not require authentication" do + archive = ArchivedExport.create!( + email: "test@example.com", + family_name: "Test", + expires_at: 30.days.from_now + ) + archive.export_file.attach( + io: StringIO.new("test zip content"), + filename: "test.zip", + content_type: "application/zip" + ) + + # No sign_in call - should still work + get archived_export_path(token: archive.download_token) + assert_response :redirect + end +end diff --git a/test/fixtures/families.yml b/test/fixtures/families.yml index 10d5bd184..be4598bae 100644 --- a/test/fixtures/families.yml +++ b/test/fixtures/families.yml @@ -3,3 +3,7 @@ empty: dylan_family: name: The Dylan Family + +inactive_trial: + name: Inactive Trial Family + created_at: <%= 90.days.ago %> diff --git a/test/fixtures/subscriptions.yml b/test/fixtures/subscriptions.yml index 333ba7fe7..7d7b7c612 100644 --- a/test/fixtures/subscriptions.yml +++ b/test/fixtures/subscriptions.yml @@ -1,9 +1,14 @@ active: - family: dylan_family - status: active + family: dylan_family + status: active stripe_id: "test_1234567890" trialing: family: empty status: trialing trial_ends_at: <%= 12.days.from_now %> + +expired_trial: + family: inactive_trial + status: paused + trial_ends_at: <%= 45.days.ago %> diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index dc55cfc0f..109b78a13 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -77,6 +77,19 @@ intro_user: show_ai_sidebar: false ui_layout: intro +inactive_trial_user: + family: inactive_trial + first_name: Inactive + last_name: User + email: inactive@example.com + password_digest: $2a$12$XoNBo/cMCyzpYtvhrPAhsubG21mELX48RAcjSVCRctW8dG8wrDIla + role: admin + onboarded_at: <%= 90.days.ago %> + ai_enabled: true + show_sidebar: true + show_ai_sidebar: true + ui_layout: dashboard + # SSO-only user: created via JIT provisioning, no local password sso_only: family: empty diff --git a/test/jobs/inactive_family_cleaner_job_test.rb b/test/jobs/inactive_family_cleaner_job_test.rb new file mode 100644 index 000000000..5a34307f4 --- /dev/null +++ b/test/jobs/inactive_family_cleaner_job_test.rb @@ -0,0 +1,149 @@ +require "test_helper" + +class InactiveFamilyCleanerJobTest < ActiveJob::TestCase + setup do + @inactive_family = families(:inactive_trial) + @inactive_user = users(:inactive_trial_user) + Rails.application.config.stubs(:app_mode).returns("managed".inquiry) + end + + test "skips in self-hosted mode" do + Rails.application.config.stubs(:app_mode).returns("self_hosted".inquiry) + + assert_no_difference "Family.count" do + InactiveFamilyCleanerJob.perform_now + end + end + + test "destroys empty post-trial family with no accounts" do + assert_equal 0, @inactive_family.accounts.count + + assert_difference "Family.count", -1 do + InactiveFamilyCleanerJob.perform_now + end + + assert_not Family.exists?(@inactive_family.id) + end + + test "does not create archive for family with no accounts" do + assert_no_difference "ArchivedExport.count" do + InactiveFamilyCleanerJob.perform_now + end + end + + test "destroys family with accounts but few transactions" do + account = @inactive_family.accounts.create!( + name: "Test", currency: "USD", balance: 0, accountable: Depository.new, status: :active + ) + # Add only 5 transactions (below threshold of 12) + 5.times do |i| + account.entries.create!( + name: "Txn #{i}", date: 50.days.ago + i.days, amount: 10, currency: "USD", + entryable: Transaction.new + ) + end + + assert_no_difference "ArchivedExport.count" do + assert_difference "Family.count", -1 do + InactiveFamilyCleanerJob.perform_now + end + end + end + + test "archives then destroys family with 12+ recent transactions" do + account = @inactive_family.accounts.create!( + name: "Test", currency: "USD", balance: 0, accountable: Depository.new, status: :active + ) + + trial_end = @inactive_family.subscription.trial_ends_at + # Create 15 transactions, some within last 14 days of trial + 15.times do |i| + account.entries.create!( + name: "Txn #{i}", date: trial_end - i.days, amount: 10, currency: "USD", + entryable: Transaction.new + ) + end + + assert_difference "ArchivedExport.count", 1 do + assert_difference "Family.count", -1 do + InactiveFamilyCleanerJob.perform_now + end + end + + archive = ArchivedExport.last + assert_equal "inactive@example.com", archive.email + assert_equal "Inactive Trial Family", archive.family_name + assert archive.export_file.attached? + assert archive.download_token_digest.present? + assert archive.expires_at > 89.days.from_now + end + + test "preserves families with active subscriptions" do + dylan_family = families(:dylan_family) + assert dylan_family.subscription.active? + + InactiveFamilyCleanerJob.perform_now + + assert Family.exists?(dylan_family.id) + end + + test "preserves families still within grace period" do + @inactive_family.subscription.update!(trial_ends_at: 5.days.ago) + + initial_count = Family.count + InactiveFamilyCleanerJob.perform_now + + assert Family.exists?(@inactive_family.id) + end + + test "destroys families with no subscription created long ago" do + old_family = Family.create!(name: "Abandoned", created_at: 90.days.ago) + old_family.users.create!( + first_name: "Old", last_name: "User", email: "old-abandoned@example.com", + password: "password123", role: :admin, onboarded_at: 90.days.ago, + ai_enabled: true, show_sidebar: true, show_ai_sidebar: true, ui_layout: :dashboard + ) + # No subscription created + + assert_nil old_family.subscription + + InactiveFamilyCleanerJob.perform_now + + assert_not Family.exists?(old_family.id) + end + + test "preserves recently created families with no subscription" do + recent_family = Family.create!(name: "New Family") + recent_family.users.create!( + first_name: "New", last_name: "User", email: "newuser-recent@example.com", + password: "password123", role: :admin, onboarded_at: 1.day.ago, + ai_enabled: true, show_sidebar: true, show_ai_sidebar: true, ui_layout: :dashboard + ) + + InactiveFamilyCleanerJob.perform_now + + assert Family.exists?(recent_family.id) + + # Cleanup + recent_family.destroy + end + + test "dry run does not destroy or archive" do + account = @inactive_family.accounts.create!( + name: "Test", currency: "USD", balance: 0, accountable: Depository.new, status: :active + ) + trial_end = @inactive_family.subscription.trial_ends_at + 15.times do |i| + account.entries.create!( + name: "Txn #{i}", date: trial_end - i.days, amount: 10, currency: "USD", + entryable: Transaction.new + ) + end + + assert_no_difference [ "Family.count", "ArchivedExport.count" ] do + InactiveFamilyCleanerJob.perform_now(dry_run: true) + end + + assert Family.exists?(@inactive_family.id) + end +end diff --git a/test/models/archived_export_test.rb b/test/models/archived_export_test.rb new file mode 100644 index 000000000..56485cf9b --- /dev/null +++ b/test/models/archived_export_test.rb @@ -0,0 +1,70 @@ +require "test_helper" + +class ArchivedExportTest < ActiveSupport::TestCase + test "downloadable? returns true when not expired and file attached" do + archive = ArchivedExport.create!( + email: "test@example.com", + family_name: "Test", + expires_at: 30.days.from_now + ) + archive.export_file.attach( + io: StringIO.new("test content"), + filename: "test.zip", + content_type: "application/zip" + ) + + assert archive.downloadable? + end + + test "downloadable? returns false when expired" do + archive = ArchivedExport.create!( + email: "test@example.com", + family_name: "Test", + expires_at: 1.day.ago + ) + archive.export_file.attach( + io: StringIO.new("test content"), + filename: "test.zip", + content_type: "application/zip" + ) + + assert_not archive.downloadable? + end + + test "downloadable? returns false when file not attached" do + archive = ArchivedExport.create!( + email: "test@example.com", + family_name: "Test", + expires_at: 30.days.from_now + ) + + assert_not archive.downloadable? + end + + test "expired scope returns only expired records" do + expired = ArchivedExport.create!( + email: "expired@example.com", + family_name: "Expired", + expires_at: 1.day.ago + ) + active = ArchivedExport.create!( + email: "active@example.com", + family_name: "Active", + expires_at: 30.days.from_now + ) + + results = ArchivedExport.expired + assert_includes results, expired + assert_not_includes results, active + end + + test "generates download_token automatically" do + archive = ArchivedExport.create!( + email: "test@example.com", + family_name: "Test", + expires_at: 30.days.from_now + ) + + assert archive.download_token.present? + end +end diff --git a/test/models/family/subscribeable_test.rb b/test/models/family/subscribeable_test.rb index 0b1aafe71..7fda5ec58 100644 --- a/test/models/family/subscribeable_test.rb +++ b/test/models/family/subscribeable_test.rb @@ -25,4 +25,86 @@ class Family::SubscribeableTest < ActiveSupport::TestCase @family.update!(stripe_customer_id: "") assert_not @family.can_manage_subscription? end + + test "inactive_trial_for_cleanup includes families with expired paused trials" do + inactive = families(:inactive_trial) + results = Family.inactive_trial_for_cleanup + + assert_includes results, inactive + end + + test "inactive_trial_for_cleanup excludes families with active subscriptions" do + results = Family.inactive_trial_for_cleanup + + assert_not_includes results, @family + end + + test "inactive_trial_for_cleanup excludes families within grace period" do + inactive = families(:inactive_trial) + inactive.subscription.update!(trial_ends_at: 5.days.ago) + + results = Family.inactive_trial_for_cleanup + + assert_not_includes results, inactive + end + + test "inactive_trial_for_cleanup includes families with no subscription created long ago" do + old_family = Family.create!(name: "Abandoned", created_at: 90.days.ago) + + results = Family.inactive_trial_for_cleanup + + assert_includes results, old_family + + old_family.destroy + end + + test "inactive_trial_for_cleanup excludes recently created families with no subscription" do + recent_family = Family.create!(name: "New") + + results = Family.inactive_trial_for_cleanup + + assert_not_includes results, recent_family + + recent_family.destroy + end + + test "requires_data_archive? returns false with few transactions" do + inactive = families(:inactive_trial) + assert_not inactive.requires_data_archive? + end + + test "requires_data_archive? returns true with 12+ recent transactions" do + inactive = families(:inactive_trial) + account = inactive.accounts.create!( + name: "Test", currency: "USD", balance: 0, accountable: Depository.new, status: :active + ) + + trial_end = inactive.subscription.trial_ends_at + 15.times do |i| + account.entries.create!( + name: "Txn #{i}", date: trial_end - i.days, amount: 10, currency: "USD", + entryable: Transaction.new + ) + end + + assert inactive.requires_data_archive? + end + + test "requires_data_archive? returns false with 12+ transactions but none recent" do + inactive = families(:inactive_trial) + account = inactive.accounts.create!( + name: "Test", currency: "USD", balance: 0, accountable: Depository.new, status: :active + ) + + # All transactions from early in the trial (more than 14 days before trial end) + trial_end = inactive.subscription.trial_ends_at + 15.times do |i| + account.entries.create!( + name: "Txn #{i}", date: trial_end - 30.days - i.days, amount: 10, currency: "USD", + entryable: Transaction.new + ) + end + + assert_not inactive.requires_data_archive? + end end From 57199d6eb97c3b067e1a05c871695888ee1503e6 Mon Sep 17 00:00:00 2001 From: Serge L Date: Sat, 14 Mar 2026 15:22:39 -0400 Subject: [PATCH 60/75] Feat: Add QIF (Quicken Interchange Format) import functionality (#1074) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feat: Add QIF (Quicken Interchange Format) import functionality - Add the ability to import QIF files for users coming from Quicken - Includes categories and tags - Comprehensive tests for QifImport, including parsing, row generation, and import functionality. - Ensure handling of hierarchical categories (ex "Home:Home Improvement" is imported as Parent:Child) * Fix QIF import issues raised in code review - Fix two-digit year windowing in QIF date parser (e.g. '99 → 1999, not 2099) - Fix ArgumentError from invalid `undef: :raise` encoding option - Nil-safe `leaf_category_name` with blank guard and `.to_s` coercion - Memoize `qif_account_type` to avoid re-parsing the full QIF file - Add strong parameters (`selection_params`) to QifCategorySelectionsController - Wrap all mutations in DB transactions in uploads and category-selections controllers - Skip unchanged tag rows (only write rows where tags actually differ) - Replace hardcoded strings with i18n keys across QIF views and nav - Fix potentially colliding checkbox/label IDs in category selection view - Improve keyboard accessibility: use semantic `