1
0
mirror of https://github.com/invoiceninja/invoiceninja.git synced 2024-11-10 05:02:36 +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\Helpers\Bank\Nordigen\Transformer\AccountTransformer;
use App\Helpers\Bank\Nordigen\Transformer\IncomeTransformer;
use Log;
use Nordigen\NordigenPHP\Exceptions\NordigenExceptions\NordigenException;
class Nordigen
{
@ -31,6 +33,7 @@ class Nordigen
$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
}
// metadata-section for frontend
@ -56,52 +59,7 @@ class Nordigen
return $this->client->requisition->getRequisition($requisitionId);
}
// NOTE: this will only cleanup the requisitions from nordigen and not within the table: bank_integration_nordigen_requisitions
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;
}
// TODO: return null on not found
public function getAccount(string $account_id)
{

View File

@ -12,151 +12,77 @@
namespace App\Helpers\Bank\Nordigen\Transformer;
use App\Helpers\Bank\BankRevenueInterface;
use App\Models\BankIntegration;
use App\Utils\Traits\AppSetup;
use Illuminate\Support\Facades\Cache;
/**
"date": "string",
"sourceId": "string",
"symbol": "string",
"cusipNumber": "string",
"highLevelCategoryId": 0,
"detailCategoryId": 0,
"description": {},
"memo": "string",
"settleDate": "string",
"type": "string",
"intermediary": [],
"baseType": "CREDIT",
"categorySource": "SYSTEM",
"principal": {},
"lastUpdated": "string",
"interest": {},
"price": {},
"commission": {},
"id": 0,
"merchantType": "string",
"amount": {
"amount": 0,
"convertedAmount": 0,
"currency": "USD",
"convertedCurrency": "USD"
},
"checkNumber": "string",
"isPhysical": true,
"quantity": 0,
"valoren": "string",
"isManual": true,
"merchant": {
"website": "string",
"address": {},
"contact": {},
"categoryLabel": [],
"coordinates": {},
"name": "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
)
{
"transactions": {
"booked": [
{
"transactionId": "string",
"debtorName": "string",
"debtorAccount": {
"iban": "string"
},
"transactionAmount": {
"currency": "string",
"amount": "328.18"
},
"bankTransactionCode": "string",
"bookingDate": "date",
"valueDate": "date",
"remittanceInformationUnstructured": "string"
},
{
"transactionId": "string",
"transactionAmount": {
"currency": "string",
"amount": "947.26"
},
"bankTransactionCode": "string",
"bookingDate": "date",
"valueDate": "date",
"remittanceInformationUnstructured": "string"
}
],
"pending": [
{
"transactionAmount": {
"currency": "string",
"amount": "99.20"
},
"valueDate": "date",
"remittanceInformationUnstructured": "string"
}
]
}
}
*/
class IncomeTransformer implements BankRevenueInterface
{
use AppSetup;
public function transform($transaction)
public function transform(BankIntegration $bank_integration, $transaction)
{
$data = [];
if (!property_exists($transaction, 'transaction'))
return $data;
foreach ($transaction->transaction as $transaction) {
$data[] = $this->transformTransaction($transaction);
}
return $data;
}
public function transformTransaction($transaction)
{
if (!property_exists($transaction, 'transactionId') || !property_exists($transaction, 'transactionAmount') || !property_exists($transaction, 'balances') || !property_exists($transaction, 'institution'))
throw new \Exception('invalid dataset');
return [
'transaction_id' => $transaction->id,
'amount' => $transaction->amount->amount,
'currency_id' => $this->convertCurrency($transaction->amount->currency),
'account_type' => $transaction->CONTAINER,
'transaction_id' => $transaction->transactionId,
'amount' => abs($transaction->transactionAmount->amount),
'currency_id' => $this->convertCurrency($transaction->transactionAmount->currency),
'account_type' => 'bank',
'category_id' => $transaction->highLevelCategoryId,
'category_type' => $transaction->categoryType,
'date' => $transaction->date,
'bank_account_id' => $transaction->accountId,
'description' => $transaction->description->original,
'base_type' => property_exists($transaction, 'baseType') ? $transaction->baseType : $this->calculateBaseType($transaction),
'date' => $transaction->bookingDate,
'bank_account_id' => $bank_integration->id,
'description' => $transaction->remittanceInformationUnstructured,
'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 Illuminate\Http\Request;
use Log;
use Nordigen\NordigenPHP\Exceptions\NordigenExceptions\NordigenException;
class NordigenController extends BaseController
{
@ -166,34 +167,42 @@ class NordigenController extends BaseController
}*/
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();
$context = Cache::get($data["hash"]);
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);
$context = Cache::get($data["one_time_token"]);
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);
$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
if (array_key_exists("redirectUri", $data))
$context["redirectUri"] = $data["redirectUri"];
if (array_key_exists("redirect", $data))
$context["redirect"] = $data["redirect"];
$context["requisitionId"] = $requisition["id"];
Cache::put($data["hash"], $context, 3600);
return response()->json([
'result' => $requisition,
'redirectUri' => array_key_exists("redirectUri", $data) ? $data["redirectUri"] : null,
]);
Cache::put($data["one_time_token"], $context, 3600);
return response()->redirectTo($requisition["link"]);
}
/**
@ -266,62 +275,27 @@ class NordigenController extends BaseController
$data = $request->all();
$context = Cache::get($data["ref"]);
if (!$context || $context["context"] != "nordigen" || !array_key_exists("requisitionId", $context)) {
if ($context && array_key_exists("redirectUri", $context))
return response()->redirectTo($context["redirectUri"] . "?action=nordigen_connect&status=failed&reason=ref-invalid");
if (!$context || $context["context"] != "nordigen" || !array_key_exists("requisitionId", $context))
return response()->redirectTo($context["redirect"] . "?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();
$account = $company->account;
if (!$account->bank_integration_nordigen_secret_id || !$account->bank_integration_nordigen_secret_key) {
if (array_key_exists("redirectUri", $context))
return response()->redirectTo($context["redirectUri"] . "?action=nordigen_connect&status=failed&reason=account-config-invalid");
return response()->json([
'status' => 'failed',
'reason' => 'account-config-invalid',
], 400);
}
if (!$account->bank_integration_nordigen_secret_id || !$account->bank_integration_nordigen_secret_key)
return response()->redirectTo($context["redirect"] . "?action=nordigen_connect&status=failed&reason=account-config-invalid");
// fetch requisition
$nordigen = new Nordigen($account->bank_integration_nordigen_secret_id, $account->bank_integration_nordigen_secret_key);
$requisition = $nordigen->getRequisition($context["requisitionId"]);
// check validity of requisition
if (!$requisition) {
if (array_key_exists("redirectUri", $context))
return response()->redirectTo($context["redirectUri"] . "?action=nordigen_connect&status=failed&reason=requisition-not-found");
return response()->json([
'status' => 'failed',
'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);
}
if (!$requisition)
return response()->redirectTo($context["redirect"] . "?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");
if (sizeof($requisition["accounts"]) == 0)
return response()->redirectTo($context["redirect"] . "?action=nordigen_connect&status=failed&reason=requisition-no-accounts");
// connect new accounts
$bank_integration_ids = [];
@ -381,14 +355,8 @@ class NordigenController extends BaseController
// prevent rerun of this method with same ref
Cache::delete($data["ref"]);
// Successfull Response
if (array_key_exists("redirectUri", $context))
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,
]);
// Successfull Response => Redirect
return response()->redirectTo($context["redirect"] . "?action=nordigen_connect&status=success&bank_integrations=" . implode(',', $bank_integration_ids));
}

View File

@ -12,6 +12,8 @@
namespace App\Http\Requests\Nordigen;
use App\Http\Requests\Request;
use Cache;
use Log;
class ConnectNordigenBankIntegrationRequest extends Request
{
@ -33,9 +35,29 @@ class ConnectNordigenBankIntegrationRequest extends Request
public function rules()
{
return [
'institutionId' => 'required|string',
'hash' => 'required|string', // One Time Token
'redirectUri' => 'string', // TODO: @turbo124 @todo validate, that this is a url without / at the end
'institution_id' => 'required|string',
'one_time_token' => 'required|string', // One Time Token
'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::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::fallback([BaseController::class, 'notFound']);