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

Merge pull request #9004 from paulwer/feature-nordigen-payment-provider

Feature: nordigen bank integration
This commit is contained in:
David Bomba 2023-12-26 09:55:54 +11:00 committed by GitHub
commit 2e20abfc41
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 2860 additions and 2848 deletions

View File

@ -68,4 +68,7 @@ MICROSOFT_REDIRECT_URI=
APPLE_CLIENT_ID=
APPLE_CLIENT_SECRET=
APPLE_REDIRECT_URI=
APPLE_REDIRECT_URI=
NORDIGEN_SECRET_ID=
NORDIGEN_SECRET_KEY=

View File

@ -97,6 +97,9 @@ class Kernel extends ConsoleKernel
/* Fires webhooks for overdue Invoice */
$schedule->job(new InvoiceCheckLateWebhook)->dailyAt('07:00')->withoutOverlapping()->name('invoice-overdue-job')->onOneServer();
/* Pulls in bank transactions from third party services */
$schedule->job(new BankTransactionSync)->everyFourHours()->withoutOverlapping()->name('bank-trans-sync-job')->onOneServer();
if (Ninja::isSelfHost()) {
$schedule->call(function () {
Account::query()->whereNotNull('id')->update(['is_scheduler_running' => true]);
@ -107,9 +110,6 @@ class Kernel extends ConsoleKernel
if (Ninja::isHosted()) {
$schedule->job(new AdjustEmailQuota)->dailyAt('23:30')->withoutOverlapping();
/* Pulls in bank transactions from third party services */
$schedule->job(new BankTransactionSync)->everyFourHours()->withoutOverlapping()->name('bank-trans-sync-job')->onOneServer();
/* Checks ACH verification status and updates state to authorize when verified */
$schedule->job(new CheckACHStatus)->everySixHours()->withoutOverlapping()->name('ach-status-job')->onOneServer();
@ -120,7 +120,7 @@ class Kernel extends ConsoleKernel
$schedule->command('ninja:s3-cleanup')->dailyAt('23:15')->withoutOverlapping()->name('s3-cleanup-job')->onOneServer();
}
if (config('queue.default') == 'database' && Ninja::isSelfHost() && config('ninja.internal_queue_enabled') && ! config('ninja.is_docker')) {
if (config('queue.default') == 'database' && Ninja::isSelfHost() && config('ninja.internal_queue_enabled') && !config('ninja.is_docker')) {
$schedule->command('queue:work database --stop-when-empty --memory=256')->everyMinute()->withoutOverlapping();
$schedule->command('queue:restart')->everyFiveMinutes()->withoutOverlapping();
@ -134,7 +134,7 @@ class Kernel extends ConsoleKernel
*/
protected function commands()
{
$this->load(__DIR__.'/Commands');
$this->load(__DIR__ . '/Commands');
require base_path('routes/console.php');
}

View File

@ -0,0 +1,126 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2023. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*
* Documentation of Api-Usage: https://developer.gocardless.com/bank-account-data/overview
*
* Institutions: Are Banks or Payment-Providers, which manages bankaccounts.
*
* Accounts: Accounts are existing bank_accounts at a specific institution.
*
* Requisitions: Are registered/active user-flows to authenticate one or many accounts. After completition, the accoundId could be used to fetch data for this account. After the access expires, the user could create a new requisition to connect accounts again.
*/
namespace App\Helpers\Bank\Nordigen;
use App\Helpers\Bank\Nordigen\Transformer\AccountTransformer;
use App\Helpers\Bank\Nordigen\Transformer\TransactionTransformer;
class Nordigen
{
public bool $test_mode; // https://developer.gocardless.com/bank-account-data/sandbox
public string $sandbox_institutionId = "SANDBOXFINANCE_SFIN0000";
protected \Nordigen\NordigenPHP\API\NordigenClient $client;
public function __construct()
{
$this->test_mode = config('ninja.nordigen.test_mode');
if (!(config('ninja.nordigen.secret_id') && config('ninja.nordigen.secret_key')))
throw new \Exception('missing nordigen credentials');
$this->client = new \Nordigen\NordigenPHP\API\NordigenClient(config('ninja.nordigen.secret_id'), config('ninja.nordigen.secret_key'));
$this->client->createAccessToken(); // access_token is valid 24h -> so we dont have to implement a refresh-cycle
}
// metadata-section for frontend
public function getInstitutions()
{
if ($this->test_mode)
return [$this->client->institution->getInstitution($this->sandbox_institutionId)];
return $this->client->institution->getInstitutions();
}
// requisition-section
public function createRequisition(string $redirect, string $initutionId, string $reference)
{
if ($this->test_mode && $initutionId != $this->sandbox_institutionId)
throw new \Exception('invalid institutionId while in test-mode');
return $this->client->requisition->createRequisition($redirect, $initutionId, null, $reference);
}
public function getRequisition(string $requisitionId)
{
try {
return $this->client->requisition->getRequisition($requisitionId);
} catch (\Exception $e) {
if (strpos($e->getMessage(), "Invalid Requisition ID") !== false)
return false;
throw $e;
}
}
// TODO: return null on not found
public function getAccount(string $account_id)
{
try {
$out = new \stdClass();
$out->data = $this->client->account($account_id)->getAccountDetails()["account"];
$out->metadata = $this->client->account($account_id)->getAccountMetaData();
$out->balances = $this->client->account($account_id)->getAccountBalances()["balances"];
$out->institution = $this->client->institution->getInstitution($out->metadata["institution_id"]);
$it = new AccountTransformer();
return $it->transform($out);
} catch (\Exception $e) {
if (strpos($e->getMessage(), "Invalid Account ID") !== false)
return false;
throw $e;
}
}
public function isAccountActive(string $account_id)
{
try {
$account = $this->client->account($account_id)->getAccountMetaData();
if ($account["status"] != "READY") {
nlog('nordigen account was not in status ready. accountId: ' . $account_id . ' status: ' . $account["status"]);
return false;
}
return true;
} catch (\Exception $e) {
if (strpos($e->getMessage(), "Invalid Account ID") !== false)
return false;
throw $e;
}
}
/**
* this method returns booked transactions from the bank_account, pending transactions are not part of the result
* @todo @turbo124 should we include pending transactions within the integration-process and mark them with a specific category?!
*/
public function getTransactions(string $accountId, string $dateFrom = null)
{
$transactionResponse = $this->client->account($accountId)->getAccountTransactions($dateFrom);
$it = new TransactionTransformer();
return $it->transform($transactionResponse);
}
}

View File

@ -0,0 +1,121 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2023. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Helpers\Bank\Nordigen\Transformer;
use App\Helpers\Bank\AccountTransformerInterface;
/**
[0] => stdClass Object
(
[data] => stdClass Object
(
[resourceId] => XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
[iban] => DE0286055592XXXXXXXXXX
[currency] => EUR
[ownerName] => Max Mustermann
[product] => GiroKomfort
[bic] => WELADE8LXXX
[usage] => PRIV
)
[metadata] => stdClass Object
(
[id] => XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
[created] => 2022-12-05T18:41:53.986028Z
[last_accessed] => 2023-10-29T08:35:34.003611Z
[iban] => DE0286055592XXXXXXXXXX
[institution_id] => STADT_KREISSPARKASSE_LEIPZIG_WELADE8LXXX
[status] => READY
[owner_name] => Max Mustermann
)
[balances] => [
{
[balanceAmount]: {
[amount] => 9825.64
[currency] => EUR
},
[balanceType] => closingBooked
[referenceDate] => 2023-12-01
},
{
[balanceAmount[: {
[amount] => 10325.64
[currency] => EUR
},
[balanceType] => interimAvailable
[creditLimitIncluded]: true,
[referenceDate] => 2023-12-01
}
]
[institution] => stdClass Object
(
[id] => STADT_KREISSPARKASSE_LEIPZIG_WELADE8LXXX
[name] => Stadt- und Kreissparkasse Leipzig
[bic] => WELADE8LXXX
[transaction_total_days] => 360
[countries] => [
"DE"
],
[logo] => https://storage.googleapis.com/gc-prd-institution_icons-production/DE/PNG/sparkasse.png
[supported_payments] => {
[single-payment] => [
"SCT",
"ISCT"
]
},
[supported_features] => [
"card_accounts",
"payments",
"pending_transactions"
],
[identification_codes] => []
)
)
*/
class AccountTransformer implements AccountTransformerInterface
{
public function transform($nordigen_account)
{
if (!property_exists($nordigen_account, 'data') || !property_exists($nordigen_account, 'metadata') || !property_exists($nordigen_account, 'balances') || !property_exists($nordigen_account, 'institution'))
throw new \Exception('invalid dataset');
$used_balance = $nordigen_account->balances[0];
// prefer entry with closingBooked
foreach ($nordigen_account->balances as $entry) {
if ($entry["balanceType"] === 'closingBooked') { // available: closingBooked, interimAvailable
$used_balance = $entry;
break;
}
}
return [
'id' => $nordigen_account->metadata["id"],
'account_type' => "bank",
'account_name' => $nordigen_account->data["iban"],
'account_status' => $nordigen_account->metadata["status"],
'account_number' => '**** ' . substr($nordigen_account->data["iban"], -7),
'provider_account_id' => $nordigen_account->metadata["id"],
'provider_id' => $nordigen_account->institution["id"],
'provider_name' => $nordigen_account->institution["name"],
'nickname' => $nordigen_account->data["ownerName"] ? $nordigen_account->data["ownerName"] : '',
'current_balance' => (int) $used_balance ? $used_balance["balanceAmount"]["amount"] : 0,
'account_currency' => $used_balance ? $used_balance["balanceAmount"]["currency"] : '',
];
}
}

View File

@ -0,0 +1,149 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2023. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
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;
use Log;
/**
{
"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 TransactionTransformer implements BankRevenueInterface
{
use AppSetup;
public function transform($transactionResponse)
{
$data = [];
if (!array_key_exists('transactions', $transactionResponse) || !array_key_exists('booked', $transactionResponse["transactions"]))
throw new \Exception('invalid dataset');
foreach ($transactionResponse["transactions"]["booked"] as $transaction) {
$data[] = $this->transformTransaction($transaction);
}
return $data;
}
public function transformTransaction($transaction)
{
if (!array_key_exists('transactionId', $transaction) || !array_key_exists('transactionAmount', $transaction))
throw new \Exception('invalid dataset');
// description could be in varios places
$description = '';
if (array_key_exists('remittanceInformationStructured', $transaction))
$description = $transaction["remittanceInformationStructured"];
else if (array_key_exists('remittanceInformationStructuredArray', $transaction))
$description = implode('\n', $transaction["remittanceInformationStructuredArray"]);
else if (array_key_exists('remittanceInformationUnstructured', $transaction))
$description = $transaction["remittanceInformationUnstructured"];
else if (array_key_exists('remittanceInformationUnstructuredArray', $transaction))
$description = implode('\n', $transaction["remittanceInformationUnstructuredArray"]);
else
Log::warning("Missing description for the following transaction: " . json_encode($transaction));
// participant
$participant = array_key_exists('debtorAccount', $transaction) && array_key_exists('iban', $transaction["debtorAccount"]) ?
$transaction['debtorAccount']['iban'] :
(array_key_exists('creditorAccount', $transaction) && array_key_exists('iban', $transaction["creditorAccount"]) ?
$transaction['creditorAccount']['iban'] : null);
$participant_name = array_key_exists('debtorName', $transaction) ?
$transaction['debtorName'] :
(array_key_exists('creditorName', $transaction) ?
$transaction['creditorName'] : null);
return [
'transaction_id' => $transaction["transactionId"],
'amount' => abs((int) $transaction["transactionAmount"]["amount"]),
'currency_id' => $this->convertCurrency($transaction["transactionAmount"]["currency"]),
'category_id' => null, // nordigen has no categories
'category_type' => array_key_exists('additionalInformation', $transaction) ? $transaction["additionalInformation"] : null, // TODO: institution specific keys like: GUTSCHRIFT, ABSCHLUSS, MONATSABSCHLUSS etc
'date' => $transaction["bookingDate"],
'description' => $description,
'participant' => $participant,
'participant_name' => $participant_name,
'base_type' => (int) $transaction["transactionAmount"]["amount"] <= 0 ? 'DEBIT' : 'CREDIT',
];
}
private function convertCurrency(string $code)
{
$currencies = Cache::get('currencies');
if (!$currencies) {
$this->buildCache(true);
}
$currency = $currencies->filter(function ($item) use ($code) {
return $item->code == $code;
})->first();
if ($currency)
return $currency->id;
return 1;
}
}

View File

@ -0,0 +1,304 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2023. 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\Utils\Ninja;
use Cache;
use Illuminate\Http\Request;
use Nordigen\NordigenPHP\Exceptions\NordigenExceptions\NordigenException;
class NordigenController extends BaseController
{
/**
* VIEW: Connect Nordigen Bank Integration
* @param ConnectNordigenBankIntegrationRequest $request
*/
public function connect(ConnectNordigenBankIntegrationRequest $request)
{
$data = $request->all();
$context = $request->getTokenContent();
$lang = $data['lang'] ?? 'en';
$context["lang"] = $lang;
if (!$context)
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();
$account = $company->account;
if (!(config('ninja.nordigen.secret_id') && config('ninja.nordigen.secret_key')))
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() && $account->isPaid() && $account->plan == 'enterprise')))
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",
]);
$nordigen = new Nordigen();
// show bank_selection_screen, when institution_id is not present
if (!array_key_exists("institution_id", $data))
return view('bank.nordigen.handler', [
'lang' => $lang,
'company' => $company,
'account' => $company->account,
'institutions' => $nordigen->getInstitutions(),
'redirectUrl' => $context["redirect"] . "?action=nordigen_connect&status=user-aborted"
]);
// redirect to requisition flow
try {
$requisition = $nordigen->createRequisition(config('ninja.app_url') . '/nordigen/confirm', $data['institution_id'], $request->token);
} 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"')) // provided institution_id was wrong
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",
]);
else if (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",
]);
}
}
// save cache
$context["requisitionId"] = $requisition["id"];
Cache::put($request->token, $context, 3600);
return response()->redirectTo($requisition["link"]);
}
/**
* VIEW: Confirm Nordigen Bank Integration (redirect after nordigen flow)
* @param ConnectNordigenBankIntegrationRequest $request
*/
public function confirm(ConfirmNordigenBankIntegrationRequest $request)
{
$data = $request->all();
$context = $request->getTokenContent();
if (!array_key_exists('lang', $data) && $context['lang'] != 'en')
return redirect()->route('nordigen.confirm', array_merge(["lang" => $context['lang']], $request->query())); // redirect is required in order for the bank-ui to display everything properly
$lang = $data['lang'] ?? 'en';
if (!$context || $context["context"] != "nordigen" || !array_key_exists("requisitionId", $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",
]);
$company = $request->getCompany();
$account = $company->account;
if (!(config('ninja.nordigen.secret_id') && config('ninja.nordigen.secret_key')))
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() && $account->isPaid() && $account->plan == 'enterprise')))
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
$nordigen = new Nordigen();
$requisition = $nordigen->getRequisition($context["requisitionId"]);
// check validity of requisition
if (!$requisition)
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")
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)
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
$bank_integration_ids = [];
foreach ($requisition["accounts"] as $nordigenAccountId) {
$nordigen_account = $nordigen->getAccount($nordigenAccountId);
$existing_bank_integration = BankIntegration::withTrashed()->where('nordigen_account_id', $nordigen_account['id'])->where('company_id', $company->id)->first();
if (!$existing_bank_integration) {
$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_status = $nordigen_account['account_status'];
$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->balance = $nordigen_account['current_balance'];
$bank_integration->currency = $nordigen_account['account_currency'];
$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();
array_push($bank_integration_ids, $bank_integration->id);
} else {
// resetting metadata for account status
$existing_bank_integration->balance = $account['current_balance'];
$existing_bank_integration->bank_account_status = $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
$company->account->bank_integrations->where("integration_type", BankIntegration::INTEGRATION_TYPE_NORDIGEN)->where('auto_sync', true)->each(function ($bank_integration) {
ProcessBankTransactionsNordigen::dispatch($bank_integration);
});
// 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));
}
/**
* Process Nordigen Institutions GETTER.
*
*
* @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)
{
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());
}
}

View File

@ -16,7 +16,7 @@ use App\Helpers\Bank\Yodlee\Yodlee;
use App\Http\Controllers\BaseController;
use App\Http\Requests\Yodlee\YodleeAdminRequest;
use App\Http\Requests\Yodlee\YodleeAuthRequest;
use App\Jobs\Bank\ProcessBankTransactions;
use App\Jobs\Bank\ProcessBankTransactionsYodlee;
use App\Models\BankIntegration;
use Illuminate\Http\Request;
@ -44,7 +44,7 @@ class YodleeController extends BaseController
$company->push();
}
$yodlee = new Yodlee($token);
if ($request->has('window_closed') && $request->input("window_closed") == "true") {
@ -90,6 +90,7 @@ class YodleeController extends BaseController
$bank_integration->balance = $account['current_balance'];
$bank_integration->currency = $account['account_currency'];
$bank_integration->from_date = now()->subYear();
$bank_integration->auto_sync = true;
$bank_integration->save();
@ -97,47 +98,45 @@ class YodleeController extends BaseController
}
$company->account->bank_integrations->each(function ($bank_integration) use ($company) {
ProcessBankTransactions::dispatch($company->account->bank_integration_account_id, $bank_integration);
$company->account->bank_integrations->where("integration_type", BankIntegration::INTEGRATION_TYPE_YODLEE)->where('auto_sync', true)->each(function ($bank_integration) use ($company) { // TODO: filter to yodlee only
ProcessBankTransactionsYodlee::dispatch($company->account->id, $bank_integration);
});
}
/**
* Process Yodlee Refresh Webhook.
*
*
* @OA\Post(
* path="/api/v1/yodlee/refresh",
* operationId="yodleeRefreshWebhook",
* tags={"yodlee"},
* summary="Processing webhooks from Yodlee",
* description="Notifies the system when a data point can be refreshed",
* @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"),
* ),
* )
*/
* Process Yodlee Refresh Webhook.
*
*
* @OA\Post(
* path="/api/v1/yodlee/refresh",
* operationId="yodleeRefreshWebhook",
* tags={"yodlee"},
* summary="Processing webhooks from Yodlee",
* description="Notifies the system when a data point can be refreshed",
* @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"),
* ),
* )
*/
/*
{
"event":{
@ -174,12 +173,12 @@ class YodleeController extends BaseController
// nlog($request->all());
return response()->json(['message' => 'Success'], 200);
//
// return response()->json(['message' => 'Unauthorized'], 403);
}
/*
{
"event":{
@ -208,12 +207,12 @@ class YodleeController extends BaseController
nlog($request->all());
return response()->json(['message' => 'Success'], 200);
//
// return response()->json(['message' => 'Unauthorized'], 403);
}
/*
{
"event":{
@ -244,7 +243,7 @@ class YodleeController extends BaseController
// nlog($request->all());
return response()->json(['message' => 'Success'], 200);
//
// return response()->json(['message' => 'Unauthorized'], 403);
@ -278,7 +277,7 @@ class YodleeController extends BaseController
return response()->json(['message' => 'Success'], 200);
//
// return response()->json(['message' => 'Unauthorized'], 403);
@ -290,19 +289,19 @@ class YodleeController extends BaseController
$user = auth()->user();
$bank_integration = BankIntegration::query()
->withTrashed()
->where('company_id', $user->company()->id)
->where('account_id', $account_number)
->exists();
->withTrashed()
->where('company_id', $user->company()->id)
->where('account_id', $account_number)
->exists();
if(!$bank_integration) {
if (!$bank_integration) {
return response()->json(['message' => 'Account does not exist.'], 400);
}
$yodlee = new Yodlee($user->account->bank_integration_account_id);
$summary = $yodlee->getAccountSummary($account_number);
$transformed_summary = AccountSummary::from($summary[0]);
return response()->json($transformed_summary, 200);

View File

@ -14,6 +14,7 @@ namespace App\Http\Controllers;
use App\Factory\BankIntegrationFactory;
use App\Filters\BankIntegrationFilters;
use App\Helpers\Bank\Yodlee\Yodlee;
use App\Helpers\Bank\Nordigen\Nordigen;
use App\Http\Requests\BankIntegration\AdminBankIntegrationRequest;
use App\Http\Requests\BankIntegration\BulkBankIntegrationRequest;
use App\Http\Requests\BankIntegration\CreateBankIntegrationRequest;
@ -22,10 +23,14 @@ use App\Http\Requests\BankIntegration\EditBankIntegrationRequest;
use App\Http\Requests\BankIntegration\ShowBankIntegrationRequest;
use App\Http\Requests\BankIntegration\StoreBankIntegrationRequest;
use App\Http\Requests\BankIntegration\UpdateBankIntegrationRequest;
use App\Jobs\Bank\ProcessBankTransactions;
use App\Jobs\Bank\ProcessBankTransactionsYodlee;
use App\Jobs\Bank\ProcessBankTransactionsNordigen;
use App\Models\Account;
use App\Models\BankIntegration;
use App\Models\User;
use App\Repositories\BankIntegrationRepository;
use App\Transformers\BankIntegrationTransformer;
use App\Utils\Ninja;
use App\Utils\Traits\MakesHash;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
@ -168,13 +173,13 @@ class BankIntegrationController extends BaseController
$action = request()->input('action');
$ids = request()->input('ids');
BankIntegration::withTrashed()->whereIn('id', $this->transformKeys($ids))
->company()
->cursor()
->each(function ($bank_integration, $key) use ($action) {
$this->bank_integration_repo->{$action}($bank_integration);
});
->company()
->cursor()
->each(function ($bank_integration, $key) use ($action) {
$this->bank_integration_repo->{$action}($bank_integration);
});
/* Need to understand which permission are required for the given bulk action ie. view / edit */
@ -189,27 +194,45 @@ class BankIntegrationController extends BaseController
*/
public function refreshAccounts(AdminBankIntegrationRequest $request)
{
// As yodlee is the first integration we don't need to perform switches yet, however
// if we add additional providers we can reuse this class
/** @var \App\Models\User $user */
$user = auth()->user();
$user_account = $user->account;
$bank_account_id = $user_account->bank_integration_account_id;
$this->refreshAccountsYodlee($user);
if (!$bank_account_id) {
return response()->json(['message' => 'Not yet authenticated with Bank Integration service'], 400);
}
$this->refreshAccountsNordigen($user);
$yodlee = new Yodlee($bank_account_id);
if (Cache::get("throttle_polling:{$user_account->key}"))
return response()->json(BankIntegration::query()->company(), 200);
// Processing transactions for each bank account
if (Ninja::isHosted() && $user->account->bank_integration_account_id)
$user_account->bank_integrations->where("integration_type", BankIntegration::INTEGRATION_TYPE_YODLEE)->each(function ($bank_integration) use ($user_account) {
ProcessBankTransactionsYodlee::dispatch($user_account->id, $bank_integration);
});
if (config('ninja.nordigen.secret_id') && config('ninja.nordigen.secret_key') && (Ninja::isSelfHost() || (Ninja::isHosted() && $user_account->isPaid() && $user_account->plan == 'enterprise')))
$user_account->bank_integrations->where("integration_type", BankIntegration::INTEGRATION_TYPE_NORDIGEN)->each(function ($bank_integration) {
ProcessBankTransactionsNordigen::dispatch($bank_integration);
});
Cache::put("throttle_polling:{$user_account->key}", true, 300);
return response()->json(BankIntegration::query()->company(), 200);
}
private function refreshAccountsYodlee(User $user)
{
if (!Ninja::isHosted() || !$user->account->bank_integration_account_id)
return;
$yodlee = new Yodlee($user->account->bank_integration_account_id);
$accounts = $yodlee->getAccounts();
foreach ($accounts as $account) {
if ($bi = BankIntegration::withTrashed()->where('bank_account_id', $account['id'])->where('company_id', $user->company()->id)->first()) {
if ($bi = BankIntegration::withTrashed()->where("integration_type", BankIntegration::INTEGRATION_TYPE_YODLEE)->where('bank_account_id', $account['id'])->where('company_id', $user->company()->id)->first()) {
$bi->balance = $account['current_balance'];
$bi->currency = $account['account_currency'];
$bi->save();
@ -229,22 +252,35 @@ class BankIntegrationController extends BaseController
$bank_integration->balance = $account['current_balance'];
$bank_integration->currency = $account['account_currency'];
$bank_integration->auto_sync = true;
$bank_integration->save();
}
}
if (Cache::get("throttle_polling:{$user_account->key}")) {
return response()->json(BankIntegration::query()->company(), 200);
}
}
$user_account->bank_integrations->each(function ($bank_integration) use ($user_account) {
ProcessBankTransactions::dispatch($user_account->bank_integration_account_id, $bank_integration);
private function refreshAccountsNordigen(User $user)
{
if (!(config('ninja.nordigen.secret_id') && config('ninja.nordigen.secret_key')))
return;
$nordigen = new Nordigen();
BankIntegration::where("integration_type", BankIntegration::INTEGRATION_TYPE_NORDIGEN)->whereNotNull('nordigen_account_id')->each(function (BankIntegration $bank_integration) use ($nordigen) {
$account = $nordigen->getAccount($bank_integration->nordigen_account_id);
if (!$account) {
$bank_integration->disabled_upstream = true;
$bank_integration->save();
return;
}
$bank_integration->disabled_upstream = false;
$bank_integration->bank_account_status = $account['account_status'];
$bank_integration->balance = $account['current_balance'];
$bank_integration->currency = $account['account_currency'];
$bank_integration->save();
});
Cache::put("throttle_polling:{$user_account->key}", true, 300);
return response()->json(BankIntegration::query()->company(), 200);
}
/**
@ -262,23 +298,27 @@ class BankIntegrationController extends BaseController
$account = $user->account;
$bank_account_id = $account->bank_integration_account_id;
$bank_integration = BankIntegration::withTrashed()->where('bank_account_id', $acc_id)->orWhere('nordigen_account_id', $acc_id)->company()->firstOrFail(); // @turbo124 please check
if (!$bank_account_id) {
if ($bank_integration->integration_type == BankIntegration::INTEGRATION_TYPE_YODLEE)
$this->removeAccountYodlee($account, $bank_integration);
// we dont remove Accounts from nordigen, because they could be used within other companies
$this->bank_integration_repo->delete($bank_integration);
return $this->itemResponse($bank_integration->fresh());
}
private function removeAccountYodlee(Account $account, BankIntegration $bank_integration)
{
if (!$account->bank_integration_account_id) {
return response()->json(['message' => 'Not yet authenticated with Bank Integration service'], 400);
}
$bi = BankIntegration::withTrashed()->where('bank_account_id', $acc_id)->company()->firstOrFail();
$yodlee = new Yodlee($bank_account_id);
$res = $yodlee->deleteAccount($acc_id);
$this->bank_integration_repo->delete($bi);
return $this->itemResponse($bi->fresh());
$yodlee = new Yodlee($account->bank_integration_account_id);
$yodlee->deleteAccount($bank_integration->bank_account_id);
}
/**
* Return the remote list of accounts stored on the third party provider
* and update our local cache.
@ -288,12 +328,20 @@ class BankIntegrationController extends BaseController
*/
public function getTransactions(AdminBankIntegrationRequest $request)
{
/** @var \App\Models\User $user */
$user = auth()->user();
/** @var \App\Models\Account $account */
$account = auth()->user()->account;
$user->account->bank_integrations->each(function ($bank_integration) use ($user) {
(new ProcessBankTransactions($user->account->bank_integration_account_id, $bank_integration))->handle();
});
if (Ninja::isHosted() && $account->isPaid() && $account->plan == 'enterprise') {
$account->bank_integrations()->where('integration_type', BankIntegration::INTEGRATION_TYPE_YODLEE)->where('auto_sync', true)->cursor()->each(function ($bank_integration) use ($account) {
(new ProcessBankTransactionsYodlee($account->id, $bank_integration))->handle();
});
}
if (config("ninja.nordigen.secret_id") && config("ninja.nordigen.secret_key") && (Ninja::isSelfHost() || (Ninja::isHosted() && $account->isPaid() && $account->plan == 'enterprise'))) {
$account->bank_integrations()->where('integration_type', BankIntegration::INTEGRATION_TYPE_NORDIGEN)->where('auto_sync', true)->cursor()->each(function ($bank_integration) {
(new ProcessBankTransactionsNordigen($bank_integration))->handle();
});
}
return response()->json(['message' => 'Fetching transactions....'], 200);
}

View File

@ -0,0 +1,58 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2023. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Http\Requests\Nordigen;
use App\Http\Requests\Request;
use App\Libraries\MultiDB;
use App\Models\Company;
use Cache;
class ConfirmNordigenBankIntegrationRequest extends Request
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'ref' => 'required|string', // nordigen redirects only with the ref-property
'lang' => 'string',
];
}
public function getTokenContent()
{
$input = $this->all();
$data = Cache::get($input['ref']);
return $data;
}
public function getCompany()
{
MultiDB::findAndSetDbByCompanyKey($this->getTokenContent()['company_key']);
return Company::where('company_key', $this->getTokenContent()['company_key'])->firstOrFail();
}
}

View File

@ -0,0 +1,75 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2023. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Http\Requests\Nordigen;
use App\Http\Requests\Request;
use App\Libraries\MultiDB;
use App\Models\Company;
use Cache;
class ConnectNordigenBankIntegrationRequest extends Request
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'lang' => 'string',
'institution_id' => 'string',
'redirect' => 'string',
];
}
// @turbo124 @todo please check for validity, when request from frontend
public function prepareForValidation()
{
$input = $this->all();
if (!array_key_exists('redirect', $input)) {
$context = $this->getTokenContent();
$input["redirect"] = isset($context["is_react"]) && $context['is_react'] ? config('ninja.react_url') . "/#/settings/bank_accounts" : config('ninja.app_url');
$this->replace($input);
}
}
public function getTokenContent()
{
if ($this->state) {
$this->token = $this->state;
}
$data = Cache::get($this->token);
return $data;
}
public function getCompany()
{
MultiDB::findAndSetDbByCompanyKey($this->getTokenContent()['company_key']);
return Company::where('company_key', $this->getTokenContent()['company_key'])->firstOrFail();
}
}

View File

@ -58,7 +58,7 @@ class MatchBankTransactions implements ShouldQueue
private $categories;
private float $available_balance = 0;
private float $applied_amount = 0;
private array $attachable_invoices = [];
@ -94,7 +94,6 @@ class MatchBankTransactions implements ShouldQueue
}
$bank_categories = Cache::get('bank_categories');
if (!$bank_categories && $yodlee) {
$_categories = $yodlee->getTransactionCategories();
$this->categories = collect($_categories->transactionCategory);
@ -135,11 +134,11 @@ class MatchBankTransactions implements ShouldQueue
return $collection->toArray();
}
private function checkPayable($invoices) :bool
private function checkPayable($invoices): bool
{
foreach ($invoices as $invoice) {
$invoice->service()->markSent();
if (!$invoice->isPayable()) {
return false;
}
@ -158,12 +157,12 @@ class MatchBankTransactions implements ShouldQueue
$_expenses = explode(",", $input['expense_id']);
foreach($_expenses as $_expense) {
foreach ($_expenses as $_expense) {
$expense = Expense::withTrashed()
->where('id', $this->decodePrimaryKey($_expense))
->where('company_id', $this->bt->company_id)
->first();
->where('id', $this->decodePrimaryKey($_expense))
->where('company_id', $this->bt->company_id)
->first();
if ($expense && !$expense->transaction_id) {
$expense->transaction_id = $this->bt->id;
@ -178,7 +177,7 @@ class MatchBankTransactions implements ShouldQueue
$this->bts->push($this->bt->id);
}
}
return $this;
}
@ -202,7 +201,7 @@ class MatchBankTransactions implements ShouldQueue
}
$payment = Payment::withTrashed()->find($input['payment_id']);
if ($payment && !$payment->transaction_id) {
$payment->transaction_id = $this->bt->id;
$payment->saveQuietly();
@ -218,7 +217,7 @@ class MatchBankTransactions implements ShouldQueue
return $this;
}
private function matchInvoicePayment($input) :self
private function matchInvoicePayment($input): self
{
$this->bt = BankTransaction::withTrashed()->find($input['id']);
@ -227,10 +226,10 @@ class MatchBankTransactions implements ShouldQueue
}
$_invoices = Invoice::query()
->withTrashed()
->where('company_id', $this->bt->company_id)
->whereIn('id', $this->getInvoices($input['invoice_ids']));
->withTrashed()
->where('company_id', $this->bt->company_id)
->whereIn('id', $this->getInvoices($input['invoice_ids']));
$amount = $this->bt->amount;
if ($_invoices && $this->checkPayable($_invoices)) {
@ -242,7 +241,7 @@ class MatchBankTransactions implements ShouldQueue
return $this;
}
private function matchExpense($input) :self
private function matchExpense($input): self
{
//if there is a category id, pull it from Yodlee and insert - or just reuse!!
$this->bt = BankTransaction::query()->withTrashed()->find($input['id']);
@ -274,7 +273,7 @@ class MatchBankTransactions implements ShouldQueue
if (array_key_exists('vendor_id', $input)) {
$this->bt->vendor_id = $input['vendor_id'];
}
$this->bt->status_id = BankTransaction::STATUS_CONVERTED;
$this->bt->save();
@ -283,7 +282,7 @@ class MatchBankTransactions implements ShouldQueue
return $this;
}
private function createPayment($invoices, float $amount) :void
private function createPayment($invoices, float $amount): void
{
$this->available_balance = $amount;
@ -320,7 +319,7 @@ class MatchBankTransactions implements ShouldQueue
if (!$this->invoice) {
return;
}
/* Create Payment */
$payment = PaymentFactory::create($this->invoice->company_id, $this->invoice->user_id);
@ -333,7 +332,7 @@ class MatchBankTransactions implements ShouldQueue
$payment->currency_id = $this->bt->currency_id;
$payment->is_manual = false;
$payment->date = $this->bt->date ? Carbon::parse($this->bt->date) : now();
/* Bank Transfer! */
$payment_type_id = 1;
@ -341,7 +340,7 @@ class MatchBankTransactions implements ShouldQueue
$payment->saveQuietly();
$payment->service()->applyNumber()->save();
if ($payment->client->getSetting('send_email_on_mark_paid')) {
$payment->service()->sendEmail();
}
@ -360,24 +359,24 @@ class MatchBankTransactions implements ShouldQueue
$this->invoice->next_send_date = null;
$this->invoice
->service()
->applyNumber()
->deletePdf()
->save();
->service()
->applyNumber()
->deletePdf()
->save();
$payment->ledger()
->updatePaymentBalance($amount * -1);
->updatePaymentBalance($amount * -1);
$this->invoice
->client
->service()
->updateBalanceAndPaidToDate($this->applied_amount*-1, $amount)
->save();
->client
->service()
->updateBalanceAndPaidToDate($this->applied_amount * -1, $amount)
->save();
$this->invoice = $this->invoice
->service()
->workFlow()
->save();
->service()
->workFlow()
->save();
/* Update Invoice balance */
event(new PaymentWasCreated($payment, $payment->company, Ninja::eventVars(auth()->user() ? auth()->user()->id : null)));
@ -389,13 +388,13 @@ class MatchBankTransactions implements ShouldQueue
$this->bt->save();
}
private function resolveCategory($input) :?int
private function resolveCategory($input): ?int
{
if (array_key_exists('ninja_category_id', $input) && (int)$input['ninja_category_id'] > 1) {
if (array_key_exists('ninja_category_id', $input) && (int) $input['ninja_category_id'] > 1) {
$this->bt->ninja_category_id = $input['ninja_category_id'];
$this->bt->save();
return (int)$input['ninja_category_id'];
return (int) $input['ninja_category_id'];
}
$category = $this->categories->firstWhere('highLevelCategoryId', $this->bt->category_id);
@ -414,7 +413,7 @@ class MatchBankTransactions implements ShouldQueue
return $ec->id;
}
return null;
}

View File

@ -0,0 +1,177 @@
<?php
/**
* Credit Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Credit Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Jobs\Bank;
use App\Helpers\Bank\Nordigen\Nordigen;
use App\Libraries\MultiDB;
use App\Models\BankIntegration;
use App\Models\BankTransaction;
use App\Models\Company;
use App\Notifications\Ninja\GenericNinjaAdminNotification;
use App\Services\Bank\BankMatchingService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class ProcessBankTransactionsNordigen implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
private BankIntegration $bank_integration;
private ?string $from_date;
public Company $company;
public Nordigen $nordigen;
public $nordigen_account;
/**
* Create a new job instance.
*/
public function __construct(BankIntegration $bank_integration)
{
$this->bank_integration = $bank_integration;
$this->from_date = $bank_integration->from_date ?: now()->subDays(90);
$this->company = $this->bank_integration->company;
}
/**
* Execute the job.
*
*
* @return void
*/
public function handle()
{
if ($this->bank_integration->integration_type != BankIntegration::INTEGRATION_TYPE_NORDIGEN)
throw new \Exception("Invalid BankIntegration Type");
if (!(config('ninja.nordigen.secret_id') && config('ninja.nordigen.secret_key')))
throw new \Exception("Missing credentials for bank_integration service nordigen");
$this->nordigen = new Nordigen();
set_time_limit(0);
nlog("Nordigen: Processing transactions for account: {$this->bank_integration->account->key}");
// UPDATE ACCOUNT
try {
$this->updateAccount();
} catch (\Exception $e) {
nlog("Nordigen: {$this->bank_integration->nordigen_account_id} - exited abnormally => " . $e->getMessage());
$content = [
"Processing transactions for account: {$this->bank_integration->nordigen_account_id} failed",
"Exception Details => ",
$e->getMessage(),
];
$this->bank_integration->company->notification(new GenericNinjaAdminNotification($content))->ninja();
throw $e;
}
if (!$this->nordigen_account)
return;
// UPDATE TRANSACTIONS
try {
$this->processTransactions();
} catch (\Exception $e) {
nlog("Nordigen: {$this->bank_integration->nordigen_account_id} - exited abnormally => " . $e->getMessage());
$content = [
"Processing transactions for account: {$this->bank_integration->nordigen_account_id} failed",
"Exception Details => ",
$e->getMessage(),
];
$this->bank_integration->company->notification(new GenericNinjaAdminNotification($content))->ninja();
throw $e;
}
// Perform Matching
BankMatchingService::dispatch($this->company->id, $this->company->db);
}
private function updateAccount()
{
if (!$this->nordigen->isAccountActive($this->bank_integration->nordigen_account_id)) {
$this->bank_integration->disabled_upstream = true;
$this->bank_integration->save();
$this->stop_loop = false;
nlog("Nordigen: account inactive: " . $this->bank_integration->nordigen_account_id);
// @turbo124 @todo send email for expired account
return;
}
$this->nordigen_account = $this->nordigen->getAccount($this->bank_integration->nordigen_account_id);
$this->bank_integration->disabled_upstream = false;
$this->bank_integration->bank_account_status = $this->nordigen_account['account_status'];
$this->bank_integration->balance = $this->nordigen_account['current_balance'];
$this->bank_integration->save();
}
private function processTransactions()
{
//Get transaction count object
$transactions = $this->nordigen->getTransactions($this->bank_integration->nordigen_account_id, $this->from_date);
//if no transactions, update the from_date and move on
if (count($transactions) == 0) {
$this->bank_integration->from_date = now()->subDays(5);
$this->bank_integration->disabled_upstream = false;
$this->bank_integration->save();
return;
}
//Harvest the company
MultiDB::setDb($this->company->db);
/*Get the user */
$user_id = $this->company->owner()->id;
/* Unguard the model to perform batch inserts */
BankTransaction::unguard();
$now = now();
foreach ($transactions as $transaction) {
if (BankTransaction::where('transaction_id', $transaction['transaction_id'])->where('company_id', $this->company->id)->where('bank_integration_id', $this->bank_integration->id)->withTrashed()->exists())
continue;
//this should be much faster to insert than using ::create()
\DB::table('bank_transactions')->insert(
array_merge($transaction, [
'company_id' => $this->company->id,
'user_id' => $user_id,
'bank_integration_id' => $this->bank_integration->id,
'created_at' => $now,
'updated_at' => $now,
])
);
}
$this->bank_integration->from_date = now()->subDays(5);
$this->bank_integration->save();
}
}

View File

@ -14,6 +14,7 @@ namespace App\Jobs\Bank;
use App\Helpers\Bank\Yodlee\Transformer\AccountTransformer;
use App\Helpers\Bank\Yodlee\Yodlee;
use App\Libraries\MultiDB;
use App\Models\Account;
use App\Models\BankIntegration;
use App\Models\BankTransaction;
use App\Models\Company;
@ -26,7 +27,7 @@ use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
class ProcessBankTransactions implements ShouldQueue
class ProcessBankTransactionsYodlee implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
@ -61,21 +62,24 @@ class ProcessBankTransactions implements ShouldQueue
*/
public function handle()
{
if ($this->bank_integration->integration_type != BankIntegration::INTEGRATION_TYPE_YODLEE)
throw new \Exception("Invalid BankIntegration Type");
set_time_limit(0);
//Loop through everything until we are up to date
$this->from_date = $this->from_date ?: '2021-01-01';
nlog("Processing transactions for account: {$this->bank_integration->account->key}");
nlog("Yodlee: Processing transactions for account: {$this->bank_integration->account->key}");
do {
try {
$this->processTransactions();
} catch(\Exception $e) {
nlog("{$this->bank_integration_account_id} - exited abnormally => ". $e->getMessage());
} catch (\Exception $e) {
nlog("Yodlee: {$this->bank_integration->bank_account_id} - exited abnormally => " . $e->getMessage());
$content = [
"Processing transactions for account: {$this->bank_integration->account->key} failed",
"Processing transactions for account: {$this->bank_integration->bank_account_id} failed",
"Exception Details => ",
$e->getMessage(),
];
@ -103,21 +107,21 @@ class ProcessBankTransactions implements ShouldQueue
try {
$account_summary = $yodlee->getAccountSummary($this->bank_integration->bank_account_id);
if($account_summary) {
if ($account_summary) {
$at = new AccountTransformer();
$account = $at->transform($account_summary);
if($account[0]['current_balance']) {
if ($account[0]['current_balance']) {
$this->bank_integration->balance = $account[0]['current_balance'];
$this->bank_integration->currency = $account[0]['account_currency'];
$this->bank_integration->bank_account_status = $account[0]['account_status'];
$this->bank_integration->save();
}
}
} catch(\Exception $e) {
nlog("YODLEE: unable to update account summary for {$this->bank_integration->bank_account_id} => ". $e->getMessage());
} catch (\Exception $e) {
nlog("YODLEE: unable to update account summary for {$this->bank_integration->bank_account_id} => " . $e->getMessage());
}
$data = [
@ -151,14 +155,14 @@ class ProcessBankTransactions implements ShouldQueue
/*Get the user */
$user_id = $this->company->owner()->id;
/* Unguard the model to perform batch inserts */
BankTransaction::unguard();
$now = now();
foreach ($transactions as $transaction) {
if (BankTransaction::query()->where('transaction_id', $transaction['transaction_id'])->where('company_id', $this->company->id)->withTrashed()->exists()) {
if (BankTransaction::query()->where('transaction_id', $transaction['transaction_id'])->where('company_id', $this->company->id)->where('bank_integration_id', $this->bank_integration->id)->withTrashed()->exists()) { // @turbo124 was not scoped to bank_integration_id => from my pov this should be present, because when an account was historized (is_deleted) a transaction can occur multiple (in the archived bank_integration and in the new one
continue;
}
@ -189,7 +193,7 @@ class ProcessBankTransactions implements ShouldQueue
{
return [new WithoutOverlapping($this->bank_integration_account_id)];
}
public function backoff()
{
return [rand(10, 15), rand(30, 40), rand(60, 79), rand(160, 200), rand(3000, 5000)];

View File

@ -11,9 +11,11 @@
namespace App\Jobs\Ninja;
use App\Jobs\Bank\ProcessBankTransactions;
use App\Jobs\Bank\ProcessBankTransactionsYodlee;
use App\Jobs\Bank\ProcessBankTransactionsNordigen;
use App\Libraries\MultiDB;
use App\Models\Account;
use App\Models\BankIntegration;
use App\Utils\Ninja;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
@ -43,20 +45,52 @@ class BankTransactionSync implements ShouldQueue
*/
public function handle()
{
//multiDB environment, need to
foreach (MultiDB::$dbs as $db) {
MultiDB::setDB($db);
if (config('ninja.db.multi_db_enabled')) {
nlog("syncing transactions");
foreach (MultiDB::$dbs as $db) {
MultiDB::setDB($db);
$a = Account::with('bank_integrations')->whereNotNull('bank_integration_account_id')->cursor()->each(function ($account) {
// $queue = Ninja::isHosted() ? 'bank' : 'default';
$this->processYodlee();
$this->processNordigen();
}
} else {
$this->processYodlee();
$this->processNordigen();
}
nlog("syncing transactions - done");
}
private function processYodlee()
{
if (Ninja::isHosted()) { // @turbo124 @todo I migrated the schedule for the job within the kernel to execute on all platforms and use the same expression here to determine if yodlee can run or not. Please chek/verify
nlog("syncing transactions - yodlee");
Account::with('bank_integrations')->whereNotNull('bank_integration_account_id')->cursor()->each(function ($account) {
if ($account->isPaid() && $account->plan == 'enterprise') {
$account->bank_integrations()->where('auto_sync', true)->cursor()->each(function ($bank_integration) use ($account) {
(new ProcessBankTransactions($account->bank_integration_account_id, $bank_integration))->handle();
$account->bank_integrations()->where('integration_type', BankIntegration::INTEGRATION_TYPE_YODLEE)->where('auto_sync', true)->cursor()->each(function ($bank_integration) use ($account) {
(new ProcessBankTransactionsYodlee($account->id, $bank_integration))->handle();
});
}
});
}
}
private function processNordigen()
{
if (config("ninja.nordigen.secret_id") && config("ninja.nordigen.secret_key")) { // @turbo124 check condition, when to execute this should be placed here (isSelfHosted || isPro/isEnterprise)
nlog("syncing transactions - nordigen");
Account::with('bank_integrations')->cursor()->each(function ($account) {
if ((Ninja::isSelfHost() || (Ninja::isHosted() && $account->isPaid() && $account->plan == 'enterprise'))) {
$account->bank_integrations()->where('integration_type', BankIntegration::INTEGRATION_TYPE_NORDIGEN)->where('auto_sync', true)->cursor()->each(function ($bank_integration) {
(new ProcessBankTransactionsNordigen($bank_integration))->handle();
});
}
});
}
}

View File

@ -233,7 +233,7 @@ class Account extends BaseModel
public function hasFeature($feature)
{
$plan_details = $this->getPlanDetails();
$self_host = ! Ninja::isNinja();
$self_host = !Ninja::isNinja();
switch ($feature) {
case self::FEATURE_TASKS:
@ -254,35 +254,35 @@ class Account extends BaseModel
case self::FEATURE_API:
case self::FEATURE_CLIENT_PORTAL_PASSWORD:
case self::FEATURE_CUSTOM_URL:
return $self_host || ! empty($plan_details);
return $self_host || !empty($plan_details);
// Pro; No trial allowed, unless they're trialing enterprise with an active pro plan
// Pro; No trial allowed, unless they're trialing enterprise with an active pro plan
case self::FEATURE_MORE_CLIENTS:
return $self_host || ! empty($plan_details) && (! $plan_details['trial'] || ! empty($this->getPlanDetails(false, false)));
return $self_host || !empty($plan_details) && (!$plan_details['trial'] || !empty($this->getPlanDetails(false, false)));
// White Label
// White Label
case self::FEATURE_WHITE_LABEL:
if (! $self_host && $plan_details && ! $plan_details['expires']) {
if (!$self_host && $plan_details && !$plan_details['expires']) {
return false;
}
// Fallthrough
// no break
// Fallthrough
// no break
case self::FEATURE_REMOVE_CREATED_BY:
return ! empty($plan_details); // A plan is required even for self-hosted users
return !empty($plan_details); // A plan is required even for self-hosted users
// Enterprise; No Trial allowed; grandfathered for old pro users
case self::FEATURE_USERS:// Grandfathered for old Pro users
// Enterprise; No Trial allowed; grandfathered for old pro users
case self::FEATURE_USERS: // Grandfathered for old Pro users
if ($plan_details && $plan_details['trial']) {
// Do they have a non-trial plan?
$plan_details = $this->getPlanDetails(false, false);
}
return $self_host || ! empty($plan_details) && ($plan_details['plan'] == self::PLAN_ENTERPRISE);
return $self_host || !empty($plan_details) && ($plan_details['plan'] == self::PLAN_ENTERPRISE);
// Enterprise; No Trial allowed
// Enterprise; No Trial allowed
case self::FEATURE_DOCUMENTS:
case self::FEATURE_USER_PERMISSIONS:
return $self_host || ! empty($plan_details) && $plan_details['plan'] == self::PLAN_ENTERPRISE && ! $plan_details['trial'];
return $self_host || !empty($plan_details) && $plan_details['plan'] == self::PLAN_ENTERPRISE && !$plan_details['trial'];
default:
return false;
@ -291,12 +291,12 @@ class Account extends BaseModel
public function isPaid(): bool
{
return Ninja::isNinja() ? ($this->isPaidHostedClient() && ! $this->isTrial()) : $this->hasFeature(self::FEATURE_WHITE_LABEL);
return Ninja::isNinja() ? ($this->isPaidHostedClient() && !$this->isTrial()) : $this->hasFeature(self::FEATURE_WHITE_LABEL);
}
public function isPaidHostedClient(): bool
{
if (! Ninja::isNinja()) {
if (!Ninja::isNinja()) {
return false;
}
@ -310,7 +310,7 @@ class Account extends BaseModel
public function isFreeHostedClient(): bool
{
if (! Ninja::isNinja()) {
if (!Ninja::isNinja()) {
return false;
}
@ -323,7 +323,7 @@ class Account extends BaseModel
public function isEnterpriseClient(): bool
{
if (! Ninja::isNinja()) {
if (!Ninja::isNinja()) {
return false;
}
@ -359,7 +359,7 @@ class Account extends BaseModel
public function isTrial(): bool
{
if (! Ninja::isNinja()) {
if (!Ninja::isNinja()) {
return false;
}
@ -370,7 +370,7 @@ class Account extends BaseModel
public function startTrial($plan): void
{
if (! Ninja::isNinja()) {
if (!Ninja::isNinja()) {
return;
}
@ -389,7 +389,7 @@ class Account extends BaseModel
$price = $this->plan_price;
$trial_plan = $this->trial_plan;
if ((! $plan || $plan == self::PLAN_FREE) && (! $trial_plan || ! $include_trial)) {
if ((!$plan || $plan == self::PLAN_FREE) && (!$trial_plan || !$include_trial)) {
return null;
}
@ -398,7 +398,7 @@ class Account extends BaseModel
$trial_started = false;
//14 day trial
$duration = 60*60*24*14;
$duration = 60 * 60 * 24 * 14;
if ($trial_plan && $include_trial) {
$trial_started = $this->trial_started;
@ -411,7 +411,7 @@ class Account extends BaseModel
$plan_active = false;
$plan_expires = false;
if ($plan) {
if ($this->plan_expires == null) {
$plan_active = true;
@ -423,22 +423,22 @@ class Account extends BaseModel
}
}
if (! $include_inactive && ! $plan_active && ! $trial_active) {
if (!$include_inactive && !$plan_active && !$trial_active) {
return null;
}
// Should we show plan details or trial details?
if (($plan && ! $trial_plan) || ! $include_trial) {
if (($plan && !$trial_plan) || !$include_trial) {
$use_plan = true;
} elseif (! $plan && $trial_plan) {
} elseif (!$plan && $trial_plan) {
$use_plan = false;
} else {
// There is both a plan and a trial
if (! empty($plan_active) && empty($trial_active)) {
if (!empty($plan_active) && empty($trial_active)) {
$use_plan = true;
} elseif (empty($plan_active) && ! empty($trial_active)) {
} elseif (empty($plan_active) && !empty($trial_active)) {
$use_plan = false;
} elseif (! empty($plan_active) && ! empty($trial_active)) {
} elseif (!empty($plan_active) && !empty($trial_active)) {
// Both are active; use whichever is a better plan
if ($plan == self::PLAN_ENTERPRISE) {
$use_plan = true;
@ -508,21 +508,21 @@ class Account extends BaseModel
public function emailsSent()
{
if (is_null(Cache::get("email_quota".$this->key))) {
if (is_null(Cache::get("email_quota" . $this->key))) {
return 0;
}
return Cache::get("email_quota".$this->key);
return Cache::get("email_quota" . $this->key);
}
public function emailQuotaExceeded() :bool
public function emailQuotaExceeded(): bool
{
if (is_null(Cache::get("email_quota".$this->key))) {
if (is_null(Cache::get("email_quota" . $this->key))) {
return false;
}
try {
if (Cache::get("email_quota".$this->key) > $this->getDailyEmailLimit()) {
if (Cache::get("email_quota" . $this->key) > $this->getDailyEmailLimit()) {
if (is_null(Cache::get("throttle_notified:{$this->key}"))) {
App::forgetInstance('translator');
$t = app('translator');
@ -544,14 +544,14 @@ class Account extends BaseModel
return true;
}
} catch(\Exception $e) {
} catch (\Exception $e) {
\Sentry\captureMessage("I encountered an error with email quotas for account {$this->key} - defaulting to SEND");
}
return false;
}
public function gmailCredentialNotification() :bool
public function gmailCredentialNotification(): bool
{
nlog("checking if gmail credential notification has already been sent");
@ -560,7 +560,7 @@ class Account extends BaseModel
}
nlog("Sending notification");
try {
if (is_null(Cache::get("gmail_credentials_notified:{$this->key}"))) {
App::forgetInstance('translator');
@ -582,7 +582,7 @@ class Account extends BaseModel
}
return true;
} catch(\Exception $e) {
} catch (\Exception $e) {
\Sentry\captureMessage("I encountered an error with sending with gmail for account {$this->key}");
}
@ -610,7 +610,7 @@ class Account extends BaseModel
if ($plan_expires->gt(now())) {
$diff = $plan_expires->diffInDays();
if ($diff > 14) {
return 0;
}

View File

@ -20,6 +20,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property int $account_id
* @property int $company_id
* @property int $user_id
* @property string $integration_type
* @property string $provider_name
* @property int $provider_id
* @property int $bank_account_id
@ -30,6 +31,8 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property float $balance
* @property int|null $currency
* @property string $nickname
* @property string $nordigen_account_id
* @property string $nordigen_institution_id
* @property string|null $from_date
* @property bool $is_deleted
* @property int|null $created_at
@ -60,7 +63,7 @@ class BankIntegration extends BaseModel
{
use SoftDeletes;
use Filterable;
protected $fillable = [
'bank_account_name',
'provider_name',
@ -73,6 +76,10 @@ class BankIntegration extends BaseModel
'auto_sync',
];
const INTEGRATION_TYPE_YODLEE = 'YODLEE';
const INTEGRATION_TYPE_NORDIGEN = 'NORDIGEN';
public function getEntityType()
{
return self::class;

View File

@ -34,6 +34,8 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property string|null $date
* @property int $bank_account_id
* @property string|null $description
* @property string|null $participant
* @property string|null $participant_name
* @property string $invoice_ids
* @property int|null $expense_id
* @property int|null $vendor_id
@ -68,7 +70,7 @@ class BankTransaction extends BaseModel
use SoftDeletes;
use MakesHash;
use Filterable;
const STATUS_UNMATCHED = 1;
const STATUS_MATCHED = 2;
@ -84,10 +86,12 @@ class BankTransaction extends BaseModel
'base_type',
'expense_id',
'vendor_id',
'amount'
'amount',
'participant',
'participant_name'
];
public function getInvoiceIds()
{
$collection = collect();
@ -162,7 +166,7 @@ class BankTransaction extends BaseModel
// return $this->belongsTo(Expense::class)->withTrashed();
// }
public function service() :BankService
public function service(): BankService
{
return new BankService($this);
}

View File

@ -28,8 +28,8 @@ class ProductObserver
public function created(Product $product)
{
$subscriptions = Webhook::where('company_id', $product->company_id)
->where('event_id', Webhook::EVENT_CREATE_PRODUCT)
->exists();
->where('event_id', Webhook::EVENT_CREATE_PRODUCT)
->exists();
if ($subscriptions) {
WebhookHandler::dispatch(Webhook::EVENT_CREATE_PRODUCT, $product, $product->company)->delay(0);
@ -49,15 +49,15 @@ class ProductObserver
if ($product->getOriginal('deleted_at') && !$product->deleted_at) {
$event = Webhook::EVENT_RESTORE_PRODUCT;
}
if ($product->is_deleted) {
$event = Webhook::EVENT_DELETE_PRODUCT;
}
$subscriptions = Webhook::where('company_id', $product->company_id)
->where('event_id', $event)
->exists();
->where('event_id', $event)
->exists();
if ($subscriptions) {
WebhookHandler::dispatch($event, $product, $product->company)->delay(0);
@ -75,10 +75,10 @@ class ProductObserver
if ($product->is_deleted) {
return;
}
$subscriptions = Webhook::where('company_id', $product->company_id)
->where('event_id', Webhook::EVENT_ARCHIVE_PRODUCT)
->exists();
->where('event_id', Webhook::EVENT_ARCHIVE_PRODUCT)
->exists();
if ($subscriptions) {
WebhookHandler::dispatch(Webhook::EVENT_ARCHIVE_PRODUCT, $product, $product->company)->delay(0);

View File

@ -41,7 +41,7 @@ class BaseRepository
*/
private function getEventClass($entity, $type)
{
return 'App\Events\\'.ucfirst(class_basename($entity)).'\\'.ucfirst(class_basename($entity)).'Was'.$type;
return 'App\Events\\' . ucfirst(class_basename($entity)) . '\\' . ucfirst(class_basename($entity)) . 'Was' . $type;
}
/**
@ -67,7 +67,7 @@ class BaseRepository
*/
public function restore($entity)
{
if (! $entity->trashed()) {
if (!$entity->trashed()) {
return;
}
@ -104,7 +104,7 @@ class BaseRepository
$className = $this->getEventClass($entity, 'Deleted');
if (class_exists($className) && ! ($entity instanceof Company)) {
if (class_exists($className) && !($entity instanceof Company)) {
event(new $className($entity, $entity->company, Ninja::eventVars(auth()->user() ? auth()->user()->id : null)));
}
}
@ -112,7 +112,7 @@ class BaseRepository
/* Returns an invoice if defined as a key in the $resource array*/
public function getInvitation($invitation, $resource)
{
if (is_array($invitation) && ! array_key_exists('key', $invitation)) {
if (is_array($invitation) && !array_key_exists('key', $invitation)) {
return false;
}
@ -163,7 +163,7 @@ class BaseRepository
$state['starting_amount'] = $model->balance;
if (! $model->id) {
if (!$model->id) {
$company_defaults = $client->setCompanyDefaults($data, lcfirst($resource));
$data['exchange_rate'] = $company_defaults['exchange_rate'];
$model->uses_inclusive_taxes = $client->getSetting('inclusive_taxes');
@ -199,7 +199,7 @@ class BaseRepository
});
}
}
$model->saveQuietly();
/* Model now persisted, now lets do some child tasks */
@ -233,7 +233,7 @@ class BaseRepository
foreach ($data['invitations'] as $invitation) {
//if no invitations are present - create one.
if (! $this->getInvitation($invitation, $resource)) {
if (!$this->getInvitation($invitation, $resource)) {
if (isset($invitation['id'])) {
unset($invitation['id']);
}
@ -245,9 +245,9 @@ class BaseRepository
$invitation_class = sprintf('App\\Models\\%sInvitation', $resource);
$new_invitation = $invitation_class::withTrashed()
->where('client_contact_id', $contact->id)
->where($lcfirst_resource_id, $model->id)
->first();
->where('client_contact_id', $contact->id)
->where($lcfirst_resource_id, $model->id)
->first();
if ($new_invitation && $new_invitation->trashed()) {
$new_invitation->restore();
@ -279,7 +279,7 @@ class BaseRepository
$model = $model->service()->applyNumber()->save();
/* Handle attempts where the deposit is greater than the amount/balance of the invoice */
if ((int)$model->balance != 0 && $model->partial > $model->amount && $model->amount > 0) {
if ((int) $model->balance != 0 && $model->partial > $model->amount && $model->amount > 0) {
$model->partial = min($model->amount, $model->balance);
}
@ -293,14 +293,14 @@ class BaseRepository
if ($model->status_id != Invoice::STATUS_DRAFT) {
$model->service()->updateStatus()->save();
$model->client->service()->calculateBalance($model);
// $diff = $state['finished_amount'] - $state['starting_amount'];
// nlog("{$diff} - {$state['finished_amount']} - {$state['starting_amount']}");
// if(floatval($state['finished_amount']) != floatval($state['starting_amount']))
// $model->ledger()->updateInvoiceBalance(($state['finished_amount'] - $state['starting_amount']), "Update adjustment for invoice {$model->number}");
}
if (! $model->design_id) {
if (!$model->design_id) {
$model->design_id = intval($this->decodePrimaryKey($client->getSetting('invoice_design_id')));
}
@ -316,7 +316,7 @@ class BaseRepository
}
/** If the client does not have tax_data - then populate this now */
if($client->country_id == 840 && !$client->tax_data && $model->company->calculate_taxes && !$model->company->account->isFreeHostedClient()) {
if ($client->country_id == 840 && !$client->tax_data && $model->company->calculate_taxes && !$model->company->account->isFreeHostedClient()) {
UpdateTaxData::dispatch($client, $client->company);
}
@ -325,7 +325,7 @@ class BaseRepository
if ($model instanceof Credit) {
$model = $model->calc()->getCredit();
if (! $model->design_id) {
if (!$model->design_id) {
$model->design_id = $this->decodePrimaryKey($client->getSetting('credit_design_id'));
}
@ -345,7 +345,7 @@ class BaseRepository
}
if ($model instanceof Quote) {
if (! $model->design_id) {
if (!$model->design_id) {
$model->design_id = intval($this->decodePrimaryKey($client->getSetting('quote_design_id')));
}
@ -359,7 +359,7 @@ class BaseRepository
}
if ($model instanceof RecurringInvoice) {
if (! $model->design_id) {
if (!$model->design_id) {
$model->design_id = intval($this->decodePrimaryKey($client->getSetting('invoice_design_id')));
}

View File

@ -50,20 +50,21 @@ class BankIntegrationTransformer extends EntityTransformer
{
return [
'id' => (string) $this->encodePrimaryKey($bank_integration->id),
'provider_name' => (string)$bank_integration->provider_name ?: '',
'provider_name' => (string) $bank_integration->provider_name ?: '',
'provider_id' => (int) $bank_integration->provider_id ?: 0,
'bank_account_id' => (int) $bank_integration->bank_account_id ?: 0,
'bank_account_name' => (string) $bank_integration->bank_account_name ?: '',
'bank_account_number' => (string) $bank_integration->bank_account_number ?: '',
'bank_account_status' => (string)$bank_integration->bank_account_status ?: '',
'bank_account_type' => (string)$bank_integration->bank_account_type ?: '',
'balance' => (float)$bank_integration->balance ?: 0,
'currency' => (string)$bank_integration->currency ?: '',
'nickname' => (string)$bank_integration->nickname ?: '',
'from_date' => (string)$bank_integration->from_date ?: '',
'bank_account_status' => (string) $bank_integration->bank_account_status ?: '',
'bank_account_type' => (string) $bank_integration->bank_account_type ?: '',
'nordigen_institution_id' => (string) $bank_integration->nordigen_institution_id ?: '',
'balance' => (float) $bank_integration->balance ?: 0,
'currency' => (string) $bank_integration->currency ?: '',
'nickname' => (string) $bank_integration->nickname ?: '',
'from_date' => (string) $bank_integration->from_date ?: '',
'is_deleted' => (bool) $bank_integration->is_deleted,
'disabled_upstream' => (bool) $bank_integration->disabled_upstream,
'auto_sync' => (bool)$bank_integration->auto_sync,
'auto_sync' => (bool) $bank_integration->auto_sync,
'created_at' => (int) $bank_integration->created_at,
'updated_at' => (int) $bank_integration->updated_at,
'archived_at' => (int) $bank_integration->deleted_at,

View File

@ -63,11 +63,13 @@ class BankTransactionTransformer extends EntityTransformer
'bank_account_id' => (int) $bank_transaction->bank_account_id,
'status_id' => (string) $bank_transaction->status_id,
'description' => (string) $bank_transaction->description ?: '',
'participant' => (string) $bank_transaction->participant ?: '',
'participant_name' => (string) $bank_transaction->participant_name ?: '',
'base_type' => (string) $bank_transaction->base_type ?: '',
'invoice_ids' => (string) $bank_transaction->invoice_ids ?: '',
'expense_id'=> (string) $bank_transaction->expense_id ?: '',
'payment_id'=> (string) $this->encodePrimaryKey($bank_transaction->payment_id) ?: '',
'vendor_id'=> (string) $this->encodePrimaryKey($bank_transaction->vendor_id) ?: '',
'expense_id' => (string) $bank_transaction->expense_id ?: '',
'payment_id' => (string) $this->encodePrimaryKey($bank_transaction->payment_id) ?: '',
'vendor_id' => (string) $this->encodePrimaryKey($bank_transaction->vendor_id) ?: '',
'bank_transaction_rule_id' => (string) $this->encodePrimaryKey($bank_transaction->bank_transaction_rule_id) ?: '',
'is_deleted' => (bool) $bank_transaction->is_deleted,
'created_at' => (int) $bank_transaction->created_at,

View File

@ -72,6 +72,7 @@
"microsoft/microsoft-graph": "^1.69",
"mollie/mollie-api-php": "^2.36",
"nelexa/zip": "^4.0",
"nordigen/nordigen-php": "^1.1",
"nwidart/laravel-modules": "^10.0",
"omnipay/paypal": "^3.0",
"payfast/payfast-php-sdk": "^1.1",
@ -172,11 +173,11 @@
}
},
"repositories": [
{
"type": "vcs",
"url": "https://github.com/turbo124/apple"
}
{
"type": "vcs",
"url": "https://github.com/turbo124/apple"
}
],
"minimum-stability": "dev",
"prefer-stable": true
}
}

2638
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,7 @@ return [
'license_url' => 'https://app.invoiceninja.com',
'react_url' => env('REACT_URL', 'https://app.invoicing.co'),
'production' => env('NINJA_PROD', false),
'license' => env('NINJA_LICENSE', ''),
'license' => env('NINJA_LICENSE', ''),
'version_url' => 'https://pdf.invoicing.co/api/version',
'app_name' => env('APP_NAME', 'Invoice Ninja'),
'app_env' => env('APP_ENV', 'selfhosted'),
@ -86,7 +86,7 @@ return [
'password' => 'password',
'stripe' => env('STRIPE_KEYS', ''),
'paypal' => env('PAYPAL_KEYS', ''),
'ppcp' => env('PPCP_KEYS', ''),
'ppcp' => env('PPCP_KEYS', ''),
'paypal_rest' => env('PAYPAL_REST_KEYS', ''),
'authorize' => env('AUTHORIZE_KEYS', ''),
'checkout' => env('CHECKOUT_KEYS', ''),
@ -196,7 +196,7 @@ return [
'ninja_default_company_id' => env('NINJA_COMPANY_ID', null),
'ninja_default_company_gateway_id' => env('NINJA_COMPANY_GATEWAY_ID', null),
'ninja_hosted_secret' => env('NINJA_HOSTED_SECRET', ''),
'ninja_hosted_header' =>env('NINJA_HEADER', ''),
'ninja_hosted_header' => env('NINJA_HEADER', ''),
'ninja_connect_secret' => env('NINJA_CONNECT_SECRET', ''),
'internal_queue_enabled' => env('INTERNAL_QUEUE_ENABLED', true),
'ninja_apple_api_key' => env('APPLE_API_KEY', false),
@ -218,6 +218,11 @@ return [
'dev_mode' => env("YODLEE_DEV_MODE", false),
'config_name' => env("YODLEE_CONFIG_NAME", false),
],
'nordigen' => [
'secret_id' => env('NORDIGEN_SECRET_ID', false),
'secret_key' => env('NORDIGEN_SECRET_KEY', false),
'test_mode' => env("NORDIGEN_TEST_MODE", false),
],
'licenses' => env('LICENSES', false),
'google_application_credentials' => env("GOOGLE_APPLICATION_CREDENTIALS", false),
'shopify' => [

View File

@ -23,6 +23,7 @@ class BankIntegrationFactory extends Factory
public function definition()
{
return [
'integration_type' => null,
'provider_name' => $this->faker->company(),
'provider_id' => 1,
'bank_account_name' => $this->faker->catchPhrase(),

View File

@ -0,0 +1,46 @@
<?php
use App\Models\Account;
use App\Models\BankIntegration;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('bank_integrations', function (Blueprint $table) {
$table->string('integration_type')->nullable();
$table->string('nordigen_account_id')->nullable();
$table->string('nordigen_institution_id')->nullable();
});
// migrate old account to be used with yodlee
BankIntegration::query()->whereNull('integration_type')->whereNotNull('account_id')->cursor()->each(function ($bank_integration) {
$bank_integration->integration_type = BankIntegration::INTEGRATION_TYPE_YODLEE;
$bank_integration->save();
});
// MAYBE migration of account->bank_account_id etc
Schema::table('bank_transactions', function (Blueprint $table) {
$table->string('participant')->nullable(); // iban, credit-card info or else
$table->string('participant_name')->nullable(); // name
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
//
}
};

View File

@ -96,7 +96,7 @@ $lang = array(
'powered_by' => 'Unterstützt durch',
'no_items' => 'Keine Elemente',
'recurring_invoices' => 'Wiederkehrende Rechnungen',
'recurring_help' => '<p>Senden Sie Ihren Kunden automatisch die gleichen Rechnungen wöchentlich, zweimonatlich, monatlich, vierteljährlich oder jährlich zu.</p>
'recurring_help' => '<p>Senden Sie Ihren Kunden automatisch die gleichen Rechnungen wöchentlich, zweimonatlich, monatlich, vierteljährlich oder jährlich zu.</p>
<p>Verwenden Sie :MONTH, :QUARTER oder :YEAR für dynamische Daten. Grundlegende Mathematik funktioniert auch, zum Beispiel :MONTH-1.</p>
<p>Beispiele für dynamische Rechnungsvariablen:</p>
<ul>
@ -3859,308 +3859,308 @@ https://invoiceninja.github.io/docs/migration/#troubleshooting',
'registration_url' => 'Registrierungs-URL',
'show_product_cost' => 'Produktkosten anzeigen',
'complete' => 'Fertigstellen',
'next' => 'Weiter',
'next_step' => 'Nächster Schritt',
'notification_credit_sent_subject' => 'Gutschrift :invoice wurde an Kunde gesendet.',
'notification_credit_viewed_subject' => 'Gutschrift :invoice wurde von :client angesehen.',
'notification_credit_sent' => 'Der folgende Kunde :client hat eine Gutschrift :invoice über :amount erhalten.',
'notification_credit_viewed' => 'Der folgende Kunde :client hat die Gutschrift :credit über :amount angeschaut.',
'reset_password_text' => 'Bitte geben Sie ihre E-Mail-Adresse an, um das Passwort zurücksetzen zu können.',
'password_reset' => 'Passwort zurücksetzten',
'account_login_text' => 'Willkommen! Schön Sie zu sehen.',
'request_cancellation' => 'Storno beantragen',
'delete_payment_method' => 'Zahlungsmethode löschen',
'about_to_delete_payment_method' => 'Diese Zahlungsmethode wird gelöscht.',
'action_cant_be_reversed' => 'Diese Aktion kann nicht widerrufen werden',
'profile_updated_successfully' => 'Das Profil wurde erfolgreich aktualisiert.',
'currency_ethiopian_birr' => 'Äthiopischer Birr',
'client_information_text' => 'Bitte nutzen Sie eine postfähige Anschrift.',
'status_id' => 'Rechnungsstatus',
'email_already_register' => 'Diese E-Mail wird bereits von einem anderen Account verwendet',
'locations' => 'Standorte',
'freq_indefinitely' => 'Unendlich',
'cycles_remaining' => 'Verbleibende Zyklen',
'i_understand_delete' => 'Ich bin mir der Risiken bewusst, löschen',
'download_files' => 'Dateien herunterladen',
'download_timeframe' => 'Nutzen Sie diesen Link um Ihre Dateien herunterzuladen. Der Link läuft in einer Stunde ab.',
'new_signup' => 'Neue Registrierung',
'new_signup_text' => 'Ein neuer Benutzer wurde von :user - :email von der IP: :ip erstellt',
'notification_payment_paid_subject' => 'Neue Zahlung von :client',
'notification_partial_payment_paid_subject' => 'Neue Anzahlung von :client',
'notification_payment_paid' => 'Eine Zahlung von :amount wurde von Kunde :client auf :invoice geleistet',
'notification_partial_payment_paid' => 'Eine Teilzahlung in Höhe von :amount wurde vom Kunden :client auf :invoice geleistet',
'notification_bot' => 'Benachrichtigungs-Bot',
'invoice_number_placeholder' => 'Rechnung # :invoice',
'entity_number_placeholder' => ':entity # :entity_number',
'email_link_not_working' => 'Wenn die Schaltfläche oben nicht funktioniert, klicken Sie bitte auf den Link',
'display_log' => 'Log anzeigen',
'send_fail_logs_to_our_server' => 'Fehler in Echtzeit melden',
'setup' => 'Setup',
'quick_overview_statistics' => 'Schnellüberblick & Statistiken',
'update_your_personal_info' => 'Aktualisieren Sie Ihre Profil',
'name_website_logo' => 'Name, Webseite & Logo',
'make_sure_use_full_link' => 'Es ist wichtig, den gesamten Link \'\'https://example.com\'\' einzutragen.',
'personal_address' => 'Private Adresse',
'enter_your_personal_address' => 'Bitte geben Sie Ihre Rechnungsadresse an',
'enter_your_shipping_address' => 'Bitte geben Sie Ihr Lieferadresse an',
'list_of_invoices' => 'Liste der Rechnungen',
'with_selected' => 'Breite ausgewählt',
'invoice_still_unpaid' => 'Diese Rechnung wurde noch nicht beglichen. Klicken um zu vervollständigen.',
'list_of_recurring_invoices' => 'Liste der wiederkehrende Rechnungen',
'details_of_recurring_invoice' => 'Details über wiederkehrende Rechnung',
'cancellation' => 'Storno',
'about_cancellation' => 'Wenn Sie die wiederkehrende Rechnung stoppen möchten, klicken Sie bitte auf , um die Stornierung zu beantragen.',
'cancellation_warning' => 'Achtung! Sie beantragen die Stornierung dieses Dienstes. Ihr Dienst kann ohne weitere Mitteilung an Sie storniert werden.',
'cancellation_pending' => 'Kündigung in Bearbeitung! Wir melden uns bei Ihnen...',
'list_of_payments' => 'Liste der Zahlungen',
'payment_details' => 'Details zu der Zahlung',
'list_of_payment_invoices' => 'Liste der Rechnungen die von dieser Zahlung betroffenen sind',
'list_of_payment_methods' => 'Liste der Zahlungsmethoden',
'payment_method_details' => 'Details zu der Zahlungsmethode',
'permanently_remove_payment_method' => 'Zahlungsmethode endgültig entfernen.',
'warning_action_cannot_be_reversed' => 'Achtung! Diese Aktion kann nicht widerrufen werden!',
'confirmation' => 'Bestätigung',
'list_of_quotes' => 'Angebote',
'waiting_for_approval' => 'Annahme ausstehend',
'quote_still_not_approved' => 'Dieses Angebot wurde noch nicht angenommen.',
'list_of_credits' => 'Gutschriften',
'required_extensions' => 'Benötigte PHP-Erweiterungen',
'php_version' => 'PHP Version',
'writable_env_file' => 'Beschreibbare .env-Datei',
'env_not_writable' => 'die .env-Datei ist vom aktuellen Benutzer nicht beschreibbar',
'minumum_php_version' => 'Minimale PHP-Version',
'satisfy_requirements' => 'Prüfen Sie, ob alle Anforderungen erfüllt sind.',
'oops_issues' => 'Entschuldigung, das ist wohl etwas schiefgelaufen!',
'open_in_new_tab' => 'In neuem Fenster öffnen',
'complete_your_payment' => 'Zahlung abschließen',
'authorize_for_future_use' => 'Zahlungsmethode für zukünftige Verwendung freigeben',
'page' => 'Seite',
'per_page' => 'pro Seite',
'of' => 'von',
'view_credit' => 'Gutschrift anzeigen',
'to_view_entity_password' => 'Um :entity anzusehen, geben Sie bitte Ihr Passwort ein.',
'showing_x_of' => 'Zeige :first bis :last von :total Ergebnissen',
'no_results' => 'Kein Ergebnis gefunden.',
'payment_failed_subject' => 'Zahlung für Kunde :client fehlgeschlagen',
'payment_failed_body' => 'Eine Zahlung von :client schlug fehl: :message',
'register' => 'Registrieren',
'register_label' => 'Benutzerkonto in wenigen Sekunden erstellen',
'password_confirmation' => 'Passwort bestätigen',
'verification' => 'Bestätigung',
'complete_your_bank_account_verification' => 'Ein Bankkonto muss verifiziert werden, bevor es genutzt werden kann.',
'checkout_com' => 'Checkout.com',
'footer_label' => 'Copyright © :year :company.',
'credit_card_invalid' => 'Die angegebene Kreditkartennummer ist ungültig.',
'month_invalid' => 'Der angegebene Monat ist ungültig',
'year_invalid' => 'Das angegebene Jahr ist ungültig',
'https_required' => 'HTTPS ist Pflicht, das Formular wird nicht funktionieren',
'if_you_need_help' => 'Wenn Sie Hilfe benötigen, wenden Sie sich bitte an unsere',
'update_password_on_confirm' => 'Nach dem Update des Passworts wird Ihr Account bestätigt.',
'bank_account_not_linked' => 'Um mit einem Bankkonto zu bezahlen, müssen Sie es zunächst als Zahlungsmethode hinzufügen.',
'application_settings_label' => 'Lassen Sie uns grundlegende Informationen über Ihr Invoice Ninja speichern!',
'recommended_in_production' => 'Ausdrücklich für Produktivumgebungen empfohlen!',
'enable_only_for_development' => 'Nur in Entwicklungsumgebung aktivieren',
'test_pdf' => 'PDF testen',
'checkout_authorize_label' => 'Checkout.com kann als Zahlungsmethode für die zukünftige Verwendung gespeichert werden, sobald Sie Ihre erste Transaktion abgeschlossen haben. Vergessen Sie nicht, die Option "Kreditkartendaten speichern" während des Zahlungsvorgangs zu aktivieren.',
'sofort_authorize_label' => 'Das Bankkonto (SOFORT) kann als Zahlungsmethode für die zukünftige Verwendung gespeichert werden, sobald Sie Ihre erste Transaktion abgeschlossen haben. Vergessen Sie nicht, die Option "Zahlungsdetails speichern" während des Zahlungsvorgangs zu aktivieren.',
'node_status' => 'Node-Status',
'npm_status' => 'NPM-Status',
'node_status_not_found' => 'Node konnte nicht gefunden werden - ist es installiert?',
'npm_status_not_found' => 'NPM konnte nicht gefunden werden - ist es installiert?',
'locked_invoice' => 'Diese Rechnung ist gesperrt und kann nicht bearbeitet werden.',
'downloads' => 'Downloads',
'resource' => 'Resourcen',
'document_details' => 'Details zu dem Dokument',
'hash' => 'Hash',
'resources' => 'Ressourcen',
'allowed_file_types' => 'Erlaubte Dateitypen:',
'common_codes' => 'Gängige Codes und ihre Bedeutungen',
'payment_error_code_20087' => '20087: Falsche Track-Daten (ungültiger CVV und/oder Verfallsdatum)',
'download_selected' => 'Ausgewählte herunterladen',
'to_pay_invoices' => 'Um Rechnungen zu bezahlen, müssen Sie',
'add_payment_method_first' => 'Zahlungsart hinzufügen',
'no_items_selected' => 'Keine Objekte ausgewählt.',
'payment_due' => 'Zahlung überfallig',
'account_balance' => 'Saldo',
'thanks' => 'Danke',
'minimum_required_payment' => 'Mindestbetrag für die Zahlung ist :amount',
'under_payments_disabled' => 'Das Unternehmen unterstützt keine Unterbezahlungen.',
'over_payments_disabled' => 'Das Unternehmen unterstützt keine Überbezahlungen.',
'saved_at' => 'Gespeichert um :time',
'credit_payment' => 'Gutschrift auf Rechnung :invoice_number angewendet',
'credit_subject' => 'Neue Gutschrift :number von :account',
'credit_message' => 'Um Ihre Gutschrift über :amount einzusehen, klicken Sie auf den untenstehenden Link.',
'payment_type_Crypto' => 'Kryptowährung',
'payment_type_Credit' => 'Gutschrift',
'store_for_future_use' => 'Für zukünftige Zahlung speichern',
'pay_with_credit' => 'Mit Kreditkarte zahlen',
'payment_method_saving_failed' => 'Die Zahlungsart konnte nicht für zukünftige Zahlungen gespeichert werden.',
'pay_with' => 'zahlen mit',
'n/a' => 'n. z.',
'by_clicking_next_you_accept_terms' => 'Wenn Sie auf "Nächster Schritt" klicken, akzeptieren Sie die Bedingungen.',
'not_specified' => 'Nicht angegeben',
'before_proceeding_with_payment_warning' => 'Bevor Sie mit der Zahlung fortfahren, müssen Sie folgende Felder ausfüllen',
'after_completing_go_back_to_previous_page' => 'Gehen Sie nach dem Ausfüllen zurück zur vorherigen Seite.',
'pay' => 'Zahlen',
'instructions' => 'Anleitung',
'notification_invoice_reminder1_sent_subject' => 'Die 1. Mahnung für Rechnung :invoice wurde an Kunde :client gesendet',
'notification_invoice_reminder2_sent_subject' => 'Die 2. Mahnung für Rechnung :invoice wurde an Kunde :client gesendet',
'notification_invoice_reminder3_sent_subject' => 'Die 3. Mahnung für Rechnung :invoice wurde an Kunde :client gesendet',
'notification_invoice_custom_sent_subject' => 'Benutzerdefinierte Mahnung für Rechnung :invoice wurde an :client gesendet',
'notification_invoice_reminder_endless_sent_subject' => 'Endlose Mahnung für Rechnung :invoice wurde an :client gesendet',
'assigned_user' => 'Zugewiesener Benutzer',
'setup_steps_notice' => 'Um mit dem nächsten Schritt fortzufahren, stellen Sie sicher, dass Sie jeden Abschnitt testen.',
'setup_phantomjs_note' => 'Anmerkung zu Phantom JS. Mehr...',
'minimum_payment' => 'Mindestbetrag',
'no_action_provided' => 'Keine Maßnahme vorgesehen. Wenn Sie glauben, dass dies falsch ist, wenden Sie sich bitte an den Support.',
'no_payable_invoices_selected' => 'Keine unbezahlten Rechnungen ausgewählt. Stellen Sie sicher, dass Sie nicht versuchen, einen Rechnungsentwurf oder eine Rechnung mit Nullsaldo zu bezahlen.',
'required_payment_information' => 'Benötigte Zahlungsinformationen',
'required_payment_information_more' => 'Um eine Zahlung abzuschließen, benötigen wir weitere Informationen über Sie.',
'required_client_info_save_label' => 'Wir speichern dies, so dass Sie es beim nächsten Mal nicht mehr eingeben müssen.',
'notification_credit_bounced' => 'Wir waren nicht in der Lage, die Gutschrift :invoice an :contact zu liefern. \n :error',
'notification_credit_bounced_subject' => 'Gutschrift nicht auslieferbar :invoice',
'save_payment_method_details' => 'Angaben zur Zahlungsart speichern',
'new_card' => 'Neue Kreditkarte',
'new_bank_account' => 'Bankverbindung hinzufügen',
'company_limit_reached' => 'Maximal :limit Firmen pro Account.',
'credits_applied_validation' => 'Die Gesamtsumme der Gutschriften kann nicht MEHR sein als die Gesamtsumme der Rechnungen',
'credit_number_taken' => 'Gutschriftsnummer bereits vergeben.',
'credit_not_found' => 'Gutschrift nicht gefunden',
'invoices_dont_match_client' => 'Die ausgewählten Rechnungen stammen von mehr als einem Kunden',
'duplicate_credits_submitted' => 'Doppelte Zahlung eingereicht',
'duplicate_invoices_submitted' => 'Doppelte Rechnung',
'credit_with_no_invoice' => 'Bei der Verwendung eines Kredits in einer Zahlung muss eine Rechnung eingestellt sein.',
'client_id_required' => 'Kundennummer wird benötigt.',
'expense_number_taken' => 'Bereits vergebene Ausgabennummer',
'invoice_number_taken' => 'Diese Rechnungsnummer wurde bereits verwendet.',
'payment_id_required' => 'Zahlungs-ID notwendig.',
'unable_to_retrieve_payment' => 'Die angegebene Zahlung kann nicht abgerufen werden',
'invoice_not_related_to_payment' => 'Rechnungsnr. :invoice ist nicht mit dieser Zahlung verknüpft',
'credit_not_related_to_payment' => 'Gutschrift :credit ist nicht mit dieser Zahlung verknüpft',
'max_refundable_invoice' => 'Es wurde versucht, mehr zu erstatten, als für die Rechnungs-ID :invoice zulässig ist, der maximal erstattungsfähige Betrag ist :amount',
'refund_without_invoices' => 'Wenn Sie versuchen, eine Zahlung mit beigefügten Rechnungen zu erstatten, geben Sie bitte gültige Rechnungen an, die erstattet werden sollen.',
'refund_without_credits' => 'Wenn Sie versuchen, eine Zahlung mit angefügter Gutschrift zu erstatten, geben Sie bitte eine gültige Gutschrift an, die erstattet werden sollen.',
'max_refundable_credit' => 'Versuch, mehr zu erstatten, als für die Gutschrift zugelassen ist :credit, maximal erstattungsfähiger Betrag ist :amount',
'project_client_do_not_match' => 'Projektkunde stimmt nicht mit Entitätskunde überein',
'quote_number_taken' => 'Angebotsnummer bereits in Verwendung',
'recurring_invoice_number_taken' => 'Wiederkehrende Rechnungsnummer :number bereits vergeben',
'user_not_associated_with_account' => 'Kein mit diesem Konto verbundener Benutzer',
'amounts_do_not_balance' => 'Die Beträge sind nicht korrekt ausgeglichen.',
'insufficient_applied_amount_remaining' => 'Der angewandte Betrag reicht nicht aus, um die Zahlung zu decken.',
'insufficient_credit_balance' => 'Unzureichende Gutschrift auf dem Kredit.',
'one_or_more_invoices_paid' => 'Eine oder mehrere dieser Rechnungen wurden bereits bezahlt',
'invoice_cannot_be_refunded' => 'Rechnung :number kann nicht erstattet werden',
'attempted_refund_failed' => 'Erstattungsversuch :amount nur :refundable_amount für Erstattung verfügbar',
'user_not_associated_with_this_account' => 'Dieser Benutzer kann nicht mit diesem Unternehmen verbunden werden. Vielleicht hat er bereits einen Benutzer für ein anderes Konto registriert?',
'migration_completed' => 'Umstellung abgeschlossen',
'migration_completed_description' => 'Die Umstellung wurde erfolgreich abgeschlossen. Bitte prüfen Sie trotzdem Ihre Daten nach dem Login.',
'api_404' => '404 | Hier gibt es nichts zu sehen!',
'large_account_update_parameter' => 'Kann ein großes Konto ohne den Parameter updated_at nicht laden',
'no_backup_exists' => 'Für diese Aktivität ist keine Sicherung vorhanden',
'company_user_not_found' => 'Firma Benutzerdatensatz nicht gefunden',
'no_credits_found' => 'Keine Gutschriften gefunden.',
'action_unavailable' => 'Die angeforderte Aktion :action ist nicht verfügbar.',
'no_documents_found' => 'Keine Dokumente gefunden.',
'no_group_settings_found' => 'Keine Gruppeneinstellungen gefunden',
'access_denied' => 'Unzureichende Berechtigungen für den Zugriff/die Änderung dieser Ressource',
'invoice_cannot_be_marked_paid' => 'Rechnung kann nicht als "bezahlt" gekennzeichnet werden.',
'invoice_license_or_environment' => 'Ungültige Lizenz, oder ungültige Umgebung :environment',
'route_not_available' => 'Pfad nicht verfügbar',
'invalid_design_object' => 'Ungültiges benutzerdefiniertes Entwurfsobjekt',
'quote_not_found' => 'Angebot/e nicht gefunden',
'quote_unapprovable' => 'Dieses Angebot kann nicht genehmigt werden, da es abgelaufen ist.',
'scheduler_has_run' => 'Aufgabenplaner lief',
'scheduler_has_never_run' => 'Aufgabenplaner lief noch nie',
'self_update_not_available' => 'Integrierter Updater auf diesem System nicht verfügbar.',
'user_detached' => 'Nutzer wurden vom Unternehmen entkoppelt',
'create_webhook_failure' => 'Webhook konnte nicht erstellt werden',
'payment_message_extended' => 'Vielen Dank für Ihre Zahlung von :amount für die Rechnung :invoice',
'online_payments_minimum_note' => 'Hinweis: Online-Zahlungen werden nur unterstützt, wenn der Betrag größer als 1€ oder der entsprechende Währungsbetrag ist.',
'payment_token_not_found' => 'Zahlungstoken nicht gefunden, bitte versuchen Sie es erneut. Wenn das Problem weiterhin besteht, versuchen Sie es mit einer anderen Zahlungsmethode',
'vendor_address1' => 'Straße Lieferant',
'vendor_address2' => 'Lieferant Apt/Suite',
'partially_unapplied' => 'Teilweise unangewandt',
'select_a_gmail_user' => 'Bitte wählen Sie einen mit Gmail authentifizierten Benutzer',
'list_long_press' => 'Liste Langes Drücken',
'show_actions' => 'Zeige Aktionen',
'start_multiselect' => 'Mehrfachauswahl',
'email_sent_to_confirm_email' => 'Eine E-Mail wurde versandt um Ihre E-Mail-Adresse zu bestätigen.',
'converted_paid_to_date' => 'Umgewandelt Bezahlt bis Datum',
'converted_credit_balance' => 'Umgerechneter Gutschriftsbetrag',
'converted_total' => 'Umgerechnet Total',
'reply_to_name' => 'Name der Antwortadresse',
'payment_status_-2' => 'Teilweise nicht angewendet',
'color_theme' => 'Farbthema',
'start_migration' => 'Beginne mit der Migration.',
'recurring_cancellation_request' => 'Antrag auf Stornierung wiederkehrender Rechnungen von :contact',
'recurring_cancellation_request_body' => ':contact vom Kunden :client bittet um Stornierung der wiederkehrenden Rechnung :invoice',
'hello' => 'Hallo',
'group_documents' => 'Gruppendokumente',
'quote_approval_confirmation_label' => 'Sind Sie sicher, dass Sie diesem Angebot / Kostenvoranschlag zustimmen möchten?',
'migration_select_company_label' => 'Wählen Sie die zu migrierenden Firmen aus',
'force_migration' => 'Migration erzwingen',
'require_password_with_social_login' => 'Anmeldung per Social Login notwendig',
'stay_logged_in' => 'Eingeloggt bleiben',
'session_about_to_expire' => 'Warnung: Ihre Sitzung läuft bald ab',
'count_hours' => ':count Stunden',
'count_day' => '1 Tag',
'count_days' => ':count Tage',
'web_session_timeout' => 'Web-Sitzungs-Timeout',
'security_settings' => 'Sicherheitseinstellungen',
'resend_email' => 'Bestätigungs-E-Mail erneut versenden ',
'confirm_your_email_address' => 'Bitte bestätigen Sie Ihre E-Mail-Adresse',
'freshbooks' => 'FreshBooks',
'invoice2go' => 'Invoice2go',
'invoicely' => 'Invoicely',
'waveaccounting' => 'Wave Accounting',
'zoho' => 'Zoho',
'accounting' => 'Buchhaltung',
'required_files_missing' => 'Bitte geben Sie alle CSV-Dateien an.',
'migration_auth_label' => 'Authentifizierung fortsetzen.',
'api_secret' => 'API-Secret',
'migration_api_secret_notice' => 'Sie finden API_SECRET in der .env-Datei oder in Invoice Ninja v5. Wenn die Eigenschaft fehlt, lassen Sie das Feld leer.',
'billing_coupon_notice' => 'Ihr Rabatt wird an der Kasse abgezogen.',
'use_last_email' => 'Vorherige E-Mail benutzen',
'activate_company' => 'Unternehmen aktivieren',
'activate_company_help' => 'E-Mails, wiederkehrende Rechnungen und Benachrichtigungen aktivieren',
'an_error_occurred_try_again' => 'Ein Fehler ist aufgetreten, bitte versuchen Sie es erneut.',
'please_first_set_a_password' => 'Bitte vergeben Sie zuerst ein Passwort.',
'changing_phone_disables_two_factor' => 'Achtung: Das Ändern Ihrer Telefonnummer wird die Zwei-Faktor-Authentifizierung deaktivieren',
'help_translate' => 'Hilf mit beim Übersetzen',
'please_select_a_country' => 'Bitte wählen Sie ein Land',
'disabled_two_factor' => '2FA erfolgreich deaktiviert',
'connected_google' => 'Konto erfolgreich verbunden.',
'disconnected_google' => 'Konto erfolgreich getrennt.',
'delivered' => 'zugestellt',
'spam' => 'Spam',
'view_docs' => 'Dokumentation ansehen.',
'enter_phone_to_enable_two_factor' => 'Bitte gib eine Handynummer an, um die Zwei-Faktor-Authentifizierung zu aktivieren',
'send_sms' => 'SMS senden',
'sms_code' => 'SMS-Code',
'connect_google' => 'Google-Konto verbinden',
'disconnect_google' => 'Google-Konto entfernen',
'disable_two_factor' => 'Zwei-Faktor-Authentifizierung deaktivieren',
'invoice_task_datelog' => 'In Aufgabe erfasste Daten in Rechnungen ausweisen',
'invoice_task_datelog_help' => 'Zeigt Datumsdetails in den Rechnungspositionen an',
'promo_code' => 'Gutscheincode',
'recurring_invoice_issued_to' => 'Wiederkehrende Rechnung ausgestellt an',
'subscription' => 'Abonnement',
'new_subscription' => 'Neues Abonnement',
'deleted_subscription' => 'Abonnement gelöscht',
'removed_subscription' => 'Abonnement entfernt',
'restored_subscription' => 'Abonnement wiederhergestellt',
'search_subscription' => 'Suchen Sie 1 Abonnement',
'search_subscriptions' => ':count Abonnements durchsuchen',
'subdomain_is_not_available' => 'Subdomain ist nicht verfügbar',
'connect_gmail' => 'Mit Gmail verbinden',
'disconnect_gmail' => 'von Gmail trennen',
'connected_gmail' => 'Mit Gmail erfolgreich verbunden',
'disconnected_gmail' => 'Von Gmail erfolgreich getrennt',
'update_fail_help' => 'Änderungen an der Codebasis können das Update blockieren, Sie können diesen Befehl ausführen, um die Änderungen zu verwerfen:',
'client_id_number' => 'Kundennummer',
'count_minutes' => ':count Minuten',
'password_timeout' => 'Passwort-Timeout',
'shared_invoice_credit_counter' => 'Rechnung / Gutschrift Zähler teilen',
'next' => 'Weiter',
'next_step' => 'Nächster Schritt',
'notification_credit_sent_subject' => 'Gutschrift :invoice wurde an Kunde gesendet.',
'notification_credit_viewed_subject' => 'Gutschrift :invoice wurde von :client angesehen.',
'notification_credit_sent' => 'Der folgende Kunde :client hat eine Gutschrift :invoice über :amount erhalten.',
'notification_credit_viewed' => 'Der folgende Kunde :client hat die Gutschrift :credit über :amount angeschaut.',
'reset_password_text' => 'Bitte geben Sie ihre E-Mail-Adresse an, um das Passwort zurücksetzen zu können.',
'password_reset' => 'Passwort zurücksetzten',
'account_login_text' => 'Willkommen! Schön Sie zu sehen.',
'request_cancellation' => 'Storno beantragen',
'delete_payment_method' => 'Zahlungsmethode löschen',
'about_to_delete_payment_method' => 'Diese Zahlungsmethode wird gelöscht.',
'action_cant_be_reversed' => 'Diese Aktion kann nicht widerrufen werden',
'profile_updated_successfully' => 'Das Profil wurde erfolgreich aktualisiert.',
'currency_ethiopian_birr' => 'Äthiopischer Birr',
'client_information_text' => 'Bitte nutzen Sie eine postfähige Anschrift.',
'status_id' => 'Rechnungsstatus',
'email_already_register' => 'Diese E-Mail wird bereits von einem anderen Account verwendet',
'locations' => 'Standorte',
'freq_indefinitely' => 'Unendlich',
'cycles_remaining' => 'Verbleibende Zyklen',
'i_understand_delete' => 'Ich bin mir der Risiken bewusst, löschen',
'download_files' => 'Dateien herunterladen',
'download_timeframe' => 'Nutzen Sie diesen Link um Ihre Dateien herunterzuladen. Der Link läuft in einer Stunde ab.',
'new_signup' => 'Neue Registrierung',
'new_signup_text' => 'Ein neuer Benutzer wurde von :user - :email von der IP: :ip erstellt',
'notification_payment_paid_subject' => 'Neue Zahlung von :client',
'notification_partial_payment_paid_subject' => 'Neue Anzahlung von :client',
'notification_payment_paid' => 'Eine Zahlung von :amount wurde von Kunde :client auf :invoice geleistet',
'notification_partial_payment_paid' => 'Eine Teilzahlung in Höhe von :amount wurde vom Kunden :client auf :invoice geleistet',
'notification_bot' => 'Benachrichtigungs-Bot',
'invoice_number_placeholder' => 'Rechnung # :invoice',
'entity_number_placeholder' => ':entity # :entity_number',
'email_link_not_working' => 'Wenn die Schaltfläche oben nicht funktioniert, klicken Sie bitte auf den Link',
'display_log' => 'Log anzeigen',
'send_fail_logs_to_our_server' => 'Fehler in Echtzeit melden',
'setup' => 'Setup',
'quick_overview_statistics' => 'Schnellüberblick & Statistiken',
'update_your_personal_info' => 'Aktualisieren Sie Ihre Profil',
'name_website_logo' => 'Name, Webseite & Logo',
'make_sure_use_full_link' => 'Es ist wichtig, den gesamten Link \'\'https://example.com\'\' einzutragen.',
'personal_address' => 'Private Adresse',
'enter_your_personal_address' => 'Bitte geben Sie Ihre Rechnungsadresse an',
'enter_your_shipping_address' => 'Bitte geben Sie Ihr Lieferadresse an',
'list_of_invoices' => 'Liste der Rechnungen',
'with_selected' => 'Breite ausgewählt',
'invoice_still_unpaid' => 'Diese Rechnung wurde noch nicht beglichen. Klicken um zu vervollständigen.',
'list_of_recurring_invoices' => 'Liste der wiederkehrende Rechnungen',
'details_of_recurring_invoice' => 'Details über wiederkehrende Rechnung',
'cancellation' => 'Storno',
'about_cancellation' => 'Wenn Sie die wiederkehrende Rechnung stoppen möchten, klicken Sie bitte auf , um die Stornierung zu beantragen.',
'cancellation_warning' => 'Achtung! Sie beantragen die Stornierung dieses Dienstes. Ihr Dienst kann ohne weitere Mitteilung an Sie storniert werden.',
'cancellation_pending' => 'Kündigung in Bearbeitung! Wir melden uns bei Ihnen...',
'list_of_payments' => 'Liste der Zahlungen',
'payment_details' => 'Details zu der Zahlung',
'list_of_payment_invoices' => 'Liste der Rechnungen die von dieser Zahlung betroffenen sind',
'list_of_payment_methods' => 'Liste der Zahlungsmethoden',
'payment_method_details' => 'Details zu der Zahlungsmethode',
'permanently_remove_payment_method' => 'Zahlungsmethode endgültig entfernen.',
'warning_action_cannot_be_reversed' => 'Achtung! Diese Aktion kann nicht widerrufen werden!',
'confirmation' => 'Bestätigung',
'list_of_quotes' => 'Angebote',
'waiting_for_approval' => 'Annahme ausstehend',
'quote_still_not_approved' => 'Dieses Angebot wurde noch nicht angenommen.',
'list_of_credits' => 'Gutschriften',
'required_extensions' => 'Benötigte PHP-Erweiterungen',
'php_version' => 'PHP Version',
'writable_env_file' => 'Beschreibbare .env-Datei',
'env_not_writable' => 'die .env-Datei ist vom aktuellen Benutzer nicht beschreibbar',
'minumum_php_version' => 'Minimale PHP-Version',
'satisfy_requirements' => 'Prüfen Sie, ob alle Anforderungen erfüllt sind.',
'oops_issues' => 'Entschuldigung, das ist wohl etwas schiefgelaufen!',
'open_in_new_tab' => 'In neuem Fenster öffnen',
'complete_your_payment' => 'Zahlung abschließen',
'authorize_for_future_use' => 'Zahlungsmethode für zukünftige Verwendung freigeben',
'page' => 'Seite',
'per_page' => 'pro Seite',
'of' => 'von',
'view_credit' => 'Gutschrift anzeigen',
'to_view_entity_password' => 'Um :entity anzusehen, geben Sie bitte Ihr Passwort ein.',
'showing_x_of' => 'Zeige :first bis :last von :total Ergebnissen',
'no_results' => 'Kein Ergebnis gefunden.',
'payment_failed_subject' => 'Zahlung für Kunde :client fehlgeschlagen',
'payment_failed_body' => 'Eine Zahlung von :client schlug fehl: :message',
'register' => 'Registrieren',
'register_label' => 'Benutzerkonto in wenigen Sekunden erstellen',
'password_confirmation' => 'Passwort bestätigen',
'verification' => 'Bestätigung',
'complete_your_bank_account_verification' => 'Ein Bankkonto muss verifiziert werden, bevor es genutzt werden kann.',
'checkout_com' => 'Checkout.com',
'footer_label' => 'Copyright © :year :company.',
'credit_card_invalid' => 'Die angegebene Kreditkartennummer ist ungültig.',
'month_invalid' => 'Der angegebene Monat ist ungültig',
'year_invalid' => 'Das angegebene Jahr ist ungültig',
'https_required' => 'HTTPS ist Pflicht, das Formular wird nicht funktionieren',
'if_you_need_help' => 'Wenn Sie Hilfe benötigen, wenden Sie sich bitte an unsere',
'update_password_on_confirm' => 'Nach dem Update des Passworts wird Ihr Account bestätigt.',
'bank_account_not_linked' => 'Um mit einem Bankkonto zu bezahlen, müssen Sie es zunächst als Zahlungsmethode hinzufügen.',
'application_settings_label' => 'Lassen Sie uns grundlegende Informationen über Ihr Invoice Ninja speichern!',
'recommended_in_production' => 'Ausdrücklich für Produktivumgebungen empfohlen!',
'enable_only_for_development' => 'Nur in Entwicklungsumgebung aktivieren',
'test_pdf' => 'PDF testen',
'checkout_authorize_label' => 'Checkout.com kann als Zahlungsmethode für die zukünftige Verwendung gespeichert werden, sobald Sie Ihre erste Transaktion abgeschlossen haben. Vergessen Sie nicht, die Option "Kreditkartendaten speichern" während des Zahlungsvorgangs zu aktivieren.',
'sofort_authorize_label' => 'Das Bankkonto (SOFORT) kann als Zahlungsmethode für die zukünftige Verwendung gespeichert werden, sobald Sie Ihre erste Transaktion abgeschlossen haben. Vergessen Sie nicht, die Option "Zahlungsdetails speichern" während des Zahlungsvorgangs zu aktivieren.',
'node_status' => 'Node-Status',
'npm_status' => 'NPM-Status',
'node_status_not_found' => 'Node konnte nicht gefunden werden - ist es installiert?',
'npm_status_not_found' => 'NPM konnte nicht gefunden werden - ist es installiert?',
'locked_invoice' => 'Diese Rechnung ist gesperrt und kann nicht bearbeitet werden.',
'downloads' => 'Downloads',
'resource' => 'Resourcen',
'document_details' => 'Details zu dem Dokument',
'hash' => 'Hash',
'resources' => 'Ressourcen',
'allowed_file_types' => 'Erlaubte Dateitypen:',
'common_codes' => 'Gängige Codes und ihre Bedeutungen',
'payment_error_code_20087' => '20087: Falsche Track-Daten (ungültiger CVV und/oder Verfallsdatum)',
'download_selected' => 'Ausgewählte herunterladen',
'to_pay_invoices' => 'Um Rechnungen zu bezahlen, müssen Sie',
'add_payment_method_first' => 'Zahlungsart hinzufügen',
'no_items_selected' => 'Keine Objekte ausgewählt.',
'payment_due' => 'Zahlung überfallig',
'account_balance' => 'Saldo',
'thanks' => 'Danke',
'minimum_required_payment' => 'Mindestbetrag für die Zahlung ist :amount',
'under_payments_disabled' => 'Das Unternehmen unterstützt keine Unterbezahlungen.',
'over_payments_disabled' => 'Das Unternehmen unterstützt keine Überbezahlungen.',
'saved_at' => 'Gespeichert um :time',
'credit_payment' => 'Gutschrift auf Rechnung :invoice_number angewendet',
'credit_subject' => 'Neue Gutschrift :number von :account',
'credit_message' => 'Um Ihre Gutschrift über :amount einzusehen, klicken Sie auf den untenstehenden Link.',
'payment_type_Crypto' => 'Kryptowährung',
'payment_type_Credit' => 'Gutschrift',
'store_for_future_use' => 'Für zukünftige Zahlung speichern',
'pay_with_credit' => 'Mit Kreditkarte zahlen',
'payment_method_saving_failed' => 'Die Zahlungsart konnte nicht für zukünftige Zahlungen gespeichert werden.',
'pay_with' => 'zahlen mit',
'n/a' => 'n. z.',
'by_clicking_next_you_accept_terms' => 'Wenn Sie auf "Nächster Schritt" klicken, akzeptieren Sie die Bedingungen.',
'not_specified' => 'Nicht angegeben',
'before_proceeding_with_payment_warning' => 'Bevor Sie mit der Zahlung fortfahren, müssen Sie folgende Felder ausfüllen',
'after_completing_go_back_to_previous_page' => 'Gehen Sie nach dem Ausfüllen zurück zur vorherigen Seite.',
'pay' => 'Zahlen',
'instructions' => 'Anleitung',
'notification_invoice_reminder1_sent_subject' => 'Die 1. Mahnung für Rechnung :invoice wurde an Kunde :client gesendet',
'notification_invoice_reminder2_sent_subject' => 'Die 2. Mahnung für Rechnung :invoice wurde an Kunde :client gesendet',
'notification_invoice_reminder3_sent_subject' => 'Die 3. Mahnung für Rechnung :invoice wurde an Kunde :client gesendet',
'notification_invoice_custom_sent_subject' => 'Benutzerdefinierte Mahnung für Rechnung :invoice wurde an :client gesendet',
'notification_invoice_reminder_endless_sent_subject' => 'Endlose Mahnung für Rechnung :invoice wurde an :client gesendet',
'assigned_user' => 'Zugewiesener Benutzer',
'setup_steps_notice' => 'Um mit dem nächsten Schritt fortzufahren, stellen Sie sicher, dass Sie jeden Abschnitt testen.',
'setup_phantomjs_note' => 'Anmerkung zu Phantom JS. Mehr...',
'minimum_payment' => 'Mindestbetrag',
'no_action_provided' => 'Keine Maßnahme vorgesehen. Wenn Sie glauben, dass dies falsch ist, wenden Sie sich bitte an den Support.',
'no_payable_invoices_selected' => 'Keine unbezahlten Rechnungen ausgewählt. Stellen Sie sicher, dass Sie nicht versuchen, einen Rechnungsentwurf oder eine Rechnung mit Nullsaldo zu bezahlen.',
'required_payment_information' => 'Benötigte Zahlungsinformationen',
'required_payment_information_more' => 'Um eine Zahlung abzuschließen, benötigen wir weitere Informationen über Sie.',
'required_client_info_save_label' => 'Wir speichern dies, so dass Sie es beim nächsten Mal nicht mehr eingeben müssen.',
'notification_credit_bounced' => 'Wir waren nicht in der Lage, die Gutschrift :invoice an :contact zu liefern. \n :error',
'notification_credit_bounced_subject' => 'Gutschrift nicht auslieferbar :invoice',
'save_payment_method_details' => 'Angaben zur Zahlungsart speichern',
'new_card' => 'Neue Kreditkarte',
'new_bank_account' => 'Bankverbindung hinzufügen',
'company_limit_reached' => 'Maximal :limit Firmen pro Account.',
'credits_applied_validation' => 'Die Gesamtsumme der Gutschriften kann nicht MEHR sein als die Gesamtsumme der Rechnungen',
'credit_number_taken' => 'Gutschriftsnummer bereits vergeben.',
'credit_not_found' => 'Gutschrift nicht gefunden',
'invoices_dont_match_client' => 'Die ausgewählten Rechnungen stammen von mehr als einem Kunden',
'duplicate_credits_submitted' => 'Doppelte Zahlung eingereicht',
'duplicate_invoices_submitted' => 'Doppelte Rechnung',
'credit_with_no_invoice' => 'Bei der Verwendung eines Kredits in einer Zahlung muss eine Rechnung eingestellt sein.',
'client_id_required' => 'Kundennummer wird benötigt.',
'expense_number_taken' => 'Bereits vergebene Ausgabennummer',
'invoice_number_taken' => 'Diese Rechnungsnummer wurde bereits verwendet.',
'payment_id_required' => 'Zahlungs-ID notwendig.',
'unable_to_retrieve_payment' => 'Die angegebene Zahlung kann nicht abgerufen werden',
'invoice_not_related_to_payment' => 'Rechnungsnr. :invoice ist nicht mit dieser Zahlung verknüpft',
'credit_not_related_to_payment' => 'Gutschrift :credit ist nicht mit dieser Zahlung verknüpft',
'max_refundable_invoice' => 'Es wurde versucht, mehr zu erstatten, als für die Rechnungs-ID :invoice zulässig ist, der maximal erstattungsfähige Betrag ist :amount',
'refund_without_invoices' => 'Wenn Sie versuchen, eine Zahlung mit beigefügten Rechnungen zu erstatten, geben Sie bitte gültige Rechnungen an, die erstattet werden sollen.',
'refund_without_credits' => 'Wenn Sie versuchen, eine Zahlung mit angefügter Gutschrift zu erstatten, geben Sie bitte eine gültige Gutschrift an, die erstattet werden sollen.',
'max_refundable_credit' => 'Versuch, mehr zu erstatten, als für die Gutschrift zugelassen ist :credit, maximal erstattungsfähiger Betrag ist :amount',
'project_client_do_not_match' => 'Projektkunde stimmt nicht mit Entitätskunde überein',
'quote_number_taken' => 'Angebotsnummer bereits in Verwendung',
'recurring_invoice_number_taken' => 'Wiederkehrende Rechnungsnummer :number bereits vergeben',
'user_not_associated_with_account' => 'Kein mit diesem Konto verbundener Benutzer',
'amounts_do_not_balance' => 'Die Beträge sind nicht korrekt ausgeglichen.',
'insufficient_applied_amount_remaining' => 'Der angewandte Betrag reicht nicht aus, um die Zahlung zu decken.',
'insufficient_credit_balance' => 'Unzureichende Gutschrift auf dem Kredit.',
'one_or_more_invoices_paid' => 'Eine oder mehrere dieser Rechnungen wurden bereits bezahlt',
'invoice_cannot_be_refunded' => 'Rechnung :number kann nicht erstattet werden',
'attempted_refund_failed' => 'Erstattungsversuch :amount nur :refundable_amount für Erstattung verfügbar',
'user_not_associated_with_this_account' => 'Dieser Benutzer kann nicht mit diesem Unternehmen verbunden werden. Vielleicht hat er bereits einen Benutzer für ein anderes Konto registriert?',
'migration_completed' => 'Umstellung abgeschlossen',
'migration_completed_description' => 'Die Umstellung wurde erfolgreich abgeschlossen. Bitte prüfen Sie trotzdem Ihre Daten nach dem Login.',
'api_404' => '404 | Hier gibt es nichts zu sehen!',
'large_account_update_parameter' => 'Kann ein großes Konto ohne den Parameter updated_at nicht laden',
'no_backup_exists' => 'Für diese Aktivität ist keine Sicherung vorhanden',
'company_user_not_found' => 'Firma Benutzerdatensatz nicht gefunden',
'no_credits_found' => 'Keine Gutschriften gefunden.',
'action_unavailable' => 'Die angeforderte Aktion :action ist nicht verfügbar.',
'no_documents_found' => 'Keine Dokumente gefunden.',
'no_group_settings_found' => 'Keine Gruppeneinstellungen gefunden',
'access_denied' => 'Unzureichende Berechtigungen für den Zugriff/die Änderung dieser Ressource',
'invoice_cannot_be_marked_paid' => 'Rechnung kann nicht als "bezahlt" gekennzeichnet werden.',
'invoice_license_or_environment' => 'Ungültige Lizenz, oder ungültige Umgebung :environment',
'route_not_available' => 'Pfad nicht verfügbar',
'invalid_design_object' => 'Ungültiges benutzerdefiniertes Entwurfsobjekt',
'quote_not_found' => 'Angebot/e nicht gefunden',
'quote_unapprovable' => 'Dieses Angebot kann nicht genehmigt werden, da es abgelaufen ist.',
'scheduler_has_run' => 'Aufgabenplaner lief',
'scheduler_has_never_run' => 'Aufgabenplaner lief noch nie',
'self_update_not_available' => 'Integrierter Updater auf diesem System nicht verfügbar.',
'user_detached' => 'Nutzer wurden vom Unternehmen entkoppelt',
'create_webhook_failure' => 'Webhook konnte nicht erstellt werden',
'payment_message_extended' => 'Vielen Dank für Ihre Zahlung von :amount für die Rechnung :invoice',
'online_payments_minimum_note' => 'Hinweis: Online-Zahlungen werden nur unterstützt, wenn der Betrag größer als 1€ oder der entsprechende Währungsbetrag ist.',
'payment_token_not_found' => 'Zahlungstoken nicht gefunden, bitte versuchen Sie es erneut. Wenn das Problem weiterhin besteht, versuchen Sie es mit einer anderen Zahlungsmethode',
'vendor_address1' => 'Straße Lieferant',
'vendor_address2' => 'Lieferant Apt/Suite',
'partially_unapplied' => 'Teilweise unangewandt',
'select_a_gmail_user' => 'Bitte wählen Sie einen mit Gmail authentifizierten Benutzer',
'list_long_press' => 'Liste Langes Drücken',
'show_actions' => 'Zeige Aktionen',
'start_multiselect' => 'Mehrfachauswahl',
'email_sent_to_confirm_email' => 'Eine E-Mail wurde versandt um Ihre E-Mail-Adresse zu bestätigen.',
'converted_paid_to_date' => 'Umgewandelt Bezahlt bis Datum',
'converted_credit_balance' => 'Umgerechneter Gutschriftsbetrag',
'converted_total' => 'Umgerechnet Total',
'reply_to_name' => 'Name der Antwortadresse',
'payment_status_-2' => 'Teilweise nicht angewendet',
'color_theme' => 'Farbthema',
'start_migration' => 'Beginne mit der Migration.',
'recurring_cancellation_request' => 'Antrag auf Stornierung wiederkehrender Rechnungen von :contact',
'recurring_cancellation_request_body' => ':contact vom Kunden :client bittet um Stornierung der wiederkehrenden Rechnung :invoice',
'hello' => 'Hallo',
'group_documents' => 'Gruppendokumente',
'quote_approval_confirmation_label' => 'Sind Sie sicher, dass Sie diesem Angebot / Kostenvoranschlag zustimmen möchten?',
'migration_select_company_label' => 'Wählen Sie die zu migrierenden Firmen aus',
'force_migration' => 'Migration erzwingen',
'require_password_with_social_login' => 'Anmeldung per Social Login notwendig',
'stay_logged_in' => 'Eingeloggt bleiben',
'session_about_to_expire' => 'Warnung: Ihre Sitzung läuft bald ab',
'count_hours' => ':count Stunden',
'count_day' => '1 Tag',
'count_days' => ':count Tage',
'web_session_timeout' => 'Web-Sitzungs-Timeout',
'security_settings' => 'Sicherheitseinstellungen',
'resend_email' => 'Bestätigungs-E-Mail erneut versenden ',
'confirm_your_email_address' => 'Bitte bestätigen Sie Ihre E-Mail-Adresse',
'freshbooks' => 'FreshBooks',
'invoice2go' => 'Invoice2go',
'invoicely' => 'Invoicely',
'waveaccounting' => 'Wave Accounting',
'zoho' => 'Zoho',
'accounting' => 'Buchhaltung',
'required_files_missing' => 'Bitte geben Sie alle CSV-Dateien an.',
'migration_auth_label' => 'Authentifizierung fortsetzen.',
'api_secret' => 'API-Secret',
'migration_api_secret_notice' => 'Sie finden API_SECRET in der .env-Datei oder in Invoice Ninja v5. Wenn die Eigenschaft fehlt, lassen Sie das Feld leer.',
'billing_coupon_notice' => 'Ihr Rabatt wird an der Kasse abgezogen.',
'use_last_email' => 'Vorherige E-Mail benutzen',
'activate_company' => 'Unternehmen aktivieren',
'activate_company_help' => 'E-Mails, wiederkehrende Rechnungen und Benachrichtigungen aktivieren',
'an_error_occurred_try_again' => 'Ein Fehler ist aufgetreten, bitte versuchen Sie es erneut.',
'please_first_set_a_password' => 'Bitte vergeben Sie zuerst ein Passwort.',
'changing_phone_disables_two_factor' => 'Achtung: Das Ändern Ihrer Telefonnummer wird die Zwei-Faktor-Authentifizierung deaktivieren',
'help_translate' => 'Hilf mit beim Übersetzen',
'please_select_a_country' => 'Bitte wählen Sie ein Land',
'disabled_two_factor' => '2FA erfolgreich deaktiviert',
'connected_google' => 'Konto erfolgreich verbunden.',
'disconnected_google' => 'Konto erfolgreich getrennt.',
'delivered' => 'zugestellt',
'spam' => 'Spam',
'view_docs' => 'Dokumentation ansehen.',
'enter_phone_to_enable_two_factor' => 'Bitte gib eine Handynummer an, um die Zwei-Faktor-Authentifizierung zu aktivieren',
'send_sms' => 'SMS senden',
'sms_code' => 'SMS-Code',
'connect_google' => 'Google-Konto verbinden',
'disconnect_google' => 'Google-Konto entfernen',
'disable_two_factor' => 'Zwei-Faktor-Authentifizierung deaktivieren',
'invoice_task_datelog' => 'In Aufgabe erfasste Daten in Rechnungen ausweisen',
'invoice_task_datelog_help' => 'Zeigt Datumsdetails in den Rechnungspositionen an',
'promo_code' => 'Gutscheincode',
'recurring_invoice_issued_to' => 'Wiederkehrende Rechnung ausgestellt an',
'subscription' => 'Abonnement',
'new_subscription' => 'Neues Abonnement',
'deleted_subscription' => 'Abonnement gelöscht',
'removed_subscription' => 'Abonnement entfernt',
'restored_subscription' => 'Abonnement wiederhergestellt',
'search_subscription' => 'Suchen Sie 1 Abonnement',
'search_subscriptions' => ':count Abonnements durchsuchen',
'subdomain_is_not_available' => 'Subdomain ist nicht verfügbar',
'connect_gmail' => 'Mit Gmail verbinden',
'disconnect_gmail' => 'von Gmail trennen',
'connected_gmail' => 'Mit Gmail erfolgreich verbunden',
'disconnected_gmail' => 'Von Gmail erfolgreich getrennt',
'update_fail_help' => 'Änderungen an der Codebasis können das Update blockieren, Sie können diesen Befehl ausführen, um die Änderungen zu verwerfen:',
'client_id_number' => 'Kundennummer',
'count_minutes' => ':count Minuten',
'password_timeout' => 'Passwort-Timeout',
'shared_invoice_credit_counter' => 'Rechnung / Gutschrift Zähler teilen',
'activity_80' => ':user hat Abonnement :subscription erstellt',
'activity_81' => ':user hat Abonnement :subscription geändert',
'activity_82' => ':user hat Abonnement :subscription archiviert',

View File

@ -3857,308 +3857,308 @@ $lang = array(
'registration_url' => 'Registration URL',
'show_product_cost' => 'Show Product Cost',
'complete' => 'Complete',
'next' => 'Next',
'next_step' => 'Next step',
'notification_credit_sent_subject' => 'Credit :invoice was sent to :client',
'notification_credit_viewed_subject' => 'Credit :invoice was viewed by :client',
'notification_credit_sent' => 'The following client :client was emailed Credit :invoice for :amount.',
'notification_credit_viewed' => 'The following client :client viewed Credit :credit for :amount.',
'reset_password_text' => 'Enter your email to reset your password.',
'password_reset' => 'Password reset',
'account_login_text' => 'Welcome! Glad to see you.',
'request_cancellation' => 'Request cancellation',
'delete_payment_method' => 'Delete Payment Method',
'about_to_delete_payment_method' => 'You are about to delete the payment method.',
'action_cant_be_reversed' => 'Action can\'t be reversed',
'profile_updated_successfully' => 'The profile has been updated successfully.',
'currency_ethiopian_birr' => 'Ethiopian Birr',
'client_information_text' => 'Use a permanent address where you can receive mail.',
'status_id' => 'Invoice Status',
'email_already_register' => 'This email is already linked to an account',
'locations' => 'Locations',
'freq_indefinitely' => 'Indefinitely',
'cycles_remaining' => 'Cycles remaining',
'i_understand_delete' => 'I understand, delete',
'download_files' => 'Download Files',
'download_timeframe' => 'Use this link to download your files, the link will expire in 1 hour.',
'new_signup' => 'New Signup',
'new_signup_text' => 'A new account has been created by :user - :email - from IP address: :ip',
'notification_payment_paid_subject' => 'Payment was made by :client',
'notification_partial_payment_paid_subject' => 'Partial payment was made by :client',
'notification_payment_paid' => 'A payment of :amount was made by client :client towards :invoice',
'notification_partial_payment_paid' => 'A partial payment of :amount was made by client :client towards :invoice',
'notification_bot' => 'Notification Bot',
'invoice_number_placeholder' => 'Invoice # :invoice',
'entity_number_placeholder' => ':entity # :entity_number',
'email_link_not_working' => 'If the button above isn\'t working for you, please click on the link',
'display_log' => 'Display Log',
'send_fail_logs_to_our_server' => 'Report errors in realtime',
'setup' => 'Setup',
'quick_overview_statistics' => 'Quick overview & statistics',
'update_your_personal_info' => 'Update your personal information',
'name_website_logo' => 'Name, website & logo',
'make_sure_use_full_link' => 'Make sure you use full link to your site',
'personal_address' => 'Personal address',
'enter_your_personal_address' => 'Enter your personal address',
'enter_your_shipping_address' => 'Enter your shipping address',
'list_of_invoices' => 'List of invoices',
'with_selected' => 'With selected',
'invoice_still_unpaid' => 'This invoice is still not paid. Click the button to complete the payment',
'list_of_recurring_invoices' => 'List of recurring invoices',
'details_of_recurring_invoice' => 'Here are some details about recurring invoice',
'cancellation' => 'Cancellation',
'about_cancellation' => 'In case you want to stop the recurring invoice, please click to request the cancellation.',
'cancellation_warning' => 'Warning! You are requesting a cancellation of this service. Your service may be cancelled with no further notification to you.',
'cancellation_pending' => 'Cancellation pending, we\'ll be in touch!',
'list_of_payments' => 'List of payments',
'payment_details' => 'Details of the payment',
'list_of_payment_invoices' => 'List of invoices affected by the payment',
'list_of_payment_methods' => 'List of payment methods',
'payment_method_details' => 'Details of payment method',
'permanently_remove_payment_method' => 'Permanently remove this payment method.',
'warning_action_cannot_be_reversed' => 'Warning! This action can not be reversed!',
'confirmation' => 'Confirmation',
'list_of_quotes' => 'Quotes',
'waiting_for_approval' => 'Waiting for approval',
'quote_still_not_approved' => 'This quote is still not approved',
'list_of_credits' => 'Credits',
'required_extensions' => 'Required extensions',
'php_version' => 'PHP version',
'writable_env_file' => 'Writable .env file',
'env_not_writable' => '.env file is not writable by the current user.',
'minumum_php_version' => 'Minimum PHP version',
'satisfy_requirements' => 'Make sure all requirements are satisfied.',
'oops_issues' => 'Oops, something does not look right!',
'open_in_new_tab' => 'Open in new tab',
'complete_your_payment' => 'Complete payment',
'authorize_for_future_use' => 'Authorize payment method for future use',
'page' => 'Page',
'per_page' => 'Per page',
'of' => 'Of',
'view_credit' => 'View Credit',
'to_view_entity_password' => 'To view the :entity you need to enter password.',
'showing_x_of' => 'Showing :first to :last out of :total results',
'no_results' => 'No results found.',
'payment_failed_subject' => 'Payment failed for Client :client',
'payment_failed_body' => 'A payment made by client :client failed with message :message',
'register' => 'Register',
'register_label' => 'Create your account in seconds',
'password_confirmation' => 'Confirm your password',
'verification' => 'Verification',
'complete_your_bank_account_verification' => 'Before using a bank account it must be verified.',
'checkout_com' => 'Checkout.com',
'footer_label' => 'Copyright © :year :company.',
'credit_card_invalid' => 'Provided credit card number is not valid.',
'month_invalid' => 'Provided month is not valid.',
'year_invalid' => 'Provided year is not valid.',
'https_required' => 'HTTPS is required, form will fail',
'if_you_need_help' => 'If you need help you can post to our',
'update_password_on_confirm' => 'After updating password, your account will be confirmed.',
'bank_account_not_linked' => 'To pay with a bank account, first you have to add it as payment method.',
'application_settings_label' => 'Let\'s store basic information about your Invoice Ninja!',
'recommended_in_production' => 'Highly recommended in production',
'enable_only_for_development' => 'Enable only for development',
'test_pdf' => 'Test PDF',
'checkout_authorize_label' => 'Checkout.com can be can saved as payment method for future use, once you complete your first transaction. Don\'t forget to check "Store credit card details" during payment process.',
'sofort_authorize_label' => 'Bank account (SOFORT) can be can saved as payment method for future use, once you complete your first transaction. Don\'t forget to check "Store payment details" during payment process.',
'node_status' => 'Node status',
'npm_status' => 'NPM status',
'node_status_not_found' => 'I could not find Node anywhere. Is it installed?',
'npm_status_not_found' => 'I could not find NPM anywhere. Is it installed?',
'locked_invoice' => 'This invoice is locked and unable to be modified',
'downloads' => 'Downloads',
'resource' => 'Resource',
'document_details' => 'Details about the document',
'hash' => 'Hash',
'resources' => 'Resources',
'allowed_file_types' => 'Allowed file types:',
'common_codes' => 'Common codes and their meanings',
'payment_error_code_20087' => '20087: Bad Track Data (invalid CVV and/or expiry date)',
'download_selected' => 'Download selected',
'to_pay_invoices' => 'To pay invoices, you have to',
'add_payment_method_first' => 'add payment method',
'no_items_selected' => 'No items selected.',
'payment_due' => 'Payment due',
'account_balance' => 'Account Balance',
'thanks' => 'Thanks',
'minimum_required_payment' => 'Minimum required payment is :amount',
'under_payments_disabled' => 'Company doesn\'t support underpayments.',
'over_payments_disabled' => 'Company doesn\'t support overpayments.',
'saved_at' => 'Saved at :time',
'credit_payment' => 'Credit applied to Invoice :invoice_number',
'credit_subject' => 'New credit :number from :account',
'credit_message' => 'To view your credit for :amount, click the link below.',
'payment_type_Crypto' => 'Cryptocurrency',
'payment_type_Credit' => 'Credit',
'store_for_future_use' => 'Store for future use',
'pay_with_credit' => 'Pay with credit',
'payment_method_saving_failed' => 'Payment method can\'t be saved for future use.',
'pay_with' => 'Pay with',
'n/a' => 'N/A',
'by_clicking_next_you_accept_terms' => 'By clicking "Next step" you accept terms.',
'not_specified' => 'Not specified',
'before_proceeding_with_payment_warning' => 'Before proceeding with payment, you have to fill following fields',
'after_completing_go_back_to_previous_page' => 'After completing, go back to previous page.',
'pay' => 'Pay',
'instructions' => 'Instructions',
'notification_invoice_reminder1_sent_subject' => 'Reminder 1 for Invoice :invoice was sent to :client',
'notification_invoice_reminder2_sent_subject' => 'Reminder 2 for Invoice :invoice was sent to :client',
'notification_invoice_reminder3_sent_subject' => 'Reminder 3 for Invoice :invoice was sent to :client',
'notification_invoice_custom_sent_subject' => 'Custom reminder for Invoice :invoice was sent to :client',
'notification_invoice_reminder_endless_sent_subject' => 'Endless reminder for Invoice :invoice was sent to :client',
'assigned_user' => 'Assigned User',
'setup_steps_notice' => 'To proceed to next step, make sure you test each section.',
'setup_phantomjs_note' => 'Note about Phantom JS. Read more.',
'minimum_payment' => 'Minimum Payment',
'no_action_provided' => 'No action provided. If you believe this is wrong, please contact the support.',
'no_payable_invoices_selected' => 'No payable invoices selected. Make sure you are not trying to pay draft invoice or invoice with zero balance due.',
'required_payment_information' => 'Required payment details',
'required_payment_information_more' => 'To complete a payment we need more details about you.',
'required_client_info_save_label' => 'We will save this, so you don\'t have to enter it next time.',
'notification_credit_bounced' => 'We were unable to deliver Credit :invoice to :contact. \n :error',
'notification_credit_bounced_subject' => 'Unable to deliver Credit :invoice',
'save_payment_method_details' => 'Save payment method details',
'new_card' => 'New card',
'new_bank_account' => 'New bank account',
'company_limit_reached' => 'Limit of :limit companies per account.',
'credits_applied_validation' => 'Total credits applied cannot be MORE than total of invoices',
'credit_number_taken' => 'Credit number already taken',
'credit_not_found' => 'Credit not found',
'invoices_dont_match_client' => 'Selected invoices are not from a single client',
'duplicate_credits_submitted' => 'Duplicate credits submitted.',
'duplicate_invoices_submitted' => 'Duplicate invoices submitted.',
'credit_with_no_invoice' => 'You must have an invoice set when using a credit in a payment',
'client_id_required' => 'Client id is required',
'expense_number_taken' => 'Expense number already taken',
'invoice_number_taken' => 'Invoice number already taken',
'payment_id_required' => 'Payment `id` required.',
'unable_to_retrieve_payment' => 'Unable to retrieve specified payment',
'invoice_not_related_to_payment' => 'Invoice id :invoice is not related to this payment',
'credit_not_related_to_payment' => 'Credit id :credit is not related to this payment',
'max_refundable_invoice' => 'Attempting to refund more than allowed for invoice id :invoice, maximum refundable amount is :amount',
'refund_without_invoices' => 'Attempting to refund a payment with invoices attached, please specify valid invoice/s to be refunded.',
'refund_without_credits' => 'Attempting to refund a payment with credits attached, please specify valid credits/s to be refunded.',
'max_refundable_credit' => 'Attempting to refund more than allowed for credit :credit, maximum refundable amount is :amount',
'project_client_do_not_match' => 'Project client does not match entity client',
'quote_number_taken' => 'Quote number already taken',
'recurring_invoice_number_taken' => 'Recurring Invoice number :number already taken',
'user_not_associated_with_account' => 'User not associated with this account',
'amounts_do_not_balance' => 'Amounts do not balance correctly.',
'insufficient_applied_amount_remaining' => 'Insufficient applied amount remaining to cover payment.',
'insufficient_credit_balance' => 'Insufficient balance on credit.',
'one_or_more_invoices_paid' => 'One or more of these invoices have been paid',
'invoice_cannot_be_refunded' => 'Invoice id :number cannot be refunded',
'attempted_refund_failed' => 'Attempting to refund :amount only :refundable_amount available for refund',
'user_not_associated_with_this_account' => 'This user is unable to be attached to this company. Perhaps they have already registered a user on another account?',
'migration_completed' => 'Migration completed',
'migration_completed_description' => 'Your migration has completed, please review your data after logging in.',
'api_404' => '404 | Nothing to see here!',
'large_account_update_parameter' => 'Cannot load a large account without a updated_at parameter',
'no_backup_exists' => 'No backup exists for this activity',
'company_user_not_found' => 'Company User record not found',
'no_credits_found' => 'No credits found.',
'action_unavailable' => 'The requested action :action is not available.',
'no_documents_found' => 'No Documents Found',
'no_group_settings_found' => 'No group settings found',
'access_denied' => 'Insufficient privileges to access/modify this resource',
'invoice_cannot_be_marked_paid' => 'Invoice cannot be marked as paid',
'invoice_license_or_environment' => 'Invalid license, or invalid environment :environment',
'route_not_available' => 'Route not available',
'invalid_design_object' => 'Invalid custom design object',
'quote_not_found' => 'Quote/s not found',
'quote_unapprovable' => 'Unable to approve this quote as it has expired.',
'scheduler_has_run' => 'Scheduler has run',
'scheduler_has_never_run' => 'Scheduler has never run',
'self_update_not_available' => 'Self update not available on this system.',
'user_detached' => 'User detached from company',
'create_webhook_failure' => 'Failed to create Webhook',
'payment_message_extended' => 'Thank you for your payment of :amount for :invoice',
'online_payments_minimum_note' => 'Note: Online payments are supported only if amount is bigger than $1 or currency equivalent.',
'payment_token_not_found' => 'Payment token not found, please try again. If an issue still persist, try with another payment method',
'vendor_address1' => 'Vendor Street',
'vendor_address2' => 'Vendor Apt/Suite',
'partially_unapplied' => 'Partially Unapplied',
'select_a_gmail_user' => 'Please select a user authenticated with Gmail',
'list_long_press' => 'List Long Press',
'show_actions' => 'Show Actions',
'start_multiselect' => 'Start Multiselect',
'email_sent_to_confirm_email' => 'An email has been sent to confirm the email address',
'converted_paid_to_date' => 'Converted Paid to Date',
'converted_credit_balance' => 'Converted Credit Balance',
'converted_total' => 'Converted Total',
'reply_to_name' => 'Reply-To Name',
'payment_status_-2' => 'Partially Unapplied',
'color_theme' => 'Color Theme',
'start_migration' => 'Start Migration',
'recurring_cancellation_request' => 'Request for recurring invoice cancellation from :contact',
'recurring_cancellation_request_body' => ':contact from Client :client requested to cancel Recurring Invoice :invoice',
'hello' => 'Hello',
'group_documents' => 'Group documents',
'quote_approval_confirmation_label' => 'Are you sure you want to approve this quote?',
'migration_select_company_label' => 'Select companies to migrate',
'force_migration' => 'Force migration',
'require_password_with_social_login' => 'Require Password with Social Login',
'stay_logged_in' => 'Stay Logged In',
'session_about_to_expire' => 'Warning: Your session is about to expire',
'count_hours' => ':count Hours',
'count_day' => '1 Day',
'count_days' => ':count Days',
'web_session_timeout' => 'Web Session Timeout',
'security_settings' => 'Security Settings',
'resend_email' => 'Resend Email',
'confirm_your_email_address' => 'Please confirm your email address',
'freshbooks' => 'FreshBooks',
'invoice2go' => 'Invoice2go',
'invoicely' => 'Invoicely',
'waveaccounting' => 'Wave Accounting',
'zoho' => 'Zoho',
'accounting' => 'Accounting',
'required_files_missing' => 'Please provide all CSVs.',
'migration_auth_label' => 'Let\'s continue by authenticating.',
'api_secret' => 'API secret',
'migration_api_secret_notice' => 'You can find API_SECRET in the .env file or Invoice Ninja v5. If property is missing, leave field blank.',
'billing_coupon_notice' => 'Your discount will be applied on the checkout.',
'use_last_email' => 'Use last email',
'activate_company' => 'Activate Company',
'activate_company_help' => 'Enable emails, recurring invoices and notifications',
'an_error_occurred_try_again' => 'An error occurred, please try again',
'please_first_set_a_password' => 'Please first set a password',
'changing_phone_disables_two_factor' => 'Warning: Changing your phone number will disable 2FA',
'help_translate' => 'Help Translate',
'please_select_a_country' => 'Please select a country',
'disabled_two_factor' => 'Successfully disabled 2FA',
'connected_google' => 'Successfully connected account',
'disconnected_google' => 'Successfully disconnected account',
'delivered' => 'Delivered',
'spam' => 'Spam',
'view_docs' => 'View Docs',
'enter_phone_to_enable_two_factor' => 'Please provide a mobile phone number to enable two factor authentication',
'send_sms' => 'Send SMS',
'sms_code' => 'SMS Code',
'connect_google' => 'Connect Google',
'disconnect_google' => 'Disconnect Google',
'disable_two_factor' => 'Disable Two Factor',
'invoice_task_datelog' => 'Invoice Task Datelog',
'invoice_task_datelog_help' => 'Add date details to the invoice line items',
'promo_code' => 'Promo code',
'recurring_invoice_issued_to' => 'Recurring invoice issued to',
'subscription' => 'Subscription',
'new_subscription' => 'New Subscription',
'deleted_subscription' => 'Successfully deleted subscription',
'removed_subscription' => 'Successfully removed subscription',
'restored_subscription' => 'Successfully restored subscription',
'search_subscription' => 'Search 1 Subscription',
'search_subscriptions' => 'Search :count Subscriptions',
'subdomain_is_not_available' => 'Subdomain is not available',
'connect_gmail' => 'Connect Gmail',
'disconnect_gmail' => 'Disconnect Gmail',
'connected_gmail' => 'Successfully connected Gmail',
'disconnected_gmail' => 'Successfully disconnected Gmail',
'update_fail_help' => 'Changes to the codebase may be blocking the update, you can run this command to discard the changes:',
'client_id_number' => 'Client ID Number',
'count_minutes' => ':count Minutes',
'password_timeout' => 'Password Timeout',
'shared_invoice_credit_counter' => 'Share Invoice/Credit Counter',
'next' => 'Next',
'next_step' => 'Next step',
'notification_credit_sent_subject' => 'Credit :invoice was sent to :client',
'notification_credit_viewed_subject' => 'Credit :invoice was viewed by :client',
'notification_credit_sent' => 'The following client :client was emailed Credit :invoice for :amount.',
'notification_credit_viewed' => 'The following client :client viewed Credit :credit for :amount.',
'reset_password_text' => 'Enter your email to reset your password.',
'password_reset' => 'Password reset',
'account_login_text' => 'Welcome! Glad to see you.',
'request_cancellation' => 'Request cancellation',
'delete_payment_method' => 'Delete Payment Method',
'about_to_delete_payment_method' => 'You are about to delete the payment method.',
'action_cant_be_reversed' => 'Action can\'t be reversed',
'profile_updated_successfully' => 'The profile has been updated successfully.',
'currency_ethiopian_birr' => 'Ethiopian Birr',
'client_information_text' => 'Use a permanent address where you can receive mail.',
'status_id' => 'Invoice Status',
'email_already_register' => 'This email is already linked to an account',
'locations' => 'Locations',
'freq_indefinitely' => 'Indefinitely',
'cycles_remaining' => 'Cycles remaining',
'i_understand_delete' => 'I understand, delete',
'download_files' => 'Download Files',
'download_timeframe' => 'Use this link to download your files, the link will expire in 1 hour.',
'new_signup' => 'New Signup',
'new_signup_text' => 'A new account has been created by :user - :email - from IP address: :ip',
'notification_payment_paid_subject' => 'Payment was made by :client',
'notification_partial_payment_paid_subject' => 'Partial payment was made by :client',
'notification_payment_paid' => 'A payment of :amount was made by client :client towards :invoice',
'notification_partial_payment_paid' => 'A partial payment of :amount was made by client :client towards :invoice',
'notification_bot' => 'Notification Bot',
'invoice_number_placeholder' => 'Invoice # :invoice',
'entity_number_placeholder' => ':entity # :entity_number',
'email_link_not_working' => 'If the button above isn\'t working for you, please click on the link',
'display_log' => 'Display Log',
'send_fail_logs_to_our_server' => 'Report errors in realtime',
'setup' => 'Setup',
'quick_overview_statistics' => 'Quick overview & statistics',
'update_your_personal_info' => 'Update your personal information',
'name_website_logo' => 'Name, website & logo',
'make_sure_use_full_link' => 'Make sure you use full link to your site',
'personal_address' => 'Personal address',
'enter_your_personal_address' => 'Enter your personal address',
'enter_your_shipping_address' => 'Enter your shipping address',
'list_of_invoices' => 'List of invoices',
'with_selected' => 'With selected',
'invoice_still_unpaid' => 'This invoice is still not paid. Click the button to complete the payment',
'list_of_recurring_invoices' => 'List of recurring invoices',
'details_of_recurring_invoice' => 'Here are some details about recurring invoice',
'cancellation' => 'Cancellation',
'about_cancellation' => 'In case you want to stop the recurring invoice, please click to request the cancellation.',
'cancellation_warning' => 'Warning! You are requesting a cancellation of this service. Your service may be cancelled with no further notification to you.',
'cancellation_pending' => 'Cancellation pending, we\'ll be in touch!',
'list_of_payments' => 'List of payments',
'payment_details' => 'Details of the payment',
'list_of_payment_invoices' => 'List of invoices affected by the payment',
'list_of_payment_methods' => 'List of payment methods',
'payment_method_details' => 'Details of payment method',
'permanently_remove_payment_method' => 'Permanently remove this payment method.',
'warning_action_cannot_be_reversed' => 'Warning! This action can not be reversed!',
'confirmation' => 'Confirmation',
'list_of_quotes' => 'Quotes',
'waiting_for_approval' => 'Waiting for approval',
'quote_still_not_approved' => 'This quote is still not approved',
'list_of_credits' => 'Credits',
'required_extensions' => 'Required extensions',
'php_version' => 'PHP version',
'writable_env_file' => 'Writable .env file',
'env_not_writable' => '.env file is not writable by the current user.',
'minumum_php_version' => 'Minimum PHP version',
'satisfy_requirements' => 'Make sure all requirements are satisfied.',
'oops_issues' => 'Oops, something does not look right!',
'open_in_new_tab' => 'Open in new tab',
'complete_your_payment' => 'Complete payment',
'authorize_for_future_use' => 'Authorize payment method for future use',
'page' => 'Page',
'per_page' => 'Per page',
'of' => 'Of',
'view_credit' => 'View Credit',
'to_view_entity_password' => 'To view the :entity you need to enter password.',
'showing_x_of' => 'Showing :first to :last out of :total results',
'no_results' => 'No results found.',
'payment_failed_subject' => 'Payment failed for Client :client',
'payment_failed_body' => 'A payment made by client :client failed with message :message',
'register' => 'Register',
'register_label' => 'Create your account in seconds',
'password_confirmation' => 'Confirm your password',
'verification' => 'Verification',
'complete_your_bank_account_verification' => 'Before using a bank account it must be verified.',
'checkout_com' => 'Checkout.com',
'footer_label' => 'Copyright © :year :company.',
'credit_card_invalid' => 'Provided credit card number is not valid.',
'month_invalid' => 'Provided month is not valid.',
'year_invalid' => 'Provided year is not valid.',
'https_required' => 'HTTPS is required, form will fail',
'if_you_need_help' => 'If you need help you can post to our',
'update_password_on_confirm' => 'After updating password, your account will be confirmed.',
'bank_account_not_linked' => 'To pay with a bank account, first you have to add it as payment method.',
'application_settings_label' => 'Let\'s store basic information about your Invoice Ninja!',
'recommended_in_production' => 'Highly recommended in production',
'enable_only_for_development' => 'Enable only for development',
'test_pdf' => 'Test PDF',
'checkout_authorize_label' => 'Checkout.com can be can saved as payment method for future use, once you complete your first transaction. Don\'t forget to check "Store credit card details" during payment process.',
'sofort_authorize_label' => 'Bank account (SOFORT) can be can saved as payment method for future use, once you complete your first transaction. Don\'t forget to check "Store payment details" during payment process.',
'node_status' => 'Node status',
'npm_status' => 'NPM status',
'node_status_not_found' => 'I could not find Node anywhere. Is it installed?',
'npm_status_not_found' => 'I could not find NPM anywhere. Is it installed?',
'locked_invoice' => 'This invoice is locked and unable to be modified',
'downloads' => 'Downloads',
'resource' => 'Resource',
'document_details' => 'Details about the document',
'hash' => 'Hash',
'resources' => 'Resources',
'allowed_file_types' => 'Allowed file types:',
'common_codes' => 'Common codes and their meanings',
'payment_error_code_20087' => '20087: Bad Track Data (invalid CVV and/or expiry date)',
'download_selected' => 'Download selected',
'to_pay_invoices' => 'To pay invoices, you have to',
'add_payment_method_first' => 'add payment method',
'no_items_selected' => 'No items selected.',
'payment_due' => 'Payment due',
'account_balance' => 'Account Balance',
'thanks' => 'Thanks',
'minimum_required_payment' => 'Minimum required payment is :amount',
'under_payments_disabled' => 'Company doesn\'t support underpayments.',
'over_payments_disabled' => 'Company doesn\'t support overpayments.',
'saved_at' => 'Saved at :time',
'credit_payment' => 'Credit applied to Invoice :invoice_number',
'credit_subject' => 'New credit :number from :account',
'credit_message' => 'To view your credit for :amount, click the link below.',
'payment_type_Crypto' => 'Cryptocurrency',
'payment_type_Credit' => 'Credit',
'store_for_future_use' => 'Store for future use',
'pay_with_credit' => 'Pay with credit',
'payment_method_saving_failed' => 'Payment method can\'t be saved for future use.',
'pay_with' => 'Pay with',
'n/a' => 'N/A',
'by_clicking_next_you_accept_terms' => 'By clicking "Next step" you accept terms.',
'not_specified' => 'Not specified',
'before_proceeding_with_payment_warning' => 'Before proceeding with payment, you have to fill following fields',
'after_completing_go_back_to_previous_page' => 'After completing, go back to previous page.',
'pay' => 'Pay',
'instructions' => 'Instructions',
'notification_invoice_reminder1_sent_subject' => 'Reminder 1 for Invoice :invoice was sent to :client',
'notification_invoice_reminder2_sent_subject' => 'Reminder 2 for Invoice :invoice was sent to :client',
'notification_invoice_reminder3_sent_subject' => 'Reminder 3 for Invoice :invoice was sent to :client',
'notification_invoice_custom_sent_subject' => 'Custom reminder for Invoice :invoice was sent to :client',
'notification_invoice_reminder_endless_sent_subject' => 'Endless reminder for Invoice :invoice was sent to :client',
'assigned_user' => 'Assigned User',
'setup_steps_notice' => 'To proceed to next step, make sure you test each section.',
'setup_phantomjs_note' => 'Note about Phantom JS. Read more.',
'minimum_payment' => 'Minimum Payment',
'no_action_provided' => 'No action provided. If you believe this is wrong, please contact the support.',
'no_payable_invoices_selected' => 'No payable invoices selected. Make sure you are not trying to pay draft invoice or invoice with zero balance due.',
'required_payment_information' => 'Required payment details',
'required_payment_information_more' => 'To complete a payment we need more details about you.',
'required_client_info_save_label' => 'We will save this, so you don\'t have to enter it next time.',
'notification_credit_bounced' => 'We were unable to deliver Credit :invoice to :contact. \n :error',
'notification_credit_bounced_subject' => 'Unable to deliver Credit :invoice',
'save_payment_method_details' => 'Save payment method details',
'new_card' => 'New card',
'new_bank_account' => 'New bank account',
'company_limit_reached' => 'Limit of :limit companies per account.',
'credits_applied_validation' => 'Total credits applied cannot be MORE than total of invoices',
'credit_number_taken' => 'Credit number already taken',
'credit_not_found' => 'Credit not found',
'invoices_dont_match_client' => 'Selected invoices are not from a single client',
'duplicate_credits_submitted' => 'Duplicate credits submitted.',
'duplicate_invoices_submitted' => 'Duplicate invoices submitted.',
'credit_with_no_invoice' => 'You must have an invoice set when using a credit in a payment',
'client_id_required' => 'Client id is required',
'expense_number_taken' => 'Expense number already taken',
'invoice_number_taken' => 'Invoice number already taken',
'payment_id_required' => 'Payment `id` required.',
'unable_to_retrieve_payment' => 'Unable to retrieve specified payment',
'invoice_not_related_to_payment' => 'Invoice id :invoice is not related to this payment',
'credit_not_related_to_payment' => 'Credit id :credit is not related to this payment',
'max_refundable_invoice' => 'Attempting to refund more than allowed for invoice id :invoice, maximum refundable amount is :amount',
'refund_without_invoices' => 'Attempting to refund a payment with invoices attached, please specify valid invoice/s to be refunded.',
'refund_without_credits' => 'Attempting to refund a payment with credits attached, please specify valid credits/s to be refunded.',
'max_refundable_credit' => 'Attempting to refund more than allowed for credit :credit, maximum refundable amount is :amount',
'project_client_do_not_match' => 'Project client does not match entity client',
'quote_number_taken' => 'Quote number already taken',
'recurring_invoice_number_taken' => 'Recurring Invoice number :number already taken',
'user_not_associated_with_account' => 'User not associated with this account',
'amounts_do_not_balance' => 'Amounts do not balance correctly.',
'insufficient_applied_amount_remaining' => 'Insufficient applied amount remaining to cover payment.',
'insufficient_credit_balance' => 'Insufficient balance on credit.',
'one_or_more_invoices_paid' => 'One or more of these invoices have been paid',
'invoice_cannot_be_refunded' => 'Invoice id :number cannot be refunded',
'attempted_refund_failed' => 'Attempting to refund :amount only :refundable_amount available for refund',
'user_not_associated_with_this_account' => 'This user is unable to be attached to this company. Perhaps they have already registered a user on another account?',
'migration_completed' => 'Migration completed',
'migration_completed_description' => 'Your migration has completed, please review your data after logging in.',
'api_404' => '404 | Nothing to see here!',
'large_account_update_parameter' => 'Cannot load a large account without a updated_at parameter',
'no_backup_exists' => 'No backup exists for this activity',
'company_user_not_found' => 'Company User record not found',
'no_credits_found' => 'No credits found.',
'action_unavailable' => 'The requested action :action is not available.',
'no_documents_found' => 'No Documents Found',
'no_group_settings_found' => 'No group settings found',
'access_denied' => 'Insufficient privileges to access/modify this resource',
'invoice_cannot_be_marked_paid' => 'Invoice cannot be marked as paid',
'invoice_license_or_environment' => 'Invalid license, or invalid environment :environment',
'route_not_available' => 'Route not available',
'invalid_design_object' => 'Invalid custom design object',
'quote_not_found' => 'Quote/s not found',
'quote_unapprovable' => 'Unable to approve this quote as it has expired.',
'scheduler_has_run' => 'Scheduler has run',
'scheduler_has_never_run' => 'Scheduler has never run',
'self_update_not_available' => 'Self update not available on this system.',
'user_detached' => 'User detached from company',
'create_webhook_failure' => 'Failed to create Webhook',
'payment_message_extended' => 'Thank you for your payment of :amount for :invoice',
'online_payments_minimum_note' => 'Note: Online payments are supported only if amount is bigger than $1 or currency equivalent.',
'payment_token_not_found' => 'Payment token not found, please try again. If an issue still persist, try with another payment method',
'vendor_address1' => 'Vendor Street',
'vendor_address2' => 'Vendor Apt/Suite',
'partially_unapplied' => 'Partially Unapplied',
'select_a_gmail_user' => 'Please select a user authenticated with Gmail',
'list_long_press' => 'List Long Press',
'show_actions' => 'Show Actions',
'start_multiselect' => 'Start Multiselect',
'email_sent_to_confirm_email' => 'An email has been sent to confirm the email address',
'converted_paid_to_date' => 'Converted Paid to Date',
'converted_credit_balance' => 'Converted Credit Balance',
'converted_total' => 'Converted Total',
'reply_to_name' => 'Reply-To Name',
'payment_status_-2' => 'Partially Unapplied',
'color_theme' => 'Color Theme',
'start_migration' => 'Start Migration',
'recurring_cancellation_request' => 'Request for recurring invoice cancellation from :contact',
'recurring_cancellation_request_body' => ':contact from Client :client requested to cancel Recurring Invoice :invoice',
'hello' => 'Hello',
'group_documents' => 'Group documents',
'quote_approval_confirmation_label' => 'Are you sure you want to approve this quote?',
'migration_select_company_label' => 'Select companies to migrate',
'force_migration' => 'Force migration',
'require_password_with_social_login' => 'Require Password with Social Login',
'stay_logged_in' => 'Stay Logged In',
'session_about_to_expire' => 'Warning: Your session is about to expire',
'count_hours' => ':count Hours',
'count_day' => '1 Day',
'count_days' => ':count Days',
'web_session_timeout' => 'Web Session Timeout',
'security_settings' => 'Security Settings',
'resend_email' => 'Resend Email',
'confirm_your_email_address' => 'Please confirm your email address',
'freshbooks' => 'FreshBooks',
'invoice2go' => 'Invoice2go',
'invoicely' => 'Invoicely',
'waveaccounting' => 'Wave Accounting',
'zoho' => 'Zoho',
'accounting' => 'Accounting',
'required_files_missing' => 'Please provide all CSVs.',
'migration_auth_label' => 'Let\'s continue by authenticating.',
'api_secret' => 'API secret',
'migration_api_secret_notice' => 'You can find API_SECRET in the .env file or Invoice Ninja v5. If property is missing, leave field blank.',
'billing_coupon_notice' => 'Your discount will be applied on the checkout.',
'use_last_email' => 'Use last email',
'activate_company' => 'Activate Company',
'activate_company_help' => 'Enable emails, recurring invoices and notifications',
'an_error_occurred_try_again' => 'An error occurred, please try again',
'please_first_set_a_password' => 'Please first set a password',
'changing_phone_disables_two_factor' => 'Warning: Changing your phone number will disable 2FA',
'help_translate' => 'Help Translate',
'please_select_a_country' => 'Please select a country',
'disabled_two_factor' => 'Successfully disabled 2FA',
'connected_google' => 'Successfully connected account',
'disconnected_google' => 'Successfully disconnected account',
'delivered' => 'Delivered',
'spam' => 'Spam',
'view_docs' => 'View Docs',
'enter_phone_to_enable_two_factor' => 'Please provide a mobile phone number to enable two factor authentication',
'send_sms' => 'Send SMS',
'sms_code' => 'SMS Code',
'connect_google' => 'Connect Google',
'disconnect_google' => 'Disconnect Google',
'disable_two_factor' => 'Disable Two Factor',
'invoice_task_datelog' => 'Invoice Task Datelog',
'invoice_task_datelog_help' => 'Add date details to the invoice line items',
'promo_code' => 'Promo code',
'recurring_invoice_issued_to' => 'Recurring invoice issued to',
'subscription' => 'Subscription',
'new_subscription' => 'New Subscription',
'deleted_subscription' => 'Successfully deleted subscription',
'removed_subscription' => 'Successfully removed subscription',
'restored_subscription' => 'Successfully restored subscription',
'search_subscription' => 'Search 1 Subscription',
'search_subscriptions' => 'Search :count Subscriptions',
'subdomain_is_not_available' => 'Subdomain is not available',
'connect_gmail' => 'Connect Gmail',
'disconnect_gmail' => 'Disconnect Gmail',
'connected_gmail' => 'Successfully connected Gmail',
'disconnected_gmail' => 'Successfully disconnected Gmail',
'update_fail_help' => 'Changes to the codebase may be blocking the update, you can run this command to discard the changes:',
'client_id_number' => 'Client ID Number',
'count_minutes' => ':count Minutes',
'password_timeout' => 'Password Timeout',
'shared_invoice_credit_counter' => 'Share Invoice/Credit Counter',
'activity_80' => ':user created subscription :subscription',
'activity_81' => ':user updated subscription :subscription',
'activity_82' => ':user archived subscription :subscription',
@ -5227,6 +5227,27 @@ $lang = array(
'primary_contact' => 'Primary Contact',
'all_contacts' => 'All Contacts',
'insert_below' => 'Insert Below',
'nordigen_handler_subtitle' => 'will gain access for your selected bank account. After selecting your institution you are redirected to theire front-page to complete the request with your account credentials.',
'nordigen_handler_error_heading_unknown' => 'An Error has occured',
'nordigen_handler_error_contents_unknown' => 'An unknown Error has occured! Reason:',
'nordigen_handler_error_heading_token_invalid' => 'Invalid Token',
'nordigen_handler_error_contents_token_invalid' => 'The provided token was invalid. Please restart the flow, with a valid one_time_token. Contact support for help, if this issue persists.',
'nordigen_handler_error_heading_account_config_invalid' => 'Missing Credentials',
'nordigen_handler_error_contents_account_config_invalid' => 'The provided credentials for nordigen are eighter missing or invalid. Contact support for help, if this issue persists.',
'nordigen_handler_error_heading_not_available' => 'Not Available',
'nordigen_handler_error_contents_not_available' => 'This flow is not available for your account. Considder upgrading to enterprise version. Contact support for help, if this issue persists.',
'nordigen_handler_error_heading_institution_invalid' => 'Invalid Institution',
'nordigen_handler_error_contents_institution_invalid' => 'The provided institution-id is invalid or no longer valid. You can go to the bank selection page by clicking the button below or cancel the flow by clicking on the \'X\' above.',
'nordigen_handler_error_heading_ref_invalid' => 'Invalid Reference',
'nordigen_handler_error_contents_ref_invalid' => 'Nordigen did not provide a valid reference. Please run flow again and contact support, if this issue persists.',
'nordigen_handler_error_heading_not_found' => 'Invalid Requisition',
'nordigen_handler_error_contents_not_found' => 'Nordigen did not provide a valid reference. Please run flow again and contact support, if this issue persists.',
'nordigen_handler_error_heading_requisition_invalid_status' => 'Not Ready',
'nordigen_handler_error_contents_requisition_invalid_status' => 'You may called this site to early. Please finish authorization and refresh this page. Contact support for help, if this issue persists.',
'nordigen_handler_error_heading_requisition_no_accounts' => 'No Accounts selected',
'nordigen_handler_error_contents_requisition_no_accounts' => 'The service has not returned any valid accounts. Considder restarting the flow.',
'nordigen_handler_restart' => 'Restart flow.',
'nordigen_handler_return' => 'Return to application.',
);
return $lang;

View File

@ -0,0 +1,135 @@
@extends('layouts.ninja')
@section('meta_title', ctrans('texts.new_bank_account'))
@push('head')
<link href="https://unpkg.com/nordigen-bank-ui@1.5.2/package/src/selector.min.css" rel="stylesheet" />
@endpush
@section('body')
<div id="institution-content-wrapper"></div>
@endsection
@push('footer')
<script type='text/javascript' src='https://unpkg.com/nordigen-bank-ui@1.5.2/package/src/selector.min.js'></script>
<script>
// Pass your redirect link after user has been authorized in institution
const config = {
// Redirect URL that is being used when modal is being closed.
redirectUrl: "{{ $redirectUrl }}" || new URL("", window.location.origin).href,
// Text that will be displayed on the left side under the logo. Text is limited to 100 characters, and rest will be truncated. @turbo124 replace with a translated version like ctrans()
text: "{{ ($account ?? false) && !$account->isPaid() ? 'Invoice Ninja' : (isset($company) && !is_null($company) ? $company->name : 'Invoice Ninja') }} {{ ctrans('texts.nordigen_handler_subtitle', [], $lang ?? 'en') }}",
// 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')) }}",
// Will display country list with corresponding institutions. When `countryFilter` is set to `false`, only list of institutions will be shown.
countryFilter: false,
// style configs
styles: {
// Primary
// Link to google font
fontFamily: new URL("assets/fonts/Roboto-Regular.ttf", window.location.origin).href,
fontSize: '15',
backgroundColor: '#F2F2F2',
textColor: '#222',
headingColor: '#222',
linkColor: '#8d9090',
// Modal
modalTextColor: '#1B2021',
modalBackgroundColor: '#fff',
// Button
buttonColor: '#3A53EE',
buttonTextColor: '#fff'
}
};
const failedReason = "{{ $failed_reason ?? '' }}".trim();
new institutionSelector(@json($institutions ?? []), 'institution-modal-content', config);
if (!failedReason) {
const institutionList = Array.from(document.querySelectorAll('.ob-list-institution > a'));
institutionList.forEach((institution) => {
institution.addEventListener('click', (e) => {
e.preventDefault()
const institutionId = institution.getAttribute('data-institution');
const url = new URL(window.location.href);
url.searchParams.set('institution_id', institutionId);
window.location.href = url.href;
});
});
} else {
document.getElementsByClassName("institution-search-container")[0].remove();
document.getElementsByClassName("institution-container")[0].remove();
const heading = document.querySelectorAll('h2')[0];
const wrapper = document.getElementById("institution-modal-content");
const contents = document.createElement("div");
contents.id = "failed-container";
contents.className = "mt-2";
contents.style["font-size"] = "80%";
contents.style["opacity"] = "80%";
let restartFlow = false; // return, restart, refresh
heading.innerHTML = "{{ ctrans('texts.nordigen_handler_error_heading_unknown', [], $lang ?? 'en') }}";
contents.innerHTML = "{{ ctrans('texts.nordigen_handler_error_contents_unknown', [], $lang ?? 'en') }} " + failedReason;
switch (failedReason) {
// Connect Screen Errors
case "token-invalid":
heading.innerHTML = "{{ ctrans('texts.nordigen_handler_error_heading_token_invalid', [], $lang ?? 'en') }}";
contents.innerHTML = "{{ ctrans('texts.nordigen_handler_error_contents_token_invalid', [], $lang ?? 'en') }}";
break;
case "account-config-invalid":
heading.innerHTML = "{{ ctrans('texts.nordigen_handler_error_heading_account_config_invalid', [], $lang ?? 'en') }}";
contents.innerHTML = "{{ ctrans('texts.nordigen_handler_error_contents_account_config_invalid', [], $lang ?? 'en') }}";
break;
case "not-available":
heading.innerHTML = "{{ ctrans('texts.nordigen_handler_error_heading_not_available', [], $lang ?? 'en') }}";
contents.innerHTML = "{{ ctrans('texts.nordigen_handler_error_contents_not_available', [], $lang ?? 'en') }}";
break;
case "institution-invalid":
restartFlow = true;
heading.innerHTML = "{{ ctrans('texts.nordigen_handler_error_heading_institution_invalid', [], $lang ?? 'en') }}";
contents.innerHTML = "{{ ctrans('texts.nordigen_handler_error_contents_institution_invalid', [], $lang ?? 'en') }}";
break;
// Confirm Screen Errors
case "ref-invalid":
heading.innerHTML = "{{ ctrans('texts.nordigen_handler_error_heading_ref_invalid', [], $lang ?? 'en') }}";
contents.innerHTML = "{{ ctrans('texts.nordigen_handler_error_contents_ref_invalid', [], $lang ?? 'en') }}";
break;
case "requisition-not-found":
heading.innerHTML = "{{ ctrans('texts.nordigen_handler_error_heading_not_found', [], $lang ?? 'en') }}";
contents.innerHTML = "{{ ctrans('texts.nordigen_handler_error_contents_not_found', [], $lang ?? 'en') }}";
break;
case "requisition-invalid-status":
heading.innerHTML = "{{ ctrans('texts.nordigen_handler_error_heading_requisition_invalid_status', [], $lang ?? 'en') }}";
contents.innerHTML = "{{ ctrans('texts.nordigen_handler_error_contents_requisition_invalid_status', [], $lang ?? 'en') }}";
break;
case "requisition-no-accounts":
heading.innerHTML = "{{ ctrans('texts.nordigen_handler_error_heading_requisition_no_accounts', [], $lang ?? 'en') }}";
contents.innerHTML = "{{ ctrans('texts.nordigen_handler_error_contents_requisition_no_accounts', [], $lang ?? 'en') }}";
break;
case "unknown":
break;
default:
console.warn('Invalid or missing failed_reason code: ' + failedReason);
break;
}
wrapper.appendChild(contents);
const restartUrl = new URL(window.location.pathname, window.location.origin); // no searchParams
const returnButton = document.createElement('div');
returnButton.className = "mt-4";
returnButton.innerHTML = `<a class="button button-primary bg-blue-600 my-4" href="${restartFlow ? restartUrl.href : config.redirectUrl}">${restartFlow ? "{{ ctrans('texts.nordigen_handler_restart', [], $lang ?? 'en') }}" : "{{ ctrans('texts.nordigen_handler_return', [], $lang ?? 'en') }}"}</a>`
wrapper.appendChild(returnButton);
}
</script>
@endpush

View File

@ -15,6 +15,7 @@ use App\Http\Controllers\ActivityController;
use App\Http\Controllers\Auth\ForgotPasswordController;
use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\Auth\PasswordTimeoutController;
use App\Http\Controllers\Bank\NordigenController;
use App\Http\Controllers\Bank\YodleeController;
use App\Http\Controllers\BankIntegrationController;
use App\Http\Controllers\BankTransactionController;
@ -121,7 +122,7 @@ Route::group(['middleware' => ['throttle:api', 'api_secret_check']], function ()
Route::post('api/v1/oauth_login', [LoginController::class, 'oauthApiLogin']);
});
Route::group(['middleware' => ['throttle:login','api_secret_check','email_db']], function () {
Route::group(['middleware' => ['throttle:login', 'api_secret_check', 'email_db']], function () {
Route::post('api/v1/login', [LoginController::class, 'apiLogin'])->name('login.submit');
Route::post('api/v1/reset_password', [ForgotPasswordController::class, 'sendResetLinkEmail']);
});
@ -325,7 +326,7 @@ Route::group(['middleware' => ['throttle:api', 'api_db', 'token_auth', 'locale']
Route::post('reports/user_sales_report', UserSalesReportController::class);
Route::post('reports/preview/{hash}', ReportPreviewController::class);
Route::post('exports/preview/{hash}', ReportExportController::class);
Route::post('templates/preview/{hash}', TemplatePreviewController::class);
Route::post('search', SearchController::class);
@ -400,7 +401,9 @@ Route::group(['middleware' => ['throttle:api', 'api_db', 'token_auth', 'locale']
Route::get('statics', StaticController::class);
// Route::post('apple_pay/upload_file','ApplyPayController::class, 'upload');
Route::post('api/v1/yodlee/status/{account_number}', [YodleeController::class, 'accountStatus']);
Route::post('yodlee/status/{account_number}', [YodleeController::class, 'accountStatus']); // @todo @turbo124 check route-path?!
Route::get('nordigen/institutions', [NordigenController::class, 'institutions'])->name('nordigen.institutions');
});
Route::post('api/v1/sms_reset', [TwilioController::class, 'generate2faResetCode'])->name('sms_reset.generate')->middleware('throttle:10,1');
@ -421,6 +424,7 @@ Route::post('api/v1/get_migration_account', [HostedMigrationController::class, '
Route::post('api/v1/confirm_forwarding', [HostedMigrationController::class, 'confirmForwarding'])->middleware('guest')->middleware('throttle:100,1');
Route::post('api/v1/process_webhook', [AppleController::class, 'process_webhook'])->middleware('throttle:1000,1');
Route::post('api/v1/confirm_purchase', [AppleController::class, 'confirm_purchase'])->middleware('throttle:1000,1');
Route::post('api/v1/yodlee/refresh', [YodleeController::class, 'refreshWebhook'])->middleware('throttle:100,1');
Route::post('api/v1/yodlee/data_updates', [YodleeController::class, 'dataUpdatesWebhook'])->middleware('throttle:100,1');
Route::post('api/v1/yodlee/refresh_updates', [YodleeController::class, 'refreshUpdatesWebhook'])->middleware('throttle:100,1');

View File

@ -3,6 +3,7 @@
use App\Http\Controllers\Auth\ForgotPasswordController;
use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\Auth\ResetPasswordController;
use App\Http\Controllers\Bank\NordigenController;
use App\Http\Controllers\Bank\YodleeController;
use App\Http\Controllers\BaseController;
use App\Http\Controllers\ClientPortal\ApplePayDomainController;
@ -55,6 +56,9 @@ Route::get('stripe/completed', [StripeConnectController::class, 'completed'])->n
Route::get('yodlee/onboard/{token}', [YodleeController::class, 'auth'])->name('yodlee.auth');
Route::get('nordigen/connect/{token}', [NordigenController::class, 'connect'])->name('nordigen.connect');
Route::any('nordigen/confirm', [NordigenController::class, 'confirm'])->name('nordigen.confirm');
Route::get('checkout/3ds_redirect/{company_key}/{company_gateway_id}/{hash}', [Checkout3dsController::class, 'index'])->middleware('domain_db')->name('checkout.3ds_redirect');
Route::get('mollie/3ds_redirect/{company_key}/{company_gateway_id}/{hash}', [Mollie3dsController::class, 'index'])->middleware('domain_db')->name('mollie.3ds_redirect');
Route::get('gocardless/ibp_redirect/{company_key}/{company_gateway_id}/{hash}', [GoCardlessController::class, 'ibpRedirect'])->middleware('domain_db')->name('gocardless.ibp_redirect');

View File

@ -17,7 +17,7 @@ use App\Factory\BankTransactionFactory;
use App\Helpers\Bank\Yodlee\Yodlee;
use App\Helpers\Invoice\InvoiceSum;
use App\Jobs\Bank\MatchBankTransactions;
use App\Jobs\Bank\ProcessBankTransactions;
use App\Jobs\Bank\ProcessBankTransactionsYodlee;
use App\Models\BankIntegration;
use App\Models\BankTransaction;
use App\Models\Expense;
@ -70,7 +70,7 @@ class YodleeApiTest extends TestCase
$expense = Expense::where('transaction_reference', 'Fuel')->first();
$this->assertNotNull($expense);
$this->assertEquals(10, (int)$expense->amount);
$this->assertEquals(10, (int) $expense->amount);
}
public function testIncomeMatchingAndPaymentGeneration()
@ -114,7 +114,7 @@ class YodleeApiTest extends TestCase
$bt->date = now()->format('Y-m-d');
$bt->transaction_id = 123456;
$bt->save();
$data['transactions'][] = [
'id' => $bt->id,
'invoice_ids' => $invoice->hashed_id
@ -127,7 +127,7 @@ class YodleeApiTest extends TestCase
$this->assertNotNull($payment);
$this->assertEquals(10, (int)$payment->amount);
$this->assertEquals(10, (int) $payment->amount);
$this->assertEquals(4, $payment->status_id);
$this->assertEquals(1, $payment->invoices()->count());
@ -182,7 +182,7 @@ class YodleeApiTest extends TestCase
// $bank_integration->nickname = $account['nickname'];
// $bank_integration->balance = $account['current_balance'];
// $bank_integration->currency = $account['account_currency'];
// $bank_integration->save();
// ProcessBankTransactions::dispatchSync('sbMem62e1e69547bfb1', $bank_integration);
@ -198,7 +198,7 @@ class YodleeApiTest extends TestCase
// $this->invoice->save();
// BankService::dispatchSync($this->company->id, $this->company->db);
// $bt = BankTransaction::where('invoice_ids', $this->invoice->hashed_id)->first();
// nlog(BankTransaction::where('company_id', $this->company->id)->pluck('invoice_ids'));
@ -213,10 +213,10 @@ class YodleeApiTest extends TestCase
public function testDataMatching()
{
$transaction = collect([
(object)[
(object) [
'description' => 'tinkertonkton'
],
(object)[
(object) [
'description' => 'spud'
],
]);
@ -237,10 +237,10 @@ class YodleeApiTest extends TestCase
$transaction = collect([
(object)[
(object) [
'description' => 'tinker and spice'
],
(object)[
(object) [
'description' => 'spud with water'
],
]);
@ -455,7 +455,7 @@ class YodleeApiTest extends TestCase
$yodlee = new Yodlee('sbMem62e1e69547bfb2');
$transactions = $yodlee->getTransactionCategories();
$this->assertIsArray($transactions->transactionCategory);
}