invoiceninja/app/Http/Controllers/Bank/NordigenController.php

316 lines
12 KiB
PHP

<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Http\Controllers\Bank;
use App\Helpers\Bank\Nordigen\Nordigen;
use App\Http\Controllers\BaseController;
use App\Http\Requests\Nordigen\ConfirmNordigenBankIntegrationRequest;
use App\Http\Requests\Nordigen\ConnectNordigenBankIntegrationRequest;
use App\Jobs\Bank\ProcessBankTransactionsNordigen;
use App\Models\BankIntegration;
use App\Models\Company;
use App\Utils\Ninja;
use Cache;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Contracts\View\View;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Nordigen\NordigenPHP\Exceptions\NordigenExceptions\NordigenException;
class NordigenController extends BaseController
{
/**
* Handles the initial bank connection flow
*/
public function connect(ConnectNordigenBankIntegrationRequest $request): View|RedirectResponse
{
$data = $request->all();
$context = $request->getTokenContent();
if (!$context) {
return $this->failed('token-invalid', ['lang' => 'en']);
}
$company = $request->getCompany();
$context['redirect'] = $data['redirect'];
$context['lang'] = $lang = substr($company->locale(), 0, 2);
if ($context['context'] != 'nordigen' || array_key_exists('requisitionId', $context)) {
return $this->failed('token-invalid', $context);
}
if (!(config('ninja.nordigen.secret_id') && config('ninja.nordigen.secret_key'))) {
return $this->failed('account-config-invalid', $context, $company);
}
if (!(Ninja::isSelfHost() || (Ninja::isHosted() && $company->account->isEnterprisePaidClient()))) {
return $this->failed('not-available', $context, $company);
}
$nordigen = new Nordigen();
$institutions = $nordigen->getInstitutions();
// show bank_selection_screen, when institution_id is not present
if (!isset($data['institution_id'], $data['tx_days'])) {
return view('bank.nordigen.handler', [
'lang' => $lang,
'company' => $company,
'account' => $company->account,
'institutions' => $institutions,
'institutionId' => $data['institution_id'] ?? null,
'redirectUrl' => $context['redirect'] . '?action=nordigen_connect&status=user-aborted'
]);
}
$institution = array_values(array_filter($institutions, function ($institution) use ($data) {
return $institution['id'] == $data['institution_id'];
}))[0];
try {
$txDays = $data['tx_days'] ?? $institution['transaction_total_days'] ?? 90; //@phpstan-ignore-line
$agreement = $nordigen->createAgreement($institution, $institution['max_access_valid_for_days'], $txDays);//@2025-07-01: this is the correct way to get the access days
} catch (\Exception $e) {
$debug = "{$e->getMessage()} ({$e->getCode()})";
nlog("Nordigen: Could not create an agreement with ${institution['name']}: {$debug}");
return $this->failed('eua-failure', $context, $company);
}
// redirect to requisition flow
try {
$requisition = $nordigen->createRequisition(
config('ninja.app_url') . '/nordigen/confirm',
$institution,
$agreement, //@phpstan-ignore-line
$request->token,
$lang,
);
} catch (NordigenException $e) { // TODO: property_exists returns null in these cases... => why => therefore we just get unknown error everytime $responseBody is typeof GuzzleHttp\Psr7\Stream
$responseBody = (string) $e->getResponse()->getBody();
if (str_contains($responseBody, '"institution_id"')) {
return $this->failed('institution-invalid', $context, $company);
}
// Reference invalid or already used, try a new token
if (str_contains($responseBody, '"reference"')) {
return $this->failed('token-invalid', $context, $company);
}
nlog("Unknown Error from nordigen: {$e}");
nlog($responseBody);
return $this->failed('unknown', $context, $company);
}
// save cache
$context['requisitionId'] = $requisition['id'];
Cache::put($request->token, $context, 3600);
return response()->redirectTo($requisition['link']);
}
/**
* Handles the OAuth redirect and account setup after bank authentication
*/
public function confirm(ConfirmNordigenBankIntegrationRequest $request): View|RedirectResponse
{
$data = $request->all();
$company = $request->getCompany();
$lang = substr($company->locale(), 0, 2);
/** @var array $context */
$context = $request->getTokenContent();
if (!array_key_exists('lang', $data) && $context['lang'] != 'en') {
return redirect()->route('nordigen.confirm', array_merge(['lang' => $context['lang']], $request->query()));
}
if (!$context || $context['context'] != 'nordigen' || !array_key_exists('requisitionId', $context)) {
return $this->failed('ref-invalid', $context);
}
if (!config('ninja.nordigen.secret_id') || !config('ninja.nordigen.secret_key')) {
return $this->failed('account-config-invalid', $context, $company);
}
if (!(Ninja::isSelfHost() || (Ninja::isHosted() && $company->account->isEnterprisePaidClient()))) {
return $this->failed('not-available', $context, $company);
}
// fetch requisition
$nordigen = new Nordigen();
$requisition = $nordigen->getRequisition($context['requisitionId']);
// check validity of requisition
if (!$requisition) {
return $this->failed('requisition-not-found', $context, $company);
}
if ($requisition['status'] != 'LN') {
return $this->failed('requisition-invalid-status&status=' . $requisition['status'], $context, $company);
}
if (sizeof($requisition['accounts']) == 0) {
return $this->failed('requisition-no-accounts', $context, $company);
}
// connect new accounts
$bank_integration_ids = [];
foreach ($requisition['accounts'] as $nordigenAccountId) {
$nordigen_account = $nordigen->getAccount($nordigenAccountId);
if (isset($nordigen_account['error'])) {
continue;
}
$bank_integration = false;
try {
$bank_integration = $this->findIntegrationBy('account', $nordigen_account, $company);
$bank_integration->deleted_at = null;
} catch (ModelNotFoundException $e) {
$bank_integration = new BankIntegration();
$bank_integration->integration_type = BankIntegration::INTEGRATION_TYPE_NORDIGEN;
$bank_integration->company_id = $company->id;
$bank_integration->account_id = $company->account_id;
$bank_integration->user_id = $company->owner()->id;
$bank_integration->nordigen_account_id = $nordigen_account['id'];
$bank_integration->bank_account_type = $nordigen_account['account_type'];
$bank_integration->bank_account_name = $nordigen_account['account_name'];
$bank_integration->bank_account_number = $nordigen_account['account_number'];
$bank_integration->nordigen_institution_id = $nordigen_account['provider_id'];
$bank_integration->provider_name = $nordigen_account['provider_name'];
$bank_integration->nickname = $nordigen_account['nickname'];
$bank_integration->currency = $nordigen_account['account_currency'];
} finally {
if ($bank_integration) {
$bank_integration->auto_sync = true;
$bank_integration->disabled_upstream = false;
$bank_integration->balance = $nordigen_account['current_balance'];
$bank_integration->bank_account_status = $nordigen_account['account_status'];
$bank_integration->from_date = now()->subDays($nordigen_account['provider_history']);
$bank_integration->save();
array_push($bank_integration_ids, $bank_integration->id);
}
}
}
// perform update in background
$company->account->bank_integrations
->where('integration_type', BankIntegration::INTEGRATION_TYPE_NORDIGEN)
->where('auto_sync', true)
->each(function ($bank_integration) {
ProcessBankTransactionsNordigen::dispatch($bank_integration)->delay(now()->addHour());
});
// prevent rerun of this method with same ref
Cache::delete($data['ref']);
// Successfull Response => Redirect
return response()->redirectTo($context['redirect'] . '?action=nordigen_connect&status=success&bank_integrations=' . implode(',', $bank_integration_ids));
}
/**
* Handles failure scenarios for Nordigen bank integrations
*
*/
private function failed(string $reason, array $context, $company = null): View
{
$companyData = $company ? [
'company' => $company,
'account' => $company->account,
] : [];
$url = $context['redirect'] ?? config('ninja.app_url');
return view('bank.nordigen.handler', [
...$companyData,
'lang' => $context['lang'],
'failed_reason' => explode('&', $reason)[0],
'redirectUrl' => $url . '?action=nordigen_connect&status=failed&reason=' . $reason,
]);
}
/**
* Find the first available Bank Integration from its Nordigen account or institution.
*
* @param 'account'|'institution' $key
* @param array{id: string} $accountOrInstitution
*/
private function findIntegrationBy(
string $key,
array $accountOrInstitution,
Company $company,
): BankIntegration {
return BankIntegration::withTrashed()
->where($key == 'id' ? 'id' : "nordigen_{$key}_id", $accountOrInstitution['id'])
->where('company_id', $company->id)
->where('is_deleted', 0)
->firstOrFail();
}
/**
* Returns list of available banking institutions from Nordigen
*
* @OA\Post(
* path="/api/v1/nordigen/institutions",
* operationId="nordigenRefreshWebhook",
* tags={"nordigen"},
* summary="Getting available institutions from nordigen",
* description="Used to determine the available institutions for sending and creating a new connect-link",
* @OA\Parameter(ref="#/components/parameters/X-Api-Token"),
* @OA\Parameter(ref="#/components/parameters/X-Requested-With"),
* @OA\Parameter(ref="#/components/parameters/include"),
* @OA\Response(
* response=200,
* description="",
* @OA\Header(header="X-MINIMUM-CLIENT-VERSION", ref="#/components/headers/X-MINIMUM-CLIENT-VERSION"),
* @OA\Header(header="X-RateLimit-Remaining", ref="#/components/headers/X-RateLimit-Remaining"),
* @OA\Header(header="X-RateLimit-Limit", ref="#/components/headers/X-RateLimit-Limit"),
* @OA\JsonContent(ref="#/components/schemas/Credit"),
* ),
* @OA\Response(
* response=422,
* description="Validation error",
* @OA\JsonContent(ref="#/components/schemas/ValidationError"),
*
* ),
* @OA\Response(
* response="default",
* description="Unexpected Error",
* @OA\JsonContent(ref="#/components/schemas/Error"),
* ),
* )
*/
public function institutions(Request $request): JsonResponse
{
if (!(config('ninja.nordigen.secret_id') && config('ninja.nordigen.secret_key'))) {
return response()->json(['message' => 'Not yet authenticated with Nordigen Bank Integration service'], 400);
}
$nordigen = new Nordigen();
return response()->json($nordigen->getInstitutions());
}
}