diff --git a/app/Http/Controllers/Import/BankController.php b/app/Http/Controllers/Import/BankController.php index 9358481970..125f8968d3 100644 --- a/app/Http/Controllers/Import/BankController.php +++ b/app/Http/Controllers/Import/BankController.php @@ -22,8 +22,9 @@ declare(strict_types=1); namespace FireflyIII\Http\Controllers\Import; +use FireflyIII\Exceptions\FireflyException; use FireflyIII\Http\Controllers\Controller; -use FireflyIII\Support\Import\Information\InformationInterface; +use FireflyIII\Repositories\ImportJob\ImportJobRepositoryInterface; use FireflyIII\Support\Import\Prerequisites\PrerequisitesInterface; use Illuminate\Http\Request; use Log; @@ -31,66 +32,27 @@ use Session; class BankController extends Controller { - /** - * This method must ask the user all parameters necessary to start importing data. This may not be enough - * to finish the import itself (ie. mapping) but it should be enough to begin: accounts to import from, - * accounts to import into, data ranges, etc. - * - * @param string $bank - * - * @return \Illuminate\Contracts\View\Factory|\Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector|\Illuminate\View\View - */ - public function form(string $bank) - { - $class = config(sprintf('firefly.import_pre.%s', $bank)); - /** @var PrerequisitesInterface $object */ - $object = app($class); - $object->setUser(auth()->user()); - - if ($object->hasPrerequisites()) { - return redirect(route('import.bank.prerequisites', [$bank])); - } - $class = config(sprintf('firefly.import_info.%s', $bank)); - /** @var InformationInterface $object */ - $object = app($class); - $object->setUser(auth()->user()); - $remoteAccounts = $object->getAccounts(); - - return view('import.bank.form', compact('remoteAccounts', 'bank')); - } /** - * With the information given in the submitted form Firefly III will call upon the bank's classes to return transaction - * information as requested. The user will be able to map unknown data and continue. Or maybe, it's put into some kind of - * fake CSV file and forwarded to the import routine. + * Once there are no prerequisites, this method will create an importjob object and + * redirect the user to a view where this object can be used by a bank specific + * class to process. * - * @param Request $request - * @param string $bank + * @param ImportJobRepositoryInterface $repository + * @param string $bank * * @return \Illuminate\Http\RedirectResponse|null + * @throws FireflyException */ - public function postForm(Request $request, string $bank) + public function createJob(ImportJobRepositoryInterface $repository, string $bank) { $class = config(sprintf('firefly.import_pre.%s', $bank)); - /** @var PrerequisitesInterface $object */ - $object = app($class); - $object->setUser(auth()->user()); - - if ($object->hasPrerequisites()) { - return redirect(route('import.bank.prerequisites', [$bank])); + if (!class_exists($class)) { + throw new FireflyException(sprintf('Cannot find class %s', $class)); } - $remoteAccounts = $request->get('do_import'); - if (!is_array($remoteAccounts) || 0 === count($remoteAccounts)) { - Session::flash('error', 'Must select accounts'); + $importJob = $repository->create($bank); - return redirect(route('import.bank.form', [$bank])); - } - $remoteAccounts = array_keys($remoteAccounts); - $class = config(sprintf('firefly.import_pre.%s', $bank)); - // get import file - unset($remoteAccounts, $class); - - // get import config + return redirect(route('import.file.configure', [$importJob->key])); } /** @@ -105,18 +67,22 @@ class BankController extends Controller * @param string $bank * * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector + * @throws FireflyException */ public function postPrerequisites(Request $request, string $bank) { Log::debug(sprintf('Now in postPrerequisites for %s', $bank)); $class = config(sprintf('firefly.import_pre.%s', $bank)); + if (!class_exists($class)) { + throw new FireflyException(sprintf('Cannot find class %s', $class)); + } /** @var PrerequisitesInterface $object */ $object = app($class); $object->setUser(auth()->user()); if (!$object->hasPrerequisites()) { Log::debug(sprintf('No more prerequisites for %s, move to form.', $bank)); - return redirect(route('import.bank.form', [$bank])); + return redirect(route('import.bank.create-job', [$bank])); } Log::debug('Going to store entered preprerequisites.'); // store post data @@ -128,7 +94,7 @@ class BankController extends Controller return redirect(route('import.bank.prerequisites', [$bank])); } - return redirect(route('import.bank.form', [$bank])); + return redirect(route('import.bank.create-job', [$bank])); } /** @@ -138,21 +104,26 @@ class BankController extends Controller * @param string $bank * * @return \Illuminate\Contracts\View\Factory|\Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector|\Illuminate\View\View + * @throws FireflyException */ public function prerequisites(string $bank) { $class = config(sprintf('firefly.import_pre.%s', $bank)); + if (!class_exists($class)) { + throw new FireflyException(sprintf('Cannot find class %s', $class)); + } /** @var PrerequisitesInterface $object */ $object = app($class); $object->setUser(auth()->user()); if ($object->hasPrerequisites()) { $view = $object->getView(); - $parameters = $object->getViewParameters(); + $parameters = ['title' => strval(trans('firefly.import_index_title')), 'mainTitleIcon' => 'fa-archive']; + $parameters = $object->getViewParameters() + $parameters; return view($view, $parameters); } - return redirect(route('import.bank.form', [$bank])); + return redirect(route('import.bank.create-job', [$bank])); } } diff --git a/app/Import/Configurator/SpectreConfigurator.php b/app/Import/Configurator/SpectreConfigurator.php new file mode 100644 index 0000000000..da118f8363 --- /dev/null +++ b/app/Import/Configurator/SpectreConfigurator.php @@ -0,0 +1,185 @@ +. + */ +declare(strict_types=1); + +namespace FireflyIII\Import\Configurator; + +use FireflyIII\Exceptions\FireflyException; +use FireflyIII\Models\ImportJob; +use FireflyIII\Support\Import\Configuration\ConfigurationInterface; +use FireflyIII\Support\Import\Configuration\Spectre\SelectProvider; +use FireflyIII\Support\Import\Configuration\Spectre\InputMandatory; +use FireflyIII\Support\Import\Configuration\Spectre\SelectCountry; +use Log; + +/** + * Class SpectreConfigurator. + */ +class SpectreConfigurator implements ConfiguratorInterface +{ + /** @var ImportJob */ + private $job; + + /** @var string */ + private $warning = ''; + + /** + * ConfiguratorInterface constructor. + */ + public function __construct() + { + } + + /** + * Store any data from the $data array into the job. + * + * @param array $data + * + * @return bool + * + * @throws FireflyException + */ + public function configureJob(array $data): bool + { + $class = $this->getConfigurationClass(); + $job = $this->job; + /** @var ConfigurationInterface $object */ + $object = new $class($this->job); + $object->setJob($job); + $result = $object->storeConfiguration($data); + $this->warning = $object->getWarningMessage(); + + return $result; + } + + /** + * Return the data required for the next step in the job configuration. + * + * @return array + * + * @throws FireflyException + */ + public function getNextData(): array + { + $class = $this->getConfigurationClass(); + $job = $this->job; + /** @var ConfigurationInterface $object */ + $object = app($class); + $object->setJob($job); + + return $object->getData(); + } + + /** + * @return string + * + * @throws FireflyException + */ + public function getNextView(): string + { + if (!$this->job->configuration['selected-country']) { + return 'import.spectre.select-country'; + } + if (!$this->job->configuration['selected-provider']) { + return 'import.spectre.select-provider'; + } + if (!$this->job->configuration['has-input-mandatory']) { + return 'import.spectre.input-fields'; + } + + throw new FireflyException('No view for state'); + } + + /** + * Return possible warning to user. + * + * @return string + */ + public function getWarningMessage(): string + { + return $this->warning; + } + + /** + * @return bool + */ + public function isJobConfigured(): bool + { + $config = $this->job->configuration; + $config['selected-country'] = $config['selected-country'] ?? false; + $config['selected-provider'] = $config['selected-provider'] ?? false; + $config['has-input-mandatory'] = $config['has-input-mandatory'] ?? false; + $this->job->configuration = $config; + $this->job->save(); + + if ($config['selected-country'] && $config['selected-provider'] && $config['has-input-mandatory'] && false) { + return true; + } + + return false; + } + + /** + * @param ImportJob $job + */ + public function setJob(ImportJob $job) + { + $this->job = $job; + if (null === $this->job->configuration || 0 === count($this->job->configuration)) { + Log::debug(sprintf('Gave import job %s initial configuration.', $this->job->key)); + $this->job->configuration = [ + 'selected-country' => false, + ]; + $this->job->save(); + } + } + + /** + * @return string + * + * @throws FireflyException + */ + private function getConfigurationClass(): string + { + $class = false; + switch (true) { + case !$this->job->configuration['selected-country']: + $class = SelectCountry::class; + break; + case !$this->job->configuration['selected-provider']: + $class = SelectProvider::class; + break; + case !$this->job->configuration['has-input-mandatory']: + $class = InputMandatory::class; + default: + break; + } + + if (false === $class || 0 === strlen($class)) { + throw new FireflyException('Cannot handle current job state in getConfigurationClass().'); + } + if (!class_exists($class)) { + throw new FireflyException(sprintf('Class %s does not exist in getConfigurationClass().', $class)); + } + + return $class; + } +} diff --git a/app/Jobs/GetSpectreProviders.php b/app/Jobs/GetSpectreProviders.php new file mode 100644 index 0000000000..0184d6ce4f --- /dev/null +++ b/app/Jobs/GetSpectreProviders.php @@ -0,0 +1,77 @@ +user = $user; + Log::debug('Constructed job GetSpectreProviders'); + } + + /** + * Execute the job. + */ + public function handle() + { + /** @var Configuration $configValue */ + $configValue = app('fireflyconfig')->get('spectre_provider_download', 0); + $now = time(); + if ($now - intval($configValue->data) < 86400) { + Log::debug(sprintf('Difference is %d, so will NOT execute job.', ($now - intval($configValue->data)))); + + return; + } + Log::debug(sprintf('Difference is %d, so will execute job.', ($now - intval($configValue->data)))); + + // get user + + // fire away! + $request = new ListProvidersRequest($this->user); + $request->call(); + + // store all providers: + $providers = $request->getProviders(); + foreach ($providers as $provider) { + // find provider? + $dbProvider = SpectreProvider::where('spectre_id', $provider['id'])->first(); + if (is_null($dbProvider)) { + $dbProvider = new SpectreProvider; + } + // update fields: + $dbProvider->spectre_id = $provider['id']; + $dbProvider->code = $provider['code']; + $dbProvider->mode = $provider['mode']; + $dbProvider->status = $provider['status']; + $dbProvider->interactive = $provider['interactive'] === 1; + $dbProvider->automatic_fetch = $provider['automatic_fetch'] === 1; + $dbProvider->country_code = $provider['country_code']; + $dbProvider->data = $provider; + $dbProvider->save(); + Log::debug(sprintf('Stored provider #%d under ID #%d', $provider['id'], $dbProvider->id)); + } + + app('fireflyconfig')->set('spectre_provider_download', time()); + + return; + } +} diff --git a/app/Models/SpectreProvider.php b/app/Models/SpectreProvider.php new file mode 100644 index 0000000000..4cdf9e9d55 --- /dev/null +++ b/app/Models/SpectreProvider.php @@ -0,0 +1,32 @@ + 'int', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'deleted_at' => 'datetime', + 'interactive' => 'boolean', + 'automatic_fetch' => 'boolean', + 'data' => 'array', + ]; + + protected $fillable = ['spectre_id', 'code', 'mode', 'name', 'status', 'interactive', 'automatic_fetch', 'country_code', 'data']; + +} \ No newline at end of file diff --git a/app/Services/Bunq/Request/BunqRequest.php b/app/Services/Bunq/Request/BunqRequest.php index dcc4a23729..ca73d0443c 100644 --- a/app/Services/Bunq/Request/BunqRequest.php +++ b/app/Services/Bunq/Request/BunqRequest.php @@ -160,6 +160,9 @@ abstract class BunqRequest return $result; } + /** + * @return array + */ protected function getDefaultHeaders(): array { $userAgent = sprintf('FireflyIII v%s', config('firefly.version')); diff --git a/app/Services/Spectre/Request/ListProvidersRequest.php b/app/Services/Spectre/Request/ListProvidersRequest.php new file mode 100644 index 0000000000..a4abd1ad2c --- /dev/null +++ b/app/Services/Spectre/Request/ListProvidersRequest.php @@ -0,0 +1,80 @@ +. + */ +declare(strict_types=1); + +namespace FireflyIII\Services\Spectre\Request; + +use Log; + +/** + * Class ListUserRequest. + */ +class ListProvidersRequest extends SpectreRequest +{ + protected $providers = []; + + /** + * + */ + public function call(): void + { + $hasNextPage = true; + $nextId = 0; + while ($hasNextPage) { + Log::debug(sprintf('Now calling for next_id %d', $nextId)); + $parameters = ['include_fake_providers' => 'true', 'include_provider_fields' => 'true', 'from_id' => $nextId]; + $uri = '/api/v3/providers?' . http_build_query($parameters); + $response = $this->sendSignedSpectreGet($uri, []); + + // count entries: + Log::debug(sprintf('Found %d entries in data-array', count($response['data']))); + + // extract next ID + $hasNextPage = false; + if (isset($response['meta']['next_id']) && intval($response['meta']['next_id']) > $nextId) { + $hasNextPage = true; + $nextId = $response['meta']['next_id']; + Log::debug(sprintf('Next ID is now %d.', $nextId)); + } else { + Log::debug('No next page.'); + } + + // store providers: + foreach ($response['data'] as $providerArray) { + $providerId = $providerArray['id']; + $this->providers[$providerId] = $providerArray; + Log::debug(sprintf('Stored provider #%d', $providerId)); + } + } + + return; + } + + /** + * @return array + */ + public function getProviders(): array + { + return $this->providers; + } + + +} diff --git a/app/Services/Spectre/Request/SpectreRequest.php b/app/Services/Spectre/Request/SpectreRequest.php new file mode 100644 index 0000000000..a7ce46f4d5 --- /dev/null +++ b/app/Services/Spectre/Request/SpectreRequest.php @@ -0,0 +1,378 @@ +. + */ +declare(strict_types=1); + +namespace FireflyIII\Services\Spectre\Request; + +use Exception; +use FireflyIII\Exceptions\FireflyException; +use FireflyIII\User; +use Log; +use Requests; +use Requests_Exception; + +//use FireflyIII\Services\Bunq\Object\ServerPublicKey; + +/** + * Class BunqRequest. + */ +abstract class SpectreRequest +{ + /** @var string */ + protected $clientId = ''; + protected $expiresAt = 0; + /** @var ServerPublicKey */ + protected $serverPublicKey; + /** @var string */ + protected $serviceSecret = ''; + /** @var string */ + private $privateKey = ''; + /** @var string */ + private $server = ''; + /** @var User */ + private $user; + + /** + * SpectreRequest constructor. + */ + public function __construct(User $user) + { + $this->user = $user; + $this->server = config('firefly.spectre.server'); + $this->expiresAt = time() + 180; + $privateKey = app('preferences')->get('spectre_private_key', null); + $this->privateKey = $privateKey->data; + + // set client ID + $clientId = app('preferences')->get('spectre_client_id', null); + $this->clientId = $clientId->data; + + // set service secret + $serviceSecret = app('preferences')->get('spectre_service_secret', null); + $this->serviceSecret = $serviceSecret->data; + } + + /** + * + */ + abstract public function call(): void; + + /** + * @return string + */ + public function getClientId(): string + { + return $this->clientId; + } + + /** + * @param string $clientId + */ + public function setClientId(string $clientId): void + { + $this->clientId = $clientId; + } + + /** + * @return string + */ + public function getServer(): string + { + return $this->server; + } + + /** + * @return ServerPublicKey + */ + public function getServerPublicKey(): ServerPublicKey + { + return $this->serverPublicKey; + } + + /** + * @param ServerPublicKey $serverPublicKey + */ + public function setServerPublicKey(ServerPublicKey $serverPublicKey) + { + $this->serverPublicKey = $serverPublicKey; + } + + /** + * @return string + */ + public function getServiceSecret(): string + { + return $this->serviceSecret; + } + + /** + * @param string $serviceSecret + */ + public function setServiceSecret(string $serviceSecret): void + { + $this->serviceSecret = $serviceSecret; + } + + /** + * @param string $privateKey + */ + public function setPrivateKey(string $privateKey) + { + $this->privateKey = $privateKey; + } + + /** + * @param string $secret + */ + public function setSecret(string $secret) + { + $this->secret = $secret; + } + + /** + * @param string $method + * @param string $uri + * @param string $data + * + * @return string + * + * @throws FireflyException + */ + protected function generateSignature(string $method, string $uri, string $data): string + { + if (0 === strlen($this->privateKey)) { + throw new FireflyException('No private key present.'); + } + if ('get' === strtolower($method) || 'delete' === strtolower($method)) { + $data = ''; + } + // base64(sha1_signature(private_key, "Expires-at|request_method|original_url|post_body|md5_of_uploaded_file|"))) + // Prepare the signature + $toSign = $this->expiresAt . '|' . strtoupper($method) . '|' . $uri . '|' . $data . ''; // no file so no content there. + Log::debug(sprintf('String to sign: %s', $toSign)); + $signature = ''; + + // Sign the data + openssl_sign($toSign, $signature, $this->privateKey, OPENSSL_ALGO_SHA1); + $signature = base64_encode($signature); + + return $signature; + } + + /** + * @return array + */ + protected function getDefaultHeaders(): array + { + $userAgent = sprintf('FireflyIII v%s', config('firefly.version')); + + return [ + 'Client-Id' => $this->getClientId(), + 'Service-Secret' => $this->getServiceSecret(), + 'Accept' => 'application/json', + 'Content-type' => 'application/json', + 'Cache-Control' => 'no-cache', + 'User-Agent' => $userAgent, + 'Expires-at' => $this->expiresAt, + ]; + } + + /** + * @param string $uri + * @param array $headers + * + * @return array + * + * @throws Exception + */ + protected function sendSignedBunqDelete(string $uri, array $headers): array + { + if (0 === strlen($this->server)) { + throw new FireflyException('No bunq server defined'); + } + + $fullUri = $this->server . $uri; + $signature = $this->generateSignature('delete', $uri, $headers, ''); + $headers['X-Bunq-Client-Signature'] = $signature; + try { + $response = Requests::delete($fullUri, $headers); + } catch (Requests_Exception $e) { + return ['Error' => [0 => ['error_description' => $e->getMessage(), 'error_description_translated' => $e->getMessage()]]]; + } + + $body = $response->body; + $array = json_decode($body, true); + $responseHeaders = $response->headers->getAll(); + $statusCode = intval($response->status_code); + $array['ResponseHeaders'] = $responseHeaders; + $array['ResponseStatusCode'] = $statusCode; + + Log::debug(sprintf('Response to DELETE %s is %s', $fullUri, $body)); + if ($this->isErrorResponse($array)) { + $this->throwResponseError($array); + } + + if (!$this->verifyServerSignature($body, $responseHeaders, $statusCode)) { + throw new FireflyException(sprintf('Could not verify signature for request to "%s"', $uri)); + } + + return $array; + } + + /** + * @param string $uri + * @param array $data + * @param array $headers + * + * @return array + * + * @throws Exception + */ + protected function sendSignedBunqPost(string $uri, array $data, array $headers): array + { + $body = json_encode($data); + $fullUri = $this->server . $uri; + $signature = $this->generateSignature('post', $uri, $headers, $body); + $headers['X-Bunq-Client-Signature'] = $signature; + try { + $response = Requests::post($fullUri, $headers, $body); + } catch (Requests_Exception $e) { + return ['Error' => [0 => ['error_description' => $e->getMessage(), 'error_description_translated' => $e->getMessage()]]]; + } + + $body = $response->body; + $array = json_decode($body, true); + $responseHeaders = $response->headers->getAll(); + $statusCode = intval($response->status_code); + $array['ResponseHeaders'] = $responseHeaders; + $array['ResponseStatusCode'] = $statusCode; + + if ($this->isErrorResponse($array)) { + $this->throwResponseError($array); + } + + if (!$this->verifyServerSignature($body, $responseHeaders, $statusCode)) { + throw new FireflyException(sprintf('Could not verify signature for request to "%s"', $uri)); + } + + return $array; + } + + /** + * @param string $uri + * @param array $data + * @param array $headers + * + * @return array + * + * @throws Exception + */ + protected function sendSignedSpectreGet(string $uri, array $data): array + { + if (0 === strlen($this->server)) { + throw new FireflyException('No Spectre server defined'); + } + + $headers = $this->getDefaultHeaders(); + $body = json_encode($data); + $fullUri = $this->server . $uri; + $signature = $this->generateSignature('get', $fullUri, $body); + $headers['Signature'] = $signature; + + Log::debug('Final headers for spectre signed get request:', $headers); + try { + $response = Requests::get($fullUri, $headers); + } catch (Requests_Exception $e) { + throw new FireflyException(sprintf('Request Exception: %s', $e->getMessage())); + } + $statusCode = intval($response->status_code); + + if ($statusCode !== 200) { + throw new FireflyException(sprintf('Status code %d: %s', $statusCode, $response->body)); + } + + $body = $response->body; + $array = json_decode($body, true); + $responseHeaders = $response->headers->getAll(); + $array['ResponseHeaders'] = $responseHeaders; + $array['ResponseStatusCode'] = $statusCode; + + return $array; + } + + /** + * @param string $uri + * @param array $headers + * + * @return array + */ + protected function sendUnsignedBunqDelete(string $uri, array $headers): array + { + $fullUri = $this->server . $uri; + try { + $response = Requests::delete($fullUri, $headers); + } catch (Requests_Exception $e) { + return ['Error' => [0 => ['error_description' => $e->getMessage(), 'error_description_translated' => $e->getMessage()]]]; + } + $body = $response->body; + $array = json_decode($body, true); + $responseHeaders = $response->headers->getAll(); + $statusCode = $response->status_code; + $array['ResponseHeaders'] = $responseHeaders; + $array['ResponseStatusCode'] = $statusCode; + + if ($this->isErrorResponse($array)) { + $this->throwResponseError($array); + } + + return $array; + } + + /** + * @param string $uri + * @param array $data + * @param array $headers + * + * @return array + */ + protected function sendUnsignedBunqPost(string $uri, array $data, array $headers): array + { + $body = json_encode($data); + $fullUri = $this->server . $uri; + try { + $response = Requests::post($fullUri, $headers, $body); + } catch (Requests_Exception $e) { + return ['Error' => [0 => ['error_description' => $e->getMessage(), 'error_description_translated' => $e->getMessage()]]]; + } + $body = $response->body; + $array = json_decode($body, true); + $responseHeaders = $response->headers->getAll(); + $statusCode = $response->status_code; + $array['ResponseHeaders'] = $responseHeaders; + $array['ResponseStatusCode'] = $statusCode; + + if ($this->isErrorResponse($array)) { + $this->throwResponseError($array); + } + + return $array; + } +} diff --git a/app/Support/Import/Configuration/Spectre/InputMandatory.php b/app/Support/Import/Configuration/Spectre/InputMandatory.php new file mode 100644 index 0000000000..b1cf305b5f --- /dev/null +++ b/app/Support/Import/Configuration/Spectre/InputMandatory.php @@ -0,0 +1,101 @@ +job->configuration; + $providerId = $config['provider']; + $provider = SpectreProvider::where('spectre_id', $providerId)->first(); + if (is_null($provider)) { + throw new FireflyException(sprintf('Cannot find Spectre provider with ID #%d', $providerId)); + } + $fields = $provider->data['required_fields'] ?? []; + $positions = []; + // Obtain a list of columns + foreach ($fields as $key => $row) { + $positions[$key] = $row['position']; + } + array_multisort($positions, SORT_ASC, $fields); + $country = SelectCountry::$allCountries[$config['country']] ?? $config['country']; + + return compact('provider', 'country', 'fields'); + } + + /** + * Return possible warning to user. + * + * @return string + */ + public function getWarningMessage(): string + { + return ''; + } + + /** + * @param ImportJob $job + * + * @return ConfigurationInterface + */ + public function setJob(ImportJob $job) + { + $this->job = $job; + } + + /** + * Store the result. + * + * @param array $data + * + * @return bool + */ + public function storeConfiguration(array $data): bool + { + $config = $this->job->configuration; + $providerId = $config['provider']; + $provider = SpectreProvider::where('spectre_id', $providerId)->first(); + if (is_null($provider)) { + throw new FireflyException(sprintf('Cannot find Spectre provider with ID #%d', $providerId)); + } + $mandatory = []; + $fields = $provider->data['required_fields'] ?? []; + foreach ($fields as $field) { + $name = $field['name']; + $mandatory[$name] = Crypt::encrypt($data[$name]) ?? null; + } + + // store in config of job: + $config['mandatory-fields'] = $mandatory; + $config['has-input-mandatory'] = true; + $this->job->configuration = $config; + $this->job->save(); + + // try to grab login for this job. See what happens? + // fire job that creates login object. user is redirected to "wait here" page (status page). Page should + // refresh and go back to interactive when user is supposed to enter SMS code or something. + // otherwise start downloading stuff + + return true; + } +} \ No newline at end of file diff --git a/app/Support/Import/Configuration/Spectre/SelectCountry.php b/app/Support/Import/Configuration/Spectre/SelectCountry.php new file mode 100644 index 0000000000..e34c15b4e7 --- /dev/null +++ b/app/Support/Import/Configuration/Spectre/SelectCountry.php @@ -0,0 +1,329 @@ + 'Afghanistan', + 'AX' => 'Aland Islands', + 'AL' => 'Albania', + 'DZ' => 'Algeria', + 'AS' => 'American Samoa', + 'AD' => 'Andorra', + 'AO' => 'Angola', + 'AI' => 'Anguilla', + 'AQ' => 'Antarctica', + 'AG' => 'Antigua and Barbuda', + 'AR' => 'Argentina', + 'AM' => 'Armenia', + 'AW' => 'Aruba', + 'AU' => 'Australia', + 'AT' => 'Austria', + 'AZ' => 'Azerbaijan', + 'BS' => 'Bahamas', + 'BH' => 'Bahrain', + 'BD' => 'Bangladesh', + 'BB' => 'Barbados', + 'BY' => 'Belarus', + 'BE' => 'Belgium', + 'BZ' => 'Belize', + 'BJ' => 'Benin', + 'BM' => 'Bermuda', + 'BT' => 'Bhutan', + 'BO' => 'Bolivia', + 'BQ' => 'Bonaire, Saint Eustatius and Saba', + 'BA' => 'Bosnia and Herzegovina', + 'BW' => 'Botswana', + 'BV' => 'Bouvet Island', + 'BR' => 'Brazil', + 'IO' => 'British Indian Ocean Territory', + 'VG' => 'British Virgin Islands', + 'BN' => 'Brunei', + 'BG' => 'Bulgaria', + 'BF' => 'Burkina Faso', + 'BI' => 'Burundi', + 'KH' => 'Cambodia', + 'CM' => 'Cameroon', + 'CA' => 'Canada', + 'CV' => 'Cape Verde', + 'KY' => 'Cayman Islands', + 'CF' => 'Central African Republic', + 'TD' => 'Chad', + 'CL' => 'Chile', + 'CN' => 'China', + 'CX' => 'Christmas Island', + 'CC' => 'Cocos Islands', + 'CO' => 'Colombia', + 'KM' => 'Comoros', + 'CK' => 'Cook Islands', + 'CR' => 'Costa Rica', + 'HR' => 'Croatia', + 'CU' => 'Cuba', + 'CW' => 'Curacao', + 'CY' => 'Cyprus', + 'CZ' => 'Czech Republic', + 'CD' => 'Democratic Republic of the Congo', + 'DK' => 'Denmark', + 'DJ' => 'Djibouti', + 'DM' => 'Dominica', + 'DO' => 'Dominican Republic', + 'TL' => 'East Timor', + 'EC' => 'Ecuador', + 'EG' => 'Egypt', + 'SV' => 'El Salvador', + 'GQ' => 'Equatorial Guinea', + 'ER' => 'Eritrea', + 'EE' => 'Estonia', + 'ET' => 'Ethiopia', + 'FK' => 'Falkland Islands', + 'FO' => 'Faroe Islands', + 'FJ' => 'Fiji', + 'FI' => 'Finland', + 'FR' => 'France', + 'GF' => 'French Guiana', + 'PF' => 'French Polynesia', + 'TF' => 'French Southern Territories', + 'GA' => 'Gabon', + 'GM' => 'Gambia', + 'GE' => 'Georgia', + 'DE' => 'Germany', + 'GH' => 'Ghana', + 'GI' => 'Gibraltar', + 'GR' => 'Greece', + 'GL' => 'Greenland', + 'GD' => 'Grenada', + 'GP' => 'Guadeloupe', + 'GU' => 'Guam', + 'GT' => 'Guatemala', + 'GG' => 'Guernsey', + 'GN' => 'Guinea', + 'GW' => 'Guinea-Bissau', + 'GY' => 'Guyana', + 'HT' => 'Haiti', + 'HM' => 'Heard Island and McDonald Islands', + 'HN' => 'Honduras', + 'HK' => 'Hong Kong', + 'HU' => 'Hungary', + 'IS' => 'Iceland', + 'IN' => 'India', + 'ID' => 'Indonesia', + 'IR' => 'Iran', + 'IQ' => 'Iraq', + 'IE' => 'Ireland', + 'IM' => 'Isle of Man', + 'IL' => 'Israel', + 'IT' => 'Italy', + 'CI' => 'Ivory Coast', + 'JM' => 'Jamaica', + 'JP' => 'Japan', + 'JE' => 'Jersey', + 'JO' => 'Jordan', + 'KZ' => 'Kazakhstan', + 'KE' => 'Kenya', + 'KI' => 'Kiribati', + 'XK' => 'Kosovo', + 'KW' => 'Kuwait', + 'KG' => 'Kyrgyzstan', + 'LA' => 'Laos', + 'LV' => 'Latvia', + 'LB' => 'Lebanon', + 'LS' => 'Lesotho', + 'LR' => 'Liberia', + 'LY' => 'Libya', + 'LI' => 'Liechtenstein', + 'LT' => 'Lithuania', + 'LU' => 'Luxembourg', + 'MO' => 'Macao', + 'MK' => 'Macedonia', + 'MG' => 'Madagascar', + 'MW' => 'Malawi', + 'MY' => 'Malaysia', + 'MV' => 'Maldives', + 'ML' => 'Mali', + 'MT' => 'Malta', + 'MH' => 'Marshall Islands', + 'MQ' => 'Martinique', + 'MR' => 'Mauritania', + 'MU' => 'Mauritius', + 'YT' => 'Mayotte', + 'MX' => 'Mexico', + 'FM' => 'Micronesia', + 'MD' => 'Moldova', + 'MC' => 'Monaco', + 'MN' => 'Mongolia', + 'ME' => 'Montenegro', + 'MS' => 'Montserrat', + 'MA' => 'Morocco', + 'MZ' => 'Mozambique', + 'MM' => 'Myanmar', + 'NA' => 'Namibia', + 'NR' => 'Nauru', + 'NP' => 'Nepal', + 'NL' => 'Netherlands', + 'NC' => 'New Caledonia', + 'NZ' => 'New Zealand', + 'NI' => 'Nicaragua', + 'NE' => 'Niger', + 'NG' => 'Nigeria', + 'NU' => 'Niue', + 'NF' => 'Norfolk Island', + 'KP' => 'North Korea', + 'MP' => 'Northern Mariana Islands', + 'NO' => 'Norway', + 'OM' => 'Oman', + 'PK' => 'Pakistan', + 'PW' => 'Palau', + 'PS' => 'Palestinian Territory', + 'PA' => 'Panama', + 'PG' => 'Papua New Guinea', + 'PY' => 'Paraguay', + 'PE' => 'Peru', + 'PH' => 'Philippines', + 'PN' => 'Pitcairn', + 'PL' => 'Poland', + 'PT' => 'Portugal', + 'PR' => 'Puerto Rico', + 'QA' => 'Qatar', + 'CG' => 'Republic of the Congo', + 'RE' => 'Reunion', + 'RO' => 'Romania', + 'RU' => 'Russia', + 'RW' => 'Rwanda', + 'BL' => 'Saint Barthelemy', + 'SH' => 'Saint Helena', + 'KN' => 'Saint Kitts and Nevis', + 'LC' => 'Saint Lucia', + 'MF' => 'Saint Martin', + 'PM' => 'Saint Pierre and Miquelon', + 'VC' => 'Saint Vincent and the Grenadines', + 'WS' => 'Samoa', + 'SM' => 'San Marino', + 'ST' => 'Sao Tome and Principe', + 'SA' => 'Saudi Arabia', + 'SN' => 'Senegal', + 'RS' => 'Serbia', + 'SC' => 'Seychelles', + 'SL' => 'Sierra Leone', + 'SG' => 'Singapore', + 'SX' => 'Sint Maarten', + 'SK' => 'Slovakia', + 'SI' => 'Slovenia', + 'SB' => 'Solomon Islands', + 'SO' => 'Somalia', + 'ZA' => 'South Africa', + 'GS' => 'South Georgia and the South Sandwich Islands', + 'KR' => 'South Korea', + 'SS' => 'South Sudan', + 'ES' => 'Spain', + 'LK' => 'Sri Lanka', + 'SD' => 'Sudan', + 'SR' => 'Suriname', + 'SJ' => 'Svalbard and Jan Mayen', + 'SZ' => 'Swaziland', + 'SE' => 'Sweden', + 'CH' => 'Switzerland', + 'SY' => 'Syria', + 'TW' => 'Taiwan', + 'TJ' => 'Tajikistan', + 'TZ' => 'Tanzania', + 'TH' => 'Thailand', + 'TG' => 'Togo', + 'TK' => 'Tokelau', + 'TO' => 'Tonga', + 'TT' => 'Trinidad and Tobago', + 'TN' => 'Tunisia', + 'TR' => 'Turkey', + 'TM' => 'Turkmenistan', + 'TC' => 'Turks and Caicos Islands', + 'TV' => 'Tuvalu', + 'VI' => 'U.S. Virgin Islands', + 'UG' => 'Uganda', + 'UA' => 'Ukraine', + 'AE' => 'United Arab Emirates', + 'GB' => 'United Kingdom', + 'US' => 'United States', + 'UM' => 'United States Minor Outlying Islands', + 'UY' => 'Uruguay', + 'UZ' => 'Uzbekistan', + 'VU' => 'Vanuatu', + 'VA' => 'Vatican', + 'VE' => 'Venezuela', + 'VN' => 'Vietnam', + 'WF' => 'Wallis and Futuna', + 'EH' => 'Western Sahara', + 'YE' => 'Yemen', + 'ZM' => 'Zambia', + 'ZW' => 'Zimbabwe', + 'XF' => 'Fake Country (for testing)', + 'XO' => 'Other financial applications', + ]; + /** @var ImportJob */ + private $job; + + /** + * Get the data necessary to show the configuration screen. + * + * @return array + */ + public function getData(): array + { + $providers = SpectreProvider::get(); + $countries = []; + /** @var SpectreProvider $provider */ + foreach ($providers as $provider) { + $countries[$provider->country_code] = self::$allCountries[$provider->country_code] ?? $provider->country_code; + } + asort($countries); + + return compact('countries'); + } + + /** + * Return possible warning to user. + * + * @return string + */ + public function getWarningMessage(): string + { + return ''; + } + + /** + * @param ImportJob $job + * + * @return ConfigurationInterface + */ + public function setJob(ImportJob $job) + { + $this->job = $job; + } + + /** + * Store the result. + * + * @param array $data + * + * @return bool + */ + public function storeConfiguration(array $data): bool + { + $config = $this->job->configuration; + $config['country'] = $data['country_code'] ?? 'XF'; // default to fake country. + $config['selected-country'] = true; + $this->job->configuration = $config; + $this->job->save(); + + return true; + } +} \ No newline at end of file diff --git a/app/Support/Import/Configuration/Spectre/SelectProvider.php b/app/Support/Import/Configuration/Spectre/SelectProvider.php new file mode 100644 index 0000000000..ec41ed78c5 --- /dev/null +++ b/app/Support/Import/Configuration/Spectre/SelectProvider.php @@ -0,0 +1,77 @@ +job->configuration; + $selection = SpectreProvider::where('country_code', $config['country'])->where('status', 'active')->get(); + $providers = []; + /** @var SpectreProvider $provider */ + foreach ($selection as $provider) { + $providerId = $provider->spectre_id; + $name = $provider->data['name']; + $providers[$providerId] = $name; + } + $country = SelectCountry::$allCountries[$config['country']] ?? $config['country']; + + return compact('providers', 'country'); + } + + /** + * Return possible warning to user. + * + * @return string + */ + public function getWarningMessage(): string + { + return ''; + } + + /** + * @param ImportJob $job + * + * @return ConfigurationInterface + */ + public function setJob(ImportJob $job) + { + $this->job = $job; + } + + /** + * Store the result. + * + * @param array $data + * + * @return bool + */ + public function storeConfiguration(array $data): bool + { + $config = $this->job->configuration; + $config['provider'] = intval($data['provider_code']) ?? 0; // default to fake country. + $config['selected-provider'] = true; + $this->job->configuration = $config; + $this->job->save(); + + return true; + } +} \ No newline at end of file diff --git a/app/Support/Import/Information/SpectreInformation.php b/app/Support/Import/Information/SpectreInformation.php new file mode 100644 index 0000000000..04a3037c3b --- /dev/null +++ b/app/Support/Import/Information/SpectreInformation.php @@ -0,0 +1,207 @@ +. + */ +declare(strict_types=1); + +namespace FireflyIII\Support\Import\Information; + +use FireflyIII\Exceptions\FireflyException; +use FireflyIII\Services\Bunq\Object\Alias; +use FireflyIII\Services\Bunq\Object\MonetaryAccountBank; +use FireflyIII\Services\Bunq\Request\DeleteDeviceSessionRequest; +use FireflyIII\Services\Bunq\Request\DeviceSessionRequest; +use FireflyIII\Services\Bunq\Request\ListMonetaryAccountRequest; +use FireflyIII\Services\Bunq\Request\ListUserRequest; +use FireflyIII\Services\Bunq\Token\SessionToken; +use FireflyIII\Support\CacheProperties; +use FireflyIII\User; +use Illuminate\Support\Collection; +use Log; +use Preferences; + +/** + * Class SpectreInformation + */ +class SpectreInformation implements InformationInterface +{ + /** @var User */ + private $user; + + /** + * Returns a collection of accounts. Preferrably, these follow a uniform Firefly III format so they can be managed over banks. + * + * The format for these bank accounts is basically this: + * + * id: bank specific id + * name: bank appointed name + * number: account number (usually IBAN) + * currency: ISO code of currency + * balance: current balance + * + * + * any other fields are optional but can be useful: + * image: logo or account specific thing + * color: any associated color. + * + * @return array + */ + public function getAccounts(): array + { + // cache for an hour: + $cache = new CacheProperties; + $cache->addProperty('bunq.get-accounts'); + $cache->addProperty(date('dmy h')); + if ($cache->has()) { + return $cache->get(); // @codeCoverageIgnore + } + Log::debug('Now in getAccounts()'); + $sessionToken = $this->startSession(); + $userId = $this->getUserInformation($sessionToken); + // get list of Bunq accounts: + $accounts = $this->getMonetaryAccounts($sessionToken, $userId); + $return = []; + /** @var MonetaryAccountBank $account */ + foreach ($accounts as $account) { + $current = [ + 'id' => $account->getId(), + 'name' => $account->getDescription(), + 'currency' => $account->getCurrency(), + 'balance' => $account->getBalance()->getValue(), + 'color' => $account->getSetting()->getColor(), + ]; + /** @var Alias $alias */ + foreach ($account->getAliases() as $alias) { + if ('IBAN' === $alias->getType()) { + $current['number'] = $alias->getValue(); + } + } + $return[] = $current; + } + $cache->store($return); + + $this->closeSession($sessionToken); + + return $return; + } + + /** + * Set the user for this Prerequisites-routine. Class is expected to implement and save this. + * + * @param User $user + */ + public function setUser(User $user): void + { + $this->user = $user; + } + + /** + * @param SessionToken $sessionToken + */ + private function closeSession(SessionToken $sessionToken): void + { + Log::debug('Going to close session'); + $apiKey = Preferences::getForUser($this->user, 'bunq_api_key')->data; + $serverPublicKey = Preferences::getForUser($this->user, 'bunq_server_public_key')->data; + $privateKey = Preferences::getForUser($this->user, 'bunq_private_key')->data; + $request = new DeleteDeviceSessionRequest(); + $request->setSecret($apiKey); + $request->setPrivateKey($privateKey); + $request->setServerPublicKey($serverPublicKey); + $request->setSessionToken($sessionToken); + $request->call(); + + return; + } + + /** + * @param SessionToken $sessionToken + * @param int $userId + * + * @return Collection + */ + private function getMonetaryAccounts(SessionToken $sessionToken, int $userId): Collection + { + $apiKey = Preferences::getForUser($this->user, 'bunq_api_key')->data; + $serverPublicKey = Preferences::getForUser($this->user, 'bunq_server_public_key')->data; + $privateKey = Preferences::getForUser($this->user, 'bunq_private_key')->data; + $request = new ListMonetaryAccountRequest; + + $request->setSessionToken($sessionToken); + $request->setSecret($apiKey); + $request->setServerPublicKey($serverPublicKey); + $request->setPrivateKey($privateKey); + $request->setUserId($userId); + $request->call(); + + return $request->getMonetaryAccounts(); + } + + /** + * @param SessionToken $sessionToken + * + * @return int + * + * @throws FireflyException + */ + private function getUserInformation(SessionToken $sessionToken): int + { + $apiKey = Preferences::getForUser($this->user, 'bunq_api_key')->data; + $serverPublicKey = Preferences::getForUser($this->user, 'bunq_server_public_key')->data; + $privateKey = Preferences::getForUser($this->user, 'bunq_private_key')->data; + $request = new ListUserRequest; + $request->setSessionToken($sessionToken); + $request->setSecret($apiKey); + $request->setServerPublicKey($serverPublicKey); + $request->setPrivateKey($privateKey); + $request->call(); + // return the first that isn't null? + $company = $request->getUserCompany(); + if ($company->getId() > 0) { + return $company->getId(); + } + $user = $request->getUserPerson(); + if ($user->getId() > 0) { + return $user->getId(); + } + throw new FireflyException('Expected user or company from Bunq, but got neither.'); + } + + /** + * @return SessionToken + */ + private function startSession(): SessionToken + { + Log::debug('Now in startSession.'); + $apiKey = Preferences::getForUser($this->user, 'bunq_api_key')->data; + $serverPublicKey = Preferences::getForUser($this->user, 'bunq_server_public_key')->data; + $privateKey = Preferences::getForUser($this->user, 'bunq_private_key')->data; + $installationToken = Preferences::getForUser($this->user, 'bunq_installation_token')->data; + $request = new DeviceSessionRequest(); + $request->setSecret($apiKey); + $request->setServerPublicKey($serverPublicKey); + $request->setPrivateKey($privateKey); + $request->setInstallationToken($installationToken); + $request->call(); + $sessionToken = $request->getSessionToken(); + Log::debug(sprintf('Now have got session token: %s', serialize($sessionToken))); + + return $sessionToken; + } +} diff --git a/app/Support/Import/Prerequisites/SpectrePrerequisites.php b/app/Support/Import/Prerequisites/SpectrePrerequisites.php new file mode 100644 index 0000000000..d67960b139 --- /dev/null +++ b/app/Support/Import/Prerequisites/SpectrePrerequisites.php @@ -0,0 +1,176 @@ +. + */ +declare(strict_types=1); + +namespace FireflyIII\Support\Import\Prerequisites; + +use FireflyIII\Jobs\GetSpectreProviders; +use FireflyIII\Models\Preference; +use FireflyIII\User; +use Illuminate\Http\Request; +use Illuminate\Support\MessageBag; +use Log; +use Preferences; + +/** + * This class contains all the routines necessary to connect to Spectre. + */ +class SpectrePrerequisites implements PrerequisitesInterface +{ + /** @var User */ + private $user; + + /** + * Returns view name that allows user to fill in prerequisites. Currently asks for the API key. + * + * @return string + */ + public function getView(): string + { + return 'import.spectre.prerequisites'; + } + + /** + * Returns any values required for the prerequisites-view. + * + * @return array + */ + public function getViewParameters(): array + { + $publicKey = $this->getPublicKey(); + $subTitle = strval(trans('bank.spectre_title')); + $subTitleIcon = 'fa-archive'; + + return compact('publicKey', 'subTitle', 'subTitleIcon'); + } + + /** + * Returns if this import method has any special prerequisites such as config + * variables or other things. The only thing we verify is the presence of the API key. Everything else + * tumbles into place: no installation token? Will be requested. No device server? Will be created. Etc. + * + * @return bool + */ + public function hasPrerequisites(): bool + { + $values = [ + Preferences::getForUser($this->user, 'spectre_client_id', false), + Preferences::getForUser($this->user, 'spectre_app_secret', false), + Preferences::getForUser($this->user, 'spectre_service_secret', false), + ]; + /** @var Preference $value */ + foreach ($values as $value) { + if (false === $value->data || null === $value->data) { + Log::info(sprintf('Config var "%s" is missing.', $value->name)); + + return true; + } + } + Log::debug('All prerequisites are here!'); + + // at this point, check if all providers are present. Providers are shared amongst + // users in a multi-user environment. + GetSpectreProviders::dispatch($this->user); + + return false; + } + + /** + * Set the user for this Prerequisites-routine. Class is expected to implement and save this. + * + * @param User $user + */ + public function setUser(User $user): void + { + $this->user = $user; + + return; + } + + /** + * This method responds to the user's submission of an API key. It tries to register this instance as a new Firefly III device. + * If this fails, the error is returned in a message bag and the user is notified (this is fairly friendly). + * + * @param Request $request + * + * @return MessageBag + */ + public function storePrerequisites(Request $request): MessageBag + { + Log::debug('Storing Spectre API keys..'); + Preferences::setForUser($this->user, 'spectre_client_id', $request->get('client_id')); + Preferences::setForUser($this->user, 'spectre_app_secret', $request->get('app_secret')); + Preferences::setForUser($this->user, 'spectre_service_secret', $request->get('service_secret')); + Log::debug('Done!'); + + return new MessageBag; + } + + /** + * This method creates a new public/private keypair for the user. This isn't really secure, since the key is generated on the fly with + * no regards for HSM's, smart cards or other things. It would require some low level programming to get this right. But the private key + * is stored encrypted in the database so it's something. + */ + private function createKeyPair(): void + { + Log::debug('Generate new Spectre key pair for user.'); + $keyConfig = [ + 'digest_alg' => 'sha512', + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]; + // Create the private and public key + $res = openssl_pkey_new($keyConfig); + + // Extract the private key from $res to $privKey + $privKey = ''; + openssl_pkey_export($res, $privKey); + + // Extract the public key from $res to $pubKey + $pubKey = openssl_pkey_get_details($res); + + Preferences::setForUser($this->user, 'spectre_private_key', $privKey); + Preferences::setForUser($this->user, 'spectre_public_key', $pubKey['key']); + Log::debug('Created key pair'); + + return; + } + + /** + * Get a public key from the users preferences. + * + * @return string + */ + private function getPublicKey(): string + { + Log::debug('get public key'); + $preference = Preferences::getForUser($this->user, 'spectre_public_key', null); + if (null === $preference) { + Log::debug('public key is null'); + // create key pair + $this->createKeyPair(); + } + $preference = Preferences::getForUser($this->user, 'spectre_public_key', null); + Log::debug('Return public key for user'); + + return $preference->data; + } +} diff --git a/config/firefly.php b/config/firefly.php index 025619c595..0ecf8112a6 100644 --- a/config/firefly.php +++ b/config/firefly.php @@ -28,39 +28,51 @@ declare(strict_types=1); */ return [ - 'configuration' => [ + 'configuration' => [ 'single_user_mode' => true, 'is_demo_site' => false, ], - 'encryption' => (is_null(env('USE_ENCRYPTION')) || env('USE_ENCRYPTION') === true), - 'version' => '4.6.11.1', - 'maxUploadSize' => 15242880, - 'allowedMimes' => ['image/png', 'image/jpeg', 'application/pdf','text/plain'], - 'list_length' => 10, - 'export_formats' => [ + 'encryption' => (is_null(env('USE_ENCRYPTION')) || env('USE_ENCRYPTION') === true), + 'version' => '4.6.11.1', + 'maxUploadSize' => 15242880, + 'allowedMimes' => ['image/png', 'image/jpeg', 'application/pdf', 'text/plain'], + 'list_length' => 10, + 'export_formats' => [ 'csv' => 'FireflyIII\Export\Exporter\CsvExporter', ], - 'import_formats' => [ - 'csv' => 'FireflyIII\Import\Configurator\CsvConfigurator', + 'import_formats' => [ + 'csv' => 'FireflyIII\Import\Configurator\CsvConfigurator', + 'spectre' => '', ], - 'import_configurators' => [ + 'import_configurators' => [ 'csv' => 'FireflyIII\Import\Configurator\CsvConfigurator', + 'spectre' => 'FireflyIII\Import\Configurator\SpectreConfigurator', ], - 'import_processors' => [ + 'import_processors' => [ 'csv' => 'FireflyIII\Import\FileProcessor\CsvProcessor', ], - 'import_pre' => [ - 'bunq' => 'FireflyIII\Support\Import\Prerequisites\BunqPrerequisites', + 'import_pre' => [ + 'bunq' => 'FireflyIII\Support\Import\Prerequisites\BunqPrerequisites', + 'spectre' => 'FireflyIII\Support\Import\Prerequisites\SpectrePrerequisites', + 'plaid' => 'FireflyIII\Support\Import\Prerequisites\PlairPrerequisites', ], - 'import_info' => [ - 'bunq' => 'FireflyIII\Support\Import\Information\BunqInformation', + 'import_info' => [ + 'bunq' => 'FireflyIII\Support\Import\Information\BunqInformation', + 'spectre' => 'FireflyIII\Support\Import\Information\SpectreInformation', + 'plaid' => 'FireflyIII\Support\Import\Information\PlaidInformation', ], - 'import_transactions' => [ - 'bunq' => 'FireflyIII\Support\Import\Transactions\BunqTransactions', + 'import_transactions' => [ + 'bunq' => 'FireflyIII\Support\Import\Transactions\BunqTransactions', + 'spectre' => 'FireflyIII\Support\Import\Transactions\SpectreTransactions', + 'plaid' => 'FireflyIII\Support\Import\Transactions\PlaidTransactions', ], - 'bunq' => [ + 'bunq' => [ 'server' => 'https://sandbox.public.api.bunq.com', ], + 'spectre' => [ + 'server' => 'https://www.saltedge.com', + ], + 'default_export_format' => 'csv', 'default_import_format' => 'csv', 'bill_periods' => ['weekly', 'monthly', 'quarterly', 'half-year', 'yearly'], diff --git a/database/migrations/2017_12_09_111046_changes_for_spectre.php b/database/migrations/2017_12_09_111046_changes_for_spectre.php new file mode 100644 index 0000000000..ff25ce1aef --- /dev/null +++ b/database/migrations/2017_12_09_111046_changes_for_spectre.php @@ -0,0 +1,47 @@ +increments('id'); + $table->timestamps(); + $table->softDeletes(); + //'spectre_id', 'code', 'mode', 'name', 'status', 'interactive', 'automatic_fetch', 'country_code', 'data' + $table->integer('spectre_id', false, true); + $table->string('code', 100); + $table->string('mode', 20); + $table->string('status', 20); + $table->boolean('interactive')->default(0); + $table->boolean('automatic_fetch')->default(0); + $table->string('country_code', 3); + $table->text('data'); + } + ); + } + } +} diff --git a/public/images/logos/bunq.png b/public/images/logos/bunq.png index c1223bc4fc..fad92342b7 100644 Binary files a/public/images/logos/bunq.png and b/public/images/logos/bunq.png differ diff --git a/public/images/logos/csv.png b/public/images/logos/csv.png new file mode 100644 index 0000000000..6b71776154 Binary files /dev/null and b/public/images/logos/csv.png differ diff --git a/public/images/logos/plaid.png b/public/images/logos/plaid.png new file mode 100644 index 0000000000..23187ce8aa Binary files /dev/null and b/public/images/logos/plaid.png differ diff --git a/public/images/logos/spectre.png b/public/images/logos/spectre.png new file mode 100644 index 0000000000..6c2189e96c Binary files /dev/null and b/public/images/logos/spectre.png differ diff --git a/resources/lang/en_US/bank.php b/resources/lang/en_US/bank.php index d7bbee6d98..b9187230d5 100644 --- a/resources/lang/en_US/bank.php +++ b/resources/lang/en_US/bank.php @@ -23,6 +23,19 @@ declare(strict_types=1); return [ - 'bunq_prerequisites_title' => 'Prerequisites for an import from bunq', - 'bunq_prerequisites_text' => 'In order to import from bunq, you need to obtain an API key. You can do this through the app.', + 'bunq_prerequisites_title' => 'Prerequisites for an import from bunq', + 'bunq_prerequisites_text' => 'In order to import from bunq, you need to obtain an API key. You can do this through the app.', + + // Spectre: + 'spectre_title' => 'Import using Spectre', + 'spectre_prerequisites_title' => 'Prerequisites for an import using Spectre', + 'spectre_prerequisites_text' => 'In order to import data using the Spectre API, you need to prove some secrets. They can be found on the secrets page.', + 'spectre_enter_pub_key' => 'The import will only work when you enter this public key on your security page.', + 'spectre_select_country_title' => 'Select a country', + 'spectre_select_country_text' => 'Firefly III has a large selection of banks and sites from which Spectre can download transactional data. These banks are sorted by country. Please not that there is a "Fake Country" for when you wish to test something. If you wish to import from other financial tools, please use the imaginary country called "Other financial applications". By default, Spectre only allows you to download data from fake banks. Make sure your status is "Live" on your Dashboard if you wish to download from real banks.', + 'spectre_select_provider_title' => 'Select a bank', + 'spectre_select_provider_text' => 'Spectre supports the following banks or financial services grouped under :country. Please pick the one you wish to import from.', + 'spectre_input_fields_title' => 'Input mandatory fields', + 'spectre_input_fields_text' => 'The following fields are mandated by ":provider" (from :country).', + 'spectre_instructions_english' => 'These instructions are provided by Spectre for your convencience. They are in English:', ]; diff --git a/resources/lang/en_US/form.php b/resources/lang/en_US/form.php index 035d405f7d..875a313db8 100644 --- a/resources/lang/en_US/form.php +++ b/resources/lang/en_US/form.php @@ -195,6 +195,11 @@ return [ 'csv_delimiter' => 'CSV field delimiter', 'csv_import_account' => 'Default import account', 'csv_config' => 'CSV import configuration', + 'client_id' => 'Client ID', + 'service_secret' => 'Service secret', + 'app_secret' => 'App secret', + 'public_key' => 'Public key', + 'country_code' => 'Country code', 'due_date' => 'Due date', diff --git a/resources/views/import/index.twig b/resources/views/import/index.twig index 8e2876dc52..bb941e7c1c 100644 --- a/resources/views/import/index.twig +++ b/resources/views/import/index.twig @@ -20,21 +20,34 @@
-
-

+

+
{# bunq import #} - {# - + bunq
Import from bunq
- #} -

+
+
+ {# import from Spectre #} + + Spectre
+ Import using Spectre +
+
+
+ {# import from Plaid #} + + Plaid
+ Import using Plaid +
+
diff --git a/resources/views/import/spectre/input-fields.twig b/resources/views/import/spectre/input-fields.twig new file mode 100644 index 0000000000..de77ba5b3f --- /dev/null +++ b/resources/views/import/spectre/input-fields.twig @@ -0,0 +1,65 @@ +{% extends "./layout/default" %} + +{% block breadcrumbs %} + {{ Breadcrumbs.renderIfExists }} +{% endblock %} +{% block content %} +
+
+ +
+
+
+

{{ trans('bank.spectre_input_fields_title') }}

+
+
+
+
+

+ {{ trans('bank.spectre_input_fields_text',{provider: data.provider.data.name, country: data.country})|raw }} +

+

+ {{ trans('bank.spectre_instructions_english') }} +

+

+ {{ data.provider.data.instruction|nl2br }} +

+
+
+ +
+
+ {% for field in data.fields %} + {# text, password, select, file #} + {% if field.nature == 'text' %} + {{ ExpandedForm.text(field.name,null, {label: field.english_name ~ ' ('~field.localized_name~')'}) }} + {% endif %} + {% if field.nature == 'password' %} + {{ ExpandedForm.password(field.name, {label: field.english_name ~ ' ('~field.localized_name~')'}) }} + {% endif %} + {% if field.nature == 'select' %} + DO NOT SUPPORT + {{ dump(field) }} + {% endif %} + {% if field.narture == 'file' %} + DO NOT SUPPORT + {{ dump(field) }} + {% endif %} + + {% endfor %} +
+
+ +
+
+ +
+{% endblock %} +{% block scripts %} +{% endblock %} +{% block styles %} +{% endblock %} diff --git a/resources/views/import/spectre/prerequisites.twig b/resources/views/import/spectre/prerequisites.twig new file mode 100644 index 0000000000..1b225e6a12 --- /dev/null +++ b/resources/views/import/spectre/prerequisites.twig @@ -0,0 +1,58 @@ +{% extends "./layout/default" %} + +{% block breadcrumbs %} + {{ Breadcrumbs.renderIfExists }} +{% endblock %} +{% block content %} +
+
+ +
+
+
+

{{ trans('bank.spectre_prerequisites_title') }}

+
+
+
+
+

+ {{ trans('bank.spectre_prerequisites_text')|raw }} +

+
+
+ +
+
+ {{ ExpandedForm.text('client_id') }} + {{ ExpandedForm.text('service_secret') }} + {{ ExpandedForm.text('app_secret') }} +
+
+
+
+

{{ trans('bank.spectre_enter_pub_key')|raw }}

+
+ + +
+ +
+
+
+
+ +
+
+ +
+{% endblock %} +{% block scripts %} +{% endblock %} +{% block styles %} +{% endblock %} diff --git a/resources/views/import/spectre/select-country.twig b/resources/views/import/spectre/select-country.twig new file mode 100644 index 0000000000..ba4f4a0c59 --- /dev/null +++ b/resources/views/import/spectre/select-country.twig @@ -0,0 +1,42 @@ +{% extends "./layout/default" %} + +{% block breadcrumbs %} + {{ Breadcrumbs.renderIfExists }} +{% endblock %} +{% block content %} +
+
+ +
+
+
+

{{ trans('bank.spectre_select_country_title') }}

+
+
+
+
+

+ {{ trans('bank.spectre_select_country_text')|raw }} +

+
+
+ +
+
+ {{ ExpandedForm.select('country_code', data.countries, null)|raw }} +
+
+ +
+
+ +
+{% endblock %} +{% block scripts %} +{% endblock %} +{% block styles %} +{% endblock %} diff --git a/resources/views/import/spectre/select-provider.twig b/resources/views/import/spectre/select-provider.twig new file mode 100644 index 0000000000..5be257d082 --- /dev/null +++ b/resources/views/import/spectre/select-provider.twig @@ -0,0 +1,42 @@ +{% extends "./layout/default" %} + +{% block breadcrumbs %} + {{ Breadcrumbs.renderIfExists }} +{% endblock %} +{% block content %} +
+
+ +
+
+
+

{{ trans('bank.spectre_select_bank_title') }}

+
+
+
+
+

+ {{ trans('bank.spectre_select_provider_text',{country: data.country})|raw }} +

+
+
+ +
+
+ {{ ExpandedForm.select('provider_code', data.providers, null)|raw }} +
+
+ +
+
+ +
+{% endblock %} +{% block scripts %} +{% endblock %} +{% block styles %} +{% endblock %} diff --git a/routes/web.php b/routes/web.php index 39083bbe46..3e46cf739e 100755 --- a/routes/web.php +++ b/routes/web.php @@ -446,8 +446,9 @@ Route::group( Route::get('bank/{bank}/prerequisites', ['uses' => 'Import\BankController@prerequisites', 'as' => 'bank.prerequisites']); Route::post('bank/{bank}/prerequisites', ['uses' => 'Import\BankController@postPrerequisites', 'as' => 'bank.prerequisites.post']); - Route::get('bank/{bank}/form', ['uses' => 'Import\BankController@form', 'as' => 'bank.form']); - Route::post('bank/{bank}/form', ['uses' => 'Import\BankController@postForm', 'as' => 'bank.form.post']); + Route::get('bank/{bank}/create', ['uses' => 'Import\BankController@createJob', 'as' => 'bank.create-job']); + Route:: get('bank/{bank}/configure/{importJob}', ['uses' => 'Import\BankController@configure', 'as' => 'bank.configure']); + Route::post('bank/{bank}/configure/{importJob}', ['uses' => 'Import\BankController@postConfigure', 'as' => 'bank.configure.post']); } );