You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

397 lines
18 KiB

<?php
/*
* ConfigurationController.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 Exception;
use JsonException;
use App\Exceptions\AgreementExpiredException;
use App\Exceptions\ImporterErrorException;
use App\Http\Controllers\Controller;
use App\Http\Middleware\ConfigurationControllerMiddleware;
use App\Http\Request\ConfigurationPostRequest;
use App\Services\CSV\Converter\Date;
use App\Services\CSV\Mapper\TransactionCurrencies;
use App\Services\Session\Constants;
use App\Services\Shared\Configuration\Configuration;
use App\Services\Shared\File\FileContentSherlock;
use App\Services\SimpleFIN\Validation\ConfigurationContractValidator;
use App\Services\Storage\StorageService;
use App\Support\Http\RestoresConfiguration;
use App\Support\Internal\CollectsAccounts;
use App\Support\Internal\MergesAccountLists;
use Carbon\Carbon;
use Illuminate\Contracts\View\Factory;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
/**
* Class ConfigurationController
* TODO for spectre and nordigen duplicate detection is only on transaction id
*/
class ConfigurationController extends Controller
{
use CollectsAccounts;
use MergesAccountLists;
use RestoresConfiguration;
/**
* StartController constructor.
*/
public function __construct()
{
parent::__construct();
app('view')->share('pageTitle', 'Configuration');
$this->middleware(ConfigurationControllerMiddleware::class);
}
/**
* @return Factory|RedirectResponse|View
*/
public function index(Request $request)
{
Log::debug(sprintf('Now at %s', __METHOD__));
$mainTitle = 'Configuration';
$subTitle = 'Configure your import';
$flow = $request->cookie(Constants::FLOW_COOKIE); // TODO should be from configuration right
$configuration = $this->restoreConfiguration();
// if config says to skip it, skip it:
$overruleSkip = 'true' === $request->get('overruleskip');
if (true === $configuration->isSkipForm() && false === $overruleSkip) {
Log::debug('Skip configuration, go straight to the next step.');
// set config as complete.
session()->put(Constants::CONFIG_COMPLETE_INDICATOR, true);
if ('nordigen' === $configuration->getFlow() || 'spectre' === $configuration->getFlow()) {
// at this point, nordigen is ready for data conversion.
session()->put(Constants::READY_FOR_CONVERSION, true);
}
// skipForm
return redirect()->route('005-roles.index');
}
// collect Firefly III accounts
$fireflyIIIaccounts = $this->getFireflyIIIAccounts();
// possibilities for duplicate detection (unique columns)
// also get the nordigen / spectre accounts
$importerAccounts = [];
$uniqueColumns = config('csv.unique_column_options');
if ('nordigen' === $flow) {
// TODO here we need to redirect to Nordigen.
try {
$importerAccounts = $this->getNordigenAccounts($configuration);
} catch (AgreementExpiredException $e) {
Log::error($e->getMessage());
// remove thing from configuration
$configuration->clearRequisitions();
// save configuration in session and on disk:
session()->put(Constants::CONFIGURATION, $configuration->toSessionArray());
$configFileName = StorageService::storeContent((string)json_encode($configuration->toArray(), JSON_PRETTY_PRINT));
session()->put(Constants::UPLOAD_CONFIG_FILE, $configFileName);
// redirect to selection.
return redirect()->route('009-selection.index');
}
$uniqueColumns = config('nordigen.unique_column_options');
$importerAccounts = $this->mergeNordigenAccountLists($importerAccounts, $fireflyIIIaccounts);
}
if ('spectre' === $flow) {
$importerAccounts = $this->getSpectreAccounts($configuration);
$uniqueColumns = config('spectre.unique_column_options');
$importerAccounts = $this->mergeSpectreAccountLists($importerAccounts, $fireflyIIIaccounts);
}
if ('simplefin' === $flow) {
$importerAccounts = $this->getSimpleFINAccounts();
$uniqueColumns = config('simplefin.unique_column_options', ['id']);
$importerAccounts = $this->mergeSimpleFINAccountLists($importerAccounts, $fireflyIIIaccounts);
}
if ('file' === $flow) {
// detect content type and save to config object.
$detector = new FileContentSherlock();
$content = StorageService::getContent(session()->get(Constants::UPLOAD_DATA_FILE), $configuration->isConversion());
$fileType = $detector->detectContentTypeFromContent($content);
$configuration->setContentType($fileType);
}
// Get currency data for SimpleFIN account creation widget
$currencies = $this->getCurrencies();
return view('import.004-configure.index', compact('mainTitle', 'subTitle', 'fireflyIIIaccounts', 'configuration', 'flow', 'importerAccounts', 'uniqueColumns', 'currencies'));
}
/**
* Get SimpleFIN accounts from session data
*/
private function getSimpleFINAccounts(): array
{
$accountsData = session()->get(Constants::SIMPLEFIN_ACCOUNTS_DATA, []);
$accounts = [];
foreach ($accountsData ?? [] as $account) {
// Ensure the account has required SimpleFIN protocol fields
if (!array_key_exists('id', $account) || '' === (string)$account['id']) {
Log::warning('SimpleFIN account data is missing a valid ID, skipping.', ['account_data' => $account]);
continue;
}
if (!array_key_exists('name', $account) || null === $account['name']) {
Log::warning('SimpleFIN account data is missing name field, adding default.', ['account_id' => $account['id']]);
$account['name'] = sprintf('Unknown Account (ID: %s)',$account['id']);
}
if (!array_key_exists('currency', $account) || null === $account['currency']) {
Log::warning('SimpleFIN account data is missing currency field, this may cause issues.', ['account_id' => $account['id']]);
}
if (!array_key_exists('balance', $account) || null === $account['balance']) {
Log::warning('SimpleFIN account data is missing balance field, this may cause issues.', ['account_id' => $account['id']]);
}
// Preserve raw SimpleFIN protocol data structure
$accounts[] = $account;
}
return $accounts;
}
/**
* Merge SimpleFIN accounts with Firefly III accounts
*/
private function mergeSimpleFINAccountLists(array $simplefinAccounts, array $fireflyAccounts): array
{
$return = [];
foreach ($simplefinAccounts as $sfinAccountData) {
// $sfinAccountData is raw SimpleFIN protocol data with fields:
// ['id', 'name', 'currency', 'balance', 'balance-date', 'org', etc.]
$importAccountRepresentation = (object)['id' => $sfinAccountData['id'], // Expected by component for form elements, and by getMappedTo (as 'identifier')
'name' => $sfinAccountData['name'], // Expected by getMappedTo, display in component
'status' => 'active', // Expected by view for status checks
'currency' => $sfinAccountData['currency'] ?? null, // SimpleFIN currency field
'balance' => $sfinAccountData['balance'] ?? null, // SimpleFIN balance (numeric string)
'balance_date' => $sfinAccountData['balance-date'] ?? null, // SimpleFIN balance timestamp
'org' => $sfinAccountData['org'] ?? null, // SimpleFIN organization data
'iban' => null, // Placeholder for consistency if component expects it
'extra' => $sfinAccountData['extra'] ?? [], // SimpleFIN extra data
'bic' => null, // Placeholder
'product' => null, // Placeholder
'cashAccountType' => null, // Placeholder
'usage' => null, // Placeholder
'resourceId' => null, // Placeholder
'bban' => null, // Placeholder
'ownerName' => null, // Placeholder
];
$return[] = ['import_account' => $importAccountRepresentation, // The DTO-like object for the component
'name' => $sfinAccountData['name'], // SimpleFIN account name
'id' => $sfinAccountData['id'], // ID for form fields (do_import[ID], accounts[ID])
'mapped_to' => $this->getMappedTo((object)['identifier' => $importAccountRepresentation->id, 'name' => $importAccountRepresentation->name], $fireflyAccounts), // getMappedTo needs 'identifier'
'type' => 'source', // Indicates it's an account from the import source
'firefly_iii_accounts' => $fireflyAccounts, // Required by x-importer-account component
];
}
return $return;
}
/**
* Stub for determining if an imported account is mapped to a Firefly III account.
* TODO: Implement actual mapping logic.
* TODO get rid of object casting.
*
* @param object $importAccount An object representing the account from the import source.
* Expected to have at least 'identifier' and 'name' properties.
* @param array $fireflyAccounts array of existing Firefly III accounts
*
* @return ?string the ID of the mapped Firefly III account, or null if not mapped
*/
private function getMappedTo(object $importAccount, array $fireflyAccounts): ?string
{
$importAccountName = $importAccount->name ?? null; // @phpstan-ignore-line
if ('' === (string) $importAccountName || null === $importAccountName) { // same thing really.
return null;
}
// Check assets accounts for name match
if (array_key_exists('assets', $fireflyAccounts) && is_array($fireflyAccounts['assets'])) {
foreach ($fireflyAccounts['assets'] as $fireflyAccount) {
$fireflyAccountName = $fireflyAccount->name ?? null;
if (null !== $fireflyAccountName && '' !== $fireflyAccountName && trim(strtolower((string) $fireflyAccountName)) === trim(strtolower($importAccountName))) {
return (string)$fireflyAccount->id;
}
}
}
// Check liability accounts for name match
if (array_key_exists('liabilities', $fireflyAccounts) && is_array($fireflyAccounts['liabilities'])) {
foreach ($fireflyAccounts['liabilities'] as $fireflyAccount) {
$fireflyAccountName = $fireflyAccount->name ?? null;
if (null !== $fireflyAccountName && '' !== $fireflyAccountName && trim(strtolower((string) $fireflyAccountName)) === trim(strtolower($importAccountName))) {
return (string)$fireflyAccount->id;
}
}
}
return null;
}
/**
* Get available currencies from Firefly III for account creation
*/
private function getCurrencies(): array
{
try {
/** @var TransactionCurrencies $mapper */
$mapper = app(TransactionCurrencies::class);
return $mapper->getMap();
} catch (Exception $e) {
Log::error(sprintf('Failed to load currencies: %s',$e->getMessage()));
return [];
}
}
public function phpDate(Request $request): JsonResponse
{
Log::debug(sprintf('Method %s', __METHOD__));
$dateObj = new Date();
[$locale, $format] = $dateObj->splitLocaleFormat((string)$request->get('format'));
/** @var Carbon $date */
$date = today()->locale($locale);
return response()->json(['result' => $date->translatedFormat($format)]);
}
/**
* @throws ImporterErrorException
*/
public function postIndex(ConfigurationPostRequest $request): RedirectResponse
{
Log::debug(sprintf('Now running %s', __METHOD__));
// store config on drive.v
$fromRequest = $request->getAll();
$configuration = Configuration::fromRequest($fromRequest);
$configuration->setFlow($request->cookie(Constants::FLOW_COOKIE));
// TODO are all fields actually in the config?
// loop accounts:
$accounts = [];
foreach (array_keys($fromRequest['do_import']) as $identifier) {
if (array_key_exists($identifier, $fromRequest['accounts'])) {
$accountValue = (int)$fromRequest['accounts'][$identifier];
$accounts[$identifier] = $accountValue;
}
if (!array_key_exists($identifier, $fromRequest['accounts'])) {
Log::warning(sprintf('Account identifier %s in do_import but not in accounts array', $identifier));
}
}
$configuration->setAccounts($accounts);
// Store new account creation data
$newAccounts = $fromRequest['new_account'] ?? [];
$configuration->setNewAccounts($newAccounts);
// Store do_import selections in session for validation
session()->put('do_import', $fromRequest['do_import'] ?? []);
// Validate configuration contract for SimpleFIN
if ('simplefin' === $configuration->getFlow()) {
$validator = new ConfigurationContractValidator();
// Validate form structure first
$formValidation = $validator->validateFormFieldStructure($fromRequest);
if (!$formValidation->isValid()) {
Log::error('SimpleFIN form validation failed', $formValidation->getErrors());
return redirect()->back()->withErrors($formValidation->getErrorMessages())->withInput();
}
// Validate complete configuration contract
$contractValidation = $validator->validateConfigurationContract($configuration);
if (!$contractValidation->isValid()) {
Log::error('SimpleFIN configuration contract validation failed', $contractValidation->getErrors());
return redirect()->back()->withErrors($contractValidation->getErrorMessages())->withInput();
}
if ($contractValidation->hasWarnings()) {
Log::warning('SimpleFIN configuration contract warnings', $contractValidation->getWarnings());
}
}
$configuration->updateDateRange();
// Map data option is now user-selectable for SimpleFIN via checkbox
$json = '{}';
try {
$json = json_encode($configuration->toArray(), JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT);
} catch (JsonException $e) {
Log::error($e->getMessage());
throw new ImporterErrorException($e->getMessage(), 0, $e);
}
StorageService::storeContent($json);
session()->put(Constants::CONFIGURATION, $configuration->toSessionArray());
// set config as complete.
session()->put(Constants::CONFIG_COMPLETE_INDICATOR, true);
if ('nordigen' === $configuration->getFlow() || 'spectre' === $configuration->getFlow() || 'simplefin' === $configuration->getFlow()) {
// at this point, nordigen, spectre, and simplefin are ready for data conversion.
session()->put(Constants::READY_FOR_CONVERSION, true);
}
// always redirect to roles, even if this isn't the step yet
// for nordigen, spectre, and simplefin, roles will be skipped right away.
return redirect(route('005-roles.index'));
}
}