Merge pull request #10045 from turbo124/v5-develop

Fixes for Rotessa
This commit is contained in:
David Bomba 2024-09-26 12:04:43 +10:00 committed by GitHub
commit f184f38819
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
90 changed files with 3575 additions and 1223 deletions

View File

@ -0,0 +1,41 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Casts;
use App\DataMapper\ClientSync;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
class ClientSyncCast implements CastsAttributes
{
public function get($model, string $key, $value, array $attributes)
{
$data = json_decode($value, true);
if (!is_array($data)) {
return null;
}
$is = new ClientSync();
$is->qb_id = $data['qb_id'];
return $is;
}
public function set($model, string $key, $value, array $attributes)
{
return [
$key => json_encode([
'qb_id' => $value->qb_id,
])
];
}
}

View File

@ -4,7 +4,7 @@
* *
* @link https://github.com/invoiceninja/invoiceninja source repository * @link https://github.com/invoiceninja/invoiceninja source repository
* *
* @copyright Copyright (c) 2021. Invoice Ninja LLC (https://invoiceninja.com) * @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
* *
* @license https://www.elastic.co/licensing/elastic-license * @license https://www.elastic.co/licensing/elastic-license
*/ */

View File

@ -0,0 +1,41 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Casts;
use App\DataMapper\InvoiceSync;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
class InvoiceSyncCast implements CastsAttributes
{
public function get($model, string $key, $value, array $attributes)
{
$data = json_decode($value, true);
if (!is_array($data)) {
return null;
}
$is = new InvoiceSync();
$is->qb_id = $data['qb_id'];
return $is;
}
public function set($model, string $key, $value, array $attributes)
{
return [
$key => json_encode([
'qb_id' => $value->qb_id,
])
];
}
}

View File

@ -0,0 +1,41 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Casts;
use App\DataMapper\ProductSync;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
class ProductSyncCast implements CastsAttributes
{
public function get($model, string $key, $value, array $attributes)
{
$data = json_decode($value, true);
if (!is_array($data)) {
return null;
}
$ps = new ProductSync();
$ps->qb_id = $data['qb_id'];
return $ps;
}
public function set($model, string $key, $value, array $attributes)
{
return [
$key => json_encode([
'qb_id' => $value->qb_id,
])
];
}
}

View File

@ -4,7 +4,7 @@
* *
* @link https://github.com/invoiceninja/invoiceninja source repository * @link https://github.com/invoiceninja/invoiceninja source repository
* *
* @copyright Copyright (c) 2021. Invoice Ninja LLC (https://invoiceninja.com) * @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
* *
* @license https://www.elastic.co/licensing/elastic-license * @license https://www.elastic.co/licensing/elastic-license
*/ */
@ -18,33 +18,19 @@ class QuickbooksSettingsCast implements CastsAttributes
{ {
public function get($model, string $key, $value, array $attributes) public function get($model, string $key, $value, array $attributes)
{ {
if (is_null($value))
return new QuickbooksSettings();
$data = json_decode($value, true); $data = json_decode($value, true);
return QuickbooksSettings::fromArray($data);
if(!is_array($data))
return null;
$qb = new QuickbooksSettings();
$qb->accessTokenKey = $data['accessTokenKey'];
$qb->refresh_token = $data['refresh_token'];
$qb->realmID = $data['realmID'];
$qb->accessTokenExpiresAt = $data['accessTokenExpiresAt'];
$qb->refreshTokenExpiresAt = $data['refreshTokenExpiresAt'];
$qb->settings = $data['settings'] ?? [];
return $qb;
} }
public function set($model, string $key, $value, array $attributes) public function set($model, string $key, $value, array $attributes)
{ {
return [ if ($value instanceof QuickbooksSettings) {
$key => json_encode([ return json_encode(get_object_vars($value));
'accessTokenKey' => $value->accessTokenKey, }
'refresh_token' => $value->refresh_token,
'realmID' => $value->realmID, return json_encode($value);
'accessTokenExpiresAt' => $value->accessTokenExpiresAt,
'refreshTokenExpiresAt' => $value->refreshTokenExpiresAt,
'settings' => $value->settings,
])
];
} }
} }

View File

@ -44,6 +44,7 @@ use App\Factory\ClientContactFactory;
use App\Factory\VendorContactFactory; use App\Factory\VendorContactFactory;
use App\Jobs\Company\CreateCompanyToken; use App\Jobs\Company\CreateCompanyToken;
use App\Models\RecurringInvoiceInvitation; use App\Models\RecurringInvoiceInvitation;
use App\Utils\Traits\CleanLineItems;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
/* /*
@ -80,10 +81,12 @@ Options:
*/ */
class CheckData extends Command class CheckData extends Command
{ {
use CleanLineItems;
/** /**
* @var string * @var string
*/ */
protected $signature = 'ninja:check-data {--database=} {--fix=} {--portal_url=} {--client_id=} {--vendor_id=} {--paid_to_date=} {--client_balance=} {--ledger_balance=} {--balance_status=} {--bank_transaction=}'; protected $signature = 'ninja:check-data {--database=} {--fix=} {--portal_url=} {--client_id=} {--vendor_id=} {--paid_to_date=} {--client_balance=} {--ledger_balance=} {--balance_status=} {--bank_transaction=} {--line_items=}';
/** /**
* @var string * @var string
@ -146,6 +149,10 @@ class CheckData extends Command
$this->fixBankTransactions(); $this->fixBankTransactions();
} }
if($this->option('line_items')) {
$this->cleanInvoiceLineItems();
}
$this->logMessage('Done: '.strtoupper($this->isValid ? Account::RESULT_SUCCESS : Account::RESULT_FAILURE)); $this->logMessage('Done: '.strtoupper($this->isValid ? Account::RESULT_SUCCESS : Account::RESULT_FAILURE));
$this->logMessage('Total execution time in seconds: ' . (microtime(true) - $time_start)); $this->logMessage('Total execution time in seconds: ' . (microtime(true) - $time_start));
@ -1177,4 +1184,21 @@ class CheckData extends Command
}); });
} }
public function cleanInvoiceLineItems()
{
Invoice::withTrashed()->cursor()->each(function ($invoice) {
$invoice->line_items = $this->cleanItems($invoice->line_items);
$invoice->saveQuietly();
});
Credit::withTrashed()->cursor()->each(function ($invoice) {
$invoice->line_items = $this->cleanItems($invoice->line_items);
$invoice->saveQuietly();
});
}
} }

View File

@ -0,0 +1,42 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\DataMapper;
use App\Casts\ClientSyncCast;
use Illuminate\Contracts\Database\Eloquent\Castable;
/**
* ClientSync.
*/
class ClientSync implements Castable
{
public string $qb_id;
public function __construct(array $attributes = [])
{
$this->qb_id = $attributes['qb_id'] ?? '';
}
/**
* Get the name of the caster class to use when casting from / to this cast target.
*
* @param array<string, mixed> $arguments
*/
public static function castUsing(array $arguments): string
{
return ClientSyncCast::class;
}
public static function fromArray(array $data): self
{
return new self($data);
}
}

View File

@ -0,0 +1,43 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\DataMapper;
use App\Casts\InvoiceSyncCast;
use Illuminate\Contracts\Database\Eloquent\Castable;
/**
* InvoiceSync.
*/
class InvoiceSync implements Castable
{
public string $qb_id;
public function __construct(array $attributes = [])
{
$this->qb_id = $attributes['qb_id'] ?? '';
}
/**
* Get the name of the caster class to use when casting from / to this cast target.
*
* @param array<string, mixed> $arguments
*/
public static function castUsing(array $arguments): string
{
return InvoiceSyncCast::class;
}
public static function fromArray(array $data): self
{
return new self($data);
}
}

View File

@ -0,0 +1,43 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\DataMapper;
use App\Casts\ProductSyncCast;
use Illuminate\Contracts\Database\Eloquent\Castable;
/**
* ProductSync.
*/
class ProductSync implements Castable
{
public string $qb_id;
public function __construct(array $attributes = [])
{
$this->qb_id = $attributes['qb_id'] ?? '';
}
/**
* Get the name of the caster class to use when casting from / to this cast target.
*
* @param array<string, mixed> $arguments
*/
public static function castUsing(array $arguments): string
{
return ProductSyncCast::class;
}
public static function fromArray(array $data): self
{
return new self($data);
}
}

View File

@ -30,34 +30,28 @@ class QuickbooksSettings implements Castable
public int $refreshTokenExpiresAt; public int $refreshTokenExpiresAt;
public string $baseURL; public string $baseURL;
/**
* entity client,invoice,quote,purchase_order,vendor,payment public QuickbooksSync $settings;
* sync true/false
* update_record true/false
* direction push/pull/birectional
* */
public array $settings = [
'client' => ['sync' => true, 'update_record' => true, 'direction' => 'bidirectional'],
'vendor' => ['sync' => true, 'update_record' => true, 'direction' => 'bidirectional'],
'invoice' => ['sync' => true, 'update_record' => true, 'direction' => 'bidirectional'],
'sales' => ['sync' => true, 'update_record' => true, 'direction' => 'bidirectional'],
'quote' => ['sync' => true, 'update_record' => true, 'direction' => 'bidirectional'],
'purchase_order' => ['sync' => true, 'update_record' => true, 'direction' => 'bidirectional'],
'product' => ['sync' => true, 'update_record' => true, 'direction' => 'bidirectional'],
'payment' => ['sync' => true, 'update_record' => true, 'direction' => 'bidirectional'],
'vendor' => ['sync' => true, 'update_record' => true, 'direction' => 'bidirectional'],
'expense' => ['sync' => true, 'update_record' => true, 'direction' => 'bidirectional'],
];
public function __construct(array $attributes = [])
{
$this->accessTokenKey = $attributes['accessTokenKey'] ?? '';
$this->refresh_token = $attributes['refresh_token'] ?? '';
$this->realmID = $attributes['realmID'] ?? '';
$this->accessTokenExpiresAt = $attributes['accessTokenExpiresAt'] ?? 0;
$this->refreshTokenExpiresAt = $attributes['refreshTokenExpiresAt'] ?? 0;
$this->baseURL = $attributes['baseURL'] ?? '';
$this->settings = new QuickbooksSync($attributes['settings'] ?? []);
}
/**
* Get the name of the caster class to use when casting from / to this cast target.
*
* @param array<string, mixed> $arguments
*/
public static function castUsing(array $arguments): string public static function castUsing(array $arguments): string
{ {
return QuickbooksSettingsCast::class; return QuickbooksSettingsCast::class;
} }
public static function fromArray(array $data): self
{
return new self($data);
}
} }

View File

@ -0,0 +1,55 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\DataMapper;
/**
* QuickbooksSync.
*/
class QuickbooksSync
{
public QuickbooksSyncMap $client;
public QuickbooksSyncMap $vendor;
public QuickbooksSyncMap $invoice;
public QuickbooksSyncMap $sales;
public QuickbooksSyncMap $quote;
public QuickbooksSyncMap $purchase_order;
public QuickbooksSyncMap $product;
public QuickbooksSyncMap $payment;
public QuickbooksSyncMap $expense;
public string $default_income_account = '';
public string $default_expense_account = '';
public function __construct(array $attributes = [])
{
$this->client = new QuickbooksSyncMap($attributes['client'] ?? []);
$this->vendor = new QuickbooksSyncMap($attributes['vendor'] ?? []);
$this->invoice = new QuickbooksSyncMap($attributes['invoice'] ?? []);
$this->sales = new QuickbooksSyncMap($attributes['sales'] ?? []);
$this->quote = new QuickbooksSyncMap($attributes['quote'] ?? []);
$this->purchase_order = new QuickbooksSyncMap($attributes['purchase_order'] ?? []);
$this->product = new QuickbooksSyncMap($attributes['product'] ?? []);
$this->payment = new QuickbooksSyncMap($attributes['payment'] ?? []);
$this->expense = new QuickbooksSyncMap($attributes['expense'] ?? []);
$this->default_income_account = $attributes['default_income_account'] ?? '';
$this->default_expense_account = $attributes['default_expense_account'] ?? '';
}
}

View File

@ -0,0 +1,30 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\DataMapper;
use App\Enum\SyncDirection;
/**
* QuickbooksSyncMap.
*/
class QuickbooksSyncMap
{
public SyncDirection $direction = SyncDirection::BIDIRECTIONAL;
public function __construct(array $attributes = [])
{
$this->direction = isset($attributes['direction'])
? SyncDirection::from($attributes['direction'])
: SyncDirection::BIDIRECTIONAL;
}
}

View File

@ -35,6 +35,10 @@ class TaxModel
$this->regions = $this->init(); $this->regions = $this->init();
} else { } else {
if(!$model->seller_subregion) {
$this->seller_subregion = '';
}
//@phpstan-ignore-next-line //@phpstan-ignore-next-line
foreach($model as $key => $value) { foreach($model as $key => $value) {
$this->{$key} = $value; $this->{$key} = $value;

View File

@ -0,0 +1,19 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Enum;
enum SyncDirection: string
{
case PUSH = 'push'; // only creates and updates records created by Invoice Ninja.
case PULL = 'pull'; // creates and updates record from QB.
case BIDIRECTIONAL = 'bidirectional'; // creates and updates records created by Invoice Ninja and from QB.
}

View File

@ -26,29 +26,15 @@ class InvoiceWasPaid implements ShouldBroadcast
{ {
use SerializesModels, DefaultInvoiceBroadcast; use SerializesModels, DefaultInvoiceBroadcast;
/**
* @var Invoice
*/
public $invoice;
public $payment;
public $company;
public $event_vars;
/** /**
* Create a new event instance. * Create a new event instance.
* *
* @param Invoice $invoice * @param Invoice $invoice
* @param Company $company * @param Company $company
* @param Payment $payment
* @param array $event_vars * @param array $event_vars
*/ */
public function __construct(Invoice $invoice, Payment $payment, Company $company, array $event_vars) public function __construct(public Invoice $invoice, public Payment $payment, public Company $company, public array $event_vars)
{ {
$this->invoice = $invoice;
$this->payment = $payment;
$this->company = $company;
$this->event_vars = $event_vars;
} }
} }

View File

@ -11,32 +11,33 @@
namespace App\Exceptions; namespace App\Exceptions;
use App\Utils\Ninja;
use Aws\Exception\CredentialsException;
use GuzzleHttp\Exception\ConnectException;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\ModelNotFoundException as ModelNotFoundException;
use Illuminate\Database\Eloquent\RelationNotFoundException;
use Illuminate\Encryption\MissingAppKeyException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\Exceptions\ThrottleRequestsException;
use Illuminate\Http\Request;
use Illuminate\Queue\MaxAttemptsExceededException;
use Illuminate\Session\TokenMismatchException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use League\Flysystem\UnableToCreateDirectory;
use PDOException;
use Sentry\Laravel\Integration;
use Sentry\State\Scope;
use Symfony\Component\Console\Exception\CommandNotFoundException;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Process\Exception\RuntimeException;
use Throwable; use Throwable;
use PDOException;
use App\Utils\Ninja;
use Sentry\State\Scope;
use Illuminate\Support\Arr;
use Illuminate\Http\Request;
use InvalidArgumentException;
use Sentry\Laravel\Integration;
use Illuminate\Support\Facades\Schema;
use Aws\Exception\CredentialsException;
use Illuminate\Database\QueryException;
use GuzzleHttp\Exception\ConnectException;
use Illuminate\Auth\AuthenticationException;
use League\Flysystem\UnableToCreateDirectory;
use Illuminate\Session\TokenMismatchException;
use Illuminate\Validation\ValidationException;
use Illuminate\Encryption\MissingAppKeyException;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Queue\MaxAttemptsExceededException;
use Illuminate\Http\Exceptions\ThrottleRequestsException;
use Symfony\Component\Process\Exception\RuntimeException;
use Illuminate\Database\Eloquent\RelationNotFoundException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Symfony\Component\Console\Exception\CommandNotFoundException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Illuminate\Database\Eloquent\ModelNotFoundException as ModelNotFoundException;
class Handler extends ExceptionHandler class Handler extends ExceptionHandler
{ {
@ -52,6 +53,7 @@ class Handler extends ExceptionHandler
ValidationException::class, ValidationException::class,
// ModelNotFoundException::class, // ModelNotFoundException::class,
NotFoundHttpException::class, NotFoundHttpException::class,
RelationNotFoundException::class,
]; ];
protected $selfHostDontReport = [ protected $selfHostDontReport = [
@ -65,6 +67,8 @@ class Handler extends ExceptionHandler
RuntimeException::class, RuntimeException::class,
InvalidArgumentException::class, InvalidArgumentException::class,
CredentialsException::class, CredentialsException::class,
RelationNotFoundException::class,
QueryException::class,
]; ];
protected $hostedDontReport = [ protected $hostedDontReport = [
@ -73,6 +77,7 @@ class Handler extends ExceptionHandler
ValidationException::class, ValidationException::class,
ModelNotFoundException::class, ModelNotFoundException::class,
NotFoundHttpException::class, NotFoundHttpException::class,
RelationNotFoundException::class,
]; ];
/** /**

View File

@ -68,7 +68,7 @@ class BankTransactionFilters extends QueryFilters
*/ */
public function client_status(string $value = ''): Builder public function client_status(string $value = ''): Builder
{ {
if (strlen($value ?? '') == 0) { if (strlen($value) == 0) {
return $this->builder; return $this->builder;
} }

View File

@ -4,7 +4,7 @@
* *
* @link https://github.com/invoiceninja/invoiceninja source repository * @link https://github.com/invoiceninja/invoiceninja source repository
* *
* @copyright Copyright (c) 2021. Invoice Ninja LLC (https://invoiceninja.com) * @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
* *
* @license https://www.elastic.co/licensing/elastic-license * @license https://www.elastic.co/licensing/elastic-license
*/ */

View File

@ -57,6 +57,7 @@ class CompanyGatewayController extends BaseController
private string $forte_key = 'kivcvjexxvdiyqtj3mju5d6yhpeht2xs'; private string $forte_key = 'kivcvjexxvdiyqtj3mju5d6yhpeht2xs';
private string $cbapowerboard_key = 'b67581d804dbad1743b61c57285142ad';
/** /**
* CompanyGatewayController constructor. * CompanyGatewayController constructor.
@ -227,17 +228,35 @@ class CompanyGatewayController extends BaseController
ApplePayDomain::dispatch($company_gateway, $company_gateway->company->db); ApplePayDomain::dispatch($company_gateway, $company_gateway->company->db);
if (in_array($company_gateway->gateway_key, $this->stripe_keys)) { switch ($company_gateway->gateway_key) {
StripeWebhook::dispatch($company_gateway->company->company_key, $company_gateway->id); case in_array($company_gateway->gateway_key, $this->stripe_keys):
} elseif($company_gateway->gateway_key == $this->checkout_key) { StripeWebhook::dispatch($company_gateway->company->company_key, $company_gateway->id);
CheckoutSetupWebhook::dispatch($company_gateway->company->company_key, $company_gateway->id); break;
} elseif($company_gateway->gateway_key == $this->forte_key) {
dispatch(function () use ($company_gateway) { case $this->checkout_key:
MultiDB::setDb($company_gateway->company->db); CheckoutSetupWebhook::dispatch($company_gateway->company->company_key, $company_gateway->id);
$company_gateway->driver()->updateFees(); break;
})->afterResponse();
case $this->forte_key:
dispatch(function () use ($company_gateway) {
MultiDB::setDb($company_gateway->company->db);
$company_gateway->driver()->updateFees();
})->afterResponse();
break;
case $this->cbapowerboard_key:
dispatch(function () use ($company_gateway) {
MultiDB::setDb($company_gateway->company->db);
$company_gateway->driver()->init()->settings()->updateSettings();
})->afterResponse();
break;
default:
# code...
break;
} }
return $this->itemResponse($company_gateway); return $this->itemResponse($company_gateway);

View File

@ -19,28 +19,6 @@ use App\Services\Quickbooks\QuickbooksService;
class ImportQuickbooksController extends BaseController class ImportQuickbooksController extends BaseController
{ {
// private array $import_entities = [
// 'client' => 'Customer',
// 'invoice' => 'Invoice',
// 'product' => 'Item',
// 'payment' => 'Payment'
// ];
public function onAuthorized(AuthorizedQuickbooksRequest $request)
{
MultiDB::findAndSetDbByCompanyKey($request->getTokenContent()['company_key']);
$company = $request->getCompany();
$qb = new QuickbooksService($company);
$realm = $request->query('realmId');
$access_token_object = $qb->sdk()->accessTokenFromCode($request->query('code'), $realm);
$qb->sdk()->saveOAuthToken($access_token_object);
return redirect(config('ninja.react_url'));
}
/** /**
* Determine if the user is authorized to make this request. * Determine if the user is authorized to make this request.
* *
@ -59,5 +37,22 @@ class ImportQuickbooksController extends BaseController
return redirect()->to($authorizationUrl); return redirect()->to($authorizationUrl);
} }
public function onAuthorized(AuthorizedQuickbooksRequest $request)
{
MultiDB::findAndSetDbByCompanyKey($request->getTokenContent()['company_key']);
$company = $request->getCompany();
$qb = new QuickbooksService($company);
$realm = $request->query('realmId');
$access_token_object = $qb->sdk()->accessTokenFromCode($request->query('code'), $realm);
$qb->sdk()->saveOAuthToken($access_token_object);
return redirect(config('ninja.react_url'));
}
} }

View File

@ -11,10 +11,12 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Http\Requests\Search\GenericSearchRequest; use App\Models\User;
use App\Utils\Ninja;
use App\Models\Client; use App\Models\Client;
use App\Models\Invoice; use App\Models\Invoice;
use App\Models\User; use Elastic\Elasticsearch\ClientBuilder;
use App\Http\Requests\Search\GenericSearchRequest;
class SearchController extends Controller class SearchController extends Controller
{ {
@ -26,6 +28,14 @@ class SearchController extends Controller
public function __invoke(GenericSearchRequest $request) public function __invoke(GenericSearchRequest $request)
{ {
if(config('scount.driver') == 'elastic' && $request->has('search') && $request->input('search') !== '') {
try{
return $this->search($request->input('search', ''));
} catch(\Exception $e) {
nlog("elk down?");
}
}
/** @var \App\Models\User $user */ /** @var \App\Models\User $user */
$user = auth()->user(); $user = auth()->user();
@ -41,6 +51,96 @@ class SearchController extends Controller
} }
public function search(string $search)
{
$user = auth()->user();
$company = $user->company();
$elastic = ClientBuilder::fromConfig(config('elastic.client.connections.default'));
$params = [
'index' => 'clients,invoices,client_contacts',
'body' => [
'query' => [
'bool' => [
'must' => [
'multi_match' => [
'query' => $search,
'fields' => ['*'],
'fuzziness' => 'AUTO',
],
],
'filter' => [
'match' => [
'company_key' => $company->company_key,
],
],
],
],
'size' => 1000,
],
];
$results = $elastic->search($params);
$this->mapResults($results['hits']['hits'] ?? []);
return response()->json([
'clients' => $this->clients,
'client_contacts' => $this->client_contacts,
'invoices' => $this->invoices,
'settings' => $this->settingsMap(),
], 200);
}
private function mapResults(array $results)
{
foreach($results as $result) {
switch($result['_index']) {
case 'clients':
if($result['_source']['is_deleted']) //do not return deleted results
break;
$this->clients[] = [
'name' => $result['_source']['name'],
'type' => '/client',
'id' => $result['_source']['hashed_id'],
'path' => "/clients/{$result['_source']['hashed_id']}"
];
break;
case 'invoices':
if ($result['_source']['is_deleted']) //do not return deleted invoices
break;
$this->invoices[] = [
'name' => $result['_source']['name'],
'type' => '/invoice',
'id' => $result['_source']['hashed_id'],
'path' => "/invoices/{$result['_source']['hashed_id']}/edit"
];
break;
case 'client_contacts':
if($result['_source']['__soft_deleted']) // do not return deleted contacts
break;
$this->client_contacts[] = [
'name' => $result['_source']['name'],
'type' => '/client',
'id' => $result['_source']['hashed_id'],
'path' => "/clients/{$result['_source']['hashed_id']}"
];
break;
}
}
}
private function clientMap(User $user) private function clientMap(User $user)
{ {
@ -81,20 +181,14 @@ class SearchController extends Controller
$invoices = Invoice::query() $invoices = Invoice::query()
->company() ->company()
->with('client') ->with('client')
->where('invoices.is_deleted', 0) ->where('is_deleted', 0)
// ->whereHas('client', function ($q) { ->whereHas('client', function ($q) {
// $q->where('is_deleted', 0); $q->where('is_deleted', 0);
// }) })
->leftJoin('clients', function ($join) {
$join->on('invoices.client_id', '=', 'clients.id')
->where('clients.is_deleted', 0);
})
->when(!$user->hasPermission('view_all') || !$user->hasPermission('view_invoice'), function ($query) use ($user) { ->when(!$user->hasPermission('view_all') || !$user->hasPermission('view_invoice'), function ($query) use ($user) {
$query->where('invoices.user_id', $user->id); $query->where('invoices.user_id', $user->id);
}) })
->orderBy('invoices.id', 'desc') ->orderBy('id', 'desc')
->take(3000) ->take(3000)
->get(); ->get();

View File

@ -37,19 +37,6 @@ class StripeConnectController extends BaseController
MultiDB::findAndSetDbByCompanyKey($request->getTokenContent()['company_key']); MultiDB::findAndSetDbByCompanyKey($request->getTokenContent()['company_key']);
// $company_gateway = CompanyGateway::query()
// ->where('gateway_key', 'd14dd26a47cecc30fdd65700bfb67b34')
// ->where('company_id', $request->getCompany()->id)
// ->first();
// if ($company_gateway) {
// $config = $company_gateway->getConfig();
// if (property_exists($config, 'account_id') && strlen($config->account_id) > 5) {
// return view('auth.connect.existing');
// }
// }
$stripe_client_id = config('ninja.ninja_stripe_client_id'); $stripe_client_id = config('ninja.ninja_stripe_client_id');
$redirect_uri = config('ninja.app_url').'/stripe/completed'; $redirect_uri = config('ninja.app_url').'/stripe/completed';
$endpoint = "https://connect.stripe.com/oauth/authorize?response_type=code&client_id={$stripe_client_id}&redirect_uri={$redirect_uri}&scope=read_write&state={$token}"; $endpoint = "https://connect.stripe.com/oauth/authorize?response_type=code&client_id={$stripe_client_id}&redirect_uri={$redirect_uri}&scope=read_write&state={$token}";
@ -98,6 +85,10 @@ class StripeConnectController extends BaseController
return view('auth.connect.access_denied'); return view('auth.connect.access_denied');
} }
if(!$request->getTokenContent()) {
return view('auth.connect.session_expired');
}
MultiDB::findAndSetDbByCompanyKey($request->getTokenContent()['company_key']); MultiDB::findAndSetDbByCompanyKey($request->getTokenContent()['company_key']);
$company = Company::query()->where('company_key', $request->getTokenContent()['company_key'])->first(); $company = Company::query()->where('company_key', $request->getTokenContent()['company_key'])->first();

View File

@ -37,7 +37,7 @@ class ShowCalculatedFieldRequest extends Request
'date_range' => 'bail|sometimes|string|in:last7_days,last30_days,last365_days,this_month,last_month,this_quarter,last_quarter,this_year,last_year,all_time,custom', 'date_range' => 'bail|sometimes|string|in:last7_days,last30_days,last365_days,this_month,last_month,this_quarter,last_quarter,this_year,last_year,all_time,custom',
'start_date' => 'bail|sometimes|date', 'start_date' => 'bail|sometimes|date',
'end_date' => 'bail|sometimes|date', 'end_date' => 'bail|sometimes|date',
'field' => 'required|bail|in:active_invoices, outstanding_invoices, completed_payments, refunded_payments, active_quotes, unapproved_quotes, logged_tasks, invoiced_tasks, paid_tasks, logged_expenses, pending_expenses, invoiced_expenses, invoice_paid_expenses', 'field' => 'required|bail|in:active_invoices,outstanding_invoices,completed_payments,refunded_payments,active_quotes,unapproved_quotes logged_tasks,invoiced_tasks,paid_tasks,logged_expenses,pending_expenses,invoiced_expenses,invoice_paid_expenses',
'calculation' => 'required|bail|in:sum,avg,count', 'calculation' => 'required|bail|in:sum,avg,count',
'period' => 'required|bail|in:current,previous,total', 'period' => 'required|bail|in:current,previous,total',
'format' => 'sometimes|bail|in:time,money', 'format' => 'sometimes|bail|in:time,money',

View File

@ -390,6 +390,7 @@ class BaseImport
try { try {
$invoice_data = $invoice_transformer->transform($raw_invoice); $invoice_data = $invoice_transformer->transform($raw_invoice);
$invoice_data['user_id'] = $this->company->owner()->id;
$invoice_data['line_items'] = $this->cleanItems( $invoice_data['line_items'] = $this->cleanItems(
$invoice_data['line_items'] ?? [] $invoice_data['line_items'] ?? []

View File

@ -0,0 +1,21 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Interfaces;
interface SyncInterface
{
public function find(string $id): mixed;
public function syncToNinja(array $records): void;
public function syncToForeign(array $records): void;
}

View File

@ -354,9 +354,9 @@ class CompanyImport implements ShouldQueue
unlink($tmp_file); unlink($tmp_file);
} }
if(Storage::exists($this->file_location)) { if(Storage::exists($this->file_location))
unlink(Storage::path($this->file_location)); Storage::delete($this->file_location);
}
} }
// //

View File

@ -154,6 +154,20 @@ class NinjaMailerJob implements ShouldQueue
LightLogs::create(new EmailSuccess($this->nmo->company->company_key, $this->nmo->mailable->subject)) LightLogs::create(new EmailSuccess($this->nmo->company->company_key, $this->nmo->mailable->subject))
->send(); ->send();
} catch(\Symfony\Component\Mailer\Exception\TransportException $e){
nlog("Mailer failed with a Transport Exception {$e->getMessage()}");
if(Ninja::isHosted() && $this->mailer == 'smtp'){
$settings = $this->nmo->settings;
$settings->email_sending_method = 'default';
$this->company->settings = $settings;
$this->company->save();
}
$this->fail();
$this->cleanUpMailers();
$this->logMailError($e->getMessage(), $this->company->clients()->first());
} catch (\Symfony\Component\Mime\Exception\RfcComplianceException $e) { } catch (\Symfony\Component\Mime\Exception\RfcComplianceException $e) {
nlog("Mailer failed with a Logic Exception {$e->getMessage()}"); nlog("Mailer failed with a Logic Exception {$e->getMessage()}");
$this->fail(); $this->fail();

View File

@ -89,7 +89,7 @@ class ProcessMailgunWebhook implements ShouldQueue
{ {
nlog($this->request); nlog($this->request);
if(!$this->request['event-data']['tags'][0]) { if(!$this->request['event-data']['tags'][0] ?? false) { //@phpstan-ignore-line
return; return;
} }

View File

@ -431,31 +431,31 @@ class BillingPortalPurchasev2 extends Component
* @throws PresenterException * @throws PresenterException
* @throws InvalidArgumentException * @throws InvalidArgumentException
*/ */
private function createClientContact() // private function createClientContact()
{ // {
$company = $this->subscription->company; // $company = $this->subscription->company;
$user = $this->subscription->user; // $user = $this->subscription->user;
$user->setCompany($company); // $user->setCompany($company);
$client_repo = new ClientRepository(new ClientContactRepository()); // $client_repo = new ClientRepository(new ClientContactRepository());
$data = [ // $data = [
'name' => '', // 'name' => '',
'group_settings_id' => $this->subscription->group_id, // 'group_settings_id' => $this->subscription->group_id,
'contacts' => [ // 'contacts' => [
['email' => $this->email], // ['email' => $this->email],
], // ],
'client_hash' => Str::random(40), // 'client_hash' => Str::random(40),
'settings' => ClientSettings::defaults(), // 'settings' => ClientSettings::defaults(),
]; // ];
$client = $client_repo->save($data, ClientFactory::create($company->id, $user->id)); // $client = $client_repo->save($data, ClientFactory::create($company->id, $user->id));
$this->contact = $client->fresh()->contacts()->first(); // $this->contact = $client->fresh()->contacts()->first();
Auth::guard('contact')->loginUsingId($this->contact->id, true); // Auth::guard('contact')->loginUsingId($this->contact->id, true);
return $this; // return $this;
} // }
/** /**

View File

@ -80,7 +80,6 @@ class PaymentMethod extends Component
{ {
nlog($e->getMessage()); nlog($e->getMessage());
$stopPropagation(); $stopPropagation();
} }

View File

@ -91,9 +91,8 @@ class ProcessPayment extends Component
$bag = new \Illuminate\Support\MessageBag(); $bag = new \Illuminate\Support\MessageBag();
$bag->add('gateway_error', $e->getMessage()); $bag->add('gateway_error', $e->getMessage());
session()->put('errors', $errors->put('default', $bag)); session()->put('errors', $errors->put('default', $bag));
$invoice_id = $this->getContext()['payable_invoices'][0]['invoice_id']; $invoice_id = $this->getContext()['payable_invoices'][0]['invoice_id'];
$this->redirectRoute('client.invoice.show', ['invoice' => $invoice_id]); $this->redirectRoute('client.invoice.show', ['invoice' => $invoice_id]);
$stopPropagation(); $stopPropagation();

View File

@ -136,4 +136,12 @@ class RequiredFields extends Component
]); ]);
} }
public function exception($e, $stopPropagation)
{
nlog($e->getMessage());
$stopPropagation();
}
} }

View File

@ -13,13 +13,14 @@ namespace App\Mail\Admin;
use App\Models\ClientContact; use App\Models\ClientContact;
use App\Models\Company; use App\Models\Company;
use App\Models\VendorContact;
use App\Utils\Ninja; use App\Utils\Ninja;
use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\App;
class ClientUnsubscribedObject class ClientUnsubscribedObject
{ {
public function __construct( public function __construct(
public ClientContact $contact, public ClientContact | VendorContact$contact,
public Company $company, public Company $company,
private bool $use_react_link = false private bool $use_react_link = false
) { ) {

View File

@ -11,23 +11,25 @@
namespace App\Models; namespace App\Models;
use App\DataMapper\ClientSettings; use Laravel\Scout\Searchable;
use App\DataMapper\CompanySettings;
use App\DataMapper\FeesAndLimits;
use App\Libraries\Currency\Conversion\CurrencyApi;
use App\Models\Presenters\ClientPresenter;
use App\Models\Traits\Excludable;
use App\Services\Client\ClientService;
use App\Utils\Traits\AppSetup; use App\Utils\Traits\AppSetup;
use App\Utils\Traits\ClientGroupSettingsSaver;
use App\Utils\Traits\GeneratesCounter;
use App\Utils\Traits\MakesDates;
use App\Utils\Traits\MakesHash; use App\Utils\Traits\MakesHash;
use Illuminate\Contracts\Translation\HasLocalePreference; use App\Utils\Traits\MakesDates;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use App\DataMapper\FeesAndLimits;
use Illuminate\Database\Eloquent\Relations\HasMany; use App\Models\Traits\Excludable;
use Illuminate\Database\Eloquent\SoftDeletes; use App\DataMapper\ClientSettings;
use App\DataMapper\ClientSync;
use App\DataMapper\CompanySettings;
use App\Services\Client\ClientService;
use App\Utils\Traits\GeneratesCounter;
use Laracasts\Presenter\PresentableTrait; use Laracasts\Presenter\PresentableTrait;
use App\Models\Presenters\ClientPresenter;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Utils\Traits\ClientGroupSettingsSaver;
use App\Libraries\Currency\Conversion\CurrencyApi;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Contracts\Translation\HasLocalePreference;
/** /**
* App\Models\Client * App\Models\Client
@ -69,6 +71,7 @@ use Laracasts\Presenter\PresentableTrait;
* @property int|null $shipping_country_id * @property int|null $shipping_country_id
* @property object|null $settings * @property object|null $settings
* @property object|null $group_settings * @property object|null $group_settings
* @property object|null $sync
* @property bool $is_deleted * @property bool $is_deleted
* @property int|null $group_settings_id * @property int|null $group_settings_id
* @property string|null $vat_number * @property string|null $vat_number
@ -124,6 +127,9 @@ class Client extends BaseModel implements HasLocalePreference
use ClientGroupSettingsSaver; use ClientGroupSettingsSaver;
use Excludable; use Excludable;
use Searchable;
protected $presenter = ClientPresenter::class; protected $presenter = ClientPresenter::class;
protected $hidden = [ protected $hidden = [
@ -186,6 +192,7 @@ class Client extends BaseModel implements HasLocalePreference
'last_login' => 'timestamp', 'last_login' => 'timestamp',
'tax_data' => 'object', 'tax_data' => 'object',
'e_invoice' => 'object', 'e_invoice' => 'object',
'sync' => ClientSync::class,
]; ];
protected $touches = []; protected $touches = [];
@ -232,6 +239,38 @@ class Client extends BaseModel implements HasLocalePreference
'custom_value4', 'custom_value4',
]; ];
public function toSearchableArray()
{
return [
'name' => $this->present()->name(),
'is_deleted' => $this->is_deleted,
'hashed_id' => $this->hashed_id,
'number' => $this->number,
'id_number' => $this->id_number,
'vat_number' => $this->vat_number,
'balance' => $this->balance,
'paid_to_date' => $this->paid_to_date,
'phone' => $this->phone,
'address1' => $this->address1,
'address2' => $this->address2,
'city' => $this->city,
'state' => $this->state,
'postal_code' => $this->postal_code,
'website' => $this->website,
'private_notes' => $this->private_notes,
'public_notes' => $this->public_notes,
'shipping_address1' => $this->shipping_address1,
'shipping_address2' => $this->shipping_address2,
'shipping_city' => $this->shipping_city,
'shipping_state' => $this->shipping_state,
'shipping_postal_code' => $this->shipping_postal_code,
'custom_value1' => $this->custom_value1,
'custom_value2' => $this->custom_value2,
'custom_value3' => $this->custom_value3,
'custom_value4' => $this->custom_value4,
'company_key' => $this->company->company_key,
];
}
public function getEntityType() public function getEntityType()
{ {

View File

@ -13,6 +13,7 @@ namespace App\Models;
use App\Utils\Ninja; use App\Utils\Ninja;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Laravel\Scout\Searchable;
use App\Jobs\Mail\NinjaMailer; use App\Jobs\Mail\NinjaMailer;
use App\Utils\Traits\AppSetup; use App\Utils\Traits\AppSetup;
use App\Utils\Traits\MakesHash; use App\Utils\Traits\MakesHash;
@ -100,6 +101,8 @@ class ClientContact extends Authenticatable implements HasLocalePreference
use HasFactory; use HasFactory;
use AppSetup; use AppSetup;
use Searchable;
/* Used to authenticate a contact */ /* Used to authenticate a contact */
protected $guard = 'contact'; protected $guard = 'contact';
@ -165,6 +168,23 @@ class ClientContact extends Authenticatable implements HasLocalePreference
'email', 'email',
]; ];
public function toSearchableArray()
{
return [
'name' => $this->present()->search_display(),
'hashed_id' => $this->client->hashed_id,
'email' => $this->email,
'first_name' => $this->first_name,
'last_name' => $this->last_name,
'phone' => $this->phone,
'custom_value1' => $this->custom_value1,
'custom_value2' => $this->custom_value2,
'custom_value3' => $this->custom_value3,
'custom_value4' => $this->custom_value4,
'company_key' => $this->company->company_key,
];
}
/* /*
V2 type of scope V2 type of scope
*/ */

View File

@ -489,7 +489,6 @@ class CompanyGateway extends BaseModel
public function getSettings() public function getSettings()
{ {
// return $this->settings;
return $this->settings ?? new \stdClass; return $this->settings ?? new \stdClass;
} }

View File

@ -11,22 +11,24 @@
namespace App\Models; namespace App\Models;
use App\Events\Invoice\InvoiceReminderWasEmailed; use App\DataMapper\InvoiceSync;
use App\Events\Invoice\InvoiceWasEmailed;
use App\Helpers\Invoice\InvoiceSum;
use App\Helpers\Invoice\InvoiceSumInclusive;
use App\Models\Presenters\EntityPresenter;
use App\Services\Invoice\InvoiceService;
use App\Services\Ledger\LedgerService;
use App\Utils\Ninja; use App\Utils\Ninja;
use App\Utils\Traits\Invoice\ActionsInvoice; use Laravel\Scout\Searchable;
use Illuminate\Support\Carbon;
use App\Utils\Traits\MakesDates; use App\Utils\Traits\MakesDates;
use App\Utils\Traits\MakesInvoiceValues; use App\Helpers\Invoice\InvoiceSum;
use App\Utils\Traits\MakesReminders; use App\Utils\Traits\MakesReminders;
use App\Utils\Traits\NumberFormatter; use App\Utils\Traits\NumberFormatter;
use Illuminate\Database\Eloquent\SoftDeletes; use App\Services\Ledger\LedgerService;
use Illuminate\Support\Carbon; use App\Services\Invoice\InvoiceService;
use App\Utils\Traits\MakesInvoiceValues;
use App\Events\Invoice\InvoiceWasEmailed;
use Laracasts\Presenter\PresentableTrait; use Laracasts\Presenter\PresentableTrait;
use App\Models\Presenters\EntityPresenter;
use App\Helpers\Invoice\InvoiceSumInclusive;
use App\Utils\Traits\Invoice\ActionsInvoice;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Events\Invoice\InvoiceReminderWasEmailed;
/** /**
* App\Models\Invoice * App\Models\Invoice
@ -52,6 +54,7 @@ use Laracasts\Presenter\PresentableTrait;
* @property bool $is_deleted * @property bool $is_deleted
* @property object|array|string $line_items * @property object|array|string $line_items
* @property object|null $backup * @property object|null $backup
* @property object|null $sync
* @property string|null $footer * @property string|null $footer
* @property string|null $public_notes * @property string|null $public_notes
* @property string|null $private_notes * @property string|null $private_notes
@ -144,6 +147,8 @@ class Invoice extends BaseModel
use MakesReminders; use MakesReminders;
use ActionsInvoice; use ActionsInvoice;
use Searchable;
protected $presenter = EntityPresenter::class; protected $presenter = EntityPresenter::class;
protected $touches = []; protected $touches = [];
@ -210,6 +215,8 @@ class Invoice extends BaseModel
'custom_surcharge_tax3' => 'bool', 'custom_surcharge_tax3' => 'bool',
'custom_surcharge_tax4' => 'bool', 'custom_surcharge_tax4' => 'bool',
'e_invoice' => 'object', 'e_invoice' => 'object',
'sync' => InvoiceSync::class,
]; ];
protected $with = []; protected $with = [];
@ -235,6 +242,25 @@ class Invoice extends BaseModel
public const STATUS_UNPAID = -2; //status < 4 || < 3 && !is_deleted && !trashed() public const STATUS_UNPAID = -2; //status < 4 || < 3 && !is_deleted && !trashed()
public function toSearchableArray()
{
return [
'name' => $this->client->present()->name() . ' - ' . $this->number,
'hashed_id' => $this->hashed_id,
'number' => $this->number,
'is_deleted' => $this->is_deleted,
'amount' => (float) $this->amount,
'balance' => (float) $this->balance,
'due_date' => $this->due_date,
'date' => $this->date,
'custom_value1' => $this->custom_value1,
'custom_value2' => $this->custom_value2,
'custom_value3' => $this->custom_value3,
'custom_value4' => $this->custom_value4,
'company_key' => $this->company->company_key,
];
}
public function getEntityType() public function getEntityType()
{ {
return self::class; return self::class;
@ -559,7 +585,7 @@ class Invoice extends BaseModel
* Filtering logic to determine * Filtering logic to determine
* whether an invoice is locked * whether an invoice is locked
* based on the current status of the invoice. * based on the current status of the invoice.
* @return bool [description] * @return bool
*/ */
public function isLocked(): bool public function isLocked(): bool
{ {
@ -569,7 +595,7 @@ class Invoice extends BaseModel
case 'off': case 'off':
return false; return false;
case 'when_sent': case 'when_sent':
return $this->status_id == self::STATUS_SENT; return $this->status_id >= self::STATUS_SENT;
case 'when_paid': case 'when_paid':
return $this->status_id == self::STATUS_PAID || $this->status_id == self::STATUS_PARTIAL; return $this->status_id == self::STATUS_PAID || $this->status_id == self::STATUS_PARTIAL;
case 'end_of_month': case 'end_of_month':
@ -739,7 +765,7 @@ class Invoice extends BaseModel
$send_email_enabled = ctrans('texts.send_email') . " " .ctrans('texts.enabled'); $send_email_enabled = ctrans('texts.send_email') . " " .ctrans('texts.enabled');
$send_email_disabled = ctrans('texts.send_email') . " " .ctrans('texts.disabled'); $send_email_disabled = ctrans('texts.send_email') . " " .ctrans('texts.disabled');
$sends_email_1 = $settings->enable_reminder2 ? $send_email_enabled : $send_email_disabled; $sends_email_1 = $settings->enable_reminder1 ? $send_email_enabled : $send_email_disabled;
$days_1 = $settings->num_days_reminder1 . " " . ctrans('texts.days'); $days_1 = $settings->num_days_reminder1 . " " . ctrans('texts.days');
$schedule_1 = ctrans("texts.{$settings->schedule_reminder1}"); //after due date etc or disabled $schedule_1 = ctrans("texts.{$settings->schedule_reminder1}"); //after due date etc or disabled
$label_1 = ctrans('texts.reminder1'); $label_1 = ctrans('texts.reminder1');
@ -749,7 +775,7 @@ class Invoice extends BaseModel
$schedule_2 = ctrans("texts.{$settings->schedule_reminder2}"); //after due date etc or disabled $schedule_2 = ctrans("texts.{$settings->schedule_reminder2}"); //after due date etc or disabled
$label_2 = ctrans('texts.reminder2'); $label_2 = ctrans('texts.reminder2');
$sends_email_3 = $settings->enable_reminder2 ? $send_email_enabled : $send_email_disabled; $sends_email_3 = $settings->enable_reminder3 ? $send_email_enabled : $send_email_disabled;
$days_3 = $settings->num_days_reminder3 . " " . ctrans('texts.days'); $days_3 = $settings->num_days_reminder3 . " " . ctrans('texts.days');
$schedule_3 = ctrans("texts.{$settings->schedule_reminder3}"); //after due date etc or disabled $schedule_3 = ctrans("texts.{$settings->schedule_reminder3}"); //after due date etc or disabled
$label_3 = ctrans('texts.reminder3'); $label_3 = ctrans('texts.reminder3');

View File

@ -11,6 +11,7 @@
namespace App\Models; namespace App\Models;
use App\DataMapper\ProductSync;
use App\Utils\Traits\MakesHash; use App\Utils\Traits\MakesHash;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use League\CommonMark\CommonMarkConverter; use League\CommonMark\CommonMarkConverter;
@ -43,6 +44,7 @@ use League\CommonMark\CommonMarkConverter;
* @property int|null $deleted_at * @property int|null $deleted_at
* @property int|null $created_at * @property int|null $created_at
* @property int|null $updated_at * @property int|null $updated_at
* @property object|null $sync
* @property bool $is_deleted * @property bool $is_deleted
* @property float $in_stock_quantity * @property float $in_stock_quantity
* @property bool $stock_notification * @property bool $stock_notification
@ -100,6 +102,13 @@ class Product extends BaseModel
'tax_id', 'tax_id',
]; ];
protected $casts = [
'updated_at' => 'timestamp',
'created_at' => 'timestamp',
'deleted_at' => 'timestamp',
'sync' => ProductSync::class,
];
public array $ubl_tax_map = [ public array $ubl_tax_map = [
self::PRODUCT_TYPE_REVERSE_TAX => 'AE', // VAT_REVERSE_CHARGE = self::PRODUCT_TYPE_REVERSE_TAX => 'AE', // VAT_REVERSE_CHARGE =
self::PRODUCT_TYPE_EXEMPT => 'E', // EXEMPT_FROM_TAX = self::PRODUCT_TYPE_EXEMPT => 'E', // EXEMPT_FROM_TAX =

View File

@ -203,7 +203,6 @@ class VendorContact extends Authenticatable implements HasLocalePreference
{ {
return $this return $this
->withTrashed() ->withTrashed()
// ->company()
->where('id', $this->decodePrimaryKey($value)) ->where('id', $this->decodePrimaryKey($value))
->firstOrFail(); ->firstOrFail();
} }
@ -219,4 +218,15 @@ class VendorContact extends Authenticatable implements HasLocalePreference
return $domain.'/vendor/key_login/'.$this->contact_key; return $domain.'/vendor/key_login/'.$this->contact_key;
} }
public function getAdminLink($use_react_link = false): string
{
return $use_react_link ? $this->getReactLink() : config('ninja.app_url');
}
private function getReactLink(): string
{
return config('ninja.react_url')."/#/vendors/{$this->vendor->hashed_id}";
}
} }

View File

@ -5,7 +5,7 @@
* *
* @link https://github.com/invoiceninja/invoiceninja source repository * @link https://github.com/invoiceninja/invoiceninja source repository
* *
* @copyright Copyright (c) 2021. Invoice Ninja LLC (https://invoiceninja.com) * @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
* *
* @license https://opensource.org/licenses/AAL * @license https://opensource.org/licenses/AAL
*/ */

View File

@ -5,7 +5,7 @@
* *
* @link https://github.com/invoiceninja/invoiceninja source repository * @link https://github.com/invoiceninja/invoiceninja source repository
* *
* @copyright Copyright (c) 2021. Invoice Ninja LLC (https://invoiceninja.com) * @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
* *
* @license https://opensource.org/licenses/AAL * @license https://opensource.org/licenses/AAL
*/ */

View File

@ -29,7 +29,7 @@ use App\PaymentDrivers\CBAPowerBoard\Models\Gateway;
class CreditCard implements LivewireMethodInterface class CreditCard implements LivewireMethodInterface
{ {
private Gateway $cba_gateway; private ?Gateway $cba_gateway;
public function __construct(public CBAPowerBoardPaymentDriver $powerboard) public function __construct(public CBAPowerBoardPaymentDriver $powerboard)
{ {
@ -39,7 +39,6 @@ class CreditCard implements LivewireMethodInterface
public function authorizeView(array $data) public function authorizeView(array $data)
{ {
$data['payment_method_id'] = GatewayType::CREDIT_CARD; $data['payment_method_id'] = GatewayType::CREDIT_CARD;
$data['threeds'] = $this->powerboard->company_gateway->getConfigField('threeds');
return render('gateways.powerboard.credit_card.authorize', $this->paymentData($data)); return render('gateways.powerboard.credit_card.authorize', $this->paymentData($data));
} }
@ -51,8 +50,6 @@ class CreditCard implements LivewireMethodInterface
{ {
$payment_source = $this->storePaymentSource($request); $payment_source = $this->storePaymentSource($request);
nlog($payment_source);
$browser_details = json_decode($request->browser_details, true); $browser_details = json_decode($request->browser_details, true);
$payload = [ $payload = [
@ -221,15 +218,15 @@ class CreditCard implements LivewireMethodInterface
{ {
$this->powerboard->init(); $this->powerboard->init();
if($this->cba_gateway->verification_status != "completed") // if(!isset($this->cba_gateway->verification_status) || $this->cba_gateway->verification_status != "completed")
throw new PaymentFailed("This payment method is not configured as yet. Reference Powerboard portal for further information", 400); // throw new PaymentFailed("This payment method is not configured as yet. Reference Powerboard portal for further information", 400);
$merge = [ $merge = [
'public_key' => $this->powerboard->company_gateway->getConfigField('publicKey'), 'public_key' => $this->powerboard->company_gateway->getConfigField('publicKey'),
'widget_endpoint' => $this->powerboard->widget_endpoint, 'widget_endpoint' => $this->powerboard->widget_endpoint,
'gateway' => $this->powerboard, 'gateway' => $this->powerboard,
'environment' => $this->powerboard->environment, 'environment' => $this->powerboard->environment,
'gateway_id' => $this->cba_gateway->_id, 'gateway_id' => $this->cba_gateway->_id ?? false,
]; ];
return array_merge($data, $merge); return array_merge($data, $merge);
@ -451,7 +448,6 @@ class CreditCard implements LivewireMethodInterface
match($error_object->error->code) { match($error_object->error->code) {
"UnfulfilledCondition" => $error_message = $error_object->error->details->messages[0] ?? $error_object->error->message ?? "Unknown error", "UnfulfilledCondition" => $error_message = $error_object->error->details->messages[0] ?? $error_object->error->message ?? "Unknown error",
"GatewayError" => $error_message = $error_object->error->message, "GatewayError" => $error_message = $error_object->error->message,
"UnfulfilledCondition" => $error_message = $error_object->error->message,
"transaction_declined" => $error_message = $error_object->error->details[0]->status_code_description, "transaction_declined" => $error_message = $error_object->error->details[0]->status_code_description,
default => $error_message = $error_object->error->message ?? "Unknown error", default => $error_message = $error_object->error->message ?? "Unknown error",
}; };

View File

@ -13,35 +13,17 @@ namespace App\PaymentDrivers\CBAPowerBoard\Models;
class Gateway class Gateway
{ {
/** @var string */
public string $_id;
/** @var string */
public string $name;
/** @var string */
public string $type;
/** @var string */
public string $mode;
/** @var string */
public string $created_at;
/** @var string */
public string $updated_at;
/** @var bool */
public bool $archived;
/** @var bool */
public bool $default;
/** @var string */
public string $verification_status;
public function __construct( public function __construct(
string $_id, public string $_id,
string $name, public string $name,
string $type, public string $type,
string $mode, public string $mode,
string $created_at, public string $created_at,
string $updated_at, public string $updated_at,
bool $archived, public bool $archived,
bool $default, public bool $default,
string $verification_status public string $verification_status = ''
) { ) {
$this->_id = $_id; $this->_id = $_id;
$this->name = $name; $this->name = $name;

View File

@ -13,10 +13,11 @@
namespace App\PaymentDrivers\CBAPowerBoard; namespace App\PaymentDrivers\CBAPowerBoard;
use App\PaymentDrivers\CBAPowerBoard\Models\Gateways; use App\Models\GatewayType;
use App\PaymentDrivers\CBAPowerBoard\Models\Gateway;
use App\PaymentDrivers\CBAPowerBoardPaymentDriver; use App\PaymentDrivers\CBAPowerBoardPaymentDriver;
use App\PaymentDrivers\CBAPowerBoard\Models\Gateway;
use App\PaymentDrivers\CBAPowerBoard\Models\Gateways;
class Settings class Settings
{ {
@ -28,21 +29,29 @@ class Settings
public function __construct(public CBAPowerBoardPaymentDriver $powerboard) public function __construct(public CBAPowerBoardPaymentDriver $powerboard)
{ {
} }
public function getGateways() /**
* Returns the API response for the gateways
*
* @return mixed
*/
public function getGateways(): mixed
{ {
$r = $this->powerboard->gatewayRequest('/v1/gateways', (\App\Enum\HttpVerb::GET)->value, [], []); $r = $this->powerboard->gatewayRequest('/v1/gateways', (\App\Enum\HttpVerb::GET)->value, [], []);
if($r->failed()) if($r->failed())
$r->throw(); $r->throw();
nlog($r->object());
return (new \App\PaymentDrivers\CBAPowerBoard\Models\Parse())->encode(Gateway::class."[]", $r->object()->resource->data); return (new \App\PaymentDrivers\CBAPowerBoard\Models\Parse())->encode(Gateway::class."[]", $r->object()->resource->data);
} }
/** We will need to have a process that updates this at intervals */ /** We will need to have a process that updates this at intervals */
/**
* updateSettings from the API
*
* @return self
*/
public function updateSettings():self public function updateSettings():self
{ {
$gateways = $this->getGateways(); $gateways = $this->getGateways();
@ -53,12 +62,23 @@ class Settings
return $this; return $this;
} }
/**
* getSettings
*
* @return mixed
*/
public function getSettings(): mixed public function getSettings(): mixed
{ {
return $this->powerboard->company_gateway->getSettings(); return $this->powerboard->company_gateway->getSettings();
} }
/**
* Entry point for getting the payment gateway configuration
*
* @param int $gateway_type_id
* @return mixed
*/
public function getPaymentGatewayConfiguration(int $gateway_type_id): mixed public function getPaymentGatewayConfiguration(int $gateway_type_id): mixed
{ {
$type = self::GATEWAY_CBA; $type = self::GATEWAY_CBA;
@ -70,25 +90,46 @@ class Settings
return $this->getGatewayByType($type); return $this->getGatewayByType($type);
} }
/**
* Returns the CBA gateway object for a given gateway type
*
* @param string $gateway_type_const
* @return mixed
*/
private function getGatewayByType(string $gateway_type_const): mixed private function getGatewayByType(string $gateway_type_const): mixed
{ {
$settings = $this->getSettings(); $settings = $this->getSettings();
if(!property_exists($settings,'gateways')){ if(!property_exists($settings, 'gateways')){
$this->updateSettings(); $this->updateSettings();
$settings = $this->getSettings(); $settings = $this->getSettings();
} }
$gateways = (new \App\PaymentDrivers\CBAPowerBoard\Models\Parse())->encode(Gateway::class."[]", $settings->gateways); $gateways = (new \App\PaymentDrivers\CBAPowerBoard\Models\Parse())->encode(Gateway::class."[]", $settings->gateways);
if ($gateway_type_const == self::GATEWAY_CBA && strlen($this->powerboard->company_gateway->getConfigField('gatewayId') ?? '') > 1) {
return collect($gateways)->first(function (Gateway $gateway) {
return $gateway->_id == $this->powerboard->company_gateway->getConfigField('gatewayId');
});
}
return collect($gateways)->first(function (Gateway $gateway) use ($gateway_type_const){ return collect($gateways)->first(function (Gateway $gateway) use ($gateway_type_const){
return $gateway->type == $gateway_type_const; return $gateway->type == $gateway_type_const;
}); });
} }
/**
* Returns the CBA gateway ID for a given gateway type
*
* @param int $gateway_type_id
* @return string
*/
public function getGatewayId(int $gateway_type_id): string public function getGatewayId(int $gateway_type_id): string
{ {
$gateway = $this->getPaymentGatewayConfiguration($gateway_type_id); $gateway = $this->getPaymentGatewayConfiguration($gateway_type_id);
return $gateway->_id; return $gateway->_id;

View File

@ -185,16 +185,10 @@ class CBAPowerBoardPaymentDriver extends BaseDriver
{ {
$this->init(); $this->init();
$this->settings()->updateSettings();
return true; return true;
// try {
// $this->verifyConnect();
// return true;
// } catch(\Exception $e) {
// }
// return false;
} }

View File

@ -5,7 +5,7 @@
* *
* @link https://github.com/invoiceninja/invoiceninja source repository * @link https://github.com/invoiceninja/invoiceninja source repository
* *
* @copyright Copyright (c) 2021. Invoice Ninja LLC (https://invoiceninja.com) * @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
* *
* @license https://www.elastic.co/licensing/elastic-license * @license https://www.elastic.co/licensing/elastic-license
*/ */

View File

@ -5,7 +5,7 @@
* *
* @link https://github.com/invoiceninja/invoiceninja source repository * @link https://github.com/invoiceninja/invoiceninja source repository
* *
* @copyright Copyright (c) 2021. Invoice Ninja LLC (https://invoiceninja.com) * @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
* *
* @license https://www.elastic.co/licensing/elastic-license * @license https://www.elastic.co/licensing/elastic-license
*/ */

View File

@ -5,7 +5,7 @@
* *
* @link https://github.com/invoiceninja/invoiceninja source repository * @link https://github.com/invoiceninja/invoiceninja source repository
* *
* @copyright Copyright (c) 2021. Invoice Ninja LLC (https://invoiceninja.com) * @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
* *
* @license https://www.elastic.co/licensing/elastic-license * @license https://www.elastic.co/licensing/elastic-license
*/ */

View File

@ -5,7 +5,7 @@
* *
* @link https://github.com/invoiceninja/invoiceninja source repository * @link https://github.com/invoiceninja/invoiceninja source repository
* *
* @copyright Copyright (c) 2021. Invoice Ninja LLC (https://invoiceninja.com) * @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
* *
* @license https://www.elastic.co/licensing/elastic-license * @license https://www.elastic.co/licensing/elastic-license
*/ */

View File

@ -5,7 +5,7 @@
* *
* @link https://github.com/invoiceninja/invoiceninja source repository * @link https://github.com/invoiceninja/invoiceninja source repository
* *
* @copyright Copyright (c) 2021. Invoice Ninja LLC (https://invoiceninja.com) * @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
* *
* @license https://www.elastic.co/licensing/elastic-license * @license https://www.elastic.co/licensing/elastic-license
*/ */

View File

@ -4,7 +4,7 @@
* *
* @link https://github.com/invoiceninja/invoiceninja source repository * @link https://github.com/invoiceninja/invoiceninja source repository
* *
* @copyright Copyright (c) 2021. Invoice Ninja LLC (https://invoiceninja.com) * @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
* *
* @license https://opensource.org/licenses/AAL * @license https://opensource.org/licenses/AAL
*/ */

View File

@ -111,7 +111,7 @@ class PaymentMethod implements MethodInterface, LivewireMethodInterface
$message = json_decode($e->getMessage(), true); $message = json_decode($e->getMessage(), true);
return redirect()->route('client.payment_methods.index')->withErrors(array_values($message['errors'])); return redirect()->route('client.payment_methods.index')->withErrors(array_values($message['errors'] ?? [$e->getMessage()]));
} }

View File

@ -141,8 +141,8 @@ trait ChartCalculations
} }
match ($data['calculation']) { match ($data['calculation']) {
'sum' => $result = $q->sum('refunded'), 'sum' => $result = $q->sum('amount'),
'avg' => $result = $q->avg('refunded'), 'avg' => $result = $q->avg('amount'),
'count' => $result = $q->count(), 'count' => $result = $q->count(),
default => $result = 0, default => $result = 0,
}; };
@ -287,14 +287,14 @@ trait ChartCalculations
return $query->get() return $query->get()
->when($data['currency_id'] == '999', function ($collection) { ->when($data['currency_id'] == '999', function ($collection) {
$collection->map(function ($e) { return $collection->map(function ($e) {
/** @var \App\Models\Expense $e */ /** @var \App\Models\Expense $e */
return $e->amount * $e->exchange_rate; return $e->amount * $e->exchange_rate;
}); });
}) })
->when($data['currency_id'] != '999', function ($collection) { ->when($data['currency_id'] != '999', function ($collection) {
$collection->map(function ($e) { return $collection->map(function ($e) {
/** @var \App\Models\Expense $e */ /** @var \App\Models\Expense $e */
return $e->amount; return $e->amount;

View File

@ -107,7 +107,7 @@ class Partner
{ {
$uri = "/partner/{$this->partner_number}/account/{$accountRegNo}"; $uri = "/partner/{$this->partner_number}/account/{$accountRegNo}";
return $this->qvalia->httpClient($uri, (\App\Enum\HttpVerb::DELETE)->value)->object(); return $this->qvalia->httpClient($uri, (\App\Enum\HttpVerb::DELETE)->value, [])->object();
} }
/** /**
@ -138,7 +138,7 @@ class Partner
{ {
$uri = "/partner/{$this->partner_number}/account/{$accountRegNo}/peppol/{$peppolId}"; $uri = "/partner/{$this->partner_number}/account/{$accountRegNo}/peppol/{$peppolId}";
return $this->qvalia->httpClient($uri, (\App\Enum\HttpVerb::DELETE)->value)->object(); return $this->qvalia->httpClient($uri, (\App\Enum\HttpVerb::DELETE)->value, [])->object();
} }
} }

View File

@ -131,6 +131,7 @@ class ZugferdEDocument extends AbstractService
$vendor->postal_code = $postcode; $vendor->postal_code = $postcode;
$country = app('countries')->first(function ($c) use ($country) { $country = app('countries')->first(function ($c) use ($country) {
/** @var \App\Models\Country $c */
return $c->iso_3166_2 == $country || $c->iso_3166_3 == $country; return $c->iso_3166_2 == $country || $c->iso_3166_3 == $country;
}); });
if ($country) if ($country)

View File

@ -291,6 +291,20 @@ class Email implements ShouldQueue
LightLogs::create(new EmailSuccess($this->company->company_key, $this->mailable->subject)) LightLogs::create(new EmailSuccess($this->company->company_key, $this->mailable->subject))
->send(); ->send();
} catch(\Symfony\Component\Mailer\Exception\TransportException $e){
nlog("Mailer failed with a Transport Exception {$e->getMessage()}");
if(Ninja::isHosted() && $this->mailer == 'smtp'){
$settings = $this->email_object->settings;
$settings->email_sending_method = 'default';
$this->company->settings = $settings;
$this->company->save();
}
$this->fail();
$this->cleanUpMailers();
$this->logMailError($e->getMessage(), $this->company->clients()->first());
} catch (\Symfony\Component\Mime\Exception\RfcComplianceException $e) { } catch (\Symfony\Component\Mime\Exception\RfcComplianceException $e) {
nlog("Mailer failed with a Logic Exception {$e->getMessage()}"); nlog("Mailer failed with a Logic Exception {$e->getMessage()}");
$this->fail(); $this->fail();

View File

@ -47,7 +47,7 @@ class UpdateReminder extends AbstractService
if (is_null($this->invoice->reminder1_sent) && if (is_null($this->invoice->reminder1_sent) &&
$this->settings->schedule_reminder1 == 'after_invoice_date') { $this->settings->schedule_reminder1 == 'after_invoice_date') {
$reminder_date = Carbon::parse($this->invoice->date)->startOfDay()->addDays((int)$this->settings->num_days_reminder1); $reminder_date = Carbon::parse($this->invoice->date)->startOfDay()->addDays((int)$this->settings->num_days_reminder1)->addSeconds($offset);
if ($reminder_date->gt(now())) { if ($reminder_date->gt(now())) {
$date_collection->push($reminder_date); $date_collection->push($reminder_date);
@ -58,7 +58,7 @@ class UpdateReminder extends AbstractService
($this->invoice->partial_due_date || $this->invoice->due_date) && ($this->invoice->partial_due_date || $this->invoice->due_date) &&
$this->settings->schedule_reminder1 == 'before_due_date') { $this->settings->schedule_reminder1 == 'before_due_date') {
$partial_or_due_date = ($this->invoice->partial > 0 && isset($this->invoice->partial_due_date)) ? $this->invoice->partial_due_date : $this->invoice->due_date; $partial_or_due_date = ($this->invoice->partial > 0 && isset($this->invoice->partial_due_date)) ? $this->invoice->partial_due_date : $this->invoice->due_date;
$reminder_date = Carbon::parse($partial_or_due_date)->startOfDay()->subDays((int)$this->settings->num_days_reminder1); $reminder_date = Carbon::parse($partial_or_due_date)->startOfDay()->subDays((int)$this->settings->num_days_reminder1)->addSeconds($offset);
// nlog("1. {$reminder_date->format('Y-m-d')}"); // nlog("1. {$reminder_date->format('Y-m-d')}");
if ($reminder_date->gt(now())) { if ($reminder_date->gt(now())) {
@ -71,7 +71,7 @@ class UpdateReminder extends AbstractService
$this->settings->schedule_reminder1 == 'after_due_date') { $this->settings->schedule_reminder1 == 'after_due_date') {
$partial_or_due_date = ($this->invoice->partial > 0 && isset($this->invoice->partial_due_date)) ? $this->invoice->partial_due_date : $this->invoice->due_date; $partial_or_due_date = ($this->invoice->partial > 0 && isset($this->invoice->partial_due_date)) ? $this->invoice->partial_due_date : $this->invoice->due_date;
$reminder_date = Carbon::parse($partial_or_due_date)->startOfDay()->addDays((int)$this->settings->num_days_reminder1); $reminder_date = Carbon::parse($partial_or_due_date)->startOfDay()->addDays((int)$this->settings->num_days_reminder1)->addSeconds($offset);
// nlog("2. {$reminder_date->format('Y-m-d')}"); // nlog("2. {$reminder_date->format('Y-m-d')}");
if ($reminder_date->gt(now())) { if ($reminder_date->gt(now())) {
@ -81,7 +81,7 @@ class UpdateReminder extends AbstractService
if (is_null($this->invoice->reminder2_sent) && if (is_null($this->invoice->reminder2_sent) &&
$this->settings->schedule_reminder2 == 'after_invoice_date') { $this->settings->schedule_reminder2 == 'after_invoice_date') {
$reminder_date = Carbon::parse($this->invoice->date)->startOfDay()->addDays((int)$this->settings->num_days_reminder2); $reminder_date = Carbon::parse($this->invoice->date)->startOfDay()->addDays((int)$this->settings->num_days_reminder2)->addSeconds($offset);
if ($reminder_date->gt(now())) { if ($reminder_date->gt(now())) {
$date_collection->push($reminder_date); $date_collection->push($reminder_date);
@ -93,7 +93,7 @@ class UpdateReminder extends AbstractService
$this->settings->schedule_reminder2 == 'before_due_date') { $this->settings->schedule_reminder2 == 'before_due_date') {
$partial_or_due_date = ($this->invoice->partial > 0 && isset($this->invoice->partial_due_date)) ? $this->invoice->partial_due_date : $this->invoice->due_date; $partial_or_due_date = ($this->invoice->partial > 0 && isset($this->invoice->partial_due_date)) ? $this->invoice->partial_due_date : $this->invoice->due_date;
$reminder_date = Carbon::parse($partial_or_due_date)->startOfDay()->subDays((int)$this->settings->num_days_reminder2); $reminder_date = Carbon::parse($partial_or_due_date)->startOfDay()->subDays((int)$this->settings->num_days_reminder2)->addSeconds($offset);
// nlog("3. {$reminder_date->format('Y-m-d')}"); // nlog("3. {$reminder_date->format('Y-m-d')}");
if ($reminder_date->gt(now())) { if ($reminder_date->gt(now())) {
@ -106,7 +106,7 @@ class UpdateReminder extends AbstractService
$this->settings->schedule_reminder2 == 'after_due_date') { $this->settings->schedule_reminder2 == 'after_due_date') {
$partial_or_due_date = ($this->invoice->partial > 0 && isset($this->invoice->partial_due_date)) ? $this->invoice->partial_due_date : $this->invoice->due_date; $partial_or_due_date = ($this->invoice->partial > 0 && isset($this->invoice->partial_due_date)) ? $this->invoice->partial_due_date : $this->invoice->due_date;
$reminder_date = Carbon::parse($partial_or_due_date)->startOfDay()->addDays((int)$this->settings->num_days_reminder2); $reminder_date = Carbon::parse($partial_or_due_date)->startOfDay()->addDays((int)$this->settings->num_days_reminder2)->addSeconds($offset);
// nlog("4. {$reminder_date->format('Y-m-d')}"); // nlog("4. {$reminder_date->format('Y-m-d')}");
if ($reminder_date->gt(now())) { if ($reminder_date->gt(now())) {
@ -116,7 +116,7 @@ class UpdateReminder extends AbstractService
if (is_null($this->invoice->reminder3_sent) && if (is_null($this->invoice->reminder3_sent) &&
$this->settings->schedule_reminder3 == 'after_invoice_date') { $this->settings->schedule_reminder3 == 'after_invoice_date') {
$reminder_date = Carbon::parse($this->invoice->date)->startOfDay()->addDays((int)$this->settings->num_days_reminder3); $reminder_date = Carbon::parse($this->invoice->date)->startOfDay()->addDays((int)$this->settings->num_days_reminder3)->addSeconds($offset);
if ($reminder_date->gt(now())) { if ($reminder_date->gt(now())) {
$date_collection->push($reminder_date); $date_collection->push($reminder_date);
@ -128,7 +128,7 @@ class UpdateReminder extends AbstractService
$this->settings->schedule_reminder3 == 'before_due_date') { $this->settings->schedule_reminder3 == 'before_due_date') {
$partial_or_due_date = ($this->invoice->partial > 0 && isset($this->invoice->partial_due_date)) ? $this->invoice->partial_due_date : $this->invoice->due_date; $partial_or_due_date = ($this->invoice->partial > 0 && isset($this->invoice->partial_due_date)) ? $this->invoice->partial_due_date : $this->invoice->due_date;
$reminder_date = Carbon::parse($partial_or_due_date)->startOfDay()->subDays((int)$this->settings->num_days_reminder3); $reminder_date = Carbon::parse($partial_or_due_date)->startOfDay()->subDays((int)$this->settings->num_days_reminder3)->addSeconds($offset);
// nlog("5. {$reminder_date->format('Y-m-d')}"); // nlog("5. {$reminder_date->format('Y-m-d')}");
if ($reminder_date->gt(now())) { if ($reminder_date->gt(now())) {
@ -141,7 +141,7 @@ class UpdateReminder extends AbstractService
$this->settings->schedule_reminder3 == 'after_due_date') { $this->settings->schedule_reminder3 == 'after_due_date') {
$partial_or_due_date = ($this->invoice->partial > 0 && isset($this->invoice->partial_due_date)) ? $this->invoice->partial_due_date : $this->invoice->due_date; $partial_or_due_date = ($this->invoice->partial > 0 && isset($this->invoice->partial_due_date)) ? $this->invoice->partial_due_date : $this->invoice->due_date;
$reminder_date = Carbon::parse($partial_or_due_date)->startOfDay()->addDays((int)$this->settings->num_days_reminder3); $reminder_date = Carbon::parse($partial_or_due_date)->startOfDay()->addDays((int)$this->settings->num_days_reminder3)->addSeconds($offset);
// nlog("6. {$reminder_date->format('Y-m-d')}"); // nlog("6. {$reminder_date->format('Y-m-d')}");
if ($reminder_date->gt(now())) { if ($reminder_date->gt(now())) {
@ -154,17 +154,15 @@ class UpdateReminder extends AbstractService
($this->invoice->reminder1_sent || $this->settings->schedule_reminder1 == "" || !$this->settings->enable_reminder1) && ($this->invoice->reminder1_sent || $this->settings->schedule_reminder1 == "" || !$this->settings->enable_reminder1) &&
($this->invoice->reminder2_sent || $this->settings->schedule_reminder2 == "" || !$this->settings->enable_reminder2) && ($this->invoice->reminder2_sent || $this->settings->schedule_reminder2 == "" || !$this->settings->enable_reminder2) &&
($this->invoice->reminder3_sent || $this->settings->schedule_reminder3 == "" || !$this->settings->enable_reminder3)) { ($this->invoice->reminder3_sent || $this->settings->schedule_reminder3 == "" || !$this->settings->enable_reminder3)) {
$reminder_date = $this->addTimeInterval($this->invoice->last_sent_date, (int) $this->settings->endless_reminder_frequency_id); $reminder_date = $this->addTimeInterval($this->invoice->last_sent_date, (int) $this->settings->endless_reminder_frequency_id)->addSeconds($offset);
if ($reminder_date) { if ($reminder_date && $reminder_date->gt(now())) {
if ($reminder_date->gt(now())) { $date_collection->push($reminder_date);
$date_collection->push($reminder_date);
}
} }
} }
if ($date_collection->count() >= 1 && $date_collection->sort()->first()->gte(now())) { if ($date_collection->count() >= 1 && $date_collection->sort()->first()->gte(now())) {
$this->invoice->next_send_date = $date_collection->sort()->first()->addSeconds($offset); $this->invoice->next_send_date = $date_collection->sort()->first();
} else { } else {
$this->invoice->next_send_date = null; $this->invoice->next_send_date = null;
} }

View File

@ -0,0 +1,245 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Services\Quickbooks\Jobs;
use App\Models\Client;
use App\Models\Vendor;
use App\Models\Company;
use App\Models\Expense;
use App\Models\Invoice;
use App\Models\Product;
use App\Libraries\MultiDB;
use Illuminate\Bus\Queueable;
use App\Factory\ClientFactory;
use App\Factory\VendorFactory;
use App\Factory\ExpenseFactory;
use App\Factory\InvoiceFactory;
use App\Factory\ProductFactory;
use App\DataMapper\QuickbooksSync;
use App\Factory\ClientContactFactory;
use App\Factory\VendorContactFactory;
use App\DataMapper\QuickbooksSettings;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use App\Services\Quickbooks\QuickbooksService;
use QuickBooksOnline\API\Data\IPPSalesReceipt;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use App\Services\Quickbooks\Transformers\ClientTransformer;
use App\Services\Quickbooks\Transformers\VendorTransformer;
use App\Services\Quickbooks\Transformers\ExpenseTransformer;
use App\Services\Quickbooks\Transformers\InvoiceTransformer;
use App\Services\Quickbooks\Transformers\PaymentTransformer;
use App\Services\Quickbooks\Transformers\ProductTransformer;
class QuickbooksImport implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
private array $entities = [
'product' => 'Item',
'client' => 'Customer',
'invoice' => 'Invoice',
'sales' => 'SalesReceipt',
// 'quote' => 'Estimate',
// 'purchase_order' => 'PurchaseOrder',
// 'payment' => 'Payment',
// 'vendor' => 'Vendor',
// 'expense' => 'Purchase',
];
private QuickbooksService $qbs;
private QuickbooksSync $settings;
private Company $company;
public function __construct(public int $company_id, public string $db)
{
}
/**
* Execute the job.
*/
public function handle()
{
MultiDB::setDb($this->db);
$this->company = Company::query()->find($this->company_id);
$this->qbs = new QuickbooksService($this->company);
$this->settings = $this->company->quickbooks->settings;
foreach($this->entities as $key => $entity) {
if(!$this->qbs->syncable($key, \App\Enum\SyncDirection::PULL)) {
nlog('skipping ' . $key);
continue;
}
$records = $this->qbs->sdk()->fetchRecords($entity);
$this->processEntitySync($key, $records);
}
}
/**
* Processes the sync for a given entity
*
* @param string $entity
* @param mixed $records
* @return void
*/
private function processEntitySync(string $entity, $records): void
{
match($entity){
'client' => $this->qbs->client->syncToNinja($records),
'product' => $this->qbs->product->syncToNinja($records),
'invoice' => $this->qbs->invoice->syncToNinja($records),
'sales' => $this->qbs->invoice->syncToNinja($records),
// 'vendor' => $this->syncQbToNinjaVendors($records),
// 'quote' => $this->syncInvoices($records),
// 'expense' => $this->syncQbToNinjaExpenses($records),
// 'purchase_order' => $this->syncInvoices($records),
// 'payment' => $this->syncPayment($records),
default => false,
};
}
private function syncQbToNinjaInvoices($records): void
{
}
private function syncQbToNinjaVendors(array $records): void
{
$transformer = new VendorTransformer($this->company);
foreach($records as $record)
{
$ninja_data = $transformer->qbToNinja($record);
if($vendor = $this->findVendor($ninja_data))
{
$vendor->fill($ninja_data[0]);
$vendor->saveQuietly();
$contact = $vendor->contacts()->where('email', $ninja_data[1]['email'])->first();
if(!$contact)
{
$contact = VendorContactFactory::create($this->company->id, $this->company->owner()->id);
$contact->vendor_id = $vendor->id;
$contact->send_email = true;
$contact->is_primary = true;
$contact->fill($ninja_data[1]);
$contact->saveQuietly();
}
elseif($this->qbs->syncable('vendor', \App\Enum\SyncDirection::PULL)){
$contact->fill($ninja_data[1]);
$contact->saveQuietly();
}
}
}
}
private function syncQbToNinjaExpenses(array $records): void
{
$transformer = new ExpenseTransformer($this->company);
foreach($records as $record)
{
$ninja_data = $transformer->qbToNinja($record);
if($expense = $this->findExpense($ninja_data))
{
$expense->fill($ninja_data);
$expense->saveQuietly();
}
}
}
private function findExpense(array $qb_data): ?Expense
{
$expense = $qb_data;
$search = Expense::query()
->withTrashed()
->where('company_id', $this->company->id)
->where('number', $expense['number']);
if($search->count() == 0) {
return ExpenseFactory::create($this->company->id, $this->company->owner()->id);
}
elseif($search->count() == 1) {
return $this->qbs->syncable('expense', \App\Enum\SyncDirection::PULL) ? $search->first() : null;
}
return null;
}
private function findVendor(array $qb_data) :?Vendor
{
$vendor = $qb_data[0];
$contact = $qb_data[1];
$vendor_meta = $qb_data[2];
$search = Vendor::query()
->withTrashed()
->where('company_id', $this->company->id)
->where(function ($q) use ($vendor, $vendor_meta, $contact){
$q->where('vendor_hash', $vendor_meta['vendor_hash'])
->orWhere('number', $vendor['number'])
->orWhereHas('contacts', function ($q) use ($contact){
$q->where('email', $contact['email']);
});
});
if($search->count() == 0) {
//new client
return VendorFactory::create($this->company->id, $this->company->owner()->id);
}
elseif($search->count() == 1) {
return $this->qbs->syncable('vendor', \App\Enum\SyncDirection::PULL) ? $search->first() : null;
}
return null;
}
public function middleware()
{
return [new WithoutOverlapping("qbs-{$this->company_id}-{$this->db}")];
}
public function failed($exception)
{
nlog("QuickbooksSync failed => ".$exception->getMessage());
config(['queue.failed.driver' => null]);
}
}

View File

@ -1,436 +0,0 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Services\Quickbooks\Jobs;
use App\Models\Client;
use App\Models\Vendor;
use App\Models\Company;
use App\Models\Expense;
use App\Models\Invoice;
use App\Models\Product;
use App\Libraries\MultiDB;
use Illuminate\Bus\Queueable;
use App\Factory\ClientFactory;
use App\Factory\VendorFactory;
use App\Factory\ExpenseFactory;
use App\Factory\InvoiceFactory;
use App\Factory\ProductFactory;
use App\Factory\ClientContactFactory;
use App\Factory\VendorContactFactory;
use App\DataMapper\QuickbooksSettings;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use App\Services\Quickbooks\QuickbooksService;
use QuickBooksOnline\API\Data\IPPSalesReceipt;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use App\Services\Quickbooks\Transformers\ClientTransformer;
use App\Services\Quickbooks\Transformers\VendorTransformer;
use App\Services\Quickbooks\Transformers\ExpenseTransformer;
use App\Services\Quickbooks\Transformers\InvoiceTransformer;
use App\Services\Quickbooks\Transformers\PaymentTransformer;
use App\Services\Quickbooks\Transformers\ProductTransformer;
class QuickbooksSync implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
private array $entities = [
'product' => 'Item',
'client' => 'Customer',
'invoice' => 'Invoice',
'quote' => 'Estimate',
'purchase_order' => 'PurchaseOrder',
'payment' => 'Payment',
'sales' => 'SalesReceipt',
'vendor' => 'Vendor',
'expense' => 'Purchase',
];
private QuickbooksService $qbs;
private ?array $settings;
private Company $company;
public function __construct(public int $company_id, public string $db)
{
}
/**
* Execute the job.
*/
public function handle()
{
MultiDB::setDb($this->db);
$this->company = Company::query()->find($this->company_id);
$this->qbs = new QuickbooksService($this->company);
$this->settings = $this->company->quickbooks->settings;
nlog("here we go!");
foreach($this->entities as $key => $entity) {
if(!$this->syncGate($key, 'pull')) {
continue;
}
$records = $this->qbs->sdk()->fetchRecords($entity);
$this->processEntitySync($key, $records);
}
}
private function syncGate(string $entity, string $direction): bool
{
return (bool) $this->settings[$entity]['sync'] && in_array($this->settings[$entity]['direction'], [$direction,'bidirectional']);
}
private function updateGate(string $entity): bool
{
return (bool) $this->settings[$entity]['sync'] && $this->settings[$entity]['update_record'];
}
// private function harvestQbEntityName(string $entity): string
// {
// return $this->entities[$entity];
// }
private function processEntitySync(string $entity, $records)
{
match($entity){
'client' => $this->syncQbToNinjaClients($records),
'product' => $this->syncQbToNinjaProducts($records),
'invoice' => $this->syncQbToNinjaInvoices($records),
'sales' => $this->syncQbToNinjaInvoices($records),
'vendor' => $this->syncQbToNinjaVendors($records),
// 'quote' => $this->syncInvoices($records),
'expense' => $this->syncQbToNinjaExpenses($records),
// 'purchase_order' => $this->syncInvoices($records),
// 'payment' => $this->syncPayment($records),
default => false,
};
}
private function syncQbToNinjaInvoices($records): void
{
nlog("invoice sync ". count($records));
$invoice_transformer = new InvoiceTransformer($this->company);
foreach($records as $record)
{
nlog($record);
$ninja_invoice_data = $invoice_transformer->qbToNinja($record);
nlog($ninja_invoice_data);
$payment_ids = $ninja_invoice_data['payment_ids'] ?? [];
$client_id = $ninja_invoice_data['client_id'] ?? null;
if(is_null($client_id))
continue;
unset($ninja_invoice_data['payment_ids']);
if($invoice = $this->findInvoice($ninja_invoice_data))
{
$invoice->fill($ninja_invoice_data);
$invoice->saveQuietly();
$invoice = $invoice->calc()->getInvoice()->service()->markSent()->save();
foreach($payment_ids as $payment_id)
{
$payment = $this->qbs->sdk->FindById('Payment', $payment_id);
$payment_transformer = new PaymentTransformer($this->company);
$transformed = $payment_transformer->qbToNinja($payment);
$ninja_payment = $payment_transformer->buildPayment($payment);
$ninja_payment->service()->applyNumber()->save();
$paymentable = new \App\Models\Paymentable();
$paymentable->payment_id = $ninja_payment->id;
$paymentable->paymentable_id = $invoice->id;
$paymentable->paymentable_type = 'invoices';
$paymentable->amount = $transformed['applied'] + $ninja_payment->credits->sum('amount');
$paymentable->created_at = $ninja_payment->date; //@phpstan-ignore-line
$paymentable->save();
$invoice->service()->applyPayment($ninja_payment, $paymentable->amount);
}
if($record instanceof IPPSalesReceipt)
{
$invoice->service()->markPaid()->save();
}
}
$ninja_invoice_data = false;
}
}
private function findInvoice(array $ninja_invoice_data): ?Invoice
{
$search = Invoice::query()
->withTrashed()
->where('company_id', $this->company->id)
->where('number', $ninja_invoice_data['number']);
if($search->count() == 0) {
//new invoice
$invoice = InvoiceFactory::create($this->company->id, $this->company->owner()->id);
$invoice->client_id = $ninja_invoice_data['client_id'];
return $invoice;
} elseif($search->count() == 1) {
return $this->settings['invoice']['update_record'] ? $search->first() : null;
}
return null;
}
private function syncQbToNinjaClients(array $records): void
{
$client_transformer = new ClientTransformer($this->company);
foreach($records as $record)
{
$ninja_client_data = $client_transformer->qbToNinja($record);
if($client = $this->findClient($ninja_client_data))
{
$client->fill($ninja_client_data[0]);
$client->saveQuietly();
$contact = $client->contacts()->where('email', $ninja_client_data[1]['email'])->first();
if(!$contact)
{
$contact = ClientContactFactory::create($this->company->id, $this->company->owner()->id);
$contact->client_id = $client->id;
$contact->send_email = true;
$contact->is_primary = true;
$contact->fill($ninja_client_data[1]);
$contact->saveQuietly();
}
elseif($this->updateGate('client')){
$contact->fill($ninja_client_data[1]);
$contact->saveQuietly();
}
}
}
}
private function syncQbToNinjaVendors(array $records): void
{
$transformer = new VendorTransformer($this->company);
foreach($records as $record)
{
$ninja_data = $transformer->qbToNinja($record);
if($vendor = $this->findVendor($ninja_data))
{
$vendor->fill($ninja_data[0]);
$vendor->saveQuietly();
$contact = $vendor->contacts()->where('email', $ninja_data[1]['email'])->first();
if(!$contact)
{
$contact = VendorContactFactory::create($this->company->id, $this->company->owner()->id);
$contact->vendor_id = $vendor->id;
$contact->send_email = true;
$contact->is_primary = true;
$contact->fill($ninja_data[1]);
$contact->saveQuietly();
}
elseif($this->updateGate('vendor')){
$contact->fill($ninja_data[1]);
$contact->saveQuietly();
}
}
}
}
private function syncQbToNinjaExpenses(array $records): void
{
$transformer = new ExpenseTransformer($this->company);
foreach($records as $record)
{
$ninja_data = $transformer->qbToNinja($record);
if($expense = $this->findExpense($ninja_data))
{
$expense->fill($ninja_data);
$expense->saveQuietly();
}
}
}
private function syncQbToNinjaProducts($records): void
{
$product_transformer = new ProductTransformer($this->company);
foreach($records as $record)
{
$ninja_data = $product_transformer->qbToNinja($record);
if($product = $this->findProduct($ninja_data['hash']))
{
$product->fill($ninja_data);
$product->save();
}
}
}
private function findExpense(array $qb_data): ?Expense
{
$expense = $qb_data;
$search = Expense::query()
->withTrashed()
->where('company_id', $this->company->id)
->where('number', $expense['number']);
if($search->count() == 0) {
return ExpenseFactory::create($this->company->id, $this->company->owner()->id);
}
elseif($search->count() == 1) {
return $this->settings['expense']['update_record'] ? $search->first() : null;
}
return null;
}
private function findVendor(array $qb_data) :?Vendor
{
$vendor = $qb_data[0];
$contact = $qb_data[1];
$vendor_meta = $qb_data[2];
$search = Vendor::query()
->withTrashed()
->where('company_id', $this->company->id)
->where(function ($q) use ($vendor, $vendor_meta, $contact){
$q->where('vendor_hash', $vendor_meta['vendor_hash'])
->orWhere('number', $vendor['number'])
->orWhereHas('contacts', function ($q) use ($contact){
$q->where('email', $contact['email']);
});
});
if($search->count() == 0) {
//new client
return VendorFactory::create($this->company->id, $this->company->owner()->id);
}
elseif($search->count() == 1) {
return $this->settings['vendor']['update_record'] ? $search->first() : null;
}
return null;
}
private function findClient(array $qb_data) :?Client
{
$client = $qb_data[0];
$contact = $qb_data[1];
$client_meta = $qb_data[2];
$search = Client::query()
->withTrashed()
->where('company_id', $this->company->id)
->where(function ($q) use ($client, $client_meta, $contact){
$q->where('client_hash', $client_meta['client_hash'])
->orWhere('number', $client['number'])
->orWhereHas('contacts', function ($q) use ($contact){
$q->where('email', $contact['email']);
});
});
if($search->count() == 0) {
//new client
$client = ClientFactory::create($this->company->id, $this->company->owner()->id);
$client->client_hash = $client_meta['client_hash'];
$client->settings = $client_meta['settings'];
return $client;
}
elseif($search->count() == 1) {
return $this->settings['client']['update_record'] ? $search->first() : null;
}
return null;
}
private function findProduct(string $key): ?Product
{
$search = Product::query()
->withTrashed()
->where('company_id', $this->company->id)
->where('hash', $key);
if($search->count() == 0) {
//new product
$product = ProductFactory::create($this->company->id, $this->company->owner()->id);
$product->hash = $key;
return $product;
} elseif($search->count() == 1) {
return $this->settings['product']['update_record'] ? $search->first() : null;
}
return null;
}
public function middleware()
{
return [new WithoutOverlapping("qbs-{$this->company_id}-{$this->db}")];
}
public function failed($exception)
{
nlog("QuickbooksSync failed => ".$exception->getMessage());
config(['queue.failed.driver' => null]);
}
}

View File

@ -0,0 +1,116 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Services\Quickbooks\Models;
use App\Models\Client;
use App\DataMapper\ClientSync;
use App\Factory\ClientFactory;
use App\Interfaces\SyncInterface;
use App\Factory\ClientContactFactory;
use App\Services\Quickbooks\QuickbooksService;
use App\Services\Quickbooks\Transformers\ClientTransformer;
class QbClient implements SyncInterface
{
public function __construct(public QuickbooksService $service)
{
}
public function find(string $id): mixed
{
return $this->service->sdk->FindById('Customer', $id);
}
public function syncToNinja(array $records): void
{
$transformer = new ClientTransformer($this->service->company);
foreach ($records as $record) {
$ninja_data = $transformer->qbToNinja($record);
if($ninja_data[0]['terms']){
$days = $this->service->findEntityById('Term', $ninja_data[0]['terms']);
nlog($days);
if($days){
$ninja_data[0]['settings']->payment_terms = (string)$days->DueDays;
}
}
if ($client = $this->findClient($ninja_data[0]['id'])) {
$qbc = $this->find($ninja_data[0]['id']);
$client->fill($ninja_data[0]);
$client->service()->applyNumber()->save();
$contact = $client->contacts()->where('email', $ninja_data[1]['email'])->first();
if(!$contact)
{
$contact = ClientContactFactory::create($this->service->company->id, $this->service->company->owner()->id);
$contact->client_id = $client->id;
$contact->send_email = true;
$contact->is_primary = true;
$contact->fill($ninja_data[1]);
$contact->saveQuietly();
}
elseif($this->service->syncable('client', \App\Enum\SyncDirection::PULL)){
$contact->fill($ninja_data[1]);
$contact->saveQuietly();
}
}
}
}
public function syncToForeign(array $records): void
{
}
public function sync(string $id, string $last_updated): void
{
}
private function findClient(string $key): ?Client
{
$search = Client::query()
->withTrashed()
->where('company_id', $this->service->company->id)
->where('sync->qb_id', $key);
if ($search->count() == 0) {
$client = ClientFactory::create($this->service->company->id, $this->service->company->owner()->id);
$sync = new ClientSync();
$sync->qb_id = $key;
$client->sync = $sync;
return $client;
} elseif ($search->count() == 1) {
return $this->service->syncable('client', \App\Enum\SyncDirection::PULL) ? $search->first() : null;
}
return null;
}
}

View File

@ -0,0 +1,221 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Services\Quickbooks\Models;
use Carbon\Carbon;
use App\Models\Invoice;
use App\DataMapper\InvoiceSync;
use App\Factory\InvoiceFactory;
use App\Interfaces\SyncInterface;
use App\Repositories\InvoiceRepository;
use App\Services\Quickbooks\QuickbooksService;
use App\Services\Quickbooks\Transformers\InvoiceTransformer;
use App\Services\Quickbooks\Transformers\PaymentTransformer;
class QbInvoice implements SyncInterface
{
protected InvoiceTransformer $invoice_transformer;
protected InvoiceRepository $invoice_repository;
public function __construct(public QuickbooksService $service)
{
$this->invoice_transformer = new InvoiceTransformer($this->service->company);
$this->invoice_repository = new InvoiceRepository();
}
public function find(string $id): mixed
{
return $this->service->sdk->FindById('Invoice', $id);
}
public function syncToNinja(array $records): void
{
foreach ($records as $record) {
$this->syncNinjaInvoice($record);
}
}
public function syncToForeign(array $records): void
{
}
private function qbInvoiceUpdate(array $ninja_invoice_data, Invoice $invoice): void
{
$current_ninja_invoice_balance = $invoice->balance;
$qb_invoice_balance = $ninja_invoice_data['balance'];
if(floatval($current_ninja_invoice_balance) == floatval($qb_invoice_balance))
{
nlog('Invoice balance is the same, skipping update of line items');
unset($ninja_invoice_data['line_items']);
$invoice->fill($ninja_invoice_data);
$invoice->saveQuietly();
}
else{
nlog('Invoice balance is different, updating line items');
$this->invoice_repository->save($ninja_invoice_data, $invoice);
}
}
private function findInvoice(string $id, ?string $client_id = null): ?Invoice
{
$search = Invoice::query()
->withTrashed()
->where('company_id', $this->service->company->id)
->where('sync->qb_id', $id);
if($search->count() == 0) {
$invoice = InvoiceFactory::create($this->service->company->id, $this->service->company->owner()->id);
$invoice->client_id = (int)$client_id;
$sync = new InvoiceSync();
$sync->qb_id = $id;
$invoice->sync = $sync;
return $invoice;
} elseif($search->count() == 1) {
return $this->service->syncable('invoice', \App\Enum\SyncDirection::PULL) ? $search->first() : null;
}
return null;
}
public function sync($id, string $last_updated): void
{
$qb_record = $this->find($id);
nlog($qb_record);
if($this->service->syncable('invoice', \App\Enum\SyncDirection::PULL))
{
$invoice = $this->findInvoice($id);
nlog("Comparing QB last updated: " . $last_updated);
nlog("Comparing Ninja last updated: " . $invoice->updated_at);
if(data_get($qb_record, 'TxnStatus') === 'Voided')
{
$this->delete($id);
return;
}
if(!$invoice->id){
$this->syncNinjaInvoice($qb_record);
}
elseif(Carbon::parse($last_updated)->gt(Carbon::parse($invoice->updated_at)) || $qb_record->SyncToken == '0')
{
$ninja_invoice_data = $this->invoice_transformer->qbToNinja($qb_record);
nlog($ninja_invoice_data);
$this->invoice_repository->save($ninja_invoice_data, $invoice);
}
}
}
/**
* syncNinjaInvoice
*
* @param $record
* @return void
*/
public function syncNinjaInvoice($record): void
{
$ninja_invoice_data = $this->invoice_transformer->qbToNinja($record);
nlog($ninja_invoice_data);
$payment_ids = $ninja_invoice_data['payment_ids'] ?? [];
$client_id = $ninja_invoice_data['client_id'] ?? null;
if (is_null($client_id)) {
return;
}
unset($ninja_invoice_data['payment_ids']);
if ($invoice = $this->findInvoice($ninja_invoice_data['id'], $ninja_invoice_data['client_id'])) {
if ($invoice->id) {
$this->qbInvoiceUpdate($ninja_invoice_data, $invoice);
}
//new invoice scaffold
$invoice->fill($ninja_invoice_data);
$invoice->saveQuietly();
$invoice = $invoice->calc()->getInvoice()->service()->markSent()->applyNumber()->createInvitations()->save();
foreach ($payment_ids as $payment_id) {
$payment = $this->service->sdk->FindById('Payment', $payment_id);
$payment_transformer = new PaymentTransformer($this->service->company);
$transformed = $payment_transformer->qbToNinja($payment);
$ninja_payment = $payment_transformer->buildPayment($payment);
$ninja_payment->service()->applyNumber()->save();
$paymentable = new \App\Models\Paymentable();
$paymentable->payment_id = $ninja_payment->id;
$paymentable->paymentable_id = $invoice->id;
$paymentable->paymentable_type = 'invoices';
$paymentable->amount = $transformed['applied'] + $ninja_payment->credits->sum('amount');
$paymentable->created_at = $ninja_payment->date; //@phpstan-ignore-line
$paymentable->save();
$invoice->service()->applyPayment($ninja_payment, $paymentable->amount);
}
if ($record instanceof \QuickBooksOnline\API\Data\IPPSalesReceipt) {
$invoice->service()->markPaid()->save();
}
}
$ninja_invoice_data = false;
}
/**
* Deletes the invoice from Ninja and sets the sync to null
*
* @param string $id
* @return void
*/
public function delete($id): void
{
$qb_record = $this->find($id);
if($this->service->syncable('invoice', \App\Enum\SyncDirection::PULL) && $invoice = $this->findInvoice($id))
{
$invoice->sync = null;
$invoice->saveQuietly();
$this->invoice_repository->delete($invoice);
}
}
}

View File

@ -0,0 +1,102 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Services\Quickbooks\Models;
use Carbon\Carbon;
use App\Models\Product;
use App\DataMapper\ProductSync;
use App\Factory\ProductFactory;
use App\Interfaces\SyncInterface;
use App\Services\Quickbooks\QuickbooksService;
use App\Services\Quickbooks\Transformers\ProductTransformer;
class QbProduct implements SyncInterface
{
protected ProductTransformer $product_transformer;
public function __construct(public QuickbooksService $service)
{
$this->product_transformer = new ProductTransformer($service->company);
}
public function find(string $id): mixed
{
return $this->service->sdk->FindById('Item', $id);
}
public function syncToNinja(array $records): void
{
foreach ($records as $record) {
$ninja_data = $this->product_transformer->qbToNinja($record);
if ($product = $this->findProduct($ninja_data['id'])) {
$product->fill($ninja_data);
$product->save();
}
}
}
public function syncToForeign(array $records): void
{
}
private function findProduct(string $key): ?Product
{
$search = Product::query()
->withTrashed()
->where('company_id', $this->service->company->id)
->where('sync->qb_id', $key);
if($search->count() == 0) {
$product = ProductFactory::create($this->service->company->id, $this->service->company->owner()->id);
$sync = new ProductSync();
$sync->qb_id = $key;
$product->sync = $sync;
return $product;
} elseif($search->count() == 1) {
return $this->service->syncable('product', \App\Enum\SyncDirection::PULL) ? $search->first() : null;
}
return null;
}
public function sync(string $id, string $last_updated): void
{
$qb_record = $this->find($id);
if($this->service->syncable('product', \App\Enum\SyncDirection::PULL) && $ninja_record = $this->findProduct($id))
{
if(Carbon::parse($last_updated) > Carbon::parse($ninja_record->updated_at))
{
$ninja_data = $this->product_transformer->qbToNinja($qb_record);
$ninja_record->fill($ninja_data);
$ninja_record->save();
}
}
}
}

View File

@ -11,17 +11,21 @@
namespace App\Services\Quickbooks; namespace App\Services\Quickbooks;
use App\Factory\ClientContactFactory;
use App\Factory\ClientFactory;
use App\Factory\InvoiceFactory;
use App\Factory\ProductFactory;
use App\Models\Client; use App\Models\Client;
use App\Models\Company; use App\Models\Company;
use App\Models\Invoice; use App\Models\Invoice;
use App\Models\Product; use App\Models\Product;
use App\Services\Quickbooks\Jobs\QuickbooksSync; use App\Factory\ClientFactory;
use App\Factory\InvoiceFactory;
use App\Factory\ProductFactory;
use App\DataMapper\QuickbooksSync;
use App\Factory\ClientContactFactory;
use QuickBooksOnline\API\Core\CoreConstants; use QuickBooksOnline\API\Core\CoreConstants;
use App\Services\Quickbooks\Models\QbInvoice;
use App\Services\Quickbooks\Models\QbProduct;
use QuickBooksOnline\API\DataService\DataService; use QuickBooksOnline\API\DataService\DataService;
use App\Services\Quickbooks\Jobs\QuickbooksImport;
use App\Services\Quickbooks\Models\QbClient;
use App\Services\Quickbooks\Transformers\ClientTransformer; use App\Services\Quickbooks\Transformers\ClientTransformer;
use App\Services\Quickbooks\Transformers\InvoiceTransformer; use App\Services\Quickbooks\Transformers\InvoiceTransformer;
use App\Services\Quickbooks\Transformers\PaymentTransformer; use App\Services\Quickbooks\Transformers\PaymentTransformer;
@ -31,9 +35,19 @@ class QuickbooksService
{ {
public DataService $sdk; public DataService $sdk;
public QbInvoice $invoice;
public QbProduct $product;
public QbClient $client;
public QuickbooksSync $settings;
private bool $testMode = true; private bool $testMode = true;
public function __construct(private Company $company) private bool $try_refresh = true;
public function __construct(public Company $company)
{ {
$this->init(); $this->init();
} }
@ -46,7 +60,6 @@ class QuickbooksService
'ClientSecret' => config('services.quickbooks.client_secret'), 'ClientSecret' => config('services.quickbooks.client_secret'),
'auth_mode' => 'oauth2', 'auth_mode' => 'oauth2',
'scope' => "com.intuit.quickbooks.accounting", 'scope' => "com.intuit.quickbooks.accounting",
// 'RedirectURI' => 'https://developer.intuit.com/v2/OAuth2Playground/RedirectUrl',
'RedirectURI' => $this->testMode ? 'https://grok.romulus.com.au/quickbooks/authorized' : 'https://invoicing.co/quickbooks/authorized', 'RedirectURI' => $this->testMode ? 'https://grok.romulus.com.au/quickbooks/authorized' : 'https://invoicing.co/quickbooks/authorized',
'baseUrl' => $this->testMode ? CoreConstants::SANDBOX_DEVELOPMENT : CoreConstants::QBO_BASEURL, 'baseUrl' => $this->testMode ? CoreConstants::SANDBOX_DEVELOPMENT : CoreConstants::QBO_BASEURL,
]; ];
@ -55,18 +68,85 @@ class QuickbooksService
$this->sdk = DataService::Configure($merged); $this->sdk = DataService::Configure($merged);
$this->sdk->setLogLocation(storage_path("logs/quickbooks.log")); // $this->sdk->setLogLocation(storage_path("logs/quickbooks.log"));
$this->sdk->enableLog(); $this->sdk->enableLog();
$this->sdk->setMinorVersion("73"); $this->sdk->setMinorVersion("73");
$this->sdk->throwExceptionOnError(true); $this->sdk->throwExceptionOnError(true);
$this->checkToken();
$this->invoice = new QbInvoice($this);
$this->product = new QbProduct($this);
$this->client = new QbClient($this);
$this->settings = $this->company->quickbooks->settings;
$this->checkDefaultAccounts();
return $this; return $this;
} }
private function checkDefaultAccounts(): self
{
$accountQuery = "SELECT * FROM Account WHERE AccountType IN ('Income', 'Cost of Goods Sold')";
if(strlen($this->settings->default_income_account) == 0 || strlen($this->settings->default_expense_account) == 0){
nlog("Checking default accounts for company {$this->company->company_key}");
$accounts = $this->sdk->Query($accountQuery);
nlog($accounts);
$find_income_account = true;
$find_expense_account = true;
foreach ($accounts as $account) {
if ($account->AccountType->value == 'Income' && $find_income_account) {
$this->settings->default_income_account = $account->Id->value;
$find_income_account = false;
} elseif ($account->AccountType->value == 'Cost of Goods Sold' && $find_expense_account) {
$this->settings->default_expense_account = $account->Id->value;
$find_expense_account = false;
}
}
nlog($this->settings);
$this->company->quickbooks->settings = $this->settings;
$this->company->save();
}
return $this;
}
private function checkToken(): self
{
if($this->company->quickbooks->accessTokenExpiresAt == 0 || $this->company->quickbooks->accessTokenExpiresAt > time())
return $this;
if($this->company->quickbooks->accessTokenExpiresAt && $this->company->quickbooks->accessTokenExpiresAt < time() && $this->try_refresh){
$this->sdk()->refreshToken($this->company->quickbooks->refresh_token);
$this->company = $this->company->fresh();
$this->try_refresh = false;
$this->init();
return $this;
}
nlog('Quickbooks token expired and could not be refreshed => ' .$this->company->company_key);
throw new \Exception('Quickbooks token expired and could not be refreshed');
}
private function ninjaAccessToken(): array private function ninjaAccessToken(): array
{ {
return isset($this->company->quickbooks->accessTokenKey) ? [ return $this->company->quickbooks->accessTokenExpiresAt > 0 ? [
'accessTokenKey' => $this->company->quickbooks->accessTokenKey, 'accessTokenKey' => $this->company->quickbooks->accessTokenKey,
'refreshTokenKey' => $this->company->quickbooks->refresh_token, 'refreshTokenKey' => $this->company->quickbooks->refresh_token,
'QBORealmID' => $this->company->quickbooks->realmID, 'QBORealmID' => $this->company->quickbooks->realmID,
@ -85,7 +165,24 @@ class QuickbooksService
*/ */
public function syncFromQb(): void public function syncFromQb(): void
{ {
QuickbooksSync::dispatch($this->company->id, $this->company->db); QuickbooksImport::dispatch($this->company->id, $this->company->db);
}
public function findEntityById(string $entity, string $id): mixed
{
return $this->sdk->FindById($entity, $id);
}
/**
* Flag to determine if a sync is allowed in either direction
*
* @param string $entity
* @param \App\Enum\SyncDirection $direction
* @return bool
*/
public function syncable(string $entity, \App\Enum\SyncDirection $direction): bool
{
return $this->settings->{$entity}->direction === $direction || $this->settings->{$entity}->direction === \App\Enum\SyncDirection::BIDIRECTIONAL;
} }
} }

View File

@ -33,7 +33,7 @@ class SdkWrapper
private function init(): self private function init(): self
{ {
isset($this->company->quickbooks->accessTokenKey) ? $this->setNinjaAccessToken($this->company->quickbooks) : null; $this->setNinjaAccessToken($this->company->quickbooks);
return $this; return $this;
@ -104,16 +104,24 @@ class SdkWrapper
$this->setAccessToken($token); $this->setAccessToken($token);
if($token_object->accessTokenExpiresAt < time()){ if($token_object->accessTokenExpiresAt != 0 && $token_object->accessTokenExpiresAt < time()){
$new_token = $this->sdk->getOAuth2LoginHelper()->refreshToken(); $this->refreshToken($token_object->refresh_token);
$this->setAccessToken($new_token);
$this->saveOAuthToken($this->accessToken());
} }
return $this; return $this;
} }
public function refreshToken(string $refresh_token): self
{
$new_token = $this->sdk->getOAuth2LoginHelper()->refreshAccessTokenWithRefreshToken($refresh_token);
$this->setAccessToken($new_token);
$this->saveOAuthToken($this->accessToken());
return $this;
}
/** /**
* SetsAccessToken * SetsAccessToken
* *

View File

@ -66,7 +66,8 @@ class BaseTransformer
$client = Client::query() $client = Client::query()
->withTrashed() ->withTrashed()
->where('company_id', $this->company->id) ->where('company_id', $this->company->id)
->where('number', $customer_reference_id) // ->where('number', $customer_reference_id)
->where('sync->qb_id', $customer_reference_id)
->first(); ->first();
return $client ? $client->id : null; return $client ? $client->id : null;

View File

@ -31,6 +31,7 @@ class ClientTransformer extends BaseTransformer
public function transform(mixed $data): array public function transform(mixed $data): array
{ {
nlog($data);
$contact = [ $contact = [
'first_name' => data_get($data, 'GivenName'), 'first_name' => data_get($data, 'GivenName'),
@ -40,6 +41,7 @@ class ClientTransformer extends BaseTransformer
]; ];
$client = [ $client = [
'id' => data_get($data, 'Id.value', null),
'name' => data_get($data,'CompanyName', ''), 'name' => data_get($data,'CompanyName', ''),
'address1' => data_get($data, 'BillAddr.Line1', ''), 'address1' => data_get($data, 'BillAddr.Line1', ''),
'address2' => data_get($data, 'BillAddr.Line2', ''), 'address2' => data_get($data, 'BillAddr.Line2', ''),
@ -53,16 +55,20 @@ class ClientTransformer extends BaseTransformer
'shipping_country_id' => $this->resolveCountry(data_get($data, 'ShipAddr.Country', '')), 'shipping_country_id' => $this->resolveCountry(data_get($data, 'ShipAddr.Country', '')),
'shipping_state' => data_get($data, 'ShipAddr.CountrySubDivisionCode', ''), 'shipping_state' => data_get($data, 'ShipAddr.CountrySubDivisionCode', ''),
'shipping_postal_code' => data_get($data, 'BillAddr.PostalCode', ''), 'shipping_postal_code' => data_get($data, 'BillAddr.PostalCode', ''),
'number' => data_get($data, 'Id.value', ''), 'client_hash' => data_get($data, 'V4IDPseudonym', \Illuminate\Support\Str::random(32)),
'vat_number' => data_get($data, 'PrimaryTaxIdentifier', ''),
'id_number' => data_get($data, 'BusinessNumber', ''),
'terms' => data_get($data, 'SalesTermRef.value', false),
'is_tax_exempt' => !data_get($data, 'Taxable', false),
'private_notes' => data_get($data, 'Notes', ''),
]; ];
$settings = ClientSettings::defaults(); $settings = ClientSettings::defaults();
$settings->currency_id = (string) $this->resolveCurrency(data_get($data, 'CurrencyRef.value')); $settings->currency_id = (string) $this->resolveCurrency(data_get($data, 'CurrencyRef.value'));
$new_client_merge = [ $client['settings'] = $settings;
'client_hash' => data_get($data, 'V4IDPseudonym', \Illuminate\Support\Str::random(32)),
'settings' => $settings, $new_client_merge = [];
];
return [$client, $contact, $new_client_merge]; return [$client, $contact, $new_client_merge];
} }

View File

@ -38,6 +38,7 @@ class InvoiceTransformer extends BaseTransformer
$client_id = $this->getClientId(data_get($qb_data, 'CustomerRef.value', null)); $client_id = $this->getClientId(data_get($qb_data, 'CustomerRef.value', null));
return $client_id ? [ return $client_id ? [
'id' => data_get($qb_data, 'Id.value', false),
'client_id' => $client_id, 'client_id' => $client_id,
'number' => data_get($qb_data, 'DocNumber', false), 'number' => data_get($qb_data, 'DocNumber', false),
'date' => data_get($qb_data, 'TxnDate', now()->format('Y-m-d')), 'date' => data_get($qb_data, 'TxnDate', now()->format('Y-m-d')),
@ -45,15 +46,62 @@ class InvoiceTransformer extends BaseTransformer
'public_notes' => data_get($qb_data, 'CustomerMemo.value', false), 'public_notes' => data_get($qb_data, 'CustomerMemo.value', false),
'due_date' => data_get($qb_data, 'DueDate', null), 'due_date' => data_get($qb_data, 'DueDate', null),
'po_number' => data_get($qb_data, 'PONumber', ""), 'po_number' => data_get($qb_data, 'PONumber', ""),
'partial' => data_get($qb_data, 'Deposit', 0), 'partial' => (float)data_get($qb_data, 'Deposit', 0),
'line_items' => $this->getLineItems(data_get($qb_data, 'Line', [])), 'line_items' => $this->getLineItems(data_get($qb_data, 'Line', []), data_get($qb_data, 'ApplyTaxAfterDiscount', 'true')),
'payment_ids' => $this->getPayments($qb_data), 'payment_ids' => $this->getPayments($qb_data),
'status_id' => Invoice::STATUS_SENT, 'status_id' => Invoice::STATUS_SENT,
'tax_rate1' => $rate = data_get($qb_data,'TxnTaxDetail.TaxLine.TaxLineDetail.TaxPercent', 0), 'tax_rate1' => $rate = $this->calculateTotalTax($qb_data),
'tax_name1' => $rate > 0 ? "Sales Tax" : "", 'tax_name1' => $rate > 0 ? "Sales Tax" : "",
'custom_surcharge1' => $this->checkIfDiscountAfterTax($qb_data),
] : false; ] : false;
} }
private function checkIfDiscountAfterTax($qb_data)
{
if($qb_data->ApplyTaxAfterDiscount == 'true'){
return 0;
}
foreach(data_get($qb_data, 'Line', []) as $line)
{
if(data_get($line, 'DetailType.value') == 'DiscountLineDetail')
{
if(!isset($this->company->custom_fields->surcharge1))
{
$this->company->custom_fields->surcharge1 = ctrans('texts.discount');
$this->company->save();
}
return (float)data_get($line, 'Amount', 0) * -1;
}
}
return 0;
}
private function calculateTotalTax($qb_data)
{
$taxLines = data_get($qb_data, 'TxnTaxDetail.TaxLine', []);
if (!is_array($taxLines)) {
$taxLines = [$taxLines];
}
$totalTaxRate = 0;
foreach ($taxLines as $taxLine) {
$taxRate = data_get($taxLine, 'TaxLineDetail.TaxPercent', 0);
$totalTaxRate += $taxRate;
}
return (float)$totalTaxRate;
}
private function getPayments(mixed $qb_data) private function getPayments(mixed $qb_data)
{ {
$payments = []; $payments = [];
@ -81,27 +129,45 @@ class InvoiceTransformer extends BaseTransformer
} }
private function getLineItems(mixed $qb_items) private function getLineItems(mixed $qb_items, string $include_discount = 'true')
{ {
$items = []; $items = [];
foreach($qb_items as $qb_item) foreach($qb_items as $qb_item)
{ {
$item = new InvoiceItem;
$item->product_key = data_get($qb_item, 'SalesItemLineDetail.ItemRef.name', '');
$item->notes = data_get($qb_item,'Description', '');
$item->quantity = data_get($qb_item,'SalesItemLineDetail.Qty', 0);
$item->cost = data_get($qb_item, 'SalesItemLineDetail.UnitPrice', 0);
$item->discount = data_get($item,'DiscountRate', data_get($qb_item,'DiscountAmount', 0));
$item->is_amount_discount = data_get($qb_item,'DiscountAmount', 0) > 0 ? true : false;
$item->type_id = stripos(data_get($qb_item, 'ItemAccountRef.name') ?? '', 'Service') !== false ? '2' : '1';
$item->tax_id = data_get($qb_item, 'TaxCodeRef.value', '') == 'NON' ? Product::PRODUCT_TYPE_EXEMPT : $item->type_id;
$item->tax_rate1 = data_get($qb_item, 'TxnTaxDetail.TaxLine.TaxLineDetail.TaxPercent', 0);
$item->tax_name1 = $item->tax_rate1 > 0 ? "Sales Tax" : "";
$items[] = (object)$item;
}
nlog($items); if(data_get($qb_item, 'DetailType.value') == 'SalesItemLineDetail')
{
$item = new InvoiceItem;
$item->product_key = data_get($qb_item, 'SalesItemLineDetail.ItemRef.name', '');
$item->notes = data_get($qb_item,'Description', '');
$item->quantity = (float)data_get($qb_item,'SalesItemLineDetail.Qty', 0);
$item->cost = (float)data_get($qb_item, 'SalesItemLineDetail.UnitPrice', 0);
$item->discount = (float)data_get($item,'DiscountRate', data_get($qb_item,'DiscountAmount', 0));
$item->is_amount_discount = data_get($qb_item,'DiscountAmount', 0) > 0 ? true : false;
$item->type_id = stripos(data_get($qb_item, 'ItemAccountRef.name') ?? '', 'Service') !== false ? '2' : '1';
$item->tax_id = data_get($qb_item, 'TaxCodeRef.value', '') == 'NON' ? Product::PRODUCT_TYPE_EXEMPT : $item->type_id;
$item->tax_rate1 = (float)data_get($qb_item, 'TxnTaxDetail.TaxLine.TaxLineDetail.TaxPercent', 0);
$item->tax_name1 = $item->tax_rate1 > 0 ? "Sales Tax" : "";
$items[] = (object)$item;
}
if(data_get($qb_item, 'DetailType.value') == 'DiscountLineDetail' && $include_discount == 'true')
{
$item = new InvoiceItem();
$item->product_key = ctrans('texts.discount');
$item->notes = ctrans('texts.discount');
$item->quantity = 1;
$item->cost = (float)data_get($qb_item, 'Amount', 0) * -1;
$item->discount = 0;
$item->is_amount_discount = true;
$item->type_id = '1';
$item->tax_id = Product::PRODUCT_TYPE_PHYSICAL;
$items[] = (object)$item;
}
}
return $items; return $items;

View File

@ -91,7 +91,7 @@ class PaymentTransformer extends BaseTransformer
if(!$credit_line) if(!$credit_line)
return $payment; return $payment;
$credit = \App\Factory\CreditFactory::create($this->company->id, $this->company->owner()->id, $payment->client_id); $credit = \App\Factory\CreditFactory::create($this->company->id, $this->company->owner()->id);
$credit->client_id = $payment->client_id; $credit->client_id = $payment->client_id;
$line = new \App\DataMapper\InvoiceItem(); $line = new \App\DataMapper\InvoiceItem();

View File

@ -30,10 +30,9 @@ class ProductTransformer extends BaseTransformer
public function transform(mixed $data): array public function transform(mixed $data): array
{ {
nlog(data_get($data, 'Id', null));
return [ return [
'hash' => data_get($data, 'Id.value', null), 'id' => data_get($data, 'Id.value', null),
'product_key' => data_get($data, 'Name', data_get($data, 'FullyQualifiedName','')), 'product_key' => data_get($data, 'Name', data_get($data, 'FullyQualifiedName','')),
'notes' => data_get($data, 'Description', ''), 'notes' => data_get($data, 'Description', ''),
'cost' => data_get($data, 'PurchaseCost', 0), 'cost' => data_get($data, 'PurchaseCost', 0),

View File

@ -166,6 +166,10 @@ class InvoiceTransformer extends EntityTransformer
$data['reminder_schedule'] = (string) $invoice->reminderSchedule(); $data['reminder_schedule'] = (string) $invoice->reminderSchedule();
} }
if (request()->has('is_locked') && request()->query('is_locked') == 'true') {
$data['is_locked'] = (bool) $invoice->isLocked();
}
return $data; return $data;
} }

View File

@ -41,6 +41,7 @@
"authorizenet/authorizenet": "^2.0", "authorizenet/authorizenet": "^2.0",
"awobaz/compoships": "^2.1", "awobaz/compoships": "^2.1",
"aws/aws-sdk-php": "^3.319", "aws/aws-sdk-php": "^3.319",
"babenkoivan/elastic-scout-driver": "^4.0",
"bacon/bacon-qr-code": "^2.0", "bacon/bacon-qr-code": "^2.0",
"beganovich/snappdf": "dev-master", "beganovich/snappdf": "dev-master",
"braintree/braintree_php": "^6.0", "braintree/braintree_php": "^6.0",
@ -68,6 +69,7 @@
"josemmo/facturae-php": "^1.7", "josemmo/facturae-php": "^1.7",
"laracasts/presenter": "^0.2.1", "laracasts/presenter": "^0.2.1",
"laravel/framework": "^11.0", "laravel/framework": "^11.0",
"laravel/scout": "^10.11",
"laravel/slack-notification-channel": "^3", "laravel/slack-notification-channel": "^3",
"laravel/socialite": "^5", "laravel/socialite": "^5",
"laravel/tinker": "^2.7", "laravel/tinker": "^2.7",

1094
composer.lock generated

File diff suppressed because it is too large Load Diff

24
config/elastic.client.php Normal file
View File

@ -0,0 +1,24 @@
<?php declare(strict_types=1);
return [
'default' => env('ELASTIC_CONNECTION', 'default'),
'connections' => [
'default' => [
'hosts' => [
env('ELASTIC_HOST'),
],
// configure basic authentication
'basicAuthentication' => [
env('ELASTIC_USERNAME'),
env('ELASTIC_PASSWORD'),
],
// configure HTTP client (Guzzle by default)
'httpClientOptions' => [
'timeout' => 2,
'verify_host' => false, // Disable SSL verification
'verify_peer' => false,
],
],
],
];

View File

@ -0,0 +1,5 @@
<?php declare(strict_types=1);
return [
'refresh_documents' => env('ELASTIC_SCOUT_DRIVER_REFRESH_DOCUMENTS', false),
];

202
config/scout.php Normal file
View File

@ -0,0 +1,202 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Search Engine
|--------------------------------------------------------------------------
|
| This option controls the default search connection that gets used while
| using Laravel Scout. This connection is used when syncing all models
| to the search service. You should adjust this based on your needs.
|
| Supported: "algolia", "meilisearch", "typesense",
| "database", "collection", "null"
|
*/
'driver' => env('SCOUT_DRIVER', 'algolia'),
/*
|--------------------------------------------------------------------------
| Index Prefix
|--------------------------------------------------------------------------
|
| Here you may specify a prefix that will be applied to all search index
| names used by Scout. This prefix may be useful if you have multiple
| "tenants" or applications sharing the same search infrastructure.
|
*/
'prefix' => env('SCOUT_PREFIX', ''),
/*
|--------------------------------------------------------------------------
| Queue Data Syncing
|--------------------------------------------------------------------------
|
| This option allows you to control if the operations that sync your data
| with your search engines are queued. When this is set to "true" then
| all automatic data syncing will get queued for better performance.
|
*/
'queue' => env('SCOUT_QUEUE', true),
/*
|--------------------------------------------------------------------------
| Database Transactions
|--------------------------------------------------------------------------
|
| This configuration option determines if your data will only be synced
| with your search indexes after every open database transaction has
| been committed, thus preventing any discarded data from syncing.
|
*/
'after_commit' => false,
/*
|--------------------------------------------------------------------------
| Chunk Sizes
|--------------------------------------------------------------------------
|
| These options allow you to control the maximum chunk size when you are
| mass importing data into the search engine. This allows you to fine
| tune each of these chunk sizes based on the power of the servers.
|
*/
'chunk' => [
'searchable' => 500,
'unsearchable' => 500,
],
/*
|--------------------------------------------------------------------------
| Soft Deletes
|--------------------------------------------------------------------------
|
| This option allows to control whether to keep soft deleted records in
| the search indexes. Maintaining soft deleted records can be useful
| if your application still needs to search for the records later.
|
*/
'soft_delete' => true,
/*
|--------------------------------------------------------------------------
| Identify User
|--------------------------------------------------------------------------
|
| This option allows you to control whether to notify the search engine
| of the user performing the search. This is sometimes useful if the
| engine supports any analytics based on this application's users.
|
| Supported engines: "algolia"
|
*/
'identify' => env('SCOUT_IDENTIFY', false),
/*
|--------------------------------------------------------------------------
| Algolia Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your Algolia settings. Algolia is a cloud hosted
| search engine which works great with Scout out of the box. Just plug
| in your application ID and admin API key to get started searching.
|
*/
'algolia' => [
'id' => env('ALGOLIA_APP_ID', ''),
'secret' => env('ALGOLIA_SECRET', ''),
],
/*
|--------------------------------------------------------------------------
| Meilisearch Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your Meilisearch settings. Meilisearch is an open
| source search engine with minimal configuration. Below, you can state
| the host and key information for your own Meilisearch installation.
|
| See: https://www.meilisearch.com/docs/learn/configuration/instance_options#all-instance-options
|
*/
'meilisearch' => [
'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'),
'key' => env('MEILISEARCH_KEY'),
'index-settings' => [
// 'users' => [
// 'filterableAttributes'=> ['id', 'name', 'email'],
// ],
],
],
/*
|--------------------------------------------------------------------------
| Typesense Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your Typesense settings. Typesense is an open
| source search engine using minimal configuration. Below, you will
| state the host, key, and schema configuration for the instance.
|
*/
'typesense' => [
'client-settings' => [
'api_key' => env('TYPESENSE_API_KEY', 'xyz'),
'nodes' => [
[
'host' => env('TYPESENSE_HOST', 'localhost'),
'port' => env('TYPESENSE_PORT', '8108'),
'path' => env('TYPESENSE_PATH', ''),
'protocol' => env('TYPESENSE_PROTOCOL', 'http'),
],
],
'nearest_node' => [
'host' => env('TYPESENSE_HOST', 'localhost'),
'port' => env('TYPESENSE_PORT', '8108'),
'path' => env('TYPESENSE_PATH', ''),
'protocol' => env('TYPESENSE_PROTOCOL', 'http'),
],
'connection_timeout_seconds' => env('TYPESENSE_CONNECTION_TIMEOUT_SECONDS', 2),
'healthcheck_interval_seconds' => env('TYPESENSE_HEALTHCHECK_INTERVAL_SECONDS', 30),
'num_retries' => env('TYPESENSE_NUM_RETRIES', 3),
'retry_interval_seconds' => env('TYPESENSE_RETRY_INTERVAL_SECONDS', 1),
],
'model-settings' => [
// User::class => [
// 'collection-schema' => [
// 'fields' => [
// [
// 'name' => 'id',
// 'type' => 'string',
// ],
// [
// 'name' => 'name',
// 'type' => 'string',
// ],
// [
// 'name' => 'created_at',
// 'type' => 'int64',
// ],
// ],
// 'default_sorting_field' => 'created_at',
// ],
// 'search-parameters' => [
// 'query_by' => 'name'
// ],
// ],
],
],
];

View File

@ -21,8 +21,13 @@ return new class extends Migration
$fields->publicKey = ''; $fields->publicKey = '';
$fields->secretKey = ''; $fields->secretKey = '';
$fields->testMode = false; $fields->testMode = false;
$fields->threeds = false; $fields->gatewayId = '';
if($gateway = Gateway::find(64)){
$gateway->fields = json_encode($fields);
$gateway->save();
}else{
$powerboard = new Gateway(); $powerboard = new Gateway();
$powerboard->id = 64; $powerboard->id = 64;
$powerboard->name = 'CBA PowerBoard'; $powerboard->name = 'CBA PowerBoard';
@ -34,7 +39,8 @@ return new class extends Migration
$powerboard->fields = json_encode($fields); $powerboard->fields = json_encode($fields);
$powerboard->save(); $powerboard->save();
}
Schema::table("company_gateways", function (\Illuminate\Database\Schema\Blueprint $table){ Schema::table("company_gateways", function (\Illuminate\Database\Schema\Blueprint $table){
$table->text('settings')->nullable(); $table->text('settings')->nullable();
}); });

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('clients', function (Blueprint $table) {
$table->text('sync')->nullable();
});
Schema::table('invoices', function (Blueprint $table) {
$table->text('sync')->nullable();
});
Schema::table('products', function (Blueprint $table) {
$table->text('sync')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
//
}
};

View File

@ -89,7 +89,7 @@ class PaymentLibrariesSeeder extends Seeder
['id' => 61, 'name' => 'PayPal Platform', 'provider' => 'PayPal_PPCP', 'key' => '80af24a6a691230bbec33e930ab40666', 'fields' => '{"testMode":false}'], ['id' => 61, 'name' => 'PayPal Platform', 'provider' => 'PayPal_PPCP', 'key' => '80af24a6a691230bbec33e930ab40666', 'fields' => '{"testMode":false}'],
['id' => 62, 'name' => 'BTCPay', 'provider' => 'BTCPay', 'key' => 'vpyfbmdrkqcicpkjqdusgjfluebftuva', 'fields' => '{"btcpayUrl":"", "apiKey":"", "storeId":"", "webhookSecret":""}'], ['id' => 62, 'name' => 'BTCPay', 'provider' => 'BTCPay', 'key' => 'vpyfbmdrkqcicpkjqdusgjfluebftuva', 'fields' => '{"btcpayUrl":"", "apiKey":"", "storeId":"", "webhookSecret":""}'],
['id' => 63, 'name' => 'Rotessa', 'is_offsite' => false, 'sort_order' => 22, 'provider' => 'Rotessa', 'key' => '91be24c7b792230bced33e930ac61676', 'fields' => '{"apiKey":"", "testMode":false}'], ['id' => 63, 'name' => 'Rotessa', 'is_offsite' => false, 'sort_order' => 22, 'provider' => 'Rotessa', 'key' => '91be24c7b792230bced33e930ac61676', 'fields' => '{"apiKey":"", "testMode":false}'],
['id' => 64, 'name' => 'CBA PowerBoard', 'is_offsite' => false, 'sort_order' => 26, 'provider' => 'CBAPowerBoard', 'key' => 'b67581d804dbad1743b61c57285142ad', 'fields' => '{"publicKey":"", "secretKey":"", "testMode":false, "Threeds":true}'], ['id' => 64, 'name' => 'CBA PowerBoard', 'is_offsite' => false, 'sort_order' => 26, 'provider' => 'CBAPowerBoard', 'key' => 'b67581d804dbad1743b61c57285142ad', 'fields' => '{"publicKey":"", "secretKey":"", "testMode":false, "gatewayId":""}'],
['id' => 65, 'name' => 'Blockonomics', 'is_offsite' => false, 'sort_order' => 27, 'provider' => 'Blockonomics', 'key' => 'wbhf02us6owgo7p4nfjd0ymssdshks4d', 'fields' => '{"apiKey":"", "callbackSecret":""}'], ['id' => 65, 'name' => 'Blockonomics', 'is_offsite' => false, 'sort_order' => 27, 'provider' => 'Blockonomics', 'key' => 'wbhf02us6owgo7p4nfjd0ymssdshks4d', 'fields' => '{"apiKey":"", "callbackSecret":""}'],
]; ];

View File

@ -5331,6 +5331,7 @@ Développe automatiquement la section des notes dans le tableau de produits pour
'country_Melilla' => 'Melilla', 'country_Melilla' => 'Melilla',
'country_Ceuta' => 'Ceuta', 'country_Ceuta' => 'Ceuta',
'country_Canary Islands' => 'Îles Canaries', 'country_Canary Islands' => 'Îles Canaries',
'lang_Vietnamese' => 'Vietnamien',
'invoice_status_changed' => 'Veuillez noter que l\'état de votre facture a été mis à jour. Nous vous recommandons de rafraîchir la page pour afficher la version la plus récente.', 'invoice_status_changed' => 'Veuillez noter que l\'état de votre facture a été mis à jour. Nous vous recommandons de rafraîchir la page pour afficher la version la plus récente.',
'no_unread_notifications' => 'Vous êtes à jour! Aucune nouvelle notification.', 'no_unread_notifications' => 'Vous êtes à jour! Aucune nouvelle notification.',
); );

View File

@ -41,7 +41,7 @@ $lang = array(
'quantity' => 'Số lượng', 'quantity' => 'Số lượng',
'line_total' => 'Tổng', 'line_total' => 'Tổng',
'subtotal' => 'Thành tiền', 'subtotal' => 'Thành tiền',
'net_subtotal' => 'Tnh', 'net_subtotal' => 'Tính',
'paid_to_date' => 'Hạn thanh toán', 'paid_to_date' => 'Hạn thanh toán',
'balance_due' => 'Số tiền thanh toán', 'balance_due' => 'Số tiền thanh toán',
'invoice_design_id' => 'Thiết kế', 'invoice_design_id' => 'Thiết kế',
@ -581,8 +581,8 @@ $lang = array(
'pro_plan_call_to_action' => 'Nâng cấp ngay!', 'pro_plan_call_to_action' => 'Nâng cấp ngay!',
'pro_plan_feature1' => 'Tạo khách hàng không giới hạn', 'pro_plan_feature1' => 'Tạo khách hàng không giới hạn',
'pro_plan_feature2' => 'Truy cập vào 10 mẫu thiết kế hóa đơn đẹp', 'pro_plan_feature2' => 'Truy cập vào 10 mẫu thiết kế hóa đơn đẹp',
'pro_plan_feature3' => 'URL tùy chỉnh - &quot;YourBrand.InvoiceNinja.com&quot;', 'pro_plan_feature3' => 'URL tùy chỉnh - "YourBrand.InvoiceNinja.com"',
'pro_plan_feature4' => 'Xóa &quot;Được tạo bởi Invoice Ninja&quot;', 'pro_plan_feature4' => 'Xóa "Được tạo bởi Invoice Ninja"',
'pro_plan_feature5' => 'Truy cập nhiều người dùng &amp; Theo dõi hoạt động', 'pro_plan_feature5' => 'Truy cập nhiều người dùng &amp; Theo dõi hoạt động',
'pro_plan_feature6' => 'Tạo báo giá và hóa đơn tạm tính', 'pro_plan_feature6' => 'Tạo báo giá và hóa đơn tạm tính',
'pro_plan_feature7' => 'Tùy chỉnh Tiêu đề và Số hiệu Trường Hóa đơn', 'pro_plan_feature7' => 'Tùy chỉnh Tiêu đề và Số hiệu Trường Hóa đơn',
@ -729,7 +729,7 @@ $lang = array(
'invoice_counter' => 'Quầy tính tiền', 'invoice_counter' => 'Quầy tính tiền',
'quote_counter' => 'Bộ đếm Báo giá ', 'quote_counter' => 'Bộ đếm Báo giá ',
'type' => 'Kiểu', 'type' => 'Kiểu',
'activity_1' => ':user đã tạo ra máy khách :client', 'activity_1' => ':user đã tạo ra khách hàng :client',
'activity_2' => ':user khách hàng lưu trữ :client', 'activity_2' => ':user khách hàng lưu trữ :client',
'activity_3' => ':user đã xóa máy khách :client', 'activity_3' => ':user đã xóa máy khách :client',
'activity_4' => ':user đã tạo hóa đơn :invoice', 'activity_4' => ':user đã tạo hóa đơn :invoice',
@ -799,8 +799,8 @@ $lang = array(
'archived_token' => 'Đã lưu trữ mã thông báo thành công', 'archived_token' => 'Đã lưu trữ mã thông báo thành công',
'archive_user' => 'Lưu trữ người dùng', 'archive_user' => 'Lưu trữ người dùng',
'archived_user' => 'Đã lưu trữ người dùng thành công', 'archived_user' => 'Đã lưu trữ người dùng thành công',
'archive_account_gateway' => 'Xóa Cổng Thanh Toán', 'archive_account_gateway' => 'Xóa cổng thanh toán',
'archived_account_gateway' => 'Cổng lưu trữ thành công', 'archived_account_gateway' => 'Lưu trữ cổng thành công',
'archive_recurring_invoice' => 'Lưu trữ hóa đơn định kỳ', 'archive_recurring_invoice' => 'Lưu trữ hóa đơn định kỳ',
'archived_recurring_invoice' => 'Đã lưu trữ thành công hóa đơn định kỳ', 'archived_recurring_invoice' => 'Đã lưu trữ thành công hóa đơn định kỳ',
'delete_recurring_invoice' => 'Xóa hóa đơn định kỳ', 'delete_recurring_invoice' => 'Xóa hóa đơn định kỳ',
@ -959,7 +959,7 @@ $lang = array(
'quote_message_button' => 'Để xem báo giá cho :amount , hãy nhấp vào nút bên dưới.', 'quote_message_button' => 'Để xem báo giá cho :amount , hãy nhấp vào nút bên dưới.',
'payment_message_button' => 'Cảm ơn bạn đã thanh toán :amount .', 'payment_message_button' => 'Cảm ơn bạn đã thanh toán :amount .',
'payment_type_direct_debit' => 'Ghi nợ trực tiếp', 'payment_type_direct_debit' => 'Ghi nợ trực tiếp',
'bank_accounts' => 'Thẻ tín dụng ngân hàng', 'bank_accounts' => 'Thẻ tín dụng & ngân hàng',
'add_bank_account' => 'Thêm tài khoản ngân hàng', 'add_bank_account' => 'Thêm tài khoản ngân hàng',
'setup_account' => 'Thiết lập tài khoản', 'setup_account' => 'Thiết lập tài khoản',
'import_expenses' => 'Chi phí nhập khẩu', 'import_expenses' => 'Chi phí nhập khẩu',
@ -1244,9 +1244,9 @@ $lang = array(
'confirm_remove_payment_method' => 'Bạn có chắc chắn muốn xóa phương thức thanh toán này không?', 'confirm_remove_payment_method' => 'Bạn có chắc chắn muốn xóa phương thức thanh toán này không?',
'remove' => 'Di dời', 'remove' => 'Di dời',
'payment_method_removed' => 'Đã xóa phương thức thanh toán.', 'payment_method_removed' => 'Đã xóa phương thức thanh toán.',
'bank_account_verification_help' => 'Chúng tôi đã thực hiện hai khoản tiền gửi vào tài khoản của bạn với mô tả &quot;XÁC MINH&quot;. Các khoản tiền gửi này sẽ mất 1-2 ngày làm việc để xuất hiện trên sao kê của bạn. Vui lòng nhập số tiền bên dưới.', 'bank_account_verification_help' => 'Chúng tôi đã thực hiện hai khoản tiền gửi vào tài khoản của bạn với mô tả "XÁC MINH". Các khoản tiền gửi này sẽ mất 1-2 ngày làm việc để xuất hiện trên sao kê của bạn. Vui lòng nhập số tiền bên dưới.',
'bank_account_verification_next_steps' => 'Chúng tôi đã thực hiện hai khoản tiền gửi vào tài khoản của bạn với tả &quot;XÁC MINH&quot;. Các khoản tiền gửi này sẽ mất 1-2 ngày làm việc để hiển thị trên sao của bạn. 'bank_account_verification_next_steps' => 'Chúng tôi đã thực hiện hai khoản tiền gửi vào tài khoản của bạn với tả "XÁC MINH". Các khoản tiền gửi này sẽ mất 1-2 ngày làm việc để hiển thị trên sao của bạn.
Khi đã số tiền, hãy quay lại trang phương thức thanh toán này nhấp vào &quot;Hoàn tất xác minh&quot; bên cạnh tài khoản.', Khi đã số tiền, hãy quay lại trang phương thức thanh toán này nhấp vào "Hoàn tất xác minh" bên cạnh tài khoản.',
'unknown_bank' => 'Ngân hàng không xác định', 'unknown_bank' => 'Ngân hàng không xác định',
'ach_verification_delay_help' => 'Bạn sẽ có thể sử dụng tài khoản sau khi hoàn tất xác minh. Việc xác minh thường mất 1-2 ngày làm việc.', 'ach_verification_delay_help' => 'Bạn sẽ có thể sử dụng tài khoản sau khi hoàn tất xác minh. Việc xác minh thường mất 1-2 ngày làm việc.',
'add_credit_card' => 'Thêm thẻ tín dụng', 'add_credit_card' => 'Thêm thẻ tín dụng',
@ -2096,7 +2096,7 @@ $lang = array(
'gateway_fees' => 'Phí cổng vào', 'gateway_fees' => 'Phí cổng vào',
'fees_disabled' => 'Phí đã bị vô hiệu hóa', 'fees_disabled' => 'Phí đã bị vô hiệu hóa',
'gateway_fees_help' => 'Tự động thêm phụ phí/chiết khấu khi thanh toán trực tuyến.', 'gateway_fees_help' => 'Tự động thêm phụ phí/chiết khấu khi thanh toán trực tuyến.',
'gateway' => 'Cổng vào', 'gateway' => 'Gateway',
'gateway_fee_change_warning' => 'Nếu có hóa đơn chưa thanh toán kèm phí, bạn cần cập nhật thủ công.', 'gateway_fee_change_warning' => 'Nếu có hóa đơn chưa thanh toán kèm phí, bạn cần cập nhật thủ công.',
'fees_surcharge_help' => 'Tùy chỉnh phụ phí :link .', 'fees_surcharge_help' => 'Tùy chỉnh phụ phí :link .',
'label_and_taxes' => 'nhãn và thuế', 'label_and_taxes' => 'nhãn và thuế',
@ -2612,7 +2612,7 @@ $lang = array(
'signature_on_pdf_help' => 'Hiển thị chữ ký của khách hàng trên hóa đơn/báo giá PDF.', 'signature_on_pdf_help' => 'Hiển thị chữ ký của khách hàng trên hóa đơn/báo giá PDF.',
'expired_white_label' => 'The white label license has expired', 'expired_white_label' => 'The white label license has expired',
'return_to_login' => 'Quay lại Đăng nhập', 'return_to_login' => 'Quay lại Đăng nhập',
'convert_products_tip' => 'Lưu ý: thêm :link có tên &quot; :name &quot; để xem tỷ giá hối đoái.', 'convert_products_tip' => 'Lưu ý: thêm :link có tên " :name " để xem tỷ giá hối đoái.',
'amount_greater_than_balance' => 'Số tiền lớn hơn số dư trên hóa đơn, chúng tôi sẽ tạo khoản tín dụng với số tiền còn lại.', 'amount_greater_than_balance' => 'Số tiền lớn hơn số dư trên hóa đơn, chúng tôi sẽ tạo khoản tín dụng với số tiền còn lại.',
'custom_fields_tip' => 'Sử dụng <code>Label|Option1,Option2</code> để hiển thị hộp chọn.', 'custom_fields_tip' => 'Sử dụng <code>Label|Option1,Option2</code> để hiển thị hộp chọn.',
'client_information' => 'Thông tin khách hàng', 'client_information' => 'Thông tin khách hàng',
@ -3062,8 +3062,8 @@ $lang = array(
'provider' => 'Nhà cung cấp', 'provider' => 'Nhà cung cấp',
'company_gateway' => 'Cổng thanh toán', 'company_gateway' => 'Cổng thanh toán',
'company_gateways' => 'Cổng thanh toán', 'company_gateways' => 'Cổng thanh toán',
'new_company_gateway' => 'Cổng mới', 'new_company_gateway' => 'Gateway mới',
'edit_company_gateway' => 'Chỉnh sửa Cổng', 'edit_company_gateway' => 'Chỉnh sửa Gateway',
'created_company_gateway' => 'Đã tạo cổng thành công', 'created_company_gateway' => 'Đã tạo cổng thành công',
'updated_company_gateway' => 'Đã cập nhật cổng thành công', 'updated_company_gateway' => 'Đã cập nhật cổng thành công',
'archived_company_gateway' => 'Cổng lưu trữ thành công', 'archived_company_gateway' => 'Cổng lưu trữ thành công',
@ -3097,7 +3097,7 @@ $lang = array(
'uploaded_logo' => 'Đã tải logo thành công', 'uploaded_logo' => 'Đã tải logo thành công',
'saved_settings' => 'Đã lưu cài đặt thành công', 'saved_settings' => 'Đã lưu cài đặt thành công',
'device_settings' => 'Cài đặt thiết bị', 'device_settings' => 'Cài đặt thiết bị',
'credit_cards_and_banks' => 'Thẻ tín dụng ngân hàng', 'credit_cards_and_banks' => 'Thẻ tín dụng & ngân hàng',
'price' => 'Giá', 'price' => 'Giá',
'email_sign_up' => 'Đăng ký Email', 'email_sign_up' => 'Đăng ký Email',
'google_sign_up' => 'Đăng ký Google', 'google_sign_up' => 'Đăng ký Google',
@ -3648,7 +3648,7 @@ $lang = array(
'view_licenses' => 'Xem Giấy phép', 'view_licenses' => 'Xem Giấy phép',
'fullscreen_editor' => 'Biên tập toàn màn hình', 'fullscreen_editor' => 'Biên tập toàn màn hình',
'sidebar_editor' => 'Biên tập thanh bên', 'sidebar_editor' => 'Biên tập thanh bên',
'please_type_to_confirm' => 'Vui lòng nhập &quot; :value &quot; để xác nhận', 'please_type_to_confirm' => 'Vui lòng nhập ":value"để xác nhận',
'purge' => 'thanh lọc', 'purge' => 'thanh lọc',
'clone_to' => 'Sao chép vào', 'clone_to' => 'Sao chép vào',
'clone_to_other' => 'Sao chép sang cái khác', 'clone_to_other' => 'Sao chép sang cái khác',
@ -3855,8 +3855,8 @@ $lang = array(
'recommended_in_production' => 'Rất khuyến khích trong sản xuất', 'recommended_in_production' => 'Rất khuyến khích trong sản xuất',
'enable_only_for_development' => 'Chỉ cho phép phát triển', 'enable_only_for_development' => 'Chỉ cho phép phát triển',
'test_pdf' => 'Kiểm tra PDF', 'test_pdf' => 'Kiểm tra PDF',
'checkout_authorize_label' => 'Checkout.com có thể được lưu làm phương thức thanh toán để sử dụng sau này, sau khi bạn hoàn tất giao dịch đầu tiên. Đừng quên kiểm tra &quot;Lưu thông tin thẻ tín dụng&quot; trong quá trình thanh toán.', 'checkout_authorize_label' => 'Checkout.com có thể được lưu làm phương thức thanh toán để sử dụng sau này, sau khi bạn hoàn tất giao dịch đầu tiên. Đừng quên kiểm tra" Lưu thông tin thẻ tín dụng" trong quá trình thanh toán.',
'sofort_authorize_label' => 'Tài khoản ngân hàng (SOFORT) có thể được lưu làm phương thức thanh toán để sử dụng trong tương lai, sau khi bạn hoàn tất giao dịch đầu tiên. Đừng quên kiểm tra &quot;Lưu chi tiết thanh toán&quot; trong quá trình thanh toán.', 'sofort_authorize_label' => 'Tài khoản ngân hàng (SOFORT) có thể được lưu làm phương thức thanh toán để sử dụng trong tương lai, sau khi bạn hoàn tất giao dịch đầu tiên. Đừng quên kiểm tra "Lưu chi tiết thanh toán" trong quá trình thanh toán.',
'node_status' => 'Trạng thái nút', 'node_status' => 'Trạng thái nút',
'npm_status' => 'Trạng thái NPM', 'npm_status' => 'Trạng thái NPM',
'node_status_not_found' => 'Tôi không tìm thấy Node ở đâu cả. Nó đã được cài đặt chưa?', 'node_status_not_found' => 'Tôi không tìm thấy Node ở đâu cả. Nó đã được cài đặt chưa?',
@ -3891,7 +3891,7 @@ $lang = array(
'payment_method_saving_failed' => 'Không thể lưu phương thức thanh toán để sử dụng sau này.', 'payment_method_saving_failed' => 'Không thể lưu phương thức thanh toán để sử dụng sau này.',
'pay_with' => 'Thanh toán bằng', 'pay_with' => 'Thanh toán bằng',
'n/a' => 'Không có', 'n/a' => 'Không có',
'by_clicking_next_you_accept_terms' => 'Bằng cách nhấp vào &quot;Tiếp theo&quot;, bạn chấp nhận các điều khoản.', 'by_clicking_next_you_accept_terms' => 'Bằng cách nhấp vào "Tiếp theo", bạn chấp nhận các điều khoản.',
'not_specified' => 'Không xác định', 'not_specified' => 'Không xác định',
'before_proceeding_with_payment_warning' => 'Trước khi tiến hành thanh toán, bạn phải điền vào các trường sau', 'before_proceeding_with_payment_warning' => 'Trước khi tiến hành thanh toán, bạn phải điền vào các trường sau',
'after_completing_go_back_to_previous_page' => 'Sau khi hoàn tất, hãy quay lại trang trước.', 'after_completing_go_back_to_previous_page' => 'Sau khi hoàn tất, hãy quay lại trang trước.',
@ -4072,7 +4072,7 @@ $lang = array(
'max_companies_desc' => 'Bạn đã đạt đến số lượng công ty tối đa. Xóa các công ty hiện có để di chuyển các công ty mới.', 'max_companies_desc' => 'Bạn đã đạt đến số lượng công ty tối đa. Xóa các công ty hiện có để di chuyển các công ty mới.',
'migration_already_completed' => 'Công ty đã di chuyển', 'migration_already_completed' => 'Công ty đã di chuyển',
'migration_already_completed_desc' => 'Có vẻ như bạn đã di chuyển <b>:company _name</b> sang phiên bản V5 của Invoice Ninja. Trong trường hợp bạn muốn bắt đầu lại, bạn có thể buộc di chuyển để xóa dữ liệu hiện có.', 'migration_already_completed_desc' => 'Có vẻ như bạn đã di chuyển <b>:company _name</b> sang phiên bản V5 của Invoice Ninja. Trong trường hợp bạn muốn bắt đầu lại, bạn có thể buộc di chuyển để xóa dữ liệu hiện có.',
'payment_method_cannot_be_authorized_first' => 'Phương thức thanh toán này có thể được lưu lại để sử dụng sau này, sau khi bạn hoàn tất giao dịch đầu tiên. Đừng quên kiểm tra &quot;Chi tiết cửa hàng&quot; trong quá trình thanh toán.', 'payment_method_cannot_be_authorized_first' => 'Phương thức thanh toán này có thể được lưu lại để sử dụng sau này, sau khi bạn hoàn tất giao dịch đầu tiên. Đừng quên kiểm tra "Chi tiết cửa hàng" trong quá trình thanh toán.',
'new_account' => 'Tài khoản mới', 'new_account' => 'Tài khoản mới',
'activity_100' => ':user đã tạo hóa đơn định kỳ :recurring_invoice', 'activity_100' => ':user đã tạo hóa đơn định kỳ :recurring_invoice',
'activity_101' => ':user hóa đơn định kỳ đã cập nhật :recurring_invoice', 'activity_101' => ':user hóa đơn định kỳ đã cập nhật :recurring_invoice',
@ -4110,7 +4110,7 @@ $lang = array(
'one_time_purchases' => 'Mua một lần', 'one_time_purchases' => 'Mua một lần',
'recurring_purchases' => 'Mua hàng định kỳ', 'recurring_purchases' => 'Mua hàng định kỳ',
'you_might_be_interested_in_following' => 'Bạn có thể quan tâm đến những điều sau đây', 'you_might_be_interested_in_following' => 'Bạn có thể quan tâm đến những điều sau đây',
'quotes_with_status_sent_can_be_approved' => 'Chỉ những báo giá có trạng thái &quot;Đã gửi&quot; mới được chấp thuận. Không thể chấp thuận những báo giá đã hết hạn.', 'quotes_with_status_sent_can_be_approved' => 'Chỉ những báo giá có trạng thái "Đã gửi" mới được chấp thuận. Không thể chấp thuận những báo giá đã hết hạn.',
'no_quotes_available_for_download' => 'Không có báo giá nào có sẵn để tải xuống.', 'no_quotes_available_for_download' => 'Không có báo giá nào có sẵn để tải xuống.',
'copyright' => 'Bản quyền', 'copyright' => 'Bản quyền',
'user_created_user' => ':user đã tạo :created_user tại :time', 'user_created_user' => ':user đã tạo :created_user tại :time',
@ -4287,7 +4287,7 @@ $lang = array(
'migration_not_yet_completed' => 'Việc di chuyển vẫn chưa hoàn tất', 'migration_not_yet_completed' => 'Việc di chuyển vẫn chưa hoàn tất',
'show_task_end_date' => 'Hiển thị ngày kết thúc nhiệm vụ', 'show_task_end_date' => 'Hiển thị ngày kết thúc nhiệm vụ',
'show_task_end_date_help' => 'Cho phép chỉ định ngày kết thúc nhiệm vụ', 'show_task_end_date_help' => 'Cho phép chỉ định ngày kết thúc nhiệm vụ',
'gateway_setup' => 'Thiết lập cổng', 'gateway_setup' => 'Cài đặt Gateway',
'preview_sidebar' => 'Xem trước thanh bên', 'preview_sidebar' => 'Xem trước thanh bên',
'years_data_shown' => 'Dữ liệu năm được hiển thị', 'years_data_shown' => 'Dữ liệu năm được hiển thị',
'ended_all_sessions' => 'Đã kết thúc thành công tất cả các phiên', 'ended_all_sessions' => 'Đã kết thúc thành công tất cả các phiên',
@ -4394,7 +4394,7 @@ $lang = array(
'document_download_subject' => 'Tài liệu của bạn đã sẵn sàng để tải xuống', 'document_download_subject' => 'Tài liệu của bạn đã sẵn sàng để tải xuống',
'reminder_message' => 'Nhắc nhở về hóa đơn :number cho :balance', 'reminder_message' => 'Nhắc nhở về hóa đơn :number cho :balance',
'gmail_credentials_invalid_subject' => 'Gửi bằng GMail thông tin đăng nhập không hợp lệ', 'gmail_credentials_invalid_subject' => 'Gửi bằng GMail thông tin đăng nhập không hợp lệ',
'gmail_credentials_invalid_body' => 'Thông tin đăng nhập GMail của bạn không đúng, vui lòng đăng nhập vào cổng thông tin quản trị viên và điều hướng đến Cài đặt &gt; Chi tiết người dùng và ngắt kết nối và kết nối lại tài khoản GMail của bạn. Chúng tôi sẽ gửi cho bạn thông báo này hàng ngày cho đến khi sự cố này được giải quyết', 'gmail_credentials_invalid_body' => 'Thông tin đăng nhập GMail của bạn không đúng, vui lòng đăng nhập vào cổng thông tin quản trị viên và điều hướng đến Cài đặt > Chi tiết người dùng và ngắt kết nối và kết nối lại tài khoản GMail của bạn. Chúng tôi sẽ gửi cho bạn thông báo này hàng ngày cho đến khi sự cố này được giải quyết',
'total_columns' => 'Tổng số trường', 'total_columns' => 'Tổng số trường',
'view_task' => 'Xem Nhiệm vụ', 'view_task' => 'Xem Nhiệm vụ',
'cancel_invoice' => 'Hủy bỏ', 'cancel_invoice' => 'Hủy bỏ',
@ -4419,8 +4419,8 @@ $lang = array(
'signed_in_as' => 'Đã đăng nhập như', 'signed_in_as' => 'Đã đăng nhập như',
'total_results' => 'Tổng kết quả', 'total_results' => 'Tổng kết quả',
'restore_company_gateway' => 'Khôi phục cổng', 'restore_company_gateway' => 'Khôi phục cổng',
'archive_company_gateway' => 'Cổng lưu trữ', 'archive_company_gateway' => 'Lưu trữ gateway',
'delete_company_gateway' => 'Xóa cổng', 'delete_company_gateway' => 'Xóa gateway',
'exchange_currency' => 'Trao đổi tiền tệ', 'exchange_currency' => 'Trao đổi tiền tệ',
'tax_amount1' => 'Số tiền thuế 1', 'tax_amount1' => 'Số tiền thuế 1',
'tax_amount2' => 'Số tiền thuế 2', 'tax_amount2' => 'Số tiền thuế 2',
@ -4506,7 +4506,7 @@ $lang = array(
'add' => 'Thêm vào', 'add' => 'Thêm vào',
'last_sent_template' => 'Mẫu gửi cuối cùng', 'last_sent_template' => 'Mẫu gửi cuối cùng',
'enable_flexible_search' => 'Bật Tìm kiếm linh hoạt', 'enable_flexible_search' => 'Bật Tìm kiếm linh hoạt',
'enable_flexible_search_help' => 'Phù hợp với các ký tự không liền kề, ví dụ: &quot;ct&quot; phù hợp với &quot;cat&quot;', 'enable_flexible_search_help' => 'Phù hợp với các ký tự không liền kề, ví dụ: "ct" phù hợp với "cat"',
'vendor_details' => 'Chi tiết nhà cung cấp', 'vendor_details' => 'Chi tiết nhà cung cấp',
'purchase_order_details' => 'Chi tiết đơn đặt hàng', 'purchase_order_details' => 'Chi tiết đơn đặt hàng',
'qr_iban' => 'Mã QR IBAN', 'qr_iban' => 'Mã QR IBAN',
@ -5333,6 +5333,7 @@ $lang = array(
'country_Melilla' => 'Melilla', 'country_Melilla' => 'Melilla',
'country_Ceuta' => 'Ceuta', 'country_Ceuta' => 'Ceuta',
'country_Canary Islands' => 'Quần đảo Canary', 'country_Canary Islands' => 'Quần đảo Canary',
'lang_Vietnamese' => 'Tiếng Việt',
'invoice_status_changed' => 'Xin lưu ý rằng trạng thái hóa đơn của bạn đã được cập nhật. Chúng tôi khuyên bạn nên làm mới trang để xem phiên bản mới nhất.', 'invoice_status_changed' => 'Xin lưu ý rằng trạng thái hóa đơn của bạn đã được cập nhật. Chúng tôi khuyên bạn nên làm mới trang để xem phiên bản mới nhất.',
'no_unread_notifications' => 'Bạn đã cập nhật đầy đủ rồi! Không có thông báo mới nào.', 'no_unread_notifications' => 'Bạn đã cập nhật đầy đủ rồi! Không có thông báo mới nào.',
); );

109
public/build/assets/app-021b0210.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,13 @@
import{i as l,w as m}from"./wait-8f4ae121.js";/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/let i=!1;function y(){const n=document.querySelector("meta[name=public_key]"),t=document.querySelector("meta[name=gateway_id]"),r=document.querySelector("meta[name=environment]"),e=new cba.HtmlWidget("#widget",n==null?void 0:n.content,t==null?void 0:t.content);e.setEnv(r==null?void 0:r.content),e.useAutoResize(),e.interceptSubmitForm("#stepone"),e.onFinishInsert('#server-response input[name="gateway_response"]',"payment_source"),e.setFormFields(["card_name*"]),e.reload();let o=document.getElementById("pay-now");return o.disabled=!1,o.querySelector("svg").classList.add("hidden"),o.querySelector("span").classList.remove("hidden"),document.querySelector('#server-response input[name="gateway_response"]').value="",e}function g(){var n,t,r;(n=document.querySelector("#widget"))==null||n.replaceChildren(),(t=document.querySelector("#widget"))==null||t.classList.remove("hidden"),(r=document.querySelector("#widget-3dsecure"))==null||r.replaceChildren()}function a(){var o,u;if(g(),!((o=document.querySelector("meta[name=gateway_id]"))==null?void 0:o.content)){let d=document.getElementById("pay-now");d.disabled=!0,d.querySelector("svg").classList.remove("hidden"),d.querySelector("span").classList.add("hidden"),document.getElementById("errors").textContent="Gateway not found or verified",document.getElementById("errors").hidden=!1}const t=y();t.on("finish",()=>{document.getElementById("errors").hidden=!0,p()}),t.on("submit",function(d){document.getElementById("errors").hidden=!0});let r=document.getElementById("pay-now");r.addEventListener("click",()=>{const d=document.getElementById("widget");if(t.getValidationState(),!t.isValidForm()&&d.offsetParent!==null){r.disabled=!1,r.querySelector("svg").classList.add("hidden"),r.querySelector("span").classList.remove("hidden");return}r.disabled=!0,r.querySelector("svg").classList.remove("hidden"),r.querySelector("span").classList.add("hidden");let s=document.querySelector("input[name=token-billing-checkbox]:checked");s&&(document.getElementById("store_card").value=s.value),d.offsetParent!==null?document.getElementById("stepone_submit").click():document.getElementById("server-response").submit()}),document.getElementById("toggle-payment-with-credit-card").addEventListener("click",d=>{var c;document.getElementById("widget").classList.remove("hidden"),document.getElementById("save-card--container").style.display="grid",document.querySelector("input[name=token]").value="",(c=document.querySelector("#powerboard-payment-container"))==null||c.classList.remove("hidden")}),Array.from(document.getElementsByClassName("toggle-payment-with-token")).forEach(d=>d.addEventListener("click",s=>{var c;document.getElementById("widget").classList.add("hidden"),document.getElementById("save-card--container").style.display="none",document.querySelector("input[name=token]").value=s.target.dataset.token,(c=document.querySelector("#powerboard-payment-container"))==null||c.classList.add("hidden")}));const e=document.querySelector('input[name="payment-type"]');e&&e.click(),i&&((u=document.getElementById("toggle-payment-with-credit-card"))==null||u.click())}async function p(){try{const n=await h();if(!n||!n.status||n.status==="not_authenticated"||n==="not_authenticated")throw i=!0,a(),new Error("There was an issue authenticating this payment method.");if(n.status==="authentication_not_supported"){document.querySelector('input[name="browser_details"]').value=null,document.querySelector('input[name="charge"]').value=JSON.stringify(n);let e=document.querySelector("input[name=token-billing-checkbox]:checked");return e&&(document.getElementById("store_card").value=e.value),document.getElementById("server-response").submit()}const t=new cba.Canvas3ds("#widget-3dsecure",n._3ds.token);t.load(),document.getElementById("widget").classList.add("hidden"),t.on("chargeAuthSuccess",function(e){document.querySelector('input[name="browser_details"]').value=null,document.querySelector('input[name="charge"]').value=JSON.stringify(e);let o=document.querySelector("input[name=token-billing-checkbox]:checked");o&&(document.getElementById("store_card").value=o.value),document.getElementById("server-response").submit()}),t.on("chargeAuthReject",function(e){document.getElementById("errors").textContent="Sorry, your transaction could not be processed...",document.getElementById("errors").hidden=!1,i=!0,a()}),t.load()}catch(n){const t=n.message??"Unknown error.";document.getElementById("errors").textContent=`Sorry, your transaction could not be processed...
${t}`,document.getElementById("errors").hidden=!1,i=!0,a()}}async function h(){const n={name:navigator.userAgent.substring(0,100),java_enabled:navigator.javaEnabled()?"true":"false",language:navigator.language||navigator.userLanguage,screen_height:window.screen.height.toString(),screen_width:window.screen.width.toString(),time_zone:(new Date().getTimezoneOffset()*-1).toString(),color_depth:window.screen.colorDepth.toString()};document.querySelector('input[name="browser_details"]').value=JSON.stringify(n);const t=JSON.stringify(Object.fromEntries(new FormData(document.getElementById("server-response")))),r=document.querySelector("meta[name=payments_route]");try{const e=await fetch(r.content,{method:"POST",headers:{"Content-Type":"application/json","X-Requested-With":"XMLHttpRequest",Accept:"application/json","X-CSRF-Token":document.querySelector('meta[name="csrf-token"]').content},body:t});return e.ok?await e.json():await e.json().then(o=>{throw new Error(o.message??"Unknown error.")})}catch(e){document.getElementById("errors").textContent=`Sorry, your transaction could not be processed...
${e.message}`,document.getElementById("errors").hidden=!1,i=!0,a()}}l()?a():m("#powerboard-credit-card-payment").then(()=>a());

View File

@ -1,13 +0,0 @@
import{i,w as u}from"./wait-8f4ae121.js";/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/let a=!1;function l(){const t=document.querySelector("meta[name=public_key]"),n=document.querySelector("meta[name=gateway_id]"),o=document.querySelector("meta[name=environment]"),e=new cba.HtmlWidget("#widget",t==null?void 0:t.content,n==null?void 0:n.content);e.setEnv(o==null?void 0:o.content),e.useAutoResize(),e.interceptSubmitForm("#stepone"),e.onFinishInsert('#server-response input[name="gateway_response"]',"payment_source"),e.setFormFields(["card_name*"]),e.reload();let r=document.getElementById("pay-now");return r.disabled=!1,r.querySelector("svg").classList.add("hidden"),r.querySelector("span").classList.remove("hidden"),document.querySelector('#server-response input[name="gateway_response"]').value="",e}function m(){var t,n,o;(t=document.querySelector("#widget"))==null||t.replaceChildren(),(n=document.querySelector("#widget"))==null||n.classList.remove("hidden"),(o=document.querySelector("#widget-3dsecure"))==null||o.replaceChildren()}function d(){var e;m();const t=l();t.on("finish",()=>{document.getElementById("errors").hidden=!0,y()}),t.on("submit",function(r){document.getElementById("errors").hidden=!0});let n=document.getElementById("pay-now");n.addEventListener("click",()=>{const r=document.getElementById("widget");if(t.getValidationState(),!t.isValidForm()&&r.offsetParent!==null){n.disabled=!1,n.querySelector("svg").classList.add("hidden"),n.querySelector("span").classList.remove("hidden");return}n.disabled=!0,n.querySelector("svg").classList.remove("hidden"),n.querySelector("span").classList.add("hidden");let s=document.querySelector("input[name=token-billing-checkbox]:checked");s&&(document.getElementById("store_card").value=s.value),r.offsetParent!==null?document.getElementById("stepone_submit").click():document.getElementById("server-response").submit()}),document.getElementById("toggle-payment-with-credit-card").addEventListener("click",r=>{var c;document.getElementById("widget").classList.remove("hidden"),document.getElementById("save-card--container").style.display="grid",document.querySelector("input[name=token]").value="",(c=document.querySelector("#powerboard-payment-container"))==null||c.classList.remove("hidden")}),Array.from(document.getElementsByClassName("toggle-payment-with-token")).forEach(r=>r.addEventListener("click",s=>{var c;document.getElementById("widget").classList.add("hidden"),document.getElementById("save-card--container").style.display="none",document.querySelector("input[name=token]").value=s.target.dataset.token,(c=document.querySelector("#powerboard-payment-container"))==null||c.classList.add("hidden")}));const o=document.querySelector('input[name="payment-type"]');o&&o.click(),console.log({focusCreditCard:a}),a&&((e=document.getElementById("toggle-payment-with-credit-card"))==null||e.click())}async function y(){try{const t=await g();if(t.status==="not_authenticated"||t==="not_authenticated")throw a=!0,d(),new Error("There was an issue authenticating this payment method.");if(t.status==="authentication_not_supported"){document.querySelector('input[name="browser_details"]').value=null,document.querySelector('input[name="charge"]').value=JSON.stringify(t);let e=document.querySelector("input[name=token-billing-checkbox]:checked");return e&&(document.getElementById("store_card").value=e.value),document.getElementById("server-response").submit()}const n=new cba.Canvas3ds("#widget-3dsecure",t._3ds.token);n.load(),document.getElementById("widget").classList.add("hidden"),n.on("chargeAuthSuccess",function(e){document.querySelector('input[name="browser_details"]').value=null,document.querySelector('input[name="charge"]').value=JSON.stringify(e);let r=document.querySelector("input[name=token-billing-checkbox]:checked");r&&(document.getElementById("store_card").value=r.value),document.getElementById("server-response").submit()}),n.on("chargeAuthReject",function(e){document.getElementById("errors").textContent="Sorry, your transaction could not be processed...",document.getElementById("errors").hidden=!1,a=!0,d()}),n.load()}catch(t){document.getElementById("errors").textContent=`Sorry, your transaction could not be processed...
${t}`,document.getElementById("errors").hidden=!1,a=!0,d()}}async function g(){const t={name:navigator.userAgent.substring(0,100),java_enabled:navigator.javaEnabled()?"true":"false",language:navigator.language||navigator.userLanguage,screen_height:window.screen.height.toString(),screen_width:window.screen.width.toString(),time_zone:(new Date().getTimezoneOffset()*-1).toString(),color_depth:window.screen.colorDepth.toString()};document.querySelector('input[name="browser_details"]').value=JSON.stringify(t);const n=JSON.stringify(Object.fromEntries(new FormData(document.getElementById("server-response")))),o=document.querySelector("meta[name=payments_route]");try{const e=await fetch(o.content,{method:"POST",headers:{"Content-Type":"application/json","X-Requested-With":"XMLHttpRequest",Accept:"application/json","X-CSRF-Token":document.querySelector('meta[name="csrf-token"]').content},body:n});return e.ok?await e.json():await e.json().then(r=>{throw new Error(r.message??"Unknown error.")})}catch(e){document.getElementById("errors").textContent=`Sorry, your transaction could not be processed...
${e.message}`,document.getElementById("errors").hidden=!1,console.error("Fetch error:",e),a=!0,d()}}i()?d():u("#powerboard-credit-card-payment").then(()=>d());

View File

@ -12,7 +12,7 @@
"file": "assets/wait-8f4ae121.js" "file": "assets/wait-8f4ae121.js"
}, },
"resources/js/app.js": { "resources/js/app.js": {
"file": "assets/app-e0713224.js", "file": "assets/app-021b0210.js",
"imports": [ "imports": [
"_index-08e160a7.js", "_index-08e160a7.js",
"__commonjsHelpers-725317a4.js" "__commonjsHelpers-725317a4.js"
@ -158,7 +158,7 @@
"src": "resources/js/clients/payments/paytrace-credit-card.js" "src": "resources/js/clients/payments/paytrace-credit-card.js"
}, },
"resources/js/clients/payments/powerboard-credit-card.js": { "resources/js/clients/payments/powerboard-credit-card.js": {
"file": "assets/powerboard-credit-card-f8810425.js", "file": "assets/powerboard-credit-card-f5f23291.js",
"imports": [ "imports": [
"_wait-8f4ae121.js" "_wait-8f4ae121.js"
], ],

View File

@ -57,6 +57,21 @@ function reload() {
function pay() { function pay() {
reload(); reload();
const gatewayId = document.querySelector('meta[name=gateway_id]')?.content;
if(!gatewayId) {
let payNow = document.getElementById('pay-now');
payNow.disabled = true;
payNow.querySelector('svg').classList.remove('hidden');
payNow.querySelector('span').classList.add('hidden');
document.getElementById(
'errors'
).textContent = 'Gateway not found or verified';
document.getElementById('errors').hidden = false;
}
const widget = setup(); const widget = setup();
widget.on('finish', () => { widget.on('finish', () => {
@ -139,8 +154,6 @@ function pay() {
first.click(); first.click();
} }
console.log({ focusCreditCard })
if (focusCreditCard) { if (focusCreditCard) {
document.getElementById('toggle-payment-with-credit-card')?.click(); document.getElementById('toggle-payment-with-credit-card')?.click();
} }
@ -151,6 +164,8 @@ async function process3ds() {
const resource = await get3dsToken(); const resource = await get3dsToken();
if ( if (
!resource ||
!resource.status ||
resource.status === 'not_authenticated' || resource.status === 'not_authenticated' ||
resource === 'not_authenticated' resource === 'not_authenticated'
) { ) {
@ -221,9 +236,12 @@ async function process3ds() {
canvas.load(); canvas.load();
} catch (error) { } catch (error) {
const msg = error.message ?? 'Unknown error.';
document.getElementById( document.getElementById(
'errors' 'errors'
).textContent = `Sorry, your transaction could not be processed...\n\n${error}`; ).textContent = `Sorry, your transaction could not be processed...\n\n${msg}`;
document.getElementById('errors').hidden = false; document.getElementById('errors').hidden = false;
focusCreditCard = true; focusCreditCard = true;
@ -274,19 +292,16 @@ async function get3dsToken() {
throw new Error(errorData.message ?? 'Unknown error.'); throw new Error(errorData.message ?? 'Unknown error.');
}); });
// const text = await response.text();
// throw new Error(`Network response was not ok: ${response.statusText}. Response text: ${text}`);
} }
return await response.json(); return await response.json();
} catch (error) { } catch (error) {
document.getElementById( document.getElementById(
'errors' 'errors'
).textContent = `Sorry, your transaction could not be processed...\n\n${error.message}`; ).textContent = `Sorry, your transaction could not be processed...\n\n${error.message}`;
document.getElementById('errors').hidden = false; document.getElementById('errors').hidden = false;
console.error('Fetch error:', error); // Log error for debugging
focusCreditCard = true; focusCreditCard = true;
pay(); pay();

View File

@ -0,0 +1,13 @@
@extends('layouts.ninja')
@section('meta_title', ctrans('texts.error'))
@section('body')
<div class="flex flex-col justify-center items-center mt-10">
<div class="mb-4">
<svg height="60" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" x="0" y="0" viewBox="0 0 468 222.5" xml:space="preserve"><style>.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#635bff}</style><path class="st0" d="M414 113.4c0-25.6-12.4-45.8-36.1-45.8-23.8 0-38.2 20.2-38.2 45.6 0 30.1 17 45.3 41.4 45.3 11.9 0 20.9-2.7 27.7-6.5v-20c-6.8 3.4-14.6 5.5-24.5 5.5-9.7 0-18.3-3.4-19.4-15.2h48.9c0-1.3.2-6.5.2-8.9zm-49.4-9.5c0-11.3 6.9-16 13.2-16 6.1 0 12.6 4.7 12.6 16h-25.8zM301.1 67.6c-9.8 0-16.1 4.6-19.6 7.8l-1.3-6.2h-22v116.6l25-5.3.1-28.3c3.6 2.6 8.9 6.3 17.7 6.3 17.9 0 34.2-14.4 34.2-46.1-.1-29-16.6-44.8-34.1-44.8zm-6 68.9c-5.9 0-9.4-2.1-11.8-4.7l-.1-37.1c2.6-2.9 6.2-4.9 11.9-4.9 9.1 0 15.4 10.2 15.4 23.3 0 13.4-6.2 23.4-15.4 23.4zM223.8 61.7l25.1-5.4V36l-25.1 5.3zM223.8 69.3h25.1v87.5h-25.1zM196.9 76.7l-1.6-7.4h-21.6v87.5h25V97.5c5.9-7.7 15.9-6.3 19-5.2v-23c-3.2-1.2-14.9-3.4-20.8 7.4zM146.9 47.6l-24.4 5.2-.1 80.1c0 14.8 11.1 25.7 25.9 25.7 8.2 0 14.2-1.5 17.5-3.3V135c-3.2 1.3-19 5.9-19-8.9V90.6h19V69.3h-19l.1-21.7zM79.3 94.7c0-3.9 3.2-5.4 8.5-5.4 7.6 0 17.2 2.3 24.8 6.4V72.2c-8.3-3.3-16.5-4.6-24.8-4.6C67.5 67.6 54 78.2 54 95.9c0 27.6 38 23.2 38 35.1 0 4.6-4 6.1-9.6 6.1-8.3 0-18.9-3.4-27.3-8v23.8c9.3 4 18.7 5.7 27.3 5.7 20.8 0 35.1-10.3 35.1-28.2-.1-29.8-38.2-24.5-38.2-35.7z"/></svg>
</div>
<p>Your session has expired. Connection to Stripe has been cancelled. Please try again.</p>
<span>Click <a class="font-semibold hover:underline" href="{{ $url ?? url('/#/settings/company_gateways') }}">here</a> to continue.</span>
</div>
@endsection

View File

@ -2,40 +2,409 @@
namespace Tests\Feature\Import\Quickbooks; namespace Tests\Feature\Import\Quickbooks;
use Tests\TestCase;
use App\Import\Providers\Quickbooks;
use App\Import\Transformer\BaseTransformer;
use App\Utils\Traits\MakesHash;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Tests\MockAccountData;
use Illuminate\Support\Facades\Cache;
use Mockery; use Mockery;
use App\Models\Client; use Tests\TestCase;
use App\Models\Product;
use App\Models\Invoice;
use Illuminate\Support\Str;
use ReflectionClass; use ReflectionClass;
use App\Models\Client;
use App\Models\Company;
use App\Models\Invoice;
use App\Models\Product;
use Tests\MockAccountData;
use Illuminate\Support\Str;
use App\Models\ClientContact;
use App\DataMapper\ClientSync;
use App\DataMapper\InvoiceItem;
use App\DataMapper\InvoiceSync;
use App\DataMapper\ProductSync;
use App\Utils\Traits\MakesHash;
use App\Import\Providers\Quickbooks;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use QuickBooksOnline\API\Facades\Item;
use App\Import\Transformer\BaseTransformer;
use App\Services\Quickbooks\QuickbooksService;
use Illuminate\Routing\Middleware\ThrottleRequests;
use QuickBooksOnline\API\Facades\Invoice as QbInvoice;
use Illuminate\Foundation\Testing\DatabaseTransactions;
class QuickbooksTest extends TestCase class QuickbooksTest extends TestCase
{ {
use MakesHash; use MakesHash;
use MockAccountData; use MockAccountData;
use DatabaseTransactions;
protected $quickbooks; protected $quickbooks;
protected $data; protected $data;
protected QuickbooksService $qb;
protected $faker;
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
$this->markTestSkipped('no bueno');
if(config('ninja.is_travis'))
{
$this->markTestSkipped('No need to run this test on Travis');
}
elseif(Company::whereNotNull('quickbooks')->count() == 0){
$this->markTestSkipped('No need to run this test on Travis');
}
$this->faker = \Faker\Factory::create();
} }
public function testCustomerSync() public function createQbProduct()
{ {
$data = (json_decode(file_get_contents(base_path('tests/Feature/Import/Quickbooks/customers.json')), false)); $service_product = Product::factory()->create([
'company_id' => $this->company->id,
'user_id' => $this->company->owner()->id,
'notes' => $this->faker->sentence(),
'product_key' => $this->faker->word(63),
'tax_id' => 2,
]);
$non_inventory_product = Product::factory()->create([
'company_id' => $this->company->id,
'user_id' => $this->company->owner()->id,
'notes' => $this->faker->sentence(),
'product_key' => $this->faker->word(63),
'tax_id' => 1,
]);
$qb_product = Item::create([
"Name" => $non_inventory_product->product_key,
"Description" => $non_inventory_product->notes,
"Active" => true,
"FullyQualifiedName" => $non_inventory_product->product_key,
"Taxable" => true,
"UnitPrice" => $non_inventory_product->price,
"Type" => "NonInventory",
"IncomeAccountRef" => [
"value" => $this->qb->settings->default_income_account, // Replace with your actual income account ID
],
// "AssetAccountRef" => [
// "value" => "81", // Replace with your actual asset account ID
// "name" => "Inventory Asset"
// ],
// "InvStartDate" => date('Y-m-d'),
// "QtyOnHand" => 100,
"TrackQtyOnHand" => false,
// "ExpenseAccountRef" => [
// "value" => "80", // Replace with your actual COGS account ID
// "name" => "Cost of Goods Sold"
// ]
]);
$qb_product = $this->qb->sdk->Add($qb_product);
$sync = new ProductSync();
$sync->qb_id = data_get($qb_product, 'Id.value');
$non_inventory_product->sync = $sync;
$non_inventory_product->save();
$qb_service = Item::create([
"Name" => $service_product->product_key,
"Description" => $service_product->notes,
"Active" => true,
"FullyQualifiedName" => $service_product->product_key,
"Taxable" => true,
"UnitPrice" => $service_product->price,
"Type" => "Service",
"IncomeAccountRef" => [
"value" => $this->qb->settings->default_income_account, // Replace with your actual income account ID
],
"TrackQtyOnHand" => false,
]);
$qb_service = $this->qb->sdk->Add($qb_service);
$sync = new ProductSync();
$sync->qb_id = data_get($qb_service, 'Id.value');
$service_product->sync = $sync;
$service_product->save();
return [$non_inventory_product, $service_product];
} }
public function createQbCustomer()
{
$client = Client::factory()->create([
'company_id' => $this->company->id,
'user_id' => $this->company->owner()->id,
'address1' => $this->faker->address(),
'city' => $this->faker->city(),
'state' => $this->faker->state(),
'postal_code' => $this->faker->postcode(),
'country_id' => 840,
'shipping_country_id' => 840,
]);
$contact = ClientContact::factory()->create([
'user_id' => $this->company->owner()->id,
'client_id' => $client->id,
'company_id' => $this->company->id,
'is_primary' => 1,
'send_email' => true,
]);
$customerData = [
"DisplayName" => $client->present()->name(), // Required and must be unique
"PrimaryEmailAddr" => [
"Address" => $client->present()->email(),
],
"PrimaryPhone" => [
"FreeFormNumber" => $client->present()->phone()
],
"CompanyName" => $client->present()->name(),
"BillAddr" => [
"Line1" => $client->address1 ?? '',
"City" => $client->city ?? '',
"CountrySubDivisionCode" => $client->state ?? '',
"PostalCode" => $client->postal_code ?? '',
"Country" => $client->country->iso_3166_3
],
"ShipAddr" => [
"Line1" => $client->shipping_address1 ?? '',
"City" => $client->shipping_city ?? '',
"CountrySubDivisionCode" => $client->shipping_state ?? '',
"PostalCode" => $client->shipping_postal_code ?? '',
"Country" => $client->shipping_country->iso_3166_3
],
"GivenName" => $client->present()->first_name(),
"FamilyName" => $client->present()->last_name(),
"PrintOnCheckName" => $client->present()->primary_contact_name(),
"Notes" => $client->public_notes,
// "TaxIdentifier" => $client->vat_number ?? '', // Federal Employer Identification Number (EIN)
"BusinessNumber" => $client->id_number ?? '',
"Active" => $client->deleted_at ? false : true,
"V4IDPseudonym" => $client->client_hash,
"WebAddr" => $client->website ?? '',
];
$customer = \QuickBooksOnline\API\Facades\Customer::create($customerData);
// Send the create request to QuickBooks
$resultingCustomerObj = $this->qb->sdk->Add($customer);
$sync = new ClientSync();
$sync->qb_id = data_get($resultingCustomerObj, 'Id.value');
$client->sync = $sync;
$client->save();
return [$resultingCustomerObj, $client->id];
}
public function testCreateInvoiceInQb()
{
$this->company = Company::whereNotNull('quickbooks')->first();
$this->qb = new QuickbooksService($this->company);
$customer = $this->createQbCustomer();
//create ninja invoice
$qb_invoice = $this->createQbInvoice($customer);
$this->assertNotNull($qb_invoice);
nlog($qb_invoice);
}
public function testCreateCustomerInQb()
{
$this->company = Company::whereNotNull('quickbooks')->first();
$this->qb = new QuickbooksService($this->company);
$resultingCustomerObj = $this->createQbCustomer();
// Check for errors
$error = $this->qb->sdk->getLastError();
if ($error) {
$this->fail("The Customer could not be created: " . $error->getResponseBody());
}
$qb_id = data_get($resultingCustomerObj[0], 'Id.value');
$this->assertNotNull($qb_id);
$c = Client::find($resultingCustomerObj[1]);
$this->assertEquals($qb_id, $c->sync->qb_id);
}
public function createQbInvoice($customer_payload)
{
$customer = $customer_payload[0];
$client_id = $customer_payload[1];
$products = $this->createQbProduct();
$non_inventory_product = $products[0];
$service_product = $products[1];
nlog(data_get($customer, 'Id.value'));
$client = Client::find($client_id);
$item_product = new InvoiceItem();
$item_product->product_key = $non_inventory_product->product_key; // Changed from randomWord() to word()
$item_product->notes = $non_inventory_product->notes;
$item_product->quantity = 1;
$item_product->cost = $non_inventory_product->price;
$item_product->line_total = $non_inventory_product->price;
$item_product->type_id = '1';
$item_service = new InvoiceItem();
$item_service->product_key = $service_product->product_key; // Changed from randomWord() to word()
$item_service->notes = $service_product->notes;
$item_service->quantity = 1;
$item_service->cost = $service_product->price;
$item_service->line_total = $service_product->price;
$item_service->type_id = '2';
$i = Invoice::factory()->create([
'company_id' => $this->company->id,
'user_id' => $client->user_id,
'client_id' => $client->id,
'date' => '2024-09-01',
'due_date' => '2024-09-30',
'public_notes' => 'Test invoice',
'tax_name1' => '',
'tax_rate1' => 0,
'tax_name2' => '',
'tax_rate2' => 0,
'tax_name3' => '',
'tax_rate3' => 0,
'amount' => 30,
'paid_to_date' => 0,
'is_amount_discount' => 0,
'discount' => 0,
'line_items' => [$item_product, $item_service],
'number' => null,
]);
$i->calc()->getInvoice()->service()->applyNumber()->markSent()->createInvitations()->save();
$this->assertEquals(2, $i->status_id);
// $this->assertEquals(30, $i->amount);
$this->assertEquals(0, $i->paid_to_date);
$this->assertEquals(0, $i->is_amount_discount);
$this->assertEquals(0, $i->discount);
// $this->assertEquals(30, $i->balance);
$line_items = [];
$line_num = 1;
foreach($i->line_items as $line_item)
{
$product = Product::where('company_id', $this->company->id)
->where('product_key', $line_item->product_key)
->first();
$this->assertNotNull($product);
$line_items[] = [
'LineNum' => $line_num, // Add the line number
'DetailType' => 'SalesItemLineDetail',
'SalesItemLineDetail' => [
'ItemRef' => [
'value' => $product->sync->qb_id,
],
'Qty' => $line_item->quantity,
'UnitPrice' => $line_item->cost,
'TaxCodeRef' => [
'value' => (in_array($product->tax_id, [5, 8])) ? 'NON' : 'TAX',
],
],
'Description' => $line_item->notes,
'Amount' => $line_item->line_total,
];
$line_num++;
}
$invoiceData = [
"Line" => $line_items,
"CustomerRef" => [
"value" => data_get($customer, 'Id.value')
],
"BillEmail" => [
"Address" => $client->present()->email()
],
"TxnDate" => $i->date,
"DueDate" => $i->due_date,
"TotalAmt" => $i->amount,
"DocNumber" => $i->number,
"ApplyTaxAfterDiscount" => false,
"GlobalTaxCalculation" => "TaxExcluded", // This tells QuickBooks to calculate taxes
"TxnTaxDetail" => [
"UseAutomatedSalesTax" => true,
// "TxnTaxCodeRef" => [
// "value" => "TAX" // Use the appropriate tax code for your QuickBooks account
// "DefaultTaxRateRef" => [
// ]
]
// "Note" => $this->invoice->public_notes,
];
nlog($invoiceData);
$invoice = \QuickBooksOnline\API\Facades\Invoice::create($invoiceData);
nlog($invoice);
$qb_invoice = $this->qb->sdk->Add($invoice);
$sync = new InvoiceSync();
$sync->qb_id = data_get($qb_invoice, 'Id.value');
$i->sync = $sync;
$i->save();
return $qb_invoice;
}
private function getQuickBooksItemType($line_item): string
{
$typeMap = [
'1' => 'NonInventory', // product
'2' => 'Service', // service
'3' => 'NonInventory', // unpaid gateway fee
'4' => 'NonInventory', // paid gateway fee
'5' => 'Service', // late fee
'6' => 'NonInventory', // expense
];
return $typeMap[$line_item->type_id] ?? 'Service';
}
protected function tearDown(): void
{
// $this->company->clients()->forceDelete();
// $this->company->products()->forceDelete();
// $this->company->projects()->forceDelete();
// $this->company->tasks()->forceDelete();
// $this->company->vendors()->forceDelete();
// $this->company->expenses()->forceDelete();
// $this->company->purchase_orders()->forceDelete();
// $this->company->bank_transaction_rules()->forceDelete();
// $this->company->bank_transactions()->forceDelete();
parent::tearDown();
}
} }

View File

@ -157,6 +157,80 @@ class ReminderTest extends TestCase
'balance' => 10, 'balance' => 10,
]); ]);
}
public function testReminderScheduleNy()
{
$settings = CompanySettings::defaults();
$settings->timezone_id = '15';
$settings->entity_send_time = 6;
$settings->payment_terms = '14';
$settings->send_reminders = true;
$settings->enable_reminder1 = true;
$settings->enable_reminder2 = false;
$settings->enable_reminder3 = false;
$settings->enable_reminder_endless = true;
$settings->schedule_reminder1 = 'after_invoice_date';
$settings->schedule_reminder2 = '';
$settings->schedule_reminder3 = '';
$settings->num_days_reminder1 = 1;
$settings->num_days_reminder2 = 0;
$settings->num_days_reminder3 = 0;
$settings->endless_reminder_frequency_id = '1';
$this->buildData($settings);
$this->travelTo(Carbon::parse('2024-09-20')->startOfDay()->addHours(1));
$invoice = Invoice::factory()->create([
'company_id' => $this->company->id,
'user_id' => $this->user->id,
'client_id' => $this->client->id,
'amount' => 10,
'balance' => 10,
'date' => '2024-09-19',
'number' => 'JJJ1-11-2024',
'due_date' => '2024-09-19',
'status_id' => 2,
'last_sent_date' => '19-09-2024',
]);
$this->assertEquals(1, $invoice->company->settings->num_days_reminder1);
$invoice->service()->setReminder($settings)->save();
$this->assertEquals(10, $invoice->balance);
$this->assertEquals('2024-09-20', $invoice->next_send_date->format('Y-m-d'));
$x = false;
do {
$this->travelTo(now()->addHour());
(new ReminderJob())->handle();
$invoice = $invoice->fresh();
$x = (bool)$invoice->reminder1_sent;
} while ($x === false);
$this->assertNotNull($invoice->reminder_last_sent);
$this->assertEquals(now()->addDays(1), $invoice->next_send_date);
$x = 0;
do {
$this->travelTo(now()->addHour());
(new ReminderJob())->handle();
$invoice = $invoice->fresh();
$x++;
} while ($x < 24);
$this->assertEquals(now()->addDays(1), $invoice->next_send_date);
} }
public function testDKRemindersNotSending() public function testDKRemindersNotSending()

View File

@ -840,6 +840,7 @@ trait MockAccountData
$item = InvoiceItemFactory::create(); $item = InvoiceItemFactory::create();
$item->quantity = 1; $item->quantity = 1;
$item->notes = $this->faker->sentence;
$item->cost = 10; $item->cost = 10;
$item->task_id = $this->encodePrimaryKey($this->task->id); $item->task_id = $this->encodePrimaryKey($this->task->id);
$item->expense_id = $this->encodePrimaryKey($this->expense->id); $item->expense_id = $this->encodePrimaryKey($this->expense->id);