diff --git a/app/DataMapper/CompanySettings.php b/app/DataMapper/CompanySettings.php index 830b17043088..cea6afa828fc 100644 --- a/app/DataMapper/CompanySettings.php +++ b/app/DataMapper/CompanySettings.php @@ -146,10 +146,12 @@ class CompanySettings extends BaseSettings { public $email_subject_invoice = ''; public $email_subject_quote = ''; public $email_subject_payment = ''; + public $email_subject_payment_partial = ''; public $email_subject_statement = ''; public $email_template_invoice = ''; public $email_template_quote = ''; public $email_template_payment = ''; + public $email_template_payment_partial = ''; public $email_template_statement = ''; public $email_subject_reminder1 = ''; public $email_subject_reminder2 = ''; @@ -311,9 +313,11 @@ class CompanySettings extends BaseSettings { 'email_subject_invoice' => 'string', 'email_subject_quote' => 'string', 'email_subject_payment' => 'string', + 'email_subject_payment_partial' => 'string', 'email_template_invoice' => 'string', 'email_template_quote' => 'string', 'email_template_payment' => 'string', + 'email_template_payment_partial' => 'string', 'email_subject_reminder1' => 'string', 'email_subject_reminder2' => 'string', 'email_subject_reminder3' => 'string', @@ -333,8 +337,8 @@ class CompanySettings extends BaseSettings { 'invoice_footer' => 'string', 'invoice_labels' => 'string', 'invoice_terms' => 'string', - 'credit_footer' => 'string', - 'credit_terms' => 'string', + 'credit_footer' => 'string', + 'credit_terms' => 'string', 'name' => 'string', 'payment_terms' => 'integer', 'payment_type_id' => 'string', diff --git a/app/Http/Controllers/CompanyUserController.php b/app/Http/Controllers/CompanyUserController.php index b1a8749f77dd..2e8b53f36ab5 100644 --- a/app/Http/Controllers/CompanyUserController.php +++ b/app/Http/Controllers/CompanyUserController.php @@ -142,6 +142,7 @@ class CompanyUserController extends BaseController } else { $company_user->fill($request->input('company_user')['settings']); + $company_user->fill($request->input('company_user')['notifications']); } $company_user->save(); diff --git a/app/Http/Middleware/StartupCheck.php b/app/Http/Middleware/StartupCheck.php index c8ce3c08a996..10be087e510f 100644 --- a/app/Http/Middleware/StartupCheck.php +++ b/app/Http/Middleware/StartupCheck.php @@ -11,7 +11,9 @@ namespace App\Http\Middleware; +use App\Models\Account; use App\Models\Language; +use App\Utils\CurlUtils; use Closure; use Illuminate\Http\Request; use Illuminate\Support\Facades\App; @@ -39,14 +41,15 @@ class StartupCheck // $start = microtime(true); // Log::error('start up check'); - $cached_tables = config('ninja.cached_tables'); - - if (Input::has('clear_cache')) { + if ($request->has('clear_cache')) { Session::flash('message', 'Cache cleared'); } + /* Make sure our cache is built */ + $cached_tables = config('ninja.cached_tables'); + foreach ($cached_tables as $name => $class) { - if (Input::has('clear_cache') || ! Cache::has($name)) { + if ($request->has('clear_cache') || ! Cache::has($name)) { // check that the table exists in case the migration is pending if (! Schema::hasTable((new $class())->getTable())) { continue; @@ -67,6 +70,69 @@ class StartupCheck } } + /* Catch claim license requests */ + if(config('ninja.environment') == 'selfhost' && $request->has('license_key') && $request->has('product_id') && $request->segment(3) == 'claim_license') + { + + $license_key = $request->input('license_key'); + $product_id = $request->input('product_id'); + + $url = config('ninja.license_url') . "/claim_license?license_key={$license_key}&product_id={$product_id}&get_date=true"; + $data = trim(CurlUtils::get($url)); + + if ($data == Account::RESULT_FAILURE) { + + $error = [ + 'message' => trans('texts.invalid_white_label_license'), + 'errors' => [] + ]; + + return response()->json($error, 400); + + } elseif ($data) { + + $date = date_create($data)->modify('+1 year'); + + if ($date < date_create()) { + + $error = [ + 'message' => trans('texts.invalid_white_label_license'), + 'errors' => [] + ]; + + return response()->json($error, 400); + + } else { + + $account = auth()->user()->company()->account; + + $account->plan_term = Account::PLAN_TERM_YEARLY; + $account->plan_paid = $data; + $account->plan_expires = $date->format('Y-m-d'); + $account->plan = Account::PLAN_WHITE_LABEL; + $account->save(); + + $error = [ + 'message' => trans('texts.bought_white_label'), + 'errors' => [] + ]; + + return response()->json($error, 200); + + } + } else { + + $error = [ + 'message' => trans('texts.white_label_license_error'), + 'errors' => [] + ]; + + return response()->json($error, 400); + + } + + } + $response = $next($request); return $response; diff --git a/app/Http/Requests/Client/StoreClientRequest.php b/app/Http/Requests/Client/StoreClientRequest.php index be7ec78824fb..bb76db4ae669 100644 --- a/app/Http/Requests/Client/StoreClientRequest.php +++ b/app/Http/Requests/Client/StoreClientRequest.php @@ -68,8 +68,11 @@ class StoreClientRequest extends Request protected function prepareForValidation() { + $input = $this->all(); + //@todo implement feature permissions for > 100 clients + if (!isset($input['settings'])) { $input['settings'] = ClientSettings::defaults(); } diff --git a/app/Models/Account.php b/app/Models/Account.php index 9a08c39922de..37132c3dcc6f 100644 --- a/app/Models/Account.php +++ b/app/Models/Account.php @@ -11,6 +11,7 @@ namespace App\Models; +use App\Utils\Ninja; use App\Utils\Traits\MakesHash; use Illuminate\Database\Eloquent\Model; use Laracasts\Presenter\PresentableTrait; @@ -52,6 +53,37 @@ class Account extends BaseModel 'discount_expires', ]; + const PLAN_FREE = 'free'; + const PLAN_PRO = 'pro'; + const PLAN_ENTERPRISE = 'enterprise'; + const PLAN_WHITE_LABEL = 'white_label'; + const PLAN_TERM_MONTHLY = 'month'; + const PLAN_TERM_YEARLY = 'year'; + + const FEATURE_TASKS = 'tasks'; + const FEATURE_EXPENSES = 'expenses'; + const FEATURE_QUOTES = 'quotes'; + const FEATURE_CUSTOMIZE_INVOICE_DESIGN = 'custom_designs'; + const FEATURE_DIFFERENT_DESIGNS = 'different_designs'; + const FEATURE_EMAIL_TEMPLATES_REMINDERS = 'template_reminders'; + const FEATURE_INVOICE_SETTINGS = 'invoice_settings'; + const FEATURE_CUSTOM_EMAILS = 'custom_emails'; + const FEATURE_PDF_ATTACHMENT = 'pdf_attachments'; + const FEATURE_MORE_INVOICE_DESIGNS = 'more_invoice_designs'; + const FEATURE_REPORTS = 'reports'; + const FEATURE_BUY_NOW_BUTTONS = 'buy_now_buttons'; + const FEATURE_API = 'api'; + const FEATURE_CLIENT_PORTAL_PASSWORD = 'client_portal_password'; + const FEATURE_CUSTOM_URL = 'custom_url'; + const FEATURE_MORE_CLIENTS = 'more_clients'; + const FEATURE_WHITE_LABEL = 'white_label'; + const FEATURE_REMOVE_CREATED_BY = 'remove_created_by'; + const FEATURE_USERS = 'users'; // Grandfathered for old Pro users + const FEATURE_DOCUMENTS = 'documents'; + const FEATURE_USER_PERMISSIONS = 'permissions'; + + const RESULT_FAILURE = 'failure'; + const RESULT_SUCCESS = 'success'; /** * @return \Illuminate\Database\Eloquent\Relations\HasMany */ @@ -82,4 +114,180 @@ class Account extends BaseModel { return $this->plan ?: ''; } + + + public function hasFeature($feature) + { + $plan_details = $this->getPlanDetails(); + $self_host = ! Ninja::isNinja(); + + switch ($feature) { + + case self::FEATURE_TASKS: + case self::FEATURE_EXPENSES: + case self::FEATURE_QUOTES: + return true; + + case self::FEATURE_CUSTOMIZE_INVOICE_DESIGN: + case self::FEATURE_DIFFERENT_DESIGNS: + case self::FEATURE_EMAIL_TEMPLATES_REMINDERS: + case self::FEATURE_INVOICE_SETTINGS: + case self::FEATURE_CUSTOM_EMAILS: + case self::FEATURE_PDF_ATTACHMENT: + case self::FEATURE_MORE_INVOICE_DESIGNS: + case self::FEATURE_REPORTS: + case self::FEATURE_BUY_NOW_BUTTONS: + case self::FEATURE_API: + case self::FEATURE_CLIENT_PORTAL_PASSWORD: + case self::FEATURE_CUSTOM_URL: + return $self_host || ! empty($plan_details); + + // Pro; No trial allowed, unless they're trialing enterprise with an active pro plan + case FEATURE_MORE_CLIENTS: + return $self_host || ! empty($plan_details) && (! $plan_details['trial'] || ! empty($this->getPlanDetails(false, false))); + + // White Label + case FEATURE_WHITE_LABEL: + if (! $self_host && $plan_details && ! $plan_details['expires']) { + return false; + } + // Fallthrough + case FEATURE_REMOVE_CREATED_BY: + return ! empty($plan_details); // A plan is required even for self-hosted users + + // Enterprise; No Trial allowed; grandfathered for old pro users + case FEATURE_USERS:// Grandfathered for old Pro users + if ($planDetails && $planDetails['trial']) { + // Do they have a non-trial plan? + $planDetails = $this->getPlanDetails(false, false); + } + + return $selfHost || ! empty($planDetails) && ($planDetails['plan'] == PLAN_ENTERPRISE || $planDetails['started'] <= date_create(PRO_USERS_GRANDFATHER_DEADLINE)); + + // Enterprise; No Trial allowed + case FEATURE_DOCUMENTS: + case FEATURE_USER_PERMISSIONS: + return $selfHost || ! empty($planDetails) && $planDetails['plan'] == PLAN_ENTERPRISE && ! $planDetails['trial']; + + default: + return false; + } + } + + public function isPaid() + { + return Ninja::isNinja() ? ($this->isPaidHostedClient() && ! $this->isTrial()) : $this->hasFeature(self::FEATURE_WHITE_LABEL); + } + + public function isPaidHostedClient() + { + return $this->plan == 'pro' || $this->plan == 'enterprise'; + } + + public function isTrial() + { + if (! Ninja::isNinja()) { + return false; + } + + $plan_details = $this->getPlanDetails(); + + return $plan_details && $plan_details['trial']; + } + + public function getPlanDetails($include_inactive = false, $include_trial = true) + { + if (!$this) { + return null; + } + + $plan = $this->plan; + $price = $this->plan_price; + $trial_plan = $this->trial_plan; + + if ((! $plan || $plan == self::PLAN_FREE) && (! $trial_plan || ! $include_trial)) { + return null; + } + + $trial_active = false; + if ($trial_plan && $include_trial) { + $trial_started = \DateTime::createFromFormat('Y-m-d', $this->trial_started); + $trial_expires = clone $trial_started; + $trial_expires->modify('+2 weeks'); + + if ($trial_expires >= date_create()) { + $trial_active = true; + } + } + + $plan_active = false; + if ($plan) { + if ($this->plan_expires == null) { + $plan_active = true; + $plan_expires = false; + } else { + $plan_expires = \DateTime::createFromFormat('Y-m-d', $this->plan_expires); + if ($plan_expires >= date_create()) { + $plan_active = true; + } + } + } + + if (! $include_inactive && ! $plan_active && ! $trial_active) { + return null; + } + + // Should we show plan details or trial details? + if (($plan && ! $trial_plan) || ! $include_trial) { + $use_plan = true; + } elseif (! $plan && $trial_plan) { + $use_plan = false; + } else { + // There is both a plan and a trial + if (! empty($plan_active) && empty($trial_active)) { + $use_plan = true; + } elseif (empty($plan_active) && ! empty($trial_active)) { + $use_plan = false; + } elseif (! empty($plan_active) && ! empty($trial_active)) { + // Both are active; use whichever is a better plan + if ($plan == self::PLAN_ENTERPRISE) { + $use_plan = true; + } elseif ($trial_plan == self::PLAN_ENTERPRISE) { + $use_plan = false; + } else { + // They're both the same; show the plan + $use_plan = true; + } + } else { + // Neither are active; use whichever expired most recently + $use_plan = $plan_expires >= $trial_expires; + } + } + + if ($use_plan) { + return [ + 'account_id' => $this->id, + 'num_users' => $this->num_users, + 'plan_price' => $price, + 'trial' => false, + 'plan' => $plan, + 'started' => \DateTime::createFromFormat('Y-m-d', $this->plan_started), + 'expires' => $plan_expires, + 'paid' => \DateTime::createFromFormat('Y-m-d', $this->plan_paid), + 'term' => $this->plan_term, + 'active' => $plan_active, + ]; + } else { + return [ + 'account_id' => $this->id, + 'num_users' => 1, + 'plan_price' => 0, + 'trial' => true, + 'plan' => $trial_plan, + 'started' => $trial_started, + 'expires' => $trial_expires, + 'active' => $trial_active, + ]; + } + } } diff --git a/app/Models/CompanyUser.php b/app/Models/CompanyUser.php index 60dfbdf348af..f4cb7985d889 100644 --- a/app/Models/CompanyUser.php +++ b/app/Models/CompanyUser.php @@ -12,17 +12,18 @@ namespace App\Models; use Illuminate\Database\Eloquent\Relations\Pivot; +use Illuminate\Database\Eloquent\SoftDeletes; class CompanyUser extends Pivot { use \Staudenmeir\EloquentHasManyDeep\HasRelationships; + use SoftDeletes; // protected $guarded = ['id']; protected $dateFormat = 'Y-m-d H:i:s.u'; - /** * The attributes that should be cast to native types. * @@ -38,6 +39,7 @@ class CompanyUser extends Pivot protected $fillable = [ 'account_id', 'permissions', + 'notifications', 'settings', 'is_admin', 'is_owner', diff --git a/app/Transformers/ClientContactTransformer.php b/app/Transformers/ClientContactTransformer.php index 5ca958758c1b..572276c56ca3 100644 --- a/app/Transformers/ClientContactTransformer.php +++ b/app/Transformers/ClientContactTransformer.php @@ -47,7 +47,7 @@ class ClientContactTransformer extends EntityTransformer 'contact_key' => $contact->contact_key ?: '', 'send_email' => (bool) $contact->send_email, 'last_login' => (int)$contact->last_login, - 'password' => '', + 'password' => isset($contact->password) ? '*****' : '', ]; } } diff --git a/app/Utils/CurlUtils.php b/app/Utils/CurlUtils.php new file mode 100644 index 000000000000..1e7904404f1f --- /dev/null +++ b/app/Utils/CurlUtils.php @@ -0,0 +1,53 @@ + $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => $method, + CURLOPT_HTTPHEADER => $headers ?: [], + ]; + + if ($data) { + $opts[CURLOPT_POSTFIELDS] = $data; + } + + curl_setopt_array($curl, $opts); + $response = curl_exec($curl); + + if ($error = curl_error($curl)) { + \Log::error('CURL Error #' . curl_errno($curl) . ': ' . $error); + } + + curl_close($curl); + + return $response; + } + +} diff --git a/app/Utils/Ninja.php b/app/Utils/Ninja.php index f70de89c1545..6df63caa9986 100644 --- a/app/Utils/Ninja.php +++ b/app/Utils/Ninja.php @@ -28,6 +28,11 @@ class Ninja return config('ninja.environment') === 'hosted'; } + public static function isNinja() + { + return config('ninja.production'); + } + public static function getDebugInfo() { $mysql_version = DB::select(DB::raw("select version() as version"))[0]->version; diff --git a/config/ninja.php b/config/ninja.php index d18d6c4fbe3d..2fe854a8aa76 100644 --- a/config/ninja.php +++ b/config/ninja.php @@ -3,6 +3,8 @@ return [ 'web_url' => 'https://www.invoiceninja.com', + 'license_url' => 'https://app.invoiceninja.com', + 'production' => env('NINJA_PROD', false), 'app_name' => env('APP_NAME'), 'site_url' => env('APP_URL', ''), 'app_domain' => env('APP_DOMAIN', 'invoicing.co'), diff --git a/database/migrations/2014_10_13_000000_create_users_table.php b/database/migrations/2014_10_13_000000_create_users_table.php index 88c3843d9d82..e64b62206512 100644 --- a/database/migrations/2014_10_13_000000_create_users_table.php +++ b/database/migrations/2014_10_13_000000_create_users_table.php @@ -198,10 +198,13 @@ class CreateUsersTable extends Migration $table->unsignedInteger('account_id'); $table->unsignedInteger('user_id')->index(); $table->mediumText('permissions')->nullable(); + $table->mediumText('notifications')->nullable(); $table->mediumText('settings')->nullable(); $table->boolean('is_owner')->default(false); $table->boolean('is_admin')->default(false); $table->boolean('is_locked')->default(false); // locks user out of account + + $table->softDeletes('deleted_at', 6); $table->timestamps(6); $table->foreign('company_id')->references('id')->on('companies')->onDelete('cascade'); diff --git a/tests/Feature/ClientTest.php b/tests/Feature/ClientTest.php index 587700c3116f..acd0230a2308 100644 --- a/tests/Feature/ClientTest.php +++ b/tests/Feature/ClientTest.php @@ -436,7 +436,8 @@ class ClientTest extends TestCase $arr = $response->json(); - \Log::error($arr); + //\Log::error($arr); + } /** @test */