1
0
mirror of https://github.com/invoiceninja/invoiceninja.git synced 2024-11-12 22:22:32 +01:00

rewrite connect/confirm for complete flow usage with only redirects

This commit is contained in:
paulwer 2023-12-09 09:27:59 +01:00
parent e349f1515d
commit c138f2f211
5 changed files with 124 additions and 251 deletions

View File

@ -16,6 +16,8 @@ namespace App\Helpers\Bank\Nordigen;
use App\Exceptions\NordigenApiException; use App\Exceptions\NordigenApiException;
use App\Helpers\Bank\Nordigen\Transformer\AccountTransformer; use App\Helpers\Bank\Nordigen\Transformer\AccountTransformer;
use App\Helpers\Bank\Nordigen\Transformer\IncomeTransformer; use App\Helpers\Bank\Nordigen\Transformer\IncomeTransformer;
use Log;
use Nordigen\NordigenPHP\Exceptions\NordigenExceptions\NordigenException;
class Nordigen class Nordigen
{ {
@ -31,6 +33,7 @@ class Nordigen
$this->client = new \Nordigen\NordigenPHP\API\NordigenClient($secret_id, $secret_key); $this->client = new \Nordigen\NordigenPHP\API\NordigenClient($secret_id, $secret_key);
$this->client->createAccessToken(); // access_token is valid 24h -> so we dont have to implement a refresh-cycle $this->client->createAccessToken(); // access_token is valid 24h -> so we dont have to implement a refresh-cycle
} }
// metadata-section for frontend // metadata-section for frontend
@ -56,52 +59,7 @@ class Nordigen
return $this->client->requisition->getRequisition($requisitionId); return $this->client->requisition->getRequisition($requisitionId);
} }
// NOTE: this will only cleanup the requisitions from nordigen and not within the table: bank_integration_nordigen_requisitions // TODO: return null on not found
public function cleanupRequisitions()
{
$requisitions = $this->client->requisition->getRequisitions();
foreach ($requisitions as $requisition) {
// filter to expired OR older than 7 days created and no accounts
if ($requisition->status == "EXPIRED" || (sizeOf($requisition->accounts) != 0 && strtotime($requisition->created) > (new \DateTime())->modify('-7 days')))
continue;
$this->client->requisition->deleteRequisition($requisition->id);
}
}
// account-section: these methods should be used to get data of connected accounts
public function getAccounts(?array $requisitionIds)
{
// get all valid requisitions
$requisitions = $this->client->requisition->getRequisitions(); // no pagination used?!
// fetch all valid accounts for activated requisitions
$nordigen_accountIds = [];
foreach ($requisitions["results"] as $requisition) {
// FILTER: for requisitionIds
if ($requisitionIds && !in_array($requisition["id"], $requisitionIds))
continue;
foreach ($requisition["accounts"] as $accountId) {
array_push($nordigen_accountIds, $accountId);
}
}
$nordigen_accountIds = array_unique($nordigen_accountIds);
$nordigen_accounts = [];
foreach ($nordigen_accountIds as $accountId) {
$nordigen_account = $this->getAccount($accountId);
array_push($nordigen_accounts, $nordigen_account);
}
return $nordigen_accounts;
}
public function getAccount(string $account_id) public function getAccount(string $account_id)
{ {

View File

@ -12,151 +12,77 @@
namespace App\Helpers\Bank\Nordigen\Transformer; namespace App\Helpers\Bank\Nordigen\Transformer;
use App\Helpers\Bank\BankRevenueInterface; use App\Helpers\Bank\BankRevenueInterface;
use App\Models\BankIntegration;
use App\Utils\Traits\AppSetup; use App\Utils\Traits\AppSetup;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
/** /**
"date": "string", {
"sourceId": "string", "transactions": {
"symbol": "string", "booked": [
"cusipNumber": "string", {
"highLevelCategoryId": 0, "transactionId": "string",
"detailCategoryId": 0, "debtorName": "string",
"description": {}, "debtorAccount": {
"memo": "string", "iban": "string"
"settleDate": "string", },
"type": "string", "transactionAmount": {
"intermediary": [], "currency": "string",
"baseType": "CREDIT", "amount": "328.18"
"categorySource": "SYSTEM", },
"principal": {}, "bankTransactionCode": "string",
"lastUpdated": "string", "bookingDate": "date",
"interest": {}, "valueDate": "date",
"price": {}, "remittanceInformationUnstructured": "string"
"commission": {}, },
"id": 0, {
"merchantType": "string", "transactionId": "string",
"amount": { "transactionAmount": {
"amount": 0, "currency": "string",
"convertedAmount": 0, "amount": "947.26"
"currency": "USD", },
"convertedCurrency": "USD" "bankTransactionCode": "string",
}, "bookingDate": "date",
"checkNumber": "string", "valueDate": "date",
"isPhysical": true, "remittanceInformationUnstructured": "string"
"quantity": 0, }
"valoren": "string", ],
"isManual": true, "pending": [
"merchant": { {
"website": "string", "transactionAmount": {
"address": {}, "currency": "string",
"contact": {}, "amount": "99.20"
"categoryLabel": [], },
"coordinates": {}, "valueDate": "date",
"name": "string", "remittanceInformationUnstructured": "string"
"id": "string", }
"source": "YODLEE", ]
"logoURL": "string" }
}, }
"sedol": "string",
"transactionDate": "string",
"categoryType": "TRANSFER",
"accountId": 0,
"createdDate": "string",
"sourceType": "AGGREGATED",
"CONTAINER": "bank",
"postDate": "string",
"parentCategoryId": 0,
"subType": "OVERDRAFT_CHARGE",
"category": "string",
"runningBalance": {},
"categoryId": 0,
"holdingDescription": "string",
"isin": "string",
"status": "POSTED"
(
[CONTAINER] => bank
[id] => 103953585
[amount] => stdClass Object
(
[amount] => 480.66
[currency] => USD
)
[categoryType] => UNCATEGORIZE
[categoryId] => 1
[category] => Uncategorized
[categorySource] => SYSTEM
[highLevelCategoryId] => 10000017
[createdDate] => 2022-08-04T21:50:17Z
[lastUpdated] => 2022-08-04T21:50:17Z
[description] => stdClass Object
(
[original] => CHEROKEE NATION TAX TA TAHLEQUAH OK
)
[isManual] =>
[sourceType] => AGGREGATED
[date] => 2022-08-03
[transactionDate] => 2022-08-03
[postDate] => 2022-08-03
[status] => POSTED
[accountId] => 12331794
[runningBalance] => stdClass Object
(
[amount] => 480.66
[currency] => USD
)
[checkNumber] => 998
)
*/ */
class IncomeTransformer implements BankRevenueInterface class IncomeTransformer implements BankRevenueInterface
{ {
use AppSetup; use AppSetup;
public function transform($transaction) public function transform(BankIntegration $bank_integration, $transaction)
{ {
$data = []; if (!property_exists($transaction, 'transactionId') || !property_exists($transaction, 'transactionAmount') || !property_exists($transaction, 'balances') || !property_exists($transaction, 'institution'))
throw new \Exception('invalid dataset');
if (!property_exists($transaction, 'transaction'))
return $data;
foreach ($transaction->transaction as $transaction) {
$data[] = $this->transformTransaction($transaction);
}
return $data;
}
public function transformTransaction($transaction)
{
return [ return [
'transaction_id' => $transaction->id, 'transaction_id' => $transaction->transactionId,
'amount' => $transaction->amount->amount, 'amount' => abs($transaction->transactionAmount->amount),
'currency_id' => $this->convertCurrency($transaction->amount->currency), 'currency_id' => $this->convertCurrency($transaction->transactionAmount->currency),
'account_type' => $transaction->CONTAINER, 'account_type' => 'bank',
'category_id' => $transaction->highLevelCategoryId, 'category_id' => $transaction->highLevelCategoryId,
'category_type' => $transaction->categoryType, 'category_type' => $transaction->categoryType,
'date' => $transaction->date, 'date' => $transaction->bookingDate,
'bank_account_id' => $transaction->accountId, 'bank_account_id' => $bank_integration->id,
'description' => $transaction->description->original, 'description' => $transaction->remittanceInformationUnstructured,
'base_type' => property_exists($transaction, 'baseType') ? $transaction->baseType : $this->calculateBaseType($transaction), 'base_type' => $transaction->transactionAmount->amount > 0 ? 'DEBIT' : 'CREDIT',
]; ];
}
private function calculateBaseType($transaction)
{
//CREDIT / DEBIT
if (property_exists($transaction, 'highLevelCategoryId') && $transaction->highLevelCategoryId == 10000012)
return 'CREDIT';
return 'DEBIT';
} }
@ -180,7 +106,6 @@ class IncomeTransformer implements BankRevenueInterface
} }
} }

View File

@ -22,6 +22,7 @@ use App\Models\Company;
use Cache; use Cache;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Log; use Log;
use Nordigen\NordigenPHP\Exceptions\NordigenExceptions\NordigenException;
class NordigenController extends BaseController class NordigenController extends BaseController
{ {
@ -166,34 +167,42 @@ class NordigenController extends BaseController
}*/ }*/
public function connect(ConnectNordigenBankIntegrationRequest $request) public function connect(ConnectNordigenBankIntegrationRequest $request)
{ {
$account = auth()->user()->account;
if (!$account->bank_integration_nordigen_secret_id || !$account->bank_integration_nordigen_secret_key)
return response()->json(['message' => 'Not yet authenticated with Nordigen Bank Integration service'], 400);
$data = $request->all(); $data = $request->all();
$context = Cache::get($data["hash"]); $context = Cache::get($data["one_time_token"]);
Log::info($context);
if (!$context || $context["context"] != "nordigen" || array_key_exists("requisition", $context)) // TODO: check for requisition array key
return response()->json(['message' => 'Invalid context one_time_token. (not-found|invalid-context|already-used) Call /api/v1/one_time_token with context: \'nordigen\' first.'], 400);
Log::info(config('ninja.app_url') . '/api/v1/nordigen/confirm'); if (!$context || $context["context"] != "nordigen" || array_key_exists("requisitionId", $context))
return response()->redirectTo($data["redirect"] . "?action=nordigen_connect&status=failed&reason=one-time-token-invalid");
$company = Company::where('company_key', $context["company_key"])->first();
$account = $company->account;
if (!$account->bank_integration_nordigen_secret_id || !$account->bank_integration_nordigen_secret_key)
return response()->redirectTo($data["redirect"] . "?action=nordigen_connect&status=failed&reason=account-config-invalid");
$nordigen = new Nordigen($account->bank_integration_nordigen_secret_id, $account->bank_integration_nordigen_secret_key); $nordigen = new Nordigen($account->bank_integration_nordigen_secret_id, $account->bank_integration_nordigen_secret_key);
$requisition = $nordigen->createRequisition(config('ninja.app_url') . '/api/v1/nordigen/confirm', $data['institutionId'], $data["hash"]); try {
$requisition = $nordigen->createRequisition(config('ninja.app_url') . '/api/v1/nordigen/confirm', $data['institution_id'], "1");
} 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
Log::error($e);
$responseBody = $e->getResponse()->getBody();
Log::info($responseBody);
if (property_exists($responseBody, "institution_id")) // provided institution_id was wrong
return response()->redirectTo($data["redirect"] . "?action=nordigen_connect&status=failed&reason=institution-invalid");
else if (property_exists($responseBody, "reference")) // this error can occur, when a reference was used double or is invalid => therefor we suggest the frontend to use another one-time-token
return response()->redirectTo($data["redirect"] . "?action=nordigen_connect&status=failed&reason=one-time-token-invalid");
else
return response()->redirectTo($data["redirect"] . "?action=nordigen_connect&status=failed&reason=unknown");
}
// save cache // save cache
if (array_key_exists("redirectUri", $data)) if (array_key_exists("redirect", $data))
$context["redirectUri"] = $data["redirectUri"]; $context["redirect"] = $data["redirect"];
$context["requisitionId"] = $requisition["id"]; $context["requisitionId"] = $requisition["id"];
Cache::put($data["hash"], $context, 3600); Cache::put($data["one_time_token"], $context, 3600);
return response()->json([
'result' => $requisition,
'redirectUri' => array_key_exists("redirectUri", $data) ? $data["redirectUri"] : null,
]);
return response()->redirectTo($requisition["link"]);
} }
/** /**
@ -266,62 +275,27 @@ class NordigenController extends BaseController
$data = $request->all(); $data = $request->all();
$context = Cache::get($data["ref"]); $context = Cache::get($data["ref"]);
if (!$context || $context["context"] != "nordigen" || !array_key_exists("requisitionId", $context)) { if (!$context || $context["context"] != "nordigen" || !array_key_exists("requisitionId", $context))
if ($context && array_key_exists("redirectUri", $context)) return response()->redirectTo($context["redirect"] . "?action=nordigen_connect&status=failed&reason=ref-invalid");
return response()->redirectTo($context["redirectUri"] . "?action=nordigen_connect&status=failed&reason=ref-invalid");
return response()->json([
'status' => 'failed',
'reason' => 'ref-invalid',
], 400);
}
$company = Company::where('company_key', $context["company_key"])->first(); $company = Company::where('company_key', $context["company_key"])->first();
$account = $company->account; $account = $company->account;
if (!$account->bank_integration_nordigen_secret_id || !$account->bank_integration_nordigen_secret_key) { if (!$account->bank_integration_nordigen_secret_id || !$account->bank_integration_nordigen_secret_key)
if (array_key_exists("redirectUri", $context)) return response()->redirectTo($context["redirect"] . "?action=nordigen_connect&status=failed&reason=account-config-invalid");
return response()->redirectTo($context["redirectUri"] . "?action=nordigen_connect&status=failed&reason=account-config-invalid");
return response()->json([
'status' => 'failed',
'reason' => 'account-config-invalid',
], 400);
}
// fetch requisition // fetch requisition
$nordigen = new Nordigen($account->bank_integration_nordigen_secret_id, $account->bank_integration_nordigen_secret_key); $nordigen = new Nordigen($account->bank_integration_nordigen_secret_id, $account->bank_integration_nordigen_secret_key);
$requisition = $nordigen->getRequisition($context["requisitionId"]); $requisition = $nordigen->getRequisition($context["requisitionId"]);
// check validity of requisition // check validity of requisition
if (!$requisition) { if (!$requisition)
if (array_key_exists("redirectUri", $context)) return response()->redirectTo($context["redirect"] . "?action=nordigen_connect&status=failed&reason=requisition-not-found");
return response()->redirectTo($context["redirectUri"] . "?action=nordigen_connect&status=failed&reason=requisition-not-found"); if ($requisition["status"] != "LN")
return response()->redirectTo($context["redirect"] . "?action=nordigen_connect&status=failed&reason=requisition-invalid-status");
return response()->json([ if (sizeof($requisition["accounts"]) == 0)
'status' => 'failed', return response()->redirectTo($context["redirect"] . "?action=nordigen_connect&status=failed&reason=requisition-no-accounts");
'reason' => 'requisition-not-found',
], 400);
}
if ($requisition["status"] != "LN") {
if (array_key_exists("redirectUri", $context))
return response()->redirectTo($context["redirectUri"] . "?action=nordigen_connect&status=failed&reason=requisition-invalid-status");
return response()->json([
'status' => 'failed',
'reason' => 'requisition-invalid-status',
], 400);
}
if (sizeof($requisition["accounts"]) == 0) {
if (array_key_exists("redirectUri", $context))
return response()->redirectTo($context["redirectUri"] . "?action=nordigen_connect&status=failed&reason=requisition-no-accounts");
return response()->json([
'status' => 'failed',
'reason' => 'requisition-no-accounts',
], 400);
}
// connect new accounts // connect new accounts
$bank_integration_ids = []; $bank_integration_ids = [];
@ -381,14 +355,8 @@ class NordigenController extends BaseController
// 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 // Successfull Response => Redirect
if (array_key_exists("redirectUri", $context)) return response()->redirectTo($context["redirect"] . "?action=nordigen_connect&status=success&bank_integrations=" . implode(',', $bank_integration_ids));
return response()->redirectTo($context["redirectUri"] . "?action=nordigen_connect&status=success&bank_integrations=" . implode(',', $bank_integration_ids));
return response()->json([
'status' => 'success',
'bank_integrations' => $bank_integration_ids,
]);
} }

View File

@ -12,6 +12,8 @@
namespace App\Http\Requests\Nordigen; namespace App\Http\Requests\Nordigen;
use App\Http\Requests\Request; use App\Http\Requests\Request;
use Cache;
use Log;
class ConnectNordigenBankIntegrationRequest extends Request class ConnectNordigenBankIntegrationRequest extends Request
{ {
@ -33,9 +35,29 @@ class ConnectNordigenBankIntegrationRequest extends Request
public function rules() public function rules()
{ {
return [ return [
'institutionId' => 'required|string', 'institution_id' => 'required|string',
'hash' => 'required|string', // One Time Token 'one_time_token' => 'required|string', // One Time Token
'redirectUri' => 'string', // TODO: @turbo124 @todo validate, that this is a url without / at the end 'redirect' => 'string', // TODO: @turbo124 @todo validate, that this is a url without / at the end
]; ];
} }
// @turbo124 @todo please check for validity, when issue request from frontend
public function prepareForValidation()
{
$input = $this->all();
if (!array_key_exists('redirect', $input)) {
$context = Cache::get($input['one_time_token']);
if (array_key_exists('is_react', $context))
$input["redirect"] = $context["is_react"] ? config("ninja.react_url") : config("ninja.app_url");
else
$input["redirect"] = config("ninja.app_url");
Log::info($input);
$this->replace($input);
}
}
} }

View File

@ -378,7 +378,7 @@ Route::post('api/v1/yodlee/refresh_updates', [YodleeController::class, 'refreshU
Route::post('api/v1/yodlee/balance', [YodleeController::class, 'balanceWebhook'])->middleware('throttle:100,1'); Route::post('api/v1/yodlee/balance', [YodleeController::class, 'balanceWebhook'])->middleware('throttle:100,1');
Route::get('api/v1/nordigen/institutions', [NordigenController::class, 'institutions'])->middleware('throttle:100,1')->middleware('token_auth')->name('nordigen_institutions'); Route::get('api/v1/nordigen/institutions', [NordigenController::class, 'institutions'])->middleware('throttle:100,1')->middleware('token_auth')->name('nordigen_institutions');
Route::post('api/v1/nordigen/connect', [NordigenController::class, 'connect'])->middleware('throttle:100,1')->middleware('token_auth')->name('nordigen_connect'); Route::any('api/v1/nordigen/connect', [NordigenController::class, 'connect'])->middleware('throttle:100,1')->name('nordigen_connect');
Route::any('api/v1/nordigen/confirm', [NordigenController::class, 'confirm'])->middleware('throttle:100,1')->name('nordigen_callback'); Route::any('api/v1/nordigen/confirm', [NordigenController::class, 'confirm'])->middleware('throttle:100,1')->name('nordigen_callback');
Route::fallback([BaseController::class, 'notFound']); Route::fallback([BaseController::class, 'notFound']);