Revert latest nordigen commit

This commit is contained in:
David Bomba 2025-01-11 18:25:54 +11:00
parent 95b8454c15
commit 5c60a3efed
6 changed files with 268 additions and 396 deletions

View File

@ -23,7 +23,6 @@ use App\Models\Company;
use App\Services\Email\Email; use App\Services\Email\Email;
use App\Models\BankIntegration; use App\Models\BankIntegration;
use App\Services\Email\EmailObject; use App\Services\Email\EmailObject;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Mail\Mailables\Address; use Illuminate\Mail\Mailables\Address;
@ -34,7 +33,7 @@ class Nordigen
{ {
public bool $test_mode; // https://developer.gocardless.com/bank-account-data/sandbox public bool $test_mode; // https://developer.gocardless.com/bank-account-data/sandbox
public string $sandbox_institutionId = 'SANDBOXFINANCE_SFIN0000'; public string $sandbox_institutionId = "SANDBOXFINANCE_SFIN0000";
protected \Nordigen\NordigenPHP\API\NordigenClient $client; protected \Nordigen\NordigenPHP\API\NordigenClient $client;
@ -61,116 +60,70 @@ class Nordigen
return $this->client->institution->getInstitutions(); return $this->client->institution->getInstitutions();
} }
/** // requisition-section
* Get end user agreement details by ID. public function createRequisition(string $redirect, string $institutionId, string $reference, string $userLanguage)
*
* @return array{
* id: string,
* created: string,
* institution_id: string,
* max_historical_days: int,
* access_valid_for_days: int,
* access_scope: string[],
* accepted: string
* } Agreement details
*/
public function getAgreement(string $euaId): array
{ {
return $this->client->endUserAgreement->getEndUserAgreement($euaId); if ($this->test_mode && $institutionId != $this->sandbox_institutionId) {
}
/**
* Get a list of end user agreements
*
* @return array{
* id: string,
* created: string,
* institution_id: string,
* max_historical_days: int,
* access_valid_for_days: int,
* access_scope: string[],
* accepted: ?string,
* }[] EndUserAgreement list
*/
public function firstValidAgreement(string $institutionId, int $accessDays, int $txDays): ?array
{
$requiredScopes = ['balances', 'details', 'transactions'];
try {
return Arr::first(
$this->client->endUserAgreement->getEndUserAgreements()['results'],
function (array $eua) use ($institutionId, $requiredScopes, $accessDays, $txDays): bool {
return $eua['institution_id'] === $institutionId
&& $eua['accepted'] === null
&& $eua['max_historical_days'] >= $txDays
&& $eua['access_valid_for_days'] >= $accessDays
&& !array_diff($requiredScopes, $eua['access_scope'] ?? []);
},
null
);
} catch (\Exception $e) {
$debug = "{$e->getMessage()} ({$e->getCode()})";
nlog("Nordigen: Unable to fetch End User Agreements for institution '{$institutionId}': {$debug}");
return null;
}
}
/**
* Create a new End User Agreement with the given parameters
*
* @param array{id: string, transaction_total_days: int, max_access_valid_for_days: int} $institution
*
* @throws \Nordigen\NordigenPHP\Exceptions\NordigenExceptions\NordigenException
*
* @return array{
* id: string,
* created: string,
* institution_id: string,
* max_historical_days: int,
* access_valid_for_days: int,
* access_scope: string[],
* accepted: string
* }|null Agreement details
*/
public function createAgreement(array $institution, int $accessDays, int $transactionDays): ?array
{
$txDays = $transactionDays < 30 ? 30 : $transactionDays;
$maxAccess = $institution['max_access_valid_for_days'];
$maxTx = $institution['transaction_total_days'];
return $this->client->endUserAgreement->createEndUserAgreement(
accessValidForDays: $accessDays > $maxAccess ? $maxAccess : $accessDays,
maxHistoricalDays: $txDays > $maxTx ? $maxTx : $txDays,
institutionId: $institution['id'],
);
}
/**
* Create a new Bank Requisition
*
* @param array{id: string} $institution,
* @param array{id: ?string, transaction_total_days: int} $agreement
*/
public function createRequisition(
string $redirect,
array $institution,
array $agreement,
string $reference,
string $userLanguage,
): array {
if ($this->test_mode && $institution['id'] != $this->sandbox_institutionId) {
throw new \Exception('invalid institutionId while in test-mode'); throw new \Exception('invalid institutionId while in test-mode');
} }
return $this->client->requisition->createRequisition( return $this->client->requisition->createRequisition($redirect, $institutionId, $this->getExtendedEndUserAggreementId($institutionId), $reference, $userLanguage);
$redirect, }
$institution['id'],
$agreement['id'] ?? null, private function getExtendedEndUserAggreementId(string $institutionId): string|null
$reference, {
$userLanguage
); $endUserAggreements = null;
$endUserAgreement = null;
// try to fetch endUserAgreements
try {
$endUserAggreements = $this->client->endUserAgreement->getEndUserAgreements();
} catch (\Exception $e) { // not able to accept it
nlog("Nordigen: Was not able to fetch endUserAgreements. We continue with defaults to setup bank_integration. {$institutionId} {$e->getMessage()} {$e->getCode()}");
return null;
}
// try to find an existing valid endUserAgreement
foreach ($endUserAggreements["results"] as $row) {
$endUserAgreement = $row;
// Validate Institution
if ($endUserAgreement["institution_id"] != $institutionId)
continue;
// Validate Access Scopes
$requiredScopes = ["balances", "details", "transactions"];
if (isset($endUserAgreement['access_scope']) && array_diff($requiredScopes, $endUserAgreement['access_scope']))
continue;
// try to accept the endUserAgreement when not already accepted
if (empty($endUserAgreement["accepted"]))
try {
$this->client->endUserAgreement->acceptEndUserAgreement($endUserAgreement["id"], request()->userAgent(), request()->ip());
} catch (\Exception $e) { // not able to accept it
nlog("Nordigen: Was not able to confirm an existing outstanding endUserAgreement for this institution. We now try to find another or will create and confirm a new one. {$institutionId} {$endUserAgreement["id"]} {$e->getMessage()} {$e->getCode()}");
$endUserAgreement = null;
continue;
}
break;
}
// try to create and accept an endUserAgreement
if (!$endUserAgreement)
try {
$endUserAgreement = $this->client->endUserAgreement->createEndUserAgreement($institutionId, ['details', 'balances', 'transactions'], 90, 180);
$this->client->endUserAgreement->acceptEndUserAgreement($endUserAgreement["id"], request()->userAgent(), request()->ip());
} catch (\Exception $e) { // not able to create this for this institution
nlog("Nordigen: Was not able to create and confirm a new endUserAgreement for this institution. We continue with defaults to setup bank_integration. {$institutionId} {$e->getMessage()} {$e->getCode()}");
return null;
}
return $endUserAgreement["id"];
} }
public function getRequisition(string $requisitionId) public function getRequisition(string $requisitionId)
@ -178,7 +131,7 @@ class Nordigen
try { try {
return $this->client->requisition->getRequisition($requisitionId); return $this->client->requisition->getRequisition($requisitionId);
} catch (\Exception $e) { } catch (\Exception $e) {
if (strpos($e->getMessage(), 'Invalid Requisition ID') !== false) { if (strpos($e->getMessage(), "Invalid Requisition ID") !== false) {
return false; return false;
} }
@ -192,10 +145,10 @@ class Nordigen
try { try {
$out = new \stdClass(); $out = new \stdClass();
$out->data = $this->client->account($account_id)->getAccountDetails()['account']; $out->data = $this->client->account($account_id)->getAccountDetails()["account"];
$out->metadata = $this->client->account($account_id)->getAccountMetaData(); $out->metadata = $this->client->account($account_id)->getAccountMetaData();
$out->balances = $this->client->account($account_id)->getAccountBalances()['balances']; $out->balances = $this->client->account($account_id)->getAccountBalances()["balances"];
$out->institution = $this->client->institution->getInstitution($out->metadata['institution_id']); $out->institution = $this->client->institution->getInstitution($out->metadata["institution_id"]);
$it = new AccountTransformer(); $it = new AccountTransformer();
return $it->transform($out); return $it->transform($out);
@ -227,9 +180,8 @@ class Nordigen
try { try {
$account = $this->client->account($account_id)->getAccountMetaData(); $account = $this->client->account($account_id)->getAccountMetaData();
if ($account['status'] != 'READY') { if ($account["status"] != "READY") {
nlog("Nordigen account '{$account_id}' is not ready (status={$account['status']})"); nlog('nordigen account was not in status ready. accountId: ' . $account_id . ' status: ' . $account["status"]);
return false; return false;
} }
@ -238,7 +190,7 @@ class Nordigen
nlog("Nordigen:: AccountActiveStatus:: {$e->getMessage()} {$e->getCode()}"); nlog("Nordigen:: AccountActiveStatus:: {$e->getMessage()} {$e->getCode()}");
if (strpos($e->getMessage(), 'Invalid Account ID') !== false) { if (strpos($e->getMessage(), "Invalid Account ID") !== false) {
return false; return false;
} }
@ -288,4 +240,4 @@ class Nordigen
} }
} }

View File

@ -1,4 +1,5 @@
<?php <?php
/** /**
* Invoice Ninja (https://invoiceninja.com). * Invoice Ninja (https://invoiceninja.com).
* *
@ -110,7 +111,6 @@ class AccountTransformer implements AccountTransformerInterface
'provider_account_id' => $nordigen_account->metadata["id"], 'provider_account_id' => $nordigen_account->metadata["id"],
'provider_id' => $nordigen_account->institution["id"], 'provider_id' => $nordigen_account->institution["id"],
'provider_name' => $nordigen_account->institution["name"], 'provider_name' => $nordigen_account->institution["name"],
'provider_history' => $nordigen_account->institution["transaction_total_days"],
'nickname' => isset($nordigen_account->data["ownerName"]) ? $nordigen_account->data["ownerName"] : '', 'nickname' => isset($nordigen_account->data["ownerName"]) ? $nordigen_account->data["ownerName"] : '',
'current_balance' => (float) $used_balance ? $used_balance["balanceAmount"]["amount"] : 0, 'current_balance' => (float) $used_balance ? $used_balance["balanceAmount"]["amount"] : 0,
'account_currency' => $used_balance ? $used_balance["balanceAmount"]["currency"] : '', 'account_currency' => $used_balance ? $used_balance["balanceAmount"]["currency"] : '',

View File

@ -17,170 +17,216 @@ use App\Http\Requests\Nordigen\ConfirmNordigenBankIntegrationRequest;
use App\Http\Requests\Nordigen\ConnectNordigenBankIntegrationRequest; use App\Http\Requests\Nordigen\ConnectNordigenBankIntegrationRequest;
use App\Jobs\Bank\ProcessBankTransactionsNordigen; use App\Jobs\Bank\ProcessBankTransactionsNordigen;
use App\Models\BankIntegration; use App\Models\BankIntegration;
use App\Models\Company;
use App\Utils\Ninja; use App\Utils\Ninja;
use Cache; 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 Illuminate\Http\Request;
use Nordigen\NordigenPHP\Exceptions\NordigenExceptions\NordigenException; use Nordigen\NordigenPHP\Exceptions\NordigenExceptions\NordigenException;
class NordigenController extends BaseController class NordigenController extends BaseController
{ {
/** /**
* Handles the initial bank connection flow * VIEW: Connect Nordigen Bank Integration
* @param ConnectNordigenBankIntegrationRequest $request
*/ */
public function connect(ConnectNordigenBankIntegrationRequest $request): View|RedirectResponse public function connect(ConnectNordigenBankIntegrationRequest $request)
{ {
$data = $request->all(); $data = $request->all();
/** @var array $context */
$context = $request->getTokenContent(); $context = $request->getTokenContent();
$company = $request->getCompany();
$lang = substr($company->locale(), 0, 2);
$context["lang"] = $lang;
if (!$context) { if (!$context) {
return $this->failed('token-invalid', ['lang' => 'en']); return view('bank.nordigen.handler', [
'lang' => $lang,
'failed_reason' => "token-invalid",
"redirectUrl" => config("ninja.app_url") . "?action=nordigen_connect&status=failed&reason=token-invalid",
]);
}
$context["redirect"] = $data["redirect"];
if ($context["context"] != "nordigen" || array_key_exists("requisitionId", $context)) {
return view('bank.nordigen.handler', [
'lang' => $lang,
'failed_reason' => "token-invalid",
"redirectUrl" => ($context["redirect"]) . "?action=nordigen_connect&status=failed&reason=token-invalid",
]);
} }
$company = $request->getCompany(); $company = $request->getCompany();
$context['redirect'] = $data['redirect']; $account = $company->account;
$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'))) { 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', [ return view('bank.nordigen.handler', [
'lang' => $lang, 'lang' => $lang,
'company' => $company, 'company' => $company,
'account' => $company->account, 'account' => $company->account,
'institutions' => $institutions, 'failed_reason' => "account-config-invalid",
'institutionId' => $data['institution_id'] ?? null, "redirectUrl" => $context["redirect"] . "?action=nordigen_connect&status=failed&reason=account-config-invalid",
'redirectUrl' => $context['redirect'] . '?action=nordigen_connect&status=user-aborted'
]); ]);
} }
$institution = array_values(array_filter($institutions, function ($institution) use ($data) { if (!(Ninja::isSelfHost() || (Ninja::isHosted() && $account->isEnterprisePaidClient()))) {
return $institution['id'] == $data['institution_id']; return view('bank.nordigen.handler', [
}))[0]; 'lang' => $lang,
'company' => $company,
'account' => $company->account,
'failed_reason' => "not-available",
"redirectUrl" => $context["redirect"] . "?action=nordigen_connect&status=failed&reason=not-available",
]);
}
try { $nordigen = new Nordigen();
$txDays = $data['tx_days'] ?? 0;
$agreement = $nordigen->firstValidAgreement($institution['id'], $data['access_days'] ?? 0, $txDays) // show bank_selection_screen, when institution_id is not present
?? $nordigen->createAgreement($institution, $data['access_days'] ?? 9999, $txDays); if (!array_key_exists("institution_id", $data)) {
} catch (\Exception $e) { return view('bank.nordigen.handler', [
$debug = "{$e->getMessage()} ({$e->getCode()})"; 'lang' => $lang,
'company' => $company,
nlog("Nordigen: Could not create an agreement with ${institution['name']}: {$debug}"); 'account' => $company->account,
'institutions' => $nordigen->getInstitutions(),
return $this->failed('eua-failure', $context, $company); 'redirectUrl' => $context["redirect"] . "?action=nordigen_connect&status=user-aborted"
]);
} }
// redirect to requisition flow // redirect to requisition flow
try { try {
$requisition = $nordigen->createRequisition( $requisition = $nordigen->createRequisition(config('ninja.app_url') . '/nordigen/confirm', $data['institution_id'], $request->token, $lang);
config('ninja.app_url') . '/nordigen/confirm',
$institution,
$agreement,
$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 } 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(); $responseBody = (string) $e->getResponse()->getBody();
if (str_contains($responseBody, '"institution_id"')) { if (str_contains($responseBody, '"institution_id"')) { // provided institution_id was wrong
return $this->failed('institution-invalid', $context, $company); return view('bank.nordigen.handler', [
'lang' => $lang,
'company' => $company,
'account' => $company->account,
'failed_reason' => "institution-invalid",
"redirectUrl" => $context["redirect"] . "?action=nordigen_connect&status=failed&reason=institution-invalid",
]);
} elseif (str_contains($responseBody, '"reference"')) { // this error can occur, when a reference was used double or is invalid => therefor we suggest the frontend to use another token
return view('bank.nordigen.handler', [
'lang' => $lang,
'company' => $company,
'account' => $company->account,
'failed_reason' => "token-invalid",
"redirectUrl" => $context["redirect"] . "?action=nordigen_connect&status=failed&reason=token-invalid",
]);
} else {
nlog("Unknown Error from nordigen: " . $e);
nlog($responseBody);
return view('bank.nordigen.handler', [
'lang' => $lang,
'company' => $company,
'account' => $company->account,
'failed_reason' => "unknown",
"redirectUrl" => $context["redirect"] . "?action=nordigen_connect&status=failed&reason=unknown",
]);
} }
// 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 // save cache
$context['requisitionId'] = $requisition['id']; $context["requisitionId"] = $requisition["id"];
Cache::put($request->token, $context, 3600); Cache::put($request->token, $context, 3600);
return response()->redirectTo($requisition['link']); return response()->redirectTo($requisition["link"]);
} }
/** /**
* Handles the OAuth redirect and account setup after bank authentication * VIEW: Confirm Nordigen Bank Integration (redirect after nordigen flow)
* @param ConfirmNordigenBankIntegrationRequest $request
*/ */
public function confirm(ConfirmNordigenBankIntegrationRequest $request): View|RedirectResponse public function confirm(ConfirmNordigenBankIntegrationRequest $request)
{ {
$data = $request->all(); $data = $request->all();
$company = $request->getCompany(); $company = $request->getCompany();
$account = $company->account;
$lang = substr($company->locale(), 0, 2); $lang = substr($company->locale(), 0, 2);
/** @var array $context */ /** @var array $context */
$context = $request->getTokenContent(); $context = $request->getTokenContent();
if (!array_key_exists('lang', $data) && $context['lang'] != 'en') { if (!array_key_exists('lang', $data) && $context['lang'] != 'en') {
return redirect()->route('nordigen.confirm', array_merge(['lang' => $context['lang']], $request->query())); return redirect()->route('nordigen.confirm', array_merge(["lang" => $context['lang']], $request->query()));
} }
if (!$context || $context['context'] != 'nordigen' || !array_key_exists('requisitionId', $context)) { if (!$context || $context["context"] != "nordigen" || !array_key_exists("requisitionId", $context)) {
return $this->failed('ref-invalid', $context); return view('bank.nordigen.handler', [
'lang' => $lang,
'failed_reason' => "ref-invalid",
"redirectUrl" => ($context && array_key_exists("redirect", $context) ? $context["redirect"] : config('ninja.app_url')) . "?action=nordigen_connect&status=failed&reason=ref-invalid",
]);
} }
if (!config('ninja.nordigen.secret_id') || !config('ninja.nordigen.secret_key')) { if (!(config('ninja.nordigen.secret_id') && config('ninja.nordigen.secret_key'))) {
return $this->failed('account-config-invalid', $context, $company); return view('bank.nordigen.handler', [
'lang' => $lang,
'company' => $company,
'account' => $company->account,
'failed_reason' => "account-config-invalid",
"redirectUrl" => $context["redirect"] . "?action=nordigen_connect&status=failed&reason=account-config-invalid",
]);
} }
if (!(Ninja::isSelfHost() || (Ninja::isHosted() && $company->account->isEnterprisePaidClient()))) { if (!(Ninja::isSelfHost() || (Ninja::isHosted() && $account->isEnterprisePaidClient()))) {
return $this->failed('not-available', $context, $company); return view('bank.nordigen.handler', [
'lang' => $lang,
'company' => $company,
'account' => $company->account,
'failed_reason' => "not-available",
"redirectUrl" => $context["redirect"] . "?action=nordigen_connect&status=failed&reason=not-available",
]);
} }
// fetch requisition // fetch requisition
$nordigen = new Nordigen(); $nordigen = new Nordigen();
$requisition = $nordigen->getRequisition($context['requisitionId']); $requisition = $nordigen->getRequisition($context["requisitionId"]);
// check validity of requisition // check validity of requisition
if (!$requisition) { if (!$requisition) {
return $this->failed('requisition-not-found', $context, $company); return view('bank.nordigen.handler', [
'lang' => $lang,
'company' => $company,
'account' => $company->account,
'failed_reason' => "requisition-not-found",
"redirectUrl" => $context["redirect"] . "?action=nordigen_connect&status=failed&reason=requisition-not-found",
]);
} }
if ($requisition['status'] != 'LN') { if ($requisition["status"] != "LN") {
return $this->failed('requisition-invalid-status&status=' . $requisition['status'], $context, $company); return view('bank.nordigen.handler', [
'lang' => $lang,
'company' => $company,
'account' => $company->account,
'failed_reason' => "requisition-invalid-status",
"redirectUrl" => $context["redirect"] . "?action=nordigen_connect&status=failed&reason=requisition-invalid-status&status=" . $requisition["status"],
]);
} }
if (sizeof($requisition['accounts']) == 0) { if (sizeof($requisition["accounts"]) == 0) {
return $this->failed('requisition-no-accounts', $context, $company); return view('bank.nordigen.handler', [
'lang' => $lang,
'company' => $company,
'account' => $company->account,
'failed_reason' => "requisition-no-accounts",
"redirectUrl" => $context["redirect"] . "?action=nordigen_connect&status=failed&reason=requisition-no-accounts",
]);
} }
// connect new accounts // connect new accounts
$bank_integration_ids = []; $bank_integration_ids = [];
foreach ($requisition['accounts'] as $nordigenAccountId) { foreach ($requisition["accounts"] as $nordigenAccountId) {
$nordigen_account = $nordigen->getAccount($nordigenAccountId); $nordigen_account = $nordigen->getAccount($nordigenAccountId);
if (isset($nordigen_account['error'])) { if (isset($nordigen_account['error'])) {
continue; continue;
} }
try { $existing_bank_integration = BankIntegration::withTrashed()->where('nordigen_account_id', $nordigen_account['id'])->where('company_id', $company->id)->where('is_deleted', 0)->first();
$bank_integration = $this->findIntegrationBy('account', $nordigen_account, $company);
if (!$existing_bank_integration) {
$bank_integration->deleted_at = null;
} catch (ModelNotFoundException $e) {
$bank_integration = new BankIntegration(); $bank_integration = new BankIntegration();
$bank_integration->integration_type = BankIntegration::INTEGRATION_TYPE_NORDIGEN; $bank_integration->integration_type = BankIntegration::INTEGRATION_TYPE_NORDIGEN;
$bank_integration->company_id = $company->id; $bank_integration->company_id = $company->id;
$bank_integration->account_id = $company->account_id; $bank_integration->account_id = $company->account_id;
@ -188,82 +234,53 @@ class NordigenController extends BaseController
$bank_integration->nordigen_account_id = $nordigen_account['id']; $bank_integration->nordigen_account_id = $nordigen_account['id'];
$bank_integration->bank_account_type = $nordigen_account['account_type']; $bank_integration->bank_account_type = $nordigen_account['account_type'];
$bank_integration->bank_account_name = $nordigen_account['account_name']; $bank_integration->bank_account_name = $nordigen_account['account_name'];
$bank_integration->bank_account_status = $nordigen_account['account_status'];
$bank_integration->bank_account_number = $nordigen_account['account_number']; $bank_integration->bank_account_number = $nordigen_account['account_number'];
$bank_integration->nordigen_institution_id = $nordigen_account['provider_id']; $bank_integration->nordigen_institution_id = $nordigen_account['provider_id'];
$bank_integration->provider_name = $nordigen_account['provider_name']; $bank_integration->provider_name = $nordigen_account['provider_name'];
$bank_integration->nickname = $nordigen_account['nickname']; $bank_integration->nickname = $nordigen_account['nickname'];
$bank_integration->currency = $nordigen_account['account_currency'];
} finally {
$bank_integration->auto_sync = true;
$bank_integration->disabled_upstream = false;
$bank_integration->balance = $nordigen_account['current_balance']; $bank_integration->balance = $nordigen_account['current_balance'];
$bank_integration->bank_account_status = $nordigen_account['account_status']; $bank_integration->currency = $nordigen_account['account_currency'];
$bank_integration->from_date = now()->subDays($nordigen_account['provider_history']); $bank_integration->disabled_upstream = false;
$bank_integration->auto_sync = true;
$bank_integration->from_date = now()->subDays(90); // default max-fetch interval of nordigen is 90 days
$bank_integration->save(); $bank_integration->save();
array_push($bank_integration_ids, $bank_integration->id); array_push($bank_integration_ids, $bank_integration->id);
} else {
// resetting metadata for account status
$existing_bank_integration->balance = $nordigen_account['current_balance'];
$existing_bank_integration->bank_account_status = $nordigen_account['account_status'];
$existing_bank_integration->disabled_upstream = false;
$existing_bank_integration->auto_sync = true;
$existing_bank_integration->from_date = now()->subDays(90); // default max-fetch interval of nordigen is 90 days
$existing_bank_integration->deleted_at = null;
$existing_bank_integration->save();
array_push($bank_integration_ids, $existing_bank_integration->id);
} }
} }
// perform update in background // perform update in background
$company->account->bank_integrations $company->account->bank_integrations->where("integration_type", BankIntegration::INTEGRATION_TYPE_NORDIGEN)->where('auto_sync', true)->each(function ($bank_integration) {
->where('integration_type', BankIntegration::INTEGRATION_TYPE_NORDIGEN) ProcessBankTransactionsNordigen::dispatch($bank_integration);
->where('auto_sync', true) });
->each(function ($bank_integration) {
ProcessBankTransactionsNordigen::dispatch($bank_integration);
});
// prevent rerun of this method with same ref // prevent rerun of this method with same ref
Cache::delete($data['ref']); Cache::delete($data["ref"]);
// Successfull Response => Redirect // Successfull Response => Redirect
return response()->redirectTo($context['redirect'] . '?action=nordigen_connect&status=success&bank_integrations=' . implode(',', $bank_integration_ids)); return response()->redirectTo($context["redirect"] . "?action=nordigen_connect&status=success&bank_integrations=" . implode(',', $bank_integration_ids));
} }
/** /**
* Handles failure scenarios for Nordigen bank integrations * Process Nordigen Institutions GETTER.
* *
* @param array{lang: string, redirect?: string}|null $context
* @param array{account: array}|null $company
*/
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( * @OA\Post(
* path="/api/v1/nordigen/institutions", * path="/api/v1/nordigen/institutions",
@ -295,14 +312,14 @@ class NordigenController extends BaseController
* ), * ),
* ) * )
*/ */
public function institutions(Request $request): JsonResponse public function institutions(Request $request)
{ {
if (!(config('ninja.nordigen.secret_id') && config('ninja.nordigen.secret_key'))) { if (!(config('ninja.nordigen.secret_id') && config('ninja.nordigen.secret_key'))) {
return response()->json(['message' => 'Not yet authenticated with Nordigen Bank Integration service'], 400); return response()->json(['message' => 'Not yet authenticated with Nordigen Bank Integration service'], 400);
} }
$nordigen = new Nordigen(); $nordigen = new Nordigen();
return response()->json($nordigen->getInstitutions()); return response()->json($nordigen->getInstitutions());
} }
}
}

View File

@ -1,4 +1,5 @@
<?php <?php
/** /**
* Invoice Ninja (https://invoiceninja.com). * Invoice Ninja (https://invoiceninja.com).
* *
@ -20,36 +21,27 @@ class ConfirmNordigenBankIntegrationRequest extends Request
{ {
/** /**
* Determine if the user is authorized to make this request. * Determine if the user is authorized to make this request.
*
* @return bool
*/ */
public function authorize(): bool public function authorize()
{ {
return true; return true;
} }
/** /**
* Get the validation rules that apply to the request. * Get the validation rules that apply to the request.
*
* @return array
*/ */
public function rules(): array public function rules()
{ {
return [ return [
'ref' => 'required|string', // nordigen redirects only with the ref-property 'ref' => 'required|string', // nordigen redirects only with the ref-property
'lang' => 'string', 'lang' => 'string',
]; ];
} }
public function getTokenContent()
/**
* @return array{
* user_id: int,
* company_key: string,
* context: string,
* is_react: bool,
* institution_id: string,
* lang: string,
* redirect: string,
* requisitionId: string
* }
*/
public function getTokenContent(): array
{ {
$input = $this->all(); $input = $this->all();
@ -58,12 +50,10 @@ class ConfirmNordigenBankIntegrationRequest extends Request
return $data; return $data;
} }
public function getCompany(): Company public function getCompany()
{ {
$key = $this->getTokenContent()['company_key']; MultiDB::findAndSetDbByCompanyKey($this->getTokenContent()['company_key']);
MultiDB::findAndSetDbByCompanyKey($key); return Company::where('company_key', $this->getTokenContent()['company_key'])->firstOrFail();
return Company::where('company_key', $key)->firstOrFail();
} }
} }

View File

@ -1,4 +1,5 @@
<?php <?php
/** /**
* Invoice Ninja (https://invoiceninja.com). * Invoice Ninja (https://invoiceninja.com).
* *
@ -20,22 +21,26 @@ class ConnectNordigenBankIntegrationRequest extends Request
{ {
/** /**
* Determine if the user is authorized to make this request. * Determine if the user is authorized to make this request.
*
* @return bool
*/ */
public function authorize(): bool public function authorize()
{ {
return true; return true;
} }
/** /**
* Get the validation rules that apply to the request. * Get the validation rules that apply to the request.
*
* @return array
*/ */
public function rules(): array public function rules()
{ {
return [ return [
]; ];
} }
public function prepareForValidation(): void public function prepareForValidation()
{ {
$input = $this->all(); $input = $this->all();
@ -45,24 +50,12 @@ class ConnectNordigenBankIntegrationRequest extends Request
$input['institution_id'] = $context['institution_id']; $input['institution_id'] = $context['institution_id'];
} }
$input['redirect'] = ($context['is_react'] ?? false) $input["redirect"] = isset($context["is_react"]) && $context['is_react'] ? config('ninja.react_url') . "/#/settings/bank_accounts" : config('ninja.app_url');
? config('ninja.react_url') . '/#/settings/bank_accounts'
: config('ninja.app_url');
$this->replace($input); $this->replace($input);
}
/** }
* @return array{ public function getTokenContent()
* user_id: int,
* company_key: string,
* context: string,
* is_react: bool,
* institution_id: string,
* requisitionId?: string
* }
*/
public function getTokenContent(): ?array
{ {
if ($this->state) { if ($this->state) {
$this->token = $this->state; $this->token = $this->state;
@ -73,12 +66,10 @@ class ConnectNordigenBankIntegrationRequest extends Request
return $data; return $data;
} }
public function getCompany(): Company public function getCompany()
{ {
$key = $this->getTokenContent()['company_key']; MultiDB::findAndSetDbByCompanyKey($this->getTokenContent()['company_key']);
MultiDB::findAndSetDbByCompanyKey($key); return Company::where('company_key', $this->getTokenContent()['company_key'])->firstOrFail();
return Company::where('company_key', $key)->firstOrFail();
} }
} }

View File

@ -4,9 +4,6 @@
@push('head') @push('head')
<link href="https://unpkg.com/nordigen-bank-ui@1.5.2/package/src/selector.min.css" rel="stylesheet" /> <link href="https://unpkg.com/nordigen-bank-ui@1.5.2/package/src/selector.min.css" rel="stylesheet" />
<style type="text/css">
.institution-modal-close { left: calc(100% - 12px); }
</style>
@endpush @endpush
@ -30,7 +27,7 @@
// Logo URL that will be shown below the modal form. // Logo URL that will be shown below the modal form.
logoUrl: "{{ ($account ?? false) && !$account->isPaid() ? asset('images/invoiceninja-black-logo-2.png') : (isset($company) && !is_null($company) ? $company->present()->logo() : asset('images/invoiceninja-black-logo-2.png')) }}", logoUrl: "{{ ($account ?? false) && !$account->isPaid() ? asset('images/invoiceninja-black-logo-2.png') : (isset($company) && !is_null($company) ? $company->present()->logo() : asset('images/invoiceninja-black-logo-2.png')) }}",
// Will display country list with corresponding institutions. When `countryFilter` is set to `false`, only list of institutions will be shown. // Will display country list with corresponding institutions. When `countryFilter` is set to `false`, only list of institutions will be shown.
countryFilter: true, countryFilter: false,
// style configs // style configs
styles: { styles: {
// Primary // Primary
@ -48,83 +45,25 @@
buttonColor: '#3A53EE', buttonColor: '#3A53EE',
buttonTextColor: '#fff' buttonTextColor: '#fff'
} }
}, createSelectionUI = (donor, institution, skippedSelect = false) => {
const clone = donor.cloneNode(true),
container = document.querySelector('.institution-container'),
max_history = parseInt(institution.transaction_total_days),
url = new URL(window.location.href);
container.innerHTML = '';
_changeHeading('Select your transaction history');
clone.classList.replace('ob-list-institution', 'ob-history-option');
clone.querySelector('.ob-span-text').innerText = `${max_history} days`;
url.searchParams.set('institution_id', institutionId);
// When we come from the renew button we need to replace the country flag
if (skippedSelect) {
const logo = document.createElement('img');
logo.setAttribute('src', institution.logo)
logo.setAttribute('class', 'ob-institution-logo');
clone.querySelector('span.fi').replaceWith(logo)
}
for (let i = 30, next = 30; i <= max_history; i += next) {
// If we're close to max, just use the real value
if (max_history - i < 15) {
continue;
}
const option = clone.cloneNode(true);
url.searchParams.set('tx_days', i == 360 ? 365 : i);
option.querySelector('.ob-span-text').innerText = `${i == 360 ? 365 : i} days`;
option.querySelector('a').href = url.href;
container.append(option);
// 1, 2, 3, 4, 6, 9, 12, 14, 18, 24 months--as of 24/12/24, no bank exceeds 730 days of history
next = i >= 500 ? 180 : i >= 400 ? 120 : i >= 360 ? 60 : i >= 180 ? 90 : i >= 120 ? 60 : 30;
}
url.searchParams.set('tx_days', max_history);
clone.querySelector('a').href = url.href;
container.append(clone);
}; };
const failedReason = "{{ $failed_reason ?? '' }}".trim(), const failedReason = "{{ $failed_reason ?? '' }}".trim();
institutions = @json($institutions ?? []);
let institutionId = "{{ $institutionId ?? '' }}"; new institutionSelector(@json($institutions ?? []), 'institution-modal-content', config);
new institutionSelector(institutions, 'institution-modal-content', config); if (!failedReason) {
if (null !== institutionId && !failedReason) { const institutionList = Array.from(document.querySelectorAll('.ob-list-institution > a'));
createSelectionUI(
document.querySelector('.ob-institution'),
institutions.find(i => i.id == institutionId),
true
);
} else if (!failedReason) {
const observer = new MutationObserver((event) => {
const institutionButtons = document.querySelectorAll('.ob-list-institution > a');
Array.from(institutionButtons).forEach((button) => { institutionList.forEach((institution) => {
button.addEventListener('click', (e) => { institution.addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault()
const institutionId = institution.getAttribute('data-institution');
createSelectionUI(button.parentElement, institutions.find( const url = new URL(window.location.href);
i => i.id == button.getAttribute('data-institution') url.searchParams.set('institution_id', institutionId);
)); window.location.href = url.href;
});
}); });
}); });
observer.observe(document.querySelector('.institution-container'), {
childList: true,
});
} else { } else {
document.getElementsByClassName("institution-search-container")[0].remove(); document.getElementsByClassName("institution-search-container")[0].remove();
document.getElementsByClassName("institution-container")[0].remove(); document.getElementsByClassName("institution-container")[0].remove();
@ -140,6 +79,7 @@
let restartFlow = false; // return, restart, refresh let restartFlow = false; // return, restart, refresh
heading.innerHTML = "{{ ctrans('texts.nordigen_handler_error_heading_unknown', [], $lang ?? 'en') }}"; heading.innerHTML = "{{ ctrans('texts.nordigen_handler_error_heading_unknown', [], $lang ?? 'en') }}";
contents.innerHTML = "{{ ctrans('texts.nordigen_handler_error_contents_unknown', [], $lang ?? 'en') }} " + failedReason; contents.innerHTML = "{{ ctrans('texts.nordigen_handler_error_contents_unknown', [], $lang ?? 'en') }} " + failedReason;
switch (failedReason) { switch (failedReason) {
// Connect Screen Errors // Connect Screen Errors
case "token-invalid": case "token-invalid":
@ -159,10 +99,6 @@
heading.innerHTML = "{{ ctrans('texts.nordigen_handler_error_heading_institution_invalid', [], $lang ?? 'en') }}"; heading.innerHTML = "{{ ctrans('texts.nordigen_handler_error_heading_institution_invalid', [], $lang ?? 'en') }}";
contents.innerHTML = "{{ ctrans('texts.nordigen_handler_error_contents_institution_invalid', [], $lang ?? 'en') }}"; contents.innerHTML = "{{ ctrans('texts.nordigen_handler_error_contents_institution_invalid', [], $lang ?? 'en') }}";
break; break;
case "eua-failure":
heading.innerHTML = "{{ ctrans('texts.nordigen_handler_error_heading_eua_failure', [], $lang ?? 'en') }}";
contents.innerHTML = "{{ ctrans('texts.nordigen_handler_error_contents_eua_failure', [], $lang ?? 'en') }} " + failedReason;
break;
// Confirm Screen Errors // Confirm Screen Errors
case "ref-invalid": case "ref-invalid":
heading.innerHTML = "{{ ctrans('texts.nordigen_handler_error_heading_ref_invalid', [], $lang ?? 'en') }}"; heading.innerHTML = "{{ ctrans('texts.nordigen_handler_error_heading_ref_invalid', [], $lang ?? 'en') }}";
@ -195,20 +131,6 @@
wrapper.appendChild(returnButton); wrapper.appendChild(returnButton);
} }
const backButton = document.querySelector('.institution-arrow-block');
const backButtonObserver = new MutationObserver((records) => {
const title = document.querySelector('#institution-modal-header h2').innerText;
backButton.style.visibility = title == 'Select your country' ? 'hidden' : 'visible';
backButton.style.display = 'flex';
});
backButton.style.display = 'flex';
backButton.style.visibility = 'hidden';
backButtonObserver.observe(document.querySelector('header h2'), {
childList: true,
});
</script> </script>
@endpush @endpush