From 69b49c6ae734c49aa57fc7b8b5fd6c3204e24378 Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Sun, 30 Apr 2017 19:09:13 +0300 Subject: [PATCH 01/81] Multi-db support --- app/Console/Commands/InitLookup.php | 51 +++++++++++++++++++++++++++++ app/Console/Kernel.php | 1 + app/Models/DbServer.php | 24 ++++++++++++++ config/database.php | 39 ++++++++++++++++++++++ 4 files changed, 115 insertions(+) create mode 100644 app/Console/Commands/InitLookup.php create mode 100644 app/Models/DbServer.php diff --git a/app/Console/Commands/InitLookup.php b/app/Console/Commands/InitLookup.php new file mode 100644 index 000000000000..cf2d95c4eb77 --- /dev/null +++ b/app/Console/Commands/InitLookup.php @@ -0,0 +1,51 @@ +info(date('Y-m-d') . ' Running InitLookup...'); + + DB::purge(DB::getDefaultConnection()); + DB::Reconnect('db-ninja-0'); + + if (! DbServer::count()) { + DbServer::create(['name' => 'db-ninja-1']); + } + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 34938ea4c590..2f2eca9da424 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -28,6 +28,7 @@ class Kernel extends ConsoleKernel 'App\Console\Commands\TestOFX', 'App\Console\Commands\MakeModule', 'App\Console\Commands\MakeClass', + 'App\Console\Commands\InitLookup', ]; /** diff --git a/app/Models/DbServer.php b/app/Models/DbServer.php new file mode 100644 index 000000000000..005a19ebb8c8 --- /dev/null +++ b/app/Models/DbServer.php @@ -0,0 +1,24 @@ + 'InnoDB', ], + 'db-ninja-0' => [ + 'driver' => 'mysql', + 'host' => env('DB_HOST', env('DB_HOST0', 'localhost')), + 'database' => env('DB_DATABASE0', env('DB_DATABASE', 'forge')), + 'username' => env('DB_USERNAME0', env('DB_USERNAME', 'forge')), + 'password' => env('DB_PASSWORD0', env('DB_PASSWORD', '')), + 'charset' => 'utf8', + 'collation' => 'utf8_unicode_ci', + 'prefix' => '', + 'strict' => env('DB_STRICT', false), + 'engine' => 'InnoDB', + ], + + 'db-ninja-1' => [ + 'driver' => 'mysql', + 'host' => env('DB_HOST', env('DB_HOST1', 'localhost')), + 'database' => env('DB_DATABASE1', env('DB_DATABASE', 'forge')), + 'username' => env('DB_USERNAME1', env('DB_USERNAME', 'forge')), + 'password' => env('DB_PASSWORD1', env('DB_PASSWORD', '')), + 'charset' => 'utf8', + 'collation' => 'utf8_unicode_ci', + 'prefix' => '', + 'strict' => env('DB_STRICT', false), + 'engine' => 'InnoDB', + ], + + 'db-ninja-2' => [ + 'driver' => 'mysql', + 'host' => env('DB_HOST', env('DB_HOST2', 'localhost')), + 'database' => env('DB_DATABASE2', env('DB_DATABASE', 'forge')), + 'username' => env('DB_USERNAME2', env('DB_USERNAME', 'forge')), + 'password' => env('DB_PASSWORD2', env('DB_PASSWORD', '')), + 'charset' => 'utf8', + 'collation' => 'utf8_unicode_ci', + 'prefix' => '', + 'strict' => env('DB_STRICT', false), + 'engine' => 'InnoDB', + ], + 'pgsql' => [ 'driver' => 'pgsql', 'host' => env('DB_HOST', 'localhost'), From 0a6db0d2517e6276c195869c116a0249fe3e6737 Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Sun, 30 Apr 2017 20:08:49 +0300 Subject: [PATCH 02/81] Multi-db support --- app/Console/Commands/InitLookup.php | 82 +++++++++++++++++++++++++++-- app/Constants.php | 4 ++ app/Models/LookupAccount.php | 25 +++++++++ app/Models/LookupCompany.php | 24 +++++++++ app/Models/LookupContact.php | 25 +++++++++ app/Models/LookupInvitation.php | 26 +++++++++ app/Models/LookupToken.php | 25 +++++++++ app/Models/LookupUser.php | 25 +++++++++ 8 files changed, 232 insertions(+), 4 deletions(-) create mode 100644 app/Models/LookupAccount.php create mode 100644 app/Models/LookupCompany.php create mode 100644 app/Models/LookupContact.php create mode 100644 app/Models/LookupInvitation.php create mode 100644 app/Models/LookupToken.php create mode 100644 app/Models/LookupUser.php diff --git a/app/Console/Commands/InitLookup.php b/app/Console/Commands/InitLookup.php index cf2d95c4eb77..f4f73a73ae4b 100644 --- a/app/Console/Commands/InitLookup.php +++ b/app/Console/Commands/InitLookup.php @@ -5,6 +5,12 @@ namespace App\Console\Commands; use Illuminate\Console\Command; use DB; use App\Models\DbServer; +use App\Models\LookupCompany; +use App\Models\LookupAccount; +use App\Models\LookupUser; +use App\Models\LookupContact; +use App\Models\LookupToken; +use App\Models\LookupInvitation; class InitLookup extends Command { @@ -41,11 +47,79 @@ class InitLookup extends Command { $this->info(date('Y-m-d') . ' Running InitLookup...'); - DB::purge(DB::getDefaultConnection()); - DB::Reconnect('db-ninja-0'); + config(['database.default' => DB_NINJA_0]); - if (! DbServer::count()) { - DbServer::create(['name' => 'db-ninja-1']); + if (DbServer::count()) { + //exit('db_server record exists!'); + } + + $dbServer = DbServer::create(['name' => DB_NINJA_1]); + $count = DB::table('companies')->count(); + + for ($i=0; $i<$count; $i += 100) { + $this->initCompanies($offset); } } + + private function initCompanies($offset = 0) + { + $this->info(date('Y-m-d') . ' initCompanies - offset: ' . $offset); + + config(['database.default' => DB_NINJA_1]); + + $companies = DB::table('companies')->orderBy('id')->get(['id']); + + foreach ($companies as $company) { + $this->parseCompany($dbServer->id, $company->id); + } + } + + private function parseCompany($dbServerId, $companyId) + { + $data = []; + + config(['database.default' => DB_NINJA_1]); + + $accounts = DB::table('accounts')->whereCompanyId($companyId)->orderBy('id')->get(['id']); + foreach ($accounts as $account) { + $data[$account->id] = $this->parseAccount($account->id); + } + + print_r($data);exit; + config(['database.default' => DB_NINJA_0]); + ///$lookupCompany = LookupCompany::create(['db_server_id' => $dbServerId]); + + } + + private function parseAccount($accountId) + { + $data = [ + 'users' => [], + 'contacts' => [], + 'invitations' => [], + 'tokens' => [], + ]; + + $users = DB::table('users')->whereAccountId($accountId)->orderBy('id')->get(['email']); + foreach ($users as $user) { + $data['users'][] = ['email' => $user->email]; + } + + $contacts = DB::table('contacts')->whereAccountId($accountId)->orderBy('id')->get(['contact_key']); + foreach ($contacts as $contact) { + $data['contacts'][] = ['contact_key' => $contact->contact_key]; + } + + $invitations = DB::table('invitations')->whereAccountId($accountId)->orderBy('id')->get(['invitation_key', 'message_id']); + foreach ($invitations as $invitation) { + $data['invitations'][] = ['invitation_key' => $invitation->invitation_key, 'message_id' => $invitation->message_id]; + } + + $tokens = DB::table('account_tokens')->whereAccountId($accountId)->orderBy('id')->get(['token']); + foreach ($tokens as $token) { + $data['tokens'][] = ['token' => $token->token]; + } + + return $data; + } } diff --git a/app/Constants.php b/app/Constants.php index 732731686597..ce2f5922a867 100644 --- a/app/Constants.php +++ b/app/Constants.php @@ -335,6 +335,10 @@ if (! defined('APP_NAME')) { define('BLANK_IMAGE', 'data:image/png;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='); + define('DB_NINJA_0', 'db-ninja-0'); + define('DB_NINJA_1', 'db-ninja-1'); + define('DB_NINJA_2', 'db-ninja-2'); + define('COUNT_FREE_DESIGNS', 4); define('COUNT_FREE_DESIGNS_SELF_HOST', 5); // include the custom design define('PRODUCT_ONE_CLICK_INSTALL', 1); diff --git a/app/Models/LookupAccount.php b/app/Models/LookupAccount.php new file mode 100644 index 000000000000..cb9f3a672d76 --- /dev/null +++ b/app/Models/LookupAccount.php @@ -0,0 +1,25 @@ + Date: Sun, 30 Apr 2017 20:09:15 +0300 Subject: [PATCH 03/81] Update downlink link --- docs/install.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/install.rst b/docs/install.rst index 4af8b0ee81d8..fb6f0e312741 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -29,7 +29,7 @@ Step 1: Download the code You can either download the zip file below or checkout the code from our GitHub repository. The zip includes all third party libraries whereas using GitHub requires you to use Composer to install the dependencies. -https://download.invoiceninja.com/ninja-v3.2.1.zip +https://download.invoiceninja.com/ninja-v3.3.0.zip .. Note:: All Pro and Enterprise features from our hosted app are included in both the zip file and the GitHub repository. We offer a $20 per year white-label license to remove our branding. From 14072b6334bd00fed4d687b592246f42b4ecfb3d Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Sun, 30 Apr 2017 21:44:52 +0300 Subject: [PATCH 04/81] Multi-db support --- app/Console/Commands/InitLookup.php | 123 ++++++++++++++---- app/Constants.php | 2 +- app/Models/LookupCompany.php | 1 + ...0_174702_add_multiple_database_support.php | 56 ++++++++ database/seeds/UserTableSeeder.php | 1 + 5 files changed, 158 insertions(+), 25 deletions(-) create mode 100644 database/migrations/2017_04_30_174702_add_multiple_database_support.php diff --git a/app/Console/Commands/InitLookup.php b/app/Console/Commands/InitLookup.php index f4f73a73ae4b..b478a18e37bb 100644 --- a/app/Console/Commands/InitLookup.php +++ b/app/Console/Commands/InitLookup.php @@ -19,7 +19,7 @@ class InitLookup extends Command * * @var string */ - protected $signature = 'ninja:init-lookup'; + protected $signature = 'ninja:init-lookup {--truncate=} {--company_id=}'; /** * The console command description. @@ -45,50 +45,104 @@ class InitLookup extends Command */ public function handle() { - $this->info(date('Y-m-d') . ' Running InitLookup...'); + $this->info(date('Y-m-d h:i:s') . ' Running InitLookup...'); - config(['database.default' => DB_NINJA_0]); + config(['database.default' => DB_NINJA_LOOKUP]); if (DbServer::count()) { - //exit('db_server record exists!'); + $dbServer = DbServer::first(); + } else { + $dbServer = DbServer::create(['name' => DB_NINJA_1]); } - $dbServer = DbServer::create(['name' => DB_NINJA_1]); - $count = DB::table('companies')->count(); - - for ($i=0; $i<$count; $i += 100) { - $this->initCompanies($offset); + if ($this->option('truncate')) { + $this->truncateTables(); } - } - - private function initCompanies($offset = 0) - { - $this->info(date('Y-m-d') . ' initCompanies - offset: ' . $offset); config(['database.default' => DB_NINJA_1]); - $companies = DB::table('companies')->orderBy('id')->get(['id']); + $count = DB::table('companies') + ->where('id', '>=', $this->option('company_id') ?: 1) + ->count(); - foreach ($companies as $company) { - $this->parseCompany($dbServer->id, $company->id); + for ($i=0; $i<$count; $i += 100) { + $this->initCompanies($dbServer->id, $i); } } - private function parseCompany($dbServerId, $companyId) + private function initCompanies($dbServerId, $offset = 0) + { + $this->info(date('Y-m-d h:i:s') . ' initCompanies - offset: ' . $offset); + $data = []; + + config(['database.default' => DB_NINJA_1]); + + $companies = DB::table('companies') + ->offset($offset) + ->limit(100) + ->orderBy('id') + ->where('id', '>=', $this->option('company_id') ?: 1) + ->get(['id']); + foreach ($companies as $company) { + $data[$company->id] = $this->parseCompany($company->id); + } + + config(['database.default' => DB_NINJA_LOOKUP]); + + foreach ($data as $companyId => $company) { + $this->info(date('Y-m-d h:i:s') . ' company: ' . $companyId); + + $lookupCompany = LookupCompany::create([ + 'db_server_id' => $dbServerId, + 'company_id' => $companyId, + ]); + + foreach ($company as $accountKey => $account) { + $lookupAccount = LookupAccount::create([ + 'lookup_company_id' => $lookupCompany->id, + 'account_key' => $accountKey + ]); + foreach ($account['users'] as $user) { + LookupUser::create([ + 'lookup_account_id' => $lookupAccount->id, + 'email' => $user['email'], + ]); + } + foreach ($account['contacts'] as $contact) { + LookupContact::create([ + 'lookup_account_id' => $lookupAccount->id, + 'contact_key' => $contact['contact_key'], + ]); + } + foreach ($account['invitations'] as $invitation) { + LookupInvitation::create([ + 'lookup_account_id' => $lookupAccount->id, + 'invitation_key' => $invitation['invitation_key'], + 'message_id' => $invitation['message_id'] ?: null, + ]); + } + foreach ($account['tokens'] as $token) { + LookupToken::create([ + 'lookup_account_id' => $lookupAccount->id, + 'token' => $token['token'], + ]); + } + } + } + } + + private function parseCompany($companyId) { $data = []; config(['database.default' => DB_NINJA_1]); - $accounts = DB::table('accounts')->whereCompanyId($companyId)->orderBy('id')->get(['id']); + $accounts = DB::table('accounts')->whereCompanyId($companyId)->orderBy('id')->get(['id', 'account_key']); foreach ($accounts as $account) { - $data[$account->id] = $this->parseAccount($account->id); + $data[$account->account_key] = $this->parseAccount($account->id); } - print_r($data);exit; - config(['database.default' => DB_NINJA_0]); - ///$lookupCompany = LookupCompany::create(['db_server_id' => $dbServerId]); - + return $data; } private function parseAccount($accountId) @@ -122,4 +176,25 @@ class InitLookup extends Command return $data; } + + private function truncateTables() + { + DB::statement('SET FOREIGN_KEY_CHECKS = 0'); + DB::statement('truncate lookup_companies'); + DB::statement('truncate lookup_accounts'); + DB::statement('truncate lookup_users'); + DB::statement('truncate lookup_contacts'); + DB::statement('truncate lookup_invitations'); + DB::statement('truncate lookup_tokens'); + DB::statement('SET FOREIGN_KEY_CHECKS = 1'); + } + + protected function getOptions() + { + return [ + ['truncate', null, InputOption::VALUE_OPTIONAL, 'Truncate', null], + ['company_id', null, InputOption::VALUE_OPTIONAL, 'Company Id', null], + ]; + } + } diff --git a/app/Constants.php b/app/Constants.php index ce2f5922a867..23ddc079c549 100644 --- a/app/Constants.php +++ b/app/Constants.php @@ -335,7 +335,7 @@ if (! defined('APP_NAME')) { define('BLANK_IMAGE', 'data:image/png;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='); - define('DB_NINJA_0', 'db-ninja-0'); + define('DB_NINJA_LOOKUP', 'db-ninja-0'); define('DB_NINJA_1', 'db-ninja-1'); define('DB_NINJA_2', 'db-ninja-2'); diff --git a/app/Models/LookupCompany.php b/app/Models/LookupCompany.php index f6023a2ade71..0e8a32120262 100644 --- a/app/Models/LookupCompany.php +++ b/app/Models/LookupCompany.php @@ -19,6 +19,7 @@ class LookupCompany extends Eloquent */ protected $fillable = [ 'db_server_id', + 'company_id', ]; } diff --git a/database/migrations/2017_04_30_174702_add_multiple_database_support.php b/database/migrations/2017_04_30_174702_add_multiple_database_support.php new file mode 100644 index 000000000000..a74c2d2fc758 --- /dev/null +++ b/database/migrations/2017_04_30_174702_add_multiple_database_support.php @@ -0,0 +1,56 @@ +unsignedInteger('company_id')->index(); + }); + + Schema::table('lookup_companies', function ($table) { + $table->unique(['db_server_id', 'company_id']); + }); + + Schema::table('lookup_accounts', function ($table) { + $table->string('account_key')->change()->unique(); + }); + + Schema::table('lookup_users', function ($table) { + $table->string('email')->change()->unique(); + }); + + Schema::table('lookup_contacts', function ($table) { + $table->string('contact_key')->change()->unique(); + }); + + Schema::table('lookup_invitations', function ($table) { + $table->string('invitation_key')->change()->unique(); + $table->string('message_id')->change()->nullable()->unique(); + }); + + Schema::table('lookup_tokens', function ($table) { + $table->string('token')->change()->unique(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('lookup_companies', function ($table) { + $table->dropColumn('company_id'); + }); + } +} diff --git a/database/seeds/UserTableSeeder.php b/database/seeds/UserTableSeeder.php index d03f0ecccace..4f769b5ec24c 100644 --- a/database/seeds/UserTableSeeder.php +++ b/database/seeds/UserTableSeeder.php @@ -85,6 +85,7 @@ class UserTableSeeder extends Seeder 'email' => env('TEST_EMAIL', TEST_USERNAME), 'is_primary' => true, 'send_invoice' => true, + 'contact_key' => strtolower(str_random(RANDOM_KEY_LENGTH)), ]); Product::create([ From 95cbbc2dc9f9041930d95312e025883fec17944d Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Sun, 30 Apr 2017 22:18:17 +0300 Subject: [PATCH 05/81] Multi-db support --- app/Models/AccountToken.php | 8 +++++++ app/Models/Contact.php | 8 +++++++ app/Models/Invitation.php | 8 +++++++ app/Models/LookupAccount.php | 7 +----- app/Models/LookupCompany.php | 7 +----- app/Models/LookupContact.php | 7 +----- app/Models/LookupInvitation.php | 7 +----- app/Models/LookupModel.php | 39 +++++++++++++++++++++++++++++++++ app/Models/LookupToken.php | 7 +----- app/Models/LookupUser.php | 7 +----- app/Models/User.php | 12 ++++++++++ 11 files changed, 81 insertions(+), 36 deletions(-) create mode 100644 app/Models/LookupModel.php diff --git a/app/Models/AccountToken.php b/app/Models/AccountToken.php index dace5f00b91b..c68dde7148d2 100644 --- a/app/Models/AccountToken.php +++ b/app/Models/AccountToken.php @@ -3,6 +3,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\SoftDeletes; +use App\Models\LookupToken; /** * Class AccountToken. @@ -39,3 +40,10 @@ class AccountToken extends EntityModel return $this->belongsTo('App\Models\User')->withTrashed(); } } + +AccountToken::creating(function ($token) +{ + LookupToken::createNew($token->account->account_key, [ + 'token' => $token->token, + ]); +}); diff --git a/app/Models/Contact.php b/app/Models/Contact.php index ef826e33792e..fe3547f99742 100644 --- a/app/Models/Contact.php +++ b/app/Models/Contact.php @@ -8,6 +8,7 @@ use Illuminate\Auth\Passwords\CanResetPassword; use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract; use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract; use Illuminate\Database\Eloquent\SoftDeletes; +use App\Models\LookupContact; /** * Class Contact. @@ -165,3 +166,10 @@ class Contact extends EntityModel implements AuthenticatableContract, CanResetPa return "{$url}/client/dashboard/{$this->contact_key}"; } } + +Contact::creating(function ($contact) +{ + LookupContact::createNew($contact->account->account_key, [ + 'contact_key' => $contact->contact_key, + ]); +}); diff --git a/app/Models/Invitation.php b/app/Models/Invitation.php index 7797eed651be..a33785af452d 100644 --- a/app/Models/Invitation.php +++ b/app/Models/Invitation.php @@ -5,6 +5,7 @@ namespace App\Models; use Carbon; use Illuminate\Database\Eloquent\SoftDeletes; use Utils; +use App\Models\LookupInvitation; /** * Class Invitation. @@ -162,3 +163,10 @@ class Invitation extends EntityModel return sprintf('

%s: %s', $this->signature_base64, trans('texts.signed'), Utils::fromSqlDateTime($this->signature_date)); } } + +Invitation::creating(function ($invitation) +{ + LookupInvitation::createNew($invitation->account->account_key, [ + 'invitation_key' => $invitation->invitation_key, + ]); +}); diff --git a/app/Models/LookupAccount.php b/app/Models/LookupAccount.php index cb9f3a672d76..935c3f6423df 100644 --- a/app/Models/LookupAccount.php +++ b/app/Models/LookupAccount.php @@ -7,13 +7,8 @@ use Eloquent; /** * Class ExpenseCategory. */ -class LookupAccount extends Eloquent +class LookupAccount extends LookupModel { - /** - * @var bool - */ - public $timestamps = false; - /** * @var array */ diff --git a/app/Models/LookupCompany.php b/app/Models/LookupCompany.php index 0e8a32120262..dd07075247d2 100644 --- a/app/Models/LookupCompany.php +++ b/app/Models/LookupCompany.php @@ -7,13 +7,8 @@ use Eloquent; /** * Class ExpenseCategory. */ -class LookupCompany extends Eloquent +class LookupCompany extends LookupModel { - /** - * @var bool - */ - public $timestamps = false; - /** * @var array */ diff --git a/app/Models/LookupContact.php b/app/Models/LookupContact.php index 025ba3e3ba55..2caf8c6ca6b1 100644 --- a/app/Models/LookupContact.php +++ b/app/Models/LookupContact.php @@ -7,13 +7,8 @@ use Eloquent; /** * Class ExpenseCategory. */ -class LookupContact extends Eloquent +class LookupContact extends LookupModel { - /** - * @var bool - */ - public $timestamps = false; - /** * @var array */ diff --git a/app/Models/LookupInvitation.php b/app/Models/LookupInvitation.php index ebf9f1a4f6bd..7829a5c9a520 100644 --- a/app/Models/LookupInvitation.php +++ b/app/Models/LookupInvitation.php @@ -7,13 +7,8 @@ use Eloquent; /** * Class ExpenseCategory. */ -class LookupInvitation extends Eloquent +class LookupInvitation extends LookupModel { - /** - * @var bool - */ - public $timestamps = false; - /** * @var array */ diff --git a/app/Models/LookupModel.php b/app/Models/LookupModel.php new file mode 100644 index 000000000000..914be6c1501d --- /dev/null +++ b/app/Models/LookupModel.php @@ -0,0 +1,39 @@ + DB_NINJA_LOOKUP]); + + $lookupAccount = LookupAccount::whereAccountKey($accountKey)->first(); + + if ($lookupAccount) { + $data['lookup_account_id'] = $lookupAccount->id; + } else { + abort('Lookup account not found for ' . $accountKey); + } + + static::create($data); + + config(['database.default' => $current]); + } +} diff --git a/app/Models/LookupToken.php b/app/Models/LookupToken.php index ed7294732204..a0b53514cbab 100644 --- a/app/Models/LookupToken.php +++ b/app/Models/LookupToken.php @@ -7,13 +7,8 @@ use Eloquent; /** * Class ExpenseCategory. */ -class LookupToken extends Eloquent +class LookupToken extends LookupModel { - /** - * @var bool - */ - public $timestamps = false; - /** * @var array */ diff --git a/app/Models/LookupUser.php b/app/Models/LookupUser.php index e5820c57dd3b..33530bd2bddd 100644 --- a/app/Models/LookupUser.php +++ b/app/Models/LookupUser.php @@ -7,13 +7,8 @@ use Eloquent; /** * Class ExpenseCategory. */ -class LookupUser extends Eloquent +class LookupUser extends LookupModel { - /** - * @var bool - */ - public $timestamps = false; - /** * @var array */ diff --git a/app/Models/User.php b/app/Models/User.php index 240234d878f5..2474ce2d42f5 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Laracasts\Presenter\PresentableTrait; use Session; +use App\Models\LookupUser; /** * Class User. @@ -412,6 +413,17 @@ class User extends Authenticatable } } +User::creating(function ($user) +{ + if (! $user->registered) { + return; + } + + LookupUser::createNew($user->account->account_key, [ + 'email' => $user->email, + ]); +}); + User::updating(function ($user) { User::onUpdatingUser($user); }); From 068666a58bba5d2c76e319e6070bce5bb6e7eedb Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Sun, 30 Apr 2017 22:29:15 +0300 Subject: [PATCH 06/81] Multi-db support --- app/Console/Commands/InitLookup.php | 21 ++++++++++++++----- app/Models/LookupUser.php | 1 + app/Models/User.php | 5 +---- ...0_174702_add_multiple_database_support.php | 5 +++++ 4 files changed, 23 insertions(+), 9 deletions(-) diff --git a/app/Console/Commands/InitLookup.php b/app/Console/Commands/InitLookup.php index b478a18e37bb..bfb6fa8e3286 100644 --- a/app/Console/Commands/InitLookup.php +++ b/app/Console/Commands/InitLookup.php @@ -106,6 +106,7 @@ class InitLookup extends Command LookupUser::create([ 'lookup_account_id' => $lookupAccount->id, 'email' => $user['email'], + 'user_id' => $user['user_id'], ]); } foreach ($account['contacts'] as $contact) { @@ -154,24 +155,34 @@ class InitLookup extends Command 'tokens' => [], ]; - $users = DB::table('users')->whereAccountId($accountId)->orderBy('id')->get(['email']); + $users = DB::table('users')->whereAccountId($accountId)->orderBy('id')->get(['email', 'id']); foreach ($users as $user) { - $data['users'][] = ['email' => $user->email]; + $data['users'][] = [ + 'email' => $user->email, + 'user_id' => $user->id, + ]; } $contacts = DB::table('contacts')->whereAccountId($accountId)->orderBy('id')->get(['contact_key']); foreach ($contacts as $contact) { - $data['contacts'][] = ['contact_key' => $contact->contact_key]; + $data['contacts'][] = [ + 'contact_key' => $contact->contact_key, + ]; } $invitations = DB::table('invitations')->whereAccountId($accountId)->orderBy('id')->get(['invitation_key', 'message_id']); foreach ($invitations as $invitation) { - $data['invitations'][] = ['invitation_key' => $invitation->invitation_key, 'message_id' => $invitation->message_id]; + $data['invitations'][] = [ + 'invitation_key' => $invitation->invitation_key, + 'message_id' => $invitation->message_id, + ]; } $tokens = DB::table('account_tokens')->whereAccountId($accountId)->orderBy('id')->get(['token']); foreach ($tokens as $token) { - $data['tokens'][] = ['token' => $token->token]; + $data['tokens'][] = [ + 'token' => $token->token, + ]; } return $data; diff --git a/app/Models/LookupUser.php b/app/Models/LookupUser.php index 33530bd2bddd..3cb8ce288e45 100644 --- a/app/Models/LookupUser.php +++ b/app/Models/LookupUser.php @@ -15,6 +15,7 @@ class LookupUser extends LookupModel protected $fillable = [ 'lookup_account_id', 'email', + 'user_id', ]; } diff --git a/app/Models/User.php b/app/Models/User.php index 2474ce2d42f5..243835e180eb 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -415,12 +415,9 @@ class User extends Authenticatable User::creating(function ($user) { - if (! $user->registered) { - return; - } - LookupUser::createNew($user->account->account_key, [ 'email' => $user->email, + 'user_id' => $user->id, ]); }); diff --git a/database/migrations/2017_04_30_174702_add_multiple_database_support.php b/database/migrations/2017_04_30_174702_add_multiple_database_support.php index a74c2d2fc758..bad6ec0e0ebb 100644 --- a/database/migrations/2017_04_30_174702_add_multiple_database_support.php +++ b/database/migrations/2017_04_30_174702_add_multiple_database_support.php @@ -26,6 +26,11 @@ class AddMultipleDatabaseSupport extends Migration Schema::table('lookup_users', function ($table) { $table->string('email')->change()->unique(); + $table->unsignedInteger('user_id')->index(); + }); + + Schema::table('lookup_users', function ($table) { + $table->unique(['lookup_account_id', 'user_id']); }); Schema::table('lookup_contacts', function ($table) { From 97c2a358688991c2f334b72ea0dc8daebb49ae06 Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Sun, 30 Apr 2017 22:52:05 +0300 Subject: [PATCH 07/81] Multi-db support --- app/Constants.php | 1 + app/Http/Controllers/Auth/AuthController.php | 3 +++ app/Http/Kernel.php | 1 + app/Http/routes.php | 8 ++++---- app/Models/LookupAccount.php | 5 +++++ app/Models/LookupCompany.php | 5 +++++ app/Models/LookupModel.php | 9 +++++++++ app/Models/LookupUser.php | 16 ++++++++++++++++ 8 files changed, 44 insertions(+), 4 deletions(-) diff --git a/app/Constants.php b/app/Constants.php index 23ddc079c549..954997211160 100644 --- a/app/Constants.php +++ b/app/Constants.php @@ -229,6 +229,7 @@ if (! defined('APP_NAME')) { define('SESSION_REFERRAL_CODE', 'referralCode'); define('SESSION_LEFT_SIDEBAR', 'showLeftSidebar'); define('SESSION_RIGHT_SIDEBAR', 'showRightSidebar'); + define('SESSION_DB_SERVER', 'dbServer'); define('SESSION_LAST_REQUEST_PAGE', 'SESSION_LAST_REQUEST_PAGE'); define('SESSION_LAST_REQUEST_TIME', 'SESSION_LAST_REQUEST_TIME'); diff --git a/app/Http/Controllers/Auth/AuthController.php b/app/Http/Controllers/Auth/AuthController.php index 0fb64d2d8ffd..1126dc7d8bd3 100644 --- a/app/Http/Controllers/Auth/AuthController.php +++ b/app/Http/Controllers/Auth/AuthController.php @@ -13,6 +13,7 @@ use Illuminate\Foundation\Auth\AuthenticatesAndRegistersUsers; use Illuminate\Http\Request; use Session; use Utils; +use App\Models\LookupUser; class AuthController extends Controller { @@ -141,6 +142,8 @@ class AuthController extends Controller */ public function postLoginWrapper(Request $request) { + LookupUser::loadEmail($request->input('email')); + $userId = Auth::check() ? Auth::user()->id : null; $user = User::where('email', '=', $request->input('email'))->first(); diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index e508f8d0c551..78685293b9d1 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -35,5 +35,6 @@ class Kernel extends HttpKernel 'guest' => 'App\Http\Middleware\RedirectIfAuthenticated', 'api' => 'App\Http\Middleware\ApiCheck', 'cors' => '\Barryvdh\Cors\HandleCors', + 'lookup' => 'App\Http\Middleware\DatabaseLookup', ]; } diff --git a/app/Http/routes.php b/app/Http/routes.php index 8506828fee72..5fc0e8b5799b 100644 --- a/app/Http/routes.php +++ b/app/Http/routes.php @@ -25,7 +25,7 @@ Route::get('/keep_alive', 'HomeController@keepAlive'); Route::post('/get_started', 'AccountController@getStarted'); // Client visible pages -Route::group(['middleware' => 'auth:client'], function () { +Route::group(['middleware' => ['auth:client', 'lookup']], function () { Route::get('view/{invitation_key}', 'ClientPortalController@view'); Route::get('download/{invitation_key}', 'ClientPortalController@download'); Route::put('sign/{invitation_key}', 'ClientPortalController@sign'); @@ -117,7 +117,7 @@ if (Utils::isTravis()) { Route::get('/check_data', 'AppController@checkData'); } -Route::group(['middleware' => 'auth:user'], function () { +Route::group(['middleware' => ['auth:user', 'lookup']], function () { Route::get('dashboard', 'DashboardController@index'); Route::get('dashboard_chart_data/{group_by}/{start_date}/{end_date}/{currency_id}/{include_expenses}', 'DashboardController@chartData'); Route::get('set_entity_filter/{entity_type}/{filter?}', 'AccountController@setEntityFilter'); @@ -230,7 +230,7 @@ Route::group(['middleware' => 'auth:user'], function () { }); Route::group([ - 'middleware' => ['auth:user', 'permissions.required'], + 'middleware' => ['auth:user', 'permissions.required', 'lookup'], 'permissions' => 'admin', ], function () { Route::get('api/users', 'UserController@getDatatable'); @@ -295,7 +295,7 @@ Route::group([ Route::get('self-update/download', 'SelfUpdateController@download'); }); -Route::group(['middleware' => 'auth:user'], function () { +Route::group(['middleware' => ['auth:user', 'lookup']], function () { Route::get('settings/{section?}', 'AccountController@showSection'); }); diff --git a/app/Models/LookupAccount.php b/app/Models/LookupAccount.php index 935c3f6423df..fcb30596ce0c 100644 --- a/app/Models/LookupAccount.php +++ b/app/Models/LookupAccount.php @@ -17,4 +17,9 @@ class LookupAccount extends LookupModel 'account_key', ]; + public function lookupCompany() + { + return $this->belongsTo('App\Models\LookupCompany'); + } + } diff --git a/app/Models/LookupCompany.php b/app/Models/LookupCompany.php index dd07075247d2..a5ba316fad4b 100644 --- a/app/Models/LookupCompany.php +++ b/app/Models/LookupCompany.php @@ -17,4 +17,9 @@ class LookupCompany extends LookupModel 'company_id', ]; + public function dbServer() + { + return $this->belongsTo('App\Models\DbServer'); + } + } diff --git a/app/Models/LookupModel.php b/app/Models/LookupModel.php index 914be6c1501d..d690247ca616 100644 --- a/app/Models/LookupModel.php +++ b/app/Models/LookupModel.php @@ -14,6 +14,10 @@ class LookupModel extends Eloquent */ public $timestamps = false; + public function lookupAccount() + { + return $this->belongsTo('App\Models\LookupAccount'); + } public static function createNew($accountKey, $data) { @@ -36,4 +40,9 @@ class LookupModel extends Eloquent config(['database.default' => $current]); } + + public function getDbServer() + { + return $this->lookupAccount->lookupCompany->dbServer->name; + } } diff --git a/app/Models/LookupUser.php b/app/Models/LookupUser.php index 3cb8ce288e45..f5c72596025b 100644 --- a/app/Models/LookupUser.php +++ b/app/Models/LookupUser.php @@ -18,4 +18,20 @@ class LookupUser extends LookupModel 'user_id', ]; + public static function loadEmail($email) + { + if (! env('MULTI_DB_ENABLED')) { + return; + } + + $current = config('database.default'); + config(['database.default' => DB_NINJA_LOOKUP]); + + if ($lookupUser = static::whereEmail($email)->first()) { + session(['SESSION_DB_SERVER' => $lookupUser->getDbServer()]); + } + + config(['database.default' => $current]); + + } } From 2e0cd34bf8bd3358f8e8ca76e3598b0e921db669 Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Sun, 30 Apr 2017 23:07:58 +0300 Subject: [PATCH 08/81] Multi-db support --- app/Http/Controllers/Auth/AuthController.php | 2 +- app/Models/LookupUser.php | 16 +++++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/app/Http/Controllers/Auth/AuthController.php b/app/Http/Controllers/Auth/AuthController.php index 1126dc7d8bd3..c9c740bed89d 100644 --- a/app/Http/Controllers/Auth/AuthController.php +++ b/app/Http/Controllers/Auth/AuthController.php @@ -142,7 +142,7 @@ class AuthController extends Controller */ public function postLoginWrapper(Request $request) { - LookupUser::loadEmail($request->input('email')); + LookupUser::setServerByEmail($request->input('email')); $userId = Auth::check() ? Auth::user()->id : null; $user = User::where('email', '=', $request->input('email'))->first(); diff --git a/app/Models/LookupUser.php b/app/Models/LookupUser.php index f5c72596025b..665d89a3f2ad 100644 --- a/app/Models/LookupUser.php +++ b/app/Models/LookupUser.php @@ -3,6 +3,7 @@ namespace App\Models; use Eloquent; +use App\Models\User; /** * Class ExpenseCategory. @@ -18,7 +19,7 @@ class LookupUser extends LookupModel 'user_id', ]; - public static function loadEmail($email) + public static function setServerByEmail($email) { if (! env('MULTI_DB_ENABLED')) { return; @@ -28,10 +29,15 @@ class LookupUser extends LookupModel config(['database.default' => DB_NINJA_LOOKUP]); if ($lookupUser = static::whereEmail($email)->first()) { - session(['SESSION_DB_SERVER' => $lookupUser->getDbServer()]); + $server = $lookupUser->getDbServer(); + session(['SESSION_DB_SERVER' => $server]); + config(['database.default' => $server]); + + if (! User::whereEmail($email)->first()) { + abort('Lookedup user not found: ' . $email); + } + } else { + config(['database.default' => $current]); } - - config(['database.default' => $current]); - } } From 2b564e4fc06c9ea5ab8336a2299d94b2cb7a8863 Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Sun, 30 Apr 2017 23:08:07 +0300 Subject: [PATCH 09/81] Multi-db support --- app/Http/Middleware/DatabaseLookup.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 app/Http/Middleware/DatabaseLookup.php diff --git a/app/Http/Middleware/DatabaseLookup.php b/app/Http/Middleware/DatabaseLookup.php new file mode 100644 index 000000000000..6aecf60d7edb --- /dev/null +++ b/app/Http/Middleware/DatabaseLookup.php @@ -0,0 +1,18 @@ + Date: Mon, 1 May 2017 09:50:10 +0300 Subject: [PATCH 10/81] Multi-db support --- app/Constants.php | 3 +- app/Http/Controllers/Auth/AuthController.php | 2 +- app/Http/Kernel.php | 2 +- app/Http/Middleware/DatabaseLookup.php | 22 +++++++++-- app/Http/routes.php | 8 ++-- app/Models/LookupModel.php | 40 ++++++++++++++++++++ app/Models/LookupUser.php | 21 ---------- 7 files changed, 67 insertions(+), 31 deletions(-) diff --git a/app/Constants.php b/app/Constants.php index 954997211160..7ddb3aabce0a 100644 --- a/app/Constants.php +++ b/app/Constants.php @@ -229,7 +229,8 @@ if (! defined('APP_NAME')) { define('SESSION_REFERRAL_CODE', 'referralCode'); define('SESSION_LEFT_SIDEBAR', 'showLeftSidebar'); define('SESSION_RIGHT_SIDEBAR', 'showRightSidebar'); - define('SESSION_DB_SERVER', 'dbServer'); + define('SESSION_USER_DB_SERVER', 'userDbServer'); + define('SESSION_CONTACT_DB_SERVER', 'contactDbServer'); define('SESSION_LAST_REQUEST_PAGE', 'SESSION_LAST_REQUEST_PAGE'); define('SESSION_LAST_REQUEST_TIME', 'SESSION_LAST_REQUEST_TIME'); diff --git a/app/Http/Controllers/Auth/AuthController.php b/app/Http/Controllers/Auth/AuthController.php index c9c740bed89d..59c8e194e389 100644 --- a/app/Http/Controllers/Auth/AuthController.php +++ b/app/Http/Controllers/Auth/AuthController.php @@ -142,7 +142,7 @@ class AuthController extends Controller */ public function postLoginWrapper(Request $request) { - LookupUser::setServerByEmail($request->input('email')); + LookupUser::setServerByField('email', $request->input('email')); $userId = Auth::check() ? Auth::user()->id : null; $user = User::where('email', '=', $request->input('email'))->first(); diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 78685293b9d1..49133b82d711 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -29,12 +29,12 @@ class Kernel extends HttpKernel * @var array */ protected $routeMiddleware = [ + 'lookup' => 'App\Http\Middleware\DatabaseLookup', 'auth' => 'App\Http\Middleware\Authenticate', 'auth.basic' => 'Illuminate\Auth\Middleware\AuthenticateWithBasicAuth', 'permissions.required' => 'App\Http\Middleware\PermissionsRequired', 'guest' => 'App\Http\Middleware\RedirectIfAuthenticated', 'api' => 'App\Http\Middleware\ApiCheck', 'cors' => '\Barryvdh\Cors\HandleCors', - 'lookup' => 'App\Http\Middleware\DatabaseLookup', ]; } diff --git a/app/Http/Middleware/DatabaseLookup.php b/app/Http/Middleware/DatabaseLookup.php index 6aecf60d7edb..f47d4fa26fa0 100644 --- a/app/Http/Middleware/DatabaseLookup.php +++ b/app/Http/Middleware/DatabaseLookup.php @@ -4,13 +4,29 @@ namespace App\Http\Middleware; use Illuminate\Http\Request; use Closure; +use App\Models\LookupContact; +use App\Models\LookupInvitation; class DatabaseLookup { - public function handle(Request $request, Closure $next) + public function handle(Request $request, Closure $next, $guard = 'user') { - if (env('MULTI_DB_ENABLED') && ! session('SESSION_DB_SERVER')) { - return redirect('/logout'); + if (! env('MULTI_DB_ENABLED')) { + return $next($request); + } + + // user's value is set when logging in + if ($guard == 'user') { + if (! session('SESSION_USER_DB_SERVER')) { + return redirect('/logout'); + } + // contacts can login with just the URL + } else { + if (request()->invitation_key) { + LookupInvitation::setServerByField('invitation_key', request()->invitation_key); + } elseif (request()->contact_key) { + LookupContact::setServerByField('contact_key', request()->contact_key); + } } return $next($request); diff --git a/app/Http/routes.php b/app/Http/routes.php index 5fc0e8b5799b..1e329598119c 100644 --- a/app/Http/routes.php +++ b/app/Http/routes.php @@ -25,7 +25,7 @@ Route::get('/keep_alive', 'HomeController@keepAlive'); Route::post('/get_started', 'AccountController@getStarted'); // Client visible pages -Route::group(['middleware' => ['auth:client', 'lookup']], function () { +Route::group(['middleware' => ['lookup:contact', 'auth:client']], function () { Route::get('view/{invitation_key}', 'ClientPortalController@view'); Route::get('download/{invitation_key}', 'ClientPortalController@download'); Route::put('sign/{invitation_key}', 'ClientPortalController@sign'); @@ -117,7 +117,7 @@ if (Utils::isTravis()) { Route::get('/check_data', 'AppController@checkData'); } -Route::group(['middleware' => ['auth:user', 'lookup']], function () { +Route::group(['middleware' => ['lookup:user', 'auth:user']], function () { Route::get('dashboard', 'DashboardController@index'); Route::get('dashboard_chart_data/{group_by}/{start_date}/{end_date}/{currency_id}/{include_expenses}', 'DashboardController@chartData'); Route::get('set_entity_filter/{entity_type}/{filter?}', 'AccountController@setEntityFilter'); @@ -230,7 +230,7 @@ Route::group(['middleware' => ['auth:user', 'lookup']], function () { }); Route::group([ - 'middleware' => ['auth:user', 'permissions.required', 'lookup'], + 'middleware' => ['lookup:user', 'auth:user', 'permissions.required'], 'permissions' => 'admin', ], function () { Route::get('api/users', 'UserController@getDatatable'); @@ -295,7 +295,7 @@ Route::group([ Route::get('self-update/download', 'SelfUpdateController@download'); }); -Route::group(['middleware' => ['auth:user', 'lookup']], function () { +Route::group(['middleware' => ['lookup:user', 'auth:user']], function () { Route::get('settings/{section?}', 'AccountController@showSection'); }); diff --git a/app/Models/LookupModel.php b/app/Models/LookupModel.php index d690247ca616..0133909cf931 100644 --- a/app/Models/LookupModel.php +++ b/app/Models/LookupModel.php @@ -41,6 +41,46 @@ class LookupModel extends Eloquent config(['database.default' => $current]); } + public static function setServerByField($field, $value) + { + if (! env('MULTI_DB_ENABLED')) { + return; + } + + $className = get_called_class(); + $className = str_replace('Lookup', '', $className); + $key = sprintf('server:%s:%s:%s', $className, $field, $value); + + // check if we've cached this lookup + if ($server = session($key)) { + static::setDbServer($server); + return; + } + + $current = config('database.default'); + config(['database.default' => DB_NINJA_LOOKUP]); + + if ($lookupUser = static::where($field, '=', $value)->first()) { + $server = $lookupUser->getDbServer(); + static::setDbServer($server); + + $entity = new $className(); + if (! $entity::where($field, '=', $value)->first()) { + abort("Looked up {$className} not found: {$field} => {$value}"); + } + + session([$key => $server]); + } else { + config(['database.default' => $current]); + } + } + + public static function setDbServer($server) + { + session(['SESSION_USER_DB_SERVER' => $server]); + config(['database.default' => $server]); + } + public function getDbServer() { return $this->lookupAccount->lookupCompany->dbServer->name; diff --git a/app/Models/LookupUser.php b/app/Models/LookupUser.php index 665d89a3f2ad..9ba87158bf8b 100644 --- a/app/Models/LookupUser.php +++ b/app/Models/LookupUser.php @@ -19,25 +19,4 @@ class LookupUser extends LookupModel 'user_id', ]; - public static function setServerByEmail($email) - { - if (! env('MULTI_DB_ENABLED')) { - return; - } - - $current = config('database.default'); - config(['database.default' => DB_NINJA_LOOKUP]); - - if ($lookupUser = static::whereEmail($email)->first()) { - $server = $lookupUser->getDbServer(); - session(['SESSION_DB_SERVER' => $server]); - config(['database.default' => $server]); - - if (! User::whereEmail($email)->first()) { - abort('Lookedup user not found: ' . $email); - } - } else { - config(['database.default' => $current]); - } - } } From 479340c07a24fd0b58a3f1142c7bd163fe59863b Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Mon, 1 May 2017 10:03:42 +0300 Subject: [PATCH 11/81] Multi-db support --- app/Console/Commands/InitLookup.php | 4 ++-- app/Http/Middleware/DatabaseLookup.php | 6 +++++- app/Http/routes.php | 2 +- app/Models/AccountToken.php | 4 ++-- app/Models/{LookupToken.php => LookupAccountToken.php} | 2 +- .../2017_04_30_174702_add_multiple_database_support.php | 4 ++++ 6 files changed, 15 insertions(+), 7 deletions(-) rename app/Models/{LookupToken.php => LookupAccountToken.php} (81%) diff --git a/app/Console/Commands/InitLookup.php b/app/Console/Commands/InitLookup.php index bfb6fa8e3286..97c4a97744b0 100644 --- a/app/Console/Commands/InitLookup.php +++ b/app/Console/Commands/InitLookup.php @@ -9,7 +9,7 @@ use App\Models\LookupCompany; use App\Models\LookupAccount; use App\Models\LookupUser; use App\Models\LookupContact; -use App\Models\LookupToken; +use App\Models\LookupAccountToken; use App\Models\LookupInvitation; class InitLookup extends Command @@ -123,7 +123,7 @@ class InitLookup extends Command ]); } foreach ($account['tokens'] as $token) { - LookupToken::create([ + LookupAccountToken::create([ 'lookup_account_id' => $lookupAccount->id, 'token' => $token['token'], ]); diff --git a/app/Http/Middleware/DatabaseLookup.php b/app/Http/Middleware/DatabaseLookup.php index f47d4fa26fa0..504961d859e9 100644 --- a/app/Http/Middleware/DatabaseLookup.php +++ b/app/Http/Middleware/DatabaseLookup.php @@ -6,6 +6,7 @@ use Illuminate\Http\Request; use Closure; use App\Models\LookupContact; use App\Models\LookupInvitation; +use App\Models\LookupAccountToken; class DatabaseLookup { @@ -20,7 +21,10 @@ class DatabaseLookup if (! session('SESSION_USER_DB_SERVER')) { return redirect('/logout'); } - // contacts can login with just the URL + } elseif ($guard == 'api') { + if ($token = $request->header('X-Ninja-Token')) { + LookupAccountToken::setServerByField('token', $token); + } } else { if (request()->invitation_key) { LookupInvitation::setServerByField('invitation_key', request()->invitation_key); diff --git a/app/Http/routes.php b/app/Http/routes.php index 1e329598119c..5b9249253cfa 100644 --- a/app/Http/routes.php +++ b/app/Http/routes.php @@ -300,7 +300,7 @@ Route::group(['middleware' => ['lookup:user', 'auth:user']], function () { }); // Route groups for API -Route::group(['middleware' => 'api', 'prefix' => 'api/v1'], function () { +Route::group(['middleware' => ['lookup:api', 'api'], 'prefix' => 'api/v1'], function () { Route::get('ping', 'AccountApiController@ping'); Route::post('login', 'AccountApiController@login'); Route::post('oauth_login', 'AccountApiController@oauthLogin'); diff --git a/app/Models/AccountToken.php b/app/Models/AccountToken.php index c68dde7148d2..baf3bd9477c7 100644 --- a/app/Models/AccountToken.php +++ b/app/Models/AccountToken.php @@ -3,7 +3,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\SoftDeletes; -use App\Models\LookupToken; +use App\Models\LookupAccountToken; /** * Class AccountToken. @@ -43,7 +43,7 @@ class AccountToken extends EntityModel AccountToken::creating(function ($token) { - LookupToken::createNew($token->account->account_key, [ + LookupAccountToken::createNew($token->account->account_key, [ 'token' => $token->token, ]); }); diff --git a/app/Models/LookupToken.php b/app/Models/LookupAccountToken.php similarity index 81% rename from app/Models/LookupToken.php rename to app/Models/LookupAccountToken.php index a0b53514cbab..d72d7613fc34 100644 --- a/app/Models/LookupToken.php +++ b/app/Models/LookupAccountToken.php @@ -7,7 +7,7 @@ use Eloquent; /** * Class ExpenseCategory. */ -class LookupToken extends LookupModel +class LookupAccountToken extends LookupModel { /** * @var array diff --git a/database/migrations/2017_04_30_174702_add_multiple_database_support.php b/database/migrations/2017_04_30_174702_add_multiple_database_support.php index bad6ec0e0ebb..01aff58507d5 100644 --- a/database/migrations/2017_04_30_174702_add_multiple_database_support.php +++ b/database/migrations/2017_04_30_174702_add_multiple_database_support.php @@ -45,6 +45,8 @@ class AddMultipleDatabaseSupport extends Migration Schema::table('lookup_tokens', function ($table) { $table->string('token')->change()->unique(); }); + + Schema::rename('lookup_tokens', 'lookup_account_tokens'); } /** @@ -57,5 +59,7 @@ class AddMultipleDatabaseSupport extends Migration Schema::table('lookup_companies', function ($table) { $table->dropColumn('company_id'); }); + + Schema::rename('lookup_account_tokens', 'lookup_tokens'); } } From d5a63ffeefe71620ef34f9111b7a486eee5d7613 Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Mon, 1 May 2017 10:19:27 +0300 Subject: [PATCH 12/81] Multi-db support --- app/Http/Middleware/DatabaseLookup.php | 4 ++-- app/Models/LookupUser.php | 22 ++++++++++++++++++++++ app/Models/User.php | 5 +++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/app/Http/Middleware/DatabaseLookup.php b/app/Http/Middleware/DatabaseLookup.php index 504961d859e9..cac9a6d2fe8a 100644 --- a/app/Http/Middleware/DatabaseLookup.php +++ b/app/Http/Middleware/DatabaseLookup.php @@ -16,8 +16,8 @@ class DatabaseLookup return $next($request); } - // user's value is set when logging in if ($guard == 'user') { + // user's value is set when logging in if (! session('SESSION_USER_DB_SERVER')) { return redirect('/logout'); } @@ -25,7 +25,7 @@ class DatabaseLookup if ($token = $request->header('X-Ninja-Token')) { LookupAccountToken::setServerByField('token', $token); } - } else { + } elseif ($guard == 'contact') { if (request()->invitation_key) { LookupInvitation::setServerByField('invitation_key', request()->invitation_key); } elseif (request()->contact_key) { diff --git a/app/Models/LookupUser.php b/app/Models/LookupUser.php index 9ba87158bf8b..dc985cde61b8 100644 --- a/app/Models/LookupUser.php +++ b/app/Models/LookupUser.php @@ -19,4 +19,26 @@ class LookupUser extends LookupModel 'user_id', ]; + public static function updateUser($accountKey, $userId, $email) + { + if (! env('MULTI_DB_ENABLED')) { + return; + } + + $current = config('database.default'); + config(['database.default' => DB_NINJA_LOOKUP]); + + $lookupAccount = LookupAccount::whereAccountKey($accountKey) + ->firstOrFail(); + + $lookupUser = LookupUser::whereLookupAccountId($lookupAccount->id) + ->whereUserId($userId) + ->firstOrFail(); + + $lookupUser->email = $email; + $lookupUser->save(); + + config(['database.default' => $current]); + } + } diff --git a/app/Models/User.php b/app/Models/User.php index 243835e180eb..d89a88b66a8d 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -423,6 +423,11 @@ User::creating(function ($user) User::updating(function ($user) { User::onUpdatingUser($user); + + $dirty = $user->getDirty(); + if (isset($dirty['email'])) { + LookupUser::updateUser($user->account->account_key, $user->id, $user->email); + } }); User::updated(function ($user) { From aaba8e4ab19bc69b04167a8e7ca9cb4e8f00113c Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Mon, 1 May 2017 11:13:15 +0300 Subject: [PATCH 13/81] Multi-db support --- app/Models/Account.php | 7 +++++ app/Models/LookupAccount.php | 27 +++++++++++++++++++ app/Models/LookupModel.php | 3 ++- app/Models/User.php | 2 +- ...0_174702_add_multiple_database_support.php | 2 +- 5 files changed, 38 insertions(+), 3 deletions(-) diff --git a/app/Models/Account.php b/app/Models/Account.php index 42e427a23936..5e830735c943 100644 --- a/app/Models/Account.php +++ b/app/Models/Account.php @@ -4,6 +4,7 @@ namespace App\Models; use App; use App\Events\UserSettingsChanged; +use App\Models\LookupAccount; use App\Models\Traits\GeneratesNumbers; use App\Models\Traits\PresentsInvoice; use App\Models\Traits\SendsEmails; @@ -1655,6 +1656,12 @@ class Account extends Eloquent } } +Account::creating(function ($account) +{ + LookupAccount::createAccount($account->account_key, $account->company_id); +}); + + Account::updated(function ($account) { // prevent firing event if the invoice/quote counter was changed // TODO: remove once counters are moved to separate table diff --git a/app/Models/LookupAccount.php b/app/Models/LookupAccount.php index fcb30596ce0c..7354e3c6746a 100644 --- a/app/Models/LookupAccount.php +++ b/app/Models/LookupAccount.php @@ -22,4 +22,31 @@ class LookupAccount extends LookupModel return $this->belongsTo('App\Models\LookupCompany'); } + public static function createAccount($accountKey, $companyId) + { + if (! env('MULTI_DB_ENABLED')) { + return; + } + + $current = config('database.default'); + config(['database.default' => DB_NINJA_LOOKUP]); + + $server = DbServer::whereName($current)->firstOrFail(); + $lookupCompany = LookupCompany::whereDbServerId($server->id) + ->whereCompanyId($companyId)->first(); + + if (! $lookupCompany) { + $lookupCompany = LookupCompany::create([ + 'db_server_id' => $server->id, + 'company_id' => $companyId, + ]); + } + + LookupAccount::create([ + 'lookup_company_id' => $lookupCompany->id, + 'account_key' => $accountKey, + ]); + + static::setDbServer($current); + } } diff --git a/app/Models/LookupModel.php b/app/Models/LookupModel.php index 0133909cf931..0e8a634bb636 100644 --- a/app/Models/LookupModel.php +++ b/app/Models/LookupModel.php @@ -61,10 +61,11 @@ class LookupModel extends Eloquent config(['database.default' => DB_NINJA_LOOKUP]); if ($lookupUser = static::where($field, '=', $value)->first()) { + $entity = new $className(); $server = $lookupUser->getDbServer(); static::setDbServer($server); - $entity = new $className(); + // check entity is found on the server if (! $entity::where($field, '=', $value)->first()) { abort("Looked up {$className} not found: {$field} => {$value}"); } diff --git a/app/Models/User.php b/app/Models/User.php index d89a88b66a8d..b9edafa6da3b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -413,7 +413,7 @@ class User extends Authenticatable } } -User::creating(function ($user) +User::created(function ($user) { LookupUser::createNew($user->account->account_key, [ 'email' => $user->email, diff --git a/database/migrations/2017_04_30_174702_add_multiple_database_support.php b/database/migrations/2017_04_30_174702_add_multiple_database_support.php index 01aff58507d5..a0fbbeb6fe61 100644 --- a/database/migrations/2017_04_30_174702_add_multiple_database_support.php +++ b/database/migrations/2017_04_30_174702_add_multiple_database_support.php @@ -25,7 +25,7 @@ class AddMultipleDatabaseSupport extends Migration }); Schema::table('lookup_users', function ($table) { - $table->string('email')->change()->unique(); + $table->string('email')->change()->nullable()->unique(); $table->unsignedInteger('user_id')->index(); }); From b3f53b99fa4c464cea6b77fea8d247258067b987 Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Mon, 1 May 2017 12:29:45 +0300 Subject: [PATCH 14/81] Multi-db support --- app/Jobs/PurgeAccountData.php | 12 ++++++++++++ app/Models/Account.php | 8 +++++++- app/Models/AccountToken.php | 7 +++++++ app/Models/Company.php | 14 ++++++++++++++ app/Models/Contact.php | 7 +++++++ app/Models/Invitation.php | 7 +++++++ app/Models/LookupModel.php | 17 ++++++++++++++++- app/Models/User.php | 11 +++++++++++ ..._30_174702_add_multiple_database_support.php | 5 ++++- 9 files changed, 85 insertions(+), 3 deletions(-) diff --git a/app/Jobs/PurgeAccountData.php b/app/Jobs/PurgeAccountData.php index c1f289e2cae3..c06425ea8db4 100644 --- a/app/Jobs/PurgeAccountData.php +++ b/app/Jobs/PurgeAccountData.php @@ -4,6 +4,7 @@ namespace App\Jobs; use App\Jobs\Job; use App\Models\Document; +use App\Models\LookupAccount; use Auth; use DB; use Exception; @@ -57,5 +58,16 @@ class PurgeAccountData extends Job $account->quote_number_counter = 1; $account->client_number_counter = 1; $account->save(); + + if (env('MULTI_DB_ENABLED')) { + $current = config('database.default'); + config(['database.default' => DB_NINJA_LOOKUP]); + + $lookupAccount = LookupAccount::whereAccountKey($account->account_key)->first(); + DB::table('lookup_contacts')->where('lookup_account_id', '=', $lookupAccount->id)->delete(); + DB::table('lookup_invitations')->where('lookup_account_id', '=', $lookupAccount->id)->delete(); + + config(['database.default' => $current]); + } } } diff --git a/app/Models/Account.php b/app/Models/Account.php index 5e830735c943..b5b8b4b2d9c6 100644 --- a/app/Models/Account.php +++ b/app/Models/Account.php @@ -1661,7 +1661,6 @@ Account::creating(function ($account) LookupAccount::createAccount($account->account_key, $account->company_id); }); - Account::updated(function ($account) { // prevent firing event if the invoice/quote counter was changed // TODO: remove once counters are moved to separate table @@ -1672,3 +1671,10 @@ Account::updated(function ($account) { Event::fire(new UserSettingsChanged()); }); + +Account::deleted(function ($account) +{ + LookupAccount::deleteWhere([ + 'account_key' => $account->account_key + ]); +}); diff --git a/app/Models/AccountToken.php b/app/Models/AccountToken.php index baf3bd9477c7..0196c64f47f4 100644 --- a/app/Models/AccountToken.php +++ b/app/Models/AccountToken.php @@ -47,3 +47,10 @@ AccountToken::creating(function ($token) 'token' => $token->token, ]); }); + +AccountToken::deleted(function ($token) +{ + LookupAccountToken::deleteWhere([ + 'token' => $token->token + ]); +}); diff --git a/app/Models/Company.php b/app/Models/Company.php index cfa121d556f4..ce580d884018 100644 --- a/app/Models/Company.php +++ b/app/Models/Company.php @@ -169,3 +169,17 @@ class Company extends Eloquent return false; } } + +Company::deleted(function ($company) +{ + if (! env('MULTI_DB_ENABLED')) { + return; + } + + $server = \App\Models\DbServer::whereName(config('database.default'))->firstOrFail(); + + LookupCompany::deleteWhere([ + 'company_id' => $company->id, + 'db_server_id' => $server->id, + ]); +}); diff --git a/app/Models/Contact.php b/app/Models/Contact.php index fe3547f99742..adc0fa752beb 100644 --- a/app/Models/Contact.php +++ b/app/Models/Contact.php @@ -173,3 +173,10 @@ Contact::creating(function ($contact) 'contact_key' => $contact->contact_key, ]); }); + +Contact::deleted(function ($contact) +{ + LookupContact::deleteWhere([ + 'contact_key' => $contact->contact_key, + ]); +}); diff --git a/app/Models/Invitation.php b/app/Models/Invitation.php index a33785af452d..1447ff4da9f1 100644 --- a/app/Models/Invitation.php +++ b/app/Models/Invitation.php @@ -170,3 +170,10 @@ Invitation::creating(function ($invitation) 'invitation_key' => $invitation->invitation_key, ]); }); + +Invitation::deleted(function ($invitation) +{ + LookupInvitation::deleteWhere([ + 'invitation_key' => $invitation->invitation_key, + ]); +}); diff --git a/app/Models/LookupModel.php b/app/Models/LookupModel.php index 0e8a634bb636..a43577482299 100644 --- a/app/Models/LookupModel.php +++ b/app/Models/LookupModel.php @@ -41,6 +41,21 @@ class LookupModel extends Eloquent config(['database.default' => $current]); } + public static function deleteWhere($where) + { + if (! env('MULTI_DB_ENABLED')) { + return; + } + + $current = config('database.default'); + config(['database.default' => DB_NINJA_LOOKUP]); + + static::where($where)->delete(); + + config(['database.default' => $current]); + + } + public static function setServerByField($field, $value) { if (! env('MULTI_DB_ENABLED')) { @@ -60,7 +75,7 @@ class LookupModel extends Eloquent $current = config('database.default'); config(['database.default' => DB_NINJA_LOOKUP]); - if ($lookupUser = static::where($field, '=', $value)->first()) { + if ($value && $lookupUser = static::where($field, '=', $value)->first()) { $entity = new $className(); $server = $lookupUser->getDbServer(); static::setDbServer($server); diff --git a/app/Models/User.php b/app/Models/User.php index b9edafa6da3b..89506050c805 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -433,3 +433,14 @@ User::updating(function ($user) { User::updated(function ($user) { User::onUpdatedUser($user); }); + +User::deleted(function ($user) +{ + if (! $user->email) { + return; + } + + LookupUser::deleteWhere([ + 'email' => $user->email + ]); +}); diff --git a/database/migrations/2017_04_30_174702_add_multiple_database_support.php b/database/migrations/2017_04_30_174702_add_multiple_database_support.php index a0fbbeb6fe61..3043cbf60a1e 100644 --- a/database/migrations/2017_04_30_174702_add_multiple_database_support.php +++ b/database/migrations/2017_04_30_174702_add_multiple_database_support.php @@ -47,7 +47,10 @@ class AddMultipleDatabaseSupport extends Migration }); Schema::rename('lookup_tokens', 'lookup_account_tokens'); - } + + DB::table('db_servers')->insert( + ['name' => 'db-server-1'] + ); /** * Reverse the migrations. From a52d6a537daa1e21a15338bd3faf015f1daf67de Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Mon, 1 May 2017 13:08:39 +0300 Subject: [PATCH 15/81] Fix for tests --- app/Jobs/PurgeAccountData.php | 2 +- .../2017_04_30_174702_add_multiple_database_support.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/Jobs/PurgeAccountData.php b/app/Jobs/PurgeAccountData.php index c06425ea8db4..a244ed5e1364 100644 --- a/app/Jobs/PurgeAccountData.php +++ b/app/Jobs/PurgeAccountData.php @@ -63,7 +63,7 @@ class PurgeAccountData extends Job $current = config('database.default'); config(['database.default' => DB_NINJA_LOOKUP]); - $lookupAccount = LookupAccount::whereAccountKey($account->account_key)->first(); + $lookupAccount = LookupAccount::whereAccountKey($account->account_key)->firstOrFail(); DB::table('lookup_contacts')->where('lookup_account_id', '=', $lookupAccount->id)->delete(); DB::table('lookup_invitations')->where('lookup_account_id', '=', $lookupAccount->id)->delete(); diff --git a/database/migrations/2017_04_30_174702_add_multiple_database_support.php b/database/migrations/2017_04_30_174702_add_multiple_database_support.php index 3043cbf60a1e..eaf683fe687a 100644 --- a/database/migrations/2017_04_30_174702_add_multiple_database_support.php +++ b/database/migrations/2017_04_30_174702_add_multiple_database_support.php @@ -51,6 +51,7 @@ class AddMultipleDatabaseSupport extends Migration DB::table('db_servers')->insert( ['name' => 'db-server-1'] ); + } /** * Reverse the migrations. From 9d560fc24d3d4e54b5c026bd4dc9f2d9215880de Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Mon, 1 May 2017 15:17:52 +0300 Subject: [PATCH 16/81] Multi-db support --- .../Commands/ChargeRenewalInvoices.php | 8 +- app/Console/Commands/CheckData.php | 5 ++ app/Console/Commands/CreateTestData.php | 8 +- app/Console/Commands/GenerateResources.php | 63 ---------------- app/Console/Commands/Inspire.php | 36 --------- app/Console/Commands/PruneData.php | 14 +++- .../Commands/RemoveOrphanedDocuments.php | 12 ++- app/Console/Commands/ResetData.php | 18 ++++- .../Commands/ResetInvoiceSchemaCounter.php | 75 ------------------- .../Commands/SendRecurringInvoices.php | 8 +- app/Console/Commands/SendReminders.php | 8 +- app/Console/Commands/SendRenewalInvoices.php | 8 +- app/Console/Kernel.php | 1 - app/Constants.php | 6 +- app/Http/Middleware/DatabaseLookup.php | 4 +- app/Http/routes.php | 9 ++- app/Models/LookupModel.php | 12 ++- app/Ninja/Repositories/AccountRepository.php | 7 +- 18 files changed, 99 insertions(+), 203 deletions(-) delete mode 100644 app/Console/Commands/GenerateResources.php delete mode 100644 app/Console/Commands/Inspire.php delete mode 100644 app/Console/Commands/ResetInvoiceSchemaCounter.php diff --git a/app/Console/Commands/ChargeRenewalInvoices.php b/app/Console/Commands/ChargeRenewalInvoices.php index 870406a3af87..ad41b3d77ea3 100644 --- a/app/Console/Commands/ChargeRenewalInvoices.php +++ b/app/Console/Commands/ChargeRenewalInvoices.php @@ -60,6 +60,10 @@ class ChargeRenewalInvoices extends Command { $this->info(date('Y-m-d').' ChargeRenewalInvoices...'); + if ($database = $this->option('database')) { + config(['database.default' => $database]); + } + $ninjaAccount = $this->accountRepo->getNinjaAccount(); $invoices = Invoice::whereAccountId($ninjaAccount->id) ->whereDueDate(date('Y-m-d')) @@ -120,6 +124,8 @@ class ChargeRenewalInvoices extends Command */ protected function getOptions() { - return []; + return [ + ['database', null, InputOption::VALUE_OPTIONAL, 'Database', null], + ]; } } diff --git a/app/Console/Commands/CheckData.php b/app/Console/Commands/CheckData.php index d24ce1432e71..445d50f49052 100644 --- a/app/Console/Commands/CheckData.php +++ b/app/Console/Commands/CheckData.php @@ -64,6 +64,10 @@ class CheckData extends Command { $this->logMessage(date('Y-m-d') . ' Running CheckData...'); + if ($database = $this->option('database')) { + config(['database.default' => $database]); + } + if (! $this->option('client_id')) { $this->checkBlankInvoiceHistory(); $this->checkPaidToDate(); @@ -544,6 +548,7 @@ class CheckData extends Command return [ ['fix', null, InputOption::VALUE_OPTIONAL, 'Fix data', null], ['client_id', null, InputOption::VALUE_OPTIONAL, 'Client id', null], + ['database', null, InputOption::VALUE_OPTIONAL, 'Database', null], ]; } } diff --git a/app/Console/Commands/CreateTestData.php b/app/Console/Commands/CreateTestData.php index ac046f3500c9..87d32b82d3c7 100644 --- a/app/Console/Commands/CreateTestData.php +++ b/app/Console/Commands/CreateTestData.php @@ -74,6 +74,10 @@ class CreateTestData extends Command $this->info(date('Y-m-d').' Running CreateTestData...'); $this->count = $this->argument('count'); + if ($database = $this->option('database')) { + config(['database.default' => $database]); + } + if (filter_var($this->argument('create_account'), FILTER_VALIDATE_BOOLEAN)) { $this->info('Creating new account...'); $account = $this->accountRepo->create( @@ -218,6 +222,8 @@ class CreateTestData extends Command */ protected function getOptions() { - return []; + return [ + ['database', null, InputOption::VALUE_OPTIONAL, 'Database', null], + ]; } } diff --git a/app/Console/Commands/GenerateResources.php b/app/Console/Commands/GenerateResources.php deleted file mode 100644 index 17e139188caf..000000000000 --- a/app/Console/Commands/GenerateResources.php +++ /dev/null @@ -1,63 +0,0 @@ - $value) { - if (is_array($value)) { - echo $key; - } else { - echo "$key => $value\n"; - } - } - } - - /** - * @return array - */ - protected function getArguments() - { - return []; - } - - /** - * @return array - */ - protected function getOptions() - { - return []; - } -} diff --git a/app/Console/Commands/Inspire.php b/app/Console/Commands/Inspire.php deleted file mode 100644 index f49e17c9d96e..000000000000 --- a/app/Console/Commands/Inspire.php +++ /dev/null @@ -1,36 +0,0 @@ -comment(PHP_EOL.Inspiring::quote().PHP_EOL); - } -} diff --git a/app/Console/Commands/PruneData.php b/app/Console/Commands/PruneData.php index 33d2bd64c0a9..0ebccbdf50a4 100644 --- a/app/Console/Commands/PruneData.php +++ b/app/Console/Commands/PruneData.php @@ -14,7 +14,7 @@ class PruneData extends Command * @var string */ protected $name = 'ninja:prune-data'; - + /** * @var string */ @@ -24,6 +24,10 @@ class PruneData extends Command { $this->info(date('Y-m-d').' Running PruneData...'); + if ($database = $this->option('database')) { + config(['database.default' => $database]); + } + // delete accounts who never registered, didn't create any invoices, // hansn't logged in within the past 6 months and isn't linked to another account $sql = 'select a.id @@ -42,14 +46,14 @@ class PruneData extends Command having count(i.id) = 0'; $results = DB::select($sql); - + foreach ($results as $result) { $this->info("Deleting {$result->id}"); DB::table('accounts') ->where('id', '=', $result->id) ->delete(); } - + $this->info('Done'); } @@ -66,6 +70,8 @@ class PruneData extends Command */ protected function getOptions() { - return []; + return [ + ['database', null, InputOption::VALUE_OPTIONAL, 'Database', null], + ]; } } diff --git a/app/Console/Commands/RemoveOrphanedDocuments.php b/app/Console/Commands/RemoveOrphanedDocuments.php index 489d8d94421f..969c62aab4ad 100644 --- a/app/Console/Commands/RemoveOrphanedDocuments.php +++ b/app/Console/Commands/RemoveOrphanedDocuments.php @@ -19,14 +19,18 @@ class RemoveOrphanedDocuments extends Command * @var string */ protected $description = 'Removes old documents not associated with an expense or invoice'; - + public function fire() { $this->info(date('Y-m-d').' Running RemoveOrphanedDocuments...'); + if ($database = $this->option('database')) { + config(['database.default' => $database]); + } + $documents = Document::whereRaw('invoice_id IS NULL AND expense_id IS NULL AND updated_at <= ?', [new DateTime('-1 hour')]) ->get(); - + $this->info(count($documents).' orphaned document(s) found'); foreach ($documents as $document) { @@ -49,6 +53,8 @@ class RemoveOrphanedDocuments extends Command */ protected function getOptions() { - return []; + return [ + ['database', null, InputOption::VALUE_OPTIONAL, 'Database', null], + ]; } } diff --git a/app/Console/Commands/ResetData.php b/app/Console/Commands/ResetData.php index 70e9791a27c9..1fb94d2d36e9 100644 --- a/app/Console/Commands/ResetData.php +++ b/app/Console/Commands/ResetData.php @@ -14,7 +14,7 @@ class ResetData extends Command * @var string */ protected $name = 'ninja:reset-data'; - + /** * @var string */ @@ -28,8 +28,24 @@ class ResetData extends Command return; } + if ($database = $this->option('database')) { + config(['database.default' => $database]); + } + Artisan::call('migrate:reset'); Artisan::call('migrate'); Artisan::call('db:seed'); } + + /** + * @return array + */ + protected function getOptions() + { + return [ + ['fix', null, InputOption::VALUE_OPTIONAL, 'Fix data', null], + ['client_id', null, InputOption::VALUE_OPTIONAL, 'Client id', null], + ['database', null, InputOption::VALUE_OPTIONAL, 'Database', null], + ]; + } } diff --git a/app/Console/Commands/ResetInvoiceSchemaCounter.php b/app/Console/Commands/ResetInvoiceSchemaCounter.php deleted file mode 100644 index be221c4d6390..000000000000 --- a/app/Console/Commands/ResetInvoiceSchemaCounter.php +++ /dev/null @@ -1,75 +0,0 @@ -invoice = $invoice; - } - - /** - * Execute the console command. - * - * @return mixed - */ - public function handle() - { - $force = $this->option('force'); - $account = $this->argument('account'); - - $accounts = null; - - if ($account) { - $accounts = Account::find($account)->get(); - } else { - $accounts = Account::all(); - } - - $latestInvoice = $this->invoice->latest()->first(); - $invoiceYear = Carbon::parse($latestInvoice->created_at)->year; - - if (Carbon::now()->year > $invoiceYear || $force) { - $accounts->transform(function ($a) { - /* @var Account $a */ - $a->invoice_number_counter = 1; - $a->update(); - }); - - $this->info('The counter has been resetted successfully for '.$accounts->count().' account(s).'); - } - } -} diff --git a/app/Console/Commands/SendRecurringInvoices.php b/app/Console/Commands/SendRecurringInvoices.php index 0a62ca626fbf..bd5dc83278c6 100644 --- a/app/Console/Commands/SendRecurringInvoices.php +++ b/app/Console/Commands/SendRecurringInvoices.php @@ -61,6 +61,10 @@ class SendRecurringInvoices extends Command $this->info(date('Y-m-d H:i:s') . ' Running SendRecurringInvoices...'); $today = new DateTime(); + if ($database = $this->option('database')) { + config(['database.default' => $database]); + } + // check for counter resets $accounts = Account::where('reset_counter_frequency_id', '>', 0) ->orderBy('id', 'asc') @@ -130,6 +134,8 @@ class SendRecurringInvoices extends Command */ protected function getOptions() { - return []; + return [ + ['database', null, InputOption::VALUE_OPTIONAL, 'Database', null], + ]; } } diff --git a/app/Console/Commands/SendReminders.php b/app/Console/Commands/SendReminders.php index af5f9bfca2c2..eb1d99666754 100644 --- a/app/Console/Commands/SendReminders.php +++ b/app/Console/Commands/SendReminders.php @@ -58,6 +58,10 @@ class SendReminders extends Command { $this->info(date('Y-m-d') . ' Running SendReminders...'); + if ($database = $this->option('database')) { + config(['database.default' => $database]); + } + $accounts = $this->accountRepo->findWithReminders(); $this->info(count($accounts) . ' accounts found'); @@ -103,6 +107,8 @@ class SendReminders extends Command */ protected function getOptions() { - return []; + return [ + ['database', null, InputOption::VALUE_OPTIONAL, 'Database', null], + ]; } } diff --git a/app/Console/Commands/SendRenewalInvoices.php b/app/Console/Commands/SendRenewalInvoices.php index edfc2471b31d..34b56e6b282d 100644 --- a/app/Console/Commands/SendRenewalInvoices.php +++ b/app/Console/Commands/SendRenewalInvoices.php @@ -51,6 +51,10 @@ class SendRenewalInvoices extends Command { $this->info(date('Y-m-d').' Running SendRenewalInvoices...'); + if ($database = $this->option('database')) { + config(['database.default' => $database]); + } + // get all accounts with plans expiring in 10 days $companies = Company::whereRaw("datediff(plan_expires, curdate()) = 10 and (plan = 'pro' or plan = 'enterprise')") ->orderBy('id') @@ -123,6 +127,8 @@ class SendRenewalInvoices extends Command */ protected function getOptions() { - return []; + return [ + ['database', null, InputOption::VALUE_OPTIONAL, 'Database', null], + ]; } } diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 2f2eca9da424..c1cf74d0e6d0 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -24,7 +24,6 @@ class Kernel extends ConsoleKernel 'App\Console\Commands\SendRenewalInvoices', 'App\Console\Commands\ChargeRenewalInvoices', 'App\Console\Commands\SendReminders', - 'App\Console\Commands\GenerateResources', 'App\Console\Commands\TestOFX', 'App\Console\Commands\MakeModule', 'App\Console\Commands\MakeClass', diff --git a/app/Constants.php b/app/Constants.php index 7ddb3aabce0a..8fd7434ffa7e 100644 --- a/app/Constants.php +++ b/app/Constants.php @@ -229,8 +229,7 @@ if (! defined('APP_NAME')) { define('SESSION_REFERRAL_CODE', 'referralCode'); define('SESSION_LEFT_SIDEBAR', 'showLeftSidebar'); define('SESSION_RIGHT_SIDEBAR', 'showRightSidebar'); - define('SESSION_USER_DB_SERVER', 'userDbServer'); - define('SESSION_CONTACT_DB_SERVER', 'contactDbServer'); + define('SESSION_DB_SERVER', 'dbServer'); define('SESSION_LAST_REQUEST_PAGE', 'SESSION_LAST_REQUEST_PAGE'); define('SESSION_LAST_REQUEST_TIME', 'SESSION_LAST_REQUEST_TIME'); @@ -293,7 +292,8 @@ if (! defined('APP_NAME')) { define('REQUESTED_PRO_PLAN', 'REQUESTED_PRO_PLAN'); define('DEMO_ACCOUNT_ID', 'DEMO_ACCOUNT_ID'); - define('NINJA_ACCOUNT_KEY', 'zg4ylmzDkdkPOT8yoKQw9LTWaoZJx79h'); + define('NINJA_ACCOUNT_KEY', env('NINJA_ACCOUNT_KEY', 'zg4ylmzDkdkPOT8yoKQw9LTWaoZJx79h')); + define('NINJA_ACCOUNT_EMAIL', env('NINJA_ACCOUNT_EMAIL', 'contact@invoiceninja.com')); define('NINJA_LICENSE_ACCOUNT_KEY', 'AsFmBAeLXF0IKf7tmi0eiyZfmWW9hxMT'); define('NINJA_GATEWAY_ID', GATEWAY_STRIPE); define('NINJA_GATEWAY_CONFIG', 'NINJA_GATEWAY_CONFIG'); diff --git a/app/Http/Middleware/DatabaseLookup.php b/app/Http/Middleware/DatabaseLookup.php index cac9a6d2fe8a..954a91c41068 100644 --- a/app/Http/Middleware/DatabaseLookup.php +++ b/app/Http/Middleware/DatabaseLookup.php @@ -18,7 +18,7 @@ class DatabaseLookup if ($guard == 'user') { // user's value is set when logging in - if (! session('SESSION_USER_DB_SERVER')) { + if (! session(SESSION_DB_SERVER)) { return redirect('/logout'); } } elseif ($guard == 'api') { @@ -31,6 +31,8 @@ class DatabaseLookup } elseif (request()->contact_key) { LookupContact::setServerByField('contact_key', request()->contact_key); } + } elseif ($guard == 'postmark') { + LookupInvitation::setServerByField('message_id', request()->MessageID); } return $next($request); diff --git a/app/Http/routes.php b/app/Http/routes.php index 5b9249253cfa..386ea02a261a 100644 --- a/app/Http/routes.php +++ b/app/Http/routes.php @@ -76,10 +76,13 @@ Route::group(['middleware' => 'cors'], function () { Route::match(['GET', 'POST', 'OPTIONS'], '/buy_now/{gateway_type?}', 'OnlinePaymentController@handleBuyNow'); }); -Route::post('/hook/email_bounced', 'AppController@emailBounced'); -Route::post('/hook/email_opened', 'AppController@emailOpened'); -Route::post('/hook/bot/{platform?}', 'BotController@handleMessage'); +Route::group(['middleware' => 'lookup:postmark'], function () { + Route::post('/hook/email_bounced', 'AppController@emailBounced'); + Route::post('/hook/email_opened', 'AppController@emailOpened'); +}); + Route::post('/payment_hook/{accountKey}/{gatewayId}', 'OnlinePaymentController@handlePaymentWebhook'); +//Route::post('/hook/bot/{platform?}', 'BotController@handleMessage'); // Laravel auth routes Route::get('/signup', ['as' => 'signup', 'uses' => 'Auth\AuthController@getRegister']); diff --git a/app/Models/LookupModel.php b/app/Models/LookupModel.php index a43577482299..aa1d5cd7c793 100644 --- a/app/Models/LookupModel.php +++ b/app/Models/LookupModel.php @@ -65,10 +65,11 @@ class LookupModel extends Eloquent $className = get_called_class(); $className = str_replace('Lookup', '', $className); $key = sprintf('server:%s:%s:%s', $className, $field, $value); + $isUser = $className == 'App\Models\User'; // check if we've cached this lookup if ($server = session($key)) { - static::setDbServer($server); + static::setDbServer($server, $isUser); return; } @@ -78,7 +79,7 @@ class LookupModel extends Eloquent if ($value && $lookupUser = static::where($field, '=', $value)->first()) { $entity = new $className(); $server = $lookupUser->getDbServer(); - static::setDbServer($server); + static::setDbServer($server, $isUser); // check entity is found on the server if (! $entity::where($field, '=', $value)->first()) { @@ -91,10 +92,13 @@ class LookupModel extends Eloquent } } - public static function setDbServer($server) + public static function setDbServer($server, $isUser = false) { - session(['SESSION_USER_DB_SERVER' => $server]); config(['database.default' => $server]); + + if ($isUser) { + session([SESSION_DB_SERVER => $server]); + } } public function getDbServer() diff --git a/app/Ninja/Repositories/AccountRepository.php b/app/Ninja/Repositories/AccountRepository.php index f64cd05566f5..89ea2c1d90c6 100644 --- a/app/Ninja/Repositories/AccountRepository.php +++ b/app/Ninja/Repositories/AccountRepository.php @@ -359,13 +359,12 @@ class AccountRepository $emailSettings = new AccountEmailSettings(); $account->account_email_settings()->save($emailSettings); - $random = strtolower(str_random(RANDOM_KEY_LENGTH)); $user = new User(); $user->registered = true; $user->confirmed = true; - $user->email = 'contact@invoiceninja.com'; - $user->password = $random; - $user->username = $random; + $user->email = NINJA_ACCOUNT_EMAIL; + $user->username = NINJA_ACCOUNT_EMAIL; + $user->password = strtolower(str_random(RANDOM_KEY_LENGTH)); $user->first_name = 'Invoice'; $user->last_name = 'Ninja'; $user->notify_sent = true; From 3f713d3b12cefdddec0f8f5d3559e96809d64a94 Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Mon, 1 May 2017 15:46:57 +0300 Subject: [PATCH 17/81] Multi-db support --- app/Console/Commands/ChargeRenewalInvoices.php | 1 + app/Console/Commands/PruneData.php | 1 + app/Console/Commands/RemoveOrphanedDocuments.php | 1 + app/Console/Commands/ResetData.php | 1 + app/Console/Commands/SendRecurringInvoices.php | 1 + app/Console/Commands/SendReminders.php | 1 + app/Console/Commands/SendRenewalInvoices.php | 1 + app/Http/Controllers/Auth/AuthController.php | 3 --- app/Http/Middleware/DatabaseLookup.php | 14 +++++++------- app/Http/routes.php | 9 ++++++--- app/Models/LookupAccount.php | 2 +- ..._04_30_174702_add_multiple_database_support.php | 2 +- 12 files changed, 22 insertions(+), 15 deletions(-) diff --git a/app/Console/Commands/ChargeRenewalInvoices.php b/app/Console/Commands/ChargeRenewalInvoices.php index ad41b3d77ea3..105a0e4675b3 100644 --- a/app/Console/Commands/ChargeRenewalInvoices.php +++ b/app/Console/Commands/ChargeRenewalInvoices.php @@ -9,6 +9,7 @@ use App\Ninja\Repositories\AccountRepository; use App\Services\PaymentService; use Illuminate\Console\Command; use Carbon; +use Symfony\Component\Console\Input\InputOption; /** * Class ChargeRenewalInvoices. diff --git a/app/Console/Commands/PruneData.php b/app/Console/Commands/PruneData.php index 0ebccbdf50a4..e42b21767f92 100644 --- a/app/Console/Commands/PruneData.php +++ b/app/Console/Commands/PruneData.php @@ -4,6 +4,7 @@ namespace App\Console\Commands; use DB; use Illuminate\Console\Command; +use Symfony\Component\Console\Input\InputOption; /** * Class PruneData. diff --git a/app/Console/Commands/RemoveOrphanedDocuments.php b/app/Console/Commands/RemoveOrphanedDocuments.php index 969c62aab4ad..4241e505cd58 100644 --- a/app/Console/Commands/RemoveOrphanedDocuments.php +++ b/app/Console/Commands/RemoveOrphanedDocuments.php @@ -5,6 +5,7 @@ namespace App\Console\Commands; use App\Models\Document; use DateTime; use Illuminate\Console\Command; +use Symfony\Component\Console\Input\InputOption; /** * Class RemoveOrphanedDocuments. diff --git a/app/Console/Commands/ResetData.php b/app/Console/Commands/ResetData.php index 1fb94d2d36e9..02ba8804c53d 100644 --- a/app/Console/Commands/ResetData.php +++ b/app/Console/Commands/ResetData.php @@ -4,6 +4,7 @@ namespace App\Console\Commands; use Illuminate\Console\Command; use Utils; +use Symfony\Component\Console\Input\InputOption; /** * Class ResetData. diff --git a/app/Console/Commands/SendRecurringInvoices.php b/app/Console/Commands/SendRecurringInvoices.php index bd5dc83278c6..6a13dc3563ad 100644 --- a/app/Console/Commands/SendRecurringInvoices.php +++ b/app/Console/Commands/SendRecurringInvoices.php @@ -9,6 +9,7 @@ use App\Ninja\Repositories\InvoiceRepository; use App\Services\PaymentService; use DateTime; use Illuminate\Console\Command; +use Symfony\Component\Console\Input\InputOption; /** * Class SendRecurringInvoices. diff --git a/app/Console/Commands/SendReminders.php b/app/Console/Commands/SendReminders.php index eb1d99666754..92334db4fefa 100644 --- a/app/Console/Commands/SendReminders.php +++ b/app/Console/Commands/SendReminders.php @@ -7,6 +7,7 @@ use App\Ninja\Mailers\ContactMailer as Mailer; use App\Ninja\Repositories\AccountRepository; use App\Ninja\Repositories\InvoiceRepository; use Illuminate\Console\Command; +use Symfony\Component\Console\Input\InputOption; /** * Class SendReminders. diff --git a/app/Console/Commands/SendRenewalInvoices.php b/app/Console/Commands/SendRenewalInvoices.php index 34b56e6b282d..ebb716347acb 100644 --- a/app/Console/Commands/SendRenewalInvoices.php +++ b/app/Console/Commands/SendRenewalInvoices.php @@ -7,6 +7,7 @@ use App\Ninja\Mailers\ContactMailer as Mailer; use App\Ninja\Repositories\AccountRepository; use Illuminate\Console\Command; use Utils; +use Symfony\Component\Console\Input\InputOption; /** * Class SendRenewalInvoices. diff --git a/app/Http/Controllers/Auth/AuthController.php b/app/Http/Controllers/Auth/AuthController.php index 59c8e194e389..0fb64d2d8ffd 100644 --- a/app/Http/Controllers/Auth/AuthController.php +++ b/app/Http/Controllers/Auth/AuthController.php @@ -13,7 +13,6 @@ use Illuminate\Foundation\Auth\AuthenticatesAndRegistersUsers; use Illuminate\Http\Request; use Session; use Utils; -use App\Models\LookupUser; class AuthController extends Controller { @@ -142,8 +141,6 @@ class AuthController extends Controller */ public function postLoginWrapper(Request $request) { - LookupUser::setServerByField('email', $request->input('email')); - $userId = Auth::check() ? Auth::user()->id : null; $user = User::where('email', '=', $request->input('email'))->first(); diff --git a/app/Http/Middleware/DatabaseLookup.php b/app/Http/Middleware/DatabaseLookup.php index 954a91c41068..cc0162ae816e 100644 --- a/app/Http/Middleware/DatabaseLookup.php +++ b/app/Http/Middleware/DatabaseLookup.php @@ -7,6 +7,7 @@ use Closure; use App\Models\LookupContact; use App\Models\LookupInvitation; use App\Models\LookupAccountToken; +use App\Models\LookupUser; class DatabaseLookup { @@ -17,19 +18,18 @@ class DatabaseLookup } if ($guard == 'user') { - // user's value is set when logging in - if (! session(SESSION_DB_SERVER)) { - return redirect('/logout'); + if ($email = $request->email) { + LookupUser::setServerByField('email', $email); } } elseif ($guard == 'api') { if ($token = $request->header('X-Ninja-Token')) { LookupAccountToken::setServerByField('token', $token); } } elseif ($guard == 'contact') { - if (request()->invitation_key) { - LookupInvitation::setServerByField('invitation_key', request()->invitation_key); - } elseif (request()->contact_key) { - LookupContact::setServerByField('contact_key', request()->contact_key); + if ($key = request()->invitation_key) { + LookupInvitation::setServerByField('invitation_key', $key); + } elseif ($key = request()->contact_key) { + LookupContact::setServerByField('contact_key', $key); } } elseif ($guard == 'postmark') { LookupInvitation::setServerByField('message_id', request()->MessageID); diff --git a/app/Http/routes.php b/app/Http/routes.php index 386ea02a261a..1f364d160e65 100644 --- a/app/Http/routes.php +++ b/app/Http/routes.php @@ -88,14 +88,17 @@ Route::post('/payment_hook/{accountKey}/{gatewayId}', 'OnlinePaymentController@h Route::get('/signup', ['as' => 'signup', 'uses' => 'Auth\AuthController@getRegister']); Route::post('/signup', ['as' => 'signup', 'uses' => 'Auth\AuthController@postRegister']); Route::get('/login', ['as' => 'login', 'uses' => 'Auth\AuthController@getLoginWrapper']); -Route::post('/login', ['as' => 'login', 'uses' => 'Auth\AuthController@postLoginWrapper']); Route::get('/logout', ['as' => 'logout', 'uses' => 'Auth\AuthController@getLogoutWrapper']); Route::get('/recover_password', ['as' => 'forgot', 'uses' => 'Auth\PasswordController@getEmail']); -Route::post('/recover_password', ['as' => 'forgot', 'uses' => 'Auth\PasswordController@postEmail']); Route::get('/password/reset/{token}', ['as' => 'forgot', 'uses' => 'Auth\PasswordController@getReset']); -Route::post('/password/reset', ['as' => 'forgot', 'uses' => 'Auth\PasswordController@postReset']); Route::get('/user/confirm/{code}', 'UserController@confirm'); +Route::group(['middleware' => ['lookup:reset']], function () { + Route::post('/login', ['as' => 'login', 'uses' => 'Auth\AuthController@postLoginWrapper']); + Route::post('/recover_password', ['as' => 'forgot', 'uses' => 'Auth\PasswordController@postEmail']); + Route::post('/password/reset', ['as' => 'forgot', 'uses' => 'Auth\PasswordController@postReset']); +}); + // Client auth Route::get('/client/login', ['as' => 'login', 'uses' => 'ClientAuth\AuthController@getLogin']); Route::post('/client/login', ['as' => 'login', 'uses' => 'ClientAuth\AuthController@postLogin']); diff --git a/app/Models/LookupAccount.php b/app/Models/LookupAccount.php index 7354e3c6746a..0d14677339a4 100644 --- a/app/Models/LookupAccount.php +++ b/app/Models/LookupAccount.php @@ -30,7 +30,7 @@ class LookupAccount extends LookupModel $current = config('database.default'); config(['database.default' => DB_NINJA_LOOKUP]); - + $server = DbServer::whereName($current)->firstOrFail(); $lookupCompany = LookupCompany::whereDbServerId($server->id) ->whereCompanyId($companyId)->first(); diff --git a/database/migrations/2017_04_30_174702_add_multiple_database_support.php b/database/migrations/2017_04_30_174702_add_multiple_database_support.php index eaf683fe687a..a809238ee3df 100644 --- a/database/migrations/2017_04_30_174702_add_multiple_database_support.php +++ b/database/migrations/2017_04_30_174702_add_multiple_database_support.php @@ -49,7 +49,7 @@ class AddMultipleDatabaseSupport extends Migration Schema::rename('lookup_tokens', 'lookup_account_tokens'); DB::table('db_servers')->insert( - ['name' => 'db-server-1'] + ['name' => 'db-ninja-1'] ); } From 5baf12ae261fc9e6bd37dacaf0c0faa66026615c Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Mon, 1 May 2017 16:06:34 +0300 Subject: [PATCH 18/81] Multi-db support --- app/Http/Middleware/DatabaseLookup.php | 4 +++- app/Http/routes.php | 2 +- app/Models/LookupModel.php | 7 ++++--- .../2017_04_30_174702_add_multiple_database_support.php | 3 ++- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/app/Http/Middleware/DatabaseLookup.php b/app/Http/Middleware/DatabaseLookup.php index cc0162ae816e..6dba0fca91fd 100644 --- a/app/Http/Middleware/DatabaseLookup.php +++ b/app/Http/Middleware/DatabaseLookup.php @@ -18,7 +18,9 @@ class DatabaseLookup } if ($guard == 'user') { - if ($email = $request->email) { + if ($server = session(SESSION_DB_SERVER)) { + config(['database.default' => $server]); + } elseif ($email = $request->email) { LookupUser::setServerByField('email', $email); } } elseif ($guard == 'api') { diff --git a/app/Http/routes.php b/app/Http/routes.php index 1f364d160e65..787081ecbbd4 100644 --- a/app/Http/routes.php +++ b/app/Http/routes.php @@ -93,7 +93,7 @@ Route::get('/recover_password', ['as' => 'forgot', 'uses' => 'Auth\PasswordContr Route::get('/password/reset/{token}', ['as' => 'forgot', 'uses' => 'Auth\PasswordController@getReset']); Route::get('/user/confirm/{code}', 'UserController@confirm'); -Route::group(['middleware' => ['lookup:reset']], function () { +Route::group(['middleware' => ['lookup:user']], function () { Route::post('/login', ['as' => 'login', 'uses' => 'Auth\AuthController@postLoginWrapper']); Route::post('/recover_password', ['as' => 'forgot', 'uses' => 'Auth\PasswordController@postEmail']); Route::post('/password/reset', ['as' => 'forgot', 'uses' => 'Auth\PasswordController@postReset']); diff --git a/app/Models/LookupModel.php b/app/Models/LookupModel.php index aa1d5cd7c793..9ddc4868aa6e 100644 --- a/app/Models/LookupModel.php +++ b/app/Models/LookupModel.php @@ -68,7 +68,7 @@ class LookupModel extends Eloquent $isUser = $className == 'App\Models\User'; // check if we've cached this lookup - if ($server = session($key)) { + if (env('MULTI_DB_CACHE_ENABLED') && $server = session($key)) { static::setDbServer($server, $isUser); return; } @@ -76,9 +76,10 @@ class LookupModel extends Eloquent $current = config('database.default'); config(['database.default' => DB_NINJA_LOOKUP]); - if ($value && $lookupUser = static::where($field, '=', $value)->first()) { + if ($value && $lookupModel = static::where($field, '=', $value)->first()) { $entity = new $className(); - $server = $lookupUser->getDbServer(); + $server = $lookupModel->getDbServer(); + static::setDbServer($server, $isUser); // check entity is found on the server diff --git a/database/migrations/2017_04_30_174702_add_multiple_database_support.php b/database/migrations/2017_04_30_174702_add_multiple_database_support.php index a809238ee3df..d32ce2dcd557 100644 --- a/database/migrations/2017_04_30_174702_add_multiple_database_support.php +++ b/database/migrations/2017_04_30_174702_add_multiple_database_support.php @@ -49,7 +49,8 @@ class AddMultipleDatabaseSupport extends Migration Schema::rename('lookup_tokens', 'lookup_account_tokens'); DB::table('db_servers')->insert( - ['name' => 'db-ninja-1'] + ['name' => 'db-ninja-1'], + ['name' => 'db-ninja-2'], ); } From b6242d9981dd16a108d2598a816c5222985204bd Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Mon, 1 May 2017 17:29:31 +0300 Subject: [PATCH 19/81] Multi-db support --- .../ClientAuth/PasswordController.php | 29 +++++-------------- app/Http/Middleware/DatabaseLookup.php | 4 ++- app/Http/routes.php | 13 +++++---- app/Models/LookupUser.php | 3 +- app/Models/User.php | 4 +-- ...0_174702_add_multiple_database_support.php | 11 +++++-- resources/views/clientauth/reset.blade.php | 7 ++--- .../views/emails/client_password.blade.php | 2 +- 8 files changed, 34 insertions(+), 39 deletions(-) diff --git a/app/Http/Controllers/ClientAuth/PasswordController.php b/app/Http/Controllers/ClientAuth/PasswordController.php index bf3f2da9c974..b13f0dffb16e 100644 --- a/app/Http/Controllers/ClientAuth/PasswordController.php +++ b/app/Http/Controllers/ClientAuth/PasswordController.php @@ -51,8 +51,8 @@ class PasswordController extends Controller $data = [ 'clientauth' => true, ]; - $contactKey = session('contact_key'); - if (!$contactKey) { + + if (! session('contact_key')) { return \Redirect::to('/client/sessionexpired'); } @@ -104,7 +104,7 @@ class PasswordController extends Controller * * @return \Illuminate\Http\Response */ - public function showResetForm(Request $request, $key = null, $token = null) + public function showResetForm(Request $request, $token = null) { if (is_null($token)) { return $this->getEmail(); @@ -115,23 +115,8 @@ class PasswordController extends Controller 'clientauth' => true, ); - if ($key) { - $contact = Contact::where('contact_key', '=', $key)->first(); - if ($contact && ! $contact->is_deleted) { - $account = $contact->account; - $data['contact_key'] = $contact->contact_key; - } else { - // Maybe it's an invitation key - $invitation = Invitation::where('invitation_key', '=', $key)->first(); - if ($invitation && ! $invitation->is_deleted) { - $account = $invitation->account; - $data['contact_key'] = $invitation->contact->contact_key; - } - } - - if ( empty($account)) { - return \Redirect::to('/client/sessionexpired'); - } + if (! session('contact_key')) { + return \Redirect::to('/client/sessionexpired'); } return view('clientauth.reset')->with($data); @@ -148,9 +133,9 @@ class PasswordController extends Controller * * @return \Illuminate\Http\Response */ - public function getReset(Request $request, $key = null, $token = null) + public function getReset(Request $request, $token = null) { - return $this->showResetForm($request, $key, $token); + return $this->showResetForm($request, $token); } /** diff --git a/app/Http/Middleware/DatabaseLookup.php b/app/Http/Middleware/DatabaseLookup.php index 6dba0fca91fd..4aff597ca1db 100644 --- a/app/Http/Middleware/DatabaseLookup.php +++ b/app/Http/Middleware/DatabaseLookup.php @@ -22,6 +22,8 @@ class DatabaseLookup config(['database.default' => $server]); } elseif ($email = $request->email) { LookupUser::setServerByField('email', $email); + } elseif ($code = $request->confirmation_code) { + LookupUser::setServerByField('confirmation_code', $code); } } elseif ($guard == 'api') { if ($token = $request->header('X-Ninja-Token')) { @@ -30,7 +32,7 @@ class DatabaseLookup } elseif ($guard == 'contact') { if ($key = request()->invitation_key) { LookupInvitation::setServerByField('invitation_key', $key); - } elseif ($key = request()->contact_key) { + } elseif ($key = request()->contact_key ?: session('contact_key')) { LookupContact::setServerByField('contact_key', $key); } } elseif ($guard == 'postmark') { diff --git a/app/Http/routes.php b/app/Http/routes.php index 787081ecbbd4..6783e32a1f75 100644 --- a/app/Http/routes.php +++ b/app/Http/routes.php @@ -91,9 +91,8 @@ Route::get('/login', ['as' => 'login', 'uses' => 'Auth\AuthController@getLoginWr Route::get('/logout', ['as' => 'logout', 'uses' => 'Auth\AuthController@getLogoutWrapper']); Route::get('/recover_password', ['as' => 'forgot', 'uses' => 'Auth\PasswordController@getEmail']); Route::get('/password/reset/{token}', ['as' => 'forgot', 'uses' => 'Auth\PasswordController@getReset']); -Route::get('/user/confirm/{code}', 'UserController@confirm'); - Route::group(['middleware' => ['lookup:user']], function () { + Route::get('/user/confirm/{confirmation_code}', 'UserController@confirm'); Route::post('/login', ['as' => 'login', 'uses' => 'Auth\AuthController@postLoginWrapper']); Route::post('/recover_password', ['as' => 'forgot', 'uses' => 'Auth\PasswordController@postEmail']); Route::post('/password/reset', ['as' => 'forgot', 'uses' => 'Auth\PasswordController@postReset']); @@ -101,13 +100,15 @@ Route::group(['middleware' => ['lookup:user']], function () { // Client auth Route::get('/client/login', ['as' => 'login', 'uses' => 'ClientAuth\AuthController@getLogin']); -Route::post('/client/login', ['as' => 'login', 'uses' => 'ClientAuth\AuthController@postLogin']); Route::get('/client/logout', ['as' => 'logout', 'uses' => 'ClientAuth\AuthController@getLogout']); Route::get('/client/sessionexpired', ['as' => 'logout', 'uses' => 'ClientAuth\AuthController@getSessionExpired']); Route::get('/client/recover_password', ['as' => 'forgot', 'uses' => 'ClientAuth\PasswordController@getEmail']); -Route::post('/client/recover_password', ['as' => 'forgot', 'uses' => 'ClientAuth\PasswordController@postEmail']); -Route::get('/client/password/reset/{invitation_key}/{token}', ['as' => 'forgot', 'uses' => 'ClientAuth\PasswordController@getReset']); -Route::post('/client/password/reset', ['as' => 'forgot', 'uses' => 'ClientAuth\PasswordController@postReset']); +Route::get('/client/password/reset/{token}', ['as' => 'forgot', 'uses' => 'ClientAuth\PasswordController@getReset']); +Route::group(['middleware' => ['lookup:contact']], function () { + Route::post('/client/login', ['as' => 'login', 'uses' => 'ClientAuth\AuthController@postLogin']); + Route::post('/client/recover_password', ['as' => 'forgot', 'uses' => 'ClientAuth\PasswordController@postEmail']); + Route::post('/client/password/reset', ['as' => 'forgot', 'uses' => 'ClientAuth\PasswordController@postReset']); +}); if (Utils::isNinja()) { Route::post('/signup/register', 'AccountController@doRegister'); diff --git a/app/Models/LookupUser.php b/app/Models/LookupUser.php index dc985cde61b8..0dafa976e444 100644 --- a/app/Models/LookupUser.php +++ b/app/Models/LookupUser.php @@ -19,7 +19,7 @@ class LookupUser extends LookupModel 'user_id', ]; - public static function updateUser($accountKey, $userId, $email) + public static function updateUser($accountKey, $userId, $email, $confirmationCode) { if (! env('MULTI_DB_ENABLED')) { return; @@ -36,6 +36,7 @@ class LookupUser extends LookupModel ->firstOrFail(); $lookupUser->email = $email; + $lookupUser->confirmation_code = $confirmationCode; $lookupUser->save(); config(['database.default' => $current]); diff --git a/app/Models/User.php b/app/Models/User.php index 89506050c805..8137eb42242f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -425,8 +425,8 @@ User::updating(function ($user) { User::onUpdatingUser($user); $dirty = $user->getDirty(); - if (isset($dirty['email'])) { - LookupUser::updateUser($user->account->account_key, $user->id, $user->email); + if (isset($dirty['email']) || isset($dirty['confirmation_code'])) { + LookupUser::updateUser($user->account->account_key, $user->id, $user->email, $user->confirmation_code); } }); diff --git a/database/migrations/2017_04_30_174702_add_multiple_database_support.php b/database/migrations/2017_04_30_174702_add_multiple_database_support.php index d32ce2dcd557..d8bdfb136269 100644 --- a/database/migrations/2017_04_30_174702_add_multiple_database_support.php +++ b/database/migrations/2017_04_30_174702_add_multiple_database_support.php @@ -26,6 +26,7 @@ class AddMultipleDatabaseSupport extends Migration Schema::table('lookup_users', function ($table) { $table->string('email')->change()->nullable()->unique(); + $table->string('confirmation_code')->nullable()->unique(); $table->unsignedInteger('user_id')->index(); }); @@ -49,8 +50,10 @@ class AddMultipleDatabaseSupport extends Migration Schema::rename('lookup_tokens', 'lookup_account_tokens'); DB::table('db_servers')->insert( - ['name' => 'db-ninja-1'], - ['name' => 'db-ninja-2'], + ['name' => 'db-ninja-1'] + ); + DB::table('db_servers')->insert( + ['name' => 'db-ninja-2'] ); } @@ -65,6 +68,10 @@ class AddMultipleDatabaseSupport extends Migration $table->dropColumn('company_id'); }); + Schema::table('lookup_users', function ($table) { + $table->dropColumn('confirmation_code'); + }); + Schema::rename('lookup_account_tokens', 'lookup_tokens'); } } diff --git a/resources/views/clientauth/reset.blade.php b/resources/views/clientauth/reset.blade.php index 73e1e4159508..62f7336eea4d 100644 --- a/resources/views/clientauth/reset.blade.php +++ b/resources/views/clientauth/reset.blade.php @@ -19,7 +19,7 @@ @endif - + @if (Session::has('warning'))

{{ Session::get('warning') }}
@endif @@ -33,7 +33,6 @@ @endif -
{!! Former::password('password')->placeholder(trans('texts.password'))->raw() !!} @@ -41,7 +40,7 @@

{!! Button::success(trans('texts.save'))->large()->submit()->withAttributes(['class' => 'green'])->block() !!}

- + {!! Former::close() !!} -@endsection \ No newline at end of file +@endsection diff --git a/resources/views/emails/client_password.blade.php b/resources/views/emails/client_password.blade.php index 1e732bcfe4ed..12b8267b83be 100644 --- a/resources/views/emails/client_password.blade.php +++ b/resources/views/emails/client_password.blade.php @@ -8,7 +8,7 @@
@include('partials.email_button', [ - 'link' => URL::to("client/password/reset/".session('contact_key')."/{$token}"), + 'link' => URL::to("client/password/reset/{$token}"), 'field' => 'reset', 'color' => '#36c157', ]) From 80c6d686479e177d1e6f8c6f7d93c78c0f4b449e Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Mon, 1 May 2017 18:25:18 +0300 Subject: [PATCH 20/81] Multi-db support --- app/Http/Middleware/DatabaseLookup.php | 2 ++ app/Http/routes.php | 10 +++++++--- app/Services/AuthService.php | 11 +++++++---- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/app/Http/Middleware/DatabaseLookup.php b/app/Http/Middleware/DatabaseLookup.php index 4aff597ca1db..a74bdfe24a4b 100644 --- a/app/Http/Middleware/DatabaseLookup.php +++ b/app/Http/Middleware/DatabaseLookup.php @@ -37,6 +37,8 @@ class DatabaseLookup } } elseif ($guard == 'postmark') { LookupInvitation::setServerByField('message_id', request()->MessageID); + } elseif ($guard == 'license') { + config(['database.default' => DB_NINJA_1]); } return $next($request); diff --git a/app/Http/routes.php b/app/Http/routes.php index 6783e32a1f75..be3ee39fc245 100644 --- a/app/Http/routes.php +++ b/app/Http/routes.php @@ -62,9 +62,11 @@ Route::group(['middleware' => ['lookup:contact', 'auth:client']], function () { Route::get('api/client.activity', ['as' => 'api.client.activity', 'uses' => 'ClientPortalController@activityDatatable']); }); -Route::get('license', 'NinjaController@show_license_payment'); -Route::post('license', 'NinjaController@do_license_payment'); -Route::get('claim_license', 'NinjaController@claim_license'); +Route::group(['middleware' => 'lookup:license'], function () { + Route::get('license', 'NinjaController@show_license_payment'); + Route::post('license', 'NinjaController@do_license_payment'); + Route::get('claim_license', 'NinjaController@claim_license'); +}); Route::post('signup/validate', 'AccountController@checkEmail'); Route::post('signup/submit', 'AccountController@submitSignup'); @@ -91,6 +93,7 @@ Route::get('/login', ['as' => 'login', 'uses' => 'Auth\AuthController@getLoginWr Route::get('/logout', ['as' => 'logout', 'uses' => 'Auth\AuthController@getLogoutWrapper']); Route::get('/recover_password', ['as' => 'forgot', 'uses' => 'Auth\PasswordController@getEmail']); Route::get('/password/reset/{token}', ['as' => 'forgot', 'uses' => 'Auth\PasswordController@getReset']); + Route::group(['middleware' => ['lookup:user']], function () { Route::get('/user/confirm/{confirmation_code}', 'UserController@confirm'); Route::post('/login', ['as' => 'login', 'uses' => 'Auth\AuthController@postLoginWrapper']); @@ -104,6 +107,7 @@ Route::get('/client/logout', ['as' => 'logout', 'uses' => 'ClientAuth\AuthContro Route::get('/client/sessionexpired', ['as' => 'logout', 'uses' => 'ClientAuth\AuthController@getSessionExpired']); Route::get('/client/recover_password', ['as' => 'forgot', 'uses' => 'ClientAuth\PasswordController@getEmail']); Route::get('/client/password/reset/{token}', ['as' => 'forgot', 'uses' => 'ClientAuth\PasswordController@getReset']); + Route::group(['middleware' => ['lookup:contact']], function () { Route::post('/client/login', ['as' => 'login', 'uses' => 'ClientAuth\AuthController@postLogin']); Route::post('/client/recover_password', ['as' => 'forgot', 'uses' => 'ClientAuth\PasswordController@postEmail']); diff --git a/app/Services/AuthService.php b/app/Services/AuthService.php index f9cc9d1ab089..2c395f01eae6 100644 --- a/app/Services/AuthService.php +++ b/app/Services/AuthService.php @@ -4,6 +4,7 @@ namespace App\Services; use App\Events\UserLoggedIn; use App\Ninja\Repositories\AccountRepository; +use App\Models\LookupUser; use Auth; use Input; use Session; @@ -59,13 +60,13 @@ class AuthService $socialiteUser = Socialite::driver($provider)->user(); $providerId = self::getProviderId($provider); + $email = $socialiteUser->email; + $oauthUserId = $socialiteUser->id; + $name = Utils::splitName($socialiteUser->name); + if (Auth::check()) { $user = Auth::user(); $isRegistered = $user->registered; - - $email = $socialiteUser->email; - $oauthUserId = $socialiteUser->id; - $name = Utils::splitName($socialiteUser->name); $result = $this->accountRepo->updateUserFromOauth($user, $name[0], $name[1], $email, $providerId, $oauthUserId); if ($result === true) { @@ -81,6 +82,8 @@ class AuthService Session::flash('error', $result); } } else { + LookupUser::setServerByField('email', $email); + if ($user = $this->accountRepo->findUserByOauth($providerId, $socialiteUser->id)) { Auth::login($user, true); event(new UserLoggedIn()); From 2d6fcb4e39d31bf764dd7f5a31d25b6adb4407bd Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Mon, 1 May 2017 18:35:06 +0300 Subject: [PATCH 21/81] Multi-db support --- app/Console/Commands/CheckData.php | 4 ++-- app/Console/Commands/SendReminders.php | 4 ++-- app/Console/Commands/SendRenewalInvoices.php | 4 ++-- app/Constants.php | 1 - app/Http/Controllers/AccountController.php | 19 ------------------- app/Http/Middleware/DatabaseLookup.php | 5 +++++ app/Http/routes.php | 6 ++++-- 7 files changed, 15 insertions(+), 28 deletions(-) diff --git a/app/Console/Commands/CheckData.php b/app/Console/Commands/CheckData.php index 445d50f49052..d769d2ed5be9 100644 --- a/app/Console/Commands/CheckData.php +++ b/app/Console/Commands/CheckData.php @@ -88,10 +88,10 @@ class CheckData extends Command $this->info($this->log); if ($errorEmail) { - Mail::raw($this->log, function ($message) use ($errorEmail) { + Mail::raw($this->log, function ($message) use ($errorEmail, $database) { $message->to($errorEmail) ->from(CONTACT_EMAIL) - ->subject('Check-Data: ' . strtoupper($this->isValid ? RESULT_SUCCESS : RESULT_FAILURE)); + ->subject("Check-Data [{$database}]: " . strtoupper($this->isValid ? RESULT_SUCCESS : RESULT_FAILURE)); }); } elseif (! $this->isValid) { throw new Exception('Check data failed!!'); diff --git a/app/Console/Commands/SendReminders.php b/app/Console/Commands/SendReminders.php index 92334db4fefa..cb76a83ef9f0 100644 --- a/app/Console/Commands/SendReminders.php +++ b/app/Console/Commands/SendReminders.php @@ -87,10 +87,10 @@ class SendReminders extends Command $this->info('Done'); if ($errorEmail = env('ERROR_EMAIL')) { - \Mail::raw('EOM', function ($message) use ($errorEmail) { + \Mail::raw('EOM', function ($message) use ($errorEmail, $database) { $message->to($errorEmail) ->from(CONTACT_EMAIL) - ->subject('SendReminders: Finished successfully'); + ->subject("SendReminders [{$database}]: Finished successfully"); }); } } diff --git a/app/Console/Commands/SendRenewalInvoices.php b/app/Console/Commands/SendRenewalInvoices.php index ebb716347acb..ca0a11568cb7 100644 --- a/app/Console/Commands/SendRenewalInvoices.php +++ b/app/Console/Commands/SendRenewalInvoices.php @@ -107,10 +107,10 @@ class SendRenewalInvoices extends Command $this->info('Done'); if ($errorEmail = env('ERROR_EMAIL')) { - \Mail::raw('EOM', function ($message) use ($errorEmail) { + \Mail::raw('EOM', function ($message) use ($errorEmail, $database) { $message->to($errorEmail) ->from(CONTACT_EMAIL) - ->subject('SendRenewalInvoices: Finished successfully'); + ->subject("SendRenewalInvoices [{$database}]: Finished successfully"); }); } } diff --git a/app/Constants.php b/app/Constants.php index 8fd7434ffa7e..a6b4608de23f 100644 --- a/app/Constants.php +++ b/app/Constants.php @@ -291,7 +291,6 @@ if (! defined('APP_NAME')) { define('EVENT_DELETE_INVOICE', 9); define('REQUESTED_PRO_PLAN', 'REQUESTED_PRO_PLAN'); - define('DEMO_ACCOUNT_ID', 'DEMO_ACCOUNT_ID'); define('NINJA_ACCOUNT_KEY', env('NINJA_ACCOUNT_KEY', 'zg4ylmzDkdkPOT8yoKQw9LTWaoZJx79h')); define('NINJA_ACCOUNT_EMAIL', env('NINJA_ACCOUNT_EMAIL', 'contact@invoiceninja.com')); define('NINJA_LICENSE_ACCOUNT_KEY', 'AsFmBAeLXF0IKf7tmi0eiyZfmWW9hxMT'); diff --git a/app/Http/Controllers/AccountController.php b/app/Http/Controllers/AccountController.php index 15236634a71a..541924d5b118 100644 --- a/app/Http/Controllers/AccountController.php +++ b/app/Http/Controllers/AccountController.php @@ -97,25 +97,6 @@ class AccountController extends BaseController $this->paymentService = $paymentService; } - /** - * @return \Illuminate\Http\RedirectResponse - */ - public function demo() - { - $demoAccountId = Utils::getDemoAccountId(); - - if (! $demoAccountId) { - return Redirect::to('/'); - } - - $account = Account::find($demoAccountId); - $user = $account->users()->first(); - - Auth::login($user, true); - - return Redirect::to('invoices/create'); - } - /** * @return \Illuminate\Http\RedirectResponse */ diff --git a/app/Http/Middleware/DatabaseLookup.php b/app/Http/Middleware/DatabaseLookup.php index a74bdfe24a4b..5a93234f2b4b 100644 --- a/app/Http/Middleware/DatabaseLookup.php +++ b/app/Http/Middleware/DatabaseLookup.php @@ -4,6 +4,7 @@ namespace App\Http\Middleware; use Illuminate\Http\Request; use Closure; +use App\Models\LookupAccount; use App\Models\LookupContact; use App\Models\LookupInvitation; use App\Models\LookupAccountToken; @@ -37,6 +38,10 @@ class DatabaseLookup } } elseif ($guard == 'postmark') { LookupInvitation::setServerByField('message_id', request()->MessageID); + } elseif ($guard == 'account') { + if ($key = request()->account_key) { + LookupAccount::setServerByField('account_key', $key); + } } elseif ($guard == 'license') { config(['database.default' => DB_NINJA_1]); } diff --git a/app/Http/routes.php b/app/Http/routes.php index be3ee39fc245..f3ad76fb0d5a 100644 --- a/app/Http/routes.php +++ b/app/Http/routes.php @@ -83,7 +83,10 @@ Route::group(['middleware' => 'lookup:postmark'], function () { Route::post('/hook/email_opened', 'AppController@emailOpened'); }); -Route::post('/payment_hook/{accountKey}/{gatewayId}', 'OnlinePaymentController@handlePaymentWebhook'); +Route::group(['middleware' => 'lookup:account'], function () { + Route::post('/payment_hook/{account_key}/{gateway_id}', 'OnlinePaymentController@handlePaymentWebhook'); +} + //Route::post('/hook/bot/{platform?}', 'BotController@handleMessage'); // Laravel auth routes @@ -117,7 +120,6 @@ Route::group(['middleware' => ['lookup:contact']], function () { if (Utils::isNinja()) { Route::post('/signup/register', 'AccountController@doRegister'); Route::get('/news_feed/{user_type}/{version}/', 'HomeController@newsFeed'); - Route::get('/demo', 'AccountController@demo'); } if (Utils::isReseller()) { From 0f8601ed1079455a8d4f8c5b2bd0ad18c333bdd6 Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Mon, 1 May 2017 18:35:18 +0300 Subject: [PATCH 22/81] Multi-db support --- app/Libraries/Utils.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app/Libraries/Utils.php b/app/Libraries/Utils.php index 4d9cd1d70bdc..695d1661cb39 100644 --- a/app/Libraries/Utils.php +++ b/app/Libraries/Utils.php @@ -251,11 +251,6 @@ class Utils } } - public static function getDemoAccountId() - { - return isset($_ENV[DEMO_ACCOUNT_ID]) ? $_ENV[DEMO_ACCOUNT_ID] : false; - } - public static function getNewsFeedResponse($userType = false) { if (! $userType) { From 65d6bfd707d470d7f7f6f5b6f62bbba420f5256b Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Mon, 1 May 2017 19:58:51 +0300 Subject: [PATCH 23/81] Multi-db support --- app/Http/Controllers/AccountApiController.php | 2 +- app/Http/routes.php | 13 ++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/app/Http/Controllers/AccountApiController.php b/app/Http/Controllers/AccountApiController.php index f73a7d2b7cff..4d9b4377f23c 100644 --- a/app/Http/Controllers/AccountApiController.php +++ b/app/Http/Controllers/AccountApiController.php @@ -192,7 +192,7 @@ class AccountApiController extends BaseAPIController $oAuth = new OAuth(); $user = $oAuth->getProvider($provider)->getTokenResponse($token); - if($user) { + if ($user) { Auth::login($user); return $this->processLogin($request); } diff --git a/app/Http/routes.php b/app/Http/routes.php index f3ad76fb0d5a..1eb7c8f3d34a 100644 --- a/app/Http/routes.php +++ b/app/Http/routes.php @@ -68,12 +68,6 @@ Route::group(['middleware' => 'lookup:license'], function () { Route::get('claim_license', 'NinjaController@claim_license'); }); -Route::post('signup/validate', 'AccountController@checkEmail'); -Route::post('signup/submit', 'AccountController@submitSignup'); - -Route::get('/auth/{provider}', 'Auth\AuthController@authLogin'); -Route::get('/auth_unlink', 'Auth\AuthController@authUnlink'); - Route::group(['middleware' => 'cors'], function () { Route::match(['GET', 'POST', 'OPTIONS'], '/buy_now/{gateway_type?}', 'OnlinePaymentController@handleBuyNow'); }); @@ -85,7 +79,7 @@ Route::group(['middleware' => 'lookup:postmark'], function () { Route::group(['middleware' => 'lookup:account'], function () { Route::post('/payment_hook/{account_key}/{gateway_id}', 'OnlinePaymentController@handlePaymentWebhook'); -} +}); //Route::post('/hook/bot/{platform?}', 'BotController@handleMessage'); @@ -96,6 +90,7 @@ Route::get('/login', ['as' => 'login', 'uses' => 'Auth\AuthController@getLoginWr Route::get('/logout', ['as' => 'logout', 'uses' => 'Auth\AuthController@getLogoutWrapper']); Route::get('/recover_password', ['as' => 'forgot', 'uses' => 'Auth\PasswordController@getEmail']); Route::get('/password/reset/{token}', ['as' => 'forgot', 'uses' => 'Auth\PasswordController@getReset']); +Route::get('/auth/{provider}', 'Auth\AuthController@authLogin'); Route::group(['middleware' => ['lookup:user']], function () { Route::get('/user/confirm/{confirmation_code}', 'UserController@confirm'); @@ -142,6 +137,10 @@ Route::group(['middleware' => ['lookup:user', 'auth:user']], function () { Route::post('contact_us', 'HomeController@contactUs'); Route::post('handle_command', 'BotController@handleCommand'); + Route::post('signup/validate', 'AccountController@checkEmail'); + Route::post('signup/submit', 'AccountController@submitSignup'); + Route::get('auth_unlink', 'Auth\AuthController@authUnlink'); + Route::get('settings/user_details', 'AccountController@showUserDetails'); Route::post('settings/user_details', 'AccountController@saveUserDetails'); Route::post('settings/payment_gateway_limits', 'AccountGatewayController@savePaymentGatewayLimits'); From ebf5a9163cb7650cbab921256eedb928c1e1e635 Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Mon, 1 May 2017 20:15:33 +0300 Subject: [PATCH 24/81] Multi-db support --- app/Console/Commands/CreateTestData.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/Console/Commands/CreateTestData.php b/app/Console/Commands/CreateTestData.php index 87d32b82d3c7..93b134206378 100644 --- a/app/Console/Commands/CreateTestData.php +++ b/app/Console/Commands/CreateTestData.php @@ -25,7 +25,7 @@ class CreateTestData extends Command /** * @var string */ - protected $signature = 'ninja:create-test-data {count=1} {create_account=false}'; + protected $signature = 'ninja:create-test-data {count=1} {create_account=false} {--database}'; /** * @var @@ -68,6 +68,7 @@ class CreateTestData extends Command public function fire() { if (Utils::isNinjaProd()) { + $this->info('Unable to run in production'); return false; } @@ -222,8 +223,6 @@ class CreateTestData extends Command */ protected function getOptions() { - return [ - ['database', null, InputOption::VALUE_OPTIONAL, 'Database', null], - ]; + return []; } } From d4f25fe4904acd64a9cd8bc401a9c00ddd740ea0 Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Mon, 1 May 2017 21:46:31 +0300 Subject: [PATCH 25/81] Check user email isn't taken in lookup tables --- app/Console/Commands/InitLookup.php | 9 ++++--- app/Http/Controllers/AccountApiController.php | 4 +++ app/Http/Controllers/AccountController.php | 27 ++++++++++++++++--- app/Http/Controllers/UserController.php | 11 +++++++- app/Models/LookupUser.php | 23 ++++++++++++++++ app/Ninja/Repositories/AccountRepository.php | 6 ++++- 6 files changed, 70 insertions(+), 10 deletions(-) diff --git a/app/Console/Commands/InitLookup.php b/app/Console/Commands/InitLookup.php index 97c4a97744b0..8b5a227599cd 100644 --- a/app/Console/Commands/InitLookup.php +++ b/app/Console/Commands/InitLookup.php @@ -19,7 +19,7 @@ class InitLookup extends Command * * @var string */ - protected $signature = 'ninja:init-lookup {--truncate=} {--company_id=}'; + protected $signature = 'ninja:init-lookup {--truncate=} {--company_id=} {--page_size=100}'; /** * The console command description. @@ -65,7 +65,7 @@ class InitLookup extends Command ->where('id', '>=', $this->option('company_id') ?: 1) ->count(); - for ($i=0; $i<$count; $i += 100) { + for ($i=0; $i<$count; $i += (int) $this->option('page_size')) { $this->initCompanies($dbServer->id, $i); } } @@ -79,7 +79,7 @@ class InitLookup extends Command $companies = DB::table('companies') ->offset($offset) - ->limit(100) + ->limit((int) $this->option('page_size')) ->orderBy('id') ->where('id', '>=', $this->option('company_id') ?: 1) ->get(['id']); @@ -196,7 +196,7 @@ class InitLookup extends Command DB::statement('truncate lookup_users'); DB::statement('truncate lookup_contacts'); DB::statement('truncate lookup_invitations'); - DB::statement('truncate lookup_tokens'); + DB::statement('truncate lookup_account_tokens'); DB::statement('SET FOREIGN_KEY_CHECKS = 1'); } @@ -205,6 +205,7 @@ class InitLookup extends Command return [ ['truncate', null, InputOption::VALUE_OPTIONAL, 'Truncate', null], ['company_id', null, InputOption::VALUE_OPTIONAL, 'Company Id', null], + ['page_size', null, InputOption::VALUE_OPTIONAL, 'Page Size', null], ]; } diff --git a/app/Http/Controllers/AccountApiController.php b/app/Http/Controllers/AccountApiController.php index 4d9b4377f23c..59584cfeea77 100644 --- a/app/Http/Controllers/AccountApiController.php +++ b/app/Http/Controllers/AccountApiController.php @@ -39,6 +39,10 @@ class AccountApiController extends BaseAPIController public function register(RegisterRequest $request) { + if (! \App\Models\LookupUser::validateEmail()) { + return $this->errorResponse(['message' => trans('texts.email_taken')], 500); + } + $account = $this->accountRepo->create($request->first_name, $request->last_name, $request->email, $request->password); $user = $account->users()->first(); diff --git a/app/Http/Controllers/AccountController.php b/app/Http/Controllers/AccountController.php index 541924d5b118..08ff6d39bb04 100644 --- a/app/Http/Controllers/AccountController.php +++ b/app/Http/Controllers/AccountController.php @@ -1085,6 +1085,14 @@ class AccountController extends BaseController { /** @var \App\Models\User $user */ $user = Auth::user(); + $email = trim(strtolower(Input::get('email'))); + + if (! \App\Models\LookupUser::validateEmail($email, $user)) { + return Redirect::to('settings/' . ACCOUNT_USER_DETAILS) + ->withError(trans('texts.email_taken')) + ->withInput(); + } + $rules = ['email' => 'email|required|unique:users,email,'.$user->id.',id']; $validator = Validator::make(Input::all(), $rules); @@ -1095,8 +1103,8 @@ class AccountController extends BaseController } else { $user->first_name = trim(Input::get('first_name')); $user->last_name = trim(Input::get('last_name')); - $user->username = trim(Input::get('email')); - $user->email = trim(strtolower(Input::get('email'))); + $user->username = $email; + $user->email = $email; $user->phone = trim(Input::get('phone')); if (! Auth::user()->is_admin) { @@ -1193,8 +1201,15 @@ class AccountController extends BaseController */ public function checkEmail() { - $email = User::withTrashed()->where('email', '=', Input::get('email')) - ->where('id', '<>', Auth::user()->registered ? 0 : Auth::user()->id) + $email = trim(strtolower(Input::get('email'))); + $user = Auth::user(); + + if (! \App\Models\LookupUser::validateEmail($email, $user)) { + return 'taken'; + } + + $email = User::withTrashed()->where('email', '=', $email) + ->where('id', '<>', $user->registered ? 0 : $user->id) ->first(); if ($email) { @@ -1234,6 +1249,10 @@ class AccountController extends BaseController $email = trim(strtolower(Input::get('new_email'))); $password = trim(Input::get('new_password')); + if (! \App\Models\LookupUser::validateEmail($email, $user)) { + return ''; + } + if ($user->registered) { $newAccount = $this->accountRepo->create($firstName, $lastName, $email, $password, $account->company); $newUser = $newAccount->users()->first(); diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index c6429521a875..9e83394cc1d4 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -170,13 +170,22 @@ class UserController extends BaseController $rules['email'] = 'required|email|unique:users,email,'.$user->id.',id'; } else { + $user = false; $rules['email'] = 'required|email|unique:users'; } $validator = Validator::make(Input::all(), $rules); if ($validator->fails()) { - return Redirect::to($userPublicId ? 'users/edit' : 'users/create')->withInput()->withErrors($validator); + return Redirect::to($userPublicId ? 'users/edit' : 'users/create') + ->withErrors($validator) + ->withInput(); + } + + if (! \App\Models\LookupUser::validateEmail($email, $user)) { + return Redirect::to($userPublicId ? 'users/edit' : 'users/create') + ->withError(trans('texts.email_taken')) + ->withInput(); } if ($userPublicId) { diff --git a/app/Models/LookupUser.php b/app/Models/LookupUser.php index 0dafa976e444..6d829fa48428 100644 --- a/app/Models/LookupUser.php +++ b/app/Models/LookupUser.php @@ -42,4 +42,27 @@ class LookupUser extends LookupModel config(['database.default' => $current]); } + public static function validateEmail($email, $user = false) + { + if (! env('MULTI_DB_ENABLED')) { + return true; + } + + $current = config('database.default'); + config(['database.default' => DB_NINJA_LOOKUP]); + + $lookupUser = LookupUser::whereEmail($email)->first(); + + if ($user) { + $lookupAccount = LookupAccount::whereAccountKey($user->account->account_key)->firstOrFail(); + $isValid = ! $lookupUser || ($lookupUser->lookup_account_id == $lookupAccount->id && $lookupUser->user_id == $user->id); + } else { + $isValid = ! $lookupUser; + } + + config(['database.default' => $current]); + + return $isValid; + } + } diff --git a/app/Ninja/Repositories/AccountRepository.php b/app/Ninja/Repositories/AccountRepository.php index 89ea2c1d90c6..51ff426c2bbc 100644 --- a/app/Ninja/Repositories/AccountRepository.php +++ b/app/Ninja/Repositories/AccountRepository.php @@ -449,12 +449,16 @@ class AccountRepository if (! $user->registered) { $rules = ['email' => 'email|required|unique:users,email,'.$user->id.',id']; $validator = Validator::make(['email' => $email], $rules); + if ($validator->fails()) { $messages = $validator->messages(); - return $messages->first('email'); } + if (! \App\Models\LookupUser::validateEmail($email, $user)) { + return trans('texts.email_taken'); + } + $user->email = $email; $user->first_name = $firstName; $user->last_name = $lastName; From bff3160dc2963aeb798fdd712c34540b5b2dceef Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Mon, 1 May 2017 22:00:21 +0300 Subject: [PATCH 26/81] Multi-db support --- app/Http/Middleware/DatabaseLookup.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/Http/Middleware/DatabaseLookup.php b/app/Http/Middleware/DatabaseLookup.php index 5a93234f2b4b..b0ccf75d1ab4 100644 --- a/app/Http/Middleware/DatabaseLookup.php +++ b/app/Http/Middleware/DatabaseLookup.php @@ -29,6 +29,8 @@ class DatabaseLookup } elseif ($guard == 'api') { if ($token = $request->header('X-Ninja-Token')) { LookupAccountToken::setServerByField('token', $token); + } elseif ($email = $request->email) { + LookupUser::setServerByField('email', $email); } } elseif ($guard == 'contact') { if ($key = request()->invitation_key) { From 6108196ee7c5c4befc5589e3a2fc739ac1a28f8d Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Tue, 2 May 2017 12:09:01 +0300 Subject: [PATCH 27/81] Multi-db support --- app/Models/LookupModel.php | 6 +++- config/database.php | 28 ++----------------- ...0_174702_add_multiple_database_support.php | 6 ---- database/seeds/DatabaseSeeder.php | 1 + database/seeds/UpdateSeeder.php | 3 +- 5 files changed, 10 insertions(+), 34 deletions(-) diff --git a/app/Models/LookupModel.php b/app/Models/LookupModel.php index 9ddc4868aa6e..7156e2afe749 100644 --- a/app/Models/LookupModel.php +++ b/app/Models/LookupModel.php @@ -93,8 +93,12 @@ class LookupModel extends Eloquent } } - public static function setDbServer($server, $isUser = false) + protected static function setDbServer($server, $isUser = false) { + if (! env('MULTI_DB_ENABLED')) { + return; + } + config(['database.default' => $server]); if ($isUser) { diff --git a/config/database.php b/config/database.php index f8ec008932f4..e5f0330b2d44 100644 --- a/config/database.php +++ b/config/database.php @@ -46,12 +46,7 @@ return [ 'connections' => [ - 'sqlite' => [ - 'driver' => 'sqlite', - 'database' => storage_path().'/database.sqlite', - 'prefix' => '', - ], - + // single database setup 'mysql' => [ 'driver' => 'mysql', 'host' => env('DB_HOST', 'localhost'), @@ -65,6 +60,7 @@ return [ 'engine' => 'InnoDB', ], + // multi-database setup 'db-ninja-0' => [ 'driver' => 'mysql', 'host' => env('DB_HOST', env('DB_HOST0', 'localhost')), @@ -104,26 +100,6 @@ return [ 'engine' => 'InnoDB', ], - 'pgsql' => [ - 'driver' => 'pgsql', - 'host' => env('DB_HOST', 'localhost'), - 'database' => env('DB_DATABASE', 'forge'), - 'username' => env('DB_USERNAME', 'forge'), - 'password' => env('DB_PASSWORD', ''), - 'charset' => 'utf8', - 'prefix' => '', - 'schema' => 'public', - ], - - 'sqlsrv' => [ - 'driver' => 'sqlsrv', - 'host' => env('DB_HOST', 'localhost'), - 'database' => env('DB_DATABASE', 'forge'), - 'username' => env('DB_USERNAME', 'forge'), - 'password' => env('DB_PASSWORD', ''), - 'prefix' => '', - ], - ], /* diff --git a/database/migrations/2017_04_30_174702_add_multiple_database_support.php b/database/migrations/2017_04_30_174702_add_multiple_database_support.php index d8bdfb136269..60f2ece903f1 100644 --- a/database/migrations/2017_04_30_174702_add_multiple_database_support.php +++ b/database/migrations/2017_04_30_174702_add_multiple_database_support.php @@ -49,12 +49,6 @@ class AddMultipleDatabaseSupport extends Migration Schema::rename('lookup_tokens', 'lookup_account_tokens'); - DB::table('db_servers')->insert( - ['name' => 'db-ninja-1'] - ); - DB::table('db_servers')->insert( - ['name' => 'db-ninja-2'] - ); } /** diff --git a/database/seeds/DatabaseSeeder.php b/database/seeds/DatabaseSeeder.php index 0d384ceb6889..11803e0c9873 100644 --- a/database/seeds/DatabaseSeeder.php +++ b/database/seeds/DatabaseSeeder.php @@ -29,5 +29,6 @@ class DatabaseSeeder extends Seeder $this->call('LanguageSeeder'); $this->call('IndustrySeeder'); $this->call('FrequencySeeder'); + $this->call('DbServerSeeder'); } } diff --git a/database/seeds/UpdateSeeder.php b/database/seeds/UpdateSeeder.php index 0d494cdfc688..a3326484ed35 100644 --- a/database/seeds/UpdateSeeder.php +++ b/database/seeds/UpdateSeeder.php @@ -25,7 +25,8 @@ class UpdateSeeder extends Seeder $this->call('LanguageSeeder'); $this->call('IndustrySeeder'); $this->call('FrequencySeeder'); - + $this->call('DbServerSeeder'); + Cache::flush(); } } From a45e14757f27bcec75a9ab0e48ac91d0a87d06ba Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Tue, 2 May 2017 12:09:14 +0300 Subject: [PATCH 28/81] Multi-db support --- database/seeds/DbServerSeeder.php | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 database/seeds/DbServerSeeder.php diff --git a/database/seeds/DbServerSeeder.php b/database/seeds/DbServerSeeder.php new file mode 100644 index 000000000000..8b861b4d2db1 --- /dev/null +++ b/database/seeds/DbServerSeeder.php @@ -0,0 +1,27 @@ + 'db-ninja-1'], + ['name' => 'db-ninja-2'], + ['name' => 'db-ninja-3'], + ]; + + foreach ($servers as $server) { + $record = DbServer::where('name', '=', $server['name'])->first(); + + if ($record) { + // do nothing + } else { + DbServer::create($server); + } + } + } +} From 2f193640690ba1693d84d5d3788718e39be19191 Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Tue, 2 May 2017 12:09:41 +0300 Subject: [PATCH 29/81] Multi-db support --- database/seeds/DbServerSeeder.php | 1 - 1 file changed, 1 deletion(-) diff --git a/database/seeds/DbServerSeeder.php b/database/seeds/DbServerSeeder.php index 8b861b4d2db1..91b1e9f7bd0e 100644 --- a/database/seeds/DbServerSeeder.php +++ b/database/seeds/DbServerSeeder.php @@ -11,7 +11,6 @@ class DbServerSeeder extends Seeder $servers = [ ['name' => 'db-ninja-1'], ['name' => 'db-ninja-2'], - ['name' => 'db-ninja-3'], ]; foreach ($servers as $server) { From 34fbd486b56dc6f0493eae44740fde34ffbedf65 Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Tue, 2 May 2017 12:52:22 +0300 Subject: [PATCH 30/81] Multi-db support --- app/Console/Commands/InitLookup.php | 158 ++++++++++++++++++++-------- 1 file changed, 112 insertions(+), 46 deletions(-) diff --git a/app/Console/Commands/InitLookup.php b/app/Console/Commands/InitLookup.php index 8b5a227599cd..699b895e40c2 100644 --- a/app/Console/Commands/InitLookup.php +++ b/app/Console/Commands/InitLookup.php @@ -19,7 +19,7 @@ class InitLookup extends Command * * @var string */ - protected $signature = 'ninja:init-lookup {--truncate=} {--company_id=} {--page_size=100}'; + protected $signature = 'ninja:init-lookup {--truncate=} {--validate=} {--company_id=} {--page_size=100} {--database=db-ninja-1}'; /** * The console command description. @@ -28,6 +28,9 @@ class InitLookup extends Command */ protected $description = 'Initialize lookup tables'; + protected $log = ''; + protected $isValid = true; + /** * Create a new command instance. * @@ -45,37 +48,35 @@ class InitLookup extends Command */ public function handle() { - $this->info(date('Y-m-d h:i:s') . ' Running InitLookup...'); + $this->logMessage('Running InitLookup...'); config(['database.default' => DB_NINJA_LOOKUP]); - if (DbServer::count()) { - $dbServer = DbServer::first(); - } else { - $dbServer = DbServer::create(['name' => DB_NINJA_1]); - } + $dbServer = DbServer::whereName($this->option('database'))->first(); if ($this->option('truncate')) { $this->truncateTables(); + $this->logMessage('Truncated'); + } else { + config(['database.default' => $this->option('database')]); + + $count = DB::table('companies') + ->where('id', '>=', $this->option('company_id') ?: 1) + ->count(); + + for ($i=0; $i<$count; $i += (int) $this->option('page_size')) { + $this->initCompanies($dbServer->id, $i); + } } - config(['database.default' => DB_NINJA_1]); - - $count = DB::table('companies') - ->where('id', '>=', $this->option('company_id') ?: 1) - ->count(); - - for ($i=0; $i<$count; $i += (int) $this->option('page_size')) { - $this->initCompanies($dbServer->id, $i); - } + $this->info($this->log); } private function initCompanies($dbServerId, $offset = 0) { - $this->info(date('Y-m-d h:i:s') . ' initCompanies - offset: ' . $offset); $data = []; - config(['database.default' => DB_NINJA_1]); + config(['database.default' => $this->option('database')]); $companies = DB::table('companies') ->offset($offset) @@ -90,43 +91,95 @@ class InitLookup extends Command config(['database.default' => DB_NINJA_LOOKUP]); foreach ($data as $companyId => $company) { - $this->info(date('Y-m-d h:i:s') . ' company: ' . $companyId); + $this->logMessage('Company Id: ' . $companyId); - $lookupCompany = LookupCompany::create([ - 'db_server_id' => $dbServerId, - 'company_id' => $companyId, - ]); + if ($this->option('validate')) { + $lookupCompany = LookupCompany::whereDbServerId($dbServerId)->whereCompanyId($companyId)->first(); + if (! $lookupCompany) { + $this->logError("LookupCompany - dbServerId: {$dbServerId}, companyId: {$companyId} | Not found!"); + continue; + } + } else { + $lookupCompany = LookupCompany::create([ + 'db_server_id' => $dbServerId, + 'company_id' => $companyId, + ]); + } foreach ($company as $accountKey => $account) { - $lookupAccount = LookupAccount::create([ - 'lookup_company_id' => $lookupCompany->id, - 'account_key' => $accountKey - ]); + if ($this->option('validate')) { + $lookupAccount = LookupAccount::whereLookupCompanyId($lookupCompany->id)->whereAccountKey($accountKey)->first(); + if (! $lookupAccount) { + $this->logError("LookupAccount - lookupCompanyId: {$lookupCompany->id}, accountKey {$accountKey} | Not found!"); + continue; + } + } else { + $lookupAccount = LookupAccount::create([ + 'lookup_company_id' => $lookupCompany->id, + 'account_key' => $accountKey + ]); + } + foreach ($account['users'] as $user) { - LookupUser::create([ - 'lookup_account_id' => $lookupAccount->id, - 'email' => $user['email'], - 'user_id' => $user['user_id'], - ]); + if ($this->option('validate')) { + $lookupUser = LookupUser::whereLookupAccountId($lookupAccount->id)->whereUserId($user['user_id'])->first(); + if (! $lookupUser) { + $this->logError("LookupUser - lookupAccountId: {$lookupAccount->id}, userId: {$user['user_id']} | Not found!"); + continue; + } + } else { + LookupUser::create([ + 'lookup_account_id' => $lookupAccount->id, + 'email' => $user['email'], + 'user_id' => $user['user_id'], + ]); + } } + foreach ($account['contacts'] as $contact) { - LookupContact::create([ - 'lookup_account_id' => $lookupAccount->id, - 'contact_key' => $contact['contact_key'], - ]); + if ($this->option('validate')) { + $lookupContact = LookupContact::whereLookupAccountId($lookupAccount->id)->whereContactKey($contact['contact_key'])->first(); + if (! $lookupContact) { + $this->logError("LookupContact - lookupAccountId: {$lookupAccount->id}, contactKey: {$contact['contact_key']} | Not found!"); + continue; + } + } else { + LookupContact::create([ + 'lookup_account_id' => $lookupAccount->id, + 'contact_key' => $contact['contact_key'], + ]); + } } + foreach ($account['invitations'] as $invitation) { - LookupInvitation::create([ - 'lookup_account_id' => $lookupAccount->id, - 'invitation_key' => $invitation['invitation_key'], - 'message_id' => $invitation['message_id'] ?: null, - ]); + if ($this->option('validate')) { + $lookupInvitation = LookupInvitation::whereLookupAccountId($lookupAccount->id)->whereInvitationKey($invitation['invitation_key'])->first(); + if (! $lookupInvitation) { + $this->logError("LookupInvitation - lookupAccountId: {$lookupAccount->id}, invitationKey: {$invitation['invitation_key']} | Not found!"); + continue; + } + } else { + LookupInvitation::create([ + 'lookup_account_id' => $lookupAccount->id, + 'invitation_key' => $invitation['invitation_key'], + 'message_id' => $invitation['message_id'] ?: null, + ]); + } } + foreach ($account['tokens'] as $token) { - LookupAccountToken::create([ - 'lookup_account_id' => $lookupAccount->id, - 'token' => $token['token'], - ]); + if ($this->option('validate')) { + $lookupToken = LookupAccountToken::whereLookupAccountId($lookupAccount->id)->whereToken($token['token'])->first(); + if (! $lookupToken) { + $this->logError("LookupAccountToken - lookupAccountId: {$lookupAccount->id}, token: {$token['token']} | Not found!"); + continue; + } + } else { + LookupAccountToken::create([ + 'lookup_account_id' => $lookupAccount->id, + 'token' => $token['token'], + ]); + } } } } @@ -136,7 +189,7 @@ class InitLookup extends Command { $data = []; - config(['database.default' => DB_NINJA_1]); + config(['database.default' => $this->option('database')]); $accounts = DB::table('accounts')->whereCompanyId($companyId)->orderBy('id')->get(['id', 'account_key']); foreach ($accounts as $account) { @@ -188,6 +241,17 @@ class InitLookup extends Command return $data; } + private function logMessage($str) + { + $this->log .= date('Y-m-d h:i:s') . ' ' . $str . "\n"; + } + + private function logError($str) + { + $this->isValid = false; + $this->logMessage($str); + } + private function truncateTables() { DB::statement('SET FOREIGN_KEY_CHECKS = 0'); @@ -206,6 +270,8 @@ class InitLookup extends Command ['truncate', null, InputOption::VALUE_OPTIONAL, 'Truncate', null], ['company_id', null, InputOption::VALUE_OPTIONAL, 'Company Id', null], ['page_size', null, InputOption::VALUE_OPTIONAL, 'Page Size', null], + ['database', null, InputOption::VALUE_OPTIONAL, 'Database', null], + ['validate', null, InputOption::VALUE_OPTIONAL, 'Validate', null], ]; } From 64050f11150b82ba3e26fa842eb145173deb2890 Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Tue, 2 May 2017 13:35:42 +0300 Subject: [PATCH 31/81] Multi-db support --- app/Http/Controllers/AccountApiController.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/AccountApiController.php b/app/Http/Controllers/AccountApiController.php index 59584cfeea77..f93bf85461bb 100644 --- a/app/Http/Controllers/AccountApiController.php +++ b/app/Http/Controllers/AccountApiController.php @@ -39,9 +39,9 @@ class AccountApiController extends BaseAPIController public function register(RegisterRequest $request) { - if (! \App\Models\LookupUser::validateEmail()) { + if (! \App\Models\LookupUser::validateEmail($request->email)) { return $this->errorResponse(['message' => trans('texts.email_taken')], 500); - } + } $account = $this->accountRepo->create($request->first_name, $request->last_name, $request->email, $request->password); $user = $account->users()->first(); From 74fcefc408c5617285268e4505bc17f0e3ae1e25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B3nio=20Pinto?= Date: Tue, 2 May 2017 18:25:02 +0100 Subject: [PATCH 32/81] #1467 Add pt_PT Locale to Language Seeder --- database/seeds/LanguageSeeder.php | 1 + 1 file changed, 1 insertion(+) diff --git a/database/seeds/LanguageSeeder.php b/database/seeds/LanguageSeeder.php index 641445aa314b..2ccce1c7b779 100644 --- a/database/seeds/LanguageSeeder.php +++ b/database/seeds/LanguageSeeder.php @@ -32,6 +32,7 @@ class LanguageSeeder extends Seeder ['name' => 'Albanian', 'locale' => 'sq'], ['name' => 'Greek', 'locale' => 'el'], ['name' => 'English - United Kingdom', 'locale' => 'en_UK'], + ['name' => 'Portuguese - Portugal', 'locale' => 'pt_PT'], ]; foreach ($languages as $language) { From c73d55d74fecbe302dd6d2290ea0b633347d2a1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B3nio=20Pinto?= Date: Tue, 2 May 2017 18:25:56 +0100 Subject: [PATCH 33/81] #1467 Add pt_PT translation files --- resources/lang/pt_PT/pagination.php | 20 + resources/lang/pt_PT/passwords.php | 22 + resources/lang/pt_PT/reminders.php | 24 + resources/lang/pt_PT/texts.php | 2492 +++++++++++++++++++++++++++ resources/lang/pt_PT/validation.php | 106 ++ 5 files changed, 2664 insertions(+) create mode 100644 resources/lang/pt_PT/pagination.php create mode 100644 resources/lang/pt_PT/passwords.php create mode 100644 resources/lang/pt_PT/reminders.php create mode 100644 resources/lang/pt_PT/texts.php create mode 100644 resources/lang/pt_PT/validation.php diff --git a/resources/lang/pt_PT/pagination.php b/resources/lang/pt_PT/pagination.php new file mode 100644 index 000000000000..39b1c02096a4 --- /dev/null +++ b/resources/lang/pt_PT/pagination.php @@ -0,0 +1,20 @@ + '« Anterior', + +'next' => 'Próximo »', + +); diff --git a/resources/lang/pt_PT/passwords.php b/resources/lang/pt_PT/passwords.php new file mode 100644 index 000000000000..71a5b54d51ce --- /dev/null +++ b/resources/lang/pt_PT/passwords.php @@ -0,0 +1,22 @@ + "A palavra-passe deve conter pelo menos seis caracteres e combinar com a confirmação.", + "user" => "Utilizador não encontrado.", + "token" => "Token inválido.", + "sent" => "Link para reposição da palavra-passe enviado por email!", + "reset" => "Palavra-passe reposta!", + +]; diff --git a/resources/lang/pt_PT/reminders.php b/resources/lang/pt_PT/reminders.php new file mode 100644 index 000000000000..4c0a422c77c6 --- /dev/null +++ b/resources/lang/pt_PT/reminders.php @@ -0,0 +1,24 @@ + "As palavras-passe devem conter no mínimo seis caracteres e devem ser iguais.", + +"user" => "Não foi encontrado um utilizador com o endereço de email indicado.", + +"token" => "Este token de redefinição de palavra-passe é inválido.", + +"sent" => "Lembrete de palavra-passe enviado!", + +); diff --git a/resources/lang/pt_PT/texts.php b/resources/lang/pt_PT/texts.php new file mode 100644 index 000000000000..cb80c46c8c6e --- /dev/null +++ b/resources/lang/pt_PT/texts.php @@ -0,0 +1,2492 @@ + 'Organização', + 'name' => 'Nome', + 'website' => 'Website', + 'work_phone' => 'Telefone', + 'address' => 'Morada', + 'address1' => 'Rua', + 'address2' => 'Complemento', + 'city' => 'Cidade', + 'state' => 'Distrito', + 'postal_code' => 'Código Postal', + 'country_id' => 'País', + 'contacts' => 'Contatos', + 'first_name' => 'Primeiro Nome', + 'last_name' => 'Último Nome', + 'phone' => 'Telefone', + 'email' => 'E-mail', + 'additional_info' => 'Informações Adicionais', + 'payment_terms' => 'Condições de Pagamento', + 'currency_id' => 'Moeda', + 'size_id' => 'Tamanho da empresa', + 'industry_id' => 'Indústria', + 'private_notes' => 'Notas Privadas', + 'invoice' => 'Nota de Pag.', + 'client' => 'Cliente', + 'invoice_date' => 'Data da NP', + 'due_date' => 'Data de Vencimento', + 'invoice_number' => 'Número NP', + 'invoice_number_short' => 'NP#', + 'po_number' => 'Núm. Ordem de Serviço', + 'po_number_short' => 'OS #', + 'frequency_id' => 'Frequência', + 'discount' => 'Desconto', + 'taxes' => 'Impostos', + 'tax' => 'Imposto', + 'item' => 'Item', + 'description' => 'Descrição', + 'unit_cost' => 'Custo Unitário', + 'quantity' => 'Quantidade', + 'line_total' => 'Total', + 'subtotal' => 'Subtotal', + 'paid_to_date' => 'Pago até à data', + 'balance_due' => 'Valor', + 'invoice_design_id' => 'Modelo', + 'terms' => 'Condições', + 'your_invoice' => 'A sua Nota de Pagamento', + 'remove_contact' => 'Remover contato', + 'add_contact' => 'Adicionar contato', + 'create_new_client' => 'Criar novo cliente', + 'edit_client_details' => 'Editar detalhes do cliente', + 'enable' => 'Ativar', + 'learn_more' => 'Saber mais', + 'manage_rates' => 'Gerir taxas', + 'note_to_client' => 'Observações', + 'invoice_terms' => 'Condições da Nota de Pagamento', + 'save_as_default_terms' => 'Guardar como condição padrão', + 'download_pdf' => 'Download PDF', + 'pay_now' => 'Pagar agora', + 'save_invoice' => 'Guardar Nota Pag.', + 'clone_invoice' => 'Clonar Nota Pag.', + 'archive_invoice' => 'Arquivar Nota Pag.', + 'delete_invoice' => 'Apagar Nota Pag.', + 'email_invoice' => 'Enviar Nota Pag.', + 'enter_payment' => 'Introduzir Pag.', + 'tax_rates' => 'Impostos', + 'rate' => 'Valor', + 'settings' => 'Definições', + 'enable_invoice_tax' => 'Permitir especificar o imposto da nota de pagamento', + 'enable_line_item_tax' => 'Permitir especificar os impostos por item', + 'dashboard' => 'Dashboard', + 'clients' => 'Clientes', + 'invoices' => 'Notas Pag.', + 'payments' => 'Pagamentos', + 'credits' => 'Créditos', + 'history' => 'Histórico', + 'search' => 'Pesquisa', + 'sign_up' => 'Registar', + 'guest' => 'Convidado', + 'company_details' => 'Detalhes da Empresa', + 'online_payments' => 'Pagamentos Online', + 'notifications' => 'Notificações de email', + 'import_export' => 'Importar/Exportar', + 'done' => 'Feito', + 'save' => 'Guardar', + 'create' => 'Criar', + 'upload' => 'Upload', + 'import' => 'Importar', + 'download' => 'Download', + 'cancel' => 'Cancelar', + 'close' => 'Fechar', + 'provide_email' => 'Forneça um endereço de e-mail válido', + 'powered_by' => 'Powered by', + 'no_items' => 'Sem itens', + 'recurring_invoices' => 'Notas de Pagamento Recorrentes', + 'recurring_help' => '

Enviar automaticamente aos clientes as mesmas nota de pagamentos semanalmente, mensalmente, bimenstralmente, trimestralmente ou anualmente.

+

Use :MONTH, :QUARTER ou :YEAR para datas dinâmicas. Operadores matemáticos também funcionam, por exemplo :MONTH-1.

+

Exemplo de variáveis de uma nota de pagamento dinâmica:

+
    +
  • "Mensalidade da academia para o mês de :MONTH" => "Mensalidade da academia para o mês de Julho"
  • +
  • "Plano anual de :YEAR+1" => "Plano anual de 2015"
  • +
  • "Pagamento retido para :QUARTER+1" => "Pagamento retido para Q2"
  • +
', + 'in_total_revenue' => 'no total de faturação', + 'billed_client' => 'Faturado ao cliente', + 'billed_clients' => 'Clientes faturados', + 'active_client' => 'Cliente ativo', + 'active_clients' => 'Clientes ativos', + 'invoices_past_due' => 'Notas de Pag. Vencidas', + 'upcoming_invoices' => 'Próximas Notas de Pag.', + 'average_invoice' => 'Média por Nota de Pag.', + 'archive' => 'Arquivar', + 'delete' => 'Apagar', + 'archive_client' => 'Arquivar Cliente', + 'delete_client' => 'Apagar Cliente', + 'archive_payment' => 'Arquivar Pagamento', + 'delete_payment' => 'Apagar Pagamento', + 'archive_credit' => 'Arquivar Crédito', + 'delete_credit' => 'Apagar Crédito', + 'show_archived_deleted' => 'Mostrar arquivados/apagados', + 'filter' => 'Filtrar', + 'new_client' => 'Novo Cliente', + 'new_invoice' => 'Nova Nota Pag.', + 'new_payment' => 'Introduzir Pagamento', + 'new_credit' => 'Introduzir Crédito', + 'contact' => 'Contato', + 'date_created' => 'Data de Criação', + 'last_login' => 'Último Login', + 'balance' => 'Saldo', + 'action' => 'Ação', + 'status' => 'Estado', + 'invoice_total' => 'Total da Nota de Pag.', + 'frequency' => 'Frequência', + 'start_date' => 'Data Inicial', + 'end_date' => 'Data Final', + 'transaction_reference' => 'Referência da Transação', + 'method' => 'Método', + 'payment_amount' => 'Valor do Pagamento', + 'payment_date' => 'Data do Pagamento', + 'credit_amount' => 'Valor do Crédito', + 'credit_balance' => 'Balanço do Crédito', + 'credit_date' => 'Data do Crédito', + 'empty_table' => 'Sem dados disponíveis', + 'select' => 'Selecionar', + 'edit_client' => 'Editar Cliente', + 'edit_invoice' => 'Editar Nota Pag.', + 'create_invoice' => 'Criar Nota Pag.', + 'enter_credit' => 'Introduzir Crédito', + 'last_logged_in' => 'Último acesso em', + 'details' => 'Detalhes', + 'standing' => 'Resumo', + 'credit' => 'Crédito', + 'activity' => 'Atividade', + 'date' => 'Data', + 'message' => 'Mensagem', + 'adjustment' => 'Ajuste', + 'are_you_sure' => 'Tem a certeza?', + 'payment_type_id' => 'Tipo de pagamento', + 'amount' => 'Valor', + 'work_email' => 'E-mail', + 'language_id' => 'Idioma', + 'timezone_id' => 'Fuso Horário', + 'date_format_id' => 'Formato da Data', + 'datetime_format_id' => 'Formato da Data/Hora', + 'users' => 'Utilizadores', + 'localization' => 'Localização', + 'remove_logo' => 'Remover logo', + 'logo_help' => 'Suportados: JPEG, GIF and PNG', + 'payment_gateway' => 'Gateway de Pagamento', + 'gateway_id' => 'Gateway', + 'email_notifications' => 'Notificações por E-mail', + 'email_sent' => 'Notificar-me por e-mail quando a nota de pagamento for enviada', + 'email_viewed' => 'Notificar-me por e-mail quando a nota de pagamento for visualizada', + 'email_paid' => 'Notificar-me por e-mail quando a nota de pagamento for paga', + 'site_updates' => 'Atualizações', + 'custom_messages' => 'Mensagens personalizadas', + 'default_email_footer' => 'Definir assinatura de e-mail padrão', + 'select_file' => 'Por favor selecionar um arquivo', + 'first_row_headers' => 'Usar as primeiras linhas como cabeçalho', + 'column' => 'Coluna', + 'sample' => 'Exemplo', + 'import_to' => 'Importar para', + 'client_will_create' => 'será criado um cliente', + 'clients_will_create' => 'serão criados clientes', + 'email_settings' => 'Definições de E-mail', + 'client_view_styling' => 'Estilo de visualização do cliente', + 'pdf_email_attachment' => 'Anexar Nota de pag.', + 'custom_css' => 'CSS Personalizado', + 'import_clients' => 'Importar Dados do Cliente', + 'csv_file' => 'Ficheiro CSV', + 'export_clients' => 'Exportar Dados do Cliente', + 'created_client' => 'Cliente criado com sucesso', + 'created_clients' => ':count clientes criados com sucesso', + 'updated_settings' => 'Definições atualizadas com sucesso', + 'removed_logo' => 'Logo removido com sucesso', + 'sent_message' => 'Mensagem enviada com sucesso', + 'invoice_error' => 'Verifique se selecionou um cliente e que não há nenhum outro erro', + 'limit_clients' => 'Desculpe, isto irá exceder o limite de :count clientes', + 'payment_error' => 'Ocorreu um erro ao processar o pagamento. Por favor tente novamente mais tarde.', + 'registration_required' => 'Inicie sessão para enviar uma nota de pagamento por e-mail', + 'confirmation_required' => 'Por favor confirme o seu email, click here para reenviar a conformação de email.', + 'updated_client' => 'Cliente atualizado com sucesso', + 'created_client' => 'Cliente criado com sucesso', + 'archived_client' => 'Cliente arquivado com sucesso', + 'archived_clients' => ':count clientes arquivados com sucesso', + 'deleted_client' => 'Clientes removidos com sucesso', + 'deleted_clients' => ':count clientes removidos com sucesso', + 'updated_invoice' => 'Nota de Pagamento atualizada com sucesso', + 'created_invoice' => 'Nota de Pagamento criada com sucesso', + 'cloned_invoice' => 'Nota de Pagamento clonada com sucesso', + 'emailed_invoice' => 'Nota de Pagamento enviada por e-mail com sucesso', + 'and_created_client' => 'e o cliente foi criado', + 'archived_invoice' => 'Nota de Pagamento arquivado com sucesso', + 'archived_invoices' => ':count nota de pagamentos arquivados com sucesso', + 'deleted_invoice' => 'Nota de Pagamento apagados com sucesso', + 'deleted_invoices' => ':count nota de pagamentos apagados com sucesso', + 'created_payment' => 'Pagamento criado com sucesso', + 'created_payments' => ':count pagamento(s) criados com sucesso', + 'archived_payment' => 'Pagamento arquivado com sucesso', + 'archived_payments' => ':count pagamentos arquivados com sucesso', + 'deleted_payment' => 'Pagamento apagado com sucesso', + 'deleted_payments' => ':count pagamentos apagados com sucesso', + 'applied_payment' => 'Pagamentos aplicados com sucesso', + 'created_credit' => 'Crédito criado com sucesso', + 'archived_credit' => 'Crédito arquivado com sucesso', + 'archived_credits' => ':count créditos arquivados com sucesso', + 'deleted_credit' => 'Crédito apagado com sucesso', + 'deleted_credits' => ':count créditos apagados com sucesso', + 'imported_file' => 'Arquivo importado com sucesso', + 'updated_vendor' => 'Fornecedor atualizado com sucesso', + 'created_vendor' => 'Fornecedor criado com sucesso', + 'archived_vendor' => 'Fornecedor arquivado com sucesso', + 'archived_vendors' => ':count fornecedores arquivados com sucesso', + 'deleted_vendor' => 'Fornecedor removido com sucesso', + 'deleted_vendors' => ':count fornecedores removidos com sucesso', + 'confirmation_subject' => 'Confirmação de Conta do Invoice Ninja', + 'confirmation_header' => 'Confirmação de Conta', + 'confirmation_message' => 'Por favor clique no link abaixo para confirmar a sua conta.', + 'invoice_subject' => 'Nova nota de pagamento :invoice de :account', + 'invoice_message' => 'Para visualizar a sua nota de pagamento de :amount, clique no link abaixo.', + 'payment_subject' => 'Pagamento recebido', + 'payment_message' => 'Obrigado, pagamento de :amount confirmado', + 'email_salutation' => 'Caro :name,', + 'email_signature' => 'Atenciosamente,', + 'email_from' => 'Equipe InvoiceNinja', + 'invoice_link_message' => 'Para visualizar a nota de pagamento do seu cliente clique no link abaixo:', + 'notification_invoice_paid_subject' => 'Nota de Pagamento :invoice foi paga por :client', + 'notification_invoice_sent_subject' => 'Nota de Pagamento :invoice foi enviada por :client', + 'notification_invoice_viewed_subject' => 'Nota de Pagamento :invoice foi visualizada por :client', + 'notification_invoice_paid' => 'Um pagamento de :amount foi realizado pelo cliente :client através da nota de pagamento :invoice.', + 'notification_invoice_sent' => 'O cliente :client foi notificado por e-mail referente à nota de pagamento :invoice de :amount.', + 'notification_invoice_viewed' => 'O cliente :client visualizou a nota de pagamento :invoice de :amount.', + 'reset_password' => 'Pode redefinir a sua palavra-passe clicando no link seguinte:', + 'secure_payment' => 'Pagamento Seguro', + 'card_number' => 'Número do cartão', + 'expiration_month' => 'Mês de expiração', + 'expiration_year' => 'Ano de expiração', + 'cvv' => 'CVV', + 'logout' => 'Sair', + 'sign_up_to_save' => 'Faça login para guardar o seu trabalho', + 'agree_to_terms' => 'Eu concordo com os :terms do Invoice Ninja', + 'terms_of_service' => 'Condições do Serviço', + 'email_taken' => 'O endereço de e-mail já está registrado', + 'working' => 'Processando', + 'success' => 'Successo', + 'success_message' => 'Registou-se com sucesso. Por favor que no link de confirmação recebido para confirmar o seu endereço de e-mail.', + 'erase_data' => 'A sua conta não está registada, apagará todos os dados permanentemente.', + 'password' => 'Palavra-passe', + 'pro_plan_product' => 'Plano Profissional', + 'pro_plan_success' => 'Muito Obrigado! Assim que o pagamento for confirmado o seu plano será ativado.', + 'unsaved_changes' => 'Existem alterações não guardadas', + 'custom_fields' => 'Campos Personalizados', + 'company_fields' => 'Campos para Empresa', + 'client_fields' => 'Campos para Clientes', + 'field_label' => 'Nome do Campo', + 'field_value' => 'Valor do Campo', + 'edit' => 'Editar', + 'set_name' => 'Informe o nome da sua empresa', + 'view_as_recipient' => 'Visualizar como destinatário', + 'product_library' => 'Lista de Produtos', + 'product' => 'Produto', + 'products' => 'Produtos', + 'fill_products' => 'Sugerir produtos', + 'fill_products_help' => 'Selecionando o produto descrição e preço serão preenchidos automaticamente', + 'update_products' => 'Atualização automática dos produtos', + 'update_products_help' => 'Atualizando na nota de pagamento o produto também será atualizado', + 'create_product' => 'Criar Produto', + 'edit_product' => 'Editar Prodruto', + 'archive_product' => 'Arquivar Produto', + 'updated_product' => 'Produto atualizado', + 'created_product' => 'Produto criado', + 'archived_product' => 'Produto arquivado', + 'pro_plan_custom_fields' => ':link para ativar campos personalizados adquira o Plano Profissional', + 'advanced_settings' => 'Definições Avançadas', + 'pro_plan_advanced_settings' => ':link para ativar as definições avançadas adquira o Plano Profissional', + 'invoice_design' => 'Design das Nota Pag.', + 'specify_colors' => 'Definição de Cores', + 'specify_colors_label' => 'Selecione as cores para a sua nota de pagamento', + 'chart_builder' => 'Contrutor de Gráficos', + 'ninja_email_footer' => 'Use :site para gerir os orçamentos e nota de pagamentos dos seus clientes!', + 'go_pro' => 'Adquira o Plano Pro', + 'quote' => 'Orçamento', + 'quotes' => 'Orçamentos', + 'quote_number' => 'Número do Orçamento', + 'quote_number_short' => 'Orçamento #', + 'quote_date' => 'Data do Orçamento', + 'quote_total' => 'Total do Orçamento', + 'your_quote' => 'Seu Orçamento', + 'total' => 'Total', + 'clone' => 'Clonar', + 'new_quote' => 'Novo Orçamento', + 'create_quote' => 'Criar Orçamento', + 'edit_quote' => 'Editar Orçamento', + 'archive_quote' => 'Arquivar Orçamento', + 'delete_quote' => 'Apagar Orçamento', + 'save_quote' => 'Guardar Oçamento', + 'email_quote' => 'Enviar Orçamento', + 'clone_quote' => 'Clonar Orçamento', + 'convert_to_invoice' => 'Converter em Nota de Pag.', + 'view_invoice' => 'Visualizar nota de pag.', + 'view_client' => 'Visualizar Cliente', + 'view_quote' => 'Visualizar Orçamento', + 'updated_quote' => 'Orçamento atualizado', + 'created_quote' => 'Orçamento criado', + 'cloned_quote' => 'Orçamento clonado', + 'emailed_quote' => 'Orçamento enviado', + 'archived_quote' => 'Orçamento aquivado', + 'archived_quotes' => ':count Orçamento(s) arquivado(s)', + 'deleted_quote' => 'Orçamento apagado', + 'deleted_quotes' => ':count Orçamento(s) apagados(s)', + 'converted_to_invoice' => 'Orçamento convertido em nota de pag,', + 'quote_subject' => 'Novo orçamento :quote de :account', + 'quote_message' => 'Para visualizar o orçamento de :amount, clique no link abaixo.', + 'quote_link_message' => 'Para visualizar o orçamento clique no link abaixo', + 'notification_quote_sent_subject' => 'Orçamento :invoice enviado para :client', + 'notification_quote_viewed_subject' => 'Orçamento :invoice visualizado por :client', + 'notification_quote_sent' => 'O cliente :client recebeu o Orçamento :invoice de:amount.', + 'notification_quote_viewed' => 'O cliente :client visualizou o Orçamento :invoice de :amount.', + 'session_expired' => 'Sessão expirada.', + 'invoice_fields' => 'Campos da Nota Pag.', + 'invoice_options' => 'Opções da Nota Pag.', + 'hide_quantity' => 'Ocultar quantidade', + 'hide_quantity_help' => 'Desativar a coluna de quantidade, simplifique as notas de pag. escondendo este campo.', + 'hide_paid_to_date' => 'Ocultar data de pagamento', + 'hide_paid_to_date_help' => 'Apenas mostrar a "Data de Pagamento" quanto o pagamento tiver sido efetuado.', + 'charge_taxes' => 'Impostos', + 'user_management' => 'Gerir utilizadores', + 'add_user' => 'Adicionar utilizadores', + 'send_invite' => 'Enviar convite', + 'sent_invite' => 'Convite enviado', + 'updated_user' => 'Utilizador atualizado', + 'invitation_message' => 'Recebeu um convite de :invitor. ', + 'register_to_add_user' => 'Registe-se para adicionar um utilizador', + 'user_state' => 'Distrito', + 'edit_user' => 'Editar Utilizador', + 'delete_user' => 'Apagar Utilizador', + 'active' => 'Ativo', + 'pending' => 'Pendente', + 'deleted_user' => 'Utilizador apagado', + 'confirm_email_invoice' => 'Deseja enviar esta nota de pagamento?', + 'confirm_email_quote' => 'Deseja enviar este orçamento?', + 'confirm_recurring_email_invoice' => 'Deseja enviar esta nota de pagamento?', + 'cancel_account' => 'Cancelar Conta', + 'cancel_account_message' => 'Aviso: Irá apagar permanentemente a sua conta.', + 'go_back' => 'Voltar', + 'data_visualizations' => 'Visualização de Dados', + 'sample_data' => 'Dados de Exemplo', + 'hide' => 'Ocultar', + 'new_version_available' => 'Uma nova versão :releases_link está disponível. A sua versão é v:user_version, a última versão é v:latest_version', + 'invoice_settings' => 'Configuração das Notas de Pagamento', + 'invoice_number_prefix' => 'Prefixo na Numeração das Notas de Pagamento', + 'invoice_number_counter' => 'Numeração das Notas de Pagamento', + 'quote_number_prefix' => 'Prefixo na Numeração dos Orçamentos', + 'quote_number_counter' => 'Numeração dos Orçamentos', + 'share_invoice_counter' => 'Usar numeração das nota de pagamentos', + 'invoice_issued_to' => 'Nota de Pagamento emitida para', + 'invalid_counter' => 'Para evitar conflitos defina prefixos de numeração para Notas de Pagamento e Orçamentos', + 'mark_sent' => 'Marcar como Enviada', + 'gateway_help_1' => ':link para registar Authorize.net.', + 'gateway_help_2' => ':link para registar Authorize.net.', + 'gateway_help_17' => ':link para adquirir a sua "PayPal API signature".', + 'gateway_help_27' => ':link registe-se no 2Checkout.com. Para garantir o rastreamento dos pagamentos configure o :complete_link como URL de redirecionamento em Account > Site Management in the 2Checkout portal.', + 'more_designs' => 'Mais Modelos', + 'more_designs_title' => 'Modelos Adicionais', + 'more_designs_cloud_header' => 'Adquira o Plano Pro para mais modelos', + 'more_designs_cloud_text' => '', + 'more_designs_self_host_text' => '', + 'buy' => 'Comprar', + 'bought_designs' => 'Novos Modelos Adicionados', + 'sent' => 'enviado', + 'vat_number' => 'NIF', + 'timesheets' => 'Folha de horas', + 'payment_title' => 'Indique a morada de faturação e as informações do Cartão de Crédito', + 'payment_cvv' => '*São os 3-4 digitos encontrados atrás do seu cartão.', + 'payment_footer1' => '*A morada de faturação deve ser igual à morada associada ao Cartão de Crédito.', + 'payment_footer2' => '*Clique em "Pagar Agora" apenas uma vez - esta operação pode levar até 1 Minuto para ser processada.', + 'id_number' => 'ID Number', + 'white_label_link' => 'White Label', + 'white_label_header' => 'White Label', + 'bought_white_label' => 'Licença "white label" ativada', + 'white_labeled' => 'White labeled', + 'restore' => 'Restaurar', + 'restore_invoice' => 'Restaurar Nota Pag.', + 'restore_quote' => 'Restaurar Orçamento', + 'restore_client' => 'Restaurar Cliente', + 'restore_credit' => 'Restaurar Crédito', + 'restore_payment' => 'Restaurar Pagamento', + 'restored_invoice' => 'Nota de Pagamento restaurada', + 'restored_quote' => 'Orçamento restaurado', + 'restored_client' => 'Cliente restaurado', + 'restored_payment' => 'Pagamento restaurado', + 'restored_credit' => 'Crédito restaurado', + 'reason_for_canceling' => 'Ajude-nos a melhorar, envie suas sugestões.', + 'discount_percent' => '%', + 'discount_amount' => 'Valor', + 'invoice_history' => 'Histórico de Notas de Pagamento', + 'quote_history' => 'Histórico de Orçamentos', + 'current_version' => 'Versão Atual', + 'select_version' => 'Selecionar versão', + 'view_history' => 'Visualizar Histórico', + 'edit_payment' => 'Editar Pagamento', + 'updated_payment' => 'Pagamento atualizado', + 'deleted' => 'Apagado', + 'restore_user' => 'Restaurar Utilizador', + 'restored_user' => 'Utilizador restaurado', + 'show_deleted_users' => 'Mostrar utilizadores apagados', + 'email_templates' => 'Modelo de E-mail', + 'invoice_email' => 'E-mail para Notas de Pag.', + 'payment_email' => 'E-mail para Pagamentos', + 'quote_email' => 'E-mail para Orçamentos', + 'reset_all' => 'Redifinir Todos', + 'approve' => 'Aprovar', + 'token_billing_type_id' => 'Token de Cobrança', + 'token_billing_help' => 'Guardar detalhes de pagamento com o WePay, Stripe ou Braintree.', + 'token_billing_1' => 'Desativado', + 'token_billing_2' => 'A checkbox de Opt-in está visível mas não seleccionada', + 'token_billing_3' => 'A checkbox de Opt-out está visível e seleccionada', + 'token_billing_4' => 'Sempre', + 'token_billing_checkbox' => 'Guardar detalhes do cartão', + 'view_in_gateway' => 'Ver em :gateway', + 'use_card_on_file' => 'Utilizar Cartão em Arquivo', + 'edit_payment_details' => 'Editar detalhes do pagamento', + 'token_billing' => 'Guardar detalhes do cartão', + 'token_billing_secure' => 'Dados armazenados com seguração por :link', + 'support' => 'Suporte', + 'contact_information' => 'Informações de Contato', + '256_encryption' => 'Criptografia de 256-Bit', + 'amount_due' => 'Valor em dívida', + 'billing_address' => 'Morada de faturação', + 'billing_method' => 'Tipo de pagamento', + 'order_overview' => 'Geral', + 'match_address' => '*A morada de faturação deve ser igual à morada associada ao Cartão de Crédito.', + 'click_once' => '*Clique em "Pagar Agora" apenas uma vez - esta operação pode levar até 1 Minuto para processar.', + 'invoice_footer' => 'Rodapé da Nota Pag.', + 'save_as_default_footer' => 'Guardar como rodapé padrão', + 'token_management' => 'Gerir Token', + 'tokens' => 'Tokens', + 'add_token' => 'Adicionar Token', + 'show_deleted_tokens' => 'Mostrar tokents apagados', + 'deleted_token' => 'Token apagado', + 'created_token' => 'Token criado', + 'updated_token' => 'Token atualizado', + 'edit_token' => 'Editat Token', + 'delete_token' => 'Apagar Token', + 'token' => 'Token', + 'add_gateway' => 'Adicionar Gateway', + 'delete_gateway' => 'Apagar Gateway', + 'edit_gateway' => 'Editar Gateway', + 'updated_gateway' => 'Gateway atualizado', + 'created_gateway' => 'Gateway Criado', + 'deleted_gateway' => 'Gateway Apagado', + 'pay_with_paypal' => 'PayPal', + 'pay_with_card' => 'Cartão de Crédito', + 'change_password' => 'Alterar palavra-passe', + 'current_password' => 'Palavra-passe atual', + 'new_password' => 'Nova palavra-passe', + 'confirm_password' => 'Confirmar palavra-passe', + 'password_error_incorrect' => 'Palavra-passe atual incorreta.', + 'password_error_invalid' => 'Nova palavra-passe inválida.', + 'updated_password' => 'Palavra-passe atualizada', + 'api_tokens' => 'API Tokens', + 'users_and_tokens' => 'Utilizadores & Tokens', + 'account_login' => 'Iniciar sessão', + 'recover_password' => 'Recuperar palavra-passe', + 'forgot_password' => 'Esqueceu-se da palavra-passe?', + 'email_address' => 'E-mail', + 'lets_go' => 'Vamos!', + 'password_recovery' => 'Recuperar palavra-passe', + 'send_email' => 'Enviar email', + 'set_password' => 'Definir palavra-passe', + 'converted' => 'Convertido', + 'email_approved' => 'Notificar-me por e-mail quando um orçamento for aprovado', + 'notification_quote_approved_subject' => 'Orçamento :invoice foi aprovado por :client', + 'notification_quote_approved' => 'O cliente :client aprovou o Orçamento :invoice de :amount.', + 'resend_confirmation' => 'Reenviar e-mail de confirmação', + 'confirmation_resent' => 'E-mail de confirmação reenviado', + 'gateway_help_42' => ':link aceder BitPay.
Aviso: use a "Legacy API Key", não "API token".', + 'payment_type_credit_card' => 'Cartão de Crédito', + 'payment_type_paypal' => 'PayPal', + 'payment_type_bitcoin' => 'Bitcoin', + 'knowledge_base' => 'Base de Conhecimento', + 'partial' => 'Depósito/Parcial', + 'partial_remaining' => ':partial de :balance', + 'more_fields' => 'Mais Campos', + 'less_fields' => 'Menos Campos', + 'client_name' => 'Cliente', + 'pdf_settings' => 'Definições do PDF', + 'product_settings' => 'Definições de Produtos', + 'auto_wrap' => 'Quebrar Linhas', + 'duplicate_post' => 'Atenção: a página anterior foi enviada duas vezes. A segunda vez foi ignorada.', + 'view_documentation' => 'Ver Documentação', + 'app_title' => 'Free Open-Source Online Invoicing', + 'app_description' => 'O Invoice Ninja é uma solução gratuita e open-source para faturar e cobrar serviços e produtos a clientes. Com o Invoice Ninja, poderá criar fácilmente faturas atrativas através de qualquer dispositivo que tenha acesso à internet. Os seus clientes podem imprimir as suas faturas, transferir como PDF e até mesmo pagar online dentro do sistema.', + 'rows' => 'linhas', + 'www' => 'www', + 'logo' => 'Logo', + 'subdomain' => 'Subdomínio', + 'provide_name_or_email' => 'Indique um nome ou email', + 'charts_and_reports' => 'Gráficos & Relatórios', + 'chart' => 'Gráfico', + 'report' => 'Relatório', + 'group_by' => 'Agrupado por', + 'paid' => 'Pago', + 'enable_report' => 'Relatório', + 'enable_chart' => 'Gráfico', + 'totals' => 'Totais', + 'run' => 'Executar', + 'export' => 'Exportar', + 'documentation' => 'Documentação', + 'zapier' => 'Zapier', + 'recurring' => 'Recorrente', + 'last_invoice_sent' => 'Última cobrança enviada a :date', + 'processed_updates' => 'Atualização completa', + 'tasks' => 'Tarefas', + 'new_task' => 'Nova Tarefa', + 'start_time' => 'Início', + 'created_task' => 'Tarefa criada', + 'updated_task' => 'Tarefa atualizada', + 'edit_task' => 'Editar Tarefa', + 'archive_task' => 'Arquivar Tarefa', + 'restore_task' => 'Restaurar Tarefa', + 'delete_task' => 'Apagar Tarefa', + 'stop_task' => 'Parar Tarefa', + 'time' => 'Tempo', + 'start' => 'Iniciar', + 'stop' => 'Parar', + 'now' => 'Agora', + 'timer' => 'Temporizador', + 'manual' => 'Manual', + 'date_and_time' => 'Data & Hora', + 'second' => 'segundo', + 'seconds' => 'segundos', + 'minute' => 'minuto', + 'minutes' => 'minutos', + 'hour' => 'hora', + 'hours' => 'horas', + 'task_details' => 'Detalhes da Tarefa', + 'duration' => 'Duração', + 'end_time' => 'Final', + 'end' => 'Fim', + 'invoiced' => 'Faturado', + 'logged' => 'Sessão Iniciado', + 'running' => 'Em execução', + 'task_error_multiple_clients' => 'Tarefas não podem pertencer a clientes diferentes', + 'task_error_running' => 'Parar as tarefas em execução', + 'task_error_invoiced' => 'Tarefa já faturada', + 'restored_task' => 'Tarefa restaurada', + 'archived_task' => 'Tarefa arquivada', + 'archived_tasks' => ':count Tarefas arquivadas', + 'deleted_task' => 'Tarefa apagada', + 'deleted_tasks' => ':count Tarefas apagadas', + 'create_task' => 'Criar Tarefa', + 'stopped_task' => 'Tarefa interrompida', + 'invoice_task' => 'Faturar Tarefa', + 'invoice_labels' => 'Etiquetas das Notas de Pag.', + 'prefix' => 'Prefixo', + 'counter' => 'Contador', + 'payment_type_dwolla' => 'Dwolla', + 'gateway_help_43' => ':link aceder Dwolla.', + 'partial_value' => 'Deve ser maior que zero e menor que o total', + 'more_actions' => 'Mais ações', + 'pro_plan_title' => 'NINJA PRO', + 'pro_plan_call_to_action' => 'Adquira Agora!', + 'pro_plan_feature1' => 'Sem Limite de Clientes', + 'pro_plan_feature2' => '+10 Modelos de nota de pagamentos', + 'pro_plan_feature3' => 'URLs personalizadas - "SeuNome.InvoiceNinja.com"', + 'pro_plan_feature4' => 'Sem "Created by Invoice Ninja"', + 'pro_plan_feature5' => 'Múltiplos usuários & Histórico de Atividades', + 'pro_plan_feature6' => 'Orçamentos & Pedidos', + 'pro_plan_feature7' => 'Campos personalizados', + 'pro_plan_feature8' => 'Opção para anexar PDFs aos e-mails', + 'resume' => 'Retormar', + 'break_duration' => 'Interromper', + 'edit_details' => 'Editar Detalhes', + 'work' => 'Trabalhar', + 'timezone_unset' => 'Por favor :link defina a sua timezone', + 'click_here' => 'clique aqui', + 'email_receipt' => 'E-mail para envio do recibo de pagamento', + 'created_payment_emailed_client' => 'Pagamento informado e notificado ao cliente por e-mail', + 'add_company' => 'Adicionar Empresa', + 'untitled' => 'Sem Título', + 'new_company' => 'Nova Empresa', + 'associated_accounts' => 'Contas vinculadas', + 'unlinked_account' => 'Contas desvinculadas', + 'login' => 'Iniciar sessão', + 'or' => 'ou', + 'email_error' => 'Houve um problema ao enviar o e-mail', + 'confirm_recurring_timing' => 'Aviso: e-mails são enviados na hora de início.', + 'payment_terms_help' => 'Definir a data de vencimento padrão ', + 'unlink_account' => 'Desvincular Conta', + 'unlink' => 'Desvincular', + 'show_address' => 'Mostrar morada', + 'show_address_help' => 'Solicitar morada de faturação ao cliente', + 'update_address' => 'Atualizar Morada', + 'update_address_help' => 'Atualizar morada do cliente', + 'times' => 'Tempo', + 'set_now' => 'Agora', + 'dark_mode' => 'Modo Escuro', + 'dark_mode_help' => 'Mostrar texto branco em fundo preto', + 'add_to_invoice' => 'Adicionar na nota de pagamento :invoice', + 'create_new_invoice' => 'Criar nota de pagamento', + 'task_errors' => 'Corrija os tempos sobrepostos', + 'from' => 'De', + 'to' => 'Para', + 'font_size' => 'Tamanho do Texto', + 'primary_color' => 'Cor Principal', + 'secondary_color' => 'Cor Secundaria', + 'customize_design' => 'Personalizar Modelo', + 'content' => 'Conteúdo', + 'styles' => 'Estilos', + 'defaults' => 'Padrões', + 'margins' => 'Margens', + 'header' => 'Cabeçalho', + 'footer' => 'Rodapé', + 'custom' => 'Personalizado', + 'invoice_to' => 'Nota de Pagamento para', + 'invoice_no' => 'Nota de Pagamento No.', + 'quote_no' => 'Orçamento número', + 'recent_payments' => 'Pagamentos Recentes', + 'outstanding' => 'Em Aberto', + 'manage_companies' => 'Gerir Empresas', + 'total_revenue' => 'Total faturado', + 'current_user' => 'Utilizador', + 'new_recurring_invoice' => 'Nova Nota de Pagamento Recorrente', + 'recurring_invoice' => 'Nota de Pagamento Recorrente', + 'recurring_too_soon' => 'Demasiado cedo para criar nova nota de pagamento recorrente, agendamento para :date', + 'created_by_invoice' => 'Criada a partir da Nota de Pagamento :invoice', + 'primary_user' => 'Utilizador Principal', + 'help' => 'Ajuda', + 'customize_help' => '

Utilizamospdfmake configuração visual das nota de pagamentos. O pdfmake playground permite visualizar e testar as configurações .

+

You can access a child property using dot notation. For example to show the client name you could use $client.name.

+

If you need help figuring something out post a question to our support forum with the design you\'re using.

', + 'invoice_due_date' => 'Data de vencimento', + 'quote_due_date' => 'Valido até', + 'valid_until' => 'Válido até', + 'reset_terms' => 'Redifinir Condições', + 'reset_footer' => 'Redifinir Rodapé', + 'invoice_sent' => ':count nota de pag. enviada', + 'invoices_sent' => ':count notas de pag. enviadas', + 'status_draft' => 'Rascunho', + 'status_sent' => 'Enviado', + 'status_viewed' => 'Visualizado', + 'status_partial' => 'Parcial', + 'status_paid' => 'Pago', + 'show_line_item_tax' => 'Exibir impostos dos itens', + 'iframe_url' => 'Website', + 'iframe_url_help1' => 'Copie o código abaixo para o seu site.', + 'iframe_url_help2' => 'Pode testar ao clicar em \'Ver como destinatário\' numa nota de pagamento.', + 'auto_bill' => 'Cobrança Automática', + 'military_time' => '24h', + 'last_sent' => 'Último Envio', + 'reminder_emails' => 'E-mails de Lembrete', + 'templates_and_reminders' => 'Modelos & Lembretes', + 'subject' => 'Assunto', + 'body' => 'Conteúdo', + 'first_reminder' => 'Primeiro Lembrete', + 'second_reminder' => 'Segundo Lembrete', + 'third_reminder' => 'Terceiro Lembrete', + 'num_days_reminder' => 'Dias após o vencimento', + 'reminder_subject' => 'Lembrente: Nota de Pagamento :invoice de :account', + 'reset' => 'Redefinir', + 'invoice_not_found' => 'A nota de pagamento não está disponível', + 'referral_program' => 'Programa de Indicação', + 'referral_code' => 'Código de Indicação', + 'last_sent_on' => 'Último envio em :date', + 'page_expire' => 'Esta página a expirar, :click_here para continuar a trabalhar', + 'upcoming_quotes' => 'Próximos Orçamentos', + 'expired_quotes' => 'Orçamentos Expirados', + 'sign_up_using' => 'Aceder', + 'invalid_credentials' => 'Utilizador e/ou palavra-passe inválidos', + 'show_all_options' => 'Mostrar todas as opções', + 'user_details' => 'Detalhes do Utilizador', + 'oneclick_login' => 'Iniciar sessão Fácil', + 'disable' => 'Desativar', + 'invoice_quote_number' => 'Nº de Notas de Pag. e Orçamentos', + 'invoice_charges' => 'Sobretaxas da Nota de Pagamento', + 'notification_invoice_bounced' => 'Não foi possível entregar a Nota de Pagamento :invoice a :contact.', + 'notification_invoice_bounced_subject' => 'A Nota de Pagamento :invoice não foi entregue', + 'notification_quote_bounced' => 'Não foi possível entregar o Orçamento :invoice a :contact.', + 'notification_quote_bounced_subject' => 'O Orçamento :invoice não foi entregue', + 'custom_invoice_link' => 'Link de Faturas Personalizado', + 'total_invoiced' => 'Notas de Pagamento', + 'open_balance' => 'Em Aberto', + 'verify_email' => 'Um e-mail de verificação foi enviado para a sua caixa de entrada..', + 'basic_settings' => 'Definições Básicas', + 'pro' => 'Pro', + 'gateways' => 'Gateways de Pagamento', + 'next_send_on' => 'Próximo Envio: :date', + 'no_longer_running' => 'Esta nota de pagamento não está agendada', + 'general_settings' => 'Definições Gerais', + 'customize' => 'Personalizar', + 'oneclick_login_help' => 'Crie uma conta para aceder sem palavra-passe.', + 'referral_code_help' => 'Recomende o nosso sistema.', + 'enable_with_stripe' => 'Ativar | Requer Stripe', + 'tax_settings' => 'Definições de Impostos', + 'create_tax_rate' => 'Adicionar Imposto', + 'updated_tax_rate' => 'Imposto Atualizado', + 'created_tax_rate' => 'Imposto Adicionado', + 'edit_tax_rate' => 'Editar Imposto', + 'archive_tax_rate' => 'Arquivar Imposto', + 'archived_tax_rate' => 'Imposto Arquivado', + 'default_tax_rate_id' => 'Imposto Padrão', + 'tax_rate' => 'Imposto', + 'recurring_hour' => 'Hora Recorrente', + 'pattern' => 'Padrão', + 'pattern_help_title' => 'Ajuda para Padrões', + 'pattern_help_1' => 'Criar números de notas de pagamento personalizados especificando um padrão', + 'pattern_help_2' => 'Variáveis disponíveis:', + 'pattern_help_3' => 'Exemplo, :example seria convertido para :value', + 'see_options' => 'Veja as Opções', + 'invoice_counter' => 'Contador de Notas de Pagamento', + 'quote_counter' => 'Contador de Orçamentos', + 'type' => 'Tipo', + 'activity_1' => ':user criou o cliente :client', + 'activity_2' => ':user arquivou o cliente :client', + 'activity_3' => ':user removeu o cliente :client', + 'activity_4' => ':user criou a nota de pagamento :invoice', + 'activity_5' => ':user atualizou a nota de pagamento :invoice', + 'activity_6' => ':user enviou a nota de pagamento :invoice a :contact', + 'activity_7' => ':contact visualizou a nota de pagamento :invoice', + 'activity_8' => ':user arquivou a nota de pagamento :invoice', + 'activity_9' => ':user removeu a nota de pagamento :invoice', + 'activity_10' => ':contact efetuou o pagamento de :payment para a nota de pagamento :invoice', + 'activity_11' => ':user atualizou o pagamento :payment', + 'activity_12' => ':user arquivou o pagamento :payment', + 'activity_13' => ':user removeu o pagamento :payment', + 'activity_14' => ':user adicionou crédito :credit', + 'activity_15' => ':user atualizou crédito :credit', + 'activity_16' => ':user arquivou crédito :credit', + 'activity_17' => ':user removeu crédito :credit', + 'activity_18' => ':user adicionou o orçamento :quote', + 'activity_19' => ':user atualizou o orçamento :quote', + 'activity_20' => ':user enviou o orçamento :quote a :contact', + 'activity_21' => ':contact visualizou o orçamento :quote', + 'activity_22' => ':user arquivou o orçamento :quote', + 'activity_23' => ':user removeu o orçamento :quote', + 'activity_24' => ':user restaurou o orçamento :quote', + 'activity_25' => ':user restaurou a nota de pagamento :invoice', + 'activity_26' => ':user restaurou o cliente :client', + 'activity_27' => ':user restaurou o pagamento :payment', + 'activity_28' => ':user restaurou o crédito :credit', + 'activity_29' => ':contact aprovou o orçamento :quote', + 'activity_30' => ':user criou o fornecedor :vendor', + 'activity_31' => ':user arquivou o fornecedor :vendor', + 'activity_32' => ':user apagou o fornecedor :vendor', + 'activity_33' => ':user restaurou o fornecedor :vendor', + 'activity_34' => ':user criou a despesa :expense', + 'activity_35' => ':user arquivou a despesa :expense', + 'activity_36' => ':user apagou a despesa :expense', + 'activity_37' => ':user restaurou a despesa :expense', + 'activity_42' => ':user criou a tarefa :task', + 'activity_43' => ':user atualizou a tarefa :task', + 'activity_44' => ':user arquivou a tarefa :task', + 'activity_45' => ':user apagou a tarefa :task', + 'activity_46' => ':user restaurou a tarefa :task', + 'activity_47' => ':user atualizou a despesa :expense', + 'payment' => 'Pagamento', + 'system' => 'Sistema', + 'signature' => 'Assinatura do E-mail', + 'default_messages' => 'Mensagens Padrões', + 'quote_terms' => 'Condições do Orçamento', + 'default_quote_terms' => 'Condições Padrões dos Orçamentos', + 'default_invoice_terms' => 'Definir condições padrões da notas de pag.', + 'default_invoice_footer' => 'Definir padrão', + 'quote_footer' => 'Rodapé do Orçamento', + 'free' => 'Grátis', + 'quote_is_approved' => 'O orçamento foi aprovado', + 'apply_credit' => 'Aplicar Crédito', + 'system_settings' => 'Definições do Sistema', + 'archive_token' => 'Arquivar Token', + 'archived_token' => 'Token arquivado', + 'archive_user' => 'Arquivar Utilizador', + 'archived_user' => 'Utilizador arquivado', + 'archive_account_gateway' => 'Arquivar Gateway', + 'archived_account_gateway' => 'Gateway arquivado', + 'archive_recurring_invoice' => 'Arquivar Nota de Pagamento Recorrente', + 'archived_recurring_invoice' => 'Nota de Pagamento Recorrente arquivada', + 'delete_recurring_invoice' => 'Remover Nota de Pagamento Recorrente', + 'deleted_recurring_invoice' => 'Nota de Pagamento Recorrente removida', + 'restore_recurring_invoice' => 'Restaurar Nota de Pagamento Recorrente', + 'restored_recurring_invoice' => 'Nota de Pagamento Recorrente restaurada', + 'archived' => 'Arquivado', + 'untitled_account' => 'Empresa Sem Nome', + 'before' => 'Antes', + 'after' => 'Depois', + 'reset_terms_help' => 'Redefinir para as condições padrões', + 'reset_footer_help' => 'Redefinir para o rodapé padrão', + 'export_data' => 'Exportar Dados', + 'user' => 'Utilizador', + 'country' => 'País', + 'include' => 'Incluir', + 'logo_too_large' => 'O seu logo tem :size, para uma melhor performance sugerimos que este tamanho não ultrapasse 200KB', + 'import_freshbooks' => 'Importar de FreshBooks', + 'import_data' => 'Importar Dados', + 'source' => 'Fonte', + 'csv' => 'CSV', + 'client_file' => 'Arquivo de Clientes', + 'invoice_file' => 'Arquivo de Notas de Pagamento', + 'task_file' => 'Arquivo de Tarefas', + 'no_mapper' => 'Mapeamento inválido', + 'invalid_csv_header' => 'CSV com cabeçalho inválido', + 'client_portal' => 'Portal do Cliente', + 'admin' => 'Admin', + 'disabled' => 'Desativado', + 'show_archived_users' => 'Mostrar utilizadores arquivados', + 'notes' => 'Observações', + 'invoice_will_create' => 'um cliente será criado', + 'invoices_will_create' => 'notas de pagamento serão criadas', + 'failed_to_import' => 'Falhou a importação dos seguintes registos', + 'publishable_key' => 'Chave Publicável', + 'secret_key' => 'Chave Secreta', + 'missing_publishable_key' => 'Defina a sua chave publicável do Stripe para um melhor processo de pagamento', + 'email_design' => 'Template de E-mail', + 'due_by' => 'Vencido a :date', + 'enable_email_markup' => 'Ativar Marcação', + 'enable_email_markup_help' => 'Tornar mais fácil para os seus clientes efetuarem os pagamentos, acrescentando marcação schema.org a seus e-mails.', + 'template_help_title' => 'Ajuda de Templates', + 'template_help_1' => 'Variáveis disponíveis:', + 'email_design_id' => 'Estilo de e-mails', + 'email_design_help' => 'Make your emails look more professional with HTML layouts.', + 'plain' => 'Plano', + 'light' => 'Claro', + 'dark' => 'Escuro', + 'industry_help' => 'Usado para fornecer comparações entre empresas.', + 'subdomain_help' => 'Indique o subdomínio ou mostre a nota de pag. no seu site.', + 'website_help' => 'Mostrar a nota de pagamento num iFrame no seu próprio site', + 'invoice_number_help' => 'Especifique um prefixo ou use um padrão personalizado para definir dinamicamente o número da nota de pagamento.', + 'quote_number_help' => 'Especifique um prefixo ou use um padrão personalizado para definir dinamicamente o número do orçamento.', + 'custom_client_fields_helps' => 'Adicionar uma entrada de texto na página Criar/Editar Cliente e exibir no PDF.', + 'custom_account_fields_helps' => 'Adicionar um rótulo e um valor para a seção detalhes da empresa do PDF.', + 'custom_invoice_fields_helps' => 'Adicionar uma entrada de texto na página Criar/Editar Nota de Pagamento e exibir no PDF.', + 'custom_invoice_charges_helps' => 'Adicionar uma entrada de texto na página Criar/Editar Nota de Pagamento e incluir nos subtotais da nota de pagamento.', + 'token_expired' => 'Token de acesso expirado. Tente novamente!', + 'invoice_link' => 'Link da Nota de Pagamento', + 'button_confirmation_message' => 'Clique para confirmar seu endereço de e-mail.', + 'confirm' => 'Confirmar', + 'email_preferences' => 'Preferências de E-mails', + 'created_invoices' => ':count nota de pagamento(s) criadas com sucesso', + 'next_invoice_number' => 'O número da próxima nota de pagamento será :number.', + 'next_quote_number' => 'O número do próximo orçamento será :number.', + 'days_before' => 'dias antes de', + 'days_after' => 'dias depois de', + 'field_due_date' => 'data de vencimento', + 'field_invoice_date' => 'data da nota de pagamento', + 'schedule' => 'Agendamento', + 'email_designs' => 'Design de E-mails', + 'assigned_when_sent' => 'Assinar quando enviar', + 'white_label_purchase_link' => 'Adquira uma licença white label', + 'expense' => 'Despesa', + 'expenses' => 'Despesas', + 'new_expense' => 'Introduzir Despesa', + 'enter_expense' => 'Incluir Despesa', + 'vendors' => 'Fornecedor', + 'new_vendor' => 'Novo Fornecedor', + 'payment_terms_net' => 'Net', + 'vendor' => 'Fornecedor', + 'edit_vendor' => 'Editar Fornecedor', + 'archive_vendor' => 'Arquivar Fornecedor', + 'delete_vendor' => 'Apagar Fornecedor', + 'view_vendor' => 'Visualizar Fornecedor', + 'deleted_expense' => 'Despesa excluída com sucesso', + 'archived_expense' => 'Despesa arquivada com sucesso', + 'deleted_expenses' => 'Despesas excluídas com sucesso', + 'archived_expenses' => 'Despesas arquivadas com sucesso', + 'expense_amount' => 'Total de Despesas', + 'expense_balance' => 'Saldo das Despesas', + 'expense_date' => 'Data da Despesa', + 'expense_should_be_invoiced' => 'Esta despesa deve ser faturada?', + 'public_notes' => 'Notas Públicas', + 'invoice_amount' => 'Total da Nota de Pagamento', + 'exchange_rate' => 'Taxa de Câmbio', + 'yes' => 'Sim', + 'no' => 'Não', + 'should_be_invoiced' => 'Deve ser faturada', + 'view_expense' => 'Visualizar despesa # :expense', + 'edit_expense' => 'Editar Despesa', + 'archive_expense' => 'Arquivar Despesa', + 'delete_expense' => 'Apagar Despesa', + 'view_expense_num' => 'Despesa # :expense', + 'updated_expense' => 'Despesa atualizada com sucesso', + 'created_expense' => 'Despesa criada com sucesso', + 'enter_expense' => 'Incluir Despesa', + 'view' => 'Visualizar', + 'restore_expense' => 'Restaurar Despesa', + 'invoice_expense' => 'Nota de Pagamento da Despesa', + 'expense_error_multiple_clients' => 'Despesas não podem pertencer a clientes diferentes', + 'expense_error_invoiced' => 'Despesa já faturada', + 'convert_currency' => 'Converter moeda', + 'num_days' => 'Número de dias', + 'create_payment_term' => 'Criar Termo de Pagamento', + 'edit_payment_terms' => 'Editar Termos de Pagamento', + 'edit_payment_term' => 'Editar Termo de Pagamento', + 'archive_payment_term' => 'Arquivar Termo de Pagamento', + 'recurring_due_dates' => 'Data de Vencimento das Notas de Pagamento Recorrentes', + 'recurring_due_date_help' => '

Definir automaticamente a data de vencimento da nota de pagamento.

+

Notas de Pagamento em um ciclo mensal ou anual com vencimento anterior ou na data em que são criadas serão nota de pagamentodas para o próximo mês. Notas de Pagamento com vencimento no dia 29 ou 30 nos meses que não tem esse dia será nota de pagamentoda no último dia do mês..

+

Notas de Pagamento em um clclo mensal com vencimento no dia da semana em que foi criada serão nota de pagamentodas para a próxima semana.

+

Exemplo:

+
    +
  • Hoje é dia 15, vencimento no primeiro dia do mês. O Vencimento será no primeiro dia do próximo mês.
  • +
  • Hoje é dia 15, vencimento no último dia do mês. O Vencimento será no último dia do mês corrente
  • +
  • Hoje é dia 15, vencimento no dia 15. O venciemnto será no dia 15 do próximo mês.
  • +
  • Hoje é Sexta-Feira, vencimento na primeira sexta-feira. O venciemnto será na próxima sexta-feira, não hoje.
  • +
', + 'due' => 'Vencimento', + 'next_due_on' => 'Próximo Vencimento: :date', + 'use_client_terms' => 'Usar condições do cliente', + 'day_of_month' => ':ordinal dia do mês ', + 'last_day_of_month' => 'Último dia do mês', + 'day_of_week_after' => ':ordinal :day depois', + 'sunday' => 'Domingo', + 'monday' => 'Segunda-Feira', + 'tuesday' => 'Terça-Feira', + 'wednesday' => 'Quarta-Feira', + 'thursday' => 'Quinta-Feira', + 'friday' => 'Sexta-Feira', + 'saturday' => 'Sábado', + 'header_font_id' => 'Fonte do Cabeçalho', + 'body_font_id' => 'Fonte dos Textos', + 'color_font_help' => 'Nota: A cor primária também é utilizada no portal do cliente e no e-mail personalizado.', + 'live_preview' => 'Pré-visualização', + 'invalid_mail_config' => 'Falha ao enviar e-mail, verifique as definições.', + 'invoice_message_button' => 'Para visualizar a sua nota de pagamento de :amount, clique no botão abaixo.', + 'quote_message_button' => 'Para visualizar o seu orçamento de :amount, clique no botão abaixo.', + 'payment_message_button' => 'Obrigado pelo pagamento de :amount.', + 'payment_type_direct_debit' => 'Débito', + 'bank_accounts' => 'Contas Bancárias', + 'add_bank_account' => 'Adicionar Conta Bancária', + 'setup_account' => 'Configurar Conta', + 'import_expenses' => 'Importar Despesas', + 'bank_id' => 'Banco', + 'integration_type' => 'Tipo de Integração', + 'updated_bank_account' => 'Conta bancária atualizada com sucesso', + 'edit_bank_account' => 'Editar Conta Bancária', + 'archive_bank_account' => 'Arquivar Conta Bancária', + 'archived_bank_account' => 'Conta bancária arquivada com sucesso', + 'created_bank_account' => 'Conta bancária criada com sucesso', + 'validate_bank_account' => 'Validar Conta Bancária', + 'bank_password_help' => 'Nota: a sua palavra-passe é transferida de forma segura e não será armazenada em nossos servidores.', + 'bank_password_warning' => 'Atenção: a sua palavra-passe será transferida de forma não segura, considere habilitar HTTPS.', + 'username' => 'Utilizador', + 'account_number' => 'Conta número', + 'account_name' => 'Nome da Conta', + 'bank_account_error' => 'Falha ao receber os detalhes da sua conta, verifique os seus dados de acesso.', + 'status_approved' => 'Aprovado', + 'quote_settings' => 'Definições dos Orçamentos', + 'auto_convert_quote' => 'Auto-converter orçamento', + 'auto_convert_quote_help' => 'Converter automaticamente um orçamento quando for aprovado pelo cliente.', + 'validate' => 'Validado', + 'info' => 'Info', + 'imported_expenses' => ':count_vendors fornecedor(s) e :count_expenses despesa(s) importadas com sucesso', + 'iframe_url_help3' => 'Nota: se o seu plano aceita detalhes do cartão de crédito recomendamos que tenha HTTPS no seu site.', + 'expense_error_multiple_currencies' => 'As despesas não podem ter diferentes moedas.', + 'expense_error_mismatch_currencies' => 'As configurações de moeda do cliente não coincidem com a moeda nesta despesa.', + 'trello_roadmap' => 'Trello Roadmap', + 'header_footer' => 'Cabeçalho/Rodapé', + 'first_page' => 'primeira página', + 'all_pages' => 'todas as páginas', + 'last_page' => 'última página', + 'all_pages_header' => 'Mostrar cabeçalho ativo', + 'all_pages_footer' => 'Mostrar rodapé ativo', + 'invoice_currency' => 'Moeda da Nota de Pagamento', + 'enable_https' => 'Recomendamos a utilização de HTTPS para receber os detalhes do cartão de crédito online.', + 'quote_issued_to' => 'Orçamento emitido para', + 'show_currency_code' => 'Código da Moeda', + 'trial_message' => 'A sua conta receberá gratuitamente duas semanas para testar nosso plano pro.', + 'trial_footer' => 'Seu período de teste expira a :count dias, :link para adquirir o plano pro.', + 'trial_footer_last_day' => 'O seu período de testes encerra hoje, :link para adquirir o plano pro.', + 'trial_call_to_action' => 'Iniciar período de testes', + 'trial_success' => 'Ativado duas semanas de teste para testar o plano Pro', + 'overdue' => 'Vencido', + + + 'white_label_text' => 'Comprar UM ANO da licença de marca branca por $:price para remover o branding do Invoice Ninja das notas de pagamento e do portal do cliente.', + 'user_email_footer' => 'Para ajustar as suas definições de notificações de e-mail aceda :link', + 'reset_password_footer' => 'Se não solicitou a redefinição da palavra-passe por favor envie um e-mail para o nosso suporte: :email', + 'limit_users' => 'Desculpe, isto irá exceder o limite de :limit utilizadores', + 'more_designs_self_host_header' => 'Obtenha mais 6 modelos de nota de pagamento por apenas $:price', + 'old_browser' => 'Utilize um navegador atualizado', + 'white_label_custom_css' => ':link apenas $:price para permitir um estilo personalizado e apoiar o nosso projecto.', + 'bank_accounts_help' => 'Conecte sua conta bancária para importar suas despesas e criar fornecedores. Suporte ao American Express e 400+ bancos americanos.', + + 'pro_plan_remove_logo' => ':link para remover a logo do Invoice Ninja contratando o plano profissional', + 'pro_plan_remove_logo_link' => 'Clique aqui', + 'invitation_status_sent' => 'Enviado', + 'invitation_status_opened' => 'Aberto', + 'invitation_status_viewed' => 'Visto', + 'email_error_inactive_client' => 'Não é possível enviar e-mails para clientes inativos', + 'email_error_inactive_contact' => 'Não é possível enviar e-mails para contatos inativos', + 'email_error_inactive_invoice' => 'Não é possível enviar e-mails de nota de pagamentos inativas', + 'email_error_user_unregistered' => 'Registe-se para enviar e-mails', + 'email_error_user_unconfirmed' => 'Confirme a sua conta para enviar e-mails', + 'email_error_invalid_contact_email' => 'E-mail do contato inválido', + + 'navigation' => 'Navegação', + 'list_invoices' => 'Listar Notas de Pagamento', + 'list_clients' => 'Listar Clientes', + 'list_quotes' => 'Listar Orçamentos', + 'list_tasks' => 'Listar Tarefas', + 'list_expenses' => 'Listar Despesas', + 'list_recurring_invoices' => 'Listar Notas de Pagamento Recorrentes', + 'list_payments' => 'Listar Pagamentos', + 'list_credits' => 'Listar Créditos', + 'tax_name' => 'Nome do Imposto', + 'report_settings' => 'Configuração de Relatórios', + 'search_hotkey' => 'atalho /', + + 'new_user' => 'Novo Utilizador', + 'new_product' => 'Novo Produto', + 'new_tax_rate' => 'Novo Imposto', + 'invoiced_amount' => 'Total da Nota de Pag.', + 'invoice_item_fields' => 'Campos de itens na Nota de Pagamento', + 'custom_invoice_item_fields_help' => 'Adicionar um campo ao adicionar um item e mostrar a etiqueta e valor no PDF.', + 'recurring_invoice_number' => 'Nº Recorrente', + 'recurring_invoice_number_prefix_help' => 'Indicar um prefixo para a numeração das notas de pagamento recorrentes. O valor padrão é \'R\'.', + + // Client Passwords + 'enable_portal_password' => 'Proteger notas de pag. com palavra-passe', + 'enable_portal_password_help' => 'Permite definir uma palavra-passe para cada contacto. Se uma palavra-passe for definida, o contacto deverá introduzir a palavra-passe antes de visualizar a nota de pagamento.', + 'send_portal_password' => 'Gerar uma Palavra-passe Automaticamente', + 'send_portal_password_help' => 'Se não definir uma palavra-passe, será gerada uma automaticamente e enviada com a primeira nota de pagamento.', + + 'expired' => 'Expirada', + 'invalid_card_number' => 'Cartão de crédito inválido.', + 'invalid_expiry' => 'Data de expiração inválida.', + 'invalid_cvv' => 'O código CVV é inválido.', + 'cost' => 'Custo', + 'create_invoice_for_sample' => 'Nota: crie a primeira nota de pagamento para ver uma pré-visualização aqui.', + + // User Permissions + 'owner' => 'Proprietário', + 'administrator' => 'Administrador', + 'administrator_help' => 'Permite ao utilizador gerir utilizadores, alterar definições e modificar registos.', + 'user_create_all' => 'Criar clientes, notas de pagamento, etc.', + 'user_view_all' => 'Ver todos os clientes, notas de pagamento, etc.', + 'user_edit_all' => 'Editar todos os clientes, notas de pagamento, etc.', + 'gateway_help_20' => ':link para ativar o Sage Pay.', + 'gateway_help_21' => ':link para ativar o Sage Pay.', + 'partial_due' => 'Vencimento Parcial', + 'restore_vendor' => 'Restaurar Fornecedor', + 'restored_vendor' => 'Fornecedor restarurado com sucesso', + 'restored_expense' => 'Despesa restaurada com sucesso', + 'permissions' => 'Permissões', + 'create_all_help' => 'Permite o utilizador criar e alterar registos', + 'view_all_help' => 'Permite o utilizador visualizar registos que ele não criou', + 'edit_all_help' => 'Permite ao utilizador editar registos que ele não criou', + 'view_payment' => 'Ver Pagamento', + + 'january' => 'Janeiro', + 'february' => 'Fevereiro', + 'march' => 'Março', + 'april' => 'Abril', + 'may' => 'Maio', + 'june' => 'Junho', + 'july' => 'Julho', + 'august' => 'Agosto', + 'september' => 'Setembro', + 'october' => 'Outubro', + 'november' => 'Novembro', + 'december' => 'Dezembro', + + // Documents + 'documents_header' => 'Documentos:', + 'email_documents_header' => 'Documentos:', + 'email_documents_example_1' => 'Recibo de Widgets.pdf', + 'email_documents_example_2' => 'Ficheiros Finais.zip', + 'invoice_documents' => 'Documentos', + 'expense_documents' => 'Documentos Anexados', + 'invoice_embed_documents' => 'Documentos Embutidos', + 'invoice_embed_documents_help' => 'Incluir imagens anexadas na nota de pagamento.', + 'document_email_attachment' => 'Anexar Documentos', + 'download_documents' => 'Transferir Documentos (:size)', + 'documents_from_expenses' => 'De despesas:', + 'dropzone_default_message' => 'Arrastar ficheiros para enviar', + 'dropzone_fallback_message' => 'O seu browser não suporta envio de ficheiros através de drag\'n\'drop.', + 'dropzone_fallback_text' => 'Por favor utilize o formulário abaixo para enviar ficheiros em modo de compatibilidade.', + 'dropzone_file_too_big' => 'Ficheiro demasiado grande ({{filesize}}MiB). Tamanho máximo: {{maxFilesize}}MiB.', + 'dropzone_invalid_file_type' => 'Não pode enviar este tipo de ficheiros.', + 'dropzone_response_error' => 'O servidor respondeu com o código {{statusCode}}.', + 'dropzone_cancel_upload' => 'Cancelar envio', + 'dropzone_cancel_upload_confirmation' => 'Tem a certeza que pretende cancelar este envio?', + 'dropzone_remove_file' => 'Remover ficheiro', + 'documents' => 'Documentos', + 'document_date' => 'Data do Documento', + 'document_size' => 'Tamanho', + + 'enable_client_portal' => 'Portal do Cliente', + 'enable_client_portal_help' => 'Mostrar/ocultar o portal do cliente.', + 'enable_client_portal_dashboard' => 'Dashboard', + 'enable_client_portal_dashboard_help' => 'Mostrar/ocultar o dashboard no portal do cliente.', + + // Plans + 'account_management' => 'Gerir Conta', + 'plan_status' => 'Estado do Plano', + + 'plan_upgrade' => 'Upgrade', + 'plan_change' => 'Alterar Plano', + 'pending_change_to' => 'Altera Para', + 'plan_changes_to' => ':plan em :date', + 'plan_term_changes_to' => ':plan (:term) em :date', + 'cancel_plan_change' => 'Cancelar Alteração', + 'plan' => 'Plano', + 'expires' => 'Expira', + 'renews' => 'Renovação', + 'plan_expired' => ':plan Plano Expirado', + 'trial_expired' => ':plan Plano de Avaliação Terminou', + 'never' => 'Nunca', + 'plan_free' => 'Gratuíto', + 'plan_pro' => 'Pro', + 'plan_enterprise' => 'Enterprise', + 'plan_white_label' => 'Self Hosted (Marca Branca)', + 'plan_free_self_hosted' => 'Self Hosted (Gratuíto)', + 'plan_trial' => 'Avaliação', + 'plan_term' => 'Período', + 'plan_term_monthly' => 'Mensal', + 'plan_term_yearly' => 'Anual', + 'plan_term_month' => 'Mês', + 'plan_term_year' => 'Ano', + 'plan_price_monthly' => '$:price/Mês', + 'plan_price_yearly' => '$:price/Ano', + 'updated_plan' => 'Definições do plano atualizadas', + 'plan_paid' => 'Iniciou o período', + 'plan_started' => 'Iniciou o Plano', + 'plan_expires' => 'Plano Expira', + + 'white_label_button' => 'Marca Branca', + + 'pro_plan_year_description' => 'Subscrição de um ano no plano Invoice Ninja Pro.', + 'pro_plan_month_description' => 'Um mês de subscrição no plano Invoice Ninja Pro.', + 'enterprise_plan_product' => 'Plano Enterprise', + 'enterprise_plan_year_description' => 'Um ano de subscrição no plano Invoice Ninja Enterprise.', + 'enterprise_plan_month_description' => 'Um mês de subscrição no plano Invoice Ninja Enterprise.', + 'plan_credit_product' => 'Crédito', + 'plan_credit_description' => 'Crédito pelo tempo não utilizado', + 'plan_pending_monthly' => 'Vamos alterar para mensal em :date', + 'plan_refunded' => 'Foi realizada um reembolso.', + + 'live_preview' => 'Pré-visualização', + 'page_size' => 'Tamanho da Página', + 'live_preview_disabled' => 'Pré-visualização em tempo real desactivada para suportar o tipo de letra selecionado', + 'invoice_number_padding' => 'Padding', + 'preview' => 'Pré-visualizar', + 'list_vendors' => 'Listar Fornecedores', + 'add_users_not_supported' => 'Altere para o plano Enterprise para adicionar utilizadores adicionais à sua conta.', + 'enterprise_plan_features' => 'O plano Enterprise adiciona suporte a multiplos utilizadores e anexos de ficheiros, :link para ver a lista completa de funcionalidades.', + 'return_to_app' => 'Voltar à app', + + + // Payment updates + 'refund_payment' => 'Reembolsar Pagamento', + 'refund_max' => 'Máx:', + 'refund' => 'Reembolsar', + 'are_you_sure_refund' => 'Reembolsar os pagamentos selecionados?', + 'status_pending' => 'Pendente', + 'status_completed' => 'Completo', + 'status_failed' => 'Falhou', + 'status_partially_refunded' => 'Parcialmente Reembolsado', + 'status_partially_refunded_amount' => ':amount Reembolsado', + 'status_refunded' => 'Reembolsado', + 'status_voided' => 'Cancelado', + 'refunded_payment' => 'Pagamento Reembolsado', + 'activity_39' => ':user cancelou um pagamento de :payment_amount (:payment)', + 'activity_40' => ':user reembolsou :adjustment de um pagamento de :payment_amount (:payment)', + 'card_expiration' => 'Venc: :expira', + + 'card_creditcardother' => 'Desconhecido', + 'card_americanexpress' => 'American Express', + 'card_carteblanche' => 'Carte Blanche', + 'card_unionpay' => 'UnionPay', + 'card_diners' => 'Diners Club', + 'card_discover' => 'Discover', + 'card_jcb' => 'JCB', + 'card_laser' => 'Laser', + 'card_maestro' => 'Maestro', + 'card_mastercard' => 'MasterCard', + 'card_solo' => 'Solo', + 'card_switch' => 'Switch', + 'card_visacard' => 'Visa', + 'card_ach' => 'ACH', + + 'payment_type_stripe' => 'Stripe', + 'ach' => 'ACH', + 'enable_ach' => 'Ativar ACH', + 'stripe_ach_help' => 'ACH também deverá estar ativo no Stripe.', + 'ach_disabled' => 'Existe outro gateway configurado para débito direto.', + + 'plaid' => 'Plaid', + 'client_id' => 'Client Id', + 'secret' => 'Secret', + 'public_key' => 'Public Key', + 'plaid_optional' => '(opcional)', + 'plaid_environment_help' => 'Quando uma chave de teste do Stripe é introduzida, um ambiente de desenvolvimento (tartan) do Plaid será utilizado.', + 'other_providers' => 'Outros Provedores', + 'country_not_supported' => 'Este país não é suportado.', + 'invalid_routing_number' => 'O número de roteamento é inválido.', + 'invalid_account_number' => 'O número da conta e inválido.', + 'account_number_mismatch' => 'Os números da conta não correspondem.', + 'missing_account_holder_type' => 'Por favor, selecione uma conta ou empresa individual.', + 'missing_account_holder_name' => 'Por favor, indique o nome do proprietário da conta.', + 'routing_number' => 'Número de Roteamento', + 'confirm_account_number' => 'Confirmar Número de Conta', + 'individual_account' => 'Conta Individual', + 'company_account' => 'Conta Empresarial', + 'account_holder_name' => 'Nome do Proprietário da Conta', + 'add_account' => 'Adicionar Conta', + 'payment_methods' => 'Métodos de Pagamento', + 'complete_verification' => 'Verificação Completa', + 'verification_amount1' => 'Valor 1', + 'verification_amount2' => 'Valor 2', + 'payment_method_verified' => 'Verificação concluída com sucesso', + 'verification_failed' => 'Verificação falhou', + 'remove_payment_method' => 'Remover método de Pagamento', + 'confirm_remove_payment_method' => 'Deseja remover este método de pagamento?', + 'remove' => 'Remover', + 'payment_method_removed' => 'Método de pagamento removido.', + 'bank_account_verification_help' => 'Fizemos dois depósitos na sua conta com a descrição "VERIFICATION". Estes depósitos podem levar 1-2 dias úteis para aparecer no seu extracto. Por favor indique os valores de cada um deles abaixo.', + 'bank_account_verification_next_steps' => 'Fizemos dois depósitos na sua conta com a descrição "VERIFICATION". Estes depósitos podem levar 1-2 dias úteis para aparecer no seu extracto. +Quando tiver os valores dos depósitos, volte a esta página e conclua a verificação da sua conta.', + 'unknown_bank' => 'Banco Desconhecido', + 'ach_verification_delay_help' => 'Poderá utilizar esta conta após a concluir a verificação. A verificação normalmente leva 1 a 2 dias.', + 'add_credit_card' => 'Adicionar Cartão de Crédito', + 'payment_method_added' => 'Método de pagamento adicionado.', + 'use_for_auto_bill' => 'Usar para Pagamentos Automáticos', + 'used_for_auto_bill' => 'Método de Pagamentos Automáticos', + 'payment_method_set_as_default' => 'Definir método de pagamento automático.', + 'activity_41' => 'pagamento (:payment) de :payment_amount falhou', + 'webhook_url' => 'Webhook URL', + 'stripe_webhook_help' => 'Deverá :link.', + 'stripe_webhook_help_link_text' => 'adicionar este URL como um endpoint no Stripe', + 'payment_method_error' => 'Houve um erro ao adicionar seu método de pagamento. Tente novamente.', + 'notification_invoice_payment_failed_subject' => 'Pagamento falhou para a Nota de Pagamento :invoice', + 'notification_invoice_payment_failed' => 'O pagamento feito pelo Cliente :client para a Nota de Pagamento :invoice falhou. O pagamento foi marcado como "falhado" e foram adicionados :amount ao saldo do cliente.', + 'link_with_plaid' => 'Ligar Automaticamente Conta com o Plaid', + 'link_manually' => 'Ligar Manualmente', + 'secured_by_plaid' => 'Assegurado por Plaid', + 'plaid_linked_status' => 'Conta bancária no :bank', + 'add_payment_method' => 'Adicionar Método de Pagamento', + 'account_holder_type' => 'Tipo do Proprietário da Conta', + 'ach_authorization' => 'Eu autorizo a :company a utilizar a minha conta bancária para pagamentos futuros e, se necessário, creditar electronicamente a minha conta para corrigir débitos incorrectos. Compreendo que posso revogar esta autorização a qualquer altura removendo o método de pagamento ou contactando :email.', + 'ach_authorization_required' => 'Deverá permitir transacções ACH.', + 'off' => 'Off', + 'opt_in' => 'Aceitar (Opt-in)', + 'opt_out' => 'Negar (Opt-out)', + 'always' => 'Sempre', + 'opted_out' => 'Negou (Opted out)', + 'opted_in' => 'Aceitou (Opted in)', + 'manage_auto_bill' => 'Gerir Pagamentos Automáticos', + 'enabled' => 'Ativo', + 'paypal' => 'PayPal', + 'braintree_enable_paypal' => 'Ativar pagamentos PayPal através do BrainTree', + 'braintree_paypal_disabled_help' => 'O gateway PayPal está a processar pagamentos PayPal', + 'braintree_paypal_help' => 'Deverá também :link.', + 'braintree_paypal_help_link_text' => 'ligar o PayPal à sua conta BrainTree', + 'token_billing_braintree_paypal' => 'Guardar detalhes do pagamento', + 'add_paypal_account' => 'Adicionar Conta PayPal', + + + 'no_payment_method_specified' => 'Nenhum método de pagamento definido', + 'chart_type' => 'Tipo de Gráfico', + 'format' => 'Formato', + 'import_ofx' => 'Importar OFX', + 'ofx_file' => 'Ficheiro OFX', + 'ofx_parse_failed' => 'Falha ao ler o ficheiro OFX', + + // WePay + 'wepay' => 'WePay', + 'sign_up_with_wepay' => 'Registar com WePay', + 'use_another_provider' => 'Utilizar outro provedor', + 'company_name' => 'Nome da Empresa', + 'wepay_company_name_help' => 'Isto irá aparecer no registo do cartão de crédito do cliente.', + 'wepay_description_help' => 'O objetivo desta conta.', + 'wepay_tos_agree' => 'Concordo com :link.', + 'wepay_tos_link_text' => 'Termos de Serviço do WePay', + 'resend_confirmation_email' => 'Reenviar Email de Confirmação', + 'manage_account' => 'Gerir Conta', + 'action_required' => 'Acção Necessária', + 'finish_setup' => 'Terminar Configuração', + 'created_wepay_confirmation_required' => 'Por favor verifique o seu email e confirme o seu email com o WePay', + 'switch_to_wepay' => 'Alterar para WePay', + 'switch' => 'Alterar', + 'restore_account_gateway' => 'Restaurar Gateway', + 'restored_account_gateway' => 'Gateway restaurado com sucesso', + 'united_states' => 'Estados Unidos', + 'canada' => 'Canadá', + 'accept_debit_cards' => 'Aceitar Cartão de Débito', + 'debit_cards' => 'Cartões de Débito', + + 'warn_start_date_changed' => 'A próxima nota de pagamento será enviada na nova data de início.', + 'original_start_date' => 'Data de início original', + 'new_start_date' => 'Nova data de início', + 'security' => 'Segurança', + 'see_whats_new' => 'Veja as novidades na versão v:version', + 'wait_for_upload' => 'Por favor aguardo pela conclusão do envio do documento', + 'upgrade_for_permissions' => 'Atualize para o nosso plano Enterprise para ativar as permissões.', + 'enable_second_tax_rate' => 'Permitir indicar um segundo imposto', + 'payment_file' => 'Ficheiro de Pagamento', + 'expense_file' => 'Ficheiro de Despesas', + 'product_file' => 'Ficheiro de Produtos', + 'import_products' => 'Produtos Importados', + 'products_will_create' => 'produtos serão criados', + 'product_key' => 'Produto', + 'created_products' => ':count produto(s) criados com sucesso', + 'export_help' => 'Utilize JSON se planear importar os dados para o Invoice Ninja.
O ficheiro incluí clientes, produtos, notas de pagamento, orçamentos e pagamentos.', + 'JSON_file' => 'Ficheiro JSON', + + 'view_dashboard' => 'Ver Dashboard', + 'client_session_expired' => 'Sessão Expirada', + 'client_session_expired_message' => 'A sua sessão expirou. Por favor abra novamente o link no seu email.', + + 'auto_bill_notification' => 'Esta nota de pagamento será paga automaticamente a :due_data através do seu :payment_method.', + 'auto_bill_payment_method_bank_transfer' => 'conta bancária', + 'auto_bill_payment_method_credit_card' => 'cartão de crédito', + 'auto_bill_payment_method_paypal' => 'conta PayPal', + 'auto_bill_notification_placeholder' => 'Esta nota de pagamento será paga automaticamente no seu cartão de crédito na data de vencimento.', + 'payment_settings' => 'Definições de Pagamento', + + 'on_send_date' => 'Na data de envio', + 'on_due_date' => 'Na data de vencimento', + 'auto_bill_ach_date_help' => 'ACH fará sempre o pagamento automático na data de vencimento.', + 'warn_change_auto_bill' => 'Devido às regras NACHA, alterações a esta nota de pagamento podem evitar pagamentos automáticos ACH.', + + 'bank_account' => 'Conta Bancária', + 'payment_processed_through_wepay' => 'Pagamentos ACH serão processados utilizando WePay.', + 'wepay_payment_tos_agree' => 'Eu concordo com :terms e :privacy_policy da WePay.', + 'privacy_policy' => 'Política de Privacidade', + 'wepay_payment_tos_agree_required' => 'Deve concordar com os Termos de Serviço e Política de Privacidade da WePay.', + 'ach_email_prompt' => 'Por favor indique o seu email:', + 'verification_pending' => 'Verificação Pendente', + + 'update_font_cache' => 'Por favor force a actualização da página para actualizar a cache dos tipos de letra.', + 'more_options' => 'Mais opções', + 'credit_card' => 'Cartão de Crédito', + 'bank_transfer' => 'Transferência Bancária', + 'no_transaction_reference' => 'Não recebemos do gateway nenhuma referência a um pagamento.', + 'use_bank_on_file' => 'Utilizar o Banco no Ficheiro', + 'auto_bill_email_message' => 'Esta nota de pagamento será paga automaticamente na data de vencimento através do método de pagamento.', + 'bitcoin' => 'Bitcoin', + 'added_on' => 'Adicionado em :date', + 'failed_remove_payment_method' => 'Erro ao remover o método de pagamento', + 'gateway_exists' => 'Este gateway já existe', + 'manual_entry' => 'Introdução manual', + 'start_of_week' => 'Primeiro Dia da Semana', + + // Frequencies + 'freq_weekly' => 'Semanal', + 'freq_two_weeks' => '2 semanas', + 'freq_four_weeks' => '4 semanas', + 'freq_monthly' => 'Mensal', + 'freq_three_months' => 'Trimestral', + 'freq_six_months' => 'Semestral', + 'freq_annually' => 'Anual', + + // Payment types + 'payment_type_Apply Credit' => 'Aplicar Crédito', + 'payment_type_Bank Transfer' => 'Transferência Bancária', + 'payment_type_Cash' => 'Dinheiro', + 'payment_type_Debit' => 'Débito', + 'payment_type_ACH' => 'ACH', + 'payment_type_Visa Card' => 'Cartão Visa', + 'payment_type_MasterCard' => 'MasterCard', + 'payment_type_American Express' => 'American Express', + 'payment_type_Discover Card' => 'Discover Card', + 'payment_type_Diners Card' => 'Diners Card', + 'payment_type_EuroCard' => 'EuroCard', + 'payment_type_Nova' => 'Nova', + 'payment_type_Credit Card Other' => 'Outro Cartão de Crédito', + 'payment_type_PayPal' => 'PayPal', + 'payment_type_Google Wallet' => 'Google Wallet', + 'payment_type_Check' => 'Cheque', + 'payment_type_Carte Blanche' => 'Carte Blanche', + 'payment_type_UnionPay' => 'UnionPay', + 'payment_type_JCB' => 'JCB', + 'payment_type_Laser' => 'Laser', + 'payment_type_Maestro' => 'Maestro', + 'payment_type_Solo' => 'Solo', + 'payment_type_Switch' => 'Switch', + 'payment_type_iZettle' => 'iZettle', + 'payment_type_Swish' => 'Swish', + + // Industries + 'industry_Accounting & Legal' => 'Contabilidade & Legislação', + 'industry_Advertising' => 'Publicidade', + 'industry_Aerospace' => 'Aeronáutica', + 'industry_Agriculture' => 'Agricultura', + 'industry_Automotive' => 'Automóveis', + 'industry_Banking & Finance' => 'Banca & Finanças', + 'industry_Biotechnology' => 'Biotecnologia', + 'industry_Broadcasting' => 'Radiodifusão', + 'industry_Business Services' => 'Serviços Empresariais', + 'industry_Commodities & Chemicals' => 'Produtos Químicos', + 'industry_Communications' => 'Comunicações', + 'industry_Computers & Hightech' => 'Computadores e Tecnologia', + 'industry_Defense' => 'Defesa', + 'industry_Energy' => 'Energia', + 'industry_Entertainment' => 'Entretenimento', + 'industry_Government' => 'Governo', + 'industry_Healthcare & Life Sciences' => 'Saúde & Ciências da Vida', + 'industry_Insurance' => 'Seguros', + 'industry_Manufacturing' => 'Indústria', + 'industry_Marketing' => 'Marketing', + 'industry_Media' => 'Media', + 'industry_Nonprofit & Higher Ed' => 'Sem Finds Lucrativos & Educação', + 'industry_Pharmaceuticals' => 'Fermacêutica', + 'industry_Professional Services & Consulting' => 'Serviços Profissionais & Consultoria', + 'industry_Real Estate' => 'Imobiliária', + 'industry_Retail & Wholesale' => 'Retalho & Armazenistas', + 'industry_Sports' => 'Desporto', + 'industry_Transportation' => 'Transporte', + 'industry_Travel & Luxury' => 'Viagens & Luxo', + 'industry_Other' => 'Outros', + 'industry_Photography' => 'Fotografia', + + // Countries + 'country_Afghanistan' => 'Afghanistan', + 'country_Albania' => 'Albania', + 'country_Antarctica' => 'Antarctica', + 'country_Algeria' => 'Algeria', + 'country_American Samoa' => 'American Samoa', + 'country_Andorra' => 'Andorra', + 'country_Angola' => 'Angola', + 'country_Antigua and Barbuda' => 'Antigua and Barbuda', + 'country_Azerbaijan' => 'Azerbaijan', + 'country_Argentina' => 'Argentina', + 'country_Australia' => 'Australia', + 'country_Austria' => 'Austria', + 'country_Bahamas' => 'Bahamas', + 'country_Bahrain' => 'Bahrain', + 'country_Bangladesh' => 'Bangladesh', + 'country_Armenia' => 'Armenia', + 'country_Barbados' => 'Barbados', + 'country_Belgium' => 'Belgium', + 'country_Bermuda' => 'Bermuda', + 'country_Bhutan' => 'Bhutan', + 'country_Bolivia, Plurinational State of' => 'Bolivia, Plurinational State of', + 'country_Bosnia and Herzegovina' => 'Bosnia and Herzegovina', + 'country_Botswana' => 'Botswana', + 'country_Bouvet Island' => 'Bouvet Island', + 'country_Brazil' => 'Brazil', + 'country_Belize' => 'Belize', + 'country_British Indian Ocean Territory' => 'British Indian Ocean Territory', + 'country_Solomon Islands' => 'Solomon Islands', + 'country_Virgin Islands, British' => 'Virgin Islands, British', + 'country_Brunei Darussalam' => 'Brunei Darussalam', + 'country_Bulgaria' => 'Bulgaria', + 'country_Myanmar' => 'Myanmar', + 'country_Burundi' => 'Burundi', + 'country_Belarus' => 'Belarus', + 'country_Cambodia' => 'Cambodia', + 'country_Cameroon' => 'Cameroon', + 'country_Canada' => 'Canada', + 'country_Cape Verde' => 'Cape Verde', + 'country_Cayman Islands' => 'Cayman Islands', + 'country_Central African Republic' => 'Central African Republic', + 'country_Sri Lanka' => 'Sri Lanka', + 'country_Chad' => 'Chad', + 'country_Chile' => 'Chile', + 'country_China' => 'China', + 'country_Taiwan, Province of China' => 'Taiwan, Province of China', + 'country_Christmas Island' => 'Christmas Island', + 'country_Cocos (Keeling) Islands' => 'Cocos (Keeling) Islands', + 'country_Colombia' => 'Colombia', + 'country_Comoros' => 'Comoros', + 'country_Mayotte' => 'Mayotte', + 'country_Congo' => 'Congo', + 'country_Congo, the Democratic Republic of the' => 'Congo, the Democratic Republic of the', + 'country_Cook Islands' => 'Cook Islands', + 'country_Costa Rica' => 'Costa Rica', + 'country_Croatia' => 'Croatia', + 'country_Cuba' => 'Cuba', + 'country_Cyprus' => 'Cyprus', + 'country_Czech Republic' => 'Czech Republic', + 'country_Benin' => 'Benin', + 'country_Denmark' => 'Denmark', + 'country_Dominica' => 'Dominica', + 'country_Dominican Republic' => 'Dominican Republic', + 'country_Ecuador' => 'Ecuador', + 'country_El Salvador' => 'El Salvador', + 'country_Equatorial Guinea' => 'Equatorial Guinea', + 'country_Ethiopia' => 'Ethiopia', + 'country_Eritrea' => 'Eritrea', + 'country_Estonia' => 'Estonia', + 'country_Faroe Islands' => 'Faroe Islands', + 'country_Falkland Islands (Malvinas)' => 'Falkland Islands (Malvinas)', + 'country_South Georgia and the South Sandwich Islands' => 'South Georgia and the South Sandwich Islands', + 'country_Fiji' => 'Fiji', + 'country_Finland' => 'Finland', + 'country_Åland Islands' => 'Åland Islands', + 'country_France' => 'France', + 'country_French Guiana' => 'French Guiana', + 'country_French Polynesia' => 'French Polynesia', + 'country_French Southern Territories' => 'French Southern Territories', + 'country_Djibouti' => 'Djibouti', + 'country_Gabon' => 'Gabon', + 'country_Georgia' => 'Georgia', + 'country_Gambia' => 'Gambia', + 'country_Palestinian Territory, Occupied' => 'Palestinian Territory, Occupied', + 'country_Germany' => 'Germany', + 'country_Ghana' => 'Ghana', + 'country_Gibraltar' => 'Gibraltar', + 'country_Kiribati' => 'Kiribati', + 'country_Greece' => 'Greece', + 'country_Greenland' => 'Greenland', + 'country_Grenada' => 'Grenada', + 'country_Guadeloupe' => 'Guadeloupe', + 'country_Guam' => 'Guam', + 'country_Guatemala' => 'Guatemala', + 'country_Guinea' => 'Guinea', + 'country_Guyana' => 'Guyana', + 'country_Haiti' => 'Haiti', + 'country_Heard Island and McDonald Islands' => 'Heard Island and McDonald Islands', + 'country_Holy See (Vatican City State)' => 'Holy See (Vatican City State)', + 'country_Honduras' => 'Honduras', + 'country_Hong Kong' => 'Hong Kong', + 'country_Hungary' => 'Hungary', + 'country_Iceland' => 'Iceland', + 'country_India' => 'India', + 'country_Indonesia' => 'Indonesia', + 'country_Iran, Islamic Republic of' => 'Iran, Islamic Republic of', + 'country_Iraq' => 'Iraq', + 'country_Ireland' => 'Ireland', + 'country_Israel' => 'Israel', + 'country_Italy' => 'Italy', + 'country_Côte d\'Ivoire' => 'Côte d\'Ivoire', + 'country_Jamaica' => 'Jamaica', + 'country_Japan' => 'Japan', + 'country_Kazakhstan' => 'Kazakhstan', + 'country_Jordan' => 'Jordan', + 'country_Kenya' => 'Kenya', + 'country_Korea, Democratic People\'s Republic of' => 'Korea, Democratic People\'s Republic of', + 'country_Korea, Republic of' => 'Korea, Republic of', + 'country_Kuwait' => 'Kuwait', + 'country_Kyrgyzstan' => 'Kyrgyzstan', + 'country_Lao People\'s Democratic Republic' => 'Lao People\'s Democratic Republic', + 'country_Lebanon' => 'Lebanon', + 'country_Lesotho' => 'Lesotho', + 'country_Latvia' => 'Latvia', + 'country_Liberia' => 'Liberia', + 'country_Libya' => 'Libya', + 'country_Liechtenstein' => 'Liechtenstein', + 'country_Lithuania' => 'Lithuania', + 'country_Luxembourg' => 'Luxembourg', + 'country_Macao' => 'Macao', + 'country_Madagascar' => 'Madagascar', + 'country_Malawi' => 'Malawi', + 'country_Malaysia' => 'Malaysia', + 'country_Maldives' => 'Maldives', + 'country_Mali' => 'Mali', + 'country_Malta' => 'Malta', + 'country_Martinique' => 'Martinique', + 'country_Mauritania' => 'Mauritania', + 'country_Mauritius' => 'Mauritius', + 'country_Mexico' => 'Mexico', + 'country_Monaco' => 'Monaco', + 'country_Mongolia' => 'Mongolia', + 'country_Moldova, Republic of' => 'Moldova, Republic of', + 'country_Montenegro' => 'Montenegro', + 'country_Montserrat' => 'Montserrat', + 'country_Morocco' => 'Morocco', + 'country_Mozambique' => 'Mozambique', + 'country_Oman' => 'Oman', + 'country_Namibia' => 'Namibia', + 'country_Nauru' => 'Nauru', + 'country_Nepal' => 'Nepal', + 'country_Netherlands' => 'Netherlands', + 'country_Curaçao' => 'Curaçao', + 'country_Aruba' => 'Aruba', + 'country_Sint Maarten (Dutch part)' => 'Sint Maarten (Dutch part)', + 'country_Bonaire, Sint Eustatius and Saba' => 'Bonaire, Sint Eustatius and Saba', + 'country_New Caledonia' => 'New Caledonia', + 'country_Vanuatu' => 'Vanuatu', + 'country_New Zealand' => 'New Zealand', + 'country_Nicaragua' => 'Nicaragua', + 'country_Niger' => 'Niger', + 'country_Nigeria' => 'Nigeria', + 'country_Niue' => 'Niue', + 'country_Norfolk Island' => 'Norfolk Island', + 'country_Norway' => 'Norway', + 'country_Northern Mariana Islands' => 'Northern Mariana Islands', + 'country_United States Minor Outlying Islands' => 'United States Minor Outlying Islands', + 'country_Micronesia, Federated States of' => 'Micronesia, Federated States of', + 'country_Marshall Islands' => 'Marshall Islands', + 'country_Palau' => 'Palau', + 'country_Pakistan' => 'Pakistan', + 'country_Panama' => 'Panama', + 'country_Papua New Guinea' => 'Papua New Guinea', + 'country_Paraguay' => 'Paraguay', + 'country_Peru' => 'Peru', + 'country_Philippines' => 'Philippines', + 'country_Pitcairn' => 'Pitcairn', + 'country_Poland' => 'Poland', + 'country_Portugal' => 'Portugal', + 'country_Guinea-Bissau' => 'Guinea-Bissau', + 'country_Timor-Leste' => 'Timor-Leste', + 'country_Puerto Rico' => 'Puerto Rico', + 'country_Qatar' => 'Qatar', + 'country_Réunion' => 'Réunion', + 'country_Romania' => 'Romania', + 'country_Russian Federation' => 'Russian Federation', + 'country_Rwanda' => 'Rwanda', + 'country_Saint Barthélemy' => 'Saint Barthélemy', + 'country_Saint Helena, Ascension and Tristan da Cunha' => 'Saint Helena, Ascension and Tristan da Cunha', + 'country_Saint Kitts and Nevis' => 'Saint Kitts and Nevis', + 'country_Anguilla' => 'Anguilla', + 'country_Saint Lucia' => 'Saint Lucia', + 'country_Saint Martin (French part)' => 'Saint Martin (French part)', + 'country_Saint Pierre and Miquelon' => 'Saint Pierre and Miquelon', + 'country_Saint Vincent and the Grenadines' => 'Saint Vincent and the Grenadines', + 'country_San Marino' => 'San Marino', + 'country_Sao Tome and Principe' => 'Sao Tome and Principe', + 'country_Saudi Arabia' => 'Saudi Arabia', + 'country_Senegal' => 'Senegal', + 'country_Serbia' => 'Serbia', + 'country_Seychelles' => 'Seychelles', + 'country_Sierra Leone' => 'Sierra Leone', + 'country_Singapore' => 'Singapore', + 'country_Slovakia' => 'Slovakia', + 'country_Viet Nam' => 'Viet Nam', + 'country_Slovenia' => 'Slovenia', + 'country_Somalia' => 'Somalia', + 'country_South Africa' => 'South Africa', + 'country_Zimbabwe' => 'Zimbabwe', + 'country_Spain' => 'Spain', + 'country_South Sudan' => 'South Sudan', + 'country_Sudan' => 'Sudan', + 'country_Western Sahara' => 'Western Sahara', + 'country_Suriname' => 'Suriname', + 'country_Svalbard and Jan Mayen' => 'Svalbard and Jan Mayen', + 'country_Swaziland' => 'Swaziland', + 'country_Sweden' => 'Sweden', + 'country_Switzerland' => 'Switzerland', + 'country_Syrian Arab Republic' => 'Syrian Arab Republic', + 'country_Tajikistan' => 'Tajikistan', + 'country_Thailand' => 'Thailand', + 'country_Togo' => 'Togo', + 'country_Tokelau' => 'Tokelau', + 'country_Tonga' => 'Tonga', + 'country_Trinidad and Tobago' => 'Trinidad and Tobago', + 'country_United Arab Emirates' => 'United Arab Emirates', + 'country_Tunisia' => 'Tunisia', + 'country_Turkey' => 'Turkey', + 'country_Turkmenistan' => 'Turkmenistan', + 'country_Turks and Caicos Islands' => 'Turks and Caicos Islands', + 'country_Tuvalu' => 'Tuvalu', + 'country_Uganda' => 'Uganda', + 'country_Ukraine' => 'Ukraine', + 'country_Macedonia, the former Yugoslav Republic of' => 'Macedonia, the former Yugoslav Republic of', + 'country_Egypt' => 'Egypt', + 'country_United Kingdom' => 'United Kingdom', + 'country_Guernsey' => 'Guernsey', + 'country_Jersey' => 'Jersey', + 'country_Isle of Man' => 'Isle of Man', + 'country_Tanzania, United Republic of' => 'Tanzania, United Republic of', + 'country_United States' => 'United States', + 'country_Virgin Islands, U.S.' => 'Virgin Islands, U.S.', + 'country_Burkina Faso' => 'Burkina Faso', + 'country_Uruguay' => 'Uruguay', + 'country_Uzbekistan' => 'Uzbekistan', + 'country_Venezuela, Bolivarian Republic of' => 'Venezuela, Bolivarian Republic of', + 'country_Wallis and Futuna' => 'Wallis and Futuna', + 'country_Samoa' => 'Samoa', + 'country_Yemen' => 'Yemen', + 'country_Zambi' => 'Zambi', + + // Languages + 'lang_Brazilian Portuguese' => 'Português do Brasil', + 'lang_Croatian' => 'Croatian', + 'lang_Czech' => 'Czech', + 'lang_Danish' => 'Danish', + 'lang_Dutch' => 'Dutch', + 'lang_English' => 'English', + 'lang_French' => 'French', + 'lang_French - Canada' => 'French - Canada', + 'lang_German' => 'German', + 'lang_Italian' => 'Italian', + 'lang_Japanese' => 'Japanese', + 'lang_Lithuanian' => 'Lithuanian', + 'lang_Norwegian' => 'Norwegian', + 'lang_Polish' => 'Polish', + 'lang_Spanish' => 'Spanish', + 'lang_Spanish - Spain' => 'Spanish - Spain', + 'lang_Swedish' => 'Swedish', + 'lang_Albanian' => 'Albanian', + 'lang_English - United Kingdom' => 'English - United Kingdom', + + // Frequencies + 'freq_weekly' => 'Semanal', + 'freq_two_weeks' => '2 semanas', + 'freq_four_weeks' => '4 semanas', + 'freq_monthly' => 'Mensal', + 'freq_three_months' => 'Trimestral', + 'freq_six_months' => 'Semestral', + 'freq_annually' => 'Anual', + + // Payment types + 'payment_type_Apply Credit' => 'Aplicar Crédito', + 'payment_type_Bank Transfer' => 'Transferência Bancária', + 'payment_type_Cash' => 'Dinheiro', + 'payment_type_Debit' => 'Débito', + 'payment_type_ACH' => 'ACH', + 'payment_type_Visa Card' => 'Cartão Visa', + 'payment_type_MasterCard' => 'MasterCard', + 'payment_type_American Express' => 'American Express', + 'payment_type_Discover Card' => 'Discover Card', + 'payment_type_Diners Card' => 'Diners Card', + 'payment_type_EuroCard' => 'EuroCard', + 'payment_type_Nova' => 'Nova', + 'payment_type_Credit Card Other' => 'Outro Cartão de Crédito', + 'payment_type_PayPal' => 'PayPal', + 'payment_type_Google Wallet' => 'Google Wallet', + 'payment_type_Check' => 'Cheque', + + // Industries + 'industry_Accounting & Legal' => 'Contabilidade & Legislação', + 'industry_Advertising' => 'Publicidade', + 'industry_Aerospace' => 'Aeronáutica', + 'industry_Agriculture' => 'Agricultura', + 'industry_Automotive' => 'Automóveis', + 'industry_Banking & Finance' => 'Banca & Finanças', + 'industry_Biotechnology' => 'Biotecnologia', + 'industry_Broadcasting' => 'Radiodifusão', + 'industry_Business Services' => 'Serviços Empresariais', + 'industry_Commodities & Chemicals' => 'Produtos Químicos', + 'industry_Communications' => 'Comunicações', + 'industry_Computers & Hightech' => 'Computadores e Tecnologia', + 'industry_Defense' => 'Defesa', + 'industry_Energy' => 'Energia', + 'industry_Entertainment' => 'Entretenimento', + 'industry_Government' => 'Governo', + 'industry_Healthcare & Life Sciences' => 'Saúde & Ciências da Vida', + 'industry_Insurance' => 'Seguros', + 'industry_Manufacturing' => 'Indústria', + 'industry_Marketing' => 'Marketing', + 'industry_Media' => 'Media', + 'industry_Nonprofit & Higher Ed' => 'Sem Finds Lucrativos & Educação', + 'industry_Pharmaceuticals' => 'Fermacêutica', + 'industry_Professional Services & Consulting' => 'Serviços Profissionais & Consultoria', + 'industry_Real Estate' => 'Imobiliária', + 'industry_Retail & Wholesale' => 'Retalho & Armazenistas', + 'industry_Sports' => 'Desporto', + 'industry_Transportation' => 'Transporte', + 'industry_Travel & Luxury' => 'Viagens & Luxo', + 'industry_Other' => 'Outros', + 'industry_Photography' =>'Fotografia', + + // Countries + 'country_Afghanistan' => 'Afghanistan', + 'country_Albania' => 'Albania', + 'country_Antarctica' => 'Antarctica', + 'country_Algeria' => 'Algeria', + 'country_American Samoa' => 'American Samoa', + 'country_Andorra' => 'Andorra', + 'country_Angola' => 'Angola', + 'country_Antigua and Barbuda' => 'Antigua and Barbuda', + 'country_Azerbaijan' => 'Azerbaijan', + 'country_Argentina' => 'Argentina', + 'country_Australia' => 'Australia', + 'country_Austria' => 'Austria', + 'country_Bahamas' => 'Bahamas', + 'country_Bahrain' => 'Bahrain', + 'country_Bangladesh' => 'Bangladesh', + 'country_Armenia' => 'Armenia', + 'country_Barbados' => 'Barbados', + 'country_Belgium' => 'Belgium', + 'country_Bermuda' => 'Bermuda', + 'country_Bhutan' => 'Bhutan', + 'country_Bolivia, Plurinational State of' => 'Bolivia, Plurinational State of', + 'country_Bosnia and Herzegovina' => 'Bosnia and Herzegovina', + 'country_Botswana' => 'Botswana', + 'country_Bouvet Island' => 'Bouvet Island', + 'country_Brazil' => 'Brazil', + 'country_Belize' => 'Belize', + 'country_British Indian Ocean Territory' => 'British Indian Ocean Territory', + 'country_Solomon Islands' => 'Solomon Islands', + 'country_Virgin Islands, British' => 'Virgin Islands, British', + 'country_Brunei Darussalam' => 'Brunei Darussalam', + 'country_Bulgaria' => 'Bulgaria', + 'country_Myanmar' => 'Myanmar', + 'country_Burundi' => 'Burundi', + 'country_Belarus' => 'Belarus', + 'country_Cambodia' => 'Cambodia', + 'country_Cameroon' => 'Cameroon', + 'country_Canada' => 'Canada', + 'country_Cape Verde' => 'Cape Verde', + 'country_Cayman Islands' => 'Cayman Islands', + 'country_Central African Republic' => 'Central African Republic', + 'country_Sri Lanka' => 'Sri Lanka', + 'country_Chad' => 'Chad', + 'country_Chile' => 'Chile', + 'country_China' => 'China', + 'country_Taiwan, Province of China' => 'Taiwan, Province of China', + 'country_Christmas Island' => 'Christmas Island', + 'country_Cocos (Keeling) Islands' => 'Cocos (Keeling) Islands', + 'country_Colombia' => 'Colombia', + 'country_Comoros' => 'Comoros', + 'country_Mayotte' => 'Mayotte', + 'country_Congo' => 'Congo', + 'country_Congo, the Democratic Republic of the' => 'Congo, the Democratic Republic of the', + 'country_Cook Islands' => 'Cook Islands', + 'country_Costa Rica' => 'Costa Rica', + 'country_Croatia' => 'Croatia', + 'country_Cuba' => 'Cuba', + 'country_Cyprus' => 'Cyprus', + 'country_Czech Republic' => 'Czech Republic', + 'country_Benin' => 'Benin', + 'country_Denmark' => 'Denmark', + 'country_Dominica' => 'Dominica', + 'country_Dominican Republic' => 'Dominican Republic', + 'country_Ecuador' => 'Ecuador', + 'country_El Salvador' => 'El Salvador', + 'country_Equatorial Guinea' => 'Equatorial Guinea', + 'country_Ethiopia' => 'Ethiopia', + 'country_Eritrea' => 'Eritrea', + 'country_Estonia' => 'Estonia', + 'country_Faroe Islands' => 'Faroe Islands', + 'country_Falkland Islands (Malvinas)' => 'Falkland Islands (Malvinas)', + 'country_South Georgia and the South Sandwich Islands' => 'South Georgia and the South Sandwich Islands', + 'country_Fiji' => 'Fiji', + 'country_Finland' => 'Finland', + 'country_Åland Islands' => 'Åland Islands', + 'country_France' => 'France', + 'country_French Guiana' => 'French Guiana', + 'country_French Polynesia' => 'French Polynesia', + 'country_French Southern Territories' => 'French Southern Territories', + 'country_Djibouti' => 'Djibouti', + 'country_Gabon' => 'Gabon', + 'country_Georgia' => 'Georgia', + 'country_Gambia' => 'Gambia', + 'country_Palestinian Territory, Occupied' => 'Palestinian Territory, Occupied', + 'country_Germany' => 'Germany', + 'country_Ghana' => 'Ghana', + 'country_Gibraltar' => 'Gibraltar', + 'country_Kiribati' => 'Kiribati', + 'country_Greece' => 'Greece', + 'country_Greenland' => 'Greenland', + 'country_Grenada' => 'Grenada', + 'country_Guadeloupe' => 'Guadeloupe', + 'country_Guam' => 'Guam', + 'country_Guatemala' => 'Guatemala', + 'country_Guinea' => 'Guinea', + 'country_Guyana' => 'Guyana', + 'country_Haiti' => 'Haiti', + 'country_Heard Island and McDonald Islands' => 'Heard Island and McDonald Islands', + 'country_Holy See (Vatican City State)' => 'Holy See (Vatican City State)', + 'country_Honduras' => 'Honduras', + 'country_Hong Kong' => 'Hong Kong', + 'country_Hungary' => 'Hungary', + 'country_Iceland' => 'Iceland', + 'country_India' => 'India', + 'country_Indonesia' => 'Indonesia', + 'country_Iran, Islamic Republic of' => 'Iran, Islamic Republic of', + 'country_Iraq' => 'Iraq', + 'country_Ireland' => 'Ireland', + 'country_Israel' => 'Israel', + 'country_Italy' => 'Italy', + 'country_Côte d\'Ivoire' => 'Côte d\'Ivoire', + 'country_Jamaica' => 'Jamaica', + 'country_Japan' => 'Japan', + 'country_Kazakhstan' => 'Kazakhstan', + 'country_Jordan' => 'Jordan', + 'country_Kenya' => 'Kenya', + 'country_Korea, Democratic People\'s Republic of' => 'Korea, Democratic People\'s Republic of', + 'country_Korea, Republic of' => 'Korea, Republic of', + 'country_Kuwait' => 'Kuwait', + 'country_Kyrgyzstan' => 'Kyrgyzstan', + 'country_Lao People\'s Democratic Republic' => 'Lao People\'s Democratic Republic', + 'country_Lebanon' => 'Lebanon', + 'country_Lesotho' => 'Lesotho', + 'country_Latvia' => 'Latvia', + 'country_Liberia' => 'Liberia', + 'country_Libya' => 'Libya', + 'country_Liechtenstein' => 'Liechtenstein', + 'country_Lithuania' => 'Lithuania', + 'country_Luxembourg' => 'Luxembourg', + 'country_Macao' => 'Macao', + 'country_Madagascar' => 'Madagascar', + 'country_Malawi' => 'Malawi', + 'country_Malaysia' => 'Malaysia', + 'country_Maldives' => 'Maldives', + 'country_Mali' => 'Mali', + 'country_Malta' => 'Malta', + 'country_Martinique' => 'Martinique', + 'country_Mauritania' => 'Mauritania', + 'country_Mauritius' => 'Mauritius', + 'country_Mexico' => 'Mexico', + 'country_Monaco' => 'Monaco', + 'country_Mongolia' => 'Mongolia', + 'country_Moldova, Republic of' => 'Moldova, Republic of', + 'country_Montenegro' => 'Montenegro', + 'country_Montserrat' => 'Montserrat', + 'country_Morocco' => 'Morocco', + 'country_Mozambique' => 'Mozambique', + 'country_Oman' => 'Oman', + 'country_Namibia' => 'Namibia', + 'country_Nauru' => 'Nauru', + 'country_Nepal' => 'Nepal', + 'country_Netherlands' => 'Netherlands', + 'country_Curaçao' => 'Curaçao', + 'country_Aruba' => 'Aruba', + 'country_Sint Maarten (Dutch part)' => 'Sint Maarten (Dutch part)', + 'country_Bonaire, Sint Eustatius and Saba' => 'Bonaire, Sint Eustatius and Saba', + 'country_New Caledonia' => 'New Caledonia', + 'country_Vanuatu' => 'Vanuatu', + 'country_New Zealand' => 'New Zealand', + 'country_Nicaragua' => 'Nicaragua', + 'country_Niger' => 'Niger', + 'country_Nigeria' => 'Nigeria', + 'country_Niue' => 'Niue', + 'country_Norfolk Island' => 'Norfolk Island', + 'country_Norway' => 'Norway', + 'country_Northern Mariana Islands' => 'Northern Mariana Islands', + 'country_United States Minor Outlying Islands' => 'United States Minor Outlying Islands', + 'country_Micronesia, Federated States of' => 'Micronesia, Federated States of', + 'country_Marshall Islands' => 'Marshall Islands', + 'country_Palau' => 'Palau', + 'country_Pakistan' => 'Pakistan', + 'country_Panama' => 'Panama', + 'country_Papua New Guinea' => 'Papua New Guinea', + 'country_Paraguay' => 'Paraguay', + 'country_Peru' => 'Peru', + 'country_Philippines' => 'Philippines', + 'country_Pitcairn' => 'Pitcairn', + 'country_Poland' => 'Poland', + 'country_Portugal' => 'Portugal', + 'country_Guinea-Bissau' => 'Guinea-Bissau', + 'country_Timor-Leste' => 'Timor-Leste', + 'country_Puerto Rico' => 'Puerto Rico', + 'country_Qatar' => 'Qatar', + 'country_Réunion' => 'Réunion', + 'country_Romania' => 'Romania', + 'country_Russian Federation' => 'Russian Federation', + 'country_Rwanda' => 'Rwanda', + 'country_Saint Barthélemy' => 'Saint Barthélemy', + 'country_Saint Helena, Ascension and Tristan da Cunha' => 'Saint Helena, Ascension and Tristan da Cunha', + 'country_Saint Kitts and Nevis' => 'Saint Kitts and Nevis', + 'country_Anguilla' => 'Anguilla', + 'country_Saint Lucia' => 'Saint Lucia', + 'country_Saint Martin (French part)' => 'Saint Martin (French part)', + 'country_Saint Pierre and Miquelon' => 'Saint Pierre and Miquelon', + 'country_Saint Vincent and the Grenadines' => 'Saint Vincent and the Grenadines', + 'country_San Marino' => 'San Marino', + 'country_Sao Tome and Principe' => 'Sao Tome and Principe', + 'country_Saudi Arabia' => 'Saudi Arabia', + 'country_Senegal' => 'Senegal', + 'country_Serbia' => 'Serbia', + 'country_Seychelles' => 'Seychelles', + 'country_Sierra Leone' => 'Sierra Leone', + 'country_Singapore' => 'Singapore', + 'country_Slovakia' => 'Slovakia', + 'country_Viet Nam' => 'Viet Nam', + 'country_Slovenia' => 'Slovenia', + 'country_Somalia' => 'Somalia', + 'country_South Africa' => 'South Africa', + 'country_Zimbabwe' => 'Zimbabwe', + 'country_Spain' => 'Spain', + 'country_South Sudan' => 'South Sudan', + 'country_Sudan' => 'Sudan', + 'country_Western Sahara' => 'Western Sahara', + 'country_Suriname' => 'Suriname', + 'country_Svalbard and Jan Mayen' => 'Svalbard and Jan Mayen', + 'country_Swaziland' => 'Swaziland', + 'country_Sweden' => 'Sweden', + 'country_Switzerland' => 'Switzerland', + 'country_Syrian Arab Republic' => 'Syrian Arab Republic', + 'country_Tajikistan' => 'Tajikistan', + 'country_Thailand' => 'Thailand', + 'country_Togo' => 'Togo', + 'country_Tokelau' => 'Tokelau', + 'country_Tonga' => 'Tonga', + 'country_Trinidad and Tobago' => 'Trinidad and Tobago', + 'country_United Arab Emirates' => 'United Arab Emirates', + 'country_Tunisia' => 'Tunisia', + 'country_Turkey' => 'Turkey', + 'country_Turkmenistan' => 'Turkmenistan', + 'country_Turks and Caicos Islands' => 'Turks and Caicos Islands', + 'country_Tuvalu' => 'Tuvalu', + 'country_Uganda' => 'Uganda', + 'country_Ukraine' => 'Ukraine', + 'country_Macedonia, the former Yugoslav Republic of' => 'Macedonia, the former Yugoslav Republic of', + 'country_Egypt' => 'Egypt', + 'country_United Kingdom' => 'United Kingdom', + 'country_Guernsey' => 'Guernsey', + 'country_Jersey' => 'Jersey', + 'country_Isle of Man' => 'Isle of Man', + 'country_Tanzania, United Republic of' => 'Tanzania, United Republic of', + 'country_United States' => 'United States', + 'country_Virgin Islands, U.S.' => 'Virgin Islands, U.S.', + 'country_Burkina Faso' => 'Burkina Faso', + 'country_Uruguay' => 'Uruguay', + 'country_Uzbekistan' => 'Uzbekistan', + 'country_Venezuela, Bolivarian Republic of' => 'Venezuela, Bolivarian Republic of', + 'country_Wallis and Futuna' => 'Wallis and Futuna', + 'country_Samoa' => 'Samoa', + 'country_Yemen' => 'Yemen', + 'country_Zambi' => 'Zambi', + + 'view_client_portal' => 'View client portal', + 'view_portal' => 'View Portal', + 'vendor_contacts' => 'Vendor Contacts', + 'all' => 'Todos', + 'selected' => 'Selecionados', + 'category' => 'Categoria', + 'categories' => 'Categorias', + 'new_expense_category' => 'Nova Categoria de Despesas', + 'edit_category' => 'Editar Categoria', + 'archive_expense_category' => 'Arquivar Categoria', + 'expense_categories' => 'Categorias de Despesas', + 'list_expense_categories' => 'Listar Categorias de Despesas', + 'updated_expense_category' => 'Categoria de despesa atualizada com sucesso', + 'created_expense_category' => 'Categoria de despesa criada com sucesso', + 'archived_expense_category' => 'Categoria de despesa arquivada com sucesso', + 'archived_expense_categories' => ':count categorias de despesa arquivadas com sucesso', + 'restore_expense_category' => 'Restaurar categoria de despesa', + 'restored_expense_category' => 'Categoria de despesa restaurada com sucesso', + 'apply_taxes' => 'Aplicar impostos', + 'min_to_max_users' => ':min a :max utilizadores', + 'max_users_reached' => 'Atingiu o números máximo de utilizadores.', + 'buy_now_buttons' => 'Botões Comprar Agora', + 'landing_page' => 'Página de Entrada', + 'payment_type' => 'Tipo de Pagamento', + 'form' => 'Formulário', + 'link' => 'Ligação', + 'fields' => 'Campos', + 'dwolla' => 'Dwolla', + 'buy_now_buttons_warning' => 'Nota: o cliente e a nota de pagamento serão criados mesmo que a transacção não seja concluída.', + 'buy_now_buttons_disabled' => 'Esta funcionalidade requer que tenha um produto e um gateway de pagamento configurados.', + 'enable_buy_now_buttons_help' => 'Ativar suporte para botões "comprar agora"', + 'changes_take_effect_immediately' => 'Nota: alterações terão efeito imediato', + 'wepay_account_description' => 'Gateway de pagamento para o Invoice Ninja', + 'payment_error_code' => 'Houve um erro a processar o seu pagamento [:code]. Por favor tente novamente mais tarde.', + 'standard_fees_apply' => 'Taxa: 2.9%/1.2% [Cartão de Cédito/Transf. Bancária] + $0.30 (aprox. + €0.25) por pagamento realizado.', + 'limit_import_rows' => 'Os dados necessitam de ser importados em parcelas de :count linhas ou menos', + 'error_title' => 'Algo correu mal', + 'error_contact_text' => 'Se gostaria de ajudar por favor contacte-nos pelo endereço :mailaddress', + 'no_undo' => 'Aviso: isto não pode ser revertido.', + 'no_contact_selected' => 'Por favor selecione um contacto', + 'no_client_selected' => 'Por favor selecione um cliente', + + 'gateway_config_error' => 'Poderá ajudar a definir novas palavras-passe ou a gerar novas chaves API.', + 'payment_type_on_file' => ':type guardado', + 'invoice_for_client' => 'Nota de Pagamento :invoice para :client', + 'intent_not_found' => 'Desculpe, não entendi o que está a perguntar.', + 'intent_not_supported' => 'Desculpe, Não consigo fazer isso.', + 'client_not_found' => 'Não consegui encontrar o cliente', + 'not_allowed' => 'Desculpe, não tem as permissões necessárias.', + 'bot_emailed_invoice' => 'A sua nota de pagamento foi enviada.', + 'bot_emailed_notify_viewed' => 'Irei enviar-lhe um email quando for visualidada.', + 'bot_emailed_notify_paid' => 'Irei enviar-lhe um email quando for paga.', + 'add_product_to_invoice' => 'Adicionar 1 :product', + 'not_authorized' => 'Não está autorizado', + 'bot_get_email' => 'Olá! (wave)
Obrigado por utilizar o Invoice Ninja Bot.
Irá necessitar de criar uma conta gratuita em invoiceninja.com para utilizar este bot.
Envie-me o email da sua conta para iniciar.', + 'bot_get_code' => 'Obrigado! Vou enviar-lhe um email com o seu código de segurança.', + 'bot_welcome' => 'Feito, a sua conta está verificada.
', + 'email_not_found' => 'Não fui capaz de encontrar uma conta para :email', + 'invalid_code' => 'O código está incorrecto', + 'security_code_email_subject' => 'Código de segurança para o Invoice Ninja Bot', + 'security_code_email_line1' => 'Este é o seu código de segurança para o Invoice Ninja Bot.', + 'security_code_email_line2' => 'Nota: irá expirar em 10 minutos.', + 'bot_help_message' => 'Atualmente suporto:
• Criar/atualizar/enviar uma nota de pagamento
• Listar produtos:
Por exemplo:
faturar o bob por 2 bilhetes, definir a data de vencimento para a próxima quinta-feira e o desconto para 10%
', + 'list_products' => 'Listar Produtos', + + 'include_item_taxes_inline' => 'Incluir impostos dos items no total da linha', + 'created_quotes' => ':count orçamento(s) criados com sucesso.', + 'limited_gateways' => 'Nota: suportamos um gateway de cartão de crédito por empresa.', + + 'warning' => 'Aviso', + 'self-update' => 'Atualizar', + 'update_invoiceninja_title' => 'Atualizar Invoice Ninja', + 'update_invoiceninja_warning' => 'Antes de começar a atualizar o Invoice Ninja crie uma cópia de segurança da sua base de dados e ficheiros!', + 'update_invoiceninja_available' => 'Uma nova versão do Invoice Ninja está disponível.', + 'update_invoiceninja_unavailable' => 'Não existe uma versão mais recente do Invoice Ninja.', + 'update_invoiceninja_instructions' => 'Por favor instale a nova versão :version clicando Atualizar agora no botão abaixo. Depois será redireccionado para o painel de controlo.', + 'update_invoiceninja_update_start' => 'Atualizar agora', + 'update_invoiceninja_download_start' => 'Transferir :version', + 'create_new' => 'Criar Nova', + + 'toggle_navigation' => 'Alternar Navegação', + 'toggle_history' => 'Alternar Histórico', + 'unassigned' => 'Não atribuído', + 'task' => 'Tarefa', + 'contact_name' => 'Nome do Contacto', + 'city_state_postal' => 'Cidade/Distrito/C. Postal', + 'custom_field' => 'Campo Personalizado', + 'account_fields' => 'Campos da Empresa', + 'facebook_and_twitter' => 'Facebook e Twitter', + 'facebook_and_twitter_help' => 'Siga os nossos feeds para nos ajudar a suportar o projecto', + 'reseller_text' => 'Nota: a licença de marca-branca é prevista para uso pessoal, por favor envie um email para :email se desejar revender a aplicação.', + 'unnamed_client' => 'Cliente Sem Nome', + + 'day' => 'Dia', + 'week' => 'Semana', + 'month' => 'Mês', + 'inactive_logout' => 'Sessão terminada devido a inatividade', + 'reports' => 'Relatórios', + 'total_profit' => 'Lucro Total', + 'total_expenses' => 'Despesas Totais', + 'quote_to' => 'Orçamento para', + + // Limits + 'limit' => 'Limite', + 'min_limit' => 'Min: :min', + 'max_limit' => 'Max: :max', + 'no_limit' => 'Sem Limites', + 'set_limits' => 'Definir Limites de :gateway_type', + 'enable_min' => 'Ativar min', + 'enable_max' => 'Ativar max', + 'min' => 'Min', + 'max' => 'Max', + 'limits_not_met' => 'Esta Nota de Pag. não cumpre os limites para este tipo de pagamento.', + + 'date_range' => 'Interevalo de Datas', + 'raw' => 'Raw', + 'raw_html' => 'Código HTML', + 'update' => 'Atualizar', + 'invoice_fields_help' => 'Arraste os campos para alterar a sua ordem e localização', + 'new_category' => 'Nova Categoria', + 'restore_product' => 'Restaurar Producto', + 'blank' => 'Vazio', + 'invoice_save_error' => 'Houve um erro ao guardar a nota de pagamento', + 'enable_recurring' => 'Ativar Recorrência', + 'disable_recurring' => 'Desativar Recorrência', + 'text' => 'Texto', + 'expense_will_create' => 'a despesa será criada', + 'expenses_will_create' => 'as despesas serão criadas', + 'created_expenses' => ':count despesa(s) criadas com sucesso', + + 'translate_app' => 'Ajude a melhorar as nossas traduções com :link', + 'expense_category' => 'Categoria de Despesas', + + 'go_ninja_pro' => 'Obtenha o Ninja Pro!', + 'go_enterprise' => 'Obtenha o Enterprise!', + 'upgrade_for_features' => 'Suba de Plano para Obter Mais Funcionalidades', + 'pay_annually_discount' => 'Pague anualmente por 10 meses + 2 grátis!', + 'pro_upgrade_title' => 'Ninja Pro', + 'pro_upgrade_feature1' => 'ASuaMarca.InvoiceNinja.com', + 'pro_upgrade_feature2' => 'Personalize todos os aspectos da sua nota de pagamento!', + 'enterprise_upgrade_feature1' => 'Defina permissões para vários utilizadores', + 'enterprise_upgrade_feature2' => 'Anexe ficheiros de terceiros às suas notas de pagamento & despesas', + 'much_more' => 'Muito Mais!', + + 'currency_symbol' => 'Símbolo', + 'currency_code' => 'Código', + + 'buy_license' => 'Comprar Licença', + 'apply_license' => 'Aplicar Linceça', + 'submit' => 'Submeter', + 'white_label_license_key' => 'Chave de Licença', + 'invalid_white_label_license' => 'A licença de marca branca é inválida', + 'created_by' => 'Criado por :nome', + 'modules' => 'Módulos', + 'financial_year_start' => 'Primeiro Mês do Ano', + 'authentication' => 'Autenticação', + 'checkbox' => 'Checkbox', + 'invoice_signature' => 'Assinatura', + 'show_accept_invoice_terms' => 'Checkbox para Termos da Nota de Pagamento', + 'show_accept_invoice_terms_help' => 'Requer que o cliente confirme que aceita os termos da nota de pagamento.', + 'show_accept_quote_terms' => 'Checkbox para Termos do Orçamento', + 'show_accept_quote_terms_help' => 'Requer que o cliente confirme que aceita os termos do orçamento.', + 'require_invoice_signature' => 'Assinatura da Nota de Pagamento', + 'require_invoice_signature_help' => 'Requer que o cliente introduza a sua assinatura.', + 'require_quote_signature' => 'Assinatura de Orçamento', + 'require_quote_signature_help' => 'Pedir ao cliente para providenciar a sua assinatura.', + 'i_agree' => 'Concordo com os termos', + 'sign_here' => 'Por favor assine aqui:', + 'authorization' => 'Autorização', + 'signed' => 'Assinado', + + // BlueVine + 'bluevine_promo' => 'Tenha uma linha de crédito flexível usando BlueVine.', + 'bluevine_modal_label' => 'Registar com BlueVine', + 'bluevine_modal_text' => '

Capital rápido para o seu negócio. Sem papelada.

+
  • Linhas de crédito flexíveis para o seu negócio.
', + 'bluevine_create_account' => 'Criar uma conta', + 'quote_types' => 'Pedir orçamento para', + 'invoice_factoring' => 'Faturação de contas', + 'line_of_credit' => 'Linha de crédito', + 'fico_score' => 'Pontuação FICO', + 'business_inception' => 'Data de Criação do Negócio', + 'average_bank_balance' => 'Média do balanço das contas bancárias', + 'annual_revenue' => 'Anual faturado', + 'desired_credit_limit_factoring' => 'Limite de crédito desejado', + 'desired_credit_limit_loc' => 'Limite da linha de crédito desejado', + 'desired_credit_limit' => 'Limite de crédito desejado', + 'bluevine_credit_line_type_required' => 'Deve escolher pelo menos uma', + 'bluevine_field_required' => 'O campo é obrigatório', + 'bluevine_unexpected_error' => 'Ocorreu um erro inesperado.', + 'bluevine_no_conditional_offer' => 'É necessário mais informação antes de obter um orçamento. Clique abaixo para continuar.', + 'bluevine_invoice_factoring' => 'Faturação de Contas', + 'bluevine_conditional_offer' => 'Oferta Condicional', + 'bluevine_credit_line_amount' => 'Linha de Crédito', + 'bluevine_advance_rate' => 'Taxa de Avanço', + 'bluevine_weekly_discount_rate' => 'Taxa de Desconto Semanal', + 'bluevine_minimum_fee_rate' => 'Taxa Mínima', + 'bluevine_line_of_credit' => 'Linha de Crédito', + 'bluevine_interest_rate' => 'Taxa de Juro', + 'bluevine_weekly_draw_rate' => 'Weekly Draw Rate', + 'bluevine_continue' => 'Continuar para BlueVine', + 'bluevine_completed' => 'Registo com o BlueVine concluído', + + 'vendor_name' => 'Fornecedor', + 'entity_state' => 'Estado', + 'client_created_at' => 'Data de Criação', + 'postmark_error' => 'Houve um problema ao enviar o email através do Postmark: :link', + 'project' => 'Projeto', + 'projects' => 'Projetos', + 'new_project' => 'Novo Projeto', + 'edit_project' => 'Editar Projeto', + 'archive_project' => 'Arquivar Projeto', + 'list_projects' => 'Listar projetos', + 'updated_project' => 'Projeto atualizado com sucesso', + 'created_project' => 'Projeto criado com sucesso', + 'archived_project' => 'Projeto arquivado com sucesso', + 'archived_projects' => ':count projetos arquivados com sucesso', + 'restore_project' => 'Restaurar projeto', + 'restored_project' => 'Projeto restaurado com sucesso', + 'delete_project' => 'Apagar projecto', + 'deleted_project' => 'Projeto apagado com sucesso', + 'deleted_projects' => ':count projectos apagadas com sucesso', + 'delete_expense_category' => 'Apagar categoria', + 'deleted_expense_category' => 'Categoria apagada com sucesso', + 'delete_product' => 'Apagar Producto', + 'deleted_product' => 'Producto apagado com sucesso', + 'deleted_products' => ':count produtos apagados com sucesso', + 'restored_product' => 'Produto restaurado com sucesso', + 'update_credit' => 'Atualizar Crédito', + 'updated_credit' => 'Crédito atualizado com sucesso', + 'edit_credit' => 'Editar Crédito', + 'live_preview_help' => 'Mostrar pré-visualização do PDF em tempo real na página da nota de pagamento.
Desative isto para melhorar a performance ao editar notas de pagamento.', + 'force_pdfjs_help' => 'Trocar o visualizador de PDF padrão em :chrome_link e :firefox_link.
Ativar isto se o seu browser está a transferir o PDF automaticamente.', + 'force_pdfjs' => 'Evitar Transferência', + 'redirect_url' => 'Redirecionar URL', + 'redirect_url_help' => 'Opcionalmente especifique um URL para redirecionar após a introdução do pagamento', + 'save_draft' => 'Guardar Rascunho', + 'refunded_credit_payment' => 'Pagamento de crédito reembolsado', + 'keyboard_shortcuts' => 'Atalhos de Teclado', + 'toggle_menu' => 'Alternar Menu', + 'new_...' => 'Novo ...', + 'list_...' => 'Listar ...', + 'created_at' => 'Criado', + 'contact_us' => 'Contacte-nos', + 'support_forum' => 'Forum de Suporte', + 'user_guide' => 'Guia do Utilizador', + 'promo_message' => 'Atualizar antes de :expires para obter :amount de desconto no seu primeiro ano dos pacotes Pro e Enterprise.', + 'discount_message' => ':amount de desconte expira a :expires', + 'mark_paid' => 'Marcar como Pago', + 'marked_sent_invoice' => 'Nota de pagamento marcada como enviada com sucesso', + 'marked_sent_invoices' => 'Notas de pagamento marcadas como enviadas com sucesso', + 'invoice_name' => 'Nota de Pagamento', + 'product_will_create' => 'produto será criado', + 'contact_us_response' => 'Obrigado pela sua mensagem! Vamos tentar responder o mais breve possível.', + 'last_7_days' => 'Últimos 7 dias', + 'last_30_days' => 'Últimos 30 dias', + 'this_month' => 'Este Mês', + 'last_month' => 'Último Mês', + 'last_year' => 'Último Ano', + 'custom_range' => 'Intervalo Personalizado', + 'url' => 'URL', + 'debug' => 'Debug', + 'https' => 'HTTPS', + 'require' => 'Requerer', + 'license_expiring' => 'Nota: A sua licença expira em :count dias, :link para renovar.', + 'security_confirmation' => 'O seu email foi confirmado.', + 'white_label_expired' => 'A sua licença de marca branca expirou, por favor consider renovar para suportar o nosso projecto.', + 'renew_license' => 'Renovar Licença', + 'iphone_app_message' => 'Considere transferir o nosso :link', + 'iphone_app' => 'iPhone app', + 'android_app' => 'Android app', + 'logged_in' => 'Sessão iniciada', + 'switch_to_primary' => 'Altere para a sua empresa primária (:name) para gerir o seu plano.', + 'inclusive' => 'Inclusive', + 'exclusive' => 'Exclusive', + 'postal_city_state' => 'Código-Postal/Cidade/Distrito', + 'phantomjs_help' => 'Em certos casos a aplicação utiliza :link_phantom para gerar os PDF, instale :link_docs para gerar os PDF localmente.', + 'phantomjs_local' => 'Usar PhantomJS local', + 'client_number' => 'Nº Cliente', + 'client_number_help' => 'Especifique um prefixo ou utilize um padrão personalizado para definir dinamicamente o número do cliente.', + 'next_client_number' => 'O próximo cliente será o número :number.', + 'generated_numbers' => 'Números gerados', + 'notes_reminder1' => 'Primeiro lembrete', + 'notes_reminder2' => 'Segundo lembrete', + 'notes_reminder3' => 'Terceiro Lembrete', + 'bcc_email' => 'Email BCC', + 'tax_quote' => 'Imposto do orçamento', + 'tax_invoice' => 'Imposto da nota de pag.', + 'emailed_invoices' => 'Notas de pag. enviadas com sucesso', + 'emailed_quotes' => 'Orçamentos enviados com sucesso', + 'website_url' => 'Website URL', + 'domain' => 'Domínio', + 'domain_help' => 'Usado no portal do cliente e quando se enviam emails', + 'domain_help_website' => 'Usado quando se enviam emails.', + 'preview' => 'Pré-visualizar', + 'import_invoices' => 'Importar notas de pag.', + 'new_report' => 'Novo relatório', + 'edit_report' => 'Editar relatório', + 'columns' => 'Colunas', + 'filters' => 'Filtros', + 'sort_by' => 'Ordenar por', + 'draft' => 'Rascunho', + 'unpaid' => 'Não pago', + 'aging' => 'Vencidas', + 'age' => 'Idade', + 'days' => 'Dias', + 'age_group_0' => '0 - 30 Dias', + 'age_group_30' => '30 - 60 Dias', + 'age_group_60' => '60 - 90 Dias', + 'age_group_90' => '90 - 120 Dias', + 'age_group_120' => '120+ Dias', + 'invoice_details' => 'Detalhes da nota de pag.', + 'qty' => 'Quantidade', + 'profit_and_loss' => 'Lucro e prejuízo', + 'revenue' => 'Faturado', + 'profit' => 'Lucro', + 'group_when_sorted' => 'Agrupar quando ordenado', + 'group_dates_by' => 'Agrupar datas por', + 'year' => 'Ano', + 'view_statement' => 'Visualizar declaração', + 'statement' => 'Declaração', + 'statement_date' => 'Data da declaração', + 'inactivity_logout' => 'Fechou por inatividade, fechou sessão automaticamente .', + 'mark_active' => 'Ativar', + 'send_automatically' => 'Enviar automaticamente', + 'initial_email' => 'Email inicial', + 'invoice_not_emailed' => 'Esta nota de pag. não foi enviada.', + 'quote_not_emailed' => 'Este orçamento não não foi enviado.', + 'sent_by' => 'Enviado por :user', + 'recipients' => 'Destinatários', + 'save_as_default' => 'Guardar como padrão', + 'template' => 'Template', + 'start_of_week_help' => 'Utilizado pelos selectores date', + 'financial_year_start_help' => 'Utilizado pelos selectores interevalo de data>', + 'reports_help' => 'Shift + ordenar por várias colunas, Ctrl + Click para limpar os grupos.', + 'this_year' => 'Este ano', + + // Updated login screen + 'ninja_tagline' => 'Criar. Enviar. Ser Pago.', + 'login_or_existing' => 'Ou inicie sessão com um conta.', + 'sign_up_now' => 'Inicie sessão agora', + 'not_a_member_yet' => 'Ainda não é membro?', + 'login_create_an_account' => 'Criar uma conta!', + 'client_login' => 'Iniciar sessão como cliente', + + // New Client Portal styling + 'invoice_from' => 'Notas de pag. From:', + 'email_alias_message' => 'Cada empresa deve ter um único email.
Consider utilizar um alias. ex, email+label@example.com', + 'full_name' => 'Nome completo', + 'month_year' => 'MÊS/ANO', + 'valid_thru' => 'Válido\npor', + + 'product_fields' => 'Campos do produto', + 'custom_product_fields_help' => 'Adicionar um campo quando criar um produto ou nota de pag. e mostrar esse campo e valor no PDF.', + 'freq_two_months' => 'Dois meses', + 'freq_yearly' => 'Anualmente', + 'profile' => 'Perfil', + 'payment_type_help' => 'Definir como padrão Tipo de pagamento manual.', + 'industry_Construction' => 'Indústria', + 'your_statement' => 'A declaração', + 'statement_issued_to' => 'Relatório aplicado a', + 'statement_to' => 'Declaração para', + 'customize_options' => 'Personalizar opções', + 'created_payment_term' => 'Criado termo de pagamento com sucesso', + 'updated_payment_term' => 'Atualizado termo de pagamento com sucesso', + 'archived_payment_term' => 'Arquivado termo de pagamento com sucesso', + 'resend_invite' => 'Reenviar convite', + 'credit_created_by' => 'Crédito criado por pagamento :transaction_reference', + 'created_payment_and_credit' => 'Criado pagamento e crédito com sucesso', + 'created_payment_and_credit_emailed_client' => 'Criado pagamento e crédito com sucesso, e cliente informado.', + 'create_project' => 'Criar projeto', + 'create_vendor' => 'Criar fornecedor', + 'create_expense_category' => 'Criar categoria', + 'pro_plan_reports' => ':link para ativar relatórios aderindo ao plano Pro', + 'mark_ready' => 'Marcar concluido', + + 'limits' => 'Limites', + 'fees' => 'Taxas', + 'fee' => 'Taxa', + 'set_limits_fees' => 'Definir :gateway_type Limits/Fees', + 'fees_tax_help' => 'Ativar taxas do item para definir as taxas de imposto.', + 'fees_sample' => 'A taxa para :amount nota de pag. deve ser :total.', + 'discount_sample' => 'O desconto para :amount nota de pag. deve ser :total.', + 'no_fees' => 'Sem taxas', + 'gateway_fees_disclaimer' => 'Aviso: nem todos os estados/gateways de pagamento permitem adicionar taxas, por favor verifique a sua legislação/termos de serviço locais.', + 'percent' => 'Percentagem', + 'location' => 'Localização', + 'line_item' => 'Item', + 'surcharge' => 'Sobretaxa', + 'location_first_surcharge' => 'Ativo - Primeira Sobretaxa', + 'location_second_surcharge' => 'Ativo - Segunda sobretaxa', + 'location_line_item' => 'Ativo - Item', + 'online_payment_surcharge' => 'Taxa de Pagamento Online', + 'gateway_fees' => 'Taxas Gateway', + 'fees_disabled' => 'Taxas estão desativadas', + 'gateway_fees_help' => 'Automaticamente adicionar uma taxa/desconto ao pagamento online.', + 'gateway' => 'Gateway', + 'gateway_fee_change_warning' => 'Se tiver notas de pag. não pagas terá que atualizar as taxas manualmente.', + 'fees_surcharge_help' => 'Personalizar taxa :link.', + 'label_and_taxes' => 'etiquetas e impostos', + 'billable' => 'Faturável', + 'logo_warning_too_large' => 'A imagem é muito grande.', + 'logo_warning_fileinfo' => 'Aviso: Para suportar GIFSa extensão fileinfo PHP etem que estar ativa.', + 'logo_warning_invalid' => 'Há um problemas a ler a imagem, por favor tente outro formato.', + + 'error_refresh_page' => 'An error occurred, please refresh the page and try again.', + 'data' => 'Data', + 'imported_settings' => 'Definições importadas com sucesso', + 'lang_Greek' => 'Grego', + 'reset_counter' => 'Redefinir contador', + 'next_reset' => 'Próxima redefinição', + 'reset_counter_help' => 'Redefinir automaticamente os contadores das notas de pag. e dos orçamentos.', + 'auto_bill_failed' => 'Auto-faturar para a nota de pag. :invoice_number failed', + 'online_payment_discount' => 'Desconto de pagamento online', + 'created_new_company' => 'Nova empresa criada com sucesso', + 'fees_disabled_for_gateway' => 'Taxas desativadas para este gateway.', + 'logout_and_delete' => 'Sair/Apagar conta', + 'tax_rate_type_help' => 'Inclusive impostos adjust the line item cost when selected.', + 'invoice_footer_help' => 'Usar $pageNumber e $pageCount para mostrar a informação da página.', + 'credit_note' => 'Nota de crédito', + 'credit_issued_to' => 'Crédito atribuido a', + 'credit_to' => 'Crédito para', + 'your_credit' => 'A sua Nota de crédito', + 'credit_number' => 'Nota de rédito número', + 'create_credit_note' => 'Criar nota de crédito', + 'menu' => 'Menu', + 'error_incorrect_gateway_ids' => 'Erro: A tabela de gateways tem ids incorretos.', + 'purge_data' => 'Purgar dados', + 'delete_data' => 'Apagar dados', + 'purge_data_help' => 'Apagar permanentemente todos os dados da conta, manter a conta e as definições.', + 'cancel_account_help' => 'Apagar permanentemente a conta, todos os dados e as definições.', + 'purge_successful' => 'Dados da conta purgados com sucesso', + 'forbidden' => 'Proibido', + 'purge_data_message' => 'Aviso: apagará todos os seus dados.', + 'contact_phone' => 'Contato telefónico', + 'contact_email' => 'Email', + 'reply_to_email' => 'Email de resposta', + 'reply_to_email_help' => 'Especificar o email de resposa para os clientes', + 'bcc_email_help' => 'Incluir este email com os emais do cliente.', + 'import_complete' => 'A importação está completa.', + 'confirm_account_to_import' => 'Por favor confirme a sua conta para importar os dados.', + 'import_started' => 'A importação começou, quando estiver completa receberá um email.', + 'listening' => 'A ouvir...', + 'microphone_help' => 'Diga "nova nota de pag. para [cliente] ou "mostre-me o pagamentos arquivados de [cliente]"', + 'voice_commands' => 'Comandos de voz', + 'sample_commands' => 'Comandos de exemplo', + 'voice_commands_feedback' => 'Estamos a trabalhar para melhorar esta questão, se quiser por enviar-nos um email para :email.', + 'payment_type_Venmo' => 'Venmo', + 'archived_products' => ':count Produtos arquivados com sucesso', + 'recommend_on' => 'Recomendamos ativar esta definição.', + 'recommend_off' => 'Recomendamos desativar esta definição.', + 'notes_auto_billed' => 'Auto-cobrado', + 'surcharge_label' => 'Etiqueta de Sobretaxa', + 'contact_fields' => 'Campos de contato', + 'custom_contact_fields_help' => 'Adicionar um campo quando é criado um contato e mostrar a etiqueta e valor no PDF.', + 'datatable_info' => 'A mostrar :start a :end de :total entradas', + 'credit_total' => 'Total em crédito', + 'mark_billable' => 'Marcar faturável', + 'billed' => 'Faturado', + 'company_variables' => 'Variáveis da empresa', + 'client_variables' => 'Variáveis do cliente', + 'invoice_variables' => 'Variáveis da nota de pag.', + 'navigation_variables' => 'Variáveis de navegação', + 'custom_variables' => 'Variáveis personalizadas', + 'invalid_file' => 'Tipo de ficheiro inválido', + 'add_documents_to_invoice' => 'Adicionar documento à nota de pag.', + 'mark_expense_paid' => 'Marcar Pago', + 'white_label_license_error' => 'Falhou a validar a licença, verifique storage/logs/laravel-error.log para mais detalhes.', + +); + +return $LANG; + +?> diff --git a/resources/lang/pt_PT/validation.php b/resources/lang/pt_PT/validation.php new file mode 100644 index 000000000000..d6df9e7d0358 --- /dev/null +++ b/resources/lang/pt_PT/validation.php @@ -0,0 +1,106 @@ + ":attribute deve ser aceite.", +"active_url" => ":attribute não é um URL válido.", +"after" => ":attribute deve ser uma data maior que :date.", +"alpha" => ":attribute deve conter apenas letras.", +"alpha_dash" => ":attribute pode conter apenas letras, número e hífens", +"alpha_num" => ":attribute pode conter apenas letras e números.", +"array" => ":attribute deve ser uma lista.", +"before" => ":attribute deve ser uma data anterior a :date.", +"between" => array( + "numeric" => ":attribute deve estar entre :min - :max.", + "file" => ":attribute deve estar entre :min - :max kilobytes.", + "string" => ":attribute deve estar entre :min - :max caracteres.", + "array" => ":attribute deve conter entre :min - :max itens.", + ), +"confirmed" => ":attribute confirmação não corresponde.", +"date" => ":attribute não é uma data válida.", +"date_format" => ":attribute não satisfaz o formato :format.", +"different" => ":attribute e :other devem ser diferentes.", +"digits" => ":attribute deve conter :digits dígitos.", +"digits_between" => ":attribute deve conter entre :min e :max dígitos.", +"email" => ":attribute está em um formato inválido.", +"exists" => "A opção selecionada :attribute é inválida.", +"image" => ":attribute deve ser uma imagem.", +"in" => "A opção selecionada :attribute é inválida.", +"integer" => ":attribute deve ser um número inteiro.", +"ip" => ":attribute deve ser um endereço IP válido.", +"max" => array( + "numeric" => ":attribute não pode ser maior que :max.", + "file" => ":attribute não pode ser maior que :max kilobytes.", + "string" => ":attribute não pode ser maior que :max caracteres.", + "array" => ":attribute não pode conter mais que :max itens.", + ), +"mimes" => ":attribute deve ser um arquivo do tipo: :values.", +"min" => array( + "numeric" => ":attribute não deve ser menor que :min.", + "file" => ":attribute deve ter no mínimo :min kilobytes.", + "string" => ":attribute deve conter no mínimo :min caracteres.", + "array" => ":attribute deve conter ao menos :min itens.", + ), +"not_in" => "A opção selecionada :attribute é inválida.", +"numeric" => ":attribute deve ser um número.", +"regex" => ":attribute está em um formato inválido.", +"required" => ":attribute é um campo obrigatório.", +"required_if" => ":attribute é necessário quando :other é :value.", +"required_with" => ":attribute é obrigatório quando :values está presente.", +"required_without" => ":attribute é obrigatório quando :values não está presente.", +"same" => ":attribute e :other devem corresponder.", +"size" => array( + "numeric" => ":attribute deve ter :size.", + "file" => ":attribute deve ter :size kilobytes.", + "string" => ":attribute deve conter :size caracteres.", + "array" => ":attribute deve conter :size itens.", + ), +"unique" => ":attribute já está sendo utilizado.", +"url" => ":attribute está num formato inválido.", + +"positive" => ":attribute deve ser maior que zero.", +"has_credit" => "O cliente não possui crédito suficiente.", +"notmasked" => "Os valores são mascarados", +"less_than" => ':attribute deve ser menor que :value', +"has_counter" => 'O valor deve conter {$counter}', +"valid_contacts" => "Todos os contatos devem conter um email ou nome", +"valid_invoice_items" => "Esta fatura excedeu o número máximo de itens", + +/* +|-------------------------------------------------------------------------- +| Custom Validation Language Lines +|-------------------------------------------------------------------------- +| +| Here you may specify custom validation messages for attributes using the +| convention "attribute.rule" to name the lines. This makes it quick to +| specify a specific custom language line for a given attribute rule. +| +*/ + +'custom' => array(), + +/* +|-------------------------------------------------------------------------- +| Custom Validation Attributes +|-------------------------------------------------------------------------- +| +| The following language lines are used to swap attribute place-holders +| with something more reader friendly such as E-Mail Address instead +| of "email". This simply helps us make messages a little cleaner. +| +*/ + +'attributes' => array(), + +); From c032143c4758f7860c9207b83725f8a302f74cfc Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Tue, 2 May 2017 21:55:36 +0300 Subject: [PATCH 34/81] Multi-db support --- .env.travis | 22 ++++++++++++ .travis.yml | 48 +++++++++++++++----------- app/Console/Commands/InitLookup.php | 12 +++++++ app/Http/Controllers/AppController.php | 1 + app/Models/LookupModel.php | 5 +-- database/seeds/LanguageSeeder.php | 2 +- 6 files changed, 66 insertions(+), 24 deletions(-) create mode 100644 .env.travis diff --git a/.env.travis b/.env.travis new file mode 100644 index 000000000000..166606f0eb03 --- /dev/null +++ b/.env.travis @@ -0,0 +1,22 @@ +APP_ENV=development +APP_DEBUG=true +APP_URL=http://ninja.dev +APP_KEY=SomeRandomStringSomeRandomString +APP_CIPHER=AES-256-CBC +APP_LOCALE=en + +MULTI_DB_ENABLED=true +MULTI_DB_CACHE_ENABLED=true + +DB_TYPE=db-ninja-1 +DB_STRICT=false +DB_HOST=localhost +DB_USERNAME=ninja +DB_PASSWORD=ninja + +DB_DATABASE0=ninja0 +DB_DATABASE1=ninja1 +DB_DATABASE2=ninja2 + +MAIL_DRIVER=log +TRAVIS=true diff --git a/.travis.yml b/.travis.yml index 067ed7c1530e..89dbfeb9d81e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -44,21 +44,21 @@ before_script: # prevent MySQL went away error - mysql -u root -e 'SET @@GLOBAL.wait_timeout=28800;' # copy configuration files - - cp .env.example .env + - cp .env.travis .env - cp tests/_bootstrap.php.default tests/_bootstrap.php - php artisan key:generate --no-interaction - - sed -i 's/APP_ENV=production/APP_ENV=development/g' .env - - sed -i 's/APP_DEBUG=false/APP_DEBUG=true/g' .env - - sed -i 's/MAIL_DRIVER=smtp/MAIL_DRIVER=log/g' .env - - sed -i 's/PHANTOMJS_CLOUD_KEY/#PHANTOMJS_CLOUD_KEY/g' .env - sed -i '$a NINJA_DEV=true' .env - - sed -i '$a TRAVIS=true' .env # create the database and user - - mysql -u root -e "create database IF NOT EXISTS ninja;" - - mysql -u root -e "GRANT ALL PRIVILEGES ON ninja.* To 'ninja'@'localhost' IDENTIFIED BY 'ninja'; FLUSH PRIVILEGES;" + - mysql -u root -e "create database IF NOT EXISTS ninja0;" + - mysql -u root -e "create database IF NOT EXISTS ninja1;" + - mysql -u root -e "create database IF NOT EXISTS ninja2;" + - mysql -u root -e "GRANT ALL PRIVILEGES ON ninja0.* To 'ninja'@'localhost' IDENTIFIED BY 'ninja'; FLUSH PRIVILEGES;" + - mysql -u root -e "GRANT ALL PRIVILEGES ON ninja1.* To 'ninja'@'localhost' IDENTIFIED BY 'ninja'; FLUSH PRIVILEGES;" + - mysql -u root -e "GRANT ALL PRIVILEGES ON ninja2.* To 'ninja'@'localhost' IDENTIFIED BY 'ninja'; FLUSH PRIVILEGES;" # migrate and seed the database - - php artisan migrate --no-interaction - - php artisan db:seed --no-interaction # default seed + - php artisan migrate --database=ninja0 --seed --no-interaction + - php artisan migrate --database=ninja1 --seed --no-interaction + - php artisan migrate --database=ninja2 --seed --no-interaction # Start webserver on ninja.dev:8000 - php artisan serve --host=ninja.dev --port=8000 & # '&' allows to run in background # Start PhantomJS @@ -69,6 +69,7 @@ before_script: - curl -L http://ninja.dev:8000/update - php artisan ninja:create-test-data 4 true - php artisan db:seed --no-interaction --class=UserTableSeeder # development seed + - sed -i 's/DB_TYPE=db-ninja-1/DB_TYPE=db-ninja-2/g' .env script: - php ./vendor/codeception/codeception/codecept run --debug acceptance APICest.php @@ -92,17 +93,22 @@ script: after_script: - php artisan ninja:check-data --no-interaction - cat .env - - mysql -u root -e 'select * from accounts;' ninja - - mysql -u root -e 'select * from users;' ninja - - mysql -u root -e 'select * from account_gateways;' ninja - - mysql -u root -e 'select * from clients;' ninja - - mysql -u root -e 'select * from contacts;' ninja - - mysql -u root -e 'select * from invoices;' ninja - - mysql -u root -e 'select * from invoice_items;' ninja - - mysql -u root -e 'select * from invitations;' ninja - - mysql -u root -e 'select * from payments;' ninja - - mysql -u root -e 'select * from credits;' ninja - - mysql -u root -e 'select * from expenses;' ninja + - mysql -u root -e 'select * from lookup_companies;' ninja0 + - mysql -u root -e 'select * from lookup_accounts;' ninja0 + - mysql -u root -e 'select * from lookup_contacts;' ninja0 + - mysql -u root -e 'select * from lookup_invitations;' ninja0 + - mysql -u root -e 'select * from accounts;' ninja1 + - mysql -u root -e 'select * from users;' ninja1 + - mysql -u root -e 'select * from account_gateways;' ninja1 + - mysql -u root -e 'select * from clients;' ninja1 + - mysql -u root -e 'select * from contacts;' ninja1 + - mysql -u root -e 'select * from invoices;' ninja1 + - mysql -u root -e 'select * from invoice_items;' ninja1 + - mysql -u root -e 'select * from invitations;' ninja1 + - mysql -u root -e 'select * from payments;' ninja1 + - mysql -u root -e 'select * from credits;' ninja1 + - mysql -u root -e 'select * from expenses;' ninja1 + - mysql -u root -e 'select * from accounts;' ninja2 - cat storage/logs/laravel-error.log - cat storage/logs/laravel-info.log - FILES=$(find tests/_output -type f -name '*.png' | sort -nr) diff --git a/app/Console/Commands/InitLookup.php b/app/Console/Commands/InitLookup.php index 699b895e40c2..64944f6d47af 100644 --- a/app/Console/Commands/InitLookup.php +++ b/app/Console/Commands/InitLookup.php @@ -4,6 +4,8 @@ namespace App\Console\Commands; use Illuminate\Console\Command; use DB; +use Mail; +use Exception; use App\Models\DbServer; use App\Models\LookupCompany; use App\Models\LookupAccount; @@ -70,6 +72,16 @@ class InitLookup extends Command } $this->info($this->log); + + if ($this->option('validate') && $errorEmail) { + Mail::raw($this->log, function ($message) use ($errorEmail, $database) { + $message->to($errorEmail) + ->from(CONTACT_EMAIL) + ->subject("Check-Lookups [{$database}]: " . strtoupper($this->isValid ? RESULT_SUCCESS : RESULT_FAILURE)); + }); + } elseif (! $this->isValid) { + throw new Exception('Check data failed!!'); + } } private function initCompanies($dbServerId, $offset = 0) diff --git a/app/Http/Controllers/AppController.php b/app/Http/Controllers/AppController.php index e269a7290fc5..c02de66e855a 100644 --- a/app/Http/Controllers/AppController.php +++ b/app/Http/Controllers/AppController.php @@ -351,6 +351,7 @@ class AppController extends BaseController { try { Artisan::call('ninja:check-data'); + //Artisan::call('ninja:check-data'); return RESULT_SUCCESS; } catch (Exception $exception) { return RESULT_FAILURE; diff --git a/app/Models/LookupModel.php b/app/Models/LookupModel.php index 7156e2afe749..16e24805456c 100644 --- a/app/Models/LookupModel.php +++ b/app/Models/LookupModel.php @@ -3,6 +3,7 @@ namespace App\Models; use Eloquent; +use Cache; /** * Class ExpenseCategory. @@ -68,7 +69,7 @@ class LookupModel extends Eloquent $isUser = $className == 'App\Models\User'; // check if we've cached this lookup - if (env('MULTI_DB_CACHE_ENABLED') && $server = session($key)) { + if (env('MULTI_DB_CACHE_ENABLED') && $server = Cache::get($key)) { static::setDbServer($server, $isUser); return; } @@ -87,7 +88,7 @@ class LookupModel extends Eloquent abort("Looked up {$className} not found: {$field} => {$value}"); } - session([$key => $server]); + Cache::put($key, $server, 120); } else { config(['database.default' => $current]); } diff --git a/database/seeds/LanguageSeeder.php b/database/seeds/LanguageSeeder.php index 2ccce1c7b779..2e25215108b0 100644 --- a/database/seeds/LanguageSeeder.php +++ b/database/seeds/LanguageSeeder.php @@ -16,7 +16,7 @@ class LanguageSeeder extends Seeder ['name' => 'Italian', 'locale' => 'it'], ['name' => 'German', 'locale' => 'de'], ['name' => 'French', 'locale' => 'fr'], - ['name' => 'Brazilian Portuguese', 'locale' => 'pt_BR'], + ['name' => 'Portuguese - Brazilian', 'locale' => 'pt_BR'], ['name' => 'Dutch', 'locale' => 'nl'], ['name' => 'Spanish', 'locale' => 'es'], ['name' => 'Norwegian', 'locale' => 'nb_NO'], From 39daf57f37a5026fc4e47e02b484b7e29c611ff7 Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Tue, 2 May 2017 21:59:22 +0300 Subject: [PATCH 35/81] Multi-db support --- .travis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 89dbfeb9d81e..32337cc3a7f1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -56,9 +56,9 @@ before_script: - mysql -u root -e "GRANT ALL PRIVILEGES ON ninja1.* To 'ninja'@'localhost' IDENTIFIED BY 'ninja'; FLUSH PRIVILEGES;" - mysql -u root -e "GRANT ALL PRIVILEGES ON ninja2.* To 'ninja'@'localhost' IDENTIFIED BY 'ninja'; FLUSH PRIVILEGES;" # migrate and seed the database - - php artisan migrate --database=ninja0 --seed --no-interaction - - php artisan migrate --database=ninja1 --seed --no-interaction - - php artisan migrate --database=ninja2 --seed --no-interaction + - php artisan migrate --database=db-ninja-0 --seed --no-interaction + - php artisan migrate --database=db-ninja-1 --seed --no-interaction + - php artisan migrate --database=db-ninja-2 --seed --no-interaction # Start webserver on ninja.dev:8000 - php artisan serve --host=ninja.dev --port=8000 & # '&' allows to run in background # Start PhantomJS From 85c0f9cd9e586e72f7aae2d1d4bec66b9c610a1e Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Tue, 2 May 2017 22:10:05 +0300 Subject: [PATCH 36/81] Multi-db support --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 32337cc3a7f1..9ddbaef62d0e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -69,7 +69,7 @@ before_script: - curl -L http://ninja.dev:8000/update - php artisan ninja:create-test-data 4 true - php artisan db:seed --no-interaction --class=UserTableSeeder # development seed - - sed -i 's/DB_TYPE=db-ninja-1/DB_TYPE=db-ninja-2/g' .env + #- sed -i 's/DB_TYPE=db-ninja-1/DB_TYPE=db-ninja-2/g' .env script: - php ./vendor/codeception/codeception/codecept run --debug acceptance APICest.php From 6aa978bd2dcb18490789bf04ffe88b59c7acedc0 Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Tue, 2 May 2017 22:23:06 +0300 Subject: [PATCH 37/81] Multi-db support --- .travis.yml | 54 ++++++++++++++++++++++++++--------------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/.travis.yml b/.travis.yml index 9ddbaef62d0e..148110217697 100644 --- a/.travis.yml +++ b/.travis.yml @@ -50,10 +50,10 @@ before_script: - sed -i '$a NINJA_DEV=true' .env # create the database and user - mysql -u root -e "create database IF NOT EXISTS ninja0;" - - mysql -u root -e "create database IF NOT EXISTS ninja1;" + - mysql -u root -e "create database IF NOT EXISTS ninja;" - mysql -u root -e "create database IF NOT EXISTS ninja2;" - mysql -u root -e "GRANT ALL PRIVILEGES ON ninja0.* To 'ninja'@'localhost' IDENTIFIED BY 'ninja'; FLUSH PRIVILEGES;" - - mysql -u root -e "GRANT ALL PRIVILEGES ON ninja1.* To 'ninja'@'localhost' IDENTIFIED BY 'ninja'; FLUSH PRIVILEGES;" + - mysql -u root -e "GRANT ALL PRIVILEGES ON ninja.* To 'ninja'@'localhost' IDENTIFIED BY 'ninja'; FLUSH PRIVILEGES;" - mysql -u root -e "GRANT ALL PRIVILEGES ON ninja2.* To 'ninja'@'localhost' IDENTIFIED BY 'ninja'; FLUSH PRIVILEGES;" # migrate and seed the database - php artisan migrate --database=db-ninja-0 --seed --no-interaction @@ -72,19 +72,19 @@ before_script: #- sed -i 's/DB_TYPE=db-ninja-1/DB_TYPE=db-ninja-2/g' .env script: - - php ./vendor/codeception/codeception/codecept run --debug acceptance APICest.php - - php ./vendor/codeception/codeception/codecept run --debug acceptance TaxRatesCest.php - - php ./vendor/codeception/codeception/codecept run --debug acceptance CheckBalanceCest.php - - php ./vendor/codeception/codeception/codecept run --debug acceptance ClientCest.php - - php ./vendor/codeception/codeception/codecept run --debug acceptance ExpenseCest.php - - php ./vendor/codeception/codeception/codecept run --debug acceptance CreditCest.php - - php ./vendor/codeception/codeception/codecept run --debug acceptance InvoiceCest.php - - php ./vendor/codeception/codeception/codecept run --debug acceptance QuoteCest.php - - php ./vendor/codeception/codeception/codecept run --debug acceptance InvoiceDesignCest.php - - php ./vendor/codeception/codeception/codecept run --debug acceptance OnlinePaymentCest.php - - php ./vendor/codeception/codeception/codecept run --debug acceptance PaymentCest.php - - php ./vendor/codeception/codeception/codecept run --debug acceptance TaskCest.php - - php ./vendor/codeception/codeception/codecept run --debug acceptance GatewayFeesCest.php + #- php ./vendor/codeception/codeception/codecept run --debug acceptance APICest.php + #- php ./vendor/codeception/codeception/codecept run --debug acceptance TaxRatesCest.php + #- php ./vendor/codeception/codeception/codecept run --debug acceptance CheckBalanceCest.php + #- php ./vendor/codeception/codeception/codecept run --debug acceptance ClientCest.php + #- php ./vendor/codeception/codeception/codecept run --debug acceptance ExpenseCest.php + #- php ./vendor/codeception/codeception/codecept run --debug acceptance CreditCest.php + #- php ./vendor/codeception/codeception/codecept run --debug acceptance InvoiceCest.php + #- php ./vendor/codeception/codeception/codecept run --debug acceptance QuoteCest.php + #- php ./vendor/codeception/codeception/codecept run --debug acceptance InvoiceDesignCest.php + #- php ./vendor/codeception/codeception/codecept run --debug acceptance OnlinePaymentCest.php + #- php ./vendor/codeception/codeception/codecept run --debug acceptance PaymentCest.php + #- php ./vendor/codeception/codeception/codecept run --debug acceptance TaskCest.php + #- php ./vendor/codeception/codeception/codecept run --debug acceptance GatewayFeesCest.php - php ./vendor/codeception/codeception/codecept run --debug acceptance AllPagesCept.php #- sed -i 's/NINJA_DEV=true/NINJA_PROD=true/g' .env @@ -97,18 +97,18 @@ after_script: - mysql -u root -e 'select * from lookup_accounts;' ninja0 - mysql -u root -e 'select * from lookup_contacts;' ninja0 - mysql -u root -e 'select * from lookup_invitations;' ninja0 - - mysql -u root -e 'select * from accounts;' ninja1 - - mysql -u root -e 'select * from users;' ninja1 - - mysql -u root -e 'select * from account_gateways;' ninja1 - - mysql -u root -e 'select * from clients;' ninja1 - - mysql -u root -e 'select * from contacts;' ninja1 - - mysql -u root -e 'select * from invoices;' ninja1 - - mysql -u root -e 'select * from invoice_items;' ninja1 - - mysql -u root -e 'select * from invitations;' ninja1 - - mysql -u root -e 'select * from payments;' ninja1 - - mysql -u root -e 'select * from credits;' ninja1 - - mysql -u root -e 'select * from expenses;' ninja1 - - mysql -u root -e 'select * from accounts;' ninja2 + - mysql -u root -e 'select * from accounts;' ninja + - mysql -u root -e 'select * from users;' ninja + - mysql -u root -e 'select * from account_gateways;' ninja + - mysql -u root -e 'select * from clients;' ninja + - mysql -u root -e 'select * from contacts;' ninja + - mysql -u root -e 'select * from invoices;' ninja + - mysql -u root -e 'select * from invoice_items;' ninja + - mysql -u root -e 'select * from invitations;' ninja + - mysql -u root -e 'select * from payments;' ninja + - mysql -u root -e 'select * from credits;' ninja + - mysql -u root -e 'select * from expenses;' ninja + - mysql -u root -e 'select * from accounts;' ninja - cat storage/logs/laravel-error.log - cat storage/logs/laravel-info.log - FILES=$(find tests/_output -type f -name '*.png' | sort -nr) From 80182d1e29eb7872424b891a56318a64f3856449 Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Tue, 2 May 2017 22:25:55 +0300 Subject: [PATCH 38/81] Multi-db support --- .env.travis | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.travis b/.env.travis index 166606f0eb03..5d85335c5110 100644 --- a/.env.travis +++ b/.env.travis @@ -15,7 +15,7 @@ DB_USERNAME=ninja DB_PASSWORD=ninja DB_DATABASE0=ninja0 -DB_DATABASE1=ninja1 +DB_DATABASE1=ninja DB_DATABASE2=ninja2 MAIL_DRIVER=log From 390c53cd127f85fdfa5ec0f65d6206e79a48b14b Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Tue, 2 May 2017 22:31:24 +0300 Subject: [PATCH 39/81] Multi-db support --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 148110217697..11e1688a84a7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -69,7 +69,7 @@ before_script: - curl -L http://ninja.dev:8000/update - php artisan ninja:create-test-data 4 true - php artisan db:seed --no-interaction --class=UserTableSeeder # development seed - #- sed -i 's/DB_TYPE=db-ninja-1/DB_TYPE=db-ninja-2/g' .env + - sed -i 's/DB_TYPE=db-ninja-1/DB_TYPE=db-ninja-2/g' .env script: #- php ./vendor/codeception/codeception/codecept run --debug acceptance APICest.php From e560186bd6692a260187ca9484eae9ee4f430be4 Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Tue, 2 May 2017 22:39:07 +0300 Subject: [PATCH 40/81] Multi-db support --- app/Http/Controllers/AppController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Http/Controllers/AppController.php b/app/Http/Controllers/AppController.php index c02de66e855a..3eb9a65f3c63 100644 --- a/app/Http/Controllers/AppController.php +++ b/app/Http/Controllers/AppController.php @@ -351,7 +351,7 @@ class AppController extends BaseController { try { Artisan::call('ninja:check-data'); - //Artisan::call('ninja:check-data'); + Artisan::call('ninja:init-lookup', ['--validate' => true]); return RESULT_SUCCESS; } catch (Exception $exception) { return RESULT_FAILURE; From ba90b3bceac81428a53d2cb523e4f98545b032c4 Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Tue, 2 May 2017 22:46:29 +0300 Subject: [PATCH 41/81] Multi-db support --- app/Http/Controllers/AppController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Http/Controllers/AppController.php b/app/Http/Controllers/AppController.php index 3eb9a65f3c63..add8da162571 100644 --- a/app/Http/Controllers/AppController.php +++ b/app/Http/Controllers/AppController.php @@ -354,7 +354,7 @@ class AppController extends BaseController Artisan::call('ninja:init-lookup', ['--validate' => true]); return RESULT_SUCCESS; } catch (Exception $exception) { - return RESULT_FAILURE; + return $exception->getMessage() ?: RESULT_FAILURE; } } From eae2fca3a5e957d159bb68c590a965174835943a Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Tue, 2 May 2017 22:50:45 +0300 Subject: [PATCH 42/81] Multi-db support --- app/Console/Commands/InitLookup.php | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/app/Console/Commands/InitLookup.php b/app/Console/Commands/InitLookup.php index 64944f6d47af..acfc9c6b0e70 100644 --- a/app/Console/Commands/InitLookup.php +++ b/app/Console/Commands/InitLookup.php @@ -73,14 +73,16 @@ class InitLookup extends Command $this->info($this->log); - if ($this->option('validate') && $errorEmail) { - Mail::raw($this->log, function ($message) use ($errorEmail, $database) { - $message->to($errorEmail) - ->from(CONTACT_EMAIL) - ->subject("Check-Lookups [{$database}]: " . strtoupper($this->isValid ? RESULT_SUCCESS : RESULT_FAILURE)); - }); - } elseif (! $this->isValid) { - throw new Exception('Check data failed!!'); + if ($this->option('validate')) { + if ($errorEmail = env('ERROR_EMAIL')) { + Mail::raw($this->log, function ($message) use ($errorEmail, $database) { + $message->to($errorEmail) + ->from(CONTACT_EMAIL) + ->subject("Check-Lookups [{$database}]: " . strtoupper($this->isValid ? RESULT_SUCCESS : RESULT_FAILURE)); + }); + } elseif (! $this->isValid) { + throw new Exception('Check lookups failed!!'); + } } } From 2d5d08b17229b03b06a7341f6277b906aa076487 Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Tue, 2 May 2017 22:58:24 +0300 Subject: [PATCH 43/81] Multi-db support --- .travis.yml | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/.travis.yml b/.travis.yml index 11e1688a84a7..d1f2b568c9fe 100644 --- a/.travis.yml +++ b/.travis.yml @@ -72,19 +72,19 @@ before_script: - sed -i 's/DB_TYPE=db-ninja-1/DB_TYPE=db-ninja-2/g' .env script: - #- php ./vendor/codeception/codeception/codecept run --debug acceptance APICest.php - #- php ./vendor/codeception/codeception/codecept run --debug acceptance TaxRatesCest.php - #- php ./vendor/codeception/codeception/codecept run --debug acceptance CheckBalanceCest.php - #- php ./vendor/codeception/codeception/codecept run --debug acceptance ClientCest.php - #- php ./vendor/codeception/codeception/codecept run --debug acceptance ExpenseCest.php - #- php ./vendor/codeception/codeception/codecept run --debug acceptance CreditCest.php - #- php ./vendor/codeception/codeception/codecept run --debug acceptance InvoiceCest.php - #- php ./vendor/codeception/codeception/codecept run --debug acceptance QuoteCest.php - #- php ./vendor/codeception/codeception/codecept run --debug acceptance InvoiceDesignCest.php - #- php ./vendor/codeception/codeception/codecept run --debug acceptance OnlinePaymentCest.php - #- php ./vendor/codeception/codeception/codecept run --debug acceptance PaymentCest.php - #- php ./vendor/codeception/codeception/codecept run --debug acceptance TaskCest.php - #- php ./vendor/codeception/codeception/codecept run --debug acceptance GatewayFeesCest.php + - php ./vendor/codeception/codeception/codecept run --debug acceptance APICest.php + - php ./vendor/codeception/codeception/codecept run --debug acceptance TaxRatesCest.php + - php ./vendor/codeception/codeception/codecept run --debug acceptance CheckBalanceCest.php + - php ./vendor/codeception/codeception/codecept run --debug acceptance ClientCest.php + - php ./vendor/codeception/codeception/codecept run --debug acceptance ExpenseCest.php + - php ./vendor/codeception/codeception/codecept run --debug acceptance CreditCest.php + - php ./vendor/codeception/codeception/codecept run --debug acceptance InvoiceCest.php + - php ./vendor/codeception/codeception/codecept run --debug acceptance QuoteCest.php + - php ./vendor/codeception/codeception/codecept run --debug acceptance InvoiceDesignCest.php + - php ./vendor/codeception/codeception/codecept run --debug acceptance OnlinePaymentCest.php + - php ./vendor/codeception/codeception/codecept run --debug acceptance PaymentCest.php + - php ./vendor/codeception/codeception/codecept run --debug acceptance TaskCest.php + - php ./vendor/codeception/codeception/codecept run --debug acceptance GatewayFeesCest.php - php ./vendor/codeception/codeception/codecept run --debug acceptance AllPagesCept.php #- sed -i 's/NINJA_DEV=true/NINJA_PROD=true/g' .env From 4a75925f5db2edf318629a8869684308522b813d Mon Sep 17 00:00:00 2001 From: David Bomba Date: Wed, 3 May 2017 11:18:30 +1000 Subject: [PATCH 44/81] Allow the API to update is_running fields in tasks (#1472) --- app/Ninja/Repositories/TaskRepository.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/Ninja/Repositories/TaskRepository.php b/app/Ninja/Repositories/TaskRepository.php index 5bf89158af54..1ad61307bb4c 100644 --- a/app/Ninja/Repositories/TaskRepository.php +++ b/app/Ninja/Repositories/TaskRepository.php @@ -146,6 +146,8 @@ class TaskRepository extends BaseRepository } elseif ($data['action'] == 'stop' && $task->is_running) { $timeLog[count($timeLog) - 1][1] = time(); $task->is_running = false; + } elseif ($data['action'] == 'offine'){ + $task->is_running = $data['is_running']; } } From 29969e45ff58bf9a49224b7d490499403e7fe09b Mon Sep 17 00:00:00 2001 From: David Bomba Date: Wed, 3 May 2017 13:51:52 +1000 Subject: [PATCH 45/81] Typo fix typo --- app/Ninja/Repositories/TaskRepository.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Ninja/Repositories/TaskRepository.php b/app/Ninja/Repositories/TaskRepository.php index 1ad61307bb4c..0119662952d0 100644 --- a/app/Ninja/Repositories/TaskRepository.php +++ b/app/Ninja/Repositories/TaskRepository.php @@ -146,7 +146,7 @@ class TaskRepository extends BaseRepository } elseif ($data['action'] == 'stop' && $task->is_running) { $timeLog[count($timeLog) - 1][1] = time(); $task->is_running = false; - } elseif ($data['action'] == 'offine'){ + } elseif ($data['action'] == 'offline'){ $task->is_running = $data['is_running']; } } From 2d28bc8e63351ea8aeb53a4e0f74ec433bcde403 Mon Sep 17 00:00:00 2001 From: David Bomba Date: Wed, 3 May 2017 14:26:26 +1000 Subject: [PATCH 46/81] Update TaskRepository.php Ternary operator for $task->is_running --- app/Ninja/Repositories/TaskRepository.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Ninja/Repositories/TaskRepository.php b/app/Ninja/Repositories/TaskRepository.php index 0119662952d0..54575488698f 100644 --- a/app/Ninja/Repositories/TaskRepository.php +++ b/app/Ninja/Repositories/TaskRepository.php @@ -147,7 +147,7 @@ class TaskRepository extends BaseRepository $timeLog[count($timeLog) - 1][1] = time(); $task->is_running = false; } elseif ($data['action'] == 'offline'){ - $task->is_running = $data['is_running']; + $task->is_running = $data['is_running'] ? 1 : 0; } } From 5dec26badd76bb52d28d0e9074db3af9e88191b6 Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Wed, 3 May 2017 07:57:26 +0300 Subject: [PATCH 47/81] Fix for tests --- .env.travis | 1 + 1 file changed, 1 insertion(+) diff --git a/.env.travis b/.env.travis index 5d85335c5110..76b578295e0d 100644 --- a/.env.travis +++ b/.env.travis @@ -20,3 +20,4 @@ DB_DATABASE2=ninja2 MAIL_DRIVER=log TRAVIS=true +API_SECRET=password From 782ae969a40bce0c702bc1f7cc1e832ec038cac9 Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Wed, 3 May 2017 19:26:03 +0300 Subject: [PATCH 48/81] Multi-db support --- app/Console/Commands/CheckData.php | 24 ++++++++++++ app/Console/Commands/InitLookup.php | 2 +- app/Console/Commands/PruneData.php | 40 +++++++++++--------- app/Ninja/Repositories/AccountRepository.php | 1 + 4 files changed, 48 insertions(+), 19 deletions(-) diff --git a/app/Console/Commands/CheckData.php b/app/Console/Commands/CheckData.php index d769d2ed5be9..636b9680c14f 100644 --- a/app/Console/Commands/CheckData.php +++ b/app/Console/Commands/CheckData.php @@ -105,6 +105,29 @@ class CheckData extends Command private function checkContacts() { + // check for contacts with the contact_key value set + $contacts = DB::table('contacts') + ->whereNull('contact_key') + ->orderBy('id') + ->get(['id']); + $this->logMessage(count($contacts) . ' contacts without a contact_key'); + + if (count($contacts) > 0) { + $this->isValid = false; + } + + if ($this->option('fix') == 'true') { + foreach ($contacts as $contact) { + DB::table('contacts') + ->where('id', $contact->id) + ->whereNull('contact_key') + ->update([ + 'contact_key' => strtolower(str_random(RANDOM_KEY_LENGTH)), + ]); + } + } + + // check for missing contacts $clients = DB::table('clients') ->leftJoin('contacts', function($join) { $join->on('contacts.client_id', '=', 'clients.id') @@ -138,6 +161,7 @@ class CheckData extends Command } } + // check for more than one primary contact $clients = DB::table('clients') ->leftJoin('contacts', function($join) { $join->on('contacts.client_id', '=', 'clients.id') diff --git a/app/Console/Commands/InitLookup.php b/app/Console/Commands/InitLookup.php index acfc9c6b0e70..2dfa0facd7e4 100644 --- a/app/Console/Commands/InitLookup.php +++ b/app/Console/Commands/InitLookup.php @@ -144,7 +144,7 @@ class InitLookup extends Command } else { LookupUser::create([ 'lookup_account_id' => $lookupAccount->id, - 'email' => $user['email'], + 'email' => $user['email'] ?: null, 'user_id' => $user['user_id'], ]); } diff --git a/app/Console/Commands/PruneData.php b/app/Console/Commands/PruneData.php index e42b21767f92..d0b889f5ab68 100644 --- a/app/Console/Commands/PruneData.php +++ b/app/Console/Commands/PruneData.php @@ -31,28 +31,32 @@ class PruneData extends Command // delete accounts who never registered, didn't create any invoices, // hansn't logged in within the past 6 months and isn't linked to another account - $sql = 'select a.id - from (select id, last_login from accounts) a - left join users u on u.account_id = a.id and u.public_id = 0 - left join invoices i on i.account_id = a.id - left join user_accounts ua1 on ua1.user_id1 = u.id - left join user_accounts ua2 on ua2.user_id2 = u.id - left join user_accounts ua3 on ua3.user_id3 = u.id - left join user_accounts ua4 on ua4.user_id4 = u.id - left join user_accounts ua5 on ua5.user_id5 = u.id - where u.registered = 0 - and a.last_login < DATE_SUB(now(), INTERVAL 6 MONTH) - and (ua1.id is null and ua2.id is null and ua3.id is null and ua4.id is null and ua5.id is null) - group by a.id - having count(i.id) = 0'; + $sql = 'select c.id + from companies c + left join accounts a on a.company_id = c.id + left join clients c on c.account_id = a.id + left join tasks t on t.account_id = a.id + left join expenses e on e.account_id = a.id + left join users u on u.account_id = a.id and u.registered = 1 + where c.created_at < DATE_SUB(now(), INTERVAL 6 MONTH) + group by c.id + having count(c.id) = 0 + and count(t.id) = 0 + and count(e.id) = 0 + and count(u.id) = 0'; $results = DB::select($sql); foreach ($results as $result) { - $this->info("Deleting {$result->id}"); - DB::table('accounts') - ->where('id', '=', $result->id) - ->delete(); + $this->info("Deleting company: {$result->id}"); + try { + DB::table('companies') + ->where('id', '=', $result->id) + ->delete(); + } catch (\Illuminate\Database\QueryException $e) { + // most likely because a user_account record exists which doesn't cascade delete + $this->info("Unable to delete companyId: {$result->id}"); + } } $this->info('Done'); diff --git a/app/Ninja/Repositories/AccountRepository.php b/app/Ninja/Repositories/AccountRepository.php index 51ff426c2bbc..c6aa4875f6b5 100644 --- a/app/Ninja/Repositories/AccountRepository.php +++ b/app/Ninja/Repositories/AccountRepository.php @@ -415,6 +415,7 @@ class AccountRepository $contact->user_id = $ninjaUser->id; $contact->account_id = $ninjaAccount->id; $contact->public_id = $account->id; + $contact->contact_key = strtolower(str_random(RANDOM_KEY_LENGTH)); $contact->is_primary = true; } From 7b797da220cca0680e1290b7a075c10996086c63 Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Wed, 3 May 2017 21:00:42 +0300 Subject: [PATCH 49/81] Multi-db support --- app/Console/Commands/InitLookup.php | 5 +++-- app/Console/Commands/PruneData.php | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/Console/Commands/InitLookup.php b/app/Console/Commands/InitLookup.php index 2dfa0facd7e4..d639068b035f 100644 --- a/app/Console/Commands/InitLookup.php +++ b/app/Console/Commands/InitLookup.php @@ -54,7 +54,8 @@ class InitLookup extends Command config(['database.default' => DB_NINJA_LOOKUP]); - $dbServer = DbServer::whereName($this->option('database'))->first(); + $database = $this->option('database'); + $dbServer = DbServer::whereName($database)->first(); if ($this->option('truncate')) { $this->truncateTables(); @@ -72,6 +73,7 @@ class InitLookup extends Command } $this->info($this->log); + $this->info('Valid: ' . ($this->isValid ? RESULT_SUCCESS : RESULT_FAILURE)); if ($this->option('validate')) { if ($errorEmail = env('ERROR_EMAIL')) { @@ -105,7 +107,6 @@ class InitLookup extends Command config(['database.default' => DB_NINJA_LOOKUP]); foreach ($data as $companyId => $company) { - $this->logMessage('Company Id: ' . $companyId); if ($this->option('validate')) { $lookupCompany = LookupCompany::whereDbServerId($dbServerId)->whereCompanyId($companyId)->first(); diff --git a/app/Console/Commands/PruneData.php b/app/Console/Commands/PruneData.php index d0b889f5ab68..1d350e1933e1 100644 --- a/app/Console/Commands/PruneData.php +++ b/app/Console/Commands/PruneData.php @@ -34,13 +34,13 @@ class PruneData extends Command $sql = 'select c.id from companies c left join accounts a on a.company_id = c.id - left join clients c on c.account_id = a.id + left join clients cl on cl.account_id = a.id left join tasks t on t.account_id = a.id left join expenses e on e.account_id = a.id left join users u on u.account_id = a.id and u.registered = 1 where c.created_at < DATE_SUB(now(), INTERVAL 6 MONTH) group by c.id - having count(c.id) = 0 + having count(cl.id) = 0 and count(t.id) = 0 and count(e.id) = 0 and count(u.id) = 0'; From f61b2842e7b97a965b9d7cc13415a7054a487814 Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Wed, 3 May 2017 22:03:33 +0300 Subject: [PATCH 50/81] Share invoice counter with Quotes Error #1474 --- app/Models/Traits/GeneratesNumbers.php | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/app/Models/Traits/GeneratesNumbers.php b/app/Models/Traits/GeneratesNumbers.php index c94e50e3edba..bbb87700f70f 100644 --- a/app/Models/Traits/GeneratesNumbers.php +++ b/app/Models/Traits/GeneratesNumbers.php @@ -26,6 +26,7 @@ trait GeneratesNumbers $prefix = $this->getNumberPrefix($entityType); $counterOffset = 0; $check = false; + $lastNumber = false; if ($entityType == ENTITY_CLIENT && ! $this->clientNumbersEnabled()) { return ''; @@ -50,6 +51,13 @@ trait GeneratesNumbers } $counter++; $counterOffset++; + + // prevent getting stuck in a loop + if ($number == $lastNumber) { + return ''; + } + $lastNumber = $number; + } while ($check); // update the counter to be caught up @@ -194,15 +202,17 @@ trait GeneratesNumbers '{$clientCounter}', ]; + $client = $invoice->client; + $clientCounter = ($invoice->isQuote && ! $this->share_counter) ? $client->quote_number_counter : $client->invoice_number_counter; + $replace = [ - $invoice->client->custom_value1, - $invoice->client->custom_value2, - $invoice->client->id_number, - $invoice->client->custom_value1, // backwards compatibility - $invoice->client->custom_value2, - $invoice->client->id_number, - str_pad($invoice->client->invoice_number_counter, $this->invoice_number_padding, '0', STR_PAD_LEFT), - str_pad($invoice->client->quote_number_counter, $this->invoice_number_padding, '0', STR_PAD_LEFT), + $client->custom_value1, + $client->custom_value2, + $client->id_number, + $client->custom_value1, // backwards compatibility + $client->custom_value2, + $client->id_number, + str_pad($clientCounter, $this->invoice_number_padding, '0', STR_PAD_LEFT), ]; return str_replace($search, $replace, $pattern); From 111aa356f35b3d5d5927448e6565dcb5cbe9cb6f Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Thu, 4 May 2017 02:39:31 +0300 Subject: [PATCH 51/81] Fix bug --- app/Models/Traits/GeneratesNumbers.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Models/Traits/GeneratesNumbers.php b/app/Models/Traits/GeneratesNumbers.php index bbb87700f70f..2c3bf6880363 100644 --- a/app/Models/Traits/GeneratesNumbers.php +++ b/app/Models/Traits/GeneratesNumbers.php @@ -203,7 +203,7 @@ trait GeneratesNumbers ]; $client = $invoice->client; - $clientCounter = ($invoice->isQuote && ! $this->share_counter) ? $client->quote_number_counter : $client->invoice_number_counter; + $clientCounter = ($invoice->isQuote() && ! $this->share_counter) ? $client->quote_number_counter : $client->invoice_number_counter; $replace = [ $client->custom_value1, From 633d73f89babc4c68a7900eee840b8881874b64b Mon Sep 17 00:00:00 2001 From: David Bomba Date: Thu, 4 May 2017 15:29:53 +1000 Subject: [PATCH 52/81] Update TaskRepository.php Allow client to be updated from API. --- app/Ninja/Repositories/TaskRepository.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/Ninja/Repositories/TaskRepository.php b/app/Ninja/Repositories/TaskRepository.php index 54575488698f..b3f4a511567e 100644 --- a/app/Ninja/Repositories/TaskRepository.php +++ b/app/Ninja/Repositories/TaskRepository.php @@ -148,6 +148,7 @@ class TaskRepository extends BaseRepository $task->is_running = false; } elseif ($data['action'] == 'offline'){ $task->is_running = $data['is_running'] ? 1 : 0; + $task->client_id = $data['client_id']; } } From bbcc04aec5f0b8aabbeaa4aee13b0ef610a244da Mon Sep 17 00:00:00 2001 From: David Bomba Date: Thu, 4 May 2017 19:59:37 +1000 Subject: [PATCH 53/81] fix for client_id (#1475) --- app/Ninja/Repositories/TaskRepository.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/Ninja/Repositories/TaskRepository.php b/app/Ninja/Repositories/TaskRepository.php index b3f4a511567e..a0b66d5bab46 100644 --- a/app/Ninja/Repositories/TaskRepository.php +++ b/app/Ninja/Repositories/TaskRepository.php @@ -134,6 +134,10 @@ class TaskRepository extends BaseRepository $timeLog = []; } + if(isset($data['client_id'])) { + $task->client_id = Client::getPrivateId($data['client_id']); + } + array_multisort($timeLog); if (isset($data['action'])) { @@ -148,7 +152,6 @@ class TaskRepository extends BaseRepository $task->is_running = false; } elseif ($data['action'] == 'offline'){ $task->is_running = $data['is_running'] ? 1 : 0; - $task->client_id = $data['client_id']; } } From aab0f6517ffc9ea735c8c21fa9729f5082ecb0ce Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Thu, 4 May 2017 14:00:43 +0300 Subject: [PATCH 54/81] Add check for user accounts --- app/Console/Commands/CheckData.php | 56 ++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/app/Console/Commands/CheckData.php b/app/Console/Commands/CheckData.php index 636b9680c14f..a000c0530a6e 100644 --- a/app/Console/Commands/CheckData.php +++ b/app/Console/Commands/CheckData.php @@ -75,6 +75,7 @@ class CheckData extends Command $this->checkBalances(); $this->checkContacts(); + //$this->checkUserAccounts(); if (! $this->option('client_id')) { $this->checkInvitations(); @@ -103,6 +104,61 @@ class CheckData extends Command $this->log .= $str . "\n"; } + private function checkUserAccounts() + { + $userAccounts = DB::table('user_accounts') + ->leftJoin('users as u1', 'u1.id', '=', 'user_accounts.user_id1') + ->leftJoin('accounts as a1', 'a1.id', '=', 'u1.account_id') + ->leftJoin('users as u2', 'u2.id', '=', 'user_accounts.user_id2') + ->leftJoin('accounts as a2', 'a2.id', '=', 'u2.account_id') + ->leftJoin('users as u3', 'u3.id', '=', 'user_accounts.user_id3') + ->leftJoin('accounts as a3', 'a3.id', '=', 'u3.account_id') + ->leftJoin('users as u4', 'u4.id', '=', 'user_accounts.user_id4') + ->leftJoin('accounts as a4', 'a4.id', '=', 'u4.account_id') + ->leftJoin('users as u5', 'u5.id', '=', 'user_accounts.user_id5') + ->leftJoin('accounts as a5', 'a5.id', '=', 'u5.account_id') + ->get([ + 'user_accounts.id', + 'a1.company_id as a1_company_id', + 'a2.company_id as a2_company_id', + 'a3.company_id as a3_company_id', + 'a4.company_id as a4_company_id', + 'a5.company_id as a5_company_id', + ]); + + $countInvalid = 0; + + foreach ($userAccounts as $userAccount) { + $ids = []; + + if ($companyId1 = $userAccount->a1_company_id) { + $ids[$companyId1] = true; + } + if ($companyId2 = $userAccount->a2_company_id) { + $ids[$companyId2] = true; + } + if ($companyId3 = $userAccount->a3_company_id) { + $ids[$companyId3] = true; + } + if ($companyId4 = $userAccount->a4_company_id) { + $ids[$companyId4] = true; + } + if ($companyId5 = $userAccount->a5_company_id) { + $ids[$companyId5] = true; + } + + if (count($ids) > 1) { + $this->info('user_account: ' . $userAccount->id); + $countInvalid++; + } + } + + if ($countInvalid > 0) { + $this->logMessage($countInvalid . ' user accounts with multiple companies'); + $this->isValid = false; + } + } + private function checkContacts() { // check for contacts with the contact_key value set From 56576e8f59e819e9a4c2c5d992f31c5da90f20f9 Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Thu, 4 May 2017 14:16:09 +0300 Subject: [PATCH 55/81] Update prune data script --- app/Console/Commands/PruneData.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/Console/Commands/PruneData.php b/app/Console/Commands/PruneData.php index 1d350e1933e1..adc67edc7f18 100644 --- a/app/Console/Commands/PruneData.php +++ b/app/Console/Commands/PruneData.php @@ -39,6 +39,8 @@ class PruneData extends Command left join expenses e on e.account_id = a.id left join users u on u.account_id = a.id and u.registered = 1 where c.created_at < DATE_SUB(now(), INTERVAL 6 MONTH) + and c.trial_started is null + and c.plan is null group by c.id having count(cl.id) = 0 and count(t.id) = 0 From ceb4ddc5d53dc5cca7bf7c513f84675d71f354cd Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Thu, 4 May 2017 14:16:48 +0300 Subject: [PATCH 56/81] Update prune data script --- app/Console/Commands/CheckData.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/Console/Commands/CheckData.php b/app/Console/Commands/CheckData.php index a000c0530a6e..7d37b3ade605 100644 --- a/app/Console/Commands/CheckData.php +++ b/app/Console/Commands/CheckData.php @@ -75,6 +75,8 @@ class CheckData extends Command $this->checkBalances(); $this->checkContacts(); + + // TODO Enable once user_account companies have been merged //$this->checkUserAccounts(); if (! $this->option('client_id')) { From 42aef958fe12867ce8a84098e4ef7e4ee230c1fa Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Thu, 4 May 2017 15:53:36 +0300 Subject: [PATCH 57/81] Fix for check data --- app/Console/Commands/CheckData.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Console/Commands/CheckData.php b/app/Console/Commands/CheckData.php index 7d37b3ade605..7cef1ca57df4 100644 --- a/app/Console/Commands/CheckData.php +++ b/app/Console/Commands/CheckData.php @@ -438,7 +438,7 @@ class CheckData extends Command $clients->where('clients.id', '=', $this->option('client_id')); } - $clients = $clients->groupBy('clients.id', 'clients.balance', 'clients.created_at') + $clients = $clients->groupBy('clients.id', 'clients.balance') ->orderBy('accounts.company_id', 'DESC') ->get(['accounts.company_id', 'clients.account_id', 'clients.id', 'clients.balance', 'clients.paid_to_date', DB::raw('sum(invoices.balance) actual_balance')]); $this->logMessage(count($clients) . ' clients with incorrect balance/activities'); From 03bb75674b154c42b75174662dc94cb640af814f Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Thu, 4 May 2017 20:08:27 +0300 Subject: [PATCH 58/81] Multi-db support --- app/Jobs/ImportData.php | 6 ++++++ app/Jobs/SendInvoiceEmail.php | 6 ++++++ app/Jobs/SendNotificationEmail.php | 6 ++++++ app/Jobs/SendPaymentEmail.php | 6 ++++++ app/Jobs/SendPushNotification.php | 6 ++++++ app/Libraries/Utils.php | 1 + app/Listeners/InvoiceListener.php | 2 ++ app/Providers/AppServiceProvider.php | 10 ++++++++++ config/queue.php | 4 +++- 9 files changed, 46 insertions(+), 1 deletion(-) diff --git a/app/Jobs/ImportData.php b/app/Jobs/ImportData.php index 31271c49e075..b1427775f7ed 100644 --- a/app/Jobs/ImportData.php +++ b/app/Jobs/ImportData.php @@ -34,6 +34,11 @@ class ImportData extends Job implements ShouldQueue */ protected $settings; + /** + * @var string + */ + protected $server; + /** * Create a new job instance. * @@ -45,6 +50,7 @@ class ImportData extends Job implements ShouldQueue $this->user = $user; $this->type = $type; $this->settings = $settings; + $this->server = config('database.default'); } /** diff --git a/app/Jobs/SendInvoiceEmail.php b/app/Jobs/SendInvoiceEmail.php index 04168b609f8e..1046372686ca 100644 --- a/app/Jobs/SendInvoiceEmail.php +++ b/app/Jobs/SendInvoiceEmail.php @@ -38,6 +38,11 @@ class SendInvoiceEmail extends Job implements ShouldQueue */ protected $userId; + /** + * @var string + */ + protected $server; + /** * Create a new job instance. * @@ -52,6 +57,7 @@ class SendInvoiceEmail extends Job implements ShouldQueue $this->userId = $userId; $this->reminder = $reminder; $this->template = $template; + $this->server = config('database.default'); } /** diff --git a/app/Jobs/SendNotificationEmail.php b/app/Jobs/SendNotificationEmail.php index 04fd4fd77c96..e74ace04d10a 100644 --- a/app/Jobs/SendNotificationEmail.php +++ b/app/Jobs/SendNotificationEmail.php @@ -40,6 +40,11 @@ class SendNotificationEmail extends Job implements ShouldQueue */ protected $notes; + /** + * @var string + */ + protected $server; + /** * Create a new job instance. @@ -58,6 +63,7 @@ class SendNotificationEmail extends Job implements ShouldQueue $this->type = $type; $this->payment = $payment; $this->notes = $notes; + $this->server = config('database.default'); } /** diff --git a/app/Jobs/SendPaymentEmail.php b/app/Jobs/SendPaymentEmail.php index e23d291efcec..8e990c00de93 100644 --- a/app/Jobs/SendPaymentEmail.php +++ b/app/Jobs/SendPaymentEmail.php @@ -20,6 +20,11 @@ class SendPaymentEmail extends Job implements ShouldQueue */ protected $payment; + /** + * @var string + */ + protected $server; + /** * Create a new job instance. @@ -28,6 +33,7 @@ class SendPaymentEmail extends Job implements ShouldQueue public function __construct($payment) { $this->payment = $payment; + $this->server = config('database.default'); } /** diff --git a/app/Jobs/SendPushNotification.php b/app/Jobs/SendPushNotification.php index 88a1e59e1789..73acd9299142 100644 --- a/app/Jobs/SendPushNotification.php +++ b/app/Jobs/SendPushNotification.php @@ -25,6 +25,11 @@ class SendPushNotification extends Job implements ShouldQueue */ protected $type; + /** + * @var string + */ + protected $server; + /** * Create a new job instance. @@ -35,6 +40,7 @@ class SendPushNotification extends Job implements ShouldQueue { $this->invoice = $invoice; $this->type = $type; + $this->server = config('database.default'); } /** diff --git a/app/Libraries/Utils.php b/app/Libraries/Utils.php index 695d1661cb39..ff935a32b906 100644 --- a/app/Libraries/Utils.php +++ b/app/Libraries/Utils.php @@ -393,6 +393,7 @@ class Utils 'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '', 'ip' => Request::getClientIp(), 'count' => Session::get('error_count', 0), + 'is_console' => App::runningInConsole() ? 'yes' : 'no', ]; if ($info) { diff --git a/app/Listeners/InvoiceListener.php b/app/Listeners/InvoiceListener.php index 3888ccbda063..4883667cd89d 100644 --- a/app/Listeners/InvoiceListener.php +++ b/app/Listeners/InvoiceListener.php @@ -153,6 +153,7 @@ class InvoiceListener public function jobFailed(JobExceptionOccurred $exception) { + /* if ($errorEmail = env('ERROR_EMAIL')) { \Mail::raw(print_r($exception->data, true), function ($message) use ($errorEmail) { $message->to($errorEmail) @@ -160,6 +161,7 @@ class InvoiceListener ->subject('Job failed'); }); } + */ Utils::logError($exception->exception); } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 518076901af9..d9e0bb4635f8 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -8,6 +8,8 @@ use Request; use URL; use Utils; use Validator; +use Queue; +use Illuminate\Queue\Events\JobProcessing; /** * Class AppServiceProvider. @@ -21,6 +23,14 @@ class AppServiceProvider extends ServiceProvider */ public function boot() { + Queue::before(function (JobProcessing $event) { + $body = $event->job->getRawBody(); + preg_match('/db-ninja-[\d+]/', $body, $matches); + if (count($matches)) { + config(['database.default' => $matches[0]]); + } + }); + Form::macro('image_data', function ($image, $contents = false) { if (! $contents) { $contents = file_get_contents($image); diff --git a/config/queue.php b/config/queue.php index 30e8e8b9d10b..6ee3a7d7f16c 100644 --- a/config/queue.php +++ b/config/queue.php @@ -36,6 +36,7 @@ return [ ], 'database' => [ + 'connection' => env('QUEUE_DATABASE', 'mysql'), 'driver' => 'database', 'table' => 'jobs', 'queue' => 'default', @@ -86,7 +87,8 @@ return [ */ 'failed' => [ - 'database' => 'mysql', 'table' => 'failed_jobs', + 'database' => env('QUEUE_DATABASE', 'mysql'), + 'table' => 'failed_jobs', ], ]; From 3988b6750b8d5db4d98ede03f227aaaa8b4d19d0 Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Thu, 4 May 2017 20:18:41 +0300 Subject: [PATCH 59/81] Multi-db support --- app/Providers/AppServiceProvider.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index d9e0bb4635f8..b7255fd4420c 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -23,6 +23,7 @@ class AppServiceProvider extends ServiceProvider */ public function boot() { + // support selecting job database Queue::before(function (JobProcessing $event) { $body = $event->job->getRawBody(); preg_match('/db-ninja-[\d+]/', $body, $matches); @@ -30,7 +31,7 @@ class AppServiceProvider extends ServiceProvider config(['database.default' => $matches[0]]); } }); - + Form::macro('image_data', function ($image, $contents = false) { if (! $contents) { $contents = file_get_contents($image); From 2cacec4d6c41f060e077a66397df7b1bafa36e3b Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Sun, 7 May 2017 09:26:54 +0300 Subject: [PATCH 60/81] Added PGK currency --- database/seeds/CurrenciesSeeder.php | 1 + 1 file changed, 1 insertion(+) diff --git a/database/seeds/CurrenciesSeeder.php b/database/seeds/CurrenciesSeeder.php index b6c87bdd3b52..3d40d661dc7f 100644 --- a/database/seeds/CurrenciesSeeder.php +++ b/database/seeds/CurrenciesSeeder.php @@ -73,6 +73,7 @@ class CurrenciesSeeder extends Seeder ['name' => 'Dominican Peso', 'code' => 'DOP', 'symbol' => 'RD$', 'precision' => '2', 'thousand_separator' => ',', 'decimal_separator' => '.'], ['name' => 'Chilean Peso', 'code' => 'CLP', 'symbol' => '$', 'precision' => '2', 'thousand_separator' => '.', 'decimal_separator' => ','], ['name' => 'Icelandic Króna', 'code' => 'ISK', 'symbol' => 'kr', 'precision' => '2', 'thousand_separator' => '.', 'decimal_separator' => ',', 'swap_currency_symbol' => true], + ['name' => 'Papua New Guinean Kina', 'code' => 'PGK', 'symbol' => 'K', 'precision' => '2', 'thousand_separator' => ',', 'decimal_separator' => '.'], ]; foreach ($currencies as $currency) { From 42032f5e9aac23b6abec5c07b6a975fbe695dd0a Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Sun, 7 May 2017 10:00:38 +0300 Subject: [PATCH 61/81] Improve JSON encoding in HTML --- app/Http/Requests/SaveClientPortalSettings.php | 4 ++-- app/Libraries/HTMLUtils.php | 15 ++++++++++++++- resources/views/accounts/client_portal.blade.php | 2 +- resources/views/accounts/invoice_design.blade.php | 2 +- resources/views/accounts/payments.blade.php | 2 +- .../accounts/templates_and_reminders.blade.php | 2 +- resources/views/clients/statement.blade.php | 4 ++-- resources/views/credits/edit.blade.php | 2 +- resources/views/dashboard.blade.php | 2 +- resources/views/expenses/edit.blade.php | 8 ++++---- resources/views/invoices/edit.blade.php | 12 ++++++------ resources/views/invoices/history.blade.php | 2 +- resources/views/invoices/knockout.blade.php | 4 ++-- resources/views/invoices/pdf.blade.php | 2 +- resources/views/invoices/view.blade.php | 6 +++--- resources/views/payments/edit.blade.php | 4 ++-- .../views/payments/paymentmethods_list.blade.php | 2 +- resources/views/projects/edit.blade.php | 2 +- resources/views/reports/d3.blade.php | 2 +- resources/views/tasks/edit.blade.php | 4 ++-- 20 files changed, 48 insertions(+), 35 deletions(-) diff --git a/app/Http/Requests/SaveClientPortalSettings.php b/app/Http/Requests/SaveClientPortalSettings.php index 7912c0df45da..cd81bf7d31dd 100644 --- a/app/Http/Requests/SaveClientPortalSettings.php +++ b/app/Http/Requests/SaveClientPortalSettings.php @@ -38,7 +38,7 @@ class SaveClientPortalSettings extends Request $input = $this->all(); if ($this->client_view_css && Utils::isNinja()) { - $input['client_view_css'] = HTMLUtils::sanitize($this->client_view_css); + $input['client_view_css'] = HTMLUtils::sanitizeCSS($this->client_view_css); } if (Utils::isNinja()) { @@ -53,7 +53,7 @@ class SaveClientPortalSettings extends Request $input['subdomain'] = null; } } - + $this->replace($input); return $this->all(); diff --git a/app/Libraries/HTMLUtils.php b/app/Libraries/HTMLUtils.php index 412252a1eb6d..3e3252a922ca 100644 --- a/app/Libraries/HTMLUtils.php +++ b/app/Libraries/HTMLUtils.php @@ -7,7 +7,7 @@ use HTMLPurifier_Config; class HTMLUtils { - public static function sanitize($css) + public static function sanitizeCSS($css) { // Allow referencing the body element $css = preg_replace('/(?purify($html); + } + + public static function encodeJSON($string) + { + return htmlentities(json_encode($string), ENT_NOQUOTES); + } } diff --git a/resources/views/accounts/client_portal.blade.php b/resources/views/accounts/client_portal.blade.php index 71fa231e65e2..22debed199ac 100644 --- a/resources/views/accounts/client_portal.blade.php +++ b/resources/views/accounts/client_portal.blade.php @@ -372,7 +372,7 @@ iframe.src = '{{ rtrim(SITE_URL ,'/') }}/view/' ")},0=0&&(u&&(u.splice(m,1),t.processAllDeferredBindingUpdates&&t.processAllDeferredBindingUpdates()),p.splice(g,0,A)),l(v,n,null),t.processAllDeferredBindingUpdates&&t.processAllDeferredBindingUpdates(),T.afterMove&&T.afterMove.call(this,b,s,r)}y&&y.apply(this,arguments)},connectWith:!!T.connectClass&&"."+T.connectClass})),void 0!==T.isEnabled&&t.computed({read:function(){A.sortable(r(T.isEnabled)?"enable":"disable")},disposeWhenNodeIsRemoved:u})},0);return t.utils.domNodeDisposal.addDisposeCallback(u,function(){(A.data("ui-sortable")||A.data("sortable"))&&A.sortable("destroy"),clearTimeout(w)}),{controlsDescendantBindings:!0}},update:function(e,n,i,a,s){var r=p(n,"foreach");l(e,o,r.foreach),t.bindingHandlers.template.update(e,function(){return r},i,a,s)},connectClass:"ko_container",allowDrop:!0,afterMove:null,beforeMove:null,options:{}},t.bindingHandlers.draggable={init:function(n,i,o,a,c){var u=r(i())||{},h=u.options||{},d=t.utils.extend({},t.bindingHandlers.draggable.options),f=p(i,"data"),m=u.connectClass||t.bindingHandlers.draggable.connectClass,g=void 0!==u.isEnabled?u.isEnabled:t.bindingHandlers.draggable.isEnabled;return u="data"in u?u.data:u,l(n,s,u),t.utils.extend(d,h),d.connectToSortable=!!m&&"."+m,e(n).draggable(d),void 0!==g&&t.computed({read:function(){e(n).draggable(r(g)?"enable":"disable")},disposeWhenNodeIsRemoved:n}),t.bindingHandlers.template.init(n,function(){return f},o,a,c)},update:function(e,n,i,o,a){var s=p(n,"data");return t.bindingHandlers.template.update(e,function(){return s},i,o,a)},connectClass:t.bindingHandlers.sortable.connectClass,options:{helper:"clone"}}}),function(){var t=this,e=t._,n=Array.prototype,i=Object.prototype,o=Function.prototype,a=n.push,s=n.slice,r=n.concat,c=i.toString,l=i.hasOwnProperty,u=Array.isArray,h=Object.keys,d=o.bind,p=function(t){return t instanceof p?t:this instanceof p?void(this._wrapped=t):new p(t)};"undefined"!=typeof exports?("undefined"!=typeof module&&module.exports&&(exports=module.exports=p),exports._=p):t._=p,p.VERSION="1.7.0";var f=function(t,e,n){if(void 0===e)return t;switch(null==n?3:n){case 1:return function(n){return t.call(e,n)};case 2:return function(n,i){return t.call(e,n,i)};case 3:return function(n,i,o){return t.call(e,n,i,o)};case 4:return function(n,i,o,a){return t.call(e,n,i,o,a)}}return function(){return t.apply(e,arguments)}};p.iteratee=function(t,e,n){return null==t?p.identity:p.isFunction(t)?f(t,e,n):p.isObject(t)?p.matches(t):p.property(t)},p.each=p.forEach=function(t,e,n){if(null==t)return t;e=f(e,n);var i,o=t.length;if(o===+o)for(i=0;i=0)},p.invoke=function(t,e){var n=s.call(arguments,2),i=p.isFunction(e);return p.map(t,function(t){return(i?e:t[e]).apply(t,n)})},p.pluck=function(t,e){return p.map(t,p.property(e))},p.where=function(t,e){return p.filter(t,p.matches(e))},p.findWhere=function(t,e){return p.find(t,p.matches(e))},p.max=function(t,e,n){var i,o,a=-(1/0),s=-(1/0);if(null==e&&null!=t){t=t.length===+t.length?t:p.values(t);for(var r=0,c=t.length;ra&&(a=i)}else e=p.iteratee(e,n),p.each(t,function(t,n,i){o=e(t,n,i),(o>s||o===-(1/0)&&a===-(1/0))&&(a=t,s=o)});return a},p.min=function(t,e,n){var i,o,a=1/0,s=1/0;if(null==e&&null!=t){t=t.length===+t.length?t:p.values(t);for(var r=0,c=t.length;ri||void 0===n)return 1;if(n>>1;n(t[r])=0;)if(t[i]===e)return i;return-1},p.range=function(t,e,n){arguments.length<=1&&(e=t||0,t=0),n=n||1;for(var i=Math.max(Math.ceil((e-t)/n),0),o=Array(i),a=0;ae?(clearTimeout(s),s=null,r=l,a=t.apply(i,o),s||(i=o=null)):s||n.trailing===!1||(s=setTimeout(c,u)),a}},p.debounce=function(t,e,n){var i,o,a,s,r,c=function(){var l=p.now()-s;l0?i=setTimeout(c,e-l):(i=null,n||(r=t.apply(a,o),i||(a=o=null)))};return function(){a=this,o=arguments,s=p.now();var l=n&&!i;return i||(i=setTimeout(c,e)),l&&(r=t.apply(a,o),a=o=null),r}},p.wrap=function(t,e){return p.partial(e,t)},p.negate=function(t){return function(){return!t.apply(this,arguments)}},p.compose=function(){var t=arguments,e=t.length-1;return function(){for(var n=e,i=t[e].apply(this,arguments);n--;)i=t[n].call(this,i);return i}},p.after=function(t,e){return function(){if(--t<1)return e.apply(this,arguments)}},p.before=function(t,e){var n;return function(){return--t>0?n=e.apply(this,arguments):e=null,n}},p.once=p.partial(p.before,2),p.keys=function(t){if(!p.isObject(t))return[];if(h)return h(t);var e=[];for(var n in t)p.has(t,n)&&e.push(n);return e},p.values=function(t){for(var e=p.keys(t),n=e.length,i=Array(n),o=0;o":">",'"':""","'":"'","`":"`"},A=p.invert(y),z=function(t){var e=function(e){return t[e]},n="(?:"+p.keys(t).join("|")+")",i=RegExp(n),o=RegExp(n,"g");return function(t){return t=null==t?"":""+t,i.test(t)?t.replace(o,e):t}};p.escape=z(y),p.unescape=z(A),p.result=function(t,e){if(null!=t){var n=t[e];return p.isFunction(n)?t[e]():n}};var _=0;p.uniqueId=function(t){var e=++_+"";return t?t+e:e},p.templateSettings={evaluate:/<%([\s\S]+?)%>/g,interpolate:/<%=([\s\S]+?)%>/g,escape:/<%-([\s\S]+?)%>/g};var T=/(.)^/,w={"'":"'","\\":"\\","\r":"r","\n":"n","\u2028":"u2028","\u2029":"u2029"},C=/\\|'|\r|\n|\u2028|\u2029/g,N=function(t){return"\\"+w[t]};p.template=function(t,e,n){!e&&n&&(e=n),e=p.defaults({},e,p.templateSettings);var i=RegExp([(e.escape||T).source,(e.interpolate||T).source,(e.evaluate||T).source].join("|")+"|$","g"),o=0,a="__p+='";t.replace(i,function(e,n,i,s,r){return a+=t.slice(o,r).replace(C,N),o=r+e.length,n?a+="'+\n((__t=("+n+"))==null?'':_.escape(__t))+\n'":i?a+="'+\n((__t=("+i+"))==null?'':__t)+\n'":s&&(a+="';\n"+s+"\n__p+='"),e}),a+="';\n",e.variable||(a="with(obj||{}){\n"+a+"}\n"),a="var __t,__p='',__j=Array.prototype.join,print=function(){__p+=__j.call(arguments,'');};\n"+a+"return __p;\n";try{var s=new Function(e.variable||"obj","_",a)}catch(r){throw r.source=a,r}var c=function(t){return s.call(this,t,p)},l=e.variable||"obj";return c.source="function("+l+"){\n"+a+"}",c},p.chain=function(t){var e=p(t);return e._chain=!0,e};var O=function(t){return this._chain?p(t).chain():t};p.mixin=function(t){p.each(p.functions(t),function(e){var n=p[e]=t[e];p.prototype[e]=function(){var t=[this._wrapped];return a.apply(t,arguments),O.call(this,n.apply(p,t))}})},p.mixin(p),p.each(["pop","push","reverse","shift","sort","splice","unshift"],function(t){var e=n[t];p.prototype[t]=function(){var n=this._wrapped;return e.apply(n,arguments),"shift"!==t&&"splice"!==t||0!==n.length||delete n[0],O.call(this,n)}}),p.each(["concat","join","slice"],function(t){var e=n[t];p.prototype[t]=function(){return O.call(this,e.apply(this._wrapped,arguments))}}),p.prototype.value=function(){return this._wrapped},"function"==typeof define&&define.amd&&define("underscore",[],function(){return p})}.call(this),function(t,e){function n(){return new Date(Date.UTC.apply(Date,arguments))}function i(){var t=new Date;return n(t.getFullYear(),t.getMonth(),t.getDate())}function o(t,e){return t.getUTCFullYear()===e.getUTCFullYear()&&t.getUTCMonth()===e.getUTCMonth()&&t.getUTCDate()===e.getUTCDate()}function a(t){return function(){return this[t].apply(this,arguments)}}function s(e,n){function i(t,e){return e.toLowerCase()}var o,a=t(e).data(),s={},r=new RegExp("^"+n.toLowerCase()+"([A-Z])");n=new RegExp("^"+n.toLowerCase());for(var c in a)n.test(c)&&(o=c.replace(r,i),s[o]=a[c]);return s}function r(e){var n={};if(m[e]||(e=e.split("-")[0],m[e])){var i=m[e];return t.each(f,function(t,e){e in i&&(n[e]=i[e])}),n}}var c=function(){var e={get:function(t){return this.slice(t)[0]},contains:function(t){for(var e=t&&t.valueOf(),n=0,i=this.length;no?(this.picker.addClass("datepicker-orient-right"),p=u.left+d-e):this.picker.addClass("datepicker-orient-left");var m,g,b=this.o.orientation.y;if("auto"===b&&(m=-s+f-n,g=s+a-(f+h+n),b=Math.max(m,g)===g?"top":"bottom"),this.picker.addClass("datepicker-orient-"+b),"top"===b?f+=h:f-=n+parseInt(this.picker.css("padding-top")),this.o.rtl){var v=o-(p+d);this.picker.css({top:f,right:v,zIndex:l})}else this.picker.css({top:f,left:p,zIndex:l});return this},_allow_update:!0,update:function(){if(!this._allow_update)return this;var e=this.dates.copy(),n=[],i=!1;return arguments.length?(t.each(arguments,t.proxy(function(t,e){e instanceof Date&&(e=this._local_to_utc(e)),n.push(e)},this)),i=!0):(n=this.isInput?this.element.val():this.element.data("date")||this.element.find("input").val(),n=n&&this.o.multidate?n.split(this.o.multidateSeparator):[n],delete this.element.data().date),n=t.map(n,t.proxy(function(t){return g.parseDate(t,this.o.format,this.o.language)},this)),n=t.grep(n,t.proxy(function(t){return tthis.o.endDate||!t},this),!0),this.dates.replace(n),this.dates.length?this.viewDate=new Date(this.dates.get(-1)):this.viewDatethis.o.endDate&&(this.viewDate=new Date(this.o.endDate)),i?this.setValue():n.length&&String(e)!==String(this.dates)&&this._trigger("changeDate"),!this.dates.length&&e.length&&this._trigger("clearDate"),this.fill(),this},fillDow:function(){var t=this.o.weekStart,e="";if(this.o.calendarWeeks){this.picker.find(".datepicker-days thead tr:first-child .datepicker-switch").attr("colspan",function(t,e){return parseInt(e)+1});var n=' ';e+=n}for(;t'+m[this.o.language].daysMin[t++%7]+"";e+="",this.picker.find(".datepicker-days thead").append(e)},fillMonths:function(){for(var t="",e=0;e<12;)t+=''+m[this.o.language].monthsShort[e++]+"";this.picker.find(".datepicker-months td").html(t)},setRange:function(e){e&&e.length?this.range=t.map(e,function(t){return t.valueOf()}):delete this.range,this.fill()},getClassNames:function(e){var n=[],i=this.viewDate.getUTCFullYear(),a=this.viewDate.getUTCMonth(),s=new Date;return e.getUTCFullYear()i||e.getUTCFullYear()===i&&e.getUTCMonth()>a)&&n.push("new"),this.focusDate&&e.valueOf()===this.focusDate.valueOf()&&n.push("focused"),this.o.todayHighlight&&e.getUTCFullYear()===s.getFullYear()&&e.getUTCMonth()===s.getMonth()&&e.getUTCDate()===s.getDate()&&n.push("today"),this.dates.contains(e)!==-1&&n.push("active"),(e.valueOf()this.o.endDate||t.inArray(e.getUTCDay(),this.o.daysOfWeekDisabled)!==-1)&&n.push("disabled"),this.o.datesDisabled.length>0&&t.grep(this.o.datesDisabled,function(t){return o(e,t)}).length>0&&n.push("disabled","disabled-date"),this.range&&(e>this.range[0]&&e"),this.o.calendarWeeks)){var y=new Date(+p+(this.o.weekStart-p.getUTCDay()-7)%7*864e5),A=new Date(Number(y)+(11-y.getUTCDay())%7*864e5),z=new Date(Number(z=n(A.getUTCFullYear(),0,1))+(11-z.getUTCDay())%7*864e5),_=(A-z)/864e5/7+1;M.push(''+_+"")}if(v=this.getClassNames(p),v.push("day"),this.o.beforeShowDay!==t.noop){var T=this.o.beforeShowDay(this._utc_to_local(p));T===e?T={}:"boolean"==typeof T?T={enabled:T}:"string"==typeof T&&(T={classes:T}),T.enabled===!1&&v.push("disabled"),T.classes&&(v=v.concat(T.classes.split(/\s+/))),T.tooltip&&(i=T.tooltip)}v=t.unique(v),M.push('"+p.getUTCDate()+""),i=null,p.getUTCDay()===this.o.weekEnd&&M.push(""),p.setUTCDate(p.getUTCDate()+1)}this.picker.find(".datepicker-days tbody").empty().append(M.join(""));var w=this.picker.find(".datepicker-months").find("th:eq(1)").text(a).end().find("span").removeClass("active");if(t.each(this.dates,function(t,e){e.getUTCFullYear()===a&&w.eq(e.getUTCMonth()).addClass("active")}),(al)&&w.addClass("disabled"),a===r&&w.slice(0,c).addClass("disabled"),a===l&&w.slice(u+1).addClass("disabled"),this.o.beforeShowMonth!==t.noop){var C=this;t.each(w,function(e,n){if(!t(n).hasClass("disabled")){var i=new Date(a,e,1),o=C.o.beforeShowMonth(i);o===!1&&t(n).addClass("disabled")}})}M="",a=10*parseInt(a/10,10);var N=this.picker.find(".datepicker-years").find("th:eq(1)").text(a+"-"+(a+9)).end().find("td");a-=1;for(var O,S=t.map(this.dates,function(t){return t.getUTCFullYear()}),x=-1;x<11;x++)O=["year"],x===-1?O.push("old"):10===x&&O.push("new"),t.inArray(a,S)!==-1&&O.push("active"),(al)&&O.push("disabled"),M+=''+a+"",a+=1;N.html(M)}},updateNavArrows:function(){if(this._allow_update){var t=new Date(this.viewDate),e=t.getUTCFullYear(),n=t.getUTCMonth();switch(this.viewMode){case 0:this.o.startDate!==-(1/0)&&e<=this.o.startDate.getUTCFullYear()&&n<=this.o.startDate.getUTCMonth()?this.picker.find(".prev").css({visibility:"hidden"}):this.picker.find(".prev").css({visibility:"visible"}),this.o.endDate!==1/0&&e>=this.o.endDate.getUTCFullYear()&&n>=this.o.endDate.getUTCMonth()?this.picker.find(".next").css({visibility:"hidden"}):this.picker.find(".next").css({visibility:"visible"});break;case 1:case 2:this.o.startDate!==-(1/0)&&e<=this.o.startDate.getUTCFullYear()?this.picker.find(".prev").css({visibility:"hidden"}):this.picker.find(".prev").css({visibility:"visible"}),this.o.endDate!==1/0&&e>=this.o.endDate.getUTCFullYear()?this.picker.find(".next").css({visibility:"hidden"}):this.picker.find(".next").css({visibility:"visible"})}}},click:function(e){e.preventDefault();var i,o,a,s=t(e.target).closest("span, td, th");if(1===s.length)switch(s[0].nodeName.toLowerCase()){case"th":switch(s[0].className){case"datepicker-switch":this.showMode(1);break;case"prev":case"next":var r=g.modes[this.viewMode].navStep*("prev"===s[0].className?-1:1);switch(this.viewMode){case 0:this.viewDate=this.moveMonth(this.viewDate,r),this._trigger("changeMonth",this.viewDate);break;case 1:case 2:this.viewDate=this.moveYear(this.viewDate,r),1===this.viewMode&&this._trigger("changeYear",this.viewDate)}this.fill();break;case"today":var c=new Date;c=n(c.getFullYear(),c.getMonth(),c.getDate(),0,0,0),this.showMode(-2);var l="linked"===this.o.todayBtn?null:"view";this._setDate(c,l);break;case"clear":this.clearDates()}break;case"span":s.hasClass("disabled")||(this.viewDate.setUTCDate(1),s.hasClass("month")?(a=1,o=s.parent().find("span").index(s),i=this.viewDate.getUTCFullYear(),this.viewDate.setUTCMonth(o),this._trigger("changeMonth",this.viewDate),1===this.o.minViewMode&&this._setDate(n(i,o,a))):(a=1,o=0,i=parseInt(s.text(),10)||0,this.viewDate.setUTCFullYear(i),this._trigger("changeYear",this.viewDate),2===this.o.minViewMode&&this._setDate(n(i,o,a))),this.showMode(-1),this.fill());break;case"td":s.hasClass("day")&&!s.hasClass("disabled")&&(a=parseInt(s.text(),10)||1,i=this.viewDate.getUTCFullYear(),o=this.viewDate.getUTCMonth(),s.hasClass("old")?0===o?(o=11,i-=1):o-=1:s.hasClass("new")&&(11===o?(o=0,i+=1):o+=1),this._setDate(n(i,o,a)))}this.picker.is(":visible")&&this._focused_from&&t(this._focused_from).focus(),delete this._focused_from},_toggle_multidate:function(t){var e=this.dates.contains(t);if(t||this.dates.clear(),e!==-1?(this.o.multidate===!0||this.o.multidate>1||this.o.toggleActive)&&this.dates.remove(e):this.o.multidate===!1?(this.dates.clear(),this.dates.push(t)):this.dates.push(t),"number"==typeof this.o.multidate)for(;this.dates.length>this.o.multidate;)this.dates.remove(0)},_setDate:function(t,e){e&&"date"!==e||this._toggle_multidate(t&&new Date(t)),e&&"view"!==e||(this.viewDate=t&&new Date(t)),this.fill(),this.setValue(),e&&"view"===e||this._trigger("changeDate");var n;this.isInput?n=this.element:this.component&&(n=this.element.find("input")),n&&n.change(),!this.o.autoclose||e&&"date"!==e||this.hide()},moveMonth:function(t,n){if(!t)return e;if(!n)return t;var i,o,a=new Date(t.valueOf()),s=a.getUTCDate(),r=a.getUTCMonth(),c=Math.abs(n);if(n=n>0?1:-1,1===c)o=n===-1?function(){return a.getUTCMonth()===r}:function(){return a.getUTCMonth()!==i},i=r+n,a.setUTCMonth(i),(i<0||i>11)&&(i=(i+12)%12);else{for(var l=0;l=this.o.startDate&&t<=this.o.endDate},keydown:function(t){if(!this.picker.is(":visible"))return void(27===t.keyCode&&this.show());var e,n,o,a=!1,s=this.focusDate||this.viewDate;switch(t.keyCode){case 27:this.focusDate?(this.focusDate=null,this.viewDate=this.dates.get(-1)||this.viewDate,this.fill()):this.hide(),t.preventDefault();break;case 37:case 39:if(!this.o.keyboardNavigation)break;e=37===t.keyCode?-1:1,t.ctrlKey?(n=this.moveYear(this.dates.get(-1)||i(),e),o=this.moveYear(s,e),this._trigger("changeYear",this.viewDate)):t.shiftKey?(n=this.moveMonth(this.dates.get(-1)||i(),e),o=this.moveMonth(s,e),this._trigger("changeMonth",this.viewDate)):(n=new Date(this.dates.get(-1)||i()),n.setUTCDate(n.getUTCDate()+e),o=new Date(s),o.setUTCDate(s.getUTCDate()+e)),this.dateWithinRange(o)&&(this.focusDate=this.viewDate=o,this.setValue(),this.fill(),t.preventDefault());break;case 38:case 40:if(!this.o.keyboardNavigation)break;e=38===t.keyCode?-1:1,t.ctrlKey?(n=this.moveYear(this.dates.get(-1)||i(),e),o=this.moveYear(s,e),this._trigger("changeYear",this.viewDate)):t.shiftKey?(n=this.moveMonth(this.dates.get(-1)||i(),e),o=this.moveMonth(s,e),this._trigger("changeMonth",this.viewDate)):(n=new Date(this.dates.get(-1)||i()),n.setUTCDate(n.getUTCDate()+7*e),o=new Date(s),o.setUTCDate(s.getUTCDate()+7*e)),this.dateWithinRange(o)&&(this.focusDate=this.viewDate=o,this.setValue(),this.fill(),t.preventDefault());break;case 32:break;case 13:s=this.focusDate||this.dates.get(-1)||this.viewDate,this.o.keyboardNavigation&&(this._toggle_multidate(s),a=!0),this.focusDate=null,this.viewDate=this.dates.get(-1)||this.viewDate,this.setValue(),this.fill(),this.picker.is(":visible")&&(t.preventDefault(),"function"==typeof t.stopPropagation?t.stopPropagation():t.cancelBubble=!0,this.o.autoclose&&this.hide());break;case 9:this.focusDate=null,this.viewDate=this.dates.get(-1)||this.viewDate,this.fill(),this.hide()}if(a){this.dates.length?this._trigger("changeDate"):this._trigger("clearDate");var r;this.isInput?r=this.element:this.component&&(r=this.element.find("input")),r&&r.change()}},showMode:function(t){t&&(this.viewMode=Math.max(this.o.minViewMode,Math.min(2,this.viewMode+t))),this.picker.children("div").hide().filter(".datepicker-"+g.modes[this.viewMode].clsName).css("display","block"),this.updateNavArrows()}};var u=function(e,n){this.element=t(e),this.inputs=t.map(n.inputs,function(t){return t.jquery?t[0]:t}),delete n.inputs,d.call(t(this.inputs),n).bind("changeDate",t.proxy(this.dateUpdated,this)),this.pickers=t.map(this.inputs,function(e){return t(e).data("datepicker")}),this.updateDates()};u.prototype={updateDates:function(){this.dates=t.map(this.pickers,function(t){return t.getUTCDate()}),this.updateRanges()},updateRanges:function(){var e=t.map(this.dates,function(t){return t.valueOf()});t.each(this.pickers,function(t,n){n.setRange(e)})},dateUpdated:function(e){if(!this.updating){this.updating=!0;var n=t(e.target).data("datepicker"),i=n.getUTCDate(),o=t.inArray(e.target,this.inputs),a=o-1,s=o+1,r=this.inputs.length;if(o!==-1){if(t.each(this.pickers,function(t,e){e.getUTCDate()||e.setUTCDate(i)}),i=0&&ithis.dates[s])for(;sthis.dates[s];)this.pickers[s++].setUTCDate(i);this.updateDates(),delete this.updating}}},remove:function(){t.map(this.pickers,function(t){t.remove()}),delete this.element.data().datepicker}};var h=t.fn.datepicker,d=function(n){var i=Array.apply(null,arguments);i.shift();var o;return this.each(function(){var a=t(this),c=a.data("datepicker"),h="object"==typeof n&&n;if(!c){var d=s(this,"date"),f=t.extend({},p,d,h),m=r(f.language),g=t.extend({},p,m,d,h);if(a.hasClass("input-daterange")||g.inputs){var b={inputs:g.inputs||a.find("input").toArray()};a.data("datepicker",c=new u(this,t.extend(g,b)))}else a.data("datepicker",c=new l(this,g))}if("string"==typeof n&&"function"==typeof c[n]&&(o=c[n].apply(c,i),o!==e))return!1}),o!==e?o:this};t.fn.datepicker=d;var p=t.fn.datepicker.defaults={autoclose:!1,beforeShowDay:t.noop,beforeShowMonth:t.noop,calendarWeeks:!1,clearBtn:!1,toggleActive:!1,daysOfWeekDisabled:[],datesDisabled:[],endDate:1/0,forceParse:!0,format:"mm/dd/yyyy",keyboardNavigation:!0,language:"en",minViewMode:0,multidate:!1,multidateSeparator:",",orientation:"auto",rtl:!1,startDate:-(1/0),startView:0,todayBtn:!1,todayHighlight:!1,weekStart:0,disableTouchKeyboard:!1,enableOnReadonly:!0,container:"body"},f=t.fn.datepicker.locale_opts=["format","rtl","weekStart"];t.fn.datepicker.Constructor=l;var m=t.fn.datepicker.dates={en:{days:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday"],daysShort:["Sun","Mon","Tue","Wed","Thu","Fri","Sat","Sun"],daysMin:["Su","Mo","Tu","We","Th","Fr","Sa","Su"],months:["January","February","March","April","May","June","July","August","September","October","November","December"],monthsShort:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],today:"Today",clear:"Clear"}},g={modes:[{clsName:"days",navFnc:"Month",navStep:1},{clsName:"months",navFnc:"FullYear",navStep:1},{clsName:"years",navFnc:"FullYear",navStep:10}],isLeapYear:function(t){return t%4===0&&t%100!==0||t%400===0},getDaysInMonth:function(t,e){return[31,g.isLeapYear(t)?29:28,31,30,31,30,31,31,30,31,30,31][e]},validParts:/dd?|DD?|mm?|MM?|yy(?:yy)?/g,nonpunctuation:/[^ -\/:-@\[\u3400-\u9fff-`{-~\t\n\r]+/g,parseFormat:function(t){var e=t.replace(this.validParts,"\0").split("\0"),n=t.match(this.validParts);if(!e||!e.length||!n||0===n.length)throw new Error("Invalid date format.");return{separators:e,parts:n}},parseDate:function(i,o,a){function s(){var t=this.slice(0,d[u].length),e=d[u].slice(0,t.length);return t.toLowerCase()===e.toLowerCase()}if(!i)return e;if(i instanceof Date)return i;"string"==typeof o&&(o=g.parseFormat(o));var r,c,u,h=/([\-+]\d+)([dmwy])/,d=i.match(/([\-+]\d+)([dmwy])/g);if(/^[\-+]\d+[dmwy]([\s,]+[\-+]\d+[dmwy])*$/.test(i)){for(i=new Date,u=0;u","#comment",o)})},Mb:function(t,e){return h.w.Na(function(n,i){var o=n.nextSibling;o&&o.nodeName.toLowerCase()===e&&h.xa(o,t,i)})}}}(),h.b("__tr_ambtns",h.Za.Mb),function(){h.n={},h.n.j=function(t){this.j=t},h.n.j.prototype.text=function(){var t=h.a.B(this.j),t="script"===t?"text":"textarea"===t?"value":"innerHTML";if(0==arguments.length)return this.j[t];var e=arguments[0];"innerHTML"===t?h.a.Va(this.j,e):this.j[t]=e};var e=h.a.f.L()+"_";h.n.j.prototype.data=function(t){return 1===arguments.length?h.a.f.get(this.j,e+t):void h.a.f.set(this.j,e+t,arguments[1])};var n=h.a.f.L();h.n.Z=function(t){this.j=t},h.n.Z.prototype=new h.n.j,h.n.Z.prototype.text=function(){if(0==arguments.length){var e=h.a.f.get(this.j,n)||{};return e.$a===t&&e.Ba&&(e.$a=e.Ba.innerHTML),e.$a}h.a.f.set(this.j,n,{$a:arguments[0]})},h.n.j.prototype.nodes=function(){return 0==arguments.length?(h.a.f.get(this.j,n)||{}).Ba:void h.a.f.set(this.j,n,{Ba:arguments[0]})},h.b("templateSources",h.n),h.b("templateSources.domElement",h.n.j),h.b("templateSources.anonymousTemplate",h.n.Z)}(),function(){function e(t,e,n){var i;for(e=h.e.nextSibling(e);t&&(i=t)!==e;)t=h.e.nextSibling(i),n(i,t)}function n(t,n){if(t.length){var i=t[0],o=t[t.length-1],a=i.parentNode,s=h.J.instance,r=s.preprocessNode;if(r){if(e(i,o,function(t,e){var n=t.previousSibling,a=r.call(s,t);a&&(t===i&&(i=a[0]||e),t===o&&(o=a[a.length-1]||n))}),t.length=0,!i)return;i===o?t.push(i):(t.push(i,o),h.a.ea(t,a))}e(i,o,function(t){1!==t.nodeType&&8!==t.nodeType||h.fb(n,t)}),e(i,o,function(t){1!==t.nodeType&&8!==t.nodeType||h.w.Ib(t,[n])}),h.a.ea(t,a)}}function i(t){return t.nodeType?t:0h.a.oa?0:t.nodes)?t.nodes():null;return e?h.a.R(e.cloneNode(!0).childNodes):(t=t.text(),h.a.Qa(t))},h.K.Ja=new h.K,h.Wa(h.K.Ja),h.b("nativeTemplateEngine",h.K),function(){h.La=function(){var t=this.ac=function(){if(!o||!o.tmpl)return 0;try{if(0<=o.tmpl.tag.tmpl.open.toString().indexOf("__"))return 2}catch(t){}return 1}();this.renderTemplateSource=function(e,i,a){if(a=a||{},2>t)throw Error("Your version of jQuery.tmpl is too old. Please upgrade to jQuery.tmpl 1.0.0pre or later.");var s=e.data("precompiled");return s||(s=e.text()||"",s=o.template(null,"{{ko_with $item.koBindingContext}}"+s+"{{/ko_with}}"),e.data("precompiled",s)),e=[i.$data],i=o.extend({koBindingContext:i},a.templateOptions),i=o.tmpl(s,e,i),i.appendTo(n.createElement("div")),o.fragments={},i},this.createJavaScriptEvaluatorBlock=function(t){return"{{ko_code ((function() { return "+t+" })()) }}"},this.addTemplate=function(t,e){n.write("")},0=0&&(u&&(u.splice(m,1),t.processAllDeferredBindingUpdates&&t.processAllDeferredBindingUpdates()),p.splice(g,0,A)),l(v,n,null),t.processAllDeferredBindingUpdates&&t.processAllDeferredBindingUpdates(),T.afterMove&&T.afterMove.call(this,b,s,r)}y&&y.apply(this,arguments)},connectWith:!!T.connectClass&&"."+T.connectClass})),void 0!==T.isEnabled&&t.computed({read:function(){A.sortable(r(T.isEnabled)?"enable":"disable")},disposeWhenNodeIsRemoved:u})},0);return t.utils.domNodeDisposal.addDisposeCallback(u,function(){(A.data("ui-sortable")||A.data("sortable"))&&A.sortable("destroy"),clearTimeout(w)}),{controlsDescendantBindings:!0}},update:function(e,n,i,a,s){var r=p(n,"foreach");l(e,o,r.foreach),t.bindingHandlers.template.update(e,function(){return r},i,a,s)},connectClass:"ko_container",allowDrop:!0,afterMove:null,beforeMove:null,options:{}},t.bindingHandlers.draggable={init:function(n,i,o,a,c){var u=r(i())||{},h=u.options||{},d=t.utils.extend({},t.bindingHandlers.draggable.options),f=p(i,"data"),m=u.connectClass||t.bindingHandlers.draggable.connectClass,g=void 0!==u.isEnabled?u.isEnabled:t.bindingHandlers.draggable.isEnabled;return u="data"in u?u.data:u,l(n,s,u),t.utils.extend(d,h),d.connectToSortable=!!m&&"."+m,e(n).draggable(d),void 0!==g&&t.computed({read:function(){e(n).draggable(r(g)?"enable":"disable")},disposeWhenNodeIsRemoved:n}),t.bindingHandlers.template.init(n,function(){return f},o,a,c)},update:function(e,n,i,o,a){var s=p(n,"data");return t.bindingHandlers.template.update(e,function(){return s},i,o,a)},connectClass:t.bindingHandlers.sortable.connectClass,options:{helper:"clone"}}}),function(){var t=this,e=t._,n=Array.prototype,i=Object.prototype,o=Function.prototype,a=n.push,s=n.slice,r=n.concat,c=i.toString,l=i.hasOwnProperty,u=Array.isArray,h=Object.keys,d=o.bind,p=function(t){return t instanceof p?t:this instanceof p?void(this._wrapped=t):new p(t)};"undefined"!=typeof exports?("undefined"!=typeof module&&module.exports&&(exports=module.exports=p),exports._=p):t._=p,p.VERSION="1.7.0";var f=function(t,e,n){if(void 0===e)return t;switch(null==n?3:n){case 1:return function(n){return t.call(e,n)};case 2:return function(n,i){return t.call(e,n,i)};case 3:return function(n,i,o){return t.call(e,n,i,o)};case 4:return function(n,i,o,a){return t.call(e,n,i,o,a)}}return function(){return t.apply(e,arguments)}};p.iteratee=function(t,e,n){return null==t?p.identity:p.isFunction(t)?f(t,e,n):p.isObject(t)?p.matches(t):p.property(t)},p.each=p.forEach=function(t,e,n){if(null==t)return t;e=f(e,n);var i,o=t.length;if(o===+o)for(i=0;i=0)},p.invoke=function(t,e){var n=s.call(arguments,2),i=p.isFunction(e);return p.map(t,function(t){return(i?e:t[e]).apply(t,n)})},p.pluck=function(t,e){return p.map(t,p.property(e))},p.where=function(t,e){return p.filter(t,p.matches(e))},p.findWhere=function(t,e){return p.find(t,p.matches(e))},p.max=function(t,e,n){var i,o,a=-(1/0),s=-(1/0);if(null==e&&null!=t){t=t.length===+t.length?t:p.values(t);for(var r=0,c=t.length;ra&&(a=i)}else e=p.iteratee(e,n),p.each(t,function(t,n,i){o=e(t,n,i),(o>s||o===-(1/0)&&a===-(1/0))&&(a=t,s=o)});return a},p.min=function(t,e,n){var i,o,a=1/0,s=1/0;if(null==e&&null!=t){t=t.length===+t.length?t:p.values(t);for(var r=0,c=t.length;ri||void 0===n)return 1;if(n>>1;n(t[r])=0;)if(t[i]===e)return i;return-1},p.range=function(t,e,n){arguments.length<=1&&(e=t||0,t=0),n=n||1;for(var i=Math.max(Math.ceil((e-t)/n),0),o=Array(i),a=0;ae?(clearTimeout(s),s=null,r=l,a=t.apply(i,o),s||(i=o=null)):s||n.trailing===!1||(s=setTimeout(c,u)),a}},p.debounce=function(t,e,n){var i,o,a,s,r,c=function(){var l=p.now()-s;l0?i=setTimeout(c,e-l):(i=null,n||(r=t.apply(a,o),i||(a=o=null)))};return function(){a=this,o=arguments,s=p.now();var l=n&&!i;return i||(i=setTimeout(c,e)),l&&(r=t.apply(a,o),a=o=null),r}},p.wrap=function(t,e){return p.partial(e,t)},p.negate=function(t){return function(){return!t.apply(this,arguments)}},p.compose=function(){var t=arguments,e=t.length-1;return function(){for(var n=e,i=t[e].apply(this,arguments);n--;)i=t[n].call(this,i);return i}},p.after=function(t,e){return function(){if(--t<1)return e.apply(this,arguments)}},p.before=function(t,e){var n;return function(){return--t>0?n=e.apply(this,arguments):e=null,n}},p.once=p.partial(p.before,2),p.keys=function(t){if(!p.isObject(t))return[];if(h)return h(t);var e=[];for(var n in t)p.has(t,n)&&e.push(n);return e},p.values=function(t){for(var e=p.keys(t),n=e.length,i=Array(n),o=0;o":">",'"':""","'":"'","`":"`"},A=p.invert(y),z=function(t){var e=function(e){return t[e]},n="(?:"+p.keys(t).join("|")+")",i=RegExp(n),o=RegExp(n,"g");return function(t){return t=null==t?"":""+t,i.test(t)?t.replace(o,e):t}};p.escape=z(y),p.unescape=z(A),p.result=function(t,e){if(null!=t){var n=t[e];return p.isFunction(n)?t[e]():n}};var _=0;p.uniqueId=function(t){var e=++_+"";return t?t+e:e},p.templateSettings={evaluate:/<%([\s\S]+?)%>/g,interpolate:/<%=([\s\S]+?)%>/g,escape:/<%-([\s\S]+?)%>/g};var T=/(.)^/,w={"'":"'","\\":"\\","\r":"r","\n":"n","\u2028":"u2028","\u2029":"u2029"},C=/\\|'|\r|\n|\u2028|\u2029/g,O=function(t){return"\\"+w[t]};p.template=function(t,e,n){!e&&n&&(e=n),e=p.defaults({},e,p.templateSettings);var i=RegExp([(e.escape||T).source,(e.interpolate||T).source,(e.evaluate||T).source].join("|")+"|$","g"),o=0,a="__p+='";t.replace(i,function(e,n,i,s,r){return a+=t.slice(o,r).replace(C,O),o=r+e.length,n?a+="'+\n((__t=("+n+"))==null?'':_.escape(__t))+\n'":i?a+="'+\n((__t=("+i+"))==null?'':__t)+\n'":s&&(a+="';\n"+s+"\n__p+='"),e}),a+="';\n",e.variable||(a="with(obj||{}){\n"+a+"}\n"),a="var __t,__p='',__j=Array.prototype.join,print=function(){__p+=__j.call(arguments,'');};\n"+a+"return __p;\n";try{var s=new Function(e.variable||"obj","_",a)}catch(r){throw r.source=a,r}var c=function(t){return s.call(this,t,p)},l=e.variable||"obj";return c.source="function("+l+"){\n"+a+"}",c},p.chain=function(t){var e=p(t);return e._chain=!0,e};var N=function(t){return this._chain?p(t).chain():t};p.mixin=function(t){p.each(p.functions(t),function(e){var n=p[e]=t[e];p.prototype[e]=function(){var t=[this._wrapped];return a.apply(t,arguments),N.call(this,n.apply(p,t))}})},p.mixin(p),p.each(["pop","push","reverse","shift","sort","splice","unshift"],function(t){var e=n[t];p.prototype[t]=function(){var n=this._wrapped;return e.apply(n,arguments),"shift"!==t&&"splice"!==t||0!==n.length||delete n[0],N.call(this,n)}}),p.each(["concat","join","slice"],function(t){var e=n[t];p.prototype[t]=function(){return N.call(this,e.apply(this._wrapped,arguments))}}),p.prototype.value=function(){return this._wrapped},"function"==typeof define&&define.amd&&define("underscore",[],function(){return p})}.call(this),function(t,e){function n(){return new Date(Date.UTC.apply(Date,arguments))}function i(){var t=new Date;return n(t.getFullYear(),t.getMonth(),t.getDate())}function o(t,e){return t.getUTCFullYear()===e.getUTCFullYear()&&t.getUTCMonth()===e.getUTCMonth()&&t.getUTCDate()===e.getUTCDate()}function a(t){return function(){return this[t].apply(this,arguments)}}function s(e,n){function i(t,e){return e.toLowerCase()}var o,a=t(e).data(),s={},r=new RegExp("^"+n.toLowerCase()+"([A-Z])");n=new RegExp("^"+n.toLowerCase());for(var c in a)n.test(c)&&(o=c.replace(r,i),s[o]=a[c]);return s}function r(e){var n={};if(m[e]||(e=e.split("-")[0],m[e])){var i=m[e];return t.each(f,function(t,e){e in i&&(n[e]=i[e])}),n}}var c=function(){var e={get:function(t){return this.slice(t)[0]},contains:function(t){for(var e=t&&t.valueOf(),n=0,i=this.length;no?(this.picker.addClass("datepicker-orient-right"),p=u.left+d-e):this.picker.addClass("datepicker-orient-left");var m,g,b=this.o.orientation.y;if("auto"===b&&(m=-s+f-n,g=s+a-(f+h+n),b=Math.max(m,g)===g?"top":"bottom"),this.picker.addClass("datepicker-orient-"+b),"top"===b?f+=h:f-=n+parseInt(this.picker.css("padding-top")),this.o.rtl){var v=o-(p+d);this.picker.css({top:f,right:v,zIndex:l})}else this.picker.css({top:f,left:p,zIndex:l});return this},_allow_update:!0,update:function(){if(!this._allow_update)return this;var e=this.dates.copy(),n=[],i=!1;return arguments.length?(t.each(arguments,t.proxy(function(t,e){e instanceof Date&&(e=this._local_to_utc(e)),n.push(e)},this)),i=!0):(n=this.isInput?this.element.val():this.element.data("date")||this.element.find("input").val(),n=n&&this.o.multidate?n.split(this.o.multidateSeparator):[n],delete this.element.data().date),n=t.map(n,t.proxy(function(t){return g.parseDate(t,this.o.format,this.o.language)},this)),n=t.grep(n,t.proxy(function(t){return tthis.o.endDate||!t},this),!0),this.dates.replace(n),this.dates.length?this.viewDate=new Date(this.dates.get(-1)):this.viewDatethis.o.endDate&&(this.viewDate=new Date(this.o.endDate)),i?this.setValue():n.length&&String(e)!==String(this.dates)&&this._trigger("changeDate"),!this.dates.length&&e.length&&this._trigger("clearDate"),this.fill(),this},fillDow:function(){var t=this.o.weekStart,e="";if(this.o.calendarWeeks){this.picker.find(".datepicker-days thead tr:first-child .datepicker-switch").attr("colspan",function(t,e){return parseInt(e)+1});var n=' ';e+=n}for(;t'+m[this.o.language].daysMin[t++%7]+"";e+="",this.picker.find(".datepicker-days thead").append(e)},fillMonths:function(){for(var t="",e=0;e<12;)t+=''+m[this.o.language].monthsShort[e++]+"";this.picker.find(".datepicker-months td").html(t)},setRange:function(e){e&&e.length?this.range=t.map(e,function(t){return t.valueOf()}):delete this.range,this.fill()},getClassNames:function(e){var n=[],i=this.viewDate.getUTCFullYear(),a=this.viewDate.getUTCMonth(),s=new Date;return e.getUTCFullYear()i||e.getUTCFullYear()===i&&e.getUTCMonth()>a)&&n.push("new"),this.focusDate&&e.valueOf()===this.focusDate.valueOf()&&n.push("focused"),this.o.todayHighlight&&e.getUTCFullYear()===s.getFullYear()&&e.getUTCMonth()===s.getMonth()&&e.getUTCDate()===s.getDate()&&n.push("today"),this.dates.contains(e)!==-1&&n.push("active"),(e.valueOf()this.o.endDate||t.inArray(e.getUTCDay(),this.o.daysOfWeekDisabled)!==-1)&&n.push("disabled"),this.o.datesDisabled.length>0&&t.grep(this.o.datesDisabled,function(t){return o(e,t)}).length>0&&n.push("disabled","disabled-date"),this.range&&(e>this.range[0]&&e"),this.o.calendarWeeks)){var y=new Date(+p+(this.o.weekStart-p.getUTCDay()-7)%7*864e5),A=new Date(Number(y)+(11-y.getUTCDay())%7*864e5),z=new Date(Number(z=n(A.getUTCFullYear(),0,1))+(11-z.getUTCDay())%7*864e5),_=(A-z)/864e5/7+1;M.push(''+_+"")}if(v=this.getClassNames(p),v.push("day"),this.o.beforeShowDay!==t.noop){var T=this.o.beforeShowDay(this._utc_to_local(p));T===e?T={}:"boolean"==typeof T?T={enabled:T}:"string"==typeof T&&(T={classes:T}),T.enabled===!1&&v.push("disabled"),T.classes&&(v=v.concat(T.classes.split(/\s+/))),T.tooltip&&(i=T.tooltip)}v=t.unique(v),M.push('"+p.getUTCDate()+""),i=null,p.getUTCDay()===this.o.weekEnd&&M.push(""),p.setUTCDate(p.getUTCDate()+1)}this.picker.find(".datepicker-days tbody").empty().append(M.join(""));var w=this.picker.find(".datepicker-months").find("th:eq(1)").text(a).end().find("span").removeClass("active");if(t.each(this.dates,function(t,e){e.getUTCFullYear()===a&&w.eq(e.getUTCMonth()).addClass("active")}),(al)&&w.addClass("disabled"),a===r&&w.slice(0,c).addClass("disabled"),a===l&&w.slice(u+1).addClass("disabled"),this.o.beforeShowMonth!==t.noop){var C=this;t.each(w,function(e,n){if(!t(n).hasClass("disabled")){var i=new Date(a,e,1),o=C.o.beforeShowMonth(i);o===!1&&t(n).addClass("disabled")}})}M="",a=10*parseInt(a/10,10);var O=this.picker.find(".datepicker-years").find("th:eq(1)").text(a+"-"+(a+9)).end().find("td");a-=1;for(var N,S=t.map(this.dates,function(t){return t.getUTCFullYear()}),x=-1;x<11;x++)N=["year"],x===-1?N.push("old"):10===x&&N.push("new"),t.inArray(a,S)!==-1&&N.push("active"),(al)&&N.push("disabled"),M+=''+a+"",a+=1;O.html(M)}},updateNavArrows:function(){if(this._allow_update){var t=new Date(this.viewDate),e=t.getUTCFullYear(),n=t.getUTCMonth();switch(this.viewMode){case 0:this.o.startDate!==-(1/0)&&e<=this.o.startDate.getUTCFullYear()&&n<=this.o.startDate.getUTCMonth()?this.picker.find(".prev").css({visibility:"hidden"}):this.picker.find(".prev").css({visibility:"visible"}),this.o.endDate!==1/0&&e>=this.o.endDate.getUTCFullYear()&&n>=this.o.endDate.getUTCMonth()?this.picker.find(".next").css({visibility:"hidden"}):this.picker.find(".next").css({visibility:"visible"});break;case 1:case 2:this.o.startDate!==-(1/0)&&e<=this.o.startDate.getUTCFullYear()?this.picker.find(".prev").css({visibility:"hidden"}):this.picker.find(".prev").css({visibility:"visible"}),this.o.endDate!==1/0&&e>=this.o.endDate.getUTCFullYear()?this.picker.find(".next").css({visibility:"hidden"}):this.picker.find(".next").css({visibility:"visible"})}}},click:function(e){e.preventDefault();var i,o,a,s=t(e.target).closest("span, td, th");if(1===s.length)switch(s[0].nodeName.toLowerCase()){case"th":switch(s[0].className){case"datepicker-switch":this.showMode(1);break;case"prev":case"next":var r=g.modes[this.viewMode].navStep*("prev"===s[0].className?-1:1);switch(this.viewMode){case 0:this.viewDate=this.moveMonth(this.viewDate,r),this._trigger("changeMonth",this.viewDate);break;case 1:case 2:this.viewDate=this.moveYear(this.viewDate,r),1===this.viewMode&&this._trigger("changeYear",this.viewDate)}this.fill();break;case"today":var c=new Date;c=n(c.getFullYear(),c.getMonth(),c.getDate(),0,0,0),this.showMode(-2);var l="linked"===this.o.todayBtn?null:"view";this._setDate(c,l);break;case"clear":this.clearDates()}break;case"span":s.hasClass("disabled")||(this.viewDate.setUTCDate(1),s.hasClass("month")?(a=1,o=s.parent().find("span").index(s),i=this.viewDate.getUTCFullYear(),this.viewDate.setUTCMonth(o),this._trigger("changeMonth",this.viewDate),1===this.o.minViewMode&&this._setDate(n(i,o,a))):(a=1,o=0,i=parseInt(s.text(),10)||0,this.viewDate.setUTCFullYear(i),this._trigger("changeYear",this.viewDate),2===this.o.minViewMode&&this._setDate(n(i,o,a))),this.showMode(-1),this.fill());break;case"td":s.hasClass("day")&&!s.hasClass("disabled")&&(a=parseInt(s.text(),10)||1,i=this.viewDate.getUTCFullYear(),o=this.viewDate.getUTCMonth(),s.hasClass("old")?0===o?(o=11,i-=1):o-=1:s.hasClass("new")&&(11===o?(o=0,i+=1):o+=1),this._setDate(n(i,o,a)))}this.picker.is(":visible")&&this._focused_from&&t(this._focused_from).focus(),delete this._focused_from},_toggle_multidate:function(t){var e=this.dates.contains(t);if(t||this.dates.clear(),e!==-1?(this.o.multidate===!0||this.o.multidate>1||this.o.toggleActive)&&this.dates.remove(e):this.o.multidate===!1?(this.dates.clear(),this.dates.push(t)):this.dates.push(t),"number"==typeof this.o.multidate)for(;this.dates.length>this.o.multidate;)this.dates.remove(0)},_setDate:function(t,e){e&&"date"!==e||this._toggle_multidate(t&&new Date(t)),e&&"view"!==e||(this.viewDate=t&&new Date(t)),this.fill(),this.setValue(),e&&"view"===e||this._trigger("changeDate");var n;this.isInput?n=this.element:this.component&&(n=this.element.find("input")),n&&n.change(),!this.o.autoclose||e&&"date"!==e||this.hide()},moveMonth:function(t,n){if(!t)return e;if(!n)return t;var i,o,a=new Date(t.valueOf()),s=a.getUTCDate(),r=a.getUTCMonth(),c=Math.abs(n);if(n=n>0?1:-1,1===c)o=n===-1?function(){return a.getUTCMonth()===r}:function(){return a.getUTCMonth()!==i},i=r+n,a.setUTCMonth(i),(i<0||i>11)&&(i=(i+12)%12);else{for(var l=0;l=this.o.startDate&&t<=this.o.endDate},keydown:function(t){if(!this.picker.is(":visible"))return void(27===t.keyCode&&this.show());var e,n,o,a=!1,s=this.focusDate||this.viewDate;switch(t.keyCode){case 27:this.focusDate?(this.focusDate=null,this.viewDate=this.dates.get(-1)||this.viewDate,this.fill()):this.hide(),t.preventDefault();break;case 37:case 39:if(!this.o.keyboardNavigation)break;e=37===t.keyCode?-1:1,t.ctrlKey?(n=this.moveYear(this.dates.get(-1)||i(),e),o=this.moveYear(s,e),this._trigger("changeYear",this.viewDate)):t.shiftKey?(n=this.moveMonth(this.dates.get(-1)||i(),e),o=this.moveMonth(s,e),this._trigger("changeMonth",this.viewDate)):(n=new Date(this.dates.get(-1)||i()),n.setUTCDate(n.getUTCDate()+e),o=new Date(s),o.setUTCDate(s.getUTCDate()+e)),this.dateWithinRange(o)&&(this.focusDate=this.viewDate=o,this.setValue(),this.fill(),t.preventDefault());break;case 38:case 40:if(!this.o.keyboardNavigation)break;e=38===t.keyCode?-1:1,t.ctrlKey?(n=this.moveYear(this.dates.get(-1)||i(),e),o=this.moveYear(s,e),this._trigger("changeYear",this.viewDate)):t.shiftKey?(n=this.moveMonth(this.dates.get(-1)||i(),e),o=this.moveMonth(s,e),this._trigger("changeMonth",this.viewDate)):(n=new Date(this.dates.get(-1)||i()),n.setUTCDate(n.getUTCDate()+7*e),o=new Date(s),o.setUTCDate(s.getUTCDate()+7*e)),this.dateWithinRange(o)&&(this.focusDate=this.viewDate=o,this.setValue(),this.fill(),t.preventDefault());break;case 32:break;case 13:s=this.focusDate||this.dates.get(-1)||this.viewDate,this.o.keyboardNavigation&&(this._toggle_multidate(s),a=!0),this.focusDate=null,this.viewDate=this.dates.get(-1)||this.viewDate,this.setValue(),this.fill(),this.picker.is(":visible")&&(t.preventDefault(),"function"==typeof t.stopPropagation?t.stopPropagation():t.cancelBubble=!0,this.o.autoclose&&this.hide());break;case 9:this.focusDate=null,this.viewDate=this.dates.get(-1)||this.viewDate,this.fill(),this.hide()}if(a){this.dates.length?this._trigger("changeDate"):this._trigger("clearDate");var r;this.isInput?r=this.element:this.component&&(r=this.element.find("input")),r&&r.change()}},showMode:function(t){t&&(this.viewMode=Math.max(this.o.minViewMode,Math.min(2,this.viewMode+t))),this.picker.children("div").hide().filter(".datepicker-"+g.modes[this.viewMode].clsName).css("display","block"),this.updateNavArrows()}};var u=function(e,n){this.element=t(e),this.inputs=t.map(n.inputs,function(t){return t.jquery?t[0]:t}),delete n.inputs,d.call(t(this.inputs),n).bind("changeDate",t.proxy(this.dateUpdated,this)),this.pickers=t.map(this.inputs,function(e){return t(e).data("datepicker")}),this.updateDates()};u.prototype={updateDates:function(){this.dates=t.map(this.pickers,function(t){return t.getUTCDate()}),this.updateRanges()},updateRanges:function(){var e=t.map(this.dates,function(t){return t.valueOf()});t.each(this.pickers,function(t,n){n.setRange(e)})},dateUpdated:function(e){if(!this.updating){this.updating=!0;var n=t(e.target).data("datepicker"),i=n.getUTCDate(),o=t.inArray(e.target,this.inputs),a=o-1,s=o+1,r=this.inputs.length;if(o!==-1){if(t.each(this.pickers,function(t,e){e.getUTCDate()||e.setUTCDate(i)}),i=0&&ithis.dates[s])for(;sthis.dates[s];)this.pickers[s++].setUTCDate(i);this.updateDates(),delete this.updating}}},remove:function(){t.map(this.pickers,function(t){t.remove()}),delete this.element.data().datepicker}};var h=t.fn.datepicker,d=function(n){var i=Array.apply(null,arguments);i.shift();var o;return this.each(function(){var a=t(this),c=a.data("datepicker"),h="object"==typeof n&&n;if(!c){var d=s(this,"date"),f=t.extend({},p,d,h),m=r(f.language),g=t.extend({},p,m,d,h);if(a.hasClass("input-daterange")||g.inputs){var b={inputs:g.inputs||a.find("input").toArray()};a.data("datepicker",c=new u(this,t.extend(g,b)))}else a.data("datepicker",c=new l(this,g))}if("string"==typeof n&&"function"==typeof c[n]&&(o=c[n].apply(c,i),o!==e))return!1}),o!==e?o:this};t.fn.datepicker=d;var p=t.fn.datepicker.defaults={autoclose:!1,beforeShowDay:t.noop,beforeShowMonth:t.noop,calendarWeeks:!1,clearBtn:!1,toggleActive:!1,daysOfWeekDisabled:[],datesDisabled:[],endDate:1/0,forceParse:!0,format:"mm/dd/yyyy",keyboardNavigation:!0,language:"en",minViewMode:0,multidate:!1,multidateSeparator:",",orientation:"auto",rtl:!1,startDate:-(1/0),startView:0,todayBtn:!1,todayHighlight:!1,weekStart:0,disableTouchKeyboard:!1,enableOnReadonly:!0,container:"body"},f=t.fn.datepicker.locale_opts=["format","rtl","weekStart"];t.fn.datepicker.Constructor=l;var m=t.fn.datepicker.dates={en:{days:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday"],daysShort:["Sun","Mon","Tue","Wed","Thu","Fri","Sat","Sun"],daysMin:["Su","Mo","Tu","We","Th","Fr","Sa","Su"],months:["January","February","March","April","May","June","July","August","September","October","November","December"],monthsShort:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],today:"Today",clear:"Clear"}},g={modes:[{clsName:"days",navFnc:"Month",navStep:1},{clsName:"months",navFnc:"FullYear",navStep:1},{clsName:"years",navFnc:"FullYear",navStep:10}],isLeapYear:function(t){return t%4===0&&t%100!==0||t%400===0},getDaysInMonth:function(t,e){return[31,g.isLeapYear(t)?29:28,31,30,31,30,31,31,30,31,30,31][e]},validParts:/dd?|DD?|mm?|MM?|yy(?:yy)?/g,nonpunctuation:/[^ -\/:-@\[\u3400-\u9fff-`{-~\t\n\r]+/g,parseFormat:function(t){var e=t.replace(this.validParts,"\0").split("\0"),n=t.match(this.validParts);if(!e||!e.length||!n||0===n.length)throw new Error("Invalid date format.");return{separators:e,parts:n}},parseDate:function(i,o,a){function s(){var t=this.slice(0,d[u].length),e=d[u].slice(0,t.length);return t.toLowerCase()===e.toLowerCase()}if(!i)return e;if(i instanceof Date)return i;"string"==typeof o&&(o=g.parseFormat(o));var r,c,u,h=/([\-+]\d+)([dmwy])/,d=i.match(/([\-+]\d+)([dmwy])/g);if(/^[\-+]\d+[dmwy]([\s,]+[\-+]\d+[dmwy])*$/.test(i)){for(i=new Date,u=0;u«»',contTemplate:'',footTemplate:''};g.template='
'+g.headTemplate+""+g.footTemplate+'
'+g.headTemplate+g.contTemplate+g.footTemplate+'
'+g.headTemplate+g.contTemplate+g.footTemplate+"
",t.fn.datepicker.DPGlobal=g,t.fn.datepicker.noConflict=function(){return t.fn.datepicker=h,this},t.fn.datepicker.version="1.4.0",t(document).on("focus.datepicker.data-api click.datepicker.data-api",'[data-provide="datepicker"]',function(e){var n=t(this);n.data("datepicker")||(e.preventDefault(),d.call(n,"show"))}),t(function(){d.call(t('[data-provide="datepicker-inline"]'))})}(window.jQuery),!function(t){t.fn.datepicker.dates.de={days:["Sonntag","Montag","Dienstag","Mittwoch","Donnerstag","Freitag","Samstag","Sonntag"],daysShort:["Son","Mon","Die","Mit","Don","Fre","Sam","Son"],daysMin:["So","Mo","Di","Mi","Do","Fr","Sa","So"],months:["Januar","Februar","März","April","Mai","Juni","Juli","August","September","Oktober","November","Dezember"],monthsShort:["Jan","Feb","Mär","Apr","Mai","Jun","Jul","Aug","Sep","Okt","Nov","Dez"],today:"Heute",clear:"Löschen",weekStart:1,format:"dd.mm.yyyy"}}(jQuery),!function(t){t.fn.datepicker.dates.da={days:["Søndag","Mandag","Tirsdag","Onsdag","Torsdag","Fredag","Lørdag","Søndag"],daysShort:["Søn","Man","Tir","Ons","Tor","Fre","Lør","Søn"],daysMin:["Sø","Ma","Ti","On","To","Fr","Lø","Sø"],months:["Januar","Februar","Marts","April","Maj","Juni","Juli","August","September","Oktober","November","December"],monthsShort:["Jan","Feb","Mar","Apr","Maj","Jun","Jul","Aug","Sep","Okt","Nov","Dec"],today:"I Dag",clear:"Nulstil"}}(jQuery),!function(t){t.fn.datepicker.dates["pt-BR"]={days:["Domingo","Segunda","Terça","Quarta","Quinta","Sexta","Sábado","Domingo"],daysShort:["Dom","Seg","Ter","Qua","Qui","Sex","Sáb","Dom"],daysMin:["Do","Se","Te","Qu","Qu","Se","Sa","Do"],months:["Janeiro","Fevereiro","Março","Abril","Maio","Junho","Julho","Agosto","Setembro","Outubro","Novembro","Dezembro"],monthsShort:["Jan","Fev","Mar","Abr","Mai","Jun","Jul","Ago","Set","Out","Nov","Dez"],today:"Hoje",clear:"Limpar"}}(jQuery),!function(t){t.fn.datepicker.dates.nl={days:["zondag","maandag","dinsdag","woensdag","donderdag","vrijdag","zaterdag","zondag"],daysShort:["zo","ma","di","wo","do","vr","za","zo"],daysMin:["zo","ma","di","wo","do","vr","za","zo"],months:["januari","februari","maart","april","mei","juni","juli","augustus","september","oktober","november","december"],monthsShort:["jan","feb","mrt","apr","mei","jun","jul","aug","sep","okt","nov","dec"],today:"Vandaag",clear:"Wissen",weekStart:1,format:"dd-mm-yyyy"}}(jQuery),!function(t){t.fn.datepicker.dates.fr={days:["dimanche","lundi","mardi","mercredi","jeudi","vendredi","samedi","dimanche"],daysShort:["dim.","lun.","mar.","mer.","jeu.","ven.","sam.","dim."],daysMin:["d","l","ma","me","j","v","s","d"],months:["janvier","février","mars","avril","mai","juin","juillet","août","septembre","octobre","novembre","décembre"],monthsShort:["janv.","févr.","mars","avril","mai","juin","juil.","août","sept.","oct.","nov.","déc."],today:"Aujourd'hui",clear:"Effacer",weekStart:1,format:"dd/mm/yyyy"}}(jQuery),!function(t){t.fn.datepicker.dates.it={days:["Domenica","Lunedì","Martedì","Mercoledì","Giovedì","Venerdì","Sabato","Domenica"],daysShort:["Dom","Lun","Mar","Mer","Gio","Ven","Sab","Dom"],daysMin:["Do","Lu","Ma","Me","Gi","Ve","Sa","Do"],months:["Gennaio","Febbraio","Marzo","Aprile","Maggio","Giugno","Luglio","Agosto","Settembre","Ottobre","Novembre","Dicembre"],monthsShort:["Gen","Feb","Mar","Apr","Mag","Giu","Lug","Ago","Set","Ott","Nov","Dic"],today:"Oggi",clear:"Cancella",weekStart:1,format:"dd/mm/yyyy"}}(jQuery),!function(t){t.fn.datepicker.dates.lt={days:["Sekmadienis","Pirmadienis","Antradienis","Trečiadienis","Ketvirtadienis","Penktadienis","Šeštadienis","Sekmadienis"],daysShort:["S","Pr","A","T","K","Pn","Š","S"],daysMin:["Sk","Pr","An","Tr","Ke","Pn","Št","Sk"],months:["Sausis","Vasaris","Kovas","Balandis","Gegužė","Birželis","Liepa","Rugpjūtis","Rugsėjis","Spalis","Lapkritis","Gruodis"],monthsShort:["Sau","Vas","Kov","Bal","Geg","Bir","Lie","Rugp","Rugs","Spa","Lap","Gru"],today:"Šiandien",weekStart:1}}(jQuery),!function(t){t.fn.datepicker.dates.no={days:["Søndag","Mandag","Tirsdag","Onsdag","Torsdag","Fredag","Lørdag"],daysShort:["Søn","Man","Tir","Ons","Tor","Fre","Lør"],daysMin:["Sø","Ma","Ti","On","To","Fr","Lø"],months:["Januar","Februar","Mars","April","Mai","Juni","Juli","August","September","Oktober","November","Desember"],monthsShort:["Jan","Feb","Mar","Apr","Mai","Jun","Jul","Aug","Sep","Okt","Nov","Des"],today:"I dag",clear:"Nullstill",weekStart:1,format:"dd.mm.yyyy"}}(jQuery),!function(t){t.fn.datepicker.dates.es={days:["Domingo","Lunes","Martes","Miércoles","Jueves","Viernes","Sábado","Domingo"],daysShort:["Dom","Lun","Mar","Mié","Jue","Vie","Sáb","Dom"],daysMin:["Do","Lu","Ma","Mi","Ju","Vi","Sa","Do"],months:["Enero","Febrero","Marzo","Abril","Mayo","Junio","Julio","Agosto","Septiembre","Octubre","Noviembre","Diciembre"],monthsShort:["Ene","Feb","Mar","Abr","May","Jun","Jul","Ago","Sep","Oct","Nov","Dic"],today:"Hoy",clear:"Borrar",weekStart:1,format:"dd/mm/yyyy"}}(jQuery),!function(t){t.fn.datepicker.dates.sv={days:["Söndag","Måndag","Tisdag","Onsdag","Torsdag","Fredag","Lördag","Söndag"],daysShort:["Sön","Mån","Tis","Ons","Tor","Fre","Lör","Sön"],daysMin:["Sö","Må","Ti","On","To","Fr","Lö","Sö"],months:["Januari","Februari","Mars","April","Maj","Juni","Juli","Augusti","September","Oktober","November","December"],monthsShort:["Jan","Feb","Mar","Apr","Maj","Jun","Jul","Aug","Sep","Okt","Nov","Dec"],today:"Idag",format:"yyyy-mm-dd",weekStart:1,clear:"Rensa"}}(jQuery),function(){var t,e,n,i,o,a,s,r,c=[].slice,l={}.hasOwnProperty,u=function(t,e){function n(){this.constructor=t}for(var i in e)l.call(e,i)&&(t[i]=e[i]);return n.prototype=e.prototype,t.prototype=new n,t.__super__=e.prototype,t};s=function(){},e=function(){function t(){}return t.prototype.addEventListener=t.prototype.on,t.prototype.on=function(t,e){return this._callbacks=this._callbacks||{},this._callbacks[t]||(this._callbacks[t]=[]),this._callbacks[t].push(e),this},t.prototype.emit=function(){var t,e,n,i,o,a;if(i=arguments[0],t=2<=arguments.length?c.call(arguments,1):[],this._callbacks=this._callbacks||{},n=this._callbacks[i])for(o=0,a=n.length;o
'),this.element.appendChild(e)),i=e.getElementsByTagName("span")[0],i&&(null!=i.textContent?i.textContent=this.options.dictFallbackMessage:null!=i.innerText&&(i.innerText=this.options.dictFallbackMessage)),this.element.appendChild(this.getFallbackForm())},resize:function(t){var e,n,i;return e={srcX:0,srcY:0,srcWidth:t.width,srcHeight:t.height},n=t.width/t.height,e.optWidth=this.options.thumbnailWidth,e.optHeight=this.options.thumbnailHeight,null==e.optWidth&&null==e.optHeight?(e.optWidth=e.srcWidth,e.optHeight=e.srcHeight):null==e.optWidth?e.optWidth=n*e.optHeight:null==e.optHeight&&(e.optHeight=1/n*e.optWidth),i=e.optWidth/e.optHeight,t.heighti?(e.srcHeight=t.height,e.srcWidth=e.srcHeight*i):(e.srcWidth=t.width,e.srcHeight=e.srcWidth/i),e.srcX=(t.width-e.srcWidth)/2,e.srcY=(t.height-e.srcHeight)/2,e},drop:function(t){return this.element.classList.remove("dz-drag-hover")},dragstart:s,dragend:function(t){return this.element.classList.remove("dz-drag-hover")},dragenter:function(t){return this.element.classList.add("dz-drag-hover")},dragover:function(t){return this.element.classList.add("dz-drag-hover")},dragleave:function(t){return this.element.classList.remove("dz-drag-hover")},paste:s,reset:function(){return this.element.classList.remove("dz-started")},addedfile:function(t){var e,i,o,a,s,r,c,l,u,h,d,p,f;if(this.element===this.previewsContainer&&this.element.classList.add("dz-started"),this.previewsContainer){for(t.previewElement=n.createElement(this.options.previewTemplate.trim()),t.previewTemplate=t.previewElement,this.previewsContainer.appendChild(t.previewElement),h=t.previewElement.querySelectorAll("[data-dz-name]"),a=0,c=h.length;a'+this.options.dictRemoveFile+""),t.previewElement.appendChild(t._removeLink)),i=function(e){return function(i){return i.preventDefault(),i.stopPropagation(),t.status===n.UPLOADING?n.confirm(e.options.dictCancelUploadConfirmation,function(){return e.removeFile(t)}):e.options.dictRemoveFileConfirmation?n.confirm(e.options.dictRemoveFileConfirmation,function(){return e.removeFile(t)}):e.removeFile(t)}}(this),p=t.previewElement.querySelectorAll("[data-dz-remove]"),f=[],r=0,u=p.length;r\n
\n
\n
\n
\n
\n
\n
\n
\n \n Check\n \n \n \n \n \n
\n
\n \n Error\n \n \n \n \n \n \n \n
\n'},i=function(){var t,e,n,i,o,a,s;for(i=arguments[0],n=2<=arguments.length?c.call(arguments,1):[],a=0,s=n.length;a'+this.options.dictDefaultMessage+"")),this.clickableElements.length&&(i=function(t){return function(){return t.hiddenFileInput&&t.hiddenFileInput.parentNode.removeChild(t.hiddenFileInput),t.hiddenFileInput=document.createElement("input"),t.hiddenFileInput.setAttribute("type","file"),(null==t.options.maxFiles||t.options.maxFiles>1)&&t.hiddenFileInput.setAttribute("multiple","multiple"),t.hiddenFileInput.className="dz-hidden-input",null!=t.options.acceptedFiles&&t.hiddenFileInput.setAttribute("accept",t.options.acceptedFiles),null!=t.options.capture&&t.hiddenFileInput.setAttribute("capture",t.options.capture),t.hiddenFileInput.style.visibility="hidden",t.hiddenFileInput.style.position="absolute",t.hiddenFileInput.style.top="0",t.hiddenFileInput.style.left="0",t.hiddenFileInput.style.height="0",t.hiddenFileInput.style.width="0",document.querySelector(t.options.hiddenInputContainer).appendChild(t.hiddenFileInput),t.hiddenFileInput.addEventListener("change",function(){var e,n,o,a;if(n=t.hiddenFileInput.files,n.length)for(o=0,a=n.length;o',this.options.dictFallbackText&&(i+="

"+this.options.dictFallbackText+"

"),i+='',e=n.createElement(i),"FORM"!==this.element.tagName?(o=n.createElement('
'),o.appendChild(e)):(this.element.setAttribute("enctype","multipart/form-data"),this.element.setAttribute("method",this.options.method)),null!=o?o:e)},n.prototype.getExistingFallback=function(){var t,e,n,i,o,a;for(e=function(t){var e,n,i;for(n=0,i=t.length;n0){for(s=["TB","GB","MB","KB","b"],n=r=0,c=s.length;r=e){i=t/Math.pow(this.options.filesizeBase,4-n),o=a;break}i=Math.round(10*i)/10}return""+i+" "+o},n.prototype._updateMaxFilesReachedClass=function(){return null!=this.options.maxFiles&&this.getAcceptedFiles().length>=this.options.maxFiles?(this.getAcceptedFiles().length===this.options.maxFiles&&this.emit("maxfilesreached",this.files),this.element.classList.add("dz-max-files-reached")):this.element.classList.remove("dz-max-files-reached")},n.prototype.drop=function(t){var e,n;t.dataTransfer&&(this.emit("drop",t),e=t.dataTransfer.files,this.emit("addedfiles",e),e.length&&(n=t.dataTransfer.items,n&&n.length&&null!=n[0].webkitGetAsEntry?this._addFilesFromItems(n):this.handleFiles(e)))},n.prototype.paste=function(t){var e,n;if(null!=(null!=t&&null!=(n=t.clipboardData)?n.items:void 0))return this.emit("paste",t),e=t.clipboardData.items,e.length?this._addFilesFromItems(e):void 0},n.prototype.handleFiles=function(t){var e,n,i,o;for(o=[],n=0,i=t.length;n0){for(a=0,s=n.length;a1024*this.options.maxFilesize*1024?e(this.options.dictFileTooBig.replace("{{filesize}}",Math.round(t.size/1024/10.24)/100).replace("{{maxFilesize}}",this.options.maxFilesize)):n.isValidFile(t,this.options.acceptedFiles)?null!=this.options.maxFiles&&this.getAcceptedFiles().length>=this.options.maxFiles?(e(this.options.dictMaxFilesExceeded.replace("{{maxFiles}}",this.options.maxFiles)),this.emit("maxfilesexceeded",t)):this.options.accept.call(this,t,e):e(this.options.dictInvalidFileType)},n.prototype.addFile=function(t){return t.upload={progress:0,total:t.size,bytesSent:0},this.files.push(t),t.status=n.ADDED,this.emit("addedfile",t),this._enqueueThumbnail(t),this.accept(t,function(e){return function(n){return n?(t.accepted=!1,e._errorProcessing([t],n)):(t.accepted=!0,e.options.autoQueue&&e.enqueueFile(t)),e._updateMaxFilesReachedClass()}}(this))},n.prototype.enqueueFiles=function(t){var e,n,i;for(n=0,i=t.length;n=e)&&(i=this.getQueuedFiles(),i.length>0)){if(this.options.uploadMultiple)return this.processFiles(i.slice(0,e-n));for(;t=B;u=0<=B?++L:--L)a.append(this._getParamName(u),t[u],this._renameFilename(t[u].name));return this.submitRequest(z,a,t)},n.prototype.submitRequest=function(t,e,n){return t.send(e)},n.prototype._finished=function(t,e,i){var o,a,s;for(a=0,s=t.length;au;)e=o[4*(c-1)+3],0===e?a=c:u=c,c=a+u>>1;return l=c/s,0===l?1:l},a=function(t,e,n,i,a,s,r,c,l,u){var h;return h=o(e),t.drawImage(e,n,i,a,s,r,c,l,u/h)},i=function(t,e){var n,i,o,a,s,r,c,l,u;if(o=!1,u=!0,i=t.document,l=i.documentElement,n=i.addEventListener?"addEventListener":"attachEvent",c=i.addEventListener?"removeEventListener":"detachEvent",r=i.addEventListener?"":"on",a=function(n){if("readystatechange"!==n.type||"complete"===i.readyState)return("load"===n.type?t:i)[c](r+n.type,a,!1),!o&&(o=!0)?e.call(t,n.type||n):void 0},s=function(){var t;try{l.doScroll("left")}catch(e){return t=e,void setTimeout(s,50)}return a("poll")},"complete"!==i.readyState){if(i.createEventObject&&l.doScroll){try{u=!t.frameElement}catch(h){}u&&s()}return i[n](r+"DOMContentLoaded",a,!1),i[n](r+"readystatechange",a,!1),t[n](r+"load",a,!1)}},t._autoDiscoverFunction=function(){if(t.autoDiscover)return t.discover()},i(window,t._autoDiscoverFunction)}.call(this),function(t,e){"function"==typeof define&&define.amd?define("typeahead.js",["jquery"],function(t){return e(t)}):"object"==typeof exports?module.exports=e(require("jquery")):e(jQuery)}(this,function(t){var e=function(){"use strict";return{isMsie:function(){return!!/(msie|trident)/i.test(navigator.userAgent)&&navigator.userAgent.match(/(msie |rv:)(\d+(.\d+)?)/i)[2]},isBlankString:function(t){return!t||/^\s*$/.test(t)},escapeRegExChars:function(t){return t.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g,"\\$&")},isString:function(t){return"string"==typeof t},isNumber:function(t){return"number"==typeof t},isArray:t.isArray,isFunction:t.isFunction,isObject:t.isPlainObject,isUndefined:function(t){return"undefined"==typeof t},isElement:function(t){return!(!t||1!==t.nodeType)},isJQuery:function(e){return e instanceof t},toStr:function(t){return e.isUndefined(t)||null===t?"":t+""},bind:t.proxy,each:function(e,n){function i(t,e){return n(e,t)}t.each(e,i)},map:t.map,filter:t.grep,every:function(e,n){var i=!0;return e?(t.each(e,function(t,o){if(!(i=n.call(null,o,t,e)))return!1}),!!i):i},some:function(e,n){var i=!1;return e?(t.each(e,function(t,o){if(i=n.call(null,o,t,e))return!1}),!!i):i},mixin:t.extend,identity:function(t){return t},clone:function(e){return t.extend(!0,{},e)},getIdGenerator:function(){var t=0;return function(){return t++}},templatify:function(e){function n(){return String(e)}return t.isFunction(e)?e:n},defer:function(t){setTimeout(t,0)},debounce:function(t,e,n){var i,o;return function(){var a,s,r=this,c=arguments;return a=function(){i=null,n||(o=t.apply(r,c))},s=n&&!i,clearTimeout(i),i=setTimeout(a,e),s&&(o=t.apply(r,c)),o}},throttle:function(t,e){var n,i,o,a,s,r;return s=0,r=function(){s=new Date,o=null,a=t.apply(n,i)},function(){var c=new Date,l=e-(c-s);return n=this,i=arguments,l<=0?(clearTimeout(o),o=null,s=c,a=t.apply(n,i)):o||(o=setTimeout(r,l)),a}},stringify:function(t){return e.isString(t)?t:JSON.stringify(t)},noop:function(){}}}(),n=function(){"use strict";function t(t){var s,r;return r=e.mixin({},a,t),s={css:o(),classes:r,html:n(r),selectors:i(r)},{css:s.css,html:s.html,classes:s.classes,selectors:s.selectors,mixin:function(t){e.mixin(t,s)}}}function n(t){return{wrapper:'',menu:'
'}}function i(t){var n={};return e.each(t,function(t,e){n[e]="."+t}),n}function o(){var t={wrapper:{position:"relative",display:"inline-block"},hint:{position:"absolute",top:"0",left:"0",borderColor:"transparent",boxShadow:"none",opacity:"1"},input:{position:"relative",verticalAlign:"top",backgroundColor:"transparent"},inputWithNoHint:{position:"relative",verticalAlign:"top"},menu:{position:"absolute",top:"100%",left:"0",zIndex:"100",display:"none"},ltr:{left:"0",right:"auto"},rtl:{left:"auto",right:" 0"}};return e.isMsie()&&e.mixin(t.input,{backgroundImage:"url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)"}),t}var a={wrapper:"twitter-typeahead",input:"tt-input",hint:"tt-hint",menu:"tt-menu",dataset:"tt-dataset",suggestion:"tt-suggestion",selectable:"tt-selectable",empty:"tt-empty",open:"tt-open",cursor:"tt-cursor",highlight:"tt-highlight"};return t}(),i=function(){"use strict";function n(e){e&&e.el||t.error("EventBus initialized without el"),this.$el=t(e.el)}var i,o;return i="typeahead:",o={render:"rendered",cursorchange:"cursorchanged",select:"selected",autocomplete:"autocompleted"},e.mixin(n.prototype,{_trigger:function(e,n){var o;return o=t.Event(i+e),(n=n||[]).unshift(o),this.$el.trigger.apply(this.$el,n),o},before:function(t){var e,n;return e=[].slice.call(arguments,1),n=this._trigger("before"+t,e),n.isDefaultPrevented()},trigger:function(t){var e;this._trigger(t,[].slice.call(arguments,1)),(e=o[t])&&this._trigger(e,[].slice.call(arguments,1))}}),n}(),o=function(){"use strict";function t(t,e,n,i){var o;if(!n)return this;for(e=e.split(c),n=i?r(n,i):n,this._callbacks=this._callbacks||{};o=e.shift();)this._callbacks[o]=this._callbacks[o]||{sync:[],async:[]},this._callbacks[o][t].push(n);return this}function e(e,n,i){return t.call(this,"async",e,n,i)}function n(e,n,i){return t.call(this,"sync",e,n,i)}function i(t){var e;if(!this._callbacks)return this;for(t=t.split(c);e=t.shift();)delete this._callbacks[e];return this}function o(t){var e,n,i,o,s;if(!this._callbacks)return this;for(t=t.split(c),i=[].slice.call(arguments,1);(e=t.shift())&&(n=this._callbacks[e]);)o=a(n.sync,this,[e].concat(i)),s=a(n.async,this,[e].concat(i)),o()&&l(s);return this}function a(t,e,n){function i(){for(var i,o=0,a=t.length;!i&&o