67 changed files with 8935 additions and 670 deletions
-
6.env.example
-
139.linuxdev/development.md
-
14.linuxdev/laravel-dev.service
-
17.linuxdev/laravel-queue.service
-
28.linuxdev/start
-
27.linuxdev/start-queue
-
13.linuxdev/stop
-
21.linuxdev/stop-queue
-
8app/Http/Controllers/Import/AuthenticateController.php
-
355app/Http/Controllers/Import/ConfigurationController.php
-
109app/Http/Controllers/Import/ConversionController.php
-
128app/Http/Controllers/Import/DuplicateCheckController.php
-
113app/Http/Controllers/Import/MapController.php
-
60app/Http/Controllers/Import/SubmitController.php
-
261app/Http/Controllers/Import/UploadController.php
-
82app/Http/Controllers/IndexController.php
-
12app/Http/Controllers/NavController.php
-
316app/Http/Controllers/TokenController.php
-
38app/Http/Middleware/ConversionControllerMiddleware.php
-
479app/Http/Middleware/IsReadyForStep.php
-
236app/Http/Request/ConfigurationPostRequest.php
-
232app/Jobs/ProcessImportSubmissionJob.php
-
143app/Services/CSV/Mapper/ExpenseRevenueAccounts.php
-
6app/Services/Session/Constants.php
-
13app/Services/Shared/Authentication/SecretManager.php
-
41app/Services/Shared/Configuration/Configuration.php
-
372app/Services/Shared/Import/Routine/ApiSubmitter.php
-
38app/Services/Shared/Import/Status/SubmissionStatus.php
-
29app/Services/Shared/Import/Status/SubmissionStatusManager.php
-
42app/Services/Shared/Response/ResponseInterface.php
-
2app/Services/SimpleFIN/AuthenticationValidator.php
-
485app/Services/SimpleFIN/Conversion/AccountMapper.php
-
352app/Services/SimpleFIN/Conversion/RoutineManager.php
-
700app/Services/SimpleFIN/Conversion/TransactionTransformer.php
-
208app/Services/SimpleFIN/Model/Account.php
-
199app/Services/SimpleFIN/Model/Transaction.php
-
48app/Services/SimpleFIN/Request/AccountsRequest.php
-
72app/Services/SimpleFIN/Request/PostAccountRequest.php
-
159app/Services/SimpleFIN/Request/SimpleFINRequest.php
-
48app/Services/SimpleFIN/Request/TransactionsRequest.php
-
76app/Services/SimpleFIN/Response/AccountsResponse.php
-
47app/Services/SimpleFIN/Response/PostAccountResponse.php
-
107app/Services/SimpleFIN/Response/SimpleFINResponse.php
-
82app/Services/SimpleFIN/Response/TransactionsResponse.php
-
341app/Services/SimpleFIN/SimpleFINService.php
-
511app/Services/SimpleFIN/Validation/ConfigurationContractValidator.php
-
16app/Services/Spectre/Request/Request.php.rej
-
10app/Support/Http/CollectsAccounts.php
-
12app/Support/Http/CollectsAccounts.php.rej
-
86app/Support/Internal/CollectsAccounts.php
-
8config/importer.php
-
113config/simplefin.php
-
28resources/js/v2/src/pages/conversion/index.js
-
7resources/js/v2/src/pages/index/index.js
-
36resources/js/v2/src/pages/submit/index.js
-
612resources/views/v2/components/create-account-widget.blade.php
-
246resources/views/v2/components/firefly-iii-account-generic.blade.php
-
134resources/views/v2/components/importer-account-title.blade.php
-
25resources/views/v2/components/importer-account.blade.php
-
103resources/views/v2/import/003-upload/index.blade.php
-
353resources/views/v2/import/004-configure/index.blade.php
-
90resources/views/v2/import/007-convert/index.blade.php
-
28resources/views/v2/import/008-submit/index.blade.php
-
382resources/views/v2/layout/v2.blade.php
-
3routes/web.php
-
114tests/Feature/SimpleFIN/DemoModeTest.php
-
384tests/Unit/Services/SimpleFIN/Validation/ConfigurationContractValidatorTest.php
@ -0,0 +1,139 @@ |
|||
# Data Importer Development Environment |
|||
|
|||
## Quick Start |
|||
|
|||
The data-importer uses Laravel's built-in development server. No Apache, no PHP-FPM, no SELinux complexity. |
|||
|
|||
### Prerequisites |
|||
- PHP 8.4+ (already installed on trashcan) |
|||
- Composer (already installed) |
|||
- Git access to lil-debian deployment repository |
|||
|
|||
### Start Development Server |
|||
|
|||
#### Option 1: Systemd Service (Recommended) |
|||
```bash |
|||
./.linuxdev/start |
|||
``` |
|||
|
|||
#### Option 2: Manual |
|||
```bash |
|||
php artisan serve --host=127.0.0.1 --port=3000 |
|||
``` |
|||
|
|||
Access at: `http://localhost:3000` |
|||
|
|||
#### Service Management |
|||
```bash |
|||
# Stop the service |
|||
./.linuxdev/stop |
|||
|
|||
# Follow Logs |
|||
journalctl --user -u laravel-dev-data-importer -f |
|||
|
|||
# View last 50 Logs |
|||
journalctl --user -u laravel-dev-data-importer -n 50 |
|||
|
|||
# Check service status |
|||
systemctl --user status laravel-dev-data-importer |
|||
``` |
|||
|
|||
### Initial Setup (if needed) |
|||
```bash |
|||
# Install dependencies |
|||
composer install |
|||
|
|||
# Generate application key (if missing) |
|||
php artisan key:generate |
|||
``` |
|||
|
|||
### Cache Control |
|||
```bash |
|||
# Clear all caches |
|||
php artisan config:clear |
|||
php artisan cache:clear |
|||
php artisan view:clear |
|||
php artisan route:clear |
|||
``` |
|||
|
|||
## Development Workflow |
|||
|
|||
### Local Changes |
|||
1. Edit files in `~/Code/skellnet/src/data-importer/` |
|||
2. Changes are immediately reflected (no restart needed) |
|||
3. Check Laravel logs: `storage/logs/laravel.log` |
|||
|
|||
### Deploy to Production |
|||
```bash |
|||
# Commit changes locally |
|||
git add . |
|||
git commit -m "Description of changes" |
|||
|
|||
# Deploy to lil-debian |
|||
ssh root@lil-debian "cd /opt/data-importer && git pull" |
|||
ssh root@lil-debian "systemctl restart php8.4-fpm" |
|||
``` |
|||
|
|||
## Environment Configuration |
|||
|
|||
### Local (.env) |
|||
- `APP_ENV=local` |
|||
- `APP_DEBUG=true` (for detailed error messages) |
|||
- Database connections point to development instances |
|||
|
|||
### Production (lil-debian) |
|||
- `APP_ENV=production` |
|||
- `APP_DEBUG=false` |
|||
- Real database connections |
|||
- Nginx + PHP-FPM stack |
|||
|
|||
## Debugging |
|||
|
|||
### Application Errors |
|||
- Check `storage/logs/laravel.log` for detailed error traces |
|||
- Set `APP_DEBUG=true` in `.env` for browser error display |
|||
- Use `app('log')->debug('message', $data)` for custom logging |
|||
|
|||
### Common Issues |
|||
- **500 errors**: Check Laravel log first, not web server logs |
|||
- **Permission issues**: Ensure `storage/` and `bootstrap/cache/` are writable |
|||
- **Missing dependencies**: Run `composer install` |
|||
- **Cache problems**: Clear all Laravel caches with artisan commands |
|||
|
|||
## Frontend Assets |
|||
|
|||
If frontend compilation is required: |
|||
```bash |
|||
npm install |
|||
npm run dev # for development |
|||
npm run build # for production |
|||
``` |
|||
|
|||
## Database |
|||
|
|||
For local database development, configure connections in `.env`: |
|||
- Use local MySQL/PostgreSQL instances |
|||
- Or connect directly to development databases on the network |
|||
- Run migrations: `php artisan migrate` |
|||
|
|||
## Testing |
|||
|
|||
```bash |
|||
# Run PHP tests |
|||
php artisan test |
|||
|
|||
# Run specific test file |
|||
php artisan test --filter SpecificTestClass |
|||
``` |
|||
|
|||
## Production Comparison |
|||
|
|||
| Aspect | Development (trashcan) | Production (lil-debian) | |
|||
|--------|------------------------|-------------------------| |
|||
| Web Server | Laravel dev server | Nginx + PHP-FPM | |
|||
| Port | 3000 | 443 (HTTPS) | |
|||
| Debug | Enabled | Disabled | |
|||
| Logs | `storage/logs/` | `storage/logs/` + syslog | |
|||
| SSL | None | Full chain via acme.sh | |
|||
|
|||
The development server handles PHP execution directly, eliminating the web server complexity needed in production. |
@ -0,0 +1,14 @@ |
|||
[Unit] |
|||
Description=Laravel Development Server |
|||
After=network.target |
|||
|
|||
[Service] |
|||
Type=simple |
|||
ExecStart=/usr/bin/php artisan serve --host=127.0.0.1 --port=3000 |
|||
WorkingDirectory=%i |
|||
Restart=on-failure |
|||
RestartSec=5 |
|||
Environment=APP_ENV=local |
|||
Environment=APP_DEBUG=true |
|||
StandardOutput=journal |
|||
StandardError=journal |
@ -0,0 +1,17 @@ |
|||
[Unit] |
|||
Description=Laravel Queue Worker |
|||
After=network.target |
|||
|
|||
[Service] |
|||
Type=simple |
|||
ExecStart=/usr/bin/php artisan queue:work --timeout=1800 --tries=1 --daemon |
|||
WorkingDirectory=%i |
|||
Restart=on-failure |
|||
RestartSec=10 |
|||
Environment=APP_ENV=local |
|||
Environment=APP_DEBUG=true |
|||
StandardOutput=journal |
|||
StandardError=journal |
|||
|
|||
[Install] |
|||
WantedBy=multi-user.target |
@ -0,0 +1,28 @@ |
|||
#!/bin/bash |
|||
set -e |
|||
|
|||
PWD=$(pwd) |
|||
SERVICE_NAME="laravel-dev-$(basename "$PWD")" |
|||
|
|||
# Check if Laravel project |
|||
if [[ ! -f "artisan" ]]; then |
|||
echo "Error: No artisan file found. Run from Laravel project root." |
|||
exit 1 |
|||
fi |
|||
|
|||
# Stop existing service if running |
|||
systemctl --user stop "$SERVICE_NAME" 2>/dev/null || true |
|||
|
|||
# Start service with current directory |
|||
systemd-run --user \ |
|||
--unit="$SERVICE_NAME" \ |
|||
--working-directory="$PWD" \ |
|||
--setenv=APP_ENV=local \ |
|||
--setenv=APP_DEBUG=true \ |
|||
php artisan serve --host=127.0.0.1 --port=3000 |
|||
|
|||
echo "Laravel development server started as user service: $SERVICE_NAME" |
|||
echo "Access at: http://localhost:3000" |
|||
echo "Stop with: ./.linuxdev/stop" |
|||
echo "(Re)start with: ./.linuxdev/start" |
|||
echo "Logs with: journalctl --user -u $SERVICE_NAME -f" |
@ -0,0 +1,27 @@ |
|||
#!/bin/bash |
|||
set -e |
|||
|
|||
PWD=$(pwd) |
|||
SERVICE_NAME="laravel-queue-$(basename "$PWD")" |
|||
|
|||
# Check if Laravel project |
|||
if [[ ! -f "artisan" ]]; then |
|||
echo "Error: No artisan file found. Run from Laravel project root." |
|||
exit 1 |
|||
fi |
|||
|
|||
# Stop existing service if running |
|||
systemctl --user stop "$SERVICE_NAME" 2>/dev/null || true |
|||
|
|||
# Start queue worker service with current directory |
|||
systemd-run --user \ |
|||
--unit="$SERVICE_NAME" \ |
|||
--working-directory="$PWD" \ |
|||
--setenv=APP_ENV=local \ |
|||
--setenv=APP_DEBUG=true \ |
|||
php artisan queue:work --timeout=1800 --tries=1 --daemon |
|||
|
|||
echo "Laravel queue worker started as user service: $SERVICE_NAME" |
|||
echo "Stop with: systemctl --user stop $SERVICE_NAME" |
|||
echo "Status with: systemctl --user status $SERVICE_NAME" |
|||
echo "Logs with: journalctl --user -u $SERVICE_NAME -f" |
@ -0,0 +1,13 @@ |
|||
#!/bin/bash |
|||
set -e |
|||
|
|||
PWD=$(pwd) |
|||
SERVICE_NAME="laravel-dev-$(basename "$PWD")" |
|||
|
|||
# Stop the service |
|||
if systemctl --user is-active --quiet "$SERVICE_NAME" 2>/dev/null; then |
|||
systemctl --user stop "$SERVICE_NAME" |
|||
echo "Stopped Laravel development server: $SERVICE_NAME" |
|||
else |
|||
echo "Laravel development server not running: $SERVICE_NAME" |
|||
fi |
@ -0,0 +1,21 @@ |
|||
#!/bin/bash |
|||
set -e |
|||
|
|||
PWD=$(pwd) |
|||
SERVICE_NAME="laravel-queue-$(basename "$PWD")" |
|||
|
|||
# Check if Laravel project |
|||
if [[ ! -f "artisan" ]]; then |
|||
echo "Error: No artisan file found. Run from Laravel project root." |
|||
exit 1 |
|||
fi |
|||
|
|||
# Stop queue worker service |
|||
if systemctl --user stop "$SERVICE_NAME" 2>/dev/null; then |
|||
echo "Laravel queue worker stopped: $SERVICE_NAME" |
|||
else |
|||
echo "No queue worker service running or already stopped: $SERVICE_NAME" |
|||
fi |
|||
|
|||
# Show final status |
|||
systemctl --user status "$SERVICE_NAME" --no-pager -l || true |
@ -0,0 +1,128 @@ |
|||
<?php |
|||
|
|||
/* |
|||
* DuplicateCheckController.php |
|||
* Copyright (c) 2021 james@firefly-iii.org |
|||
* |
|||
* This file is part of the Firefly III Data Importer |
|||
* (https://github.com/firefly-iii/data-importer). |
|||
* |
|||
* This program is free software: you can redistribute it and/or modify |
|||
* it under the terms of the GNU Affero General Public License as |
|||
* published by the Free Software Foundation, either version 3 of the |
|||
* License, or (at your option) any later version. |
|||
* |
|||
* This program is distributed in the hope that it will be useful, |
|||
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
* GNU Affero General Public License for more details. |
|||
* |
|||
* You should have received a copy of the GNU Affero General Public License |
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>. |
|||
*/ |
|||
|
|||
declare(strict_types=1); |
|||
|
|||
namespace App\Http\Controllers\Import; |
|||
|
|||
use App\Http\Controllers\Controller; |
|||
use App\Http\Middleware\ConfigurationControllerMiddleware; |
|||
use App\Services\SimpleFIN\Validation\ConfigurationContractValidator; |
|||
use Illuminate\Http\JsonResponse; |
|||
use Illuminate\Http\Request; |
|||
use Illuminate\Support\Facades\Log; |
|||
|
|||
/** |
|||
* Class DuplicateCheckController |
|||
* |
|||
* Provides AJAX endpoint for real-time duplicate account validation |
|||
*/ |
|||
class DuplicateCheckController extends Controller |
|||
{ |
|||
/** |
|||
* Create a new controller instance. |
|||
*/ |
|||
public function __construct() |
|||
{ |
|||
parent::__construct(); |
|||
$this->middleware(ConfigurationControllerMiddleware::class); |
|||
} |
|||
|
|||
/** |
|||
* Check if an account name and type combination already exists |
|||
* |
|||
* @param Request $request |
|||
* @return JsonResponse |
|||
*/ |
|||
public function checkDuplicate(Request $request): JsonResponse |
|||
{ |
|||
try { |
|||
$name = trim($request->input('name', '')); |
|||
$type = trim($request->input('type', '')); |
|||
|
|||
Log::debug('DUPLICATE_CHECK: Received request', [ |
|||
'name' => $name, |
|||
'type' => $type, |
|||
'name_length' => strlen($name) |
|||
]); |
|||
|
|||
// Empty name or type means no duplicate possible
|
|||
if ('' === $name || '' === $type) { |
|||
Log::debug('DUPLICATE_CHECK: Empty name or type, returning no duplicate'); |
|||
return response()->json([ |
|||
'isDuplicate' => false, |
|||
'message' => null |
|||
]); |
|||
} |
|||
|
|||
// Validate account type
|
|||
$validTypes = ['asset', 'liability', 'expense', 'revenue']; |
|||
if (!in_array($type, $validTypes, true)) { |
|||
Log::warning('DUPLICATE_CHECK: Invalid account type provided', [ |
|||
'type' => $type, |
|||
'valid_types' => $validTypes |
|||
]); |
|||
return response()->json([ |
|||
'isDuplicate' => false, |
|||
'message' => null |
|||
]); |
|||
} |
|||
|
|||
// Create validator instance and check for duplicates
|
|||
$validator = new ConfigurationContractValidator(); |
|||
$isDuplicate = $validator->checkSingleAccountDuplicate($name, $type); |
|||
|
|||
$message = null; |
|||
if ($isDuplicate) { |
|||
$message = sprintf('%s <em>%s</em> already exists!', ucfirst($type), $name); |
|||
} |
|||
|
|||
Log::debug('DUPLICATE_CHECK: Validation result', [ |
|||
'name' => $name, |
|||
'type' => $type, |
|||
'isDuplicate' => $isDuplicate, |
|||
'message' => $message |
|||
]); |
|||
|
|||
return response()->json([ |
|||
'isDuplicate' => $isDuplicate, |
|||
'message' => $message |
|||
]); |
|||
|
|||
} catch (\Exception $e) { |
|||
Log::error('DUPLICATE_CHECK: Exception during duplicate check', [ |
|||
'error' => $e->getMessage(), |
|||
'trace' => $e->getTraceAsString(), |
|||
'name' => $request->input('name', ''), |
|||
'type' => $request->input('type', '') |
|||
]); |
|||
|
|||
// Return safe response on error - assume no duplicate to avoid blocking user
|
|||
return response()->json([ |
|||
'isDuplicate' => false, |
|||
'message' => null, |
|||
'error' => 'Unable to check for duplicates at this time' |
|||
]); |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,232 @@ |
|||
<?php |
|||
|
|||
declare(strict_types=1); |
|||
|
|||
namespace App\Jobs; |
|||
|
|||
use App\Services\Shared\Configuration\Configuration; |
|||
use App\Services\Shared\Import\Routine\ApiSubmitter; |
|||
use App\Services\Shared\Import\Routine\InfoCollector; |
|||
use App\Services\Shared\Import\Routine\RoutineManager; |
|||
use App\Services\Shared\Import\Status\SubmissionStatus; |
|||
use App\Services\Shared\Import\Status\SubmissionStatusManager; |
|||
use Illuminate\Bus\Queueable; |
|||
use Illuminate\Contracts\Queue\ShouldQueue; |
|||
use Illuminate\Foundation\Bus\Dispatchable; |
|||
use Illuminate\Queue\InteractsWithQueue; |
|||
use Illuminate\Queue\SerializesModels; |
|||
use Illuminate\Support\Facades\Log; |
|||
use Throwable; |
|||
|
|||
class ProcessImportSubmissionJob implements ShouldQueue |
|||
{ |
|||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; |
|||
|
|||
/** |
|||
* The number of times the job may be attempted. |
|||
* |
|||
* @var int |
|||
*/ |
|||
public $tries = 1; |
|||
|
|||
/** |
|||
* The maximum number of seconds the job can run for. |
|||
* |
|||
* @var int |
|||
*/ |
|||
public $timeout = 1800; // 30 minutes
|
|||
|
|||
private string $identifier; |
|||
private Configuration $configuration; |
|||
private array $transactions; |
|||
private string $accessToken; |
|||
private string $baseUrl; |
|||
private ?string $vanityUrl; |
|||
|
|||
/** |
|||
* Create a new job instance. |
|||
*/ |
|||
public function __construct( |
|||
string $identifier, |
|||
Configuration $configuration, |
|||
array $transactions, |
|||
string $accessToken, |
|||
string $baseUrl, |
|||
?string $vanityUrl |
|||
) { |
|||
$this->identifier = $identifier; |
|||
$this->configuration = $configuration; |
|||
$this->transactions = $transactions; |
|||
$this->accessToken = $accessToken; |
|||
$this->baseUrl = $baseUrl; |
|||
$this->vanityUrl = $vanityUrl; |
|||
} |
|||
|
|||
/** |
|||
* Execute the job. |
|||
*/ |
|||
public function handle(): void |
|||
{ |
|||
Log::info('ProcessImportSubmissionJob started', [ |
|||
'identifier' => $this->identifier, |
|||
'transaction_count' => count($this->transactions), |
|||
]); |
|||
|
|||
// Validate authentication credentials before proceeding
|
|||
if (empty($this->accessToken)) { |
|||
throw new \Exception( |
|||
'Access token is empty - cannot authenticate with Firefly III' |
|||
); |
|||
} |
|||
|
|||
if (empty($this->baseUrl)) { |
|||
throw new \Exception( |
|||
'Base URL is empty - cannot connect to Firefly III' |
|||
); |
|||
} |
|||
|
|||
Log::info('Job authentication credentials validation', [ |
|||
'identifier' => $this->identifier, |
|||
'access_token_length' => strlen($this->accessToken), |
|||
'access_token_preview' => substr($this->accessToken, 0, 20) . '...', |
|||
'base_url' => $this->baseUrl, |
|||
'vanity_url' => $this->vanityUrl ?? 'null', |
|||
]); |
|||
|
|||
// Backup original configuration
|
|||
$originalConfig = [ |
|||
'importer.access_token' => config('importer.access_token'), |
|||
'importer.url' => config('importer.url'), |
|||
'importer.vanity_url' => config('importer.vanity_url'), |
|||
]; |
|||
|
|||
Log::debug('Original config backup', [ |
|||
'identifier' => $this->identifier, |
|||
'original_token_length' => strlen( |
|||
$originalConfig['importer.access_token'] |
|||
), |
|||
'original_url' => $originalConfig['importer.url'], |
|||
'original_vanity' => $originalConfig['importer.vanity_url'], |
|||
]); |
|||
|
|||
try { |
|||
// Set authentication context for this job
|
|||
config([ |
|||
'importer.access_token' => $this->accessToken, |
|||
'importer.url' => $this->baseUrl, |
|||
'importer.vanity_url' => $this->vanityUrl ?? $this->baseUrl, |
|||
]); |
|||
|
|||
Log::debug('Authentication context set for job', [ |
|||
'identifier' => $this->identifier, |
|||
'base_url' => $this->baseUrl, |
|||
'vanity_url' => $this->vanityUrl ?? $this->baseUrl, |
|||
'access_token_length' => strlen($this->accessToken), |
|||
]); |
|||
|
|||
// Verify config was actually set
|
|||
$verifyToken = config('importer.access_token'); |
|||
$verifyUrl = config('importer.url'); |
|||
|
|||
Log::debug('Config verification after setting', [ |
|||
'identifier' => $this->identifier, |
|||
'config_token_matches' => $verifyToken === $this->accessToken, |
|||
'config_url_matches' => $verifyUrl === $this->baseUrl, |
|||
'config_token_length' => strlen($verifyToken), |
|||
'config_url' => $verifyUrl, |
|||
]); |
|||
|
|||
if ($verifyToken !== $this->accessToken) { |
|||
throw new \Exception( |
|||
'Failed to set access token in config properly' |
|||
); |
|||
} |
|||
|
|||
if ($verifyUrl !== $this->baseUrl) { |
|||
throw new \Exception( |
|||
'Failed to set base URL in config properly' |
|||
); |
|||
} |
|||
|
|||
// Set initial running status
|
|||
SubmissionStatusManager::setSubmissionStatus( |
|||
SubmissionStatus::SUBMISSION_RUNNING, |
|||
$this->identifier |
|||
); |
|||
|
|||
// Initialize routine manager and execute import
|
|||
$routine = new RoutineManager($this->identifier); |
|||
$routine->setConfiguration($this->configuration); |
|||
$routine->setTransactions($this->transactions); |
|||
|
|||
Log::debug('Starting routine execution', [ |
|||
'identifier' => $this->identifier, |
|||
]); |
|||
|
|||
// Execute the import process
|
|||
$routine->start(); |
|||
|
|||
// Set completion status
|
|||
SubmissionStatusManager::setSubmissionStatus( |
|||
SubmissionStatus::SUBMISSION_DONE, |
|||
$this->identifier |
|||
); |
|||
|
|||
Log::info('ProcessImportSubmissionJob completed successfully', [ |
|||
'identifier' => $this->identifier, |
|||
'messages' => count($routine->getAllMessages()), |
|||
'warnings' => count($routine->getAllWarnings()), |
|||
'errors' => count($routine->getAllErrors()), |
|||
]); |
|||
} catch (Throwable $e) { |
|||
Log::error('ProcessImportSubmissionJob failed', [ |
|||
'identifier' => $this->identifier, |
|||
'error' => $e->getMessage(), |
|||
'trace' => $e->getTraceAsString(), |
|||
]); |
|||
|
|||
// Set error status
|
|||
SubmissionStatusManager::setSubmissionStatus( |
|||
SubmissionStatus::SUBMISSION_ERRORED, |
|||
$this->identifier |
|||
); |
|||
|
|||
// Re-throw to mark job as failed
|
|||
throw $e; |
|||
} finally { |
|||
// Always restore original configuration
|
|||
config($originalConfig); |
|||
|
|||
Log::debug('Authentication context restored', [ |
|||
'identifier' => $this->identifier, |
|||
]); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Handle a job failure. |
|||
*/ |
|||
public function failed(Throwable $exception): void |
|||
{ |
|||
Log::error('ProcessImportSubmissionJob marked as failed', [ |
|||
'identifier' => $this->identifier, |
|||
'exception' => $exception->getMessage(), |
|||
]); |
|||
|
|||
// Ensure error status is set even if job fails catastrophically
|
|||
SubmissionStatusManager::setSubmissionStatus( |
|||
SubmissionStatus::SUBMISSION_ERRORED, |
|||
$this->identifier |
|||
); |
|||
} |
|||
|
|||
/** |
|||
* Get the tags that should be assigned to the job. |
|||
* |
|||
* @return array<int, string> |
|||
*/ |
|||
public function tags(): array |
|||
{ |
|||
return ['import-submission', $this->identifier]; |
|||
} |
|||
} |
@ -0,0 +1,143 @@ |
|||
<?php |
|||
|
|||
/* |
|||
* ExpenseRevenueAccounts.php |
|||
* Copyright (c) 2021 james@firefly-iii.org |
|||
* |
|||
* This file is part of the Firefly III Data Importer |
|||
* (https://github.com/firefly-iii/data-importer). |
|||
* |
|||
* This program is free software: you can redistribute it and/or modify |
|||
* it under the terms of the GNU Affero General Public License as |
|||
* published by the Free Software Foundation, either version 3 of the |
|||
* License, or (at your option) any later version. |
|||
* |
|||
* This program is distributed in the hope that it will be useful, |
|||
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
* GNU Affero General Public License for more details. |
|||
* |
|||
* You should have received a copy of the GNU Affero General Public License |
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>. |
|||
*/ |
|||
|
|||
declare(strict_types=1); |
|||
|
|||
namespace App\Services\CSV\Mapper; |
|||
|
|||
use App\Exceptions\ImporterErrorException; |
|||
use App\Services\Shared\Authentication\SecretManager; |
|||
use GrumpyDictator\FFIIIApiSupport\Exceptions\ApiHttpException; |
|||
use GrumpyDictator\FFIIIApiSupport\Model\Account; |
|||
use GrumpyDictator\FFIIIApiSupport\Request\GetAccountsRequest; |
|||
use GrumpyDictator\FFIIIApiSupport\Response\GetAccountsResponse; |
|||
|
|||
/** |
|||
* Class ExpenseRevenueAccounts |
|||
*/ |
|||
class ExpenseRevenueAccounts implements MapperInterface |
|||
{ |
|||
/** |
|||
* Get map of expense and revenue accounts. |
|||
* |
|||
* @throws ImporterErrorException |
|||
*/ |
|||
public function getMap(): array |
|||
{ |
|||
$accounts = $this->getExpenseRevenueAccounts(); |
|||
return $this->mergeExpenseRevenue($accounts); |
|||
} |
|||
|
|||
/** |
|||
* Get expense and revenue accounts from Firefly III API. |
|||
*/ |
|||
protected function getExpenseRevenueAccounts(): array |
|||
{ |
|||
app('log')->debug('getExpenseRevenueAccounts: Fetching expense and revenue accounts.'); |
|||
|
|||
$url = SecretManager::getBaseUrl(); |
|||
$token = SecretManager::getAccessToken(); |
|||
|
|||
// Fetch all accounts since API doesn't have separate expense/revenue endpoints
|
|||
$request = new GetAccountsRequest($url, $token); |
|||
$request->setVerify(config('importer.connection.verify')); |
|||
$request->setTimeOut(config('importer.connection.timeout')); |
|||
$request->setType(GetAccountsRequest::ALL); |
|||
|
|||
try { |
|||
$response = $request->get(); |
|||
} catch (ApiHttpException $e) { |
|||
app('log')->error($e->getMessage()); |
|||
throw new ImporterErrorException(sprintf('Could not download accounts: %s', $e->getMessage())); |
|||
} |
|||
|
|||
if (!$response instanceof GetAccountsResponse) { |
|||
throw new ImporterErrorException('Could not get list of accounts.'); |
|||
} |
|||
|
|||
$allAccounts = $this->toArray($response); |
|||
|
|||
// Filter for expense and revenue accounts only
|
|||
$expenseRevenueAccounts = array_filter($allAccounts, function (Account $account) { |
|||
return in_array($account->type, ['expense', 'revenue'], true); |
|||
}); |
|||
|
|||
app('log')->debug(sprintf('getExpenseRevenueAccounts: Found %d expense/revenue accounts', count($expenseRevenueAccounts))); |
|||
|
|||
return $expenseRevenueAccounts; |
|||
} |
|||
|
|||
/** |
|||
* Convert response to array of Account objects. |
|||
*/ |
|||
protected function toArray(GetAccountsResponse $response): array |
|||
{ |
|||
$accounts = []; |
|||
|
|||
/** @var Account $account */ |
|||
foreach ($response as $account) { |
|||
$accounts[] = $account; |
|||
} |
|||
|
|||
return $accounts; |
|||
} |
|||
|
|||
/** |
|||
* Merge expense and revenue accounts into select-ready list. |
|||
*/ |
|||
protected function mergeExpenseRevenue(array $accounts): array |
|||
{ |
|||
$result = []; |
|||
$invalidTypes = ['initial-balance', 'reconciliation']; |
|||
|
|||
/** @var Account $account */ |
|||
foreach ($accounts as $account) { |
|||
// Skip invalid types
|
|||
if (in_array($account->type, $invalidTypes, true)) { |
|||
continue; |
|||
} |
|||
|
|||
// Only include expense and revenue accounts
|
|||
if (!in_array($account->type, ['expense', 'revenue'], true)) { |
|||
continue; |
|||
} |
|||
|
|||
$name = $account->name; |
|||
|
|||
// Add optgroup to result
|
|||
$group = trans(sprintf('import.account_types_%s', $account->type)); |
|||
$result[$group] ??= []; |
|||
$result[$group][$account->id] = $name; |
|||
} |
|||
|
|||
// Sort each group
|
|||
foreach ($result as $group => $accounts) { |
|||
asort($accounts, SORT_STRING); |
|||
$result[$group] = $accounts; |
|||
} |
|||
|
|||
// Create new account functionality temporarily removed for stock compatibility
|
|||
|
|||
return $result; |
|||
} |
|||
} |
@ -0,0 +1,42 @@ |
|||
<?php |
|||
|
|||
/* |
|||
* ResponseInterface.php |
|||
* Copyright (c) 2021 james@firefly-iii.org |
|||
* |
|||
* This file is part of the Firefly III Data Importer |
|||
* (https://github.com/firefly-iii/data-importer). |
|||
* |
|||
* This program is free software: you can redistribute it and/or modify |
|||
* it under the terms of the GNU Affero General Public License as |
|||
* published by the Free Software Foundation, either version 3 of the |
|||
* License, or (at your option) any later version. |
|||
* |
|||
* This program is distributed in the hope that it will be useful, |
|||
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
* GNU Affero General Public License for more details. |
|||
* |
|||
* You should have received a copy of the GNU Affero General Public License |
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>. |
|||
*/ |
|||
|
|||
declare(strict_types=1); |
|||
|
|||
namespace App\Services\Shared\Response; |
|||
|
|||
/** |
|||
* Interface ResponseInterface |
|||
*/ |
|||
interface ResponseInterface |
|||
{ |
|||
/** |
|||
* Check if the response has an error |
|||
*/ |
|||
public function hasError(): bool; |
|||
|
|||
/** |
|||
* Get the HTTP status code |
|||
*/ |
|||
public function getStatusCode(): int; |
|||
} |
@ -0,0 +1,485 @@ |
|||
<?php |
|||
|
|||
/* |
|||
* AccountMapper.php |
|||
* Copyright (c) 2021 james@firefly-iii.org |
|||
* |
|||
* This file is part of the Firefly III Data Importer |
|||
* (https://github.com/firefly-iii/data-importer). |
|||
* |
|||
* This program is free software: you can redistribute it and/or modify |
|||
* it under the terms of the GNU Affero General Public License as |
|||
* published by the Free Software Foundation, either version 3 of the |
|||
* License, or (at your option) any later version. |
|||
* |
|||
* This program is distributed in the hope that it will be useful, |
|||
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
* GNU Affero General Public License for more details. |
|||
* |
|||
* You should have received a copy of the GNU Affero General Public License |
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>. |
|||
*/ |
|||
|
|||
declare(strict_types=1); |
|||
|
|||
namespace App\Services\SimpleFIN\Conversion; |
|||
|
|||
use App\Exceptions\ImporterErrorException; |
|||
use App\Services\Shared\Authentication\SecretManager; |
|||
use App\Services\SimpleFIN\Model\Account as SimpleFINAccount; |
|||
use App\Services\SimpleFIN\Request\PostAccountRequest; |
|||
use App\Services\SimpleFIN\Response\PostAccountResponse; |
|||
use GrumpyDictator\FFIIIApiSupport\Exceptions\ApiHttpException; |
|||
use GrumpyDictator\FFIIIApiSupport\Model\Account; |
|||
use GrumpyDictator\FFIIIApiSupport\Model\AccountType; |
|||
use GrumpyDictator\FFIIIApiSupport\Request\GetAccountsRequest; |
|||
use GrumpyDictator\FFIIIApiSupport\Request\GetSearchAccountRequest; |
|||
use GrumpyDictator\FFIIIApiSupport\Response\GetAccountsResponse; |
|||
use GrumpyDictator\FFIIIApiSupport\Response\Response; |
|||
use GrumpyDictator\FFIIIApiSupport\Response\ValidationErrorResponse; |
|||
|
|||
/** |
|||
* Class AccountMapper |
|||
*/ |
|||
class AccountMapper |
|||
{ |
|||
private array $fireflyAccounts = []; |
|||
private array $accountMapping = []; |
|||
private array $createdAccounts = []; |
|||
|
|||
public function __construct() |
|||
{ |
|||
// Defer account loading until actually needed to avoid authentication errors
|
|||
// during constructor when authentication context may not be available
|
|||
} |
|||
|
|||
/** |
|||
* Map SimpleFIN accounts to Firefly III accounts |
|||
* @param array $simplefinAccounts |
|||
* @param array $configuration |
|||
* @return array |
|||
*/ |
|||
public function mapAccounts(array $simplefinAccounts, array $configuration = []): array |
|||
{ |
|||
$mapping = []; |
|||
|
|||
foreach ($simplefinAccounts as $simplefinAccount) { |
|||
if (!$simplefinAccount instanceof SimpleFINAccount) { |
|||
continue; |
|||
} |
|||
|
|||
$accountKey = $simplefinAccount->getId(); |
|||
|
|||
// Check if mapping is already configured
|
|||
if (isset($configuration['account_mapping'][$accountKey])) { |
|||
$mappingConfig = $configuration['account_mapping'][$accountKey]; |
|||
|
|||
if ($mappingConfig['action'] === 'map' && isset($mappingConfig['firefly_account_id'])) { |
|||
// Map to existing account
|
|||
$fireflyAccount = $this->getFireflyAccountById((int) $mappingConfig['firefly_account_id']); |
|||
if ($fireflyAccount) { |
|||
$mapping[$accountKey] = [ |
|||
'simplefin_account' => $simplefinAccount, |
|||
'firefly_account_id' => $fireflyAccount->id, |
|||
'firefly_account_name' => $fireflyAccount->name, |
|||
'action' => 'map', |
|||
]; |
|||
} |
|||
} elseif ($mappingConfig['action'] === 'create') { |
|||
// Create new account
|
|||
$fireflyAccount = $this->createFireflyAccount($simplefinAccount, $mappingConfig); |
|||
if ($fireflyAccount) { |
|||
$mapping[$accountKey] = [ |
|||
'simplefin_account' => $simplefinAccount, |
|||
'firefly_account_id' => $fireflyAccount->id, |
|||
'firefly_account_name' => $fireflyAccount->name, |
|||
'action' => 'create', |
|||
]; |
|||
} |
|||
} |
|||
} else { |
|||
// Auto-map by searching for existing accounts
|
|||
$fireflyAccount = $this->findMatchingFireflyAccount($simplefinAccount); |
|||
if ($fireflyAccount) { |
|||
$mapping[$accountKey] = [ |
|||
'simplefin_account' => $simplefinAccount, |
|||
'firefly_account_id' => $fireflyAccount->id, |
|||
'firefly_account_name' => $fireflyAccount->name, |
|||
'action' => 'auto_map', |
|||
]; |
|||
} else { |
|||
// No mapping found - will need user input
|
|||
$mapping[$accountKey] = [ |
|||
'simplefin_account' => $simplefinAccount, |
|||
'firefly_account_id' => null, |
|||
'firefly_account_name' => null, |
|||
'action' => 'unmapped', |
|||
]; |
|||
} |
|||
} |
|||
} |
|||
|
|||
return $mapping; |
|||
} |
|||
|
|||
/** |
|||
* Get available Firefly III accounts for mapping |
|||
*/ |
|||
public function getAvailableFireflyAccounts(): array |
|||
{ |
|||
$this->loadFireflyAccounts(); |
|||
return $this->fireflyAccounts; |
|||
} |
|||
|
|||
/** |
|||
* Find a matching Firefly III account for a SimpleFIN account |
|||
*/ |
|||
private function findMatchingFireflyAccount(SimpleFINAccount $simplefinAccount): ?Account |
|||
{ |
|||
$this->loadFireflyAccounts(); |
|||
|
|||
// Try to find by name first
|
|||
$matchingAccounts = array_filter($this->fireflyAccounts, function (Account $account) use ($simplefinAccount) { |
|||
return strtolower($account->name) === strtolower($simplefinAccount->getName()); |
|||
}); |
|||
|
|||
if (!empty($matchingAccounts)) { |
|||
return reset($matchingAccounts); |
|||
} |
|||
|
|||
// Try to search via API
|
|||
try { |
|||
$request = new GetSearchAccountRequest(SecretManager::getBaseUrl(), SecretManager::getAccessToken()); |
|||
$request->setQuery($simplefinAccount->getName()); |
|||
$response = $request->get(); |
|||
|
|||
if ($response instanceof GetAccountsResponse && count($response) > 0) { |
|||
foreach ($response as $account) { |
|||
if (strtolower($account->name) === strtolower($simplefinAccount->getName())) { |
|||
return $account; |
|||
} |
|||
} |
|||
} |
|||
} catch (ApiHttpException $e) { |
|||
app('log')->warning(sprintf('Could not search for account "%s": %s', $simplefinAccount->getName(), $e->getMessage())); |
|||
} |
|||
|
|||
return null; |
|||
} |
|||
|
|||
/** |
|||
* Create account immediately via Firefly III API |
|||
* @param SimpleFINAccount $simplefinAccount |
|||
* @param array $config |
|||
* @return Account|null |
|||
*/ |
|||
public function createFireflyAccount(SimpleFINAccount $simplefinAccount, array $config): ?Account |
|||
{ |
|||
$accountName = $config['name'] ?? $simplefinAccount->getName(); |
|||
$accountType = $this->determineAccountType($simplefinAccount, $config); |
|||
$currencyCode = $this->getCurrencyCode($simplefinAccount, $config); |
|||
$openingBalance = $config['opening_balance'] ?? '0.00'; |
|||
|
|||
app('log')->info(sprintf('Creating Firefly III account "%s" immediately via API', $accountName)); |
|||
|
|||
try { |
|||
$request = new PostAccountRequest(SecretManager::getBaseUrl(), SecretManager::getAccessToken()); |
|||
|
|||
// Build account creation payload
|
|||
$payload = [ |
|||
'name' => $accountName, |
|||
'type' => $accountType, |
|||
'currency_code' => $currencyCode, |
|||
'opening_balance' => $openingBalance, |
|||
'active' => true, |
|||
'include_net_worth' => true, |
|||
]; |
|||
|
|||
// Add opening balance date if opening balance is provided
|
|||
if (!empty($config['opening_balance']) && is_numeric($config['opening_balance'])) { |
|||
$payload['opening_balance_date'] = $config['opening_balance_date'] ?? date('Y-m-d'); |
|||
} |
|||
|
|||
// Add account role for asset accounts
|
|||
if ($accountType === AccountType::ASSET) { |
|||
$payload['account_role'] = $config['account_role'] ?? 'defaultAsset'; |
|||
} |
|||
|
|||
// Add liability-specific fields for liability accounts
|
|||
if (in_array($accountType, [AccountType::DEBT, AccountType::LOAN, AccountType::MORTGAGE, AccountType::LIABILITIES, 'liability'], true)) { |
|||
// Map account type to liability type
|
|||
$liabilityTypeMap = [ |
|||
AccountType::DEBT => 'debt', |
|||
AccountType::LOAN => 'loan', |
|||
AccountType::MORTGAGE => 'mortgage', |
|||
AccountType::LIABILITIES => 'debt', // Default generic liabilities to debt
|
|||
'liability' => 'debt', // Handle user-provided 'liability' type
|
|||
]; |
|||
|
|||
$payload['liability_type'] = $config['liability_type'] ?? $liabilityTypeMap[$accountType] ?? 'debt'; |
|||
$payload['liability_direction'] = $config['liability_direction'] ?? 'credit'; |
|||
} |
|||
|
|||
// Add IBAN if provided
|
|||
if (!empty($config['iban'])) { |
|||
$payload['iban'] = $config['iban']; |
|||
} |
|||
|
|||
// Add account number if provided
|
|||
if (!empty($config['account_number'])) { |
|||
$payload['account_number'] = $config['account_number']; |
|||
} |
|||
|
|||
$request->setBody($payload); |
|||
$response = $this->makeApiCallWithRetry($request, $accountName); |
|||
|
|||
if ($response instanceof ValidationErrorResponse) { |
|||
app('log')->error(sprintf('Failed to create account "%s": %s', $accountName, json_encode($response->errors->toArray()))); |
|||
return null; |
|||
} |
|||
|
|||
if ($response instanceof PostAccountResponse) { |
|||
$account = $response->getAccount(); |
|||
if ($account) { |
|||
app('log')->info(sprintf('Successfully created account "%s" with ID %d', $accountName, $account->id)); |
|||
|
|||
// Add to our local cache
|
|||
$this->fireflyAccounts[] = $account; |
|||
$this->createdAccounts[] = $account; |
|||
|
|||
return $account; |
|||
} |
|||
} |
|||
|
|||
app('log')->error(sprintf('Unexpected response type when creating account "%s"', $accountName)); |
|||
return null; |
|||
|
|||
} catch (ApiHttpException $e) { |
|||
app('log')->error(sprintf('API error creating account "%s": %s', $accountName, $e->getMessage())); |
|||
return null; |
|||
} catch (\Exception $e) { |
|||
app('log')->error(sprintf('Unexpected error creating account "%s": %s', $accountName, $e->getMessage())); |
|||
return null; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Determine the appropriate Firefly III account type |
|||
* @param SimpleFINAccount $simplefinAccount |
|||
* @param array $config |
|||
* @return string |
|||
*/ |
|||
private function determineAccountType(SimpleFINAccount $simplefinAccount, array $config): string |
|||
{ |
|||
if (isset($config['type'])) { |
|||
return $config['type']; |
|||
} |
|||
|
|||
// Default to asset account for most SimpleFIN accounts
|
|||
return AccountType::ASSET; |
|||
} |
|||
|
|||
/** |
|||
* Get currency code for account creation |
|||
*/ |
|||
private function getCurrencyCode(SimpleFINAccount $simplefinAccount, array $config): string |
|||
{ |
|||
// 1. Use user-configured currency first
|
|||
if (!empty($config['currency'])) { |
|||
return $config['currency']; |
|||
} |
|||
|
|||
// 2. Fall back to SimpleFIN account currency
|
|||
$currency = $simplefinAccount->getCurrency(); |
|||
if ($simplefinAccount->isCustomCurrency()) { |
|||
// For custom currencies, default to user's base currency or USD
|
|||
return 'USD'; // Could be made configurable
|
|||
} |
|||
|
|||
// 3. Final fallback
|
|||
return $currency ?: 'USD'; |
|||
} |
|||
|
|||
/** |
|||
* Get Firefly III account by ID |
|||
*/ |
|||
private function getFireflyAccountById(int $id): ?Account |
|||
{ |
|||
$this->loadFireflyAccounts(); |
|||
|
|||
foreach ($this->fireflyAccounts as $account) { |
|||
if ($account->id === $id) { |
|||
return $account; |
|||
} |
|||
} |
|||
|
|||
return null; |
|||
} |
|||
|
|||
/** |
|||
* Load all Firefly III accounts |
|||
*/ |
|||
private function loadFireflyAccounts(): void |
|||
{ |
|||
// Only load once
|
|||
if (!empty($this->fireflyAccounts)) { |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
// Verify authentication context before making API calls
|
|||
$baseUrl = SecretManager::getBaseUrl(); |
|||
$accessToken = SecretManager::getAccessToken(); |
|||
|
|||
if (empty($baseUrl) || empty($accessToken)) { |
|||
app('log')->warning('Missing authentication context for Firefly III account loading'); |
|||
throw new ImporterErrorException('Authentication context not available for account loading'); |
|||
} |
|||
|
|||
$request = new GetAccountsRequest($baseUrl, $accessToken); |
|||
$request->setType(AccountType::ASSET); |
|||
$response = $request->get(); |
|||
|
|||
if ($response instanceof GetAccountsResponse) { |
|||
$this->fireflyAccounts = iterator_to_array($response); |
|||
app('log')->debug(sprintf('Loaded %d Firefly III accounts', count($this->fireflyAccounts))); |
|||
} |
|||
} catch (ApiHttpException $e) { |
|||
app('log')->error(sprintf('Could not load Firefly III accounts: %s', $e->getMessage())); |
|||
throw new ImporterErrorException(sprintf('Could not load Firefly III accounts: %s', $e->getMessage())); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Make API call with DNS resilience retry pattern |
|||
* @param PostAccountRequest $request |
|||
* @param string $accountName |
|||
* @return Response |
|||
* @throws ApiHttpException |
|||
*/ |
|||
private function makeApiCallWithRetry(PostAccountRequest $request, string $accountName): Response |
|||
{ |
|||
$retryDelays = [0, 2, 5]; // immediate, 2s delay, 5s delay
|
|||
$lastException = null; |
|||
|
|||
foreach ($retryDelays as $attempt => $delay) { |
|||
try { |
|||
if ($delay > 0) { |
|||
app('log')->debug(sprintf('Retrying account creation for "%s" after %ds delay (attempt %d)', $accountName, $delay, $attempt + 1)); |
|||
sleep($delay); |
|||
} |
|||
|
|||
return $request->post(); |
|||
|
|||
} catch (ApiHttpException $e) { |
|||
$lastException = $e; |
|||
$errorMessage = $e->getMessage(); |
|||
|
|||
// Check if this is a DNS/connection timeout error that we should retry
|
|||
$shouldRetry = $this->shouldRetryApiCall($errorMessage, $attempt, count($retryDelays)); |
|||
|
|||
if (!$shouldRetry) { |
|||
app('log')->error(sprintf('Non-retryable API error for account "%s": %s', $accountName, $errorMessage)); |
|||
throw $e; |
|||
} |
|||
|
|||
app('log')->warning(sprintf('DNS/connection error for account "%s" (attempt %d): %s', $accountName, $attempt + 1, $errorMessage)); |
|||
|
|||
// If this was the last attempt, we'll throw after the loop
|
|||
if ($attempt === count($retryDelays) - 1) { |
|||
break; |
|||
} |
|||
} |
|||
} |
|||
|
|||
// All retries exhausted
|
|||
app('log')->error(sprintf('All retries exhausted for account "%s": %s', $accountName, $lastException->getMessage())); |
|||
throw $lastException; |
|||
} |
|||
|
|||
/** |
|||
* Determine if an API call should be retried based on the error |
|||
* @param string $errorMessage |
|||
* @param int $attempt |
|||
* @param int $maxAttempts |
|||
* @return bool |
|||
*/ |
|||
private function shouldRetryApiCall(string $errorMessage, int $attempt, int $maxAttempts): bool |
|||
{ |
|||
// Don't retry if we've exhausted all attempts
|
|||
if ($attempt >= $maxAttempts - 1) { |
|||
return false; |
|||
} |
|||
|
|||
// Retry on DNS resolution timeouts, connection timeouts, and network errors
|
|||
$retryableErrors = [ |
|||
'Resolving timed out', |
|||
'cURL error 28', |
|||
'Connection timed out', |
|||
'cURL error 6', // Couldn't resolve host
|
|||
'cURL error 7', // Couldn't connect to host
|
|||
'Failed to connect', |
|||
'Name or service not known', |
|||
'Temporary failure in name resolution' |
|||
]; |
|||
|
|||
foreach ($retryableErrors as $retryableError) { |
|||
if (stripos($errorMessage, $retryableError) !== false) { |
|||
return true; |
|||
} |
|||
} |
|||
|
|||
return false; |
|||
} |
|||
|
|||
/** |
|||
* Get mapping options for UI |
|||
* @param SimpleFINAccount $simplefinAccount |
|||
* @return array<string,mixed> |
|||
*/ |
|||
public function getMappingOptions(SimpleFINAccount $simplefinAccount): array |
|||
{ |
|||
$this->loadFireflyAccounts(); |
|||
|
|||
$options = [ |
|||
'account_name' => $simplefinAccount->getName(), |
|||
'account_id' => $simplefinAccount->getId(), |
|||
'currency' => $simplefinAccount->getCurrency(), |
|||
'balance' => $simplefinAccount->getBalance(), |
|||
'organization' => $simplefinAccount->getOrganizationName() ?? $simplefinAccount->getOrganizationDomain(), |
|||
'firefly_accounts' => [], |
|||
'suggested_account' => null, |
|||
]; |
|||
|
|||
// Add all available Firefly accounts as options
|
|||
foreach ($this->fireflyAccounts as $account) { |
|||
$options['firefly_accounts'][] = [ |
|||
'id' => $account->id, |
|||
'name' => $account->name, |
|||
'type' => $account->type, |
|||
'currency_code' => $account->currencyCode ?? 'USD', |
|||
]; |
|||
} |
|||
|
|||
// Try to suggest a matching account
|
|||
$suggested = $this->findMatchingFireflyAccount($simplefinAccount); |
|||
if ($suggested) { |
|||
$options['suggested_account'] = [ |
|||
'id' => $suggested->id, |
|||
'name' => $suggested->name, |
|||
'type' => $suggested->type, |
|||
]; |
|||
} |
|||
|
|||
return $options; |
|||
} |
|||
|
|||
/** |
|||
* Get created accounts during this session |
|||
*/ |
|||
public function getCreatedAccounts(): array |
|||
{ |
|||
return $this->createdAccounts; |
|||
} |
|||
} |
@ -0,0 +1,352 @@ |
|||
<?php |
|||
|
|||
/* |
|||
* RoutineManager.php |
|||
* Copyright (c) 2021 james@firefly-iii.org |
|||
* |
|||
* This file is part of the Firefly III Data Importer |
|||
* (https://github.com/firefly-iii/data-importer). |
|||
* |
|||
* This program is free software: you can redistribute it and/or modify |
|||
* it under the terms of the GNU Affero General Public License as |
|||
* published by the Free Software Foundation, either version 3 of the |
|||
* License, or (at your option) any later version. |
|||
* |
|||
* This program is distributed in the hope that it will be useful, |
|||
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
* GNU Affero General Public License for more details. |
|||
* |
|||
* You should have received a copy of the GNU Affero General Public License |
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>. |
|||
*/ |
|||
|
|||
declare(strict_types=1); |
|||
|
|||
namespace App\Services\SimpleFIN\Conversion; |
|||
|
|||
use App\Exceptions\ImporterErrorException; |
|||
use App\Services\Session\Constants; |
|||
use App\Services\Shared\Configuration\Configuration; |
|||
use App\Services\Shared\Conversion\RoutineManagerInterface; |
|||
use App\Services\SimpleFIN\SimpleFINService; |
|||
use App\Services\SimpleFIN\Conversion\AccountMapper; |
|||
use App\Services\SimpleFIN\Conversion\TransactionTransformer; |
|||
use App\Services\SimpleFIN\Model\Account; |
|||
use App\Services\Storage\StorageService; |
|||
use Illuminate\Support\Str; |
|||
|
|||
/** |
|||
* Class RoutineManager |
|||
*/ |
|||
class RoutineManager implements RoutineManagerInterface |
|||
{ |
|||
private string $identifier; |
|||
private Configuration $configuration; |
|||
private SimpleFINService $simpleFINService; |
|||
private AccountMapper $accountMapper; |
|||
private TransactionTransformer $transformer; |
|||
|
|||
/** |
|||
* RoutineManager constructor. |
|||
*/ |
|||
public function __construct(?string $identifier = null) |
|||
{ |
|||
app('log')->debug('Constructed SimpleFIN RoutineManager'); |
|||
|
|||
$this->identifier = $identifier ?? Str::random(16); |
|||
$this->simpleFINService = app(SimpleFINService::class); |
|||
$this->accountMapper = new AccountMapper(); |
|||
$this->transformer = new TransactionTransformer($this->accountMapper); |
|||
} |
|||
|
|||
/** |
|||
* @throws ImporterErrorException |
|||
*/ |
|||
public function start(): array |
|||
{ |
|||
app('log')->debug('Now in SimpleFIN RoutineManager::start()'); |
|||
|
|||
$token = session()->get(Constants::SIMPLEFIN_TOKEN); // Retained for general session validation
|
|||
$bridgeUrl = session()->get(Constants::SIMPLEFIN_BRIDGE_URL); // Retained for general session validation
|
|||
$allAccountsSimpleFINData = session()->get( |
|||
Constants::SIMPLEFIN_ACCOUNTS_DATA, |
|||
[] |
|||
); |
|||
|
|||
if ( |
|||
empty($token) || |
|||
empty($bridgeUrl) || |
|||
empty($allAccountsSimpleFINData) |
|||
) { |
|||
app('log')->error( |
|||
'SimpleFIN session data incomplete for conversion.', |
|||
[ |
|||
'has_token' => !empty($token), |
|||
'has_bridge_url' => !empty($bridgeUrl), |
|||
'has_accounts_data' => !empty($allAccountsSimpleFINData), |
|||
] |
|||
); |
|||
throw new ImporterErrorException( |
|||
'SimpleFIN session data (token, URL, or accounts data) not found or incomplete' |
|||
); |
|||
} |
|||
|
|||
$transactions = []; |
|||
$accounts = $this->configuration->getAccounts(); |
|||
$dateRange = $this->getDateRange(); |
|||
|
|||
app('log')->info('Processing SimpleFIN accounts', [ |
|||
'account_count' => count($accounts), |
|||
'date_range' => $dateRange, |
|||
]); |
|||
|
|||
foreach ($accounts as $simplefinAccountId => $fireflyAccountId) { |
|||
// Handle account creation if requested (fireflyAccountId === 0 means "create_new")
|
|||
if (0 === $fireflyAccountId) { |
|||
$newAccountData = |
|||
$this->configuration->getNewAccounts()[ |
|||
$simplefinAccountId |
|||
] ?? null; |
|||
if (!$newAccountData) { |
|||
app('log')->error( |
|||
"No new account data found for SimpleFIN account: {$simplefinAccountId}" |
|||
); |
|||
continue; |
|||
} |
|||
|
|||
// Validate required fields for account creation
|
|||
if (empty($newAccountData['name'])) { |
|||
app('log')->error( |
|||
"Account name is required for creating SimpleFIN account: {$simplefinAccountId}" |
|||
); |
|||
continue; |
|||
} |
|||
|
|||
// Find the SimpleFIN account data for account creation
|
|||
$simplefinAccountData = null; |
|||
foreach ($allAccountsSimpleFINData as $accountData) { |
|||
if ($accountData['id'] === $simplefinAccountId) { |
|||
$simplefinAccountData = $accountData; |
|||
break; |
|||
} |
|||
} |
|||
|
|||
if (!$simplefinAccountData) { |
|||
app('log')->error( |
|||
"SimpleFIN account data not found for ID: {$simplefinAccountId}" |
|||
); |
|||
continue; |
|||
} |
|||
|
|||
// Prepare account creation configuration with defaults
|
|||
$accountConfig = [ |
|||
'name' => $newAccountData['name'], |
|||
'type' => $newAccountData['type'] ?? 'asset', |
|||
'currency' => $newAccountData['currency'] ?? 'USD', |
|||
]; |
|||
|
|||
// Add opening balance if provided
|
|||
if ( |
|||
!empty($newAccountData['opening_balance']) && |
|||
is_numeric($newAccountData['opening_balance']) |
|||
) { |
|||
$accountConfig['opening_balance'] = |
|||
$newAccountData['opening_balance']; |
|||
$accountConfig['opening_balance_date'] = date('Y-m-d'); |
|||
} |
|||
|
|||
app('log')->info('Creating new Firefly III account', [ |
|||
'simplefin_account_id' => $simplefinAccountId, |
|||
'account_config' => $accountConfig, |
|||
]); |
|||
|
|||
// Create SimpleFIN Account object and create Firefly III account
|
|||
$simplefinAccount = Account::fromArray($simplefinAccountData); |
|||
$accountMapper = new AccountMapper(); |
|||
$createdAccount = $accountMapper->createFireflyAccount( |
|||
$simplefinAccount, |
|||
$accountConfig |
|||
); |
|||
|
|||
if ($createdAccount) { |
|||
// Account was created immediately - update configuration
|
|||
$fireflyAccountId = $createdAccount->id; |
|||
$updatedAccounts = $this->configuration->getAccounts(); |
|||
$updatedAccounts[$simplefinAccountId] = $fireflyAccountId; |
|||
$this->configuration->setAccounts($updatedAccounts); |
|||
|
|||
// CRITICAL: Update local accounts mapping to reflect the new account ID
|
|||
// This ensures TransactionTransformer receives the correct account ID mapping
|
|||
$accounts = $this->configuration->getAccounts(); |
|||
|
|||
app('log')->info( |
|||
'Successfully created new Firefly III account', |
|||
[ |
|||
'simplefin_account_id' => $simplefinAccountId, |
|||
'firefly_account_id' => $fireflyAccountId, |
|||
'account_name' => $createdAccount->name, |
|||
'account_type' => $accountConfig['type'], |
|||
'currency' => $accountConfig['currency'], |
|||
] |
|||
); |
|||
|
|||
} else { |
|||
// Account creation failed - this is a critical error that must be reported
|
|||
$errorMessage = sprintf( |
|||
'CRITICAL: Failed to create Firefly III account "%s" (type: %s, currency: %s). Cannot proceed with transaction import for this account.', |
|||
$accountConfig['name'], |
|||
$accountConfig['type'], |
|||
$accountConfig['currency'] |
|||
); |
|||
|
|||
app('log')->error($errorMessage, [ |
|||
'simplefin_account_id' => $simplefinAccountId, |
|||
'account_name' => $accountConfig['name'], |
|||
'account_type' => $accountConfig['type'], |
|||
'currency' => $accountConfig['currency'], |
|||
]); |
|||
|
|||
// Throw exception to prevent silent failure - user must be notified
|
|||
throw new ImporterErrorException($errorMessage); |
|||
} |
|||
} |
|||
|
|||
// Find the specific SimpleFIN account data array for the current $simplefinAccountId.
|
|||
// $allAccountsSimpleFINData is an indexed array of account data arrays.
|
|||
$currentSimpleFINAccountData = null; |
|||
foreach ($allAccountsSimpleFINData as $accountDataFromArrayInLoop) { |
|||
if ( |
|||
isset($accountDataFromArrayInLoop['id']) && |
|||
$accountDataFromArrayInLoop['id'] === $simplefinAccountId |
|||
) { |
|||
$currentSimpleFINAccountData = $accountDataFromArrayInLoop; |
|||
break; |
|||
} |
|||
} |
|||
|
|||
if (null === $currentSimpleFINAccountData) { |
|||
app('log')->error( |
|||
'Failed to find SimpleFIN account raw data in session for current account ID during transformation.', |
|||
['simplefin_account_id_sought' => $simplefinAccountId] |
|||
); |
|||
// If the account data for this ID isn't found, we can't process its transactions.
|
|||
// This might indicate an inconsistency in session data or configuration.
|
|||
continue; // Skip to the next account in $accounts.
|
|||
} |
|||
|
|||
try { |
|||
app('log')->debug( |
|||
"Extracting transactions for account {$simplefinAccountId} from stored data" |
|||
); |
|||
|
|||
// Fetch transactions for the current account using the new method signature,
|
|||
// passing the complete SimpleFIN accounts data retrieved from the session.
|
|||
$accountTransactions = $this->simpleFINService->fetchTransactions( |
|||
$allAccountsSimpleFINData, // Pass the full dataset
|
|||
$simplefinAccountId, |
|||
$dateRange |
|||
); |
|||
|
|||
app('log')->debug( |
|||
"Extracted {count} transactions for account {$simplefinAccountId}", |
|||
['count' => count($accountTransactions)] |
|||
); |
|||
|
|||
// $accountTransactions now contains raw transaction data arrays (from SimpleFIN JSON)
|
|||
foreach ($accountTransactions as $transactionData) { |
|||
// Renamed $transactionObject to $transactionData for clarity
|
|||
try { |
|||
// Use current account mapping (accounts are created immediately, no deferred creation)
|
|||
$accountMappingForTransformer = $accounts; |
|||
|
|||
|
|||
// The transformer now expects:
|
|||
// 1. Raw transaction data (array)
|
|||
// 2. Parent SimpleFIN account data (array)
|
|||
// 3. Full Firefly III account mapping configuration (array)
|
|||
// 4. New account configuration data (array) - contains user-provided names
|
|||
$transformedTransaction = $this->transformer->transform( |
|||
$transactionData, |
|||
$currentSimpleFINAccountData, // The specific SimpleFIN account data for this transaction's parent
|
|||
$accountMappingForTransformer, // Current mapping with actual account IDs
|
|||
$this->configuration->getNewAccounts() // User-provided account configuration data
|
|||
); |
|||
|
|||
|
|||
// Skip zero-amount transactions that transformer filtered out
|
|||
if (empty($transformedTransaction)) { |
|||
continue; |
|||
} |
|||
|
|||
// Wrap transaction in group structure expected by Firefly III
|
|||
$transactionGroup = [ |
|||
'group_title' => |
|||
$transformedTransaction['description'] ?? |
|||
'SimpleFIN Transaction', |
|||
'transactions' => [$transformedTransaction], |
|||
]; |
|||
|
|||
$transactions[] = $transactionGroup; |
|||
} catch (ImporterErrorException $e) { |
|||
app('log')->warning( |
|||
'Transaction transformation failed for a specific transaction.', |
|||
[ |
|||
'simplefin_account_id' => $simplefinAccountId, |
|||
'transaction_id' => |
|||
isset($transactionData['id']) && |
|||
is_scalar($transactionData['id']) |
|||
? (string)$transactionData['id'] |
|||
: 'unknown', |
|||
'error' => $e->getMessage(), |
|||
// Avoid logging full $transactionData unless necessary for deep debug, could be large/sensitive.
|
|||
] |
|||
); |
|||
} |
|||
} |
|||
} catch (ImporterErrorException $e) { |
|||
app('log')->error('Failed to fetch transactions for account', [ |
|||
'account' => $simplefinAccountId, |
|||
'error' => $e->getMessage(), |
|||
]); |
|||
throw $e; |
|||
} |
|||
} |
|||
|
|||
app('log')->info('SimpleFIN conversion completed', [ |
|||
'total_transactions' => count($transactions), |
|||
]); |
|||
|
|||
return $transactions; |
|||
} |
|||
|
|||
public function setConfiguration(Configuration $configuration): void |
|||
{ |
|||
$this->configuration = $configuration; |
|||
} |
|||
|
|||
public function getIdentifier(): string |
|||
{ |
|||
return $this->identifier; |
|||
} |
|||
|
|||
#[\Override]
|
|||
public function getServiceAccounts(): array |
|||
{ |
|||
return session()->get(Constants::SIMPLEFIN_ACCOUNTS_DATA, []); |
|||
} |
|||
|
|||
/** |
|||
* Get date range for transaction fetching |
|||
*/ |
|||
private function getDateRange(): array |
|||
{ |
|||
$dateAfter = $this->configuration->getDateNotBefore(); |
|||
$dateBefore = $this->configuration->getDateNotAfter(); |
|||
|
|||
return [ |
|||
'start' => !empty($dateAfter) ? $dateAfter : null, |
|||
'end' => !empty($dateBefore) ? $dateBefore : null, |
|||
]; |
|||
} |
|||
} |
@ -0,0 +1,700 @@ |
|||
<?php |
|||
|
|||
/* |
|||
* TransactionTransformer.php |
|||
* Copyright (c) 2021 james@firefly-iii.org |
|||
* |
|||
* This file is part of the Firefly III Data Importer |
|||
* (https://github.com/firefly-iii/data-importer). |
|||
* |
|||
* This program is free software: you can redistribute it and/or modify |
|||
* it under the terms of the GNU Affero General Public License as |
|||
* published by the Free Software Foundation, either version 3 of the |
|||
* License, or (at your option) any later version. |
|||
* |
|||
* This program is distributed in the hope that it will be useful, |
|||
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
* GNU Affero General Public License for more details. |
|||
* |
|||
* You should have received a copy of the GNU Affero General Public License |
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>. |
|||
*/ |
|||
|
|||
declare(strict_types=1); |
|||
|
|||
namespace App\Services\SimpleFIN\Conversion; |
|||
|
|||
use App\Support\Http\CollectsAccounts; |
|||
use Carbon\Carbon; |
|||
// Removed SimpleFINModel imports as we now use arrays
|
|||
|
|||
/** |
|||
* Class TransactionTransformer |
|||
*/ |
|||
class TransactionTransformer |
|||
{ |
|||
use CollectsAccounts; |
|||
|
|||
private array $expenseAccounts = []; |
|||
private array $revenueAccounts = []; |
|||
private bool $accountsCollected = false; |
|||
private array $pendingTransactionClusters = []; // For clustering similar transactions in clean instances
|
|||
/** |
|||
* Transform SimpleFIN transaction data (array) to Firefly III transaction format |
|||
* @param array $transactionData Raw transaction data from SimpleFIN JSON |
|||
* @param array $simpleFINAccountData Raw account data from SimpleFIN JSON for the account this transaction belongs to |
|||
* @param array $accountMapping Mapping configuration for Firefly III accounts |
|||
* @param array $newAccountConfig User-provided new account configuration data |
|||
* @return array |
|||
*/ |
|||
public function transform(array $transactionData, array $simpleFINAccountData, array $accountMapping = [], array $newAccountConfig = []): array |
|||
{ |
|||
// Ensure amount is a float. SimpleFIN provides it as a string.
|
|||
$amount = isset($transactionData['amount']) ? (float) $transactionData['amount'] : 0.0; |
|||
|
|||
// Skip zero-amount transactions as they're invalid for Firefly III
|
|||
if (abs($amount) === 0.0) { |
|||
app('log')->warning('Skipping zero-amount transaction', [ |
|||
'transaction_id' => $transactionData['id'] ?? 'unknown', |
|||
'description' => $transactionData['description'] ?? 'unknown' |
|||
]); |
|||
return []; |
|||
} |
|||
|
|||
$isDeposit = $amount >= 0; |
|||
$absoluteAmount = abs($amount); |
|||
|
|||
// Determine transaction type and accounts
|
|||
if ($isDeposit) { |
|||
$type = 'deposit'; |
|||
$sourceAccount = $this->getCounterAccount($transactionData, $isDeposit); |
|||
$destinationAccount = $this->getFireflyAccount($simpleFINAccountData, $accountMapping, $newAccountConfig); |
|||
} else { |
|||
$type = 'withdrawal'; |
|||
$sourceAccount = $this->getFireflyAccount($simpleFINAccountData, $accountMapping, $newAccountConfig); |
|||
$destinationAccount = $this->getCounterAccount($transactionData, $isDeposit); |
|||
} |
|||
|
|||
// Use 'posted' date as the primary transaction date.
|
|||
// SimpleFIN 'posted' is a UNIX timestamp.
|
|||
$transactionTimestamp = isset($transactionData['posted']) ? (int)$transactionData['posted'] : Carbon::now()->timestamp; |
|||
$transactionDateCarbon = Carbon::createFromTimestamp($transactionTimestamp); |
|||
|
|||
return [ |
|||
'type' => $type, |
|||
'date' => $transactionDateCarbon->format('Y-m-d'), |
|||
'amount' => number_format($absoluteAmount, 2, '.', ''), |
|||
'description' => $this->sanitizeDescription($transactionData['description'] ?? 'N/A'), |
|||
'source_id' => $sourceAccount['id'] ?? null, |
|||
'source_name' => $sourceAccount['name'] ?? null, |
|||
'source_iban' => $sourceAccount['iban'] ?? null, |
|||
'source_number' => $sourceAccount['number'] ?? null, |
|||
'source_bic' => $sourceAccount['bic'] ?? null, |
|||
'destination_id' => $destinationAccount['id'] ?? null, |
|||
'destination_name' => $destinationAccount['name'] ?? null, |
|||
'destination_iban' => $destinationAccount['iban'] ?? null, |
|||
'destination_number' => $destinationAccount['number'] ?? null, |
|||
'destination_bic' => $destinationAccount['bic'] ?? null, |
|||
'currency_id' => null, |
|||
'currency_code' => $this->getCurrencyCode($simpleFINAccountData), |
|||
'foreign_currency_id' => null, |
|||
'foreign_currency_code' => null, |
|||
'foreign_amount' => null, |
|||
'budget_id' => null, |
|||
'budget_name' => null, |
|||
'category_id' => null, |
|||
'category_name' => $this->extractCategory($transactionData), |
|||
'bill_id' => null, |
|||
'bill_name' => null, |
|||
'reconciled' => false, |
|||
'notes' => $this->buildNotes($transactionData), |
|||
'tags' => $this->extractTags($transactionData), |
|||
'internal_reference' => $transactionData['id'] ?? null, |
|||
'external_id' => $this->buildExternalId($transactionData, $simpleFINAccountData), |
|||
'external_url' => null, |
|||
'original_source' => 'simplefin-v1', |
|||
'recurrence_id' => null, |
|||
'bunq_payment_id' => null, |
|||
'import_hash_v2' => $this->generateImportHash($transactionData, $simpleFINAccountData), |
|||
'sepa_cc' => null, |
|||
'sepa_ct_op' => null, |
|||
'sepa_ct_id' => null, |
|||
'sepa_db' => null, |
|||
'sepa_country' => null, |
|||
'sepa_ep' => null, |
|||
'sepa_ci' => null, |
|||
'sepa_batch_id' => null, |
|||
'interest_date' => null, |
|||
'book_date' => $this->getBookDate($transactionData), |
|||
'process_date' => $this->getProcessDate($transactionData), |
|||
'due_date' => null, |
|||
'payment_date' => null, |
|||
'invoice_date' => null, |
|||
]; |
|||
} |
|||
|
|||
/** |
|||
* Get the Firefly III account information from mapping or account data |
|||
*/ |
|||
private function getFireflyAccount(array $simpleFINAccountData, array $accountMapping, array $newAccountConfig = []): array |
|||
{ |
|||
$accountKey = $simpleFINAccountData['id'] ?? null; |
|||
|
|||
// Check for user-provided account name first, then fall back to SimpleFIN account name
|
|||
$userProvidedName = null; |
|||
if ($accountKey && isset($newAccountConfig[$accountKey]['name'])) { |
|||
$userProvidedName = $newAccountConfig[$accountKey]['name']; |
|||
} |
|||
|
|||
$accountName = $userProvidedName ?? $simpleFINAccountData['name'] ?? 'Unknown SimpleFIN Account'; |
|||
|
|||
// Check if account is mapped and has a valid (non-zero) Firefly III account ID
|
|||
if ($accountKey && isset($accountMapping[$accountKey]) && $accountMapping[$accountKey] > 0) { |
|||
return [ |
|||
'id' => $accountMapping[$accountKey], // Configuration maps SimpleFIN account ID directly to Firefly account ID
|
|||
'name' => $accountName, |
|||
'iban' => null, |
|||
'number' => $accountKey, |
|||
'bic' => null, |
|||
]; |
|||
} |
|||
|
|||
// No mapping or mapped to 0 (deferred creation) - return null ID to trigger name-based account creation
|
|||
return [ |
|||
'id' => null, |
|||
'name' => $accountName, |
|||
'iban' => null, |
|||
'number' => $accountKey, |
|||
'bic' => null, |
|||
]; |
|||
} |
|||
|
|||
/** |
|||
* Get counter account (revenue/expense account) based on transaction data |
|||
*/ |
|||
private function getCounterAccount(array $transactionData, bool $isDeposit): array |
|||
{ |
|||
$description = $transactionData['description'] ?? 'N/A'; |
|||
|
|||
// Ensure accounts are collected
|
|||
$this->ensureAccountsCollected(); |
|||
|
|||
// Try to find existing expense or revenue account first
|
|||
$existingAccount = $this->findExistingAccount($description, $isDeposit); |
|||
if ($existingAccount) { |
|||
return [ |
|||
'id' => $existingAccount['id'], |
|||
'name' => $existingAccount['name'], |
|||
'iban' => null, |
|||
'number' => null, |
|||
'bic' => null, |
|||
]; |
|||
} |
|||
|
|||
// For clean instances: try clustering when no existing accounts found
|
|||
// This includes both clean instances (successful collection of zero accounts)
|
|||
// and failed collection scenarios
|
|||
if (config('simplefin.enable_transaction_clustering', true)) { |
|||
$accountsToCheck = $isDeposit ? $this->revenueAccounts : $this->expenseAccounts; |
|||
|
|||
if (empty($accountsToCheck)) { |
|||
$clusteredAccountName = $this->findClusteredAccountName($description, $isDeposit); |
|||
if ($clusteredAccountName) { |
|||
return [ |
|||
'id' => null, |
|||
'name' => $clusteredAccountName, |
|||
'iban' => null, |
|||
'number' => null, |
|||
'bic' => null, |
|||
]; |
|||
} |
|||
} |
|||
} |
|||
|
|||
// Check if automatic account creation is enabled
|
|||
if (!config('simplefin.auto_create_expense_accounts', true)) { |
|||
app('log')->warning(sprintf('Auto-creation disabled. No %s account will be created for "%s"', |
|||
$isDeposit ? 'revenue' : 'expense', |
|||
$description |
|||
)); |
|||
|
|||
// Return a generic account name instead of creating new ones
|
|||
$genericAccountName = $isDeposit ? 'Unmatched Revenue' : 'Unmatched Expenses'; |
|||
return [ |
|||
'id' => null, |
|||
'name' => $genericAccountName, |
|||
'iban' => null, |
|||
'number' => null, |
|||
'bic' => null, |
|||
]; |
|||
} |
|||
|
|||
// Fallback: extract meaningful counter account name from description
|
|||
$counterAccountName = $this->extractCounterAccountName($description); |
|||
|
|||
app('log')->info(sprintf('Creating new %s account "%s" for transaction "%s"', |
|||
$isDeposit ? 'revenue' : 'expense', |
|||
$counterAccountName, |
|||
$description |
|||
)); |
|||
|
|||
return [ |
|||
'id' => null, |
|||
'name' => $counterAccountName, |
|||
'iban' => null, |
|||
'number' => null, |
|||
'bic' => null, |
|||
]; |
|||
} |
|||
|
|||
/** |
|||
* Extract a meaningful counter account name from transaction description |
|||
*/ |
|||
private function extractCounterAccountName(string $description): string |
|||
{ |
|||
// Clean up and format the description for use as account name
|
|||
$cleaned = trim($description); |
|||
|
|||
// Remove common prefixes/suffixes that don't help identify the account
|
|||
$patterns = [ |
|||
'/^(PAYMENT|DEPOSIT|TRANSFER|DEBIT|CREDIT)\s+/i', |
|||
'/\s+(PAYMENT|DEPOSIT|TRANSFER|DEBIT|CREDIT)$/i', |
|||
'/^(FROM|TO)\s+/i', |
|||
'/\s+\d{4}[-\/]\d{2}[-\/]\d{2}.*$/', // Remove trailing dates
|
|||
]; |
|||
|
|||
foreach ($patterns as $pattern) { |
|||
$cleaned = preg_replace($pattern, '', $cleaned); |
|||
} |
|||
|
|||
$cleaned = trim($cleaned); |
|||
|
|||
// If we end up with an empty string, use a generic name
|
|||
if (empty($cleaned)) { |
|||
$cleaned = 'Unknown'; |
|||
} |
|||
|
|||
// Limit length to reasonable size
|
|||
if (strlen($cleaned) > 100) { |
|||
$cleaned = substr($cleaned, 0, 97) . '...'; |
|||
} |
|||
|
|||
return $cleaned; |
|||
} |
|||
|
|||
/** |
|||
* Get currency code, handling custom currencies |
|||
*/ |
|||
private function getCurrencyCode(array $simpleFINAccountData): string |
|||
{ |
|||
$currency = $simpleFINAccountData['currency'] ?? 'USD'; // Default to USD if not present
|
|||
|
|||
// Replicate basic logic from SimpleFINAccount::isCustomCurrency() if it checked for 'XXX' or non-standard codes.
|
|||
// For now, pass through, or use a simple check. Let Firefly III handle currency validation.
|
|||
// If currency code is not 3 uppercase letters, SimpleFIN spec might imply it's "custom".
|
|||
// The previous code returned 'XXX' for custom.
|
|||
if (strlen($currency) === 3 && ctype_upper($currency)) { |
|||
return $currency; |
|||
} |
|||
return 'XXX'; // Default for non-standard or missing currency codes, matching previous behavior.
|
|||
} |
|||
|
|||
/** |
|||
* Extract category from transaction extra data |
|||
*/ |
|||
private function extractCategory(array $transactionData): ?string |
|||
{ |
|||
$extra = $transactionData['extra'] ?? null; |
|||
if (!is_array($extra)) { |
|||
return null; |
|||
} |
|||
|
|||
// Check common category field names
|
|||
$categoryFields = ['category', 'Category', 'CATEGORY', 'merchant_category', 'transaction_category']; |
|||
|
|||
foreach ($categoryFields as $field) { |
|||
if (isset($extra[$field]) && !empty($extra[$field])) { |
|||
return (string) $extra[$field]; |
|||
} |
|||
} |
|||
|
|||
return null; |
|||
} |
|||
|
|||
/** |
|||
* Extract tags from transaction extra data |
|||
*/ |
|||
private function extractTags(array $transactionData): array |
|||
{ |
|||
$tags = []; |
|||
|
|||
if (isset($transactionData['pending']) && $transactionData['pending'] === true) { |
|||
$tags[] = 'pending'; |
|||
} |
|||
|
|||
$extra = $transactionData['extra'] ?? null; |
|||
if (!is_array($extra)) { |
|||
// If no extra data, or not an array, return current tags (e.g. only 'pending' if applicable)
|
|||
return array_unique($tags); |
|||
} |
|||
|
|||
// Look for tags in extra data
|
|||
if (isset($extra['tags']) && is_array($extra['tags'])) { |
|||
$tags = array_merge($tags, $extra['tags']); |
|||
} |
|||
|
|||
// Add organization domain as tag if available
|
|||
// Note: We don't have account info here, so this would need to be passed in
|
|||
|
|||
return array_unique($tags); |
|||
} |
|||
|
|||
/** |
|||
* Build notes from transaction extra data |
|||
*/ |
|||
private function buildNotes(array $transactionData): ?string |
|||
{ |
|||
$notes = []; |
|||
$extra = $transactionData['extra'] ?? null; |
|||
|
|||
if (isset($transactionData['pending']) && $transactionData['pending'] === true) { |
|||
$notes[] = 'Transaction is pending'; |
|||
} |
|||
|
|||
if (is_array($extra)) { |
|||
// Add any extra fields that might be useful as notes
|
|||
$noteFields = ['memo', 'notes', 'reference', 'check_number']; |
|||
|
|||
foreach ($noteFields as $field) { |
|||
if (isset($extra[$field]) && !empty($extra[$field])) { |
|||
$notes[] = sprintf('%s: %s', ucfirst($field), $extra[$field]); |
|||
} |
|||
} |
|||
} |
|||
|
|||
return empty($notes) ? null : implode("\n", $notes); |
|||
} |
|||
|
|||
/** |
|||
* Build external ID for transaction |
|||
*/ |
|||
private function buildExternalId(array $transactionData, array $simpleFINAccountData): string |
|||
{ |
|||
return sprintf('simplefin-%s-%s', $simpleFINAccountData['id'] ?? 'unknown_account', $transactionData['id'] ?? 'unknown_transaction'); |
|||
} |
|||
|
|||
/** |
|||
* Generate import hash for duplicate detection |
|||
*/ |
|||
private function generateImportHash(array $transactionData, array $simpleFINAccountData): string |
|||
{ |
|||
$postedTimestamp = isset($transactionData['posted']) ? (int)$transactionData['posted'] : time(); |
|||
$date = Carbon::createFromTimestamp($postedTimestamp)->format('Y-m-d'); |
|||
|
|||
$data = [ |
|||
'account_id' => $simpleFINAccountData['id'] ?? 'unknown_account', |
|||
'transaction_id' => $transactionData['id'] ?? 'unknown_transaction', |
|||
'amount' => $transactionData['amount'] ?? '0.00', |
|||
'description' => $transactionData['description'] ?? 'N/A', |
|||
'date' => $date, |
|||
]; |
|||
|
|||
return hash('sha256', json_encode($data)); |
|||
} |
|||
|
|||
/** |
|||
* Get book date from transaction data (using 'posted' timestamp) |
|||
*/ |
|||
private function getBookDate(array $transactionData): ?string |
|||
{ |
|||
if (isset($transactionData['posted']) && (int)$transactionData['posted'] > 0) { |
|||
return Carbon::createFromTimestamp((int)$transactionData['posted'])->format('Y-m-d'); |
|||
} |
|||
|
|||
return null; |
|||
} |
|||
|
|||
/** |
|||
* Get process date from transaction data |
|||
* SimpleFIN JSON does not typically include a separate 'transacted_at'. |
|||
* This method will return null unless 'transacted_at' is explicitly in $transactionData. |
|||
*/ |
|||
private function getProcessDate(array $transactionData): ?string |
|||
{ |
|||
if (isset($transactionData['transacted_at']) && (int)$transactionData['transacted_at'] > 0) { |
|||
return Carbon::createFromTimestamp((int)$transactionData['transacted_at'])->format('Y-m-d'); |
|||
} |
|||
|
|||
return null; |
|||
} |
|||
|
|||
/** |
|||
* Ensure expense and revenue accounts are collected from Firefly III |
|||
*/ |
|||
private function ensureAccountsCollected(): void |
|||
{ |
|||
if ($this->accountsCollected) { |
|||
return; |
|||
} |
|||
|
|||
// Check if smart matching is enabled before attempting collection
|
|||
if (!config('simplefin.smart_expense_matching', true)) { |
|||
app('log')->debug('Smart expense matching is disabled, skipping account collection'); |
|||
$this->expenseAccounts = []; |
|||
$this->revenueAccounts = []; |
|||
$this->accountsCollected = true; |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
// Verify authentication context exists before making API calls
|
|||
$baseUrl = \App\Services\Shared\Authentication\SecretManager::getBaseUrl(); |
|||
$accessToken = \App\Services\Shared\Authentication\SecretManager::getAccessToken(); |
|||
|
|||
if (empty($baseUrl) || empty($accessToken)) { |
|||
app('log')->warning('Missing authentication context for account collection, skipping smart matching'); |
|||
$this->expenseAccounts = []; |
|||
$this->revenueAccounts = []; |
|||
$this->accountsCollected = true; |
|||
return; |
|||
} |
|||
|
|||
app('log')->debug('Collecting expense accounts from Firefly III'); |
|||
$this->expenseAccounts = $this->collectExpenseAccounts(); |
|||
|
|||
app('log')->debug('Collecting revenue accounts from Firefly III'); |
|||
$this->revenueAccounts = $this->collectRevenueAccounts(); |
|||
|
|||
app('log')->debug(sprintf('Collected %d expense accounts and %d revenue accounts', |
|||
count($this->expenseAccounts), |
|||
count($this->revenueAccounts) |
|||
)); |
|||
|
|||
$this->accountsCollected = true; |
|||
} catch (\Exception $e) { |
|||
app('log')->error(sprintf('Failed to collect accounts: %s', $e->getMessage())); |
|||
app('log')->debug('Continuing without smart expense matching due to collection failure'); |
|||
$this->expenseAccounts = []; |
|||
$this->revenueAccounts = []; |
|||
$this->accountsCollected = true; // Mark as collected to avoid repeated failures
|
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Find existing expense or revenue account that matches the transaction description |
|||
*/ |
|||
private function findExistingAccount(string $description, bool $isDeposit): ?array |
|||
{ |
|||
$accountsToSearch = $isDeposit ? $this->revenueAccounts : $this->expenseAccounts; |
|||
$accountType = $isDeposit ? 'revenue' : 'expense'; |
|||
|
|||
if (empty($accountsToSearch)) { |
|||
app('log')->debug(sprintf('No %s accounts to search', $accountType)); |
|||
return null; |
|||
} |
|||
|
|||
// Normalize description for matching
|
|||
$normalizedDescription = $this->normalizeForMatching($description); |
|||
|
|||
// Try exact matches first
|
|||
foreach ($accountsToSearch as $key => $account) { |
|||
$normalizedAccountName = $this->normalizeForMatching($account['name']); |
|||
|
|||
// Check for exact match
|
|||
if ($normalizedAccountName === $normalizedDescription) { |
|||
app('log')->debug(sprintf('Exact match found: "%s" -> "%s"', $description, $account['name'])); |
|||
return $account; |
|||
} |
|||
} |
|||
|
|||
// Try fuzzy matching if no exact match found
|
|||
$bestMatch = $this->findBestFuzzyMatch($normalizedDescription, $accountsToSearch); |
|||
if ($bestMatch) { |
|||
app('log')->debug(sprintf('Fuzzy match found: "%s" -> "%s" (similarity: %.2f)', |
|||
$description, |
|||
$bestMatch['account']['name'], |
|||
$bestMatch['similarity'] |
|||
)); |
|||
return $bestMatch['account']; |
|||
} |
|||
|
|||
return null; |
|||
} |
|||
|
|||
/** |
|||
* Normalize string for matching (lowercase, remove special chars, etc.) |
|||
*/ |
|||
private function normalizeForMatching(string $text): string |
|||
{ |
|||
// Convert to lowercase
|
|||
$normalized = strtolower($text); |
|||
|
|||
// Remove common transaction prefixes/suffixes
|
|||
$patterns = [ |
|||
'/^(payment|deposit|transfer|debit|credit)\s+/i', |
|||
'/\s+(payment|deposit|transfer|debit|credit)$/i', |
|||
'/^(from|to)\s+/i', |
|||
'/\s+\d{4}[-\/]\d{2}[-\/]\d{2}.*$/', // Remove trailing dates
|
|||
'/\s+#\w+.*$/', // Remove trailing reference numbers
|
|||
]; |
|||
|
|||
foreach ($patterns as $pattern) { |
|||
$normalized = preg_replace($pattern, '', $normalized); |
|||
} |
|||
|
|||
// Remove special characters and extra spaces
|
|||
$normalized = preg_replace('/[^a-z0-9\s]/', '', $normalized); |
|||
$normalized = preg_replace('/\s+/', ' ', $normalized); |
|||
|
|||
return trim($normalized); |
|||
} |
|||
|
|||
/** |
|||
* Find best fuzzy match using similarity algorithms |
|||
*/ |
|||
private function findBestFuzzyMatch(string $normalizedDescription, array $accounts): ?array |
|||
{ |
|||
// Check if smart matching is enabled
|
|||
if (!config('simplefin.smart_expense_matching', true)) { |
|||
return null; |
|||
} |
|||
|
|||
$bestMatch = null; |
|||
$bestSimilarity = 0; |
|||
$threshold = config('simplefin.expense_matching_threshold', 0.7); |
|||
|
|||
foreach ($accounts as $key => $account) { |
|||
$normalizedAccountName = $this->normalizeForMatching($account['name']); |
|||
|
|||
// Calculate similarity using multiple algorithms
|
|||
$similarity = $this->calculateSimilarity($normalizedDescription, $normalizedAccountName); |
|||
|
|||
if ($similarity > $bestSimilarity && $similarity >= $threshold) { |
|||
$bestSimilarity = $similarity; |
|||
$bestMatch = [ |
|||
'account' => $account, |
|||
'similarity' => $similarity |
|||
]; |
|||
} |
|||
} |
|||
|
|||
return $bestMatch; |
|||
} |
|||
|
|||
/** |
|||
* Calculate similarity between two strings using multiple algorithms |
|||
*/ |
|||
private function calculateSimilarity(string $str1, string $str2): float |
|||
{ |
|||
// Use Levenshtein distance for similarity
|
|||
$maxLen = max(strlen($str1), strlen($str2)); |
|||
if ($maxLen === 0) { |
|||
return 1.0; |
|||
} |
|||
|
|||
$levenshtein = levenshtein($str1, $str2); |
|||
$levenshteinSimilarity = 1 - ($levenshtein / $maxLen); |
|||
|
|||
// Use similar_text for additional comparison
|
|||
similar_text($str1, $str2, $percent); |
|||
$similarTextSimilarity = $percent / 100; |
|||
|
|||
// Check for substring matches (give bonus for contains)
|
|||
$substringBonus = 0; |
|||
if (strpos($str1, $str2) !== false || strpos($str2, $str1) !== false) { |
|||
$substringBonus = 0.2; |
|||
} |
|||
|
|||
// Weighted average of different similarity measures
|
|||
$finalSimilarity = ($levenshteinSimilarity * 0.5) + ($similarTextSimilarity * 0.4) + $substringBonus; |
|||
|
|||
return min(1.0, $finalSimilarity); |
|||
} |
|||
|
|||
/** |
|||
* Sanitize description for safe display |
|||
*/ |
|||
private function sanitizeDescription(string $description): string |
|||
{ |
|||
// Remove any potentially harmful characters
|
|||
$sanitized = strip_tags($description); |
|||
$sanitized = trim($sanitized); |
|||
|
|||
// Ensure we have a non-empty description
|
|||
if (empty($sanitized)) { |
|||
$sanitized = 'SimpleFIN Transaction'; |
|||
} |
|||
|
|||
return $sanitized; |
|||
} |
|||
|
|||
/** |
|||
* Find clustered account name for clean instances without existing accounts |
|||
*/ |
|||
private function findClusteredAccountName(string $description, bool $isDeposit): ?string |
|||
{ |
|||
$accountType = $isDeposit ? 'revenue' : 'expense'; |
|||
$normalizedDescription = $this->normalizeForMatching($description); |
|||
$threshold = config('simplefin.clustering_similarity_threshold', 0.7); |
|||
|
|||
// Check existing clusters for similar descriptions
|
|||
foreach ($this->pendingTransactionClusters as $clusterName => $cluster) { |
|||
if ($cluster['type'] !== $accountType) { |
|||
continue; |
|||
} |
|||
|
|||
// Check similarity against cluster representative
|
|||
$similarity = $this->calculateSimilarity($normalizedDescription, $cluster['normalized_name']); |
|||
|
|||
if ($similarity >= $threshold) { |
|||
app('log')->debug(sprintf('Clustering "%s" with existing cluster "%s" (similarity: %.2f)', |
|||
$description, $clusterName, $similarity)); |
|||
|
|||
// Add to existing cluster
|
|||
$this->pendingTransactionClusters[$clusterName]['descriptions'][] = $description; |
|||
$this->pendingTransactionClusters[$clusterName]['count']++; |
|||
|
|||
return $clusterName; |
|||
} |
|||
} |
|||
|
|||
// No matching cluster found, create new cluster
|
|||
$clusterName = $this->generateClusterName($description); |
|||
$this->pendingTransactionClusters[$clusterName] = [ |
|||
'type' => $accountType, |
|||
'normalized_name' => $normalizedDescription, |
|||
'descriptions' => [$description], |
|||
'count' => 1, |
|||
'created_at' => time() |
|||
]; |
|||
|
|||
app('log')->debug(sprintf('Created new %s cluster "%s" for "%s"', $accountType, $clusterName, $description)); |
|||
|
|||
return $clusterName; |
|||
} |
|||
|
|||
/** |
|||
* Generate meaningful cluster name from transaction description |
|||
*/ |
|||
private function generateClusterName(string $description): string |
|||
{ |
|||
// Extract core business/merchant name for clustering
|
|||
$cleaned = $this->extractCounterAccountName($description); |
|||
|
|||
// Further normalize for cluster naming
|
|||
$clusterName = preg_replace('/\b(payment|deposit|transfer|debit|credit|from|to)\b/i', '', $cleaned); |
|||
$clusterName = preg_replace('/\s+/', ' ', trim($clusterName)); |
|||
|
|||
// Remove trailing numbers/references that could vary
|
|||
$clusterName = preg_replace('/\s+\d+\s*$/', '', $clusterName); |
|||
$clusterName = preg_replace('/\s+#\w+.*$/', '', $clusterName); |
|||
|
|||
// Ensure minimum meaningful length
|
|||
if (strlen($clusterName) < 3) { |
|||
$clusterName = $cleaned; // Fall back to basic cleaning
|
|||
} |
|||
|
|||
return trim($clusterName); |
|||
} |
|||
} |
@ -0,0 +1,208 @@ |
|||
<?php |
|||
|
|||
/* |
|||
* Account.php |
|||
* Copyright (c) 2021 james@firefly-iii.org |
|||
* |
|||
* This file is part of the Firefly III Data Importer |
|||
* (https://github.com/firefly-iii/data-importer). |
|||
* |
|||
* This program is free software: you can redistribute it and/or modify |
|||
* it under the terms of the GNU Affero General Public License as |
|||
* published by the Free Software Foundation, either version 3 of the |
|||
* License, or (at your option) any later version. |
|||
* |
|||
* This program is distributed in the hope that it will be useful, |
|||
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
* GNU Affero General Public License for more details. |
|||
* |
|||
* You should have received a copy of the GNU Affero General Public License |
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>. |
|||
*/ |
|||
|
|||
declare(strict_types=1); |
|||
|
|||
namespace App\Services\SimpleFIN\Model; |
|||
|
|||
use Carbon\Carbon; |
|||
|
|||
/** |
|||
* Class Account |
|||
*/ |
|||
class Account |
|||
{ |
|||
private array $org; |
|||
private string $id; |
|||
private string $name; |
|||
private string $currency; |
|||
private string $balance; |
|||
private ?string $availableBalance; |
|||
private int $balanceDate; |
|||
private array $transactions; |
|||
private array $extra; |
|||
|
|||
public function __construct(array $data) |
|||
{ |
|||
$this->validateRequiredFields($data); |
|||
|
|||
$this->org = $data['org']; |
|||
$this->id = $data['id']; |
|||
$this->name = $data['name']; |
|||
$this->currency = $data['currency']; |
|||
$this->balance = $data['balance']; |
|||
$this->availableBalance = $data['available-balance'] ?? null; |
|||
$this->balanceDate = $data['balance-date']; |
|||
$this->transactions = $data['transactions'] ?? []; |
|||
$this->extra = $data['extra'] ?? []; |
|||
} |
|||
|
|||
public static function fromArray(array $data): self |
|||
{ |
|||
return new self($data); |
|||
} |
|||
|
|||
public function getOrganization(): array |
|||
{ |
|||
return $this->org; |
|||
} |
|||
|
|||
public function getOrganizationDomain(): ?string |
|||
{ |
|||
return $this->org['domain'] ?? null; |
|||
} |
|||
|
|||
public function getOrganizationName(): ?string |
|||
{ |
|||
return $this->org['name'] ?? null; |
|||
} |
|||
|
|||
public function getOrganizationSfinUrl(): string |
|||
{ |
|||
return $this->org['sfin-url']; |
|||
} |
|||
|
|||
public function getId(): string |
|||
{ |
|||
return $this->id; |
|||
} |
|||
|
|||
public function getName(): string |
|||
{ |
|||
return $this->name; |
|||
} |
|||
|
|||
public function getCurrency(): string |
|||
{ |
|||
return $this->currency; |
|||
} |
|||
|
|||
public function isCustomCurrency(): bool |
|||
{ |
|||
return str_starts_with($this->currency, 'http://') || str_starts_with($this->currency, 'https://'); |
|||
} |
|||
|
|||
public function getBalance(): string |
|||
{ |
|||
return $this->balance; |
|||
} |
|||
|
|||
public function getBalanceAsFloat(): float |
|||
{ |
|||
return (float) $this->balance; |
|||
} |
|||
|
|||
public function getAvailableBalance(): ?string |
|||
{ |
|||
return $this->availableBalance; |
|||
} |
|||
|
|||
public function getAvailableBalanceAsFloat(): ?float |
|||
{ |
|||
return $this->availableBalance ? (float) $this->availableBalance : null; |
|||
} |
|||
|
|||
public function getBalanceDate(): int |
|||
{ |
|||
return $this->balanceDate; |
|||
} |
|||
|
|||
public function getBalanceDateAsCarbon(): Carbon |
|||
{ |
|||
return Carbon::createFromTimestamp($this->balanceDate); |
|||
} |
|||
|
|||
public function getTransactions(): array |
|||
{ |
|||
return $this->transactions; |
|||
} |
|||
|
|||
public function getTransactionCount(): int |
|||
{ |
|||
return count($this->transactions); |
|||
} |
|||
|
|||
public function hasTransactions(): bool |
|||
{ |
|||
return !empty($this->transactions); |
|||
} |
|||
|
|||
public function getExtra(): array |
|||
{ |
|||
return $this->extra; |
|||
} |
|||
|
|||
public function getExtraValue(string $key): mixed |
|||
{ |
|||
return $this->extra[$key] ?? null; |
|||
} |
|||
|
|||
public function hasExtra(string $key): bool |
|||
{ |
|||
return array_key_exists($key, $this->extra); |
|||
} |
|||
|
|||
public function toArray(): array |
|||
{ |
|||
return [ |
|||
'org' => $this->org, |
|||
'id' => $this->id, |
|||
'name' => $this->name, |
|||
'currency' => $this->currency, |
|||
'balance' => $this->balance, |
|||
'available-balance' => $this->availableBalance, |
|||
'balance-date' => $this->balanceDate, |
|||
'transactions' => $this->transactions, |
|||
'extra' => $this->extra, |
|||
]; |
|||
} |
|||
|
|||
private function validateRequiredFields(array $data): void |
|||
{ |
|||
$requiredFields = ['org', 'id', 'name', 'currency', 'balance', 'balance-date']; |
|||
|
|||
foreach ($requiredFields as $field) { |
|||
if (!array_key_exists($field, $data)) { |
|||
throw new \InvalidArgumentException(sprintf('Missing required field: %s', $field)); |
|||
} |
|||
} |
|||
|
|||
// Validate organization structure
|
|||
if (!is_array($data['org'])) { |
|||
throw new \InvalidArgumentException('Organization must be an array'); |
|||
} |
|||
|
|||
if (!isset($data['org']['sfin-url'])) { |
|||
throw new \InvalidArgumentException('Organization must have sfin-url'); |
|||
} |
|||
|
|||
if (!isset($data['org']['domain']) && !isset($data['org']['name'])) { |
|||
throw new \InvalidArgumentException('Organization must have either domain or name'); |
|||
} |
|||
|
|||
// Validate balance-date is numeric
|
|||
if (!is_numeric($data['balance-date'])) { |
|||
throw new \InvalidArgumentException('Balance date must be a numeric timestamp'); |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,199 @@ |
|||
<?php |
|||
|
|||
/* |
|||
* Transaction.php |
|||
* Copyright (c) 2021 james@firefly-iii.org |
|||
* |
|||
* This file is part of the Firefly III Data Importer |
|||
* (https://github.com/firefly-iii/data-importer). |
|||
* |
|||
* This program is free software: you can redistribute it and/or modify |
|||
* it under the terms of the GNU Affero General Public License as |
|||
* published by the Free Software Foundation, either version 3 of the |
|||
* License, or (at your option) any later version. |
|||
* |
|||
* This program is distributed in the hope that it will be useful, |
|||
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
* GNU Affero General Public License for more details. |
|||
* |
|||
* You should have received a copy of the GNU Affero General Public License |
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>. |
|||
*/ |
|||
|
|||
declare(strict_types=1); |
|||
|
|||
namespace App\Services\SimpleFIN\Model; |
|||
|
|||
use Carbon\Carbon; |
|||
|
|||
/** |
|||
* Class Transaction |
|||
*/ |
|||
class Transaction |
|||
{ |
|||
private string $id; |
|||
private int $posted; |
|||
private string $amount; |
|||
private string $description; |
|||
private ?int $transactedAt; |
|||
private bool $pending; |
|||
private array $extra; |
|||
|
|||
public function __construct(array $data) |
|||
{ |
|||
$this->validateRequiredFields($data); |
|||
|
|||
$this->id = $data['id']; |
|||
$this->posted = $data['posted']; |
|||
$this->amount = $data['amount']; |
|||
$this->description = $data['description']; |
|||
$this->transactedAt = $data['transacted_at'] ?? null; |
|||
$this->pending = $data['pending'] ?? false; |
|||
$this->extra = $data['extra'] ?? []; |
|||
} |
|||
|
|||
public static function fromArray(array $data): self |
|||
{ |
|||
return new self($data); |
|||
} |
|||
|
|||
public function getId(): string |
|||
{ |
|||
return $this->id; |
|||
} |
|||
|
|||
public function getPosted(): int |
|||
{ |
|||
return $this->posted; |
|||
} |
|||
|
|||
public function getPostedAsCarbon(): ?Carbon |
|||
{ |
|||
return $this->posted === 0 ? null : Carbon::createFromTimestamp($this->posted); |
|||
} |
|||
|
|||
public function getAmount(): string |
|||
{ |
|||
return $this->amount; |
|||
} |
|||
|
|||
public function getAmountAsFloat(): float |
|||
{ |
|||
return (float) $this->amount; |
|||
} |
|||
|
|||
public function isDeposit(): bool |
|||
{ |
|||
return $this->getAmountAsFloat() >= 0; |
|||
} |
|||
|
|||
public function isWithdrawal(): bool |
|||
{ |
|||
return $this->getAmountAsFloat() < 0; |
|||
} |
|||
|
|||
public function getAbsoluteAmount(): float |
|||
{ |
|||
return abs($this->getAmountAsFloat()); |
|||
} |
|||
|
|||
public function getDescription(): string |
|||
{ |
|||
return $this->description; |
|||
} |
|||
|
|||
public function getTransactedAt(): ?int |
|||
{ |
|||
return $this->transactedAt; |
|||
} |
|||
|
|||
public function getTransactedAtAsCarbon(): ?Carbon |
|||
{ |
|||
return $this->transactedAt ? Carbon::createFromTimestamp($this->transactedAt) : null; |
|||
} |
|||
|
|||
public function isPending(): bool |
|||
{ |
|||
return $this->pending; |
|||
} |
|||
|
|||
public function isPosted(): bool |
|||
{ |
|||
return !$this->pending && $this->posted > 0; |
|||
} |
|||
|
|||
public function getExtra(): array |
|||
{ |
|||
return $this->extra; |
|||
} |
|||
|
|||
public function getExtraValue(string $key): mixed |
|||
{ |
|||
return $this->extra[$key] ?? null; |
|||
} |
|||
|
|||
public function hasExtra(string $key): bool |
|||
{ |
|||
return array_key_exists($key, $this->extra); |
|||
} |
|||
|
|||
public function getEffectiveDate(): Carbon |
|||
{ |
|||
// Use transacted_at if available, otherwise fall back to posted date
|
|||
if ($this->transactedAt && $this->transactedAt > 0) { |
|||
return Carbon::createFromTimestamp($this->transactedAt); |
|||
} |
|||
|
|||
if ($this->posted > 0) { |
|||
return Carbon::createFromTimestamp($this->posted); |
|||
} |
|||
|
|||
// If both are 0 or invalid, return current time
|
|||
return Carbon::now(); |
|||
} |
|||
|
|||
public function toArray(): array |
|||
{ |
|||
return [ |
|||
'id' => $this->id, |
|||
'posted' => $this->posted, |
|||
'amount' => $this->amount, |
|||
'description' => $this->description, |
|||
'transacted_at' => $this->transactedAt, |
|||
'pending' => $this->pending, |
|||
'extra' => $this->extra, |
|||
]; |
|||
} |
|||
|
|||
private function validateRequiredFields(array $data): void |
|||
{ |
|||
$requiredFields = ['id', 'posted', 'amount', 'description']; |
|||
|
|||
foreach ($requiredFields as $field) { |
|||
if (!array_key_exists($field, $data)) { |
|||
throw new \InvalidArgumentException(sprintf('Missing required field: %s', $field)); |
|||
} |
|||
} |
|||
|
|||
// Validate posted is numeric
|
|||
if (!is_numeric($data['posted'])) { |
|||
throw new \InvalidArgumentException('Posted date must be a numeric timestamp'); |
|||
} |
|||
|
|||
// Validate amount is numeric string
|
|||
if (!is_numeric($data['amount'])) { |
|||
throw new \InvalidArgumentException('Amount must be a numeric string'); |
|||
} |
|||
|
|||
// Validate transacted_at if present
|
|||
if (isset($data['transacted_at']) && !is_numeric($data['transacted_at'])) { |
|||
throw new \InvalidArgumentException('Transacted at must be a numeric timestamp'); |
|||
} |
|||
|
|||
// Validate pending if present
|
|||
if (isset($data['pending']) && !is_bool($data['pending'])) { |
|||
throw new \InvalidArgumentException('Pending must be a boolean'); |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,48 @@ |
|||
<?php |
|||
|
|||
/* |
|||
* AccountsRequest.php |
|||
* Copyright (c) 2021 james@firefly-iii.org |
|||
* |
|||
* This file is part of the Firefly III Data Importer |
|||
* (https://github.com/firefly-iii/data-importer). |
|||
* |
|||
* This program is free software: you can redistribute it and/or modify |
|||
* it under the terms of the GNU Affero General Public License as |
|||
* published by the Free Software Foundation, either version 3 of the |
|||
* License, or (at your option) any later version. |
|||
* |
|||
* This program is distributed in the hope that it will be useful, |
|||
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
* GNU Affero General Public License for more details. |
|||
* |
|||
* You should have received a copy of the GNU Affero General Public License |
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>. |
|||
*/ |
|||
|
|||
declare(strict_types=1); |
|||
|
|||
namespace App\Services\SimpleFIN\Request; |
|||
|
|||
use App\Exceptions\ImporterHttpException; |
|||
use App\Services\SimpleFIN\Response\AccountsResponse; |
|||
use App\Services\Shared\Response\ResponseInterface as SharedResponseInterface; |
|||
|
|||
/** |
|||
* Class AccountsRequest |
|||
*/ |
|||
class AccountsRequest extends SimpleFINRequest |
|||
{ |
|||
/** |
|||
* @throws ImporterHttpException |
|||
*/ |
|||
public function get(): AccountsResponse |
|||
{ |
|||
app('log')->debug(sprintf('Now at %s', __METHOD__)); |
|||
|
|||
$response = $this->authenticatedGet('/accounts'); |
|||
|
|||
return new AccountsResponse($response); |
|||
} |
|||
} |
@ -0,0 +1,72 @@ |
|||
<?php |
|||
|
|||
declare(strict_types=1); |
|||
|
|||
namespace App\Services\SimpleFIN\Request; |
|||
|
|||
use GrumpyDictator\FFIIIApiSupport\Request\Request; |
|||
use GrumpyDictator\FFIIIApiSupport\Response\Response; |
|||
use GrumpyDictator\FFIIIApiSupport\Response\ValidationErrorResponse; |
|||
use App\Services\SimpleFIN\Response\PostAccountResponse; |
|||
|
|||
/** |
|||
* Class PostAccountRequest |
|||
* POST an account to Firefly III. |
|||
*/ |
|||
class PostAccountRequest extends Request |
|||
{ |
|||
/** |
|||
* PostAccountRequest constructor. |
|||
* |
|||
* @param string $url |
|||
* @param string $token |
|||
*/ |
|||
public function __construct(string $url, string $token) |
|||
{ |
|||
$this->setBase($url); |
|||
$this->setToken($token); |
|||
$this->setUri('accounts'); |
|||
} |
|||
|
|||
/** |
|||
* @return Response |
|||
*/ |
|||
public function get(): Response |
|||
{ |
|||
// TODO: Implement get() method.
|
|||
} |
|||
|
|||
/** |
|||
* @return Response |
|||
*/ |
|||
public function post(): Response |
|||
{ |
|||
$data = $this->authenticatedPost(); |
|||
|
|||
// found error in response:
|
|||
if (array_key_exists('errors', $data) && is_array($data['errors'])) { |
|||
return new ValidationErrorResponse($data['errors']); |
|||
} |
|||
|
|||
// should be impossible to get here (see previous code) but still check.
|
|||
if (!array_key_exists('data', $data)) { |
|||
// return with error array:
|
|||
if (array_key_exists('errors', $data) && is_array($data['errors'])) { |
|||
return new ValidationErrorResponse($data['errors']); |
|||
} |
|||
// no data array and no error info, that's weird!
|
|||
$info = ['unknown_field' => [sprintf('Unknown error: %s', json_encode($data, 0, 16))]]; |
|||
return new ValidationErrorResponse($info); |
|||
} |
|||
|
|||
return new PostAccountResponse($data['data'] ?? []); |
|||
} |
|||
|
|||
/** |
|||
* {@inheritdoc} |
|||
*/ |
|||
public function put(): Response |
|||
{ |
|||
// TODO: Implement put() method.
|
|||
} |
|||
} |
@ -0,0 +1,159 @@ |
|||
<?php |
|||
|
|||
/* |
|||
* SimpleFINRequest.php |
|||
* Copyright (c) 2021 james@firefly-iii.org |
|||
* |
|||
* This file is part of the Firefly III Data Importer |
|||
* (https://github.com/firefly-iii/data-importer). |
|||
* |
|||
* This program is free software: you can redistribute it and/or modify |
|||
* it under the terms of the GNU Affero General Public License as |
|||
* published by the Free Software Foundation, either version 3 of the |
|||
* License, or (at your option) any later version. |
|||
* |
|||
* This program is distributed in the hope that it will be useful, |
|||
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
* GNU Affero General Public License for more details. |
|||
* |
|||
* You should have received a copy of the GNU Affero General Public License |
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>. |
|||
*/ |
|||
|
|||
declare(strict_types=1); |
|||
|
|||
namespace App\Services\SimpleFIN\Request; |
|||
|
|||
use App\Exceptions\ImporterErrorException; |
|||
use App\Exceptions\ImporterHttpException; |
|||
use App\Services\Session\Constants; |
|||
use App\Services\Shared\Response\ResponseInterface as SharedResponseInterface; |
|||
use GuzzleHttp\Client; |
|||
use GuzzleHttp\Exception\ClientException; |
|||
use GuzzleHttp\Exception\GuzzleException; |
|||
use GuzzleHttp\Exception\ServerException; |
|||
use Psr\Http\Message\ResponseInterface; |
|||
|
|||
/** |
|||
* Class SimpleFINRequest |
|||
*/ |
|||
abstract class SimpleFINRequest |
|||
{ |
|||
private string $apiUrl; |
|||
private string $token; |
|||
private array $parameters = []; |
|||
private float $timeOut; |
|||
|
|||
/** |
|||
* @throws ImporterHttpException |
|||
*/ |
|||
abstract public function get(): SharedResponseInterface; |
|||
|
|||
public function setApiUrl(string $apiUrl): void |
|||
{ |
|||
$this->apiUrl = rtrim($apiUrl, '/'); |
|||
} |
|||
|
|||
public function setToken(string $token): void |
|||
{ |
|||
$this->token = $token; |
|||
} |
|||
|
|||
public function setParameters(array $parameters): void |
|||
{ |
|||
app('log')->debug('SimpleFIN request parameters set to: ', $parameters); |
|||
$this->parameters = $parameters; |
|||
} |
|||
|
|||
public function setTimeOut(float $timeOut): void |
|||
{ |
|||
$this->timeOut = $timeOut; |
|||
} |
|||
|
|||
protected function authenticatedGet(string $endpoint): ResponseInterface |
|||
{ |
|||
app('log')->debug(sprintf('SimpleFIN authenticated GET to %s%s', $this->apiUrl, $endpoint)); |
|||
|
|||
$client = new Client(); |
|||
$fullUrl = sprintf('%s%s', $this->apiUrl, $endpoint); |
|||
|
|||
$origin = session()->get(Constants::SIMPLEFIN_BRIDGE_URL); |
|||
$options = [ |
|||
'timeout' => $this->timeOut, |
|||
'headers' => [ |
|||
'Accept' => 'application/json', |
|||
'Content-Type' => 'application/json', |
|||
'Origin' => $origin, |
|||
], |
|||
]; |
|||
|
|||
// Only add basic auth if userinfo is not already in the apiUrl
|
|||
// and a token is provided. SimpleFIN typically uses userinfo in the Access URL.
|
|||
if (strpos($this->apiUrl, '@') === false && !empty($this->token)) { |
|||
$options['auth'] = [$this->token, '']; |
|||
} |
|||
|
|||
|
|||
if (!empty($this->parameters)) { |
|||
$options['query'] = $this->parameters; |
|||
} |
|||
|
|||
try { |
|||
$response = $client->get($fullUrl, $options); |
|||
} catch (ClientException $e) { |
|||
app('log')->error(sprintf('SimpleFIN ClientException: %s', $e->getMessage())); |
|||
$this->handleClientException($e); |
|||
throw new ImporterHttpException($e->getMessage(), $e->getCode(), $e); |
|||
} catch (ServerException $e) { |
|||
app('log')->error(sprintf('SimpleFIN ServerException: %s', $e->getMessage())); |
|||
throw new ImporterHttpException($e->getMessage(), $e->getCode(), $e); |
|||
} catch (GuzzleException $e) { |
|||
app('log')->error(sprintf('SimpleFIN GuzzleException: %s', $e->getMessage())); |
|||
throw new ImporterHttpException($e->getMessage(), $e->getCode(), $e); |
|||
} |
|||
|
|||
return $response; |
|||
} |
|||
|
|||
private function handleClientException(ClientException $e): void |
|||
{ |
|||
$statusCode = $e->getResponse()->getStatusCode(); |
|||
$body = (string) $e->getResponse()->getBody(); |
|||
|
|||
app('log')->error(sprintf('SimpleFIN HTTP %d error: %s', $statusCode, $body)); |
|||
|
|||
switch ($statusCode) { |
|||
case 401: |
|||
throw new ImporterErrorException('Invalid SimpleFIN token or authentication failed'); |
|||
case 403: |
|||
throw new ImporterErrorException('Access denied to SimpleFIN resource'); |
|||
case 404: |
|||
throw new ImporterErrorException('SimpleFIN resource not found'); |
|||
case 429: |
|||
throw new ImporterErrorException('SimpleFIN rate limit exceeded'); |
|||
default: |
|||
throw new ImporterErrorException(sprintf('SimpleFIN API error (HTTP %d): %s', $statusCode, $body)); |
|||
} |
|||
} |
|||
|
|||
protected function getApiUrl(): string |
|||
{ |
|||
return $this->apiUrl; |
|||
} |
|||
|
|||
protected function getToken(): string |
|||
{ |
|||
return $this->token; |
|||
} |
|||
|
|||
protected function getParameters(): array |
|||
{ |
|||
return $this->parameters; |
|||
} |
|||
|
|||
protected function getTimeOut(): float |
|||
{ |
|||
return $this->timeOut; |
|||
} |
|||
} |
@ -0,0 +1,48 @@ |
|||
<?php |
|||
|
|||
/* |
|||
* TransactionsRequest.php |
|||
* Copyright (c) 2021 james@firefly-iii.org |
|||
* |
|||
* This file is part of the Firefly III Data Importer |
|||
* (https://github.com/firefly-iii/data-importer). |
|||
* |
|||
* This program is free software: you can redistribute it and/or modify |
|||
* it under the terms of the GNU Affero General Public License as |
|||
* published by the Free Software Foundation, either version 3 of the |
|||
* License, or (at your option) any later version. |
|||
* |
|||
* This program is distributed in the hope that it will be useful, |
|||
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
* GNU Affero General Public License for more details. |
|||
* |
|||
* You should have received a copy of the GNU Affero General Public License |
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>. |
|||
*/ |
|||
|
|||
declare(strict_types=1); |
|||
|
|||
namespace App\Services\SimpleFIN\Request; |
|||
|
|||
use App\Exceptions\ImporterHttpException; |
|||
use App\Services\SimpleFIN\Response\TransactionsResponse; |
|||
use App\Services\Shared\Response\ResponseInterface as SharedResponseInterface; |
|||
|
|||
/** |
|||
* Class TransactionsRequest |
|||
*/ |
|||
class TransactionsRequest extends SimpleFINRequest |
|||
{ |
|||
/** |
|||
* @throws ImporterHttpException |
|||
*/ |
|||
public function get(): SharedResponseInterface |
|||
{ |
|||
app('log')->debug(sprintf('Now at %s', __METHOD__)); |
|||
|
|||
$response = $this->authenticatedGet(''); |
|||
|
|||
return new TransactionsResponse($response); |
|||
} |
|||
} |
@ -0,0 +1,76 @@ |
|||
<?php |
|||
|
|||
/* |
|||
* AccountsResponse.php |
|||
* Copyright (c) 2021 james@firefly-iii.org |
|||
* |
|||
* This file is part of the Firefly III Data Importer |
|||
* (https://github.com/firefly-iii/data-importer). |
|||
* |
|||
* This program is free software: you can redistribute it and/or modify |
|||
* it under the terms of the GNU Affero General Public License as |
|||
* published by the Free Software Foundation, either version 3 of the |
|||
* License, or (at your option) any later version. |
|||
* |
|||
* This program is distributed in the hope that it will be useful, |
|||
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
* GNU Affero General Public License for more details. |
|||
* |
|||
* You should have received a copy of the GNU Affero General Public License |
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>. |
|||
*/ |
|||
|
|||
declare(strict_types=1); |
|||
|
|||
namespace App\Services\SimpleFIN\Response; |
|||
|
|||
use Psr\Http\Message\ResponseInterface; |
|||
|
|||
/** |
|||
* Class AccountsResponse |
|||
*/ |
|||
class AccountsResponse extends SimpleFINResponse |
|||
{ |
|||
private array $accounts = []; |
|||
|
|||
public function __construct(ResponseInterface $response) |
|||
{ |
|||
parent::__construct($response); |
|||
$this->parseAccounts(); |
|||
} |
|||
|
|||
public function getAccounts(): array |
|||
{ |
|||
return $this->accounts; |
|||
} |
|||
|
|||
public function getAccountCount(): int |
|||
{ |
|||
return count($this->accounts); |
|||
} |
|||
|
|||
public function hasAccounts(): bool |
|||
{ |
|||
return !empty($this->accounts); |
|||
} |
|||
|
|||
private function parseAccounts(): void |
|||
{ |
|||
$data = $this->getData(); |
|||
|
|||
if (empty($data)) { |
|||
app('log')->warning('SimpleFIN AccountsResponse: No data to parse'); |
|||
return; |
|||
} |
|||
|
|||
// SimpleFIN API returns accounts in the 'accounts' array
|
|||
if (isset($data['accounts']) && is_array($data['accounts'])) { |
|||
$this->accounts = $data['accounts']; |
|||
app('log')->debug(sprintf('SimpleFIN AccountsResponse: Parsed %d accounts', count($this->accounts))); |
|||
} else { |
|||
app('log')->warning('SimpleFIN AccountsResponse: No accounts array found in response'); |
|||
$this->accounts = []; |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,47 @@ |
|||
<?php |
|||
|
|||
declare(strict_types=1); |
|||
|
|||
namespace App\Services\SimpleFIN\Response; |
|||
|
|||
use GrumpyDictator\FFIIIApiSupport\Response\Response; |
|||
use GrumpyDictator\FFIIIApiSupport\Model\Account; |
|||
|
|||
/** |
|||
* Class PostAccountResponse. |
|||
*/ |
|||
class PostAccountResponse extends Response |
|||
{ |
|||
private ?Account $account; |
|||
private array $rawData; |
|||
|
|||
/** |
|||
* Response constructor. |
|||
* |
|||
* @param array $data |
|||
*/ |
|||
public function __construct(array $data) |
|||
{ |
|||
$this->account = null; |
|||
if (isset($data['id'])) { |
|||
$this->account = Account::fromArray($data); |
|||
} |
|||
$this->rawData = $data; |
|||
} |
|||
|
|||
/** |
|||
* @return array |
|||
*/ |
|||
public function getRawData(): array |
|||
{ |
|||
return $this->rawData; |
|||
} |
|||
|
|||
/** |
|||
* @return Account|null |
|||
*/ |
|||
public function getAccount(): ?Account |
|||
{ |
|||
return $this->account; |
|||
} |
|||
} |
@ -0,0 +1,107 @@ |
|||
<?php |
|||
|
|||
/* |
|||
* SimpleFINResponse.php |
|||
* Copyright (c) 2021 james@firefly-iii.org |
|||
* |
|||
* This file is part of the Firefly III Data Importer |
|||
* (https://github.com/firefly-iii/data-importer). |
|||
* |
|||
* This program is free software: you can redistribute it and/or modify |
|||
* it under the terms of the GNU Affero General Public License as |
|||
* published by the Free Software Foundation, either version 3 of the |
|||
* License, or (at your option) any later version. |
|||
* |
|||
* This program is distributed in the hope that it will be useful, |
|||
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
* GNU Affero General Public License for more details. |
|||
* |
|||
* You should have received a copy of the GNU Affero General Public License |
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>. |
|||
*/ |
|||
|
|||
declare(strict_types=1); |
|||
|
|||
namespace App\Services\SimpleFIN\Response; |
|||
|
|||
use App\Services\Shared\Response\ResponseInterface as SharedResponseInterface; |
|||
use Psr\Http\Message\ResponseInterface; |
|||
|
|||
/** |
|||
* Class SimpleFINResponse |
|||
*/ |
|||
abstract class SimpleFINResponse implements SharedResponseInterface |
|||
{ |
|||
private array $data = []; |
|||
private int $statusCode; |
|||
private string $rawBody; |
|||
|
|||
public function __construct(ResponseInterface $response) |
|||
{ |
|||
$this->statusCode = $response->getStatusCode(); |
|||
$this->rawBody = (string) $response->getBody(); |
|||
|
|||
app('log')->debug(sprintf('SimpleFIN Response: HTTP %d', $this->statusCode)); |
|||
app('log')->debug(sprintf('SimpleFIN Response body: %s', $this->rawBody)); |
|||
|
|||
$this->parseResponse(); |
|||
} |
|||
|
|||
/** |
|||
* Check if the response has an error |
|||
*/ |
|||
public function hasError(): bool |
|||
{ |
|||
return $this->statusCode >= 400; |
|||
} |
|||
|
|||
/** |
|||
* Get the HTTP status code |
|||
*/ |
|||
public function getStatusCode(): int |
|||
{ |
|||
return $this->statusCode; |
|||
} |
|||
|
|||
public function getRawBody(): string |
|||
{ |
|||
return $this->rawBody; |
|||
} |
|||
|
|||
public function getData(): array |
|||
{ |
|||
return $this->data; |
|||
} |
|||
|
|||
protected function setData(array $data): void |
|||
{ |
|||
$this->data = $data; |
|||
} |
|||
|
|||
private function parseResponse(): void |
|||
{ |
|||
if (empty($this->rawBody)) { |
|||
app('log')->warning('SimpleFIN Response body is empty'); |
|||
$this->data = []; |
|||
return; |
|||
} |
|||
|
|||
$decoded = json_decode($this->rawBody, true); |
|||
|
|||
if (json_last_error() !== JSON_ERROR_NONE) { |
|||
app('log')->error(sprintf('SimpleFIN JSON decode error: %s', json_last_error_msg())); |
|||
$this->data = []; |
|||
return; |
|||
} |
|||
|
|||
if (!is_array($decoded)) { |
|||
app('log')->error('SimpleFIN Response is not a valid JSON array'); |
|||
$this->data = []; |
|||
return; |
|||
} |
|||
|
|||
$this->data = $decoded; |
|||
app('log')->debug('SimpleFIN Response parsed successfully'); |
|||
} |
|||
} |
@ -0,0 +1,82 @@ |
|||
<?php |
|||
|
|||
/* |
|||
* TransactionsResponse.php |
|||
* Copyright (c) 2021 james@firefly-iii.org |
|||
* |
|||
* This file is part of the Firefly III Data Importer |
|||
* (https://github.com/firefly-iii/data-importer). |
|||
* |
|||
* This program is free software: you can redistribute it and/or modify |
|||
* it under the terms of the GNU Affero General Public License as |
|||
* published by the Free Software Foundation, either version 3 of the |
|||
* License, or (at your option) any later version. |
|||
* |
|||
* This program is distributed in the hope that it will be useful, |
|||
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
* GNU Affero General Public License for more details. |
|||
* |
|||
* You should have received a copy of the GNU Affero General Public License |
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>. |
|||
*/ |
|||
|
|||
declare(strict_types=1); |
|||
|
|||
namespace App\Services\SimpleFIN\Response; |
|||
|
|||
use Psr\Http\Message\ResponseInterface; |
|||
|
|||
/** |
|||
* Class TransactionsResponse |
|||
*/ |
|||
class TransactionsResponse extends SimpleFINResponse |
|||
{ |
|||
private array $transactions = []; |
|||
|
|||
public function __construct(ResponseInterface $response) |
|||
{ |
|||
parent::__construct($response); |
|||
$this->parseTransactions(); |
|||
} |
|||
|
|||
public function getTransactions(): array |
|||
{ |
|||
return $this->transactions; |
|||
} |
|||
|
|||
public function getTransactionCount(): int |
|||
{ |
|||
return count($this->transactions); |
|||
} |
|||
|
|||
public function hasTransactions(): bool |
|||
{ |
|||
return !empty($this->transactions); |
|||
} |
|||
|
|||
private function parseTransactions(): void |
|||
{ |
|||
$data = $this->getData(); |
|||
|
|||
if (empty($data)) { |
|||
app('log')->warning('SimpleFIN TransactionsResponse: No data to parse'); |
|||
return; |
|||
} |
|||
|
|||
// SimpleFIN API returns transactions in the 'transactions' array within accounts
|
|||
if (isset($data['accounts']) && is_array($data['accounts'])) { |
|||
$transactions = []; |
|||
foreach ($data['accounts'] as $account) { |
|||
if (isset($account['transactions']) && is_array($account['transactions'])) { |
|||
$transactions = array_merge($transactions, $account['transactions']); |
|||
} |
|||
} |
|||
$this->transactions = $transactions; |
|||
app('log')->debug(sprintf('SimpleFIN TransactionsResponse: Parsed %d transactions', count($this->transactions))); |
|||
} else { |
|||
app('log')->warning('SimpleFIN TransactionsResponse: No accounts array found in response'); |
|||
$this->transactions = []; |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,341 @@ |
|||
<?php |
|||
|
|||
/* |
|||
* SimpleFINService.php |
|||
* Copyright (c) 2021 james@firefly-iii.org |
|||
* |
|||
* This file is part of the Firefly III Data Importer |
|||
* (https://github.com/firefly-iii/data-importer). |
|||
* |
|||
* This program is free software: you can redistribute it and/or modify |
|||
* it under the terms of the GNU Affero General Public License as |
|||
* published by the Free Software Foundation, either version 3 of the |
|||
* License, or (at your option) any later version. |
|||
* |
|||
* This program is distributed in the hope that it will be useful, |
|||
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
* GNU Affero General Public License for more details. |
|||
* |
|||
* You should have received a copy of the GNU Affero General Public License |
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>. |
|||
*/ |
|||
|
|||
declare(strict_types=1); |
|||
|
|||
namespace App\Services\SimpleFIN; |
|||
|
|||
use App\Exceptions\ImporterErrorException; |
|||
use App\Services\Session\Constants; |
|||
use App\Services\Shared\Configuration\Configuration; |
|||
use App\Services\SimpleFIN\Request\AccountsRequest; |
|||
use GuzzleHttp\Client; |
|||
use GuzzleHttp\Exception\ClientException; |
|||
use GuzzleHttp\Exception\GuzzleException; |
|||
use App\Services\SimpleFIN\Request\TransactionsRequest; |
|||
|
|||
/** |
|||
* Class SimpleFINService |
|||
*/ |
|||
class SimpleFINService |
|||
{ |
|||
/** |
|||
* @throws ImporterHttpException |
|||
* @throws ImporterErrorException |
|||
*/ |
|||
public function fetchAccountsAndInitialData(string $token, string $apiUrl, ?Configuration $configuration = null): array |
|||
{ |
|||
app('log')->debug(sprintf('Now at %s', __METHOD__)); |
|||
|
|||
// Check if token is a base64-encoded claim URL
|
|||
$actualApiUrl = $apiUrl; |
|||
$actualToken = $token; |
|||
|
|||
if ($this->isBase64ClaimUrl($token)) { |
|||
app('log')->debug('Token appears to be a base64-encoded claim URL, processing exchange'); |
|||
$actualApiUrl = $this->exchangeClaimUrlForAccessUrl($token); |
|||
$actualToken = ''; // Access URL contains auth info
|
|||
app('log')->debug(sprintf('Successfully exchanged claim URL for access URL: %s', $actualApiUrl)); |
|||
} else { |
|||
// Token is not a base64 claim URL, we need an API URL
|
|||
if (empty($apiUrl)) { |
|||
throw new ImporterErrorException('SimpleFIN API URL is required when token is not a base64-encoded claim URL'); |
|||
} |
|||
} |
|||
|
|||
app('log')->debug(sprintf('SimpleFIN fetching accounts from: %s', $actualApiUrl)); |
|||
|
|||
$request = new AccountsRequest(); |
|||
$request->setToken($actualToken); |
|||
$request->setApiUrl($actualApiUrl); |
|||
$request->setTimeOut($this->getTimeout()); |
|||
|
|||
// Set parameters to retrieve all transactions
|
|||
// Use a very old start-date (Jan 1, 2000) to ensure we get all historical transactions
|
|||
$parameters = [ |
|||
'start-date' => 946684800, // January 1, 2000 00:00:00 UTC
|
|||
'pending' => ($configuration && $configuration->getPendingTransactions()) ? 1 : 0, |
|||
]; |
|||
$request->setParameters($parameters); |
|||
|
|||
app('log')->debug('SimpleFIN requesting all transactions with parameters', $parameters); |
|||
|
|||
$response = $request->get(); |
|||
|
|||
if ($response->hasError()) { |
|||
throw new ImporterErrorException(sprintf('SimpleFIN API error: HTTP %d', $response->getStatusCode())); |
|||
} |
|||
|
|||
$accounts = $response->getAccounts(); |
|||
|
|||
if (empty($accounts)) { |
|||
app('log')->warning('SimpleFIN API returned no accounts'); |
|||
return []; |
|||
} |
|||
|
|||
app('log')->debug(sprintf('SimpleFIN fetched %d accounts successfully', count($accounts))); |
|||
|
|||
return $accounts; |
|||
} |
|||
|
|||
/** |
|||
* Extracts transactions for a specific account from the pre-fetched SimpleFIN accounts data. |
|||
* Applies date filtering if specified. |
|||
* |
|||
* @param array $allAccountsData Array of account data (associative arrays from SimpleFIN JSON). |
|||
* @param string $accountId The ID of the account for which to extract transactions. |
|||
* @param array|null $dateRange Optional date range for filtering transactions. Expects ['start' => 'Y-m-d', 'end' => 'Y-m-d']. |
|||
* @return array List of transaction data (associative arrays from SimpleFIN JSON). |
|||
*/ |
|||
public function fetchTransactions(array $allAccountsData, string $accountId, ?array $dateRange = null): array |
|||
{ |
|||
app('log')->debug(sprintf('Now at %s', __METHOD__)); |
|||
app('log')->debug(sprintf('SimpleFIN extracting transactions for account ID: "%s" from provided data structure.', $accountId)); |
|||
|
|||
$accountTransactions = []; |
|||
$accountFound = false; |
|||
|
|||
foreach ($allAccountsData as $accountData) { |
|||
// $accountData is now an associative array from the SimpleFIN JSON response.
|
|||
// Ensure $accountData is an array and has an 'id' key before accessing.
|
|||
if (is_array($accountData) && isset($accountData['id']) && is_string($accountData['id']) && $accountData['id'] === $accountId) { |
|||
$accountFound = true; |
|||
// Transactions are expected to be in $accountData['transactions'] as an array
|
|||
if (isset($accountData['transactions']) && is_array($accountData['transactions'])) { |
|||
$accountTransactions = $accountData['transactions']; |
|||
} else { |
|||
// If 'transactions' key is missing or not an array, treat as no transactions.
|
|||
$accountTransactions = []; |
|||
} |
|||
break; |
|||
} |
|||
} |
|||
|
|||
if (!$accountFound) { |
|||
app('log')->warning(sprintf('Account with ID "%s" not found in provided SimpleFIN accounts data.', $accountId)); |
|||
return []; |
|||
} |
|||
|
|||
if (empty($accountTransactions)) { |
|||
app('log')->debug(sprintf('No transactions found internally for account ID "%s".', $accountId)); |
|||
return []; |
|||
} |
|||
|
|||
// Apply date range filtering
|
|||
$filteredTransactions = []; |
|||
if (!empty($dateRange) && (isset($dateRange['start']) || isset($dateRange['end']))) { |
|||
$startDateTimestamp = null; |
|||
$endDateTimestamp = null; |
|||
|
|||
if (isset($dateRange['start']) && !empty($dateRange['start'])) { |
|||
try { |
|||
$startDateTimestamp = (new \DateTime($dateRange['start'], new \DateTimeZone('UTC')))->setTime(0,0,0)->getTimestamp(); |
|||
} catch (\Exception $e) { |
|||
app('log')->warning('Invalid start date format for SimpleFIN transaction filtering.', ['date' => $dateRange['start'], 'error' => $e->getMessage()]); |
|||
} |
|||
} |
|||
if (isset($dateRange['end']) && !empty($dateRange['end'])) { |
|||
try { |
|||
$endDateTimestamp = (new \DateTime($dateRange['end'], new \DateTimeZone('UTC')))->setTime(23, 59, 59)->getTimestamp(); |
|||
} catch (\Exception $e) { |
|||
app('log')->warning('Invalid end date format for SimpleFIN transaction filtering.', ['date' => $dateRange['end'], 'error' => $e->getMessage()]); |
|||
} |
|||
} |
|||
|
|||
foreach ($accountTransactions as $transaction) { |
|||
// $transaction is now an associative array from the SimpleFIN JSON response.
|
|||
// Ensure $transaction is an array and has a 'posted' key before accessing.
|
|||
if (!is_array($transaction) || !isset($transaction['posted']) || !is_numeric($transaction['posted'])) { |
|||
$transactionIdForLog = (is_array($transaction) && isset($transaction['id']) && is_string($transaction['id'])) ? $transaction['id'] : 'unknown'; |
|||
app('log')->warning('Transaction array missing, not an array, or has invalid "posted" field.', ['transaction_id' => $transactionIdForLog, 'transaction_data' => $transaction]); |
|||
continue; |
|||
} |
|||
$postedTimestamp = (int)$transaction['posted']; // Ensure it's an integer for comparison
|
|||
$passesFilter = true; |
|||
|
|||
if ($startDateTimestamp !== null && $postedTimestamp < $startDateTimestamp) { |
|||
$passesFilter = false; |
|||
} |
|||
if ($endDateTimestamp !== null && $postedTimestamp > $endDateTimestamp) { |
|||
$passesFilter = false; |
|||
} |
|||
|
|||
if ($passesFilter) { |
|||
$filteredTransactions[] = $transaction; |
|||
} |
|||
} |
|||
app('log')->debug(sprintf('Applied date filtering. Start: %s, End: %s. Original count: %d, Filtered count: %d', |
|||
$dateRange['start'] ?? 'N/A', $dateRange['end'] ?? 'N/A', count($accountTransactions), count($filteredTransactions) |
|||
)); |
|||
} else { |
|||
$filteredTransactions = $accountTransactions; |
|||
} |
|||
|
|||
app('log')->debug(sprintf('SimpleFIN extracted %d transactions for account ID "%s" (after potential filtering).', count($filteredTransactions), $accountId)); |
|||
return $filteredTransactions; |
|||
} |
|||
|
|||
/** |
|||
* Test connectivity to SimpleFIN API with given credentials |
|||
*/ |
|||
public function testConnection(string $token, string $apiUrl): bool |
|||
{ |
|||
app('log')->debug(sprintf('Now at %s', __METHOD__)); |
|||
|
|||
try { |
|||
$accounts = $this->fetchAccountsAndInitialData($token, $apiUrl); |
|||
app('log')->debug('SimpleFIN connection test successful'); |
|||
return true; |
|||
} catch (ImporterHttpException|ImporterErrorException $e) { |
|||
app('log')->error(sprintf('SimpleFIN connection test failed: %s', $e->getMessage())); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Get demo credentials for testing |
|||
*/ |
|||
public function getDemoCredentials(): array |
|||
{ |
|||
return [ |
|||
'token' => config('importer.simplefin.demo_token'), |
|||
'url' => config('importer.simplefin.demo_url'), |
|||
]; |
|||
} |
|||
|
|||
/** |
|||
* Check if a token is a base64-encoded claim URL |
|||
*/ |
|||
private function isBase64ClaimUrl(string $token): bool |
|||
{ |
|||
// Try to decode as base64
|
|||
$decoded = base64_decode($token, true); |
|||
|
|||
// Check if decode was successful and result looks like a URL
|
|||
if ($decoded === false) { |
|||
return false; |
|||
} |
|||
|
|||
// Check if decoded string looks like a SimpleFIN claim URL
|
|||
return (bool) preg_match('/^https?:\/\/.+\/simplefin\/claim\/.+$/', $decoded); |
|||
} |
|||
|
|||
/** |
|||
* Exchange a base64-encoded claim URL for an access URL |
|||
* |
|||
* @throws ImporterErrorException |
|||
*/ |
|||
private function exchangeClaimUrlForAccessUrl(string $base64ClaimUrl): string |
|||
{ |
|||
app('log')->debug('Exchanging SimpleFIN claim URL for access URL'); |
|||
|
|||
// Decode the base64 claim URL
|
|||
$claimUrl = base64_decode($base64ClaimUrl, true); |
|||
if ($claimUrl === false) { |
|||
throw new ImporterErrorException('Invalid base64 encoding in SimpleFIN token'); |
|||
} |
|||
|
|||
app('log')->debug(sprintf('Decoded claim URL: %s', $claimUrl)); |
|||
|
|||
try { |
|||
$client = new Client([ |
|||
'timeout' => $this->getTimeout(), |
|||
'verify' => config('importer.connection.verify'), |
|||
]); |
|||
|
|||
// Make POST request to claim URL with empty body
|
|||
// Use user-provided bridge URL as Origin header for CORS
|
|||
$origin = session()->get(Constants::SIMPLEFIN_BRIDGE_URL); |
|||
if (empty($origin)) { |
|||
throw new ImporterErrorException('SimpleFIN bridge URL not found in session. Please provide a valid bridge URL.'); |
|||
} |
|||
app('log')->debug(sprintf('SimpleFIN using user-provided Origin: %s', $origin)); |
|||
|
|||
$response = $client->post($claimUrl, [ |
|||
'headers' => [ |
|||
'Content-Length' => '0', |
|||
'Origin' => $origin, |
|||
], |
|||
]); |
|||
|
|||
$accessUrl = (string) $response->getBody(); |
|||
|
|||
if (empty($accessUrl)) { |
|||
throw new ImporterErrorException('Empty access URL returned from SimpleFIN claim exchange'); |
|||
} |
|||
|
|||
// Validate access URL format
|
|||
if (!filter_var($accessUrl, FILTER_VALIDATE_URL)) { |
|||
throw new ImporterErrorException('Invalid access URL format returned from SimpleFIN claim exchange'); |
|||
} |
|||
|
|||
app('log')->debug('Successfully exchanged claim URL for access URL'); |
|||
return $accessUrl; |
|||
|
|||
} catch (ClientException $e) { |
|||
$statusCode = $e->getResponse()->getStatusCode(); |
|||
$responseBody = (string) $e->getResponse()->getBody(); |
|||
|
|||
app('log')->error(sprintf('SimpleFIN claim URL exchange failed with HTTP %d: %s', $statusCode, $e->getMessage())); |
|||
app('log')->error(sprintf('SimpleFIN 403 response body: %s', $responseBody)); |
|||
|
|||
if ($statusCode === 403) { |
|||
// Log the actual response for debugging
|
|||
app('log')->error(sprintf('DETAILED 403 ERROR - URL: %s, Response: %s', $claimUrl, $responseBody)); |
|||
throw new ImporterErrorException(sprintf('SimpleFIN claim URL exchange failed (403 Forbidden): %s', $responseBody ?: 'No response body available')); |
|||
} |
|||
|
|||
throw new ImporterErrorException(sprintf('Failed to exchange SimpleFIN claim URL: HTTP %d error - %s', $statusCode, $responseBody ?: $e->getMessage())); |
|||
} catch (GuzzleException $e) { |
|||
app('log')->error(sprintf('Failed to exchange SimpleFIN claim URL: %s', $e->getMessage())); |
|||
throw new ImporterErrorException(sprintf('Failed to exchange SimpleFIN claim URL: %s', $e->getMessage())); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Validate SimpleFIN credentials format |
|||
*/ |
|||
public function validateCredentials(string $token, string $apiUrl): array |
|||
{ |
|||
$errors = []; |
|||
|
|||
if (empty($token)) { |
|||
$errors[] = 'SimpleFIN token is required'; |
|||
} |
|||
|
|||
if (empty($apiUrl)) { |
|||
$errors[] = 'SimpleFIN bridge URL is required'; |
|||
} elseif (!filter_var($apiUrl, FILTER_VALIDATE_URL)) { |
|||
$errors[] = 'SimpleFIN bridge URL must be a valid URL'; |
|||
} elseif (!str_starts_with($apiUrl, 'https://')) { |
|||
$errors[] = 'SimpleFIN bridge URL must use HTTPS'; |
|||
} |
|||
|
|||
return $errors; |
|||
} |
|||
|
|||
private function getTimeout(): float |
|||
{ |
|||
return (float) config('importer.simplefin.timeout', 30.0); |
|||
} |
|||
} |
@ -0,0 +1,511 @@ |
|||
<?php |
|||
|
|||
/* |
|||
* ConfigurationContractValidator.php |
|||
* Copyright (c) 2021 james@firefly-iii.org |
|||
* |
|||
* This file is part of the Firefly III Data Importer |
|||
* (https://github.com/firefly-iii/data-importer). |
|||
* |
|||
* This program is free software: you can redistribute it and/or modify |
|||
* it under the terms of the GNU Affero General Public License as |
|||
* published by the Free Software Foundation, either version 3 of the |
|||
* License, or (at your option) any later version. |
|||
* |
|||
* This program is distributed in the hope that it will be useful, |
|||
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
* GNU Affero General Public License for more details. |
|||
* |
|||
* You should have received a copy of the GNU Affero General Public License |
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>. |
|||
*/ |
|||
|
|||
declare(strict_types=1); |
|||
|
|||
namespace App\Services\SimpleFIN\Validation; |
|||
|
|||
use App\Exceptions\ImporterErrorException; |
|||
use App\Services\Shared\Authentication\SecretManager; |
|||
use App\Services\Shared\Configuration\Configuration; |
|||
use GrumpyDictator\FFIIIApiSupport\Exceptions\ApiHttpException; |
|||
use GrumpyDictator\FFIIIApiSupport\Model\Account; |
|||
use GrumpyDictator\FFIIIApiSupport\Request\GetAccountsRequest; |
|||
use GrumpyDictator\FFIIIApiSupport\Response\GetAccountsResponse; |
|||
use Illuminate\Support\Facades\Log; |
|||
|
|||
/** |
|||
* Class ConfigurationContractValidator |
|||
* |
|||
* Validates the data contract between SimpleFIN configuration and conversion steps |
|||
*/ |
|||
class ConfigurationContractValidator |
|||
{ |
|||
private array $errors = []; |
|||
private array $warnings = []; |
|||
private array $existingAccounts = []; |
|||
|
|||
private const REQUIRED_ACCOUNT_TYPES = ['asset', 'liability', 'expense', 'revenue']; |
|||
private const VALID_ACCOUNT_ROLES = ['defaultAsset', 'sharedAsset', 'savingAsset', 'ccAsset', 'cashWalletAsset']; |
|||
private const VALID_LIABILITY_TYPES = ['debt', 'loan', 'mortgage']; |
|||
private const VALID_LIABILITY_DIRECTIONS = ['credit', 'debit']; |
|||
|
|||
public function validateConfigurationContract(Configuration $configuration): ValidationResult |
|||
{ |
|||
$this->errors = []; |
|||
$this->warnings = []; |
|||
|
|||
Log::debug('Starting SimpleFIN configuration contract validation'); |
|||
|
|||
// Load existing accounts first for duplicate validation
|
|||
$this->loadExistingAccounts(); |
|||
|
|||
// Core validation
|
|||
$this->validateSimpleFINFlow($configuration); |
|||
$this->validateSessionData($configuration); |
|||
$this->validateAccountMappings($configuration); |
|||
$this->validateNewAccountConfigurations($configuration); |
|||
$this->validateImportSelections($configuration); |
|||
|
|||
return new ValidationResult( |
|||
count($this->errors) === 0, |
|||
$this->errors, |
|||
$this->warnings |
|||
); |
|||
} |
|||
|
|||
private function validateSimpleFINFlow(Configuration $configuration): void |
|||
{ |
|||
if ('simplefin' !== $configuration->getFlow()) { |
|||
$this->addError('configuration.flow', 'Configuration must be SimpleFIN flow', $configuration->getFlow()); |
|||
} |
|||
} |
|||
|
|||
private function validateSessionData(Configuration $configuration): void |
|||
{ |
|||
// Check for SimpleFIN accounts data in session
|
|||
$sessionData = session()->get('simplefin_accounts_data'); |
|||
if (empty($sessionData) || !is_array($sessionData)) { |
|||
$this->addError('session.simplefin_accounts_data', 'SimpleFIN accounts data missing from session'); |
|||
return; |
|||
} |
|||
|
|||
// Validate SimpleFIN account structure
|
|||
foreach ($sessionData as $index => $account) { |
|||
$this->validateSimpleFINAccount($account, $index); |
|||
} |
|||
} |
|||
|
|||
private function validateSimpleFINAccount(array $account, int $index): void |
|||
{ |
|||
$requiredFields = ['id', 'name', 'currency', 'balance', 'balance-date', 'org']; |
|||
|
|||
foreach ($requiredFields as $field) { |
|||
if (!array_key_exists($field, $account)) { |
|||
$this->addError("session.simplefin_accounts_data.{$index}.{$field}", "Required field '{$field}' missing from SimpleFIN account"); |
|||
} |
|||
} |
|||
|
|||
// Validate currency format (should be 3-letter ISO code)
|
|||
if (isset($account['currency']) && !preg_match('/^[A-Z]{3}$/', $account['currency'])) { |
|||
$this->addWarning("session.simplefin_accounts_data.{$index}.currency", 'Currency should be 3-letter ISO code', $account['currency']); |
|||
} |
|||
|
|||
// Validate balance is numeric
|
|||
if (isset($account['balance']) && !is_numeric($account['balance'])) { |
|||
$this->addError("session.simplefin_accounts_data.{$index}.balance", 'Balance must be numeric', $account['balance']); |
|||
} |
|||
|
|||
// Validate balance-date is unix timestamp
|
|||
if (isset($account['balance-date']) && (!is_numeric($account['balance-date']) || $account['balance-date'] < 0)) { |
|||
$this->addError("session.simplefin_accounts_data.{$index}.balance-date", 'Balance date must be valid unix timestamp', $account['balance-date']); |
|||
} |
|||
} |
|||
|
|||
private function validateAccountMappings(Configuration $configuration): void |
|||
{ |
|||
$accounts = $configuration->getAccounts(); |
|||
if (empty($accounts)) { |
|||
$this->addError('configuration.accounts', 'Account mappings cannot be empty'); |
|||
return; |
|||
} |
|||
|
|||
foreach ($accounts as $simplefinId => $fireflyId) { |
|||
// Validate SimpleFIN ID format
|
|||
if (!is_string($simplefinId) || empty($simplefinId)) { |
|||
$this->addError('configuration.accounts.key', 'SimpleFIN account ID must be non-empty string', $simplefinId); |
|||
} |
|||
|
|||
// Validate Firefly III ID (0 means create new, positive integer means existing account)
|
|||
if (!is_int($fireflyId) || $fireflyId < 0) { |
|||
$this->addError("configuration.accounts.{$simplefinId}", 'Firefly III account ID must be non-negative integer', $fireflyId); |
|||
} |
|||
} |
|||
} |
|||
|
|||
private function validateNewAccountConfigurations(Configuration $configuration): void |
|||
{ |
|||
$newAccounts = $configuration->getNewAccounts(); |
|||
$accounts = $configuration->getAccounts(); |
|||
|
|||
foreach ($accounts as $simplefinId => $fireflyId) { |
|||
if (0 === $fireflyId) { |
|||
// This account should be created, validate its configuration
|
|||
if (!isset($newAccounts[$simplefinId])) { |
|||
$this->addError("configuration.new_account.{$simplefinId}", 'New account configuration missing for account marked for creation'); |
|||
continue; |
|||
} |
|||
|
|||
$this->validateNewAccountConfig($newAccounts[$simplefinId], $simplefinId); |
|||
} |
|||
} |
|||
|
|||
// Check for orphaned new account configurations
|
|||
foreach ($newAccounts as $simplefinId => $config) { |
|||
if (!isset($accounts[$simplefinId]) || 0 !== $accounts[$simplefinId]) { |
|||
$this->addWarning("configuration.new_account.{$simplefinId}", 'New account configuration exists but account not marked for creation'); |
|||
} |
|||
} |
|||
} |
|||
|
|||
private function validateNewAccountConfig(array $config, string $simplefinId): void |
|||
{ |
|||
$requiredFields = ['name', 'type', 'currency', 'opening_balance']; |
|||
|
|||
foreach ($requiredFields as $field) { |
|||
if (!isset($config[$field]) || (is_string($config[$field]) && trim($config[$field]) === '')) { |
|||
$this->addError("configuration.new_account.{$simplefinId}.{$field}", "Required field '{$field}' missing or empty"); |
|||
} |
|||
} |
|||
|
|||
// Validate account type
|
|||
if (isset($config['type']) && !in_array($config['type'], self::REQUIRED_ACCOUNT_TYPES, true)) { |
|||
$this->addError("configuration.new_account.{$simplefinId}.type", 'Invalid account type', $config['type']); |
|||
} |
|||
|
|||
// Validate account role for asset accounts
|
|||
if (isset($config['type']) && 'asset' === $config['type'] && isset($config['account_role'])) { |
|||
if (!in_array($config['account_role'], self::VALID_ACCOUNT_ROLES, true)) { |
|||
$this->addError("configuration.new_account.{$simplefinId}.account_role", 'Invalid account role for asset account', $config['account_role']); |
|||
} |
|||
} |
|||
|
|||
// Validate liability-specific fields
|
|||
if (isset($config['type']) && 'liability' === $config['type']) { |
|||
if (!isset($config['liability_type']) || !in_array($config['liability_type'], self::VALID_LIABILITY_TYPES, true)) { |
|||
$this->addError("configuration.new_account.{$simplefinId}.liability_type", 'Liability type required and must be valid', $config['liability_type'] ?? null); |
|||
} |
|||
|
|||
if (!isset($config['liability_direction']) || !in_array($config['liability_direction'], self::VALID_LIABILITY_DIRECTIONS, true)) { |
|||
$this->addError("configuration.new_account.{$simplefinId}.liability_direction", 'Liability direction required and must be valid', $config['liability_direction'] ?? null); |
|||
} |
|||
} |
|||
|
|||
// Validate currency format
|
|||
if (isset($config['currency']) && !preg_match('/^[A-Z]{3}$/', $config['currency'])) { |
|||
$this->addError("configuration.new_account.{$simplefinId}.currency", 'Currency must be 3-letter ISO code', $config['currency']); |
|||
} |
|||
|
|||
// Validate opening balance is numeric
|
|||
if (isset($config['opening_balance']) && !is_numeric($config['opening_balance'])) { |
|||
$this->addError("configuration.new_account.{$simplefinId}.opening_balance", 'Opening balance must be numeric', $config['opening_balance']); |
|||
} |
|||
|
|||
// Validate opening balance date format if provided
|
|||
if (isset($config['opening_balance_date']) && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $config['opening_balance_date'])) { |
|||
$this->addError("configuration.new_account.{$simplefinId}.opening_balance_date", 'Opening balance date must be YYYY-MM-DD format', $config['opening_balance_date']); |
|||
} |
|||
|
|||
// Validate account name length and content
|
|||
if (isset($config['name'])) { |
|||
if (strlen($config['name']) > 255) { |
|||
$this->addError("configuration.new_account.{$simplefinId}.name", 'Account name too long (max 255 characters)'); |
|||
} |
|||
if (strlen(trim($config['name'])) === 0) { |
|||
$this->addError("configuration.new_account.{$simplefinId}.name", 'Account name cannot be empty'); |
|||
} |
|||
} |
|||
|
|||
// Validate no duplicate account name/type combinations
|
|||
$this->validateNoDuplicateAccount($config, $simplefinId); |
|||
} |
|||
|
|||
private function validateImportSelections(Configuration $configuration): void |
|||
{ |
|||
$doImport = session()->get('do_import', []); |
|||
if (empty($doImport)) { |
|||
$this->addError('session.do_import', 'No accounts selected for import'); |
|||
return; |
|||
} |
|||
|
|||
$accounts = $configuration->getAccounts(); |
|||
foreach ($doImport as $accountId => $selected) { |
|||
if ('1' !== $selected) { |
|||
continue; // Not selected for import
|
|||
} |
|||
|
|||
if (!isset($accounts[$accountId])) { |
|||
$this->addError('session.do_import', 'Account selected for import but not in account mappings', $accountId); |
|||
} |
|||
} |
|||
|
|||
// Validate that all mapped accounts are either selected for import or explicitly excluded
|
|||
foreach ($accounts as $simplefinId => $fireflyId) { |
|||
if (!isset($doImport[$simplefinId])) { |
|||
$this->addWarning('session.do_import', 'Account mapped but no import selection specified', $simplefinId); |
|||
} |
|||
} |
|||
} |
|||
|
|||
private function addError(string $field, string $message, $value = null): void |
|||
{ |
|||
$this->errors[] = [ |
|||
'field' => $field, |
|||
'message' => $message, |
|||
'value' => $value, |
|||
]; |
|||
|
|||
Log::error("Configuration contract validation error: {$field} - {$message}", [ |
|||
'value' => $value, |
|||
]); |
|||
} |
|||
|
|||
private function addWarning(string $field, string $message, $value = null): void |
|||
{ |
|||
$this->warnings[] = [ |
|||
'field' => $field, |
|||
'message' => $message, |
|||
'value' => $value, |
|||
]; |
|||
|
|||
Log::warning("Configuration contract validation warning: {$field} - {$message}", [ |
|||
'value' => $value, |
|||
]); |
|||
} |
|||
|
|||
private function loadExistingAccounts(): void |
|||
{ |
|||
try { |
|||
$url = SecretManager::getBaseUrl(); |
|||
$token = SecretManager::getAccessToken(); |
|||
$request = new GetAccountsRequest($url, $token); |
|||
$request->setType(GetAccountsRequest::ALL); |
|||
$request->setVerify(config('importer.connection.verify')); |
|||
$request->setTimeOut(config('importer.connection.timeout')); |
|||
|
|||
$response = $request->get(); |
|||
if ($response instanceof GetAccountsResponse) { |
|||
$this->existingAccounts = iterator_to_array($response); |
|||
Log::debug(sprintf('Loaded %d existing Firefly III accounts for duplicate validation', count($this->existingAccounts))); |
|||
} |
|||
} catch (ApiHttpException $e) { |
|||
Log::warning(sprintf('Could not load existing accounts for duplicate validation: %s', $e->getMessage())); |
|||
// Don't fail validation entirely, just log the warning
|
|||
$this->existingAccounts = []; |
|||
} |
|||
} |
|||
|
|||
private function validateNoDuplicateAccount(array $config, string $simplefinId): void |
|||
{ |
|||
if (!isset($config['name']) || !isset($config['type'])) { |
|||
return; // Cannot validate without name and type
|
|||
} |
|||
|
|||
$accountName = trim($config['name']); |
|||
$accountType = $config['type']; |
|||
|
|||
// Check against existing accounts
|
|||
foreach ($this->existingAccounts as $existingAccount) { |
|||
if (!$existingAccount instanceof Account) { |
|||
continue; |
|||
} |
|||
|
|||
// Check for exact name match (case-insensitive) and type match
|
|||
if (strtolower($existingAccount->name) === strtolower($accountName) && |
|||
$existingAccount->type === $accountType) { |
|||
$this->addError( |
|||
"configuration.new_account.{$simplefinId}.name", |
|||
sprintf('Account "%s" of type "%s" already exists. Cannot create duplicate account.', $accountName, $accountType), |
|||
$accountName |
|||
); |
|||
return; |
|||
} |
|||
} |
|||
|
|||
Log::debug(sprintf('No duplicate found for account "%s" of type "%s"', $accountName, $accountType)); |
|||
} |
|||
|
|||
/** |
|||
* Check if a single account name and type combination already exists |
|||
* Used for AJAX duplicate checking during account creation |
|||
* |
|||
* @param string $accountName |
|||
* @param string $accountType |
|||
* @return bool |
|||
*/ |
|||
public function checkSingleAccountDuplicate(string $accountName, string $accountType): bool |
|||
{ |
|||
$accountName = trim($accountName); |
|||
$accountType = trim($accountType); |
|||
|
|||
// Empty name or type cannot be duplicate
|
|||
if (empty($accountName) || empty($accountType)) { |
|||
Log::debug('DUPLICATE_CHECK: Empty name or type provided'); |
|||
return false; |
|||
} |
|||
|
|||
// Load existing accounts if not already loaded
|
|||
if (empty($this->existingAccounts)) { |
|||
Log::debug('DUPLICATE_CHECK: Loading existing accounts for validation'); |
|||
$this->loadExistingAccounts(); |
|||
} |
|||
|
|||
// If loading failed, return false to avoid blocking user (graceful degradation)
|
|||
if (empty($this->existingAccounts)) { |
|||
Log::warning('DUPLICATE_CHECK: No existing accounts loaded, cannot validate duplicates'); |
|||
return false; |
|||
} |
|||
|
|||
// Check against existing accounts
|
|||
foreach ($this->existingAccounts as $existingAccount) { |
|||
if (!$existingAccount instanceof Account) { |
|||
continue; |
|||
} |
|||
|
|||
// Check for exact name match (case-insensitive) and type match
|
|||
if (strtolower($existingAccount->name) === strtolower($accountName) && |
|||
$existingAccount->type === $accountType) { |
|||
Log::debug('DUPLICATE_CHECK: Found duplicate account', [ |
|||
'requested_name' => $accountName, |
|||
'requested_type' => $accountType, |
|||
'existing_name' => $existingAccount->name, |
|||
'existing_type' => $existingAccount->type |
|||
]); |
|||
return true; |
|||
} |
|||
} |
|||
|
|||
Log::debug('DUPLICATE_CHECK: No duplicate found', [ |
|||
'requested_name' => $accountName, |
|||
'requested_type' => $accountType, |
|||
'checked_accounts' => count($this->existingAccounts) |
|||
]); |
|||
return false; |
|||
} |
|||
|
|||
public function validateFormFieldStructure(array $formData): ValidationResult |
|||
{ |
|||
$this->errors = []; |
|||
$this->warnings = []; |
|||
|
|||
Log::debug('Validating SimpleFIN form field structure'); |
|||
|
|||
// Validate expected form structure
|
|||
$expectedStructure = [ |
|||
'do_import' => 'array', |
|||
'accounts' => 'array', |
|||
'new_account' => 'array' |
|||
]; |
|||
|
|||
foreach ($expectedStructure as $field => $expectedType) { |
|||
if (!isset($formData[$field])) { |
|||
$this->addError("form.{$field}", "Required form field '{$field}' missing"); |
|||
continue; |
|||
} |
|||
|
|||
if ('array' === $expectedType && !is_array($formData[$field])) { |
|||
$this->addError("form.{$field}", "Form field '{$field}' must be array"); |
|||
} |
|||
} |
|||
|
|||
// Validate new_account structure follows expected pattern
|
|||
if (isset($formData['new_account']) && is_array($formData['new_account'])) { |
|||
foreach ($formData['new_account'] as $accountId => $accountData) { |
|||
if (!is_array($accountData)) { |
|||
$this->addError("form.new_account.{$accountId}", 'Account data must be array'); |
|||
continue; |
|||
} |
|||
|
|||
$this->validateFormAccountData($accountData, $accountId); |
|||
} |
|||
} |
|||
|
|||
return new ValidationResult( |
|||
count($this->errors) === 0, |
|||
$this->errors, |
|||
$this->warnings |
|||
); |
|||
} |
|||
|
|||
private function validateFormAccountData(array $accountData, string $accountId): void |
|||
{ |
|||
$expectedFields = ['name', 'type', 'currency', 'opening_balance']; |
|||
|
|||
foreach ($expectedFields as $field) { |
|||
if (!isset($accountData[$field])) { |
|||
$this->addError("form.new_account.{$accountId}.{$field}", "Required form field '{$field}' missing"); |
|||
} |
|||
} |
|||
|
|||
// Check for properly structured field names
|
|||
if (isset($accountData['account_role']) && 'asset' !== ($accountData['type'] ?? '')) { |
|||
$this->addWarning("form.new_account.{$accountId}.account_role", 'Account role specified for non-asset account'); |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Validation result container |
|||
*/ |
|||
class ValidationResult |
|||
{ |
|||
public function __construct( |
|||
private bool $isValid, |
|||
private array $errors = [], |
|||
private array $warnings = [] |
|||
) {} |
|||
|
|||
public function isValid(): bool |
|||
{ |
|||
return $this->isValid; |
|||
} |
|||
|
|||
public function getErrors(): array |
|||
{ |
|||
return $this->errors; |
|||
} |
|||
|
|||
public function getWarnings(): array |
|||
{ |
|||
return $this->warnings; |
|||
} |
|||
|
|||
public function hasErrors(): bool |
|||
{ |
|||
return count($this->errors) > 0; |
|||
} |
|||
|
|||
public function hasWarnings(): bool |
|||
{ |
|||
return count($this->warnings) > 0; |
|||
} |
|||
|
|||
public function getErrorMessages(): array |
|||
{ |
|||
return array_map(fn($error) => $error['message'], $this->errors); |
|||
} |
|||
|
|||
public function getWarningMessages(): array |
|||
{ |
|||
return array_map(fn($warning) => $warning['message'], $this->warnings); |
|||
} |
|||
|
|||
public function toArray(): array |
|||
{ |
|||
return [ |
|||
'valid' => $this->isValid, |
|||
'errors' => $this->errors, |
|||
'warnings' => $this->warnings, |
|||
]; |
|||
} |
|||
} |
@ -0,0 +1,16 @@ |
|||
diff a/app/Services/Spectre/Request/Request.php b/app/Services/Spectre/Request/Request.php (rejected hunks) |
|||
@@ -101,10 +101,10 @@ abstract class Request |
|||
$fullUrl, |
|||
[ |
|||
'headers' => [ |
|||
- 'Accept' => 'application/json', |
|||
- 'Content-Type' => 'application/json', |
|||
- 'App-id' => $this->getAppId(), |
|||
- 'Secret' => $this->getSecret(), |
|||
+ 'Accept' => 'application/json', |
|||
+ 'Content-Type' => 'application/json', |
|||
+ 'App-id' => $this->getAppId(), |
|||
+ 'Secret' => $this->getSecret(), |
|||
'User-Agent' => sprintf('Firefly III Spectre importer / %s / %s', config('importer.version'), config('auth.line_c')), |
|||
], |
|||
] |
@ -0,0 +1,12 @@ |
|||
diff a/app/Support/Http/CollectsAccounts.php b/app/Support/Http/CollectsAccounts.php (rejected hunks) |
|||
@@ -115,8 +115,8 @@ trait CollectsAccounts |
|||
Log::debug(sprintf('Now in collectAccounts("%s")', $type)); |
|||
|
|||
// send account list request to Firefly III. |
|||
- $token = SecretManager::getAccessToken(); |
|||
- $url = SecretManager::getBaseUrl(); |
|||
+ $token = SecretManager::getAccessToken(); |
|||
+ $url = SecretManager::getBaseUrl(); |
|||
$request = new GetAccountsRequest($url, $token); |
|||
$request->setType($type); |
|||
$request->setVerify(config('importer.connection.verify')); |
@ -0,0 +1,113 @@ |
|||
<?php |
|||
|
|||
/* |
|||
* simplefin.php |
|||
* Copyright (c) 2021 james@firefly-iii.org |
|||
* |
|||
* This file is part of the Firefly III Data Importer |
|||
* (https://github.com/firefly-iii/data-importer). |
|||
* |
|||
* This program is free software: you can redistribute it and/or modify |
|||
* it under the terms of the GNU Affero General Public License as |
|||
* published by the Free Software Foundation, either version 3 of the |
|||
* License, or (at your option) any later version. |
|||
* |
|||
* This program is distributed in the hope that it will be useful, |
|||
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
* GNU Affero General Public License for more details. |
|||
* |
|||
* You should have received a copy of the GNU Affero General Public License |
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>. |
|||
*/ |
|||
|
|||
declare(strict_types=1); |
|||
|
|||
return [ |
|||
/* |
|||
|-------------------------------------------------------------------------- |
|||
| SimpleFIN Configuration |
|||
|-------------------------------------------------------------------------- |
|||
| |
|||
| Configuration for SimpleFIN integration |
|||
| |
|||
*/ |
|||
|
|||
/* |
|||
|-------------------------------------------------------------------------- |
|||
| Demo Configuration |
|||
|-------------------------------------------------------------------------- |
|||
*/ |
|||
'demo_url' => env('SIMPLEFIN_DEMO_URL', ''), |
|||
'demo_token' => env('SIMPLEFIN_DEMO_TOKEN', ''), |
|||
|
|||
/* |
|||
|-------------------------------------------------------------------------- |
|||
| Connection Settings |
|||
|-------------------------------------------------------------------------- |
|||
*/ |
|||
'connection_timeout' => env('SIMPLEFIN_CONNECTION_TIMEOUT', 30), |
|||
'request_timeout' => env('SIMPLEFIN_REQUEST_TIMEOUT', 60), |
|||
|
|||
/* |
|||
|-------------------------------------------------------------------------- |
|||
| Transaction Processing |
|||
|-------------------------------------------------------------------------- |
|||
*/ |
|||
'unique_column_options' => [ |
|||
'id' => 'Transaction ID', |
|||
'account_id' => 'Account ID', |
|||
'posted' => 'Posted Date', |
|||
'amount' => 'Amount', |
|||
'description' => 'Description', |
|||
], |
|||
|
|||
/* |
|||
|-------------------------------------------------------------------------- |
|||
| Account Mapping |
|||
|-------------------------------------------------------------------------- |
|||
*/ |
|||
'account_types' => [ |
|||
'checking' => 'asset', |
|||
'savings' => 'asset', |
|||
'credit' => 'debt', // Credit cards are debt accounts
|
|||
'loan' => 'loan', // Loans use specific loan account type
|
|||
'mortgage' => 'mortgage', // Mortgages use specific mortgage account type
|
|||
'investment' => 'asset', |
|||
], |
|||
|
|||
/* |
|||
|-------------------------------------------------------------------------- |
|||
| Import Settings |
|||
|-------------------------------------------------------------------------- |
|||
*/ |
|||
'max_transactions' => env('SIMPLEFIN_MAX_TRANSACTIONS', 10000), |
|||
'default_date_range' => env('SIMPLEFIN_DEFAULT_DATE_RANGE', 90), // days
|
|||
'enable_caching' => env('SIMPLEFIN_ENABLE_CACHING', true), |
|||
'cache_duration' => env('SIMPLEFIN_CACHE_DURATION', 3600), // seconds
|
|||
|
|||
/* |
|||
|-------------------------------------------------------------------------- |
|||
| Expense Account Assignment |
|||
|-------------------------------------------------------------------------- |
|||
*/ |
|||
'smart_expense_matching' => env('SIMPLEFIN_SMART_EXPENSE_MATCHING', true), |
|||
'expense_matching_threshold' => env('SIMPLEFIN_EXPENSE_MATCHING_THRESHOLD', 0.7), // Restored default for better clustering
|
|||
'auto_create_expense_accounts' => env('SIMPLEFIN_AUTO_CREATE_EXPENSE_ACCOUNTS', true), |
|||
|
|||
/* |
|||
|-------------------------------------------------------------------------- |
|||
| Transaction Clustering (Clean Instances) |
|||
|-------------------------------------------------------------------------- |
|||
*/ |
|||
'enable_transaction_clustering' => env('SIMPLEFIN_ENABLE_TRANSACTION_CLUSTERING', true), |
|||
'clustering_similarity_threshold' => env('SIMPLEFIN_CLUSTERING_SIMILARITY_THRESHOLD', 0.7), |
|||
|
|||
/* |
|||
|-------------------------------------------------------------------------- |
|||
| Error Handling |
|||
|-------------------------------------------------------------------------- |
|||
*/ |
|||
'retry_attempts' => env('SIMPLEFIN_RETRY_ATTEMPTS', 3), |
|||
'retry_delay' => env('SIMPLEFIN_RETRY_DELAY', 1), // seconds
|
|||
]; |
@ -0,0 +1,612 @@ |
|||
<!-- Create Account Widget - Hidden by default, shown when "Create New Account" is selected --> |
|||
<div class="collapse mt-1" id="create-account-widget-{{ $account['import_account']->id }}"> |
|||
<div class="card"> |
|||
<div class="card-body"> |
|||
<!-- Account Name --> |
|||
<div class="row mb-3"> |
|||
<div class="col-12"> |
|||
<div class="d-flex justify-content-between align-items-center"> |
|||
<div class="d-flex align-items-center flex-grow-1"> |
|||
<label class="form-label mb-0 me-3" style="min-width: 60px;">Name:</label> |
|||
<span id="account-name-display-{{ $account['import_account']->id }}" class="fw-bold">{{ $account['import_account']->name ?? 'New Account' }}</span> |
|||
<input type="text" |
|||
class="form-control form-control-sm d-none" |
|||
id="account-name-edit-{{ $account['import_account']->id }}" |
|||
name="new_account[{{ str_replace(' ', '_', $account['import_account']->id) }}][name]" |
|||
value="{{ $account['import_account']->name ?? 'New Account' }}" |
|||
style="display: inline-block; width: auto; min-width: 200px;"> |
|||
</div> |
|||
<div class="btn-group btn-group-sm" role="group"> |
|||
<button type="button" |
|||
class="btn btn-outline-secondary btn-sm" |
|||
id="edit-name-btn-{{ $account['import_account']->id }}" |
|||
onclick="toggleAccountNameEdit('{{ $account['import_account']->id }}', true)" |
|||
title="Edit account name"> |
|||
<i class="fas fa-pencil-alt"></i> |
|||
</button> |
|||
<button type="button" |
|||
class="btn btn-success btn-sm d-none" |
|||
id="commit-name-btn-{{ $account['import_account']->id }}" |
|||
onclick="commitAccountNameEdit('{{ $account['import_account']->id }}')" |
|||
title="Save account name"> |
|||
<i class="fas fa-check"></i> |
|||
</button> |
|||
<button type="button" |
|||
class="btn btn-outline-secondary btn-sm d-none" |
|||
id="cancel-name-btn-{{ $account['import_account']->id }}" |
|||
onclick="toggleAccountNameEdit('{{ $account['import_account']->id }}', false)" |
|||
title="Cancel edit"> |
|||
<i class="fas fa-times"></i> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<!-- Account Type --> |
|||
<div class="row mb-3"> |
|||
<div class="col-12"> |
|||
<div class="d-flex align-items-center"> |
|||
<label class="form-label mb-0 me-3" style="min-width: 60px;">Type:</label> |
|||
<select class="form-control" |
|||
id="new-account-type-{{ $account['import_account']->id }}" |
|||
name="new_account[{{ str_replace(' ', '_', $account['import_account']->id) }}][type]" |
|||
onchange="updateAccountTypeVisibility('{{ $account['import_account']->id }}')" |
|||
required> |
|||
@php |
|||
// Simplified account type inference - default all to Asset Account
|
|||
$inferredType = 'asset'; // All accounts default to Asset Account
|
|||
$accountName = strtolower($account['import_account']->name ?? ''); |
|||
@endphp |
|||
|
|||
<option value="asset" @if($inferredType === 'asset') selected @endif>Asset Account</option> |
|||
<option value="liability" @if($inferredType === 'liability') selected @endif>Liability Account</option> |
|||
<option value="expense" @if($inferredType === 'expense') selected @endif>Expense Account</option> |
|||
<option value="revenue" @if($inferredType === 'revenue') selected @endif>Revenue Account</option> |
|||
</select> |
|||
<div class="invalid-feedback"> |
|||
Please select an account type. |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Account Role Section (Asset accounts only) --> |
|||
<div class="row mb-3" id="account-role-section-{{ $account['import_account']->id }}" style="@if($inferredType === 'asset') display: block; @else display: none; @endif"> |
|||
<div class="col-12"> |
|||
<div class="d-flex align-items-center"> |
|||
<label class="form-label mb-0 me-3" style="min-width: 60px;">Role:</label> |
|||
<select class="form-control" |
|||
id="new-account-role-{{ $account['import_account']->id }}" |
|||
name="new_account[{{ str_replace(' ', '_', $account['import_account']->id) }}][account_role]"> |
|||
@php |
|||
// Intelligent role detection for asset accounts
|
|||
$inferredRole = 'defaultAsset'; // Default fallback
|
|||
|
|||
if ($inferredType === 'asset') { |
|||
// Negative balance detection (priority for credit cards)
|
|||
if (isset($account['import_account']->balance) && |
|||
floatval($account['import_account']->balance) < 0) { |
|||
$inferredRole = 'ccAsset'; |
|||
} |
|||
// Credit card name pattern detection
|
|||
elseif (preg_match('/credit\s*card|visa|mastercard|amex|american\s*express|discover/', $accountName)) { |
|||
$inferredRole = 'ccAsset'; |
|||
} |
|||
// Savings account detection
|
|||
elseif (preg_match('/savings|save|high\s*yield|money\s*market|cd|certificate/', $accountName)) { |
|||
$inferredRole = 'savingAsset'; |
|||
} |
|||
// Cash wallet detection
|
|||
elseif (preg_match('/cash|wallet|petty\s*cash/', $accountName) || |
|||
(isset($account['import_account']->balance) && |
|||
floatval($account['import_account']->balance) < 1000 && |
|||
floatval($account['import_account']->balance) > 0)) { |
|||
$inferredRole = 'cashWalletAsset'; |
|||
} |
|||
// Shared asset detection (joint accounts)
|
|||
elseif (preg_match('/joint|shared|family|couple/', $accountName)) { |
|||
$inferredRole = 'sharedAsset'; |
|||
} |
|||
} |
|||
@endphp |
|||
<option value="defaultAsset" @if($inferredRole === 'defaultAsset') selected @endif>Default Asset</option> |
|||
<option value="sharedAsset" @if($inferredRole === 'sharedAsset') selected @endif>Shared Asset</option> |
|||
<option value="savingAsset" @if($inferredRole === 'savingAsset') selected @endif>Savings Account</option> |
|||
<option value="ccAsset" @if($inferredRole === 'ccAsset') selected @endif>Credit Card</option> |
|||
<option value="cashWalletAsset" @if($inferredRole === 'cashWalletAsset') selected @endif>Cash Wallet</option> |
|||
</select> |
|||
<div class="invalid-feedback"> |
|||
Please select an account role. |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Liability Role and Direction Section (Liability accounts only) --> |
|||
<div id="liability-fields-section-{{ $account['import_account']->id }}" style="@if($inferredType === 'liability') display: block; @else display: none; @endif"> |
|||
<!-- Role Selection --> |
|||
<div class="row mb-3"> |
|||
<div class="col-12"> |
|||
<div class="d-flex align-items-center"> |
|||
<label class="form-label mb-0 me-3" style="min-width: 60px;">Role:</label> |
|||
<select class="form-control" |
|||
id="liability-type-{{ $account['import_account']->id }}" |
|||
name="new_account[{{ str_replace(' ', '_', $account['import_account']->id) }}][liability_type]"> |
|||
@php |
|||
// Intelligent liability role detection
|
|||
$inferredLiabilityRole = 'debt'; // Default fallback
|
|||
|
|||
if (preg_match('/mortgage|home\s*loan/', $accountName)) { |
|||
$inferredLiabilityRole = 'mortgage'; |
|||
} |
|||
elseif (preg_match('/loan|auto\s*loan|student\s*loan|personal\s*loan/', $accountName)) { |
|||
$inferredLiabilityRole = 'loan'; |
|||
} |
|||
@endphp |
|||
<option value="debt" @if($inferredLiabilityRole === 'debt') selected @endif>Debt</option> |
|||
<option value="loan" @if($inferredLiabilityRole === 'loan') selected @endif>Loan</option> |
|||
<option value="mortgage" @if($inferredLiabilityRole === 'mortgage') selected @endif>Mortgage</option> |
|||
</select> |
|||
<div class="invalid-feedback"> |
|||
Please select a liability role. |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Direction Selection --> |
|||
<div class="row mb-3"> |
|||
<div class="col-12"> |
|||
<div class="d-flex align-items-center"> |
|||
<label class="form-label mb-0 me-3" style="min-width: 60px;">Direction:</label> |
|||
@php |
|||
// Balance-based direction logic
|
|||
$inferredDirection = 'credit'; // Default fallback
|
|||
|
|||
if (isset($account['import_account']->balance)) { |
|||
$balance = floatval($account['import_account']->balance); |
|||
// Negative balance = we owe them (credit)
|
|||
// Positive balance = they owe us (debit)
|
|||
$inferredDirection = ($balance < 0) ? 'credit' : 'debit'; |
|||
} |
|||
@endphp |
|||
<div class="btn-group" role="group" data-bs-toggle="buttons"> |
|||
<input type="radio" class="btn-check" |
|||
name="new_account[{{ str_replace(' ', '_', $account['import_account']->id) }}][liability_direction]" |
|||
id="direction-credit-{{ $account['import_account']->id }}" |
|||
value="credit" |
|||
@if($inferredDirection === 'credit') checked @endif> |
|||
<label class="btn btn-outline-primary btn-sm" for="direction-credit-{{ $account['import_account']->id }}"> |
|||
We owe them |
|||
</label> |
|||
|
|||
<input type="radio" class="btn-check" |
|||
name="new_account[{{ str_replace(' ', '_', $account['import_account']->id) }}][liability_direction]" |
|||
id="direction-debit-{{ $account['import_account']->id }}" |
|||
value="debit" |
|||
@if($inferredDirection === 'debit') checked @endif> |
|||
<label class="btn btn-outline-primary btn-sm" for="direction-debit-{{ $account['import_account']->id }}"> |
|||
They owe us |
|||
</label> |
|||
</div> |
|||
<div class="invalid-feedback"> |
|||
Please select a liability direction. |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Balance and Currency Section --> |
|||
<div class="row mb-3"> |
|||
<div class="col-12"> |
|||
<!-- Balance Label Row with Edit Button --> |
|||
<div class="d-flex justify-content-between align-items-center mb-2"> |
|||
<label class="form-label mb-0"><i class="fas fa-coins me-2"></i>Balance:</label> |
|||
<div class="btn-group btn-group-sm" role="group"> |
|||
<button type="button" |
|||
class="btn btn-outline-secondary btn-sm" |
|||
id="edit-balance-btn-{{ $account['import_account']->id }}" |
|||
onclick="toggleBalanceCurrencyEdit('{{ $account['import_account']->id }}', true)" |
|||
title="Edit balance and currency"> |
|||
<i class="fas fa-pencil-alt"></i> |
|||
</button> |
|||
<button type="button" |
|||
class="btn btn-success btn-sm d-none" |
|||
id="commit-balance-btn-{{ $account['import_account']->id }}" |
|||
onclick="commitBalanceCurrencyEdit('{{ $account['import_account']->id }}')" |
|||
title="Save changes"> |
|||
<i class="fas fa-check"></i> |
|||
</button> |
|||
<button type="button" |
|||
class="btn btn-outline-secondary btn-sm d-none" |
|||
id="cancel-balance-btn-{{ $account['import_account']->id }}" |
|||
onclick="toggleBalanceCurrencyEdit('{{ $account['import_account']->id }}', false)" |
|||
title="Cancel edit"> |
|||
<i class="fas fa-times"></i> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Balance and Currency Values Row --> |
|||
<div class="d-flex align-items-center"> |
|||
@php |
|||
$rawBalance = $account['import_account']->balance ?? null; |
|||
$convertedFloat = (float)($rawBalance ?? '0.00'); |
|||
$displayFormat = number_format($convertedFloat, 2); |
|||
@endphp |
|||
|
|||
<!-- Balance Display/Edit --> |
|||
<div class="me-3 flex-grow-1"> |
|||
<span id="balance-display-{{ $account['import_account']->id }}" class="fw-bold"> |
|||
{{ $displayFormat }} |
|||
</span> |
|||
<input type="number" |
|||
step="0.01" |
|||
class="form-control form-control-sm d-none" |
|||
id="balance-edit-{{ $account['import_account']->id }}" |
|||
name="new_account[{{ str_replace(' ', '_', $account['import_account']->id) }}][opening_balance]" |
|||
value="{{ $convertedFloat }}" |
|||
placeholder="0.00"> |
|||
</div> |
|||
|
|||
<!-- Currency Display/Edit --> |
|||
<div> |
|||
@php |
|||
// Check for previously committed currency selection, fall back to SimpleFIN currency, then USD
|
|||
$accountId = $account['import_account']->id; |
|||
$newAccounts = $configuration->getNewAccounts(); |
|||
$committedCurrency = $newAccounts[$accountId]['currency'] ?? null; |
|||
$displayCurrency = $committedCurrency ?? $account['import_account']->currency ?? 'USD'; |
|||
@endphp |
|||
<span id="currency-display-{{ $account['import_account']->id }}" class="fw-bold"> |
|||
{{ $displayCurrency }} |
|||
</span> |
|||
<select class="form-control form-control-sm d-none" |
|||
id="currency-edit-{{ $account['import_account']->id }}" |
|||
name="new_account[{{ str_replace(' ', '_', $account['import_account']->id) }}][currency]"> |
|||
@php |
|||
// Use the same committed currency logic for option selection
|
|||
$defaultCurrency = $displayCurrency; |
|||
@endphp |
|||
@foreach($currencies ?? [] as $currencyId => $currencyDisplay) |
|||
@php |
|||
// Extract ISO code from currency display (e.g., "US Dollar (USD)" -> "USD")
|
|||
preg_match('/\(([A-Z]{3})\)/', $currencyDisplay, $matches); |
|||
$isoCode = $matches[1] ?? 'USD'; |
|||
@endphp |
|||
<option value="{{ $isoCode }}" @if($isoCode === $defaultCurrency) selected @endif> |
|||
{{ $currencyDisplay }} |
|||
</option> |
|||
@endforeach |
|||
@if(empty($currencies)) |
|||
<option value="USD">USD (US Dollar)</option> |
|||
@endif |
|||
</select> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Metadata section removed - debug information not valuable in production widget --> |
|||
|
|||
<!-- Dynamic status footer area --> |
|||
<div class="row"> |
|||
<div class="col-12"> |
|||
<div class="text-end"> |
|||
<small id="widget-status-{{ $account['import_account']->id }}" class="text-muted"> |
|||
<i class="fas fa-check-circle me-1"></i> |
|||
Ready for import |
|||
</small> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<script> |
|||
// Track edit states to prevent form submission during edits
|
|||
window.accountEditStates = window.accountEditStates || {}; |
|||
|
|||
|
|||
// Initialize visibility on page load
|
|||
document.addEventListener('DOMContentLoaded', function() { |
|||
@foreach($accounts ?? [] as $account) |
|||
updateAccountTypeVisibility('{{ $account['import_account']->id }}'); |
|||
@endforeach |
|||
}); |
|||
|
|||
// Updated function to handle both account role and liability field visibility
|
|||
function updateAccountTypeVisibility(accountId) { |
|||
updateAccountRoleVisibility(accountId); |
|||
updateLiabilityFieldsVisibility(accountId); |
|||
} |
|||
|
|||
function updateAccountRoleVisibility(accountId) { |
|||
const typeSelect = document.getElementById('new-account-type-' + accountId); |
|||
const roleSection = document.getElementById('account-role-section-' + accountId); |
|||
|
|||
if (!typeSelect || !roleSection) { |
|||
console.error('Required elements not found for role visibility update:', accountId); |
|||
return; |
|||
} |
|||
|
|||
if (typeSelect.value === 'asset') { |
|||
roleSection.style.display = 'block'; |
|||
console.log('Showed role section for asset account:', accountId); |
|||
} else { |
|||
roleSection.style.display = 'none'; |
|||
console.log('Hidden role section for non-asset account:', accountId); |
|||
} |
|||
} |
|||
|
|||
function updateLiabilityFieldsVisibility(accountId) { |
|||
const typeSelect = document.getElementById('new-account-type-' + accountId); |
|||
const liabilitySection = document.getElementById('liability-fields-section-' + accountId); |
|||
|
|||
if (!typeSelect || !liabilitySection) { |
|||
console.error('Required elements not found for liability visibility update:', accountId); |
|||
return; |
|||
} |
|||
|
|||
if (typeSelect.value === 'liability') { |
|||
liabilitySection.style.display = 'block'; |
|||
console.log('Showed liability fields for account:', accountId); |
|||
} else { |
|||
liabilitySection.style.display = 'none'; |
|||
console.log('Hidden liability fields for account:', accountId); |
|||
} |
|||
} |
|||
|
|||
// Enhanced widget visibility control functions with error handling
|
|||
function showAccountWidget(accountId) { |
|||
try { |
|||
const widget = document.getElementById('create-account-widget-' + accountId); |
|||
if (!widget) { |
|||
console.error('Widget element not found for account:', accountId); |
|||
return false; |
|||
} |
|||
|
|||
if (typeof bootstrap !== 'undefined' && bootstrap.Collapse) { |
|||
const collapse = new bootstrap.Collapse(widget, { show: true }); |
|||
console.log('Showed account widget using Bootstrap for:', accountId); |
|||
} else { |
|||
widget.classList.add('show'); |
|||
console.log('Showed account widget using fallback for:', accountId); |
|||
} |
|||
|
|||
// Initialize edit states
|
|||
window.accountEditStates[accountId] = { |
|||
nameEditing: false, |
|||
balanceEditing: false |
|||
}; |
|||
|
|||
return true; |
|||
} catch (error) { |
|||
console.error('Error showing widget for account:', accountId, error); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
// Account name inline editing functions moved to global layout
|
|||
|
|||
|
|||
|
|||
// Form submission state management and status updates
|
|||
function updateFormSubmitState() { |
|||
const form = document.querySelector('form'); |
|||
if (!form) return; |
|||
|
|||
const submitButtons = form.querySelectorAll('[type="submit"]'); |
|||
let hasActiveEdits = false; |
|||
|
|||
// Check if any account is in edit state and update status text
|
|||
for (const accountId in window.accountEditStates) { |
|||
const state = window.accountEditStates[accountId]; |
|||
const statusElement = document.getElementById('widget-status-' + accountId); |
|||
|
|||
if (state.nameEditing || state.balanceEditing) { |
|||
hasActiveEdits = true; |
|||
if (statusElement) { |
|||
statusElement.innerHTML = '<i class="fas fa-exclamation-triangle me-1 text-warning"></i>Finish editing before import'; |
|||
statusElement.className = 'text-warning'; |
|||
} |
|||
} else { |
|||
if (statusElement) { |
|||
statusElement.innerHTML = '<i class="fas fa-check-circle me-1"></i>Ready for import'; |
|||
statusElement.className = 'text-muted'; |
|||
} |
|||
} |
|||
} |
|||
|
|||
// Disable/enable submit buttons based on edit state
|
|||
submitButtons.forEach(button => { |
|||
if (hasActiveEdits) { |
|||
button.disabled = true; |
|||
button.title = 'Complete all edits before submitting'; |
|||
} else { |
|||
button.disabled = false; |
|||
button.title = ''; |
|||
} |
|||
}); |
|||
} |
|||
|
|||
function hideAccountWidget(accountId) { |
|||
try { |
|||
const widget = document.getElementById('create-account-widget-' + accountId); |
|||
const select = document.getElementById('account-select-' + accountId); |
|||
|
|||
if (widget) { |
|||
if (typeof bootstrap !== 'undefined' && bootstrap.Collapse) { |
|||
const collapse = new bootstrap.Collapse(widget, { hide: true }); |
|||
console.log('Hid account widget using Bootstrap for:', accountId); |
|||
} else { |
|||
widget.classList.remove('show'); |
|||
console.log('Hid account widget using fallback for:', accountId); |
|||
} |
|||
|
|||
// Clear any validation states
|
|||
const inputs = widget.querySelectorAll('.is-invalid'); |
|||
inputs.forEach(input => input.classList.remove('is-invalid')); |
|||
} |
|||
|
|||
// Reset dropdown to first non-create option
|
|||
if (select && select.options.length > 1) { |
|||
for (let i = 1; i < select.options.length; i++) { |
|||
if (select.options[i].value !== 'create_new') { |
|||
select.selectedIndex = i; |
|||
break; |
|||
} |
|||
} |
|||
console.log('Reset dropdown selection for account:', accountId); |
|||
} |
|||
|
|||
return true; |
|||
} catch (error) { |
|||
console.error('Error hiding widget for account:', accountId, error); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
// Real-time validation feedback
|
|||
function setupWidgetValidation(accountId) { |
|||
const widget = document.getElementById('create-account-widget-' + accountId); |
|||
if (!widget) return; |
|||
|
|||
const nameInput = widget.querySelector('input[name*="[name]"]'); |
|||
const typeSelect = widget.querySelector('select[name*="[type]"]'); |
|||
|
|||
if (nameInput) { |
|||
nameInput.addEventListener('blur', function() { |
|||
if (!this.value.trim()) { |
|||
this.classList.add('is-invalid'); |
|||
console.warn('Account name validation failed for:', accountId); |
|||
} else { |
|||
this.classList.remove('is-invalid'); |
|||
} |
|||
}); |
|||
|
|||
nameInput.addEventListener('input', function() { |
|||
if (this.value.trim()) { |
|||
this.classList.remove('is-invalid'); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
if (typeSelect) { |
|||
typeSelect.addEventListener('change', function() { |
|||
if (!this.value) { |
|||
this.classList.add('is-invalid'); |
|||
} else { |
|||
this.classList.remove('is-invalid'); |
|||
} |
|||
}); |
|||
} |
|||
} |
|||
|
|||
// Initialize widget on page load
|
|||
document.addEventListener('DOMContentLoaded', function() { |
|||
const accountId = '{{ $account['import_account']->id }}'; |
|||
console.log('Initializing create account widget for:', accountId); |
|||
|
|||
// Initialize edit states
|
|||
window.accountEditStates = window.accountEditStates || {}; |
|||
window.accountEditStates[accountId] = { |
|||
nameEditing: false, |
|||
balanceEditing: false |
|||
}; |
|||
|
|||
// Set up real-time validation
|
|||
setupWidgetValidation(accountId); |
|||
|
|||
// Initialize duplicate checking
|
|||
if (window.updateDuplicateStatus) { |
|||
window.updateDuplicateStatus(accountId); |
|||
} |
|||
|
|||
// Set up custom event listeners
|
|||
document.addEventListener('accountWidgetToggled', function(event) { |
|||
const detail = event.detail; |
|||
console.log('Account widget toggle event:', detail); |
|||
|
|||
if (detail.accountId === accountId && detail.success && detail.isCreateNew) { |
|||
// Widget was successfully shown, set up any additional handlers
|
|||
setupWidgetValidation(accountId); |
|||
// Initialize edit states
|
|||
window.accountEditStates[accountId] = { |
|||
nameEditing: false, |
|||
balanceEditing: false |
|||
}; |
|||
} |
|||
}); |
|||
|
|||
// Set up keyboard shortcuts for inline editing
|
|||
document.addEventListener('keydown', function(event) { |
|||
if (event.key === 'Escape') { |
|||
// Cancel any active edits on Escape
|
|||
const state = window.accountEditStates[accountId]; |
|||
if (state && state.nameEditing) { |
|||
toggleAccountNameEdit(accountId, false); |
|||
} |
|||
if (state && state.balanceEditing) { |
|||
toggleBalanceCurrencyEdit(accountId, false); |
|||
} |
|||
} |
|||
if (event.key === 'Enter' && (event.target.id.includes('name-edit') || event.target.id.includes('balance-edit'))) { |
|||
// Commit edits on Enter
|
|||
if (event.target.id.includes('name-edit')) { |
|||
commitAccountNameEdit(accountId); |
|||
} else if (event.target.id.includes('balance-edit')) { |
|||
commitBalanceCurrencyEdit(accountId); |
|||
} |
|||
event.preventDefault(); |
|||
} |
|||
}); |
|||
}); |
|||
|
|||
// Duplicate function definitions removed - using the globally accessible versions above
|
|||
|
|||
// Form validation enhancement
|
|||
document.addEventListener('DOMContentLoaded', function() { |
|||
const form = document.querySelector('form'); |
|||
if (form) { |
|||
form.addEventListener('submit', function(event) { |
|||
// Validate create account widgets that are visible
|
|||
let isValid = true; |
|||
const visibleWidgets = document.querySelectorAll('.collapse.show[id^="create-account-widget-"]'); |
|||
|
|||
console.log('Form submission: Validating', visibleWidgets.length, 'visible widgets'); |
|||
|
|||
visibleWidgets.forEach(widget => { |
|||
const nameInput = widget.querySelector('input[name*="[name]"]'); |
|||
const typeSelect = widget.querySelector('select[name*="[type]"]'); |
|||
|
|||
if (nameInput && !nameInput.value.trim()) { |
|||
nameInput.classList.add('is-invalid'); |
|||
isValid = false; |
|||
console.warn('Form validation failed: Account name empty'); |
|||
} else if (nameInput) { |
|||
nameInput.classList.remove('is-invalid'); |
|||
} |
|||
|
|||
if (typeSelect && !typeSelect.value) { |
|||
typeSelect.classList.add('is-invalid'); |
|||
isValid = false; |
|||
console.warn('Form validation failed: Account type not selected'); |
|||
} else if (typeSelect) { |
|||
typeSelect.classList.remove('is-invalid'); |
|||
} |
|||
}); |
|||
|
|||
if (!isValid) { |
|||
console.error('Form validation failed, preventing submission'); |
|||
event.preventDefault(); |
|||
event.stopPropagation(); |
|||
} else { |
|||
console.log('Form validation passed'); |
|||
} |
|||
}); |
|||
} |
|||
}); |
|||
</script> |
@ -1,22 +1,238 @@ |
|||
@if('disabled' !== $account['import_account']->status) |
|||
<select style="width:100%;" |
|||
class="custom-select custom-select-sm form-control" |
|||
name="accounts[{{ $account['import_account']->id }}]"> |
|||
<!-- loop all Firefly III accounts --> |
|||
@foreach($account['firefly_iii_accounts'] as $ff3Account) |
|||
<option value="{{ $ff3Account->id }}" |
|||
{{-- loop configuration --}} |
|||
@foreach($configuration->getAccounts() as $key => $preConfig) |
|||
{{-- if this account matches, pre-select dropdown. --}} |
|||
@if((string) $key === (string) $account['import_account']->id && $preConfig === $ff3Account->id) selected="selected" |
|||
@endif |
|||
name="accounts[{{ str_replace(' ', '_', $account['import_account']->id) }}]" |
|||
onchange="handleAccountSelection('{{ $account['import_account']->id }}', this.value)" |
|||
id="account-select-{{ $account['import_account']->id }}"> |
|||
|
|||
<!-- Create New Account option --> |
|||
<option value="create_new" |
|||
@php |
|||
$configuredAccount = $configuration->getAccounts()[$account['import_account']->id] ?? null; |
|||
$mappedTo = $account['mapped_to'] ?? null; |
|||
$isCreateNewSelected = (!$configuredAccount || $configuredAccount === 'create_new') && !$mappedTo; |
|||
@endphp |
|||
@if($isCreateNewSelected) selected @endif>➕ Create New Account</option> |
|||
|
|||
<!-- loop all Firefly III account groups (assets, liabilities) --> |
|||
@foreach($account['firefly_iii_accounts'] as $accountGroupKey => $accountGroup) |
|||
{{-- $accountGroupKey is 'assets' or 'liabilities' --}} |
|||
{{-- $accountGroup is the array of account objects --}} |
|||
@if(is_array($accountGroup) && count($accountGroup) > 0) |
|||
<optgroup label="{{ ucfirst($accountGroupKey) }}"> |
|||
@foreach($accountGroup as $ff3Account) {{-- $ff3Account is now a single Firefly III Account object/array --}} |
|||
<option value="{{ $ff3Account->id ?? '' }}" |
|||
@php |
|||
$isSelected = false; |
|||
// First check if mapped_to matches this account
|
|||
if (isset($account['mapped_to']) && (string) $account['mapped_to'] === (string) ($ff3Account->id ?? '')) { |
|||
$isSelected = true; |
|||
} |
|||
// Otherwise check configuration for pre-selection
|
|||
else { |
|||
foreach($configuration->getAccounts() as $key => $preConfig) { |
|||
if((string) $key === (string) $account['import_account']->id && (int) $preConfig === (int) ($ff3Account->id ?? null)) { |
|||
$isSelected = true; |
|||
break; |
|||
} |
|||
} |
|||
} |
|||
@endphp |
|||
@if($isSelected) selected="selected" @endif |
|||
label="{{ $ff3Account->name ?? 'Unknown Account' }} @if($ff3Account->iban ?? null) ({{ $ff3Account->iban ?? '' }}) @endif"> |
|||
{{ $ff3Account->name ?? 'Unknown Account' }} @if($ff3Account->iban ?? null) ({{ $ff3Account->iban ?? '' }}) @endif |
|||
</option> |
|||
@endforeach |
|||
label="{{ $ff3Account->name }} @if($ff3Account->iban) ({{ $ff3Account->iban }}) @endif"> |
|||
{{ $ff3Account->id }}: |
|||
{{ $ff3Account->name }} @if($ff3Account->iban) |
|||
({{ $ff3Account->iban }}) |
|||
@endif |
|||
</option> |
|||
</optgroup> |
|||
@endif |
|||
@endforeach |
|||
</select> |
|||
|
|||
<!-- Status text for existing account selection --> |
|||
<div id="existing-account-status-container-{{ $account['import_account']->id }}" class="p-3"> |
|||
<div class="row"> |
|||
<div class="col-12"> |
|||
<div class="text-end"> |
|||
<small id="existing-account-status-{{ $account['import_account']->id }}" class="text-success"> |
|||
<i class="fas fa-check-circle me-1"></i>Ready for import |
|||
</small> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Hidden field to indicate account creation is requested when create_new is selected --> |
|||
<input type="hidden" |
|||
id="create-new-indicator-{{ $account['import_account']->id }}" |
|||
name="new_account[{{ str_replace(' ', '_', $account['import_account']->id) }}][create]" |
|||
value="0"> |
|||
|
|||
<input type="hidden" |
|||
name="do_import[{{ str_replace(' ', '_', $account['import_account']->id) }}]" |
|||
value="1"> |
|||
|
|||
<script> |
|||
function handleAccountSelection(accountId, selectedValue) { |
|||
const isCreateNew = selectedValue === 'create_new'; |
|||
|
|||
console.log('Account selection changed:', { |
|||
accountId: accountId, |
|||
selectedValue: selectedValue, |
|||
isCreateNew: isCreateNew |
|||
}); |
|||
|
|||
// Update hidden field to indicate create new status
|
|||
const createIndicator = document.getElementById('create-new-indicator-' + accountId); |
|||
if (createIndicator) { |
|||
createIndicator.value = isCreateNew ? '1' : '0'; |
|||
console.log('Updated create indicator for account:', accountId, 'value:', createIndicator.value); |
|||
} else { |
|||
console.warn('Create new indicator not found for account:', accountId); |
|||
} |
|||
|
|||
// Toggle widget visibility with enhanced error handling
|
|||
try { |
|||
if (typeof window.toggleAccountNameEditing === 'function') { |
|||
window.toggleAccountNameEditing(accountId, isCreateNew); |
|||
} else { |
|||
// Fallback: directly control widget if toggle function not available
|
|||
console.warn('toggleAccountNameEditing function not found, using fallback'); |
|||
const widget = document.getElementById('create-account-widget-' + accountId); |
|||
|
|||
if (widget) { |
|||
if (isCreateNew) { |
|||
// Show widget
|
|||
if (typeof bootstrap !== 'undefined' && bootstrap.Collapse) { |
|||
const collapse = new bootstrap.Collapse(widget, { show: true }); |
|||
} else { |
|||
widget.classList.add('show'); |
|||
} |
|||
console.log('Showed widget for account:', accountId); |
|||
} else { |
|||
// Hide widget
|
|||
if (typeof bootstrap !== 'undefined' && bootstrap.Collapse) { |
|||
const collapse = new bootstrap.Collapse(widget, { hide: true }); |
|||
} else { |
|||
widget.classList.remove('show'); |
|||
} |
|||
console.log('Hid widget for account:', accountId); |
|||
} |
|||
} else { |
|||
console.error('Widget not found for account:', accountId); |
|||
} |
|||
} |
|||
} catch (error) { |
|||
console.error('Error in widget coordination for account:', accountId, error); |
|||
} |
|||
|
|||
// Toggle existing account status container visibility
|
|||
const existingAccountStatusContainer = document.getElementById('existing-account-status-container-' + accountId); |
|||
if (existingAccountStatusContainer) { |
|||
if (isCreateNew) { |
|||
// Hide entire status container when creating new account
|
|||
existingAccountStatusContainer.style.display = 'none'; |
|||
} else { |
|||
// Show status container when existing account selected
|
|||
existingAccountStatusContainer.style.display = 'block'; |
|||
} |
|||
console.log('Updated existing account status container visibility for account:', accountId, 'visible:', !isCreateNew); |
|||
} else { |
|||
console.warn('Existing account status container not found for account:', accountId); |
|||
} |
|||
|
|||
// Validate form fields if widget is being shown
|
|||
if (isCreateNew) { |
|||
setTimeout(() => validateAccountWidget(accountId), 100); |
|||
} |
|||
} |
|||
|
|||
// Validate widget form fields
|
|||
function validateAccountWidget(accountId) { |
|||
const widget = document.getElementById('create-account-widget-' + accountId); |
|||
if (!widget || !widget.classList.contains('show')) { |
|||
return; |
|||
} |
|||
|
|||
const nameInput = widget.querySelector('input[name*="[name]"]'); |
|||
const typeSelect = widget.querySelector('select[name*="[type]"]'); |
|||
|
|||
if (nameInput && !nameInput.value.trim()) { |
|||
console.warn('Account name is empty for:', accountId); |
|||
} |
|||
|
|||
if (typeSelect && !typeSelect.value) { |
|||
console.warn('Account type not selected for:', accountId); |
|||
} |
|||
} |
|||
|
|||
// Handle import checkbox state changes - Visibility-based UX
|
|||
function handleImportToggle(accountId, isImportEnabled) { |
|||
const ff3AccountContent = document.getElementById('firefly-account-content-' + accountId); |
|||
const notImportedText = document.getElementById('not-imported-text-' + accountId); |
|||
|
|||
if (ff3AccountContent && notImportedText) { |
|||
if (isImportEnabled) { |
|||
// Show Firefly III account content
|
|||
ff3AccountContent.style.display = 'block'; |
|||
notImportedText.style.display = 'none'; |
|||
} else { |
|||
// Hide Firefly III account content and show "Not Imported" text
|
|||
ff3AccountContent.style.display = 'none'; |
|||
notImportedText.style.display = 'block'; |
|||
} |
|||
|
|||
console.log('Import toggle for account:', accountId, 'enabled:', isImportEnabled); |
|||
} else { |
|||
console.error('Could not find firefly account content or not imported text elements for account:', accountId); |
|||
} |
|||
} |
|||
|
|||
// Enhanced initialization with error handling
|
|||
document.addEventListener('DOMContentLoaded', function() { |
|||
const select = document.getElementById('account-select-{{ $account['import_account']->id }}'); |
|||
const importCheckbox = document.getElementById('do_import_{{ $account['import_account']->id }}'); |
|||
const accountId = '{{ $account['import_account']->id }}'; |
|||
|
|||
if (select) { |
|||
console.log('Initializing account selection for:', accountId); |
|||
|
|||
// Set up change event listener with enhanced handling
|
|||
select.addEventListener('change', function(event) { |
|||
try { |
|||
handleAccountSelection(accountId, event.target.value); |
|||
} catch (error) { |
|||
console.error('Error handling account selection change:', error); |
|||
} |
|||
}); |
|||
|
|||
// Initialize current state
|
|||
try { |
|||
if (importCheckbox && !importCheckbox.checked) { |
|||
handleImportToggle(accountId, false); |
|||
} else { |
|||
handleAccountSelection(accountId, select.value); |
|||
} |
|||
} catch (error) { |
|||
console.error('Error during initialization for account:', accountId, error); |
|||
} |
|||
} else { |
|||
console.error('Account select element not found for:', accountId); |
|||
} |
|||
|
|||
// Set up import checkbox listener
|
|||
if (importCheckbox) { |
|||
importCheckbox.addEventListener('change', function(event) { |
|||
try { |
|||
handleImportToggle(accountId, event.target.checked); |
|||
} catch (error) { |
|||
console.error('Error handling import checkbox change:', error); |
|||
} |
|||
}); |
|||
|
|||
console.log('Import checkbox listener attached for:', accountId); |
|||
} else { |
|||
console.error('Import checkbox not found for:', accountId); |
|||
} |
|||
}); |
|||
</script> |
|||
@endif |
@ -1,31 +1,105 @@ |
|||
<input |
|||
id="do_import_{{ $account['import_account']->id }}" |
|||
type="checkbox" |
|||
name="do_import[{{ $account['import_account']->id }}]" |
|||
value="1" |
|||
aria-describedby="accountsHelp" |
|||
@if('disabled' === $account['import_account']->status) disabled="disabled" @endif |
|||
@if(0 !== ($configuration->getAccounts()[$account['import_account']->id] ?? '')) checked="checked" @endif |
|||
/> |
|||
<label |
|||
class="form-check-label" |
|||
for="do_import_{{ $account['import_account']->id }}" |
|||
@if('' !== $account['import_account']->iban) title="IBAN: {{ $account['import_account']->iban }}" @endif |
|||
> |
|||
@if('' !== $account['import_account']->name) |
|||
Account "{{ $account['import_account']->name }}" |
|||
@else |
|||
Account with no name |
|||
@endif |
|||
</label> |
|||
<br> |
|||
<small> |
|||
@foreach($account['import_account']->extra as $key => $item) |
|||
@if('' !== $item) |
|||
{{ $key }}: {{ $item }}<br> |
|||
<div class="d-flex align-items-start p-3 bg-dark text-light rounded mb-2 border border-secondary"> |
|||
<div class="form-check me-3"> |
|||
<input |
|||
id="do_import_{{ $account['import_account']->id }}" |
|||
type="checkbox" |
|||
name="do_import[{{ $account['import_account']->id }}]" |
|||
value="1" |
|||
class="form-check-input" |
|||
aria-describedby="accountsHelp" |
|||
@if('disabled' === $account['import_account']->status) disabled="disabled" @endif |
|||
@php |
|||
$accountId = $account['import_account']->id; |
|||
$configuredValue = $configuration->getAccounts()[$accountId] ?? null; |
|||
$allAccounts = $configuration->getAccounts(); |
|||
$mappedTo = $account['mapped_to'] ?? null; |
|||
|
|||
// Check if account should be checked:
|
|||
// 1. If configured explicitly (any non-null value including 0 for "create new")
|
|||
// 2. If no configuration exists yet - use sensible defaults
|
|||
$shouldCheck = false; |
|||
|
|||
if ($configuredValue !== null && $configuredValue !== '') { |
|||
// Explicitly configured (including 0 for "create new")
|
|||
$shouldCheck = true; |
|||
} elseif (empty($allAccounts)) { |
|||
// No configuration yet - use sensible defaults
|
|||
// Check if there's an automatic mapping
|
|||
if ($mappedTo !== null) { |
|||
$shouldCheck = true; // Auto-mapped accounts should be checked
|
|||
} else { |
|||
$shouldCheck = true; // Default to checked for user convenience
|
|||
} |
|||
} |
|||
@endphp |
|||
@if($shouldCheck) checked="checked" @endif |
|||
/> |
|||
</div> |
|||
|
|||
<div class="flex-grow-1"> |
|||
<label |
|||
class="form-check-label d-block mb-2" |
|||
for="do_import_{{ $account['import_account']->id }}" |
|||
@if('' !== $account['import_account']->iban) title="IBAN: {{ $account['import_account']->iban }}" @endif |
|||
> |
|||
<div class="d-flex align-items-center mb-1"> |
|||
<span class="fw-bold fs-6">{{ $account['import_account']->name ?? 'Unnamed SimpleFIN Account' }}</span> |
|||
</div> |
|||
@if(isset($account['import_account']->org) && is_array($account['import_account']->org) && !empty($account['import_account']->org['name'])) |
|||
<div class="text-muted small"> |
|||
<i class="fas fa-building me-1"></i> |
|||
{{ $account['import_account']->org['name'] }} |
|||
</div> |
|||
@endif |
|||
</label> |
|||
|
|||
@if(isset($account['import_account']->balance)) |
|||
<div class="mb-2"> |
|||
<i class="fas fa-coins me-1"></i> |
|||
<span class="badge bg-secondary text-light px-3 py-1 fw-bold"> |
|||
{{ number_format((float)$account['import_account']->balance, 2) }} {{ $account['import_account']->currency ?? '' }} |
|||
</span> |
|||
@if(isset($account['import_account']->balance_date) && $account['import_account']->balance_date) |
|||
<small class="text-muted ms-2">({{ date('M j, Y', (int)$account['import_account']->balance_date) }})</small> |
|||
@endif |
|||
</div> |
|||
@endif |
|||
@endforeach |
|||
</small> |
|||
@if('disabled' === $account['import_account']->status) |
|||
<small class="text-danger">(this account is disabled)</small> |
|||
@endif |
|||
|
|||
@if(isset($account['import_account']->available_balance) && $account['import_account']->available_balance !== ($account['import_account']->balance ?? null)) |
|||
<div class="mb-2"> |
|||
<i class="fas fa-wallet me-1"></i> |
|||
<span class="badge bg-secondary text-light px-3 py-1 fw-bold"> |
|||
{{ number_format((float)$account['import_account']->available_balance, 2) }} {{ $account['import_account']->currency ?? '' }} |
|||
</span> |
|||
</div> |
|||
@endif |
|||
|
|||
<div class="d-flex align-items-center justify-content-between"> |
|||
<small class="text-muted"> |
|||
<i class="fas fa-id-card me-1"></i> |
|||
<code class="text-muted">{{ $account['import_account']->id ?? 'N/A' }}</code> |
|||
</small> |
|||
@if('disabled' === $account['import_account']->status) |
|||
<small class="text-warning"> |
|||
<i class="fas fa-exclamation-triangle me-1"></i> |
|||
Disabled |
|||
</small> |
|||
@endif |
|||
</div> |
|||
|
|||
{{-- Display 'extra' fields if any --}} |
|||
@php $extraData = (array)($account['import_account']->extra ?? []); @endphp |
|||
@if(count($extraData) > 0) |
|||
<div class="mt-2 pt-2 border-top border-secondary"> |
|||
@foreach($extraData as $key => $item) |
|||
@if(!empty($item) && is_scalar($item)) |
|||
<div class="d-flex justify-content-between align-items-center small text-muted mb-1"> |
|||
<span>{{ ucfirst(str_replace(['_', '-'], ' ', $key)) }}:</span> |
|||
<span>{{ $item }}</span> |
|||
</div> |
|||
@endif |
|||
@endforeach |
|||
</div> |
|||
@endif |
|||
</div> |
|||
</div> |
@ -0,0 +1,114 @@ |
|||
<?php |
|||
|
|||
namespace Tests\Feature\SimpleFIN; |
|||
|
|||
use Tests\TestCase; |
|||
use App\Support\Constants; |
|||
use Illuminate\Foundation\Testing\RefreshDatabase; |
|||
use Illuminate\Support\Facades\Log; |
|||
use Illuminate\Support\Facades\Config; |
|||
|
|||
class DemoModeTest extends TestCase |
|||
{ |
|||
use RefreshDatabase; |
|||
|
|||
protected function setUp(): void |
|||
{ |
|||
parent::setUp(); |
|||
|
|||
// Set up SimpleFIN demo configuration
|
|||
Config::set('importer.simplefin.demo_token', 'demo_token_123'); |
|||
Config::set('importer.simplefin.demo_url', 'https://demo:demo@beta-bridge.simplefin.org/simplefin'); |
|||
} |
|||
|
|||
public function test_demo_mode_checkbox_submission() |
|||
{ |
|||
// Set the flow cookie to SimpleFIN
|
|||
$response = $this->withCookie(Constants::FLOW_COOKIE, 'simplefin') |
|||
->post(route('003-upload.upload'), [ |
|||
'use_demo' => '1' |
|||
]); |
|||
|
|||
// Check that we don't redirect back to upload (which indicates validation failure)
|
|||
$this->assertNotEquals(route('003-upload.index'), $response->getTargetUrl()); |
|||
|
|||
// If demo mode works correctly, we should redirect to configuration
|
|||
$response->assertRedirect(route('004-configure.index')); |
|||
|
|||
// Verify session data was set correctly
|
|||
$this->assertEquals('demo_token_123', session(Constants::SIMPLEFIN_TOKEN)); |
|||
$this->assertEquals('https://demo:demo@beta-bridge.simplefin.org/simplefin', session(Constants::SIMPLEFIN_BRIDGE_URL)); |
|||
$this->assertTrue(session(Constants::SIMPLEFIN_IS_DEMO)); |
|||
$this->assertTrue(session(Constants::HAS_UPLOAD)); |
|||
} |
|||
|
|||
public function test_demo_mode_checkbox_value_interpretation() |
|||
{ |
|||
// Test various ways the checkbox might be submitted
|
|||
$testCases = [ |
|||
['use_demo' => '1'], |
|||
['use_demo' => 'on'], |
|||
['use_demo' => true], |
|||
['use_demo' => 1], |
|||
]; |
|||
|
|||
foreach ($testCases as $case) { |
|||
$response = $this->withCookie(Constants::FLOW_COOKIE, 'simplefin') |
|||
->post(route('003-upload.upload'), $case); |
|||
|
|||
$this->assertNotEquals(route('003-upload.index'), $response->getTargetUrl(), |
|||
'Failed for case: ' . json_encode($case)); |
|||
} |
|||
} |
|||
|
|||
public function test_manual_mode_requires_token_and_url() |
|||
{ |
|||
// Test that manual mode (no demo checkbox) requires token and URL
|
|||
$response = $this->withCookie(Constants::FLOW_COOKIE, 'simplefin') |
|||
->post(route('003-upload.upload'), [ |
|||
// No use_demo, no token, no URL
|
|||
]); |
|||
|
|||
// Should redirect back to upload with validation errors
|
|||
$response->assertRedirect(route('003-upload.index')); |
|||
$response->assertSessionHasErrors(['simplefin_token', 'bridge_url']); |
|||
} |
|||
|
|||
public function test_manual_mode_with_valid_credentials() |
|||
{ |
|||
$response = $this->withCookie(Constants::FLOW_COOKIE, 'simplefin') |
|||
->post(route('003-upload.upload'), [ |
|||
'simplefin_token' => 'valid_token_123', |
|||
'bridge_url' => 'https://bridge.example.com' |
|||
]); |
|||
|
|||
// Should attempt to connect (may fail due to invalid credentials, but shouldn't fail validation)
|
|||
// The exact behavior depends on whether SimpleFINService is mocked
|
|||
$this->assertNotEquals(route('003-upload.index'), $response->getTargetUrl()); |
|||
} |
|||
|
|||
public function test_request_data_logging() |
|||
{ |
|||
// Enable debug logging to capture the request data
|
|||
Log::shouldReceive('debug') |
|||
->with('UploadController::upload() - Request All:', \Mockery::type('array')) |
|||
->once(); |
|||
|
|||
Log::shouldReceive('debug') |
|||
->with('handleSimpleFINFlow() - Request All:', \Mockery::type('array')) |
|||
->once(); |
|||
|
|||
Log::shouldReceive('debug') |
|||
->with('handleSimpleFINFlow() - Raw use_demo input:', \Mockery::type('array')) |
|||
->once(); |
|||
|
|||
Log::shouldReceive('debug') |
|||
->with('handleSimpleFINFlow() - Evaluated $isDemo:', [true]) |
|||
->once(); |
|||
|
|||
$this->withCookie(Constants::FLOW_COOKIE, 'simplefin') |
|||
->post(route('003-upload.upload'), [ |
|||
'use_demo' => '1' |
|||
]); |
|||
} |
|||
} |
@ -0,0 +1,384 @@ |
|||
<?php |
|||
|
|||
/* |
|||
* ConfigurationContractValidatorTest.php |
|||
* Copyright (c) 2021 james@firefly-iii.org |
|||
* |
|||
* This file is part of the Firefly III Data Importer |
|||
* (https://github.com/firefly-iii/data-importer). |
|||
* |
|||
* This program is free software: you can redistribute it and/or modify |
|||
* it under the terms of the GNU Affero General Public License as |
|||
* published by the Free Software Foundation, either version 3 of the |
|||
* License, or (at your option) any later version. |
|||
* |
|||
* This program is distributed in the hope that it will be useful, |
|||
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
* GNU Affero General Public License for more details. |
|||
* |
|||
* You should have received a copy of the GNU Affero General Public License |
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>. |
|||
*/ |
|||
|
|||
declare(strict_types=1); |
|||
|
|||
namespace Tests\Unit\Services\SimpleFIN\Validation; |
|||
|
|||
use App\Services\Shared\Configuration\Configuration; |
|||
use App\Services\SimpleFIN\Validation\ConfigurationContractValidator; |
|||
use App\Services\SimpleFIN\Validation\ValidationResult; |
|||
use Illuminate\Foundation\Testing\WithFaker; |
|||
use Illuminate\Support\Facades\Session; |
|||
use Tests\TestCase; |
|||
|
|||
/** |
|||
* Class ConfigurationContractValidatorTest |
|||
*/ |
|||
class ConfigurationContractValidatorTest extends TestCase |
|||
{ |
|||
use WithFaker; |
|||
|
|||
private ConfigurationContractValidator $validator; |
|||
private Configuration $mockConfiguration; |
|||
|
|||
protected function setUp(): void |
|||
{ |
|||
parent::setUp(); |
|||
$this->validator = new ConfigurationContractValidator(); |
|||
|
|||
// Create a basic valid configuration
|
|||
$this->mockConfiguration = $this->createMockConfiguration(); |
|||
} |
|||
|
|||
protected function tearDown(): void |
|||
{ |
|||
Session::flush(); |
|||
parent::tearDown(); |
|||
} |
|||
|
|||
/** |
|||
* Test successful validation with complete valid configuration |
|||
*/ |
|||
public function testValidateConfigurationContractSuccess(): void |
|||
{ |
|||
$this->setupValidSessionData(); |
|||
|
|||
$result = $this->validator->validateConfigurationContract($this->mockConfiguration); |
|||
|
|||
$this->assertTrue($result->isValid()); |
|||
$this->assertEmpty($result->getErrors()); |
|||
} |
|||
|
|||
/** |
|||
* Test validation failure with invalid flow |
|||
*/ |
|||
public function testValidateConfigurationContractInvalidFlow(): void |
|||
{ |
|||
$invalidConfig = $this->createMockConfiguration('nordigen'); |
|||
|
|||
$result = $this->validator->validateConfigurationContract($invalidConfig); |
|||
|
|||
$this->assertFalse($result->isValid()); |
|||
$this->assertNotEmpty($result->getErrors()); |
|||
$this->assertStringContainsString('SimpleFIN flow', $result->getErrorMessages()[0]); |
|||
} |
|||
|
|||
/** |
|||
* Test validation failure with missing session data |
|||
*/ |
|||
public function testValidateConfigurationContractMissingSessionData(): void |
|||
{ |
|||
// Don't set up session data
|
|||
|
|||
$result = $this->validator->validateConfigurationContract($this->mockConfiguration); |
|||
|
|||
$this->assertFalse($result->isValid()); |
|||
$this->assertStringContainsString('SimpleFIN accounts data missing', $result->getErrorMessages()[0]); |
|||
} |
|||
|
|||
/** |
|||
* Test validation failure with invalid SimpleFIN account structure |
|||
*/ |
|||
public function testValidateConfigurationContractInvalidAccountStructure(): void |
|||
{ |
|||
Session::put('simplefin_accounts_data', [ |
|||
[ |
|||
'id' => 'acc1', |
|||
'name' => 'Test Account', |
|||
// Missing required fields: currency, balance, balance-date, org
|
|||
] |
|||
]); |
|||
|
|||
$result = $this->validator->validateConfigurationContract($this->mockConfiguration); |
|||
|
|||
$this->assertFalse($result->isValid()); |
|||
$this->assertTrue(count($result->getErrors()) >= 4); // Missing 4 required fields
|
|||
} |
|||
|
|||
/** |
|||
* Test validation failure with invalid account mappings |
|||
*/ |
|||
public function testValidateConfigurationContractInvalidAccountMappings(): void |
|||
{ |
|||
$this->setupValidSessionData(); |
|||
|
|||
// Create configuration with invalid account mappings
|
|||
$config = $this->createMockConfiguration(); |
|||
$config->setAccounts([ |
|||
'acc1' => -1, // Invalid negative ID
|
|||
'' => 0, // Invalid empty account ID
|
|||
]); |
|||
|
|||
$result = $this->validator->validateConfigurationContract($config); |
|||
|
|||
$this->assertFalse($result->isValid()); |
|||
$this->assertNotEmpty($result->getErrors()); |
|||
} |
|||
|
|||
/** |
|||
* Test validation failure with missing new account configuration |
|||
*/ |
|||
public function testValidateConfigurationContractMissingNewAccountConfig(): void |
|||
{ |
|||
$this->setupValidSessionData(); |
|||
|
|||
// Create configuration with account marked for creation but no new account config
|
|||
$config = $this->createMockConfiguration(); |
|||
$config->setAccounts(['acc1' => 0]); // Mark for creation
|
|||
$config->setNewAccounts([]); // But no configuration
|
|||
|
|||
$result = $this->validator->validateConfigurationContract($config); |
|||
|
|||
$this->assertFalse($result->isValid()); |
|||
$this->assertStringContainsString('New account configuration missing', $result->getErrorMessages()[0]); |
|||
} |
|||
|
|||
/** |
|||
* Test validation failure with invalid new account configuration |
|||
*/ |
|||
public function testValidateConfigurationContractInvalidNewAccountConfig(): void |
|||
{ |
|||
$this->setupValidSessionData(); |
|||
|
|||
$config = $this->createMockConfiguration(); |
|||
$config->setAccounts(['acc1' => 0]); |
|||
$config->setNewAccounts([ |
|||
'acc1' => [ |
|||
'name' => '', // Empty name
|
|||
'type' => 'invalid', // Invalid type
|
|||
'currency' => 'USDD', // Invalid currency format
|
|||
'opening_balance' => 'not_numeric', // Invalid balance
|
|||
] |
|||
]); |
|||
|
|||
$result = $this->validator->validateConfigurationContract($config); |
|||
|
|||
$this->assertFalse($result->isValid()); |
|||
$this->assertTrue(count($result->getErrors()) >= 4); |
|||
} |
|||
|
|||
/** |
|||
* Test validation of liability account requirements |
|||
*/ |
|||
public function testValidateConfigurationContractLiabilityAccountRequirements(): void |
|||
{ |
|||
$this->setupValidSessionData(); |
|||
|
|||
$config = $this->createMockConfiguration(); |
|||
$config->setAccounts(['acc1' => 0]); |
|||
$config->setNewAccounts([ |
|||
'acc1' => [ |
|||
'name' => 'Credit Card', |
|||
'type' => 'liability', |
|||
'currency' => 'USD', |
|||
'opening_balance' => '1000.00', |
|||
// Missing liability_type and liability_direction
|
|||
] |
|||
]); |
|||
|
|||
$result = $this->validator->validateConfigurationContract($config); |
|||
|
|||
$this->assertFalse($result->isValid()); |
|||
$errors = $result->getErrors(); |
|||
$this->assertTrue( |
|||
collect($errors)->contains(fn($error) => str_contains($error['message'], 'Liability type required')) |
|||
); |
|||
$this->assertTrue( |
|||
collect($errors)->contains(fn($error) => str_contains($error['message'], 'Liability direction required')) |
|||
); |
|||
} |
|||
|
|||
/** |
|||
* Test form field structure validation success |
|||
*/ |
|||
public function testValidateFormFieldStructureSuccess(): void |
|||
{ |
|||
$validFormData = [ |
|||
'do_import' => ['acc1' => '1'], |
|||
'accounts' => ['acc1' => 0], |
|||
'new_account' => [ |
|||
'acc1' => [ |
|||
'name' => 'Test Account', |
|||
'type' => 'asset', |
|||
'currency' => 'USD', |
|||
'opening_balance' => '1000.00', |
|||
] |
|||
] |
|||
]; |
|||
|
|||
$result = $this->validator->validateFormFieldStructure($validFormData); |
|||
|
|||
$this->assertTrue($result->isValid()); |
|||
$this->assertEmpty($result->getErrors()); |
|||
} |
|||
|
|||
/** |
|||
* Test form field structure validation failure |
|||
*/ |
|||
public function testValidateFormFieldStructureFailure(): void |
|||
{ |
|||
$invalidFormData = [ |
|||
'do_import' => 'not_array', // Should be array
|
|||
// Missing 'accounts' and 'new_account'
|
|||
]; |
|||
|
|||
$result = $this->validator->validateFormFieldStructure($invalidFormData); |
|||
|
|||
$this->assertFalse($result->isValid()); |
|||
$this->assertTrue(count($result->getErrors()) >= 3); // Missing/invalid fields
|
|||
} |
|||
|
|||
/** |
|||
* Test ValidationResult class functionality |
|||
*/ |
|||
public function testValidationResultClass(): void |
|||
{ |
|||
$errors = [ |
|||
['field' => 'test', 'message' => 'Test error', 'value' => null] |
|||
]; |
|||
$warnings = [ |
|||
['field' => 'test', 'message' => 'Test warning', 'value' => null] |
|||
]; |
|||
|
|||
// Test invalid result
|
|||
$invalidResult = new ValidationResult(false, $errors, $warnings); |
|||
$this->assertFalse($invalidResult->isValid()); |
|||
$this->assertTrue($invalidResult->hasErrors()); |
|||
$this->assertTrue($invalidResult->hasWarnings()); |
|||
$this->assertEquals(['Test error'], $invalidResult->getErrorMessages()); |
|||
$this->assertEquals(['Test warning'], $invalidResult->getWarningMessages()); |
|||
|
|||
// Test valid result
|
|||
$validResult = new ValidationResult(true); |
|||
$this->assertTrue($validResult->isValid()); |
|||
$this->assertFalse($validResult->hasErrors()); |
|||
$this->assertFalse($validResult->hasWarnings()); |
|||
$this->assertEmpty($validResult->getErrors()); |
|||
$this->assertEmpty($validResult->getWarnings()); |
|||
|
|||
// Test toArray
|
|||
$array = $invalidResult->toArray(); |
|||
$this->assertArrayHasKey('valid', $array); |
|||
$this->assertArrayHasKey('errors', $array); |
|||
$this->assertArrayHasKey('warnings', $array); |
|||
$this->assertFalse($array['valid']); |
|||
} |
|||
|
|||
/** |
|||
* Test asset account role validation |
|||
*/ |
|||
public function testAssetAccountRoleValidation(): void |
|||
{ |
|||
$this->setupValidSessionData(); |
|||
|
|||
$config = $this->createMockConfiguration(); |
|||
$config->setAccounts(['acc1' => 0]); |
|||
$config->setNewAccounts([ |
|||
'acc1' => [ |
|||
'name' => 'Savings Account', |
|||
'type' => 'asset', |
|||
'currency' => 'USD', |
|||
'opening_balance' => '1000.00', |
|||
'account_role' => 'invalidRole', // Invalid role
|
|||
] |
|||
]); |
|||
|
|||
$result = $this->validator->validateConfigurationContract($config); |
|||
|
|||
$this->assertFalse($result->isValid()); |
|||
$this->assertTrue( |
|||
collect($result->getErrors())->contains(fn($error) => str_contains($error['message'], 'Invalid account role')) |
|||
); |
|||
} |
|||
|
|||
/** |
|||
* Test import selection validation |
|||
*/ |
|||
public function testImportSelectionValidation(): void |
|||
{ |
|||
$this->setupValidSessionData(); |
|||
Session::put('do_import', ['nonexistent_acc' => '1']); |
|||
|
|||
$result = $this->validator->validateConfigurationContract($this->mockConfiguration); |
|||
|
|||
$this->assertFalse($result->isValid()); |
|||
$this->assertTrue( |
|||
collect($result->getErrors())->contains(fn($error) => str_contains($error['message'], 'selected for import but not in account mappings')) |
|||
); |
|||
} |
|||
|
|||
/** |
|||
* Create a mock Configuration object for testing |
|||
*/ |
|||
private function createMockConfiguration(string $flow = 'simplefin'): Configuration |
|||
{ |
|||
$config = Configuration::fromArray([ |
|||
'flow' => $flow, |
|||
'accounts' => ['acc1' => 1, 'acc2' => 0], |
|||
'new_account' => [ |
|||
'acc2' => [ |
|||
'name' => 'New Account', |
|||
'type' => 'asset', |
|||
'currency' => 'USD', |
|||
'opening_balance' => '1000.00', |
|||
'account_role' => 'defaultAsset', |
|||
] |
|||
] |
|||
]); |
|||
|
|||
return $config; |
|||
} |
|||
|
|||
/** |
|||
* Set up valid session data for testing |
|||
*/ |
|||
private function setupValidSessionData(): void |
|||
{ |
|||
Session::put('simplefin_accounts_data', [ |
|||
[ |
|||
'id' => 'acc1', |
|||
'name' => 'Test Account 1', |
|||
'currency' => 'USD', |
|||
'balance' => '1000.00', |
|||
'balance-date' => time(), |
|||
'org' => ['name' => 'Test Bank'], |
|||
'extra' => [] |
|||
], |
|||
[ |
|||
'id' => 'acc2', |
|||
'name' => 'Test Account 2', |
|||
'currency' => 'USD', |
|||
'balance' => '2000.00', |
|||
'balance-date' => time(), |
|||
'org' => ['name' => 'Test Bank'], |
|||
'extra' => [] |
|||
] |
|||
]); |
|||
|
|||
Session::put('do_import', [ |
|||
'acc1' => '1', |
|||
'acc2' => '1' |
|||
]); |
|||
} |
|||
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue