diff --git a/app/controllers/api/v1/balance_sheet_controller.rb b/app/controllers/api/v1/balance_sheet_controller.rb new file mode 100644 index 000000000..24ac01165 --- /dev/null +++ b/app/controllers/api/v1/balance_sheet_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# Returns the family's balance sheet data (net worth, assets, liabilities) +# with all monetary values converted to the family's primary currency. +class Api::V1::BalanceSheetController < Api::V1::BaseController + before_action :ensure_read_scope + + # GET /api/v1/balance_sheet + # Returns net worth, total assets, and total liabilities as Money objects. + def show + family = current_resource_owner.family + balance_sheet = family.balance_sheet + + render json: { + currency: family.currency, + net_worth: balance_sheet.net_worth_money.as_json, + assets: balance_sheet.assets.total_money.as_json, + liabilities: balance_sheet.liabilities.total_money.as_json + } + end + + private + + def ensure_read_scope + authorize_scope!(:read) + end +end diff --git a/config/routes.rb b/config/routes.rb index 14eca4b64..f76c5c3be 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -400,6 +400,7 @@ Rails.application.routes.draw do resources :valuations, only: [ :create, :update, :show ] resources :imports, only: [ :index, :show, :create ] resource :usage, only: [ :show ], controller: :usage + resource :balance_sheet, only: [ :show ], controller: :balance_sheet post :sync, to: "sync#create" resources :chats, only: [ :index, :show, :create, :update, :destroy ] do diff --git a/mobile/lib/providers/accounts_provider.dart b/mobile/lib/providers/accounts_provider.dart index efa6e65bd..35c85a928 100644 --- a/mobile/lib/providers/accounts_provider.dart +++ b/mobile/lib/providers/accounts_provider.dart @@ -3,12 +3,14 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import '../models/account.dart'; import '../services/accounts_service.dart'; +import '../services/balance_sheet_service.dart'; import '../services/offline_storage_service.dart'; import '../services/connectivity_service.dart'; import '../services/log_service.dart'; class AccountsProvider with ChangeNotifier { final AccountsService _accountsService = AccountsService(); + final BalanceSheetService _balanceSheetService = BalanceSheetService(); final OfflineStorageService _offlineStorage = OfflineStorageService(); final LogService _log = LogService.instance; @@ -19,11 +21,23 @@ class AccountsProvider with ChangeNotifier { Map? _pagination; ConnectivityService? _connectivityService; + // Summary / net worth data + String? _netWorthFormatted; + String? _assetsFormatted; + String? _liabilitiesFormatted; + String? _familyCurrency; + bool _isBalanceSheetStale = false; + List get accounts => _accounts; bool get isLoading => _isLoading; bool get isInitializing => _isInitializing; String? get errorMessage => _errorMessage; Map? get pagination => _pagination; + String? get netWorthFormatted => _netWorthFormatted; + String? get assetsFormatted => _assetsFormatted; + String? get liabilitiesFormatted => _liabilitiesFormatted; + String? get familyCurrency => _familyCurrency; + bool get isBalanceSheetStale => _isBalanceSheetStale; List get assetAccounts { final assets = _accounts.where((a) => a.isAsset).toList(); @@ -126,6 +140,11 @@ class AccountsProvider with ChangeNotifier { _errorMessage = 'You are offline. Please connect to the internet to load accounts.'; } + // Fetch balance sheet independently — works even with cached accounts + if (isOnline) { + await _fetchBalanceSheet(accessToken); + } + _isLoading = false; _isInitializing = false; notifyListeners(); @@ -164,11 +183,46 @@ class AccountsProvider with ChangeNotifier { } } + /// Fetches balance sheet data and updates formatted net worth, assets, + /// and liabilities values for display. On failure, marks the existing + /// values as stale rather than clearing them. + Future _fetchBalanceSheet(String accessToken) async { + try { + final result = await _balanceSheetService.getBalanceSheet(accessToken: accessToken); + if (result['success'] == true) { + _familyCurrency = result['currency'] as String?; + final netWorth = result['net_worth'] as Map?; + final assets = result['assets'] as Map?; + final liabilities = result['liabilities'] as Map?; + _netWorthFormatted = netWorth?['formatted'] as String?; + _assetsFormatted = assets?['formatted'] as String?; + _liabilitiesFormatted = liabilities?['formatted'] as String?; + _isBalanceSheetStale = false; + } else { + // Keep existing values but mark as stale + if (_netWorthFormatted != null) { + _isBalanceSheetStale = true; + } + } + } catch (e) { + _log.error('AccountsProvider', 'Error fetching balance sheet: $e'); + // Keep existing values but mark as stale + if (_netWorthFormatted != null) { + _isBalanceSheetStale = true; + } + } + } + void clearAccounts() { _accounts = []; _pagination = null; _errorMessage = null; _isInitializing = true; + _netWorthFormatted = null; + _assetsFormatted = null; + _liabilitiesFormatted = null; + _familyCurrency = null; + _isBalanceSheetStale = false; notifyListeners(); } diff --git a/mobile/lib/screens/dashboard_screen.dart b/mobile/lib/screens/dashboard_screen.dart index 9b872b7ab..0f51247b0 100644 --- a/mobile/lib/screens/dashboard_screen.dart +++ b/mobile/lib/screens/dashboard_screen.dart @@ -516,6 +516,8 @@ class DashboardScreenState extends State { }); }, formatAmount: _formatAmount, + netWorthFormatted: accountsProvider.netWorthFormatted, + isStale: accountsProvider.isBalanceSheetStale, ), ), diff --git a/mobile/lib/services/balance_sheet_service.dart b/mobile/lib/services/balance_sheet_service.dart new file mode 100644 index 000000000..f143f0587 --- /dev/null +++ b/mobile/lib/services/balance_sheet_service.dart @@ -0,0 +1,51 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'api_config.dart'; + +/// Service for fetching balance sheet data (net worth, assets, liabilities) +/// from the Sure API. +class BalanceSheetService { + /// Fetches the family's balance sheet from GET /api/v1/balance_sheet. + /// + /// Returns a map with 'success' flag and balance sheet fields on success, + /// or 'error' message on failure. + Future> getBalanceSheet({ + required String accessToken, + }) async { + try { + final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/balance_sheet'); + + final response = await http.get( + url, + headers: ApiConfig.getAuthHeaders(accessToken), + ).timeout(const Duration(seconds: 30)); + + if (response.statusCode == 200) { + final responseData = jsonDecode(response.body); + + return { + 'success': true, + 'currency': responseData['currency'] as String?, + 'net_worth': responseData['net_worth'], + 'assets': responseData['assets'], + 'liabilities': responseData['liabilities'], + }; + } else if (response.statusCode == 401) { + return { + 'success': false, + 'error': 'unauthorized', + }; + } else { + return { + 'success': false, + 'error': 'Failed to fetch balance sheet', + }; + } + } catch (e) { + return { + 'success': false, + 'error': 'Unable to load balance sheet. Please try again later.', + }; + } + } +} diff --git a/mobile/lib/widgets/net_worth_card.dart b/mobile/lib/widgets/net_worth_card.dart index 241a7b91e..2d0ca03a0 100644 --- a/mobile/lib/widgets/net_worth_card.dart +++ b/mobile/lib/widgets/net_worth_card.dart @@ -8,6 +8,8 @@ class NetWorthCard extends StatelessWidget { final AccountFilter currentFilter; final ValueChanged onFilterChanged; final String Function(String currency, double amount) formatAmount; + final String? netWorthFormatted; + final bool isStale; const NetWorthCard({ super.key, @@ -16,6 +18,8 @@ class NetWorthCard extends StatelessWidget { required this.currentFilter, required this.onFilterChanged, required this.formatAmount, + this.netWorthFormatted, + this.isStale = false, }); @override @@ -33,14 +37,49 @@ class NetWorthCard extends StatelessWidget { ), child: Column( children: [ - // Net Worth Section (Placeholder) + // Net Worth Section Padding( padding: const EdgeInsets.fromLTRB(16, 14, 16, 12), - child: Text( - 'Net Worth — coming soon', - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Net Worth', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + if (isStale) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: colorScheme.secondaryContainer.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'Outdated', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.secondary, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 4), + Text( + netWorthFormatted ?? '--', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: isStale ? colorScheme.secondary : colorScheme.onSurface, + ), + ), + ], ), ), diff --git a/spec/requests/api/v1/balance_sheet_spec.rb b/spec/requests/api/v1/balance_sheet_spec.rb new file mode 100644 index 000000000..72132871a --- /dev/null +++ b/spec/requests/api/v1/balance_sheet_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'API V1 Balance Sheet', 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/balance_sheet' do + get 'Show balance sheet' do + tags 'Balance Sheet' + description 'Returns the family balance sheet including net worth, total assets, and total liabilities ' \ + 'with amounts converted to the family\'s primary currency.' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + + response '200', 'balance sheet returned' do + schema '$ref' => '#/components/schemas/BalanceSheet' + + run_test! + end + + response '401', 'unauthorized' do + let(:'X-Api-Key') { 'invalid-key' } + + run_test! + end + end + end +end diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb index 1a4c6d835..ad99750c1 100644 --- a/spec/swagger_helper.rb +++ b/spec/swagger_helper.rb @@ -516,6 +516,25 @@ RSpec.configure do |config| pagination: { '$ref' => '#/components/schemas/Pagination' } } }, + Money: { + type: :object, + required: %w[amount currency formatted], + properties: { + amount: { type: :string, description: 'Numeric amount as string' }, + currency: { type: :string, description: 'ISO 4217 currency code' }, + formatted: { type: :string, description: 'Locale-formatted money string' } + } + }, + BalanceSheet: { + type: :object, + required: %w[currency net_worth assets liabilities], + properties: { + currency: { type: :string, description: 'Family primary currency' }, + net_worth: { '$ref' => '#/components/schemas/Money' }, + assets: { '$ref' => '#/components/schemas/Money' }, + liabilities: { '$ref' => '#/components/schemas/Money' } + } + }, SuccessMessage: { type: :object, required: %w[message], diff --git a/test/controllers/api/v1/balance_sheet_controller_test.rb b/test/controllers/api/v1/balance_sheet_controller_test.rb new file mode 100644 index 000000000..4597a387f --- /dev/null +++ b/test/controllers/api/v1/balance_sheet_controller_test.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "test_helper" + +class Api::V1::BalanceSheetControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:family_admin) + @family = @user.family + + @user.api_keys.active.destroy_all + + @auth = ApiKey.create!( + user: @user, + name: "Test Read Key", + scopes: [ "read" ], + display_key: "test_ro_#{SecureRandom.hex(8)}", + source: "mobile" + ) + + Redis.new.del("api_rate_limit:#{@auth.id}") + end + + test "should require authentication" do + get "/api/v1/balance_sheet" + assert_response :unauthorized + end + + test "should return balance sheet with net worth data" do + get "/api/v1/balance_sheet", headers: api_headers(@auth) + + assert_response :success + response_body = JSON.parse(response.body) + + assert response_body.key?("currency") + assert response_body.key?("net_worth") + assert response_body.key?("assets") + assert response_body.key?("liabilities") + + %w[net_worth assets liabilities].each do |field| + assert response_body[field].key?("amount"), "#{field} should have amount" + assert response_body[field].key?("currency"), "#{field} should have currency" + assert response_body[field].key?("formatted"), "#{field} should have formatted" + end + end + + private + + def api_headers(auth) + { "X-Api-Key" => auth.display_key } + end +end